2026-10-02 16:38:29 +02:00

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