From 0a3793922eae1cf46101909b7724568c83cbd96e Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 23:05:41 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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"; -}