From f7d0383919e8fe2cabf87cf89ee1d68a87a5c100 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 08:38:01 +0100 Subject: [PATCH 01/17] :zap: Improve performance of enumerate data helper. --- common/app/common/data.cljc | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/common/app/common/data.cljc b/common/app/common/data.cljc index bc3d14dba..26722cf75 100644 --- a/common/app/common/data.cljc +++ b/common/app/common/data.cljc @@ -42,7 +42,6 @@ ([a b & rest] (reduce deep-merge a (cons b rest)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Structures Manipulation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -70,14 +69,14 @@ (defn enumerate ([items] (enumerate items 0)) ([items start] - (loop [idx start + (loop [idx start items items - res []] + res (transient [])] (if (empty? items) - res + (persistent! res) (recur (inc idx) (rest items) - (conj res [idx (first items)])))))) + (conj! res [idx (first items)])))))) (defn seek ([pred coll] From 2de1c92ee87a1629657d9a530596d0e9be33ead7 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 08:38:32 +0100 Subject: [PATCH 02/17] :tada: Add transducer version of mapm data helper. --- common/app/common/data.cljc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/app/common/data.cljc b/common/app/common/data.cljc index 26722cf75..088a36f07 100644 --- a/common/app/common/data.cljc +++ b/common/app/common/data.cljc @@ -146,8 +146,10 @@ (defn mapm "Map over the values of a map" - [mfn coll] - (into {} (map (fn [[key val]] [key (mfn key val)]) coll))) + ([mfn] + (map (fn [[key val]] [key (mfn key val)]))) + ([mfn coll] + (into {} (mapm mfn) coll))) (defn filterm "Filter values of a map that satisfy a predicate" From 439e5ee6a10e1431606fa402d1efc3feebf723d5 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 08:39:24 +0100 Subject: [PATCH 03/17] :tada: Add array util ns (frontend only). --- frontend/src/app/util/array.cljs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frontend/src/app/util/array.cljs diff --git a/frontend/src/app/util/array.cljs b/frontend/src/app/util/array.cljs new file mode 100644 index 000000000..93bce41f5 --- /dev/null +++ b/frontend/src/app/util/array.cljs @@ -0,0 +1,18 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns app.util.array + "A collection of helpers for work with javascript arrays." + (:refer-clojure :exclude [conj!])) + +(defn conj! + "A conj like function for js arrays." + [a v] + (.push ^js a v) + a) From 3bef80932d2f3b0dab0056a7c685df661dd075e0 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 08:43:23 +0100 Subject: [PATCH 04/17] :recycle: Replace slate editor with draft-js. --- common/app/common/attrs.cljc | 13 +- common/app/common/text.cljc | 79 +++++ frontend/package.json | 4 +- .../resources/styles/main/partials/texts.scss | 72 +++- frontend/src/app/main/data/workspace.cljs | 125 +------ .../src/app/main/data/workspace/common.cljs | 109 +++++- .../data/workspace/libraries_helpers.cljs | 28 +- .../src/app/main/data/workspace/texts.cljs | 312 +++++++++--------- frontend/src/app/main/refs.cljs | 6 + frontend/src/app/main/ui.cljs | 16 +- .../app/main/ui/handoff/attributes/text.cljs | 94 +++--- frontend/src/app/main/ui/shapes/text.cljs | 91 +++-- .../src/app/main/ui/shapes/text/embed.cljs | 57 ++-- .../src/app/main/ui/shapes/text/styles.cljs | 201 ++++++----- frontend/src/app/main/ui/workspace.cljs | 1 - .../app/main/ui/workspace/shapes/text.cljs | 21 +- .../main/ui/workspace/shapes/text/editor.cljs | 303 ++++++----------- .../app/main/ui/workspace/sidebar/assets.cljs | 4 +- .../workspace/sidebar/options/menus/fill.cljs | 4 +- .../workspace/sidebar/options/menus/text.cljs | 29 +- .../sidebar/options/menus/typography.cljs | 26 +- .../sidebar/options/shapes/multiple.cljs | 16 +- .../sidebar/options/shapes/text.cljs | 74 +++-- .../src/app/main/ui/workspace/viewport.cljs | 12 +- frontend/src/app/util/code_gen.cljs | 59 +++- frontend/src/app/util/text.cljs | 123 ------- frontend/src/app/util/text_editor.cljs | 298 +++++++++++++++++ frontend/yarn.lock | 76 ++++- 28 files changed, 1272 insertions(+), 981 deletions(-) create mode 100644 common/app/common/text.cljc delete mode 100644 frontend/src/app/util/text.cljs create mode 100644 frontend/src/app/util/text_editor.cljs diff --git a/common/app/common/attrs.cljc b/common/app/common/attrs.cljc index 3cb24a935..f341a9adb 100644 --- a/common/app/common/attrs.cljc +++ b/common/app/common/attrs.cljc @@ -7,7 +7,8 @@ ;; ;; Copyright (c) 2020 UXBOX Labs SL -(ns app.common.attrs) +(ns app.common.attrs + (:refer-clojure :exclude [merge])) ;; Extract some attributes of a list of shapes. ;; For each attribute, if the value is the same in all shapes, @@ -48,7 +49,6 @@ (loop [attr (first attrs) attrs (rest attrs) result (transient {})] - (if attr (let [value (loop [curr (first objs) @@ -75,3 +75,12 @@ (persistent! result))))) +(defn merge + "Attrs specific merge function." + [obj attrs] + (reduce-kv (fn [obj k v] + (if (nil? v) + (dissoc obj k) + (assoc obj k v))) + obj + attrs)) diff --git a/common/app/common/text.cljc b/common/app/common/text.cljc new file mode 100644 index 000000000..d8123c039 --- /dev/null +++ b/common/app/common/text.cljc @@ -0,0 +1,79 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.common.text + (:require + [app.common.attrs :as attrs] + [app.common.data :as d] + [app.util.transit :as t] + [clojure.walk :as walk] + [cuerdas.core :as str])) + +(def default-text-attrs + {:typography-ref-file nil + :typography-ref-id nil + :font-id "sourcesanspro" + :font-family "sourcesanspro" + :font-variant-id "regular" + :font-size "14" + :font-weight "400" + :font-style "normal" + :line-height "1.2" + :letter-spacing "0" + :text-transform "none" + :text-align "left" + :text-decoration "none" + :fill-color nil + :fill-opacity 1}) + +(def typography-fields + [:font-id + :font-family + :font-variant-id + :font-size + :font-weight + :font-style + :line-height + :letter-spacing + :text-transform]) + +(def default-typography + (merge + {:name "Source Sans Pro Regular"} + (select-keys default-text-attrs typography-fields))) + +(defn transform-nodes + ([transform root] + (transform-nodes identity transform root)) + ([pred transform root] + (walk/postwalk + (fn [item] + (if (and (map? item) (pred item)) + (transform item) + item)) + root))) + +(defn node-seq + ([root] (node-seq identity root)) + ([match? root] + (->> (tree-seq map? :children root) + (filter match?) + (seq)))) + +(defn ^boolean is-text-node? + [node] + (string? (:text node))) + +(defn ^boolean is-paragraph-node? + [node] + (= "paragraph" (:type node))) + +(defn ^boolean is-root-node? + [node] + (= "root" (:type node))) diff --git a/frontend/package.json b/frontend/package.json index b8c8974a2..dd4ff7333 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,10 +34,10 @@ "shadow-cljs": "^2.11.20" }, "dependencies": { - "humanize-duration": "~3.25.0", - "luxon": "~1.25.0", "date-fns": "^2.19.0", + "draft-js": "^0.11.7", "highlight.js": "^10.6.0", + "humanize-duration": "~3.25.0", "js-beautify": "^1.13.5", "mousetrap": "^1.6.5", "randomcolor": "^0.6.2", diff --git a/frontend/resources/styles/main/partials/texts.scss b/frontend/resources/styles/main/partials/texts.scss index 24ebc8f0f..b132d18cc 100644 --- a/frontend/resources/styles/main/partials/texts.scss +++ b/frontend/resources/styles/main/partials/texts.scss @@ -1,5 +1,69 @@ -foreignObject .rich-text { - color: $color-black; - height: 100%; - white-space: pre-wrap; +foreignObject { + .text-editor, .rich-text { + color: $color-black; + height: 100%; + white-space: pre-wrap; + font-family: sourcesanspro; + + div { + line-height: inherit; + user-select: text; + } + + span { + line-height: inherit; + } + } + + .text-editor { + .public-DraftStyleDefault-rtl { + direction: rtl; + } + .public-DraftStyleDefault-rtl { + direction: ltr; + } + + .DraftEditor-root { + height: 100%; + display: flex; + flex-direction: column; + } + + &.align-top { + .DraftEditor-root { + justify-content: flex-start; + } + } + + &.align-center { + .DraftEditor-root { + justify-content: center; + } + } + + &.align-bottom { + .DraftEditor-root { + justify-content: flex-end; + } + } + } + + .rich-text .paragraphs { + height: 100%; + display: flex; + flex-direction: column; + + &.align-top { + justify-content: flex-start; + } + + &.align-center { + justify-content: center; + } + + &.align-bottom { + justify-content: flex-end; + } + } } + diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8ea1041d4..4901d0a82 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -33,7 +33,6 @@ [app.main.data.workspace.notifications :as dwn] [app.main.data.workspace.persistence :as dwp] [app.main.data.workspace.selection :as dws] - [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.transforms :as dwt] [app.main.repo :as rp] [app.main.store :as st] @@ -603,22 +602,6 @@ (let [selected (get-in state [:workspace-local :selected])] (rx/from (map #(update-shape % attrs) selected)))))) -(defn update-color-on-selected-shapes - [{:keys [fill-color stroke-color] :as attrs}] - (us/verify ::shape-attrs attrs) - (ptk/reify ::update-color-on-selected-shapes - ptk/WatchEvent - (watch [_ state stream] - (let [selected (get-in state [:workspace-local :selected]) - update-fn - (fn [shape] - (cond-> (merge shape attrs) - (and (= :text (:type shape)) - (string? (:fill-color attrs))) - (dwtxt/impl-update-shape-attrs {:fill (:fill-color attrs)})))] - (rx/of (dwc/update-shapes-recursive selected update-fn)))))) - - ;; --- Shape Movement (using keyboard shorcuts) (declare initial-selection-align) @@ -649,119 +632,13 @@ ;; --- Delete Selected -(defn- delete-shapes - [ids] - (us/assert (s/coll-of ::us/uuid) ids) - (ptk/reify ::delete-shapes - ptk/WatchEvent - (watch [_ state stream] - (let [page-id (:current-page-id state) - objects (dwc/lookup-page-objects state page-id) - - get-empty-parents - (fn [parents] - (->> parents - (map (fn [id] - (let [obj (get objects id)] - (when (and (= :group (:type obj)) - (= 1 (count (:shapes obj)))) - obj)))) - (take-while (complement nil?)) - (map :id))) - - groups-to-unmask - (reduce (fn [group-ids id] - ;; When the shape to delete is the mask of a masked group, - ;; the mask condition must be removed, and it must be - ;; converted to a normal group. - (let [obj (get objects id) - parent (get objects (:parent-id obj))] - (if (and (:masked-group? parent) - (= id (first (:shapes parent)))) - (conj group-ids (:id parent)) - group-ids))) - #{} - ids) - - rchanges - (d/concat - (reduce (fn [res id] - (let [children (cp/get-children id objects) - parents (cp/get-parents id objects) - del-change #(array-map - :type :del-obj - :page-id page-id - :id %)] - (d/concat res - (map del-change (reverse children)) - [(del-change id)] - (map del-change (get-empty-parents parents)) - [{:type :reg-objects - :page-id page-id - :shapes (vec parents)}]))) - [] - ids) - (map #(array-map - :type :mod-obj - :page-id page-id - :id % - :operations [{:type :set - :attr :masked-group? - :val false}]) - groups-to-unmask)) - - uchanges - (d/concat - (reduce (fn [res id] - (let [children (cp/get-children id objects) - parents (cp/get-parents id objects) - parent (get objects (first parents)) - add-change (fn [id] - (let [item (get objects id)] - {:type :add-obj - :id (:id item) - :page-id page-id - :index (cp/position-on-parent id objects) - :frame-id (:frame-id item) - :parent-id (:parent-id item) - :obj item}))] - (d/concat res - (map add-change (reverse (get-empty-parents parents))) - [(add-change id)] - (map add-change children) - [{:type :reg-objects - :page-id page-id - :shapes (vec parents)}] - (when (some? parent) - [{:type :mod-obj - :page-id page-id - :id (:id parent) - :operations [{:type :set-touched - :touched (:touched parent)}]}])))) - [] - ids) - (map #(array-map - :type :mod-obj - :page-id page-id - :id % - :operations [{:type :set - :attr :masked-group? - :val true}]) - groups-to-unmask))] - - ;; (println "================ rchanges") - ;; (cljs.pprint/pprint rchanges) - ;; (println "================ uchanges") - ;; (cljs.pprint/pprint uchanges) - (rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})))))) - (def delete-selected "Deselect all and remove all selected shapes." (ptk/reify ::delete-selected ptk/WatchEvent (watch [_ state stream] (let [selected (get-in state [:workspace-local :selected])] - (rx/of (delete-shapes selected) + (rx/of (dwc/delete-shapes selected) (dws/deselect-all)))))) ;; --- Shape Vertical Ordering diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index be9196187..19043af5f 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -395,7 +395,6 @@ ;; Shapes ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - (defn expand-all-parents [ids objects] (ptk/reify ::expand-all-parents @@ -672,6 +671,114 @@ :shapes [shape-id]})))] (rx/of (commit-changes rchanges uchanges {:commit-local? true})))))) + +(defn delete-shapes + [ids] + (us/assert (s/coll-of ::us/uuid) ids) + (ptk/reify ::delete-shapes + ptk/WatchEvent + (watch [_ state stream] + (let [page-id (:current-page-id state) + objects (lookup-page-objects state page-id) + + get-empty-parents + (fn [parents] + (->> parents + (map (fn [id] + (let [obj (get objects id)] + (when (and (= :group (:type obj)) + (= 1 (count (:shapes obj)))) + obj)))) + (take-while (complement nil?)) + (map :id))) + + groups-to-unmask + (reduce (fn [group-ids id] + ;; When the shape to delete is the mask of a masked group, + ;; the mask condition must be removed, and it must be + ;; converted to a normal group. + (let [obj (get objects id) + parent (get objects (:parent-id obj))] + (if (and (:masked-group? parent) + (= id (first (:shapes parent)))) + (conj group-ids (:id parent)) + group-ids))) + #{} + ids) + + rchanges + (d/concat + (reduce (fn [res id] + (let [children (cp/get-children id objects) + parents (cp/get-parents id objects) + del-change #(array-map + :type :del-obj + :page-id page-id + :id %)] + (d/concat res + (map del-change (reverse children)) + [(del-change id)] + (map del-change (get-empty-parents parents)) + [{:type :reg-objects + :page-id page-id + :shapes (vec parents)}]))) + [] + ids) + (map #(array-map + :type :mod-obj + :page-id page-id + :id % + :operations [{:type :set + :attr :masked-group? + :val false}]) + groups-to-unmask)) + + uchanges + (d/concat + (reduce (fn [res id] + (let [children (cp/get-children id objects) + parents (cp/get-parents id objects) + parent (get objects (first parents)) + add-change (fn [id] + (let [item (get objects id)] + {:type :add-obj + :id (:id item) + :page-id page-id + :index (cp/position-on-parent id objects) + :frame-id (:frame-id item) + :parent-id (:parent-id item) + :obj item}))] + (d/concat res + (map add-change (reverse (get-empty-parents parents))) + [(add-change id)] + (map add-change children) + [{:type :reg-objects + :page-id page-id + :shapes (vec parents)}] + (when (some? parent) + [{:type :mod-obj + :page-id page-id + :id (:id parent) + :operations [{:type :set-touched + :touched (:touched parent)}]}])))) + [] + ids) + (map #(array-map + :type :mod-obj + :page-id page-id + :id % + :operations [{:type :set + :attr :masked-group? + :val true}]) + groups-to-unmask))] + + ;; (println "================ rchanges") + ;; (cljs.pprint/pprint rchanges) + ;; (println "================ uchanges") + ;; (cljs.pprint/pprint uchanges) + (rx/of (commit-changes rchanges uchanges {:commit-local? true})))))) + + ;; --- Add shape to Workspace (defn- viewport-center diff --git a/frontend/src/app/main/data/workspace/libraries_helpers.cljs b/frontend/src/app/main/data/workspace/libraries_helpers.cljs index 44fd6c31f..437a5431a 100644 --- a/frontend/src/app/main/data/workspace/libraries_helpers.cljs +++ b/frontend/src/app/main/data/workspace/libraries_helpers.cljs @@ -5,20 +5,20 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.data.workspace.libraries-helpers (:require - [cljs.spec.alpha :as s] - [clojure.set :as set] - [app.common.spec :as us] [app.common.data :as d] [app.common.geom.point :as gpt] [app.common.geom.shapes :as geom] [app.common.pages :as cp] + [app.common.spec :as us] + [app.common.text :as txt] [app.main.data.workspace.groups :as dwg] [app.util.logging :as log] - [app.util.text :as ut])) + [cljs.spec.alpha :as s] + [clojure.set :as set])) ;; Change this to :info :debug or :trace to debug this module (log/set-level! :warn) @@ -317,11 +317,11 @@ (->> shape :content ;; Check if any node in the content has a reference for the library - (ut/some-node - #(or (and (some? (:stroke-color-ref-id %)) - (= library-id (:stroke-color-ref-file %))) - (and (some? (:fill-color-ref-id %)) - (= library-id (:fill-color-ref-file %)))))) + (txt/node-seq + #(or (and (some? (:stroke-color-ref-id %)) + (= library-id (:stroke-color-ref-file %))) + (and (some? (:fill-color-ref-id %)) + (= library-id (:fill-color-ref-file %)))))) (some #(let [attr (name %) attr-ref-id (keyword (str attr "-ref-id")) @@ -336,9 +336,9 @@ (->> shape :content ;; Check if any node in the content has a reference for the library - (ut/some-node - #(and (some? (:typography-ref-id %)) - (= library-id (:typography-ref-file %))))))))) + (txt/node-seq + #(and (some? (:typography-ref-id %)) + (= library-id (:typography-ref-file %))))))))) (defmulti generate-sync-shape "Generate changes to synchronize one shape with all assets of the given type @@ -356,7 +356,7 @@ (defn- generate-sync-text-shape [shape container update-node] (let [old-content (:content shape) - new-content (ut/map-node update-node old-content) + new-content (txt/transform-nodes update-node old-content) rchanges [(make-change container {:type :mod-obj diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 27ef5ce4a..94eb3076f 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -5,199 +5,188 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.data.workspace.texts (:require - ["slate" :as slate :refer [Editor Node Transforms Text]] - ["slate-react" :as rslate] [app.common.math :as mth] [app.common.attrs :as attrs] + [app.common.text :as txt] [app.common.geom.shapes :as gsh] [app.common.pages :as cp] + [app.common.data :as d] + [app.main.data.workspace.selection :as dws] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.transforms :as dwt] [app.main.fonts :as fonts] [app.util.object :as obj] - [app.util.text :as ut] + [app.util.text-editor :as ted] + [app.util.timers :as ts] [beicon.core :as rx] [cljs.spec.alpha :as s] - [clojure.walk :as walk] [goog.object :as gobj] + [cuerdas.core :as str] [potok.core :as ptk])) -(defn create-editor - [] - (rslate/withReact (slate/createEditor))) - -(defn assign-editor - [id editor] - (ptk/reify ::assign-editor +(defn update-editor + [editor] + (ptk/reify ::update-editor ptk/UpdateEvent (update [_ state] - (-> state - (assoc-in [:workspace-local :editors id] editor) - (update-in [:workspace-local :editor-n] (fnil inc 0)))))) + (if (some? editor) + (assoc state :workspace-editor editor) + (dissoc state :workspace-editor))))) + +(defn focus-editor + [] + (ptk/reify ::focus-editor + ptk/EffectEvent + (effect [_ state stream] + (when-let [editor (:workspace-editor state)] + (ts/schedule #(.focus ^js editor)))))) + +(defn update-editor-state + [{:keys [id] :as shape} editor-state] + (ptk/reify ::update-editor-state + ptk/UpdateEvent + (update [_ state] + (if (some? editor-state) + (update state :workspace-editor-state assoc id editor-state) + (update state :workspace-editor-state dissoc id))))) + +(defn initialize-editor-state + [{:keys [id content] :as shape}] + (ptk/reify ::initialize-editor-state + ptk/UpdateEvent + (update [_ state] + (update-in state [:workspace-editor-state id] + (fn [_] + (ted/create-editor-state + (some->> content ted/import-content))))))) + +(defn finalize-editor-state + [{:keys [id] :as shape}] + (ptk/reify ::finalize-editor-state + ptk/WatchEvent + (watch [_ state stream] + (let [content (-> (get-in state [:workspace-editor-state id]) + (ted/get-editor-current-content))] + (if (ted/content-has-text? content) + (let [content (d/merge (ted/export-content content) + (dissoc (:content shape) :children))] + (rx/merge + (rx/of (update-editor-state shape nil)) + (when (not= content (:content shape)) + (rx/of (dwc/update-shapes [id] #(assoc % :content content)))))) + (rx/of (dws/deselect-shape id) + (dwc/delete-shapes [id]))))))) + +(defn select-all + "Select all content of the current editor. When not editor found this + event is noop." + [{:keys [id] :as shape}] + (ptk/reify ::editor-select-all + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:workspace-editor-state id] ted/editor-select-all)))) ;; --- Helpers -(defn- calculate-full-selection - [editor] - (let [children (obj/get editor "children") - paragraphs (obj/get-in children [0 "children" 0 "children"]) - lastp (aget paragraphs (dec (alength paragraphs))) - lastptxt (.string Node lastp)] - #js {:anchor #js {:path #js [0 0 0] - :offset 0} - :focus #js {:path #js [0 0 (dec (alength paragraphs))] - :offset (alength lastptxt)}})) - -(defn- editor-select-all! - [editor] - (let [children (obj/get editor "children") - paragraphs (obj/get-in children [0 "children" 0 "children"]) - range (calculate-full-selection editor)] - (.select Transforms editor range))) - -(defn- editor-set! - ([editor props] - (editor-set! editor props #js {})) - ([editor props options] - (.setNodes Transforms editor props options) - editor)) - -(defn- transform-nodes - [pred transform data] - (walk/postwalk - (fn [item] - (if (and (map? item) (pred item)) - (transform item) - item)) - data)) - -;; --- Editor Related Helpers - -(defn- ^boolean is-text-node? - [node] - (cond - (object? node) (.isText Text node) - (map? node) (string? (:text node)) - (nil? node) false - :else (throw (ex-info "unexpected type" {:node node})))) - -(defn- ^boolean is-paragraph-node? - [node] - (cond - (object? node) (= (.-type node) "paragraph") - (map? node) (= "paragraph" (:type node)) - (nil? node) false - :else (throw (ex-info "unexpected type" {:node node})))) - -(defn- ^boolean is-root-node? - [node] - (cond - (object? node) (= (.-type node) "root") - (map? node) (= "root" (:type node)) - (nil? node) false - :else (throw (ex-info "unexpected type" {:node node})))) - -(defn- editor-current-values - [editor pred attrs universal?] - (let [options #js {:match pred :universal universal?} - _ (when (nil? (obj/get editor "selection")) - (obj/set! options "at" (calculate-full-selection editor))) - result (.nodes Editor editor options) - match (ffirst (es6-iterator-seq result))] - (when (object? match) - (let [attrs (clj->js attrs) - result (areduce attrs i ret #js {} - (let [val (obj/get match (aget attrs i))] - (if val - (obj/set! ret (aget attrs i) val) - ret)))] - (js->clj result :keywordize-keys true))))) - -(defn nodes-seq - [match? node] - (->> (tree-seq map? :children node) - (filter match?))) - (defn- shape-current-values [shape pred attrs] (let [root (:content shape) - nodes (->> (nodes-seq pred root) - (map #(if (is-text-node? %) - (merge ut/default-text-attrs %) + nodes (->> (txt/node-seq pred root) + (map #(if (txt/is-text-node? %) + (merge txt/default-text-attrs %) %)))] (attrs/get-attrs-multi nodes attrs))) -(defn current-text-values - [{:keys [editor default attrs shape]}] - (if editor - (editor-current-values editor is-text-node? attrs true) - (shape-current-values shape is-text-node? attrs))) - (defn current-paragraph-values - [{:keys [editor attrs shape]}] - (if editor - (editor-current-values editor is-paragraph-node? attrs false) - (shape-current-values shape is-paragraph-node? attrs))) + [{:keys [editor-state attrs shape]}] + (if editor-state + (-> (ted/get-editor-current-block-data editor-state) + (select-keys attrs)) + (shape-current-values shape txt/is-paragraph-node? attrs))) -(defn current-root-values - [{:keys [editor attrs shape]}] - (if editor - (editor-current-values editor is-root-node? attrs false) - (shape-current-values shape is-root-node? attrs))) +(defn current-text-values + [{:keys [editor-state attrs shape]}] + (if editor-state + (-> (ted/get-editor-current-inline-styles editor-state) + (select-keys attrs)) + (shape-current-values shape txt/is-text-node? attrs))) -(defn- merge-attrs - [node attrs] - (reduce-kv (fn [node k v] - (if (nil? v) - (dissoc node k) - (assoc node k v))) - node - attrs)) -(defn impl-update-shape-attrs - ([shape attrs] - ;; NOTE: this arity is used in workspace for properly update the - ;; fill color using colorpalette, then the predicate should be - ;; defined. - (impl-update-shape-attrs shape attrs is-text-node?)) - ([{:keys [type content] :as shape} attrs pred] - (assert (= :text type) "should be shape type") - (let [merge-attrs #(merge-attrs % attrs)] - (update shape :content #(transform-nodes pred merge-attrs %))))) +;; --- TEXT EDITION IMPL -(defn update-attrs - [{:keys [id editor attrs pred split] - :or {pred is-text-node?}}] - (if editor - (ptk/reify ::update-attrs - ptk/EffectEvent - (effect [_ state stream] - (editor-set! editor (clj->js attrs) #js {:match pred :split split}))) - - (ptk/reify ::update-attrs - ptk/WatchEvent - (watch [_ state stream] - (let [objects (dwc/lookup-page-objects state) - shape (get objects id) - ids (cond (= (:type shape) :text) [id] - (= (:type shape) :group) (cp/get-children id objects))] - (rx/of (dwc/update-shapes ids #(impl-update-shape-attrs % attrs pred)))))))) - -(defn update-text-attrs - [options] - (update-attrs (assoc options :pred is-text-node? :split true))) - -(defn update-paragraph-attrs - [options] - (update-attrs (assoc options :pred is-paragraph-node? :split false))) +(defn- update-shape + [shape pred-fn attrs] + (let [merge-attrs #(attrs/merge % attrs) + transform #(txt/transform-nodes pred-fn merge-attrs %)] + (update shape :content transform))) (defn update-root-attrs - [options] - (update-attrs (assoc options :pred is-root-node? :split false))) + [{:keys [id attrs]}] + (ptk/reify ::update-root-attrs + ptk/WatchEvent + (watch [_ state stream] + (let [objects (dwc/lookup-page-objects state) + shape (get objects id) + + update-fn #(update-shape % txt/is-root-node? attrs) + shape-ids (cond (= (:type shape) :text) [id] + (= (:type shape) :group) (cp/get-children id objects))] + + (rx/of (dwc/update-shapes shape-ids update-fn) + (focus-editor)))))) + +(defn update-paragraph-attrs + [{:keys [id attrs]}] + (let [attrs (d/without-nils attrs)] + (ptk/reify ::update-paragraph-attrs + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-block-data attrs)) + + ptk/WatchEvent + (watch [_ state stream] + (cond + (some? (get-in state [:workspace-editor-state id])) + (rx/of (focus-editor)) + + :else + (let [objects (dwc/lookup-page-objects state) + shape (get objects id) + + update-fn #(update-shape % txt/is-paragraph-node? attrs) + shape-ids (cond (= (:type shape) :text) [id] + (= (:type shape) :group) (cp/get-children id objects))] + + (rx/of (dwc/update-shapes shape-ids update-fn)))))))) + +(defn update-text-attrs + [{:keys [id attrs]}] + (let [attrs (d/without-nils attrs)] + (ptk/reify ::update-text-attrs + ptk/UpdateEvent + (update [_ state] + (d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-inline-styles attrs)) + + ptk/WatchEvent + (watch [_ state stream] + (cond + (some? (get-in state [:workspace-editor-state id])) + (rx/of (focus-editor)) + + :else + (let [objects (dwc/lookup-page-objects state) + shape (get objects id) + + update-fn #(update-shape % txt/is-text-node? attrs) + shape-ids (cond (= (:type shape) :text) [id] + (= (:type shape) :group) (cp/get-children id objects))] + (rx/of (dwc/update-shapes shape-ids update-fn)))))))) + +;; --- RESIZE UTILS (defn update-overflow-text [id value] (ptk/reify ::update-overflow-text @@ -211,7 +200,7 @@ (ptk/reify ::start-edit-if-selected ptk/UpdateEvent (update [_ state] - (let [objects (dwc/lookup-page-objects state) + (let [objects (dwc/lookup-page-objects state) selected (->> state :workspace-local :selected (map #(get objects %)))] (cond-> state (and (= 1 (count selected)) @@ -284,7 +273,8 @@ ;; together. This improves the performance because we only re-render the ;; resized components once even if there are changes that applies to ;; lots of texts like changing a font -(defn resize-text [id new-width new-height] +(defn resize-text + [id new-width new-height] (ptk/reify ::resize-text IDeref (-deref [_] diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index f1647bc13..73c52b381 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -180,6 +180,12 @@ (def workspace-frames (l/derived cp/select-frames workspace-page-objects)) +(def workspace-editor + (l/derived :workspace-editor st/state)) + +(def workspace-editor-state + (l/derived :workspace-editor-state st/state)) + (defn object-by-id [id] (l/derived #(get % id) workspace-page-objects)) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index fb5861ec0..c5ca0bb90 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -9,35 +9,35 @@ (ns app.main.ui (:require - [app.config :as cfg] [app.common.data :as d] [app.common.exceptions :as ex] - [app.common.uuid :as uuid] [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.config :as cfg] [app.main.data.auth :refer [logout]] [app.main.data.messages :as dm] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.auth :refer [auth]] [app.main.ui.auth.verify-token :refer [verify-token]] - [app.main.ui.cursors :as c] [app.main.ui.context :as ctx] - [app.main.ui.onboarding] + [app.main.ui.cursors :as c] [app.main.ui.dashboard :refer [dashboard]] + [app.main.ui.handoff :refer [handoff]] [app.main.ui.icons :as i] [app.main.ui.messages :as msgs] + [app.main.ui.onboarding] [app.main.ui.render :as render] [app.main.ui.settings :as settings] [app.main.ui.static :as static] [app.main.ui.viewer :refer [viewer-page]] - [app.main.ui.handoff :refer [handoff]] [app.main.ui.workspace :as workspace] [app.util.i18n :as i18n :refer [tr t]] - [app.util.timers :as ts] [app.util.router :as rt] - [cuerdas.core :as str] - [cljs.spec.alpha :as s] + [app.util.timers :as ts] [cljs.pprint :refer [pprint]] + [cljs.spec.alpha :as s] + [cuerdas.core :as str] [expound.alpha :as expound] [potok.core :as ptk] [rumext.alpha :as mf])) diff --git a/frontend/src/app/main/ui/handoff/attributes/text.cljs b/frontend/src/app/main/ui/handoff/attributes/text.cljs index e61c1922f..2af5f9f31 100644 --- a/frontend/src/app/main/ui/handoff/attributes/text.cljs +++ b/frontend/src/app/main/ui/handoff/attributes/text.cljs @@ -5,24 +5,23 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.handoff.attributes.text (:require - [rumext.alpha :as mf] + [app.common.text :as txt] + [app.main.fonts :as fonts] + [app.main.store :as st] + [app.main.ui.components.copy-button :refer [copy-button]] + [app.main.ui.handoff.attributes.common :refer [color-row]] + [app.main.ui.icons :as i] + [app.util.i18n :refer [tr]] + [app.util.code-gen :as cg] + [app.util.color :as uc] + [app.util.webapi :as wapi] [cuerdas.core :as str] [okulary.core :as l] - [app.util.data :as d] - [app.util.i18n :refer [t]] - [app.util.color :as uc] - [app.util.text :as ut] - [app.main.fonts :as fonts] - [app.main.ui.icons :as i] - [app.util.webapi :as wapi] - [app.main.ui.handoff.attributes.common :refer [color-row]] - [app.util.code-gen :as cg] - [app.main.store :as st] - [app.main.ui.components.copy-button :refer [copy-button]])) + [rumext.alpha :as mf])) (defn has-text? [shape] (:content shape)) @@ -72,7 +71,7 @@ ([style & properties] (cg/generate-css-props style properties params))) -(mf/defc typography-block [{:keys [shape locale text style full-style]}] +(mf/defc typography-block [{:keys [shape text style full-style]}] (let [typography-library-ref (mf/use-memo (mf/deps (:typography-ref-file style)) (make-typographies-library-ref (:typography-ref-file style))) @@ -93,7 +92,7 @@ {:style {:font-family (:font-family typography) :font-weight (:font-weight typography) :font-style (:font-style typography)}} - (t locale "workspace.assets.typography.sample")]] + (tr "workspace.assets.typography.sample")]] [:div.typography-entry-name (:name typography)] [:& copy-button {:data (copy-style-data typography)}]] @@ -102,7 +101,7 @@ {:style {:font-family (:font-family full-style) :font-weight (:font-weight full-style) :font-style (:font-style full-style)}} - (t locale "workspace.assets.typography.sample")] + (tr "workspace.assets.typography.sample")] [:& copy-button {:data (copy-style-data style)}]]) [:div.attributes-content-row @@ -117,78 +116,83 @@ (when (:font-id style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.font-family")] + [:div.attributes-label (tr "handoff.attributes.typography.font-family")] [:div.attributes-value (-> style :font-id fonts/get-font-data :name)] [:& copy-button {:data (copy-style-data style :font-family)}]]) (when (:font-style style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.font-style")] + [:div.attributes-label (tr "handoff.attributes.typography.font-style")] [:div.attributes-value (str (:font-style style))] [:& copy-button {:data (copy-style-data style :font-style)}]]) (when (:font-size style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.font-size")] + [:div.attributes-label (tr "handoff.attributes.typography.font-size")] [:div.attributes-value (str (:font-size style)) "px"] [:& copy-button {:data (copy-style-data style :font-size)}]]) (when (:line-height style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.line-height")] + [:div.attributes-label (tr "handoff.attributes.typography.line-height")] [:div.attributes-value (str (:line-height style)) "px"] [:& copy-button {:data (copy-style-data style :line-height)}]]) (when (:letter-spacing style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.letter-spacing")] + [:div.attributes-label (tr "handoff.attributes.typography.letter-spacing")] [:div.attributes-value (str (:letter-spacing style)) "px"] [:& copy-button {:data (copy-style-data style :letter-spacing)}]]) (when (:text-decoration style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.text-decoration")] - [:div.attributes-value (->> style :text-decoration (str "handoff.attributes.typography.text-decoration.") (t locale))] + [:div.attributes-label (tr "handoff.attributes.typography.text-decoration")] + [:div.attributes-value (->> style :text-decoration (str "handoff.attributes.typography.text-decoration.") (tr))] [:& copy-button {:data (copy-style-data style :text-decoration)}]]) (when (:text-transform style) [:div.attributes-unit-row - [:div.attributes-label (t locale "handoff.attributes.typography.text-transform")] - [:div.attributes-value (->> style :text-transform (str "handoff.attributes.typography.text-transform.") (t locale))] + [:div.attributes-label (tr "handoff.attributes.typography.text-transform")] + [:div.attributes-value (->> style :text-transform (str "handoff.attributes.typography.text-transform.") (tr))] [:& copy-button {:data (copy-style-data style :text-transform)}]])])) -(mf/defc text-block [{:keys [shape locale]}] - (let [font (ut/search-text-attrs (:content shape) - (keys ut/default-text-attrs)) - style-text-blocks (->> (keys ut/default-text-attrs) - (ut/parse-style-text-blocks (:content shape)) - (remove (fn [[style text]] (str/empty? (str/trim text)))) - (mapv (fn [[style text]] (vector (merge ut/default-text-attrs style) text)))) +(defn- remove-equal-values + [m1 m2] + (if (and (map? m1) (map? m2) (not (nil? m1)) (not (nil? m2))) + (->> m1 + (remove (fn [[k v]] (= (k m2) v))) + (into {})) + m1)) - font (merge ut/default-text-attrs font)] +(mf/defc text-block [{:keys [shape]}] + (let [font (cg/search-text-attrs (:content shape) + (keys txt/default-text-attrs)) + style-text-blocks (->> (keys txt/default-text-attrs) + (cg/parse-style-text-blocks (:content shape)) + (remove (fn [[style text]] (str/empty? (str/trim text)))) + (mapv (fn [[style text]] (vector (merge txt/default-text-attrs style) text)))) + + font (merge txt/default-text-attrs font)] (for [[idx [full-style text]] (map-indexed vector style-text-blocks)] (let [previus-style (first (nth style-text-blocks (dec idx) nil)) - style (d/remove-equal-values full-style previus-style) + style (remove-equal-values full-style previus-style) ;; If the color is set we need to add opacity otherwise the display will not work style (cond-> style (:fill-color style) (assoc :fill-opacity (:fill-opacity full-style)))] [:& typography-block {:shape shape - :locale locale :full-style full-style :style style :text text}])))) -(mf/defc text-panel [{:keys [shapes locale]}] - (let [shapes (->> shapes (filter has-text?))] - (when (seq shapes) - [:div.attributes-block - [:div.attributes-block-title - [:div.attributes-block-title-text (t locale "handoff.attributes.typography")]] - - (for [shape shapes] - [:& text-block {:shape shape - :locale locale}])]))) +(mf/defc text-panel + [{:keys [shapes]}] + (when-let [shapes (seq (filter has-text? shapes))] + [:div.attributes-block + [:div.attributes-block-title + [:div.attributes-block-title-text (tr "handoff.attributes.typography")]] + (for [shape shapes] + [:& text-block {:shape shape}])])) diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index d3d9b769c..81f2e0d95 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -5,103 +5,86 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.shapes.text (:require - [cuerdas.core :as str] - [rumext.alpha :as mf] - [app.main.ui.context :as muc] [app.common.data :as d] [app.common.geom.shapes :as geom] - [app.common.geom.matrix :as gmt] - [app.util.object :as obj] - [app.util.color :as uc] - [app.main.ui.shapes.text.styles :as sts] + [app.main.ui.context :as muc] [app.main.ui.shapes.text.embed :as ste] - [app.util.perf :as perf])) + [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 props)] - [:span {:style style - :className (when (:fill-color-gradient node) "gradient")} + (let [node (obj/get props "node") + text (:text node) + style (sts/generate-text-styles node)] + [:span {:style style} (if (= text "") "\u00A0" text)])) (mf/defc render-root {::mf/wrap-props false} [props] - (let [node (obj/get props "node") - embed-fonts? (obj/get props "embed-fonts?") + (let [node (obj/get props "node") + embed? (obj/get props "embed-fonts?") children (obj/get props "children") - style (sts/generate-root-styles props)] + 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"} - [:* - [:style ".gradient { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] - (when embed-fonts? - [ste/embed-fontfaces-style {:node node}])] + (when embed? + [ste/embed-fontfaces-style {:node node}]) children])) (mf/defc render-paragraph-set {::mf/wrap-props false} [props] - (let [node (obj/get props "node") + (let [node (obj/get props "node") children (obj/get props "children") - style (sts/generate-paragraph-set-styles props)] + 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") + (let [node (obj/get props "node") + shape (obj/get props "shape") children (obj/get props "children") - style (sts/generate-paragraph-styles props)] - [:p.paragraph {:style style} children])) + style (sts/generate-paragraph-styles shape node)] + [:p.paragraph {:style style :dir "auto"} children])) ;; -- Text nodes (mf/defc render-node {::mf/wrap-props false} [props] - (let [node (obj/get props "node") - index (obj/get props "index") - {:keys [type text children]} node] + (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 (obj/set! props "key" index) - (for [[index child] (d/enumerate children)] + [:> component props + (for [[index node] (d/enumerate children)] (let [props (-> (obj/clone props) - (obj/set! "node" child) + (obj/set! "node" node) (obj/set! "index" index) (obj/set! "key" index))] [:> render-node props]))]))))) -(mf/defc text-content - {::mf/wrap-props false} - [props] - (let [root (obj/get props "content") - shape (obj/get props "shape") - embed-fonts? (obj/get props "embed-fonts?")] - [:& render-node {:index 0 - :node root - :shape shape - :embed-fonts? embed-fonts?}])) - (defn- retrieve-colors [shape] - (let [colors (->> shape - :content + (let [colors (->> (:content shape) (tree-seq map? :children) (into #{} (comp (map :fill-color) (filter string?))))] (if (empty? colors) @@ -112,20 +95,20 @@ {::mf/wrap-props false ::mf/forward-ref true} [props ref] - (let [shape (unchecked-get props "shape") - grow-type (unchecked-get props "grow-type") + (let [{:keys [id x y width height content grow-type] :as shape} (obj/get props "shape") embed-fonts? (mf/use-ctx muc/embed-ctx) - {:keys [id x y width height content]} shape ;; We add 8px to add a padding for the exporter - width (+ width 8)] + ;; width (+ width 8) + ] [:foreignObject {:x x :y y - :id (:id shape) + :id id :data-colors (retrieve-colors shape) :transform (geom/transform-matrix shape) :width (if (#{:auto-width} grow-type) 100000 width) :height (if (#{:auto-height :auto-width} grow-type) 100000 height) :ref ref} - [:& text-content {:shape shape - :content (:content shape) - :embed-fonts? embed-fonts?}]])) + [:& render-node {:index 0 + :shape shape + :node content + :embed-fonts? embed-fonts?}]])) diff --git a/frontend/src/app/main/ui/shapes/text/embed.cljs b/frontend/src/app/main/ui/shapes/text/embed.cljs index 9d41810f0..8e6a0b46f 100644 --- a/frontend/src/app/main/ui/shapes/text/embed.cljs +++ b/frontend/src/app/main/ui/shapes/text/embed.cljs @@ -5,43 +5,46 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.shapes.text.embed (:require - [clojure.set :as set] - [promesa.core :as p] - [cuerdas.core :as str] - [rumext.alpha :as mf] + [app.common.data :as d] + [app.common.text :as txt] [app.main.data.fetch :as df] [app.main.fonts :as fonts] - [app.util.text :as ut])) + [app.util.object :as obj] + [clojure.set :as set] + [cuerdas.core :as str] + [promesa.core :as p] + [rumext.alpha :as mf])) -(defonce font-face-template " +(def font-face-template " /* latin */ @font-face { - font-family: '$0'; - font-style: $3; - font-weight: $2; + font-family: '%(family)s'; + font-style: %(style)s; + font-weight: %(weight)s; font-display: block; - src: url(/fonts/%(0)s-$1.woff) format('woff'); + src: url(/fonts/%(family)s-%(style)s.woff) format('woff'); } ") ;; -- Embed fonts into styles -(defn get-node-fonts [node] +(defn get-node-fonts + [node] (let [current-font (if (not (nil? (:font-id node))) #{(select-keys node [:font-id :font-variant-id])} #{}) children-font (map get-node-fonts (:children node))] (reduce set/union (conj children-font current-font)))) - -(defn get-local-font-css [font-id font-variant-id] - (let [{:keys [family variants]} (get @fonts/fontsdb font-id) - {:keys [name weight style]} (->> variants (filter #(= (:id %) font-variant-id)) first) - css-str (str/format font-face-template [family name weight style])] - (p/resolved css-str))) +(defn get-local-font-css + [font-id font-variant-id] + (let [{:keys [family variants] :as font} (get @fonts/fontsdb font-id) + {:keys [name weight style] :as variant} (d/seek #(= (:id %) font-variant-id) variants)] + (-> (str/format font-face-template {:family family :style style :width weight}) + (p/resolved)))) (defn get-text-font-data [text] (->> text @@ -59,17 +62,19 @@ replace-text (fn [text [url data]] (str/replace text url data))] (reduce replace-text font-text url-to-data)))) -(mf/defc embed-fontfaces-style [{:keys [node]}] - (let [embeded-fonts (mf/use-state nil)] +(mf/defc embed-fontfaces-style + {::mf/wrap-props false} + [props] + (let [node (obj/get props "node") + style (mf/use-state nil)] (mf/use-effect (mf/deps node) (fn [] (let [font-to-embed (get-node-fonts node) - font-to-embed (if (empty? font-to-embed) #{ut/default-text-attrs} font-to-embed) - embeded (map embed-font font-to-embed)] + font-to-embed (if (empty? font-to-embed) #{txt/default-text-attrs} font-to-embed) + embeded (map embed-font font-to-embed)] (-> (p/all embeded) - (p/then (fn [result] (reset! embeded-fonts (str/join "\n" result)))))))) + (p/then (fn [result] (reset! style (str/join "\n" result)))))))) - - (when (not (nil? @embeded-fonts)) - [:style @embeded-fonts]))) + (when (some? @style) + [:style @style]))) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 208dbf65b..93f6cde2e 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -5,135 +5,120 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.shapes.text.styles (:require - [cuerdas.core :as str] - [app.main.fonts :as fonts] [app.common.data :as d] - [app.util.object :as obj] + [app.common.text :as txt] + [app.main.fonts :as fonts] [app.util.color :as uc] - [app.util.text :as ut])) + [app.util.object :as obj] + [cuerdas.core :as str])) (defn generate-root-styles - ([props] (generate-root-styles (clj->js (obj/get props "node")) props)) - ([data props] - (let [valign (obj/get data "vertical-align" "top") - shape (obj/get props "shape") - base #js {:height (or (:height shape) "100%") - :width (or (:width shape) "100%")}] - (cond-> base - (= valign "top") (obj/set! "justifyContent" "flex-start") - (= valign "center") (obj/set! "justifyContent" "center") - (= valign "bottom") (obj/set! "justifyContent" "flex-end") - )))) + [shape node] + (let [valign (or (:vertical-align node "top")) + base #js {:height (or (:height shape) "100%") + :width (or (:width shape) "100%")}] + (cond-> base + (= valign "top") (obj/set! "justifyContent" "flex-start") + (= valign "center") (obj/set! "justifyContent" "center") + (= valign "bottom") (obj/set! "justifyContent" "flex-end")))) (defn generate-paragraph-set-styles - ([props] (generate-paragraph-set-styles (clj->js (obj/get props "node")) props)) - ([data props] - ;; This element will control the auto-width/auto-height size for the - ;; shape. The properties try to adjust to the shape and "overflow" if - ;; the shape is not big enough. - ;; We `inherit` the property `justify-content` so it's set by the root where - ;; the property it's known. - ;; `inline-flex` is similar to flex but `overflows` outside the bounds of the - ;; parent - (let [shape (obj/get props "shape") - grow-type (:grow-type shape) - auto-width? (= grow-type :auto-width) - auto-height? (= grow-type :auto-height) - - base #js {:display "inline-flex" - :flexDirection "column" - :justifyContent "inherit" - :minHeight (when-not (or auto-width? auto-height?) "100%") - :minWidth (when-not auto-width? "100%") - :verticalAlign "top"}] - base))) + [{:keys [grow-type] :as shape}] + ;; This element will control the auto-width/auto-height size for the + ;; shape. The properties try to adjust to the shape and "overflow" if + ;; the shape is not big enough. + ;; We `inherit` the property `justify-content` so it's set by the root where + ;; the property it's known. + ;; `inline-flex` is similar to flex but `overflows` outside the bounds of the + ;; parent + (let [auto-width? (= grow-type :auto-width) + auto-height? (= grow-type :auto-height)] + #js {:display "inline-flex" + :flexDirection "column" + :justifyContent "inherit" + :minHeight (when-not (or auto-width? auto-height?) "100%") + :minWidth (when-not auto-width? "100%") + :verticalAlign "top"})) (defn generate-paragraph-styles - ([props] (generate-paragraph-styles (clj->js (obj/get props "node")) props)) - ([data props] - (let [shape (obj/get props "shape") - grow-type (:grow-type shape) - base #js {:fontSize "14px" - :margin "inherit" - :lineHeight "1.2"} - lh (obj/get data "line-height") - ta (obj/get data "text-align")] - (cond-> base - ta (obj/set! "textAlign" ta) - lh (obj/set! "lineHeight" lh) - (= grow-type :auto-width) (obj/set! "whiteSpace" "pre"))))) + [shape data] + (let [line-height (:line-height data) + text-align (:text-align data) + grow-type (:grow-type shape) + + base #js {:fontSize (str (:font-size txt/default-text-attrs) "px") + :lineHeight (:line-height txt/default-text-attrs) + :margin "inherit"}] + (cond-> base + (some? line-height) (obj/set! "lineHeight" line-height) + (some? text-align) (obj/set! "textAlign" text-align) + (= grow-type :auto-width) (obj/set! "whiteSpace" "pre")))) (defn generate-text-styles - ([props] (generate-text-styles (clj->js (obj/get props "node")) props)) - ([data props] - (let [letter-spacing (obj/get data "letter-spacing") - text-decoration (obj/get data "text-decoration") - text-transform (obj/get data "text-transform") - line-height (obj/get data "line-height") + [data] + (let [letter-spacing (:letter-spacing data) + text-decoration (:text-decoration data) + text-transform (:text-transform data) + line-height (:line-height data) - font-id (obj/get data "font-id" (:font-id ut/default-text-attrs)) - font-variant-id (obj/get data "font-variant-id") + font-id (:font-id data (:font-id txt/default-text-attrs)) + font-variant-id (:font-variant-id data) - font-family (obj/get data "font-family") - font-size (obj/get data "font-size") + font-family (:font-family data) + font-size (:font-size data) - ;; Old properties for backwards compatibility - fill (obj/get data "fill") - opacity (obj/get data "opacity" 1) + fill-color (:fill-color data) + fill-opacity (:fill-opacity data) - fill-color (obj/get data "fill-color" fill) - fill-opacity (obj/get data "fill-opacity" opacity) - fill-color-gradient (obj/get data "fill-color-gradient" nil) - fill-color-gradient (when fill-color-gradient - (-> (js->clj fill-color-gradient :keywordize-keys true) - (update :type keyword))) + ;; Uncomment this to allow to remove text colors. This could break the texts that already exist + ;;[r g b a] (if (nil? fill-color) + ;; [0 0 0 0] ;; Transparent color + ;; (uc/hex->rgba fill-color fill-opacity)) - ;; Uncomment this to allow to remove text colors. This could break the texts that already exist - ;;[r g b a] (if (nil? fill-color) - ;; [0 0 0 0] ;; Transparent color - ;; (uc/hex->rgba fill-color fill-opacity)) + [r g b a] (uc/hex->rgba fill-color fill-opacity) + text-color (str/format "rgba(%s, %s, %s, %s)" r g b a) + fontsdb (deref fonts/fontsdb) - [r g b a] (uc/hex->rgba fill-color fill-opacity) + base #js {:textDecoration text-decoration + :textTransform text-transform + :lineHeight (or line-height "inherit") + :color text-color}] - text-color (if fill-color-gradient - (uc/gradient->css (js->clj fill-color-gradient)) - (str/format "rgba(%s, %s, %s, %s)" r g b a)) + (when-let [gradient (:fill-color-gradient data)] + (let [text-color (-> (update gradient :type keyword) + (uc/gradient->css))] + (-> base + (obj/set! "background" "var(--text-color)") + (obj/set! "WebkitTextFillColor" "transparent") + (obj/set! "WebkitBackgroundClip" "text") + (obj/set! "--text-color" text-color)))) - fontsdb (deref fonts/fontsdb) + (when (and (string? letter-spacing) + (pos? (alength letter-spacing))) + (obj/set! base "letterSpacing" (str letter-spacing "px"))) - base #js {:textDecoration text-decoration - :textTransform text-transform - :lineHeight (or line-height "inherit") - :color text-color - "--text-color" text-color}] + (when (and (string? font-size) + (pos? (alength font-size))) + (obj/set! base "fontSize" (str font-size "px"))) - (when (and (string? letter-spacing) - (pos? (alength letter-spacing))) - (obj/set! base "letterSpacing" (str letter-spacing "px"))) + (when (and (string? font-id) + (pos? (alength font-id))) + (fonts/ensure-loaded! font-id) + (let [font (get fontsdb font-id)] + (let [font-family (or (:family font) + (obj/get data "fontFamily")) + font-variant (d/seek #(= font-variant-id (:id %)) + (:variants font)) + font-style (or (:style font-variant) + (obj/get data "fontStyle")) + font-weight (or (:weight font-variant) + (obj/get data "fontWeight"))] + (obj/set! base "fontFamily" font-family) + (obj/set! base "fontStyle" font-style) + (obj/set! base "fontWeight" font-weight)))) - (when (and (string? font-size) - (pos? (alength font-size))) - (obj/set! base "fontSize" (str font-size "px"))) - - (when (and (string? font-id) - (pos? (alength font-id))) - (fonts/ensure-loaded! font-id) - (let [font (get fontsdb font-id)] - (let [font-family (or (:family font) - (obj/get data "fontFamily")) - font-variant (d/seek #(= font-variant-id (:id %)) - (:variants font)) - font-style (or (:style font-variant) - (obj/get data "fontStyle")) - font-weight (or (:weight font-variant) - (obj/get data "fontWeight"))] - (obj/set! base "fontFamily" font-family) - (obj/set! base "fontStyle" font-style) - (obj/set! base "fontWeight" font-weight)))) - - - base))) + base)) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index acc321f3b..2fd32a5dd 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -141,7 +141,6 @@ [:& (mf/provider ctx/current-team-id) {:value (:team-id project)} [:& (mf/provider ctx/current-project-id) {:value (:id project)} [:& (mf/provider ctx/current-page-id) {:value page-id} - [:section#workspace [:& header {:file file :page-id page-id diff --git a/frontend/src/app/main/ui/workspace/shapes/text.cljs b/frontend/src/app/main/ui/workspace/shapes/text.cljs index 212979b81..fcb1c88b6 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text.cljs @@ -5,7 +5,7 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.workspace.shapes.text (:require @@ -26,6 +26,7 @@ [app.util.logging :as log] [app.util.object :as obj] [app.util.timers :as timers] + [app.util.text-editor :as ted] [beicon.core :as rx] [rumext.alpha :as mf])) @@ -52,8 +53,17 @@ (mf/defc text-resize-content {::mf/wrap-props false} [props] - (let [shape (obj/get props "shape") - {:keys [id name x y grow-type]} shape + (let [{:keys [id name x y grow-type] :as shape} (obj/get props "shape") + + state-map (mf/deref refs/workspace-editor-state) + editor-state (get state-map id) + + shape (cond-> shape + (some? editor-state) + (assoc :content (-> editor-state + (ted/get-editor-current-content) + (ted/export-content)))) + paragraph-ref (mf/use-state nil) handle-resize-text @@ -91,8 +101,7 @@ #(.disconnect observer))))) [:& text/text-shape {:ref text-ref-cb - :shape shape - :grow-type (:grow-type shape)}])) + :shape shape}])) (mf/defc text-wrapper {::mf/wrap-props false} @@ -118,7 +127,6 @@ [:& text-static-content {:shape shape}] [:& text-resize-content {:shape shape}])] - (when (and (not ghost?) edition?) [:& editor/text-shape-edit {:key (str "editor" (:id shape)) :shape shape}]) @@ -136,4 +144,3 @@ :on-pointer-out handle-pointer-leave :on-double-click handle-double-click :transform (gsh/transform-matrix shape)}])])) - diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 1e40979c9..f09baf264 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -9,190 +9,96 @@ (ns app.main.ui.workspace.shapes.text.editor (:require - ["slate" :as slate] - ["slate-react" :as rslate] - [goog.events :as events] - [rumext.alpha :as mf] + ["draft-js" :as draft] [app.common.data :as d] [app.common.geom.shapes :as gsh] - [app.util.dom :as dom] - [app.util.text :as ut] - [app.util.object :as obj] - [app.main.refs :as refs] - [app.main.store :as st] [app.main.data.workspace :as dw] [app.main.data.workspace.common :as dwc] - [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.cursors :as cur] - [app.main.ui.shapes.text.styles :as sts]) + [app.main.ui.shapes.text.styles :as sts] + [app.util.dom :as dom] + [app.util.object :as obj] + [app.util.text-editor :as ted] + [cuerdas.core :as str] + [goog.events :as events] + [okulary.core :as l] + [rumext.alpha :as mf]) (:import goog.events.EventType goog.events.KeyCodes)) ;; --- Data functions -(defn- initial-text - [text] - (clj->js - [{:type "root" - :children [{:type "paragraph-set" - :children [{:type "paragraph" - :children [{:fill-color "#000000" - :fill-opacity 1 - :text (or text "")}]}]}]}])) -(defn- parse-content - [content] - (cond - (string? content) (initial-text content) - (map? content) (clj->js [content]) - :else (initial-text ""))) - -(defn- content-size - [node] - (let [current (count (:text node)) - children-count (->> node :children (map content-size) (reduce +))] - (+ current children-count))) - -(defn- fix-gradients - "Fix for the gradient types that need to be keywords" - [content] - (let [fix-node - (fn [node] - (d/update-in-when node [:fill-color-gradient :type] keyword))] - (ut/map-node fix-node content))) +;; TODO: why we need this? +;; (defn- fix-gradients +;; "Fix for the gradient types that need to be keywords" +;; [content] +;; (let [fix-node +;; (fn [node] +;; (d/update-in-when node [:fill-color-gradient :type] keyword))] +;; (txt/map-node fix-node content))) ;; --- Text Editor Rendering -(mf/defc editor-root-node - {::mf/wrap-props false - ::mf/wrap [mf/memo]} - [props] - (let [ - childs (obj/get props "children") - data (obj/get props "element") - type (obj/get data "type") - style (sts/generate-root-styles data props) - attrs (-> (obj/get props "attributes") - (obj/set! "style" style) - (obj/set! "className" type))] - [:> :div attrs childs])) - -(mf/defc editor-paragraph-set-node +(mf/defc block-component {::mf/wrap-props false} [props] - (let [childs (obj/get props "children") - data (obj/get props "element") - type (obj/get data "type") - shape (obj/get props "shape") - style (sts/generate-paragraph-set-styles data props) - attrs (-> (obj/get props "attributes") - (obj/set! "style" style) - (obj/set! "className" type))] - [:> :div attrs childs])) + (let [children (obj/get props "children") + bprops (obj/get props "blockProps") + style (sts/generate-paragraph-styles (obj/get bprops "shape") + (obj/get bprops "data"))] -(mf/defc editor-paragraph-node - {::mf/wrap-props false} - [props] - (let [ - childs (obj/get props "children") - data (obj/get props "element") - type (obj/get data "type") - style (sts/generate-paragraph-styles data props) - attrs (-> (obj/get props "attributes") - (obj/set! "style" style) - (obj/set! "className" type))] - [:> :p attrs childs])) + [:div {:style style :dir "auto"} + [:> draft/EditorBlock props]])) -(mf/defc editor-text-node - {::mf/wrap-props false} - [props] - (let [childs (obj/get props "children") - data (obj/get props "leaf") - type (obj/get data "type") - style (sts/generate-text-styles data props) - attrs (-> (obj/get props "attributes") - (obj/set! "style" style)) - gradient (obj/get data "fill-color-gradient" nil)] - (if gradient - (obj/set! attrs "className" (str type " gradient")) - (obj/set! attrs "className" type)) - [:> :span attrs childs])) +(defn render-block + [block shape] + (let [type (ted/get-editor-block-type block)] + (case type + "unstyled" + #js {:editable true + :component block-component + :props #js {:data (ted/get-editor-block-data block) + :shape shape}} + nil))) -(defn- render-element - [shape props] - (mf/html - (let [element (obj/get props "element") - type (obj/get element "type") - props (obj/merge! props #js {:shape shape}) - props (cond-> props - (= type "root") (obj/set! "key" "root") - (= type "paragraph-set") (obj/set! "key" "paragraph-set"))] - - (case type - "root" [:> editor-root-node props] - "paragraph-set" [:> editor-paragraph-set-node props] - "paragraph" [:> editor-paragraph-node props] - nil)))) - -(defn- render-text - [props] - (mf/html - [:> editor-text-node props])) - -;; --- Text Shape Edit +(def empty-editor-state + (ted/create-editor-state)) (mf/defc text-shape-edit-html {::mf/wrap [mf/memo] ::mf/wrap-props false ::mf/forward-ref true} [props ref] - (let [shape (unchecked-get props "shape") - node-ref (unchecked-get props "node-ref") + (let [{:keys [id x y width height grow-type content] :as shape} (obj/get props "shape") + + zoom (mf/deref refs/selected-zoom) + state-map (mf/deref refs/workspace-editor-state) + state (get state-map id empty-editor-state) - {:keys [id x y width height content grow-type]} shape - zoom (mf/deref refs/selected-zoom) - state (mf/use-state #(parse-content content)) - editor (mf/use-memo #(dwt/create-editor)) self-ref (mf/use-ref) - selecting-ref (mf/use-ref) - measure-ref (mf/use-ref) - - content-var (mf/use-var content) - - on-close - (fn [] - (st/emit! dw/clear-edition-mode) - (when (= 0 (content-size @content-var)) - (st/emit! (dws/deselect-shape id) - (dw/delete-shapes [id])))) on-click-outside (fn [event] - (let [target (dom/get-target event) - options (dom/get-element-by-class "element-options") - assets (dom/get-element-by-class "assets-bar") - cpicker (dom/get-element-by-class "colorpicker-tooltip") - palette (dom/get-element-by-class "color-palette") - self (mf/ref-val self-ref) - selecting? (mf/ref-val selecting-ref)] + (let [target (dom/get-target event) + options (dom/get-element-by-class "element-options") + assets (dom/get-element-by-class "assets-bar") + cpicker (dom/get-element-by-class "colorpicker-tooltip") + palette (dom/get-element-by-class "color-palette") - (when-not (or (and options (.contains options target)) - (and assets (.contains assets target)) - (and self (.contains self target)) - (and cpicker (.contains cpicker target)) - (and palette (.contains palette target))) - (if selecting? - (mf/set-ref-val! selecting-ref false) - (on-close))))) - - on-mouse-down - (fn [event] - (mf/set-ref-val! selecting-ref true)) - - on-mouse-up - (fn [event] - (mf/set-ref-val! selecting-ref false)) + self (mf/ref-val self-ref)] + (if (or (and options (.contains options target)) + (and assets (.contains assets target)) + (and self (.contains self target)) + (and cpicker (.contains cpicker target)) + (and palette (.contains palette target)) + (= "foreignObject" (.-tagName ^js target))) + (dom/stop-propagation event) + (st/emit! dw/clear-edition-mode)))) on-key-up (fn [event] @@ -200,86 +106,71 @@ (when (= (.-keyCode event) 27) ; ESC (do (st/emit! :interrupt) - (on-close)))) + (st/emit! dw/clear-edition-mode)))) on-mount (fn [] (let [keys [(events/listen js/document EventType.MOUSEDOWN on-click-outside) (events/listen js/document EventType.CLICK on-click-outside) (events/listen js/document EventType.KEYUP on-key-up)]] - (st/emit! (dwt/assign-editor id editor) - (dwc/start-undo-transaction)) - + (st/emit! (dwt/initialize-editor-state shape) + (dwt/select-all shape)) #(do - (st/emit! (dwt/assign-editor id nil) - (dwc/commit-undo-transaction)) + (st/emit! (dwt/finalize-editor-state shape)) (doseq [key keys] (events/unlistenByKey key))))) - on-focus + on-blur (fn [event] - (dwt/editor-select-all! editor)) - - on-composition-start - (mf/use-callback - (fn [event] - (.insertText slate/Editor editor ""))) + (dom/stop-propagation event) + (dom/prevent-default event)) on-change (mf/use-callback (fn [val] - (let [content (js->clj val :keywordize-keys true) - content (first content) - content (fix-gradients content)] - ;; Append timestamp so we can react to cursor change events - (st/emit! (dw/update-shape id {:content (assoc content :ts (js->clj (.now js/Date)))})) - (reset! state val) - (reset! content-var content))))] + (st/emit! (dwt/update-editor-state shape val)))) - (mf/use-effect on-mount) + on-editor + (mf/use-callback + (fn [editor] + (st/emit! (dwt/update-editor editor)) + (when editor + (.focus ^js editor)))) - (mf/use-effect - (mf/deps content) - (fn [] - (reset! state (parse-content content)) - (reset! content-var content))) + handle-return + (mf/use-callback + (fn [event state] + (st/emit! (dwt/update-editor-state shape (ted/editor-split-block state))) + "handled")) + ] - [:div.text-editor {:ref self-ref} - [:style "span { line-height: inherit; } - .gradient { background: var(--text-color); -webkit-text-fill-color: transparent; -webkit-background-clip: text;"] - [:> rslate/Slate {:editor editor - :value @state - :on-change on-change} - [:> rslate/Editable - {:auto-focus "true" - :spell-check "false" - :on-focus on-focus - :class "rich-text" - :style {:cursor cur/text - :width (:width shape)} - :render-element #(render-element shape %) - :render-leaf render-text - :on-mouse-up on-mouse-up - :on-mouse-down on-mouse-down - :on-blur (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - ;; WARN: monky patch - (obj/set! slate/Transforms "deselect" (constantly nil))) - :on-composition-start on-composition-start - ;; :placeholder (when (= :fixed grow-type) "Type some text here...") - }]]])) + (mf/use-layout-effect on-mount) + + [:div.text-editor {:ref self-ref + :class (dom/classnames + :align-top (= (:vertical-align content "top") "top") + :align-center (= (:vertical-align content) "center") + :align-bottom (= (:vertical-align content) "bottom"))} + [:> draft/Editor + {:on-change on-change + :on-blur on-blur + :handle-return handle-return + :custom-style-fn (fn [styles _] + (-> (ted/styles-to-attrs styles) + (sts/generate-text-styles))) + :block-renderer-fn #(render-block % shape) + :ref on-editor + :editor-state state}]])) (mf/defc text-shape-edit {::mf/wrap [mf/memo] ::mf/wrap-props false ::mf/forward-ref true} [props ref] - (let [shape (unchecked-get props "shape") - {:keys [x y width height grow-type]} shape] + (let [{:keys [id x y width height grow-type] :as shape} (obj/get props "shape")] [:foreignObject {:transform (gsh/transform-matrix shape) :x x :y y :width (if (#{:auto-width} grow-type) 100000 width) :height (if (#{:auto-height :auto-width} grow-type) 100000 height)} - [:& text-shape-edit-html {:shape shape}]])) + [:& text-shape-edit-html {:shape shape :key (str id)}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index 7ce343ebc..7b8694407 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -14,6 +14,7 @@ [app.common.geom.shapes :as geom] [app.common.media :as cm] [app.common.pages :as cp] + [app.common.text :as txt] [app.common.uuid :as uuid] [app.config :as cfg] [app.main.data.colors :as dc] @@ -38,7 +39,6 @@ [app.util.i18n :as i18n :refer [tr t]] [app.util.keyboard :as kbd] [app.util.router :as rt] - [app.util.text :as ut] [app.util.timers :as timers] [cuerdas.core :as str] [okulary.core :as l] @@ -431,7 +431,7 @@ (mf/use-callback (mf/deps file-id) (fn [value opacity] - (st/emit! (dwl/add-typography ut/default-typography)))) + (st/emit! (dwl/add-typography txt/default-typography)))) handle-change (mf/use-callback diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 3a5491aa2..d2e8725e0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -30,8 +30,8 @@ :fill-color-gradient]) (mf/defc fill-menu - {::mf/wrap [#(mf/memo' % (mf/check-props ["ids" "editor" "values"]))]} - [{:keys [ids type values editor] :as props}] + {::mf/wrap [#(mf/memo' % (mf/check-props ["ids" "values"]))]} + [{:keys [ids type values] :as props}] (let [locale (mf/deref i18n/locale) show? (or (not (nil? (:fill-color values))) (not (nil? (:fill-color-gradient values)))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index aaaddf629..c1c4c780e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -11,6 +11,7 @@ (:require [app.common.data :as d] [app.common.uuid :as uuid] + [app.common.text :as txt] [app.main.data.workspace.common :as dwc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] @@ -22,7 +23,6 @@ [app.main.ui.workspace.sidebar.options.menus.typography :refer [typography-entry typography-options]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] - [app.util.text :as ut] [cuerdas.core :as str] [rumext.alpha :as mf])) @@ -49,7 +49,7 @@ (def attrs (d/concat #{} shape-attrs root-attrs paragraph-attrs text-attrs)) (mf/defc text-align-options - [{:keys [editor ids values on-change] :as props}] + [{:keys [ids values on-change] :as props}] (let [{:keys [text-align]} values text-align (or text-align "left") @@ -83,7 +83,7 @@ (mf/defc vertical-align - [{:keys [shapes editor ids values on-change] :as props}] + [{:keys [shapes ids values on-change] :as props}] (let [{:keys [vertical-align]} values vertical-align (or vertical-align "top") handle-change @@ -108,7 +108,7 @@ i/align-bottom]])) (mf/defc grow-options - [{:keys [editor ids values on-change] :as props}] + [{:keys [ids values on-change] :as props}] (let [to-single-value (fn [coll] (if (> (count coll) 1) nil (first coll))) grow-type (->> values :grow-type) handle-change-grow @@ -133,7 +133,7 @@ i/auto-height]])) (mf/defc text-decoration-options - [{:keys [editor ids values on-change] :as props}] + [{:keys [ids values on-change] :as props}] (let [{:keys [text-decoration]} values text-decoration (or text-decoration "none") @@ -160,14 +160,14 @@ :on-click #(handle-change % "line-through")} i/strikethrough]])) -(defn generate-typography-name [{:keys [font-id font-variant-id] :as typography}] +(defn generate-typography-name + [{:keys [font-id font-variant-id] :as typography}] (let [{:keys [name]} (fonts/get-font-data font-id)] - (-> typography - (assoc :name (str name " " (str/title font-variant-id))))) ) + (assoc typography :name (str name " " (str/title font-variant-id))))) (mf/defc text-menu {::mf/wrap [mf/memo]} - [{:keys [ids type editor values] :as props}] + [{:keys [ids type values] :as props}] (let [current-file-id (mf/use-ctx ctx/current-file-id) typographies (mf/deref refs/workspace-file-typography) @@ -181,15 +181,15 @@ (fn [id attrs] (let [attrs (select-keys attrs root-attrs)] (when-not (empty? attrs) - (st/emit! (dwt/update-root-attrs {:id id :editor editor :attrs attrs})))) + (st/emit! (dwt/update-root-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs paragraph-attrs)] (when-not (empty? attrs) - (st/emit! (dwt/update-paragraph-attrs {:id id :editor editor :attrs attrs})))) + (st/emit! (dwt/update-paragraph-attrs {:id id :attrs attrs})))) (let [attrs (select-keys attrs text-attrs)] (when-not (empty? attrs) - (st/emit! (dwt/update-text-attrs {:id id :editor editor :attrs attrs}))))) + (st/emit! (dwt/update-text-attrs {:id id :attrs attrs}))))) typography (cond (and (:typography-ref-id values) @@ -213,7 +213,7 @@ (d/concat text-font-attrs text-spacing-attrs text-transform-attrs))) - typography (merge ut/default-typography setted-values) + typography (merge txt/default-typography setted-values) typography (generate-typography-name typography)] (let [id (uuid/next)] (st/emit! (dwl/add-typography (assoc typography :id id) false)) @@ -230,8 +230,7 @@ (fn [changes] (st/emit! (dwl/update-typography (merge typography changes) current-file-id))) - opts #js {:editor editor - :ids ids + opts #js {:ids ids :values values :on-change (fn [attrs] (run! #(emit-update! % attrs) ids))}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 42daf3820..2ccde0b28 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -5,25 +5,25 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.main.ui.workspace.sidebar.options.menus.typography (:require - [rumext.alpha :as mf] - [cuerdas.core :as str] - [app.main.ui.icons :as i] + [app.common.data :as d] + [app.common.text :as txt] + [app.main.data.workspace.texts :as dwt] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] - [app.common.data :as d] - [app.main.data.workspace.texts :as dwt] [app.main.ui.components.editable-select :refer [editable-select]] + [app.main.ui.icons :as i] [app.main.ui.workspace.sidebar.options.common :refer [advanced-options]] - [app.main.fonts :as fonts] [app.util.dom :as dom] - [app.util.text :as ut] - [app.util.timers :as ts] [app.util.i18n :as i18n :refer [t]] - [app.util.router :as rt])) + [app.util.router :as rt] + [app.util.timers :as ts] + [cuerdas.core :as str] + [rumext.alpha :as mf])) (defn- attr->string [value] (if (= value :multiple) @@ -51,9 +51,9 @@ font-size font-variant-id]} values - font-id (or font-id (:font-id ut/default-text-attrs)) - font-size (or font-size (:font-size ut/default-text-attrs)) - font-variant-id (or font-variant-id (:font-variant-id ut/default-text-attrs)) + font-id (or font-id (:font-id txt/default-text-attrs)) + font-size (or font-size (:font-size txt/default-text-attrs)) + font-variant-id (or font-variant-id (:font-variant-id txt/default-text-attrs)) fonts (mf/deref fonts/fontsdb) font (get fonts font-id) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 0e4a47ab2..a191e7b41 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -9,17 +9,17 @@ (ns app.main.ui.workspace.sidebar.options.shapes.multiple (:require - [app.common.data :as d] - [rumext.alpha :as mf] [app.common.attrs :as attrs] - [app.util.text :as ut] - [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu]] - [app.main.ui.workspace.sidebar.options.menus.fill :refer [fill-attrs fill-menu]] - [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-attrs shadow-menu]] + [app.common.data :as d] + [app.common.text :as txt] [app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-attrs blur-menu]] + [app.main.ui.workspace.sidebar.options.menus.fill :refer [fill-attrs fill-menu]] + [app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu]] + [app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu]] + [app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-attrs shadow-menu]] [app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu]] [app.main.ui.workspace.sidebar.options.menus.text :as ot] - [app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu]])) + [rumext.alpha :as mf])) ;; We define a map that goes from type to ;; attribute and how to handle them @@ -161,7 +161,7 @@ :text [(conj ids id) (-> values (merge-attrs (select-keys shape attrs)) - (merge-attrs (ut/get-text-attrs-multi content attrs)))] + (merge-attrs (attrs/get-attrs-multi (txt/node-seq content) attrs)))] :children (let [children (->> (:shapes shape []) (map #(get objects %))) [new-ids new-values] (get-attrs children objects attr-type)] [(d/concat ids new-ids) (merge-attrs values new-values)]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 7ff4071ad..b6304eeb1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -21,18 +21,16 @@ (mf/defc options [{:keys [shape] :as props}] - (let [ids [(:id shape)] - type (:type shape) + (let [ids [(:id shape)] + type (:type shape) - editors (mf/deref refs/editors) - editor (get editors (:id shape)) + state-map (mf/deref refs/workspace-editor-state) + editor-state (get state-map (:id shape)) - measure-values (select-keys shape measure-attrs) - - fill-values (dwt/current-text-values - {:editor editor - :shape shape - :attrs text-fill-attrs}) + fill-values (dwt/current-text-values + {:editor-state editor-state + :shape shape + :attrs text-fill-attrs}) fill-values (d/update-in-when fill-values [:fill-color-gradient :type] keyword) @@ -41,32 +39,42 @@ (:fill fill-values) (assoc :fill-color (:fill fill-values)) (:opacity fill-values) (assoc :fill-opacity (:fill fill-values))) - text-values (merge - (select-keys shape [:grow-type]) - (dwt/current-root-values - {:editor editor :shape shape - :attrs root-attrs}) - (dwt/current-text-values - {:editor editor :shape shape + (select-keys shape [:grow-type :vertical-align :text-align]) + #_(dwt/current-root-values + {:editor-state editor-state + :shape shape + :attrs root-attrs}) + (dwt/current-paragraph-values + {:editor-state editor-state + :shape shape :attrs paragraph-attrs}) (dwt/current-text-values - {:editor editor :shape shape + {:editor-state editor-state + :shape shape :attrs text-attrs}))] [:* - [:& measures-menu {:ids ids - :type type - :values measure-values}] - [:& fill-menu {:ids ids - :type type - :values fill-values - :editor editor}] - [:& shadow-menu {:ids ids - :values (select-keys shape [:shadow])}] - [:& blur-menu {:ids ids - :values (select-keys shape [:blur])}] - [:& text-menu {:ids ids - :type type - :values text-values - :editor editor}]])) + + [:& measures-menu + {:ids ids + :type type + :values (select-keys shape measure-attrs)}] + + [:& fill-menu + {:ids ids + :type type + :values fill-values}] + + [:& shadow-menu + {:ids ids + :values (select-keys shape [:shadow])}] + + [:& blur-menu + {:ids ids + :values (select-keys shape [:blur])}] + + [:& text-menu + {:ids ids + :type type + :values text-values}]])) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index fb27888ce..32c214814 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -434,11 +434,13 @@ on-pointer-down (mf/use-callback (fn [event] - (let [target (dom/get-target event)] - ; Capture mouse pointer to detect the movements even if cursor - ; leaves the viewport or the browser itself - ; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture - (.setPointerCapture target (.-pointerId event))))) + (let [target (dom/get-target event) + closest (.closest target ".public-DraftStyleDefault-block")] + (when-not closest + ;; Capture mouse pointer to detect the movements even if cursor + ;; leaves the viewport or the browser itself + ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + (.setPointerCapture target (.-pointerId event)))))) on-pointer-up (mf/use-callback diff --git a/frontend/src/app/util/code_gen.cljs b/frontend/src/app/util/code_gen.cljs index 86e0bd8f5..8338fd621 100644 --- a/frontend/src/app/util/code_gen.cljs +++ b/frontend/src/app/util/code_gen.cljs @@ -5,14 +5,15 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.util.code-gen (:require - [cuerdas.core :as str] + [app.common.data :as d] [app.common.math :as mth] - [app.util.text :as ut] - [app.util.color :as uc])) + [app.common.text :as txt] + [app.util.color :as uc] + [cuerdas.core :as str])) (defn shadow->css [shadow] (let [{:keys [style offset-x offset-y blur spread]} shadow @@ -136,17 +137,55 @@ :format format :multi multi :tab-size 2}))) + +(defn search-text-attrs + [node attrs] + (->> (txt/node-seq node) + (map #(select-keys % attrs)) + (reduce d/merge))) + + +;; TODO: used on handoff +(defn parse-style-text-blocks + [node attrs] + (letfn + [(rec-style-text-map [acc node style] + (let [node-style (merge style (select-keys node attrs)) + head (or (-> acc first) [{} ""]) + [head-style head-text] head + + new-acc + (cond + (:children node) + (reduce #(rec-style-text-map %1 %2 node-style) acc (:children node)) + + (not= head-style node-style) + (cons [node-style (:text node "")] acc) + + :else + (cons [node-style (str head-text "" (:text node))] (rest acc))) + + ;; We add an end-of-line when finish a paragraph + new-acc + (if (= (:type node) "paragraph") + (let [[hs ht] (first new-acc)] + (cons [hs (str ht "\n")] (rest new-acc))) + new-acc)] + new-acc))] + + (-> (rec-style-text-map [] node {}) + reverse))) + (defn text->properties [shape] (let [text-shape-style (select-keys styles-data [:layout :shadow :blur]) - shape-props (->> text-shape-style vals (mapcat :props)) - shape-to-prop (->> text-shape-style vals (map :to-prop) (reduce merge)) - shape-format (->> text-shape-style vals (map :format) (reduce merge)) + shape-props (->> text-shape-style vals (mapcat :props)) + shape-to-prop (->> text-shape-style vals (map :to-prop) (reduce merge)) + shape-format (->> text-shape-style vals (map :format) (reduce merge)) - text-values (->> (ut/search-text-attrs (:content shape) (conj (:props style-text) :fill-color-gradient)) - (merge ut/default-text-attrs))] - + text-values (->> (search-text-attrs (:content shape) (conj (:props style-text) :fill-color-gradient)) + (d/merge txt/default-text-attrs))] (str/join "\n" [(generate-css-props shape diff --git a/frontend/src/app/util/text.cljs b/frontend/src/app/util/text.cljs deleted file mode 100644 index ef75f6535..000000000 --- a/frontend/src/app/util/text.cljs +++ /dev/null @@ -1,123 +0,0 @@ -(ns app.util.text - (:require - [cuerdas.core :as str] - [app.common.attrs :refer [get-attrs-multi]])) - -(defonce default-text-attrs - {:typography-ref-file nil - :typography-ref-id nil - :font-id "sourcesanspro" - :font-family "sourcesanspro" - :font-variant-id "regular" - :font-size "14" - :font-weight "400" - :font-style "normal" - :line-height "1.2" - :letter-spacing "0" - :text-transform "none" - :text-align "left" - :text-decoration "none" - :fill-color nil - :fill-opacity 1}) - -(def typography-fields - [:font-id - :font-family - :font-variant-id - :font-size - :font-weight - :font-style - :line-height - :letter-spacing - :text-transform]) - -(def default-typography - (merge - {:name "Source Sans Pro Regular"} - (select-keys default-text-attrs typography-fields))) - -(defn some-node - [predicate node] - (or (predicate node) - (some #(some-node predicate %) (:children node)))) - -(defn map-node - [map-fn node] - (cond-> (map-fn node) - (:children node) (update :children (fn [children] (mapv #(map-node map-fn %) children))))) - -(defn content->text - [node] - (str - (if (:children node) - (str/join (if (= "paragraph-set" (:type node)) "\n" "") (map content->text (:children node))) - (:text node "")))) - -(defn parse-style-text-blocks - [node attrs] - (letfn - [(rec-style-text-map [acc node style] - (let [node-style (merge style (select-keys node attrs)) - head (or (-> acc first) [{} ""]) - [head-style head-text] head - - new-acc - (cond - (:children node) - (reduce #(rec-style-text-map %1 %2 node-style) acc (:children node)) - - (not= head-style node-style) - (cons [node-style (:text node "")] acc) - - :else - (cons [node-style (str head-text "" (:text node))] (rest acc))) - - ;; We add an end-of-line when finish a paragraph - new-acc - (if (= (:type node) "paragraph") - (let [[hs ht] (first new-acc)] - (cons [hs (str ht "\n")] (rest new-acc))) - new-acc)] - new-acc))] - - (-> (rec-style-text-map [] node {}) - reverse))) - -(defn search-text-attrs - [node attrs] - (let [rec-fn - (fn rec-fn [current node] - (let [current (reduce rec-fn current (:children node []))] - (merge current - (select-keys node attrs))))] - (rec-fn {} node))) - - -(defn content->nodes [node] - (loop [result (transient []) - curr node - pending (transient [])] - - (let [result (conj! result curr)] - ;; Adds children to the pending list - (let [children (:children curr) - pending (loop [child (first children) - children (rest children) - pending pending] - (if child - (recur (first children) - (rest children) - (conj! pending child)) - pending))] - - (if (= 0 (count pending)) - (persistent! result) - ;; Iterates with the next value in pending - (let [next (get pending (dec (count pending)))] - (recur result next (pop! pending)))))))) - -(defn get-text-attrs-multi - [node attrs] - (let [nodes (content->nodes node)] - (get-attrs-multi nodes attrs))) - diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs new file mode 100644 index 000000000..88e8e5253 --- /dev/null +++ b/frontend/src/app/util/text_editor.cljs @@ -0,0 +1,298 @@ +;; 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/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020-2021 UXBOX Labs SL + +(ns app.util.text-editor + "Draft related abstraction functions." + (:require + ["draft-js" :as draft] + [app.common.attrs :as attrs] + [app.common.text :as txt] + [app.common.data :as d] + [app.util.transit :as t] + [app.util.array :as arr] + [app.util.object :as obj] + [clojure.walk :as walk] + [cuerdas.core :as str])) + +;; --- INLINE STYLES ENCODING + +(defn encode-style-value + [v] + (cond + (string? v) (str "s:" v) + (number? v) (str "n:" v) + (keyword? v) (str "k:" (name v)) + (map? v) (str "m:" (t/encode v)) + + :else (str "o:" v))) + +(defn decode-style-value + [v] + (let [prefix (subs v 0 2)] + (case prefix + "s:" (subs v 2) + "n:" (js/Number (subs v 2)) + "k:" (keyword (subs v 2)) + "m:" (t/decode (subs v 2)) + "o:" (subs v 2) + v))) + +(defn encode-style + [key val] + (let [k (d/name key) + v (encode-style-value val)] + (str "PENPOT$$$" k "$$$" v))) + +(defn attrs-to-styles + [attrs] + (reduce-kv (fn [res k v] + (conj res (encode-style k v))) + #{} + attrs)) + +(defn styles-to-attrs + [styles] + (persistent! + (reduce (fn [result style] + (let [[_ k v] (str/split style "$$$" 3)] + (assoc! result (keyword k) (decode-style-value v)))) + (transient {}) + (seq styles)))) + +;; --- CONVERSION + +(defn- parse-draft-styles + "Parses draft-js style ranges, converting encoded style name into a + key/val pair of data." + [styles] + (map (fn [item] + (let [[_ k v] (-> (obj/get item "style") + (str/split "$$$" 3))] + {:key (keyword k) + :val (decode-style-value v) + :offset (obj/get item "offset") + :length (obj/get item "length")})) + styles)) + +(defn- build-style-index + "Generates a character based index with associated styles map." + [text ranges] + (loop [result (->> (range (count text)) + (mapv (constantly {})) + (transient)) + ranges (seq ranges)] + (if-let [{:keys [offset length] :as item} (first ranges)] + (recur (reduce (fn [result index] + (let [prev (get result index)] + (assoc! result index (assoc prev (:key item) (:val item))))) + result + (range offset (+ offset length))) + (rest ranges)) + (persistent! result)))) + +(defn- convert-from-draft + [content] + (letfn [(build-text [text part] + (let [start (ffirst part) + end (inc (first (last part)))] + (-> (second (first part)) + (assoc :text (subs text start end))))) + + (split-texts [text styles] + (->> (parse-draft-styles styles) + (build-style-index text) + (d/enumerate) + (partition-by second) + (mapv #(build-text text %)))) + + (build-paragraph [block] + (let [key (obj/get block "key") + text (obj/get block "text") + styles (obj/get block "inlineStyleRanges") + data (obj/get block "data")] + (-> (js->clj data :keywordize-keys true) + (assoc :key key) + (assoc :type "paragraph") + (assoc :children (split-texts text styles)))))] + + {:type "root" + :children + [{:type "paragraph-set" + :children (->> (obj/get content "blocks") + (mapv build-paragraph))}]})) + +(defn- convert-to-draft + [root] + (letfn [(process-attr [children ranges [k v]] + (loop [children (seq children) + start nil + offset 0 + ranges ranges] + (if-let [{:keys [text] :as item} (first children)] + (if (= v (get item k ::novalue)) + (recur (rest children) + (if (nil? start) offset start) + (+ offset (alength text)) + ranges) + (if (some? start) + (recur (rest children) + nil + (+ offset (alength text)) + (arr/conj! ranges #js {:offset start + :length (- offset start) + :style (encode-style k v)})) + (recur (rest children) + start + (+ offset (alength text)) + ranges))) + (cond-> ranges + (some? start) + (arr/conj! #js {:offset start + :length (- offset start) + :style (encode-style k v)}))))) + + (calc-ranges [{:keys [children] :as blok}] + (let [xform (comp (map #(dissoc % :key :text)) + (remove empty?) + (mapcat vec) + (distinct)) + proc #(process-attr children %1 %2)] + (transduce xform proc #js [] children))) + + (build-block [result {:keys [key children] :as paragraph}] + (->> #js {:key key + :depth 0 + :text (apply str (map :text children)) + :data (-> (dissoc paragraph :key :children :type) + (clj->js)) + :type "unstyled" + :entityRanges #js [] + :inlineStyleRanges (calc-ranges paragraph)} + (arr/conj! result)))] + + #js {:blocks (reduce build-block #js [] (txt/node-seq #(= (:type %) "paragraph") root)) + :entityMap #js {}})) + +(defn immutable-map->map + [obj] + (into {} (map (fn [[k v]] [(keyword k) v])) (seq obj))) + + +;; --- DRAFT-JS HELPERS + +(defn create-editor-state + ([] + (.createEmpty ^js draft/EditorState)) + ([content] + (if (some? content) + (.createWithContent ^js draft/EditorState content) + (.createEmpty ^js draft/EditorState)))) + +(defn import-content + [content] + (-> content convert-to-draft draft/convertFromRaw)) + +(defn export-content + [content] + (-> content + (draft/convertToRaw) + (convert-from-draft))) + +(defn get-editor-current-content + [state] + (.getCurrentContent ^js state)) + +(defn ^boolean content-has-text? + [content] + (.hasText ^js content)) + +(defn editor-select-all + [state] + (let [content (get-editor-current-content state) + fblock (.. ^js content getBlockMap first) + lblock (.. ^js content getBlockMap last) + fbk (.getKey ^js fblock) + lbk (.getKey ^js lblock) + lbl (.getLength ^js lblock) + params #js {:anchorKey fbk + :anchorOffset 0 + :focusKey lbk + :focusOffset lbl} + selection (draft/SelectionState. params)] + (.forceSelection ^js draft/EditorState state selection))) + +(defn get-editor-block-data + [block] + (-> (.getData ^js block) + (immutable-map->map))) + +(defn get-editor-block-type + [block] + (.getType ^js block)) + +(defn get-editor-current-block-data + [state] + (let [content (.getCurrentContent ^js state) + key (.. ^js state getSelection getStartKey) + block (.getBlockForKey ^js content key)] + (get-editor-block-data block))) + +(defn get-editor-current-inline-styles + [state] + (-> (.getCurrentInlineStyle ^js state) + (styles-to-attrs))) + +(defn update-editor-current-block-data + [state attrs] + (loop [selection (.getSelection ^js state) + start-key (.getStartKey ^js selection) + end-key (.getEndKey ^js selection) + content (.getCurrentContent ^js state) + target selection] + (if (and (not= start-key end-key) + (zero? (.getEndOffset ^js selection))) + (let [before-block (.getBlockBefore ^js content end-key)] + (recur selection + start-key + (.getKey ^js before-block) + content + (.merge ^js target + #js {:anchorKey start-key + :anchorOffset (.getStartOffset ^js selection) + :focusKey end-key + :focusOffset (.getLength ^js before-block) + :isBackward false}))) + (.push ^js draft/EditorState + state + (.mergeBlockData ^js draft/Modifier content target (clj->js attrs)) + "change-block-data")))) + +(defn update-editor-current-inline-styles + [state attrs] + (let [selection (.getSelection ^js state) + content (.getCurrentContent ^js state) + styles (attrs-to-styles attrs)] + (reduce (fn [state style] + (let [modifier (.applyInlineStyle draft/Modifier + (.getCurrentContent ^js state) + selection + style)] + (.push draft/EditorState state modifier "change-inline-style"))) + state + styles))) + +(defn editor-split-block + [state] + (let [content (.getCurrentContent ^js state) + selection (.getSelection ^js state) + content (.splitBlock ^js draft/Modifier content selection) + block-data (.. ^js content -blockMap (get (.. content -selectionBefore getStartKey)) getData) + block-key (.. ^js content -selectionAfter getStartKey) + block-map (.. ^js content -blockMap (update block-key (fn [block] (.set ^js block "data" block-data))))] + (.push ^js draft/EditorState state (.set ^js content "blockMap" block-map) "split-block"))) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ecd93d7e4..17778502f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -255,6 +255,11 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + asn1.js@^5.2.0: version "5.4.1" resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" @@ -996,6 +1001,11 @@ core-js-pure@^3.0.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.9.1.tgz#677b322267172bd490e4464696f790cbc355bec5" integrity sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A== +core-js@^3.6.4: + version "3.9.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.9.1.tgz#cec8de593db8eb2a85ffb0dbdeb312cb6e5460ae" + integrity sha512-gSjRvzkxQc1zjM/5paAmL4idJBFzuJoo+jDjF1tStYFMV2ERfD02HhahhCGXUyHxQRG4yFKVSdO6g62eoRMcDg== + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -1042,6 +1052,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" +cross-fetch@^3.0.4: + version "3.0.6" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" + integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -1336,6 +1353,15 @@ domutils@^1.7.0: dom-serializer "0" domelementtype "1" +draft-js@^0.11.7: + version "0.11.7" + resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.11.7.tgz#be293aaa255c46d8a6647f3860aa4c178484a206" + integrity sha512-ne7yFfN4sEL82QPQEn80xnADR8/Q6ALVworbC5UOSzOvjffmYfFsr3xSZtxbIirti14R7Y33EZC5rivpLgIbsg== + dependencies: + fbjs "^2.0.0" + immutable "~3.7.4" + object-assign "^4.1.1" + duplexify@^3.6.0: version "3.7.1" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" @@ -1661,6 +1687,25 @@ fast-safe-stringify@^2.0.4: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743" integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA== +fbjs-css-vars@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" + integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== + +fbjs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-2.0.0.tgz#01fb812138d7e31831ed3e374afe27b9169ef442" + integrity sha512-8XA8ny9ifxrAWlyhAbexXcs3rRMtxWcs3M0lctLfB49jRDHiaxj+Mo0XxbwE7nKZYzgCFoq64FS+WFd4IycPPQ== + dependencies: + core-js "^3.6.4" + cross-fetch "^3.0.4" + fbjs-css-vars "^1.0.0" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -2316,6 +2361,11 @@ immer@^5.0.0: resolved "https://registry.yarnpkg.com/immer/-/immer-5.3.6.tgz#51eab8cbbeb13075fe2244250f221598818cac04" integrity sha512-pqWQ6ozVfNOUDjrLfm4Pt7q4Q12cGw2HUZgry4Q5+Myxu9nmHRkWBpI0J4+MK0AxbdFtdMTwEGVl7Vd+vEiK+A== +immutable@~3.7.4: + version "3.7.6" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" + integrity sha1-E7TTyxK++hVIKib+Gy665kAHHks= + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -3015,7 +3065,7 @@ logform@^2.2.0: ms "^2.1.1" triple-beam "^1.3.0" -loose-envify@^1.1.0: +loose-envify@^1.0.0, loose-envify@^1.1.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -3045,11 +3095,6 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" -luxon@~1.25.0: - version "1.25.0" - resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" - integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== - make-iterator@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6" @@ -3341,6 +3386,11 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-gyp@^3.8.0: version "3.8.0" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" @@ -3944,6 +3994,13 @@ progress@^1.1.8: resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74= +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -4423,7 +4480,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4: +setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= @@ -5125,6 +5182,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +ua-parser-js@^0.7.18: + version "0.7.24" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.24.tgz#8d3ecea46ed4f1f1d63ec25f17d8568105dc027c" + integrity sha512-yo+miGzQx5gakzVK3QFfN0/L9uVhosXBBO7qmnk7c2iw1IhL212wfA3zbnI54B0obGwC/5NWub/iT9sReMx+Fw== + ultron@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" From 7709d219a95a1572c41fee7d505fd01b86df26ea Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:29:19 +0100 Subject: [PATCH 05/17] :bug: Fix minor issue with text directionality. --- frontend/resources/styles/main/partials/texts.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/resources/styles/main/partials/texts.scss b/frontend/resources/styles/main/partials/texts.scss index b132d18cc..8f26f6d53 100644 --- a/frontend/resources/styles/main/partials/texts.scss +++ b/frontend/resources/styles/main/partials/texts.scss @@ -19,7 +19,7 @@ foreignObject { .public-DraftStyleDefault-rtl { direction: rtl; } - .public-DraftStyleDefault-rtl { + .public-DraftStyleDefault-ltr { direction: ltr; } From 7db82a6af11b5bcd3db0ce30b332bca47b8a7d68 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:51:32 +0100 Subject: [PATCH 06/17] :bug: Add missing text cursor on the editor. --- frontend/src/app/main/ui/workspace/shapes/text/editor.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index f09baf264..525373ff2 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -147,6 +147,7 @@ (mf/use-layout-effect on-mount) [:div.text-editor {:ref self-ref + :style {:cursor cur/text} :class (dom/classnames :align-top (= (:vertical-align content "top") "top") :align-center (= (:vertical-align content) "center") From fde6126ac674bc7a3b9bcec983e555431b35b086 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:51:55 +0100 Subject: [PATCH 07/17] :bug: Remove pasted styles on the editor. --- frontend/src/app/main/ui/workspace/shapes/text/editor.cljs | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 525373ff2..35e211c81 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -156,6 +156,7 @@ {:on-change on-change :on-blur on-blur :handle-return handle-return + :strip-pasted-styles true :custom-style-fn (fn [styles _] (-> (ted/styles-to-attrs styles) (sts/generate-text-styles))) From 48747d9553b0a6bd32617d155f0d1a7be6a3d856 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:52:23 +0100 Subject: [PATCH 08/17] :bug: Handle properly the mouse capture. --- frontend/src/app/main/ui/workspace/viewport.cljs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 32c214814..a9aeca2be 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -435,11 +435,12 @@ (mf/use-callback (fn [event] (let [target (dom/get-target event) - closest (.closest target ".public-DraftStyleDefault-block")] - (when-not closest - ;; Capture mouse pointer to detect the movements even if cursor - ;; leaves the viewport or the browser itself - ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + closest (.closest target "foreignObject")] + ;; Capture mouse pointer to detect the movements even if cursor + ;; leaves the viewport or the browser itself + ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + (if closest + (.setPointerCapture closest (.-pointerId event)) (.setPointerCapture target (.-pointerId event)))))) on-pointer-up From 92254a175eee021f98d29759d5ca0417a828788b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:56:38 +0100 Subject: [PATCH 09/17] :bug: Handle properly pointer capture on text edition shape. --- .../main/ui/workspace/shapes/text/editor.cljs | 26 ++++++++++++++----- .../src/app/main/ui/workspace/viewport.cljs | 7 ++--- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 35e211c81..9c879aa7e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -142,16 +142,30 @@ (fn [event state] (st/emit! (dwt/update-editor-state shape (ted/editor-split-block state))) "handled")) + + on-pointer-down + (mf/use-callback + (fn [event] + (let [target (dom/get-target event) + closest (.closest ^js target "foreignObject")] + ;; Capture mouse pointer to detect the movements even if cursor + ;; leaves the viewport or the browser itself + ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture + (when closest + (dom/stop-propagation event) + (.setPointerCapture closest (.-pointerId event)))))) ] (mf/use-layout-effect on-mount) - [:div.text-editor {:ref self-ref - :style {:cursor cur/text} - :class (dom/classnames - :align-top (= (:vertical-align content "top") "top") - :align-center (= (:vertical-align content) "center") - :align-bottom (= (:vertical-align content) "bottom"))} + [:div.text-editor + {:ref self-ref + :style {:cursor cur/text} + :on-pointer-down on-pointer-down + :class (dom/classnames + :align-top (= (:vertical-align content "top") "top") + :align-center (= (:vertical-align content) "center") + :align-bottom (= (:vertical-align content) "bottom"))} [:> draft/Editor {:on-change on-change :on-blur on-blur diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index a9aeca2be..3a3b4de04 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -434,14 +434,11 @@ on-pointer-down (mf/use-callback (fn [event] - (let [target (dom/get-target event) - closest (.closest target "foreignObject")] + (let [target (dom/get-target event)] ;; Capture mouse pointer to detect the movements even if cursor ;; leaves the viewport or the browser itself ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture - (if closest - (.setPointerCapture closest (.-pointerId event)) - (.setPointerCapture target (.-pointerId event)))))) + (.setPointerCapture target (.-pointerId event))))) on-pointer-up (mf/use-callback From 13131a02265229ab1fcd8af527fad4e051cf408c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 15:59:43 +0100 Subject: [PATCH 10/17] :paperclip: Update changelog. --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 6f016b14a..4a6a3e2fa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,8 @@ - Duplicate and move files and projects [Taiga #267](https://tree.taiga.io/project/penpot/us/267) - Import SVG will create Penpot's shapes - Improve french translations [#731](https://github.com/penpot/penpot/pull/731) +- Replace Slate-Editor with DraftJS [Taiga #1346](https://tree.taiga.io/project/penpot/us/1346) + ### :bug: Bugs fixed From 68e3566b8b10cf8dbc4155a8e5bef86ccb3ff951 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 17:17:01 +0100 Subject: [PATCH 11/17] :bug: Properly handle empty blocks on draft-js format conversion. --- frontend/src/app/util/text_editor.cljs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs index 88e8e5253..4eabbf4ef 100644 --- a/frontend/src/app/util/text_editor.cljs +++ b/frontend/src/app/util/text_editor.cljs @@ -105,11 +105,14 @@ (assoc :text (subs text start end))))) (split-texts [text styles] - (->> (parse-draft-styles styles) - (build-style-index text) - (d/enumerate) - (partition-by second) - (mapv #(build-text text %)))) + (let [children (->> (parse-draft-styles styles) + (build-style-index text) + (d/enumerate) + (partition-by second) + (mapv #(build-text text %)))] + (cond-> children + (empty? children) + (conj {:text ""})))) (build-paragraph [block] (let [key (obj/get block "key") From 5519cdfd7cd3260603235ce905bf01c1271168dd Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 15 Mar 2021 22:29:46 +0100 Subject: [PATCH 12/17] :bug: Remove some drop-propagation that causes strange behavior. --- .../app/main/ui/workspace/shapes/text/editor.cljs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 9c879aa7e..47577da8c 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -89,15 +89,13 @@ assets (dom/get-element-by-class "assets-bar") cpicker (dom/get-element-by-class "colorpicker-tooltip") palette (dom/get-element-by-class "color-palette") - self (mf/ref-val self-ref)] - (if (or (and options (.contains options target)) - (and assets (.contains assets target)) - (and self (.contains self target)) - (and cpicker (.contains cpicker target)) - (and palette (.contains palette target)) - (= "foreignObject" (.-tagName ^js target))) - (dom/stop-propagation event) + (when-not (or (and options (.contains options target)) + (and assets (.contains assets target)) + (and self (.contains self target)) + (and cpicker (.contains cpicker target)) + (and palette (.contains palette target)) + (= "foreignObject" (.-tagName ^js target))) (st/emit! dw/clear-edition-mode)))) on-key-up @@ -152,7 +150,6 @@ ;; leaves the viewport or the browser itself ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture (when closest - (dom/stop-propagation event) (.setPointerCapture closest (.-pointerId event)))))) ] From f0087e11b05bf572df62b1fd0510371100487001 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 16 Mar 2021 11:31:28 +0100 Subject: [PATCH 13/17] :bug: Proper handle visual selection on blured editor. --- .../src/app/main/data/workspace/texts.cljs | 20 +-- .../main/ui/workspace/shapes/text/editor.cljs | 49 +++++--- .../src/app/main/ui/workspace/viewport.cljs | 9 +- frontend/src/app/util/draft_helpers.js | 57 +++++++++ frontend/src/app/util/text_editor.cljs | 119 +++++++++++++++--- 5 files changed, 202 insertions(+), 52 deletions(-) create mode 100644 frontend/src/app/util/draft_helpers.js diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 94eb3076f..96556aa0e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -55,14 +55,15 @@ (update state :workspace-editor-state dissoc id))))) (defn initialize-editor-state - [{:keys [id content] :as shape}] + [{:keys [id content] :as shape} decorator] (ptk/reify ::initialize-editor-state ptk/UpdateEvent (update [_ state] (update-in state [:workspace-editor-state id] (fn [_] (ted/create-editor-state - (some->> content ted/import-content))))))) + (some->> content ted/import-content) + decorator)))))) (defn finalize-editor-state [{:keys [id] :as shape}] @@ -136,8 +137,7 @@ shape-ids (cond (= (:type shape) :text) [id] (= (:type shape) :group) (cp/get-children id objects))] - (rx/of (dwc/update-shapes shape-ids update-fn) - (focus-editor)))))) + (rx/of (dwc/update-shapes shape-ids update-fn)))))) (defn update-paragraph-attrs [{:keys [id attrs]}] @@ -149,11 +149,7 @@ ptk/WatchEvent (watch [_ state stream] - (cond - (some? (get-in state [:workspace-editor-state id])) - (rx/of (focus-editor)) - - :else + (when-not (some? (get-in state [:workspace-editor-state id])) (let [objects (dwc/lookup-page-objects state) shape (get objects id) @@ -173,11 +169,7 @@ ptk/WatchEvent (watch [_ state stream] - (cond - (some? (get-in state [:workspace-editor-state id])) - (rx/of (focus-editor)) - - :else + (when-not (some? (get-in state [:workspace-editor-state id])) (let [objects (dwc/lookup-page-objects state) shape (get objects id) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 47577da8c..e10a1ced5 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -55,6 +55,12 @@ [:div {:style style :dir "auto"} [:> draft/EditorBlock props]])) +(mf/defc selection-component + {::mf/wrap-props false} + [props] + (let [children (obj/get props "children")] + [:span {:style {:background "#ccc" :display "inline-block"}} children])) + (defn render-block [block shape] (let [type (ted/get-editor-block-type block)] @@ -66,8 +72,11 @@ :shape shape}} nil))) +(def default-decorator + (ted/create-decorator "PENPOT_SELECTION" selection-component)) + (def empty-editor-state - (ted/create-editor-state)) + (ted/create-editor-state nil default-decorator)) (mf/defc text-shape-edit-html {::mf/wrap [mf/memo] @@ -79,9 +88,10 @@ zoom (mf/deref refs/selected-zoom) state-map (mf/deref refs/workspace-editor-state) state (get state-map id empty-editor-state) - self-ref (mf/use-ref) + blured (mf/use-var false) + on-click-outside (fn [event] (let [target (dom/get-target event) @@ -111,7 +121,7 @@ (let [keys [(events/listen js/document EventType.MOUSEDOWN on-click-outside) (events/listen js/document EventType.CLICK on-click-outside) (events/listen js/document EventType.KEYUP on-key-up)]] - (st/emit! (dwt/initialize-editor-state shape) + (st/emit! (dwt/initialize-editor-state shape default-decorator) (dwt/select-all shape)) #(do (st/emit! (dwt/finalize-editor-state shape)) @@ -119,14 +129,26 @@ (events/unlistenByKey key))))) on-blur - (fn [event] - (dom/stop-propagation event) - (dom/prevent-default event)) + (mf/use-callback + (mf/deps shape state) + (fn [event] + (dom/stop-propagation event) + (dom/prevent-default event) + (reset! blured true))) + + on-focus + (mf/use-callback + (mf/deps shape state) + (fn [event] + (reset! blured false))) on-change (mf/use-callback (fn [val] - (st/emit! (dwt/update-editor-state shape val)))) + (let [val (if (true? @blured) + (ted/add-editor-blur-selection val) + (ted/remove-editor-blur-selection val))] + (st/emit! (dwt/update-editor-state shape val))))) on-editor (mf/use-callback @@ -140,17 +162,6 @@ (fn [event state] (st/emit! (dwt/update-editor-state shape (ted/editor-split-block state))) "handled")) - - on-pointer-down - (mf/use-callback - (fn [event] - (let [target (dom/get-target event) - closest (.closest ^js target "foreignObject")] - ;; Capture mouse pointer to detect the movements even if cursor - ;; leaves the viewport or the browser itself - ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture - (when closest - (.setPointerCapture closest (.-pointerId event)))))) ] (mf/use-layout-effect on-mount) @@ -158,7 +169,6 @@ [:div.text-editor {:ref self-ref :style {:cursor cur/text} - :on-pointer-down on-pointer-down :class (dom/classnames :align-top (= (:vertical-align content "top") "top") :align-center (= (:vertical-align content) "center") @@ -166,6 +176,7 @@ [:> draft/Editor {:on-change on-change :on-blur on-blur + :on-focus on-focus :handle-return handle-return :strip-pasted-styles true :custom-style-fn (fn [styles _] diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 3a3b4de04..bb235e5f0 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -434,11 +434,16 @@ on-pointer-down (mf/use-callback (fn [event] - (let [target (dom/get-target event)] + ;; We need to handle editor related stuff here because + ;; handling on editor dom node does not works properly. + (let [target (dom/get-target event) + editor (.closest ^js target ".public-DraftEditor-content")] ;; Capture mouse pointer to detect the movements even if cursor ;; leaves the viewport or the browser itself ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture - (.setPointerCapture target (.-pointerId event))))) + (if editor + (.setPointerCapture editor (.-pointerId event)) + (.setPointerCapture target (.-pointerId event)))))) on-pointer-up (mf/use-callback diff --git a/frontend/src/app/util/draft_helpers.js b/frontend/src/app/util/draft_helpers.js new file mode 100644 index 000000000..f75ee0277 --- /dev/null +++ b/frontend/src/app/util/draft_helpers.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) UXBOX Labs SL + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +'use strict'; + +import {CharacterMetadata} from "draft-js"; +import {Map} from "immutable"; + +function removeStylePrefix(chmeta, stylePrefix) { + var withoutStyle = chmeta.set('style', chmeta.getStyle().filter((s) => !s.startsWith(stylePrefix))) + return CharacterMetadata.create(withoutStyle); +}; + +export function removeInlineStylePrefix(contentState, selectionState, stylePrefix) { + var blockMap = contentState.getBlockMap(); + var startKey = selectionState.getStartKey(); + var startOffset = selectionState.getStartOffset(); + var endKey = selectionState.getEndKey(); + var endOffset = selectionState.getEndOffset(); + var newBlocks = blockMap.skipUntil(function (_, k) { + return k === startKey; + }).takeUntil(function (_, k) { + return k === endKey; + }).concat(Map([[endKey, blockMap.get(endKey)]])).map(function (block, blockKey) { + var sliceStart; + var sliceEnd; + + if (startKey === endKey) { + sliceStart = startOffset; + sliceEnd = endOffset; + } else { + sliceStart = blockKey === startKey ? startOffset : 0; + sliceEnd = blockKey === endKey ? endOffset : block.getLength(); + } + + var chars = block.getCharacterList(); + var current; + + while (sliceStart < sliceEnd) { + current = chars.get(sliceStart); + chars = chars.set(sliceStart, removeStylePrefix(current, stylePrefix)); + sliceStart++; + } + + return block.set('characterList', chars); + }); + + return contentState.merge({ + blockMap: blockMap.merge(newBlocks), + selectionBefore: selectionState, + selectionAfter: selectionState + }); +} diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs index 4eabbf4ef..26a3fa8b2 100644 --- a/frontend/src/app/util/text_editor.cljs +++ b/frontend/src/app/util/text_editor.cljs @@ -11,6 +11,7 @@ "Draft related abstraction functions." (:require ["draft-js" :as draft] + ["./draft_helpers.js" :as helpers] [app.common.attrs :as attrs] [app.common.text :as txt] [app.common.data :as d] @@ -49,6 +50,16 @@ v (encode-style-value val)] (str "PENPOT$$$" k "$$$" v))) +(defn encode-style-prefix + [key] + (let [k (d/name key)] + (str "PENPOT$$$" k "$$$"))) + +(defn decode-style + [style] + (let [[_ k v] (str/split style "$$$" 3)] + [(keyword k) (decode-style-value v)])) + (defn attrs-to-styles [attrs] (reduce-kv (fn [res k v] @@ -60,8 +71,12 @@ [styles] (persistent! (reduce (fn [result style] - (let [[_ k v] (str/split style "$$$" 3)] - (assoc! result (keyword k) (decode-style-value v)))) + (if (str/starts-with? style "PENPOT") + (if (= style "PENPOT_SELECTION") + (assoc! result :penpot-selection true) + (let [[_ k v] (str/split style "$$$" 3)] + (assoc! result (keyword k) (decode-style-value v)))) + result)) (transient {}) (seq styles)))) @@ -71,14 +86,15 @@ "Parses draft-js style ranges, converting encoded style name into a key/val pair of data." [styles] - (map (fn [item] - (let [[_ k v] (-> (obj/get item "style") - (str/split "$$$" 3))] - {:key (keyword k) - :val (decode-style-value v) - :offset (obj/get item "offset") - :length (obj/get item "length")})) - styles)) + (->> styles + (filter #(str/starts-with? (obj/get % "style") "PENPOT$$$")) + (map (fn [item] + (let [[_ k v] (-> (obj/get item "style") + (str/split "$$$" 3))] + {:key (keyword k) + :val (decode-style-value v) + :offset (obj/get item "offset") + :length (obj/get item "length")}))))) (defn- build-style-index "Generates a character based index with associated styles map." @@ -123,7 +139,6 @@ (assoc :key key) (assoc :type "paragraph") (assoc :children (split-texts text styles)))))] - {:type "root" :children [{:type "paragraph-set" @@ -193,9 +208,25 @@ ([] (.createEmpty ^js draft/EditorState)) ([content] + (.createWithContent ^js draft/EditorState content)) + ([content decorator] (if (some? content) - (.createWithContent ^js draft/EditorState content) - (.createEmpty ^js draft/EditorState)))) + (.createWithContent ^js draft/EditorState content decorator) + (.createEmpty ^js draft/EditorState decorator)))) + +(defn create-decorator + [type component] + (letfn [(find-entity [block callback content] + (.findEntityRanges ^js block + (fn [cmeta] + (let [ekey (.getEntity ^js cmeta)] + (boolean + (and (some? ekey) + (= type (.. ^js content (getEntity ekey) (getType))))))) + callback))] + (draft/CompositeDecorator. + #js [#js {:strategy find-entity + :component component}]))) (defn import-content [content] @@ -276,17 +307,33 @@ (.mergeBlockData ^js draft/Modifier content target (clj->js attrs)) "change-block-data")))) +(defn get-editor-current-entity-key + [state] + (let [content (.getCurrentContent ^js state) + selection (.getSelection ^js state) + start-key (.getStartKey ^js selection) + start-offset (.getStartOffset ^js selection) + block (.getBlockForKey ^js content start-key)] + (.getEntityAt ^js block start-offset))) + (defn update-editor-current-inline-styles [state attrs] (let [selection (.getSelection ^js state) - content (.getCurrentContent ^js state) styles (attrs-to-styles attrs)] (reduce (fn [state style] - (let [modifier (.applyInlineStyle draft/Modifier - (.getCurrentContent ^js state) + (let [[sk sv] (decode-style style) + prefix (encode-style-prefix sk) + + content (.getCurrentContent ^js state) + content (helpers/removeInlineStylePrefix content + selection + prefix) + + content (.applyInlineStyle ^js draft/Modifier + content selection style)] - (.push draft/EditorState state modifier "change-inline-style"))) + (.push ^js draft/EditorState state content "change-inline-style"))) state styles))) @@ -299,3 +346,41 @@ block-key (.. ^js content -selectionAfter getStartKey) block-map (.. ^js content -blockMap (update block-key (fn [block] (.set ^js block "data" block-data))))] (.push ^js draft/EditorState state (.set ^js content "blockMap" block-map) "split-block"))) + +(defn add-editor-blur-selection + [state] + (let [content (.getCurrentContent ^js state) + selection (.getSelection ^js state) + content (.createEntity ^js content "PENPOT_SELECTION" "MUTABLE") + ekey (.getLastCreatedEntityKey ^js content) + content (.applyEntity draft/Modifier + content + selection + ekey)] + (.push draft/EditorState state content "apply-entity"))) + + +(defn remove-editor-blur-selection + [state] + (let [content (get-editor-current-content state) + fblock (.. ^js content getBlockMap first) + lblock (.. ^js content getBlockMap last) + fbk (.getKey ^js fblock) + lbk (.getKey ^js lblock) + lbl (.getLength ^js lblock) + params #js {:anchorKey fbk + :anchorOffset 0 + :focusKey lbk + :focusOffset lbl} + + prev-selection (.getSelection state) + + selection (draft/SelectionState. params) + content (.applyEntity draft/Modifier + content + selection + nil)] + (as-> state $ + (.push draft/EditorState $ content "apply-entity") + (.forceSelection ^js draft/EditorState $ prev-selection)))) + From ede42e42b172db6050e8d34b3a42ab092f770b42 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 16 Mar 2021 11:38:47 +0100 Subject: [PATCH 14/17] :bug: Don't emit update-shape when no page-id. --- frontend/src/app/main/data/workspace/texts.cljs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 96556aa0e..aaa1d5a55 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -77,7 +77,8 @@ (dissoc (:content shape) :children))] (rx/merge (rx/of (update-editor-state shape nil)) - (when (not= content (:content shape)) + (when (and (not= content (:content shape)) + (some? (:current-page-id state))) (rx/of (dwc/update-shapes [id] #(assoc % :content content)))))) (rx/of (dws/deselect-shape id) (dwc/delete-shapes [id]))))))) From ca52f4f8eac106b67050f0514374b069dd9c9a51 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 16 Mar 2021 11:39:09 +0100 Subject: [PATCH 15/17] :sparkles: Improve use-previous hook. --- frontend/src/app/main/ui/hooks.cljs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index f77217d24..a028c0bc8 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -218,8 +218,11 @@ #(rx/dispose! sub))))) ;; https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state -(defn use-previous [value] - (let [ref (mf/use-ref)] +(defn use-previous + [value] + (let [ref (mf/use-ref value)] (mf/use-effect - #(mf/set-ref-val! ref value)) + (mf/deps value) + (fn [] + (mf/set-ref-val! ref value))) (mf/ref-val ref))) From 7bc91e7224fd129a11e8520721401bc3d58dfeeb Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 16 Mar 2021 13:50:28 +0100 Subject: [PATCH 16/17] :sparkles: Allow to unselect the text alignment. Defaulting to 'start' (rtl friendly). --- .../src/app/main/data/workspace/common.cljs | 1 - .../src/app/main/data/workspace/texts.cljs | 18 +- .../src/app/main/ui/shapes/text/styles.cljs | 2 +- .../workspace/sidebar/options/menus/text.cljs | 92 +++++--- frontend/src/app/util/draft_helpers.js | 57 ----- frontend/src/app/util/text_editor.cljs | 132 +---------- frontend/src/app/util/text_editor_impl.js | 212 ++++++++++++++++++ 7 files changed, 298 insertions(+), 216 deletions(-) delete mode 100644 frontend/src/app/util/draft_helpers.js create mode 100644 frontend/src/app/util/text_editor_impl.js diff --git a/frontend/src/app/main/data/workspace/common.cljs b/frontend/src/app/main/data/workspace/common.cljs index 19043af5f..c597bb5c9 100644 --- a/frontend/src/app/main/data/workspace/common.cljs +++ b/frontend/src/app/main/data/workspace/common.cljs @@ -454,7 +454,6 @@ (if (empty? rch-operations) rch (conj rch rchg)) (if (empty? uch-operations) uch (conj uch uchg))))))))))) - (defn update-shapes-recursive [ids f] (us/assert ::coll-of-uuid ids) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index aaa1d5a55..8f37b97ab 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -121,8 +121,8 @@ ;; --- TEXT EDITION IMPL (defn- update-shape - [shape pred-fn attrs] - (let [merge-attrs #(attrs/merge % attrs) + [shape pred-fn merge-fn attrs] + (let [merge-attrs #(merge-fn % attrs) transform #(txt/transform-nodes pred-fn merge-attrs %)] (update shape :content transform))) @@ -134,7 +134,7 @@ (let [objects (dwc/lookup-page-objects state) shape (get objects id) - update-fn #(update-shape % txt/is-root-node? attrs) + update-fn #(update-shape % txt/is-root-node? attrs/merge attrs) shape-ids (cond (= (:type shape) :text) [id] (= (:type shape) :group) (cp/get-children id objects))] @@ -154,7 +154,15 @@ (let [objects (dwc/lookup-page-objects state) shape (get objects id) - update-fn #(update-shape % txt/is-paragraph-node? attrs) + merge-fn (fn [node attrs] + (reduce-kv (fn [node k v] + (if (= (get node k) v) + (dissoc node k) + (assoc node k v))) + node + attrs)) + + update-fn #(update-shape % txt/is-paragraph-node? merge-fn attrs) shape-ids (cond (= (:type shape) :text) [id] (= (:type shape) :group) (cp/get-children id objects))] @@ -174,7 +182,7 @@ (let [objects (dwc/lookup-page-objects state) shape (get objects id) - update-fn #(update-shape % txt/is-text-node? attrs) + update-fn #(update-shape % txt/is-text-node? attrs/merge attrs) shape-ids (cond (= (:type shape) :text) [id] (= (:type shape) :group) (cp/get-children id objects))] (rx/of (dwc/update-shapes shape-ids update-fn)))))))) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 93f6cde2e..aaa01bf1f 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -47,7 +47,7 @@ (defn generate-paragraph-styles [shape data] (let [line-height (:line-height data) - text-align (:text-align data) + text-align (:text-align data "start") grow-type (:grow-type shape) base #js {:fontSize (str (:font-size txt/default-text-attrs) "px") diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index c1c4c780e..4c8a78bf1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -26,34 +26,62 @@ [cuerdas.core :as str] [rumext.alpha :as mf])) -(def text-typography-attrs [:typography-ref-id :typography-ref-file]) -(def text-fill-attrs [:fill-color :fill-opacity :fill-color-ref-id :fill-color-ref-file :fill-color-gradient :fill :opacity ]) -(def text-font-attrs [:font-id :font-family :font-variant-id :font-size :font-weight :font-style]) -(def text-align-attrs [:text-align]) -(def text-spacing-attrs [:line-height :letter-spacing]) -(def text-valign-attrs [:vertical-align]) -(def text-decoration-attrs [:text-decoration]) -(def text-transform-attrs [:text-transform]) +(def text-typography-attrs + [:typography-ref-id + :typography-ref-file]) -(def shape-attrs [:grow-type]) -(def root-attrs (d/concat text-valign-attrs - text-align-attrs)) -(def paragraph-attrs text-align-attrs) -(def text-attrs (d/concat text-typography-attrs - text-font-attrs - text-align-attrs - text-spacing-attrs - text-decoration-attrs - text-transform-attrs)) +(def text-fill-attrs + [:fill-color + :fill-opacity + :fill-color-ref-id + :fill-color-ref-file + :fill-color-gradient]) + +(def text-font-attrs + [:font-id + :font-family + :font-variant-id + :font-size + :font-weight + :font-style]) + +(def text-align-attrs + [:text-align]) + +(def text-spacing-attrs + [:line-height + :letter-spacing]) + +(def text-valign-attrs + [:vertical-align]) + +(def text-decoration-attrs + [:text-decoration]) + +(def text-transform-attrs + [:text-transform]) + +(def shape-attrs + [:grow-type]) + +(def root-attrs + (d/concat text-valign-attrs text-align-attrs)) + +(def paragraph-attrs + text-align-attrs) + +(def text-attrs + (d/concat text-typography-attrs + text-font-attrs + text-spacing-attrs + text-decoration-attrs + text-transform-attrs)) (def attrs (d/concat #{} shape-attrs root-attrs paragraph-attrs text-attrs)) (mf/defc text-align-options [{:keys [ids values on-change] :as props}] (let [{:keys [text-align]} values - - text-align (or text-align "left") - handle-change (fn [event new-align] (on-change {:text-align new-align}))] @@ -169,13 +197,13 @@ {::mf/wrap [mf/memo]} [{:keys [ids type values] :as props}] - (let [current-file-id (mf/use-ctx ctx/current-file-id) + (let [file-id (mf/use-ctx ctx/current-file-id) typographies (mf/deref refs/workspace-file-typography) - shared-libs (mf/deref refs/workspace-libraries) - label (case type - :multiple (tr "workspace.options.text-options.title-selection") - :group (tr "workspace.options.text-options.title-group") - (tr "workspace.options.text-options.title")) + shared-libs (mf/deref refs/workspace-libraries) + label (case type + :multiple (tr "workspace.options.text-options.title-selection") + :group (tr "workspace.options.text-options.title-group") + (tr "workspace.options.text-options.title")) emit-update! (fn [id attrs] @@ -194,14 +222,14 @@ typography (cond (and (:typography-ref-id values) (not= (:typography-ref-id values) :multiple) - (not= (:typography-ref-file values) current-file-id)) + (not= (:typography-ref-file values) file-id)) (-> shared-libs (get-in [(:typography-ref-file values) :data :typographies (:typography-ref-id values)]) (assoc :file-id (:typography-ref-file values))) (and (:typography-ref-id values) (not= (:typography-ref-id values) :multiple) - (= (:typography-ref-file values) current-file-id)) + (= (:typography-ref-file values) file-id)) (get typographies (:typography-ref-id values))) on-convert-to-typography @@ -218,7 +246,7 @@ (let [id (uuid/next)] (st/emit! (dwl/add-typography (assoc typography :id id) false)) (run! #(emit-update! % {:typography-ref-id id - :typography-ref-file current-file-id}) ids))))) + :typography-ref-file file-id}) ids))))) handle-detach-typography (fn [] @@ -228,7 +256,7 @@ handle-change-typography (fn [changes] - (st/emit! (dwl/update-typography (merge typography changes) current-file-id))) + (st/emit! (dwl/update-typography (merge typography changes) file-id))) opts #js {:ids ids :values values @@ -244,7 +272,7 @@ (cond typography [:& typography-entry {:typography typography - :read-only? (not= (:typography-ref-file values) current-file-id) + :read-only? (not= (:typography-ref-file values) file-id) :file (get shared-libs (:typography-ref-file values)) :on-detach handle-detach-typography :on-change handle-change-typography}] diff --git a/frontend/src/app/util/draft_helpers.js b/frontend/src/app/util/draft_helpers.js deleted file mode 100644 index f75ee0277..000000000 --- a/frontend/src/app/util/draft_helpers.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) UXBOX Labs SL - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ -'use strict'; - -import {CharacterMetadata} from "draft-js"; -import {Map} from "immutable"; - -function removeStylePrefix(chmeta, stylePrefix) { - var withoutStyle = chmeta.set('style', chmeta.getStyle().filter((s) => !s.startsWith(stylePrefix))) - return CharacterMetadata.create(withoutStyle); -}; - -export function removeInlineStylePrefix(contentState, selectionState, stylePrefix) { - var blockMap = contentState.getBlockMap(); - var startKey = selectionState.getStartKey(); - var startOffset = selectionState.getStartOffset(); - var endKey = selectionState.getEndKey(); - var endOffset = selectionState.getEndOffset(); - var newBlocks = blockMap.skipUntil(function (_, k) { - return k === startKey; - }).takeUntil(function (_, k) { - return k === endKey; - }).concat(Map([[endKey, blockMap.get(endKey)]])).map(function (block, blockKey) { - var sliceStart; - var sliceEnd; - - if (startKey === endKey) { - sliceStart = startOffset; - sliceEnd = endOffset; - } else { - sliceStart = blockKey === startKey ? startOffset : 0; - sliceEnd = blockKey === endKey ? endOffset : block.getLength(); - } - - var chars = block.getCharacterList(); - var current; - - while (sliceStart < sliceEnd) { - current = chars.get(sliceStart); - chars = chars.set(sliceStart, removeStylePrefix(current, stylePrefix)); - sliceStart++; - } - - return block.set('characterList', chars); - }); - - return contentState.merge({ - blockMap: blockMap.merge(newBlocks), - selectionBefore: selectionState, - selectionAfter: selectionState - }); -} diff --git a/frontend/src/app/util/text_editor.cljs b/frontend/src/app/util/text_editor.cljs index 26a3fa8b2..3d90a73c1 100644 --- a/frontend/src/app/util/text_editor.cljs +++ b/frontend/src/app/util/text_editor.cljs @@ -11,7 +11,7 @@ "Draft related abstraction functions." (:require ["draft-js" :as draft] - ["./draft_helpers.js" :as helpers] + ["./text_editor_impl.js" :as impl] [app.common.attrs :as attrs] [app.common.text :as txt] [app.common.data :as d] @@ -206,27 +206,15 @@ (defn create-editor-state ([] - (.createEmpty ^js draft/EditorState)) + (impl/createEditorState nil nil)) ([content] - (.createWithContent ^js draft/EditorState content)) + (impl/createEditorState content nil)) ([content decorator] - (if (some? content) - (.createWithContent ^js draft/EditorState content decorator) - (.createEmpty ^js draft/EditorState decorator)))) + (impl/createEditorState content decorator))) (defn create-decorator [type component] - (letfn [(find-entity [block callback content] - (.findEntityRanges ^js block - (fn [cmeta] - (let [ekey (.getEntity ^js cmeta)] - (boolean - (and (some? ekey) - (= type (.. ^js content (getEntity ekey) (getType))))))) - callback))] - (draft/CompositeDecorator. - #js [#js {:strategy find-entity - :component component}]))) + (impl/createDecorator type component)) (defn import-content [content] @@ -248,18 +236,7 @@ (defn editor-select-all [state] - (let [content (get-editor-current-content state) - fblock (.. ^js content getBlockMap first) - lblock (.. ^js content getBlockMap last) - fbk (.getKey ^js fblock) - lbk (.getKey ^js lblock) - lbl (.getLength ^js lblock) - params #js {:anchorKey fbk - :anchorOffset 0 - :focusKey lbk - :focusOffset lbl} - selection (draft/SelectionState. params)] - (.forceSelection ^js draft/EditorState state selection))) + (impl/selectAll state)) (defn get-editor-block-data [block] @@ -272,9 +249,7 @@ (defn get-editor-current-block-data [state] - (let [content (.getCurrentContent ^js state) - key (.. ^js state getSelection getStartKey) - block (.getBlockForKey ^js content key)] + (let [block (impl/getCurrentBlock state)] (get-editor-block-data block))) (defn get-editor-current-inline-styles @@ -284,103 +259,20 @@ (defn update-editor-current-block-data [state attrs] - (loop [selection (.getSelection ^js state) - start-key (.getStartKey ^js selection) - end-key (.getEndKey ^js selection) - content (.getCurrentContent ^js state) - target selection] - (if (and (not= start-key end-key) - (zero? (.getEndOffset ^js selection))) - (let [before-block (.getBlockBefore ^js content end-key)] - (recur selection - start-key - (.getKey ^js before-block) - content - (.merge ^js target - #js {:anchorKey start-key - :anchorOffset (.getStartOffset ^js selection) - :focusKey end-key - :focusOffset (.getLength ^js before-block) - :isBackward false}))) - (.push ^js draft/EditorState - state - (.mergeBlockData ^js draft/Modifier content target (clj->js attrs)) - "change-block-data")))) - -(defn get-editor-current-entity-key - [state] - (let [content (.getCurrentContent ^js state) - selection (.getSelection ^js state) - start-key (.getStartKey ^js selection) - start-offset (.getStartOffset ^js selection) - block (.getBlockForKey ^js content start-key)] - (.getEntityAt ^js block start-offset))) + (impl/updateCurrentBlockData state (clj->js attrs))) (defn update-editor-current-inline-styles [state attrs] - (let [selection (.getSelection ^js state) - styles (attrs-to-styles attrs)] - (reduce (fn [state style] - (let [[sk sv] (decode-style style) - prefix (encode-style-prefix sk) - - content (.getCurrentContent ^js state) - content (helpers/removeInlineStylePrefix content - selection - prefix) - - content (.applyInlineStyle ^js draft/Modifier - content - selection - style)] - (.push ^js draft/EditorState state content "change-inline-style"))) - state - styles))) + (impl/applyInlineStyle state (attrs-to-styles attrs))) (defn editor-split-block [state] - (let [content (.getCurrentContent ^js state) - selection (.getSelection ^js state) - content (.splitBlock ^js draft/Modifier content selection) - block-data (.. ^js content -blockMap (get (.. content -selectionBefore getStartKey)) getData) - block-key (.. ^js content -selectionAfter getStartKey) - block-map (.. ^js content -blockMap (update block-key (fn [block] (.set ^js block "data" block-data))))] - (.push ^js draft/EditorState state (.set ^js content "blockMap" block-map) "split-block"))) + (impl/splitBlockPreservingData state)) (defn add-editor-blur-selection [state] - (let [content (.getCurrentContent ^js state) - selection (.getSelection ^js state) - content (.createEntity ^js content "PENPOT_SELECTION" "MUTABLE") - ekey (.getLastCreatedEntityKey ^js content) - content (.applyEntity draft/Modifier - content - selection - ekey)] - (.push draft/EditorState state content "apply-entity"))) - + (impl/addBlurSelectionEntity state)) (defn remove-editor-blur-selection [state] - (let [content (get-editor-current-content state) - fblock (.. ^js content getBlockMap first) - lblock (.. ^js content getBlockMap last) - fbk (.getKey ^js fblock) - lbk (.getKey ^js lblock) - lbl (.getLength ^js lblock) - params #js {:anchorKey fbk - :anchorOffset 0 - :focusKey lbk - :focusOffset lbl} - - prev-selection (.getSelection state) - - selection (draft/SelectionState. params) - content (.applyEntity draft/Modifier - content - selection - nil)] - (as-> state $ - (.push draft/EditorState $ content "apply-entity") - (.forceSelection ^js draft/EditorState $ prev-selection)))) - + (impl/removeBlurSelectionEntity state)) diff --git a/frontend/src/app/util/text_editor_impl.js b/frontend/src/app/util/text_editor_impl.js new file mode 100644 index 000000000..e41422656 --- /dev/null +++ b/frontend/src/app/util/text_editor_impl.js @@ -0,0 +1,212 @@ +/** + * 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/. + * + * This Source Code Form is "Incompatible With Secondary Licenses", as + * defined by the Mozilla Public License, v. 2.0. + * + * Copyright (c) UXBOX Labs SL + */ + +'use strict'; + +import { + CharacterMetadata, + EditorState, + CompositeDecorator, + SelectionState, + Modifier +} from "draft-js"; + +import {Map} from "immutable"; + +function isDefined(v) { + return v !== undefined && v !== null; +} + +export function createEditorState(content, decorator) { + if (content === null) { + return EditorState.createEmpty(decorator); + } else { + return EditorState.createWithContent(content, decorator); + } +} + +export function createDecorator(type, component) { + const strategy = (block, callback, content) => { + return block.findEntityRanges((cmeta) => { + const entityKey = cmeta.getEntity(); + return isDefined(entityKey) && (type === content.getEntity(entityKey).getType()); + }, callback); + }; + + return new CompositeDecorator([ + {strategy, component} + ]); +} + +function getSelectAllSelection(state) { + const content = state.getCurrentContent(); + const firstBlock = content.getBlockMap().first(); + const lastBlock = content.getBlockMap().last(); + + return new SelectionState({ + "anchorKey": firstBlock.getKey(), + "anchorOffset": 0, + "focusKey": lastBlock.getKey(), + "focusOffset": lastBlock.getLength() + }); +} + +export function selectAll(state) { + return EditorState.forceSelection(state, getSelectAllSelection(state)); +} + +function modifySelectedBlocks(contentState, selectionState, operation) { + var startKey = selectionState.getStartKey(); + var endKey = selectionState.getEndKey(); + var blockMap = contentState.getBlockMap(); + + var newBlocks = blockMap.toSeq().skipUntil(function (_, k) { + return k === startKey; + }).takeUntil(function (_, k) { + return k === endKey; + }).concat(Map([[endKey, blockMap.get(endKey)]])).map(operation); + + return contentState.merge({ + blockMap: blockMap.merge(newBlocks), + selectionBefore: selectionState, + selectionAfter: selectionState + }); +} + +export function updateCurrentBlockData(state, attrs) { + const selection = state.getSelection(); + let content = state.getCurrentContent(); + + content = modifySelectedBlocks(content, selection, (block) => { + let data = block.getData(); + for (let key of Object.keys(attrs)) { + const oldVal = data.get(key); + if (oldVal === attrs[key]) { + data = data.delete(key); + } else { + data = data.set(key, attrs[key]); + } + } + + return block.merge({ + data: data + }); + }); + + return EditorState.push(state, content, "change-block-data"); +} + +export function applyInlineStyle(state, styles) { + const selection = state.getSelection(); + + let state = state; + let content = null; + + for (let style of styles) { + const [p, k, _] = style.split("$$$"); + const prefix = [p, k, ""].join("$$$"); + + content = state.getCurrentContent(); + content = removeInlineStylePrefix(content, selection, prefix); + content = Modifier.applyInlineStyle(content, selection, style); + state = EditorState.push(state, content, "change-inline-style"); + } + + return state; +} + +export function splitBlockPreservingData(state) { + let content = state.getCurrentContent(); + const selection = state.getSelection(); + + content = Modifier.splitBlock(content, selection); + + const blockData = content.blockMap.get(content.selectionBefore.getStartKey()).getData(); + const blockKey = content.selectionAfter.getStartKey(); + const blockMap = content.blockMap.update(blockKey, (block) => { + return block.set("data", blockData); + }); + + content = content.set("blockMap", blockMap); + + return EditorState.push(state, content, "split-block"); +} + +export function addBlurSelectionEntity(state) { + let content = state.getCurrentContent(state); + const selection = state.getSelection(); + + content = content.createEntity("PENPOT_SELECTION", "MUTABLE"); + const entityKey = content.getLastCreatedEntityKey(); + + content = Modifier.applyEntity(content, selection, entityKey); + return EditorState.push(state, content, "apply-entity"); +} + +export function removeBlurSelectionEntity(state) { + const selectionAll = getSelectAllSelection(state); + const selection = state.getSelection(); + + let content = state.getCurrentContent(); + content = Modifier.applyEntity(content, selectionAll, null); + + state = EditorState.push(state, content, "apply-entity"); + state = EditorState.forceSelection(state, selection); + + return state; +} + +export function getCurrentBlock(state) { + const content = state.getCurrentContent(); + const selection = state.getSelection(); + const startKey = selection.getStartKey(); + return content.getBlockForKey(startKey); +} + +export function getCurrentEntityKey(state) { + const block = getCurrentBlock(state); + const selection = state.getSelection(); + const startOffset = selection.getStartOffset(); + return block.getEntityAt(startOffset); +} + +export function removeInlineStylePrefix(contentState, selectionState, stylePrefix) { + const startKey = selectionState.getStartKey(); + const startOffset = selectionState.getStartOffset(); + const endKey = selectionState.getEndKey(); + const endOffset = selectionState.getEndOffset(); + + return modifySelectedBlocks(contentState, selectionState, (block, blockKey) => { + let sliceStart; + let sliceEnd; + + if (startKey === endKey) { + sliceStart = startOffset; + sliceEnd = endOffset; + } else { + sliceStart = blockKey === startKey ? startOffset : 0; + sliceEnd = blockKey === endKey ? endOffset : block.getLength(); + } + + let chars = block.getCharacterList(); + let current; + + while (sliceStart < sliceEnd) { + current = chars.get(sliceStart); + current = current.set("style", current.getStyle().filter((s) => !s.startsWith(stylePrefix))) + chars = chars.set(sliceStart, CharacterMetadata.create(current)); + + sliceStart++; + } + + return block.set('characterList', chars); + }); +} From aa83f1bbd3111dcecbfcaaa782195b0b2f835b66 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 16 Mar 2021 14:41:51 +0100 Subject: [PATCH 17/17] :bug: Fix undo with text shapes. --- frontend/src/app/main/data/workspace/texts.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 8f37b97ab..c1ffae67e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -79,7 +79,9 @@ (rx/of (update-editor-state shape nil)) (when (and (not= content (:content shape)) (some? (:current-page-id state))) - (rx/of (dwc/update-shapes [id] #(assoc % :content content)))))) + (rx/of + (dwc/update-shapes [id] #(assoc % :content content)) + (dwc/commit-undo-transaction))))) (rx/of (dws/deselect-shape id) (dwc/delete-shapes [id])))))))