From 62cc5550843f0e00e0d06354a959d92dec7aba4b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 30 Mar 2026 11:47:27 +0200 Subject: [PATCH 1/6] :bug: Fix spurious mixed text styles on multi-span selection --- .../editor/controllers/SelectionController.js | 31 ++++++++---- .../controllers/SelectionController.test.js | 50 ++++++++++++++++++- 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index d5c5ad7db4..8c98662396 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -278,19 +278,30 @@ export class SelectionController extends EventTarget { // FIXME: I don't like this approximation. Having to iterate nodes twice // is bad for performance. I think we need another way of "computing" // the cascade. - for (const textNode of this.#textNodeIterator.iterateFrom( - startNode, - endNode, - )) { + const textNodesInRange = [ + ...this.#textNodeIterator.iterateFrom(startNode, endNode), + ]; + for (const textNode of textNodesInRange) { const paragraph = textNode.parentElement.parentElement; this.#applyStylesFromElementToCurrentStyle(paragraph); } - for (const textNode of this.#textNodeIterator.iterateFrom( - startNode, - endNode, - )) { - const textSpan = textNode.parentElement; - this.#mergeStylesFromElementToCurrentStyle(textSpan); + // Empty trailing text runs (length 0) often carry paragraph fallback styles + // (e.g. font-size 0) and are not user-visible; merging them with real text + // yields false "mixed" in the sidebar. Skip empty nodes when the selection + // also includes non-empty text; if everything is empty, keep prior behavior. + const nonEmptyTextNodes = textNodesInRange.filter( + (textNode) => textNode.length > 0, + ); + const spanMergeNodes = + nonEmptyTextNodes.length > 0 ? nonEmptyTextNodes : textNodesInRange; + + if (spanMergeNodes.length > 0) { + const firstTextSpan = spanMergeNodes[0].parentElement; + this.#applyStylesFromElementToCurrentStyle(firstTextSpan); + for (let i = 1; i < spanMergeNodes.length; i++) { + const textSpan = spanMergeNodes[i].parentElement; + this.#mergeStylesFromElementToCurrentStyle(textSpan); + } } } return this; diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.test.js b/frontend/text-editor/src/editor/controllers/SelectionController.test.js index 076db92f41..5d5b4a7c8c 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.test.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.test.js @@ -4,7 +4,7 @@ import { createParagraph, createParagraphWith, } from "../content/dom/Paragraph.js"; -import { createTextSpan } from "../content/dom/TextSpan.js"; +import { createTextSpan, createVoidTextSpan } from "../content/dom/TextSpan.js"; import { createLineBreak } from "../content/dom/LineBreak.js"; import { TextEditorMock } from "../../test/TextEditorMock.js"; import { SelectionController } from "./SelectionController.js"; @@ -1683,6 +1683,54 @@ describe("SelectionController", () => { expect(selectionController.focusAtEnd).toBeTruthy(); }) + test("`currentStyle` ignores empty text nodes when merging span styles (no false mixed font-size)", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([ + createTextSpan(new Text("Hello"), { "font-size": "50" }), + createVoidTextSpan({ "font-size": "0" }), + ]), + ]); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const firstTextNode = paragraph.firstChild.firstChild; + const lastTextNode = paragraph.firstChild.nextSibling.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 0); + document.dispatchEvent(new Event("selectionchange")); + expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe( + "50px", + ); + }); + + test("`currentStyle` stays mixed when two non-empty spans have different font sizes", () => { + const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ + createParagraph([ + createTextSpan(new Text("Hello"), { "font-size": "50" }), + createTextSpan(new Text("World"), { "font-size": "36" }), + ]), + ]); + const root = textEditorMock.root; + const paragraph = root.firstChild; + const firstTextNode = paragraph.firstChild.firstChild; + const lastTextNode = paragraph.firstChild.nextSibling.firstChild; + const selection = document.getSelection(); + const selectionController = new SelectionController( + textEditorMock, + selection, + ); + textEditorMock.element.focus(); + selection.setBaseAndExtent(firstTextNode, 0, lastTextNode, 5); + document.dispatchEvent(new Event("selectionchange")); + expect(selectionController.currentStyle.getPropertyValue("font-size")).toBe( + "mixed", + ); + }); + test("`currentStyle` uses text span font-size when anchor is paragraph (Firefox-style word selection)", () => { const textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([ createParagraph([ From f7e1bcf87f4fe4b5c7206e2764be1c1e252dc2a6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 1 Apr 2026 11:37:27 +0200 Subject: [PATCH 2/6] :bug: Handle plugin errors gracefully without crashing the UI (#8810) * :bug: Handle plugin errors gracefully without crashing the UI Plugin errors (like 'Set is not a constructor') were propagating to the global error handler and showing the exception page. This fix: - Uses a WeakMap to track plugin errors (works in SES hardened environment) - Wraps setTimeout/setInterval handlers to mark errors and re-throw them - Frontend global handler checks isPluginError and logs to console Plugin errors are now logged to console with 'Plugin Error' prefix but don't crash the main application or show the exception page. Signed-off-by: AI Agent * :sparkles: Improved handling of plugin errors on initialization * :sparkles: Fix test and linter --------- Signed-off-by: AI Agent Co-authored-by: alonso.torres --- frontend/src/app/main/data/plugins.cljs | 68 ++++++++------- frontend/src/app/main/errors.cljs | 87 ++++++++++++++----- frontend/src/app/plugins.cljs | 3 + .../poc-state-plugin/src/app/app.component.ts | 2 +- plugins/libs/plugins-runtime/src/index.ts | 3 + .../src/lib/create-plugin.spec.ts | 7 +- .../plugins-runtime/src/lib/create-plugin.ts | 8 +- .../plugins-runtime/src/lib/create-sandbox.ts | 79 ++++++++++++++++- .../src/lib/load-plugin.spec.ts | 13 ++- .../plugins-runtime/src/lib/load-plugin.ts | 7 +- .../plugins-runtime/src/lib/plugin-manager.ts | 7 ++ 11 files changed, 215 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/main/data/plugins.cljs b/frontend/src/app/main/data/plugins.cljs index e04aecbf7c..b091518b67 100644 --- a/frontend/src/app/main/data/plugins.cljs +++ b/frontend/src/app/main/data/plugins.cljs @@ -7,12 +7,14 @@ (ns app.main.data.plugins (:require [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.changes-builder :as pcb] [app.common.time :as ct] [app.main.data.changes :as dch] [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.errors :as errors] [app.main.store :as st] [app.plugins.flags :as pflag] [app.plugins.register :as preg] @@ -20,7 +22,8 @@ [app.util.http :as http] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [potok.v2.core :as ptk])) + [potok.v2.core :as ptk] + [promesa.core :as p])) (defn save-plugin-permissions-peek [id permissions] @@ -54,40 +57,45 @@ (defn start-plugin! [{:keys [plugin-id name version description host code permissions allow-background]} ^js extensions] - (.ɵloadPlugin - ^js ug/global - #js {:pluginId plugin-id - :name name - :version version - :description description - :host host - :code code - :allowBackground (boolean allow-background) - :permissions (apply array permissions)} - nil - extensions)) + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :version version + :description description + :host host + :code code + :allowBackground (boolean allow-background) + :permissions (apply array permissions)} + nil + extensions) + + (p/catch (fn [cause] + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) (defn- load-plugin! [{:keys [plugin-id name version description host code icon permissions]}] - (try - (st/emit! (pflag/clear plugin-id) - (save-current-plugin plugin-id)) + (st/emit! (pflag/clear plugin-id) + (save-current-plugin plugin-id)) - (.ɵloadPlugin ^js ug/global - #js {:pluginId plugin-id - :name name - :description description - :version version - :host host - :code code - :icon icon - :permissions (apply array permissions)} - (fn [] - (st/emit! (remove-current-plugin plugin-id)))) + (-> (.ɵloadPlugin + ^js ug/global + #js {:pluginId plugin-id + :name name + :description description + :version version + :host host + :code code + :icon icon + :permissions (apply array permissions)} + (fn [] + (st/emit! (remove-current-plugin plugin-id)))) - (catch :default e - (st/emit! (remove-current-plugin plugin-id)) - (.error js/console "Error" e)))) + (p/catch (fn [cause] + (st/emit! (remove-current-plugin plugin-id)) + (ex/print-throwable cause :prefix "Plugin Error") + (errors/flash :cause cause :type :handled))))) (defn open-plugin! [{:keys [url] :as manifest} user-can-edit?] diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 4eb52b5b35..49f4080abb 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -33,6 +33,16 @@ ;; Will contain last uncaught exception (def last-exception nil) +(defn is-plugin-error? + "This is a placeholder that always return false. It will be + overwritten when plugin system is initialized. This works this way + because we can't import plugins here because plugins requries full + DOM. + + This placeholder is set on app.plugins/initialize event" + [_] + false) + ;; --- Stale-asset error detection and auto-reload ;; ;; When the browser loads JS modules from different builds (e.g. shared.js from @@ -387,6 +397,15 @@ (and (string? stack) (str/includes? stack "posthog")))) + ;; Check if the error is marked as originating from plugin code. + ;; The plugin runtime tracks plugin errors in a WeakMap, which works + ;; even in SES hardened environments where error objects may be frozen. + (from-plugin? [cause] + (try + (is-plugin-error? cause) + (catch :default _ + false))) + (is-ignorable-exception? [cause] (let [message (ex-message cause)] (or (from-extension? cause) @@ -405,32 +424,56 @@ (on-unhandled-error [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "error")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Exception") - (ts/asap #(flash :cause cause :type :unhandled))))))))) + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Exception") + (ts/asap #(flash :cause cause :type :unhandled)))))))) (on-unhandled-rejection [event] (.preventDefault ^js event) (when-let [cause (unchecked-get event "reason")] - (when-not (is-ignorable-exception? cause) - (if (stale-asset-error? cause) - (cf/throttled-reload :reason (ex-message cause)) - (let [data (ex-data cause) - type (get data :type)] - (set! last-exception cause) - (if (= :wasm-error type) - (on-error cause) - (do - (ex/print-throwable cause :prefix "Uncaught Rejection") - (ts/asap #(flash :cause cause :type :unhandled)))))))))] + (cond + (stale-asset-error? cause) + (cf/throttled-reload :reason (ex-message cause)) + + ;; Plugin errors: log to console and ignore + (from-plugin? cause) + (ex/print-throwable cause :prefix "Plugin Error") + + ;; Other ignorable exceptions: ignore silently + (is-ignorable-exception? cause) + nil + + ;; All other errors: show exception page + :else + (let [data (ex-data cause) + type (get data :type)] + (set! last-exception cause) + (if (= :wasm-error type) + (on-error cause) + (do + (ex/print-throwable cause :prefix "Uncaught Rejection") + (ts/asap #(flash :cause cause :type :unhandled))))))))] + (.addEventListener g/window "error" on-unhandled-error) (.addEventListener g/window "unhandledrejection" on-unhandled-rejection) diff --git a/frontend/src/app/plugins.cljs b/frontend/src/app/plugins.cljs index 36f31ff110..3ce022753e 100644 --- a/frontend/src/app/plugins.cljs +++ b/frontend/src/app/plugins.cljs @@ -8,6 +8,7 @@ "RPC for plugins runtime." (:require ["@penpot/plugins-runtime" :as runtime] + [app.main.errors :as errors] [app.main.features :as features] [app.main.store :as st] [app.plugins.api :as api] @@ -30,6 +31,8 @@ (ptk/reify ::initialize ptk/WatchEvent (watch [_ _ stream] + (set! errors/is-plugin-error? runtime/isPluginError) + (->> stream (rx/filter (ptk/type? ::features/initialize)) (rx/observe-on :async) diff --git a/plugins/apps/poc-state-plugin/src/app/app.component.ts b/plugins/apps/poc-state-plugin/src/app/app.component.ts index 8996e1eb83..0eae67b448 100644 --- a/plugins/apps/poc-state-plugin/src/app/app.component.ts +++ b/plugins/apps/poc-state-plugin/src/app/app.component.ts @@ -393,7 +393,7 @@ export class AppComponent { } #startDownload(name: string, data: Uint8Array) { - const blob = new Blob([data], { type: 'application/octet-stream' }); + const blob = new Blob([data as any], { type: 'application/octet-stream' }); // We need to start a download with this URL const downloadURL = URL.createObjectURL(blob); diff --git a/plugins/libs/plugins-runtime/src/index.ts b/plugins/libs/plugins-runtime/src/index.ts index 2516cfb2f2..a38203471d 100644 --- a/plugins/libs/plugins-runtime/src/index.ts +++ b/plugins/libs/plugins-runtime/src/index.ts @@ -8,6 +8,9 @@ import { ɵunloadPlugin, } from './lib/load-plugin.js'; +// Export the plugin error checker so the frontend can identify plugin errors +export { isPluginError } from './lib/create-sandbox.js'; + import type { Context } from '@penpot/plugin-types'; console.log('%c[PLUGINS] Loading plugin system', 'color: #008d7c'); diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts index 70d9e74ad4..29d0b6d691 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.spec.ts @@ -11,6 +11,7 @@ vi.mock('./plugin-manager.js', () => ({ vi.mock('./create-sandbox.js', () => ({ createSandbox: vi.fn(), + markPluginError: vi.fn(), })); describe('createPlugin', () => { @@ -116,7 +117,11 @@ describe('createPlugin', () => { throw new Error('Evaluation error'); }); - await createPlugin(mockContext, manifest, onCloseCallback); + try { + await createPlugin(mockContext, manifest, onCloseCallback); + } catch (err) { + expect.assert(err); + } expect(mockPluginManager.close).toHaveBeenCalled(); }); diff --git a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts index f30977d5aa..fd7bc6a0a8 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-plugin.ts @@ -1,7 +1,7 @@ import type { Context } from '@penpot/plugin-types'; import type { Manifest } from './models/manifest.model.js'; import { createPluginManager } from './plugin-manager.js'; -import { createSandbox } from './create-sandbox.js'; +import { createSandbox, markPluginError } from './create-sandbox.js'; export async function createPlugin( context: Context, @@ -13,9 +13,9 @@ export async function createPlugin( try { sandbox.evaluate(); } catch (error) { - console.error(error); - + markPluginError(error); plugin.close(); + throw error; } }; @@ -33,7 +33,7 @@ export async function createPlugin( const sandbox = createSandbox(plugin, apiExtensions); - evaluateSandbox(); + await evaluateSandbox(); return { plugin, diff --git a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts index 01f132548b..292ae49183 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-sandbox.ts @@ -3,6 +3,61 @@ import type { createPluginManager } from './plugin-manager'; import { createApi } from './api'; import { ses } from './ses.js'; +/** + * WeakMap used to track errors originating from plugin code. + * Using a WeakMap is safer than extending error objects because: + * 1. It works even if the error object is frozen (SES hardened environment) + * 2. It doesn't require modifying the error object + * 3. It allows garbage collection of error objects when no longer referenced + */ +const pluginErrors = new WeakMap(); + +/** + * Checks if an error originated from plugin code. + */ +export function isPluginError(error: unknown): boolean { + if (error !== null && typeof error === 'object') { + return pluginErrors.has(error as object); + } + return false; +} + +/** + * Marks an error as originating from plugin code. + * Uses a WeakMap so it works even if the error object is frozen. + */ +export function markPluginError(error: unknown): void { + if (error !== null && typeof error === 'object') { + pluginErrors.set(error as object, true); + } +} + +/** + * Wraps a handler function to mark any thrown errors as plugin errors. + * Errors are marked and re-thrown so they propagate to the global error handler, + * where they can be identified and handled appropriately. + */ +function wrapHandler unknown>( + handler: T, +): (...args: Parameters) => ReturnType { + return function (...args: Parameters) { + try { + const result = handler(...args); + // Handle async functions - mark errors in the returned promise + if (result instanceof Promise) { + return result.catch((error: unknown) => { + markPluginError(error); + throw error; + }) as ReturnType; + } + return result as ReturnType; + } catch (error) { + markPluginError(error); + throw error; + } + }; +} + export function createSandbox( plugin: Awaited>, apiExtensions?: object, @@ -58,9 +113,10 @@ export function createSandbox( fetch: ses.harden(safeFetch), setTimeout: ses.harden( (...[handler, timeout]: Parameters) => { - const timeoutId = setTimeout(() => { - handler(); - }, timeout); + const wrappedHandler = wrapHandler( + typeof handler === 'function' ? handler : () => {}, + ); + const timeoutId = setTimeout(wrappedHandler, timeout); plugin.timeouts.add(timeoutId); @@ -72,6 +128,23 @@ export function createSandbox( plugin.timeouts.delete(id); }), + setInterval: ses.harden( + (...[handler, interval]: Parameters) => { + const wrappedHandler = wrapHandler( + typeof handler === 'function' ? handler : () => {}, + ); + const intervalId = setInterval(wrappedHandler, interval); + + plugin.intervals.add(intervalId); + + return ses.safeReturn(intervalId); + }, + ) as typeof setInterval, + clearInterval: ses.harden((id: ReturnType) => { + clearInterval(id); + + plugin.intervals.delete(id); + }), /** * GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index a46bbc8573..cff0f5e57a 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -19,6 +19,10 @@ vi.mock('./create-plugin', () => ({ createPlugin: vi.fn(), })); +vi.mock('./create-sandbox.js', () => ({ + markPluginError: vi.fn(), +})); + vi.mock('./ses.js', () => ({ ses: { harden: vi.fn().mockImplementation((obj) => obj), @@ -102,16 +106,17 @@ describe('plugin-loader', () => { }); it('should handle errors and close all plugins', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.mocked(createPlugin).mockRejectedValue( new Error('Plugin creation failed'), ); - await loadPlugin(manifest); + try { + await loadPlugin(manifest); + } catch (err) { + expect.assert(err); + } expect(getPlugins()).toHaveLength(0); - expect(consoleSpy).toHaveBeenCalled(); }); it('should handle messages sent to plugins', async () => { diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index 990a9148a1..8a88f23050 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -64,11 +64,10 @@ export const loadPlugin = async function ( }, apiExtensions, ); - plugins.push(plugin); } catch (error) { closeAllPlugins(); - console.error(error); + throw error; } }; @@ -77,12 +76,12 @@ export const ɵloadPlugin = async function ( closeCallback?: () => void, apiExtensions?: object, ) { - loadPlugin(manifest, closeCallback, apiExtensions); + await loadPlugin(manifest, closeCallback, apiExtensions); }; export const ɵloadPluginByUrl = async function (manifestUrl: string) { const manifest = await loadManifest(manifestUrl); - ɵloadPlugin(manifest); + await ɵloadPlugin(manifest); }; export const ɵunloadPlugin = function (id: Manifest['pluginId']) { diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 5b0b1bddd0..0b4035794a 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -21,6 +21,7 @@ export async function createPluginManager( let modal: PluginModalElement | null = null; let uiMessagesCallbacks: ((message: unknown) => void)[] = []; const timeouts = new Set>(); + const intervals = new Set>(); const allowDownloads = !!manifest.permissions.find( (s) => s === 'allow:downloads', @@ -55,6 +56,9 @@ export async function createPluginManager( timeouts.forEach(clearTimeout); timeouts.clear(); + intervals.forEach(clearInterval); + intervals.clear(); + if (modal) { modal.removeEventListener('close', closePlugin); modal.remove(); @@ -151,6 +155,9 @@ export async function createPluginManager( get timeouts() { return timeouts; }, + get intervals() { + return intervals; + }, get code() { return code; }, From cbe3a3f33ec3434f383bf8af50c6403dddbd20ff Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 31 Mar 2026 17:08:52 +0200 Subject: [PATCH 3/6] :bug: Fix problem when changing grow-type --- .../src/app/main/ui/workspace/sidebar/options/menus/text.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 75c0344662..38c6f86cd7 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 @@ -144,10 +144,12 @@ (content/dom->cljs (dwt/get-editor-root editor-instance)))] (when (some? content) (st/emit! (dwt/v2-update-text-shape-content (first ids) content :finalize? true))))) + (st/emit! (dwsh/update-shapes ids #(assoc % :grow-type grow-type))) (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-all ids))) + (st/emit! (dwwt/resize-wasm-text-all ids) + (ptk/data-event :layout/update {:ids ids}))) ;; We asynchronously commit so every sychronous event is resolved first and inside the transaction (ts/schedule #(st/emit! (dwu/commit-undo-transaction uid)))) (when (some? on-blur) (on-blur))))] From 68760c8e267543e2b0cd2cebf60e8d5fbf5567bc Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 31 Mar 2026 13:00:30 +0200 Subject: [PATCH 4/6] :tada: Improve text inner stroke rendering --- render-wasm/src/render.rs | 103 ++++++++---- render-wasm/src/render/shadows.rs | 46 ++++-- render-wasm/src/render/text.rs | 259 ++++++++++++++++++++++-------- render-wasm/src/shapes/text.rs | 35 ++++ 4 files changed, 330 insertions(+), 113 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1b8fcb87e3..17e29f08d2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -26,7 +26,7 @@ use crate::error::{Error, Result}; use crate::performance; use crate::shapes::{ all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, - Stroke, Type, + Stroke, StrokeKind, Type, }; use crate::state::{ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; @@ -1030,6 +1030,8 @@ impl RenderState { let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); + let stroke_kinds: Vec = + shape.visible_strokes().rev().map(|s| s.kind).collect(); let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape .visible_strokes() .rev() @@ -1038,7 +1040,6 @@ impl RenderState { &text_content, stroke, &shape.selrect(), - count_inner_strokes, None, ) }) @@ -1057,22 +1058,41 @@ impl RenderState { None, )?; - for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) + .enumerate() { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - None, - text_stroke_blur_outset, - None, - *layer_opacity, - )?; + if stroke_kinds[i] == StrokeKind::Inner { + let mut mask_builders = text_content.paragraph_builder_group_opaque(); + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + &mut mask_builders, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + None, + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + None, + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } } } else { let mut drop_shadows = shape.drop_shadow_paints(); @@ -1096,7 +1116,6 @@ impl RenderState { &text_content, stroke, &shape.selrect(), - count_inner_strokes, Some(true), ) }) @@ -1126,6 +1145,8 @@ impl RenderState { text_drop_shadows_surface_id.into(), &parent_shadows, &blur_filter, + &stroke_kinds, + &text_content, )?; } } else { @@ -1168,25 +1189,47 @@ impl RenderState { text_drop_shadows_surface_id.into(), &drop_shadows, &blur_filter, + &stroke_kinds, + &text_content, )?; // 4. Stroke fills - for (stroke_paragraphs, layer_opacity) in stroke_paragraphs_list + for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list .iter_mut() .zip(stroke_opacities.iter()) + .enumerate() { - text::render_with_bounds_outset( - Some(self), - None, - &shape, - stroke_paragraphs, - Some(strokes_surface_id), - None, - blur_filter.as_ref(), - text_stroke_blur_outset, - None, - *layer_opacity, - )?; + if stroke_kinds[i] == StrokeKind::Inner { + let mut mask_builders = + text_content.paragraph_builder_group_opaque(); + let mut fill_builders = + text_content.paragraph_builder_group_from_text(None); + text::render_inner_stroke( + Some(self), + None, + &shape, + &mut mask_builders, + stroke_paragraphs, + &mut fill_builders, + Some(strokes_surface_id), + blur_filter.as_ref(), + text_stroke_blur_outset, + *layer_opacity, + )?; + } else { + text::render_with_bounds_outset( + Some(self), + None, + &shape, + stroke_paragraphs, + Some(strokes_surface_id), + None, + blur_filter.as_ref(), + text_stroke_blur_outset, + None, + *layer_opacity, + )?; + } } // 5. Stroke inner shadows @@ -1198,6 +1241,8 @@ impl RenderState { Some(innershadows_surface_id), &inner_shadows, &blur_filter, + &stroke_kinds, + &text_content, )?; // 6. Fill Inner shadows diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index d392305327..6965f16ed0 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -1,6 +1,6 @@ use super::{RenderState, SurfaceId}; use crate::render::strokes; -use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, Type}; +use crate::shapes::{ParagraphBuilderGroup, Shadow, Shape, Stroke, StrokeKind, TextContent, Type}; use skia_safe::{canvas::SaveLayerRec, Paint, Path}; use crate::error::Result; @@ -127,6 +127,7 @@ fn render_shadow_paint( } } +#[allow(clippy::too_many_arguments)] pub fn render_text_shadows( render_state: &mut RenderState, shape: &Shape, @@ -135,6 +136,8 @@ pub fn render_text_shadows( surface_id: Option, shadows: &[Paint], blur_filter: &Option, + stroke_kinds: &[StrokeKind], + text_content: &TextContent, ) -> Result<()> { if stroke_paragraphs_group.is_empty() { return Ok(()); @@ -160,18 +163,35 @@ pub fn render_text_shadows( None, )?; - for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { - text::render( - None, - Some(canvas), - shape, - stroke_paragraphs, - surface_id, - None, - blur_filter.as_ref(), - None, - None, - )?; + for (i, stroke_paragraphs) in stroke_paragraphs_group.iter_mut().enumerate() { + if i < stroke_kinds.len() && stroke_kinds[i] == StrokeKind::Inner { + let mut mask_builders = text_content.paragraph_builder_group_opaque(); + let mut fill_builders = text_content.paragraph_builder_group_from_text(Some(true)); + text::render_inner_stroke( + None, + Some(canvas), + shape, + &mut mask_builders, + stroke_paragraphs, + &mut fill_builders, + surface_id, + blur_filter.as_ref(), + 0.0, + None, + )?; + } else { + text::render( + None, + Some(canvas), + shape, + stroke_paragraphs, + surface_id, + None, + blur_filter.as_ref(), + None, + None, + )?; + } } canvas.restore(); diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 85a150284b..cbddd9e290 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_position_data, calculate_text_layout_data, merge_fills, set_paint_fill, - ParagraphBuilderGroup, Stroke, StrokeKind, TextContent, + calculate_position_data, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, + Stroke, StrokeKind, TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -19,7 +19,6 @@ pub fn stroke_paragraph_builder_group_from_text( text_content: &TextContent, stroke: &Stroke, bounds: &Rect, - count_inner_strokes: usize, use_shadow: Option, ) -> (Vec, Option) { let fallback_fonts = get_fallback_fonts(); @@ -33,14 +32,8 @@ pub fn stroke_paragraph_builder_group_from_text( std::collections::HashMap::new(); for span in paragraph.children().iter() { - let text_paint: skia_safe::Handle<_> = merge_fills(span.fills(), *bounds); - let (stroke_paints, stroke_layer_opacity) = get_text_stroke_paints( - stroke, - bounds, - &text_paint, - count_inner_strokes, - remove_stroke_alpha, - ); + let (stroke_paints, stroke_layer_opacity) = + get_text_stroke_paints(stroke, bounds, remove_stroke_alpha); if group_layer_opacity.is_none() { group_layer_opacity = stroke_layer_opacity; @@ -79,8 +72,6 @@ pub fn stroke_paragraph_builder_group_from_text( fn get_text_stroke_paints( stroke: &Stroke, bounds: &Rect, - text_paint: &Paint, - count_inner_strokes: usize, remove_stroke_alpha: bool, ) -> (Vec, Option) { let mut paints = Vec::new(); @@ -104,56 +95,19 @@ fn get_text_stroke_paints( match stroke.kind { StrokeKind::Inner => { - let shader = text_paint.shader(); - let mut is_opaque = true; - - if let Some(shader) = shader { - is_opaque = shader.is_opaque(); - } - - if is_opaque && count_inner_strokes == 1 { - let mut paint = text_paint.clone(); - paint.set_style(skia::PaintStyle::Fill); - paint.set_anti_alias(true); - paints.push(paint); - - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Stroke); - paint.set_blend_mode(skia::BlendMode::SrcIn); - paint.set_anti_alias(true); - paint.set_stroke_width(stroke.width * 2.0); - fill_for_paint(&mut paint); - paints.push(paint); - } else { - let mut paint = skia::Paint::default(); - if remove_stroke_alpha { - paint.set_color(skia::Color::BLACK); - paint.set_alpha(255); - } else { - paint = text_paint.clone(); - if needs_opacity_layer { - let opaque_fill = stroke.fill.with_full_opacity(); - set_paint_fill(&mut paint, &opaque_fill, bounds, false); - } else { - set_paint_fill(&mut paint, &stroke.fill, bounds, false); - } - } - - paint.set_style(skia::PaintStyle::Fill); - paint.set_anti_alias(false); - paints.push(paint); - - let mut paint = skia::Paint::default(); - let image_filter = - skia_safe::image_filters::erode((stroke.width, stroke.width), None, None); - - paint.set_image_filter(image_filter); - paint.set_anti_alias(false); + // Just the stroke paint — mask+SrcIn+DstOver layering is handled + // by render_inner_stroke_on_canvas. + let mut paint = skia::Paint::default(); + paint.set_style(skia::PaintStyle::Stroke); + paint.set_anti_alias(true); + paint.set_stroke_width(stroke.width * 2.0); + if remove_stroke_alpha { paint.set_color(skia::Color::BLACK); paint.set_alpha(255); - paint.set_blend_mode(skia::BlendMode::DstOut); - paints.push(paint); + } else { + fill_for_paint(&mut paint); } + paints.push(paint); } StrokeKind::Center => { let mut paint = skia::Paint::default(); @@ -330,25 +284,16 @@ fn render_text_on_canvas( canvas.restore(); } -fn draw_text( +/// Lays out and paints paragraph builders without any layer management. +fn paint_text( canvas: &Canvas, shape: &Shape, paragraph_builder_groups: &mut [Vec], - layer_opacity: Option, ) { let text_content = shape.get_text_content(); let layout_info = calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true); - if let Some(opacity) = layer_opacity { - let mut opacity_paint = Paint::default(); - opacity_paint.set_alpha_f(opacity); - let layer_rec = SaveLayerRec::default().paint(&opacity_paint); - canvas.save_layer(&layer_rec); - } else { - canvas.save_layer(&SaveLayerRec::default()); - } - for para in &layout_info.paragraphs { para.paragraph.paint(canvas, (para.x, para.y)); for deco in ¶.decorations { @@ -364,6 +309,178 @@ fn draw_text( } } +fn draw_text( + canvas: &Canvas, + shape: &Shape, + paragraph_builder_groups: &mut [Vec], + layer_opacity: Option, +) { + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + let layer_rec = SaveLayerRec::default().paint(&opacity_paint); + canvas.save_layer(&layer_rec); + } else { + canvas.save_layer(&SaveLayerRec::default()); + } + + paint_text(canvas, shape, paragraph_builder_groups); +} + +/// Renders an inner stroke using mask + SrcIn + DstOver layer structure. +/// +/// Layer structure: +/// saveLayer() — outer layer +/// saveLayer() — mask group (isolation) +/// paint mask — opaque fill as clip mask +/// saveLayer(SrcIn) — clips stroke to mask shape +/// paint stroke +/// restore +/// restore +/// saveLayer(DstOver) — fill behind the stroke +/// paint fill +/// restore +/// restore +#[allow(clippy::too_many_arguments)] +fn render_inner_stroke_on_canvas( + canvas: &Canvas, + shape: &Shape, + mask_builders: &mut [Vec], + stroke_builders: &mut [Vec], + fill_builders: &mut [Vec], + blur: Option<&ImageFilter>, + layer_opacity: Option, +) { + if let Some(blur_filter) = blur { + let mut blur_paint = Paint::default(); + blur_paint.set_image_filter(blur_filter.clone()); + canvas.save_layer(&SaveLayerRec::default().paint(&blur_paint)); + } + + // Opacity layer wraps the entire composition + if let Some(opacity) = layer_opacity { + let mut opacity_paint = Paint::default(); + opacity_paint.set_alpha_f(opacity); + canvas.save_layer(&SaveLayerRec::default().paint(&opacity_paint)); + } + + // Outer layer + canvas.save_layer(&SaveLayerRec::default()); + + // Mask group layer (isolates mask from parent surface content) + canvas.save_layer(&SaveLayerRec::default()); + + // Draw opaque mask (full alpha text shape) + paint_text(canvas, shape, mask_builders); + + // SrcIn layer — only keeps stroke pixels where mask has alpha + let mut src_in_paint = Paint::default(); + src_in_paint.set_blend_mode(skia::BlendMode::SrcIn); + canvas.save_layer(&SaveLayerRec::default().paint(&src_in_paint)); + + // Draw stroke + paint_text(canvas, shape, stroke_builders); + + canvas.restore(); // SrcIn layer + canvas.restore(); // mask group layer + + // Fill with DstOver (behind the stroke result) + let mut dst_over_paint = Paint::default(); + dst_over_paint.set_blend_mode(skia::BlendMode::DstOver); + canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint)); + + paint_text(canvas, shape, fill_builders); + + canvas.restore(); // DstOver layer + canvas.restore(); // outer layer + + if layer_opacity.is_some() { + canvas.restore(); // opacity layer + } + + if blur.is_some() { + canvas.restore(); // blur layer + } +} + +/// Public API for rendering inner strokes with mask+SrcIn+DstOver approach. +#[allow(clippy::too_many_arguments)] +pub fn render_inner_stroke( + render_state: Option<&mut RenderState>, + canvas: Option<&Canvas>, + shape: &Shape, + mask_builders: &mut [Vec], + stroke_builders: &mut [Vec], + fill_builders: &mut [Vec], + surface_id: Option, + blur: Option<&ImageFilter>, + stroke_bounds_outset: f32, + layer_opacity: Option, +) -> Result<()> { + if let Some(render_state) = render_state { + let target_surface = surface_id.unwrap_or(SurfaceId::Fills); + + if let Some(blur_filter) = blur { + let mut text_bounds = shape + .get_text_content() + .calculate_bounds(shape, false) + .to_rect(); + if stroke_bounds_outset > 0.0 { + text_bounds.inset((-stroke_bounds_outset, -stroke_bounds_outset)); + } + let bounds = blur_filter.compute_fast_bounds(text_bounds); + if bounds.is_finite() && bounds.width() > 0.0 && bounds.height() > 0.0 { + let blur_filter_clone = blur_filter.clone(); + if filters::render_with_filter_surface( + render_state, + bounds, + target_surface, + |state, temp_surface| { + let temp_canvas = state.surfaces.canvas(temp_surface); + render_inner_stroke_on_canvas( + temp_canvas, + shape, + mask_builders, + stroke_builders, + fill_builders, + Some(&blur_filter_clone), + layer_opacity, + ); + Ok(()) + }, + )? { + return Ok(()); + } + } + } + + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); + render_inner_stroke_on_canvas( + canvas, + shape, + mask_builders, + stroke_builders, + fill_builders, + blur, + layer_opacity, + ); + return Ok(()); + } + + if let Some(canvas) = canvas { + render_inner_stroke_on_canvas( + canvas, + shape, + mask_builders, + stroke_builders, + fill_builders, + blur, + layer_opacity, + ); + } + Ok(()) +} + fn draw_text_decorations( canvas: &Canvas, text_style: &TextStyle, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index d5592b591a..2bcf2b23f8 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -605,6 +605,40 @@ impl TextContent { paragraph_group } + /// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255). + /// Used as a clip mask for inner stroke rendering. + pub fn paragraph_builder_group_opaque(&self) -> Vec { + let fonts = get_font_collection(); + let fallback_fonts = get_fallback_fonts(); + let mut paragraph_group = Vec::new(); + + for paragraph in self.paragraphs() { + let paragraph_style = paragraph.paragraph_to_style(); + let mut builder = ParagraphBuilder::new(¶graph_style, fonts); + let mut has_text = false; + for span in paragraph.children() { + let text_style = span.to_style( + &self.bounds(), + fallback_fonts, + true, // always opaque + paragraph.line_height(), + ); + let text: String = span.apply_text_transform(); + if !text.is_empty() { + has_text = true; + } + builder.push_style(&text_style); + builder.add_text(&text); + } + if !has_text { + builder.add_text(" "); + } + paragraph_group.push(vec![builder]); + } + + paragraph_group + } + /// Performs an Auto Width text layout. fn text_layout_auto_width(&self) -> TextContentLayoutResult { let mut paragraph_builders = self.paragraph_builder_group_from_text(None); @@ -1094,6 +1128,7 @@ impl TextSpan { self.text = text; } + #[allow(dead_code)] pub fn fills(&self) -> &[shapes::Fill] { &self.fills } From 9aa2abff2e91dffcec82128ffea5ffb8c7a63043 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 6 Apr 2026 12:37:57 +0200 Subject: [PATCH 5/6] :bug: Fix text-editor v2 waitForIdle not having a timeout --- frontend/playwright/ui/pages/WasmWorkspacePage.js | 7 +++++++ frontend/playwright/ui/pages/WorkspacePage.js | 11 ++++++----- frontend/playwright/ui/specs/text-editor-v2.spec.js | 1 - 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/playwright/ui/pages/WasmWorkspacePage.js b/frontend/playwright/ui/pages/WasmWorkspacePage.js index a877f03dba..8b5139e9a0 100644 --- a/frontend/playwright/ui/pages/WasmWorkspacePage.js +++ b/frontend/playwright/ui/pages/WasmWorkspacePage.js @@ -40,6 +40,13 @@ export class WasmWorkspacePage extends WorkspacePage { this.canvas = page.getByTestId("canvas-wasm-shapes"); } + async waitForIdle(options) { + return this.page.evaluate( + (options) => new Promise((resolve) => globalThis.requestIdleCallback(resolve, options)), + options + ); + } + async waitForFirstRender() { await this.pageName.waitFor(); await this.canvas.waitFor(); diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index 1dcac5c4ca..f114d8abda 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -53,19 +53,18 @@ export class WorkspacePage extends BaseWebSocketPage { for (let i = 0; i < amount; i++) { await this.page.keyboard.press("ArrowLeft"); } - await this.waitForIdle(); + await this.waitForIdle({ timeout: 100 }); } async moveToRight(amount = 0) { for (let i = 0; i < amount; i++) { await this.page.keyboard.press("ArrowRight"); } - await this.waitForIdle(); + await this.waitForIdle({ timeout: 100 }); } async moveFromStart(offset = 0) { await this.page.keyboard.press("Home"); - await this.waitForIdle(); await this.moveToRight(offset); } @@ -107,8 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage { return this.changeNumericInput(this.letterSpacing, newValue); } - async waitForIdle() { - await this.page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve))); + async waitForIdle(options) { + await this.page.evaluate( + (options) => new Promise( + (resolve) => globalThis.requestIdleCallback(resolve, options)), options); } }; diff --git a/frontend/playwright/ui/specs/text-editor-v2.spec.js b/frontend/playwright/ui/specs/text-editor-v2.spec.js index effa431d73..c12fef1bba 100644 --- a/frontend/playwright/ui/specs/text-editor-v2.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v2.spec.js @@ -96,7 +96,6 @@ test("Update an already created text shape by prepending text", async ({ await workspace.clickLeafLayer("Lorem ipsum"); await workspace.textEditor.startEditing(); await workspace.textEditor.moveFromStart(0); - await page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve))); await page.keyboard.type("Dolor sit amet "); await workspace.textEditor.stopEditing(); await workspace.waitForSelectedShapeName("Dolor sit amet Lorem ipsum"); From 11d9c09a2ee2ee0091caf37d9465535adf479b80 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 7 Apr 2026 10:10:08 +0200 Subject: [PATCH 6/6] :bug: Fix problem with dashboard thumbnails (#8862) --- frontend/src/app/main.cljs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 25dfbed0d7..9583b86686 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -62,9 +62,9 @@ [] (ptk/reify ::initialize-rasterizer ptk/EffectEvent - (effect [_ state _] - (when-not (feat/active-feature? state "render-wasm/v1") - (thr/init!))))) + (effect [_ _ _] + ;; The rasterizer is used for the dashboard thumbnails + (thr/init!)))) (defn initialize []