Merge pull request #10563 from penpot/ladybenko-gh-10467-fix-autowidth

🐛 Fix autowidth in text editor v3
This commit is contained in:
Aitor Moreno 2026-07-07 14:24:19 +02:00 committed by GitHub
commit f51db39b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 133 additions and 26 deletions

View File

@ -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.

View File

@ -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");
});

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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)