🎉 Add a CJK text selection menu and readings over several spans

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
This commit is contained in:
alonso.torres 2026-11-12 00:46:06 +01:00
parent beb3b2f930
commit f171c147f5
42 changed files with 1833 additions and 155 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="5.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 169 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="1.5" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 169 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="5.5"/>
</svg>

After

Width:  |  Height:  |  Size: 149 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 149 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.6 3.4C6.6 3.4 12.8 5.4 12.4 8.7v1.9a1 1 0 0 1-2 0C9.3 9.1 8.2 5.6 4.6 3.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 207 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path fill="currentColor" d="M4.6 3.4C6.6 3.4 12.8 5.4 12.4 8.7v1.9a1 1 0 0 1-2 0C9.3 9.1 8.2 5.6 4.6 3.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 #"<ruby" markup)))
(is (= 1 (count-of #"かんじ" markup)))
(is (re-find #"<ruby[^>]*>.*漢.*字.*<rt" markup))))))
(deftest a-reading-over-two-spans-has-one-set-of-ruby-rules
(testing "the code rules the wrapper of the first span only"
(let [css (css/generate-text-css (text-shape joined-ruby-text-content))]
(is (= 1 (count-of #"-ruby \{" css)))
(is (= 1 (count-of #"-rt \{" css))))))

View File

@ -112,3 +112,54 @@
(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))))))
(defn- editor-selection
"A WASM editor selection from `start` to `end` in the first paragraph."
[start end]
{:anchor-para 0 :anchor-offset start :focus-para 0 :focus-offset end})
(def ^:private reading-text
"日本 has one reading over two spans, the second with emphasis; 語 has
none; 天 has a reading of its own, without a group."
(content [(para [{:text "日" :ruby "にほん" :ruby-group "g"}
{:text "本" :ruby "にほん" :ruby-group "g" :text-emphasis "filled-sesame"}
{:text "語"}
{:text "を" :text-emphasis "filled-dot"}
{:text "天" :ruby "てん"}])]))
(t/deftest a-reading-takes-text-of-several-styles
(t/is (text-editor/reading-selection?
(content [(para [{:text "日"} {:text "本" :text-emphasis "filled-dot"}])])
(editor-selection 0 2))
"emphasis on part of the text does not block a reading"))
(t/deftest a-reading-edits-a-whole-base-only
(t/is (text-editor/reading-selection? reading-text (editor-selection 0 2))
"the whole base of a reading over two spans")
(t/is (not (text-editor/reading-selection? reading-text (editor-selection 1 2)))
"part of a base")
(t/is (not (text-editor/reading-selection? reading-text (editor-selection 1 3)))
"a base and the text after it")
(t/is (text-editor/reading-selection? reading-text (editor-selection 2 4))
"text of two styles without readings")
(t/is (text-editor/reading-selection? reading-text (editor-selection 4 5))
"the whole of a reading without a group"))
(t/deftest a-warichu-note-takes-one-span
(t/is (text-editor/single-span-selection? reading-text (editor-selection 2 3)))
(t/is (not (text-editor/single-span-selection? reading-text (editor-selection 2 4)))))
(t/deftest emphasis-on-part-of-a-grouped-reading-keeps-one-base
(let [paragraph {:children [{:text "日本" :ruby "にほん" :ruby-group "g"} {:text "語"}]}
result (apply-attrs-to-paragraph paragraph 1 2 {:text-emphasis "filled-dot"})]
(t/is (= [{:text "日" :ruby "にほん" :ruby-group "g"}
{:text "本" :ruby "にほん" :ruby-group "g" :text-emphasis "filled-dot"}
{:text "語"}]
(:children result)))))
(t/deftest emphasis-on-part-of-a-reading-without-a-group-covers-its-base
(let [paragraph {:children [{:text "日本" :ruby "にほん"} {:text "語"}]}
result (apply-attrs-to-paragraph paragraph 1 2 {:text-emphasis "filled-dot"})]
(t/is (= [{:text "日本" :ruby "にほん" :text-emphasis "filled-dot"} {:text "語"}]
(:children result))
"splitting it would repeat the reading")))

View File

@ -56,10 +56,12 @@
(t/deftest reading-selection-stays-with-one-base-group
(let [content (editor-content [{:text "你好"} {:text "世界" :font-weight "700"}]
[{:text "中国" :ruby "zhōng guó"}])]
;; A reading joins text of several styles into one base.
(doseq [range [(selection 0 0 0 1) (selection 0 2 0 0)
(selection 0 2 0 4) (selection 1 0 1 2)]]
(selection 0 2 0 4) (selection 1 0 1 2)
(selection 0 1 0 3)]]
(t/is (true? (text-editor/reading-selection? content range))))
(doseq [range [(selection 0 1 0 3) (selection 0 0 1 2)
(doseq [range [(selection 0 0 1 2)
(selection 0 1 0 1) (selection 1 0 1 1)]]
(t/is (false? (text-editor/reading-selection? content range))))
(t/is (false? (text-editor/reading-selection? nil nil)))))
@ -73,6 +75,7 @@
:text-emphasis-position "auto"
:text-underline-position "auto"
:ruby "にち"
:ruby-group ""
:ruby-hidden false
:ruby-zhuyin false
:ruby-size "half"
@ -89,6 +92,7 @@
:text-emphasis-position "auto"
:text-underline-position "auto"
:ruby ""
:ruby-group ""
:ruby-hidden false
:ruby-zhuyin false
:ruby-size "half"
@ -141,6 +145,7 @@
:text-emphasis-position :multiple
:text-underline-position :multiple
:ruby :multiple
:ruby-group ""
:ruby-hidden :multiple
:ruby-zhuyin false
:ruby-size :multiple

View File

@ -9282,6 +9282,50 @@ msgstr "Add page"
msgid "workspace.sitemap"
msgstr "Sitemap"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.back"
msgstr "Back"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.combine"
msgstr "Combine"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.emphasis"
msgstr "Emphasis"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.reading"
msgstr "Reading"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.reading-hint"
msgstr "Select text without a reading or warichu, or a whole reading"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.remove"
msgstr "Remove"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.ruby"
msgstr "Ruby"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.ruby-options"
msgstr "Ruby options"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.single-span-hint"
msgstr "Select text with a single style, or a whole annotation"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.title"
msgstr "Text annotations"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.warichu"
msgstr "Warichu"
#: src/app/main/data/workspace/comments.cljs:49
msgid "workspace.toast.comments-hidden"
msgstr "Comments hidden"

View File

@ -9185,6 +9185,50 @@ msgstr "Añadir página"
msgid "workspace.sitemap"
msgstr "Mapa del sitio"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.back"
msgstr "Volver"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.combine"
msgstr "Combinar"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.emphasis"
msgstr "Énfasis"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.reading"
msgstr "Lectura"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.reading-hint"
msgstr "Selecciona texto sin lectura ni warichu, o una lectura completa"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.remove"
msgstr "Quitar"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.ruby"
msgstr "Ruby"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.ruby-options"
msgstr "Opciones de ruby"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.single-span-hint"
msgstr "Selecciona texto con un solo estilo o una anotación completa"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.title"
msgstr "Anotaciones de texto"
#: src/app/main/ui/workspace/shapes/text/selection_menu.cljs
msgid "workspace.text-selection-menu.warichu"
msgstr "Warichu"
#: src/app/main/data/workspace/comments.cljs:49
msgid "workspace.toast.comments-hidden"
msgstr "Comentarios ocultos"

View File

@ -86,10 +86,15 @@ behavior; the supported modes do not claim complete CSS `keep-all` conformance.
The Chinese layout panel offers typed Pinyin readings when the selected
paragraph has an explicit Chinese profile. In the WASM text editor, select
one character or a group within one text style, then enter its reading.
To edit an existing reading, select its whole base. Selections across styles
or paragraphs do not offer a reading field, which prevents repeated readings.
For character-by-character readings, annotate each character separately.
one character or a group, in any text styles, then enter its reading. A
reading over spans of several styles, such as characters with and without
emphasis marks, gives them one `ruby-group`; the renderer draws one base
over the spans of a group (`Paragraph::ruby_groups`), whose first span holds
the reading and its presentation. To edit an existing reading, select its
whole base. Selections across paragraphs, or that cut a reading or warichu
note, do not offer a reading field, which prevents repeated readings. For
character-by-character readings, annotate each character separately:
neighbours with the same reading but no shared group stay separate bases.
Readings use the saved `ruby` attributes and the shared annotation renderer.
They support size, alignment, overhang, side, hiding and annotation clearance.
@ -145,8 +150,9 @@ Position data carries the mark's em box centred on it, because SVG export
draws the mark at a font size of that box's width. Vertical marks already
centre the glyph and their position-data box on the same point.
Decoration edits that intersect a reading or note apply to its whole base,
so changing a side cannot duplicate its reading on split spans. Other text
Decoration edits that intersect a note, or a reading without a group, apply
to its whole base, so changing a side cannot duplicate its reading on split
spans. A grouped reading keeps one base when an edit splits its spans. Other text
styles remain unchanged. Selection snapshots, Plugin API properties,
CSS handoff and saved content retain the independent positions. CSS auto
positions inherit the Chinese paragraph defaults; explicit positions emit

View File

@ -352,6 +352,27 @@ pub(crate) fn range_origin(
.map(|rect| Point::new(rect.left(), rect.top()))
}
/// Box in page coordinates around the line boxes of the text between `start`
/// and `end` (paragraph, char offset), mapped through the shape transform: on
/// a rotated text it encloses the text as drawn. `None` when the range covers
/// no text.
pub(crate) fn range_page_bounds(
start: (usize, usize),
end: (usize, usize),
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
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<Rect> {
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");

View File

@ -173,12 +173,15 @@ pub fn apply_policy_to_span_texts(
ruby_breaks: &[Option<Vec<usize>>],
policy: TextLayoutPolicy,
) -> Option<(Vec<String>, 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<Vec<usize>>],
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)

View File

@ -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<std::ops::Range<usize>> {
let mut groups: Vec<std::ops::Range<usize>> = 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<Option<usize>> {
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<TextSpan> {
&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<TextSpan>) -> 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);

View File

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

View File

@ -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<bool> = 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);
}

View File

@ -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<std::ops::Range<usize>> {
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<usize>, std::ops::Range<usize>)> = 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

View File

@ -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<usize> = 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<usize> = 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::<f32>()
+ 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::<f32>()
+ 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<usize
counts
}
/// (span index, builder-text range) of every span whose ruby is painted:
/// visible ruby on a base that is not warichu.
/// (first span index, builder-text range) of every base whose ruby is
/// painted: visible ruby on a base that is not warichu. A base covers the
/// spans of its `Paragraph::ruby_groups` entry; its first span holds the
/// reading and its presentation.
pub(super) fn horizontal_ruby_targets(
paragraph: &Paragraph,
offsets: &HorizontalOffsets,
) -> Vec<(usize, std::ops::Range<usize>)> {
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<skia::textlayout::TextBox>) -> Vec<skia::textlayout::TextBox> {
let mut lines: Vec<skia::textlayout::TextBox> = 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(

View File

@ -2808,3 +2808,136 @@ fn push_out_first_keeps_push_out_when_it_resolves_the_line_start() {
);
}
}
fn reading_paragraph(spans: Vec<TextSpan>) -> 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(&paragraph);
// 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::<usize>();
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(&paragraph);
let ranges = &plan.offsets.ranges;
assert_eq!(
horizontal_ruby_targets(&paragraph, &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<TextSpan>| {
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:?}"
);
}

View File

@ -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<usize>,
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<RubyBaseUnit> {
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<usize, usize> {
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 = &paragraph.children()[span_index];
let base: Vec<usize> = 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<TextSpan>) -> 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![

View File

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

View File

@ -228,15 +228,17 @@ impl From<RawTextSpan> 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::<RawTextSpan>()];
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);
}
}

View File

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