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/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/_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 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..a661b65 100644 --- a/web/powerpoint.html +++ b/web/powerpoint.html @@ -46,6 +46,24 @@
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/copy.ts b/web/src/copy.ts new file mode 100644 index 0000000..316b199 --- /dev/null +++ b/web/src/copy.ts @@ -0,0 +1,122 @@ +/** + * 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 { + 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 { + // Some hosts expose ClipboardItem but reject SVG payloads at runtime, + // so we fall back to writing plain text below. + } + } + + 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..b78d9b6 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); } /** @@ -144,6 +148,7 @@ export async function updatePreview() { if (!rawCode) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); diagnosticsContainer.style.display = "none"; return; } @@ -159,6 +164,7 @@ export async function updatePreview() { if (!result.svg) { previewElement.innerHTML = ""; + setPreviewCopyButtonEnabled(false); return; } @@ -172,6 +178,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..aaced54 100644 --- a/web/src/svg.ts +++ b/web/src/svg.ts @@ -68,11 +68,156 @@ 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); + normalizeAlphaHexColors(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; }; +type ColorOpacityAttributes = Record; + /** * Parses #RGBA or #RRGGBBAA colors into RGB + alpha. */ @@ -106,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. * @@ -113,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", @@ -122,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)); } 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;