Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tests/_support/browser-mocks/typst-state-module.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ declare module "*__test__/typst-state.js" {
artifactContent: number[];
data_selection: Record<string, boolean>;
}[];
previewSvg: string;
};

export function typstMockReady(): boolean;
Expand All @@ -21,4 +22,6 @@ declare module "*__test__/typst-state.js" {
data_selection: Record<string, boolean>;
}[];
};

export function setTypstMockPreviewSvg(_svg: string): void;
}
12 changes: 12 additions & 0 deletions tests/_support/browser-mocks/typst-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ type TypstMockState = {
addSourceCalls: AddSourceCall[];
compileCalls: CompileCall[];
renderSvgCalls: RenderSvgCall[];
previewSvg: string;
};

const defaultPreviewSvg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
'<text x="0" y="20" fill="#000">integral preview</text>',
"</svg>",
].join("");

function freshState(): TypstMockState {
return {
rendererInitOptions: [],
addSourceCalls: [],
compileCalls: [],
renderSvgCalls: [],
previewSvg: defaultPreviewSvg,
};
}

Expand All @@ -35,3 +43,7 @@ export function typstMockCalls() {
renderSvgCalls: structuredClone(typstMockState.renderSvgCalls),
};
}

export function setTypstMockPreviewSvg(svg: string) {
typstMockState.previewSvg = svg;
}
8 changes: 1 addition & 7 deletions tests/_support/browser-mocks/typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ type RenderSvgOptions = {
data_selection: Record<string, boolean>;
};

const previewSvg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
'<text x="0" y="20" fill="#000">integral preview</text>',
"</svg>",
].join("");

export function createTypstCompiler() {
return {
init(options: CompilerInitOptions) {
Expand Down Expand Up @@ -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);
},
};
}
9 changes: 8 additions & 1 deletion tests/_support/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export { expect };
const extendedTest = base.extend<PptypstFixtures>({
// 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();
});
Expand Down
10 changes: 10 additions & 0 deletions tests/_support/typst-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
38 changes: 38 additions & 0 deletions tests/pages/powerpoint-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down Expand Up @@ -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();
}
Expand All @@ -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<string> {
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<string[]> {
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 }) => {
Expand Down
57 changes: 57 additions & 0 deletions tests/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<svg");
expect(copiedSvg).toContain("integral preview");
expect(copiedSvg).toContain('fill="#000000"');
expect(copiedSvg).not.toContain('style="width: 100%');

await powerPointPage.copyPreviewSvg({ invertColors: true });
await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#ffffff"');
const invertedSvg = await powerPointPage.readClipboardText();
expect(invertedSvg).toContain("integral preview");
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([
'<svg xmlns="http://www.w3.org/2000/svg" width="196.39001" height="93.14115">',
'<path fill="#ff000032" stroke="#000" d="M 0 0 L 10 0 L 10 10 Z"/>',
'<path fill="#0000ff32" stroke="#000" d="M 20 0 L 30 0 L 30 10 Z"/>',
'<path style="fill: #00ff0080; stroke: #00000080" d="M 40 0 L 50 0 L 50 10 Z"/>',
"</svg>",
].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");
});
18 changes: 18 additions & 0 deletions web/powerpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@

<div id="previewContainer">
<span id="previewLabel">Preview</span>
<button
id="previewCopyBtn"
type="button"
class="preview-copy-btn"
aria-label="Copy preview SVG"
disabled
hidden
title="Copy preview SVG (shift-click to copy with inverted colors)"
>
<!-- noun-copy-7535222 -->
<svg class="preview-copy-icon-clipboard" version="1.1" viewBox="280 280 660 640" xmlns="http://www.w3.org/2000/svg">
<path d="m439.69 279.61c-24.469 0-45.984 13.875-56.672 34.312-4.6406 8.7656-1.2656 19.641 7.5 24.281 8.8125 4.5938 19.641 1.2188 24.281-7.5469 4.6875-8.9062 13.875-14.953 24.891-14.953h416.63c15.844 0 28.078 12.141 28.078 27.984v416.68c0 10.547-5.625 19.359-13.875 24.188h-0.046876c-8.5781 5.0156-11.484 16.031-6.4688 24.609 2.3906 4.125 6.375 7.125 10.969 8.3438 4.6406 1.2188 9.5625 0.5625 13.688-1.875 19.031-11.109 31.734-31.828 31.734-55.266v-416.68c0-35.156-28.875-64.078-64.078-64.078zm-82.547 96c-42.609 0-77.531 34.922-77.531 77.531v389.72c0 42.609 34.922 77.438 77.531 77.438h389.72c42.609 0 77.438-34.828 77.438-77.438v-389.72c0-42.609-34.828-77.531-77.438-77.531zm0 36.047h389.72c23.297 0 41.484 18.188 41.484 41.484v389.72c0 23.297-18.188 41.484-41.484 41.484h-389.72c-23.297 0-41.484-18.188-41.484-41.484v-389.72c0-23.297 18.188-41.484 41.484-41.484z" />
</svg>
<!-- noun-tick-1296920 -->
<svg class="preview-copy-icon-tick" version="1.1" viewBox="444 486 300 252" xmlns="http://www.w3.org/2000/svg">
<path d="m474.31 616.87c-7.2539-6.7969-18.645-6.4297-25.445 0.82031-6.7969 7.2539-6.4297 18.645 0.82031 25.445l96 90c7.7305 7.2461 20.016 6.2812 26.52-2.082l168-216c6.1055-7.8477 4.6914-19.156-3.1562-25.258-7.8477-6.1055-19.156-4.6914-25.258 3.1562l-155.88 200.42z" />
</svg>
</button>
<div id="previewContent"></div>
</div>

Expand Down
1 change: 1 addition & 0 deletions web/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
122 changes: 122 additions & 0 deletions web/src/copy.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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);
}
Loading