Skip to main content

marco_core/render/
preview_document.rs

1use super::base_css;
2
3/// Shared HTML preview document wrapper.
4///
5/// This is intentionally **UI-toolkit agnostic**: it just produces a full HTML
6/// document as a String. Both the editor and viewer can load this into a WebView.
7///
8/// The embedded JS exposes a preview bridge for smooth content updates and
9/// installs interactive table resizing (column + row) in the rendered preview.
10///
11/// CSS injection order:
12///   1. `inline_bg_style`  — instant background-colour flash-prevention
13///   2. `base_css()`       — base structural stylesheet (all HTML elements + viewer components)
14///   3. `css`              — active theme token file (only CSS custom property declarations)
15///   4. `table_resize_css` — interactive table/slider/marco-heading-anchor rules (separate `<style>`)
16pub fn wrap_preview_html_document(
17    body: &str,
18    css: &str,
19    theme_class: &str,
20    background_color: Option<&str>,
21) -> String {
22    // Generate inline background style for instant dark mode support (eliminates white flash)
23    let inline_bg_style = if let Some(bg_color) = background_color {
24        format!("body {{ background-color: {} !important; }}\n", bg_color)
25    } else {
26        String::new()
27    };
28
29    // Combine: base structural CSS first, then the theme token overrides.
30    let css = format!(
31        "{}\n\n/* ── Theme tokens ── */\n{}",
32        base_css::base_css(),
33        css
34    );
35
36    // Table resize affordances (JS drives cursor; CSS disables selection during drag).
37    // Keep this lightweight and self-contained to avoid fighting user themes.
38    let table_resize_css = r#"
39/* Viewer: interactive table resizing */
40body.marco-table-resizing,
41body.marco-table-resizing * {
42    -webkit-user-select: none !important;
43    user-select: none !important;
44}
45
46table.marco-resize-active {
47    table-layout: fixed;
48}
49
50table.marco-resize-active th,
51table.marco-resize-active td {
52    overflow: hidden;
53    text-overflow: ellipsis;
54}
55
56/* Viewer: heading anchor - the heading text itself is the link */
57.marco-heading-anchor {
58    text-decoration: none !important;
59    color: inherit !important;
60    display: inline;
61}
62
63.marco-heading-anchor:link,
64.marco-heading-anchor:visited,
65.marco-heading-anchor:hover,
66.marco-heading-anchor:focus,
67.marco-heading-anchor:focus-visible,
68.marco-heading-anchor:active {
69    color: inherit !important;
70    text-decoration: none !important;
71    -webkit-text-fill-color: inherit !important;
72    background: inherit !important;
73    -webkit-background-clip: inherit !important;
74    background-clip: inherit !important;
75}
76
77/* Viewer: internal and external links
78   - Keeps links looking like normal links.
79   - On hover/focus, suppresses theme hover effects.
80   - Excludes the injected heading anchor link itself.
81*/
82a[href^='#']:not(.marco-heading-anchor),
83a[href^='http:']:not(.marco-heading-anchor),
84a[href^='https:']:not(.marco-heading-anchor),
85a[href^='mailto:']:not(.marco-heading-anchor) {
86    position: relative;
87}
88
89a[href^='#']:not(.marco-heading-anchor):link,
90a[href^='#']:not(.marco-heading-anchor):visited,
91a[href^='http:']:not(.marco-heading-anchor):link,
92a[href^='http:']:not(.marco-heading-anchor):visited,
93a[href^='https:']:not(.marco-heading-anchor):link,
94a[href^='https:']:not(.marco-heading-anchor):visited,
95a[href^='mailto:']:not(.marco-heading-anchor):link,
96a[href^='mailto:']:not(.marco-heading-anchor):visited {
97    color: var(--link-color) !important;
98}
99
100a[href^='#']:not(.marco-heading-anchor):hover,
101a[href^='#']:not(.marco-heading-anchor):focus,
102a[href^='#']:not(.marco-heading-anchor):focus-visible,
103a[href^='#']:not(.marco-heading-anchor):active,
104a[href^='http:']:not(.marco-heading-anchor):hover,
105a[href^='http:']:not(.marco-heading-anchor):focus,
106a[href^='http:']:not(.marco-heading-anchor):focus-visible,
107a[href^='http:']:not(.marco-heading-anchor):active,
108a[href^='https:']:not(.marco-heading-anchor):hover,
109a[href^='https:']:not(.marco-heading-anchor):focus,
110a[href^='https:']:not(.marco-heading-anchor):focus-visible,
111a[href^='https:']:not(.marco-heading-anchor):active,
112a[href^='mailto:']:not(.marco-heading-anchor):hover,
113a[href^='mailto:']:not(.marco-heading-anchor):focus,
114a[href^='mailto:']:not(.marco-heading-anchor):focus-visible,
115a[href^='mailto:']:not(.marco-heading-anchor):active {
116    color: var(--link-hover, var(--link-color)) !important;
117    text-decoration: underline !important;
118    text-shadow: none !important;
119    background: none !important;
120    box-shadow: none !important;
121    transform: none !important;
122    filter: none !important;
123}
124
125
126/* Viewer: sliders / slide decks */
127.marco-sliders {
128    position: relative;
129    margin: 1rem 0;
130    padding: 0.75rem 0.9rem;
131    border-radius: 10px;
132    border: 1px solid var(--mc-sliders-border, transparent);
133    background: var(--mc-sliders-bg, transparent);
134}
135
136.marco-sliders__viewport {
137    position: relative;
138    display: grid;
139    grid-template-columns: 1fr;
140    overflow: hidden;
141}
142
143.marco-sliders__slide {
144    grid-area: 1 / 1;
145    align-self: start;
146    justify-self: stretch;
147    opacity: 0;
148    visibility: hidden;
149    pointer-events: none;
150    transform: translateY(0.35rem);
151    transition: opacity 180ms ease-in-out, transform 180ms ease-in-out;
152}
153
154.marco-sliders__slide.is-active {
155    opacity: 1;
156    visibility: visible;
157    pointer-events: auto;
158    transform: translateY(0);
159}
160
161@media (prefers-reduced-motion: reduce) {
162    .marco-sliders__slide {
163        transition: none !important;
164        transform: none !important;
165    }
166}
167
168.marco-sliders__controls {
169    display: flex;
170    align-items: center;
171    justify-content: space-between;
172    gap: 0.75rem;
173    margin-top: 0.5rem;
174}
175
176.marco-sliders__btn {
177    display: inline-flex;
178    align-items: center;
179    justify-content: center;
180    gap: 0.25rem;
181    padding: 0.25rem 0.35rem;
182    border: none;
183    background: transparent;
184    color: inherit;
185    cursor: pointer;
186    opacity: 0.85;
187}
188
189.marco-sliders__btn:hover,
190.marco-sliders__dot:hover {
191    opacity: 1;
192}
193
194.marco-sliders__btn:disabled {
195    opacity: 0.35;
196    cursor: default;
197}
198
199.marco-sliders__btn svg,
200.marco-sliders__dot svg {
201    width: 1.15em;
202    height: 1.15em;
203    display: block;
204}
205
206.marco-sliders__dots {
207    display: flex;
208    align-items: center;
209    justify-content: center;
210    gap: 0.25rem;
211    margin-top: 0.35rem;
212}
213
214.marco-sliders__dot {
215    display: inline-flex;
216    align-items: center;
217    justify-content: center;
218    padding: 0.1rem;
219    border: none;
220    background: transparent;
221    color: inherit;
222    cursor: pointer;
223    opacity: 0.75;
224}
225
226.marco-sliders__dot.is-active {
227    opacity: 1;
228}
229
230.marco-sliders__dot-icon--active {
231    display: none;
232}
233
234.marco-sliders__dot.is-active .marco-sliders__dot-icon--inactive {
235    display: none;
236}
237
238.marco-sliders__dot.is-active .marco-sliders__dot-icon--active {
239    display: inline-flex;
240}
241
242/* Toggle button shows play when paused, pause when playing */
243.marco-sliders .marco-sliders__icon--pause {
244    display: none;
245}
246
247.marco-sliders.is-playing .marco-sliders__icon--play {
248    display: none;
249}
250
251.marco-sliders.is-playing .marco-sliders__icon--pause {
252    display: inline-flex;
253}
254"#;
255
256    // NOTE: This HTML template is used as a Rust `format!` string. All literal
257    // braces inside the template must be escaped as `{{` and `}}`.
258    format!(
259        r#"<!DOCTYPE html>
260<html class="{}">
261    <head>
262        <meta charset=\"utf-8\">
263        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
264        <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css\" integrity=\"sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV\" crossorigin=\"anonymous\">
265        <style id=\"mc-preview-style\">{}{}</style>
266        <style id=\"mc-preview-internal-style\">{}</style>
267        <script>
268            // Preview management object to avoid global namespace pollution
269            window.MarcoCorePreview = (function() {{
270                var scrollTimeouts = [];
271                var tableResizerCleanup = null;
272                var tableSizeState = Object.create(null);
273                var sliderDeckState = Object.create(null);
274                var sliderDelegatedInstalled = false;
275                var sliderResizeObservers = Object.create(null);
276                var sliderMeasureScheduled = Object.create(null);
277                var sliderWindowResizeInstalled = false;
278                
279                // Cleanup function to clear any pending timeouts
280                function cleanupScrollRestoration() {{
281                    scrollTimeouts.forEach(function(id) {{
282                        clearTimeout(id);
283                    }});
284                    scrollTimeouts = [];
285                }}
286
287                // Full cleanup used on page unload / WebView destroy.
288                // NOTE: updateContent() should NOT call this, otherwise it would
289                // uninstall delegated event listeners and break interactions.
290                function cleanup() {{
291                    cleanupScrollRestoration();
292
293                    // Stop any slider timers
294                    try {{
295                        Object.keys(sliderDeckState).forEach(function(deckId) {{
296                            var st = sliderDeckState[deckId];
297                            if (st && st.intervalId) {{
298                                clearInterval(st.intervalId);
299                                st.intervalId = null;
300                            }}
301                        }});
302                    }} catch(e) {{
303                        console.error('Error stopping sliders:', e);
304                    }}
305
306                    // Disconnect any ResizeObservers
307                    try {{
308                        Object.keys(sliderResizeObservers).forEach(function(deckId) {{
309                            var ro = sliderResizeObservers[deckId];
310                            if (ro && typeof ro.disconnect === 'function') {{
311                                ro.disconnect();
312                            }}
313                        }});
314                    }} catch(e) {{
315                        console.error('Error disconnecting slider ResizeObservers:', e);
316                    }}
317
318                    // Remove table resizer listeners (if installed)
319                    try {{
320                        if (typeof tableResizerCleanup === 'function') {{
321                            tableResizerCleanup();
322                        }}
323                    }} catch(e) {{
324                        console.error('Error cleaning up table resizer:', e);
325                    }}
326
327                    // Clear any persisted state
328                    tableSizeState = Object.create(null);
329                    sliderDeckState = Object.create(null);
330                    sliderResizeObservers = Object.create(null);
331                    sliderMeasureScheduled = Object.create(null);
332                }}
333
334                function parsePositiveInt(s) {{
335                    var n = parseInt(s, 10);
336                    if (!isFinite(n) || isNaN(n) || n <= 0) return null;
337                    return n;
338                }}
339
340                function setDeckPlaying(deck, playing) {{
341                    try {{
342                        if (playing) deck.classList.add('is-playing');
343                        else deck.classList.remove('is-playing');
344                    }} catch(_e) {{
345                        // ignore
346                    }}
347                }}
348
349                function getDeckState(deck) {{
350                    if (!deck || !deck.id) return null;
351                    return sliderDeckState[deck.id] || null;
352                }}
353
354                function measureDeckViewportHeight(deck) {{
355                    try {{
356                        if (!deck) return;
357                        var viewport = deck.querySelector('.marco-sliders__viewport');
358                        if (!viewport) return;
359
360                        var slides = deck.querySelectorAll('.marco-sliders__slide');
361                        if (!slides || slides.length === 0) return;
362
363                        var maxH = 0;
364                        for (var i = 0; i < slides.length; i++) {{
365                            var el = slides[i];
366                            if (!el) continue;
367                            var r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
368                            var h = (r && r.height) ? r.height : (el.scrollHeight || 0);
369                            if (h > maxH) maxH = h;
370                        }}
371
372                        if (maxH > 0) {{
373                            viewport.style.minHeight = Math.ceil(maxH) + 'px';
374                        }}
375                    }} catch(e) {{
376                        console.error('Failed to measure slider deck height:', e);
377                    }}
378                }}
379
380                function scheduleDeckMeasure(deck) {{
381                    try {{
382                        if (!deck || !deck.id) return;
383                        if (sliderMeasureScheduled[deck.id]) return;
384                        sliderMeasureScheduled[deck.id] = true;
385                        requestAnimationFrame(function() {{
386                            try {{
387                                delete sliderMeasureScheduled[deck.id];
388                                measureDeckViewportHeight(deck);
389                            }} catch(_e) {{
390                                // ignore
391                            }}
392                        }});
393                    }} catch(_e) {{
394                        // ignore
395                    }}
396                }}
397
398                function ensureSliderWindowResizeInstalled() {{
399                    if (sliderWindowResizeInstalled) return;
400                    sliderWindowResizeInstalled = true;
401
402                    window.addEventListener('resize', function() {{
403                        try {{
404                            Object.keys(sliderDeckState).forEach(function(deckId) {{
405                                var st = sliderDeckState[deckId];
406                                if (st && st.deckEl) scheduleDeckMeasure(st.deckEl);
407                            }});
408                        }} catch(e) {{
409                            console.error('Slider resize handler error:', e);
410                        }}
411                    }}, true);
412                }}
413
414                function installDeckResizeObserver(deck) {{
415                    try {{
416                        if (!deck || !deck.id) return;
417                        if (sliderResizeObservers[deck.id]) return;
418
419                        if (!window.ResizeObserver) return;
420                        var ro = new ResizeObserver(function(_entries) {{
421                            scheduleDeckMeasure(deck);
422                        }});
423
424                        // Observe the viewport and each slide so height changes (e.g. images loading)
425                        // trigger a re-measure.
426                        var viewport = deck.querySelector('.marco-sliders__viewport');
427                        if (viewport) ro.observe(viewport);
428
429                        var slides = deck.querySelectorAll('.marco-sliders__slide');
430                        for (var i = 0; i < slides.length; i++) {{
431                            ro.observe(slides[i]);
432                        }}
433
434                        sliderResizeObservers[deck.id] = ro;
435                    }} catch(e) {{
436                        // ResizeObserver is best-effort; don't break sliders if it fails.
437                        console.error('Failed to install ResizeObserver for slider deck:', e);
438                    }}
439                }}
440
441                function showSlide(deck, index) {{
442                    var st = getDeckState(deck);
443                    if (!st) return;
444                    var slides = deck.querySelectorAll('.marco-sliders__slide');
445                    var dots = deck.querySelectorAll('.marco-sliders__dot');
446                    if (!slides || slides.length === 0) return;
447
448                    var n = slides.length;
449                    var i = index;
450                    if (i < 0) i = n - 1;
451                    if (i >= n) i = 0;
452                    st.index = i;
453
454                    for (var k = 0; k < slides.length; k++) {{
455                        if (k === i) slides[k].classList.add('is-active');
456                        else slides[k].classList.remove('is-active');
457
458                        // Keep hidden slides out of the accessibility tree.
459                        try {{
460                            if (k === i) slides[k].removeAttribute('aria-hidden');
461                            else slides[k].setAttribute('aria-hidden', 'true');
462                        }} catch(_e) {{
463                            // ignore
464                        }}
465                    }}
466
467                    for (var d = 0; d < dots.length; d++) {{
468                        if (d === i) dots[d].classList.add('is-active');
469                        else dots[d].classList.remove('is-active');
470
471                        // Sync ARIA for keyboard/screen-reader navigation.
472                        try {{
473                            if (d === i) {{
474                                dots[d].setAttribute('aria-selected', 'true');
475                                dots[d].setAttribute('tabindex', '0');
476                            }} else {{
477                                dots[d].setAttribute('aria-selected', 'false');
478                                dots[d].setAttribute('tabindex', '-1');
479                            }}
480                        }} catch(_e) {{
481                            // ignore
482                        }}
483                    }}
484
485                    // Lock the viewport size to the tallest slide to avoid layout jumps.
486                    scheduleDeckMeasure(deck);
487                }}
488
489                function slidersPauseDeck(deckId) {{
490                    var st = sliderDeckState[deckId];
491                    if (!st) return;
492                    if (st.intervalId) {{
493                        clearInterval(st.intervalId);
494                        st.intervalId = null;
495                    }}
496                    st.playing = false;
497                    if (st.deckEl) setDeckPlaying(st.deckEl, false);
498                }}
499
500                function slidersPlayDeck(deckId) {{
501                    var st = sliderDeckState[deckId];
502                    if (!st) return;
503                    if (!st.timerSeconds || st.timerSeconds <= 0) return;
504
505                    slidersPauseDeck(deckId);
506                    st.playing = true;
507                    if (st.deckEl) setDeckPlaying(st.deckEl, true);
508
509                    st.intervalId = setInterval(function() {{
510                        try {{
511                            var deck = st.deckEl;
512                            if (!deck) return;
513                            showSlide(deck, st.index + 1);
514                        }} catch(e) {{
515                            console.error('Slider tick error:', e);
516                        }}
517                    }}, st.timerSeconds * 1000);
518                }}
519
520                function slidersToggleDeck(deckId) {{
521                    var st = sliderDeckState[deckId];
522                    if (!st) return;
523                    if (st.playing) slidersPauseDeck(deckId);
524                    else slidersPlayDeck(deckId);
525                }}
526
527                function slidersPauseAll() {{
528                    Object.keys(sliderDeckState).forEach(function(deckId) {{
529                        slidersPauseDeck(deckId);
530                    }});
531                }}
532
533                function slidersPlayAll() {{
534                    Object.keys(sliderDeckState).forEach(function(deckId) {{
535                        slidersPlayDeck(deckId);
536                    }});
537                }}
538
539                function slidersToggleAll() {{
540                    Object.keys(sliderDeckState).forEach(function(deckId) {{
541                        slidersToggleDeck(deckId);
542                    }});
543                }}
544
545                function initSliderDeck(deck) {{
546                    if (!deck || !deck.id) return;
547                    var timerSeconds = parsePositiveInt(deck.getAttribute('data-timer-seconds'));
548                    var slides = deck.querySelectorAll('.marco-sliders__slide');
549                    if (!slides || slides.length === 0) return;
550
551                    sliderDeckState[deck.id] = {{
552                        deckEl: deck,
553                        index: 0,
554                        timerSeconds: timerSeconds,
555                        intervalId: null,
556                        playing: false
557                    }};
558
559                    // Disable toggle button if no timer.
560                    var toggleBtn = deck.querySelector('.marco-sliders__btn--toggle');
561                    if (toggleBtn) {{
562                        if (!timerSeconds) {{
563                            toggleBtn.disabled = true;
564                            toggleBtn.setAttribute('aria-disabled', 'true');
565                        }} else {{
566                            toggleBtn.disabled = false;
567                            toggleBtn.removeAttribute('aria-disabled');
568                        }}
569                    }}
570
571                    showSlide(deck, 0);
572                    setDeckPlaying(deck, false);
573
574                    // Prevent content jumps by measuring the largest slide and
575                    // keeping the viewport height stable.
576                    ensureSliderWindowResizeInstalled();
577                    installDeckResizeObserver(deck);
578                    scheduleDeckMeasure(deck);
579
580                    // Autoplay if timer is present.
581                    if (timerSeconds) {{
582                        slidersPlayDeck(deck.id);
583                    }}
584                }}
585
586                function ensureSliderDelegationInstalled() {{
587                    if (sliderDelegatedInstalled) return;
588                    sliderDelegatedInstalled = true;
589
590                    // Delegated click handler; survives innerHTML updates.
591                    document.addEventListener('click', function(ev) {{
592                        try {{
593                            var target = ev.target;
594                            if (!target) return;
595                            
596                            // Handle code block copy button
597                            var copyBtn = target.closest('.marco-copy-btn');
598                            if (copyBtn) {{
599                                var wrapper = copyBtn.closest('.marco-code-block');
600                                if (!wrapper) return;
601                                
602                                var codeEl = wrapper.querySelector('code');
603                                if (!codeEl) return;
604                                
605                                var codeText = codeEl.textContent || '';
606                                
607                                // Try to copy to clipboard
608                                try {{
609                                    if (navigator.clipboard && navigator.clipboard.writeText) {{
610                                        navigator.clipboard.writeText(codeText).then(function() {{
611                                            // Show success feedback
612                                            copyBtn.classList.add('copied');
613                                            setTimeout(function() {{
614                                                copyBtn.classList.remove('copied');
615                                            }}, 2000);
616                                        }}).catch(function(err) {{
617                                            console.error('Failed to copy code:', err);
618                                        }});
619                                    }} else {{
620                                        // Fallback for older browsers
621                                        var textArea = document.createElement('textarea');
622                                        textArea.value = codeText;
623                                        textArea.style.position = 'fixed';
624                                        textArea.style.left = '-9999px';
625                                        document.body.appendChild(textArea);
626                                        textArea.select();
627                                        try {{
628                                            document.execCommand('copy');
629                                            copyBtn.classList.add('copied');
630                                            setTimeout(function() {{
631                                                copyBtn.classList.remove('copied');
632                                            }}, 2000);
633                                        }} catch(err) {{
634                                            console.error('Fallback copy failed:', err);
635                                        }}
636                                        document.body.removeChild(textArea);
637                                    }}
638                                }} catch(err) {{
639                                    console.error('Copy error:', err);
640                                }}
641                                return;
642                            }}
643                            
644                            // Handle slider controls
645                            var btn = target.closest('button');
646                            if (!btn) return;
647                            var deck = btn.closest('.marco-sliders');
648                            if (!deck) return;
649
650                            var action = btn.getAttribute('data-action');
651                            var st = getDeckState(deck);
652                            if (!st) return;
653
654                            if (action === 'prev') {{
655                                showSlide(deck, st.index - 1);
656                            }} else if (action === 'next') {{
657                                showSlide(deck, st.index + 1);
658                            }} else if (action === 'goto') {{
659                                var idx = parseInt(btn.getAttribute('data-index'), 10);
660                                if (!isNaN(idx)) showSlide(deck, idx);
661                            }} else if (action === 'toggle') {{
662                                slidersToggleDeck(deck.id);
663                            }}
664                        }} catch(e) {{
665                            console.error('Click handler error:', e);
666                        }}
667                    }}, true);
668                }}
669
670                function installSliders(container) {{
671                    try {{
672                        // Stop existing timers and rebuild state for the new DOM.
673                        slidersPauseAll();
674
675                        // Disconnect any prior observers (they reference old DOM nodes).
676                        try {{
677                            Object.keys(sliderResizeObservers).forEach(function(deckId) {{
678                                var ro = sliderResizeObservers[deckId];
679                                if (ro && typeof ro.disconnect === 'function') ro.disconnect();
680                            }});
681                        }} catch(_e) {{
682                            // ignore
683                        }}
684
685                        sliderDeckState = Object.create(null);
686                        sliderResizeObservers = Object.create(null);
687                        sliderMeasureScheduled = Object.create(null);
688                        ensureSliderDelegationInstalled();
689
690                        if (!container) return;
691                        var decks = container.querySelectorAll('.marco-sliders');
692                        for (var i = 0; i < decks.length; i++) {{
693                            initSliderDeck(decks[i]);
694                        }}
695                    }} catch(e) {{
696                        console.error('Failed to install sliders:', e);
697                    }}
698                }}
699
700                function applyStoredTableSizes(container) {{
701                    try {{
702                        if (!container) return;
703                        var tables = container.querySelectorAll('table');
704
705                        function firstRowCellCount(tbl) {{
706                            try {{
707                                if (!tbl || !tbl.rows || tbl.rows.length === 0) return 0;
708                                return (tbl.rows[0] && tbl.rows[0].cells) ? tbl.rows[0].cells.length : 0;
709                            }} catch(_e) {{
710                                return 0;
711                            }}
712                        }}
713
714                        function ensureColGroup(tbl, colCount) {{
715                            if (!tbl || colCount <= 0) return null;
716                            var cg = tbl.querySelector('colgroup');
717                            if (!cg) {{
718                                cg = document.createElement('colgroup');
719
720                                // Insert after caption if present, otherwise as the first child.
721                                var first = tbl.firstElementChild;
722                                if (first && first.tagName === 'CAPTION') {{
723                                    if (first.nextSibling) {{
724                                        tbl.insertBefore(cg, first.nextSibling);
725                                    }} else {{
726                                        tbl.appendChild(cg);
727                                    }}
728                                }} else if (first) {{
729                                    tbl.insertBefore(cg, first);
730                                }} else {{
731                                    tbl.appendChild(cg);
732                                }}
733                            }}
734
735                            // Normalize number of <col> elements.
736                            var cols = cg.querySelectorAll('col');
737                            if (cols.length !== colCount) {{
738                                cg.innerHTML = '';
739                                for (var i = 0; i < colCount; i++) {{
740                                    cg.appendChild(document.createElement('col'));
741                                }}
742                            }}
743                            return cg;
744                        }}
745
746                        for (var i = 0; i < tables.length; i++) {{
747                            var tbl = tables[i];
748                            var key = 't' + i;
749                            var state = tableSizeState[key];
750                            if (!state) continue;
751
752                            // Apply stored column widths
753                            if (state.cols) {{
754                                var colCount = firstRowCellCount(tbl);
755                                var wantCols = Math.max(colCount, state.cols.length || 0);
756                                var cg = ensureColGroup(tbl, wantCols);
757                                if (cg) {{
758                                    var cols = cg.querySelectorAll('col');
759                                    for (var ci = 0; ci < state.cols.length && ci < cols.length; ci++) {{
760                                        if (state.cols[ci]) {{
761                                            cols[ci].style.width = state.cols[ci];
762                                        }}
763                                    }}
764                                    try {{
765                                        tbl.classList.add('marco-resize-active');
766                                        tbl.style.tableLayout = 'fixed';
767                                    }} catch(_e) {{
768                                        // ignore
769                                    }}
770                                }}
771                            }}
772
773                            // Apply stored table width (helps keep col widths stable)
774                            if (state.tableWidth) {{
775                                try {{
776                                    tbl.style.width = state.tableWidth;
777                                }} catch(_e) {{
778                                    // ignore
779                                }}
780                            }}
781
782                            // Apply stored row heights
783                            if (state.rows) {{
784                                var trs = tbl.querySelectorAll('tr');
785                                for (var ri = 0; ri < state.rows.length && ri < trs.length; ri++) {{
786                                    if (state.rows[ri]) {{
787                                        trs[ri].style.height = state.rows[ri];
788                                    }}
789                                }}
790                            }}
791                        }}
792                    }} catch(e) {{
793                        console.error('Error applying stored table sizes:', e);
794                    }}
795                }}
796
797                // Interactive table row/column resizing (HTML preview only).
798                // - Column resize: near right edge of a TH/TD (priority over row)
799                // - Row resize: near bottom edge of a TR
800                // - Uses <colgroup> widths for column stability
801                // - Disables text selection while actively resizing
802                function installTableResizer() {{
803                    var EDGE_PX = 5;
804                    var MIN_COL_W = 40;
805                    var MAX_COL_W = 2000;
806                    var MIN_ROW_H = 18;
807                    var MAX_ROW_H = 1200;
808
809                    var active = false;
810                    var mode = null; // 'col' | 'row'
811                    var startX = 0;
812                    var startY = 0;
813                    var table = null;
814                    var colIndex = -1;
815                    var colEl = null;
816                    var startColW = 0;
817                    var startTableW = 0;
818                    var rowEl = null;
819                    var startRowH = 0;
820
821                    function clamp(v, minV, maxV) {{
822                        return Math.max(minV, Math.min(maxV, v));
823                    }}
824
825                    function setCursor(cursor) {{
826                        try {{
827                            if (document && document.body) {{
828                                document.body.style.cursor = cursor || '';
829                            }}
830                        }} catch(_e) {{
831                            // ignore
832                        }}
833                    }}
834
835                    function closestCell(target) {{
836                        if (!target) return null;
837                        if (target.nodeType !== 1) return null;
838                        if (target.tagName === 'TD' || target.tagName === 'TH') return target;
839                        return target.closest ? target.closest('td, th') : null;
840                    }}
841
842                    function getTableFromCell(cell) {{
843                        if (!cell) return null;
844                        return cell.closest ? cell.closest('table') : null;
845                    }}
846
847                    function firstRowCellCount(tbl) {{
848                        try {{
849                            if (!tbl || !tbl.rows || tbl.rows.length === 0) return 0;
850                            return (tbl.rows[0] && tbl.rows[0].cells) ? tbl.rows[0].cells.length : 0;
851                        }} catch(_e) {{
852                            return 0;
853                        }}
854                    }}
855
856                    function ensureColGroup(tbl, colCount) {{
857                        if (!tbl || colCount <= 0) return null;
858                        var cg = tbl.querySelector('colgroup');
859                        if (!cg) {{
860                            cg = document.createElement('colgroup');
861
862                            // Insert after caption if present, otherwise as the first child.
863                            var first = tbl.firstElementChild;
864                            if (first && first.tagName === 'CAPTION') {{
865                                if (first.nextSibling) {{
866                                    tbl.insertBefore(cg, first.nextSibling);
867                                }} else {{
868                                    tbl.appendChild(cg);
869                                }}
870                            }} else if (first) {{
871                                tbl.insertBefore(cg, first);
872                            }} else {{
873                                tbl.appendChild(cg);
874                            }}
875                        }}
876
877                        // Normalize number of <col> elements.
878                        var cols = cg.querySelectorAll('col');
879                        if (cols.length !== colCount) {{
880                            cg.innerHTML = '';
881                            for (var i = 0; i < colCount; i++) {{
882                                cg.appendChild(document.createElement('col'));
883                            }}
884                        }}
885                        return cg;
886                    }}
887
888                    function initColumnWidths(tbl) {{
889                        var colCount = firstRowCellCount(tbl);
890                        if (colCount <= 0) return null;
891                        var cg = ensureColGroup(tbl, colCount);
892                        if (!cg) return null;
893                        var cols = cg.querySelectorAll('col');
894
895                        // Lock initial widths only if not already explicit.
896                        for (var i = 0; i < cols.length; i++) {{
897                            if (!cols[i].style.width) {{
898                                var cell = (tbl.rows[0] && tbl.rows[0].cells[i]) ? tbl.rows[0].cells[i] : null;
899                                if (cell) {{
900                                    var r = cell.getBoundingClientRect();
901                                    cols[i].style.width = Math.max(MIN_COL_W, Math.round(r.width)) + 'px';
902                                }}
903                            }}
904                        }}
905                        return cg;
906                    }}
907
908                    function isInRightEdgeZone(cell, x) {{
909                        if (!cell) return false;
910                        var r = cell.getBoundingClientRect();
911                        return Math.abs(r.right - x) <= EDGE_PX;
912                    }}
913
914                    function isInBottomEdgeZone(cell, y) {{
915                        if (!cell) return false;
916                        var r = cell.getBoundingClientRect();
917                        return Math.abs(r.bottom - y) <= EDGE_PX;
918                    }}
919
920                    function findResizeTarget(ev) {{
921                        var cell = closestCell(ev.target);
922                        if (!cell) return null;
923                        var tbl = getTableFromCell(cell);
924                        if (!tbl) return null;
925
926                        // Ignore nested tables (choose the closest table of the cell).
927                        var x = ev.clientX;
928                        var y = ev.clientY;
929
930                        // Priority: column resize > row resize
931                        if (isInRightEdgeZone(cell, x)) {{
932                            return {{ mode: 'col', table: tbl, cell: cell }};
933                        }}
934                        if (isInBottomEdgeZone(cell, y)) {{
935                            var tr = cell.parentElement;
936                            if (tr && tr.tagName === 'TR') {{
937                                return {{ mode: 'row', table: tbl, row: tr, cell: cell }};
938                            }}
939                        }}
940                        return null;
941                    }}
942
943                    function startColResize(tbl, cell, ev) {{
944                        var cg = initColumnWidths(tbl);
945                        if (!cg) return false;
946
947                        var idx = (typeof cell.cellIndex === 'number') ? cell.cellIndex : -1;
948                        if (idx < 0) return false;
949
950                        var cols = cg.querySelectorAll('col');
951                        if (idx >= cols.length) return false;
952
953                        table = tbl;
954                        colIndex = idx;
955                        colEl = cols[idx];
956                        startX = ev.clientX;
957                        var cellRect = cell.getBoundingClientRect();
958                        startColW = Math.max(MIN_COL_W, Math.round(cellRect.width));
959                        startTableW = Math.round(tbl.getBoundingClientRect().width);
960
961                        // Freeze layout so only the target column changes.
962                        try {{
963                            tbl.classList.add('marco-resize-active');
964                            tbl.style.tableLayout = 'fixed';
965                            tbl.style.width = startTableW + 'px';
966                        }} catch(_e) {{
967                            // ignore
968                        }}
969
970                        // Ensure the col reflects our start width.
971                        colEl.style.width = startColW + 'px';
972
973                        mode = 'col';
974                        active = true;
975                        return true;
976                    }}
977
978                    function startRowResize(tr, ev) {{
979                        rowEl = tr;
980                        startY = ev.clientY;
981                        startRowH = Math.round(tr.getBoundingClientRect().height);
982                        mode = 'row';
983                        active = true;
984                        return true;
985                    }}
986
987                    function beginResize(ev, target) {{
988                        if (!target) return false;
989                        if (ev.button !== 0) return false;
990
991                        // Prevent text selection / link activation while resizing.
992                        ev.preventDefault();
993                        ev.stopPropagation();
994
995                        if (document && document.body) {{
996                            document.body.classList.add('marco-table-resizing');
997                        }}
998
999                        if (target.mode === 'col') {{
1000                            return startColResize(target.table, target.cell, ev);
1001                        }}
1002                        if (target.mode === 'row') {{
1003                            return startRowResize(target.row, ev);
1004                        }}
1005                        return false;
1006                    }}
1007
1008                    function applyResize(ev) {{
1009                        if (!active) return;
1010                        ev.preventDefault();
1011                        ev.stopPropagation();
1012
1013                        if (mode === 'col' && table && colEl) {{
1014                            var dx = ev.clientX - startX;
1015                            var newW = clamp(startColW + dx, MIN_COL_W, MAX_COL_W);
1016                            colEl.style.width = Math.round(newW) + 'px';
1017
1018                            // Keep other columns stable by changing the overall table width.
1019                            var newTableW = clamp(startTableW + (newW - startColW), MIN_COL_W, MAX_COL_W * 50);
1020                            table.style.width = Math.round(newTableW) + 'px';
1021                            return;
1022                        }}
1023                        if (mode === 'row' && rowEl) {{
1024                            var dy = ev.clientY - startY;
1025                            var newH = clamp(startRowH + dy, MIN_ROW_H, MAX_ROW_H);
1026                            rowEl.style.height = Math.round(newH) + 'px';
1027                            return;
1028                        }}
1029                    }}
1030
1031                    function endResize() {{
1032                        if (!active) return;
1033
1034                        // Persist the last resize so it survives smooth preview updates.
1035                        try {{
1036                            function getTableKey(tbl) {{
1037                                var container = document.getElementById("mc-content-container");
1038                                if (!container || !tbl) return null;
1039                                var tables = container.querySelectorAll('table');
1040                                for (var i = 0; i < tables.length; i++) {{
1041                                    if (tables[i] === tbl) return 't' + i;
1042                                }}
1043                                return null;
1044                            }}
1045
1046                            function getRowIndex(tbl, tr) {{
1047                                if (!tbl || !tr) return -1;
1048                                var trs = tbl.querySelectorAll('tr');
1049                                for (var i = 0; i < trs.length; i++) {{
1050                                    if (trs[i] === tr) return i;
1051                                }}
1052                                return -1;
1053                            }}
1054
1055                            if (mode === 'col' && table && colIndex >= 0 && colEl) {{
1056                                var key = getTableKey(table);
1057                                if (key) {{
1058                                    if (!tableSizeState[key]) tableSizeState[key] = {{ cols: [], rows: [] }};
1059                                    tableSizeState[key].cols[colIndex] = colEl.style.width || null;
1060                                    tableSizeState[key].tableWidth = (table.style && table.style.width) ? table.style.width : null;
1061                                }}
1062                            }} else if (mode === 'row' && rowEl) {{
1063                                var t = rowEl.closest ? rowEl.closest('table') : null;
1064                                var key2 = getTableKey(t);
1065                                if (key2 && t) {{
1066                                    if (!tableSizeState[key2]) tableSizeState[key2] = {{ cols: [], rows: [] }};
1067                                    var idx = getRowIndex(t, rowEl);
1068                                    if (idx >= 0) {{
1069                                        tableSizeState[key2].rows[idx] = rowEl.style.height || null;
1070                                    }}
1071                                }}
1072                            }}
1073                        }} catch(e) {{
1074                            console.error('Error persisting table resize state:', e);
1075                        }}
1076
1077                        active = false;
1078                        mode = null;
1079                        colIndex = -1;
1080                        colEl = null;
1081                        rowEl = null;
1082
1083                        if (document && document.body) {{
1084                            document.body.classList.remove('marco-table-resizing');
1085                        }}
1086                        setCursor('');
1087                    }}
1088
1089                    function onMouseMove(ev) {{
1090                        if (active) {{
1091                            applyResize(ev);
1092                            return;
1093                        }}
1094
1095                        var t = findResizeTarget(ev);
1096                        if (t && t.mode === 'col') {{
1097                            setCursor('col-resize');
1098                            return;
1099                        }}
1100                        if (t && t.mode === 'row') {{
1101                            setCursor('row-resize');
1102                            return;
1103                        }}
1104                        setCursor('');
1105                    }}
1106
1107                    function onMouseDown(ev) {{
1108                        if (active) return;
1109                        var t = findResizeTarget(ev);
1110                        if (t) {{
1111                            beginResize(ev, t);
1112                        }}
1113                    }}
1114
1115                    function onMouseUp(_ev) {{
1116                        endResize();
1117                    }}
1118
1119                    function onKeyDown(ev) {{
1120                        // Escape cancels an active resize.
1121                        if (ev && ev.key === 'Escape') {{
1122                            endResize();
1123                        }}
1124                    }}
1125
1126                    // Install listeners once (event delegation; works across content updates).
1127                    document.addEventListener('mousemove', onMouseMove, true);
1128                    document.addEventListener('mousedown', onMouseDown, true);
1129                    document.addEventListener('mouseup', onMouseUp, true);
1130                    window.addEventListener('blur', endResize, true);
1131                    document.addEventListener('keydown', onKeyDown, true);
1132
1133                    return function uninstall() {{
1134                        try {{
1135                            document.removeEventListener('mousemove', onMouseMove, true);
1136                            document.removeEventListener('mousedown', onMouseDown, true);
1137                            document.removeEventListener('mouseup', onMouseUp, true);
1138                            window.removeEventListener('blur', endResize, true);
1139                            document.removeEventListener('keydown', onKeyDown, true);
1140                        }} catch(_e) {{
1141                            // ignore
1142                        }}
1143                        endResize();
1144                    }};
1145                }}
1146
1147                // Install immediately (listeners are delegated, no per-table init required)
1148                try {{
1149                    tableResizerCleanup = installTableResizer();
1150                }} catch(e) {{
1151                    console.error('Failed to install table resizer:', e);
1152                }}
1153
1154                // Initialize any sliders that are already present in the initial HTML.
1155                // Without this, slider slides default to `display:none` and nothing shows
1156                // until the host app calls setContent()/updateContent().
1157                try {{
1158                    document.addEventListener('DOMContentLoaded', function() {{
1159                        var container = document.getElementById("mc-content-container");
1160                        if (container) {{
1161                            applyStoredTableSizes(container);
1162                            installSliders(container);
1163                        }}
1164                    }});
1165                }} catch(e) {{
1166                    console.error('Failed to auto-init sliders:', e);
1167                }}
1168                
1169                return {{
1170                    setCSS: function(css) {{
1171                        try {{
1172                            var el = document.getElementById("mc-preview-style");
1173                            if (el) {{
1174                                el.innerHTML = css;
1175                            }}
1176                        }} catch(e) {{
1177                            console.error('Error setting CSS:', e);
1178                        }}
1179                    }},
1180                    
1181                    setTheme: function(mode) {{
1182                        try {{
1183                            document.documentElement.className = mode;
1184                        }} catch(e) {{
1185                            console.error('Error setting theme:', e);
1186                        }}
1187                    }},
1188                    
1189                    updateContent: function(htmlContent) {{
1190                        try {{
1191                            // Clean up any pending scroll restoration (keep interactions installed)
1192                            cleanupScrollRestoration();
1193                            
1194                            // Save current scroll position
1195                            var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
1196                            
1197                            // Update content container
1198                            var container = document.getElementById("mc-content-container");
1199                            if (container) {{
1200                                container.innerHTML = htmlContent;
1201                                applyStoredTableSizes(container);
1202                                installSliders(container);
1203                                
1204                                // Restore scroll position after a brief delay
1205                                var timeoutId = setTimeout(function() {{
1206                                    document.documentElement.scrollTop = scrollTop;
1207                                    document.body.scrollTop = scrollTop;
1208                                    // Remove this timeout from tracking
1209                                    var index = scrollTimeouts.indexOf(timeoutId);
1210                                    if (index > -1) {{
1211                                        scrollTimeouts.splice(index, 1);
1212                                    }}
1213                                }}, 10);
1214                                scrollTimeouts.push(timeoutId);
1215                            }}
1216                        }} catch(e) {{
1217                            console.error('Error updating content:', e);
1218                        }}
1219                    }},
1220                    
1221                    setContent: function(htmlContent) {{
1222                        try {{
1223                            var container = document.getElementById("mc-content-container");
1224                            if (container) {{
1225                                container.innerHTML = htmlContent;
1226                                applyStoredTableSizes(container);
1227                                installSliders(container);
1228                            }}
1229                        }} catch(e) {{
1230                            console.error('Error setting content:', e);
1231                        }}
1232                    }},
1233
1234                    sliders: {{
1235                        playAll: slidersPlayAll,
1236                        pauseAll: slidersPauseAll,
1237                        toggleAll: slidersToggleAll,
1238                        playDeck: slidersPlayDeck,
1239                        pauseDeck: slidersPauseDeck,
1240                        toggleDeck: slidersToggleDeck
1241                    }},
1242                    
1243                    cleanup: cleanup
1244                }};
1245            }})();
1246            
1247            // Cleanup on page unload
1248            window.addEventListener('beforeunload', function() {{
1249                if (window.MarcoCorePreview) {{
1250                    MarcoCorePreview.cleanup();
1251                }}
1252            }});
1253        </script>
1254    </head>
1255    <body>
1256        <div id="mc-content-container">{}</div>
1257    </body>
1258</html>"#,
1259        theme_class, inline_bg_style, css, table_resize_css, body
1260    )
1261}
1262
1263/// Options for CSS paged media rendering via paged.js.
1264///
1265/// Pass this to [`wrap_preview_html_document_paged`] to control page layout.
1266pub struct PageViewOptions<'a> {
1267    /// The full source of the paged.js polyfill (usually `pagedjs::PAGED_POLYFILL_JS`).
1268    pub paged_js_source: &'a str,
1269    /// CSS paper size: `"A4"`, `"Letter"`, `"A3"`, `"A5"`, `"Legal"`, `"B5"`.
1270    pub paper: &'a str,
1271    /// Page orientation: `"portrait"` or `"landscape"`.
1272    pub orientation: &'a str,
1273    /// Page margin in millimetres.
1274    pub margin_mm: u8,
1275    /// Whether to inject a `@page` counter rule so page numbers appear in the margin.
1276    pub show_page_numbers: bool,
1277    /// `<script>` blocks for wheel scaling and scroll-position reporting (bidirectional scroll sync).
1278    /// Pass the combined `wheel_js + SCROLL_REPORT_JS` string, or `""` to disable.
1279    pub wheel_js: &'a str,
1280    /// Number of page columns to show side-by-side (1-4). Values outside this range are clamped.
1281    pub columns_per_row: u8,
1282    /// When `true`, inject a `@media print` CSS block that removes paged.js visual
1283    /// decorations (shadows, gaps, desk background) so pages export cleanly to PDF.
1284    /// Set to `false` for normal preview rendering.
1285    pub for_export: bool,
1286    /// Document title for the HTML `<title>` tag.  Pass `""` to omit the tag.
1287    /// Used for standalone HTML file exports; leave as `""` for live preview.
1288    pub title: &'a str,
1289    /// When `true`, the WebView integration JS (scroll-sync signals and page-ready
1290    /// title hooks) is replaced with a minimal opacity-restore callback.
1291    /// Set to `true` when producing a standalone HTML file; leave `false`
1292    /// for live preview in the editor WebView.
1293    pub standalone_export: bool,
1294}
1295
1296/// Variant of [`wrap_preview_html_document`] that injects paged.js for true CSS Paged Media
1297/// simulation.
1298///
1299/// paged.js restructures the entire DOM into fixed-size page boxes, so **content updates
1300/// in page view mode always require a full HTML reload** - incremental update
1301/// paths are incompatible. The caller is responsible for triggering a full reload.
1302///
1303/// # Arguments
1304/// * `body` — Rendered markdown HTML body (without `<html>`/`<head>` wrapper).
1305/// * `css` — Theme CSS string.
1306/// * `theme_class` — Theme mode string, e.g. `"dark"` or `"light"`.
1307/// * `background_color` — Optional explicit background color for instant dark-mode.
1308/// * `page_opts` — Page layout and paged.js source.
1309pub fn wrap_preview_html_document_paged(
1310    body: &str,
1311    css: &str,
1312    theme_class: &str,
1313    background_color: Option<&str>,
1314    page_opts: &PageViewOptions<'_>,
1315) -> String {
1316    // paged.js requires the document to be served with a real base URI; the
1317    // caller is responsible for that.  We simply build a minimal page wrapper
1318    // that includes the @page rule and the polyfill inline so that paged.js
1319    // runs on DOMContentLoaded (its default auto:true behaviour).
1320
1321    let inline_bg_style = if let Some(bg) = background_color {
1322        format!("body {{ background-color: {} !important; }}\n", bg)
1323    } else {
1324        String::new()
1325    };
1326
1327    // Combine: base structural CSS first, then the theme token overrides.
1328    let css = format!(
1329        "{}\n\n/* ── Theme tokens ── */\n{}",
1330        base_css::base_css(),
1331        css
1332    );
1333
1334    // Build @page CSS rule
1335    let page_size_rule = format!(
1336        "@page {{ size: {} {}; margin: {}mm; }}\n",
1337        page_opts.paper, page_opts.orientation, page_opts.margin_mm
1338    );
1339
1340    // Optional page number counter rule (rendered via CSS generated content).
1341    // Use a CSS custom property so the colour adapts to the active theme; fall
1342    // back to a neutral grey that is readable on both light and dark papers.
1343    // paged.js processes @page rules itself and supports var() resolution.
1344    let page_counter_rule = if page_opts.show_page_numbers {
1345        r#"@page {
1346  @bottom-center {
1347    content: counter(page) " / " counter(pages);
1348    font-size: 0.75em;
1349    color: var(--text-muted, #888);
1350  }
1351}
1352"#
1353    } else {
1354        ""
1355    };
1356
1357    // Clamp columns to 1-4 range.
1358    let columns = page_opts.columns_per_row.clamp(1, 4);
1359
1360    // Multi-column CSS: when columns > 1 switch the page container from a single
1361    // vertical stack to a wrapping flex row so pages appear side-by-side.
1362    let multi_col_css = if columns > 1 {
1363        format!(
1364            r#"
1365/* ── Multi-column layout: {cols} pages per row ─────────────────────────── */
1366.pagedjs_pages {{
1367    flex-direction: row !important;
1368    flex-wrap: wrap !important;
1369    justify-content: center !important;
1370    align-items: flex-start !important;
1371    gap: 2em !important;
1372    padding-left: 1em !important;
1373    padding-right: 1em !important;
1374}}
1375.pagedjs_page {{
1376    margin-bottom: 0 !important;
1377}}
1378"#,
1379            cols = columns
1380        )
1381    } else {
1382        String::new()
1383    };
1384
1385    // paged.js layout overrides.
1386    //
1387    // These rules handle structural/layout concerns only.  Using !important is
1388    // intentional where needed so paged.js cannot override layout rules.
1389    //
1390    // Theme/dark-mode notes:
1391    //   • Each theme CSS file owns `.pagedjs_page { background-color, color }` via
1392    //     CSS custom properties set by .theme-light / .theme-dark on <html>.
1393    //   • The "desk" (the area visible around pages) is a distinct neutral colour so
1394    //     the paper stands out visually.  Desk colour is NOT part of the theme.
1395    let paged_body_css = format!(
1396        r#"
1397/* paged.js: reset every theme layout constraint on html/body */
1398html, body {{
1399    margin: 0 !important;
1400    padding: 0 !important;
1401    max-width: none !important;
1402    width: 100% !important;
1403    box-sizing: border-box !important;
1404}}
1405
1406/* ── Viewport / desk ──────────────────────────────────────────────────────
1407   The body background is the "desk" that surrounds the page boxes.
1408   It must be visually distinct from the paper so pages have contrast.     */
1409body {{
1410    background-color: #d0d0d0 !important;   /* light-mode desk (medium grey) */
1411    min-height: 100vh;
1412}}
1413
1414/* Dark-mode desk: dark grey, clearly separate from typical dark-theme papers */
1415html.theme-dark body {{
1416    background-color: #2b2b2b !important;
1417}}
1418
1419/* ── All-pages container ──────────────────────────────────────────────────
1420   Flex column centres pages horizontally and adds vertical breathing room. */
1421.pagedjs_pages {{
1422    display: flex !important;
1423    flex-direction: column !important;
1424    align-items: center !important;
1425    padding-top: 3em !important;
1426    padding-bottom: 3em !important;
1427    width: 100% !important;
1428    box-sizing: border-box !important;
1429}}
1430
1431/* ── Tell WebKit which color-scheme is active so scrollbars, form controls,
1432   and native UI elements render correctly in dark mode.                    */
1433html.theme-dark {{
1434    color-scheme: dark;
1435}}
1436html.theme-light {{
1437    color-scheme: light;
1438}}
1439
1440/* ── Individual page (paper) ──────────────────────────────────────────────
1441   Shadow and margins are structural — they stay here.
1442   background-color and color are owned by each theme's .pagedjs_page rule,
1443   which cascades from the active .theme-light / .theme-dark variables.    */
1444.pagedjs_page {{
1445    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.20) !important;
1446    margin-bottom: 2em !important;
1447    margin-top: 0 !important;
1448    margin-left: 0 !important;
1449    margin-right: 0 !important;
1450}}
1451
1452/* Dark-mode paper: stronger shadow to separate page from the dark desk. */
1453html.theme-dark .pagedjs_page {{
1454    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.55) !important;
1455}}
1456{multi_col}
1457"#,
1458        multi_col = multi_col_css
1459    );
1460
1461    // Optional @media print overrides for PDF/print export.
1462    // Removes paged.js visual decorations so pages print without gaps or shadows.
1463    let export_css_block: &str = if page_opts.for_export {
1464        concat!(
1465            "        <style id=\"mc-print-export-css\">\n",
1466            "@media print {\n",
1467            "  @page { margin: 0 !important; }\n",
1468            "  html, body { background-color: white !important; }\n",
1469            "  body { background-color: white !important; }\n",
1470            "  .pagedjs_page { box-shadow: none !important; margin-bottom: 0 !important;",
1471            " margin-top: 0 !important; }\n",
1472            "  .pagedjs_pages { padding-top: 0 !important; padding-bottom: 0 !important; }\n",
1473            "}\n",
1474            "        </style>\n",
1475        )
1476    } else {
1477        ""
1478    };
1479
1480    // Title tag: only emitted when a non-empty title is provided.
1481    let title_tag = if page_opts.title.is_empty() {
1482        String::new()
1483    } else {
1484        // Escape HTML special characters to prevent XSS / malformed HTML.
1485        let escaped = page_opts
1486            .title
1487            .replace('&', "&amp;")
1488            .replace('<', "&lt;")
1489            .replace('>', "&gt;");
1490        format!("        <title>{}</title>\n", escaped)
1491    };
1492
1493    // JavaScript integration block: WebKit-specific hooks for live preview vs.
1494    // minimal opacity-restore for standalone HTML file exports.
1495    let integration_js = if page_opts.standalone_export {
1496        // Standalone HTML: paged.js just restores opacity when done.
1497        // No WebKit scroll-sync signals, no document.title tricks.
1498        r#"window.PagedConfig = {
1499    auto: true,
1500    after: function() {
1501        document.body.style.transition = 'opacity 0.12s ease-in';
1502        document.body.style.opacity = '1';
1503    }
1504};
1505/* Safety net: reveal the page if after() never fires. */
1506setTimeout(function() {
1507    if (document.body.style.opacity !== '1') {
1508        document.body.style.opacity = '1';
1509    }
1510}, 8000);"#
1511    } else {
1512        // Live preview: WebKit integration hooks (scroll-sync, title polling).
1513        r#"/* Must be set BEFORE paged.js script evaluates so Ym reads these values. */
1514window.__pagedJsReady = false;
1515window.PagedConfig = {
1516    auto: true,
1517    /* paged.js calls after() once layout is fully complete — this is the
1518       official hook and avoids all manual-preview() timing issues.        */
1519    after: function() {
1520        document.body.style.transition = 'opacity 0.12s ease-in';
1521        document.body.style.opacity = '1';
1522        /* Arm baseline guard so the initial scroll-position-0 the webview
1523           reports right after layout is silently cached, not forwarded to
1524           the editor (which would yank the editor caret to the top).      */
1525        window.__pagedJsJustReady = true;
1526        /* Tell Rust scroll-sync to restore the preview scroll to where the
1527           editor cursor currently is.                                      */
1528        document.title = 'mc_paged_ready';
1529        window.__pagedJsReady = true;
1530        setTimeout(function() { window.__pagedJsJustReady = false; }, 500);
1531    }
1532};"#
1533    };
1534
1535    // Safety-net block is only needed for the live WebKit preview.
1536    let safety_net_js = if page_opts.standalone_export {
1537        "" // already inlined in integration_js above
1538    } else {
1539        r#"
1540        <script>
1541/* Safety net: reveal the page if the after() hook never fires (e.g. empty
1542   document or paged.js internal error).                                   */
1543setTimeout(function() {
1544    if (!window.__pagedJsReady) {
1545        document.body.style.opacity = '1';
1546        window.__pagedJsJustReady = true;
1547        document.title = 'mc_paged_ready';
1548        window.__pagedJsReady = true;
1549        setTimeout(function() { window.__pagedJsJustReady = false; }, 500);
1550    }
1551}, 8000);
1552        </script>"#
1553    };
1554
1555    format!(
1556        r#"<!DOCTYPE html>
1557<html class="{}">
1558    <head>
1559        <meta charset="UTF-8">
1560        <meta name="viewport" content="width=device-width, initial-scale=1">
1561{}        <style id="mc-paged-page-css">
1562{}{}{}
1563        </style>
1564        <style id="mc-preview-style">
1565{}{}
1566        </style>
1567{}    </head>
1568    <body style="opacity:0">
1569        <div id="mc-content-container">{}</div>
1570        <script>
1571{}
1572        </script>
1573        <script>
1574{}
1575        </script>
1576        {}{}
1577    </body>
1578</html>"#,
1579        theme_class,
1580        title_tag,
1581        page_size_rule,
1582        page_counter_rule,
1583        paged_body_css,
1584        inline_bg_style,
1585        css,
1586        export_css_block,
1587        body,
1588        integration_js,
1589        page_opts.paged_js_source,
1590        page_opts.wheel_js,
1591        safety_net_js,
1592    )
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598
1599    #[test]
1600    fn smoke_wrap_preview_contains_expected_hooks() {
1601        let doc = wrap_preview_html_document(
1602            "<table><tr><td>a</td></tr></table>",
1603            "body { color: red; }",
1604            "dark",
1605            Some("#000000"),
1606        );
1607        assert!(doc.contains("id=\\\"mc-preview-style\\\""));
1608        assert!(doc.contains("id=\\\"mc-preview-internal-style\\\""));
1609        assert!(doc.contains("window.MarcoCorePreview"));
1610        assert!(doc.contains("installTableResizer"));
1611        assert!(doc.contains("installSliders"));
1612        assert!(doc.contains("sliders:"));
1613        assert!(doc.contains("content-container"));
1614    }
1615
1616    #[test]
1617    fn smoke_wrap_preview_paged_contains_page_css() {
1618        let opts = PageViewOptions {
1619            paged_js_source: "/* paged.js stub */",
1620            paper: "A4",
1621            orientation: "portrait",
1622            margin_mm: 20,
1623            show_page_numbers: true,
1624            wheel_js: "",
1625            columns_per_row: 1,
1626            for_export: false,
1627            title: "",
1628            standalone_export: false,
1629        };
1630        let doc = wrap_preview_html_document_paged(
1631            "<p>Hello</p>",
1632            "body { color: red; }",
1633            "light",
1634            None,
1635            &opts,
1636        );
1637        assert!(doc.contains("@page"));
1638        assert!(doc.contains("A4 portrait"));
1639        assert!(doc.contains("20mm"));
1640        assert!(doc.contains("counter(page)"));
1641        assert!(doc.contains("content-container"));
1642        assert!(doc.contains("paged.js stub"));
1643    }
1644
1645    #[test]
1646    fn smoke_paged_multi_column_css_injected() {
1647        let opts = PageViewOptions {
1648            paged_js_source: "/* paged.js stub */",
1649            paper: "A4",
1650            orientation: "portrait",
1651            margin_mm: 20,
1652            show_page_numbers: false,
1653            wheel_js: "",
1654            columns_per_row: 2,
1655            for_export: false,
1656            title: "",
1657            standalone_export: false,
1658        };
1659        let doc = wrap_preview_html_document_paged("<p>Test</p>", "", "light", None, &opts);
1660        // Multi-column layout CSS must be injected when columns_per_row > 1
1661        assert!(
1662            doc.contains("flex-direction: row"),
1663            "expected flex-direction: row for multi-column"
1664        );
1665        assert!(
1666            doc.contains("flex-wrap: wrap"),
1667            "expected flex-wrap: wrap for multi-column"
1668        );
1669    }
1670
1671    #[test]
1672    fn smoke_paged_single_column_no_multi_col_css() {
1673        let opts = PageViewOptions {
1674            paged_js_source: "/* paged.js stub */",
1675            paper: "A4",
1676            orientation: "portrait",
1677            margin_mm: 20,
1678            show_page_numbers: false,
1679            wheel_js: "",
1680            columns_per_row: 1,
1681            for_export: false,
1682            title: "",
1683            standalone_export: false,
1684        };
1685        let doc = wrap_preview_html_document_paged("<p>Test</p>", "", "light", None, &opts);
1686        // Single-column: the multi-column layout comment must NOT be present
1687        assert!(
1688            !doc.contains("pages per row"),
1689            "single-column should not have multi-column override"
1690        );
1691    }
1692}