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