From 075fc23cd0565052b8df42a983a1b058eda57a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 4 Aug 2026 13:04:22 +0200 Subject: [PATCH] :bug: Fix microinteractions on text shape selrects for autowidth/autoheight --- .../text-editor/get-file-fixed-size-text.json | 349 ++++++++++++++++++ .../ui/specs/text-editor-v3.spec.js | 111 +++++- .../main/ui/workspace/viewport/selection.cljs | 23 +- 3 files changed, 467 insertions(+), 16 deletions(-) create mode 100644 frontend/playwright/data/text-editor/get-file-fixed-size-text.json diff --git a/frontend/playwright/data/text-editor/get-file-fixed-size-text.json b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json new file mode 100644 index 0000000000..5e88e658a2 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json @@ -0,0 +1,349 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Fixed size text", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0004-clean-shadow-color", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": [ + "~u238a17e0-75ff-8075-8006-934586ea2231" + ], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0.0, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ucc6f0580-449c-8019-8006-9345db077fa0" + ] + } + }, + "~ucc6f0580-449c-8019-8006-9345db077fa0": { + "~#shape": { + "~:y": 150, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~:type": "root", + "~:key": "1s4am1jl24s", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "13p0zwl2yhc", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Lorem ipsum" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "20hf3kmyoub", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Fixed text", + "~:width": 300, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 200, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 350 + } + }, + { + "~#point": { + "~:x": 200, + "~:y": 350 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 200, + "~:selrect": { + "~#rect": { + "~:x": 200, + "~:y": 150, + "~:width": 300, + "~:height": 200, + "~:x1": 200, + "~:y1": 150, + "~:x2": 500, + "~:y2": 350 + } + }, + "~:flip-x": null, + "~:height": 200, + "~:flip-y": null + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 963d48a9ca..45f0103d19 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -9,7 +9,9 @@ const FILE = { test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); // WASM_FLAGS already enables render-wasm; add the WASM text editor on top. - await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); + await WasmWorkspacePage.mockConfigFlags(page, [ + "enable-feature-text-editor-wasm", + ]); }); async function openEditorAndSelectAll(workspace) { @@ -22,12 +24,12 @@ async function openEditorAndSelectAll(workspace) { } test.describe("BUG 10502 - Mixed families and variants", () => { - test("Multiple variants of the same font family", async ({ - page, - }) => { + test("Multiple variants of the same font family", async ({ page }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-variants.json", + ); await workspace.goToWorkspace(FILE); await workspace.waitForFirstRender(); @@ -47,10 +49,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => { await expect(fontVariant).toHaveText("--"); }); - test("Mixed font families appear as such in the dropdown", async ({ page }) => { + test("Mixed font families appear as such in the dropdown", async ({ + page, + }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-families.json", + ); // Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch. // Glyphs are irrelevant here: the assertion only inspects the sidebar. await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf"); @@ -204,9 +210,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ await workspace.copy("keyboard"); // Assert the text was copied correctly - const copiedText = await page.evaluate(() => - navigator.clipboard.readText(), - ); + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toBe("Lorem ipsum"); }); +test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => { + // Sets up the workspace and loads a text shape whose size is larger than its text + async function setupFixedSizeText(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + // Enable token inputs so they use the new component with accessible DOM + await workspace.mockConfigFlags(["enable-feature-token-input"]); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the text and zoom to fit, so it is fully visible in the viewport + await workspace.clickLeafLayer("Fixed text"); + await page.keyboard.press("Shift+1"); + await workspace.waitForIdle(); + + return workspace; + } + + async function doubleClickSideHandle(workspace, position) { + const handle = workspace.viewport.getByTestId( + `resize-side-handler-${position}`, + ); + await handle.waitFor(); + const box = await handle.boundingBox(); + await workspace.page.mouse.dblclick( + box.x + box.width / 2, + box.y + box.height / 2, + ); + } + + function measureInput(workspace, name) { + return workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name, exact: true }); + } + + test("Double-clicking the right handle switches to auto-width", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const widthInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Width", exact: true }); + const initialWidth = Number(await widthInput.inputValue()); + + await doubleClickSideHandle(workspace, "right"); + + // Assert auto-width is selected and that the width has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto width", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await widthInput.inputValue())) + .toBeLessThan(initialWidth); + }); + + test("Double-clicking the bottom handle switches to auto-height", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const heightInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Height", exact: true }); + const initialHeight = Number(await heightInput.inputValue()); + + await doubleClickSideHandle(workspace, "bottom"); + + // Assert auto-height is selected and that the height has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto height", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await heightInput.inputValue())) + .toBeLessThan(initialHeight); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 9e497ef8d0..4315ff2d0b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -18,6 +18,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -26,6 +28,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.object :as obj] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def rotation-handler-size 20) @@ -295,13 +298,20 @@ on-double-click (mf/use-fn (mf/deps shape-id position shape-type) - (fn [_event] + (fn [event] (when (= shape-type :text) - (cond - (= position :right) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width))) - (= position :bottom) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))] + ;; Prevent the viewport double-click handler from entering text editor + (dom/stop-propagation event) + (let [grow-type (case position + :right :auto-width + :bottom :auto-height + nil)] + (when (some? grow-type) + (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id]) + (ptk/data-event :layout/update {:ids [shape-id]}))))))))] [:g.resize-handler (when ^boolean show-handler @@ -321,6 +331,7 @@ :height height :class cursor :data-position (name position) + :data-testid (dm/str "resize-side-handler-" (name position)) :transform transform-str :on-pointer-down on-resize :on-double-click on-double-click