From 0a3793922eae1cf46101909b7724568c83cbd96e Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 23:05:41 +0200 Subject: [PATCH 01/15] Init copy button for svg preview --- tests/_support/fixtures.ts | 9 ++- tests/pages/powerpoint-page.ts | 10 +++ tests/preview.spec.ts | 20 +++++ web/powerpoint.html | 18 +++++ web/src/constants.ts | 1 + web/src/preview.ts | 69 +++++++++++++++- web/src/svg.ts | 142 +++++++++++++++++++++++++++++++++ web/styles/main.css | 73 +++++++++++++++++ 8 files changed, 340 insertions(+), 2 deletions(-) diff --git a/tests/_support/fixtures.ts b/tests/_support/fixtures.ts index 5dd9a37..315a166 100644 --- a/tests/_support/fixtures.ts +++ b/tests/_support/fixtures.ts @@ -22,7 +22,14 @@ export { expect }; const extendedTest = base.extend({ // https://playwright.dev/docs/test-fixtures#adding-global-beforeeachaftereach-hooks _forEachTest: [ - async ({ page }, use) => { + async ({ baseURL, context, page }, use) => { + if (baseURL) { + await context.grantPermissions( + ["clipboard-read", "clipboard-write"], + { origin: new URL(baseURL).origin }, + ); + } + await page.addInitScript(() => { window.localStorage.clear(); }); diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 6d154d2..22dd188 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -131,6 +131,16 @@ export class PowerPointPage { await this.page.locator("#bulkUpdateBtn").click(); } + async copyPreviewSvg(options: { invertColors?: boolean } = {}) { + await this.page.locator("#previewCopyBtn").click({ + modifiers: options.invertColors ? ["Shift"] : [], + }); + } + + async readClipboardText(): Promise { + return this.page.evaluate(() => navigator.clipboard.readText()); + } + async selectShapes(slideId: string, shapeIds: string[]) { await this.page.evaluate( async ({ selectedSlideId, selectedShapeIds }) => { diff --git a/tests/preview.spec.ts b/tests/preview.spec.ts index 25f59af..1cc7ec5 100644 --- a/tests/preview.spec.ts +++ b/tests/preview.spec.ts @@ -31,3 +31,23 @@ test("previews Typst math expressions", async ({ powerPointPage, typstMock }) => }, ]); }); + +test("copies the preview SVG with optional inverted colors", async ({ powerPointPage }) => { + await powerPointPage.previewExpression("integral_a^b f(x) dif x"); + await powerPointPage.expectPreviewVisible(); + + await powerPointPage.copyPreviewSvg(); + await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#000000"'); + const copiedSvg = await powerPointPage.readClipboardText(); + expect(copiedSvg).toContain(" Preview +
diff --git a/web/src/constants.ts b/web/src/constants.ts index 2f2e237..94e7fc6 100644 --- a/web/src/constants.ts +++ b/web/src/constants.ts @@ -48,6 +48,7 @@ export const DOM_IDS = { TYPST_INPUT: "typstInput", INSERT_BTN: "insertBtn", BULK_UPDATE_BTN: "bulkUpdateBtn", + PREVIEW_COPY_BTN: "previewCopyBtn", PREVIEW_CONTENT: "previewContent", DARK_MODE_TOGGLE: "darkModeToggle", DIAGNOSTICS_CONTAINER: "diagnosticsContainer", diff --git a/web/src/preview.ts b/web/src/preview.ts index 98ac8a7..db14bd4 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -1,5 +1,5 @@ import { DiagnosticMessage, typst } from "./typst.js"; -import { applyFillColor, parseAndApplySize } from "./svg.js"; +import { applyFillColor, parseAndApplySize, serializeSvgForClipboard } from "./svg.js"; import { DOM_IDS, PREVIEW_CONFIG, STORAGE_KEYS, FILL_COLOR_DISABLED } from "./constants.js"; import { getAreaElement, getHTMLElement, getInputElement } from "./utils/dom"; import { @@ -9,11 +9,14 @@ import { getEditorMode, getMathModeEnabled, getTypstCode, + setStatus, setButtonEnabled, setMathModeEnabled, } from "./ui"; import { storeValue, getStoredValue } from "./utils/storage.js"; +let copyFeedbackTimeout: ReturnType | undefined; + /** * Sets up event listeners for preview updates. */ @@ -25,6 +28,16 @@ export function setupPreviewListeners() { const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED); const previewFillEnabled = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED); const mathModeEnabled = getInputElement(DOM_IDS.MATH_MODE_ENABLED); + const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN) as HTMLButtonElement; + + if (!window.isSecureContext) { + previewCopyButton.dataset.clipboardUnavailable = "true"; + previewCopyButton.hidden = true; + } else { + previewCopyButton.addEventListener("click", (event) => { + void copyPreviewSvg(event.shiftKey); + }); + } typstInput.addEventListener("input", () => { updateButtonState(); @@ -75,6 +88,7 @@ export function setupPreviewListeners() { syncPreviewFillToggleState(fillColorEnabled.checked); updateMathModeVisuals(); + setPreviewCopyButtonEnabled(false); } /** @@ -144,6 +158,7 @@ export async function updatePreview() { if (!rawCode) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); diagnosticsContainer.style.display = "none"; return; } @@ -159,6 +174,7 @@ export async function updatePreview() { if (!result.svg) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); return; } @@ -172,6 +188,7 @@ export async function updatePreview() { svgElement.style.width = "100%"; svgElement.style.height = "auto"; svgElement.style.maxHeight = PREVIEW_CONFIG.MAX_HEIGHT; + setPreviewCopyButtonEnabled(true); const isDarkMode = document.documentElement.classList.contains("dark-mode"); const previewFill = isDarkMode ? PREVIEW_CONFIG.DARK_MODE_FILL : PREVIEW_CONFIG.LIGHT_MODE_FILL; @@ -181,6 +198,56 @@ export async function updatePreview() { } } +async function copyPreviewSvg(invertColors: boolean) { + const previewElement = getHTMLElement(DOM_IDS.PREVIEW_CONTENT); + const svgElement = previewElement.querySelector("svg"); + if (!svgElement) { + return; + } + + try { + const svgText = serializeSvgForClipboard(svgElement, invertColors); + await navigator.clipboard.writeText(svgText); + showPreviewCopyFeedback(); + } catch { + setStatus("Could not copy preview SVG.", true); + } +} + +function setPreviewCopyButtonEnabled(enabled: boolean) { + const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN) as HTMLButtonElement; + if (previewCopyButton.dataset.clipboardUnavailable === "true") { + return; + } + + if (!enabled) { + previewCopyButton.hidden = true; + previewCopyButton.classList.remove("is-copied"); + if (copyFeedbackTimeout) { + clearTimeout(copyFeedbackTimeout); + copyFeedbackTimeout = undefined; + } + } else { + previewCopyButton.hidden = false; + } + + previewCopyButton.disabled = !enabled; +} + +function showPreviewCopyFeedback() { + const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN); + previewCopyButton.classList.add("is-copied"); + + if (copyFeedbackTimeout) { + clearTimeout(copyFeedbackTimeout); + } + + copyFeedbackTimeout = setTimeout(() => { + previewCopyButton.classList.remove("is-copied"); + copyFeedbackTimeout = undefined; + }, 1300); +} + /** * Displays diagnostics in the UI. */ diff --git a/web/src/svg.ts b/web/src/svg.ts index 7063f8d..eded2fb 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -68,6 +68,148 @@ export function applyFillColor(svg: SVGElement, fillColor: string) { }); } +/** + * Serializes the displayed preview SVG for clipboard use. + */ +export function serializeSvgForClipboard(svg: SVGElement, invertColors = false): string { + const clipboardSvg = svg.cloneNode(true) as SVGElement; + removePreviewLayoutStyles(clipboardSvg); + + if (invertColors) { + invertSvgColors(clipboardSvg); + } + + return new XMLSerializer().serializeToString(clipboardSvg); +} + +function removePreviewLayoutStyles(svg: SVGElement) { + const inlineStyle = svg.getAttribute("style"); + if (!inlineStyle) { + return; + } + + const style = document.createElement("span").style; + style.cssText = inlineStyle; + style.removeProperty("width"); + style.removeProperty("height"); + style.removeProperty("max-height"); + + if (style.cssText) { + svg.setAttribute("style", style.cssText); + } else { + svg.removeAttribute("style"); + } +} + +function invertSvgColors(svg: SVGElement) { + const colorAttributes = [ + "color", + "fill", + "stroke", + "stop-color", + "flood-color", + "lighting-color", + ]; + const colorProperties = [ + "color", + "fill", + "stroke", + "stop-color", + "flood-color", + "lighting-color", + ]; + const elements: Element[] = [svg, ...Array.from(svg.querySelectorAll("*"))]; + + elements.forEach((el) => { + colorAttributes.forEach((attribute) => { + const color = el.getAttribute(attribute); + const invertedColor = color ? invertCssColor(color) : null; + if (invertedColor) { + el.setAttribute(attribute, invertedColor); + } + }); + + const inlineStyle = el.getAttribute("style"); + if (!inlineStyle) { + return; + } + + const style = document.createElement("span").style; + style.cssText = inlineStyle; + colorProperties.forEach((property) => { + const color = style.getPropertyValue(property); + const invertedColor = color ? invertCssColor(color) : null; + if (invertedColor) { + style.setProperty(property, invertedColor, style.getPropertyPriority(property)); + } + }); + el.setAttribute("style", style.cssText); + }); +} + +function invertCssColor(color: string): string | null { + const value = color.trim(); + const normalizedValue = value.toLowerCase(); + if (!value || normalizedValue === "none" || normalizedValue.startsWith("url(")) { + return null; + } + + const parserElement = document.createElement("span"); + parserElement.style.color = value; + if (!parserElement.style.color) { + return null; + } + + document.body.appendChild(parserElement); + const computedColor = window.getComputedStyle(parserElement).color; + document.body.removeChild(parserElement); + + const parsed = computedColor.match( + /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?|\d+(?:\.\d+)?%))?\s*\)$/, + ); + if (!parsed) { + return null; + } + + const red = 255 - clampColorComponent(Number(parsed[1])); + const green = 255 - clampColorComponent(Number(parsed[2])); + const blue = 255 - clampColorComponent(Number(parsed[3])); + const alpha = parseAlpha(parsed[4]); + + if (alpha === null || alpha >= 1) { + return `#${toHex(red)}${toHex(green)}${toHex(blue)}`; + } + + return `rgba(${red.toString()}, ${green.toString()}, ${blue.toString()}, ${alpha.toString()})`; +} + +function clampColorComponent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.min(255, Math.round(value))); +} + +function parseAlpha(alpha: string | undefined): number | null { + if (!alpha) { + return null; + } + + if (alpha.endsWith("%")) { + return Math.max(0, Math.min(1, Number(alpha.slice(0, -1)) / 100)); + } + + const parsedAlpha = Number(alpha); + if (!Number.isFinite(parsedAlpha)) { + return null; + } + return Math.max(0, Math.min(1, parsedAlpha)); +} + +function toHex(value: number): string { + return value.toString(16).padStart(2, "0"); +} + type ParsedHexAlpha = { rgbHex: string; alpha: number; diff --git a/web/styles/main.css b/web/styles/main.css index 8def53c..18450d7 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -8,6 +8,11 @@ --button-bg: #0078d4; --button-hover: #005a9e; --button-disabled: #aaaaaa; + --icon-button-bg: #ffffff; + --icon-button-hover: #f1f1f1; + --icon-button-border: #d5d5d5; + --icon-button-color: #6b6b6b; + --success-color: #2e8b57; --error-color: #b00020; &.dark-mode { @@ -20,6 +25,11 @@ --button-bg: #0078d4; --button-hover: #005a9e; --button-disabled: #555; + --icon-button-bg: #2f2f2f; + --icon-button-hover: #383838; + --icon-button-border: #4a4a4a; + --icon-button-color: #d0d0d0; + --success-color: #67c587; --error-color: #f48771; } } @@ -266,6 +276,7 @@ button { } #previewContainer { + position: relative; margin-top: 10px; border: 1px dashed var(--border-color); padding: 10px; @@ -274,6 +285,68 @@ button { text-align: center; } +.preview-copy-btn { + position: absolute; + top: 6px; + right: 6px; + width: 28px; + height: 28px; + margin: 0; + padding: 0; + color: var(--icon-button-color); + border: 1px solid var(--icon-button-border); + border-radius: 4px; + background-color: var(--icon-button-bg); + cursor: pointer; + opacity: 0.86; + transition: background-color 100ms, border-color 100ms; + + &:hover:not(:disabled) { + background-color: var(--icon-button-hover); + opacity: 1; + } + + &:disabled { + opacity: 0; + cursor: default; + pointer-events: none; + visibility: hidden; + } + + > * { + position: absolute; + top: 50%; + left: 50%; + transition: opacity 220ms ease-in-out, transform 500ms ease-in-out; + } +} + +.preview-copy-icon-clipboard { + width: 14px; + fill: currentColor; + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.preview-copy-icon-tick { + width: 12px; + fill: var(--success-color); + opacity: 0; + transform: translate(-50%, -50%) scale(0); +} + +.preview-copy-btn.is-copied { + .preview-copy-icon-clipboard { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + + .preview-copy-icon-tick { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + #previewLabel { color: #999; font-size: 11px; From 984a8f6545f4154da22617cf4cfc682333ac47ff Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 23:17:23 +0200 Subject: [PATCH 02/15] Fix normalizing of alpha hex colors in clipboard svg --- web/src/svg.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/svg.ts b/web/src/svg.ts index eded2fb..8e973e0 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -74,6 +74,7 @@ export function applyFillColor(svg: SVGElement, fillColor: string) { export function serializeSvgForClipboard(svg: SVGElement, invertColors = false): string { const clipboardSvg = svg.cloneNode(true) as SVGElement; removePreviewLayoutStyles(clipboardSvg); + normalizeAlphaHexColors(clipboardSvg); if (invertColors) { invertSvgColors(clipboardSvg); From 5e26c61eb94ba1eed00b48aecf93fe4096cfcc8e Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 23:19:16 +0200 Subject: [PATCH 03/15] Test alpha fill for clipboard SVGs --- .../browser-mocks/typst-state-module.d.ts | 3 +++ tests/_support/browser-mocks/typst-state.ts | 12 +++++++++ tests/_support/browser-mocks/typst.ts | 8 +----- tests/_support/typst-mock.ts | 10 +++++++ tests/pages/powerpoint-page.ts | 4 +++ tests/preview.spec.ts | 26 +++++++++++++++++++ 6 files changed, 56 insertions(+), 7 deletions(-) diff --git a/tests/_support/browser-mocks/typst-state-module.d.ts b/tests/_support/browser-mocks/typst-state-module.d.ts index 134a919..4ed51e0 100644 --- a/tests/_support/browser-mocks/typst-state-module.d.ts +++ b/tests/_support/browser-mocks/typst-state-module.d.ts @@ -8,6 +8,7 @@ declare module "*__test__/typst-state.js" { artifactContent: number[]; data_selection: Record; }[]; + previewSvg: string; }; export function typstMockReady(): boolean; @@ -21,4 +22,6 @@ declare module "*__test__/typst-state.js" { data_selection: Record; }[]; }; + + export function setTypstMockPreviewSvg(_svg: string): void; } diff --git a/tests/_support/browser-mocks/typst-state.ts b/tests/_support/browser-mocks/typst-state.ts index 5f53dde..7cd1d78 100644 --- a/tests/_support/browser-mocks/typst-state.ts +++ b/tests/_support/browser-mocks/typst-state.ts @@ -11,14 +11,22 @@ type TypstMockState = { addSourceCalls: AddSourceCall[]; compileCalls: CompileCall[]; renderSvgCalls: RenderSvgCall[]; + previewSvg: string; }; +const defaultPreviewSvg = [ + '', + 'integral preview', + "", +].join(""); + function freshState(): TypstMockState { return { rendererInitOptions: [], addSourceCalls: [], compileCalls: [], renderSvgCalls: [], + previewSvg: defaultPreviewSvg, }; } @@ -35,3 +43,7 @@ export function typstMockCalls() { renderSvgCalls: structuredClone(typstMockState.renderSvgCalls), }; } + +export function setTypstMockPreviewSvg(svg: string) { + typstMockState.previewSvg = svg; +} diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 68578be..372de44 100644 --- a/tests/_support/browser-mocks/typst.ts +++ b/tests/_support/browser-mocks/typst.ts @@ -8,12 +8,6 @@ type RenderSvgOptions = { data_selection: Record; }; -const previewSvg = [ - '', - 'integral preview', - "", -].join(""); - export function createTypstCompiler() { return { init(options: CompilerInitOptions) { @@ -46,7 +40,7 @@ export function createTypstRenderer() { artifactContent: Array.from(options.artifactContent), data_selection: options.data_selection, }); - return Promise.resolve(previewSvg); + return Promise.resolve(typstMockState.previewSvg); }, }; } diff --git a/tests/_support/typst-mock.ts b/tests/_support/typst-mock.ts index c51e26e..f0decd0 100644 --- a/tests/_support/typst-mock.ts +++ b/tests/_support/typst-mock.ts @@ -57,6 +57,16 @@ export class TypstMock { }, stateModuleUrl); } + /** Overrides the SVG that the mocked renderer returns for subsequent preview renders. */ + async setPreviewSvg(svg: string) { + await this.page.evaluate(async ({ moduleUrl, previewSvg }) => { + const stateModule = await import(moduleUrl) as { + setTypstMockPreviewSvg: (_svg: string) => void; + }; + stateModule.setTypstMockPreviewSvg(previewSvg); + }, { moduleUrl: stateModuleUrl, previewSvg: svg }); + } + private async routeModule(url: string, fileName: string) { await this.page.route(url, async (route) => { await route.fulfill({ diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 22dd188..4a5fc8e 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -123,6 +123,10 @@ export class PowerPointPage { await this.page.locator("#fillColor").fill(fillColor); } + async setPreviewTypstFillEnabled(enabled: boolean) { + await this.page.locator("#previewFillEnabled").setChecked(enabled); + } + async insertOrUpdate() { await this.page.locator("#insertBtn").click(); } diff --git a/tests/preview.spec.ts b/tests/preview.spec.ts index 1cc7ec5..0c0810f 100644 --- a/tests/preview.spec.ts +++ b/tests/preview.spec.ts @@ -51,3 +51,29 @@ test("copies the preview SVG with optional inverted colors", async ({ powerPoint expect(invertedSvg).toContain('fill="#ffffff"'); expect(invertedSvg).not.toContain('fill="#000000"'); }); + +test("copies preview SVGs with alpha fills normalized for compatibility", + async ({ powerPointPage, typstMock }) => { + await typstMock.setPreviewSvg([ + '', + '', + '', + "", + ].join("")); + + await powerPointPage.setFillColor(null); + await powerPointPage.setPreviewTypstFillEnabled(true); + await powerPointPage.previewExpression("compatibility check"); + await powerPointPage.expectPreviewVisible(); + + await powerPointPage.copyPreviewSvg(); + await expect.poll(() => powerPointPage.readClipboardText()) + .toContain('fill-opacity="0.19607843137254902"'); + + const copiedSvg = await powerPointPage.readClipboardText(); + expect(copiedSvg).toContain('fill="#ff0000"'); + expect(copiedSvg).toContain('fill="#0000ff"'); + expect(copiedSvg).toContain('fill-opacity="0.19607843137254902"'); + expect(copiedSvg).not.toContain("#ff000032"); + expect(copiedSvg).not.toContain("#0000ff32"); + }); From 8d6d531d5daaafdabe0dcd37577aa13a8fe00c92 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 23:50:49 +0200 Subject: [PATCH 04/15] Also handle inline styles for alpha normalization --- web/src/svg.ts | 91 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/web/src/svg.ts b/web/src/svg.ts index 8e973e0..aaced54 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -216,6 +216,8 @@ type ParsedHexAlpha = { alpha: number; }; +type ColorOpacityAttributes = Record; + /** * Parses #RGBA or #RRGGBBAA colors into RGB + alpha. */ @@ -249,6 +251,29 @@ function parseHexWithAlpha(value: string): ParsedHexAlpha | null { return null; } +function parseRgbaWithAlpha(value: string): ParsedHexAlpha | null { + const parsed = value.trim().match( + /^rgba\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?|\d+(?:\.\d+)?%)\s*\)$/, + ); + if (!parsed) { + return null; + } + + const alpha = parseAlpha(parsed[4]); + if (alpha === null || alpha >= 1) { + return null; + } + + return { + rgbHex: `#${toHex(clampColorComponent(Number(parsed[1])))}${toHex(clampColorComponent(Number(parsed[2])))}${toHex(clampColorComponent(Number(parsed[3])))}`, + alpha, + }; +} + +function parseColorWithAlpha(value: string): ParsedHexAlpha | null { + return parseHexWithAlpha(value) || parseRgbaWithAlpha(value); +} + /** * Converts alpha hex colors to RGB + explicit opacity attributes. * @@ -256,7 +281,7 @@ function parseHexWithAlpha(value: string): ParsedHexAlpha | null { * these to maximize compatibility when inserting shapes. */ export function normalizeAlphaHexColors(svg: SVGElement) { - const colorToOpacityAttr: Record = { + const colorToOpacityAttr: ColorOpacityAttributes = { "fill": "fill-opacity", "stroke": "stroke-opacity", "stop-color": "stop-opacity", @@ -265,22 +290,58 @@ export function normalizeAlphaHexColors(svg: SVGElement) { const elements: Element[] = [svg, ...Array.from(svg.querySelectorAll("*"))]; elements.forEach((el) => { Object.entries(colorToOpacityAttr).forEach(([colorAttr, opacityAttr]) => { - const value = el.getAttribute(colorAttr); - if (!value) { - return; - } + normalizeAlphaColorAttribute(el, colorAttr, opacityAttr); + }); + normalizeAlphaColorStyles(el, colorToOpacityAttr); + }); +} - const parsed = parseHexWithAlpha(value); - if (!parsed) { - return; - } +function normalizeAlphaColorAttribute(el: Element, colorAttr: string, opacityAttr: string) { + const value = el.getAttribute(colorAttr); + if (!value) { + return; + } - el.setAttribute(colorAttr, parsed.rgbHex); + const parsed = parseColorWithAlpha(value); + if (!parsed) { + return; + } - const existingOpacity = parseFloat(el.getAttribute(opacityAttr) || "1"); - const safeOpacity = Number.isFinite(existingOpacity) ? existingOpacity : 1; - const combinedOpacity = Math.max(0, Math.min(1, safeOpacity * parsed.alpha)); - el.setAttribute(opacityAttr, combinedOpacity.toString()); - }); + el.setAttribute(colorAttr, parsed.rgbHex); + const combinedOpacity = combineOpacity(el.getAttribute(opacityAttr), parsed.alpha); + el.setAttribute(opacityAttr, combinedOpacity.toString()); +} + +function normalizeAlphaColorStyles(el: Element, colorToOpacityAttr: ColorOpacityAttributes) { + const inlineStyle = el.getAttribute("style"); + if (!inlineStyle) { + return; + } + + const style = document.createElement("span").style; + style.cssText = inlineStyle; + + Object.entries(colorToOpacityAttr).forEach(([colorProperty, opacityProperty]) => { + const value = style.getPropertyValue(colorProperty); + if (!value) { + return; + } + + const parsed = parseColorWithAlpha(value); + if (!parsed) { + return; + } + + style.setProperty(colorProperty, parsed.rgbHex, style.getPropertyPriority(colorProperty)); + const combinedOpacity = combineOpacity(style.getPropertyValue(opacityProperty), parsed.alpha); + style.setProperty(opacityProperty, combinedOpacity.toString(), style.getPropertyPriority(opacityProperty)); }); + + el.setAttribute("style", style.cssText); +} + +function combineOpacity(opacity: string | null, alpha: number): number { + const existingOpacity = parseFloat(opacity || "1"); + const safeOpacity = Number.isFinite(existingOpacity) ? existingOpacity : 1; + return Math.max(0, Math.min(1, safeOpacity * alpha)); } From d0c802218968640acaa8302755d78121cd64e2ad Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 8 Jun 2026 00:04:17 +0200 Subject: [PATCH 05/15] Move copy button functionality to separate file --- web/src/copy.ts | 177 +++++++++++++++++++++++++++++++++++++++++++++ web/src/preview.ts | 66 +---------------- 2 files changed, 180 insertions(+), 63 deletions(-) create mode 100644 web/src/copy.ts diff --git a/web/src/copy.ts b/web/src/copy.ts new file mode 100644 index 0000000..0c65b05 --- /dev/null +++ b/web/src/copy.ts @@ -0,0 +1,177 @@ +/** + * Handles copying the preview SVG to the clipboard upon clicking the copy button. + */ + +import { DOM_IDS } from "./constants.js"; +import { serializeSvgForClipboard } from "./svg.js"; +import { setStatus } from "./ui.js"; +import { getButtonElement, getHTMLElement } from "./utils/dom.js"; + +type ClipboardTextWriter = { + writeText: (_text: string) => Promise; +}; + +type ClipboardSvgWriter = ClipboardTextWriter & { + write?: (_items: ClipboardItem[]) => Promise; +}; + +type ClipboardItemConstructorLike = { + new ( + _items: Record>, + _options?: ClipboardItemOptions, + ): ClipboardItem; + supports?: (_type: string) => boolean; +}; + +let copyFeedbackTimeout: ReturnType | undefined; +let clipboardUnavailable = false; + +/** + * Sets up the preview SVG copy button and its clipboard behavior. + */ +export function setupPreviewCopyButton() { + const previewCopyButton = getPreviewCopyButton(); + const clipboard = getClipboardWriter(); + + if (!window.isSecureContext || !clipboard) { + clipboardUnavailable = true; + previewCopyButton.hidden = true; + return; + } + + previewCopyButton.addEventListener("click", (event) => { + void copyPreviewSvg(event.shiftKey); + }); + setPreviewCopyButtonEnabled(false); +} + +/** + * Shows or hides the preview copy button based on preview SVG availability. + */ +export function setPreviewCopyButtonEnabled(enabled: boolean) { + if (clipboardUnavailable) { + return; + } + + const previewCopyButton = getPreviewCopyButton(); + + if (!enabled) { + previewCopyButton.hidden = true; + previewCopyButton.classList.remove("is-copied"); + if (copyFeedbackTimeout) { + clearTimeout(copyFeedbackTimeout); + copyFeedbackTimeout = undefined; + } + } else { + previewCopyButton.hidden = false; + } + + previewCopyButton.disabled = !enabled; +} + +async function copyPreviewSvg(invertColors: boolean) { + const previewElement = getHTMLElement(DOM_IDS.PREVIEW_CONTENT); + const svgElement = previewElement.querySelector("svg"); + if (!svgElement) { + return; + } + + try { + const svgText = serializeSvgForClipboard(svgElement, invertColors); + await writeSvgToClipboard(svgText); + showPreviewCopyFeedback(); + } catch { + setStatus("Could not copy preview SVG.", true); + } +} + +async function writeSvgToClipboard(svgText: string) { + const clipboard = getClipboardWriter(); + if (!clipboard) { + throw new Error("Clipboard API is not available."); + } + + const ClipboardItemConstructor = getClipboardItemConstructor(); + if (clipboard.write && ClipboardItemConstructor && clipboardItemSupportsSvg(ClipboardItemConstructor)) { + try { + await clipboard.write([ + new ClipboardItemConstructor({ + "image/svg+xml": new Blob([svgText], { type: "image/svg+xml" }), + "text/plain": new Blob([svgText], { type: "text/plain" }), + }), + ]); + return; + } catch { + // Some hosts expose rich clipboard APIs but reject SVG items at runtime. + } + } + + await clipboard.writeText(svgText); +} + +function getClipboardWriter(): ClipboardSvgWriter | null { + const clipboard = Reflect.get(navigator, "clipboard") as unknown; + if (!isRecord(clipboard)) { + return null; + } + + const writeText = clipboard.writeText; + if (typeof writeText !== "function") { + return null; + } + + const write = clipboard.write; + if (write !== undefined && typeof write !== "function") { + return null; + } + + return { + writeText: async (text) => { + await writeText.call(clipboard, text); + }, + write: typeof write === "function" + ? async (items) => { + await write.call(clipboard, items); + } + : undefined, + }; +} + +function getClipboardItemConstructor(): ClipboardItemConstructorLike | null { + const ClipboardItemConstructor = Reflect.get(globalThis, "ClipboardItem") as unknown; + return isClipboardItemConstructor(ClipboardItemConstructor) ? ClipboardItemConstructor : null; +} + +function clipboardItemSupportsSvg(ClipboardItemConstructor: ClipboardItemConstructorLike): boolean { + if (typeof ClipboardItemConstructor.supports !== "function") { + return true; + } + + return ClipboardItemConstructor.supports("image/svg+xml"); +} + +function showPreviewCopyFeedback() { + const previewCopyButton = getPreviewCopyButton(); + previewCopyButton.classList.add("is-copied"); + + if (copyFeedbackTimeout) { + clearTimeout(copyFeedbackTimeout); + } + + copyFeedbackTimeout = setTimeout(() => { + previewCopyButton.classList.remove("is-copied"); + copyFeedbackTimeout = undefined; + }, 1300); +} + +function getPreviewCopyButton(): HTMLButtonElement { + return getButtonElement(DOM_IDS.PREVIEW_COPY_BTN); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isClipboardItemConstructor(value: unknown): value is ClipboardItemConstructorLike { + return typeof value === "function"; +} diff --git a/web/src/preview.ts b/web/src/preview.ts index db14bd4..b78d9b6 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -1,7 +1,8 @@ import { DiagnosticMessage, typst } from "./typst.js"; -import { applyFillColor, parseAndApplySize, serializeSvgForClipboard } from "./svg.js"; +import { applyFillColor, parseAndApplySize } from "./svg.js"; import { DOM_IDS, PREVIEW_CONFIG, STORAGE_KEYS, FILL_COLOR_DISABLED } from "./constants.js"; import { getAreaElement, getHTMLElement, getInputElement } from "./utils/dom"; +import { setupPreviewCopyButton, setPreviewCopyButtonEnabled } from "./copy.js"; import { getFillColor, getFontSize, @@ -9,14 +10,11 @@ import { getEditorMode, getMathModeEnabled, getTypstCode, - setStatus, setButtonEnabled, setMathModeEnabled, } from "./ui"; import { storeValue, getStoredValue } from "./utils/storage.js"; -let copyFeedbackTimeout: ReturnType | undefined; - /** * Sets up event listeners for preview updates. */ @@ -28,16 +26,8 @@ export function setupPreviewListeners() { const fillColorEnabled = getInputElement(DOM_IDS.FILL_COLOR_ENABLED); const previewFillEnabled = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED); const mathModeEnabled = getInputElement(DOM_IDS.MATH_MODE_ENABLED); - const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN) as HTMLButtonElement; - if (!window.isSecureContext) { - previewCopyButton.dataset.clipboardUnavailable = "true"; - previewCopyButton.hidden = true; - } else { - previewCopyButton.addEventListener("click", (event) => { - void copyPreviewSvg(event.shiftKey); - }); - } + setupPreviewCopyButton(); typstInput.addEventListener("input", () => { updateButtonState(); @@ -198,56 +188,6 @@ export async function updatePreview() { } } -async function copyPreviewSvg(invertColors: boolean) { - const previewElement = getHTMLElement(DOM_IDS.PREVIEW_CONTENT); - const svgElement = previewElement.querySelector("svg"); - if (!svgElement) { - return; - } - - try { - const svgText = serializeSvgForClipboard(svgElement, invertColors); - await navigator.clipboard.writeText(svgText); - showPreviewCopyFeedback(); - } catch { - setStatus("Could not copy preview SVG.", true); - } -} - -function setPreviewCopyButtonEnabled(enabled: boolean) { - const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN) as HTMLButtonElement; - if (previewCopyButton.dataset.clipboardUnavailable === "true") { - return; - } - - if (!enabled) { - previewCopyButton.hidden = true; - previewCopyButton.classList.remove("is-copied"); - if (copyFeedbackTimeout) { - clearTimeout(copyFeedbackTimeout); - copyFeedbackTimeout = undefined; - } - } else { - previewCopyButton.hidden = false; - } - - previewCopyButton.disabled = !enabled; -} - -function showPreviewCopyFeedback() { - const previewCopyButton = getHTMLElement(DOM_IDS.PREVIEW_COPY_BTN); - previewCopyButton.classList.add("is-copied"); - - if (copyFeedbackTimeout) { - clearTimeout(copyFeedbackTimeout); - } - - copyFeedbackTimeout = setTimeout(() => { - previewCopyButton.classList.remove("is-copied"); - copyFeedbackTimeout = undefined; - }, 1300); -} - /** * Displays diagnostics in the UI. */ From 3c749b6725ed623260c0276d88d0277974181298 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 8 Jun 2026 00:04:33 +0200 Subject: [PATCH 06/15] Test clipboard content and type with more examples --- tests/pages/powerpoint-page.ts | 24 ++++++++++++++++++++++++ tests/preview.spec.ts | 11 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 4a5fc8e..05e53cd 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -49,6 +49,7 @@ export type OfficeSnapshot = { type OfficeMockWindow = Window & typeof globalThis & { __pptypstOfficeSeed?: OfficeMockSeed; + __pptypstClipboardWriteTypes?: string[]; __pptypstOfficeMock: { reset: (_seed?: OfficeMockSeed) => void; selectShapes: (_slideId: string, _shapeIds: string[]) => Promise; @@ -145,6 +146,29 @@ export class PowerPointPage { return this.page.evaluate(() => navigator.clipboard.readText()); } + async recordClipboardWrites() { + await this.page.evaluate(() => { + const appWindow = window as OfficeMockWindow; + const originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); + Object.defineProperty(navigator.clipboard, "write", { + configurable: true, + value: async (items: ClipboardItem[]) => { + appWindow.__pptypstClipboardWriteTypes = items.flatMap(item => item.types); + const firstItem = items[0]; + const textBlob = await firstItem.getType("text/plain"); + await originalWriteText(await textBlob.text()); + }, + }); + }); + } + + async clipboardWriteTypes(): Promise { + return this.page.evaluate(() => { + const appWindow = window as OfficeMockWindow; + return appWindow.__pptypstClipboardWriteTypes || []; + }); + } + async selectShapes(slideId: string, shapeIds: string[]) { await this.page.evaluate( async ({ selectedSlideId, selectedShapeIds }) => { diff --git a/tests/preview.spec.ts b/tests/preview.spec.ts index 0c0810f..7aa48b4 100644 --- a/tests/preview.spec.ts +++ b/tests/preview.spec.ts @@ -36,9 +36,14 @@ test("copies the preview SVG with optional inverted colors", async ({ powerPoint await powerPointPage.previewExpression("integral_a^b f(x) dif x"); await powerPointPage.expectPreviewVisible(); + await powerPointPage.recordClipboardWrites(); await powerPointPage.copyPreviewSvg(); await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#000000"'); const copiedSvg = await powerPointPage.readClipboardText(); + await expect.poll(() => powerPointPage.clipboardWriteTypes()).toEqual([ + "image/svg+xml", + "text/plain", + ]); expect(copiedSvg).toContain("', '', '', + '', "", ].join("")); @@ -74,6 +80,11 @@ test("copies preview SVGs with alpha fills normalized for compatibility", expect(copiedSvg).toContain('fill="#ff0000"'); expect(copiedSvg).toContain('fill="#0000ff"'); expect(copiedSvg).toContain('fill-opacity="0.19607843137254902"'); + expect(copiedSvg).toContain("fill: rgb(0, 255, 0);"); + expect(copiedSvg).toContain("fill-opacity: 0.5;"); + expect(copiedSvg).toContain("stroke-opacity: 0.5;"); expect(copiedSvg).not.toContain("#ff000032"); expect(copiedSvg).not.toContain("#0000ff32"); + expect(copiedSvg).not.toContain("#00ff0080"); + expect(copiedSvg).not.toContain("#00000080"); }); From 3e574d8097132c7835c94e188e4fdbfa8fdd3049 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 8 Jun 2026 00:10:55 +0200 Subject: [PATCH 07/15] Refactor copy edge cases to avoid complicated TypeScript logic --- web/src/copy.ts | 89 ++++++++++--------------------------------------- 1 file changed, 17 insertions(+), 72 deletions(-) diff --git a/web/src/copy.ts b/web/src/copy.ts index 0c65b05..316b199 100644 --- a/web/src/copy.ts +++ b/web/src/copy.ts @@ -7,22 +7,6 @@ import { serializeSvgForClipboard } from "./svg.js"; import { setStatus } from "./ui.js"; import { getButtonElement, getHTMLElement } from "./utils/dom.js"; -type ClipboardTextWriter = { - writeText: (_text: string) => Promise; -}; - -type ClipboardSvgWriter = ClipboardTextWriter & { - write?: (_items: ClipboardItem[]) => Promise; -}; - -type ClipboardItemConstructorLike = { - new ( - _items: Record>, - _options?: ClipboardItemOptions, - ): ClipboardItem; - supports?: (_type: string) => boolean; -}; - let copyFeedbackTimeout: ReturnType | undefined; let clipboardUnavailable = false; @@ -31,9 +15,8 @@ let clipboardUnavailable = false; */ export function setupPreviewCopyButton() { const previewCopyButton = getPreviewCopyButton(); - const clipboard = getClipboardWriter(); - if (!window.isSecureContext || !clipboard) { + if (!isClipboardAvailable()) { clipboardUnavailable = true; previewCopyButton.hidden = true; return; @@ -85,69 +68,39 @@ async function copyPreviewSvg(invertColors: boolean) { } } +/** + * Writes the SVG as a rich clipboard item so it can be pasted into vector + * editors, falling back to plain text when that is not possible. + */ async function writeSvgToClipboard(svgText: string) { - const clipboard = getClipboardWriter(); - if (!clipboard) { - throw new Error("Clipboard API is not available."); - } - - const ClipboardItemConstructor = getClipboardItemConstructor(); - if (clipboard.write && ClipboardItemConstructor && clipboardItemSupportsSvg(ClipboardItemConstructor)) { + if (hasClipboardItem()) { try { - await clipboard.write([ - new ClipboardItemConstructor({ + await navigator.clipboard.write([ + new ClipboardItem({ "image/svg+xml": new Blob([svgText], { type: "image/svg+xml" }), "text/plain": new Blob([svgText], { type: "text/plain" }), }), ]); return; } catch { - // Some hosts expose rich clipboard APIs but reject SVG items at runtime. + // Some hosts expose ClipboardItem but reject SVG payloads at runtime, + // so we fall back to writing plain text below. } } - await clipboard.writeText(svgText); + await navigator.clipboard.writeText(svgText); } -function getClipboardWriter(): ClipboardSvgWriter | null { - const clipboard = Reflect.get(navigator, "clipboard") as unknown; - if (!isRecord(clipboard)) { - return null; - } - - const writeText = clipboard.writeText; - if (typeof writeText !== "function") { - return null; - } - - const write = clipboard.write; - if (write !== undefined && typeof write !== "function") { - return null; - } - - return { - writeText: async (text) => { - await writeText.call(clipboard, text); - }, - write: typeof write === "function" - ? async (items) => { - await write.call(clipboard, items); - } - : undefined, - }; +function isClipboardAvailable(): boolean { + return window.isSecureContext && isDefined(navigator.clipboard); } -function getClipboardItemConstructor(): ClipboardItemConstructorLike | null { - const ClipboardItemConstructor = Reflect.get(globalThis, "ClipboardItem") as unknown; - return isClipboardItemConstructor(ClipboardItemConstructor) ? ClipboardItemConstructor : null; +function hasClipboardItem(): boolean { + return isDefined(globalThis.ClipboardItem); } -function clipboardItemSupportsSvg(ClipboardItemConstructor: ClipboardItemConstructorLike): boolean { - if (typeof ClipboardItemConstructor.supports !== "function") { - return true; - } - - return ClipboardItemConstructor.supports("image/svg+xml"); +function isDefined(value: unknown): boolean { + return value !== undefined; } function showPreviewCopyFeedback() { @@ -167,11 +120,3 @@ function showPreviewCopyFeedback() { function getPreviewCopyButton(): HTMLButtonElement { return getButtonElement(DOM_IDS.PREVIEW_COPY_BTN); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isClipboardItemConstructor(value: unknown): value is ClipboardItemConstructorLike { - return typeof value === "function"; -} From 60ea8a4f5453af6384e1b2073e61f322226f8d2a Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 20:34:03 +0200 Subject: [PATCH 08/15] Rename attribute: currentColor -> currentColor --- web/powerpoint.html | 20 ++++++++++---------- web/styles/main.css | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/powerpoint.html b/web/powerpoint.html index a661b65..baaa4c8 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -111,9 +111,9 @@
About diff --git a/web/styles/main.css b/web/styles/main.css index 18450d7..c9262cd 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -323,7 +323,7 @@ button { .preview-copy-icon-clipboard { width: 14px; - fill: currentColor; + fill: currentcolor; opacity: 1; transform: translate(-50%, -50%) scale(1); } From 08ffbb2eb628bf575153c25503b01e894901d3b0 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 20:53:55 +0200 Subject: [PATCH 09/15] Handle percentage opacity correctly --- web/src/svg.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/svg.ts b/web/src/svg.ts index aaced54..e739e88 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -341,7 +341,11 @@ function normalizeAlphaColorStyles(el: Element, colorToOpacityAttr: ColorOpacity } function combineOpacity(opacity: string | null, alpha: number): number { - const existingOpacity = parseFloat(opacity || "1"); - const safeOpacity = Number.isFinite(existingOpacity) ? existingOpacity : 1; - return Math.max(0, Math.min(1, safeOpacity * alpha)); + const raw = (opacity || "1").trim(); + const isPercent = raw.endsWith("%"); + const parsed = parseFloat(isPercent ? raw.slice(0, -1) : raw); + const existingOpacity = Number.isFinite(parsed) + ? (isPercent ? parsed / 100 : parsed) + : 1; + return Math.max(0, Math.min(1, existingOpacity * alpha)); } From a2b83a3080da2c031651701a1fca1112e493de67 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 22:31:36 +0200 Subject: [PATCH 10/15] Log copy errors --- web/src/copy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/copy.ts b/web/src/copy.ts index 316b199..23e57ee 100644 --- a/web/src/copy.ts +++ b/web/src/copy.ts @@ -63,7 +63,8 @@ async function copyPreviewSvg(invertColors: boolean) { const svgText = serializeSvgForClipboard(svgElement, invertColors); await writeSvgToClipboard(svgText); showPreviewCopyFeedback(); - } catch { + } catch (error) { + console.warn("Could not copy preview SVG:", error); setStatus("Could not copy preview SVG.", true); } } @@ -82,9 +83,10 @@ async function writeSvgToClipboard(svgText: string) { }), ]); return; - } catch { + } catch (error) { // Some hosts expose ClipboardItem but reject SVG payloads at runtime, // so we fall back to writing plain text below. + console.warn("Rich SVG clipboard write failed, falling back to text:", error); } } From c1be3ce2f54c2e045116bee19561720bc9aa2c6b Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 22:35:22 +0200 Subject: [PATCH 11/15] Don't update preview if raw code has changed during compilation --- web/src/preview.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/src/preview.ts b/web/src/preview.ts index b78d9b6..2662b32 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -155,6 +155,11 @@ export async function updatePreview() { const result = await typst({ body: rawCode, preamble }, fontSize, mathMode); + // A newer keystroke might have arrived while Typst was compiling + if (getTypstCode().trim() !== rawCode) { + return; + } + if (result.diagnostics && result.diagnostics.length > 0) { diagnosticsContainer.style.display = "block"; displayDiagnostics(result.diagnostics, diagnosticsContent); From c19345abc856e829b954244c4328118a71445b90 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 22:38:30 +0200 Subject: [PATCH 12/15] Use cache for inverted SVG colors & refactor SVG color handling --- web/src/svg.ts | 54 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/web/src/svg.ts b/web/src/svg.ts index e739e88..1b7b6b9 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -74,12 +74,13 @@ export function applyFillColor(svg: SVGElement, fillColor: string) { export function serializeSvgForClipboard(svg: SVGElement, invertColors = false): string { const clipboardSvg = svg.cloneNode(true) as SVGElement; removePreviewLayoutStyles(clipboardSvg); - normalizeAlphaHexColors(clipboardSvg); if (invertColors) { invertSvgColors(clipboardSvg); } + normalizeAlphaHexColors(clipboardSvg); + return new XMLSerializer().serializeToString(clipboardSvg); } @@ -102,27 +103,20 @@ function removePreviewLayoutStyles(svg: SVGElement) { } } +const SVG_COLOR_KEYS = [ + "color", + "fill", + "stroke", + "stop-color", + "flood-color", + "lighting-color", +]; + function invertSvgColors(svg: SVGElement) { - const colorAttributes = [ - "color", - "fill", - "stroke", - "stop-color", - "flood-color", - "lighting-color", - ]; - const colorProperties = [ - "color", - "fill", - "stroke", - "stop-color", - "flood-color", - "lighting-color", - ]; const elements: Element[] = [svg, ...Array.from(svg.querySelectorAll("*"))]; elements.forEach((el) => { - colorAttributes.forEach((attribute) => { + SVG_COLOR_KEYS.forEach((attribute) => { const color = el.getAttribute(attribute); const invertedColor = color ? invertCssColor(color) : null; if (invertedColor) { @@ -137,19 +131,34 @@ function invertSvgColors(svg: SVGElement) { const style = document.createElement("span").style; style.cssText = inlineStyle; - colorProperties.forEach((property) => { + const before = style.cssText; + SVG_COLOR_KEYS.forEach((property) => { const color = style.getPropertyValue(property); const invertedColor = color ? invertCssColor(color) : null; if (invertedColor) { style.setProperty(property, invertedColor, style.getPropertyPriority(property)); } }); - el.setAttribute("style", style.cssText); + if (style.cssText !== before) { + el.setAttribute("style", style.cssText); + } }); } +const invertedColorCache = new Map(); + function invertCssColor(color: string): string | null { const value = color.trim(); + const cached = invertedColorCache.get(value); + if (cached !== undefined) { + return cached; + } + const inverted = computeInvertedCssColor(value); + invertedColorCache.set(value, inverted); + return inverted; +} + +function computeInvertedCssColor(value: string): string | null { const normalizedValue = value.toLowerCase(); if (!value || normalizedValue === "none" || normalizedValue.startsWith("url(")) { return null; @@ -320,6 +329,7 @@ function normalizeAlphaColorStyles(el: Element, colorToOpacityAttr: ColorOpacity const style = document.createElement("span").style; style.cssText = inlineStyle; + const before = style.cssText; Object.entries(colorToOpacityAttr).forEach(([colorProperty, opacityProperty]) => { const value = style.getPropertyValue(colorProperty); @@ -337,7 +347,9 @@ function normalizeAlphaColorStyles(el: Element, colorToOpacityAttr: ColorOpacity style.setProperty(opacityProperty, combinedOpacity.toString(), style.getPropertyPriority(opacityProperty)); }); - el.setAttribute("style", style.cssText); + if (style.cssText !== before) { + el.setAttribute("style", style.cssText); + } } function combineOpacity(opacity: string | null, alpha: number): number { From 177e1b4749bb2262982373d56c2a433f9082870f Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 22:49:27 +0200 Subject: [PATCH 13/15] Outsource preview button styling to new CSS file --- web/powerpoint.html | 1 + web/styles/main.css | 73 --------------------------- web/styles/preview-copy-button.css | 81 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 73 deletions(-) create mode 100644 web/styles/preview-copy-button.css diff --git a/web/powerpoint.html b/web/powerpoint.html index baaa4c8..e1eba20 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -8,6 +8,7 @@ + diff --git a/web/styles/main.css b/web/styles/main.css index c9262cd..8def53c 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -8,11 +8,6 @@ --button-bg: #0078d4; --button-hover: #005a9e; --button-disabled: #aaaaaa; - --icon-button-bg: #ffffff; - --icon-button-hover: #f1f1f1; - --icon-button-border: #d5d5d5; - --icon-button-color: #6b6b6b; - --success-color: #2e8b57; --error-color: #b00020; &.dark-mode { @@ -25,11 +20,6 @@ --button-bg: #0078d4; --button-hover: #005a9e; --button-disabled: #555; - --icon-button-bg: #2f2f2f; - --icon-button-hover: #383838; - --icon-button-border: #4a4a4a; - --icon-button-color: #d0d0d0; - --success-color: #67c587; --error-color: #f48771; } } @@ -276,7 +266,6 @@ button { } #previewContainer { - position: relative; margin-top: 10px; border: 1px dashed var(--border-color); padding: 10px; @@ -285,68 +274,6 @@ button { text-align: center; } -.preview-copy-btn { - position: absolute; - top: 6px; - right: 6px; - width: 28px; - height: 28px; - margin: 0; - padding: 0; - color: var(--icon-button-color); - border: 1px solid var(--icon-button-border); - border-radius: 4px; - background-color: var(--icon-button-bg); - cursor: pointer; - opacity: 0.86; - transition: background-color 100ms, border-color 100ms; - - &:hover:not(:disabled) { - background-color: var(--icon-button-hover); - opacity: 1; - } - - &:disabled { - opacity: 0; - cursor: default; - pointer-events: none; - visibility: hidden; - } - - > * { - position: absolute; - top: 50%; - left: 50%; - transition: opacity 220ms ease-in-out, transform 500ms ease-in-out; - } -} - -.preview-copy-icon-clipboard { - width: 14px; - fill: currentcolor; - opacity: 1; - transform: translate(-50%, -50%) scale(1); -} - -.preview-copy-icon-tick { - width: 12px; - fill: var(--success-color); - opacity: 0; - transform: translate(-50%, -50%) scale(0); -} - -.preview-copy-btn.is-copied { - .preview-copy-icon-clipboard { - opacity: 0; - transform: translate(-50%, -50%) scale(0); - } - - .preview-copy-icon-tick { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - #previewLabel { color: #999; font-size: 11px; diff --git a/web/styles/preview-copy-button.css b/web/styles/preview-copy-button.css new file mode 100644 index 0000000..f9afbfc --- /dev/null +++ b/web/styles/preview-copy-button.css @@ -0,0 +1,81 @@ +:root { + --icon-button-bg: #ffffff; + --icon-button-hover: #f1f1f1; + --icon-button-border: #d5d5d5; + --icon-button-color: #6b6b6b; + --success-color: #2e8b57; + + &.dark-mode { + --icon-button-bg: #2f2f2f; + --icon-button-hover: #383838; + --icon-button-border: #4a4a4a; + --icon-button-color: #d0d0d0; + --success-color: #67c587; + } +} + +#previewContainer { + position: relative; +} + +.preview-copy-btn { + position: absolute; + top: 6px; + right: 6px; + width: 28px; + height: 28px; + margin: 0; + padding: 0; + color: var(--icon-button-color); + border: 1px solid var(--icon-button-border); + border-radius: 4px; + background-color: var(--icon-button-bg); + cursor: pointer; + opacity: 0.86; + transition: background-color 100ms, border-color 100ms; + + &:hover:not(:disabled) { + background-color: var(--icon-button-hover); + opacity: 1; + } + + &:disabled { + opacity: 0; + cursor: default; + pointer-events: none; + visibility: hidden; + } + + > * { + position: absolute; + top: 50%; + left: 50%; + transition: opacity 220ms ease-in-out, transform 500ms ease-in-out; + } +} + +.preview-copy-icon-clipboard { + width: 14px; + fill: currentcolor; + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.preview-copy-icon-tick { + width: 12px; + fill: var(--success-color); + opacity: 0; + transform: translate(-50%, -50%) scale(0); +} + +.preview-copy-btn.is-copied { + .preview-copy-icon-clipboard { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + + .preview-copy-icon-tick { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} From 83c016d0f74900052577daef912732885825c627 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 23:16:13 +0200 Subject: [PATCH 14/15] Avoid ugly JS module hacks for testing --- playwright.config.ts | 2 + .../browser-mocks/typst-state-module.d.ts | 27 --------- tests/_support/browser-mocks/typst-state.ts | 49 --------------- tests/_support/browser-mocks/typst.ts | 53 +++++++++++++++-- tests/_support/fixtures.ts | 9 +-- tests/_support/typst-mock.ts | 59 ++++++++----------- 6 files changed, 76 insertions(+), 123 deletions(-) delete mode 100644 tests/_support/browser-mocks/typst-state-module.d.ts delete mode 100644 tests/_support/browser-mocks/typst-state.ts diff --git a/playwright.config.ts b/playwright.config.ts index b0365e8..c9e545c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,6 +26,8 @@ export default defineConfig({ use: { /* Base URL to use in actions like `await page.goto('')`. */ baseURL: testBaseUrl, + /* The preview copy button writes to the clipboard. */ + permissions: ["clipboard-read", "clipboard-write"], }, /* Configure projects for major browsers */ diff --git a/tests/_support/browser-mocks/typst-state-module.d.ts b/tests/_support/browser-mocks/typst-state-module.d.ts deleted file mode 100644 index 4ed51e0..0000000 --- a/tests/_support/browser-mocks/typst-state-module.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -declare module "*__test__/typst-state.js" { - export const typstMockState: { - rendererInitOptions: { hasGetModule: boolean }[]; - addSourceCalls: { path: string; source: string }[]; - compileCalls: { mainFilePath: string }[]; - renderSvgCalls: { - format: string; - artifactContent: number[]; - data_selection: Record; - }[]; - previewSvg: string; - }; - - export function typstMockReady(): boolean; - - export function typstMockCalls(): { - addSourceCalls: { path: string; source: string }[]; - compileCalls: { mainFilePath: string }[]; - renderSvgCalls: { - format: string; - artifactContent: number[]; - data_selection: Record; - }[]; - }; - - export function setTypstMockPreviewSvg(_svg: string): void; -} diff --git a/tests/_support/browser-mocks/typst-state.ts b/tests/_support/browser-mocks/typst-state.ts deleted file mode 100644 index 7cd1d78..0000000 --- a/tests/_support/browser-mocks/typst-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -type AddSourceCall = { path: string; source: string }; -type CompileCall = { mainFilePath: string }; -type RenderSvgCall = { - format: string; - artifactContent: number[]; - data_selection: Record; -}; - -type TypstMockState = { - rendererInitOptions: { hasGetModule: boolean }[]; - addSourceCalls: AddSourceCall[]; - compileCalls: CompileCall[]; - renderSvgCalls: RenderSvgCall[]; - previewSvg: string; -}; - -const defaultPreviewSvg = [ - '', - 'integral preview', - "", -].join(""); - -function freshState(): TypstMockState { - return { - rendererInitOptions: [], - addSourceCalls: [], - compileCalls: [], - renderSvgCalls: [], - previewSvg: defaultPreviewSvg, - }; -} - -export const typstMockState = freshState(); - -export function typstMockReady() { - return typstMockState.rendererInitOptions.length === 1; -} - -export function typstMockCalls() { - return { - addSourceCalls: structuredClone(typstMockState.addSourceCalls), - compileCalls: structuredClone(typstMockState.compileCalls), - renderSvgCalls: structuredClone(typstMockState.renderSvgCalls), - }; -} - -export function setTypstMockPreviewSvg(svg: string) { - typstMockState.previewSvg = svg; -} diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 372de44..691cc63 100644 --- a/tests/_support/browser-mocks/typst.ts +++ b/tests/_support/browser-mocks/typst.ts @@ -1,4 +1,45 @@ -import { typstMockState } from "/pptypst/__test__/typst-state.js"; +/** + * Browser mock for `@myriaddreamin/typst.ts`. + * + * `TypstMock` (see ../typst-mock.ts) serves this in place of the real + * WASM-backed library. Recorded compiler/renderer calls and the SVG the + * renderer returns live on `window.__typstMock` so the Node-side helper can + * read and tweak them via `page.evaluate`. + */ + +export type TypstMockState = { + rendererInitOptions: { hasGetModule: boolean }[]; + addSourceCalls: { path: string; source: string }[]; + compileCalls: { mainFilePath: string }[]; + renderSvgCalls: { + format: string; + artifactContent: number[]; + data_selection: Record; + }[]; + previewSvg: string; +}; + +declare global { + interface Window { + __typstMock?: TypstMockState; + } +} + +const DEFAULT_PREVIEW_SVG = [ + '', + 'integral preview', + "", +].join(""); + +const state: TypstMockState = { + rendererInitOptions: [], + addSourceCalls: [], + compileCalls: [], + renderSvgCalls: [], + previewSvg: DEFAULT_PREVIEW_SVG, +}; + +window.__typstMock = state; type CompilerInitOptions = { beforeBuild: unknown[]; getModule: unknown }; type CompileOptions = { mainFilePath: string }; @@ -19,10 +60,10 @@ export function createTypstCompiler() { return Promise.resolve(); }, addSource(path: string, source: string) { - typstMockState.addSourceCalls.push({ path, source }); + state.addSourceCalls.push({ path, source }); }, compile(options: CompileOptions) { - typstMockState.compileCalls.push(options); + state.compileCalls.push(options); return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) }); }, }; @@ -31,16 +72,16 @@ export function createTypstCompiler() { export function createTypstRenderer() { return { init(options: { getModule: unknown }) { - typstMockState.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" }); + state.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" }); return Promise.resolve(); }, renderSvg(options: RenderSvgOptions) { - typstMockState.renderSvgCalls.push({ + state.renderSvgCalls.push({ format: options.format, artifactContent: Array.from(options.artifactContent), data_selection: options.data_selection, }); - return Promise.resolve(typstMockState.previewSvg); + return Promise.resolve(state.previewSvg); }, }; } diff --git a/tests/_support/fixtures.ts b/tests/_support/fixtures.ts index 315a166..5dd9a37 100644 --- a/tests/_support/fixtures.ts +++ b/tests/_support/fixtures.ts @@ -22,14 +22,7 @@ export { expect }; const extendedTest = base.extend({ // https://playwright.dev/docs/test-fixtures#adding-global-beforeeachaftereach-hooks _forEachTest: [ - async ({ baseURL, context, page }, use) => { - if (baseURL) { - await context.grantPermissions( - ["clipboard-read", "clipboard-write"], - { origin: new URL(baseURL).origin }, - ); - } - + async ({ page }, use) => { await page.addInitScript(() => { window.localStorage.clear(); }); diff --git a/tests/_support/typst-mock.ts b/tests/_support/typst-mock.ts index f0decd0..45ecec5 100644 --- a/tests/_support/typst-mock.ts +++ b/tests/_support/typst-mock.ts @@ -1,18 +1,12 @@ import type { Page } from "@playwright/test"; import path from "node:path"; import { compileBrowserMock } from "./transpile-browser-mock"; +import type { TypstMockState } from "./browser-mocks/typst"; -export type TypstMockCalls = { - addSourceCalls: { path: string; source: string }[]; - compileCalls: { mainFilePath: string }[]; - renderSvgCalls: { - format: string; - artifactContent: number[]; - data_selection: Record; - }[]; -}; - -const stateModuleUrl = "/pptypst/__test__/typst-state.js"; +export type TypstMockCalls = Pick< + TypstMockState, + "addSourceCalls" | "compileCalls" | "renderSvgCalls" +>; function browserMockPath(fileName: string) { return path.join(process.cwd(), "tests", "_support", "browser-mocks", fileName); @@ -28,7 +22,6 @@ export class TypstMock { /** Routes only the Typst modules that web/src/typst.ts and font-cache.ts import. */ async install() { - await this.routeModule("**/__test__/typst-state.js", "typst-state.ts"); await this.routeModule("**/@myriaddreamin_typst__ts.js*", "typst.ts"); await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_options__init__mjs.js*", "typst-options.ts"); await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_fs_package__node__mjs.js*", "typst-package-registry.ts"); @@ -37,34 +30,34 @@ export class TypstMock { await this.routeModule("**/typst_ts_renderer_bg.wasm?*", "typst-wasm-url.ts"); } - /** Waits for the mocked renderer init call, which means the Typst wrapper initialized. */ + /** Resolves once the app has initialized the mocked renderer. */ async waitUntilReady() { - await this.page.waitForFunction(async (moduleUrl) => { - const stateModule = await import(moduleUrl) as { - typstMockReady: () => boolean; - }; - return stateModule.typstMockReady(); - }, stateModuleUrl); + await this.page.waitForFunction( + () => window.__typstMock?.rendererInitOptions.length === 1, + ); } - /** Returns the Typst compiler and renderer calls recorded in the browser. */ + /** Snapshot of the compiler/renderer calls recorded so far. */ async calls(): Promise { - return this.page.evaluate(async (moduleUrl) => { - const stateModule = await import(moduleUrl) as { - typstMockCalls: () => TypstMockCalls; - }; - return stateModule.typstMockCalls(); - }, stateModuleUrl); + return this.page.evaluate(() => { + const state = window.__typstMock; + if (!state) { + throw new Error("Typst mock has not been initialized yet."); + } + const { addSourceCalls, compileCalls, renderSvgCalls } = state; + return { addSourceCalls, compileCalls, renderSvgCalls }; + }); } - /** Overrides the SVG that the mocked renderer returns for subsequent preview renders. */ + /** Overrides the SVG the mocked renderer returns for subsequent preview renders. */ async setPreviewSvg(svg: string) { - await this.page.evaluate(async ({ moduleUrl, previewSvg }) => { - const stateModule = await import(moduleUrl) as { - setTypstMockPreviewSvg: (_svg: string) => void; - }; - stateModule.setTypstMockPreviewSvg(previewSvg); - }, { moduleUrl: stateModuleUrl, previewSvg: svg }); + await this.page.evaluate((value) => { + const state = window.__typstMock; + if (!state) { + throw new Error("Typst mock has not been initialized yet."); + } + state.previewSvg = value; + }, svg); } private async routeModule(url: string, fileName: string) { From b491c8df0706f036546d3bf6e8a239bd54e54ac1 Mon Sep 17 00:00:00 2001 From: Splines Date: Mon, 31 Aug 2026 23:41:16 +0200 Subject: [PATCH 15/15] Add global counter to request id --- web/src/preview.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/preview.ts b/web/src/preview.ts index 2662b32..1da8511 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -134,10 +134,15 @@ export function updateMathModeVisuals() { } } +// Bumped on every updatePreview() call so a slow compilation cannot overwrite +// the preview or copy state produced by a newer one. +let latestPreviewRequestId = 0; + /** * Updates the preview panel with compiled SVG. */ export async function updatePreview() { + const requestId = ++latestPreviewRequestId; const rawCode = getTypstCode().trim(); const preamble = getPreambleCode(); const fontSize = getFontSize(); @@ -155,8 +160,7 @@ export async function updatePreview() { const result = await typst({ body: rawCode, preamble }, fontSize, mathMode); - // A newer keystroke might have arrived while Typst was compiling - if (getTypstCode().trim() !== rawCode) { + if (requestId !== latestPreviewRequestId) { return; }