Skip to content
Merged
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
8 changes: 4 additions & 4 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

12,780 changes: 6,390 additions & 6,390 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions src/features/mermaid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export async function loadMermaid() {
const mermaid = await (mermaidAPI ??= importMermaid());
const isDarkMode = matchMedia('(prefers-color-scheme: dark)').matches;
if (isDarkMode !== mermaidDarkMode) {
mermaid.initialize({ theme: isDarkMode ? 'dark' : undefined });
mermaidDarkMode = isDarkMode;
}

return mermaid;
}

export function renderMermaidDiagrams(container: HTMLElement, process: () => void) {
const nodes = [...container.querySelectorAll<HTMLElement>('.mermaid')]
.filter(node => !node.getAttribute('data-processed'));
if (nodes.length === 0) {
return;
}

// Mermaid measures HTML labels in viewport pixels. Keep its layout outside
// the zoomed preview, but retain the markdown styles and available width.
const staging = document.createElement('div');
staging.className = 'markdown-body';
staging.setAttribute('aria-hidden', 'true');
staging.style.cssText = 'position: fixed; inset: 0 0 auto; visibility: hidden; pointer-events: none; display: block; padding: 0; margin: 0; zoom: 1;';

const diagrams = nodes.map(node => {
const diagram = node.cloneNode(true) as HTMLElement;
const layout = document.createElement('div');
layout.style.width = node.clientWidth > 0 ? `${node.clientWidth}px` : '100%';
layout.appendChild(diagram);
staging.appendChild(layout);
node.setAttribute('data-processed', 'true');
return diagram;
});

document.body.appendChild(staging);
void loadMermaid().then(mermaid => mermaid.run({ nodes: diagrams })).catch(error => {
console.error(error);
}).finally(() => {
let updated = false;
nodes.forEach((node, index) => {
if (container.isConnected && container.contains(node)) {
// Move the rendered element, including Mermaid's event listeners and
// any error diagram, rather than serializing it again.
node.replaceWith(diagrams[index]);
updated = true;
}
});

staging.remove();
if (updated) {
process();
}
});
}

// MARK: - Internal functions

const importMermaid = __FULL_BUILD__
? () => import('mermaid').then(mod => mod.default)
: async () => ({
initialize: () => {},
render: async () => ({ svg: '' }),
run: async ({ postRenderCallback }: { postRenderCallback?: () => void }) => postRenderCallback?.(),
});

let mermaidAPI: ReturnType<typeof importMermaid> | undefined;
let mermaidDarkMode: boolean | undefined;
2 changes: 1 addition & 1 deletion src/quicklook/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function createRenderer(previewPane: HTMLElement): {
anchor.removeAttribute('target');
});

handlePostRender(() => {});
handlePostRender(() => {}, previewPane);
rendered = true;
} catch (error) {
prerender = undefined;
Expand Down
31 changes: 3 additions & 28 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import githubAlerts from 'markdown-it-github-alerts';

import { MarkEdit } from 'markedit-api';
import { createFrontMatterPlugin } from './features/frontMatter';
import { loadMermaid, renderMermaidDiagrams } from './features/mermaid';
import { coreCss, previewThemeCss, alertsCss, hljsCss, codeCopyCss } from './styling';
import { localized } from './shared/strings';
import { hasFullHost } from './support/host';
Expand Down Expand Up @@ -98,14 +99,9 @@ export async function renderKatexHTML(content: string) {
return katex.renderToString(content.trim(), { displayMode: true, throwOnError: false });
}

export function handlePostRender(process: () => void) {
export function handlePostRender(process: () => void, container: HTMLElement) {
if (__FULL_BUILD__) {
loadMermaid().then(mermaid => {
mermaid.run({
querySelector: '.mermaid',
postRenderCallback: process,
});
});
renderMermaidDiagrams(container, process);
} else {
process();
}
Expand Down Expand Up @@ -168,29 +164,8 @@ const importKatex = __FULL_BUILD__
? () => import('katex').then(mod => mod.default)
: async () => ({ renderToString: (..._args: unknown[]) => '' });

const importMermaid = __FULL_BUILD__
? () => import('mermaid').then(mod => mod.default)
: async () => ({
initialize: () => {},
render: async () => ({ svg: '' }),
run: async ({ postRenderCallback }: { postRenderCallback?: () => void }) => postRenderCallback?.(),
});

let mermaidAPI: ReturnType<typeof importMermaid> | undefined;
let mermaidDarkMode: boolean | undefined;
let mermaidRenderID = 0;

async function loadMermaid() {
const mermaid = await (mermaidAPI ??= importMermaid());
const isDarkMode = matchMedia('(prefers-color-scheme: dark)').matches;
if (isDarkMode !== mermaidDarkMode) {
mermaid.initialize({ theme: isDarkMode ? 'dark' : undefined });
mermaidDarkMode = isDarkMode;
}

return mermaid;
}

// Create the markdown-it instance
const mdit = markdownit(markdownItPreset, {
html: true,
Expand Down
2 changes: 1 addition & 1 deletion src/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function scrollToProgress(container: HTMLElement, line: number, progress: number
return scrollToPosition(container, 0, animated);
}

const allBlocks = Array.from(document.querySelectorAll<HTMLElement>('[data-line-from]'));
const allBlocks = Array.from(container.querySelectorAll<HTMLElement>('[data-line-from]'));
const bestBlock = proposeTargetBlock(allBlocks, line);

// The best result, we can scroll to a block with relative progress
Expand Down
2 changes: 1 addition & 1 deletion src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export async function renderHtmlPreview(syncScroll = currentViewMode() !== ViewM
previewPane.scrollTo(offset);
}

handlePostRender(alignPosition);
handlePostRender(alignPosition, previewPane);
}

export function handlePageZoom(event: KeyboardEvent) {
Expand Down
19 changes: 12 additions & 7 deletions tests/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ describe('build variants', () => {
});

test('uses no-op renderers in lite builds', async () => {
const source = readFileSync(new URL('../src/render.ts', import.meta.url), 'utf8');
const transformRenderer = (fullBuild: boolean) => transform(source, {
loader: 'ts',
define: { __FULL_BUILD__: String(fullBuild) },
treeShaking: true,
minifySyntax: true,
});
const sources = ['../src/render.ts', '../src/features/mermaid.ts']
.map(path => readFileSync(new URL(path, import.meta.url), 'utf8'));
const transformRenderer = async (fullBuild: boolean) => {
const results = await Promise.all(sources.map(source => transform(source, {
loader: 'ts',
define: { __FULL_BUILD__: String(fullBuild) },
treeShaking: true,
minifySyntax: true,
})));

return { code: results.map(result => result.code).join('\n') };
};

const lite = await transformRenderer(false);
const full = await transformRenderer(true);
Expand Down
209 changes: 209 additions & 0 deletions tests/mermaidZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { handlePostRender } from '../src/render';

const mocks = vi.hoisted(() => ({
run: vi.fn<(options: { nodes: HTMLElement[] }) => Promise<void>>(),
error: vi.fn(),
}));

vi.mock('mermaid', () => ({
default: { initialize: vi.fn(), run: mocks.run },
}));

beforeEach(() => {
document.body.innerHTML = '';
mocks.error.mockReset();
vi.spyOn(console, 'error').mockImplementation(mocks.error);
mocks.run.mockReset();
mocks.run.mockImplementation(async ({ nodes }) => {
nodes.forEach(node => {
node.setAttribute('data-processed', 'true');
node.innerHTML = '<svg><text>preview</text></svg>';
});
});
});

afterEach(() => {
try {
expect(mocks.error).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
});

function createPreview(zoom = '0.8') {
const pane = document.createElement('div');
pane.className = 'markdown-body';
pane.style.zoom = zoom;
pane.innerHTML = '<div class="mermaid" data-line-from="3">flowchart TB\nA[preview]</div>';
document.body.appendChild(pane);
return pane;
}

function deferred() {
let resolve!: () => void;
const promise = new Promise<void>(complete => { resolve = complete; });
return { promise, resolve };
}

describe('Mermaid preview measurement', () => {
test.each(['0.8', '1', '1.5'])('renders outside preview zoom %s and retains bindings and metadata', async zoom => {
const pane = createPreview(zoom);
const clicked = vi.fn();
const source = pane.firstElementChild;
Object.defineProperty(source, 'clientWidth', { value: 420 });

mocks.run.mockImplementation(async ({ nodes }) => {
expect(nodes).toHaveLength(1);
const diagram = nodes[0];
expect(pane.contains(diagram)).toBe(false);
expect(diagram.isConnected).toBe(true);
expect(diagram.closest('.markdown-body')?.parentElement).toBe(document.body);
expect(diagram.parentElement?.style.width).toBe('420px');
expect(diagram.getAttribute('data-processed')).toBeNull();
expect(pane.style.zoom).toBe(zoom);
diagram.innerHTML = '<svg><text>preview</text></svg>';
diagram.setAttribute('data-processed', 'true');
diagram.addEventListener('click', clicked);
});

const finished = vi.fn(() => {
expect(pane.style.zoom).toBe(zoom);
expect(document.body.children).toHaveLength(1);
});

handlePostRender(finished, pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
const diagram = pane.querySelector<HTMLElement>('.mermaid');
expect(diagram?.getAttribute('data-line-from')).toBe('3');
expect(diagram?.querySelector('svg')).not.toBeNull();
diagram?.click();
expect(clicked).toHaveBeenCalledOnce();
});

test('overrides custom markdown-body zoom only in the staging container', async () => {
const pane = createPreview('');
pane.insertAdjacentHTML('afterbegin', '<style>.markdown-body { zoom: 0.8; }</style>');
let measuredZoom: string | undefined;
mocks.run.mockImplementation(async ({ nodes }) => {
const staging = nodes[0].closest<HTMLElement>('.markdown-body')!;
measuredZoom = getComputedStyle(staging).zoom;
expect(staging).not.toBe(pane);
expect(getComputedStyle(pane).zoom).toBe('0.8');
nodes[0].innerHTML = '<svg></svg>';
});

const finished = vi.fn();
handlePostRender(finished, pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
expect(measuredZoom).toBe('1');
expect(pane.style.zoom).toBe('');
expect(getComputedStyle(pane).zoom).toBe('0.8');
expect(pane.querySelector('svg')).not.toBeNull();
expect(document.body.children).toHaveLength(1);
});

test('keeps stylesheet-defined Quick Look zoom outside measurement', async () => {
const pane = createPreview('');
pane.innerHTML = '<style>.quicklook-content { zoom: 0.9; }</style><div class="quicklook-content"><div class="mermaid">flowchart TB\nA[preview]</div></div>';

const inner = pane.querySelector<HTMLElement>('.quicklook-content')!;
mocks.run.mockImplementation(async ({ nodes }) => {
expect(getComputedStyle(inner).zoom).toBe('0.9');
expect(inner.style.zoom).toBe('');
expect(nodes[0].closest('.quicklook-content')).toBeNull();
expect(nodes[0].closest('.markdown-body')).not.toBe(pane);
});

const finished = vi.fn();
handlePostRender(finished, pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
expect(inner.style.zoom).toBe('');
expect(getComputedStyle(inner).zoom).toBe('0.9');
});

test('leaves zoom changes untouched throughout an asynchronous render', async () => {
const pane = createPreview();
const pending = deferred();

mocks.run.mockImplementation(async ({ nodes }) => {
expect(pane.style.zoom).toBe('0.8');
await pending.promise;
expect(nodes[0].closest('.markdown-body')).not.toBe(pane);
nodes[0].innerHTML = '<svg></svg>';
});

const finished = vi.fn();
handlePostRender(finished, pane);
await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledOnce());
pane.style.zoom = String(Number(pane.style.zoom) - 0.1);
expect(Number(pane.style.zoom)).toBeCloseTo(0.7);
pending.resolve();
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
expect(Number(pane.style.zoom)).toBeCloseTo(0.7);
});

test('renders multiple diagrams together and aligns only after insertion', async () => {
const pane = createPreview();
pane.insertAdjacentHTML('beforeend', '<div class="mermaid">flowchart TB\nB[second]</div>');

const finished = vi.fn(() => expect(pane.querySelectorAll('svg')).toHaveLength(2));
handlePostRender(finished, pane);
handlePostRender(vi.fn(), pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
expect(mocks.run).toHaveBeenCalledOnce();
expect(mocks.run.mock.calls[0][0].nodes).toHaveLength(2);
handlePostRender(vi.fn(), pane);
expect(mocks.run).toHaveBeenCalledOnce();
});

test('discards an obsolete render without replacing newer content or aligning it', async () => {
const pane = createPreview();
const pending = deferred();
mocks.run.mockImplementationOnce(async ({ nodes }) => {
await pending.promise;
nodes[0].innerHTML = '<svg><text>obsolete</text></svg>';
});

const obsoleteFinished = vi.fn();
const finished = vi.fn();

handlePostRender(obsoleteFinished, pane);
await vi.waitFor(() => expect(mocks.run).toHaveBeenCalledOnce());
pane.innerHTML = '<div class="mermaid">flowchart TB\nB[new]</div>';
handlePostRender(finished, pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
pending.resolve();
await vi.waitFor(() => expect(document.body.children).toHaveLength(1));
expect(pane.textContent).toBe('preview');
expect(obsoleteFinished).not.toHaveBeenCalled();
expect(pane.style.zoom).toBe('0.8');
});

test('retains Mermaid error output, reports the failure, and cleans up', async () => {
const pane = createPreview();
const error = new Error('Invalid diagram');
mocks.run.mockImplementationOnce(async ({ nodes }) => {
nodes[0].innerHTML = '<svg><text>Syntax error</text></svg>';
throw error;
});

const finished = vi.fn();
handlePostRender(finished, pane);
await vi.waitFor(() => expect(finished).toHaveBeenCalledOnce());
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(error);
mocks.error.mockClear();
expect(pane.textContent).toBe('Syntax error');
expect(pane.style.zoom).toBe('0.8');
expect(document.body.children).toHaveLength(1);
});

test('does not load Mermaid or create a staging container without diagrams', () => {
const pane = createPreview();
pane.innerHTML = '<p>Plain text</p>';
handlePostRender(vi.fn(), pane);
expect(mocks.run).not.toHaveBeenCalled();
expect(document.body.children).toHaveLength(1);
});
});
Loading
Loading