From 6d458c80a196c96b5aaa2be580dad25aa49f7b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 1 Jul 2026 20:15:20 +0200 Subject: [PATCH] :bug: Fix font and variant dropdowns on mixed text styles (#10520) * :bug: Fix dropdown shown Mixed Font Families for same family with different variant * :bug: Fix variants dropdown appearing blank on mixed variants but same family * :sparkles: Add playwright test for mixed font families/variants --- .../get-file-10502-mixed-families.json | 372 ++++++++++++++++++ .../get-file-10502-mixed-variants.json | 372 ++++++++++++++++++ .../ui/specs/text-editor-v3.spec.js | 69 ++++ .../sidebar/options/menus/typography.cljs | 3 + frontend/src/app/render_wasm/serializers.cljs | 7 + frontend/src/app/render_wasm/text_editor.cljs | 47 ++- frontend/src/app/render_wasm/wasm.cljs | 1 + render-wasm/src/shapes/fonts.rs | 3 + render-wasm/src/state/text_editor.rs | 33 +- render-wasm/src/wasm/text_editor.rs | 47 +-- 10 files changed, 900 insertions(+), 54 deletions(-) create mode 100644 frontend/playwright/data/text-editor/get-file-10502-mixed-families.json create mode 100644 frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json create mode 100644 frontend/playwright/ui/specs/text-editor-v3.spec.js diff --git a/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json b/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json new file mode 100644 index 0000000000..05a0b07fbe --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-10502-mixed-families.json @@ -0,0 +1,372 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type", + "text-editor-wasm/v1" + ] + }, + "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660", + "~: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": "simple-text", + "~:revn": 7, + "~:modified-at": "~m1749629891313", + "~:vern": 0, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~: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" + ] + }, + "~:version": 67, + "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07", + "~:created-at": "~m1749629823499", + "~:data": { + "~:pages": [ + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73" + ], + "~:pages-index": { + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73": { + "~: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": [ + "~u7274a6af-66db-8009-8006-52c837bed25d" + ] + } + }, + "~u7274a6af-66db-8009-8006-52c837bed25d": { + "~#shape": { + "~:y": 368.000005463652, + "~: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": "13hr3ftth2o", + "~: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": "1qm8gi1rphc", + "~:font-size": "48", + "~: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": "Hello " + }, + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "gfont-sora", + "~:key": "2qm8gi1rphd", + "~:font-size": "48", + "~: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": "Sora", + "~:text": "World" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "r8gahivbg7", + "~:font-size": "48", + "~: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": "this is a text", + "~:width": 237.0000390021974, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 426.0000039162686 + } + }, + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 426.0000039162686 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u7274a6af-66db-8009-8006-52c837bed25d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 414.9999714372274, + "~:selrect": { + "~#rect": { + "~:x": 414.9999714372274, + "~:y": 368.000005463652, + "~:width": 237.0000390021974, + "~:height": 57.99999845261664, + "~:x1": 414.9999714372274, + "~:y1": 368.000005463652, + "~:x2": 652.0000104394248, + "~:y2": 426.0000039162686 + } + }, + "~:flip-x": null, + "~:height": 57.99999845261664, + "~:flip-y": null + } + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73", + "~:name": "Page 1" + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json b/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json new file mode 100644 index 0000000000..d26099ef68 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-10502-mixed-variants.json @@ -0,0 +1,372 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type", + "text-editor-wasm/v1" + ] + }, + "~:team-id": "~u1091e979-bbec-8194-8005-f7aa420b5660", + "~: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": "simple-text", + "~:revn": 7, + "~:modified-at": "~m1749629891313", + "~:vern": 0, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~: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" + ] + }, + "~:version": 67, + "~:project-id": "~u1091e979-bbec-8194-8005-f7aa420b8b07", + "~:created-at": "~m1749629823499", + "~:data": { + "~:pages": [ + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73" + ], + "~:pages-index": { + "~u3b0d758a-8c9d-8013-8006-52c8337e5c73": { + "~: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": [ + "~u7274a6af-66db-8009-8006-52c837bed25d" + ] + } + }, + "~u7274a6af-66db-8009-8006-52c837bed25d": { + "~#shape": { + "~:y": 368.000005463652, + "~: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": "13hr3ftth2o", + "~: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": "1qm8gi1rphc", + "~:font-size": "48", + "~: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": "Hello " + }, + { + "~:line-height": "1.2", + "~:font-style": "italic", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "2qm8gi1rphd", + "~:font-size": "48", + "~:font-weight": "600", + "~:typography-ref-file": null, + "~:font-variant-id": "600italic", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "World" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "r8gahivbg7", + "~:font-size": "48", + "~: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": "this is a text", + "~:width": 237.0000390021974, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 368.000005463652 + } + }, + { + "~#point": { + "~:x": 652.0000104394247, + "~:y": 426.0000039162686 + } + }, + { + "~#point": { + "~:x": 414.9999714372273, + "~:y": 426.0000039162686 + } + } + ], + "~:layout-item-h-sizing": "~:fix", + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:layout-item-v-sizing": "~:fix", + "~:id": "~u7274a6af-66db-8009-8006-52c837bed25d", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 414.9999714372274, + "~:selrect": { + "~#rect": { + "~:x": 414.9999714372274, + "~:y": 368.000005463652, + "~:width": 237.0000390021974, + "~:height": 57.99999845261664, + "~:x1": 414.9999714372274, + "~:y1": 368.000005463652, + "~:x2": 652.0000104394248, + "~:y2": 426.0000039162686 + } + }, + "~:flip-x": null, + "~:height": 57.99999845261664, + "~:flip-y": null + } + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c73", + "~:name": "Page 1" + } + }, + "~:id": "~u3b0d758a-8c9d-8013-8006-52c8337e5c72", + "~: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 new file mode 100644 index 0000000000..90b2316a88 --- /dev/null +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; + +const FILE = { + id: "3b0d758a-8c9d-8013-8006-52c8337e5c72", + pageId: "3b0d758a-8c9d-8013-8006-52c8337e5c73", +}; + +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"]); +}); + +async function openEditorAndSelectAll(workspace) { + await workspace.clickLeafLayer("this is a text"); + // Enter edit mode (waits until the typography controls are ready) and then + // select every character so the sidebar reflects the combined styles of the + // whole text via the WASM editor path. + await workspace.textEditor.startEditing(); + await workspace.page.keyboard.press("ControlOrMeta+a"); +} + +test.describe("BUG 10502 - Mixed families and variants", () => { + 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.goToWorkspace(FILE); + await workspace.waitForFirstRender(); + + await openEditorAndSelectAll(workspace); + + // The whole selection shares a single font family, so it must be shown even + // though the variants differ. + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Source Sans Pro"); + + // The variants differ across the selection, so the variant dropdown shows the + // "mixed" placeholder. + const fontVariant = workspace.rightSidebar + .getByTitle("Font Style") + .getByRole("combobox"); + await expect(fontVariant).toHaveText("--"); + }); + + 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"); + // 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"); + + await workspace.goToWorkspace(FILE); + await workspace.waitForFirstRender(); + + await openEditorAndSelectAll(workspace); + + // The selection mixes two different font families (Source Sans Pro and Sora), + // so the font family dropdown reports it as mixed. + const fontFamily = workspace.rightSidebar.getByTitle("Font Family"); + await expect(fontFamily).toContainText("Mixed Font Families"); + }); +}); + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index 5c96b00077..e5ee3647bd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -360,6 +360,9 @@ {:value (:id variant) :key (pr-str variant) :label (:name variant)}))) + ;; When the selection mixes variants we prepend a "--" entry: it is + ;; shown as the collapsed value (nothing single is selected) while + ;; the real variants of the resolved font are still listed below it. variant-options (if (or (= font-variant-id :multiple) (= font-variant-id "mixed")) (conj basic-variant-options {:value "" diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index f30f12d81c..3cc62c3c0c 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -286,6 +286,13 @@ default (unchecked-get values "parent")] (d/nilv (unchecked-get values (d/name kind)) default))) + +(defn translate-multiple-state + [multiple-state] + (let [values (unchecked-get wasm/serializers "multiple-state") + default (unchecked-get values "undefined")] + (d/nilv (unchecked-get values (d/name multiple-state)) default))) + ;; --- Guides ;; Each guide is serialized as 5 x 32-bit words: diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 25d02211da..574ec1a72b 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -13,8 +13,11 @@ [app.render-wasm.api.fonts :as fonts] [app.render-wasm.helpers :as h] [app.render-wasm.mem :as mem] + [app.render-wasm.serializers :as sr] [app.render-wasm.wasm :as wasm])) +(def multiple-state-multiple (sr/translate-multiple-state :multiple)) + (def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4)) (def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0) (def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1) @@ -260,33 +263,41 @@ text-direction-state (aget heap-u32 (+ u32-offset 2)) text-decoration-state (aget heap-u32 (+ u32-offset 3)) text-transform-state (aget heap-u32 (+ u32-offset 4)) - font-family-state (aget heap-u32 (+ u32-offset 5)) + font-family-id-state (aget heap-u32 (+ u32-offset 5)) font-size-state (aget heap-u32 (+ u32-offset 6)) font-weight-state (aget heap-u32 (+ u32-offset 7)) - font-variant-id-state (aget heap-u32 (+ u32-offset 8)) + ;; Unused: the variant id is stored as a zero uuid for every span + _font-variant-id-state (aget heap-u32 (+ u32-offset 8)) line-height-state (aget heap-u32 (+ u32-offset 9)) letter-spacing-state (aget heap-u32 (+ u32-offset 10)) - num-fills (aget heap-u32 (+ u32-offset 11)) - multiple-fills (aget heap-u32 (+ u32-offset 12)) + font-style-state (aget heap-u32 (+ u32-offset 11)) + num-fills (aget heap-u32 (+ u32-offset 12)) + multiple-fills (aget heap-u32 (+ u32-offset 13)) - text-align-value (aget heap-u32 (+ u32-offset 13)) - text-direction-value (aget heap-u32 (+ u32-offset 14)) - text-decoration-value (aget heap-u32 (+ u32-offset 15)) - text-transform-value (aget heap-u32 (+ u32-offset 16)) - font-family-id-a (aget heap-u32 (+ u32-offset 17)) - font-family-id-b (aget heap-u32 (+ u32-offset 18)) - font-family-id-c (aget heap-u32 (+ u32-offset 19)) - font-family-id-d (aget heap-u32 (+ u32-offset 20)) + text-align-value (aget heap-u32 (+ u32-offset 14)) + text-direction-value (aget heap-u32 (+ u32-offset 15)) + text-decoration-value (aget heap-u32 (+ u32-offset 16)) + text-transform-value (aget heap-u32 (+ u32-offset 17)) + font-family-id-a (aget heap-u32 (+ u32-offset 18)) + font-family-id-b (aget heap-u32 (+ u32-offset 19)) + font-family-id-c (aget heap-u32 (+ u32-offset 20)) + font-family-id-d (aget heap-u32 (+ u32-offset 21)) font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d) - font-family-style-value (aget heap-u32 (+ u32-offset 21)) - _font-family-weight-value (aget heap-u32 (+ u32-offset 22)) + font-style-raw-value (aget heap-u32 (+ u32-offset 22)) font-size-value (aget heap-f32 (+ u32-offset 23)) font-weight-value (aget heap-i32 (+ u32-offset 24)) line-height-value (aget heap-f32 (+ u32-offset 29)) letter-spacing-value (aget heap-f32 (+ u32-offset 30)) font-id (fonts/uuid->font-id font-family-id-value) - font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value)) + font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-style-state font-style-raw-value)) font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value) + ;; A font variant is defined by its family + weight + style, so it + ;; is "mixed" when any of those is mixed. When the family itself is + ;; mixed there is no single font to resolve variants against, so we + ;; also report the variant as mixed. + font-variant-multiple? (or (= font-family-id-state multiple-state-multiple) + (= font-weight-state multiple-state-multiple) + (= font-style-state multiple-state-multiple)) fills (->> (range num-fills) (map (fn [idx] @@ -313,9 +324,9 @@ :font-size (text-editor-get-style-property font-size-state font-size-value) :font-weight (text-editor-get-style-property font-weight-state font-weight-value) :font-style font-style-value - :font-family (text-editor-get-style-property font-family-state font-id) - :font-id (text-editor-get-style-property font-family-state font-id) - :font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed) + :font-family (text-editor-get-style-property font-family-id-state font-id) + :font-id (text-editor-get-style-property font-family-id-state font-id) + :font-variant-id (if font-variant-multiple? :multiple font-variant-id-computed) :typography-ref-file nil :typography-ref-id nil :selected-colors selected-colors diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index cf394896ed..77d8726347 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -75,6 +75,7 @@ :text-direction shared/RawTextDirection :text-decoration shared/RawTextDecoration :text-transform shared/RawTextTransform + :multiple-state shared/MultipleState :transform-entry-kind shared/RawTransformEntryKind :segment-data shared/RawSegmentData :stroke-linecap shared/RawStrokeLineCap diff --git a/render-wasm/src/shapes/fonts.rs b/render-wasm/src/shapes/fonts.rs index f4da4daa6c..56dbe10e59 100644 --- a/render-wasm/src/shapes/fonts.rs +++ b/render-wasm/src/shapes/fonts.rs @@ -38,9 +38,12 @@ impl FontFamily { pub fn id(&self) -> Uuid { self.id } + pub fn style(&self) -> FontStyle { self.style } + + #[allow(dead_code)] pub fn weight(&self) -> u32 { self.weight } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 8277bfc832..3216586998 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -3,7 +3,7 @@ use macros::ToJs; use crate::shapes::{ - Fill, FontFamily, TextAlign, TextContent, TextDecoration, TextDirection, + Fill, FontStyle, TextAlign, TextContent, TextDecoration, TextDirection, TextPositionWithAffinity, TextTransform, VerticalAlign, }; use crate::uuid::Uuid; @@ -88,6 +88,7 @@ impl TextSelection { } /// Events that the text editor can emit for frontend synchronization +/// FIXME: the serialization should be in the wasm module #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)] pub enum TextEditorEvent { @@ -111,7 +112,12 @@ pub struct TextEditorStyles { pub text_direction: Multiple, // Multiple pub text_decoration: Multiple, pub text_transform: Multiple, - pub font_family: Multiple, + // The font family is decomposed into its independent parts so the family + // dropdown shows a single family even when the selection mixes variants of + // the same family (e.g. regular + bold italic). The id identifies the + // family; weight is tracked by `font_weight`; only the style is left here. + pub font_family_id: Multiple, + pub font_style: Multiple, pub font_size: Multiple, pub font_weight: Multiple, pub font_variant_id: Multiple, @@ -121,7 +127,8 @@ pub struct TextEditorStyles { pub fills: Vec, } -#[derive(Debug, Clone, Copy, PartialEq)] +// FIXME: the serialization should be in the wasm module +#[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] pub enum MultipleState { Undefined = 0, @@ -228,7 +235,8 @@ impl TextEditorStyles { text_direction: Multiple::empty(), text_decoration: Multiple::empty(), text_transform: Multiple::empty(), - font_family: Multiple::empty(), + font_family_id: Multiple::empty(), + font_style: Multiple::empty(), font_size: Multiple::empty(), font_weight: Multiple::empty(), font_variant_id: Multiple::empty(), @@ -244,7 +252,8 @@ impl TextEditorStyles { self.text_direction.reset(); self.text_decoration.reset(); self.text_transform.reset(); - self.font_family.reset(); + self.font_family_id.reset(); + self.font_style.reset(); self.font_size.reset(); self.font_weight.reset(); self.font_variant_id.reset(); @@ -614,8 +623,11 @@ impl TextEditorState { .text_transform .merge(span.text_transform); self.current_styles - .font_family - .merge(Some(span.font_family)); + .font_family_id + .merge(Some(span.font_family.id())); + self.current_styles + .font_style + .merge(Some(span.font_family.style())); self.current_styles.font_size.merge(Some(span.font_size)); self.current_styles .font_weight @@ -667,8 +679,11 @@ impl TextEditorState { .text_transform .set_single(text_span.text_transform); self.current_styles - .font_family - .set_single(Some(text_span.font_family)); + .font_family_id + .set_single(Some(text_span.font_family.id())); + self.current_styles + .font_style + .set_single(Some(text_span.font_family.style())); self.current_styles .font_size .set_single(Some(text_span.font_size)); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 189bbd8713..1ee391b40b 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -722,27 +722,20 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { .unwrap_or(RawTextTransform::None as u32); let font_family_id = styles - .font_family + .font_family_id .value() .as_ref() .map(|value| { - let (a, b, c, d) = uuid_to_u32_quartet(&value.id()); + let (a, b, c, d) = uuid_to_u32_quartet(value); [a, b, c, d] }) .unwrap_or_default(); - let font_family_weight = styles - .font_family + let font_style = styles + .font_style .value() .as_ref() - .map(|value| value.weight()) - .unwrap_or_default(); - - let font_family_style = styles - .font_family - .value() - .as_ref() - .map(|value| value.style() as u32) + .map(|value| *value as u32) .unwrap_or_default(); let font_size = styles.font_size.value().unwrap_or(0.0); @@ -767,35 +760,35 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { } } - // Layout: 48-byte fixed header + fixed values + serialized fills. + // Layout: 56-byte fixed header + fixed values + serialized fills. let mut bytes = Vec::with_capacity(132 + fill_bytes.len()); - // Header data // offset // index + // Header data (multiple-states) // offset // index bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0 bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1 bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2 bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3 bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4 - bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes()); // 20 // 5 + bytes.extend_from_slice(&(*styles.font_family_id.state() as u32).to_le_bytes()); // 20 // 5 bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6 bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7 bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8 bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9 bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10 - bytes.extend_from_slice(&fill_count.to_le_bytes()); // 44 // 11 - bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 48 // 12 + bytes.extend_from_slice(&(*styles.font_style.state() as u32).to_le_bytes()); // 44 // 11 + bytes.extend_from_slice(&fill_count.to_le_bytes()); // 48 // 12 + bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 52 // 13 // Value section. - bytes.extend_from_slice(&text_align.to_le_bytes()); // 52 // 13 - bytes.extend_from_slice(&text_direction.to_le_bytes()); // 56 // 14 - bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 60 // 15 - bytes.extend_from_slice(&text_transform.to_le_bytes()); // 64 // 16 - bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 68 // 17 - bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 72 // 18 - bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 76 // 19 - bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 80 // 20 - bytes.extend_from_slice(&font_family_style.to_le_bytes()); // 84 // 21 - bytes.extend_from_slice(&font_family_weight.to_le_bytes()); // 88 // 22 + bytes.extend_from_slice(&text_align.to_le_bytes()); // 56 // 14 + bytes.extend_from_slice(&text_direction.to_le_bytes()); // 60 // 15 + bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 64 // 16 + bytes.extend_from_slice(&text_transform.to_le_bytes()); // 68 // 17 + bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 72 // 18 + bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 76 // 19 + bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 80 // 20 + bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 84 // 21 + bytes.extend_from_slice(&font_style.to_le_bytes()); // 88 // 22 bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23 bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24 bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25