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 270678de25..ec963f718a 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,10 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage { return this.changeNumericInput(this.letterSpacing, newValue); } - async waitForIdle() { + async waitForIdle(options) { await this.page.evaluate( - () => new Promise((resolve) => globalThis.requestIdleCallback(resolve)), - ); + (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"); 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 [] 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/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 9ed0a9edfb..118a1f5a25 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 @@ -370,7 +370,8 @@ (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}))) (ts/schedule #(st/emit! (dwu/commit-undo-transaction uid)))))) handle-detach-typography 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/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([ 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; }, 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 }