mirror of
https://github.com/penpot/penpot.git
synced 2026-10-03 01:06:14 +00:00
1622 lines
67 KiB
Clojure
1622 lines
67 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
|
|
|
(ns app.main.data.workspace.texts
|
|
(:require
|
|
["@penpot/text-editor" :as editor.v2]
|
|
[app.common.attrs :as attrs]
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.common.files.changes-builder :as pcb]
|
|
[app.common.files.helpers :as cfh]
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.geom.rect :as grc]
|
|
[app.common.geom.shapes :as gsh]
|
|
[app.common.math :as mth]
|
|
[app.common.types.fills :as types.fills]
|
|
[app.common.types.modifiers :as ctm]
|
|
[app.common.types.shape.layout :as ctl]
|
|
[app.common.types.text :as txt]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.changes :as dch]
|
|
[app.main.data.helpers :as dsh]
|
|
[app.main.data.notifications :as ntf]
|
|
[app.main.data.workspace :as-alias dw]
|
|
[app.main.data.workspace.common :as dwc]
|
|
[app.main.data.workspace.modifiers :as dwm]
|
|
[app.main.data.workspace.pages :as-alias dwpg]
|
|
[app.main.data.workspace.reflow :as wrf]
|
|
[app.main.data.workspace.selection :as dws]
|
|
[app.main.data.workspace.shapes :as dwsh]
|
|
[app.main.data.workspace.texts-v3 :as dwt-v3]
|
|
[app.main.data.workspace.transforms :as dwt]
|
|
[app.main.data.workspace.undo :as dwu]
|
|
[app.main.data.workspace.wasm-text :as dwwt]
|
|
[app.main.features :as features]
|
|
[app.main.fonts :as fonts]
|
|
[app.main.router :as rt]
|
|
[app.main.store :as st]
|
|
[app.render-wasm.api :as wasm.api]
|
|
[app.render-wasm.api.fonts :as wasm.fonts]
|
|
[app.render-wasm.text-editor :as wasm.text-editor]
|
|
[app.util.clipboard :as clipboard]
|
|
[app.util.text-editor :as ted]
|
|
[app.util.text.content :as tc]
|
|
[app.util.text.content.styles :as styles]
|
|
[app.util.timers :as ts]
|
|
[beicon.v2.core :as rx]
|
|
[cuerdas.core :as str]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
;; -- V2 Editor Helpers
|
|
|
|
(def ^function create-root-from-string editor.v2/createRootFromString)
|
|
(def ^function create-root-from-html editor.v2/createRootFromHTML)
|
|
(def ^function create-editor editor.v2/create)
|
|
(def ^function set-editor-root! editor.v2/setRoot)
|
|
(def ^function get-editor-root editor.v2/getRoot)
|
|
(def ^function is-empty? editor.v2/isEmpty)
|
|
(def ^function dispose! editor.v2/dispose)
|
|
|
|
(declare v2-update-text-shape-content)
|
|
(declare v2-update-text-editor-styles)
|
|
(declare v2-sync-wasm-text-layout)
|
|
|
|
(defn- bridge-to-measurement
|
|
"Marks `ids` pending until the text pipeline marks its own work:
|
|
`:text-measure` in the DOM renderer, `:text-resize` in wasm. Emits nothing."
|
|
[ids]
|
|
(wrf/bridge-pending ids #{:text-measure :text-resize} :text-bridge))
|
|
|
|
(defn- page-finalize?
|
|
[event]
|
|
(= ::dwpg/finalize-page (ptk/type event)))
|
|
|
|
(defn- text-work-stopper
|
|
[stream]
|
|
(rx/filter
|
|
(fn [event]
|
|
(or (= ::dw/finalize-workspace (ptk/type event))
|
|
(page-finalize? event)))
|
|
stream))
|
|
|
|
(defn initialize-text-reflow
|
|
"Tracks the texts the DOM pipeline still has to re-measure, so a reflow wait
|
|
covers the measurement that resizes them."
|
|
[]
|
|
(ptk/reify ::initialize-text-reflow
|
|
ptk/WatchEvent
|
|
(watch [_ _ stream]
|
|
(let [stopper (rx/filter (ptk/type? ::finalize-text-reflow) stream)
|
|
page-stopper (rx/filter page-finalize? stream)]
|
|
(->> stream
|
|
(rx/filter (ptk/type? :text/reflow))
|
|
(rx/map deref)
|
|
(rx/merge-map
|
|
(fn [{:keys [ids]}]
|
|
(->> (bridge-to-measurement ids)
|
|
(rx/take-until page-stopper))))
|
|
(rx/take-until stopper))))))
|
|
|
|
(defn finalize-text-reflow
|
|
[]
|
|
(ptk/data-event ::finalize-text-reflow))
|
|
|
|
(defn- resolve-text-ids
|
|
"Returns the text shapes an attribute change on `id` applies to: `id` itself
|
|
when it is a text, its text descendants when it is a group. These are the
|
|
shapes that get re-measured, and so the ones the reflow waits track."
|
|
[objects id]
|
|
(let [shape (get objects id)]
|
|
(cond
|
|
(cfh/text-shape? shape)
|
|
[id]
|
|
|
|
(cfh/group-shape? shape)
|
|
(into [] (filter #(cfh/text-shape? (get objects %))) (cfh/get-children-ids objects id))
|
|
|
|
:else
|
|
[])))
|
|
|
|
(defn- await-font-faces
|
|
"Waits for missing WASM faces, then resizes the affected texts."
|
|
[stream face-keys ids]
|
|
(let [resize-opts {:stack-undo? true :undo-transation? false}
|
|
resize-stream (->> (rx/from ids) (rx/map #(dwwt/resize-wasm-text % resize-opts)))]
|
|
(if (empty? face-keys)
|
|
resize-stream
|
|
(->> (rx/merge wasm.fonts/font-stored-stream
|
|
wasm.fonts/font-storage-failed-stream)
|
|
(rx/filter face-keys)
|
|
(rx/scan disj face-keys)
|
|
(rx/filter empty?)
|
|
(rx/take 1)
|
|
(rx/take-until (text-work-stopper stream))
|
|
(rx/observe-on :async)
|
|
(rx/mapcat (constantly resize-stream))
|
|
(wrf/with-pending :font ids)))))
|
|
|
|
(defn- pending-font-faces
|
|
[ids]
|
|
(let [objects (dsh/lookup-page-objects @st/state)]
|
|
(into #{}
|
|
(comp
|
|
(map #(get objects %))
|
|
(keep :content)
|
|
(mapcat wasm.fonts/get-content-fonts)
|
|
(map wasm.fonts/make-font-data)
|
|
(remove wasm.fonts/font-ready?)
|
|
(map wasm.fonts/font-data-key))
|
|
ids)))
|
|
|
|
(defn- await-font-resize
|
|
"Waits for missing font faces, then resizes `ids`."
|
|
[stream ids]
|
|
(if (empty? ids)
|
|
(rx/empty)
|
|
(->> (rx/of ::await-fonts)
|
|
(rx/mapcat
|
|
(fn [_]
|
|
(await-font-faces stream (pending-font-faces ids) ids))))))
|
|
|
|
(defn- await-html-font
|
|
"Keeps legacy DOM text pending while its new font is loading. The DOM
|
|
measurement also awaits this promise, so the font task bridges the state
|
|
update to the renderer commit without relying on a fixed settle delay."
|
|
[stream font-id font-variant-id ids]
|
|
(if (or (nil? font-id) (empty? ids))
|
|
(rx/empty)
|
|
(->> (rx/of ::load-font)
|
|
;; `ensure-loaded!` is intentionally invoked after `with-pending`
|
|
;; subscribes, so even an immediately settled promise cannot create a
|
|
;; gap before the task is visible to waiters.
|
|
(rx/mapcat (fn [_]
|
|
(rx/from (fonts/ensure-loaded! font-id font-variant-id))))
|
|
(rx/take-until (text-work-stopper stream))
|
|
(rx/mapcat (fn [_]
|
|
(st/emit! (dwsh/update-shapes
|
|
ids
|
|
#(dissoc % :position-data)
|
|
{:save-undo? false}))
|
|
(rx/empty)))
|
|
(wrf/with-pending :font ids))))
|
|
|
|
(def ruby-presentation-attrs
|
|
[:ruby-hidden :ruby-size :ruby-align :ruby-overhang :ruby-side])
|
|
|
|
;; -- Content helpers
|
|
|
|
;; Style attrs typed as `::sm/text` in the content schema (see
|
|
;; app.common.types.shape.text/schema:content): when the key is present its
|
|
;; value must be a non-blank string, so an explicit nil fails backend
|
|
;; `validate-shape`. `:key` is likewise a plain `:string`.
|
|
(def ^:private non-nilable-style-attrs
|
|
#{:font-family :font-size :font-style :font-weight
|
|
:direction :text-direction :text-decoration :text-transform :key})
|
|
|
|
(defn- remove-nil-style-attrs
|
|
"Strip nil-valued non-nilable style attrs from every node in a content tree.
|
|
Repairs content already corrupted with e.g. nil :font-family/:font-weight/
|
|
:font-style (from an unloaded font) so it can pass the backend schema again."
|
|
[content]
|
|
(txt/transform-nodes
|
|
(fn [node]
|
|
(reduce (fn [node k]
|
|
(if (and (contains? node k) (nil? (get node k)))
|
|
(dissoc node k)
|
|
node))
|
|
node
|
|
non-nilable-style-attrs))
|
|
content))
|
|
|
|
(defn ensure-valid-text-content
|
|
"Repair structurally incomplete text :content to a canonical
|
|
root -> paragraph-set -> paragraph -> span tree. Returns the
|
|
content unchanged when it is already well-formed.
|
|
|
|
A `nil` content, a root with no :children, or a root with an empty
|
|
:children vector all fail the backend `validate-shape` schema
|
|
(children must contain at least one paragraph-set). This helper
|
|
is the defensive normalizer used by content-commit paths.
|
|
|
|
It also scrubs nil-valued non-nilable style attrs (e.g. nil
|
|
:font-family/:font-weight/:font-style left over from an unloaded font),
|
|
so already-corrupted content self-heals on the next commit."
|
|
[content]
|
|
(if (and (map? content)
|
|
(= "root" (:type content))
|
|
(or (nil? (:children content))
|
|
(empty? (:children content))))
|
|
(let [base (tc/v2-default-text-content)]
|
|
(d/txt-merge base (select-keys content txt/root-attrs)))
|
|
(remove-nil-style-attrs content)))
|
|
|
|
(defn- v2-content-has-text?
|
|
[content]
|
|
(boolean
|
|
(when content
|
|
(some (fn [node]
|
|
(not (str/blank? (:text node ""))))
|
|
(txt/node-seq txt/is-text-node? content)))))
|
|
|
|
;; -- Editor
|
|
|
|
(defn update-editor
|
|
[editor]
|
|
(ptk/reify ::update-editor
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(if (some? editor)
|
|
(assoc state :workspace-editor editor)
|
|
(dissoc state :workspace-editor)))))
|
|
|
|
(defn focus-editor
|
|
[]
|
|
(ptk/reify ::focus-editor
|
|
ptk/EffectEvent
|
|
(effect [_ _ _]
|
|
;; The focus is deferred, so we re-read the current editor at fire
|
|
;; time: the editor present now can be unmounted before the timeout
|
|
;; runs (e.g. switching renderer while editing a text), and focusing a
|
|
;; stale instance throws.
|
|
(ts/schedule
|
|
(fn []
|
|
(let [editor (:workspace-editor @st/state)
|
|
element (when editor (.-element editor))]
|
|
(cond
|
|
;; V1 (DraftEditor)
|
|
(and (some? editor) (.-focus editor))
|
|
(.focus ^js editor)
|
|
|
|
;; V2
|
|
(and element (.-focus element))
|
|
(.focus ^js element))))))))
|
|
|
|
(defn gen-name
|
|
[editor]
|
|
(when (some? editor)
|
|
(let [result
|
|
(-> (ted/get-editor-current-plain-text editor)
|
|
(txt/generate-shape-name))]
|
|
(when (not= result "") result))))
|
|
|
|
(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)
|
|
(-> state
|
|
(update :workspace-editor-state dissoc id)
|
|
(update :workspace-new-text-shapes disj id))))))
|
|
|
|
(defn finalize-editor-state
|
|
[id update-name?]
|
|
(ptk/reify ::finalize-editor-state
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(when (dwc/initialized? state)
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
editor-state (get-in state [:workspace-editor-state id])
|
|
content (-> editor-state
|
|
(ted/get-editor-current-content))
|
|
name (gen-name editor-state)
|
|
|
|
new-shape? (contains? (:workspace-new-text-shapes state) id)]
|
|
(if (ted/content-has-text? content)
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(let [content (d/merge (ted/export-content content)
|
|
(dissoc (:content shape) :children))
|
|
new-size (dwwt/get-wasm-text-new-size shape content)]
|
|
(rx/merge
|
|
(rx/of (update-editor-state shape nil))
|
|
(when (and (not= content (:content shape))
|
|
(some? (:current-page-id state))
|
|
(some? shape))
|
|
(rx/of
|
|
(dwsh/update-shapes
|
|
[id]
|
|
(fn [shape]
|
|
(-> shape
|
|
(assoc :content content)
|
|
(cond-> (and update-name? (some? name))
|
|
(assoc :name name))
|
|
(cond-> (some? new-size)
|
|
(gsh/transform-shape
|
|
(ctm/change-size shape (:width new-size) (:height new-size))))))
|
|
{:undo-group (when new-shape? id)})))))
|
|
|
|
(let [content (d/merge (ted/export-content content)
|
|
(dissoc (:content shape) :children))
|
|
modifiers (get-in state [:workspace-text-modifier id])]
|
|
(rx/merge
|
|
(rx/of (update-editor-state shape nil))
|
|
(when (and (not= content (:content shape))
|
|
(some? (:current-page-id state))
|
|
(some? shape))
|
|
(rx/of
|
|
(dwsh/update-shapes
|
|
[id]
|
|
(fn [shape]
|
|
(let [{:keys [width height position-data]} modifiers]
|
|
(-> shape
|
|
(assoc :content content)
|
|
(cond-> position-data
|
|
(assoc :position-data position-data))
|
|
(cond-> (and update-name? (some? name))
|
|
(assoc :name name))
|
|
(cond-> (or (some? width) (some? height))
|
|
(gsh/transform-shape (ctm/change-size shape width height))))))
|
|
{:undo-group (when new-shape? id)}))))))
|
|
|
|
(when (some? id)
|
|
(rx/of (dws/deselect-shape id)
|
|
(dwsh/delete-shapes #{id})))))))))
|
|
|
|
(defn initialize-editor-state
|
|
[{:keys [id name content] :as shape} decorator]
|
|
(ptk/reify ::initialize-editor-state
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [text-state (some->> content ted/import-content)
|
|
attrs (merge (txt/get-default-text-attrs)
|
|
(fonts/valid-default-font
|
|
(get-in state [:workspace-global :default-font])))
|
|
editor (cond-> (ted/create-editor-state text-state decorator)
|
|
(and (nil? content) (some? attrs))
|
|
(ted/update-editor-current-block-data attrs))]
|
|
(assoc-in state [:workspace-editor-state id] editor)))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
;; We need to finalize editor on two main events: (1) when user
|
|
;; explicitly navigates to other section or page; (2) when user
|
|
;; leaves the editor.
|
|
(let [editor (dm/get-in state [:workspace-editor-state id])
|
|
update-name? (or (nil? content) (= name (gen-name editor)))]
|
|
(->> (rx/merge
|
|
(rx/filter (ptk/type? ::rt/navigate) stream)
|
|
(rx/filter #(= ::finalize-editor-state %) stream))
|
|
(rx/take 1)
|
|
(rx/map #(finalize-editor-state id update-name?)))))))
|
|
|
|
(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))))
|
|
|
|
(defn cursor-to-end
|
|
[{:keys [id] :as shape}]
|
|
(ptk/reify ::cursor-to-end
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(d/update-in-when state [:workspace-editor-state id] ted/cursor-to-end))))
|
|
|
|
;; --- Helpers
|
|
|
|
(defn- to-new-fills
|
|
[data]
|
|
;; FIXME: maybe export this as a specific helper ?
|
|
(types.fills/create
|
|
(d/without-nils (select-keys data types.fills/fill-attrs))))
|
|
|
|
(defn- shape-current-values
|
|
[shape pred attrs]
|
|
(let [root (:content shape)
|
|
nodes (->> (txt/node-seq pred root)
|
|
(map (fn [node]
|
|
(if (txt/is-text-node? node)
|
|
(let [default-text-attrs
|
|
(txt/get-default-text-attrs)
|
|
|
|
fills
|
|
(cond
|
|
(types.fills/has-valid-fill-attrs? node)
|
|
(to-new-fills node)
|
|
|
|
(some? (:fills node))
|
|
(:fills node)
|
|
|
|
:else
|
|
(:fills default-text-attrs))]
|
|
|
|
(-> (merge default-text-attrs node)
|
|
(assoc :fills fills)))
|
|
node))))]
|
|
(attrs/get-attrs-multi nodes attrs)))
|
|
|
|
(defn current-root-values
|
|
[{:keys [attrs shape]}]
|
|
(shape-current-values shape txt/is-root-node? attrs))
|
|
|
|
(defn current-ruby-values
|
|
[{:keys [attrs shape]}]
|
|
(shape-current-values shape
|
|
#(and (txt/is-text-node? %)
|
|
(not (str/blank? (:ruby %))))
|
|
attrs))
|
|
|
|
(defn v3-current-text-values
|
|
[{:keys [editor-styles attrs]}]
|
|
(let [result (-> editor-styles
|
|
;; If we use dm/select-keys compilation fails
|
|
(select-keys attrs))
|
|
result (if (empty? result) txt/default-text-attrs result)]
|
|
result))
|
|
|
|
(defn v2-current-text-values
|
|
[{:keys [editor-instance attrs]}]
|
|
(let [result (-> (.-currentStyle editor-instance)
|
|
(styles/get-styles-from-style-declaration)
|
|
(select-keys attrs))
|
|
result (if (empty? result) txt/default-text-attrs result)]
|
|
result))
|
|
|
|
(defn v1-current-paragraph-values
|
|
[{: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-paragraph-values
|
|
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
|
|
(cond
|
|
(some? editor-styles) (merge (shape-current-values shape txt/is-paragraph-node? attrs)
|
|
(select-keys editor-styles attrs))
|
|
(some? editor-instance) (v2-current-text-values options)
|
|
(some? editor-state) (v1-current-paragraph-values options)
|
|
:else (shape-current-values shape txt/is-paragraph-node? attrs)))
|
|
|
|
(defn v1-current-text-values
|
|
[{:keys [editor-state attrs]}]
|
|
(let [result (-> (ted/get-editor-current-inline-styles editor-state)
|
|
(select-keys attrs))
|
|
result (if (empty? result)
|
|
(txt/get-default-text-attrs)
|
|
result)]
|
|
result))
|
|
|
|
(defn current-text-values
|
|
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
|
|
(cond
|
|
(some? editor-styles) (v3-current-text-values options)
|
|
(some? editor-instance) (v2-current-text-values options)
|
|
(some? editor-state) (v1-current-text-values options)
|
|
:else (shape-current-values shape txt/is-text-node? attrs)))
|
|
|
|
;; --- TEXT EDITION IMPL
|
|
|
|
(defn count-node-chars
|
|
([node]
|
|
(count-node-chars node false))
|
|
([node last?]
|
|
(case (:type node)
|
|
("root" "paragraph-set")
|
|
(apply + (concat (map count-node-chars (drop-last (:children node)))
|
|
(map #(count-node-chars % true) (take-last 1 (:children node)))))
|
|
|
|
"paragraph"
|
|
(+ (apply + (map count-node-chars (:children node))) (if last? 0 1))
|
|
|
|
(count (:text node)))))
|
|
|
|
(defn decorate-range-info
|
|
"Adds information about ranges inside the metadata of the text nodes"
|
|
[content]
|
|
(->> (with-meta content {:start 0 :end (count-node-chars content)})
|
|
(txt/transform-nodes
|
|
(fn [node]
|
|
(d/update-when
|
|
node
|
|
:children
|
|
(fn [children]
|
|
(let [start (-> node meta (:start 0))]
|
|
(->> children
|
|
(reduce (fn [[result start] node]
|
|
(let [end (+ start (count-node-chars node))]
|
|
[(-> result
|
|
(conj (with-meta node {:start start :end end})))
|
|
end]))
|
|
[[] start])
|
|
(first)))))))))
|
|
|
|
(defn split-content-at
|
|
[content position]
|
|
(->> content
|
|
(txt/transform-nodes
|
|
(fn [node]
|
|
(and (txt/is-paragraph-node? node)
|
|
(< (-> node meta :start) position (-> node meta :end))))
|
|
(fn [node]
|
|
(letfn
|
|
[(process-node [child]
|
|
(let [start (-> child meta :start)
|
|
end (-> child meta :end)]
|
|
(if (< start position end)
|
|
[(-> child
|
|
(vary-meta assoc :end position)
|
|
(update :text subs 0 (- position start)))
|
|
(-> child
|
|
(vary-meta assoc :start position)
|
|
(update :text subs (- position start)))]
|
|
[child])))]
|
|
(-> node
|
|
(d/update-when :children #(into [] (mapcat process-node) %))))))))
|
|
|
|
(defn update-content-range
|
|
[content start end attrs]
|
|
(->> content
|
|
(txt/transform-nodes
|
|
(fn [node]
|
|
(and (txt/is-text-node? node)
|
|
(and (>= (-> node meta :start) start)
|
|
(<= (-> node meta :end) end))))
|
|
#(d/patch-object % attrs))))
|
|
|
|
(defn- update-text-range-attrs
|
|
[shape start end attrs]
|
|
(let [new-content (-> (:content shape)
|
|
(decorate-range-info)
|
|
(split-content-at start)
|
|
(split-content-at end)
|
|
(update-content-range start end attrs))]
|
|
(assoc shape :content new-content)))
|
|
|
|
(defn update-text-range
|
|
[id start end attrs]
|
|
(ptk/reify ::update-text-range
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
|
|
update-fn
|
|
(fn [shape]
|
|
(cond-> shape
|
|
(cfh/text-shape? shape)
|
|
(update-text-range-attrs start end attrs)))
|
|
|
|
shape-ids (cond (cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
|
text-ids (resolve-text-ids objects id)]
|
|
|
|
(rx/concat
|
|
(rx/of (dwsh/update-shapes shape-ids update-fn))
|
|
(cond
|
|
(features/active-feature? state "render-wasm/v1")
|
|
(->> (rx/from text-ids)
|
|
(rx/map dwwt/resize-wasm-text-debounce))
|
|
|
|
(contains? attrs :font-id)
|
|
(await-html-font stream (:font-id attrs) (:font-variant-id attrs) text-ids)
|
|
|
|
:else
|
|
(rx/empty)))))))
|
|
|
|
(defn update-root-attrs
|
|
[{:keys [id attrs]}]
|
|
(ptk/reify ::update-root-attrs
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
|
|
update-fn
|
|
(fn [shape]
|
|
(if (some? (:content shape))
|
|
(txt/update-text-content shape txt/is-root-node? d/txt-merge attrs)
|
|
;; Shape has no :content yet (e.g. a brand-new text
|
|
;; shape that has never been edited). Seed the
|
|
;; canonical root/paragraph-set/paragraph/span tree
|
|
;; before applying the new root attrs; the
|
|
;; `validate-shape` schema requires :children to
|
|
;; contain at least one paragraph-set.
|
|
(assoc shape :content
|
|
(d/txt-merge (tc/v2-default-text-content) attrs))))
|
|
|
|
shape-ids
|
|
(cond (cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
|
|
|
(rx/of (dwsh/update-shapes shape-ids update-fn))))))
|
|
|
|
(defn update-paragraph-attrs
|
|
[{:keys [id 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 _]
|
|
(when-not (some? (get-in state [:workspace-editor-state id]))
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
|
|
merge-fn (fn [node attrs]
|
|
(reduce-kv
|
|
(fn [node k v]
|
|
(if (nil? v)
|
|
(dissoc node k)
|
|
(assoc node k v)))
|
|
node
|
|
attrs))
|
|
|
|
update-fn #(txt/update-text-content % txt/is-paragraph-node? merge-fn attrs)
|
|
shape-ids (cond
|
|
(cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))]
|
|
|
|
(rx/of (dwsh/update-shapes shape-ids update-fn)))))))
|
|
|
|
(defn update-text-attrs
|
|
[{:keys [id 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 _]
|
|
(when-not (some? (get-in state [:workspace-editor-state id]))
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
wasm? (features/active-feature? state "render-wasm/v1")
|
|
update-node? (fn [node]
|
|
(or (txt/is-text-node? node)
|
|
(txt/is-paragraph-node? node)))
|
|
shape-ids (cond
|
|
(cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
|
;; Keep WASM editor cache in sync with merged :content so a following
|
|
;; `apply-styles-to-selection` in `update-attrs` does not read stale
|
|
;; `shape-text-contents` and overwrite per-run fills (e.g. line-height).
|
|
merge-shape
|
|
(fn [sh]
|
|
(let [updated-shape (txt/update-text-content sh update-node? d/txt-merge attrs)]
|
|
(when wasm?
|
|
(wasm.text-editor/cache-shape-text-content! (:id updated-shape) (:content updated-shape)))
|
|
updated-shape))]
|
|
(rx/of (dwsh/update-shapes shape-ids merge-shape)))))))
|
|
|
|
(defn update-ruby-presentation-attrs
|
|
[shape attrs]
|
|
(txt/update-text-content
|
|
shape
|
|
#(and (txt/is-text-node? %)
|
|
(not (str/blank? (:ruby %))))
|
|
d/txt-merge
|
|
attrs))
|
|
|
|
(defn update-ruby-presentation
|
|
[id attrs]
|
|
(ptk/reify ::update-ruby-presentation
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
wasm? (features/active-feature? state "render-wasm/v1")
|
|
shape-ids (cond
|
|
(cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
|
update-fn (fn [shape]
|
|
(let [updated-shape (update-ruby-presentation-attrs shape attrs)]
|
|
(when (and wasm? (cfh/text-shape? updated-shape))
|
|
(wasm.text-editor/cache-shape-text-content!
|
|
(:id updated-shape)
|
|
(:content updated-shape)))
|
|
updated-shape))]
|
|
(rx/concat
|
|
(rx/of (dwsh/update-shapes shape-ids update-fn))
|
|
(if wasm?
|
|
(rx/of (dwwt/resize-wasm-text-all shape-ids))
|
|
(rx/empty)))))))
|
|
|
|
(defn update-all-ruby-presentation
|
|
[ids attrs]
|
|
(ptk/reify ::update-all-ruby-presentation
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-id (js/Symbol)]
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(->> (rx/from ids)
|
|
(rx/map #(update-ruby-presentation % attrs)))
|
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(defn migrate-node
|
|
[node]
|
|
(let [color-attrs (not-empty (select-keys node types.fills/fill-attrs))]
|
|
(cond-> node
|
|
(nil? (:fills node))
|
|
(assoc :fills (types.fills/create))
|
|
|
|
;; Migrate old colors and remove the old fromat
|
|
color-attrs
|
|
(-> (dissoc :fill-color :fill-opacity :fill-color-ref-id :fill-color-ref-file :fill-color-gradient)
|
|
(update :fills types.fills/update conj color-attrs))
|
|
|
|
;; We don't have the fills attribute. It's an old text without color
|
|
;; so need to be black
|
|
(and (nil? (:fills node)) (empty? color-attrs))
|
|
(assoc :fills (txt/get-default-text-fills)))))
|
|
|
|
(defn migrate-content
|
|
[content]
|
|
(txt/transform-nodes (some-fn txt/is-text-node? txt/is-paragraph-node?) migrate-node content))
|
|
|
|
(defn update-text-with-function
|
|
([id update-node-fn] (update-text-with-function id update-node-fn nil))
|
|
([id update-node-fn options]
|
|
(ptk/reify ::update-text-with-function
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
;; This is only called when `[:workspace-editor-state id]` is set, this property
|
|
;; keeps a Draft.js EditorState object.
|
|
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-inline-styles-fn (comp update-node-fn migrate-node)))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(when (or
|
|
(and (features/active-feature? state "text-editor/v2")
|
|
(nil? (:workspace-editor state)))
|
|
(and (not (features/active-feature? state "text-editor/v2"))
|
|
(nil? (get-in state [:workspace-editor-state id]))))
|
|
(let [page-id (or (get options :page-id)
|
|
(get state :current-page-id))
|
|
objects (dsh/lookup-page-objects state page-id)
|
|
shape (get objects id)
|
|
update-node? (some-fn txt/is-text-node? txt/is-paragraph-node?)
|
|
|
|
shape-ids
|
|
(cond
|
|
(cfh/text-shape? shape) [id]
|
|
(cfh/group-shape? shape) (cfh/get-children-ids objects id))
|
|
|
|
update-content
|
|
(fn [content]
|
|
(->> content
|
|
(migrate-content)
|
|
(txt/transform-nodes update-node? update-node-fn)))
|
|
|
|
update-shape
|
|
(fn [shape]
|
|
(-> shape
|
|
(dissoc :fills)
|
|
(d/update-when :content update-content)))]
|
|
|
|
(rx/concat (rx/of (dwsh/update-shapes shape-ids update-shape options))
|
|
(when (features/active-feature? state "text-editor-wasm/v1")
|
|
;; Transform each span so add-fill preserves its existing fills.
|
|
(let [result (wasm.api/apply-styles-to-selection
|
|
(comp update-node-fn migrate-node)
|
|
{:with-fills? true})]
|
|
(when result
|
|
(rx/of (v2-update-text-shape-content
|
|
(:shape-id result)
|
|
(:content result)
|
|
:update-name? true)
|
|
;; Refresh the panel now, not only after a reselect.
|
|
(dwt-v3/v3-update-text-editor-styles
|
|
(:shape-id result)
|
|
{:fills (:fills result)})))))))))
|
|
|
|
ptk/EffectEvent
|
|
(effect [_ state _]
|
|
(when (features/active-feature? state "text-editor/v2")
|
|
(when-let [instance (:workspace-editor state)]
|
|
(let [styles (some-> (editor.v2/getCurrentStyle instance)
|
|
(styles/get-styles-from-style-declaration :removed-mixed true)
|
|
((comp update-node-fn migrate-node))
|
|
(styles/attrs->styles))]
|
|
(editor.v2/applyStylesToSelection instance styles))))))))
|
|
|
|
;; --- RESIZE UTILS
|
|
|
|
(def start-edit-if-selected
|
|
(ptk/reify ::start-edit-if-selected
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
selected (->> state dsh/lookup-selected (mapv #(get objects %)))]
|
|
(cond-> state
|
|
(and (= 1 (count selected))
|
|
(= (-> selected first :type) :text))
|
|
(assoc-in [:workspace-local :edition] (-> selected first :id)))))))
|
|
|
|
(defn not-changed? [old-dim new-dim]
|
|
(> (mth/abs (- old-dim new-dim)) 0.1))
|
|
|
|
(defn commit-resize-text
|
|
[]
|
|
(ptk/reify ::commit-resize-text
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [props (::resize-text-debounce-props state)
|
|
objects (dsh/lookup-page-objects state)
|
|
undo-id (js/Symbol)]
|
|
|
|
(letfn [(changed-text? [id]
|
|
(let [shape (get objects id)
|
|
[new-width new-height] (get props id)]
|
|
(or (and (not-changed? (:width shape) new-width) (= (:grow-type shape) :auto-width))
|
|
(and (not-changed? (:height shape) new-height)
|
|
(or (= (:grow-type shape) :auto-height) (= (:grow-type shape) :auto-width))))))
|
|
|
|
(update-fn [{:keys [id selrect grow-type] :as shape}]
|
|
(let [{shape-width :width shape-height :height} selrect
|
|
[new-width new-height] (get props id)
|
|
|
|
shape
|
|
(cond-> shape
|
|
(and (or (not (ctl/any-layout-immediate-child? objects shape))
|
|
(not (ctl/fill-width? shape)))
|
|
(not-changed? shape-width new-width)
|
|
(= grow-type :auto-width))
|
|
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width new-width {:ignore-lock? true})))
|
|
|
|
shape
|
|
(cond-> shape
|
|
(and (or (not (ctl/any-layout-immediate-child? objects shape))
|
|
(not (ctl/fill-height? shape)))
|
|
(not-changed? shape-height new-height)
|
|
(or (= grow-type :auto-height) (= grow-type :auto-width)))
|
|
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height new-height {:ignore-lock? true})))]
|
|
|
|
shape))]
|
|
|
|
(let [ids (into #{} (filter changed-text?) (keys props))]
|
|
(rx/of (dwu/start-undo-transaction undo-id)
|
|
(dwsh/update-shapes ids update-fn {:with-objects? true
|
|
:reg-objects? true
|
|
:stack-undo? true
|
|
:ignore-touched true})
|
|
(ptk/data-event :layout/update {:ids ids})
|
|
(dwu/commit-undo-transaction undo-id))))))))
|
|
|
|
(defn resize-text
|
|
[id new-width new-height]
|
|
|
|
(let [cur-event (js/Symbol)
|
|
reflow-task (wrf/task :text-resize [id])]
|
|
(ptk/reify ::resize-text
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(-> state
|
|
(update ::resize-text-debounce-props (fnil assoc {}) id [new-width new-height])
|
|
(update ::resize-text-reflow-tasks (fnil conj []) reflow-task)
|
|
(cond-> (nil? (::resize-text-debounce-event state))
|
|
(assoc ::resize-text-debounce-event cur-event))))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(wrf/start! reflow-task)
|
|
(if (= (::resize-text-debounce-event state) cur-event)
|
|
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
|
|
(rx/concat
|
|
(rx/merge
|
|
(->> stream
|
|
(rx/filter (ptk/type? ::resize-text))
|
|
(rx/debounce 50)
|
|
(rx/take 1)
|
|
(rx/map #(commit-resize-text))
|
|
(rx/take-until stopper))
|
|
(rx/of (resize-text id new-width new-height)))
|
|
(rx/of (fn [state]
|
|
(wrf/finish-tasks! (::resize-text-reflow-tasks state))
|
|
(dissoc state
|
|
::resize-text-debounce-props
|
|
::resize-text-reflow-tasks
|
|
::resize-text-debounce-event)))))
|
|
(rx/empty))))))
|
|
|
|
(defn save-default-font
|
|
[data]
|
|
(ptk/reify ::save-default-font
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [multiple? (->> data vals (d/seek #(= % :multiple)))
|
|
font (dissoc data :typography-ref-id :typography-ref-file)]
|
|
(cond-> state
|
|
(not multiple?)
|
|
(update :workspace-global assoc :default-font font))))))
|
|
|
|
(defn apply-text-modifier
|
|
[shape text-modifier]
|
|
|
|
(if (some? text-modifier)
|
|
(let [{:keys [width height position-data]} text-modifier
|
|
new-shape
|
|
(cond-> shape
|
|
(some? width)
|
|
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :width width {:ignore-lock? true}))
|
|
|
|
(some? height)
|
|
(gsh/transform-shape (ctm/change-dimensions-modifiers shape :height height {:ignore-lock? true}))
|
|
|
|
(some? position-data)
|
|
(assoc :position-data position-data))
|
|
|
|
delta-move
|
|
(gpt/subtract (gpt/point (ctm/safe-size-rect new-shape))
|
|
(gpt/point (ctm/safe-size-rect shape)))
|
|
|
|
new-shape
|
|
(update new-shape :position-data gsh/move-position-data delta-move)]
|
|
|
|
new-shape)
|
|
shape))
|
|
|
|
(defn commit-update-text-modifier
|
|
[]
|
|
(ptk/reify ::commit-update-text-modifier
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [ids (::update-text-modifier-debounce-ids state)
|
|
modif-tree (dwm/create-modif-tree ids (ctm/reflow-modifiers))]
|
|
(rx/of (dwm/update-modifiers modif-tree false true))))))
|
|
|
|
(defn update-text-modifier
|
|
[id props]
|
|
|
|
(let [cur-event (js/Symbol)]
|
|
(ptk/reify ::update-text-modifier
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(-> state
|
|
(update-in [:workspace-text-modifier id] (fnil merge {}) props)
|
|
(update ::update-text-modifier-debounce-ids (fnil conj #{}) id)
|
|
(cond-> (nil? (::update-text-modifier-debounce-event state))
|
|
(assoc ::update-text-modifier-debounce-event cur-event))))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(if (= (::update-text-modifier-debounce-event state) cur-event)
|
|
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
|
|
(rx/concat
|
|
(rx/merge
|
|
(->> stream
|
|
(rx/filter (ptk/type? ::update-text-modifier))
|
|
(rx/debounce 50)
|
|
(rx/take 1)
|
|
(rx/map #(commit-update-text-modifier))
|
|
(rx/take-until stopper))
|
|
(rx/of (update-text-modifier id props)))
|
|
(rx/of #(dissoc % ::update-text-modifier-debounce-event ::update-text-modifier-debounce-ids))))
|
|
(rx/empty))))))
|
|
|
|
(defn clean-text-modifier
|
|
[id]
|
|
(ptk/reify ::clean-text-modifier
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [current-value (dm/get-in state [:workspace-text-modifier id])]
|
|
;; We only dissocc the value when hasn't change after a time
|
|
(->> (rx/of (fn [state]
|
|
(cond-> state
|
|
(identical? (dm/get-in state [:workspace-text-modifier id]) current-value)
|
|
(update :workspace-text-modifier dissoc id))))
|
|
(rx/delay 100))))))
|
|
|
|
(defn remove-text-modifier
|
|
[id]
|
|
(ptk/reify ::remove-text-modifier
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(d/dissoc-in state [:workspace-text-modifier id]))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(rx/of (dwm/apply-modifiers {:stack-undo? true})))))
|
|
|
|
(defn commit-position-data
|
|
[]
|
|
(ptk/reify ::commit-position-data
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [ids (keys (::update-position-data state))]
|
|
(update state :workspace-text-modifier #(apply dissoc % ids))))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [position-data (::update-position-data state)]
|
|
(rx/of (dwsh/update-shapes
|
|
(keys position-data)
|
|
(fn [shape]
|
|
(-> shape
|
|
(assoc :position-data (get position-data (:id shape)))))
|
|
{:stack-undo? true :reg-objects? false}))))))
|
|
|
|
(defn update-position-data
|
|
[id position-data]
|
|
|
|
(let [cur-event (js/Symbol)
|
|
reflow-task (wrf/task :text-position [id])]
|
|
(ptk/reify ::update-position-data
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(let [state (assoc-in state [:workspace-text-modifier id :position-data] position-data)]
|
|
(-> state
|
|
(update ::update-position-data-reflow-tasks (fnil conj []) reflow-task)
|
|
(cond-> (nil? (::update-position-data-debounce state))
|
|
(assoc ::update-position-data-debounce cur-event))
|
|
(cond-> (some? (::update-position-data-debounce state))
|
|
(assoc-in [::update-position-data id] position-data)))))
|
|
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(wrf/start! reflow-task)
|
|
(if (= (::update-position-data-debounce state) cur-event)
|
|
(let [stopper (text-work-stopper stream)]
|
|
(rx/concat
|
|
(rx/merge
|
|
(->> stream
|
|
(rx/filter (ptk/type? ::update-position-data))
|
|
(rx/debounce 50)
|
|
(rx/take 1)
|
|
(rx/map #(commit-position-data))
|
|
(rx/take-until stopper))
|
|
(rx/of (update-position-data id position-data)))
|
|
(rx/of (fn [state]
|
|
(wrf/finish-tasks! (::update-position-data-reflow-tasks state))
|
|
(dissoc state
|
|
::update-position-data-debounce
|
|
::update-position-data
|
|
::update-position-data-reflow-tasks)))))
|
|
(rx/empty))))))
|
|
|
|
(defn update-attrs
|
|
[id attrs]
|
|
(ptk/reify ::update-attrs
|
|
ptk/WatchEvent
|
|
(watch [_ state stream]
|
|
(let [text-editor-instance (:workspace-editor state)
|
|
objects (dsh/lookup-page-objects state)
|
|
text-ids (resolve-text-ids objects id)
|
|
|
|
wasm-editing?
|
|
(and (features/active-feature? state "text-editor-wasm/v1")
|
|
(= id (wasm.api/text-editor-get-active-shape-id)))
|
|
|
|
wasm-editing-selection?
|
|
(and wasm-editing? (wasm.api/text-editor-has-selection?))]
|
|
(if (and (features/active-feature? state "text-editor/v2")
|
|
(some? text-editor-instance))
|
|
(rx/empty)
|
|
(rx/concat
|
|
(let [attrs (select-keys attrs txt/root-attrs)]
|
|
(if-not (empty? attrs)
|
|
(rx/of (update-root-attrs {:id id :attrs attrs}))
|
|
(rx/empty)))
|
|
|
|
;; `:line-height` is stored on both the paragraph and its spans, and
|
|
;; the renderer takes the larger of the two.
|
|
(let [pattrs (if wasm-editing-selection?
|
|
(conj txt/paragraph-attrs :line-height)
|
|
txt/paragraph-attrs)
|
|
attrs (select-keys attrs pattrs)
|
|
result (when (and (seq attrs) wasm-editing?)
|
|
(wasm.api/apply-paragraph-attrs-to-selection attrs))]
|
|
(cond
|
|
(empty? attrs)
|
|
(rx/empty)
|
|
|
|
(some? result)
|
|
(rx/of (v2-update-text-shape-content
|
|
(:shape-id result) (:content result)
|
|
:update-name? true))
|
|
|
|
:else
|
|
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))))
|
|
|
|
(let [attrs (select-keys attrs txt/text-node-attrs)]
|
|
(cond
|
|
(or (empty? attrs) wasm-editing-selection?)
|
|
(rx/empty)
|
|
|
|
;; Collapsed caret: stash a pending caret style for the next typed
|
|
;; character instead of restyling the whole shape.
|
|
wasm-editing?
|
|
(do
|
|
(wasm.text-editor/merge-pending-caret-styles! id attrs)
|
|
(rx/of (dwt-v3/v3-update-text-editor-styles id attrs)))
|
|
|
|
:else
|
|
(rx/of (update-text-attrs {:id id :attrs attrs}))))
|
|
|
|
(when (and (features/active-feature? state "text-editor/v2")
|
|
(not (features/active-feature? state "text-editor-wasm/v1")))
|
|
(rx/of (v2-update-text-editor-styles id attrs)))
|
|
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(rx/concat
|
|
;; Apply style to selected spans and sync content
|
|
(let [has-selection? (wasm.api/text-editor-has-selection?)]
|
|
(when has-selection?
|
|
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
|
|
(when (not (empty? span-attrs))
|
|
(let [result (wasm.api/apply-styles-to-selection span-attrs)]
|
|
(when result
|
|
(rx/of (v2-update-text-shape-content
|
|
(:shape-id result) (:content result)
|
|
:update-name? true))))))))
|
|
;; Resize (with delay for font-id changes). Only auto-height and
|
|
;; auto-width shapes have geometry to recompute.
|
|
(let [auto-ids (into [] (remove #(= :fixed (:grow-type (get objects %)))) text-ids)]
|
|
(if (contains? attrs :font-id)
|
|
;; The geometry depends on the font, so wait until wasm has it.
|
|
(await-font-resize stream auto-ids)
|
|
;; No font change: measurable right away.
|
|
(->> (rx/from auto-ids)
|
|
(rx/map dwwt/resize-wasm-text)))))
|
|
|
|
;; The legacy renderer re-measures these in the DOM on its own,
|
|
;; but font loading starts before that render commits.
|
|
(if (contains? attrs :font-id)
|
|
(await-html-font
|
|
stream
|
|
(:font-id attrs)
|
|
(:font-variant-id attrs)
|
|
text-ids)
|
|
(rx/empty)))))))
|
|
|
|
ptk/EffectEvent
|
|
(effect [_ state _]
|
|
(when (features/active-feature? state "text-editor/v2")
|
|
(when-let [instance (:workspace-editor state)]
|
|
(when (seq attrs)
|
|
;; DOM `getCurrentStyle` reflects one resolved style (e.g. caret color). Merging
|
|
;; it with sidebar `attrs` and applying to the whole selection collapses mixed
|
|
;; fills/fonts when the user only changes one property (e.g. line-height).
|
|
;; Apply only the explicit attributes from this action.
|
|
(let [styles (styles/attrs->styles attrs)]
|
|
(editor.v2/applyStylesToSelection instance styles))))))))
|
|
|
|
(defn update-all-attrs
|
|
[ids attrs]
|
|
(ptk/reify ::update-all-attrs
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-id (js/Symbol)]
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(->> (rx/from ids)
|
|
(rx/map #(update-attrs % attrs)))
|
|
(rx/of (dwu/commit-undo-transaction undo-id)))))))
|
|
|
|
(defn apply-typography
|
|
"A higher level event that has the resposability of to apply the
|
|
specified typography to the selected shapes."
|
|
([typography file-id]
|
|
(apply-typography nil typography file-id))
|
|
|
|
([ids typography file-id]
|
|
(assert (or (nil? ids) (and (set? ids) (every? uuid? ids))))
|
|
(ptk/reify ::apply-typography
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [editor-state (:workspace-editor-state state)
|
|
ids (d/nilv ids (dsh/lookup-selected state))
|
|
attrs (-> typography
|
|
(assoc :typography-ref-file file-id)
|
|
(assoc :typography-ref-id (:id typography))
|
|
(dissoc :id :name))
|
|
undo-id (js/Symbol)]
|
|
|
|
(rx/concat
|
|
(rx/of (dwu/start-undo-transaction undo-id))
|
|
(->> (rx/from (seq ids))
|
|
(rx/map (fn [id]
|
|
(let [editor (get editor-state id)]
|
|
(update-text-attrs {:id id :editor editor :attrs attrs})))))
|
|
(rx/of (dwu/commit-undo-transaction undo-id))))))))
|
|
|
|
(defn generate-typography-name
|
|
[{:keys [font-id font-variant-id] :as typography}]
|
|
(let [{:keys [name]} (fonts/get-font-data font-id)]
|
|
(assoc typography :name (str name " " (str/title font-variant-id)))))
|
|
|
|
;; -- Text Editor v2
|
|
|
|
(defn v2-update-text-editor-styles
|
|
[id new-styles]
|
|
(ptk/reify ::v2-update-text-editor-styles
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
;; `stylechange` can fire on every `selectionchange` while typing.
|
|
;; Avoid swapping the global store when the computed styles are unchanged,
|
|
;; otherwise we can end up in store->rerender->selectionchange loops.
|
|
(let [merged-styles (merge (txt/get-default-text-attrs)
|
|
(fonts/valid-default-font
|
|
(get-in state [:workspace-global :default-font]))
|
|
new-styles)
|
|
prev (get-in state [:workspace-v2-editor-state id])]
|
|
(if (= merged-styles prev)
|
|
state
|
|
(assoc-in state [:workspace-v2-editor-state id] merged-styles))))))
|
|
|
|
(defn v2-sync-wasm-text-layout
|
|
"Live-sync WASM text layout from the DOM editor without writing shape :content.
|
|
Intended to be called from Text Editor v2 `needslayout` events (coalesced)."
|
|
[id content]
|
|
(ptk/reify ::v2-sync-wasm-text-layout
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)]
|
|
(if-not (and (some? shape) (cfh/text-shape? shape))
|
|
(rx/empty)
|
|
(let [new-size (dwwt/get-wasm-text-new-size shape content)
|
|
modifiers (when (and (some? new-size)
|
|
(not= :fixed (:grow-type shape)))
|
|
(dwwt/resize-wasm-text-modifiers shape content))]
|
|
;; `get-wasm-text-new-size` has the side effect of syncing WASM's internal text
|
|
;; content/layout. Only non-fixed grow-types need geometry modifiers updates.
|
|
(if (some? modifiers)
|
|
(rx/of (dwm/set-wasm-modifiers modifiers))
|
|
(rx/empty))))))
|
|
|
|
ptk/EffectEvent
|
|
(effect [_ _ _]
|
|
;; While typing, v2 only commits shape :content on debounced `change`.
|
|
;; We still need to repaint the WASM canvas for live preview.
|
|
(wasm.api/request-render "text-editor-v2-needslayout"))))
|
|
|
|
(defn v2-update-text-shape-position-data
|
|
[shape-id position-data]
|
|
(ptk/reify ::v2-update-text-shape-position-data
|
|
ptk/UpdateEvent
|
|
(update [_ state]
|
|
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
|
|
|
|
(defn- add-geometry-undo-to-commit
|
|
"Adds geometry undo/redo to a commit so undo restores both content and geometry.
|
|
old-geom and final-geom are maps with :selrect :points and optionally :width :height."
|
|
[base objects id old-geom final-geom attrs]
|
|
(let [objects-with-old (update objects id #(merge % old-geom))
|
|
final-shape-fn (fn [shape] (merge shape final-geom))]
|
|
(-> base
|
|
(pcb/with-objects objects-with-old)
|
|
(pcb/update-shapes [id] final-shape-fn {:attrs attrs}))))
|
|
|
|
(defn- build-finalize-commit-changes
|
|
"Builds the commit changes for text finalization (content + geometry undo).
|
|
For auto-width text, include geometry so undo restores e.g. width.
|
|
Includes :name when update-name? so we can skip save-undo on the preceding
|
|
update-shapes for finalize without losing name undo."
|
|
[it state id {:keys [new-shape? content-has-text? content original-content
|
|
update-name? name resize-geom]}]
|
|
(let [page-id (:current-page-id state)
|
|
objects (dsh/lookup-page-objects state page-id)
|
|
shape* (get objects id)
|
|
base (-> (pcb/empty-changes it page-id)
|
|
(pcb/with-objects objects)
|
|
(pcb/set-text-content id content original-content)
|
|
(cond-> (and update-name? (some? name) (not= (:name shape*) name))
|
|
(pcb/update-shapes [id] (fn [s] (assoc s :name name)) {:attrs [:name]}))
|
|
(cond-> new-shape?
|
|
(-> (pcb/set-undo-group id)
|
|
(pcb/set-stack-undo? true))))
|
|
;; `resize-geom` is the post-resize geometry; `shape*` still holds the pre-resize selrect.
|
|
final-geom (or resize-geom (select-keys shape* [:selrect :points :width :height]))
|
|
geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height])
|
|
old-geom (when (and content-has-text? (not= :fixed (:grow-type shape*)))
|
|
(or (get-in state [:workspace-text-session-geom id])
|
|
(let [sr (:selrect shape*)
|
|
r (grc/make-rect (or (:x sr) 0) (or (:y sr) 0) 0.01 0.01)]
|
|
{:selrect r :points (grc/rect->points r)})))]
|
|
(if (some? old-geom)
|
|
(add-geometry-undo-to-commit base objects id
|
|
(select-keys old-geom geom-keys)
|
|
(select-keys final-geom geom-keys)
|
|
geom-keys)
|
|
base)))
|
|
|
|
(defn v2-update-text-shape-content
|
|
[id content & {:keys [update-name? name finalize? save-undo? original-content]
|
|
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
|
|
;; Defensive: ensure the content we are about to commit has the
|
|
;; canonical root/paragraph-set/paragraph/span tree. The v2 editor
|
|
;; always produces well-formed content from `dom->cljs`, but legacy
|
|
;; producers (or programmatic API calls) can still pass a root with
|
|
;; empty :children, which would fail the backend `validate-shape`
|
|
;; schema.
|
|
(let [content (ensure-valid-text-content content)
|
|
original-content (ensure-valid-text-content original-content)]
|
|
(ptk/reify ::v2-update-text-shape-content
|
|
ptk/WatchEvent
|
|
(watch [it state _]
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(let [;; v3 editor always passes :finalize? from keyword opts; when absent
|
|
;; that binds nil and :or defaults do not apply — coerce so undo flags
|
|
;; stay strict booleans for changes-builder schema validation.
|
|
finalize? (boolean finalize?)
|
|
objects (dsh/lookup-page-objects state)
|
|
shape (get objects id)
|
|
new-shape? (contains? (:workspace-new-text-shapes state) id)
|
|
prev-content (:content shape)
|
|
has-prev-content? (not (nil? (:prev-content shape)))
|
|
;; For existing shapes, capture geometry at session start once so
|
|
;; finalize can build a single undo entry. Stored in workspace state,
|
|
;; not in the shape, to avoid persisting session-only data.
|
|
session-start-geom (or (get-in state [:workspace-text-session-geom id])
|
|
(select-keys shape [:selrect :points :width :height]))
|
|
content-has-text? (v2-content-has-text? content)
|
|
prev-content-has-text? (v2-content-has-text? prev-content)
|
|
;; Only measure/resize the shape on finalize. While the user is
|
|
;; actively typing, the WASM editor already renders the growing text
|
|
;; (and the editor overlay measures it live), so a per-keystroke
|
|
;; resize is redundant and, going through the interactive-transform
|
|
;; modifier machinery, made auto-width typing very laggy.
|
|
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
|
|
(dwwt/get-wasm-text-new-size shape content))
|
|
;; Also compute the resized geometry for the finalize commit; the
|
|
;; async `apply-wasm-modifiers` below never updates this `state`.
|
|
resize-modifiers (when (some? new-size)
|
|
(dwwt/resize-wasm-text-modifiers shape content))
|
|
resize-geom (when resize-modifiers
|
|
(-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers]))
|
|
(select-keys [:selrect :points :width :height])))
|
|
;; New shapes: single undo on finalize only (no per-keystroke undo)
|
|
effective-save-undo? (if new-shape? finalize? save-undo?)
|
|
effective-stack-undo? (and new-shape? finalize?)
|
|
;; No save-undo on first update when finalizing: either build-finalize
|
|
;; holds undo (non-new), or we delete empty text and only delete-shapes
|
|
;; should record undo.
|
|
finalize-save-undo-first?
|
|
(if (and finalize? (or (not new-shape?) (not content-has-text?)))
|
|
false
|
|
effective-save-undo?)
|
|
|
|
;; Whether any content-changing edit happened this editing session.
|
|
session-touched? (some? (get-in state [:workspace-text-session-geom id]))
|
|
;; A finalize on an existing shape that wasn't edited must not create any undo entry
|
|
;; (exception being newly created shapes)
|
|
finalize-no-op? (and finalize?
|
|
(not new-shape?)
|
|
content-has-text?
|
|
(not session-touched?))]
|
|
|
|
(rx/concat
|
|
(rx/of
|
|
;; Store session-start geometry in workspace state once for existing shapes
|
|
(when (and (not new-shape?)
|
|
(nil? (get-in state [:workspace-text-session-geom id])))
|
|
(fn [s] (assoc-in s [:workspace-text-session-geom id] session-start-geom)))
|
|
(dwsh/update-shapes
|
|
[id]
|
|
(fn [shape]
|
|
(-> shape
|
|
(assoc :content content)
|
|
(cond-> (and (not new-shape?)
|
|
content-has-text?
|
|
has-prev-content?)
|
|
(dissoc :prev-content))
|
|
|
|
(cond-> (and (not new-shape?)
|
|
prev-content-has-text?
|
|
(not content-has-text?)
|
|
(not finalize?))
|
|
(assoc :prev-content prev-content))
|
|
|
|
(cond-> (and update-name? (some? name))
|
|
(assoc :name name))))
|
|
{:save-undo? finalize-save-undo-first?
|
|
:stack-undo? effective-stack-undo?
|
|
:undo-group (when new-shape? id)})
|
|
|
|
;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`.
|
|
;; Skipped for a no-op finalize: applying it would record an undo transaction.
|
|
(when (and (some? resize-modifiers) (not finalize-no-op?))
|
|
(dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)})))
|
|
|
|
(when finalize?
|
|
(rx/concat
|
|
(if (and (not content-has-text?) (some? id))
|
|
(rx/concat
|
|
(if (and (some? original-content) (v2-content-has-text? original-content))
|
|
(rx/of
|
|
(dwsh/update-shapes
|
|
[id]
|
|
(fn [s] (-> s (assoc :content original-content) (dissoc :prev-content)))
|
|
{:save-undo? false}))
|
|
(rx/empty))
|
|
(rx/of (dws/deselect-shape id)
|
|
(dwsh/delete-shapes #{id})))
|
|
(rx/empty))
|
|
(rx/concat
|
|
(if (and content-has-text? (not finalize-no-op?))
|
|
(rx/of
|
|
(dch/commit-changes
|
|
(build-finalize-commit-changes it state id
|
|
{:new-shape? new-shape?
|
|
:content-has-text? content-has-text?
|
|
:content content
|
|
;; Undo baseline for the finalize commit: restore the
|
|
;; content as it was right before this commit. For existing
|
|
;; shapes that's `prev-content`; using the (unset, nil)
|
|
;; `original-content` here wiped `:content` to nil on undo,
|
|
;; which emptied the shape and crashed the WASM editor's
|
|
;; select-all on 0 paragraphs. New shapes keep the previous
|
|
;; behavior (their create is bundled in the undo group).
|
|
:original-content (if new-shape? original-content prev-content)
|
|
:update-name? update-name?
|
|
:name name
|
|
:resize-geom resize-geom})))
|
|
(rx/empty))
|
|
(rx/of (dwt/finish-transform)
|
|
(fn [state]
|
|
(-> state
|
|
(update :workspace-new-text-shapes disj id)
|
|
(update :workspace-text-session-geom (fnil dissoc {}) id)))))))))
|
|
|
|
(let [modifiers (get-in state [:workspace-text-modifier id])
|
|
new-shape? (contains? (:workspace-new-text-shapes state) id)]
|
|
(rx/of
|
|
(dwsh/update-shapes [id]
|
|
(fn [shape]
|
|
(let [{:keys [width height position-data]} modifiers]
|
|
(-> shape
|
|
(assoc :content content)
|
|
(cond-> position-data
|
|
(assoc :position-data position-data))
|
|
(cond-> (and update-name? (some? name))
|
|
(assoc :name name))
|
|
(cond-> (or (some? width) (some? height))
|
|
(gsh/transform-shape (ctm/change-size shape width height))))))
|
|
{:undo-group (when new-shape? id)}))))))))
|
|
|
|
(defn v3-sync-editor-content
|
|
"Event pushing the WASM editor content back into the shape, or nil when there is
|
|
nothing to sync. Every text edit commits through it, menu or keystroke alike."
|
|
[& {:keys [finalize?]}]
|
|
(when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)]
|
|
(let [text (txt/content->text content)
|
|
name (when (not= text "")
|
|
(txt/generate-shape-name text))]
|
|
(v2-update-text-shape-content shape-id content
|
|
:update-name? true
|
|
:name name
|
|
:finalize? finalize?))))
|
|
|
|
(defn- sync-editor-content-stream
|
|
"Stream of the sync event for `reason`, after asking WASM to repaint."
|
|
[reason]
|
|
(let [event (v3-sync-editor-content)]
|
|
(wasm.api/request-render-preserving-target reason)
|
|
(if (some? event)
|
|
(rx/of event)
|
|
(rx/empty))))
|
|
|
|
(defn- editor-selected-text
|
|
"Plain text of the current WASM editor selection, or nil when there is none."
|
|
[]
|
|
(when (and (wasm.text-editor/text-editor-has-focus?)
|
|
(wasm.text-editor/text-editor-has-selection?))
|
|
(let [text (wasm.text-editor/text-editor-export-selection)]
|
|
(when (seq text) text))))
|
|
|
|
(defn- write-selection-to-clipboard
|
|
"Write `text` as plain text and HTML; Windows apps often prefer CF_HTML."
|
|
[text]
|
|
(clipboard/to-clipboard-multi {"text/plain" text
|
|
"text/html" (clipboard/plain-text->html text)}))
|
|
|
|
(defn- on-clipboard-error
|
|
[cause]
|
|
(if-let [message (clipboard/error-message cause)]
|
|
(rx/of (ntf/show {:content message
|
|
:type :toast
|
|
:level :warning
|
|
:timeout 5000}))
|
|
(do
|
|
(js/console.error "Clipboard error:" cause)
|
|
(rx/empty))))
|
|
|
|
(defn v3-copy-selection
|
|
"Copy the text editor selection to the system clipboard."
|
|
[]
|
|
(ptk/reify ::v3-copy-selection
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(if-let [text (editor-selected-text)]
|
|
(->> (rx/from (write-selection-to-clipboard text))
|
|
(rx/ignore)
|
|
(rx/catch on-clipboard-error))
|
|
(rx/empty)))))
|
|
|
|
(defn v3-cut-selection
|
|
"Copy the text editor selection to the system clipboard and remove it."
|
|
[]
|
|
(ptk/reify ::v3-cut-selection
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(if-let [text (editor-selected-text)]
|
|
(->> (rx/from (write-selection-to-clipboard text))
|
|
(rx/mapcat (fn [_]
|
|
;; Delete only once the text is safely on the clipboard,
|
|
;; so a refused clipboard cannot lose the selection.
|
|
(wasm.text-editor/text-editor-delete-backward)
|
|
(sync-editor-content-stream "text-cut")))
|
|
(rx/catch on-clipboard-error))
|
|
(rx/empty)))))
|
|
|
|
(defn v3-paste-text
|
|
"Insert the system clipboard text at the caret, replacing the selection."
|
|
[]
|
|
(ptk/reify ::v3-paste-text
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(if-not (wasm.text-editor/text-editor-has-focus?)
|
|
(rx/empty)
|
|
(->> (rx/from (clipboard/read-text))
|
|
(rx/mapcat (fn [text]
|
|
(if (seq text)
|
|
(do
|
|
;; Pasted text keeps the surrounding style.
|
|
(wasm.text-editor/clear-pending-caret-styles!)
|
|
(wasm.text-editor/text-editor-insert-text text)
|
|
(sync-editor-content-stream "text-paste"))
|
|
(rx/empty))))
|
|
(rx/catch on-clipboard-error))))))
|
|
|
|
(defn v3-select-all
|
|
"Select every character of the text being edited."
|
|
[]
|
|
(ptk/reify ::v3-select-all
|
|
ptk/EffectEvent
|
|
(effect [_ _ _]
|
|
(when (wasm.text-editor/text-editor-has-focus?)
|
|
(wasm.text-editor/clear-pending-caret-styles!)
|
|
(wasm.text-editor/text-editor-select-all)
|
|
(wasm.api/render-text-editor-overlay!)))))
|
|
|
|
(defn replace-layer-names-in-shapes
|
|
[ids search replacement]
|
|
(ptk/reify ::replace-layer-names-in-shapes
|
|
ptk/WatchEvent
|
|
(watch [_ _ _]
|
|
(let [undo-group (uuid/next)]
|
|
(rx/of
|
|
(dwsh/update-shapes
|
|
ids
|
|
(fn [shape] (update shape :name txt/replace-all-case-insensitive search replacement))
|
|
{:attrs #{:name} :undo-group undo-group}))))))
|
|
|
|
(defn replace-text-in-shapes
|
|
[ids search replacement]
|
|
(ptk/reify ::replace-text-in-shapes
|
|
ptk/WatchEvent
|
|
(watch [_ state _]
|
|
(let [undo-group (uuid/next)
|
|
update-event
|
|
(dwsh/update-shapes
|
|
ids
|
|
(fn [shape]
|
|
(if (and (= :text (:type shape)) (some? (:content shape)))
|
|
(let [new-content (txt/replace-text-in-content (:content shape) search replacement)
|
|
new-name (txt/generate-shape-name (txt/content->text new-content))]
|
|
(-> shape (assoc :content new-content) (assoc :name new-name)))
|
|
shape))
|
|
{:attrs #{:content :name} :undo-group undo-group})]
|
|
(rx/concat
|
|
(rx/of update-event)
|
|
(if (features/active-feature? state "render-wasm/v1")
|
|
(->> (rx/from ids)
|
|
(rx/map #(dwwt/resize-wasm-text-debounce % {:undo-group undo-group})))
|
|
(rx/empty)))))))
|
|
|
|
;; -- Text Editor v3
|
|
|
|
;; @see texts_v3.cljs
|