Skip to content
Closed
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
106 changes: 53 additions & 53 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

640 changes: 320 additions & 320 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

104 changes: 96 additions & 8 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,105 @@ export async function renderKatexHTML(content: string) {
return katex.renderToString(content.trim(), { displayMode: true, throwOnError: false });
}

const cssZoomGeneration = new WeakMap<HTMLElement, number>();

function cssZoomScale(zoom: string): number | undefined {
if (zoom === '') {
return undefined;
}

const scale = Number(zoom);
if (!Number.isFinite(scale) || scale <= 0 || scale === 1) {
return undefined;
}

return scale;
}

function bumpCssZoomGeneration(element: HTMLElement): number {
const next = (cssZoomGeneration.get(element) ?? 0) + 1;
cssZoomGeneration.set(element, next);
return next;
}

/**
* Cancel a zoom restore started by `suspendCssZoom`.
* Call this before a user zoom write so an in-flight Mermaid render cannot put the old zoom back.
*/
export function invalidateCssZoomSuspend(element: HTMLElement): void {
bumpCssZoomGeneration(element);
}

/**
* Measure layout as if `element.style.zoom` were 1.
*
* `getBoundingClientRect()` includes CSS zoom. Mermaid copies that rect onto
* `<foreignObject>` width and height, then draws glyphs in SVG user units, so a
* pane zoom of 0.8 stores a label box 0.8× the text and clips the last glyph.
* The returned function restores the previous zoom unless a later suspend or
* `invalidateCssZoomSuspend` took ownership.
*/
export function suspendCssZoom(element: HTMLElement): () => void {
const zoom = element.style.zoom;
if (cssZoomScale(zoom) === undefined) {
return () => {};
}

const token = bumpCssZoomGeneration(element);
element.style.zoom = '1';
void element.getBoundingClientRect();

return () => {
if (cssZoomGeneration.get(element) !== token) {
return;
}

element.style.zoom = zoom;
};
}

function suspendZoomAroundMermaid(): Array<() => void> {
const panes = new Set<HTMLElement>();
for (const node of document.querySelectorAll('.mermaid')) {
let element = node.parentElement;
while (element !== null) {
if (cssZoomScale(element.style.zoom) !== undefined) {
panes.add(element);
}
element = element.parentElement;
}
}

return [...panes].map(pane => suspendCssZoom(pane));
}

export function handlePostRender(process: () => void) {
if (__FULL_BUILD__) {
loadMermaid().then(mermaid => {
mermaid.run({
querySelector: '.mermaid',
postRenderCallback: process,
});
});
} else {
if (!__FULL_BUILD__) {
process();
return;
}

// Drop ancestor zoom before mermaid.run() reads label boxes, then put it back
// before the callback so scroll sync sees the zoom the user left in place.
const restoreZoom = suspendZoomAroundMermaid();
void (async () => {
let rendered = false;
try {
const mermaid = await loadMermaid();
await mermaid.run({ querySelector: '.mermaid' });
rendered = true;
} catch (error) {
console.error(error);
} finally {
for (const restore of restoreZoom) {
restore();
}
}

if (rendered) {
process();
}
})();
}

export async function applyStyles(html: string) {
Expand Down
4 changes: 3 additions & 1 deletion src/view.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Annotation } from '@codemirror/state';
import { MarkEdit } from 'markedit-api';
import { appendStyle, getBlockRange, getFileExtension, getFileName, joinPaths, selectFullRange, writeClipboard, htmlToPlainText } from './shared/utils';
import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles } from './render';
import { renderMarkdown, renderMermaid, renderKatex, handlePostRender, applyStyles, invalidateCssZoomSuspend } from './render';
import { replaceImageURLs } from './features/image';
import { hidePreviewButtons, viewModes } from './support/settings';
import { localized } from './shared/strings';
Expand Down Expand Up @@ -349,6 +349,8 @@ function updateGutterStyle() {
}

function setPageZoom(value: string) {
// A zoom key during an in-flight diagram render must win over the restore.
invalidateCssZoomSuspend(previewPane);
previewPane.style.zoom = value;

// Zooming in narrows the layout width,
Expand Down
112 changes: 112 additions & 0 deletions tests/mermaidZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, test, vi } from 'vitest';

const mermaidRun = vi.fn(async () => {});

vi.mock('mermaid', () => ({
default: {
initialize: vi.fn(),
render: vi.fn(async () => ({ svg: '<svg></svg>' })),
run: mermaidRun,
},
}));

import { handlePostRender, invalidateCssZoomSuspend, suspendCssZoom } from '../src/render';

beforeEach(() => {
mermaidRun.mockReset();
mermaidRun.mockResolvedValue(undefined);
document.body.innerHTML = '';
});

describe('suspendCssZoom', () => {
test('restores a non-1 zoom after measurement', () => {
const pane = document.createElement('div');
pane.style.zoom = '0.8';
const restore = suspendCssZoom(pane);

expect(pane.style.zoom).toBe('1');
restore();
expect(pane.style.zoom).toBe('0.8');
});

test.each(['', '1'])('leaves zoom %j untouched', zoom => {
const pane = document.createElement('div');
pane.style.zoom = zoom;
const restore = suspendCssZoom(pane);

expect(pane.style.zoom).toBe(zoom);
restore();
expect(pane.style.zoom).toBe(zoom);
});

test('a later zoom write wins over the restore', () => {
const pane = document.createElement('div');
pane.style.zoom = '0.8';
const restore = suspendCssZoom(pane);

invalidateCssZoomSuspend(pane);
pane.style.zoom = '1';
restore();
expect(pane.style.zoom).toBe('1');
});
});

function previewPane(): HTMLElement {
const pane = document.querySelector('.markdown-body');
if (!(pane instanceof HTMLElement)) {
throw new Error('missing preview pane');
}
return pane;
}

describe('handlePostRender', () => {
test('runs Mermaid at zoom 1 and restores the pane afterwards', async () => {
document.body.innerHTML = '<div class="markdown-body" style="zoom: 0.8"><div class="mermaid">flowchart TB\nA[preview]</div></div>';
const pane = previewPane();
let zoomDuringRun = '';
mermaidRun.mockImplementation(async () => {
zoomDuringRun = pane.style.zoom;
});

let finished = false;
handlePostRender(() => {
finished = true;
});

await vi.waitUntil(() => finished);
expect(zoomDuringRun).toBe('1');
expect(pane.style.zoom).toBe('0.8');
expect(mermaidRun).toHaveBeenCalledWith({ querySelector: '.mermaid' });
});

test('keeps a zoom change that lands while Mermaid is running', async () => {
document.body.innerHTML = '<div class="markdown-body" style="zoom: 0.8"><div class="mermaid">flowchart TB\nA[preview]</div></div>';
const pane = previewPane();
mermaidRun.mockImplementation(async () => {
invalidateCssZoomSuspend(pane);
pane.style.zoom = '1';
});

let finished = false;
handlePostRender(() => {
finished = true;
});

await vi.waitUntil(() => finished);
expect(pane.style.zoom).toBe('1');
});

test('does not change zoom when the preview has no diagram', async () => {
document.body.innerHTML = '<div class="markdown-body" style="zoom: 0.8"><p>No diagram</p></div>';
const pane = previewPane();

let finished = false;
handlePostRender(() => {
finished = true;
});

await vi.waitUntil(() => finished);
expect(pane.style.zoom).toBe('0.8');
});
});