diff --git a/frontend/playwright/data/workspace/get-file-text-multiple-selection.json b/frontend/playwright/data/workspace/get-file-text-multiple-selection.json new file mode 100644 index 0000000000..74fe4f69d8 --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-text-multiple-selection.json @@ -0,0 +1,484 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u04868522-3ebf-81e8-8006-306b0c9b5f59", + "~: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": "Text: Custom Fonts", + "~:revn": 13, + "~:modified-at": "~m1750151641034", + "~:vern": 0, + "~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b", + "~: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", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content", + "0004-clean-shadow-and-colors", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-opacity", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u53a7ff09-2228-81d3-8006-4b5ea964593b", + "~:created-at": "~m1750081311326", + "~:data": { + "~:pages": ["~u434b0541-fa2f-802f-8006-6a827d964a9c"], + "~:pages-index": { + "~u434b0541-fa2f-802f-8006-6a827d964a9c": { + "~: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": [ + "~u7d85a63e-18e7-809f-8006-6a827fe8501e", + "~u7d85a63e-18e7-809f-8006-6a833ef5fcef" + ] + } + }, + "~u7d85a63e-18e7-809f-8006-6a827fe8501e": { + "~#shape": { + "~:y": 451.9999962296588, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "xgmgu1frox", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "gfont-rufina", + "~:key": "ee7vl7klqs", + "~:font-size": "72", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "normal-400", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "\"Rufina\"", + "~:text": "Text multiple selection one" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "center", + "~:font-id": "gfont-rufina", + "~:key": "17bt2f4evfs", + "~:font-size": "72", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "normal-400", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "\"Rufina\"" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text multiple selection one", + "~:width": 403.99995992417394, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 744.0000211580308, + "~:y": 451.9999962296588 + } + }, + { + "~#point": { + "~:x": 1147.9999810822046, + "~:y": 451.9999962296588 + } + }, + { + "~#point": { + "~:x": 1147.9999810822046, + "~:y": 537.9999971833331 + } + }, + { + "~#point": { + "~:x": 744.0000211580308, + "~:y": 537.9999971833331 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u7d85a63e-18e7-809f-8006-6a827fe8501e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 744.0000211580307, + "~:selrect": { + "~#rect": { + "~:x": 744.0000211580307, + "~:y": 451.9999962296588, + "~:width": 403.99995992417394, + "~:height": 86.00000095367432, + "~:x1": 744.0000211580307, + "~:y1": 451.9999962296588, + "~:x2": 1147.9999810822046, + "~:y2": 537.9999971833331 + } + }, + "~:flip-x": null, + "~:height": 86.00000095367432, + "~:flip-y": null + } + }, + "~u7d85a63e-18e7-809f-8006-6a833ef5fcef": { + "~#shape": { + "~:y": 537.9999971833331, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "xgmgu1frox", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "gfont-rufina", + "~:key": "ee7vl7klqs", + "~:font-size": "36", + "~:font-weight": "500", + "~:typography-ref-file": null, + "~:font-variant-id": "normal-500", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "\"Rufina\"", + "~:text": "Second text, same font" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "center", + "~:font-id": "gfont-rufina", + "~:key": "17bt2f4evfs", + "~:font-size": "0", + "~:font-weight": "500", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "normal-500", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "\"Rufina\"" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Text multiple selection two", + "~:width": 466.0000131576671, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 712.9999941849438, + "~:y": 537.9999971833331 + } + }, + { + "~#point": { + "~:x": 1179.0000073426108, + "~:y": 537.9999971833331 + } + }, + { + "~#point": { + "~:x": 1179.0000073426108, + "~:y": 580.9999976601703 + } + }, + { + "~#point": { + "~:x": 712.9999941849438, + "~:y": 580.9999976601703 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u7d85a63e-18e7-809f-8006-6a833ef5fcef", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 712.9999941849437, + "~:selrect": { + "~#rect": { + "~:x": 712.9999941849437, + "~:y": 537.9999971833331, + "~:width": 466.0000131576671, + "~:height": 43.00000047683716, + "~:x1": 712.9999941849437, + "~:y1": 537.9999971833331, + "~:x2": 1179.0000073426108, + "~:y2": 580.9999976601703 + } + }, + "~:flip-x": null, + "~:height": 43.00000047683716, + "~:flip-y": null + } + } + }, + "~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9c", + "~:name": "Page 1" + } + }, + "~:id": "~u434b0541-fa2f-802f-8006-6a827d964a9b", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/ui/specs/text-options-missing-font.spec.js b/frontend/playwright/ui/specs/text-options-missing-font.spec.js new file mode 100644 index 0000000000..dfac536d89 --- /dev/null +++ b/frontend/playwright/ui/specs/text-options-missing-font.spec.js @@ -0,0 +1,201 @@ +import { test, expect } from "@playwright/test"; +import { WorkspacePage } from "../pages/WorkspacePage"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +// --------------------------------------------------------------------------- +// The "Create typography style" button (workspace.options.convert-to-typography) +// in the text options sidebar is only shown when ALL of these hold for the +// selected text shape(s) (src/app/main/ui/workspace/sidebar/options/menus/text.cljs): +// (and (some? font) (not typography) (not multiple?) (not applied-token-name)) +// Each test below isolates one condition that must independently hide it: +// - font missing (font-id not registered in app.main.fonts/fontsdb) +// - a typography asset is applied (typography-ref-id set) +// - multiple shapes are selected with differing attributes +// - a typography design token is applied (applied-tokens :typography) +// --------------------------------------------------------------------------- + +function convertToTypographyButton(workspace) { + return workspace.rightSidebar.getByRole("button", { + name: "Create typography style", + }); +} + +test.describe("font missing", () => { + // Fixture render-wasm/get-file-text-custom-fonts.json has a text shape + // ("Penpot & Dragons") using a custom team font-id and no typography/token + // applied - otherwise exactly the state that reveals the button once its + // font resolves. Toggling the get-font-variants mock between "the team owns + // this font" and "empty" simulates the font being present vs. missing. + const FILE = { + id: "434b0541-fa2f-802f-8006-59827d964a9b", + pageId: "434b0541-fa2f-802f-8006-59827d964a9c", + }; + + test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); + }); + + test("Create typography style button is hidden when the shape font is missing", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "render-wasm/get-file-text-custom-fonts.json", + ); + // The team does not own the shape's custom font, so it can't be resolved. + await workspace.mockRPC( + "get-font-variants?team-id=*", + "workspace/get-font-variants-empty.json", + ); + await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId }); + + await workspace.clickLeafLayer("Penpot & Dragons"); + + await expect(convertToTypographyButton(workspace)).not.toBeVisible(); + }); + + test("Create typography style button is visible once the shape font resolves", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "render-wasm/get-file-text-custom-fonts.json", + ); + // The team owns the shape's custom font, so it resolves normally. + await workspace.mockRPC( + "get-font-variants?team-id=*", + "render-wasm/get-font-variants-custom-fonts.json", + ); + await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId }); + + await workspace.clickLeafLayer("Penpot & Dragons"); + + await expect(convertToTypographyButton(workspace)).toBeVisible(); + }); +}); + +test.describe("typography asset applied", () => { + // multiselection-typography.json: "Text with typography asset one" has a + // typography-ref-id pointing at an in-file typography asset (font + // gfont-agdasima, a built-in Google font that resolves with no extra + // mocking), and is not multi-selected or token-applied. + const FILE = { + id: "1062e0a0-8fe0-80ae-8007-e70b4993f5ef", + pageId: "1062e0a0-8fe0-80ae-8007-e70b4993f5f0", + }; + + test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); + }); + + test("Create typography style button is hidden when a typography asset is applied", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/multiselection-typography.json", + ); + await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId }); + + await workspace.clickLeafLayer("Text with typography asset one"); + + // Sanity check: the text options panel did render for this shape - the + // button is specifically hidden by the applied typography, not because + // the whole panel failed to show up. + await expect( + workspace.rightSidebar.getByRole("region", { name: "Text section" }), + ).toBeVisible(); + await expect(convertToTypographyButton(workspace)).not.toBeVisible(); + }); +}); + +test.describe("multiple selection", () => { + // get-file-text-multiple-selection.json has two text shapes sharing the + // same (resolvable, built-in) font-id but differing font-size, with no + // typography or token applied - so selecting both together isolates + // `multiple?` becoming true without also making the font unresolved. + const FILE = { + id: "434b0541-fa2f-802f-8006-6a827d964a9b", + pageId: "434b0541-fa2f-802f-8006-6a827d964a9c", + }; + + test.beforeEach(async ({ page }) => { + await WorkspacePage.init(page); + }); + + test("Create typography style button is hidden when multiple shapes with different values are selected", async ({ + page, + }) => { + const workspace = new WorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-text-multiple-selection.json", + ); + await workspace.goToWorkspace({ fileId: FILE.id, pageId: FILE.pageId }); + + await workspace.clickLeafLayer("Text multiple selection one"); + await expect(convertToTypographyButton(workspace)).toBeVisible(); + + await workspace.clickLeafLayer("Text multiple selection two", { + modifiers: ["Shift"], + }); + + await expect(convertToTypographyButton(workspace)).not.toBeVisible(); + }); +}); + +test.describe("typography token applied", () => { + // get-file-token-tooltip.json: "Text with token" has a typography design + // token applied (applied-tokens :typography) using font gfont-arizonia (a + // built-in Google font that resolves with no extra mocking). + test.beforeEach(async ({ page }) => { + await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json"); + }); + + test("Create typography style button is hidden when a typography token is applied", async ({ + page, + }) => { + const workspace = new WasmWorkspacePage(page); + await workspace.mockConfigFlags(["enable-feature-token-input"]); + await workspace.setupEmptyFile(); + await workspace.mockRPC("get-team?id=*", "workspace/get-team-tokens.json"); + await workspace.mockRPC( + /get\-file\?/, + "workspace/get-file-token-tooltip.json", + ); + await workspace.mockRPC( + /get\-file\-fragment\?/, + "workspace/get-file-fragment-tokens.json", + ); + await workspace.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + await workspace.goToWorkspace({ + fileId: "c7ce0794-0992-8105-8004-38f280443849", + pageId: "4530574a-7a0a-807b-8008-0107b2c4628e", + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + await workspace.layers + .getByTestId("layer-row") + .filter({ hasText: "Text with token" }) + .click(); + + // Sanity check: the text options panel did render for this shape - the + // button is specifically hidden by the applied token, not because the + // whole panel failed to show up. + await expect( + workspace.rightSidebar.getByRole("region", { name: "Text section" }), + ).toBeVisible(); + await expect(convertToTypographyButton(workspace)).not.toBeVisible(); + }); +}); 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 2054cc763c..5cba4c7071 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 @@ -20,6 +20,7 @@ [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] + [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] @@ -307,6 +308,11 @@ main-menu-open? (:main-menu menu-state) more-options-open? (:more-options menu-state) + font-id (or (:font-id values) (:font-id txt/default-typography)) + + fonts (mf/deref fonts/fontsdb) + font (get fonts font-id) + token-dropdown-open* (mf/use-state false) token-dropdown-open? (deref token-dropdown-open*) @@ -512,7 +518,7 @@ :on-click toggle-token-dropdown :tooltip-placement "top-left" :icon i/tokens}]) - (when (and (not typography) (not multiple?) (not applied-token-name)) + (when (and (some? font) (not typography) (not multiple?) (not applied-token-name)) [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.options.convert-to-typography") :on-click on-convert-to-typography