mirror of
https://github.com/penpot/penpot.git
synced 2026-07-21 13:37:49 +00:00
🐛 Fix autowidth in text editor v3
This commit is contained in:
parent
83d2edf256
commit
a58c0e1407
@ -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.
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user