diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index f31016ead4..f6b6a5a11a 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -378,6 +378,29 @@ export class WorkspacePage extends BaseWebSocketPage { } } + /** + * Creates a new auto-width Text Shape by single-clicking at the given + * coordinates (as opposed to dragging a fixed-size box) and, optionally, + * types an initial text. + * + * @param {number} x + * @param {number} y + * @param {string} [initialText] + * @param {*} [options] + */ + async createAutoWidthTextShape(x, y, initialText, options) { + const timeToWait = options?.timeToWait ?? 100; + await this.page.keyboard.press("T"); + await this.page.waitForTimeout(timeToWait); + + await this.clickAt(x, y); + + if (initialText) { + await this.waitForSelectedShapeName("Text"); + await this.page.keyboard.type(initialText); + } + } + /** * Copies the selected element into the clipboard, or copy the * content of the locator into the clipboard. diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 35e5e1853a..4a5964ea01 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -108,3 +108,24 @@ test.describe("BUG 10530 - Empty text box left behind when leaving the editor", }); }); +test("BUG 10467 - Auto-width text captures every typed character", 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"); + + // A single click with the text tool creates an auto-width text box by default + await workspace.createAutoWidthTextShape(200, 150, "hello world"); + + // Leave the editor to finalize the content + await workspace.textEditor.stopEditing(); + + // Assert the whole typed text made it into the shape + await workspace.layers.getByTestId("layer-row").first().click(); + await workspace.waitForSelectedShapeName("hello world"); +}); + diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 30b2ecf40b..df0508a1ba 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1098,7 +1098,12 @@ (select-keys shape [:selrect :points :width :height])) content-has-text? (v2-content-has-text? content) prev-content-has-text? (v2-content-has-text? prev-content) - new-size (when (not= :fixed (:grow-type shape)) + ;; Only measure/resize the shape on finalize. While the user is + ;; actively typing, the WASM editor already renders the growing text + ;; (and the editor overlay measures it live), so a per-keystroke + ;; resize is redundant and, going through the interactive-transform + ;; 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)) ;; New shapes: single undo on finalize only (no per-keystroke undo) effective-save-undo? (if new-shape? finalize? save-undo?) @@ -1139,15 +1144,12 @@ :stack-undo? effective-stack-undo? :undo-group (when new-shape? id)}) - ;; When `get-wasm-text-new-size` reports a change, `update-shapes` above resizes the - ;; shape data; the WASM renderer still needs matching modifiers. While editing, use - ;; `set-wasm-modifiers` for a temporary preview; on `finalize?`, `apply-wasm-modifiers` - ;; commits layout (flex parents, sidebar width, etc.) like other transform flows. + ;; `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)] - (if finalize? - (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)}) - (dwm/set-wasm-modifiers modifiers {:undo-group (when new-shape? id)}))))) + (dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)})))) (when finalize? (rx/concat diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 6468f487aa..5ae5cb8bc7 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -38,8 +38,14 @@ ;; returning nil makes callers skip the WASM resize/modifier path. (when (and id (wasm.api/initialized?)) (wasm.api/use-shape id) - (wasm.api/set-shape-text-content id content) - (wasm.api/set-shape-text-images id content) + ;; 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))] {:width (if (#{:fixed :auto-height} grow-type) 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 47cb26696f..458153f3b5 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 @@ -9,6 +9,7 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data.macros :as dm] + [app.common.types.text :as txt] [app.main.data.helpers :as dsh] [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] @@ -28,10 +29,37 @@ [& {:keys [finalize?]}] (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? finalize?)))) + ;; Derive the layer name from the text so it tracks the content. + (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 + :finalize? finalize?))))) + +(defn- reset-input-node + "Empties the contenteditable capture surface and restores a collapsed caret + inside it. + + The surface only exists to capture keystrokes for the WASM editor, so we clear + it after every input. But removing the text node the caret lived in leaves the + document without a valid selection, and the browser then stops firing `input` + events for subsequent keystrokes (you can only type one character). Re-placing + the caret inside the (now empty) node keeps input flowing, and we re-focus only + if focus was actually lost so we don't reset the WASM cursor on every keystroke." + [^js node] + (when (some? node) + (set! (.-textContent node) "") + (when (not= (.-activeElement js/document) node) + (.focus node)) + (when-let [sel (.getSelection js/window)] + (let [range (.createRange js/document)] + (.selectNodeContents range node) + (.collapse range true) + (.removeAllRanges sel) + (.addRange sel range))))) (defn- font-family-from-font-id [font-id] (if (str/includes? font-id "gfont-noto-sans") @@ -73,18 +101,28 @@ [{:keys [x y width height]} transform] (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) + vbox (mf/deref refs/vbox) [selrect transform] (dsh/get-selrect selrect-transform shape) selrect-height (:height selrect) selrect-width (:width selrect) max-width (max width selrect-width) max-height (max height selrect-height) + ;; During auto-width editing the shape width is trimmed to the content, so an + ;; empty text box ends up only a few pixels wide. That is not enough room for + ;; the caret and the contenteditable overlay may fail to receive input when it + ;; is that small. Expand the overlay by one viewport width for auto-width texts + ;; (mirroring the v2 editor) so typing works and the caret is not clipped. + viewport-width (or (:width vbox) 0) + overlay-width (if (= (:grow-type shape) :auto-width) + (+ max-width viewport-width) + max-width) valign (-> shape :content :vertical-align) y (:y selrect) y (case valign "bottom" (+ y (- selrect-height height)) "center" (+ y (/ (- selrect-height height) 2)) y)] - [(assoc selrect :y y :width max-width :height max-height) transform]) + [(assoc selrect :y y :width overlay-width :height max-height) transform]) on-composition-start (mf/use-fn @@ -101,8 +139,7 @@ (text-editor/text-editor-composition-update data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) + (reset-input-node (mf/ref-val contenteditable-ref))))) on-composition-end (mf/use-fn @@ -111,8 +148,7 @@ (text-editor/text-editor-composition-end data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-composition")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) "")))) + (reset-input-node (mf/ref-val contenteditable-ref)))) on-paste (mf/use-fn @@ -124,8 +160,7 @@ (text-editor/text-editor-insert-text text) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-paste")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) + (reset-input-node (mf/ref-val contenteditable-ref))))) on-copy (mf/use-fn @@ -148,8 +183,7 @@ (text-editor/text-editor-delete-backward) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-cut")))) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) ""))))) + (reset-input-node (mf/ref-val contenteditable-ref))))) on-key-down (mf/use-fn @@ -258,8 +292,7 @@ (text-editor/text-editor-insert-text data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-input")) - (when-let [node (mf/ref-val contenteditable-ref)] - (set! (.-textContent node) "")))))) + (reset-input-node (mf/ref-val contenteditable-ref)))))) on-pointer-down (mf/use-fn diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 579eaa0ade..2403814e18 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -283,6 +283,7 @@ (def text-editor-get-current-styles text-editor/text-editor-get-current-styles) (def text-editor-has-focus? text-editor/text-editor-has-focus?) (def text-editor-has-selection? text-editor/text-editor-has-selection?) +(def text-editor-get-active-shape-id text-editor/text-editor-get-active-shape-id) (def text-editor-select-all text-editor/text-editor-select-all) (def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) (def text-editor-sync-content text-editor/text-editor-sync-content) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 574ec1a72b..5dba3e675c 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -8,6 +8,7 @@ "Text editor WASM bindings" (:require [app.common.types.fills.impl :as types.fills.impl] + [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.fonts :as main-fonts] [app.render-wasm.api.fonts :as fonts] @@ -546,6 +547,22 @@ new-para-set (assoc para-set :children new-paras)] (assoc content :children [new-para-set]))) +(defn- default-empty-text-content + "Build a default, empty text content tree used as a merge template. + + A text shape created by a single click starts with `:content` nil, so + `set-shape-text-content` never seeds the content cache for it. Without a + template `text-editor-sync-content` would bail and the characters typed into + the WASM editor would never reach the shape. This provides the default + (Source Sans Pro) styling the WASM editor uses for a fresh empty shape." + [] + (let [attrs (txt/get-default-text-attrs)] + {:type "root" + :children [{:type "paragraph-set" + :children [(merge attrs + {:type "paragraph" + :children [(merge attrs {:text ""})]})]}]})) + (defn text-editor-sync-content "Sync text content from the WASM text editor back to the frontend shape. @@ -559,7 +576,11 @@ new-texts (text-editor-export-content)] (when (and shape-id new-texts) (let [texts-clj (js->clj new-texts) - content (get-cached-content shape-id)] + ;; A brand-new empty text shape (single click) has no cached + ;; content yet, so fall back to a default template so the first + ;; keystrokes are synced back to the shape instead of dropped. + content (or (get-cached-content shape-id) + (default-empty-text-content))] (when content (let [merged (merge-exported-texts-into-content content texts-clj)] (swap! shape-text-contents assoc shape-id merged)