🎉 Add caret style changes (text editor v3) (#11171)

* 🐛 Fix editor v3 quitting when changing typography options

* 🎉 Apply text styles to collapsed caret

* 🐛 Fix not persisting the new selrect

* 🐛 Fix selrect not being recomputed on caret style changes

* 🐛 Fix quitting the editor when changing typography on empty texts
This commit is contained in:
Belén Albeza 2026-08-13 07:23:15 +02:00 committed by GitHub
parent 1c14c854ae
commit be83656d55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 319 additions and 66 deletions

View File

@ -23,6 +23,49 @@ async function openEditorAndSelectAll(workspace) {
await workspace.page.keyboard.press("ControlOrMeta+a");
}
test("Typography at a collapsed caret only styles newly typed text", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const fontSize = workspace.textEditor.fontSize;
const editorInput = page.locator("#text-editor-wasm-input");
// Draw a text box, focus it, and type some text; the caret ends up collapsed
// after it.
await workspace.createTextShape(200, 150, 460, 260);
await workspace.clickAt(210, 160);
await expect(editorInput).toBeFocused();
await page.keyboard.type("ab");
const originalSize = await fontSize.inputValue();
const newSize = String(Number(originalSize) + 20);
// Change the font size with a collapsed caret. This must not restyle the
// existing text; it is stashed as a pending style for the next input. Focus
// returns to the editor once the sidebar input blurs.
await workspace.textEditor.changeFontSize(newSize);
await expect(editorInput).toBeFocused();
// Typing now adopts the pending size as its own span.
await page.keyboard.type("X");
// The just-typed "X" carries the new size...
await page.keyboard.press("Shift+ArrowLeft");
await expect(fontSize).toHaveValue(newSize);
// ...while the pre-existing "ab" keeps the original size (the bug applied the
// change to the whole shape instead).
await page.keyboard.press("Home");
await page.keyboard.press("Shift+ArrowRight");
await page.keyboard.press("Shift+ArrowRight");
await expect(fontSize).toHaveValue(originalSize);
});
test.describe("BUG 10502 - Mixed families and variants", () => {
test("Multiple variants of the same font family", async ({ page }) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
@ -114,6 +157,69 @@ test.describe("BUG 10530 - Empty text box left behind when leaving the editor",
});
});
test.describe("BUG 11083 - Changing typography must not quit the editor", () => {
test("Changing a numeric input must not quit the editor", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const layerRows = workspace.layers.getByTestId("layer-row");
// Draw an empty text box and, without typing anything, change the font size.
await workspace.createTextShape(200, 150, 320, 210);
await expect(layerRows).toHaveCount(1);
await workspace.textEditor.changeFontSize(24);
// The shape is not deleted and the editor is still mounted.
await expect(layerRows).toHaveCount(1);
await expect(page.getByTestId("text-editor")).toBeVisible();
// The edition survives, so we can click back into the box and keep typing.
await workspace.clickAt(210, 160);
await page.keyboard.type("hello");
await workspace.textEditor.stopEditing();
await layerRows.first().click();
await workspace.waitForSelectedShapeName("hello");
});
test("Opening the font family selector must not quit the editor", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page, { textEditor: true });
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.waitForFirstRender();
const layerRows = workspace.layers.getByTestId("layer-row");
// Draw an empty text box and, without typing anything, open the font family
// selector
await workspace.createTextShape(200, 150, 320, 210);
await expect(layerRows).toHaveCount(1);
await workspace.rightSidebar.getByTitle("Font Family").click();
// The shape is not deleted and the editor is still mounted.
await expect(layerRows).toHaveCount(1);
await expect(page.getByTestId("text-editor")).toBeVisible();
// The edition survives, so we can click back into the box and keep typing.
await workspace.clickAt(210, 160);
await page.keyboard.type("hello");
await workspace.textEditor.stopEditing();
await layerRows.first().click();
await workspace.waitForSelectedShapeName("hello");
});
});
test("BUG 10467 - Auto-width text captures every typed character", async ({
page,
}) => {

View File

@ -1014,8 +1014,18 @@
(rx/of (update-paragraph-attrs {:id id :attrs attrs}))))
(let [attrs (select-keys attrs txt/text-node-attrs)]
(if (or (empty? attrs) wasm-editing-selection?)
(cond
(or (empty? attrs) wasm-editing-selection?)
(rx/empty)
;; Collapsed caret: stash a pending caret style for the next typed
;; character instead of restyling the whole shape.
wasm-editing?
(do
(wasm.text-editor/merge-pending-caret-styles! id attrs)
(rx/of (dwt-v3/v3-update-text-editor-styles id attrs)))
:else
(rx/of (update-text-attrs {:id id :attrs attrs}))))
(when (and (features/active-feature? state "text-editor/v2")
@ -1238,7 +1248,7 @@
Includes :name when update-name? so we can skip save-undo on the preceding
update-shapes for finalize without losing name undo."
[it state id {:keys [new-shape? content-has-text? content original-content
update-name? name]}]
update-name? name resize-geom]}]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
shape* (get objects id)
@ -1250,7 +1260,8 @@
(cond-> new-shape?
(-> (pcb/set-undo-group id)
(pcb/set-stack-undo? true))))
final-geom (select-keys shape* [:selrect :points :width :height])
;; `resize-geom` is the post-resize geometry; `shape*` still holds the pre-resize selrect.
final-geom (or resize-geom (select-keys shape* [:selrect :points :width :height]))
geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height])
old-geom (when (and content-has-text? (not= :fixed (:grow-type shape*)))
(or (get-in state [:workspace-text-session-geom id])
@ -1302,6 +1313,13 @@
;; modifier machinery, made auto-width typing very laggy.
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
(dwwt/get-wasm-text-new-size shape content))
;; Also compute the resized geometry for the finalize commit; the
;; async `apply-wasm-modifiers` below never updates this `state`.
resize-modifiers (when (some? new-size)
(dwwt/resize-wasm-text-modifiers shape content))
resize-geom (when resize-modifiers
(-> (gsh/transform-shape shape (get-in resize-modifiers [id :modifiers]))
(select-keys [:selrect :points :width :height])))
;; New shapes: single undo on finalize only (no per-keystroke undo)
effective-save-undo? (if new-shape? finalize? save-undo?)
effective-stack-undo? (and new-shape? finalize?)
@ -1341,12 +1359,9 @@
:stack-undo? effective-stack-undo?
:undo-group (when new-shape? id)})
;; `new-size` is only computed on finalize (see above), so this commits
;; the final auto-width/auto-height geometry via `apply-wasm-modifiers`
;; like other transform flows (flex parents, sidebar width, etc.).
(when (some? new-size)
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)}))))
;; Push the auto-grow geometry to WASM/app state; the commit persists it via `resize-geom`.
(when (some? resize-modifiers)
(dwm/apply-wasm-modifiers resize-modifiers {:undo-group (when new-shape? id)})))
(when finalize?
(rx/concat
@ -1379,7 +1394,8 @@
;; behavior (their create is bundled in the undo group).
:original-content (if new-shape? original-content prev-content)
:update-name? update-name?
:name name})))
:name name
:resize-geom resize-geom})))
(rx/empty))
(rx/of (dwt/finish-transform)
(fn [state]

View File

@ -7,7 +7,6 @@
(ns app.main.ui.workspace.shapes.text.text-edition-outline
(:require
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
@ -22,11 +21,13 @@
(let [selrect-transform (mf/deref refs/workspace-selrect)
[selrect transform] (dsh/get-selrect selrect-transform shape)
[sr-width sr-height]
(if (or (mth/close? (:width selrect) 0.01) (mth/close? (:height selrect) 0.01))
(let [{:keys [width height]} (wasm.api/get-text-dimensions (:id shape))]
[width height])
[(:width selrect) (:height selrect)])]
;; While editing, the committed selrect lags the text (geometry is
;; finalize-only), so measure the live WASM text for the growing axes:
;; width grows on auto-width, height on auto-width/auto-height.
grow-type (:grow-type shape)
{live-width :width live-height :height} (wasm.api/get-text-dimensions (:id shape))
sr-width (if (= grow-type :auto-width) live-width (:width selrect))
sr-height (if (= grow-type :fixed) (:height selrect) live-height)]
[:rect.main.viewport-selrect
{:x (:x selrect)
:y (:y selrect)

View File

@ -23,6 +23,20 @@
(def caret-blink-interval-ms 250)
;; Elements carrying this attr keep the edit alive when focus moves onto them (see `keep-editing-on-blur?`).
(def ^:private keep-editing-selector "[data-keep-editing-on-blur]")
(defn- keep-editing-on-blur?
"True when a surface `blur` must NOT exit the editor:
- Firefox triggering a blur when MacOS Character Viewer is open
- Focus switched to a data-keep-editing-on-blur region (e.g. typography options),
ancestors or descendants"
[^js event ^js surface]
(or (= (.-activeElement js/document) surface)
(when-let [related (dom/get-related-target event)]
(or (some? (.closest related keep-editing-selector))
(some? (.querySelector related keep-editing-selector))))))
(defn- sync-wasm-text-editor-content!
"Sync WASM text editor content back to the shape via the standard
commit pipeline. Called after every text-modifying input."
@ -39,6 +53,48 @@
:name name
:finalize? finalize?)))))
;; Keys that move/reset the caret (or delete): pressing any abandons the pending
;; caret style. Plain character keys instead reach `on-input`, which consumes it.
(def ^:private caret-abandon-keys
#{"ArrowLeft" "ArrowRight" "ArrowUp" "ArrowDown"
"Home" "End" "PageUp" "PageDown"
"Enter" "Backspace" "Delete" "Escape" "Tab"})
(defn- caret-position
"Collapsed caret as {:para :offset} from the WASM selection, or nil."
[]
(when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)]
{:para focus-para :offset focus-offset}))
(defn- typed-range
"Normalized range covering the text inserted between `before` and `after`, or nil."
[before after]
(when (and before after)
(if (or (< (:para before) (:para after))
(and (= (:para before) (:para after))
(<= (:offset before) (:offset after))))
{:start-para (:para before) :start-offset (:offset before)
:end-para (:para after) :end-offset (:offset after)}
{:start-para (:para after) :start-offset (:offset after)
:end-para (:para before) :end-offset (:offset before)})))
(defn- sync-with-pending-caret-styles!
"Commit an insertion that consumed a pending caret style: sync the new text,
then restyle the just-typed `range` into its own span. `before` is the
pre-insert caret."
[shape-id before]
(let [range (typed-range before (caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(let [text (txt/content->text content)
name (when (not= text "") (txt/generate-shape-name text))]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:name name)))
(sync-wasm-text-editor-content!))))
(defn- reset-input-node
"Empties the contenteditable capture surface and restores a collapsed caret
inside it.
@ -163,6 +219,8 @@
on-composition-start
(mf/use-fn
(fn [_event]
;; IME composition supplies its own text; drop any pending caret style.
(text-editor/clear-pending-caret-styles!)
(text-editor/text-editor-composition-start)))
on-composition-update
@ -190,6 +248,8 @@
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
;; Pasted text keeps the surrounding style; drop any pending caret style.
(text-editor/clear-pending-caret-styles!)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
@ -229,6 +289,10 @@
(let [key (.-key event)
ctrl? (or (.-ctrlKey event) (.-metaKey event))
shift? (.-shiftKey event)]
;; Ctrl+A adds select-all to the caret-abandon-keys set.
(when (or (contains? caret-abandon-keys key)
(and ctrl? (= (str/lower key) "a")))
(text-editor/clear-pending-caret-styles!))
(cond
;; Escape: finalize and stop
(= key "Escape")
@ -365,8 +429,14 @@
(let [pending (mf/ref-val pending-replace-ref)]
(dotimes [_ pending]
(text-editor/text-editor-delete-backward)))
(text-editor/text-editor-insert-text data)
(sync-wasm-text-editor-content!)
(let [shape-id (text-editor/text-editor-get-active-shape-id)
;; The inserted character adopts a pending caret style, if any.
pending-styles? (some? (text-editor/get-pending-caret-styles shape-id))
before (when pending-styles? (caret-position))]
(text-editor/text-editor-insert-text data)
(if pending-styles?
(sync-with-pending-caret-styles! shape-id before)
(sync-wasm-text-editor-content!)))
(wasm.api/request-render-preserving-target "text-input"))
(mf/set-ref-val! pending-replace-ref 0)
;; IMPORTANT: do NOT clear the surface here (see keep-input-alive):
@ -379,6 +449,9 @@
(fn [^js event]
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)]
;; Repositioning the caret abandons the pending caret style (also
;; covers click and double-click, which fire pointer-down first).
(text-editor/clear-pending-caret-styles!)
(mf/set-ref-val! dragging-ref true)
(if (.-shiftKey event)
(wasm.api/text-editor-pointer-down-extend off-pt)
@ -431,12 +504,10 @@
on-blur
(mf/use-fn
(fn [^js event]
;; MacOS Character Viewer on Firefox fires a `blur` when it opens.
;; To avoid losing the selected character, we need guard against
;; `activeElement` being the surface itself.
;; A blur exits the editor unless keep-editing-on-blur? is true
(when-not (and (some? event)
(= (.-activeElement js/document)
(mf/ref-val contenteditable-ref)))
(keep-editing-on-blur? event (mf/ref-val contenteditable-ref)))
(text-editor/clear-pending-caret-styles!)
(sync-wasm-text-editor-content! {:finalize? true})
(wasm.api/text-editor-blur))))

View File

@ -498,6 +498,8 @@
(ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!))))
[:section {:class (stl/css :element-set)
;; Focusing these controls must not exit the v3 text editor (see `keep-editing-on-blur?`).
:data-keep-editing-on-blur true
:aria-label (tr "workspace.options.text-options.text-section")}
[:div {:class (stl/css :element-title)}
[:> title-bar* {:collapsable true

View File

@ -735,6 +735,17 @@
(request-render "apply-paragraph-attrs-to-selection")
result))
(defn apply-pending-caret-styles!
"Apply the shape's pending caret style over `range` (the just-typed text) and
clear it; returns {:shape-id :content} or nil when there is none."
[shape-id range]
(when-let [styles (text-editor/get-pending-caret-styles shape-id)]
(let [result (text-editor/apply-styles-to-range
shape-id range styles use-shape set-shape-text-content)]
(text-editor/clear-pending-caret-styles!)
(request-render "apply-pending-caret-styles")
result)))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]

View File

@ -547,6 +547,24 @@
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
;; Typography chosen at a collapsed caret: not applied to existing text, but
;; picked up (as a new span) by the next inserted text. Keyed by shape-id.
(def ^:private pending-caret-styles (atom {}))
(defn merge-pending-caret-styles!
"Stack `styles` onto the shape's pending caret style."
[shape-id styles]
(swap! pending-caret-styles update shape-id merge styles))
(defn get-pending-caret-styles
[shape-id]
(get @pending-caret-styles shape-id))
(defn clear-pending-caret-styles!
"Drop every pending caret style; only the active shape can hold one."
[]
(reset! pending-caret-styles {}))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
@ -704,18 +722,49 @@
(= 1 (count fills-set)) (first fills-set)
:else :multiple)))
(defn- apply-styles-over-range
"Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans."
[content {:keys [start-para start-offset end-para end-offset]} styles]
(let [paragraph-set (first (:children content))
paragraphs (:children paragraph-set)
new-paragraphs (mapv (fn [idx para]
(cond
;; paragraph outside the range of paragraphs.
(or (< idx start-para) (> idx end-para))
para
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset styles)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) styles)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset styles)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) styles)))
(range (count paragraphs))
paragraphs)]
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
(defn- clean-styles
"Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail
the backend schema); a per-span fn is passed through untouched."
[styles]
(if (fn? styles)
styles
(into {} (remove (comp nil? val)) 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 [;; Drop nil-valued attrs so they are never merged onto text spans.
;; The DOM editor path strips these in `attrs->styles`; the WASM merge
;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr
;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded
;; font) would corrupt the span and fail the backend schema.
styles (if (fn? styles)
styles
(into {} (remove (comp nil? val)) styles))
(let [styles (clean-styles styles)
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
@ -725,41 +774,10 @@
(let [normalized-selection (normalize-selection selection)
{:keys [start-para start-offset end-para end-offset]} normalized-selection
collapsed? (and (= start-para end-para) (= start-offset end-offset))
collapsed? (and (= start-para end-para) (= start-offset end-offset))
paragraph-set (first (:children content))
paragraphs (:children paragraph-set)
new-paragraphs
(when (not collapsed?)
(mapv (fn [idx para]
(cond
;; paragraph outside the range of paragraphs.
(or (< idx start-para) (> idx end-para))
para
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset styles)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) styles)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset styles)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) styles)))
(range (count paragraphs))
paragraphs))
new-content (when new-paragraphs
(assoc content :children
[(assoc paragraph-set :children new-paragraphs)]))]
new-content (when (not collapsed?)
(apply-styles-over-range content normalized-selection styles))]
(when new-content
(update-cached-content! shape-id new-content)
@ -770,6 +788,24 @@
with-fills?
(assoc :fills (selection-fills new-content normalized-selection)))))))))))
(defn apply-styles-to-range
"Like `apply-styles-to-selection` but over an explicit range (used to restyle
just-inserted text); returns `{:shape-id :content}` or nil."
[shape-id {:keys [start-para start-offset end-para end-offset] :as range} styles
use-shape-fn set-shape-text-content-fn]
(when (wasm/ready?)
(let [styles (clean-styles styles)
content (get-cached-content shape-id)]
(when (and content
(seq styles)
(not (and (= start-para end-para) (= start-offset end-offset))))
(let [new-content (apply-styles-over-range content range styles)]
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
{:shape-id shape-id
:content new-content})))))
(defn apply-paragraph-attrs-to-selection
"Apply paragraph level attrs (text-align, text-direction) to the whole
paragraphs the editor selection touches; a collapsed caret means just the one

View File

@ -46,8 +46,18 @@
[]
(dom/query "[data-itype=\"editor\"]"))
(defn v3-get-text-editor-content
[]
(dom/get-element "text-editor-wasm-input"))
(defn get-text-editor-content
[]
(if (features/active-feature? @st/state "text-editor/v2")
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
(v3-get-text-editor-content)
(features/active-feature? @st/state "text-editor/v2")
(v2-get-text-editor-content)
:else
(v1-get-text-editor-content)))