alonso.torres 5d647b2cd5 ✨ Replace the CJK layout toggles with a text Language panel
The CJK text options were behind per-language "Enable layout" toggles in
the file menu and the settings, each with its own sidebar section. A text
now has one language, picked in a single Language panel, and the options
of that language show below it.

- Rename the `vertical-text-layout` flag and feature to `cjk-support`.
- Remove the per-file and per-profile layout toggles, the file options
  that stored them and the `:set-text-layout` change.
- Add the Language panel: a grouped dropdown of Japanese, Korean and the
  regional Chinese variants, a row to change the language and a button to
  remove it, "Mixed" when the selected texts differ. Picking a language
  writes it to every paragraph of the texts.
- Render the CJK attrs only under the language that uses them, so a text
  without language is horizontal and plain. The stored content keeps
  every attr.
- Remember the last picked language for the next new texts.
- Add File > Language: the language of the texts without one of their
  own, applied at render time and laid out again when it changes.
- Add the profile text language in Settings > Options: new files start
  with it as their file language.
- Extract the grouped dropdown of the board size presets into a shared
  component used by the presets and the Language panel.
- Name the Japanese readings furigana, drop the redundant prefixes of
  the furigana and pinyin options, move Distribute below the writing
  mode row and show only the position sides of the writing mode.

AI-assisted-by: claude-opus-5-5
2026-11-05 17:49:00 +01:00

303 lines
13 KiB
Clojure

;; 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.data.workspace.wasm-text
"Helpers/events to resize wasm text shapes without depending on workspace.texts.
This exists to avoid circular deps:
workspace.texts -> workspace.libraries -> workspace.texts"
(:require
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.matrix :as gmt]
[app.common.geom.point :as gpt]
[app.common.render-wasm.api.select :as wselect]
[app.common.types.modifiers :as ctm]
[app.common.types.text :as txt]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as-alias dw]
[app.main.data.workspace.modifiers :as dwm]
[app.main.data.workspace.reflow :as wrf]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[app.util.text.geometry :as text.geom]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(def debounce-resize-text-time 40)
(defn resolve-text-size
"Selects the axes controlled by WASM for a text grow type. Vertical
auto-height keeps the physical height as its wrap budget and grows width."
[selrect grow-type content dimension]
(when (or (= :fixed grow-type) (some? dimension))
(text.geom/resolve-size selrect grow-type content dimension)))
(defn get-wasm-text-new-size
"Computes the new {width, height} for a text shape from WASM text layout.
For :fixed grow-type, updates WASM content and returns current dimensions (no resize)."
([shape]
(get-wasm-text-new-size shape (:content shape)))
([{:keys [id selrect grow-type] :as shape} content]
;; Skip when the WASM context is not ready (e.g. switching renderer while a
;; text shape is being edited): there is no design state to query, and
;; returning nil makes callers skip the WASM resize/modifier path.
(when (and id (wasm.api/initialized?))
(wselect/use-shape id)
;; While the WASM text editor is actively editing it already holds the live
;; content and layout. Re-pushing the content here calls `_clear_shape_text`
;; + `_update_shape_text_layout`, which resets the editor and drops every
;; keystroke after the first, so we just measure the live layout instead.
(when-not (and (wasm.api/text-editor-has-focus?)
(= id (wasm.api/text-editor-get-active-shape-id)))
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content))
(let [dimension (when (not= :fixed grow-type)
(wasm.api/get-text-dimensions))]
(resolve-text-size selrect grow-type content dimension)))))
(defn resize-wasm-text-modifiers
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id selrect] :as shape} content]
(when-let [new-size (get-wasm-text-new-size shape content)]
(let [width-scale (/ (:width new-size) (:width selrect))
height-scale (/ (:height new-size) (:height selrect))
resize-v (gpt/point width-scale height-scale)
origin (text.geom/resize-origin (assoc shape :content content))]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}}))))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.
No-op if the id is not a text shape or is :fixed.
`opts` are forwarded to `apply-wasm-modifiers`, so a caller whose undo
transaction is already closed when the resize lands can still get the
geometry into the right undo entry."
([id]
(resize-wasm-text id nil))
([id opts]
(ptk/reify ::resize-wasm-text
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
apply-opts (or opts {})
resize-stream
(if (and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape) apply-opts))
(rx/empty))]
(wrf/with-pending :text-resize [id] resize-stream))))))
(defn- merge-resize-debounce-opts
[prev {:keys [undo-group undo-id skip-component-sync?]}]
(cond-> (or prev {})
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)
skip-component-sync? (assoc :skip-component-sync? true)))
(defn resize-wasm-text-debounce-commit
[]
(ptk/reify ::resize-wasm-text-debounce-commit
ptk/WatchEvent
(watch [_ state _]
(let [ids (get state ::resize-wasm-text-debounce-ids)
{:keys [undo-group undo-id skip-component-sync?]} (get state ::resize-wasm-text-debounce-opts)
objects (dsh/lookup-page-objects state)
modifiers
(reduce
(fn [modifiers id]
(let [shape (get objects id)]
(cond-> modifiers
(and (some? shape)
(cfh/text-shape? shape)
(not= :fixed (:grow-type shape)))
(merge (resize-wasm-text-modifiers shape)))))
{}
ids)
;; When undo-id is present, extend the current undo transaction instead of
;; creating a new one, and commit it after the resize (single undo action).
extend-tx? (some? undo-id)
apply-opts (cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
extend-tx? (assoc :undo-transation? false)
skip-component-sync? (assoc :skip-component-sync? true))]
(cond
(not (empty? modifiers))
(if extend-tx?
(rx/concat
(rx/of (dwm/apply-wasm-modifiers modifiers apply-opts))
(rx/of (dwu/commit-undo-transaction undo-id)))
(rx/of (dwm/apply-wasm-modifiers modifiers apply-opts)))
extend-tx?
;; No resize needed (e.g. :fixed grow-type) but we must commit the add
(rx/of (dwu/commit-undo-transaction undo-id))
:else
(rx/empty))))))
;; This event will debounce the resize events so, if there are many, they
;; are processed at the same time and not one-by-one. This will improve
;; performance because it's better to make only one layout calculation instead
;; of (potentialy) hundreds.
(defn resize-wasm-text-debounce-inner
([id]
(resize-wasm-text-debounce-inner id nil))
([id opts]
(let [cur-event (js/Symbol)
reflow-task (wrf/task :text-resize [id])]
(ptk/reify ::resize-wasm-text-debounce-inner
ptk/UpdateEvent
(update [_ state]
(-> state
(update ::resize-wasm-text-debounce-ids (fnil conj []) id)
(update ::resize-wasm-text-reflow-tasks (fnil conj []) reflow-task)
(cond-> (seq opts)
(update ::resize-wasm-text-debounce-opts merge-resize-debounce-opts opts))
(cond-> (nil? (::resize-wasm-text-debounce-event state))
(assoc ::resize-wasm-text-debounce-event cur-event))))
ptk/WatchEvent
(watch [_ state stream]
(wrf/start! reflow-task)
(if (= (::resize-wasm-text-debounce-event state) cur-event)
(let [stopper (->> stream (rx/filter (ptk/type? ::dw/finalize-workspace)))]
(rx/concat
(rx/merge
(->> stream
(rx/filter (ptk/type? ::resize-wasm-text-debounce-inner))
(rx/debounce debounce-resize-text-time)
(rx/take 1)
(rx/map (fn [_] (resize-wasm-text-debounce-commit)))
(rx/take-until stopper))
(rx/of (resize-wasm-text-debounce-inner id opts)))
;; Cleanup, reached both after the commit and when the stopper
;; cancels the debounce, so the batch always drains and stays
;; pending until the resize is applied. All exact tasks in the
;; batch are retained in state and finished by the cleanup.
(rx/of (fn [state]
(wrf/finish-tasks! (::resize-wasm-text-reflow-tasks state))
(dissoc state
::resize-wasm-text-debounce-ids
::resize-wasm-text-reflow-tasks
::resize-wasm-text-debounce-opts
::resize-wasm-text-debounce-event)))))
(rx/empty)))))))
(defn resize-wasm-text-debounce
([id]
(resize-wasm-text-debounce id nil))
([id {:keys [undo-group undo-id skip-component-sync?] :as opts}]
(ptk/reify ::resize-wasm-text-debounce
ptk/WatchEvent
(watch [_ state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
content (dm/get-in objects [id :content])
fonts (wasm.fonts/get-content-fonts content)
fonts-ready?
(->> fonts
(every?
(fn [font]
(let [font-data (wasm.fonts/make-font-data font)]
(wasm.fonts/font-ready? font-data)))))
resize-wasm-stream
(if fonts-ready?
(let [pass-opts (when (or (some? undo-group) (some? undo-id) skip-component-sync?)
(cond-> {}
(some? undo-group) (assoc :undo-group undo-group)
(some? undo-id) (assoc :undo-id undo-id)
skip-component-sync? (assoc :skip-component-sync? true)))]
(rx/of (resize-wasm-text-debounce-inner id pass-opts)))
;; Fonts not loaded; retry after 20 msecs
(->> (rx/of (resize-wasm-text-debounce id opts))
(rx/delay 20)))]
;; Holds the shape pending across the font-retry loop: the retried
;; event opens its task before this one drains, so the task set never
;; becomes empty in between.
(wrf/with-pending :text-resize [id] resize-wasm-stream))))))
(defn resize-wasm-text-all
"Resize all text shapes (auto-width/auto-height) from a collection of ids."
([ids]
(resize-wasm-text-all ids nil))
([ids opts]
(ptk/reify ::resize-wasm-text-all
ptk/WatchEvent
(watch [_ state stream]
(let [resize-stream
(->> (rx/from ids)
(rx/map #(resize-wasm-text-debounce % opts)))
buffer-finished-stream
(->> (rx/merge
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-commit))
(rx/map (constantly :commit)))
;; Let a buffered commit beat the stop signal.
(->> stream
(rx/filter (ptk/type? ::dwsh/update-shapes-buffer-stop))
(rx/observe-on :async)
(rx/map (constantly :stop)))
(->> stream
(rx/filter (ptk/type? ::dw/finalize-workspace))
(rx/map (constantly :finalize))))
(rx/take 1))]
(if (::dwsh/update-shapes-buffer state)
;; If we're in the middle of a token propagation we wait until is finished to
;; recalculate the text sizes. The shapes stay pending for that whole wait,
;; since the per-shape debounce only marks them once dispatched.
(wrf/with-pending
:text-resize ids
(->> buffer-finished-stream
(rx/mapcat
(fn [reason]
(if (= reason :finalize)
(rx/empty)
resize-stream)))))
resize-stream))))))
(defn- file-language-text?
"True when some paragraph of the text `shape` has no language of its own,
so it renders with the file language."
[shape]
(and (cfh/text-shape? shape)
(let [paragraphs (txt/node-seq txt/is-paragraph-node? (:content shape))]
(or (empty? paragraphs)
(some #(nil? (:text-language %)) paragraphs)))))
(defn refresh-file-language-texts
"Lay out again the texts of the page that render with the file language,
after it changed, and resize the auto-sized ones."
[]
(ptk/reify ::refresh-file-language-texts
ptk/WatchEvent
(watch [_ state _]
(let [shapes (into [] (filter file-language-text?) (vals (dsh/lookup-page-objects state)))]
(rx/concat
(rx/defer (fn []
(wasm.api/refresh-text-contents shapes)
(rx/empty)))
(rx/of (resize-wasm-text-all (mapv :id shapes))))))))