diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 3173142967..caf62884fe 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -247,6 +247,41 @@ item)) root))) +(defn- span-style + "The text node attrs of `span` that differ from their default value, with + legacy numeric values as strings." + [span] + (reduce (fn [style attr] + (let [value (get span attr) + value (if (number? value) (str value) value)] + (if (or (nil? value) + (and (or (string? value) (coll? value)) (empty? value)) + (= value (get default-text-attrs attr)) + (= value (get cjk/span-attr-defaults attr))) + style + (assoc style attr value)))) + {} + text-node-attrs)) + +(defn- mergeable-spans? + [a b] + (and (not (cjk/annotated-span? a)) + (not (cjk/annotated-span? b)) + (= (span-style a) (span-style b)))) + +(defn merge-equal-spans + "Joins neighbouring `spans` of a paragraph that have the same style, so + span boundaries only exist where the style changes. Spans with a ruby + reading or a warichu note are never joined: each one is its own group." + [spans] + (reduce (fn [result span] + (let [prev (peek result)] + (if (and (some? prev) (mergeable-spans? prev span)) + (conj (pop result) (update prev :text str (:text span))) + (conj result span)))) + [] + spans)) + (defn update-text-content [shape pred-fn update-fn attrs] (let [update-attrs-fn #(update-fn % attrs) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index fb562fc36a..5d023980b5 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -540,3 +540,25 @@ (t/is (< (:y first-normal) (:y last-normal))) (t/is (> (:x tone) (:x last-normal))) (t/is (< (:y tone) (:y last-normal))))) + +(t/deftest merge-equal-spans-joins-neighbours-with-the-same-style + (t/testing "a missing attr equals its default, CJK defaults included" + (t/is (= [{:text "2027" :text-combine-upright "all"}] + (cttx/merge-equal-spans [{:text "2" :text-combine-upright "all"} + {:text "027" :text-combine-upright "all"}]))) + (t/is (= [{:text "abcd" :font-size 14}] + (cttx/merge-equal-spans [{:text "ab" :font-size 14} + {:text "cd" :text-combine-upright "none" :ruby nil}])))) + + (t/testing "spans with different styles stay apart" + (t/is (= [{:text "ab"} {:text "cd" :font-weight "700"} {:text "ef"}] + (cttx/merge-equal-spans [{:text "ab"} {:text "cd" :font-weight "700"} {:text "ef"}])))) + + (t/testing "readings and warichu notes are never joined, even when equal" + (let [spans [{:text "人" :ruby "hito"} {:text "々" :ruby "hito"} {:text "x"}]] + (t/is (= spans (cttx/merge-equal-spans spans)))) + (let [spans [{:text "注" :warichu "warichu"} {:text "釈" :warichu "warichu"}]] + (t/is (= spans (cttx/merge-equal-spans spans))))) + + (t/testing "no spans give no spans" + (t/is (= [] (cttx/merge-equal-spans []))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 593c1fed7e..d0c07386a5 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -595,14 +595,17 @@ (defn update-text-range-attrs "Applies `attrs` to the characters [start, end) of a text shape. A range - that cuts a ruby or warichu span covers the whole span." + that cuts a ruby or warichu span covers the whole span. Spans left with + the same style as their neighbour are joined back." [shape start end attrs] (let [content (decorate-range-info (:content shape)) [start end] (annotated-span-bounds content start end) - new-content (-> content - (split-content-at start) - (split-content-at end) - (update-content-range start end attrs))] + new-content (->> (-> content + (split-content-at start) + (split-content-at end) + (update-content-range start end attrs)) + (txt/transform-nodes txt/is-paragraph-node? + #(d/update-when % :children txt/merge-equal-spans)))] (assoc shape :content new-content))) (defn update-text-range diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index b2118ccd8a..8df9dbddd7 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -975,7 +975,8 @@ [start end])) (defn apply-attrs-to-paragraph - "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans." + "Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), + splitting spans and joining back the ones left with their neighbour's style." [para sel-start sel-end styles] (let [[sel-start sel-end] (annotation-style-range para sel-start sel-end styles) spans (:children para) @@ -1009,7 +1010,7 @@ (recur (rest spans) span-end (-> acc (into (keep identity [before selected after])))))))))] - (assoc para :children result))) + (assoc para :children (txt/merge-equal-spans result)))) (defn selection-fills "The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty." diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index c1106663eb..1baa5c14d3 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -920,3 +920,31 @@ (rx/subs! #(swap! events conj %))) (let [updates (filter #(= ::dwsh/update-shapes (ptk/type %)) @events)] (t/is (= 1 (count updates)))))) + +(defn- spans-of + [shape] + (->> (:content shape) (txt/node-seq txt/is-text-node?) (mapv #(select-keys % [:text :ruby :text-combine-upright])))) + +(defn- one-span-text + [text] + {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:text text :font-size "20"}]}]}]}) + +(t/deftest update-text-range-joins-spans-left-with-the-same-style + (t/testing "the same style set over overlapping ranges ends in one span" + (let [shape (-> {:content (one-span-text "ab123c")} + (dwt/update-text-range-attrs 3 5 {:text-combine-upright "all"}) + (dwt/update-text-range-attrs 2 5 {:text-combine-upright "all"}))] + (t/is (= [{:text "ab"} {:text "123" :text-combine-upright "all"} {:text "c"}] + (spans-of shape))))) + + (t/testing "removing per-character readings lets a group reading cover the word" + (let [shape (reduce (fn [shape [idx reading]] + (dwt/update-text-range-attrs shape idx (inc idx) {:ruby reading})) + {:content (one-span-text "一盅兩件")} + (map-indexed vector ["jat1" "zung1" "loeng5" "gin6"])) + shape (dwt/update-text-range-attrs shape 0 4 {:ruby nil})] + (t/is (= [{:text "一盅兩件"}] (spans-of shape))) + (t/is (dwt/single-span-range? (:content shape) 0 4))))) diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs index f9681063b0..674f98e896 100644 --- a/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_editor_apply_styles_test.cljs @@ -100,3 +100,15 @@ (t/testing "a collapsed selection has no selected spans" (let [c (content [(para [(span "hello" [red])])])] (t/is (nil? (text-editor/selection-fills c (selection 0 2 0 2))))))) + +(t/deftest apply-attrs-joins-spans-left-with-the-same-style + (t/testing "extending a style over its neighbour leaves one span" + (let [para {:children [{:text "ab"} {:text "12" :text-combine-upright "all"} {:text "3c"}]} + result (apply-attrs-to-paragraph para 4 5 {:text-combine-upright "all"})] + (t/is (= [{:text "ab"} {:text "123" :text-combine-upright "all"} {:text "c"}] + (:children result))))) + + (t/testing "resetting a style to its default rejoins the text" + (let [para {:children [{:text "ab"} {:text "cd" :text-emphasis "filled-dot"} {:text "ef"}]} + result (apply-attrs-to-paragraph para 2 4 {:text-emphasis "none"})] + (t/is (= [{:text "abcdef"}] (:children result))))))