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 134a919..0000000 --- a/tests/_support/browser-mocks/typst-state-module.d.ts +++ /dev/null @@ -1,24 +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; - }[]; - }; - - export function typstMockReady(): boolean; - - export function typstMockCalls(): { - addSourceCalls: { path: string; source: string }[]; - compileCalls: { mainFilePath: string }[]; - renderSvgCalls: { - format: string; - artifactContent: number[]; - data_selection: Record; - }[]; - }; -} diff --git a/tests/_support/browser-mocks/typst-state.ts b/tests/_support/browser-mocks/typst-state.ts deleted file mode 100644 index 5f53dde..0000000 --- a/tests/_support/browser-mocks/typst-state.ts +++ /dev/null @@ -1,37 +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[]; -}; - -function freshState(): TypstMockState { - return { - rendererInitOptions: [], - addSourceCalls: [], - compileCalls: [], - renderSvgCalls: [], - }; -} - -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), - }; -} diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 68578be..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 }; @@ -8,12 +49,6 @@ type RenderSvgOptions = { data_selection: Record; }; -const previewSvg = [ - '', - 'integral preview', - "", -].join(""); - export function createTypstCompiler() { return { init(options: CompilerInitOptions) { @@ -25,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]) }); }, }; @@ -37,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(previewSvg); + return Promise.resolve(state.previewSvg); }, }; } diff --git a/tests/_support/typst-mock.ts b/tests/_support/typst-mock.ts index c51e26e..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,24 +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 the mocked renderer returns for subsequent preview renders. */ + async setPreviewSvg(svg: string) { + 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) { diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index 6d154d2..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; @@ -123,6 +124,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(); } @@ -131,6 +136,39 @@ 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 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 25f59af..7aa48b4 100644 --- a/tests/preview.spec.ts +++ b/tests/preview.spec.ts @@ -31,3 +31,60 @@ 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.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(" { + 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).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"); + }); diff --git a/web/powerpoint.html b/web/powerpoint.html index 7fd72b3..e1eba20 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -8,6 +8,7 @@ + @@ -46,6 +47,24 @@
Preview +
@@ -93,9 +112,9 @@
About 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/copy.ts b/web/src/copy.ts new file mode 100644 index 0000000..23e57ee --- /dev/null +++ b/web/src/copy.ts @@ -0,0 +1,124 @@ +/** + * 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"; + +let copyFeedbackTimeout: ReturnType | undefined; +let clipboardUnavailable = false; + +/** + * Sets up the preview SVG copy button and its clipboard behavior. + */ +export function setupPreviewCopyButton() { + const previewCopyButton = getPreviewCopyButton(); + + if (!isClipboardAvailable()) { + 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 (error) { + console.warn("Could not copy preview SVG:", error); + setStatus("Could not copy preview SVG.", true); + } +} + +/** + * 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) { + if (hasClipboardItem()) { + try { + 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 (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); + } + } + + await navigator.clipboard.writeText(svgText); +} + +function isClipboardAvailable(): boolean { + return window.isSecureContext && isDefined(navigator.clipboard); +} + +function hasClipboardItem(): boolean { + return isDefined(globalThis.ClipboardItem); +} + +function isDefined(value: unknown): boolean { + return value !== undefined; +} + +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); +} diff --git a/web/src/preview.ts b/web/src/preview.ts index 98ac8a7..1da8511 100644 --- a/web/src/preview.ts +++ b/web/src/preview.ts @@ -2,6 +2,7 @@ import { DiagnosticMessage, typst } from "./typst.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, @@ -26,6 +27,8 @@ export function setupPreviewListeners() { const previewFillEnabled = getInputElement(DOM_IDS.PREVIEW_FILL_ENABLED); const mathModeEnabled = getInputElement(DOM_IDS.MATH_MODE_ENABLED); + setupPreviewCopyButton(); + typstInput.addEventListener("input", () => { updateButtonState(); void updatePreview(); @@ -75,6 +78,7 @@ export function setupPreviewListeners() { syncPreviewFillToggleState(fillColorEnabled.checked); updateMathModeVisuals(); + setPreviewCopyButtonEnabled(false); } /** @@ -130,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(); @@ -144,12 +153,17 @@ export async function updatePreview() { if (!rawCode) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); diagnosticsContainer.style.display = "none"; return; } const result = await typst({ body: rawCode, preamble }, fontSize, mathMode); + if (requestId !== latestPreviewRequestId) { + return; + } + if (result.diagnostics && result.diagnostics.length > 0) { diagnosticsContainer.style.display = "block"; displayDiagnostics(result.diagnostics, diagnosticsContent); @@ -159,6 +173,7 @@ export async function updatePreview() { if (!result.svg) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); return; } @@ -172,6 +187,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; diff --git a/web/src/svg.ts b/web/src/svg.ts index 7063f8d..1b7b6b9 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -68,11 +68,165 @@ 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); + } + + normalizeAlphaHexColors(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"); + } +} + +const SVG_COLOR_KEYS = [ + "color", + "fill", + "stroke", + "stop-color", + "flood-color", + "lighting-color", +]; + +function invertSvgColors(svg: SVGElement) { + const elements: Element[] = [svg, ...Array.from(svg.querySelectorAll("*"))]; + + elements.forEach((el) => { + SVG_COLOR_KEYS.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; + 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)); + } + }); + 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; + } + + 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; }; +type ColorOpacityAttributes = Record; + /** * Parses #RGBA or #RRGGBBAA colors into RGB + alpha. */ @@ -106,6 +260,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. * @@ -113,7 +290,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", @@ -122,22 +299,65 @@ 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; + } + + const parsed = parseColorWithAlpha(value); + if (!parsed) { + return; + } - el.setAttribute(colorAttr, parsed.rgbHex); + el.setAttribute(colorAttr, parsed.rgbHex); + const combinedOpacity = combineOpacity(el.getAttribute(opacityAttr), parsed.alpha); + el.setAttribute(opacityAttr, combinedOpacity.toString()); +} - 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()); - }); +function normalizeAlphaColorStyles(el: Element, colorToOpacityAttr: ColorOpacityAttributes) { + const inlineStyle = el.getAttribute("style"); + if (!inlineStyle) { + return; + } + + const style = document.createElement("span").style; + style.cssText = inlineStyle; + const before = style.cssText; + + 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)); }); + + if (style.cssText !== before) { + el.setAttribute("style", style.cssText); + } +} + +function combineOpacity(opacity: string | null, alpha: number): number { + 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)); } 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); + } +}