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
318import { el } from '../utils/dom' ;
419import { store } from '../state' ;
520import { EditorView , basicSetup } from 'codemirror' ;
621import { EditorState } from '@codemirror/state' ;
722import { json } from '@codemirror/lang-json' ;
823import { oneDark } from '@codemirror/theme-one-dark' ;
9- import { marked } from 'marked' ;
1024import 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
1278let 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+
1493export 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
141274function 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}
0 commit comments