diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 45f0103d19..21d7d97909 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -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, }) => { diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 862a40ceb6..7499aae52e 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 35529a64a0..977ec783c1 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -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) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index d872d684a8..86cce0327d 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -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)))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 60b0c15c02..60832b642c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 36772bd49f..9e4e4c1010 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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)] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 767a0619cb..241d8960d6 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -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 diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs index a6242cb7e3..9f8576fcce 100644 --- a/frontend/src/app/util/text/ui.cljs +++ b/frontend/src/app/util/text/ui.cljs @@ -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)))