Skip to content

Commit cb0fd48

Browse files
committed
demo: non-blocking markdown/html rendering via worker + render cache
1 parent cf70c3f commit cb0fd48

2 files changed

Lines changed: 234 additions & 69 deletions

File tree

Lines changed: 203 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,113 @@
1-
/** OutputViewer — right pane: CodeMirror editor for JSON/Text, rendered HTML/Markdown. */
1+
/**
2+
* OutputViewer — right pane.
3+
*
4+
* Performance design:
5+
* • JSON / Text → CodeMirror (virtualizes rows internally — always fast)
6+
* • Markdown → marked.parse() runs in render-worker (off-thread), then
7+
* DOMPurify sanitizes, result cached in renderCache
8+
* • HTML → DOMPurify only (no markdown conversion); deferred one rAF
9+
* so the loading overlay paints before the sync work runs
10+
*
11+
* renderCache (Map<format → sanitized HTML string>) is cleared when a new PDF
12+
* is loaded (outputText changes), so stale renders are never served.
13+
*
14+
* renderRevision guards against race conditions when the user switches tabs
15+
* quickly while an async render is in flight.
16+
*/
217

318
import { el } from '../utils/dom';
419
import { store } from '../state';
520
import { EditorView, basicSetup } from 'codemirror';
621
import { EditorState } from '@codemirror/state';
722
import { json } from '@codemirror/lang-json';
823
import { oneDark } from '@codemirror/theme-one-dark';
9-
import { marked } from 'marked';
1024
import DOMPurify from 'dompurify';
25+
import type { OutputFormat } from '../types';
26+
27+
// ── Render worker (marked.parse off-thread) ───────────────────────────────────
28+
29+
type RenderWorkerMsg =
30+
| { id: string; ok: true; html: string }
31+
| { id: string; ok: false; error: string };
32+
33+
const renderWorker = new Worker(
34+
new URL('../workers/render-worker.ts', import.meta.url),
35+
{ type: 'module' },
36+
);
37+
38+
const renderPending = new Map<
39+
string,
40+
{ resolve: (h: string) => void; reject: (e: unknown) => void }
41+
>();
42+
let _rid = 0;
43+
44+
renderWorker.addEventListener('message', (e: MessageEvent<RenderWorkerMsg>) => {
45+
const msg = e.data;
46+
const p = renderPending.get(msg.id);
47+
if (!p) return;
48+
renderPending.delete(msg.id);
49+
if (msg.ok) p.resolve(msg.html);
50+
else p.reject(new Error(msg.error));
51+
});
52+
53+
function markdownToHtml(markdown: string): Promise<string> {
54+
const id = String(_rid++);
55+
return new Promise((resolve, reject) => {
56+
renderPending.set(id, { resolve, reject });
57+
renderWorker.postMessage({ id, markdown });
58+
});
59+
}
60+
61+
// ── Helpers ───────────────────────────────────────────────────────────────────
62+
63+
/** Yield to the browser so pending paints (e.g. loading overlay) flush first. */
64+
function nextPaint(): Promise<void> {
65+
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r())));
66+
}
67+
68+
/** Sanitize and inject HTML into a container using a <template> inert parse. */
69+
function applyHtml(target: HTMLElement, html: string): void {
70+
const clean = DOMPurify.sanitize(html);
71+
const tmpl = document.createElement('template');
72+
tmpl.innerHTML = clean;
73+
target.replaceChildren(tmpl.content);
74+
}
75+
76+
// ── Module-level state ────────────────────────────────────────────────────────
1177

1278
let editorView: EditorView | null = null;
1379

80+
// Sanitized HTML string cache — keyed by OutputFormat, cleared on new PDF.
81+
const renderCache = new Map<OutputFormat, string>();
82+
83+
// Monotonically-increasing counter. Each async render captures the current
84+
// value; if it has changed by the time the render completes the result is
85+
// discarded (user switched tabs again).
86+
let renderRevision = 0;
87+
88+
// True while an async render (worker + DOMPurify) is running.
89+
let renderBusy = false;
90+
91+
// ── Component ─────────────────────────────────────────────────────────────────
92+
1493
export function createOutputViewer(): HTMLElement {
1594
const container = el('div', { className: 'output-viewer' });
1695

17-
// Code pane (CodeMirror)
1896
const codePane = el('div', { className: 'output-viewer__code' });
19-
// Rendered pane (HTML / Markdown)
2097
const renderedPane = el('div', {
2198
className: 'output-viewer__rendered',
2299
style: 'display: none;',
23100
});
24101

25-
// Copy & Download buttons
26102
const actions = el('div', {
27103
className: 'output-viewer__actions',
28104
innerHTML: `
29-
<button class="output-viewer__btn" data-action="copy" aria-label="Copy output">Copy</button>
105+
<button class="output-viewer__btn" data-action="copy" aria-label="Copy output">Copy</button>
30106
<button class="output-viewer__btn" data-action="download" aria-label="Download output">Download</button>
31107
`,
32108
});
33109

34-
// Loading overlay — shown while WASM initialises or PDF is being parsed
110+
// Shared loading overlay — covers both parse-busy and render-busy states.
35111
const loadingOverlay = el('div', {
36112
className: 'output-viewer__loading',
37113
ariaLive: 'polite',
@@ -45,8 +121,8 @@ export function createOutputViewer(): HTMLElement {
45121

46122
container.append(actions, codePane, renderedPane, loadingOverlay);
47123

48-
// CodeMirror setup
49-
const state = EditorState.create({
124+
// CodeMirror
125+
const cmState = EditorState.create({
50126
doc: '',
51127
extensions: [
52128
basicSetup,
@@ -56,48 +132,134 @@ export function createOutputViewer(): HTMLElement {
56132
EditorView.lineWrapping,
57133
],
58134
});
135+
editorView = new EditorView({ state: cmState, parent: codePane });
59136

60-
editorView = new EditorView({ state, parent: codePane });
137+
// ── Loading overlay management ──────────────────────────────────────────────
61138

62-
// State subscriptions
63-
store.subscribe('outputText', () => updateContent(renderedPane));
64-
store.subscribe('outputFormat', () => updateContent(renderedPane));
65-
store.subscribe('darkMode', () => updateTheme());
139+
function loadingMsg(): string {
140+
if (renderBusy) return 'Rendering…';
141+
const ws = store.get('wasmStatus');
142+
return ws === 'loading' ? 'Loading parser…' : 'Parsing PDF…';
143+
}
66144

67-
function updateLoadingState() {
68-
const wasmStatus = store.get('wasmStatus');
69-
const parseStatus = store.get('parseStatus');
70-
const hasPdf = !!store.get('pdfBytes');
71-
// Show overlay only when actively processing — not during background WASM pre-warm
72-
const busy = parseStatus === 'parsing' || (wasmStatus === 'loading' && hasPdf);
145+
function updateLoadingState(): void {
146+
const ws = store.get('wasmStatus');
147+
const ps = store.get('parseStatus');
148+
const has = !!store.get('pdfBytes');
149+
const parseBusy = ps === 'parsing' || (ws === 'loading' && has);
150+
const busy = parseBusy || renderBusy;
73151
loadingOverlay.style.display = busy ? '' : 'none';
74-
const msg = loadingOverlay.querySelector('.loading-text') as HTMLElement | null;
75-
if (msg) {
76-
msg.textContent = wasmStatus === 'loading' ? 'Loading parser…' : 'Parsing PDF…';
77-
}
152+
(loadingOverlay.querySelector('.loading-text') as HTMLElement).textContent =
153+
loadingMsg();
78154
}
79-
store.subscribe('wasmStatus', updateLoadingState);
155+
156+
store.subscribe('wasmStatus', updateLoadingState);
80157
store.subscribe('parseStatus', updateLoadingState);
81-
store.subscribe('pdfBytes', updateLoadingState);
158+
store.subscribe('pdfBytes', updateLoadingState);
82159
updateLoadingState();
83160

84-
// Copy button
161+
// ── Content update ──────────────────────────────────────────────────────────
162+
163+
async function updateContent(): Promise<void> {
164+
const text = store.get('outputText');
165+
const format = store.get('outputFormat');
166+
167+
const isCode = format === 'json' || format === 'text';
168+
169+
// Switch CodeMirror / rendered pane visibility
170+
const codePaneEl = editorView?.dom.parentElement;
171+
if (codePaneEl) codePaneEl.style.display = isCode ? '' : 'none';
172+
renderedPane.style.display = isCode ? 'none' : '';
173+
174+
if (isCode) {
175+
// Cancel any in-flight render so its finally-block won't re-show overlay.
176+
renderBusy = false;
177+
renderRevision++;
178+
updateLoadingState();
179+
editorView?.dispatch({
180+
changes: { from: 0, to: editorView.state.doc.length, insert: text },
181+
});
182+
return;
183+
}
184+
185+
// ── Rendered view (markdown | html) ────────────────────────────────────
186+
187+
const cached = renderCache.get(format);
188+
if (cached !== undefined) {
189+
// Instant: re-use cached sanitized HTML string
190+
applyHtml(renderedPane, cached);
191+
return;
192+
}
193+
194+
// Show loading overlay immediately, then do the heavy work.
195+
const rev = ++renderRevision;
196+
renderBusy = true;
197+
updateLoadingState();
198+
199+
try {
200+
let rawHtml: string;
201+
202+
if (format === 'markdown') {
203+
// Heavy step 1: marked.parse() — runs in worker (never blocks main thread)
204+
rawHtml = await markdownToHtml(text);
205+
} else {
206+
// HTML format: WASM already produced HTML.
207+
// DOMPurify is synchronous — yield first so the overlay paints.
208+
await nextPaint();
209+
rawHtml = text;
210+
}
211+
212+
if (rev !== renderRevision) return; // user switched tabs — discard
213+
214+
// Heavy step 2: DOMPurify — synchronous DOM traversal (~100–600 ms for large docs).
215+
// For markdown, yield again so the overlay is visible before this blocks.
216+
if (format === 'markdown') await nextPaint();
217+
if (rev !== renderRevision) return;
218+
219+
const sanitized = DOMPurify.sanitize(rawHtml);
220+
if (rev !== renderRevision) return;
221+
222+
renderCache.set(format, sanitized);
223+
applyHtml(renderedPane, sanitized);
224+
} catch (err: unknown) {
225+
if (rev === renderRevision) {
226+
store.set('errorMessage', `Render error: ${err}`);
227+
}
228+
} finally {
229+
if (rev === renderRevision) {
230+
renderBusy = false;
231+
updateLoadingState();
232+
}
233+
}
234+
}
235+
236+
// Clear render cache whenever a new PDF is parsed (new outputText).
237+
store.subscribe('outputText', () => {
238+
renderCache.clear();
239+
renderRevision++; // cancel any in-flight render
240+
updateContent();
241+
});
242+
store.subscribe('outputFormat', () => updateContent());
243+
store.subscribe('darkMode', () => updateTheme());
244+
245+
// ── Copy / Download ─────────────────────────────────────────────────────────
246+
85247
actions.querySelector('[data-action="copy"]')!.addEventListener('click', () => {
86-
const text = store.get('outputText');
87-
navigator.clipboard.writeText(text).then(
88-
() => store.set('errorMessage', null), // could show toast "Copied!"
248+
navigator.clipboard.writeText(store.get('outputText')).then(
249+
() => store.set('errorMessage', null),
89250
() => store.set('errorMessage', 'Failed to copy to clipboard'),
90251
);
91252
});
92253

93-
// Download button
94254
actions.querySelector('[data-action="download"]')!.addEventListener('click', () => {
95-
const text = store.get('outputText');
255+
const text = store.get('outputText');
96256
const format = store.get('outputFormat');
97-
const ext = format === 'json' ? 'json' : format === 'html' ? 'html' : format === 'markdown' ? 'md' : 'txt';
257+
const ext = format === 'json' ? 'json'
258+
: format === 'html' ? 'html'
259+
: format === 'markdown' ? 'md' : 'txt';
98260
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
99-
const url = URL.createObjectURL(blob);
100-
const a = document.createElement('a');
261+
const url = URL.createObjectURL(blob);
262+
const a = document.createElement('a');
101263
a.href = url;
102264
a.download = `${store.get('fileName') || 'output'}.${ext}`;
103265
a.click();
@@ -107,41 +269,11 @@ export function createOutputViewer(): HTMLElement {
107269
return container;
108270
}
109271

110-
function updateContent(renderedPane: HTMLElement): void {
111-
const text = store.get('outputText');
112-
const format = store.get('outputFormat');
113-
114-
const codeFormats = ['json', 'text'];
115-
const showCode = codeFormats.includes(format);
116-
117-
if (editorView) {
118-
const parent = editorView.dom.parentElement;
119-
if (parent) parent.style.display = showCode ? '' : 'none';
120-
}
121-
renderedPane.style.display = showCode ? 'none' : '';
122-
123-
if (showCode && editorView) {
124-
editorView.dispatch({
125-
changes: {
126-
from: 0,
127-
to: editorView.state.doc.length,
128-
insert: text,
129-
},
130-
});
131-
} else if (format === 'markdown') {
132-
const html = marked.parse(text);
133-
if (typeof html === 'string') {
134-
renderedPane.innerHTML = DOMPurify.sanitize(html);
135-
}
136-
} else if (format === 'html') {
137-
renderedPane.innerHTML = DOMPurify.sanitize(text);
138-
}
139-
}
272+
// ── Theme switch ──────────────────────────────────────────────────────────────
140273

141274
function updateTheme(): void {
142275
if (!editorView) return;
143-
const dark = store.get('darkMode');
144-
// Recreate with theme
276+
const dark = store.get('darkMode');
145277
const parent = editorView.dom.parentElement;
146278
if (!parent) return;
147279

@@ -157,6 +289,8 @@ function updateTheme(): void {
157289
];
158290
if (dark) extensions.push(oneDark);
159291

160-
const state = EditorState.create({ doc, extensions });
161-
editorView = new EditorView({ state, parent });
292+
editorView = new EditorView({
293+
state: EditorState.create({ doc, extensions }),
294+
parent,
295+
});
162296
}

demo/src/workers/render-worker.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* render-worker.ts — converts Markdown → HTML off the main thread.
3+
*
4+
* marked.parse() is synchronous and can take 50–300 ms on large documents.
5+
* Running it in a worker keeps the UI fully responsive while rendering.
6+
*/
7+
/// <reference lib="webworker" />
8+
9+
import { marked } from 'marked';
10+
11+
type RenderReq = { id: string; markdown: string };
12+
type RenderResp = { id: string; ok: true; html: string }
13+
| { id: string; ok: false; error: string };
14+
15+
(self as DedicatedWorkerGlobalScope).onmessage = async (
16+
e: MessageEvent<RenderReq>,
17+
) => {
18+
const { id, markdown } = e.data;
19+
try {
20+
// marked.parse returns string | Promise<string> depending on version/config.
21+
const result = marked.parse(markdown);
22+
const html = result instanceof Promise ? await result : result;
23+
(self as DedicatedWorkerGlobalScope).postMessage(
24+
{ id, ok: true, html } satisfies RenderResp,
25+
);
26+
} catch (err: unknown) {
27+
(self as DedicatedWorkerGlobalScope).postMessage(
28+
{ id, ok: false, error: String(err) } satisfies RenderResp,
29+
);
30+
}
31+
};

0 commit comments

Comments
 (0)