Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
14 changes: 14 additions & 0 deletions tests/pages/powerpoint-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -131,6 +135,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<string> {
return this.page.evaluate(() => navigator.clipboard.readText());
}

async selectShapes(slideId: string, shapeIds: string[]) {
await this.page.evaluate(
async ({ selectedSlideId, selectedShapeIds }) => {
Expand Down
46 changes: 46 additions & 0 deletions tests/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,49 @@ 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("<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"/>',
"</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).not.toContain("#ff000032");
expect(copiedSvg).not.toContain("#0000ff32");
});
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
69 changes: 68 additions & 1 deletion web/src/preview.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -9,11 +9,14 @@ import {
getEditorMode,
getMathModeEnabled,
getTypstCode,
setStatus,
setButtonEnabled,
setMathModeEnabled,
} from "./ui";
import { storeValue, getStoredValue } from "./utils/storage.js";

let copyFeedbackTimeout: ReturnType<typeof setTimeout> | undefined;

/**
* Sets up event listeners for preview updates.
*/
Expand All @@ -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);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

typstInput.addEventListener("input", () => {
updateButtonState();
Expand Down Expand Up @@ -75,6 +88,7 @@ export function setupPreviewListeners() {

syncPreviewFillToggleState(fillColorEnabled.checked);
updateMathModeVisuals();
setPreviewCopyButtonEnabled(false);
}

/**
Expand Down Expand Up @@ -144,6 +158,7 @@ export async function updatePreview() {

if (!rawCode) {
previewElement.innerHTML = "";
setPreviewCopyButtonEnabled(false);
diagnosticsContainer.style.display = "none";
return;
}
Expand All @@ -159,6 +174,7 @@ export async function updatePreview() {

if (!result.svg) {
previewElement.innerHTML = "";
setPreviewCopyButtonEnabled(false);
return;
}

Expand All @@ -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;
Expand All @@ -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.
*/
Expand Down
Loading
Loading