diff --git a/common/src/app/common/render_wasm/text_content.cljs b/common/src/app/common/render_wasm/text_content.cljs
index 5b807bdf4d..2453e5d360 100644
--- a/common/src/app/common/render_wasm/text_content.cljs
+++ b/common/src/app/common/render_wasm/text_content.cljs
@@ -22,6 +22,7 @@
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills.impl :as types.fills.impl]
+ [app.common.types.text.cjk-layout :as cjk]
[app.common.uuid :as uuid]
[cuerdas.core :as str]))
@@ -142,7 +143,7 @@
paragraph-font-weight (-> paragraph :font-weight serialize-font-weight)
paragraph-line-height (serialize-line-height (get paragraph :line-height))]
(reduce
- (fn [offset span]
+ (fn [offset [previous span]]
(let [font-style (sr/translate-font-style (get span :font-style "normal"))
font-size (serialize-font-size (get span :font-size paragraph-font-size))
line-height (serialize-line-height (get span :line-height) paragraph-line-height)
@@ -194,7 +195,11 @@
(mem/write-u8 dview ruby-size)
(mem/write-u8 dview ruby-align)
(mem/write-u8 dview ruby-overhang)
- (mem/write-u8 dview (bit-or ruby-side (if (true? (:ruby-zhuyin span)) 2 0)))
+ ;; Bit 1 selects Zhuyin; bit 2 joins the span to the reading of
+ ;; the span before it (`cjk/ruby-groups`).
+ (mem/write-u8 dview (bit-or ruby-side
+ (if (true? (:ruby-zhuyin span)) 2 0)
+ (if (cjk/ruby-joins-previous? previous span) 4 0)))
;; Use the former padding bytes; the span header stays the same size.
(mem/write-u8 dview emphasis-position)
(mem/write-u8 dview underline-position)
@@ -211,7 +216,7 @@
(mem/assert-written offset SPAN-ATTR-U8-SIZE)
(write-span-fills dview fills))))
offset
- spans)))
+ (map vector (cons nil spans) spans))))
(defn write-shape-text!
"Writes one paragraph's spans + text into WASM and appends it to the current
diff --git a/common/src/app/common/types/shape/text.cljc b/common/src/app/common/types/shape/text.cljc
index 4a9e1fa670..d7e7206988 100644
--- a/common/src/app/common/types/shape/text.cljc
+++ b/common/src/app/common/types/shape/text.cljc
@@ -76,6 +76,7 @@
[:text-emphasis-position {:optional true} (schema:cjk-enum :text-emphasis-position)]
[:text-underline-position {:optional true} (schema:cjk-enum :text-underline-position)]
[:ruby {:optional true} :string]
+ [:ruby-group {:optional true} :string]
[:ruby-hidden {:optional true} :boolean]
[:ruby-zhuyin {:optional true} :boolean]
[:ruby-size {:optional true} (schema:cjk-enum :ruby-size)]
@@ -113,6 +114,9 @@
[:emphasis-mark {:optional true} :boolean]
[:zhuyin-glyph {:optional true} :boolean]
[:ruby {:optional true} :string]
+ ;; A horizontal reading over several strips of its base spans them all.
+ [:ruby-x {:optional true} ::sm/safe-number]
+ [:ruby-width {:optional true} ::sm/safe-number]
[:ruby-size {:optional true} ::sm/text]
[:ruby-align {:optional true} ::sm/text]
[:ruby-overhang {:optional true} ::sm/text]
diff --git a/common/src/app/common/types/text/cjk_layout.cljc b/common/src/app/common/types/text/cjk_layout.cljc
index de19d876a5..8d87f5cf7a 100644
--- a/common/src/app/common/types/text/cjk_layout.cljc
+++ b/common/src/app/common/types/text/cjk_layout.cljc
@@ -71,20 +71,22 @@
(def ruby-presentation-attrs
[:ruby-hidden :ruby-zhuyin :ruby-size :ruby-align :ruby-overhang :ruby-side])
-;; Span attrs; `:ruby` holds the reading (furigana) of the span.
+;; Span attrs; `:ruby` holds the reading (furigana) of the span and
+;; `:ruby-group` joins it to the spans of the same reading (`ruby-groups`).
(def span-attrs
- (into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position :ruby]
+ (into [:text-combine-upright :text-emphasis :text-emphasis-position :text-underline-position
+ :ruby :ruby-group]
(concat ruby-presentation-attrs
[:warichu :font-features :annotation-clearance])))
;; Span attrs owned by the span's characters, not its style: a reading or a
-;; warichu note annotates one span and is never copied to other text.
+;; warichu note annotates its own text and is never copied to other text.
(def text-content-attrs
- [:ruby :warichu])
+ [:ruby :ruby-group :warichu])
;; Values of the span attrs above when a span does not store them.
(def span-attr-defaults
- (into {:ruby "" :ruby-hidden false :ruby-zhuyin false}
+ (into {:ruby "" :ruby-group "" :ruby-hidden false :ruby-zhuyin false}
(comp (filter #(contains? enum-values %))
(map (juxt identity enum-default)))
span-attrs))
@@ -176,6 +178,67 @@
[node]
(not (str/blank? (:ruby node))))
+(defn ruby-joins-previous?
+ "True when text node `node` continues the reading of `previous`, the node
+ before it: both carry the same reading in the same non-blank
+ `:ruby-group`, so their text is one base whose first node sets the
+ presentation. Without a group, neighbours with the same reading stay
+ apart, as in 天天 (tiān tiān). Zhuyin annotates one character, so its
+ nodes stand alone."
+ [previous node]
+ (let [group (:ruby-group node)]
+ (boolean
+ (and (some? previous)
+ (string? group)
+ (not (str/blank? group))
+ (= group (:ruby-group previous))
+ (ruby-span? node)
+ (ruby-span? previous)
+ (not= "warichu" (:warichu node))
+ (not= "warichu" (:warichu previous))
+ (not (true? (:ruby-zhuyin node)))
+ (not (true? (:ruby-zhuyin previous)))
+ (= (str/trim (:ruby node)) (str/trim (:ruby previous)))))))
+
+(defn ruby-groups
+ "Index ranges `[start end)` of the text `nodes` of a paragraph whose text is
+ one ruby base: a node with a reading and the nodes after it that join it
+ (`ruby-joins-previous?`). A reading over characters of several styles,
+ such as some with emphasis marks, is one base."
+ [nodes]
+ (let [nodes (vec nodes)]
+ (reduce (fn [groups index]
+ (let [node (nth nodes index)]
+ (cond
+ (not (ruby-span? node))
+ groups
+
+ (and (seq groups)
+ (= index (second (peek groups)))
+ (ruby-joins-previous? (nth nodes (dec index)) node))
+ (conj (pop groups) [(first (peek groups)) (inc index)])
+
+ :else
+ (conj groups [index (inc index)]))))
+ []
+ (range (count nodes)))))
+
+(defn ruby-base-runs
+ "Index ranges `[start end)` that cover the `nodes` of a paragraph in order:
+ each reading over several nodes (`ruby-groups`) as one range, every other
+ node alone. Renderers draw a range as one reading over its nodes."
+ [nodes]
+ (let [nodes (vec nodes)
+ starts (into {} (comp (filter (fn [[start end]] (> (- end start) 1)))
+ (map (juxt first identity)))
+ (ruby-groups nodes))]
+ (loop [index 0
+ runs []]
+ (if (>= index (count nodes))
+ runs
+ (let [[_ end :as run] (get starts index [index (inc index)])]
+ (recur end (conj runs run)))))))
+
(defn annotated-span?
"True when a text node carries a ruby reading or is a warichu note."
[node]
@@ -250,7 +313,7 @@
nil)))
(def ^:private ruby-attrs
- (into #{:ruby} ruby-presentation-attrs))
+ (into #{:ruby :ruby-group} ruby-presentation-attrs))
;; CJK attrs each language renders; the others stay in the content and come
;; back when the text takes that language again.
diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc
index 1338b8f90c..280eb34e9a 100644
--- a/common/test/common_tests/types/text_test.cljc
+++ b/common/test/common_tests/types/text_test.cljc
@@ -640,3 +640,38 @@
(let [active (cjk/active-content (cjk-content {:text-language "ko"}) "ja")]
(t/is (= "ko" (:text-language (first-paragraph active)))
"the text language wins over the file language")))
+
+(defn- reading
+ ([text ruby] {:text text :ruby ruby})
+ ([text ruby group] {:text text :ruby ruby :ruby-group group}))
+
+(t/deftest a-reading-joins-the-spans-of-its-group
+ (let [nodes [(reading "日" "にほん" "g")
+ (assoc (reading "本" "にほん" "g") :text-emphasis "filled-sesame")
+ {:text "語"}]]
+ (t/is (cjk/ruby-joins-previous? (first nodes) (second nodes)))
+ (t/is (= [[0 2]] (cjk/ruby-groups nodes)))
+ (t/is (= [[0 2] [2 3]] (cjk/ruby-base-runs nodes)))))
+
+(t/deftest neighbours-with-the-same-reading-stay-apart-without-a-group
+ (let [nodes [(reading "天" "tiān") (reading "天" "tiān")]]
+ (t/is (not (cjk/ruby-joins-previous? (first nodes) (second nodes))))
+ (t/is (= [[0 1] [1 2]] (cjk/ruby-groups nodes)))
+ (t/is (= [[0 1] [1 2]] (cjk/ruby-base-runs nodes)))))
+
+(t/deftest a-group-joins-only-the-same-reading
+ (t/is (= [[0 1] [1 2]]
+ (cjk/ruby-groups [(reading "日" "に" "g") (reading "本" "ほん" "g")])))
+ (t/is (= [[0 1] [1 2]]
+ (cjk/ruby-groups [(reading "日" "にほん" "a") (reading "本" "にほん" "b")]))))
+
+(t/deftest zhuyin-and-warichu-spans-never-join
+ (t/is (= [[0 1] [1 2]]
+ (cjk/ruby-groups [(assoc (reading "天" "ㄊㄧㄢ" "g") :ruby-zhuyin true)
+ (assoc (reading "天" "ㄊㄧㄢ" "g") :ruby-zhuyin true)])))
+ (t/is (not (cjk/ruby-joins-previous? (reading "割" "わり" "g")
+ (assoc (reading "注" "わり" "g") :warichu "warichu")))))
+
+(t/deftest the-ruby-group-is-text-content-not-style
+ (t/is (some #{:ruby-group} cjk/text-content-attrs))
+ (t/is (= "" (:ruby-group cjk/span-attr-defaults))))
diff --git a/frontend/resources/images/icons/text-emphasis-filled-circle.svg b/frontend/resources/images/icons/text-emphasis-filled-circle.svg
new file mode 100644
index 0000000000..fb5269a95a
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis-filled-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-emphasis-filled-dot.svg b/frontend/resources/images/icons/text-emphasis-filled-dot.svg
new file mode 100644
index 0000000000..ffce4048e2
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis-filled-dot.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-emphasis-open-circle.svg b/frontend/resources/images/icons/text-emphasis-open-circle.svg
new file mode 100644
index 0000000000..eee36c9989
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis-open-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-emphasis-open-dot.svg b/frontend/resources/images/icons/text-emphasis-open-dot.svg
new file mode 100644
index 0000000000..81108caf2e
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis-open-dot.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-emphasis-open-sesame.svg b/frontend/resources/images/icons/text-emphasis-open-sesame.svg
new file mode 100644
index 0000000000..2c55c5baa7
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis-open-sesame.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/text-emphasis.svg b/frontend/resources/images/icons/text-emphasis.svg
new file mode 100644
index 0000000000..965015e1fa
--- /dev/null
+++ b/frontend/resources/images/icons/text-emphasis.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index 8bd6967112..929ad5f40c 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -628,6 +628,7 @@
:current-file-id
:workspace-editor-state
:workspace-wasm-editor-styles
+ :workspace-wasm-editor-selection-rects
:workspace-media-objects
:workspace-presence
:workspace-tokens
diff --git a/frontend/src/app/main/data/workspace/edition.cljs b/frontend/src/app/main/data/workspace/edition.cljs
index 185289276e..41a57e4126 100644
--- a/frontend/src/app/main/data/workspace/edition.cljs
+++ b/frontend/src/app/main/data/workspace/edition.cljs
@@ -64,7 +64,8 @@
(not path-editing?)
(update :workspace-drawing dissoc :object :lock))
(dissoc :workspace-grid-edition)
- (dissoc :workspace-wasm-editor-styles))))
+ (dissoc :workspace-wasm-editor-styles)
+ (dissoc :workspace-wasm-editor-selection-rects))))
ptk/WatchEvent
(watch [_ _ _]
diff --git a/frontend/src/app/main/data/workspace/texts_v3.cljs b/frontend/src/app/main/data/workspace/texts_v3.cljs
index d9c288eac3..d91fccd7b7 100644
--- a/frontend/src/app/main/data/workspace/texts_v3.cljs
+++ b/frontend/src/app/main/data/workspace/texts_v3.cljs
@@ -23,3 +23,20 @@
(update-in state [:workspace-wasm-editor-styles id]
(fn [current-styles]
(merge defaults current-styles new-styles))))))))
+
+(defn v3-update-text-editor-selection-rect
+ "Store the box around the text selected in the editor of the text `id`, in
+ page coordinates, or drop it when `rect` is nil: the selection collapsed."
+ [id rect]
+ (ptk/reify ::v3-update-text-editor-selection-rect
+ ptk/UpdateEvent
+ (update [_ state]
+ (cond
+ (not= id (get-in state [:workspace-local :edition]))
+ state
+
+ (nil? rect)
+ (update state :workspace-wasm-editor-selection-rects dissoc id)
+
+ :else
+ (assoc-in state [:workspace-wasm-editor-selection-rects id] rect)))))
diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs
index a6f30ba453..7be635f71f 100644
--- a/frontend/src/app/main/refs.cljs
+++ b/frontend/src/app/main/refs.cljs
@@ -445,6 +445,9 @@
(def workspace-wasm-editor-styles
(l/derived :workspace-wasm-editor-styles st/state))
+(def workspace-wasm-editor-selection-rects
+ (l/derived :workspace-wasm-editor-selection-rects st/state))
+
(def workspace-wasm-modifiers
(let [a (atom nil)]
(rx/sub! ms/wasm-modifiers #(reset! a %))
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index 7c68570f53..ded11e6608 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -288,6 +288,12 @@
(def ^:icon-id text-combine-upright-all "text-combine-upright-all")
(def ^:icon-id text-combine-upright-digits "text-combine-upright-digits")
(def ^:icon-id text-combine-upright-none "text-combine-upright-none")
+(def ^:icon-id text-emphasis "text-emphasis")
+(def ^:icon-id text-emphasis-filled-circle "text-emphasis-filled-circle")
+(def ^:icon-id text-emphasis-filled-dot "text-emphasis-filled-dot")
+(def ^:icon-id text-emphasis-open-circle "text-emphasis-open-circle")
+(def ^:icon-id text-emphasis-open-dot "text-emphasis-open-dot")
+(def ^:icon-id text-emphasis-open-sesame "text-emphasis-open-sesame")
(def ^:icon-id text-fixed "text-fixed")
(def ^:icon-id text-font-family "text-font-family")
(def ^:icon-id text-font-size "text-font-size")
diff --git a/frontend/src/app/main/ui/shapes/text/fo_text.cljs b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
index 93e791cad1..41cd9153dd 100644
--- a/frontend/src/app/main/ui/shapes/text/fo_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/fo_text.cljs
@@ -30,6 +30,24 @@
[:span.text-node {:style style}
(if (= text "") "\u00A0" (sts/text-children text node))])))
+(mf/defc render-ruby-group*
+ "One reading over the text `nodes` of its base (`cjk/ruby-groups`), each
+ node with its own style. The first node holds the reading."
+ {::mf/private true}
+ [{:keys [nodes shape]}]
+ (let [node (first nodes)
+ ruby (cjk/visible-ruby node)]
+ (if (some? ruby)
+ [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)}
+ (for [[index node] (d/enumerate nodes)]
+ [:span.text-node {:key index :style (sts/generate-text-styles shape node)}
+ (sts/text-children (:text node) node)])
+ [:rt {:style (sts/generate-ruby-styles shape node)} ruby]]
+ [:*
+ (for [[index node] (d/enumerate nodes)]
+ [:span.text-node {:key index :style (sts/generate-text-styles shape node)}
+ (sts/text-children (:text node) node)])])))
+
(mf/defc render-root*
[{:keys [node children shape]}]
(let [style (sts/generate-root-styles shape node)]
@@ -62,10 +80,15 @@
"paragraph" render-paragraph*
nil)]
(when component
- [:> component props
- (for [[index child-node] (d/enumerate children)]
- [:> render-node*
- (mf/spread-props props {:node child-node :index index :key index})])])))))
+ (let [children (vec children)]
+ [:> component props
+ (for [[start end] (cjk/ruby-base-runs children)]
+ (if (= (inc start) end)
+ [:> render-node*
+ (mf/spread-props props {:node (nth children start) :index start :key start})]
+ [:> render-ruby-group* {:key start
+ :nodes (subvec children start end)
+ :shape (unchecked-get props "shape")}]))]))))))
(defn- next-color
"Given a set of colors try to get a color not yet used"
diff --git a/frontend/src/app/main/ui/shapes/text/html_text.cljs b/frontend/src/app/main/ui/shapes/text/html_text.cljs
index 27ad945b6f..2858a1cef7 100644
--- a/frontend/src/app/main/ui/shapes/text/html_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/html_text.cljs
@@ -49,6 +49,32 @@
[:span.text-node {:style style :class class}
(if (= text "") "\u00A0" (sts/text-children text node))])))
+(mf/defc render-ruby-group*
+ "One reading over the text `nodes` of its base (`cjk/ruby-groups`), each
+ node with its own style. The first node holds the reading."
+ {::mf/private true}
+ [{:keys [nodes shape is-code]}]
+ (let [node (first nodes)
+ class (when is-code (:$id node))
+ ruby (cjk/visible-ruby node)]
+ (if (some? ruby)
+ [:ruby.ruby-node {:style (sts/generate-ruby-container-styles node)
+ :class (when is-code (dm/str class "-ruby"))}
+ (for [[index node] (d/enumerate nodes)]
+ [:span.text-node {:key index
+ :style (sts/generate-text-styles shape node)
+ :class (when is-code (:$id node))}
+ (sts/text-children (:text node) node)])
+ [:rt {:style (sts/generate-ruby-styles shape node)
+ :class (when is-code (dm/str class "-rt"))}
+ ruby]]
+ [:*
+ (for [[index node] (d/enumerate nodes)]
+ [:span.text-node {:key index
+ :style (sts/generate-text-styles shape node)
+ :class (when is-code (:$id node))}
+ (sts/text-children (:text node) node)])])))
+
(mf/defc render-root*
[{:keys [node children shape is-code]}]
(let [style (sts/generate-root-styles shape node is-code)
@@ -85,15 +111,21 @@
"paragraph" render-paragraph*
nil)]
(when component
- [:> component props
- (for [[index child-node] (d/enumerate children)]
- [:> render-node*
- (mf/spread-props props
- {:node child-node
- :parent parent
- :index index
- :key index
- :is-code is-code})])])))))
+ (let [children (vec children)]
+ [:> component props
+ (for [[start end] (cjk/ruby-base-runs children)]
+ (if (= (inc start) end)
+ [:> render-node*
+ (mf/spread-props props
+ {:node (nth children start)
+ :parent parent
+ :index start
+ :key start
+ :is-code is-code})]
+ [:> render-ruby-group* {:key start
+ :nodes (subvec children start end)
+ :shape (unchecked-get props "shape")
+ :is-code is-code}]))]))))))
(mf/defc text-shape*
{::mf/forward-ref true}
diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
index e3a4988a86..761e353bd1 100644
--- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs
+++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs
@@ -148,6 +148,10 @@
:lengthAdjust "spacingAndGlyphs"
:style style}))
(let [rtl? (= "rtl" (:direction data))
+ ;; A reading over several strips spans its whole base.
+ data (assoc data
+ :x (:ruby-x data (:x data))
+ :width (:ruby-width data (:width data)))
ruby-align (if word? "center" (:ruby-align data (cjk/enum-default :ruby-align)))
fit? (and (not word?)
(or (= "none" (:ruby-overhang data (cjk/enum-default :ruby-overhang)))
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs
new file mode 100644
index 0000000000..e24244ecb0
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.cljs
@@ -0,0 +1,402 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns app.main.ui.workspace.shapes.text.selection-menu
+ "Popover over the text selected in the WASM text editor, with the CJK
+ annotations of the text language."
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [app.common.types.text.cjk-layout :as cjk]
+ [app.main.data.workspace.texts :as dwt]
+ [app.main.data.workspace.texts-v3 :as dwt-v3]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.workspace.sidebar.options.menus.text-cjk-controls :as cjk-controls]
+ [app.render-wasm.text-editor :as wasm.text-editor]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [app.util.keyboard :as kbd]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+;; Languages that show the menu.
+(def ^:private menu-language-families
+ #{:japanese :chinese})
+
+;; Contenteditable of the WASM text editor (see the v3 editor).
+(def ^:private editor-input-id "text-editor-wasm-input")
+
+;; Icon of each `:text-emphasis` value.
+(def ^:private emphasis-icons
+ {"none" i/remove
+ "filled-dot" i/text-emphasis-filled-dot
+ "open-dot" i/text-emphasis-open-dot
+ "filled-circle" i/text-emphasis-filled-circle
+ "open-circle" i/text-emphasis-open-circle
+ "filled-sesame" i/text-emphasis
+ "open-sesame" i/text-emphasis-open-sesame})
+
+(defn- keep-editor-focus
+ "Keep the focus where it is when a press lands on the menu, so the editor
+ keeps its selection. The reading input takes the focus to type in it."
+ [event]
+ (when-not (= "INPUT" (.-tagName (dom/get-target event)))
+ (dom/prevent-default event)))
+
+(defn- focus-editor
+ []
+ (some-> (dom/get-element editor-input-id) (dom/focus!)))
+
+(defn- composing-key?
+ "True for a key-down that an IME composition takes."
+ [event]
+ (let [native (dom/event->native-event event)]
+ (or (.-isComposing ^js native)
+ (= 229 (.-keyCode ^js native)))))
+
+(defn- escape-key?
+ "True for an Escape key-down that does not end an IME composition."
+ [event]
+ (and (kbd/esc? event) (not (composing-key? event))))
+
+(defn- apply-text-attrs
+ "Apply span `attrs` to the text selected in the editor of `shape-id`, as
+ the design sidebar does."
+ [shape-id attrs]
+ (st/emit! (dwt-v3/v3-update-text-editor-styles shape-id attrs)
+ (dwt/update-all-attrs #{shape-id} attrs)))
+
+(defn- span-value
+ "Value of a span attr of the selection: nil when its spans differ."
+ [styles attr]
+ (let [value (get styles attr)]
+ (when-not (= :multiple value) value)))
+
+(mf/defc menu-item*
+ "Button with `children` as its icon, before the label, and a caret after
+ it when it opens a dropdown, open when `expanded` is true. `active` shows
+ that the selected text has the annotation; `pressed` also tells it to
+ assistive tech on toggles. A `disabled` item explains why in `hint`."
+ {::mf/private true}
+ [{:keys [label children on-click hint pressed
+ ^boolean dropdown ^boolean expanded ^boolean active ^boolean disabled]}]
+ [:button {:type "button"
+ :class (stl/css-case :menu-item true
+ :menu-item-active active)
+ :aria-haspopup (when dropdown "menu")
+ :aria-expanded (when dropdown expanded)
+ :aria-pressed pressed
+ :aria-disabled disabled
+ :title (when disabled hint)
+ :on-click (when-not disabled on-click)}
+ children
+ [:span {:class (stl/css :label)} label]
+ (when dropdown
+ [:> icon* {:icon-id i/arrow-down
+ :size "s"
+ :class (stl/css :caret)
+ :aria-hidden true}])])
+
+(mf/defc glyph*
+ "A CJK character standing for an annotation that has no icon."
+ {::mf/private true}
+ [{:keys [glyph]}]
+ [:span {:class (stl/css :glyph) :aria-hidden true} glyph])
+
+(mf/defc emphasis-list*
+ "The emphasis marks to choose from, `selected` checked."
+ {::mf/private true}
+ [{:keys [selected on-select]}]
+ (let [on-click
+ (mf/use-fn
+ (mf/deps on-select)
+ (fn [event]
+ (on-select (dom/get-data (dom/get-current-target event) "value"))))]
+ [:ul {:class (stl/css :emphasis-list)
+ :role "menu"
+ :aria-label (tr "workspace.options.text-options.text-emphasis")}
+ (for [{:keys [id label]} (cjk-controls/text-emphasis-options)]
+ (let [selected? (= id selected)]
+ [:li {:key id :role "none"}
+ [:button {:type "button"
+ :role "menuitemradio"
+ :aria-checked selected?
+ :class (stl/css-case :emphasis-option true
+ :emphasis-option-selected selected?)
+ :data-value id
+ :on-click on-click}
+ [:> icon* {:icon-id i/tick
+ :size "s"
+ :class (stl/css :emphasis-check)
+ :aria-hidden true}]
+ [:> icon* {:icon-id (get emphasis-icons id)
+ :class (stl/css-case :emphasis-mark true
+ :emphasis-mark-none (= id "none"))
+ :aria-hidden true}]
+ [:span {:class (stl/css :emphasis-label)} label]]]))]))
+
+(mf/defc toolbar*
+ "The annotations of the `family` language for the selected text of
+ `shape-id`. `annotations` holds what the text has (nil when its spans
+ differ). A reading takes text without annotations or a whole base
+ (`readable`); a warichu note, the text of one span (`warichu-enabled`)."
+ {::mf/private true}
+ [{:keys [shape-id family annotations on-ruby
+ ^boolean readable ^boolean ruby-enabled ^boolean warichu-enabled]}]
+ (let [emphasis-open* (mf/use-state false)
+ emphasis-open (deref emphasis-open*)
+
+ {:keys [ruby emphasis warichu combine]} annotations
+
+ warichu? (= "warichu" warichu)
+ combine? (= "all" combine)
+ ruby? (not (str/blank? ruby))
+
+ span-hint (tr "workspace.text-selection-menu.single-span-hint")
+
+ toggle-emphasis
+ (mf/use-fn #(swap! emphasis-open* not))
+
+ open-ruby
+ (mf/use-fn
+ (mf/deps on-ruby)
+ (fn []
+ (reset! emphasis-open* false)
+ (on-ruby)))
+
+ select-emphasis
+ (mf/use-fn
+ (mf/deps shape-id)
+ (fn [value]
+ (reset! emphasis-open* false)
+ (apply-text-attrs shape-id {:text-emphasis value})))
+
+ toggle-warichu
+ (mf/use-fn
+ (mf/deps shape-id warichu?)
+ (fn []
+ (reset! emphasis-open* false)
+ (apply-text-attrs shape-id {:warichu (if warichu? "none" "warichu")})))
+
+ toggle-combine
+ (mf/use-fn
+ (mf/deps shape-id combine?)
+ (fn []
+ (reset! emphasis-open* false)
+ (apply-text-attrs shape-id {:text-combine-upright (if combine? "none" "all")})))]
+
+ [:*
+ [:> menu-item* {:label (tr "workspace.text-selection-menu.ruby")
+ :active ruby?
+ :disabled (not ruby-enabled)
+ :hint (if readable
+ (tr "workspace.options.text-options.zhuyin-selection-hint")
+ (tr "workspace.text-selection-menu.reading-hint"))
+ :on-click open-ruby}
+ [:> glyph* {:glyph "あ"}]]
+ [:div {:class (stl/css :emphasis-anchor)}
+ [:> menu-item* {:label (tr "workspace.text-selection-menu.emphasis")
+ :dropdown true
+ :expanded emphasis-open
+ :active (and (some? emphasis) (not= "none" emphasis))
+ :on-click toggle-emphasis}
+ [:> icon* {:icon-id i/text-emphasis :aria-hidden true}]]
+ (when emphasis-open
+ [:> emphasis-list* {:selected emphasis
+ :on-select select-emphasis}])]
+ ;; Chinese layout renders neither warichu nor tate-chu-yoko.
+ (when (= family :japanese)
+ [:*
+ [:hr {:class (stl/css :separator)}]
+ [:> menu-item* {:label (tr "workspace.text-selection-menu.warichu")
+ :active warichu?
+ :pressed warichu?
+ :disabled (not warichu-enabled)
+ :hint span-hint
+ :on-click toggle-warichu}
+ [:> icon* {:icon-id i/warichu :aria-hidden true}]]
+ [:> menu-item* {:label (tr "workspace.text-selection-menu.combine")
+ :active combine?
+ :pressed combine?
+ :on-click toggle-combine}
+ [:> icon* {:icon-id i/text-combine-upright-all :aria-hidden true}]]])]))
+
+(mf/defc ruby-input*
+ "Input for the ruby reading of the selected text, starting from its
+ current `reading`. Enter applies it, Remove drops the current one, and
+ Escape and the back button return to the toolbar. A `validate` fn rejects
+ the readings it returns false for."
+ {::mf/private true}
+ [{:keys [reading validate invalid-hint on-apply on-back]}]
+ (let [reading* (mf/use-state (or reading ""))
+ value (deref reading*)
+ valid? (or (str/blank? value) (nil? validate) (validate (str/trim value)))
+
+ ;; True when the pending Escape key-up ends a real Escape press, not
+ ;; an IME composition.
+ escape-ref (mf/use-ref false)
+
+ on-change
+ (mf/use-fn
+ (fn [event]
+ (reset! reading* (dom/get-value (dom/get-target event)))))
+
+ on-key-down
+ (mf/use-fn
+ (mf/deps value valid? on-apply)
+ (fn [event]
+ (cond
+ (kbd/esc? event)
+ (mf/set-ref-val! escape-ref (escape-key? event))
+
+ (and (kbd/enter? event) (not (composing-key? event)))
+ (do
+ (dom/prevent-default event)
+ (when valid?
+ (on-apply (str/trim value)))))))
+
+ ;; The editor exits on an Escape key-up reaching the document, so the
+ ;; input keeps every Escape key-up to itself.
+ on-key-up
+ (mf/use-fn
+ (mf/deps on-back)
+ (fn [event]
+ (when (kbd/esc? event)
+ (dom/stop-propagation event)
+ (when (mf/ref-val escape-ref)
+ (mf/set-ref-val! escape-ref false)
+ (on-back)))))
+
+ on-remove
+ (mf/use-fn
+ (mf/deps on-apply)
+ #(on-apply ""))]
+
+ [:*
+ [:button {:type "button"
+ :class (stl/css :back-button)
+ :aria-label (tr "workspace.text-selection-menu.back")
+ :on-click on-back}
+ [:> icon* {:icon-id i/arrow-left :aria-hidden true}]
+ [:span {:class (stl/css :label)} (tr "workspace.text-selection-menu.ruby")]]
+ [:input {:type "text"
+ :class (stl/css :reading-input)
+ :aria-label (tr "workspace.text-selection-menu.reading")
+ :aria-invalid (not valid?)
+ :title (when-not valid? invalid-hint)
+ :auto-focus true
+ :value value
+ :on-change on-change
+ :on-key-down on-key-down
+ :on-key-up on-key-up}]
+ (when-not (str/blank? reading)
+ [:button {:type "button"
+ :class (stl/css :remove-button)
+ :on-click on-remove}
+ [:span {:class (stl/css :label)} (tr "workspace.text-selection-menu.remove")]])
+ [:button {:type "button"
+ :class (stl/css :more-button)
+ :aria-label (tr "workspace.text-selection-menu.ruby-options")}
+ [:> icon* {:icon-id i/menu
+ :class (stl/css :more-icon)
+ :aria-hidden true}]]]))
+
+(mf/defc menu*
+ {::mf/private true}
+ [{:keys [shape-id rect vbox zoom family annotations zhuyin
+ ^boolean readable ^boolean ruby-enabled ^boolean warichu-enabled]}]
+ (let [view* (mf/use-state :toolbar)
+ view (deref view*)
+
+ ;; Centred over the selection box, on top of it.
+ left (* (- (+ (:x rect) (/ (:width rect) 2)) (:x vbox)) zoom)
+ top (* (- (:y rect) (:y vbox)) zoom)
+ style (mf/with-memo [left top]
+ #js {:left (dm/str left "px")
+ :top (dm/str top "px")})
+
+ open-ruby
+ (mf/use-fn #(reset! view* :ruby))
+
+ close-ruby
+ (mf/use-fn
+ (fn []
+ (reset! view* :toolbar)
+ (focus-editor)))
+
+ apply-ruby
+ (mf/use-fn
+ (mf/deps shape-id close-ruby)
+ (fn [reading]
+ (apply-text-attrs shape-id {:ruby reading})
+ (close-ruby)))]
+
+ [:div {:class (stl/css-case :selection-menu true
+ :ruby-view (= view :ruby))
+ :style style
+ :role "toolbar"
+ :aria-label (tr "workspace.text-selection-menu.title")
+ ;; Focus moving into the menu keeps the editor alive (see
+ ;; `keep-editing-on-blur?` in the v3 editor).
+ :data-keep-editing-on-blur true
+ :on-mouse-down keep-editor-focus}
+ (case view
+ :ruby [:> ruby-input* {:reading (:ruby annotations)
+ :validate (when zhuyin cjk/zhuyin-reading?)
+ :invalid-hint (tr "workspace.options.text-options.zhuyin-invalid")
+ :on-apply apply-ruby
+ :on-back close-ruby}]
+ [:> toolbar* {:shape-id shape-id
+ :family family
+ :annotations annotations
+ :readable readable
+ :ruby-enabled ruby-enabled
+ :warichu-enabled warichu-enabled
+ :on-ruby open-ruby}])]))
+
+(mf/defc selection-menu*
+ "Shows the menu over the text selected in the editor of `shape` when the
+ text renders with a Japanese or Chinese layout."
+ [{:keys [shape vbox zoom]}]
+ (let [shape-id (:id shape)
+ rects (mf/deref refs/workspace-wasm-editor-selection-rects)
+ styles-map (mf/deref refs/workspace-wasm-editor-styles)
+ file-language (mf/deref refs/file-text-language)
+ rect (get rects shape-id)
+ family (cjk/language-family
+ (or (cjk/content-language (:content shape)) file-language))]
+ (when (and (some? rect) (contains? menu-language-families family))
+ (let [styles (get styles-map shape-id)
+ emphasis (get styles :text-emphasis)
+ annotations {:ruby (span-value styles :ruby)
+ ;; Spans without a mark store none.
+ :emphasis (cond
+ (nil? emphasis) "none"
+ (= :multiple emphasis) nil
+ :else emphasis)
+ :warichu (span-value styles :warichu)
+ :combine (span-value styles :text-combine-upright)}
+ content (wasm.text-editor/get-cached-content shape-id)
+ selection (wasm.text-editor/text-editor-get-selection)
+ ;; A reading joins the spans it covers into one base; a warichu
+ ;; note stays in one span.
+ readable (wasm.text-editor/reading-selection? content selection)
+ ;; Zhuyin annotates one Han character at a time.
+ zhuyin (and (= family :chinese) (true? (:ruby-zhuyin styles)))]
+ [:> menu* {:shape-id shape-id
+ :rect rect
+ :vbox vbox
+ :zoom zoom
+ :family family
+ :annotations annotations
+ :zhuyin zhuyin
+ :readable readable
+ :ruby-enabled (and readable
+ (or (not zhuyin)
+ (wasm.text-editor/zhuyin-selection? content selection)))
+ :warichu-enabled (wasm.text-editor/single-span-selection? content selection)}]))))
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss
new file mode 100644
index 0000000000..f6f518712b
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/shapes/text/selection_menu.scss
@@ -0,0 +1,207 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+@use "ds/_borders.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/typography.scss" as *;
+
+%surface {
+ background-color: var(--color-background-tertiary);
+ border: $b-1 solid var(--color-background-quaternary);
+ border-radius: $br-8;
+ box-shadow: 0 px2rem(2) px2rem(8) 0 var(--color-shadow-light);
+}
+
+%plain-button {
+ appearance: none;
+ display: flex;
+ align-items: center;
+ block-size: $sz-32;
+ border: none;
+ border-radius: $br-6;
+ background: transparent;
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--color-background-quaternary);
+ }
+
+ &:focus-visible {
+ outline: $b-1 solid var(--color-accent-primary);
+ }
+}
+
+// Its left and top, set inline, are the centre of the selection top edge.
+.selection-menu {
+ @extend %surface;
+
+ --selection-menu-margin: var(--sp-s);
+
+ position: absolute;
+ translate: -50% calc(-100% - var(--selection-menu-margin));
+ display: flex;
+ align-items: center;
+ column-gap: var(--sp-xxs);
+ padding: var(--sp-xs);
+ white-space: nowrap;
+ pointer-events: auto;
+}
+
+.ruby-view {
+ column-gap: var(--sp-s);
+}
+
+.menu-item {
+ @extend %plain-button;
+
+ column-gap: px2rem(6);
+ padding: 0 var(--sp-s);
+ color: var(--color-foreground-primary);
+
+ &[aria-expanded="true"] {
+ background-color: var(--color-background-quaternary);
+ }
+
+ &[aria-disabled="true"] {
+ color: var(--color-foreground-secondary);
+ cursor: default;
+
+ &:hover {
+ background: transparent;
+ }
+ }
+}
+
+// The selected text has the annotation.
+.menu-item-active {
+ background-color: var(--color-background-quaternary);
+ color: var(--color-accent-primary);
+}
+
+.label {
+ @include use-typography("body-small");
+}
+
+.glyph {
+ @include use-typography("body-medium");
+
+ inline-size: $sz-16;
+ text-align: center;
+}
+
+.caret {
+ color: var(--color-icon-default);
+}
+
+.separator {
+ inline-size: $b-1;
+ block-size: px2rem(20);
+ margin: 0;
+ border: none;
+ background-color: var(--color-background-quaternary);
+}
+
+// The list hangs below the Emphasis button, past the menu padding and border.
+.emphasis-anchor {
+ position: relative;
+}
+
+.emphasis-list {
+ @extend %surface;
+
+ position: absolute;
+ inset-block-start: calc(100% + 2 * var(--sp-xs) + #{$b-1});
+ inset-inline-start: 0;
+ display: flex;
+ flex-direction: column;
+ inline-size: $sz-200;
+ margin: 0;
+ padding: var(--sp-xs);
+ list-style: none;
+}
+
+.emphasis-option {
+ @extend %plain-button;
+
+ column-gap: var(--sp-s);
+ inline-size: 100%;
+ padding: 0 var(--sp-m);
+ color: var(--color-foreground-primary);
+}
+
+.emphasis-check {
+ color: var(--color-accent-primary);
+ visibility: hidden;
+}
+
+.emphasis-mark-none {
+ color: var(--color-foreground-secondary);
+}
+
+.emphasis-label {
+ @include use-typography("body-small");
+}
+
+.emphasis-option-selected {
+ .emphasis-check {
+ visibility: visible;
+ }
+
+ .emphasis-label {
+ color: var(--color-accent-primary);
+ }
+}
+
+.back-button {
+ @extend %plain-button;
+
+ column-gap: var(--sp-xs);
+ padding: 0 var(--sp-xs);
+ color: var(--color-foreground-secondary);
+}
+
+.reading-input {
+ @include use-typography("body-medium");
+
+ inline-size: px2rem(180);
+ block-size: $sz-32;
+ margin: 0;
+ padding: 0 var(--sp-s);
+ border: $b-1 solid transparent;
+ border-radius: $br-6;
+ background-color: var(--color-background-primary);
+ color: var(--color-foreground-primary);
+ caret-color: var(--color-accent-primary);
+
+ &:focus {
+ border-color: var(--color-accent-primary);
+ outline: none;
+ }
+
+ &[aria-invalid="true"] {
+ border-color: var(--color-accent-error);
+ }
+}
+
+.remove-button {
+ @extend %plain-button;
+
+ padding: 0 var(--sp-s);
+ color: var(--color-foreground-primary);
+}
+
+.more-button {
+ @extend %plain-button;
+
+ padding: 0 var(--sp-s);
+ color: var(--color-foreground-secondary);
+}
+
+// The menu icon is vertical; the design shows it lying down.
+.more-icon {
+ rotate: 90deg;
+}
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index cfa1a3d38d..9227f390ed 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -29,6 +29,7 @@
[app.main.ui.measurements :as msr]
[app.main.ui.workspace.shapes.path.editor :refer [path-editor*]]
[app.main.ui.workspace.shapes.text.editor :as editor-v1]
+ [app.main.ui.workspace.shapes.text.selection-menu :as text-selection-menu]
[app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline*]]
[app.main.ui.workspace.shapes.text.v2-editor :as editor-v2]
[app.main.ui.workspace.shapes.text.v3-editor :as editor-v3]
@@ -695,7 +696,13 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
- :canvas-ref canvas-ref}])]
+ :canvas-ref canvas-ref}])
+
+ (when (and show-text-editor?
+ (features/active-feature? @st/state "text-editor-wasm/v1"))
+ [:> text-selection-menu/selection-menu* {:shape editing-shape
+ :vbox vbox
+ :zoom zoom}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index 7156fc96c2..ba2cb4f976 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -417,16 +417,30 @@
;; See `request-render-preserving-target`.
(defonce ^:private preserve-target-render? (atom false))
+(defn- sync-text-editor-selection-rect!
+ "Store the box around the editor selection when it moved: the selection,
+ the text or its layout changed."
+ []
+ (let [shape-id (text-editor/text-editor-get-active-shape-id)
+ rect (text-editor/text-editor-get-selection-rect)]
+ (when (not= rect (dm/get-in @st/state [:workspace-wasm-editor-selection-rects shape-id]))
+ (st/emit! (texts/v3-update-text-editor-selection-rect shape-id rect)))))
+
(defn- drain-text-editor-events!
"Pop and handle every pending text-editor event.
- StylesChanged syncs the caret's current styles to the toolbar. Returns true
- when some event needs a full shape re-render (content or layout changed)."
+ StylesChanged syncs the caret's current styles to the toolbar, and any
+ event refreshes the box around the selection. Returns true when some event
+ needs a full shape re-render (content or layout changed)."
[]
- (loop [needs-render? false]
+ (loop [needs-render? false
+ drained? false]
(let [ev (text-editor/text-editor-poll-event)]
(if (or (nil? ev) (= ev TEXT_EDITOR_EVENT_NONE))
- needs-render?
+ (do
+ (when drained?
+ (sync-text-editor-selection-rect!))
+ needs-render?)
(do
(when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED)
(let [current-styles (merge
@@ -438,7 +452,8 @@
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))))
(recur (or needs-render?
(= ev TEXT_EDITOR_EVENT_CONTENT_CHANGED)
- (= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT))))))))
+ (= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT))
+ true))))))
;; This should never be called from the outside.
(defn- render
@@ -3053,6 +3068,42 @@
:fills (get element :fills)
:text (subs element-text start-pos end-pos)})))))
+(defn- ruby-base-starts
+ "First span of the reading of each span of `paragraph` that has one
+ (`cjk/ruby-groups`), by span index."
+ [paragraph]
+ (into {}
+ (mapcat (fn [[start end]] (map (fn [span] [span start]) (range start end))))
+ (cjk/ruby-groups (:children paragraph))))
+
+(defn- join-ruby-strips
+ "A horizontal reading draws once per line of its base. When its base has
+ several strips on a line (spans of other styles, fallback fonts), the
+ first keeps the reading, spread over all of them (`:ruby-x`,
+ `:ruby-width`), and the others drop it. `::ruby-base` keys each strip
+ with a reading by its paragraph and base."
+ [entries]
+ (let [line-of (fn [entry] [(::ruby-base entry) (:y entry)])
+ lines (->> entries
+ (filter #(and (some? (::ruby-base %)) (some? (:ruby %))))
+ (group-by line-of))]
+ (mapv (fn [entry]
+ (let [strips (get lines (line-of entry))
+ first? (identical? entry (first strips))
+ entry (dissoc entry ::ruby-base)]
+ (cond
+ (< (count strips) 2)
+ entry
+
+ first?
+ (let [left (apply min (map :x strips))
+ right (apply max (map #(+ (:x %) (:width %)) strips))]
+ (assoc entry :ruby-x left :ruby-width (- right left)))
+
+ :else
+ (dissoc entry :ruby))))
+ entries)))
+
(defn calculate-position-data
[shape]
(when (initialized?)
@@ -3078,19 +3129,24 @@
(mem/free)
- (into []
- (keep
- (fn [{:keys [paragraph span direction] :as entry}]
- (let [paragraph-node (-> content :children
- (get 0) :children ;; paragraph-set
- (get paragraph))
- element (-> paragraph-node :children (get span))]
- (condp = direction
- POSITION-DATA-RUBY (ruby-strip-entry element entry)
- POSITION-DATA-ZHUYIN (zhuyin-glyph-entry element entry)
- POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry)
- (text-strip-entry paragraph-node element entry)))))
- result))))
+ (let [base-starts (memoize ruby-base-starts)]
+ (->> result
+ (into []
+ (keep
+ (fn [{:keys [paragraph span direction] :as entry}]
+ (let [paragraph-node (-> content :children
+ (get 0) :children ;; paragraph-set
+ (get paragraph))
+ element (-> paragraph-node :children (get span))]
+ (condp = direction
+ POSITION-DATA-RUBY (ruby-strip-entry element entry)
+ POSITION-DATA-ZHUYIN (zhuyin-glyph-entry element entry)
+ POSITION-DATA-EMPHASIS-MARK (emphasis-mark-entry element entry)
+ (some-> (text-strip-entry paragraph-node element entry)
+ (assoc ::ruby-base
+ (when-let [start (get (base-starts paragraph-node) span)]
+ [paragraph start]))))))))
+ (join-ruby-strips))))))
(defn apply-canvas-blur
[]
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index 1eded1a9e7..8a55f46fa4 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -22,7 +22,8 @@
[app.render-wasm.api.fonts :as fonts]
[app.render-wasm.text-editor-history :as history]
[app.util.color :as uc]
- [app.util.dom :as dom]))
+ [app.util.dom :as dom]
+ [cuerdas.core :as str]))
(def multiple-state-multiple (sr/translate-multiple-state :multiple))
@@ -406,6 +407,23 @@
(mem/free)
{:x x :y y :width width :height height})))))
+(defn text-editor-get-selection-rect
+ "Read the box around the selected text from the WASM editor, in page
+ coordinates: a map with :x :y :width :height that encloses the text as
+ drawn, rotated or not. Nil when the selection is collapsed."
+ []
+ (when (wasm/ready?)
+ (let [ptr (h/call wasm/internal-module "_text_editor_get_selection_rect")]
+ (when (and ptr (not (zero? ptr)))
+ (let [heap-f32 (mem/get-heap-f32)
+ offset (mem/->offset-32 ptr)
+ x (aget heap-f32 offset)
+ y (aget heap-f32 (+ offset 1))
+ width (aget heap-f32 (+ offset 2))
+ height (aget heap-f32 (+ offset 3))]
+ (mem/free)
+ {:x x :y y :width width :height height})))))
+
(defn text-editor-encode-text-pre
[text]
(when (and (not (empty? text))
@@ -878,8 +896,45 @@
(if (= idx end-para) end-offset (para-char-count para)))))
(range start-para (inc end-para))))
+(defn- span-bounds
+ "Char range `[start end)` of each span of `paragraph`."
+ [paragraph]
+ (->> (:children paragraph)
+ (reductions (fn [[_ end] span] [end (+ end (count (:text span)))]) [0 0])
+ (rest)
+ (vec)))
+
+(defn- annotation-bases
+ "Char ranges `[start end)` of the annotated text of `paragraph`: each
+ reading (`cjk/ruby-groups`, which can cross spans) and each warichu note."
+ [paragraph]
+ (let [spans (:children paragraph)
+ bounds (span-bounds paragraph)]
+ (into (mapv (fn [[start end]] [(first (nth bounds start)) (second (nth bounds (dec end)))])
+ (cjk/ruby-groups spans))
+ (keep-indexed (fn [index span]
+ (when (= "warichu" (:warichu span)) (nth bounds index))))
+ spans)))
+
(defn reading-selection?
- "True for characters in one span, including the whole base of an existing reading."
+ "True for text that can take one reading: characters of one paragraph
+ without a reading or warichu note, whatever their styles (the reading
+ joins their spans into one base), or the whole base of an existing one."
+ [content selection]
+ (boolean
+ (when (and content selection)
+ (let [{:keys [start-para start-offset end-para end-offset]} (normalize-selection selection)
+ paragraph (get-in content [:children 0 :children start-para])]
+ (and (= start-para end-para)
+ (some? paragraph)
+ (< start-offset end-offset)
+ (or (not-any? cjk/annotated-span?
+ (paragraph-selected-spans paragraph start-offset end-offset))
+ (some #(= [start-offset end-offset] %) (annotation-bases paragraph))))))))
+
+(defn single-span-selection?
+ "True for characters of one span, including the whole of an annotated one:
+ the text a warichu note can take, as it never crosses spans."
[content selection]
(boolean
(when (and content selection)
@@ -956,6 +1011,15 @@
(merge (selection-cjk-styles content selection)
(selection-paragraph-styles content selection)))))
+(defn- whole-base-span?
+ "True for an annotated span that a decoration edit must cover whole: a
+ warichu note, or a reading without `:ruby-group`, which splitting would
+ repeat over each piece. A grouped reading keeps one base when split."
+ [span]
+ (and (cjk/annotated-span? span)
+ (or (= "warichu" (:warichu span))
+ (str/blank? (:ruby-group span)))))
+
(defn- annotation-style-range
[paragraph start end styles]
(if (and (< start end)
@@ -965,7 +1029,7 @@
(let [[start end]
(reduce (fn [[from to offset] span]
(let [span-end (+ offset (count (:text span)))]
- (if (and (< offset end) (< start span-end) (cjk/annotated-span? span))
+ (if (and (< offset end) (< start span-end) (whole-base-span? span))
[(min from offset) (max to span-end) span-end]
[from to span-end])))
[start end 0]
@@ -1171,11 +1235,19 @@
(= k :text-decoration) [k "none"])))
styles)))
+(defn- with-ruby-group
+ "A reading set on a selection joins its spans into one base with a fresh
+ `:ruby-group`, whatever their styles; removing the reading drops it."
+ [styles]
+ (if (and (map? styles) (contains? styles :ruby) (not (contains? styles :ruby-group)))
+ (assoc styles :ruby-group (if (str/blank? (:ruby styles)) "" (str (uuid/next))))
+ styles))
+
(defn apply-styles-to-selection
"Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`."
[styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]]
(when (wasm/ready?)
- (let [styles (clean-styles styles)
+ (let [styles (-> styles clean-styles with-ruby-group)
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
diff --git a/frontend/src/app/util/code_gen/style_css.cljs b/frontend/src/app/util/code_gen/style_css.cljs
index 8ea5eebfc5..addf07e98a 100644
--- a/frontend/src/app/util/code_gen/style_css.cljs
+++ b/frontend/src/app/util/code_gen/style_css.cljs
@@ -275,16 +275,31 @@ body {
(types.text/node-seq types.text/is-text-node? (:content shape)))
(dm/fmt ".% .tcy {\n%\n}" shape-selector (format-js-styles sts/tcy-run-style nil))))
+(defn- joined-ruby-ids
+ "Ids of the text nodes that continue the reading of an earlier node
+ (`cjk/ruby-groups`): the markup draws one reading per base, under the
+ wrapper of its first node."
+ [content]
+ (into #{}
+ (comp (filter types.text/is-paragraph-node?)
+ (mapcat (fn [paragraph]
+ (let [nodes (vec (:children paragraph))]
+ (for [[start end] (cjk/ruby-groups nodes)
+ index (range (inc start) end)]
+ (:$id (nth nodes index)))))))
+ (types.text/node-seq content)))
+
(defn generate-text-css
[shape]
- (let [selector (cgc/shape->selector shape)]
- (->> shape
- :content
- (legacy.txt/index-content)
+ (let [selector (cgc/shape->selector shape)
+ content (legacy.txt/index-content (:content shape))
+ joined (joined-ruby-ids content)]
+ (->> content
(types.text/node-seq)
(mapcat (fn [node]
[(node->css shape selector node)
- (ruby-css shape selector node)]))
+ (when-not (contains? joined (:$id node))
+ (ruby-css shape selector node))]))
(cons (tcy-css shape selector))
(remove nil?)
(str/join "\n"))))
diff --git a/frontend/test/frontend_tests/code_gen_style_test.cljs b/frontend/test/frontend_tests/code_gen_style_test.cljs
index 4fb51a260c..e3e74fdf37 100644
--- a/frontend/test/frontend_tests/code_gen_style_test.cljs
+++ b/frontend/test/frontend_tests/code_gen_style_test.cljs
@@ -390,3 +390,30 @@
(is (str/includes? css "text-emphasis-position: over left"))
(is (str/includes? css "text-underline-position: under right"))
(is (str/includes? css "text-emphasis-position: under right"))))
+
+(def ^:private joined-ruby-text-content
+ "One reading over two spans, the second with emphasis marks."
+ (assoc-in ruby-text-content [:children 0 :children 0 :children]
+ [{:text "漢" :ruby "かんじ" :ruby-group "g" :font-size "20"
+ :fill-color "#000000" :fill-opacity 1}
+ {:text "字" :ruby "かんじ" :ruby-group "g" :font-size "20"
+ :text-emphasis "filled-dot" :fill-color "#000000" :fill-opacity 1}]))
+
+(defn- count-of [re text]
+ (count (re-seq re text)))
+
+(deftest a-reading-over-two-spans-is-one-ruby-in-the-markup
+ (testing "generated markup and the foreignObject render draw the reading once over both spans"
+ (let [text (text-shape joined-ruby-text-content)]
+ (doseq [markup [(html/generate-markup (objects text) [text])
+ (rds/renderToStaticMarkup
+ (mf/element fo-text/text-shape* #js {:shape text :grow-type :fixed}))]]
+ (is (= 1 (count-of #"]*>.*漢.*字.*