mirror of
https://github.com/penpot/penpot.git
synced 2026-07-23 06:28:14 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
83833896c9
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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
|
||||
[]
|
||||
|
||||
@ -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?]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<object, true>();
|
||||
|
||||
/**
|
||||
* 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<T extends (...args: unknown[]) => unknown>(
|
||||
handler: T,
|
||||
): (...args: Parameters<T>) => ReturnType<T> {
|
||||
return function (...args: Parameters<T>) {
|
||||
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<T>;
|
||||
}
|
||||
return result as ReturnType<T>;
|
||||
} catch (error) {
|
||||
markPluginError(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createSandbox(
|
||||
plugin: Awaited<ReturnType<typeof createPluginManager>>,
|
||||
apiExtensions?: object,
|
||||
@ -58,9 +113,10 @@ export function createSandbox(
|
||||
fetch: ses.harden(safeFetch),
|
||||
setTimeout: ses.harden(
|
||||
(...[handler, timeout]: Parameters<typeof setTimeout>) => {
|
||||
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<typeof setInterval>) => {
|
||||
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<typeof setInterval>) => {
|
||||
clearInterval(id);
|
||||
|
||||
plugin.intervals.delete(id);
|
||||
}),
|
||||
|
||||
/**
|
||||
* GLOBAL FUNCTIONS ACCESIBLE TO PLUGINS
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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']) {
|
||||
|
||||
@ -21,6 +21,7 @@ export async function createPluginManager(
|
||||
let modal: PluginModalElement | null = null;
|
||||
let uiMessagesCallbacks: ((message: unknown) => void)[] = [];
|
||||
const timeouts = new Set<ReturnType<typeof setTimeout>>();
|
||||
const intervals = new Set<ReturnType<typeof setInterval>>();
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
@ -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<StrokeKind> =
|
||||
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
|
||||
|
||||
@ -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<SurfaceId>,
|
||||
shadows: &[Paint],
|
||||
blur_filter: &Option<skia_safe::ImageFilter>,
|
||||
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();
|
||||
|
||||
@ -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<bool>,
|
||||
) -> (Vec<ParagraphBuilderGroup>, Option<f32>) {
|
||||
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<Paint>, Option<f32>) {
|
||||
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<ParagraphBuilder>],
|
||||
layer_opacity: Option<f32>,
|
||||
) {
|
||||
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<ParagraphBuilder>],
|
||||
layer_opacity: Option<f32>,
|
||||
) {
|
||||
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<ParagraphBuilder>],
|
||||
stroke_builders: &mut [Vec<ParagraphBuilder>],
|
||||
fill_builders: &mut [Vec<ParagraphBuilder>],
|
||||
blur: Option<&ImageFilter>,
|
||||
layer_opacity: Option<f32>,
|
||||
) {
|
||||
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<ParagraphBuilder>],
|
||||
stroke_builders: &mut [Vec<ParagraphBuilder>],
|
||||
fill_builders: &mut [Vec<ParagraphBuilder>],
|
||||
surface_id: Option<SurfaceId>,
|
||||
blur: Option<&ImageFilter>,
|
||||
stroke_bounds_outset: f32,
|
||||
layer_opacity: Option<f32>,
|
||||
) -> 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,
|
||||
|
||||
@ -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<ParagraphBuilderGroup> {
|
||||
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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user