🐛 Join text spans left with the same style after range styling

Styling a range splits spans at its ends, and nothing joined them back, so
a text kept invisible boundaries between spans with equal styles. Two CJK
features depend on those boundaries: tate-chu-yoko `all` combines per
span, so "2" + "027" rendered as two upright blocks, and a ruby reading or
warichu note over a range that crossed a hidden boundary was rejected.

`merge-equal-spans` joins neighbouring spans of a paragraph whose styles
match, counting a missing attr as its default value. Spans with a reading
or a warichu note are never joined: each one is its own group. Both places
that split spans use it: `update-text-range-attrs` (plugin API) and
`apply-attrs-to-paragraph` (WASM editor styling).

AI-assisted-by: claude-opus-5-5
This commit is contained in:
alonso.torres 2026-10-27 11:47:54 +00:00
parent f99d7d22b8
commit 996f498e9b
6 changed files with 108 additions and 7 deletions

View File

@ -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)

View File

@ -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 [])))))

View File

@ -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

View File

@ -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."

View File

@ -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)))))

View File

@ -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))))))