mirror of
https://github.com/penpot/penpot.git
synced 2026-11-13 21:40:33 +00:00
🎉 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:
parent
beb3b2f930
commit
f171c147f5
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
3
frontend/resources/images/icons/text-emphasis.svg
Normal file
3
frontend/resources/images/icons/text-emphasis.svg
Normal 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 |
@ -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
|
||||
|
||||
@ -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 [_ _ _]
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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 %))
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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)}]))))
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
[]
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
@ -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"))))
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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")))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(¶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::<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(¶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<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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 = ¶graph.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![
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user