From f171c147f517178681099b9cd26da3036ae9646c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 12 Nov 2026 00:46:06 +0100 Subject: [PATCH] :tada: Add a CJK text selection menu and readings over several spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting text in a Japanese or Chinese layout now shows a menu above the selection: Ruby, Emphasis, and for Japanese, Warichu and Combine. Ruby opens an input to add, change or remove the reading; Emphasis opens a list of marks with icons; Warichu and Combine toggle on the selection. The renderer exports the selection bounds so the menu can centre on them, and new icons draw each emphasis mark. A reading had to cover a single span, so putting emphasis on part of a word blocked furigana on it. A reading can now cover adjacent spans of different styles: - Applying a reading gives the selected spans one new ruby-group id. Splitting a span keeps the id. Spans without one, as in every existing file, keep one base per span; matching readings alone never join, so per-character readings such as 天天 stay apart. - The span binary marks a span that joins the previous base with bit 2 of the ruby flags byte, so the format keeps its size. - Paragraph::ruby_groups defines a base for the renderer. Horizontal placement, long-reading spacing, paint, bounds, justification, overflow and line breaks use it, as do the vertical base units, ruby layout and column breaks. - The editor accepts a reading over any selection without readings or warichu, or over a whole base. Warichu still needs one span. - HTML and foreignObject draw one ruby per base, CSS code generation writes one set of rules per base, and SVG export draws one reading per line of a base through the new ruby-x and ruby-width keys. AI-assisted-by: claude-opus-5-5 --- .../app/common/render_wasm/text_content.cljs | 11 +- common/src/app/common/types/shape/text.cljc | 4 + .../src/app/common/types/text/cjk_layout.cljc | 75 +++- common/test/common_tests/types/text_test.cljc | 35 ++ .../icons/text-emphasis-filled-circle.svg | 3 + .../images/icons/text-emphasis-filled-dot.svg | 3 + .../icons/text-emphasis-open-circle.svg | 3 + .../images/icons/text-emphasis-open-dot.svg | 3 + .../icons/text-emphasis-open-sesame.svg | 3 + .../resources/images/icons/text-emphasis.svg | 3 + frontend/src/app/main/data/workspace.cljs | 1 + .../src/app/main/data/workspace/edition.cljs | 3 +- .../src/app/main/data/workspace/texts_v3.cljs | 17 + frontend/src/app/main/refs.cljs | 3 + .../main/ui/ds/foundations/assets/icon.cljs | 6 + .../src/app/main/ui/shapes/text/fo_text.cljs | 31 +- .../app/main/ui/shapes/text/html_text.cljs | 50 ++- .../src/app/main/ui/shapes/text/svg_text.cljs | 4 + .../workspace/shapes/text/selection_menu.cljs | 402 ++++++++++++++++++ .../workspace/shapes/text/selection_menu.scss | 207 +++++++++ .../app/main/ui/workspace/viewport_wasm.cljs | 9 +- frontend/src/app/render_wasm/api.cljs | 92 +++- frontend/src/app/render_wasm/text_editor.cljs | 80 +++- frontend/src/app/util/code_gen/style_css.cljs | 25 +- .../frontend_tests/code_gen_style_test.cljs | 27 ++ .../text_editor_apply_styles_test.cljs | 51 +++ .../render_wasm/texts_test.cljs | 9 +- frontend/translations/en.po | 44 ++ frontend/translations/es.po | 44 ++ render-wasm/docs/cjk-layout.md | 18 +- render-wasm/src/render/text_editor.rs | 69 +++ render-wasm/src/shapes/line_breaks.rs | 8 +- render-wasm/src/shapes/text.rs | 125 ++++++ .../src/shapes/text_horizontal/justify.rs | 6 +- render-wasm/src/shapes/text_horizontal/mod.rs | 17 +- .../src/shapes/text_horizontal/overflow.rs | 28 +- .../src/shapes/text_horizontal/ruby.rs | 128 ++++-- .../src/shapes/text_horizontal/tests.rs | 133 ++++++ .../src/shapes/text_vertical/annotations.rs | 127 +++++- .../src/shapes/text_vertical/layout.rs | 12 +- render-wasm/src/wasm/text.rs | 21 +- render-wasm/src/wasm/text_editor.rs | 48 ++- 42 files changed, 1833 insertions(+), 155 deletions(-) create mode 100644 frontend/resources/images/icons/text-emphasis-filled-circle.svg create mode 100644 frontend/resources/images/icons/text-emphasis-filled-dot.svg create mode 100644 frontend/resources/images/icons/text-emphasis-open-circle.svg create mode 100644 frontend/resources/images/icons/text-emphasis-open-dot.svg create mode 100644 frontend/resources/images/icons/text-emphasis-open-sesame.svg create mode 100644 frontend/resources/images/icons/text-emphasis.svg create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs index 5b807bdf4d..2453e5d360 100644 --- a/common/src/app/common/render_wasm/text_content.cljs +++ b/common/src/app/common/render_wasm/text_content.cljs @@ -22,6 +22,7 @@ [app.common.render-wasm.serializers :as sr] [app.common.render-wasm.wasm :as wasm] [app.common.types.fills.impl :as types.fills.impl] + [app.common.types.text.cjk-layout :as cjk] [app.common.uuid :as uuid] [cuerdas.core :as str])) @@ -142,7 +143,7 @@ paragraph-font-weight (-> paragraph :font-weight serialize-font-weight) paragraph-line-height (serialize-line-height (get paragraph :line-height))] (reduce - (fn [offset span] + (fn [offset [previous span]] (let [font-style (sr/translate-font-style (get span :font-style "normal")) font-size (serialize-font-size (get span :font-size paragraph-font-size)) line-height (serialize-line-height (get span :line-height) paragraph-line-height) @@ -194,7 +195,11 @@ (mem/write-u8 dview ruby-size) (mem/write-u8 dview ruby-align) (mem/write-u8 dview ruby-overhang) - (mem/write-u8 dview (bit-or ruby-side (if (true? (:ruby-zhuyin span)) 2 0))) + ;; Bit 1 selects Zhuyin; bit 2 joins the span to the reading of + ;; the span before it (`cjk/ruby-groups`). + (mem/write-u8 dview (bit-or ruby-side + (if (true? (:ruby-zhuyin span)) 2 0) + (if (cjk/ruby-joins-previous? previous span) 4 0))) ;; Use the former padding bytes; the span header stays the same size. (mem/write-u8 dview emphasis-position) (mem/write-u8 dview underline-position) @@ -211,7 +216,7 @@ (mem/assert-written offset SPAN-ATTR-U8-SIZE) (write-span-fills dview fills)))) offset - spans))) + (map vector (cons nil spans) spans)))) (defn write-shape-text! "Writes one paragraph's spans + text into WASM and appends it to the current diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc index 4a9e1fa670..d7e7206988 100644 --- a/common/src/app/common/types/shape/text.cljc +++ b/common/src/app/common/types/shape/text.cljc @@ -76,6 +76,7 @@ [:text-emphasis-position {:optional true} (schema:cjk-enum :text-emphasis-position)] [:text-underline-position {:optional true} (schema:cjk-enum :text-underline-position)] [:ruby {:optional true} :string] + [:ruby-group {:optional true} :string] [:ruby-hidden {:optional true} :boolean] [:ruby-zhuyin {:optional true} :boolean] [:ruby-size {:optional true} (schema:cjk-enum :ruby-size)] @@ -113,6 +114,9 @@ [:emphasis-mark {:optional true} :boolean] [:zhuyin-glyph {:optional true} :boolean] [:ruby {:optional true} :string] + ;; A horizontal reading over several strips of its base spans them all. + [:ruby-x {:optional true} ::sm/safe-number] + [:ruby-width {:optional true} ::sm/safe-number] [:ruby-size {:optional true} ::sm/text] [:ruby-align {:optional true} ::sm/text] [:ruby-overhang {:optional true} ::sm/text] diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc index de19d876a5..8d87f5cf7a 100644 --- a/common/src/app/common/types/text/cjk_layout.cljc +++ b/common/src/app/common/types/text/cjk_layout.cljc @@ -71,20 +71,22 @@ (def ruby-presentation-attrs [:ruby-hidden :ruby-zhuyin :ruby-size :ruby-align :ruby-overhang :ruby-side]) -;; Span attrs; `:ruby` holds the reading (furigana) of the span. +;; Span attrs; `:ruby` holds the reading (furigana) of the span and +;; `:ruby-group` joins it to the spans of the same reading (`ruby-groups`). (def span-attrs - (into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position :ruby] + (into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position + :ruby :ruby-group] (concat ruby-presentation-attrs [:warichu :font-features :annotation-clearance]))) ;; Span attrs owned by the span's characters, not its style: a reading or a -;; warichu note annotates one span and is never copied to other text. +;; warichu note annotates its own text and is never copied to other text. (def text-content-attrs - [:ruby :warichu]) + [:ruby :ruby-group :warichu]) ;; Values of the span attrs above when a span does not store them. (def span-attr-defaults - (into {:ruby "" :ruby-hidden false :ruby-zhuyin false} + (into {:ruby "" :ruby-group "" :ruby-hidden false :ruby-zhuyin false} (comp (filter #(contains? enum-values %)) (map (juxt identity enum-default))) span-attrs)) @@ -176,6 +178,67 @@ [node] (not (str/blank? (:ruby node)))) +(defn ruby-joins-previous? + "True when text node `node` continues the reading of `previous`, the node + before it: both carry the same reading in the same non-blank + `:ruby-group`, so their text is one base whose first node sets the + presentation. Without a group, neighbours with the same reading stay + apart, as in 天天 (tiān tiān). Zhuyin annotates one character, so its + nodes stand alone." + [previous node] + (let [group (:ruby-group node)] + (boolean + (and (some? previous) + (string? group) + (not (str/blank? group)) + (= group (:ruby-group previous)) + (ruby-span? node) + (ruby-span? previous) + (not= "warichu" (:warichu node)) + (not= "warichu" (:warichu previous)) + (not (true? (:ruby-zhuyin node))) + (not (true? (:ruby-zhuyin previous))) + (= (str/trim (:ruby node)) (str/trim (:ruby previous))))))) + +(defn ruby-groups + "Index ranges `[start end)` of the text `nodes` of a paragraph whose text is + one ruby base: a node with a reading and the nodes after it that join it + (`ruby-joins-previous?`). A reading over characters of several styles, + such as some with emphasis marks, is one base." + [nodes] + (let [nodes (vec nodes)] + (reduce (fn [groups index] + (let [node (nth nodes index)] + (cond + (not (ruby-span? node)) + groups + + (and (seq groups) + (= index (second (peek groups))) + (ruby-joins-previous? (nth nodes (dec index)) node)) + (conj (pop groups) [(first (peek groups)) (inc index)]) + + :else + (conj groups [index (inc index)])))) + [] + (range (count nodes))))) + +(defn ruby-base-runs + "Index ranges `[start end)` that cover the `nodes` of a paragraph in order: + each reading over several nodes (`ruby-groups`) as one range, every other + node alone. Renderers draw a range as one reading over its nodes." + [nodes] + (let [nodes (vec nodes) + starts (into {} (comp (filter (fn [[start end]] (> (- end start) 1))) + (map (juxt first identity))) + (ruby-groups nodes))] + (loop [index 0 + runs []] + (if (>= index (count nodes)) + runs + (let [[_ end :as run] (get starts index [index (inc index)])] + (recur end (conj runs run))))))) + (defn annotated-span? "True when a text node carries a ruby reading or is a warichu note." [node] @@ -250,7 +313,7 @@ nil))) (def ^:private ruby-attrs - (into #{:ruby} ruby-presentation-attrs)) + (into #{:ruby :ruby-group} ruby-presentation-attrs)) ;; CJK attrs each language renders; the others stay in the content and come ;; back when the text takes that language again. diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index 1338b8f90c..280eb34e9a 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -640,3 +640,38 @@ (let [active (cjk/active-content (cjk-content {:text-language "ko"}) "ja")] (t/is (= "ko" (:text-language (first-paragraph active))) "the text language wins over the file language"))) + +(defn- reading + ([text ruby] {:text text :ruby ruby}) + ([text ruby group] {:text text :ruby ruby :ruby-group group})) + +(t/deftest a-reading-joins-the-spans-of-its-group + (let [nodes [(reading "日" "にほん" "g") + (assoc (reading "本" "にほん" "g") :text-emphasis "filled-sesame") + {:text "語"}]] + (t/is (cjk/ruby-joins-previous? (first nodes) (second nodes))) + (t/is (= [[0 2]] (cjk/ruby-groups nodes))) + (t/is (= [[0 2] [2 3]] (cjk/ruby-base-runs nodes))))) + +(t/deftest neighbours-with-the-same-reading-stay-apart-without-a-group + (let [nodes [(reading "天" "tiān") (reading "天" "tiān")]] + (t/is (not (cjk/ruby-joins-previous? (first nodes) (second nodes)))) + (t/is (= [[0 1] [1 2]] (cjk/ruby-groups nodes))) + (t/is (= [[0 1] [1 2]] (cjk/ruby-base-runs nodes))))) + +(t/deftest a-group-joins-only-the-same-reading + (t/is (= [[0 1] [1 2]] + (cjk/ruby-groups [(reading "日" "に" "g") (reading "本" "ほん" "g")]))) + (t/is (= [[0 1] [1 2]] + (cjk/ruby-groups [(reading "日" "にほん" "a") (reading "本" "にほん" "b")])))) + +(t/deftest zhuyin-and-warichu-spans-never-join + (t/is (= [[0 1] [1 2]] + (cjk/ruby-groups [(assoc (reading "天" "ㄊㄧㄢ" "g") :ruby-zhuyin true) + (assoc (reading "天" "ㄊㄧㄢ" "g") :ruby-zhuyin true)]))) + (t/is (not (cjk/ruby-joins-previous? (reading "割" "わり" "g") + (assoc (reading "注" "わり" "g") :warichu "warichu"))))) + +(t/deftest the-ruby-group-is-text-content-not-style + (t/is (some #{:ruby-group} cjk/text-content-attrs)) + (t/is (= "" (:ruby-group cjk/span-attr-defaults)))) diff --git a/frontend/resources/images/icons/text-emphasis-filled-circle.svg b/frontend/resources/images/icons/text-emphasis-filled-circle.svg new file mode 100644 index 0000000000..fb5269a95a --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis-filled-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-emphasis-filled-dot.svg b/frontend/resources/images/icons/text-emphasis-filled-dot.svg new file mode 100644 index 0000000000..ffce4048e2 --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis-filled-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-emphasis-open-circle.svg b/frontend/resources/images/icons/text-emphasis-open-circle.svg new file mode 100644 index 0000000000..eee36c9989 --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis-open-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-emphasis-open-dot.svg b/frontend/resources/images/icons/text-emphasis-open-dot.svg new file mode 100644 index 0000000000..81108caf2e --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis-open-dot.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-emphasis-open-sesame.svg b/frontend/resources/images/icons/text-emphasis-open-sesame.svg new file mode 100644 index 0000000000..2c55c5baa7 --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis-open-sesame.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/icons/text-emphasis.svg b/frontend/resources/images/icons/text-emphasis.svg new file mode 100644 index 0000000000..965015e1fa --- /dev/null +++ b/frontend/resources/images/icons/text-emphasis.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 8bd6967112..929ad5f40c 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -628,6 +628,7 @@ :current-file-id :workspace-editor-state :workspace-wasm-editor-styles + :workspace-wasm-editor-selection-rects :workspace-media-objects :workspace-presence :workspace-tokens diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs index 185289276e..41a57e4126 100644 --- a/frontend/src/app/main/data/workspace/edition.cljs +++ b/frontend/src/app/main/data/workspace/edition.cljs @@ -64,7 +64,8 @@ (not path-editing?) (update :workspace-drawing dissoc :object :lock)) (dissoc :workspace-grid-edition) - (dissoc :workspace-wasm-editor-styles)))) + (dissoc :workspace-wasm-editor-styles) + (dissoc :workspace-wasm-editor-selection-rects)))) ptk/WatchEvent (watch [_ _ _] diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs index d9c288eac3..d91fccd7b7 100644 --- a/frontend/src/app/main/data/workspace/texts_v3.cljs +++ b/frontend/src/app/main/data/workspace/texts_v3.cljs @@ -23,3 +23,20 @@ (update-in state [:workspace-wasm-editor-styles id] (fn [current-styles] (merge defaults current-styles new-styles)))))))) + +(defn v3-update-text-editor-selection-rect + "Store the box around the text selected in the editor of the text `id`, in + page coordinates, or drop it when `rect` is nil: the selection collapsed." + [id rect] + (ptk/reify ::v3-update-text-editor-selection-rect + ptk/UpdateEvent + (update [_ state] + (cond + (not= id (get-in state [:workspace-local :edition])) + state + + (nil? rect) + (update state :workspace-wasm-editor-selection-rects dissoc id) + + :else + (assoc-in state [:workspace-wasm-editor-selection-rects id] rect))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index a6f30ba453..7be635f71f 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -445,6 +445,9 @@ (def workspace-wasm-editor-styles (l/derived :workspace-wasm-editor-styles st/state)) +(def workspace-wasm-editor-selection-rects + (l/derived :workspace-wasm-editor-selection-rects st/state)) + (def workspace-wasm-modifiers (let [a (atom nil)] (rx/sub! ms/wasm-modifiers #(reset! a %)) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 7c68570f53..ded11e6608 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -288,6 +288,12 @@ (def ^:icon-id text-combine-upright-all "text-combine-upright-all") (def ^:icon-id text-combine-upright-digits "text-combine-upright-digits") (def ^:icon-id text-combine-upright-none "text-combine-upright-none") +(def ^:icon-id text-emphasis "text-emphasis") +(def ^:icon-id text-emphasis-filled-circle "text-emphasis-filled-circle") +(def ^:icon-id text-emphasis-filled-dot "text-emphasis-filled-dot") +(def ^:icon-id text-emphasis-open-circle "text-emphasis-open-circle") +(def ^:icon-id text-emphasis-open-dot "text-emphasis-open-dot") +(def ^:icon-id text-emphasis-open-sesame "text-emphasis-open-sesame") (def ^:icon-id text-fixed "text-fixed") (def ^:icon-id text-font-family "text-font-family") (def ^:icon-id text-font-size "text-font-size") diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs index 93e791cad1..41cd9153dd 100644 --- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs @@ -30,6 +30,24 @@ [:span.text-node {:style style} (if (= text "") "\u00A0" (sts/text-children text node))]))) +(mf/defc render-ruby-group* + "One reading over the text `nodes` of its base (`cjk/ruby-groups`), each + node with its own style. The first node holds the reading." + {::mf/private true} + [{:keys [nodes shape]}] + (let [node (first nodes) + ruby (cjk/visible-ruby node)] + (if (some? ruby) + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)} + (for [[index node] (d/enumerate nodes)] + [:span.text-node {:key index :style (sts/generate-text-styles shape node)} + (sts/text-children (:text node) node)]) + [:rt {:style (sts/generate-ruby-styles shape node)} ruby]] + [:* + (for [[index node] (d/enumerate nodes)] + [:span.text-node {:key index :style (sts/generate-text-styles shape node)} + (sts/text-children (:text node) node)])]))) + (mf/defc render-root* [{:keys [node children shape]}] (let [style (sts/generate-root-styles shape node)] @@ -62,10 +80,15 @@ "paragraph" render-paragraph* nil)] (when component - [:> component props - (for [[index child-node] (d/enumerate children)] - [:> render-node* - (mf/spread-props props {:node child-node :index index :key index})])]))))) + (let [children (vec children)] + [:> component props + (for [[start end] (cjk/ruby-base-runs children)] + (if (= (inc start) end) + [:> render-node* + (mf/spread-props props {:node (nth children start) :index start :key start})] + [:> render-ruby-group* {:key start + :nodes (subvec children start end) + :shape (unchecked-get props "shape")}]))])))))) (defn- next-color "Given a set of colors try to get a color not yet used" diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs index 27ad945b6f..2858a1cef7 100644 --- a/frontend/src/app/main/ui/shapes/text/html_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs @@ -49,6 +49,32 @@ [:span.text-node {:style style :class class} (if (= text "") "\u00A0" (sts/text-children text node))]))) +(mf/defc render-ruby-group* + "One reading over the text `nodes` of its base (`cjk/ruby-groups`), each + node with its own style. The first node holds the reading." + {::mf/private true} + [{:keys [nodes shape is-code]}] + (let [node (first nodes) + class (when is-code (:$id node)) + ruby (cjk/visible-ruby node)] + (if (some? ruby) + [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node) + :class (when is-code (dm/str class "-ruby"))} + (for [[index node] (d/enumerate nodes)] + [:span.text-node {:key index + :style (sts/generate-text-styles shape node) + :class (when is-code (:$id node))} + (sts/text-children (:text node) node)]) + [:rt {:style (sts/generate-ruby-styles shape node) + :class (when is-code (dm/str class "-rt"))} + ruby]] + [:* + (for [[index node] (d/enumerate nodes)] + [:span.text-node {:key index + :style (sts/generate-text-styles shape node) + :class (when is-code (:$id node))} + (sts/text-children (:text node) node)])]))) + (mf/defc render-root* [{:keys [node children shape is-code]}] (let [style (sts/generate-root-styles shape node is-code) @@ -85,15 +111,21 @@ "paragraph" render-paragraph* nil)] (when component - [:> component props - (for [[index child-node] (d/enumerate children)] - [:> render-node* - (mf/spread-props props - {:node child-node - :parent parent - :index index - :key index - :is-code is-code})])]))))) + (let [children (vec children)] + [:> component props + (for [[start end] (cjk/ruby-base-runs children)] + (if (= (inc start) end) + [:> render-node* + (mf/spread-props props + {:node (nth children start) + :parent parent + :index start + :key start + :is-code is-code})] + [:> render-ruby-group* {:key start + :nodes (subvec children start end) + :shape (unchecked-get props "shape") + :is-code is-code}]))])))))) (mf/defc text-shape* {::mf/forward-ref true} diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index e3a4988a86..761e353bd1 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -148,6 +148,10 @@ :lengthAdjust "spacingAndGlyphs" :style style})) (let [rtl? (= "rtl" (:direction data)) + ;; A reading over several strips spans its whole base. + data (assoc data + :x (:ruby-x data (:x data)) + :width (:ruby-width data (:width data))) ruby-align (if word? "center" (:ruby-align data (cjk/enum-default :ruby-align))) fit? (and (not word?) (or (= "none" (:ruby-overhang data (cjk/enum-default :ruby-overhang))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs new file mode 100644 index 0000000000..e24244ecb0 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs @@ -0,0 +1,402 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.main.ui.workspace.shapes.text.selection-menu + "Popover over the text selected in the WASM text editor, with the CJK + annotations of the text language." + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.types.text.cjk-layout :as cjk] + [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.texts-v3 :as dwt-v3] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.workspace.sidebar.options.menus.text-cjk-controls :as cjk-controls] + [app.render-wasm.text-editor :as wasm.text-editor] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +;; Languages that show the menu. +(def ^:private menu-language-families + #{:japanese :chinese}) + +;; Contenteditable of the WASM text editor (see the v3 editor). +(def ^:private editor-input-id "text-editor-wasm-input") + +;; Icon of each `:text-emphasis` value. +(def ^:private emphasis-icons + {"none" i/remove + "filled-dot" i/text-emphasis-filled-dot + "open-dot" i/text-emphasis-open-dot + "filled-circle" i/text-emphasis-filled-circle + "open-circle" i/text-emphasis-open-circle + "filled-sesame" i/text-emphasis + "open-sesame" i/text-emphasis-open-sesame}) + +(defn- keep-editor-focus + "Keep the focus where it is when a press lands on the menu, so the editor + keeps its selection. The reading input takes the focus to type in it." + [event] + (when-not (= "INPUT" (.-tagName (dom/get-target event))) + (dom/prevent-default event))) + +(defn- focus-editor + [] + (some-> (dom/get-element editor-input-id) (dom/focus!))) + +(defn- composing-key? + "True for a key-down that an IME composition takes." + [event] + (let [native (dom/event->native-event event)] + (or (.-isComposing ^js native) + (= 229 (.-keyCode ^js native))))) + +(defn- escape-key? + "True for an Escape key-down that does not end an IME composition." + [event] + (and (kbd/esc? event) (not (composing-key? event)))) + +(defn- apply-text-attrs + "Apply span `attrs` to the text selected in the editor of `shape-id`, as + the design sidebar does." + [shape-id attrs] + (st/emit! (dwt-v3/v3-update-text-editor-styles shape-id attrs) + (dwt/update-all-attrs #{shape-id} attrs))) + +(defn- span-value + "Value of a span attr of the selection: nil when its spans differ." + [styles attr] + (let [value (get styles attr)] + (when-not (= :multiple value) value))) + +(mf/defc menu-item* + "Button with `children` as its icon, before the label, and a caret after + it when it opens a dropdown, open when `expanded` is true. `active` shows + that the selected text has the annotation; `pressed` also tells it to + assistive tech on toggles. A `disabled` item explains why in `hint`." + {::mf/private true} + [{:keys [label children on-click hint pressed + ^boolean dropdown ^boolean expanded ^boolean active ^boolean disabled]}] + [:button {:type "button" + :class (stl/css-case :menu-item true + :menu-item-active active) + :aria-haspopup (when dropdown "menu") + :aria-expanded (when dropdown expanded) + :aria-pressed pressed + :aria-disabled disabled + :title (when disabled hint) + :on-click (when-not disabled on-click)} + children + [:span {:class (stl/css :label)} label] + (when dropdown + [:> icon* {:icon-id i/arrow-down + :size "s" + :class (stl/css :caret) + :aria-hidden true}])]) + +(mf/defc glyph* + "A CJK character standing for an annotation that has no icon." + {::mf/private true} + [{:keys [glyph]}] + [:span {:class (stl/css :glyph) :aria-hidden true} glyph]) + +(mf/defc emphasis-list* + "The emphasis marks to choose from, `selected` checked." + {::mf/private true} + [{:keys [selected on-select]}] + (let [on-click + (mf/use-fn + (mf/deps on-select) + (fn [event] + (on-select (dom/get-data (dom/get-current-target event) "value"))))] + [:ul {:class (stl/css :emphasis-list) + :role "menu" + :aria-label (tr "workspace.options.text-options.text-emphasis")} + (for [{:keys [id label]} (cjk-controls/text-emphasis-options)] + (let [selected? (= id selected)] + [:li {:key id :role "none"} + [:button {:type "button" + :role "menuitemradio" + :aria-checked selected? + :class (stl/css-case :emphasis-option true + :emphasis-option-selected selected?) + :data-value id + :on-click on-click} + [:> icon* {:icon-id i/tick + :size "s" + :class (stl/css :emphasis-check) + :aria-hidden true}] + [:> icon* {:icon-id (get emphasis-icons id) + :class (stl/css-case :emphasis-mark true + :emphasis-mark-none (= id "none")) + :aria-hidden true}] + [:span {:class (stl/css :emphasis-label)} label]]]))])) + +(mf/defc toolbar* + "The annotations of the `family` language for the selected text of + `shape-id`. `annotations` holds what the text has (nil when its spans + differ). A reading takes text without annotations or a whole base + (`readable`); a warichu note, the text of one span (`warichu-enabled`)." + {::mf/private true} + [{:keys [shape-id family annotations on-ruby + ^boolean readable ^boolean ruby-enabled ^boolean warichu-enabled]}] + (let [emphasis-open* (mf/use-state false) + emphasis-open (deref emphasis-open*) + + {:keys [ruby emphasis warichu combine]} annotations + + warichu? (= "warichu" warichu) + combine? (= "all" combine) + ruby? (not (str/blank? ruby)) + + span-hint (tr "workspace.text-selection-menu.single-span-hint") + + toggle-emphasis + (mf/use-fn #(swap! emphasis-open* not)) + + open-ruby + (mf/use-fn + (mf/deps on-ruby) + (fn [] + (reset! emphasis-open* false) + (on-ruby))) + + select-emphasis + (mf/use-fn + (mf/deps shape-id) + (fn [value] + (reset! emphasis-open* false) + (apply-text-attrs shape-id {:text-emphasis value}))) + + toggle-warichu + (mf/use-fn + (mf/deps shape-id warichu?) + (fn [] + (reset! emphasis-open* false) + (apply-text-attrs shape-id {:warichu (if warichu? "none" "warichu")}))) + + toggle-combine + (mf/use-fn + (mf/deps shape-id combine?) + (fn [] + (reset! emphasis-open* false) + (apply-text-attrs shape-id {:text-combine-upright (if combine? "none" "all")})))] + + [:* + [:> menu-item* {:label (tr "workspace.text-selection-menu.ruby") + :active ruby? + :disabled (not ruby-enabled) + :hint (if readable + (tr "workspace.options.text-options.zhuyin-selection-hint") + (tr "workspace.text-selection-menu.reading-hint")) + :on-click open-ruby} + [:> glyph* {:glyph "あ"}]] + [:div {:class (stl/css :emphasis-anchor)} + [:> menu-item* {:label (tr "workspace.text-selection-menu.emphasis") + :dropdown true + :expanded emphasis-open + :active (and (some? emphasis) (not= "none" emphasis)) + :on-click toggle-emphasis} + [:> icon* {:icon-id i/text-emphasis :aria-hidden true}]] + (when emphasis-open + [:> emphasis-list* {:selected emphasis + :on-select select-emphasis}])] + ;; Chinese layout renders neither warichu nor tate-chu-yoko. + (when (= family :japanese) + [:* + [:hr {:class (stl/css :separator)}] + [:> menu-item* {:label (tr "workspace.text-selection-menu.warichu") + :active warichu? + :pressed warichu? + :disabled (not warichu-enabled) + :hint span-hint + :on-click toggle-warichu} + [:> icon* {:icon-id i/warichu :aria-hidden true}]] + [:> menu-item* {:label (tr "workspace.text-selection-menu.combine") + :active combine? + :pressed combine? + :on-click toggle-combine} + [:> icon* {:icon-id i/text-combine-upright-all :aria-hidden true}]]])])) + +(mf/defc ruby-input* + "Input for the ruby reading of the selected text, starting from its + current `reading`. Enter applies it, Remove drops the current one, and + Escape and the back button return to the toolbar. A `validate` fn rejects + the readings it returns false for." + {::mf/private true} + [{:keys [reading validate invalid-hint on-apply on-back]}] + (let [reading* (mf/use-state (or reading "")) + value (deref reading*) + valid? (or (str/blank? value) (nil? validate) (validate (str/trim value))) + + ;; True when the pending Escape key-up ends a real Escape press, not + ;; an IME composition. + escape-ref (mf/use-ref false) + + on-change + (mf/use-fn + (fn [event] + (reset! reading* (dom/get-value (dom/get-target event))))) + + on-key-down + (mf/use-fn + (mf/deps value valid? on-apply) + (fn [event] + (cond + (kbd/esc? event) + (mf/set-ref-val! escape-ref (escape-key? event)) + + (and (kbd/enter? event) (not (composing-key? event))) + (do + (dom/prevent-default event) + (when valid? + (on-apply (str/trim value))))))) + + ;; The editor exits on an Escape key-up reaching the document, so the + ;; input keeps every Escape key-up to itself. + on-key-up + (mf/use-fn + (mf/deps on-back) + (fn [event] + (when (kbd/esc? event) + (dom/stop-propagation event) + (when (mf/ref-val escape-ref) + (mf/set-ref-val! escape-ref false) + (on-back))))) + + on-remove + (mf/use-fn + (mf/deps on-apply) + #(on-apply ""))] + + [:* + [:button {:type "button" + :class (stl/css :back-button) + :aria-label (tr "workspace.text-selection-menu.back") + :on-click on-back} + [:> icon* {:icon-id i/arrow-left :aria-hidden true}] + [:span {:class (stl/css :label)} (tr "workspace.text-selection-menu.ruby")]] + [:input {:type "text" + :class (stl/css :reading-input) + :aria-label (tr "workspace.text-selection-menu.reading") + :aria-invalid (not valid?) + :title (when-not valid? invalid-hint) + :auto-focus true + :value value + :on-change on-change + :on-key-down on-key-down + :on-key-up on-key-up}] + (when-not (str/blank? reading) + [:button {:type "button" + :class (stl/css :remove-button) + :on-click on-remove} + [:span {:class (stl/css :label)} (tr "workspace.text-selection-menu.remove")]]) + [:button {:type "button" + :class (stl/css :more-button) + :aria-label (tr "workspace.text-selection-menu.ruby-options")} + [:> icon* {:icon-id i/menu + :class (stl/css :more-icon) + :aria-hidden true}]]])) + +(mf/defc menu* + {::mf/private true} + [{:keys [shape-id rect vbox zoom family annotations zhuyin + ^boolean readable ^boolean ruby-enabled ^boolean warichu-enabled]}] + (let [view* (mf/use-state :toolbar) + view (deref view*) + + ;; Centred over the selection box, on top of it. + left (* (- (+ (:x rect) (/ (:width rect) 2)) (:x vbox)) zoom) + top (* (- (:y rect) (:y vbox)) zoom) + style (mf/with-memo [left top] + #js {:left (dm/str left "px") + :top (dm/str top "px")}) + + open-ruby + (mf/use-fn #(reset! view* :ruby)) + + close-ruby + (mf/use-fn + (fn [] + (reset! view* :toolbar) + (focus-editor))) + + apply-ruby + (mf/use-fn + (mf/deps shape-id close-ruby) + (fn [reading] + (apply-text-attrs shape-id {:ruby reading}) + (close-ruby)))] + + [:div {:class (stl/css-case :selection-menu true + :ruby-view (= view :ruby)) + :style style + :role "toolbar" + :aria-label (tr "workspace.text-selection-menu.title") + ;; Focus moving into the menu keeps the editor alive (see + ;; `keep-editing-on-blur?` in the v3 editor). + :data-keep-editing-on-blur true + :on-mouse-down keep-editor-focus} + (case view + :ruby [:> ruby-input* {:reading (:ruby annotations) + :validate (when zhuyin cjk/zhuyin-reading?) + :invalid-hint (tr "workspace.options.text-options.zhuyin-invalid") + :on-apply apply-ruby + :on-back close-ruby}] + [:> toolbar* {:shape-id shape-id + :family family + :annotations annotations + :readable readable + :ruby-enabled ruby-enabled + :warichu-enabled warichu-enabled + :on-ruby open-ruby}])])) + +(mf/defc selection-menu* + "Shows the menu over the text selected in the editor of `shape` when the + text renders with a Japanese or Chinese layout." + [{:keys [shape vbox zoom]}] + (let [shape-id (:id shape) + rects (mf/deref refs/workspace-wasm-editor-selection-rects) + styles-map (mf/deref refs/workspace-wasm-editor-styles) + file-language (mf/deref refs/file-text-language) + rect (get rects shape-id) + family (cjk/language-family + (or (cjk/content-language (:content shape)) file-language))] + (when (and (some? rect) (contains? menu-language-families family)) + (let [styles (get styles-map shape-id) + emphasis (get styles :text-emphasis) + annotations {:ruby (span-value styles :ruby) + ;; Spans without a mark store none. + :emphasis (cond + (nil? emphasis) "none" + (= :multiple emphasis) nil + :else emphasis) + :warichu (span-value styles :warichu) + :combine (span-value styles :text-combine-upright)} + content (wasm.text-editor/get-cached-content shape-id) + selection (wasm.text-editor/text-editor-get-selection) + ;; A reading joins the spans it covers into one base; a warichu + ;; note stays in one span. + readable (wasm.text-editor/reading-selection? content selection) + ;; Zhuyin annotates one Han character at a time. + zhuyin (and (= family :chinese) (true? (:ruby-zhuyin styles)))] + [:> menu* {:shape-id shape-id + :rect rect + :vbox vbox + :zoom zoom + :family family + :annotations annotations + :zhuyin zhuyin + :readable readable + :ruby-enabled (and readable + (or (not zhuyin) + (wasm.text-editor/zhuyin-selection? content selection))) + :warichu-enabled (wasm.text-editor/single-span-selection? content selection)}])))) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss new file mode 100644 index 0000000000..f6f518712b --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss @@ -0,0 +1,207 @@ +// 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 + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/typography.scss" as *; + +%surface { + background-color: var(--color-background-tertiary); + border: $b-1 solid var(--color-background-quaternary); + border-radius: $br-8; + box-shadow: 0 px2rem(2) px2rem(8) 0 var(--color-shadow-light); +} + +%plain-button { + appearance: none; + display: flex; + align-items: center; + block-size: $sz-32; + border: none; + border-radius: $br-6; + background: transparent; + cursor: pointer; + + &:hover { + background-color: var(--color-background-quaternary); + } + + &:focus-visible { + outline: $b-1 solid var(--color-accent-primary); + } +} + +// Its left and top, set inline, are the centre of the selection top edge. +.selection-menu { + @extend %surface; + + --selection-menu-margin: var(--sp-s); + + position: absolute; + translate: -50% calc(-100% - var(--selection-menu-margin)); + display: flex; + align-items: center; + column-gap: var(--sp-xxs); + padding: var(--sp-xs); + white-space: nowrap; + pointer-events: auto; +} + +.ruby-view { + column-gap: var(--sp-s); +} + +.menu-item { + @extend %plain-button; + + column-gap: px2rem(6); + padding: 0 var(--sp-s); + color: var(--color-foreground-primary); + + &[aria-expanded="true"] { + background-color: var(--color-background-quaternary); + } + + &[aria-disabled="true"] { + color: var(--color-foreground-secondary); + cursor: default; + + &:hover { + background: transparent; + } + } +} + +// The selected text has the annotation. +.menu-item-active { + background-color: var(--color-background-quaternary); + color: var(--color-accent-primary); +} + +.label { + @include use-typography("body-small"); +} + +.glyph { + @include use-typography("body-medium"); + + inline-size: $sz-16; + text-align: center; +} + +.caret { + color: var(--color-icon-default); +} + +.separator { + inline-size: $b-1; + block-size: px2rem(20); + margin: 0; + border: none; + background-color: var(--color-background-quaternary); +} + +// The list hangs below the Emphasis button, past the menu padding and border. +.emphasis-anchor { + position: relative; +} + +.emphasis-list { + @extend %surface; + + position: absolute; + inset-block-start: calc(100% + 2 * var(--sp-xs) + #{$b-1}); + inset-inline-start: 0; + display: flex; + flex-direction: column; + inline-size: $sz-200; + margin: 0; + padding: var(--sp-xs); + list-style: none; +} + +.emphasis-option { + @extend %plain-button; + + column-gap: var(--sp-s); + inline-size: 100%; + padding: 0 var(--sp-m); + color: var(--color-foreground-primary); +} + +.emphasis-check { + color: var(--color-accent-primary); + visibility: hidden; +} + +.emphasis-mark-none { + color: var(--color-foreground-secondary); +} + +.emphasis-label { + @include use-typography("body-small"); +} + +.emphasis-option-selected { + .emphasis-check { + visibility: visible; + } + + .emphasis-label { + color: var(--color-accent-primary); + } +} + +.back-button { + @extend %plain-button; + + column-gap: var(--sp-xs); + padding: 0 var(--sp-xs); + color: var(--color-foreground-secondary); +} + +.reading-input { + @include use-typography("body-medium"); + + inline-size: px2rem(180); + block-size: $sz-32; + margin: 0; + padding: 0 var(--sp-s); + border: $b-1 solid transparent; + border-radius: $br-6; + background-color: var(--color-background-primary); + color: var(--color-foreground-primary); + caret-color: var(--color-accent-primary); + + &:focus { + border-color: var(--color-accent-primary); + outline: none; + } + + &[aria-invalid="true"] { + border-color: var(--color-accent-error); + } +} + +.remove-button { + @extend %plain-button; + + padding: 0 var(--sp-s); + color: var(--color-foreground-primary); +} + +.more-button { + @extend %plain-button; + + padding: 0 var(--sp-s); + color: var(--color-foreground-secondary); +} + +// The menu icon is vertical; the design shows it lying down. +.more-icon { + rotate: 90deg; +} diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index cfa1a3d38d..9227f390ed 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -29,6 +29,7 @@ [app.main.ui.measurements :as msr] [app.main.ui.workspace.shapes.path.editor :refer [path-editor*]] [app.main.ui.workspace.shapes.text.editor :as editor-v1] + [app.main.ui.workspace.shapes.text.selection-menu :as text-selection-menu] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline*]] [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] [app.main.ui.workspace.shapes.text.v3-editor :as editor-v3] @@ -695,7 +696,13 @@ (when picking-color? [:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref - :canvas-ref canvas-ref}])] + :canvas-ref canvas-ref}]) + + (when (and show-text-editor? + (features/active-feature? @st/state "text-editor-wasm/v1")) + [:> text-selection-menu/selection-menu* {:shape editing-shape + :vbox vbox + :zoom zoom}])] [:canvas {:id "render" :data-testid "canvas-wasm-shapes" diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7156fc96c2..ba2cb4f976 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -417,16 +417,30 @@ ;; See `request-render-preserving-target`. (defonce ^:private preserve-target-render? (atom false)) +(defn- sync-text-editor-selection-rect! + "Store the box around the editor selection when it moved: the selection, + the text or its layout changed." + [] + (let [shape-id (text-editor/text-editor-get-active-shape-id) + rect (text-editor/text-editor-get-selection-rect)] + (when (not= rect (dm/get-in @st/state [:workspace-wasm-editor-selection-rects shape-id])) + (st/emit! (texts/v3-update-text-editor-selection-rect shape-id rect))))) + (defn- drain-text-editor-events! "Pop and handle every pending text-editor event. - StylesChanged syncs the caret's current styles to the toolbar. Returns true - when some event needs a full shape re-render (content or layout changed)." + StylesChanged syncs the caret's current styles to the toolbar, and any + event refreshes the box around the selection. Returns true when some event + needs a full shape re-render (content or layout changed)." [] - (loop [needs-render? false] + (loop [needs-render? false + drained? false] (let [ev (text-editor/text-editor-poll-event)] (if (or (nil? ev) (= ev TEXT_EDITOR_EVENT_NONE)) - needs-render? + (do + (when drained? + (sync-text-editor-selection-rect!)) + needs-render?) (do (when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED) (let [current-styles (merge @@ -438,7 +452,8 @@ (st/emit! (texts/v3-update-text-editor-styles shape-id current-styles)))) (recur (or needs-render? (= ev TEXT_EDITOR_EVENT_CONTENT_CHANGED) - (= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT)))))))) + (= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT)) + true)))))) ;; This should never be called from the outside. (defn- render @@ -3053,6 +3068,42 @@ :fills (get element :fills) :text (subs element-text start-pos end-pos)}))))) +(defn- ruby-base-starts + "First span of the reading of each span of `paragraph` that has one + (`cjk/ruby-groups`), by span index." + [paragraph] + (into {} + (mapcat (fn [[start end]] (map (fn [span] [span start]) (range start end)))) + (cjk/ruby-groups (:children paragraph)))) + +(defn- join-ruby-strips + "A horizontal reading draws once per line of its base. When its base has + several strips on a line (spans of other styles, fallback fonts), the + first keeps the reading, spread over all of them (`:ruby-x`, + `:ruby-width`), and the others drop it. `::ruby-base` keys each strip + with a reading by its paragraph and base." + [entries] + (let [line-of (fn [entry] [(::ruby-base entry) (:y entry)]) + lines (->> entries + (filter #(and (some? (::ruby-base %)) (some? (:ruby %)))) + (group-by line-of))] + (mapv (fn [entry] + (let [strips (get lines (line-of entry)) + first? (identical? entry (first strips)) + entry (dissoc entry ::ruby-base)] + (cond + (< (count strips) 2) + entry + + first? + (let [left (apply min (map :x strips)) + right (apply max (map #(+ (:x %) (:width %)) strips))] + (assoc entry :ruby-x left :ruby-width (- right left))) + + :else + (dissoc entry :ruby)))) + entries))) + (defn calculate-position-data [shape] (when (initialized?) @@ -3078,19 +3129,24 @@ (mem/free) - (into [] - (keep - (fn [{:keys [paragraph span direction] :as entry}] - (let [paragraph-node (-> content :children - (get 0) :children ;; paragraph-set - (get paragraph)) - element (-> paragraph-node :children (get span))] - (condp = direction - POSITION-DATA-RUBY (ruby-strip-entry element entry) - POSITION-DATA-ZHUYIN (zhuyin-glyph-entry element entry) - POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry) - (text-strip-entry paragraph-node element entry))))) - result)))) + (let [base-starts (memoize ruby-base-starts)] + (->> result + (into [] + (keep + (fn [{:keys [paragraph span direction] :as entry}] + (let [paragraph-node (-> content :children + (get 0) :children ;; paragraph-set + (get paragraph)) + element (-> paragraph-node :children (get span))] + (condp = direction + POSITION-DATA-RUBY (ruby-strip-entry element entry) + POSITION-DATA-ZHUYIN (zhuyin-glyph-entry element entry) + POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry) + (some-> (text-strip-entry paragraph-node element entry) + (assoc ::ruby-base + (when-let [start (get (base-starts paragraph-node) span)] + [paragraph start])))))))) + (join-ruby-strips)))))) (defn apply-canvas-blur [] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 1eded1a9e7..8a55f46fa4 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -22,7 +22,8 @@ [app.render-wasm.api.fonts :as fonts] [app.render-wasm.text-editor-history :as history] [app.util.color :as uc] - [app.util.dom :as dom])) + [app.util.dom :as dom] + [cuerdas.core :as str])) (def multiple-state-multiple (sr/translate-multiple-state :multiple)) @@ -406,6 +407,23 @@ (mem/free) {:x x :y y :width width :height height}))))) +(defn text-editor-get-selection-rect + "Read the box around the selected text from the WASM editor, in page + coordinates: a map with :x :y :width :height that encloses the text as + drawn, rotated or not. Nil when the selection is collapsed." + [] + (when (wasm/ready?) + (let [ptr (h/call wasm/internal-module "_text_editor_get_selection_rect")] + (when (and ptr (not (zero? ptr))) + (let [heap-f32 (mem/get-heap-f32) + offset (mem/->offset-32 ptr) + x (aget heap-f32 offset) + y (aget heap-f32 (+ offset 1)) + width (aget heap-f32 (+ offset 2)) + height (aget heap-f32 (+ offset 3))] + (mem/free) + {:x x :y y :width width :height height}))))) + (defn text-editor-encode-text-pre [text] (when (and (not (empty? text)) @@ -878,8 +896,45 @@ (if (= idx end-para) end-offset (para-char-count para))))) (range start-para (inc end-para)))) +(defn- span-bounds + "Char range `[start end)` of each span of `paragraph`." + [paragraph] + (->> (:children paragraph) + (reductions (fn [[_ end] span] [end (+ end (count (:text span)))]) [0 0]) + (rest) + (vec))) + +(defn- annotation-bases + "Char ranges `[start end)` of the annotated text of `paragraph`: each + reading (`cjk/ruby-groups`, which can cross spans) and each warichu note." + [paragraph] + (let [spans (:children paragraph) + bounds (span-bounds paragraph)] + (into (mapv (fn [[start end]] [(first (nth bounds start)) (second (nth bounds (dec end)))]) + (cjk/ruby-groups spans)) + (keep-indexed (fn [index span] + (when (= "warichu" (:warichu span)) (nth bounds index)))) + spans))) + (defn reading-selection? - "True for characters in one span, including the whole base of an existing reading." + "True for text that can take one reading: characters of one paragraph + without a reading or warichu note, whatever their styles (the reading + joins their spans into one base), or the whole base of an existing one." + [content selection] + (boolean + (when (and content selection) + (let [{:keys [start-para start-offset end-para end-offset]} (normalize-selection selection) + paragraph (get-in content [:children 0 :children start-para])] + (and (= start-para end-para) + (some? paragraph) + (< start-offset end-offset) + (or (not-any? cjk/annotated-span? + (paragraph-selected-spans paragraph start-offset end-offset)) + (some #(= [start-offset end-offset] %) (annotation-bases paragraph)))))))) + +(defn single-span-selection? + "True for characters of one span, including the whole of an annotated one: + the text a warichu note can take, as it never crosses spans." [content selection] (boolean (when (and content selection) @@ -956,6 +1011,15 @@ (merge (selection-cjk-styles content selection) (selection-paragraph-styles content selection))))) +(defn- whole-base-span? + "True for an annotated span that a decoration edit must cover whole: a + warichu note, or a reading without `:ruby-group`, which splitting would + repeat over each piece. A grouped reading keeps one base when split." + [span] + (and (cjk/annotated-span? span) + (or (= "warichu" (:warichu span)) + (str/blank? (:ruby-group span))))) + (defn- annotation-style-range [paragraph start end styles] (if (and (< start end) @@ -965,7 +1029,7 @@ (let [[start end] (reduce (fn [[from to offset] span] (let [span-end (+ offset (count (:text span)))] - (if (and (< offset end) (< start span-end) (cjk/annotated-span? span)) + (if (and (< offset end) (< start span-end) (whole-base-span? span)) [(min from offset) (max to span-end) span-end] [from to span-end]))) [start end 0] @@ -1171,11 +1235,19 @@ (= k :text-decoration) [k "none"]))) styles))) +(defn- with-ruby-group + "A reading set on a selection joins its spans into one base with a fresh + `:ruby-group`, whatever their styles; removing the reading drops it." + [styles] + (if (and (map? styles) (contains? styles :ruby) (not (contains? styles :ruby-group))) + (assoc styles :ruby-group (if (str/blank? (:ruby styles)) "" (str (uuid/next)))) + styles)) + (defn apply-styles-to-selection "Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`." [styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]] (when (wasm/ready?) - (let [styles (clean-styles styles) + (let [styles (-> styles clean-styles with-ruby-group) shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs index 8ea5eebfc5..addf07e98a 100644 --- a/frontend/src/app/util/code_gen/style_css.cljs +++ b/frontend/src/app/util/code_gen/style_css.cljs @@ -275,16 +275,31 @@ body { (types.text/node-seq types.text/is-text-node? (:content shape))) (dm/fmt ".% .tcy {\n%\n}" shape-selector (format-js-styles sts/tcy-run-style nil)))) +(defn- joined-ruby-ids + "Ids of the text nodes that continue the reading of an earlier node + (`cjk/ruby-groups`): the markup draws one reading per base, under the + wrapper of its first node." + [content] + (into #{} + (comp (filter types.text/is-paragraph-node?) + (mapcat (fn [paragraph] + (let [nodes (vec (:children paragraph))] + (for [[start end] (cjk/ruby-groups nodes) + index (range (inc start) end)] + (:$id (nth nodes index))))))) + (types.text/node-seq content))) + (defn generate-text-css [shape] - (let [selector (cgc/shape->selector shape)] - (->> shape - :content - (legacy.txt/index-content) + (let [selector (cgc/shape->selector shape) + content (legacy.txt/index-content (:content shape)) + joined (joined-ruby-ids content)] + (->> content (types.text/node-seq) (mapcat (fn [node] [(node->css shape selector node) - (ruby-css shape selector node)])) + (when-not (contains? joined (:$id node)) + (ruby-css shape selector node))])) (cons (tcy-css shape selector)) (remove nil?) (str/join "\n")))) diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs index 4fb51a260c..e3e74fdf37 100644 --- a/frontend/test/frontend_tests/code_gen_style_test.cljs +++ b/frontend/test/frontend_tests/code_gen_style_test.cljs @@ -390,3 +390,30 @@ (is (str/includes? css "text-emphasis-position: over left")) (is (str/includes? css "text-underline-position: under right")) (is (str/includes? css "text-emphasis-position: under right")))) + +(def ^:private joined-ruby-text-content + "One reading over two spans, the second with emphasis marks." + (assoc-in ruby-text-content [:children 0 :children 0 :children] + [{:text "漢" :ruby "かんじ" :ruby-group "g" :font-size "20" + :fill-color "#000000" :fill-opacity 1} + {:text "字" :ruby "かんじ" :ruby-group "g" :font-size "20" + :text-emphasis "filled-dot" :fill-color "#000000" :fill-opacity 1}])) + +(defn- count-of [re text] + (count (re-seq re text))) + +(deftest a-reading-over-two-spans-is-one-ruby-in-the-markup + (testing "generated markup and the foreignObject render draw the reading once over both spans" + (let [text (text-shape joined-ruby-text-content)] + (doseq [markup [(html/generate-markup (objects text) [text]) + (rds/renderToStaticMarkup + (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]] + (is (= 1 (count-of #"]*>.*漢.*字.* Option { + let matrix = shape.get_matrix(); + let bounds = range_rects(start, end, text_content, shape) + .iter() + .map(|rect| matrix.map_rect(rect).0) + .reduce(|mut bounds, rect| { + bounds.join(rect); + bounds + })?; + (!bounds.is_empty()).then_some(bounds) +} + /// Line boxes, shape-local, of the text between `start` and `end` (paragraph, /// char offset). fn range_rects( @@ -531,6 +552,54 @@ mod tests { assert!(world.y > 10.0, "{world:?}"); } + fn page_bounds_of(shape: &Shape, start: usize, end: usize) -> Option { + let Type::Text(content) = &shape.shape_type else { + unreachable!() + }; + range_page_bounds((0, start), (0, end), content, shape) + } + + #[test] + fn range_page_bounds_are_offset_by_the_shape_position() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let mut shape = text_shape("hello world", 500.0); + let local = page_bounds_of(&shape, 6, 11).expect("world has text"); + shape.set_selrect(100.0, 50.0, 600.0, 150.0); + + let page = page_bounds_of(&shape, 6, 11).expect("world has text"); + + assert!( + (page.left() - (local.left() + 100.0)).abs() < 0.01, + "{page:?}" + ); + assert!((page.top() - (local.top() + 50.0)).abs() < 0.01, "{page:?}"); + assert!((page.width() - local.width()).abs() < 0.01, "{page:?}"); + } + + #[test] + fn range_page_bounds_of_wrapped_text_cover_every_line() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + // Too narrow for both words on one line. + let shape = text_shape("hello world", 45.0); + + let hello = page_bounds_of(&shape, 0, 5).expect("hello has text"); + let both = page_bounds_of(&shape, 0, 11).expect("the text has text"); + + assert!(both.top() <= hello.top() + 0.01, "{both:?}"); + assert!(both.height() > hello.height() * 1.5, "{both:?}"); + } + + #[test] + fn range_page_bounds_are_none_for_an_empty_range() { + let mut resources = RenderResources::try_new_headless().expect("headless resources"); + let _guard = TestRenderResourcesGuard::install(&mut resources); + let shape = text_shape("hello", 500.0); + + assert!(page_bounds_of(&shape, 2, 2).is_none()); + } + #[test] fn range_origin_is_none_for_an_empty_range() { let mut resources = RenderResources::try_new_headless().expect("headless resources"); diff --git a/render-wasm/src/shapes/line_breaks.rs b/render-wasm/src/shapes/line_breaks.rs index 16211ce3be..ce41703b05 100644 --- a/render-wasm/src/shapes/line_breaks.rs +++ b/render-wasm/src/shapes/line_breaks.rs @@ -173,12 +173,15 @@ pub fn apply_policy_to_span_texts( ruby_breaks: &[Option>], policy: TextLayoutPolicy, ) -> Option<(Vec, OffsetMap)> { - apply_policy_with_breaks(span_texts, ruby_breaks, policy, &[], &[]) + apply_policy_with_breaks(span_texts, ruby_breaks, &[], policy, &[], &[]) } +/// `joined[span]` is true when the span continues the ruby base of the span +/// before it, so its start is internal to that base too. pub fn apply_policy_with_breaks( span_texts: &[String], ruby_breaks: &[Option>], + joined: &[bool], policy: TextLayoutPolicy, emergency_breaks: &[usize], forced_breaks: &[usize], @@ -211,7 +214,8 @@ pub fn apply_policy_with_breaks( continue; } let current = (c, classify(c)); - let ruby_forbids_break = local_utf16 > 0 + let inside_base = local_utf16 > 0 || joined.get(span_index).copied().unwrap_or(false); + let ruby_forbids_break = inside_base && ruby_breaks .get(span_index) .and_then(Option::as_ref) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 65f695b4ac..55d5d14829 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -2268,6 +2268,41 @@ impl Paragraph { &self.children } + /// Index ranges of the spans whose text forms one ruby base: a span with + /// a reading and the spans after it that join it + /// (`TextSpan::shares_ruby_base_with`). A reading over characters of + /// several styles, such as some with emphasis marks, is one base. + pub fn ruby_groups(&self) -> Vec> { + let mut groups: Vec> = Vec::new(); + for (index, span) in self.children.iter().enumerate() { + if !span.has_ruby() { + continue; + } + match groups.last_mut() { + Some(group) + if group.end == index + && span.shares_ruby_base_with(&self.children[index - 1]) => + { + group.end = index + 1 + } + _ => groups.push(index..index + 1), + } + } + groups + } + + /// Index in `ruby_groups` of the base of each span, or None for a span + /// without a reading. + pub fn ruby_group_of_spans(&self) -> Vec> { + let mut group_of = vec![None; self.children.len()]; + for (group, spans) in self.ruby_groups().into_iter().enumerate() { + for span in spans { + group_of[span] = Some(group); + } + } + group_of + } + pub fn children_mut(&mut self) -> &mut Vec { &mut self.children } @@ -2550,6 +2585,10 @@ pub struct TextSpan { /// Ruby (furigana) annotation for this span; empty means no ruby. pub ruby: String, pub ruby_zhuyin: bool, + /// The span continues the reading of the span before it: the frontend + /// gives the spans of one reading the same group, so their text forms + /// one base (`Paragraph::ruby_groups`). + pub ruby_joins_previous: bool, pub ruby_size: RubySize, pub ruby_align: RubyAlign, pub ruby_overhang: RubyOverhang, @@ -2585,6 +2624,7 @@ impl Default for TextSpan { text_underline_position: TextUnderlinePosition::default(), ruby: String::default(), ruby_zhuyin: false, + ruby_joins_previous: false, ruby_size: RubySize::default(), ruby_align: RubyAlign::default(), ruby_overhang: RubyOverhang::default(), @@ -2659,6 +2699,20 @@ impl TextSpan { !self.warichu && !self.ruby_text().is_empty() } + /// True when this span continues the reading of `previous`, the span + /// before it: it joins that reading and shows the same one, so their text + /// forms one base whose first span sets the presentation. Neighbours with + /// the same reading stay apart without the join, as in 天天 (tiān tiān). + /// Zhuyin annotates one character, so its spans stand alone. + pub fn shares_ruby_base_with(&self, previous: &TextSpan) -> bool { + self.ruby_joins_previous + && self.has_ruby() + && previous.has_ruby() + && !self.is_zhuyin() + && !previous.is_zhuyin() + && self.ruby_text() == previous.ruby_text() + } + pub fn ruby_font_size(&self) -> f32 { self.font_size * if self.is_zhuyin() { @@ -3323,6 +3377,77 @@ mod tests { assert_eq!(span.annotation_room_em(), 0.0); } + fn reading_span(text: &str, ruby: &str, joins_previous: bool) -> TextSpan { + TextSpan { + text: text.to_string(), + ruby: ruby.to_string(), + ruby_joins_previous: joins_previous, + ..TextSpan::default() + } + } + + fn paragraph_of(spans: Vec) -> Paragraph { + Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + spans, + ) + } + + #[test] + fn a_reading_over_spans_of_other_styles_is_one_base() { + let emphasized = TextSpan { + text_emphasis: TextEmphasis::FilledSesame, + ..reading_span("本", "にほん", true) + }; + let paragraph = paragraph_of(vec![ + reading_span("日", "にほん", false), + emphasized, + reading_span("語", "", false), + ]); + + assert_eq!(paragraph.ruby_groups(), vec![0..2]); + assert_eq!( + paragraph.ruby_group_of_spans(), + vec![Some(0), Some(0), None] + ); + } + + #[test] + fn neighbours_with_the_same_reading_stay_apart_without_the_join() { + let paragraph = paragraph_of(vec![ + reading_span("天", "tiān", false), + reading_span("天", "tiān", false), + ]); + + assert_eq!(paragraph.ruby_groups(), vec![0..1, 1..2]); + } + + #[test] + fn a_join_to_another_reading_starts_a_new_base() { + let paragraph = paragraph_of(vec![ + reading_span("日", "に", false), + reading_span("本", "ほん", true), + ]); + + assert_eq!(paragraph.ruby_groups(), vec![0..1, 1..2]); + } + + #[test] + fn zhuyin_readings_never_join() { + let zhuyin = |joins_previous| TextSpan { + ruby_zhuyin: true, + ..reading_span("天", "ㄊㄧㄢ", joins_previous) + }; + let paragraph = paragraph_of(vec![zhuyin(false), zhuyin(true)]); + + assert_eq!(paragraph.ruby_groups(), vec![0..1, 1..2]); + } + #[test] fn vertical_align_top_keeps_the_content_at_the_origin() { assert_eq!(vertical_align_offset(200.0, 60.0, VerticalAlign::Top), 0.0); diff --git a/render-wasm/src/shapes/text_horizontal/justify.rs b/render-wasm/src/shapes/text_horizontal/justify.rs index 9cfa04589e..2d214da6db 100644 --- a/render-wasm/src/shapes/text_horizontal/justify.rs +++ b/render-wasm/src/shapes/text_horizontal/justify.rs @@ -167,6 +167,8 @@ pub(crate) fn horizontal_pairwise_justify_spacing( }) .collect(); let mut result = vec![Vec::new(); plan.texts.len()]; + // Ruby base of each span: distribution never opens a base. + let group_of = paragraph.ruby_group_of_spans(); let notes: Vec<_> = plan .offsets .ranges @@ -202,8 +204,8 @@ pub(crate) fn horizontal_pairwise_justify_spacing( || nonbreaking || starts.binary_search(&(index + 1)).is_err() || (distribute - && before.span == after.span - && paragraph.children()[before.span].has_ruby()) + && group_of[before.span].is_some() + && group_of[before.span] == group_of[after.span]) || note.is_some_and(|r| { r.builder_start > before.utf16 && r.builder_start < after.utf16 }) diff --git a/render-wasm/src/shapes/text_horizontal/mod.rs b/render-wasm/src/shapes/text_horizontal/mod.rs index 735ac66d1c..2144fba601 100644 --- a/render-wasm/src/shapes/text_horizontal/mod.rs +++ b/render-wasm/src/shapes/text_horizontal/mod.rs @@ -75,8 +75,21 @@ fn layout_span_texts_with_breaks( .iter() .map(|span| span.has_ruby().then(Vec::new)) .collect(); - let shifted = - line_breaks::apply_policy_with_breaks(&texts, &ruby_breaks, policy, emergency, forced); + // A base that crosses spans stays whole across their boundaries. + let group_of = paragraph.ruby_group_of_spans(); + let joined: Vec = group_of + .iter() + .enumerate() + .map(|(index, group)| index > 0 && group.is_some() && *group == group_of[index - 1]) + .collect(); + let shifted = line_breaks::apply_policy_with_breaks( + &texts, + &ruby_breaks, + &joined, + policy, + emergency, + forced, + ); if let Some((shifted, map)) = shifted { return (shifted, map); } diff --git a/render-wasm/src/shapes/text_horizontal/overflow.rs b/render-wasm/src/shapes/text_horizontal/overflow.rs index 685ca48589..97a01aa8ff 100644 --- a/render-wasm/src/shapes/text_horizontal/overflow.rs +++ b/render-wasm/src/shapes/text_horizontal/overflow.rs @@ -13,19 +13,25 @@ fn transformed_text(paragraph: &Paragraph) -> String { .collect() } -/// UTF-16 ranges, in the transformed text, of the spans whose ruby or -/// warichu must stay whole. +/// UTF-16 ranges, in the transformed text, of the ruby bases +/// (`Paragraph::ruby_groups`) and warichu spans that must stay whole. fn annotation_ranges(paragraph: &Paragraph) -> Vec> { + let group_of = paragraph.ruby_group_of_spans(); let mut offset = 0; - paragraph - .children() - .iter() - .filter_map(|span| { - let start = offset; - offset += span.apply_text_transform().encode_utf16().count(); - (span.has_ruby() || span.is_warichu()).then_some(start..offset) - }) - .collect() + let mut ranges: Vec<(Option, std::ops::Range)> = Vec::new(); + for (index, span) in paragraph.children().iter().enumerate() { + let start = offset; + offset += span.apply_text_transform().encode_utf16().count(); + let group = group_of[index]; + match ranges.last_mut() { + Some((last_group, range)) if group.is_some() && *last_group == group => { + range.end = offset + } + _ if group.is_some() || span.is_warichu() => ranges.push((group, start..offset)), + _ => {} + } + } + ranges.into_iter().map(|(_, range)| range).collect() } /// True when `position` falls strictly inside one of the ascending diff --git a/render-wasm/src/shapes/text_horizontal/ruby.rs b/render-wasm/src/shapes/text_horizontal/ruby.rs index f6a4a8b376..d7bcf3dced 100644 --- a/render-wasm/src/shapes/text_horizontal/ruby.rs +++ b/render-wasm/src/shapes/text_horizontal/ruby.rs @@ -198,24 +198,35 @@ pub(super) fn horizontal_ruby_spacing_with( .collect() }; let mut claims = OverhangClaims::new(); - let mut span_start = 0usize; - for (index, (span, text)) in spans.iter().zip(texts).enumerate() { - let text_start = span_start; - span_start += text.encode_utf16().count(); - if !span.has_ruby() { - continue; - } - // Source characters of the base; inserted spacing and joiners are not. - let mut utf16 = text_start; - let base: Vec = text - .chars() - .enumerate() - .filter(|(_, ch)| { - let inserted = offset_map.is_inserted(utf16); - utf16 += ch.len_utf16(); - !inserted + // UTF-16 start of each span in the layout text. + let span_starts: Vec = texts + .iter() + .scan(0usize, |start, text| { + let span_start = *start; + *start += text.encode_utf16().count(); + Some(span_start) + }) + .collect(); + for group in paragraph.ruby_groups() { + // The first span holds the reading and its presentation. + let index = group.start; + let span = &spans[index]; + // Source characters of the base, as (span, char index); inserted + // spacing and joiners are not. + let base: Vec<(usize, usize)> = group + .clone() + .flat_map(|member| { + let mut utf16 = span_starts[member]; + texts[member] + .chars() + .enumerate() + .filter(move |(_, ch)| { + let inserted = offset_map.is_inserted(utf16); + utf16 += ch.len_utf16(); + !inserted + }) + .map(move |(char_index, _)| (member, char_index)) }) - .map(|(char_index, _)| char_index) .collect(); let Some(&last) = base.last() else { continue; @@ -223,8 +234,8 @@ pub(super) fn horizontal_ruby_spacing_with( if span.is_zhuyin() { // Inter-character annotations consume inline space, not ruby's // above/below leading. Reserve the strip even at a line end. - for char_index in &base { - spacing.adjustments[index].push(( + for (member, char_index) in &base { + spacing.adjustments[*member].push(( *char_index, span.font_size * super::super::text_zhuyin::STRIP_WIDTH, )); @@ -232,10 +243,21 @@ pub(super) fn horizontal_ruby_spacing_with( continue; } let ruby_font_size = span.ruby_font_size(); - let base_width = advances(span, &span.apply_text_transform(), span.font_size) - .iter() - .sum::() - + span.letter_spacing * base.len() as f32; + let base_width: f32 = group + .clone() + .map(|member| { + let member_span = &spans[member]; + let chars = base.iter().filter(|(owner, _)| *owner == member).count(); + advances( + member_span, + &member_span.apply_text_transform(), + member_span.font_size, + ) + .iter() + .sum::() + + member_span.letter_spacing * chars as f32 + }) + .sum(); // The painter sets the reading in pieces (see `RubyLine::paint`). let shaped = shape_segment_with_fallbacks( span.ruby_text(), @@ -250,8 +272,8 @@ pub(super) fn horizontal_ruby_spacing_with( let (_, pieces, _) = horizontal_ruby_pieces(&ruby_glyph_refs(span.ruby_text(), &shaped, ruby_font_size)); let ruby_length: f32 = pieces.iter().sum(); - let previous = layout_neighbour(texts, index, false); - let next = layout_neighbour(texts, index, true); + let previous = layout_neighbour(texts, group.start, false); + let next = layout_neighbour(texts, group.end - 1, true); let room = |neighbour: Option<(usize, usize, char)>| { neighbour.map_or(0.0, |(owner, _, ch)| { ruby_overhang_room( @@ -276,16 +298,20 @@ pub(super) fn horizontal_ruby_spacing_with( continue; } spacing.base_gaps[index] = gap; - for &char_index in &base { - let amount = if char_index == last { gap / 2.0 } else { gap }; - spacing.adjustments[index].push((char_index, amount)); + for &(member, char_index) in &base { + let amount = if (member, char_index) == last { + gap / 2.0 + } else { + gap + }; + spacing.adjustments[member].push((char_index, amount)); } match previous { Some((owner, char_index, _)) => { spacing.adjustments[owner].push((char_index, gap / 2.0)); spacing.leading_gaps[index] = gap / 2.0; } - None => spacing.adjustments[index].push((last, gap / 2.0)), + None => spacing.adjustments[last.0].push((last.1, gap / 2.0)), } } spacing @@ -317,26 +343,44 @@ pub(super) fn split_counts_by_extent(extents: &[f32], total: usize) -> Vec Vec<(usize, std::ops::Range)> { paragraph - .children() - .iter() - .zip(&offsets.ranges) - .filter(|(span, range)| span.has_ruby() && range.builder_start < range.builder_end) - .map(|(_, range)| { - ( - range.span, - offsets.transformed_to_builder(range.source_start)..range.builder_end, - ) + .ruby_groups() + .into_iter() + .filter_map(|spans| { + let first = offsets.ranges.get(spans.start)?; + let last = offsets.ranges.get(spans.end - 1)?; + let start = offsets.transformed_to_builder(first.source_start); + (start < last.builder_end).then_some((first.span, start..last.builder_end)) }) .collect() } +/// Rects of a base range joined per line: a base that crosses spans or +/// font runs gets one rect for each run, but its reading spreads over the +/// whole line part. +fn base_line_rects(rects: Vec) -> Vec { + let mut lines: Vec = Vec::new(); + for rect in rects { + match lines.last_mut() { + Some(line) + if rect.rect.top() < line.rect.bottom() && rect.rect.bottom() > line.rect.top() => + { + line.rect.join(rect.rect) + } + _ => lines.push(rect), + } + } + lines +} + /// Paint of the pass that laid out the base: fill, stroke, shadow or mask. /// `fallback` covers a base Skia kept no style metrics for. pub(super) fn horizontal_ruby_paint( @@ -548,11 +592,11 @@ fn visit_horizontal_ruby_glyphs( &offsets.ranges[span_index], merge_fills(&span.fills, bounds), ); - let rects = laid_out.get_rects_for_range( + let rects = base_line_rects(laid_out.get_rects_for_range( span_range, skia::textlayout::RectHeightStyle::Tight, skia::textlayout::RectWidthStyle::Tight, - ); + )); if let Some(syllable) = super::super::text_zhuyin::syllable(span) { if let Some(rect) = rects.first() { let (origin, glyphs) = horizontal_zhuyin_glyphs( diff --git a/render-wasm/src/shapes/text_horizontal/tests.rs b/render-wasm/src/shapes/text_horizontal/tests.rs index fa014da994..17b3779850 100644 --- a/render-wasm/src/shapes/text_horizontal/tests.rs +++ b/render-wasm/src/shapes/text_horizontal/tests.rs @@ -2808,3 +2808,136 @@ fn push_out_first_keeps_push_out_when_it_resolves_the_line_start() { ); } } + +fn reading_paragraph(spans: Vec) -> Paragraph { + let mut paragraph = make_paragraph( + [vec![make_span("a", 0.0)], spans, vec![make_span("b", 0.0)]].concat(), + 0.0, + ); + paragraph.set_language("ja".to_string()); + paragraph +} + +fn long_reading(text: &str, joins_previous: bool) -> TextSpan { + TextSpan { + ruby: "MMMMMMMMMMMM".to_string(), + ruby_overhang: RubyOverhang::None, + ruby_joins_previous: joins_previous, + ..make_span(text, 0.0) + } +} + +#[test] +fn a_joined_long_reading_spreads_its_whole_base_like_one_span() { + init_state(); + let one = test_ruby_spacing(&reading_paragraph(vec![long_reading("ii", false)])); + let paragraph = reading_paragraph(vec![ + long_reading("i", false), + TextSpan { + text_emphasis: TextEmphasis::FilledDot, + ..long_reading("i", true) + }, + ]); + let joined = test_ruby_spacing(¶graph); + // A joiner keeps the line from breaking between the two spans. + let (texts, _) = paragraph.layout_span_texts(); + let second = texts[2].chars().position(|ch| ch == 'i').unwrap(); + + let gap = one.base_gaps[1]; + assert!(gap > 0.0, "the base grows under a long reading"); + assert_eq!(joined.base_gaps[1], gap); + assert_eq!(joined.leading_gaps[1], one.leading_gaps[1]); + assert_eq!(joined.adjustments[1], vec![(0, gap)]); + assert_eq!(joined.adjustments[2], vec![(second, gap / 2.0)]); +} + +#[test] +fn a_joined_reading_never_breaks_between_its_spans() { + init_state(); + let paragraph = reading_paragraph(vec![long_reading("日", false), long_reading("本", true)]); + let (texts, map) = paragraph.layout_span_texts(); + let start = texts[..2] + .iter() + .map(|text| text.encode_utf16().count()) + .sum::(); + + assert!( + texts[2].starts_with('\u{2060}') && map.is_inserted(start), + "a word joiner starts the joined span: {texts:?}" + ); +} + +#[test] +fn a_joined_reading_has_one_target_over_its_spans() { + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let paragraph = reading_paragraph(vec![ + long_reading("日", false), + long_reading("本", true), + long_reading("語", false), + ]); + let plan = HorizontalParagraphPlan::new(¶graph); + let ranges = &plan.offsets.ranges; + + assert_eq!( + horizontal_ruby_targets(¶graph, &plan.offsets), + vec![ + ( + 1, + plan.offsets.transformed_to_builder(ranges[1].source_start)..ranges[2].builder_end + ), + ( + 3, + plan.offsets.transformed_to_builder(ranges[3].source_start)..ranges[3].builder_end + ), + ] + ); +} + +#[test] +fn a_joined_reading_paints_like_the_reading_of_one_span() { + init_state(); + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let ink = |spans: Vec| { + let bounds = crate::math::Rect::from_xywh(0.0, 0.0, 360.0, 60.0); + let mut content = TextContent::new(bounds, crate::shapes::GrowType::Fixed); + content.add_paragraph(reading_paragraph(spans)); + content.update_layout(bounds); + super::ruby::horizontal_ruby_ink_bounds( + &content, + 0, + &content.layout.paragraphs[0][0], + 0.0, + 0.0, + ) + .expect("the reading paints") + }; + let reading = |text: &str, joins_previous| TextSpan { + font_size: 48.0, + ruby: "にほんご".to_string(), + ruby_joins_previous: joins_previous, + ..make_span(text, 0.0) + }; + + let one = ink(vec![reading("日本", false)]); + let joined = ink(vec![ + reading("日", false), + TextSpan { + text_emphasis: TextEmphasis::FilledSesame, + ..reading("本", true) + }, + ]); + + assert!( + (joined.left() - one.left()).abs() < 0.5, + "{joined:?} vs {one:?}" + ); + assert!( + (joined.right() - one.right()).abs() < 0.5, + "{joined:?} vs {one:?}" + ); +} diff --git a/render-wasm/src/shapes/text_vertical/annotations.rs b/render-wasm/src/shapes/text_vertical/annotations.rs index 18cf50a347..e8cef8443e 100644 --- a/render-wasm/src/shapes/text_vertical/annotations.rs +++ b/render-wasm/src/shapes/text_vertical/annotations.rs @@ -102,11 +102,12 @@ pub(super) fn ruby_strip_x( } } -/// The base text of one ruby span: the transformed UTF-16 range it covers in -/// its paragraph and the glyph count of its reading. -#[derive(Debug, Clone, Copy)] +/// The base text of one reading: its spans (`Paragraph::ruby_groups`), the +/// transformed UTF-16 range they cover in their paragraph and the glyph +/// count of the reading, which the first span holds. +#[derive(Debug, Clone)] pub(super) struct RubyBaseUnit { - span: usize, + spans: std::ops::Range, start: usize, end: usize, /// Flow length of the reading set solid. @@ -119,7 +120,7 @@ pub(super) struct RubyBaseUnit { impl RubyBaseUnit { fn annotates(&self, cell: &VerticalCell) -> bool { - cell.span == self.span && cell.start < self.end && cell.end > self.start + self.spans.contains(&cell.span) && cell.start < self.end && cell.end > self.start } /// Flow length of the reading set solid. @@ -136,16 +137,29 @@ pub(super) fn ruby_base_units( span_starts: &[usize], fonts: &Fonts, ) -> Vec { + let spans = paragraph.children(); paragraph - .children() - .iter() - .zip(transforms) - .enumerate() - .filter(|(_, (span, transform))| span.has_ruby() && !transform.text.is_empty()) - .map(|(span_index, (span, transform))| RubyBaseUnit { - span: span_index, - start: span_starts[span_index], - end: span_starts[span_index] + transform.text.encode_utf16().count(), + .ruby_groups() + .into_iter() + .filter(|group| { + group + .clone() + .any(|index| !transforms[index].text.is_empty()) + }) + .map(|group| { + let span = &spans[group.start]; + let last = group.end - 1; + ( + group.clone(), + span, + span_starts[group.start], + span_starts[last] + transforms[last].text.encode_utf16().count(), + ) + }) + .map(|(group, span, start, end)| RubyBaseUnit { + spans: group, + start, + end, ruby_length: if span.is_zhuyin() { span.font_size } else { @@ -250,7 +264,7 @@ fn units_by_span(ruby_units: &[RubyBaseUnit]) -> HashMap { ruby_units .iter() .enumerate() - .map(|(index, unit)| (unit.span, index)) + .flat_map(|(index, unit)| unit.spans.clone().map(move |span| (span, index))) .collect() } @@ -300,7 +314,7 @@ pub(super) type RubyRooms = HashMap<(usize, usize), (f32, f32)>; pub(super) fn ruby_rooms(paragraph: usize, ruby_units: &[RubyBaseUnit]) -> RubyRooms { ruby_units .iter() - .map(|unit| ((paragraph, unit.span), unit.room)) + .map(|unit| ((paragraph, unit.spans.start), unit.room)) .collect() } @@ -446,8 +460,9 @@ fn room_inside_column( /// Ruby (furigana) placement. Runs after column placement, since ruby follows /// its base's final column and flow extent. Group ruby keeps each base in one -/// column, so each reading is one `RubyCell`. `rooms` holds the overhang -/// room of each (paragraph, span). +/// column, so each reading is one `RubyCell`, keyed by the first span of its +/// base (`Paragraph::ruby_groups`). `rooms` holds the overhang room of each +/// (paragraph, first span). pub(super) fn layout_ruby( text_content: &TextContent, cells: &[VerticalCell], @@ -459,13 +474,19 @@ pub(super) fn layout_ruby( let span_cells = cells_by_span(cells); let column_cells = cells_by_column(cells); for (paragraph_index, paragraph) in text_content.paragraphs().iter().enumerate() { - for (span_index, span) in paragraph.children().iter().enumerate() { - if !span.has_ruby() { + for group in paragraph.ruby_groups() { + // The first span holds the reading; the base is the text of + // every span of the group. + let span_index = group.start; + let span = ¶graph.children()[span_index]; + let base: Vec = group + .filter_map(|index| span_cells.get(&(paragraph_index, index))) + .flatten() + .copied() + .collect(); + if base.is_empty() { continue; } - let Some(base) = span_cells.get(&(paragraph_index, span_index)) else { - continue; - }; let (Some(first), Some(last)) = ( base.iter() .map(|index| &cells[*index]) @@ -1509,6 +1530,66 @@ mod tests { } } + fn reading_over(spans: Vec) -> VerticalLayout { + layout_content( + &spans_content( + [vec![make_span("か")], spans, vec![make_span("か")]].concat(), + 1000.0, + ), + 1000.0, + ) + } + + #[test] + fn a_reading_joined_over_spans_of_other_styles_is_set_like_one_span() { + let one = reading_over(vec![TextSpan { + ruby: "にほんご".to_string(), + ..make_span("日本") + }]); + let joined = reading_over(vec![ + TextSpan { + ruby: "にほんご".to_string(), + ..make_span("日") + }, + TextSpan { + ruby: "にほんご".to_string(), + ruby_joins_previous: true, + text_emphasis: TextEmphasis::FilledSesame, + ..make_span("本") + }, + ]); + + assert_eq!(joined.ruby_cells.len(), 1, "one reading over the two spans"); + let (one, joined) = (&one.ruby_cells[0], &joined.ruby_cells[0]); + assert_eq!( + joined.span, 1, + "the first span of the base holds the reading" + ); + assert_eq!(joined.glyphs.len(), one.glyphs.len()); + for (a, b) in one.glyph_tops.iter().zip(&joined.glyph_tops) { + assert!( + (a - b).abs() < 0.01, + "glyph at {b}, one span sets it at {a}" + ); + } + } + + #[test] + fn neighbour_readings_without_the_join_annotate_each_span() { + let layout = reading_over(vec![ + TextSpan { + ruby: "てん".to_string(), + ..make_span("天") + }, + TextSpan { + ruby: "てん".to_string(), + ..make_span("天") + }, + ]); + + assert_eq!(layout.ruby_cells.len(), 2); + } + fn neighbour_ruby_content(overhang: RubyOverhang) -> TextContent { spans_content( vec![ diff --git a/render-wasm/src/shapes/text_vertical/layout.rs b/render-wasm/src/shapes/text_vertical/layout.rs index 43ff4db5d6..b6b7113116 100644 --- a/render-wasm/src/shapes/text_vertical/layout.rs +++ b/render-wasm/src/shapes/text_vertical/layout.rs @@ -475,13 +475,15 @@ fn keep_transform_expansions_together(flow: &mut [FlowCell], text_map: &Paragrap } } -/// A span reading is group ruby: its base moves between columns whole, so -/// the reading never splits. +/// A reading is group ruby: its base, which can cross spans +/// (`Paragraph::ruby_groups`), moves between columns whole, so the reading +/// never splits. fn keep_ruby_bases_together(flow: &mut [FlowCell], paragraph: &Paragraph) { - let spans = paragraph.children(); + let group_of = paragraph.ruby_group_of_spans(); + let group = |span: usize| group_of.get(span).copied().flatten(); for index in 1..flow.len() { - let span = flow[index].cell.span; - if flow[index - 1].cell.span == span && spans.get(span).is_some_and(TextSpan::has_ruby) { + let current = group(flow[index].cell.span); + if current.is_some() && group(flow[index - 1].cell.span) == current { flow[index].keep_with_previous = true; } } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index db8c3f8a45..11e552d9f3 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -228,15 +228,17 @@ impl From for shapes::TextSpan { span.ruby_size = RawRubySize::from(value.ruby_size).into(); span.ruby_align = RawRubyAlign::from(value.ruby_align).into(); span.ruby_overhang = RawRubyOverhang::from(value.ruby_overhang).into(); - // Bit 1 selects Zhuyin; bit 0 retains the existing over/under side. + // Bit 2 joins the span to the reading of the span before it, bit 1 + // selects Zhuyin and bit 0 retains the existing over/under side. // Reject unknown flags instead of treating corrupt bytes as Zhuyin. - let ruby_flags = if value.ruby_flags <= 3 { + let ruby_flags = if value.ruby_flags <= 7 { value.ruby_flags } else { 0 }; span.ruby_side = RawRubySide::from(ruby_flags & 1).into(); span.ruby_zhuyin = ruby_flags & 2 != 0; + span.ruby_joins_previous = ruby_flags & 4 != 0; span } } @@ -536,18 +538,21 @@ mod tests { /// layouts. #[test] fn zhuyin_flags_keep_legacy_side_and_header_size() { - for (flags, zhuyin, side) in [ - (0, false, AnnotationSide::Over), - (1, false, AnnotationSide::Under), - (2, true, AnnotationSide::Over), - (3, true, AnnotationSide::Under), - (255, false, AnnotationSide::Over), + for (flags, zhuyin, side, joins) in [ + (0, false, AnnotationSide::Over, false), + (1, false, AnnotationSide::Under, false), + (2, true, AnnotationSide::Over, false), + (3, true, AnnotationSide::Under, false), + (4, false, AnnotationSide::Over, true), + (5, false, AnnotationSide::Under, true), + (255, false, AnnotationSide::Over, false), ] { let mut bytes = [0u8; std::mem::size_of::()]; bytes[13] = flags; let span: shapes::TextSpan = RawTextSpan::from(bytes).into(); assert_eq!(span.ruby_zhuyin, zhuyin); assert_eq!(span.ruby_side, side); + assert_eq!(span.ruby_joins_previous, joins); } } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 4bac093051..a6c66b9723 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -3,7 +3,7 @@ use macros::{wasm_error, ToJs}; use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point}; use crate::mem; -use crate::render::text_editor::range_origin; +use crate::render::text_editor::{range_origin, range_page_bounds}; use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; @@ -746,6 +746,52 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { }) } +/// Box in page coordinates around the selected text (x, y, width, height), +/// enclosing it as drawn on a rotated text. Null when the selection is +/// collapsed or covers no text. +#[no_mangle] +pub extern "C" fn text_editor_get_selection_rect() -> *mut u8 { + with_state!(state, { + let editor_state = get_text_editor_state(); + if !editor_state.selection.is_selection() { + return std::ptr::null_mut(); + } + + let Some(shape_id) = editor_state.active_shape_id else { + return std::ptr::null_mut(); + }; + + update_text_layout_if_needed(state, shape_id); + + let Some(shape) = state.shapes.get(&shape_id) else { + return std::ptr::null_mut(); + }; + + let Type::Text(text_content) = &shape.shape_type else { + return std::ptr::null_mut(); + }; + + let selection = &editor_state.selection; + let start = selection.start(); + let end = selection.end(); + let Some(rect) = range_page_bounds( + (start.paragraph, start.offset), + (end.paragraph, end.offset), + text_content, + shape, + ) else { + return std::ptr::null_mut(); + }; + + let mut bytes = vec![0u8; 16]; + bytes[0..4].copy_from_slice(&rect.x().to_le_bytes()); + bytes[4..8].copy_from_slice(&rect.y().to_le_bytes()); + bytes[8..12].copy_from_slice(&rect.width().to_le_bytes()); + bytes[12..16].copy_from_slice(&rect.height().to_le_bytes()); + mem::write_bytes(bytes) + }) +} + #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { with_state!(state, {