From 290df3930d90dc62cafce2f3f7144b7aeeba38e9 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 15 Oct 2026 15:11:34 +0200 Subject: [PATCH] :bug: Keep text names and measured sizes derived The name a text takes from its content and the size it is measured to are derived: they never touch a copy (decisions (d) and (g), Phase 3c). - Let a copy text's automatic name follow its own letters. The editors name a text after its letters on every edit. In a copy that rename went through the same write as the content, so it touched :name-group (F27), against decision (d) of the component contract: only an explicit rename is an override. - update-shapes takes :ignore-touched as a set of attributes too, and the editors' commits (v1 and v3, finalize and keystrokes) and the replace of text pass #{:name}: the name follows the content, so its write is derived. Token unapply still runs for such an update. - Sync gives an untouched copy text the name generated from the letters it shows after the sync when its main's name is the one generated from the main's letters (decision (d), refined), and the main's name otherwise. So a copy that retyped its text keeps that name, and one that did not follows the main's. The pure runner's text edit makes the same commit. Case Y no longer pins F27 and checks the copy's name follows its letters; both golden masters record the copy without :name-group. text-sync-test: an untouched copy takes the main's letters and name, a copy that typed its letters keeps the name they give, and a main named by hand passes its name on. - Name a text with several paragraphs without line breaks. The editors name a text after its letters, with the line breaks of its paragraphs included, so a text "Hello world" + Enter + "Ends Sunday" was named "Hello world\nEnds Sunday"; the layers panel showed a space there (F82). generate-shape-name now turns each line break, with the spaces around it, into one space. text-test: the name of a two-paragraph text joins them with a space. - Keep the size a WASM text grows to from touching the copy. A text that grows with its content (auto width, auto height) gets its size from the WASM renderer after a font loads, a style changes, a paste or an edit ends. That size is derived from the content, yet the resize applied it as a user change, so a copy's text took :geometry-group each time and kept that size against its main (F1). The resize and the debounced resize, the size an edit ends with and its geometry undo now pass ignore-touched. These paths measure the text in WASM, so no headless test reaches them. Checked in the browser with the WASM renderer: an edit of a copy's text touches :content-group only, and a resize after a font size change of 24 grows the text with no :geometry-group. - Keep the finalize resize of a text out of the undo stack. When an edit of an existing text ends, the WASM renderer gives the size it grows to, and the finalize commit stores that size along with its undo. The resize that shows it on the canvas recorded an undo of its own with no undo group: a release build kept a second undo entry for one edit, and a dev build failed the undo entry check (F36). It now records none; a new text still joins the undo group of its creation. Checked in the browser with the WASM renderer: an edit of an existing text adds one undo entry. - Keep the size a text edit ends with from touching the copy. When an edit of a text ends, the editor stores its content together with the size measured for it (the SVG renderer's text modifier, or the WASM measure in the v1 editor). Only the name was written as derived, so an edit of a copy's text touched :geometry-group besides :content-group, and the text kept its size against its main (F1, in the SVG renderer). These writes now take the size, the position data and the name as derived; the content still touches. workspace-text-modifier-test: an edit that ends with a measured width touches the copy text's content only. Checked in the browser with the SVG renderer: an edit of a copy's text touches :content-group and :text-content-text only. - Measure again on WASM the texts a sync writes. The size of a text that grows with its content is derived (decision (g) of the component contract): sync writes the main's, and the renderer measures the text again. The SVG renderer does so when it draws the text; the WASM renderer did not, so a copy text with content of its own kept its main's width and height after a sync (F35). Now, with the WASM renderer, a sync of the current file measures again the auto-grow texts of the current page it writes, without a component sync of its own, in the undo group of the sync; a sync with no undo group gets one for both. Texts on other pages keep the size sync wrote until they are measured again. workspace-sync-wasm-text-test: a sync that writes a copy text with content of its own measures it again, in the undo group of the sync. Checked in the browser with the WASM renderer: after the main text grows or shrinks, two copy texts with content of their own get their own widths and no :geometry-group. Benchmark (lower quantile, ms; baseline / now / ratio): deepest-edit-through-three-levels 35.2 / 18.1 / 0.51 edit-through-a-chain-of-dependents 68.9 / 34.0 / 0.49 library-update-into-copies 83.1 / 40.2 / 0.48 main-edit-among-many-shapes 4.8 / 2.5 / 0.51 main-edit-into-10-copies 1.8 / 0.8 / 0.44 main-edit-into-100-copies 15.4 / 8.5 / 0.55 main-edit-into-1000-copies 153.1 / 83.5 / 0.55 variant-switch-into-copies 32.1 / 18.1 / 0.56 AI-assisted-by: claude-opus-5-5 --- .../src/app/common/files/changes_builder.cljc | 7 +- common/src/app/common/logic/libraries.cljc | 35 +++++++- common/src/app/common/logic/shapes.cljc | 2 +- .../test_helpers/composable/comp/cases.cljc | 15 ++-- .../test_helpers/composable/comp/nodes.cljc | 4 +- common/src/app/common/types/text.cljc | 5 +- .../test/common_tests/logic/golden_master.edn | 4 +- .../common_tests/logic/text_sync_test.cljc | 45 ++++++++++ common/test/common_tests/types/text_test.cljc | 5 ++ .../app/main/data/workspace/libraries.cljs | 32 ++++++- .../src/app/main/data/workspace/texts.cljs | 34 ++++++-- .../app/main/data/workspace/wasm_text.cljs | 6 +- .../composable_tests/golden_master.edn | 4 +- .../data/workspace_sync_wasm_text_test.cljs | 84 +++++++++++++++++++ .../data/workspace_text_modifier_test.cljs | 37 ++++++++ frontend/test/frontend_tests/runner.cljs | 4 + 16 files changed, 292 insertions(+), 31 deletions(-) create mode 100644 frontend/test/frontend_tests/data/workspace_sync_wasm_text_test.cljs create mode 100644 frontend/test/frontend_tests/data/workspace_text_modifier_test.cljs diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index eebae2e2ef..f7e720e5ba 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -585,7 +585,8 @@ (defn update-shapes "Calculate the changes and undos to be done when a function is applied to a - single object" + single object. `ignore-touched` is a boolean, or the set of attributes whose + change on a copy is derived and touches nothing." ([changes ids update-fn] (update-shapes changes ids update-fn nil)) @@ -612,7 +613,9 @@ changed? (conj {:type :set :attr attr :val new-val :ignore-geometry ignore-geometry? - :ignore-touched ignore-touched})) + :ignore-touched (if (set? ignore-touched) + (contains? ignore-touched attr) + ignore-touched)})) uops (cond-> uops diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 215fe4ffcf..b360942448 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -971,6 +971,29 @@ [[] '()] updatable-attrs)) +(defn- automatic-name + "The name the editors give a text after its `content`, or nil when it has + no letters." + [content] + (let [name (txt/generate-shape-name (txt/content->text content))] + (when (not= name "") name))) + +(defn- synced-text-name + "The name an untouched copy text takes from `origin-shape`, its main: the + main's own name, or, when that one follows the main's letters, the name + that follows the letters the copy shows after the sync." + [dest-shape origin-shape touched omit-touched?] + (let [origin-name (:name origin-shape)] + (if (and (some? origin-name) + (= origin-name (automatic-name (:content origin-shape)))) + (let [content (if (and omit-touched? (touched :content-group)) + (txt/merge-overrides (:content dest-shape) + (:content origin-shape) + (txt/text-overrides touched)) + (:content origin-shape))] + (or (automatic-name content) origin-name)) + origin-name))) + (defn- sync-attr-operations "The `[attr value]` pairs that sync `attr` from `origin-shape`, a main shape placed like `dest-shape`, into `dest-shape`, its copy. With @@ -1003,8 +1026,15 @@ (= :content attr) (touched sync-group)) + ;; the name of a text may follow its letters + text-name? + (and (cfh/text-shape? origin-shape) + (cfh/text-shape? dest-shape) + (= :name attr)) + skip? - (or (= (get origin-shape attr) (get dest-shape attr)) + (or (and (not text-name?) + (= (get origin-shape attr) (get dest-shape attr))) (and (touched sync-group) omit-touched? ;; When it is a text-partial-change, we should generate operations @@ -1025,6 +1055,9 @@ (:content origin-shape) (txt/text-overrides touched)) + text-name? + (synced-text-name dest-shape origin-shape touched omit-touched?) + :else (get origin-shape attr)))] diff --git a/common/src/app/common/logic/shapes.cljc b/common/src/app/common/logic/shapes.cljc index 31fc414f54..2b9c697ac1 100644 --- a/common/src/app/common/logic/shapes.cljc +++ b/common/src/app/common/logic/shapes.cljc @@ -106,7 +106,7 @@ (-> (pcb/update-shapes grid-ids ctl/assign-cell-positions {:with-objects? true}) (pcb/reorder-grid-children ids)) - (not ignore-touched) + (not (true? ignore-touched)) (generate-unapply-tokens objects changed-sub-attr changed-item-index))] changes)) diff --git a/common/src/app/common/test_helpers/composable/comp/cases.cljc b/common/src/app/common/test_helpers/composable/comp/cases.cljc index 8a289afc91..89a47abd2c 100644 --- a/common/src/app/common/test_helpers/composable/comp/cases.cljc +++ b/common/src/app/common/test_helpers/composable/comp/cases.cljc @@ -37,7 +37,7 @@ W — one undo node undoes several steps over nested cascades. X — an undo committed before the edit's sync leaves copies unchanged (frontend only). - AJ — one undo after a nested sync reverts only the reflow (F43, frontend + AJ — one undo after a nested sync reverts the change and its reflow (frontend only). Y — a text override merges with a main text change per subgroup. Z — a move of a main child reaches the copies (frontend only). @@ -85,6 +85,7 @@ [app.common.types.components-list :as ctkl] [app.common.types.shape-tree :as ctst] [app.common.types.shape.layout :as ctl] + [app.common.types.text :as txt] [app.common.uuid :as uuid] [clojure.test :as t] [clojure.walk :as walk])) @@ -653,7 +654,8 @@ the same for the font size. An edited copy has `:content-group` and the group of its edit touched; an unedited one has nothing touched. The text starts with the name generated from its content, so an edit of its characters renames - it, an automatic rename that must not touch `:name-group` (decision (d))." + it, an automatic rename that must not touch `:name-group` (decision (d)), + and the copy's name keeps following the letters it shows." [] (let [copy-edit (tm/one-of [(n/edit-text :copy-instance :text "copy text") (n/edit-text :copy-instance :font-size "32")]) @@ -667,6 +669,8 @@ main (setup/main-instance s) edited (tm/get-choice s copy-edit)] (t/is (n/has-property-of (tm/get-choice s main-edit) main)) + (t/is (= (txt/generate-shape-name (txt/content->text (:content copy))) + (:name copy))) (doseq [edit [:text :font-size]] (t/is (= (if (= edit (:edit edited)) (:value edited) @@ -677,13 +681,6 @@ (nil? edited) (t/is (empty? (:touched copy))) - ;; the automatic rename touches the name - (= :text (:edit edited)) - (tm/known-failure "F27" - #{:content-group :text-content-text} - #{:content-group :text-content-text :name-group} - (:touched copy)) - :else (t/is (= #{:content-group (subgroup (:edit edited))} (:touched copy))))))})) diff --git a/common/src/app/common/test_helpers/composable/comp/nodes.cljc b/common/src/app/common/test_helpers/composable/comp/nodes.cljc index e013e12a28..6810186388 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -254,11 +254,13 @@ (let [the-file (tm/file situation) shape-id (tm/target-shape-id situation target) page (thf/current-page the-file) + ;; as the editor's commit, the name that follows the content is a + ;; derived write changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) #{shape-id} #(edit-text-content % edit value) (:objects page) - {}) + {:ignore-touched #{:name}}) file' (thf/apply-changes the-file changes)] (-> situation (tm/with-file file') diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 4aa1d5e29f..b85f6b10a5 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -242,8 +242,11 @@ (update :content transform)))) (defn generate-shape-name + "The name of a text whose letters are `text`: its first 280 characters, + each line break a space." [text] - (subs text 0 (min 280 (count text)))) + (let [text (str/replace text #"\s*\n\s*" " ")] + (subs text 0 (min 280 (count text))))) (defn- compare-text-attr "Compare two attribute values and return true if they are different. diff --git a/common/test/common_tests/logic/golden_master.edn b/common/test/common_tests/logic/golden_master.edn index 0c788be712..ae0d47f1e8 100644 --- a/common/test/common_tests/logic/golden_master.edn +++ b/common/test/common_tests/logic/golden_master.edn @@ -185,8 +185,8 @@ {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 0, :outer sync-main-outercomp-0}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 6}", :states ["1de51816cdc0ad11" "ed2117b3a6ae1dd6" "c90876e1b44d00fb" "a5b1e4b00086c891" "f09bed0fa67d711c"]} {:transcript "create-component {:component \"main\", :id sync-main-component}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n make-nested-component {:component \"main\", :level 1, :outer sync-main-outercomp-1}\n instantiate-copy {:component \"main\", :copy sync-main-copyhead-0}\n change-property {:target #fn, :property :fills, :value \"#0000ff\"}\n change-property {:target #fn, :property :fills, :value \"#00ff00\"}\n change-property {:target #fn, :property :fills, :value \"#aaaaaa\"}\n undo {:undone 7}", :states ["262f632dd4a0ac41" "af99191229bac7b4" "8ba7cefbe6e101d8" "ab0a68e1af334a44" "987979e4d37001b6"]}] :y - [{:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :text, :value \"main text\"}", :states ["8cd35014bebff5a0" "260d9897b862470e" "01f96b240a9f7786"]} - {:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["8cd35014bebff5a0" "9a2bfd72cc5a6898" "8df2236d989a127c"]} + [{:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :text, :value \"main text\"}", :states ["73a1f9665fb9d817" "c6b6d7c691dfcaa8" "728400277dd671c4"]} + {:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :text, :value \"copy text\"}\n edit-text {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["73a1f9665fb9d817" "85eac5dd891c03b4" "3eaf765ba42c63d1"]} {:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :font-size, :value \"32\"}\n edit-text {:target :main-instance, :edit :text, :value \"main text\"}", :states ["c0b1a5e708231296" "75e32d376c1957f9" "b1213922be7ac4cb"]} {:transcript "one-of -> recorded-choice\n edit-text {:target :copy-instance, :edit :font-size, :value \"32\"}\n edit-text {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["c0b1a5e708231296" "8eaab011168c2a4b" "14063d206179c049"]} {:transcript "one-of -> skip\n edit-text {:target :main-instance, :edit :text, :value \"main text\"}", :states ["6c385dbccb923ae0" "f357affeab5963f4"]} diff --git a/common/test/common_tests/logic/text_sync_test.cljc b/common/test/common_tests/logic/text_sync_test.cljc index 32296284fa..6b325d46c6 100644 --- a/common/test/common_tests/logic/text_sync_test.cljc +++ b/common/test/common_tests/logic/text_sync_test.cljc @@ -15,6 +15,7 @@ [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.texts :as thtx] [app.common.types.text :as txt] [clojure.test :as t])) @@ -933,3 +934,47 @@ (t/is (= "copy text" (:text line))) (t/is (= "32" (:font-size line))) (t/is (= #{:content-group :text-content-text} (:touched copy-child'))))) + +(defn- type-letters + "The editor's commit of `text` (in the notation of `thtx`) into the text + `label`: the content, and the name that follows it, a derived write." + [file label text] + (let [page (thf/current-page file) + content (thtx/content text) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(thi/id label)} + #(assoc % :content content + :name (txt/generate-shape-name (txt/content->text content))) + (:objects page) + {:ignore-touched #{:name}})] + (thf/apply-changes file changes))) + +(defn- sync-component1 + [file] + (thf/apply-changes file (cll/generate-sync-file-changes (pcb/empty-changes) nil :components + (:id file) (thi/id :component1) (:id file) + {(:id file) file} (:id file)))) + +(t/deftest the-automatic-name-of-a-copy-text-follows-its-letters + (let [file (-> (thf/sample-file :file1) + (thtx/add-text-component-with-copy [[["hello world"]]]) + (type-letters :main-child [[["hello world"]]]) + (sync-component1))] + (t/testing "an untouched copy takes the main's letters and name" + (let [file' (-> file (type-letters :main-child [[["main text"]]]) (sync-component1))] + (t/is (= "main text" (:name (ths/get-shape file' :copy-child)))) + (t/is (nil? (:touched (ths/get-shape file' :copy-child)))))) + (t/testing "a copy that typed its letters keeps the name they give" + (let [file' (-> file + (type-letters :copy-child [[["copy text"]]]) + (type-letters :main-child [[["main text"]]]) + (sync-component1)) + copy (ths/get-shape file' :copy-child)] + (t/is (= "copy text" (:name copy))) + (t/is (= #{:content-group :text-content-text} (:touched copy))))) + (t/testing "a main named by hand passes its name on" + (let [file' (-> file + (ths/update-shape :main-child :name "Title") + (sync-component1))] + (t/is (= "Title" (:name (ths/get-shape file' :copy-child)))))))) + diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index ace36f0c4d..ad61648a4f 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -368,3 +368,8 @@ (t/is (every? #(= % "14") original-font-sizes)) (t/is (every? #(= % "32") updated-font-sizes)))) + +(t/deftest the-name-of-a-text-with-several-paragraphs + (t/is (= "Hello world Ends Sunday" (cttx/generate-shape-name "Hello world\nEnds Sunday"))) + (t/is (= "One Two" (cttx/generate-shape-name "One \n\n Two"))) + (t/is (= 280 (count (cttx/generate-shape-name (apply str (repeat 300 "a"))))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 1894ee4dee..ef579e7de8 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1070,13 +1070,33 @@ (assoc state :updating-library true) (dissoc state :updating-library))))) +(defn- synced-text-ids + "The texts of the current page that grow with their content and that + `changes` write: their size comes from their main, so the WASM renderer + measures them again (the SVG renderer does when it draws them)." + [state changes] + (when (features/active-feature? state "render-wasm/v1") + (let [page-id (:current-page-id state) + objects (dsh/lookup-page-objects state page-id)] + (into [] + (comp (filter #(and (= :mod-obj (:type %)) (= page-id (:page-id %)))) + (map :id) + (distinct) + (filter #(let [shape (get objects %)] + (and (cfh/text-shape? shape) + (not= :fixed (:grow-type shape)))))) + (:redo-changes changes))))) + (defn- sync-file-frontend-events - [file-id changes updated-frames undo-group] + [file-id changes updated-frames text-ids undo-group] (rx/concat (rx/of (set-updating-library false) (ntf/hide {:tag :sync-dialog})) (when (seq (:redo-changes changes)) (rx/of (dch/commit-changes changes))) + (when (seq text-ids) + (rx/of (dwwt/resize-wasm-text-all text-ids {:skip-component-sync? true + :undo-group undo-group}))) (when-not (empty? updated-frames) (let [frames-by-page (group-by :page-id updated-frames)] (rx/merge @@ -1159,9 +1179,17 @@ pending-ids (sync-file-pending-ids file-id changes) + text-ids (when (= file-id current-file-id) + (synced-text-ids state changes)) + + ;; the texts measured again join the undo of the sync + undo-group (or undo-group + (when (seq text-ids) (uuid/next))) + changes (pcb/set-undo-group changes undo-group) + frontend-sync (sync-file-frontend-events - file-id changes updated-frames undo-group)] + file-id changes updated-frames text-ids undo-group)] (log/debug :msg "SYNC-FILE finished" :js/rchanges (log-changes (:redo-changes changes) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index bc077f92c4..49c3039d3c 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -297,6 +297,11 @@ (update :workspace-editor-state dissoc id) (update :workspace-new-text-shapes disj id)))))) +(def ^:private derived-attrs + "The attributes an edit of a text writes that follow its content: its name + and the size it grows to." + #{:name :selrect :points :x :y :width :height :transform :transform-inverse :position-data}) + (defn finalize-editor-state [id update-name?] (ptk/reify ::finalize-editor-state @@ -332,7 +337,8 @@ (cond-> (some? new-size) (gsh/transform-shape (ctm/change-size shape (:width new-size) (:height new-size)))))) - {:undo-group (when new-shape? id)}))))) + {:undo-group (when new-shape? id) + :ignore-touched derived-attrs}))))) (let [content (d/merge (ted/export-content content) (dissoc (:content shape) :children)) @@ -355,7 +361,8 @@ (assoc :name name)) (cond-> (or (some? width) (some? height)) (gsh/transform-shape (ctm/change-size shape width height)))))) - {:undo-group (when new-shape? id)})))))) + {:undo-group (when new-shape? id) + :ignore-touched derived-attrs})))))) (when (some? id) (rx/of (dws/deselect-shape id) @@ -1270,7 +1277,8 @@ 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})))) + ;; the size a text grows to is derived: it touches nothing + (pcb/update-shapes [id] final-shape-fn {:attrs attrs :ignore-touched true})))) (defn- build-finalize-commit-changes "Builds the commit changes for text finalization (content + geometry undo). @@ -1286,7 +1294,8 @@ (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]})) + ;; the name follows the content: a derived write + (pcb/update-shapes [id] (fn [s] (assoc s :name name)) {:attrs [:name] :ignore-touched true})) (cond-> new-shape? (-> (pcb/set-undo-group id) (pcb/set-stack-undo? true)))) @@ -1396,12 +1405,19 @@ (assoc :name name)))) {:save-undo? finalize-save-undo-first? :stack-undo? effective-stack-undo? - :undo-group (when new-shape? id)}) + :undo-group (when new-shape? id) + ;; the name follows the content: a derived write + :ignore-touched #{:name}}) ;; 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. + ;; An existing text's finalize commit holds the undo of that geometry, so this + ;; one records none. The size a text grows to is derived: it touches nothing. (when (and (some? resize-modifiers) (not finalize-no-op?)) - (dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)}))) + (dwm/apply-wasm-modifiers resize-modifiers + (if new-shape? + {:undo-group id :ignore-touched true} + {:save-undo? false :ignore-touched true})))) (when finalize? (rx/concat @@ -1457,7 +1473,8 @@ (assoc :name name)) (cond-> (or (some? width) (some? height)) (gsh/transform-shape (ctm/change-size shape width height)))))) - {:undo-group (when new-shape? id)})))))))) + {:undo-group (when new-shape? id) + :ignore-touched derived-attrs})))))))) (defn- editor-content-update "Event saving `content` from the WASM editor, renaming the shape after its text." @@ -1862,7 +1879,8 @@ 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})] + ;; the name follows the content: a derived write + {:attrs #{:content :name} :undo-group undo-group :ignore-touched #{:name}})] (rx/concat (rx/of update-event) (if (features/active-feature? state "render-wasm/v1") diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 9ff79af325..555dd3681b 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -92,7 +92,8 @@ (watch [_ state _] (let [objects (dsh/lookup-page-objects state) shape (get objects id) - apply-opts (or opts {}) + ;; the size a text grows to is derived: it touches nothing + apply-opts (assoc opts :ignore-touched true) resize-stream (if (and (some? shape) (cfh/text-shape? shape) @@ -132,7 +133,8 @@ ;; When undo-id is present, extend the current undo transaction instead of ;; creating a new one, and commit it after the resize (single undo action). extend-tx? (some? undo-id) - apply-opts (cond-> {} + ;; the size a text grows to is derived: it touches nothing + apply-opts (cond-> {:ignore-touched true} (some? undo-group) (assoc :undo-group undo-group) extend-tx? (assoc :undo-transation? false) skip-component-sync? (assoc :skip-component-sync? true))] diff --git a/frontend/test/frontend_tests/composable_tests/golden_master.edn b/frontend/test/frontend_tests/composable_tests/golden_master.edn index 26259fbe2d..09adef69e1 100644 --- a/frontend/test/frontend_tests/composable_tests/golden_master.edn +++ b/frontend/test/frontend_tests/composable_tests/golden_master.edn @@ -207,8 +207,8 @@ :case-x-undo-before-sync-leaves-copies-unchanged [{:transcript " {:target :main-instance, :property :fills, :value \"#ff0000\"}", :states ["734ec524d88eb6bb" "ff1fa863b88027e5"]}] :case-y-text-override-merges-with-main-text-change - [{:transcript "one-of -> \n {:target :copy-instance, :edit :text, :value \"copy text\"}\n {:target :main-instance, :edit :text, :value \"main text\"}", :states ["1fdda10ff2de93bb" "9caff776f882850b" "cc39679f8f901c8e"]} - {:transcript "one-of -> \n {:target :copy-instance, :edit :text, :value \"copy text\"}\n {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["1fdda10ff2de93bb" "b44573ca4817a1c2" "805af3e9523db814"]} + [{:transcript "one-of -> \n {:target :copy-instance, :edit :text, :value \"copy text\"}\n {:target :main-instance, :edit :text, :value \"main text\"}", :states ["7ea87003598e5626" "f823f8147e941701" "3a6ecb19a9315cb8"]} + {:transcript "one-of -> \n {:target :copy-instance, :edit :text, :value \"copy text\"}\n {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["7ea87003598e5626" "bc06b54fa303bde9" "230d1c77384fc557"]} {:transcript "one-of -> \n {:target :copy-instance, :edit :font-size, :value \"32\"}\n {:target :main-instance, :edit :text, :value \"main text\"}", :states ["c5ad2b1e98811951" "ed2ffe7c84c65ffa" "50166a4e5025f6cb"]} {:transcript "one-of -> \n {:target :copy-instance, :edit :font-size, :value \"32\"}\n {:target :main-instance, :edit :font-size, :value \"24\"}", :states ["c5ad2b1e98811951" "c75ab24b318795d7" "8896671645bb0a65"]} {:transcript "one-of -> \n {:target :main-instance, :edit :text, :value \"main text\"}", :states ["dd9f23b4112dddae" "3bd3720125090159"]} diff --git a/frontend/test/frontend_tests/data/workspace_sync_wasm_text_test.cljs b/frontend/test/frontend_tests/data/workspace_sync_wasm_text_test.cljs new file mode 100644 index 0000000000..9f7f6b6467 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_sync_wasm_text_test.cljs @@ -0,0 +1,84 @@ +;; 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 frontend-tests.data.workspace-sync-wasm-text-test + "With the WASM renderer, the size of a text that grows with its content + is measured again after a sync writes it, as the SVG renderer does when + it draws the text: a copy text with content of its own keeps its own + size." + (:require + [app.common.files.changes-builder :as pcb] + [app.common.geom.shapes :as gsh] + [app.common.logic.shapes :as cls] + [app.common.test-helpers.components :as cthc] + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.texts :as ctht] + [app.common.types.modifiers :as ctm] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.wasm-text :as dwwt] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(def ^:private orig-resize-wasm-text-all dwwt/resize-wasm-text-all) + +(def ^:private resize-calls (atom [])) + +(defn- mock-resize-wasm-text-all + ([ids] (mock-resize-wasm-text-all ids nil)) + ([ids opts] + (swap! resize-calls conj {:ids (set ids) :opts opts}) + nil)) + +(t/use-fixtures :each + {:before (fn [] + (cthi/reset-idmap!) + (reset! resize-calls []) + (set! dwwt/resize-wasm-text-all mock-resize-wasm-text-all)) + :after (fn [] + (set! dwwt/resize-wasm-text-all orig-resize-wasm-text-all))}) + +(defn- resize + "Resize the shape `label` of `file` to `width`, as a measure of its text + would." + [file label width] + (let [page (cthf/current-page file) + changes (cls/generate-update-shapes + (pcb/empty-changes nil (:id page)) + #{(cthi/id label)} + #(gsh/transform-shape % (ctm/change-size % width (:height %))) + (:objects page) + {:ignore-touched true})] + (cthf/apply-changes file changes))) + +(defn- setup-file + "A component with a text and a copy whose text has content of its own; + the main text then grows." + [] + (-> (cthf/sample-file :file1) + (ctho/add-frame-with-text :main-root :main-text "hello") + (cthc/make-component :component1 :main-root) + (cthc/instantiate-component :component1 :copy-root + :children-labels [:copy-text]) + (ctht/set-text :copy-text [[["a longer text of its own"]]]) + (resize :main-text 200))) + +(t/deftest a-sync-measures-again-the-copy-texts-it-writes + (t/async + done + (let [file (setup-file) + store (ths/setup-store file) + undo-group (random-uuid)] + (ths/run-store + store done + [(dwl/sync-file (:id file) (:id file) :components (cthi/id :component1) undo-group)] + (fn [_] + (t/is (= 1 (count @resize-calls))) + (let [{:keys [ids opts]} (first @resize-calls)] + (t/is (contains? ids (cthi/id :copy-text))) + (t/is (:skip-component-sync? opts)) + (t/is (= undo-group (:undo-group opts))))))))) diff --git a/frontend/test/frontend_tests/data/workspace_text_modifier_test.cljs b/frontend/test/frontend_tests/data/workspace_text_modifier_test.cljs new file mode 100644 index 0000000000..5e3d381c20 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_text_modifier_test.cljs @@ -0,0 +1,37 @@ +;; 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 frontend-tests.data.workspace-text-modifier-test + "The size the SVG renderer measures for a text while it is edited, which + the edit stores when it ends: it follows the content, so it touches + nothing." + (:require + [app.common.test-helpers.composable.comp.setups :as setup] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.test-helpers.texts :as tht] + [app.main.data.workspace.texts :as dwt] + [cljs.test :as t :include-macros true] + [frontend-tests.composable-tests.interpreter :as ftm])) + +(t/use-fixtures :each ftm/fixture) + +(defn- measure + "Stands in for the DOM measurement of the text `id`." + [id size] + (fn [state] + (assoc-in state [:workspace-text-modifier id] size))) + +(t/deftest ^:async an-edit-of-a-copy-text-touches-its-content-only + (ftm/install! (setup/simple-text-component-with-copy)) + (let [id (thi/id :copy-text)] + (await (ftm/await-step [(measure id {:width 140}) + (dwt/v2-update-text-shape-content id (tht/content [[["hello there"]]]))])) + (let [text (ths/get-shape (ftm/current-file) :copy-text)] + (t/is (= [[["hello there"]]] (tht/text-of (ftm/current-file) :copy-text))) + (t/is (= 140 (:width text))) + (t/is (contains? (:touched text) :content-group)) + (t/is (not (contains? (:touched text) :geometry-group)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 6fcb10a101..2f1e0b5e83 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -46,6 +46,8 @@ [frontend-tests.data.workspace-reflow-test] [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-sync-barrier-test] + [frontend-tests.data.workspace-sync-wasm-text-test] + [frontend-tests.data.workspace-text-modifier-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] [frontend-tests.data.workspace-undo-test] @@ -216,6 +218,7 @@ 'frontend-tests.data.workspace-interactions-test 'frontend-tests.data.workspace-font-variants-test 'frontend-tests.data.workspace-frame-guides-undo-test + 'frontend-tests.data.workspace-text-modifier-test 'frontend-tests.data.workspace-libraries-test 'frontend-tests.data.workspace-library-links-test 'frontend-tests.data.workspace-library-updates-test @@ -226,6 +229,7 @@ 'frontend-tests.data.workspace-reflow-test 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-sync-barrier-test + 'frontend-tests.data.workspace-sync-wasm-text-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test 'frontend-tests.data.workspace-undo-test