diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 147df1d1cc..28174ba84f 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -1874,6 +1874,110 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0025-repair-empty-text-content" + ;; Repair text shapes whose :content tree has empty/missing :children + ;; at any of the three levels: + ;; Level 1: root with no paragraph-set + ;; Level 2: paragraph-set with no paragraph + ;; Level 3: paragraph with no span + ;; Such shapes fail the backend `validate-shape` schema and would also + ;; break the v2 editor's `cljs->dom` roundtrip. Re-seed the canonical + ;; root -> paragraph-set -> paragraph -> span tree, preserving the + ;; original root-level attributes (e.g. :vertical-align) when present. + ;; Idempotent on healthy content. + [data _] + (let [default-span {:text "" :fills types.text/default-text-fills} + default-paragraph {:type "paragraph" :children [default-span]} + default-paragraph-set {:type "paragraph-set" :children [default-paragraph]} + + ;; Level 3: repair paragraph with empty/missing children + repair-span (fn [span] + (if (and (map? span) + (string? (:text span))) + span + default-span)) + + repair-paragraph (fn [paragraph] + (if (and (map? paragraph) + (= "paragraph" (:type paragraph))) + (cond + ;; Children is nil or empty vector - seed with default span + (or (nil? (:children paragraph)) + (and (vector? (:children paragraph)) + (empty? (:children paragraph)))) + (assoc paragraph :children [default-span]) + + ;; Children is a vector - repair any invalid spans + (vector? (:children paragraph)) + (update paragraph :children + (fn [children] + (mapv repair-span children))) + + ;; Children is not a vector - replace with default + :else + (assoc paragraph :children [default-span])) + default-paragraph)) + + ;; Level 2: repair paragraph-set with empty/missing children + repair-paragraph-set (fn [paragraph-set] + (if (and (map? paragraph-set) + (= "paragraph-set" (:type paragraph-set))) + (cond + ;; Children is nil or empty vector - seed with default paragraph + (or (nil? (:children paragraph-set)) + (and (vector? (:children paragraph-set)) + (empty? (:children paragraph-set)))) + (assoc paragraph-set :children [default-paragraph]) + + ;; Children is a vector - repair any invalid paragraphs + (vector? (:children paragraph-set)) + (update paragraph-set :children + (fn [children] + (mapv repair-paragraph children))) + + ;; Children is not a vector - replace with default + :else + (assoc paragraph-set :children [default-paragraph])) + default-paragraph-set)) + + ;; Repair content at all levels, handling all edge cases + repair-content (fn [content] + (cond + ;; Content is not a valid root map - create default + ;; Preserve root-level attrs if content is a map + (or (nil? content) + (not (map? content)) + (not= "root" (:type content))) + (merge types.text/default-root-attrs + {:type "root" + :children [default-paragraph-set]} + (when (map? content) + (select-keys content types.text/root-attrs))) + + ;; Content is a valid root - repair all levels + :else + (let [children (if (and (vector? (:children content)) + (seq (:children content))) + (:children content) + [default-paragraph-set])] + (merge types.text/default-root-attrs + {:type "root"} + (select-keys content types.text/root-attrs) + {:children (mapv repair-paragraph-set children)})))) + + ;; Simplified gatekeeper - just check if it's a text shape + fix-shape (fn [shape] + (if (cfh/text-shape? shape) + (update shape :content repair-content) + shape)) + + update-container (fn [container] + (d/update-when container :objects d/update-vals fix-shape))] + + (-> data + (update :pages-index d/update-vals update-container) + (d/update-when :components d/update-vals update-container)))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -1955,4 +2059,5 @@ "0021-fix-shape-svg-attrs" "0022-normalize-component-root-and-resync" "0023-repair-token-themes-with-inexistent-sets" - "0024b-fix-stroke-cap-placement"])) + "0024b-fix-stroke-cap-placement" + "0025-repair-empty-text-content"])) diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index c5211e8a4a..8c0595daa6 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -20,44 +20,42 @@ [:type [:= "root"]] [:key {:optional true} :string] [:children - {:optional true} - [:maybe - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:type [:= "paragraph-set"]] - [:key {:optional true} :string] - [:children - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:type [:= "paragraph"]] - [:key {:optional true} :string] - [:fills {:optional true} - [:maybe schema:fills]] - [:font-family {:optional true} ::sm/text] - [:font-size {:optional true} ::sm/text] - [:font-style {:optional true} ::sm/text] - [:font-weight {:optional true} ::sm/text] - [:direction {:optional true} ::sm/text] - [:text-decoration {:optional true} ::sm/text] - [:text-transform {:optional true} ::sm/text] - [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] - [:typography-ref-file {:optional true} [:maybe ::sm/uuid]] - [:children - [:vector {:min 1 :gen/max 2 :gen/min 1} - [:map - [:text :string] - [:key {:optional true} :string] - [:fills {:optional true} - [:maybe schema:fills]] - [:font-family {:optional true} ::sm/text] - [:font-size {:optional true} ::sm/text] - [:font-style {:optional true} ::sm/text] - [:font-weight {:optional true} ::sm/text] - [:direction {:optional true} ::sm/text] - [:text-decoration {:optional true} ::sm/text] - [:text-transform {:optional true} ::sm/text] - [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] - [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]]) + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:type [:= "paragraph-set"]] + [:key {:optional true} :string] + [:children + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:type [:= "paragraph"]] + [:key {:optional true} :string] + [:fills {:optional true} + [:maybe schema:fills]] + [:font-family {:optional true} ::sm/text] + [:font-size {:optional true} ::sm/text] + [:font-style {:optional true} ::sm/text] + [:font-weight {:optional true} ::sm/text] + [:direction {:optional true} ::sm/text] + [:text-decoration {:optional true} ::sm/text] + [:text-transform {:optional true} ::sm/text] + [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] + [:typography-ref-file {:optional true} [:maybe ::sm/uuid]] + [:children + [:vector {:min 1 :gen/max 2 :gen/min 1} + [:map + [:text :string] + [:key {:optional true} :string] + [:fills {:optional true} + [:maybe schema:fills]] + [:font-family {:optional true} ::sm/text] + [:font-size {:optional true} ::sm/text] + [:font-style {:optional true} ::sm/text] + [:font-weight {:optional true} ::sm/text] + [:direction {:optional true} ::sm/text] + [:text-decoration {:optional true} ::sm/text] + [:text-transform {:optional true} ::sm/text] + [:typography-ref-id {:optional true} [:maybe ::sm/uuid]] + [:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]) (def valid-content? (sm/lazy-validator schema:content)) diff --git a/common/test/common_tests/files_changes_test.cljc b/common/test/common_tests/files_changes_test.cljc index 3cbc475000..7671f7a787 100644 --- a/common/test/common_tests/files_changes_test.cljc +++ b/common/test/common_tests/files_changes_test.cljc @@ -912,3 +912,4 @@ (nil? (get-in result2 [:pages-index page-id :default-grids]))))) {:num 1000}))) + diff --git a/common/test/common_tests/files_migrations_0025_test.cljc b/common/test/common_tests/files_migrations_0025_test.cljc new file mode 100644 index 0000000000..4d57398953 --- /dev/null +++ b/common/test/common_tests/files_migrations_0025_test.cljc @@ -0,0 +1,833 @@ +;; 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 INC Sucursal en España SL + +(ns common-tests.files-migrations-0025-test + (:require + [app.common.files.migrations :as cfm] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; 0025-repair-empty-text-content +;; Text shapes whose :content is a root with an empty/missing :children +;; vector used to slip past the schema (children was optional). With the +;; schema tightening those shapes must be repaired on next load. +(defn- make-text-shape-with-content + "Build a text shape with arbitrary content structure" + [shape-id content] + (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content content))) + +(defn- make-broken-text-shape + "Build a fully-initialised text shape with a broken :content and the + supplied root-level attrs overlaid on it." + [shape-id root-attrs] + (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content (merge {:type "root"} + (when (seq root-attrs) root-attrs) + {:children []})))) + +(t/deftest migration-0025-repair-empty-text-content-empty-children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-broken-text-shape shape-id {:vertical-align "top"})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= 1 (count (:children content))) "exactly one paragraph-set seeded") + (t/is (= "paragraph-set" (get-in content [:children 0 :type]))) + (t/is (pos? (count (get-in content [:children 0 :children]))) + "paragraph-set has at least one paragraph") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) + "seeded span has empty text") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-missing-children + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A text shape whose :content has no :children key at all. + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "center"}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (:children content)) "missing children becomes a vector") + (t/is (pos? (count (:children content))) "missing children gets a paragraph-set") + (t/is (= "center" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-no-content + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A text shape with no :content at all. Should be repaired with default content. + data {:pages-index + {page-id + {:objects + {shape-id (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-repair-empty-text-content-idempotent + (let [shape-id (uuid/next) + page-id (uuid/next) + ;; A healthy text shape with a proper paragraph-set/paragraph/ + ;; span tree. The migration must leave it untouched. + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "hello"}]}]}]}))}}}} + original (get-in data [:pages-index page-id :objects shape-id]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (cts/valid-shape? original) "baseline shape is valid") + (t/is (= original shape') "healthy content is unchanged"))) + +(t/deftest migration-0025-repair-empty-text-content-component + ;; The migration also walks :components, so a broken text inside a + ;; component is also repaired. + (let [shape-id (uuid/next) + comp-id (uuid/next) + data {:components + {comp-id + {:objects + {shape-id (make-broken-text-shape shape-id nil)}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:components comp-id :objects shape-id])] + + (t/is (cts/valid-shape? shape) "repaired component shape is valid") + (t/is (pos? (count (get-in shape [:content :children]))) + "children vector is no longer empty"))) + +(t/deftest migration-0025-repair-empty-text-content-level2 + ;; Level 2: paragraph-set with empty/missing children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children []}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "paragraph-set preserved") + (t/is (pos? (count (get-in content [:children 0 :children]))) + "paragraph-set now has at least one paragraph") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) + "seeded child is a paragraph") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-level3 + ;; Level 3: paragraph with empty/missing children + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children []}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "paragraph preserved") + (t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) + "paragraph now has at least one span") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) + "seeded span has empty text") + (t/is (= "top" (:vertical-align content)) + "preserves pre-existing :vertical-align"))) + +(t/deftest migration-0025-repair-empty-text-content-mixed-levels + ;; Valid level 1, but broken at levels 2 and 3 in different paragraph-sets + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph-set" + :children []} + {:type "paragraph-set" + :children [{:type "paragraph" + :children []}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 2 (count (:children content))) "both paragraph-sets preserved") + ;; First paragraph-set had empty children (level 2 broken) + (t/is (pos? (count (get-in content [:children 0 :children]))) + "first paragraph-set now has paragraphs") + ;; Second paragraph-set had a paragraph with empty children (level 3 broken) + (t/is (pos? (count (get-in content [:children 1 :children 0 :children]))) + "second paragraph's paragraph now has spans"))) + +;; ============================================================================ +;; Category A: Shape-level guards (fix-shape) +;; ============================================================================ + +(t/deftest migration-0025-non-text-shape-untouched + ;; A: Non-text shape should not be processed + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0})}}}} + original (get-in data [:pages-index page-id :objects shape-id]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id])] + + (t/is (= original shape') "non-text shape is unchanged"))) + +(t/deftest migration-0025-text-shape-non-map-content-repaired + ;; A: Text shape with non-map content should be repaired + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content "not a map"))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-wrong-root-type-repaired + ;; A: Text shape with content :type not "root" should be repaired, preserving root-level attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "paragraph" + :vertical-align "center" + :children []}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (= "center" (:vertical-align content)) "root-level attrs preserved") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-nil-content + ;; I: Text shape with :content nil should be repaired with default content + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content nil))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (map? content) "content is now a map") + (t/is (= "root" (:type content)) "content has root type") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-empty-map-content + ;; I: Text shape with :content {} (empty map) should be repaired with default content + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-wrong-type-with-root-attrs + ;; I: Text shape with wrong type but valid root-level attrs should preserve attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "paragraph" + :vertical-align "bottom" + :children []}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "type is now root") + (t/is (= "bottom" (:vertical-align content)) "root-level attrs preserved") + (t/is (vector? (:children content)) "children is a vector") + (t/is (pos? (count (:children content))) "has at least one paragraph-set"))) + +(t/deftest migration-0025-text-shape-partial-salvage-paragraphs-under-root + ;; K: Root has children but they're paragraphs (not paragraph-sets) - should preserve level 1 attrs + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width}) + (assoc :content {:type "root" + :vertical-align "top" + :children [{:type "paragraph" + :children [{:text "hello"}]}]}))}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (= "top" (:vertical-align content)) "root-level attrs preserved") + (t/is (= 1 (count (:children content))) "has one paragraph-set") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "child is paragraph-set") + (t/is (pos? (count (get-in content [:children 0 :children]))) "paragraph-set has paragraphs"))) + +;; ============================================================================ +;; Category B: Level 1 (root) variants +;; ============================================================================ + +(t/deftest migration-0025-root-non-vector-children-map + ;; B: Root with non-vector children (map) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "top" + :children {:invalid "map"}})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "root" (:type content)) "root type preserved") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= "top" (:vertical-align content)) "preserves vertical-align"))) + +(t/deftest migration-0025-root-non-vector-children-string + ;; B: Root with non-vector children (string) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "center" + :children "not a vector"})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (:children content)) "children is now a vector") + (t/is (= "center" (:vertical-align content)) "preserves vertical-align"))) + +;; ============================================================================ +;; Category C: Level 2 (paragraph-set) variants +;; ============================================================================ + +(t/deftest migration-0025-paragraph-set-nil-children + ;; C: Paragraph-set with nil children key + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set"}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is a vector") + (t/is (pos? (count (get-in content [:children 0 :children]))) "has at least one paragraph"))) + +(t/deftest migration-0025-paragraph-set-non-vector-children-map + ;; C: Paragraph-set with non-vector children (map) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children {:invalid "map"}}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "seeded with default paragraph"))) + +(t/deftest migration-0025-paragraph-set-non-vector-children-string + ;; C: Paragraph-set with non-vector children (string) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children "not a vector"}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children])) "children is now a vector"))) + +(t/deftest migration-0025-paragraph-set-item-not-map + ;; C: Paragraph-set with non-map item in children vector + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children ["not a map"]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "non-map item replaced with default paragraph"))) + +(t/deftest migration-0025-paragraph-set-mixed-valid-nil-non-map + ;; C: Paragraph-set with mix of valid paragraphs, nil, and non-map items + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "ok"}]} + nil + "not-a-map" + {:type "paragraph" + :children [{:text "also ok"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + paragraphs (get-in shape [:content :children 0 :children])] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 4 (count paragraphs)) "all items preserved as paragraphs") + (t/is (= "paragraph" (:type (nth paragraphs 0))) "valid paragraph preserved") + (t/is (= "ok" (:text (get-in (nth paragraphs 0) [:children 0]))) "valid span text preserved") + (t/is (= "paragraph" (:type (nth paragraphs 1))) "nil replaced with default paragraph") + (t/is (= "paragraph" (:type (nth paragraphs 2))) "non-map replaced with default paragraph") + (t/is (= "paragraph" (:type (nth paragraphs 3))) "valid paragraph preserved") + (t/is (= "also ok" (:text (get-in (nth paragraphs 3) [:children 0]))) "valid span text preserved"))) + +(t/deftest migration-0025-paragraph-set-wrong-type + ;; C: Paragraph-set with wrong :type + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph-set" (get-in content [:children 0 :type])) "wrong type replaced with default paragraph-set"))) + +;; ============================================================================ +;; Category D: Level 3 (paragraph) variants +;; ============================================================================ + +(t/deftest migration-0025-paragraph-nil-children + ;; D: Paragraph with nil children key + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is a vector") + (t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) "has at least one span"))) + +(t/deftest migration-0025-paragraph-non-vector-children-map + ;; D: Paragraph with non-vector children (map) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children {:invalid "map"}}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "seeded with default span"))) + +(t/deftest migration-0025-paragraph-non-vector-children-string + ;; D: Paragraph with non-vector children (string) - GAP: should repair + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children "not a vector"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector"))) + +(t/deftest migration-0025-paragraph-item-not-map + ;; D: Paragraph with non-map item in children vector + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children ["not a map"]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "non-map item replaced with default span"))) + +(t/deftest migration-0025-paragraph-wrong-type + ;; D: Paragraph with wrong :type + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "span" + :text "hello"}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "wrong type replaced with default paragraph"))) + +(t/deftest migration-0025-paragraph-valid-spans-preserved + ;; D: Paragraph with valid spans should be preserved + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "hello"} + {:text "world"}]}]}]})}}}} + original-spans (get-in data [:pages-index page-id :objects shape-id :content :children 0 :children 0 :children]) + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 2 (count (get-in content [:children 0 :children 0 :children]))) "both spans preserved") + (t/is (= original-spans (get-in content [:children 0 :children 0 :children])) "spans unchanged"))) + +;; ============================================================================ +;; Category E: Preservation tests +;; ============================================================================ + +(t/deftest migration-0025-root-attrs-preserved-level2-repair + ;; E: Root-level attrs preserved when level 2 repaired + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :vertical-align "bottom" + :children [{:type "paragraph-set" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "bottom" (:vertical-align content)) "root attrs preserved during level 2 repair"))) + +(t/deftest migration-0025-paragraph-set-attrs-preserved + ;; E: Paragraph-set attrs preserved when repaired at level 2 + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :custom-attr "preserve-me" + :children []}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "preserve-me" (get-in content [:children 0 :custom-attr])) "paragraph-set attrs preserved"))) + +(t/deftest migration-0025-paragraph-attrs-preserved + ;; E: Paragraph attrs preserved when repaired at level 3 + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :text-align "center" + :children []}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= "center" (get-in content [:children 0 :children 0 :text-align])) "paragraph attrs preserved"))) + +;; ============================================================================ +;; Category F: Multi-item tests +;; ============================================================================ + +(t/deftest migration-0025-multiple-paragraphs-mixed-valid-broken + ;; F: Multiple paragraphs within one paragraph-set, mix of valid and broken + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "valid"}]} + {:type "paragraph" + :children []} + {:type "paragraph" + :children [{:text "also valid"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 3 (count (get-in content [:children 0 :children]))) "all three paragraphs preserved") + (t/is (= "valid" (get-in content [:children 0 :children 0 :children 0 :text])) "first paragraph preserved") + (t/is (= "" (get-in content [:children 0 :children 1 :children 0 :text])) "second paragraph repaired") + (t/is (= "also valid" (get-in content [:children 0 :children 2 :children 0 :text])) "third paragraph preserved"))) + +(t/deftest migration-0025-multiple-spans-all-preserved + ;; F: Multiple spans within one paragraph (all should be preserved) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "span1"} + {:text "span2"} + {:text "span3"}]}]}]})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape (get-in data' [:pages-index page-id :objects shape-id]) + content (:content shape)] + + (t/is (cts/valid-shape? shape) "repaired shape is valid") + (t/is (= 3 (count (get-in content [:children 0 :children 0 :children]))) "all spans preserved") + (t/is (= "span1" (get-in content [:children 0 :children 0 :children 0 :text]))) + (t/is (= "span2" (get-in content [:children 0 :children 0 :children 1 :text]))) + (t/is (= "span3" (get-in content [:children 0 :children 0 :children 2 :text]))))) + +;; ============================================================================ +;; Category G: Container coverage +;; ============================================================================ + +(t/deftest migration-0025-multiple-pages-broken-shapes + ;; G: Multiple pages, each with broken shapes + (let [shape-id-1 (uuid/next) + shape-id-2 (uuid/next) + page-id-1 (uuid/next) + page-id-2 (uuid/next) + data {:pages-index + {page-id-1 + {:objects + {shape-id-1 (make-text-shape-with-content + shape-id-1 + {:type "root" :children []})}} + page-id-2 + {:objects + {shape-id-2 (make-text-shape-with-content + shape-id-2 + {:type "root" :children []})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + shape-1 (get-in data' [:pages-index page-id-1 :objects shape-id-1]) + shape-2 (get-in data' [:pages-index page-id-2 :objects shape-id-2])] + + (t/is (cts/valid-shape? shape-1) "first page shape is valid") + (t/is (cts/valid-shape? shape-2) "second page shape is valid") + (t/is (pos? (count (get-in shape-1 [:content :children]))) "first page shape repaired") + (t/is (pos? (count (get-in shape-2 [:content :children]))) "second page shape repaired"))) + +(t/deftest migration-0025-container-without-objects + ;; G: Container without :objects key should not crash + (let [page-id (uuid/next) + data {:pages-index + {page-id {}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content")] + + (t/is (= data data') "container without objects is unchanged"))) + +;; ============================================================================ +;; Category H: Idempotency +;; ============================================================================ + +(t/deftest migration-0025-already-repaired-unchanged + ;; H: Already-repaired content unchanged (run migration twice) + (let [shape-id (uuid/next) + page-id (uuid/next) + data {:pages-index + {page-id + {:objects + {shape-id (make-text-shape-with-content + shape-id + {:type "root" :children []})}}}} + data' (cfm/migrate-data data "0025-repair-empty-text-content") + data'' (cfm/migrate-data data' "0025-repair-empty-text-content") + shape' (get-in data' [:pages-index page-id :objects shape-id]) + shape'' (get-in data'' [:pages-index page-id :objects shape-id])] + + (t/is (cts/valid-shape? shape') "first repair produces valid shape") + (t/is (cts/valid-shape? shape'') "second repair produces valid shape") + (t/is (= shape' shape'') "migration is idempotent"))) diff --git a/common/test/common_tests/files_migrations_test.cljc b/common/test/common_tests/files_migrations_test.cljc index 3f6bd635fa..36ff3a09e2 100644 --- a/common/test/common_tests/files_migrations_test.cljc +++ b/common/test/common_tests/files_migrations_test.cljc @@ -8,7 +8,6 @@ (:require [app.common.data :as d] [app.common.files.migrations :as cfm] - [app.common.pprint :as pp] [app.common.types.file :as ctf] [app.common.uuid :as uuid] [clojure.test :as t])) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 7738c5dd9e..9451a72931 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -18,6 +18,7 @@ [common-tests.data-test] [common-tests.files-builder-test] [common-tests.files-changes-test] + [common-tests.files-migrations-0025-test] [common-tests.files-migrations-test] [common-tests.files.shapes-builder-test] [common-tests.files.validate-test] @@ -92,6 +93,7 @@ 'common-tests.data-test 'common-tests.files-changes-test 'common-tests.files-builder-test + 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-test 'common-tests.files.validate-test 'common-tests.geom-align-test diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index df0508a1ba..2a96cee2d2 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -39,6 +39,7 @@ [app.render-wasm.api :as wasm.api] [app.render-wasm.text-editor :as wasm.text-editor] [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] @@ -61,6 +62,24 @@ ;; -- Content helpers +(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." + [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))) + content)) + (defn- v2-content-has-text? [content] (boolean @@ -426,7 +445,14 @@ (fn [shape] (if (some? (:content shape)) (txt/update-text-content shape txt/is-root-node? d/txt-merge attrs) - (assoc shape :content (d/txt-merge {:type "root"} 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] @@ -1078,126 +1104,134 @@ (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}}] - (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)) - ;; 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?)] + ;; 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)) + ;; 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?)] - (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)) + (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 (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)}) + (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)}) - ;; `new-size` is only computed on finalize (see above), so this commits - ;; the final auto-width/auto-height geometry via `apply-wasm-modifiers` - ;; like other transform flows (flex parents, sidebar width, etc.). - (when (some? new-size) - (when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)] - (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)})))) + ;; `new-size` is only computed on finalize (see above), so this commits + ;; the final auto-width/auto-height geometry via `apply-wasm-modifiers` + ;; like other transform flows (flex parents, sidebar width, etc.). + (when (some? new-size) + (when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)] + (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)})))) - (when finalize? - (rx/concat - (if (and (not content-has-text?) (some? 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 (some? original-content) (v2-content-has-text? original-content)) + (if content-has-text? (rx/of - (dwsh/update-shapes - [id] - (fn [s] (-> s (assoc :content original-content) (dissoc :prev-content))) - {:save-undo? false})) + (dch/commit-changes + (build-finalize-commit-changes it state id + {:new-shape? new-shape? + :content-has-text? content-has-text? + :content content + :original-content original-content + :update-name? update-name? + :name name}))) (rx/empty)) - (rx/of (dws/deselect-shape id) - (dwsh/delete-shapes #{id}))) - (rx/empty)) - (rx/concat - (if content-has-text? - (rx/of - (dch/commit-changes - (build-finalize-commit-changes it state id - {:new-shape? new-shape? - :content-has-text? content-has-text? - :content content - :original-content original-content - :update-name? update-name? - :name name}))) - (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))))))))) + (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)}))))))) + (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 replace-layer-names-in-shapes [ids search replacement] diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index ee438672c5..a52202bc48 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -407,3 +407,51 @@ result (vth/fix-position shape')] (t/is (some? result)) (t/is (some? (:selrect result)))))) + +;; --------------------------------------------------------------------------- +;; Tests: ensure-valid-text-content +;; --------------------------------------------------------------------------- + +(t/deftest ensure-valid-text-content-empty-children-repaired + (t/testing "root with empty :children vector is repaired to canonical tree" + (let [broken {:type "root" + :vertical-align "top" + :children []} + fixed (dwt/ensure-valid-text-content broken)] + (t/is (= "root" (:type fixed))) + (t/is (vector? (:children fixed))) + (t/is (= 1 (count (:children fixed))) + "exactly one paragraph-set is seeded") + (t/is (= "paragraph-set" (get-in fixed [:children 0 :type]))) + (t/is (pos? (count (get-in fixed [:children 0 :children]))) + "paragraph-set has at least one paragraph") + (t/is (= "" (get-in fixed [:children 0 :children 0 :children 0 :text])) + "seeded span has empty text") + (t/is (= "top" (:vertical-align fixed)) + "preserves the original :vertical-align")))) + +(t/deftest ensure-valid-text-content-missing-children-repaired + (t/testing "root with no :children key is repaired to canonical tree" + (let [broken {:type "root" :vertical-align "center"} + fixed (dwt/ensure-valid-text-content broken)] + (t/is (vector? (:children fixed))) + (t/is (pos? (count (:children fixed)))) + (t/is (= "center" (:vertical-align fixed)))))) + +(t/deftest ensure-valid-text-content-healthy-tree-unchanged + (t/testing "a well-formed content is returned unchanged" + (let [healthy {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text "hello"}]}]}]} + fixed (dwt/ensure-valid-text-content healthy)] + (t/is (= healthy fixed))))) + +(t/deftest ensure-valid-text-content-nil-unchanged + (t/testing "nil content is returned unchanged (no repair)" + (t/is (nil? (dwt/ensure-valid-text-content nil))))) + +(t/deftest ensure-valid-text-content-non-root-unchanged + (t/testing "a non-root content (e.g. paragraph) is left alone" + (let [node {:type "paragraph" :children []}] + (t/is (= node (dwt/ensure-valid-text-content node))))))