1use super::base_css;
2
3pub fn wrap_preview_html_document(
17 body: &str,
18 css: &str,
19 theme_class: &str,
20 background_color: Option<&str>,
21) -> String {
22 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 let css = format!(
31 "{}\n\n/* ── Theme tokens ── */\n{}",
32 base_css::base_css(),
33 css
34 );
35
36 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 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
1270pub struct PageViewOptions<'a> {
1274 pub paged_js_source: &'a str,
1276 pub paper: &'a str,
1278 pub orientation: &'a str,
1280 pub margin_mm: u8,
1282 pub show_page_numbers: bool,
1284 pub wheel_js: &'a str,
1287 pub columns_per_row: u8,
1289 pub for_export: bool,
1293 pub title: &'a str,
1296 pub standalone_export: bool,
1301}
1302
1303pub 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 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 let css = format!(
1336 "{}\n\n/* ── Theme tokens ── */\n{}",
1337 base_css::base_css(),
1338 css
1339 );
1340
1341 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 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 let columns = page_opts.columns_per_row.clamp(1, 4);
1366
1367 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 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 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 let title_tag = if page_opts.title.is_empty() {
1489 String::new()
1490 } else {
1491 let escaped = page_opts
1493 .title
1494 .replace('&', "&")
1495 .replace('<', "<")
1496 .replace('>', ">");
1497 format!(" <title>{}</title>\n", escaped)
1498 };
1499
1500 let integration_js = if page_opts.standalone_export {
1503 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 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 let safety_net_js = if page_opts.standalone_export {
1544 "" } 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 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 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 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}