From a411cbc640888122a04bed81b46b1eb57bb645e7 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 14 Feb 2022 12:05:07 +0100 Subject: [PATCH] :sparkles: Initial SVG text support --- .../app/common/geom/shapes/transforms.cljc | 12 +- exporter/src/app/renderer/svg.cljs | 6 +- .../src/app/main/data/workspace/texts.cljs | 6 +- .../app/main/data/workspace/transforms.cljs | 1 + frontend/src/app/main/ui/render.cljs | 9 +- frontend/src/app/main/ui/shapes/text.cljs | 213 +---------------- .../src/app/main/ui/shapes/text/fo_text.cljs | 218 ++++++++++++++++++ .../src/app/main/ui/shapes/text/svg_text.cljs | 45 ++++ .../app/main/ui/workspace/shapes/frame.cljs | 10 +- .../app/main/ui/workspace/shapes/text.cljs | 72 ++++-- frontend/src/app/util/dom.cljs | 10 +- frontend/src/app/util/text_svg_position.cljs | 92 ++++++++ 12 files changed, 465 insertions(+), 229 deletions(-) create mode 100644 frontend/src/app/main/ui/shapes/text/fo_text.cljs create mode 100644 frontend/src/app/main/ui/shapes/text/svg_text.cljs create mode 100644 frontend/src/app/util/text_svg_position.cljs diff --git a/common/src/app/common/geom/shapes/transforms.cljc b/common/src/app/common/geom/shapes/transforms.cljc index fa29f33b6..a63623322 100644 --- a/common/src/app/common/geom/shapes/transforms.cljc +++ b/common/src/app/common/geom/shapes/transforms.cljc @@ -39,6 +39,14 @@ (->> points (mapv #(gpt/add % move-vec)))) +(defn move-position-data + [position-data dx dy] + + (->> position-data + (map #(-> % + (update :x + dx) + (update :y + dy))))) + (defn move "Move the shape relatively to its current position applying the provided delta." @@ -52,6 +60,7 @@ (update :points move-points move-vec) (d/update-when :x + dx) (d/update-when :y + dy) + (d/update-when :position-data move-position-data dx dy) (cond-> (= :bool type) (update :bool-content gpa/move-content move-vec)) (cond-> (= :path type) (update :content gpa/move-content move-vec))))) @@ -533,7 +542,8 @@ :else (let [shape (apply-displacement shape) - modifiers (:modifiers shape)] + modifiers (:modifiers shape) + shape (cond-> shape (= :text (:type shape)) (assoc :dirty? true))] (cond-> shape (not (empty-modifiers? modifiers)) (-> (set-flip modifiers) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 894d77c4d..45b0afc06 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -311,7 +311,11 @@ xmldata (bw/eval! dom (fn [elem] (.-outerHTML ^js elem))) nodes (process-text-nodes page) nodes (d/index-by :id nodes) - result (replace-text-nodes xmldata nodes)] + result (replace-text-nodes xmldata nodes) + + ;; SVG standard don't allow the entity nbsp.   is equivalent but + ;; compatible with SVG + result (str/replace result " " " ")] ;; (println "------- ORIGIN:") ;; (cljs.pprint/pprint (xml->clj xmldata)) ;; (println "------- RESULT:") diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 061130b15..0c0f79228 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -65,7 +65,7 @@ (when (and (not= content (:content shape)) (some? (:current-page-id state))) (rx/of - (dch/update-shapes [id] #(assoc % :content content)) + (dch/update-shapes [id] #(assoc % :content content :dirty? true)) (dwu/commit-undo-transaction))))) (when (some? id) @@ -149,7 +149,9 @@ [shape pred-fn merge-fn attrs] (let [merge-attrs #(merge-fn % attrs) transform #(txt/transform-nodes pred-fn merge-attrs %)] - (update shape :content transform))) + (-> shape + (update :content transform) + (assoc :dirty? true)))) (defn update-root-attrs [{:keys [id attrs]}] diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index ddc4f5686..699bd919d 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -185,6 +185,7 @@ :transform :transform-inverse :rotation + :dirty? :flip-x :flip-y]}) (clear-local-transform) diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index 0db2dfb00..b5dc467a1 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -20,6 +20,7 @@ [app.main.ui.shapes.embed :as embed] [app.main.ui.shapes.filters :as filters] [app.main.ui.shapes.shape :refer [shape-container]] + [app.main.ui.shapes.text.fontfaces :as ff] [app.util.dom :as dom] [beicon.core :as rx] [cuerdas.core :as str] @@ -72,6 +73,8 @@ (:hide-fill-on-export object) (assoc :fills [])) + all-children (cph/get-children objects object-id) + {:keys [x y width height] :as bs} (calc-bounds object objects) [_ _ width height :as coords] (->> [x y width height] (map #(* % zoom))) @@ -92,7 +95,9 @@ text-shapes (->> objects (filter (fn [[_ shape]] (= :text (:type shape)))) - (mapv second))] + (mapv second)) + + render-texts? (and render-texts? (some #(nil? (:position-data %)) text-shapes))] (mf/with-effect [width height] (dom/set-page-style {:size (str (mth/ceil width) "px " @@ -110,6 +115,8 @@ ;; https://bugs.chromium.org/p/chromium/issues/detail?id=1244560#c5 :style {:-webkit-print-color-adjust :exact}} + [:& ff/fontfaces-style {:shapes all-children}] + (case (:type object) :frame [:& frame-wrapper {:shape object :view-box vbox}] :group [:> shape-container {:shape object} diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index 77ddea023..6da9c4642 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -6,213 +6,16 @@ (ns app.main.ui.shapes.text (:require - [app.common.colors :as clr] - [app.common.data :as d] - [app.common.geom.shapes :as geom] - [app.main.ui.context :as muc] - [app.main.ui.shapes.attrs :as attrs] - [app.main.ui.shapes.text.styles :as sts] - [app.util.color :as uc] + [app.main.ui.shapes.text.fo-text :as fo] + [app.main.ui.shapes.text.svg-text :as svg] [app.util.object :as obj] - [cuerdas.core :as str] [rumext.alpha :as mf])) -(mf/defc render-text - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - text (:text node) - style (sts/generate-text-styles node)] - [:span.text-node {:style style} - (if (= text "") "\u00A0" text)])) - -(mf/defc render-root - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - children (obj/get props "children") - shape (obj/get props "shape") - style (sts/generate-root-styles shape node)] - [:div.root.rich-text - {:style style - :xmlns "http://www.w3.org/1999/xhtml"} - children])) - -(mf/defc render-paragraph-set - {::mf/wrap-props false} - [props] - (let [children (obj/get props "children") - shape (obj/get props "shape") - style (sts/generate-paragraph-set-styles shape)] - [:div.paragraph-set {:style style} children])) - -(mf/defc render-paragraph - {::mf/wrap-props false} - [props] - (let [node (obj/get props "node") - shape (obj/get props "shape") - children (obj/get props "children") - style (sts/generate-paragraph-styles shape node) - dir (:text-direction node "auto")] - [:p.paragraph {:style style :dir dir} children])) - -;; -- Text nodes -(mf/defc render-node - {::mf/wrap-props false} - [props] - (let [{:keys [type text children] :as node} (obj/get props "node")] - (if (string? text) - [:> render-text props] - (let [component (case type - "root" render-root - "paragraph-set" render-paragraph-set - "paragraph" render-paragraph - nil)] - (when component - [:> component props - (for [[index node] (d/enumerate children)] - (let [props (-> (obj/clone props) - (obj/set! "node" node) - (obj/set! "index" index) - (obj/set! "key" index))] - [:> render-node props]))]))))) - -(defn- next-color - "Given a set of colors try to get a color not yet used" - [colors] - (assert (set? colors)) - (loop [current-rgb [0 0 0]] - (let [current-hex (uc/rgb->hex current-rgb)] - (if (contains? colors current-hex) - (recur (uc/next-rgb current-rgb)) - current-hex)))) - -(defn- remap-colors - "Returns a new content replacing the original colors by their mapped 'simple color'" - [content color-mapping] - - (cond-> content - (and (:fill-opacity content) (< (:fill-opacity content) 1.0)) - (-> (assoc :fill-color (get color-mapping [(:fill-color content) (:fill-opacity content)])) - (assoc :fill-opacity 1.0)) - - (some? (:fill-color-gradient content)) - (-> (assoc :fill-color (get color-mapping (:fill-color-gradient content))) - (assoc :fill-opacity 1.0) - (dissoc :fill-color-gradient)) - - (contains? content :children) - (update :children #(mapv (fn [node] (remap-colors node color-mapping)) %)))) - -(defn- fill->color - "Given a content node returns the information about that node fill color" - [{:keys [fill-color fill-opacity fill-color-gradient]}] - - (cond - (some? fill-color-gradient) - {:type :gradient - :gradient fill-color-gradient} - - (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) - {:type :transparent - :hex fill-color - :opacity fill-opacity} - - (string? fill-color) - {:type :solid - :hex fill-color - :map-to fill-color})) - -(defn- retrieve-colors - "Given a text shape returns a triple with the values: - - colors used as fills - - a mapping from simple solid colors to complex ones (transparents/gradients) - - the inverse of the previous mapping (to restore the value in the SVG)" - [shape] - (let [color-data - (->> (:content shape) - (tree-seq map? :children) - (map fill->color) - (filter some?)) - - colors (->> color-data - (into #{clr/black} - (comp (filter #(= :solid (:type %))) - (map :hex)))) - - [colors color-data] - (loop [colors colors - head (first color-data) - tail (rest color-data) - result []] - - (if (nil? head) - [colors result] - - (if (= :solid (:type head)) - (recur colors - (first tail) - (rest tail) - (conj result head)) - - (let [next-color (next-color colors) - head (assoc head :map-to next-color) - colors (conj colors next-color)] - (recur colors - (first tail) - (rest tail) - (conj result head)))))) - - color-mapping-inverse - (->> color-data - (remove #(= :solid (:type %))) - (group-by :map-to) - (d/mapm #(first %2))) - - color-mapping - (merge - (->> color-data - (filter #(= :transparent (:type %))) - (map #(vector [(:hex %) (:opacity %)] (:map-to %))) - (into {})) - - (->> color-data - (filter #(= :gradient (:type %))) - (map #(vector (:gradient %) (:map-to %))) - (into {})))] - - [colors color-mapping color-mapping-inverse])) - (mf/defc text-shape - {::mf/wrap-props false - ::mf/forward-ref true} - [props ref] - (let [{:keys [id x y width height content] :as shape} (obj/get props "shape") - grow-type (obj/get props "grow-type") ;; This is only needed in workspace - ;; We add 8px to add a padding for the exporter - ;; width (+ width 8) - [colors color-mapping color-mapping-inverse] (retrieve-colors shape) + {::mf/wrap-props false} + [props] - plain-colors? (mf/use-ctx muc/text-plain-colors-ctx) - - content (cond-> content - plain-colors? - (remap-colors color-mapping))] - - [:foreignObject {:x x - :y y - :id id - :data-colors (->> colors (str/join ",")) - :data-mapping (-> color-mapping-inverse (clj->js) (js/JSON.stringify)) - :transform (geom/transform-matrix shape) - :width (if (#{:auto-width} grow-type) 100000 width) - :height (if (#{:auto-height :auto-width} grow-type) 100000 height) - :style (-> (obj/new) (attrs/add-layer-props shape)) - :ref ref} - ;; We use a class here because react has a bug that won't use the appropriate selector for - ;; `background-clip` - [:style ".text-node { background-clip: text; - -webkit-background-clip: text;" ] - [:& render-node {:index 0 - :shape shape - :node content}]])) + (let [{:keys [position-data]} (obj/get props "shape")] + (if (some? position-data) + [:> svg/text-shape props] + [:> fo/text-shape props]))) diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs new file mode 100644 index 000000000..7c88c8b69 --- /dev/null +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -0,0 +1,218 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.main.ui.shapes.text.fo-text + (:require + [app.common.colors :as clr] + [app.common.data :as d] + [app.common.geom.shapes :as geom] + [app.main.ui.context :as muc] + [app.main.ui.shapes.attrs :as attrs] + [app.main.ui.shapes.text.styles :as sts] + [app.util.color :as uc] + [app.util.object :as obj] + [cuerdas.core :as str] + [rumext.alpha :as mf])) + +(mf/defc render-text + {::mf/wrap-props false} + [props] + (let [node (obj/get props "node") + text (:text node) + style (sts/generate-text-styles node)] + [:span.text-node {:style style} + (if (= text "") "\u00A0" text)])) + +(mf/defc render-root + {::mf/wrap-props false} + [props] + (let [node (obj/get props "node") + children (obj/get props "children") + shape (obj/get props "shape") + style (sts/generate-root-styles shape node)] + [:div.root.rich-text + {:style style + :xmlns "http://www.w3.org/1999/xhtml"} + children])) + +(mf/defc render-paragraph-set + {::mf/wrap-props false} + [props] + (let [children (obj/get props "children") + shape (obj/get props "shape") + style (sts/generate-paragraph-set-styles shape)] + [:div.paragraph-set {:style style} children])) + +(mf/defc render-paragraph + {::mf/wrap-props false} + [props] + (let [node (obj/get props "node") + shape (obj/get props "shape") + children (obj/get props "children") + style (sts/generate-paragraph-styles shape node) + dir (:text-direction node "auto")] + [:p.paragraph {:style style :dir dir} children])) + +;; -- Text nodes +(mf/defc render-node + {::mf/wrap-props false} + [props] + (let [{:keys [type text children] :as node} (obj/get props "node")] + (if (string? text) + [:> render-text props] + (let [component (case type + "root" render-root + "paragraph-set" render-paragraph-set + "paragraph" render-paragraph + nil)] + (when component + [:> component props + (for [[index node] (d/enumerate children)] + (let [props (-> (obj/clone props) + (obj/set! "node" node) + (obj/set! "index" index) + (obj/set! "key" index))] + [:> render-node props]))]))))) + +(defn- next-color + "Given a set of colors try to get a color not yet used" + [colors] + (assert (set? colors)) + (loop [current-rgb [0 0 0]] + (let [current-hex (uc/rgb->hex current-rgb)] + (if (contains? colors current-hex) + (recur (uc/next-rgb current-rgb)) + current-hex)))) + +(defn- remap-colors + "Returns a new content replacing the original colors by their mapped 'simple color'" + [content color-mapping] + + (cond-> content + (and (:fill-opacity content) (< (:fill-opacity content) 1.0)) + (-> (assoc :fill-color (get color-mapping [(:fill-color content) (:fill-opacity content)])) + (assoc :fill-opacity 1.0)) + + (some? (:fill-color-gradient content)) + (-> (assoc :fill-color (get color-mapping (:fill-color-gradient content))) + (assoc :fill-opacity 1.0) + (dissoc :fill-color-gradient)) + + (contains? content :children) + (update :children #(mapv (fn [node] (remap-colors node color-mapping)) %)))) + +(defn- fill->color + "Given a content node returns the information about that node fill color" + [{:keys [fill-color fill-opacity fill-color-gradient]}] + + (cond + (some? fill-color-gradient) + {:type :gradient + :gradient fill-color-gradient} + + (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + {:type :transparent + :hex fill-color + :opacity fill-opacity} + + (string? fill-color) + {:type :solid + :hex fill-color + :map-to fill-color})) + +(defn- retrieve-colors + "Given a text shape returns a triple with the values: + - colors used as fills + - a mapping from simple solid colors to complex ones (transparents/gradients) + - the inverse of the previous mapping (to restore the value in the SVG)" + [shape] + (let [color-data + (->> (:content shape) + (tree-seq map? :children) + (map fill->color) + (filter some?)) + + colors (->> color-data + (into #{clr/black} + (comp (filter #(= :solid (:type %))) + (map :hex)))) + + [colors color-data] + (loop [colors colors + head (first color-data) + tail (rest color-data) + result []] + + (if (nil? head) + [colors result] + + (if (= :solid (:type head)) + (recur colors + (first tail) + (rest tail) + (conj result head)) + + (let [next-color (next-color colors) + head (assoc head :map-to next-color) + colors (conj colors next-color)] + (recur colors + (first tail) + (rest tail) + (conj result head)))))) + + color-mapping-inverse + (->> color-data + (remove #(= :solid (:type %))) + (group-by :map-to) + (d/mapm #(first %2))) + + color-mapping + (merge + (->> color-data + (filter #(= :transparent (:type %))) + (map #(vector [(:hex %) (:opacity %)] (:map-to %))) + (into {})) + + (->> color-data + (filter #(= :gradient (:type %))) + (map #(vector (:gradient %) (:map-to %))) + (into {})))] + + [colors color-mapping color-mapping-inverse])) + +(mf/defc text-shape + {::mf/wrap-props false + ::mf/forward-ref true} + [props ref] + (let [{:keys [id x y width height content] :as shape} (obj/get props "shape") + grow-type (obj/get props "grow-type") ;; This is only needed in workspace + ;; We add 8px to add a padding for the exporter + ;; width (+ width 8) + [colors color-mapping color-mapping-inverse] (retrieve-colors shape) + + plain-colors? (mf/use-ctx muc/text-plain-colors-ctx) + + content (cond-> content + plain-colors? + (remap-colors color-mapping))] + + [:foreignObject {:x x + :y y + :id id + :data-colors (->> colors (str/join ",")) + :data-mapping (-> color-mapping-inverse (clj->js) (js/JSON.stringify)) + :transform (geom/transform-matrix shape) + :width (if (#{:auto-width} grow-type) 100000 width) + :height (if (#{:auto-height :auto-width} grow-type) 100000 height) + :style (-> (obj/new) (attrs/add-layer-props shape)) + :ref ref} + ;; We use a class here because react has a bug that won't use the appropriate selector for + ;; `background-clip` + [:style ".text-node { background-clip: text; + -webkit-background-clip: text;" ] + [:& render-node {:index 0 + :shape shape + :node content}]])) diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs new file mode 100644 index 000000000..9863c99ff --- /dev/null +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -0,0 +1,45 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.main.ui.shapes.text.svg-text + (:require + [app.common.geom.matrix :as gmt] + [app.common.geom.shapes :as gsh] + [app.main.store :as st] + [app.util.object :as obj] + [rumext.alpha :as mf])) + +(mf/defc text-shape + {::mf/wrap-props false + ::mf/wrap [mf/memo]} + [props] + + (let [{:keys [x y width height position-data] :as shape} (obj/get props "shape") + zoom (or (get-in @st/state [:workspace-local :zoom]) 1)] + [:text {:x x + :y y + :width width + :height height + :dominant-baseline "ideographic" + :transform (gsh/transform-matrix shape) + } + (for [data position-data] + [:tspan {:x (:x data) + :y (:y data) + :transform (:transform-inverse shape (gmt/matrix)) + :style {:fill "black" + :fill-opacity 1 + :stroke "red" + :stroke-width (/ 0.5 zoom) + :font-family (:font-family data) + :font-size (:font-size data) + :font-weight (:font-weight data) + :text-transform (:text-transform data) + :text-decoration (:text-decoration data) + :font-style (:font-style data) + :direction (if (:rtl? data) "rtl" "ltr") + :white-space "pre"}} + (:text data)])])) diff --git a/frontend/src/app/main/ui/workspace/shapes/frame.cljs b/frontend/src/app/main/ui/workspace/shapes/frame.cljs index def968464..6ccb13636 100644 --- a/frontend/src/app/main/ui/workspace/shapes/frame.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/frame.cljs @@ -108,8 +108,16 @@ (-> (cph/get-children objects (:id shape)) (hooks/use-equal-memo)) + all-svg-text? + (mf/use-memo + (mf/deps all-children) + (fn [] + (->> all-children + (filter #(= :text (:type %))) + (every? #(some? (:position-data %)))))) + show-thumbnail? - (and thumbnail? (some? (:thumbnail shape)))] + (and thumbnail? (some? (:thumbnail shape)) all-svg-text?)] [:g.frame-wrapper {:display (when (:hidden shape) "none")} [:> shape-container {:shape shape} diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index 09fc36698..e9563a175 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -8,14 +8,17 @@ (:require [app.common.logging :as log] [app.common.math :as mth] + [app.main.data.workspace.changes :as dch] [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.shapes.shape :refer [shape-container]] - [app.main.ui.shapes.text :as text] + [app.main.ui.shapes.text.fo-text :as fo] + [app.main.ui.shapes.text.svg-text :as svg] [app.util.dom :as dom] [app.util.object :as obj] [app.util.text-editor :as ted] + [app.util.text-svg-position :as utp] [app.util.timers :as timers] [app.util.webapi :as wapi] [beicon.core :as rx] @@ -29,7 +32,7 @@ (mf/defc text-static-content [{:keys [shape]}] - [:& text/text-shape {:shape shape + [:& fo/text-shape {:shape shape :grow-type (:grow-type shape)}]) (defn- update-with-current-editor-state @@ -99,24 +102,67 @@ (mf/use-effect (fn [] #(mf/set-ref-val! mnt false))) - [:& text/text-shape {:ref text-ref-cb :shape shape :grow-type (:grow-type shape)}])) + [:& fo/text-shape {:ref text-ref-cb :shape shape :grow-type (:grow-type shape)}])) (mf/defc text-wrapper {::mf/wrap-props false} [props] - (let [{:keys [id] :as shape} (unchecked-get props "shape") + (let [{:keys [id dirty?] :as shape} (unchecked-get props "shape") edition-ref (mf/use-memo (mf/deps id) #(l/derived (fn [o] (= id (:edition o))) refs/workspace-local)) - edition? (mf/deref edition-ref)] + edition? (mf/deref edition-ref) + shape-ref (mf/use-ref nil)] + + (mf/use-layout-effect + (mf/deps dirty?) + (fn [] + (when (and (or dirty? (not (:position-data shape))) (some? id)) + (let [base-node (mf/ref-val shape-ref) + viewport (dom/get-element "render") + zoom (get-in @st/state [:workspace-local :zoom]) + text-data (utp/calc-text-node-positions base-node viewport zoom) + position-data + (->> text-data + (map (fn [{:keys [node position text]}] + (let [{:keys [x y width height]} position + rtl? (= "rtl" (.-dir (.-parentElement ^js node))) + styles (.computedStyleMap ^js node)] + {:rtl? rtl? + :x (if rtl? (+ x width) x) + :y (+ y height) + :width width + :height height + :font-family (str (.get styles "font-family")) + :font-size (str (.get styles "font-size")) + :font-weight (str (.get styles "font-weight")) + :text-transform (str (.get styles "text-transform")) + :text-decoration (str (.get styles "text-decoration")) + :font-style (str (.get styles "font-style")) + :text text}))))] + (st/emit! (dch/update-shapes + [id] + (fn [shape] + (-> shape + (dissoc :dirty?) + (assoc :position-data position-data))))))))) [:> shape-container {:shape shape} ;; We keep hidden the shape when we're editing so it keeps track of the size ;; and updates the selrect accordingly - [:g.text-shape {:opacity (when edition? 0) - :pointer-events "none"} + [:* + [:g.text-shape {:ref shape-ref + :opacity (when (or edition? (some? (:position-data shape))) 0) + :pointer-events "none"} - ;; The `:key` prop here is mandatory because the - ;; text-resize-content breaks a hooks rule and we can't reuse - ;; the component if the edition flag changes. - [:& text-resize-content {:shape shape - :edition? edition? - :key (str id edition?)}]]])) + ;; The `:key` prop here is mandatory because the + ;; text-resize-content breaks a hooks rule and we can't reuse + ;; the component if the edition flag changes. + [:& text-resize-content {:shape (cond-> shape + (:position-data shape) + (dissoc :transform :transform-inverse)) + :edition? edition? + :key (str id edition?)}]] + + [:g {:opacity (when edition? 0) + :pointer-events "none"} + (when (some? (:position-data shape)) + [:& svg/text-shape {:shape shape}])]]])) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 76a7500cf..d35c8c3ae 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -264,11 +264,11 @@ :height (.-height ^js rect)})) (defn bounding-rect->rect - [{:keys [left top width height]}] - {:x left - :y top - :width width - :height height}) + [rect] + {:x (or (.-left rect) (:left rect)) + :y (or (.-top rect) (:top rect)) + :width (or (.-width rect) (:width rect)) + :height (or (.-height rect) (:height rect))}) (defn get-window-size [] diff --git a/frontend/src/app/util/text_svg_position.cljs b/frontend/src/app/util/text_svg_position.cljs new file mode 100644 index 000000000..ffea979c1 --- /dev/null +++ b/frontend/src/app/util/text_svg_position.cljs @@ -0,0 +1,92 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.util.text-svg-position + (:require + [app.common.data :as d] + [app.common.geom.point :as gpt] + [app.util.dom :as dom] + [app.util.globals :as global])) + +(defn get-range-rects + "Retrieve the rectangles that cover the selection given by a `node` adn + the start and end index `start-i`, `end-i`" + [^js node start-i end-i] + (let [^js range (.createRange global/document)] + (.setStart range node start-i) + (.setEnd range node end-i) + (.getClientRects range))) + +(defn parse-text-nodes + "Given a text node retrieves the rectangles for everyone of its paragraphs and its text." + [parent-node rtl? text-node] + + (let [content (.-textContent text-node) + text-size (.-length content)] + + (loop [from-i 0 + to-i 0 + current "" + result []] + (if (>= to-i text-size) + (let [rects (get-range-rects text-node from-i to-i) + entry {:node parent-node + :position (dom/bounding-rect->rect (first rects)) + :text current}] + ;; We need to add the last element not closed yet + (conj result entry)) + + (let [rects (get-range-rects text-node from-i (inc to-i))] + ;; If the rects increase means we're in a new paragraph + (if (> (.-length rects) 1) + (let [entry {:node parent-node + :position (dom/bounding-rect->rect (if rtl? (second rects) (first rects))) + :text current}] + (recur to-i to-i "" (conj result entry))) + (recur from-i (inc to-i) (str current (nth content to-i)) result))))))) + + +(defn calc-text-node-positions + [base-node viewport zoom] + + (when (some? viewport) + (let [translate-point + (fn [pt] + (let [vbox (.. ^js viewport -viewBox -baseVal) + brect (dom/get-bounding-rect viewport) + brect (gpt/point (d/parse-integer (:left brect)) + (d/parse-integer (:top brect))) + box (gpt/point (.-x vbox) (.-y vbox)) + zoom (gpt/point zoom)] + + (-> (gpt/subtract pt brect) + (gpt/divide zoom) + (gpt/add box)))) + + translate-rect + (fn [{:keys [x y width height] :as rect}] + (let [p1 (-> (gpt/point x y) + (translate-point)) + + p2 (-> (gpt/point (+ x width) (+ y height)) + (translate-point))] + (assoc rect + :x (:x p1) + :y (:y p1) + :width (- (:x p2) (:x p1)) + :height (- (:y p2) (:y p1))))) + + text-nodes (dom/query-all base-node ".text-node")] + + (->> text-nodes + (mapcat + (fn [parent-node] + (let [rtl? (= "rtl" (.-dir (.-parentElement parent-node)))] + (->> (.-childNodes parent-node) + (mapcat #(parse-text-nodes parent-node rtl? %)))))) + (map #(update % :position translate-rect)))))) + +