@markdstage/markdstage 3.4.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,13 @@ import { sceneToPptxElements } from "./scene-pptx.mjs";
6
6
  import { attachArchitectureEditor } from "./architecture-editor.mjs";
7
7
  import {
8
8
  DEFAULT_THEME,
9
+ mermaidC4ThemeVariables,
9
10
  mermaidThemeVariables,
10
11
  normalizeTheme,
11
12
  parseFrontMatter,
13
+ resolveThemeBackground,
12
14
  } from "./theme.mjs";
15
+ import { parseSlideBackground } from "./slide-background.mjs";
13
16
  import {
14
17
  extractSpeakerNotes,
15
18
  speakerNotesToPlainText,
@@ -17,6 +20,7 @@ import {
17
20
  stripSpeakerNotes,
18
21
  } from "./speaker-notes.mjs";
19
22
  import { splitImportPath } from "./import-path.mjs";
23
+ import { createSlideViewport, OUTPUT_WIDTH, OUTPUT_HEIGHT } from "./slide-viewport.mjs";
20
24
 
21
25
  // Client-side slide renderer for the MarkdStage canvas.
22
26
  //
@@ -102,6 +106,11 @@ let previewMode = false;
102
106
  let previewOffset = 0;
103
107
  let navigationEnabled = true;
104
108
  let fixedPreviewMode = false;
109
+ let surfaceMode = false;
110
+ let outputViewport = null;
111
+ let currentViewport = null;
112
+ let nextViewport = null;
113
+ let surfaceDiagnostic = null;
105
114
  let moreControlsOpen = false;
106
115
  // Markdown for the most recently rendered slide, retained for editing-mode rerenders.
107
116
  let lastMarkdown = "";
@@ -119,8 +128,6 @@ let layoutFrame = 0;
119
128
  // past clientHeight, which is invisible but still enough for `overflow:auto` to
120
129
  // draw a scrollbar.
121
130
  const SCROLL_EPSILON = 2;
122
- const OUTPUT_WIDTH = 1280;
123
- const OUTPUT_HEIGHT = 720;
124
131
  const PPTX_LAYOUT_NAMES = ["title", "default", "center", "section", "backcover"];
125
132
  const LAYOUT_HINT_LIMIT = 5;
126
133
 
@@ -265,11 +272,8 @@ function elementPath(element, root) {
265
272
  return parts.join(" > ");
266
273
  }
267
274
 
268
- // The live 16:9 preview scales #stage with a CSS transform, so getBoundingClientRect()
269
- // reports scaled pixels while scrollWidth/clientWidth stay in the untransformed 1280x720
270
- // layout space. Every rect-derived delta is divided by this factor before the two kinds of
271
- // measurement are combined, keeping the preview, PDF, and PNG diagnostics in one coordinate
272
- // system.
275
+ // Normalize any content-authored transform before combining rect and scroll metrics.
276
+ // Display scaling happens outside the fixed browsing context and is not measured here.
273
277
  function layoutScale(deck) {
274
278
  const width = deck.offsetWidth;
275
279
  if (!width) return 1;
@@ -427,7 +431,9 @@ function updateFixedPreviewWarning() {
427
431
  const warning = document.getElementById("layoutWarning");
428
432
  const button = document.getElementById("navFixedPreview");
429
433
  const empty = document.body.classList.contains("markdstage-empty");
430
- if (!fixedPreviewMode || !layoutTarget || empty) {
434
+ const diagnostic = fixedPreviewMode && !presenterMode &&
435
+ (surfaceDiagnostic || (layoutTarget && collectSlideLayout(layoutTarget, navIndex)));
436
+ if (!fixedPreviewMode || !diagnostic || empty || presenterMode) {
431
437
  document.body.classList.remove("fixed-preview-overflow");
432
438
  if (warning) {
433
439
  warning.hidden = true;
@@ -438,7 +444,6 @@ function updateFixedPreviewWarning() {
438
444
  return;
439
445
  }
440
446
 
441
- const diagnostic = collectSlideLayout(layoutTarget, navIndex);
442
447
  document.body.classList.toggle("fixed-preview-overflow", diagnostic.pdfClipped);
443
448
  if (button) button.dataset.state = diagnostic.pdfClipped ? "error" : "active";
444
449
  syncMoreControls();
@@ -459,20 +464,17 @@ function updateFixedPreviewWarning() {
459
464
  warning.hidden = false;
460
465
  }
461
466
 
462
- function updateFixedPreviewScale() {
463
- if (!fixedPreviewMode) return;
464
- const availableWidth = Math.max(1, window.innerWidth - 24);
465
- const availableHeight = Math.max(1, window.innerHeight - 88);
466
- const scale = Math.min(availableWidth / OUTPUT_WIDTH, availableHeight / OUTPUT_HEIGHT, 1);
467
- document.body.style.setProperty("--fixed-preview-scale", String(scale));
468
- }
469
-
470
467
  function refreshLayout() {
471
468
  const target = layoutTarget;
472
469
  if (!target || !target.deck.isConnected) return;
473
470
  if (target.autoSize) applyAutoSize(target.deck, target.bodyEl);
474
471
  updateBodyScroll(target.bodyEl);
475
472
  updateFixedPreviewWarning();
473
+ if (surfaceMode && !document.body.classList.contains("mermaid-loading")) {
474
+ window.frameElement?.dispatchEvent(new CustomEvent("slide-layout", {
475
+ detail: collectSlideLayout(target, navIndex),
476
+ }));
477
+ }
476
478
  }
477
479
 
478
480
  // Coalesce the (re)layout into one frame: several triggers — render, resize,
@@ -583,13 +585,19 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
583
585
  return Promise.resolve();
584
586
  }
585
587
  try {
586
- const themeVariables = mermaidThemeVariables(getComputedStyle(deckEl));
588
+ const sources = [...nodes].map((node) => node.textContent || "");
589
+ const themeVariables = mermaidThemeVariables(
590
+ getComputedStyle(deckEl),
591
+ (value) => resolveModelColor(value, deckEl),
592
+ );
593
+ const c4ThemeVariables = mermaidC4ThemeVariables(themeVariables);
587
594
  const serializedThemeVariables = JSON.stringify(themeVariables);
588
595
  if (serializedThemeVariables !== lastMermaidThemeVariables) {
589
596
  window.mermaid.initialize({
590
597
  startOnLoad: false,
591
598
  theme: "base",
592
599
  themeVariables,
600
+ c4: c4ThemeVariables,
593
601
  securityLevel: "strict",
594
602
  });
595
603
  lastMermaidThemeVariables = serializedThemeVariables;
@@ -601,11 +609,60 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
601
609
  const source = host.querySelector("svg");
602
610
  if (!source || source.hasAttribute("data-scene-backend")) continue;
603
611
  try {
612
+ repairC4ThemeDefaults(source, sources[index], themeVariables);
604
613
  renderMermaidScene(source, deckEl, index);
605
614
  } catch (e) {
606
615
  console.error("Mermaid scene render failed", e);
607
616
  }
608
617
  }
618
+
619
+ // Mermaid 11.15.0 does not expose theme variables for C4 boundary/relation
620
+ // paint or its arrow markers. Repair only those known renderer defaults, and
621
+ // leave diagrams with explicit C4 style updates untouched so source colors
622
+ // remain authoritative.
623
+ function repairC4ThemeDefaults(svg, source, themeVariables) {
624
+ if (svg.getAttribute("aria-roledescription") !== "c4") return;
625
+ const commands = source.replace(/^\s*%%.*$/gm, "");
626
+ const hasElementStyles = /\bUpdateElementStyle\s*\(/i.test(commands);
627
+ const hasRelationStyles = /\bUpdateRelStyle\s*\(/i.test(commands);
628
+ const boundaryGroups = new Set(
629
+ [...svg.querySelectorAll('rect[stroke-dasharray="7.0,7.0"], rect[fill="none"]')]
630
+ .map((element) => element.parentElement),
631
+ );
632
+ const relationGroups = new Set(
633
+ [...svg.querySelectorAll("[marker-end], [marker-start]")].map((element) => element.parentElement),
634
+ );
635
+ const boundaryPaint = new Set(["#444", "#444444", "rgb(68, 68, 68)"]);
636
+ const relationPaint = new Set(["#444", "#444444", "rgb(68, 68, 68)"]);
637
+ const setDefaultPaint = (element, property, defaults, value) => {
638
+ const current = element.getAttribute(property)?.trim().toLowerCase();
639
+ if (!defaults.has(current)) return;
640
+ element.setAttribute(property, value);
641
+ if (element.style?.getPropertyValue(property)) {
642
+ element.style.setProperty(property, value);
643
+ }
644
+ };
645
+
646
+ for (const element of svg.querySelectorAll("rect, line, path, text")) {
647
+ const parent = element.parentElement;
648
+ if (!hasElementStyles && boundaryGroups.has(parent)) {
649
+ setDefaultPaint(element, "stroke", boundaryPaint, themeVariables.lineColor);
650
+ setDefaultPaint(element, "fill", boundaryPaint, themeVariables.textColor);
651
+ }
652
+ if (!hasRelationStyles && relationGroups.has(parent)) {
653
+ setDefaultPaint(element, "stroke", relationPaint, themeVariables.lineColor);
654
+ setDefaultPaint(element, "fill", relationPaint, themeVariables.textColor);
655
+ }
656
+ }
657
+ if (!hasRelationStyles) {
658
+ for (const marker of svg.querySelectorAll("marker")) {
659
+ for (const element of marker.querySelectorAll("path, polygon, line")) {
660
+ setDefaultPaint(element, "stroke", new Set(["black", "#000", "#000000", "rgb(0, 0, 0)"]), themeVariables.lineColor);
661
+ setDefaultPaint(element, "fill", new Set([undefined, "black", "#000", "#000000", "rgb(0, 0, 0)"]), themeVariables.lineColor);
662
+ }
663
+ }
664
+ }
665
+ }
609
666
  })
610
667
  .finally(reveal);
611
668
  } catch (e) {
@@ -615,26 +672,28 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
615
672
  }
616
673
  }
617
674
 
675
+ function withoutMermaidLoadingVeil(callback) {
676
+ const loading = document.body.classList.contains("mermaid-loading");
677
+ if (loading) document.body.classList.remove("mermaid-loading");
678
+ try {
679
+ return callback();
680
+ } finally {
681
+ if (loading) document.body.classList.add("mermaid-loading");
682
+ }
683
+ }
684
+
618
685
  function renderMermaidScene(svg, deck, blockIndex) {
619
- const result = mermaidSvgToScene(svg, {
620
- path: `mermaid[${blockIndex}]`,
621
- deck,
622
- includeSourceElements: true,
623
- resolveColor: (value) => resolveModelColor(value, deck),
624
- });
686
+ const result = withoutMermaidLoadingVeil(() =>
687
+ mermaidSvgToScene(svg, {
688
+ path: `mermaid[${blockIndex}]`,
689
+ deck,
690
+ includeSourceElements: true,
691
+ resolveColor: (value) => resolveModelColor(value, deck),
692
+ }),
693
+ );
625
694
  let { scene } = result;
626
- const computedStyle = (element) => {
627
- const style = getComputedStyle(element);
628
- return {
629
- getPropertyValue(property) {
630
- // The loading veil is inherited by every SVG descendant, not diagram style.
631
- if (property === "visibility" && document.body.classList.contains("mermaid-loading")) {
632
- return element.style?.visibility || element.getAttribute("visibility") || "";
633
- }
634
- return style.getPropertyValue(property);
635
- },
636
- };
637
- };
695
+ const capture = (element, options) =>
696
+ withoutMermaidLoadingVeil(() => captureSvgTree(element, options));
638
697
  const slots = new Map();
639
698
  try {
640
699
  for (const [index, node] of scene.nodes.entries()) {
@@ -657,7 +716,7 @@ function renderMermaidScene(svg, deck, blockIndex) {
657
716
  node.meta = { ...node.meta, svgOwner: slots.get(source) };
658
717
  continue;
659
718
  }
660
- node.meta = { ...node.meta, svg: captureSvgTree(source, { computedStyle }) };
719
+ node.meta = { ...node.meta, svg: capture(source) };
661
720
  slots.set(source, index);
662
721
  }
663
722
  for (const [source, index] of slots) {
@@ -676,14 +735,14 @@ function renderMermaidScene(svg, deck, blockIndex) {
676
735
  kind: "fallback", sourcePath: "svg", z: 0,
677
736
  bounds: { x: 0, y: 0, width: scene.width, height: scene.height },
678
737
  capability: { pptx: "fallback", reason }, reason,
679
- meta: { svg: captureSvgTree(svg, { computedStyle }) },
738
+ meta: { svg: capture(svg) },
680
739
  }] };
681
740
  slots.clear();
682
741
  slots.set(svg, 0);
683
742
  }
684
743
  const template = slots.has(svg)
685
744
  ? { sceneNode: slots.get(svg) }
686
- : captureSvgTree(svg, { slots, computedStyle });
745
+ : capture(svg, { slots });
687
746
  scene.meta = { ...scene.meta, svgRoot: template };
688
747
  svg.replaceWith(sceneToSvg(scene, { document, template }));
689
748
  }
@@ -698,7 +757,12 @@ function moveLeadingSlideTitle(header, bodyEl, specialLayout) {
698
757
  return title;
699
758
  }
700
759
 
701
- function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
760
+ function createSlide(
761
+ markdown,
762
+ fallbackTheme,
763
+ themeLocked = deckThemeLocked,
764
+ { backgroundImage: backgroundOverride } = {},
765
+ ) {
702
766
  const placeholder = !nonEmpty(markdown);
703
767
  const md = placeholder ? PLACEHOLDER : markdown;
704
768
  const { meta, body: rawBody } = splitFrontMatter(md);
@@ -721,6 +785,11 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
721
785
  // as well as <html> so print mode can render differently themed pages together.
722
786
  const theme = normalizeTheme(themeLocked ? fallbackTheme : meta.theme || fallbackTheme);
723
787
  const themeMetadata = theme === "custom" ? customThemeMeta : null;
788
+ const backgroundImage = parseSlideBackground(backgroundOverride ?? meta["background-image"]);
789
+ const backgroundUrl = backgroundImage
790
+ ? localAssetUrl(backgroundImage.replace(/^\/assets\//, "/background-assets/")
791
+ .split("/").map(encodeURIComponent).join("/"))
792
+ : "";
724
793
 
725
794
  const deck = document.createElement("div");
726
795
  deck.className = "deck";
@@ -732,13 +801,18 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
732
801
  if (placeholder) deck.classList.add("markdstage-placeholder");
733
802
  if (sizeMode !== "auto") setSizeLevel(deck, sizeMode);
734
803
 
804
+ const background = themeImage(
805
+ backgroundUrl
806
+ ? { image: backgroundUrl }
807
+ : resolveThemeBackground(themeMetadata, layout),
808
+ titleSlide ? "slide-background theme-cover-background" : "slide-background",
809
+ { decorative: true },
810
+ );
811
+ if (background) {
812
+ deck.classList.add("has-slide-background");
813
+ deck.appendChild(background);
814
+ }
735
815
  if (titleSlide) {
736
- const background = themeImage(
737
- themeMetadata?.cover?.background,
738
- "theme-cover-background",
739
- { decorative: true },
740
- );
741
- if (background) deck.appendChild(background);
742
816
  const logo = themeImage(themeMetadata?.cover?.logo, "theme-cover-logo");
743
817
  if (logo) deck.appendChild(logo);
744
818
  }
@@ -870,6 +944,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
870
944
  deck,
871
945
  bodyEl,
872
946
  theme,
947
+ backgroundImage,
873
948
  sizeMode,
874
949
  titleSlide,
875
950
  sectionSlide,
@@ -889,6 +964,19 @@ function renderSlide(markdown) {
889
964
  architectureEditors = [];
890
965
  applyCustomThemeCss(customThemeCss);
891
966
  const token = ++renderToken;
967
+ if (!surfaceMode && (fixedPreviewMode || presenterViewOpen)) {
968
+ const { meta } = splitFrontMatter(lastMarkdown);
969
+ document.title = meta.title || meta.deck || "Slide";
970
+ document.documentElement.setAttribute("data-theme",
971
+ deckThemeLocked ? deckTheme : normalizeTheme(meta.theme || deckTheme));
972
+ layoutTarget = null;
973
+ surfaceDiagnostic = null;
974
+ if (layoutFrame) cancelAnimationFrame(layoutFrame);
975
+ layoutFrame = 0;
976
+ if (!presenterViewRequested || !presenterViewAvailable || navTotal <= 0) updateSlideViewports();
977
+ updateFixedPreviewWarning();
978
+ return;
979
+ }
892
980
  const slide = createSlide(markdown, deckTheme);
893
981
  document.title = slide.title;
894
982
  document.documentElement.setAttribute("data-theme", slide.theme);
@@ -917,14 +1005,30 @@ function renderSlide(markdown) {
917
1005
  // that the slide is fully painted, and PDF export and the visual regression
918
1006
  // suite rely on it. Revealing while an architecture icon under `assets/` is
919
1007
  // still loading would capture a half-drawn slide.
920
- const images = waitForImages(slide.deck).then(() => {
921
- if (token === renderToken) scheduleLayoutRefresh();
922
- });
923
- const mermaid = runMermaid(slide.bodyEl, slide.deck, token, false).finally(() => {
1008
+ const images = waitForImages(slide.deck)
1009
+ .then(() => {
1010
+ if (token === renderToken) scheduleLayoutRefresh();
1011
+ })
1012
+ .catch((error) => {
1013
+ if (token !== renderToken) return;
1014
+ console.error(error.message);
1015
+ if (surfaceMode) {
1016
+ window.frameElement?.dispatchEvent(new CustomEvent("slide-error", { detail: error.message }));
1017
+ } else {
1018
+ showExportNotification("error", error.message);
1019
+ }
1020
+ });
1021
+ const mermaid = Promise.resolve(document.fonts?.ready).then(() => {
1022
+ if (token === renderToken) return runMermaid(slide.bodyEl, slide.deck, token, false);
1023
+ }).finally(() => {
924
1024
  if (token === renderToken) scheduleLayoutRefresh();
925
1025
  });
926
- Promise.all([mermaid, images]).finally(() => {
927
- if (token === renderToken) document.body.classList.remove("mermaid-loading");
1026
+ Promise.all([mermaid, images]).finally(async () => {
1027
+ await afterLayout();
1028
+ if (token === renderToken) {
1029
+ document.body.classList.remove("mermaid-loading");
1030
+ refreshLayout();
1031
+ }
928
1032
  });
929
1033
  }
930
1034
 
@@ -961,7 +1065,14 @@ function waitForImages(root) {
961
1065
  }),
962
1066
  );
963
1067
  }
964
- return Promise.all(pending);
1068
+ return Promise.all(pending).then(() => {
1069
+ const failedBackground = [...root.querySelectorAll("img.slide-background")].find(
1070
+ (image) => !image.naturalWidth,
1071
+ );
1072
+ if (failedBackground) {
1073
+ throw new Error(`Could not load slide background: ${failedBackground.getAttribute("src")}`);
1074
+ }
1075
+ });
965
1076
  }
966
1077
 
967
1078
  async function reportOutputStatus(token, status, error = "", layout = null) {
@@ -2033,7 +2144,7 @@ function markMermaidNativeElements(svg, mappedElements, pathPrefix, sourceElemen
2033
2144
  source = sourceElements.get(ownerPath);
2034
2145
  }
2035
2146
  if (source && !source.hasAttribute("data-pptx-native")) {
2036
- const nativeKind = element.type === "shape" ? "shape" : element.type;
2147
+ const nativeKind = element.mermaid?.nativeMask || (element.type === "shape" ? "shape" : element.type);
2037
2148
  source.setAttribute("data-pptx-native", nativeKind);
2038
2149
  }
2039
2150
  if (!sourceElements && element.type === "connector") {
@@ -2098,10 +2209,17 @@ function collectMermaidObjects(element, deck, blockIndex) {
2098
2209
  mermaidElementForSourcePath(svg, sourcePath) ||
2099
2210
  mermaidFallbackElementForBounds(svg, deck, fallback) ||
2100
2211
  svg;
2101
- const sourceBounds = source.getBoundingClientRect();
2102
- const padding = ["path", "line", "polyline"].includes(source.localName)
2103
- ? Math.max(1, (fallback.width - sourceBounds.width) / 2, (fallback.height - sourceBounds.height) / 2)
2104
- : 0;
2212
+ const sourceBounds = fallbackBounds(source, deck, 0, true);
2213
+ const inferredPadding = Math.max(
2214
+ 0,
2215
+ (fallback.width - sourceBounds.width) / 2,
2216
+ (fallback.height - sourceBounds.height) / 2,
2217
+ );
2218
+ const geometryPadding = ["path", "line", "polyline"].includes(source.localName)
2219
+ ? Math.max(1, inferredPadding)
2220
+ : inferredPadding;
2221
+ const effectPadding = subtreeEffectPaintPadding(source);
2222
+ const padding = Math.max(geometryPadding, effectPadding);
2105
2223
  const captured = pptxFallback("mermaid", source, deck, fallback.reason, {
2106
2224
  captureElement: source,
2107
2225
  includeDescendants: true,
@@ -2113,10 +2231,14 @@ function collectMermaidObjects(element, deck, blockIndex) {
2113
2231
  path: fallback.path,
2114
2232
  sourcePath,
2115
2233
  reason: fallback.reason,
2116
- x: fallback.x,
2117
- y: fallback.y,
2118
- width: fallback.width,
2119
- height: fallback.height,
2234
+ ...(effectPadding
2235
+ ? {}
2236
+ : {
2237
+ x: fallback.x,
2238
+ y: fallback.y,
2239
+ width: fallback.width,
2240
+ height: fallback.height,
2241
+ }),
2120
2242
  zOrder: fallback.zOrder,
2121
2243
  ...(node?.id ? { id: node.id } : {}),
2122
2244
  ...(fallback.artwork === false ? { artwork: false } : {}),
@@ -2125,7 +2247,7 @@ function collectMermaidObjects(element, deck, blockIndex) {
2125
2247
  return { elements: mapped.elements, fallbacks };
2126
2248
  }
2127
2249
 
2128
- async function collectPptxSlide(slide, index) {
2250
+ async function collectPptxSlide(slide, index, options = {}) {
2129
2251
  const { deck } = slide;
2130
2252
  assignPptxPaintOrder(deck);
2131
2253
  const elements = [];
@@ -2232,6 +2354,12 @@ async function collectPptxSlide(slide, index) {
2232
2354
  for (const [blockIndex, element] of [...deck.querySelectorAll("pre.mermaid")].entries()) {
2233
2355
  const covered = [...fallbackRoots].some((root) => root === element || root.contains(element));
2234
2356
  if (covered) continue;
2357
+ if (options.mermaidImageFallback === true) {
2358
+ // The scroll container clips the diagram; SVG definitions have no painted bounds.
2359
+ // The capture pipeline trims transparent space inside this safe envelope.
2360
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2361
+ continue;
2362
+ }
2235
2363
  try {
2236
2364
  const mermaid = collectMermaidObjects(element, deck, blockIndex);
2237
2365
  elements.push(...mermaid.elements);
@@ -2496,7 +2624,7 @@ async function collectPptxSlide(slide, index) {
2496
2624
  for (const image of deck.querySelectorAll("img")) {
2497
2625
  if (
2498
2626
  image.closest(".architecture-diagram") ||
2499
- image.classList.contains("theme-cover-background") ||
2627
+ image.classList.contains("slide-background") ||
2500
2628
  image.classList.contains("theme-cover-logo") ||
2501
2629
  insideFallback(image)
2502
2630
  ) {
@@ -2597,20 +2725,20 @@ async function collectPptxSlide(slide, index) {
2597
2725
  };
2598
2726
  }
2599
2727
 
2600
- function createPptxLayoutTemplate(theme, layout) {
2728
+ function createPptxLayoutTemplate(theme, layout, id = `${theme}:${layout}`, backgroundImage) {
2601
2729
  const markdown = `---
2602
2730
  layout: ${layout}
2603
2731
  theme: ${theme}
2604
2732
  ---
2605
2733
  `;
2606
- const slide = createSlide(markdown, theme, true);
2734
+ const slide = createSlide(markdown, theme, true, { backgroundImage });
2607
2735
  if (layout === "backcover") {
2608
2736
  slide.deck
2609
2737
  .querySelectorAll(".theme-backcover-logo, .theme-backcover-copyright")
2610
2738
  .forEach((element) => element.remove());
2611
2739
  }
2612
2740
  slide.deck.classList.add("pptx-layout-template");
2613
- slide.deck.dataset.pptxLayoutId = `${theme}:${layout}`;
2741
+ slide.deck.dataset.pptxLayoutId = id;
2614
2742
  return slide;
2615
2743
  }
2616
2744
 
@@ -2620,6 +2748,7 @@ async function renderPptxDeck(
2620
2748
  customCss = "",
2621
2749
  themeMetadata = null,
2622
2750
  themeLocked = false,
2751
+ options = {},
2623
2752
  ) {
2624
2753
  deckTheme = normalizeTheme(theme);
2625
2754
  deckThemeLocked = Boolean(themeLocked);
@@ -2655,7 +2784,7 @@ async function renderPptxDeck(
2655
2784
 
2656
2785
  const pptxSlides = [];
2657
2786
  for (const [index, slide] of rendered.entries()) {
2658
- pptxSlides.push(await collectPptxSlide(slide, index));
2787
+ pptxSlides.push(await collectPptxSlide(slide, index, options));
2659
2788
  }
2660
2789
  const themes = [...new Set(rendered.map((slide) => slide.theme))];
2661
2790
  const layoutTemplates = themes.flatMap((slideTheme) =>
@@ -2666,6 +2795,26 @@ async function renderPptxDeck(
2666
2795
  slide: createPptxLayoutTemplate(slideTheme, layout),
2667
2796
  })),
2668
2797
  );
2798
+ // Per-slide overrides need their own layout artwork, not a background shared
2799
+ // with every slide of the same theme and layout.
2800
+ const backgroundLayouts = new Map();
2801
+ for (const [index, slide] of rendered.entries()) {
2802
+ if (!slide.backgroundImage) continue;
2803
+ const model = pptxSlides[index];
2804
+ const key = JSON.stringify([slide.theme, model.layout, slide.backgroundImage]);
2805
+ let id = backgroundLayouts.get(key);
2806
+ if (!id) {
2807
+ id = `${model.layoutId}:background-${backgroundLayouts.size + 1}`;
2808
+ backgroundLayouts.set(key, id);
2809
+ layoutTemplates.push({
2810
+ id,
2811
+ name: model.layout,
2812
+ theme: slide.theme,
2813
+ slide: createPptxLayoutTemplate(slide.theme, model.layout, id, slide.backgroundImage),
2814
+ });
2815
+ }
2816
+ model.layoutId = id;
2817
+ }
2669
2818
  stage.append(...layoutTemplates.map((layout) => layout.slide.deck));
2670
2819
  await waitForImages(stage);
2671
2820
  await afterLayout();
@@ -2683,7 +2832,7 @@ async function renderPptxDeck(
2683
2832
  masters: themes.map((slideTheme) => ({
2684
2833
  id: slideTheme,
2685
2834
  theme: slideTheme,
2686
- layoutIds: PPTX_LAYOUT_NAMES.map((layout) => `${slideTheme}:${layout}`),
2835
+ layoutIds: pptxLayouts.filter((layout) => layout.theme === slideTheme).map((layout) => layout.id),
2687
2836
  })),
2688
2837
  layouts: pptxLayouts,
2689
2838
  slides: pptxSlides,
@@ -2721,6 +2870,7 @@ async function initPptx(params) {
2721
2870
  data.customThemeCss,
2722
2871
  data.customThemeMeta,
2723
2872
  data.themeLocked,
2873
+ { mermaidImageFallback: data.mermaidImageFallback === true },
2724
2874
  );
2725
2875
  await reportOutputStatus(token, "ready", "", output.layout);
2726
2876
  } catch (error) {
@@ -3021,7 +3171,7 @@ async function fetchDeck() {
3021
3171
  function setArchitectureEditMode(enabled) {
3022
3172
  const next = Boolean(enabled) && architectureEditAvailable && !presenterMode;
3023
3173
  if (next === architectureEditMode) return false;
3024
- if (next && fixedPreviewMode) setFixedPreviewMode(false);
3174
+ if (next && fixedPreviewMode) setFixedPreviewMode(false, { rerender: false });
3025
3175
  architectureEditMode = next;
3026
3176
  document.body.classList.toggle("architecture-edit-mode", next);
3027
3177
  updateArchitectureEditButton(next);
@@ -3127,7 +3277,8 @@ async function requestArchitectureEditMode(enabled) {
3127
3277
  }
3128
3278
 
3129
3279
  function architectureDiagramOptions() {
3130
- return [...document.querySelectorAll(".architecture-diagram[data-architecture-block]")].map(
3280
+ const slideDocument = document.getElementById("outputFrame")?.contentDocument || document;
3281
+ return [...slideDocument.querySelectorAll(".architecture-diagram[data-architecture-block]")].map(
3131
3282
  (wrapper, index) => ({
3132
3283
  block: Number(wrapper.dataset.architectureBlock),
3133
3284
  title: wrapper.dataset.architectureTitle || `Diagram ${index + 1}`,
@@ -3453,6 +3604,10 @@ async function fetchState() {
3453
3604
  // re-fetches /state for an instant update (without waiting for the SSE nudge).
3454
3605
  async function navigate(payload) {
3455
3606
  if (!navigationEnabled) return;
3607
+ if (surfaceMode) {
3608
+ window.frameElement?.dispatchEvent(new CustomEvent("slide-navigate", { detail: payload }));
3609
+ return;
3610
+ }
3456
3611
  try {
3457
3612
  const res = await fetch("./navigate", {
3458
3613
  method: "POST",
@@ -3681,9 +3836,45 @@ function showExportNotification(state, message, path = "") {
3681
3836
  resumeExportNotification();
3682
3837
  }
3683
3838
 
3684
- async function exportFromCanvas(format) {
3839
+ let pptxOptionsPending = false;
3840
+
3841
+ async function requestPptxExport() {
3842
+ const dialog = document.getElementById("pptxExportDialog");
3843
+ if (exportPending || pptxOptionsPending || dialog.open || !pptxExportAvailable) return;
3844
+ pptxOptionsPending = true;
3845
+ let hasMermaid;
3846
+ try {
3847
+ const response = await fetch("./deck", { cache: "no-store" });
3848
+ if (!response.ok) throw new Error(`Could not load the export deck (${response.status}).`);
3849
+ const data = await response.json();
3850
+ if (!Array.isArray(data?.slides) || !data.slides.every((slide) => typeof slide === "string")) {
3851
+ throw new Error("The export deck is invalid.");
3852
+ }
3853
+ const slides = [...data.slides];
3854
+ if (navMode === "adhoc") slides[Math.max(0, Math.min(navIndex, slides.length - 1))] = lastMarkdown;
3855
+ hasMermaid = slides.some((markdown) => {
3856
+ const { body } = splitFrontMatter(markdown);
3857
+ const content = document.createElement("div");
3858
+ content.innerHTML = window.DOMPurify.sanitize(window.marked.parse(stripSpeakerNotes(body)));
3859
+ return codeBlocksForLanguage(content, "mermaid").length > 0 || Boolean(content.querySelector(".mermaid"));
3860
+ });
3861
+ } catch (error) {
3862
+ showExportNotification("error", `Could not save PowerPoint. ${error.message}`);
3863
+ return;
3864
+ } finally {
3865
+ pptxOptionsPending = false;
3866
+ }
3867
+ if (hasMermaid) {
3868
+ document.getElementById("pptxExportForm").reset();
3869
+ dialog.showModal();
3870
+ } else {
3871
+ await exportFromCanvas("pptx");
3872
+ }
3873
+ }
3874
+
3875
+ async function exportFromCanvas(format, { mermaidImageFallback = false } = {}) {
3685
3876
  const isPdf = format === "pdf";
3686
- if (exportPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3877
+ if (exportPending || pptxOptionsPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3687
3878
  exportPending = true;
3688
3879
  const label = isPdf ? "PDF" : "PowerPoint";
3689
3880
  const pdfButton = document.getElementById("navExport");
@@ -3700,7 +3891,11 @@ async function exportFromCanvas(format) {
3700
3891
  try {
3701
3892
  const response = await fetch(isPdf ? "./export" : "./export-pptx", {
3702
3893
  method: "POST",
3703
- headers: { Accept: "application/json" },
3894
+ headers: {
3895
+ Accept: "application/json",
3896
+ ...(isPdf ? {} : { "Content-Type": "application/json" }),
3897
+ },
3898
+ ...(isPdf ? {} : { body: JSON.stringify({ mermaidImageFallback }) }),
3704
3899
  cache: "no-store",
3705
3900
  });
3706
3901
  const data = await response.json();
@@ -3732,26 +3927,29 @@ async function exportFromCanvas(format) {
3732
3927
  }
3733
3928
  }
3734
3929
 
3735
- function setFixedPreviewMode(enabled) {
3736
- fixedPreviewMode = Boolean(enabled);
3930
+ function setFixedPreviewMode(enabled, { rerender = true } = {}) {
3931
+ fixedPreviewMode = presenterMode || Boolean(enabled);
3737
3932
  document.body.classList.toggle("fixed-preview-mode", fixedPreviewMode);
3738
- document.body.classList.toggle("fixed-output-mode", fixedPreviewMode);
3933
+ document.body.classList.toggle("responsive-preview-mode", !fixedPreviewMode);
3739
3934
  const button = document.getElementById("navFixedPreview");
3740
3935
  if (button) {
3741
3936
  button.setAttribute("aria-pressed", fixedPreviewMode ? "true" : "false");
3742
3937
  button.dataset.state = fixedPreviewMode ? "active" : "";
3743
3938
  button.title = fixedPreviewMode
3744
- ? "Return to responsive canvas layout"
3939
+ ? "Switch to responsive editing layout (not output-equivalent)"
3745
3940
  : "Preview PDF layout at 16:9";
3941
+ button.querySelector(".nav-more-label").textContent =
3942
+ fixedPreviewMode ? "Output preview" : "Responsive editing";
3746
3943
  }
3747
3944
  syncMoreControls();
3748
- if (fixedPreviewMode) {
3749
- updateFixedPreviewScale();
3750
- } else {
3751
- document.body.style.removeProperty("--fixed-preview-scale");
3945
+ if (!fixedPreviewMode) {
3946
+ outputViewport?.dispose();
3947
+ outputViewport = null;
3948
+ document.getElementById("stage").classList.remove("slide-viewport");
3752
3949
  document.body.classList.remove("fixed-preview-overflow");
3753
3950
  }
3754
- scheduleLayoutRefresh();
3951
+ surfaceDiagnostic = null;
3952
+ if (rerender && currentVersion >= 0) renderSlide(lastMarkdown);
3755
3953
  updateFixedPreviewWarning();
3756
3954
  }
3757
3955
 
@@ -3759,6 +3957,103 @@ function toggleFixedPreviewMode() {
3759
3957
  setFixedPreviewMode(!fixedPreviewMode);
3760
3958
  }
3761
3959
 
3960
+ function slideViewportState(markdown, index, interactive) {
3961
+ return {
3962
+ markdown, index, total: navTotal, theme: deckTheme, themeLocked: deckThemeLocked,
3963
+ customThemeCss, customThemeMeta, navigationEnabled: interactive,
3964
+ };
3965
+ }
3966
+
3967
+ function mountSlideViewport(host, id, title, interactive) {
3968
+ return createSlideViewport(host, {
3969
+ id, title,
3970
+ onNavigate: (payload) => { if (interactive) navigate(payload); },
3971
+ onPointer: () => { if (interactive) setMoreControlsOpen(false); },
3972
+ onError: (message) => showExportNotification("error", message),
3973
+ onKey: (detail) => {
3974
+ if (!interactive) return;
3975
+ const event = new KeyboardEvent("keydown", { ...detail, bubbles: true, cancelable: true });
3976
+ document.dispatchEvent(event);
3977
+ detail.handled = event.defaultPrevented;
3978
+ },
3979
+ onLayout: (diagnostic) => {
3980
+ if (id === "presenterNext") return;
3981
+ surfaceDiagnostic = diagnostic;
3982
+ document.body.classList.remove("mermaid-loading");
3983
+ updateFixedPreviewWarning();
3984
+ },
3985
+ });
3986
+ }
3987
+
3988
+ function updateSlideViewports() {
3989
+ const stage = document.getElementById("stage");
3990
+ document.body.classList.add("mermaid-loading");
3991
+ if (presenterViewOpen) {
3992
+ stage.replaceChildren();
3993
+ currentViewport ??= mountSlideViewport(
3994
+ document.getElementById("presenterCurrentViewport"), "presenterCurrent", "Current slide", true,
3995
+ );
3996
+ currentViewport.setState(slideViewportState(lastMarkdown, navIndex, true));
3997
+ const hasNext = navMode !== "deck" || navIndex < navTotal - 1;
3998
+ if (hasNext) {
3999
+ const index = Math.min(navIndex + 1, navTotal - 1);
4000
+ nextViewport ??= mountSlideViewport(
4001
+ document.getElementById("presenterNextViewport"), "presenterNext", "Next slide", false,
4002
+ );
4003
+ nextViewport.setState(slideViewportState(deckSlides[index] || "", index, false));
4004
+ }
4005
+ } else {
4006
+ if (!outputViewport) {
4007
+ stage.replaceChildren();
4008
+ stage.classList.add("slide-viewport");
4009
+ outputViewport = mountSlideViewport(stage, "outputFrame", "Slide", navigationEnabled);
4010
+ }
4011
+ outputViewport.setState(slideViewportState(lastMarkdown, navIndex, navigationEnabled));
4012
+ }
4013
+ }
4014
+
4015
+ function initSlideSurface() {
4016
+ surfaceMode = true;
4017
+ presenterMode = true;
4018
+ document.body.classList.add("surface-mode", "fixed-output-mode", "presenter-mode", "preview-mode");
4019
+ let previousState = "";
4020
+ window.__markdstageSurface = {
4021
+ render(state) {
4022
+ const key = JSON.stringify(state);
4023
+ if (key === previousState) {
4024
+ refreshLayout();
4025
+ return;
4026
+ }
4027
+ previousState = key;
4028
+ deckTheme = state.theme;
4029
+ deckThemeLocked = state.themeLocked;
4030
+ applyCustomThemeCss(state.customThemeCss);
4031
+ customThemeMeta = state.customThemeMeta;
4032
+ navIndex = state.index;
4033
+ navTotal = state.total;
4034
+ navigationEnabled = state.navigationEnabled;
4035
+ renderSlide(state.markdown);
4036
+ },
4037
+ };
4038
+ wirePointerNavigation();
4039
+ document.addEventListener("pointerdown", () => {
4040
+ window.frameElement?.dispatchEvent(new CustomEvent("slide-pointer"));
4041
+ });
4042
+ document.addEventListener("keydown", (event) => {
4043
+ if (!navigationEnabled || event.defaultPrevented) return;
4044
+ const target = event.target;
4045
+ if (target?.closest("input, textarea, select, [contenteditable], button, a")) return;
4046
+ const detail = {
4047
+ key: event.key, code: event.code, repeat: event.repeat,
4048
+ ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, shiftKey: event.shiftKey,
4049
+ };
4050
+ window.frameElement?.dispatchEvent(new CustomEvent("slide-key", { detail }));
4051
+ if (detail.handled) event.preventDefault();
4052
+ });
4053
+ window.addEventListener("resize", scheduleLayoutRefresh);
4054
+ document.fonts?.addEventListener("loadingdone", scheduleLayoutRefresh);
4055
+ }
4056
+
3762
4057
  function updateNav() {
3763
4058
  const nav = document.getElementById("nav");
3764
4059
  if (!nav) return;
@@ -3788,14 +4083,9 @@ function openPresenterView() {
3788
4083
  document.body.classList.add("presenter-view-mode");
3789
4084
  const view = document.getElementById("presenterView");
3790
4085
  if (view) view.hidden = false;
3791
- const current = document.getElementById("presenterCurrent");
3792
- const next = document.getElementById("presenterNext");
3793
- if (current && !current.getAttribute("src")) {
3794
- current.setAttribute("src", "./?preview=1&offset=0&navigate=1");
3795
- }
3796
- if (next && !next.getAttribute("src")) {
3797
- next.setAttribute("src", "./?preview=1&offset=1");
3798
- }
4086
+ outputViewport?.dispose();
4087
+ outputViewport = null;
4088
+ renderSlide(lastMarkdown);
3799
4089
  updatePresenterView();
3800
4090
  }
3801
4091
 
@@ -3804,6 +4094,11 @@ function closePresenterView() {
3804
4094
  document.body.classList.remove("presenter-view-mode");
3805
4095
  const view = document.getElementById("presenterView");
3806
4096
  if (view) view.hidden = true;
4097
+ currentViewport?.dispose();
4098
+ nextViewport?.dispose();
4099
+ currentViewport = null;
4100
+ nextViewport = null;
4101
+ renderSlide(lastMarkdown);
3807
4102
  document.getElementById("navMore")?.focus();
3808
4103
  }
3809
4104
 
@@ -3816,7 +4111,7 @@ function updatePresenterView() {
3816
4111
  if (prev) prev.disabled = navMode === "deck" && navIndex <= 0;
3817
4112
  if (next) next.disabled = navMode === "deck" && navIndex >= navTotal - 1;
3818
4113
  const hasNext = navMode !== "deck" || navIndex < navTotal - 1;
3819
- const nextFrame = document.getElementById("presenterNext");
4114
+ const nextFrame = document.getElementById("presenterNextViewport");
3820
4115
  const nextEmpty = document.getElementById("presenterNextEmpty");
3821
4116
  if (nextFrame) nextFrame.hidden = !hasNext;
3822
4117
  if (nextEmpty) nextEmpty.hidden = hasNext;
@@ -4175,7 +4470,7 @@ function wireControls() {
4175
4470
  bind("navPresenterView", openPresenterView, { closeMore: true });
4176
4471
  bind("navFixedPreview", toggleFixedPreviewMode, { closeMore: true });
4177
4472
  bind("navExport", () => exportFromCanvas("pdf"), { closeMore: true });
4178
- bind("navExportPptx", () => exportFromCanvas("pptx"), { closeMore: true });
4473
+ bind("navExportPptx", requestPptxExport, { closeMore: true });
4179
4474
  bind("navImport", toggleImportPicker, { closeMore: true });
4180
4475
  bind("navSourceMode", toggleSourceMode, { closeMore: true });
4181
4476
  bind("overviewClose", closeOverview);
@@ -4187,6 +4482,29 @@ function wireControls() {
4187
4482
  bind("presenterToggleButton", togglePresenterWindow);
4188
4483
  bind("presenterReturnButton", closePresenterView);
4189
4484
 
4485
+ const pptxDialog = document.getElementById("pptxExportDialog");
4486
+ pptxDialog.addEventListener("keydown", (event) => {
4487
+ if (event.key !== "Tab") return;
4488
+ const controls = [...pptxDialog.querySelectorAll("input:checked, button:not(:disabled)")];
4489
+ const first = controls[0];
4490
+ const last = controls.at(-1);
4491
+ if (event.shiftKey && document.activeElement === first) {
4492
+ event.preventDefault();
4493
+ last.focus();
4494
+ } else if (!event.shiftKey && document.activeElement === last) {
4495
+ event.preventDefault();
4496
+ first.focus();
4497
+ }
4498
+ });
4499
+ document.getElementById("pptxExportCancel").addEventListener("click", () => pptxDialog.close());
4500
+ pptxDialog.addEventListener("close", () => document.getElementById("navMore").focus());
4501
+ document.getElementById("pptxExportForm").addEventListener("submit", (event) => {
4502
+ event.preventDefault();
4503
+ const mermaidImageFallback = new FormData(event.currentTarget).get("mermaidOutput") === "images";
4504
+ pptxDialog.close();
4505
+ exportFromCanvas("pptx", { mermaidImageFallback });
4506
+ });
4507
+
4190
4508
  const exportNotification = document.getElementById("exportNotification");
4191
4509
  document.getElementById("exportNotificationClose").addEventListener("click", dismissExportNotification);
4192
4510
  exportNotification.addEventListener("pointerenter", pauseExportNotification);
@@ -4242,6 +4560,7 @@ function wireControls() {
4242
4560
 
4243
4561
  document.addEventListener("keydown", (e) => {
4244
4562
  if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
4563
+ if (pptxDialog.open) return;
4245
4564
  const t = e.target;
4246
4565
  // Keep Esc active while an input has focus; otherwise filtering in the import
4247
4566
  // dialog could leave the user unable to close it.
@@ -4320,6 +4639,10 @@ function init() {
4320
4639
  } catch (_) {}
4321
4640
 
4322
4641
  const params = new URLSearchParams(window.location.search);
4642
+ if (params.get("surface") === "1") {
4643
+ initSlideSurface();
4644
+ return;
4645
+ }
4323
4646
  presenterViewRequested = params.get("presenter") === "1";
4324
4647
  if (params.get("pptx") === "1") {
4325
4648
  initPptx(params).catch(reportPptxBootstrapFailure);
@@ -4354,15 +4677,12 @@ function init() {
4354
4677
  requestArchitectureEditMode(true);
4355
4678
  }
4356
4679
 
4357
- // Canvas and CLI preview start on the fixed 16:9 output surface. Presenter
4358
- // views keep their purpose-built layouts, and the control still lets users
4359
- // switch back to the responsive canvas layout.
4360
- if (
4361
- !presenterMode &&
4362
- !presenterViewRequested &&
4363
- params.get("responsive") !== "1"
4364
- ) {
4680
+ // Only the explicitly requested editing layout is responsive. Audience and
4681
+ // Desktop previews use the same isolated surface as Canvas and CLI previews.
4682
+ if (presenterMode || params.get("responsive") !== "1") {
4365
4683
  setFixedPreviewMode(true);
4684
+ } else {
4685
+ setFixedPreviewMode(false);
4366
4686
  }
4367
4687
 
4368
4688
  updateArchitectureEditButton();
@@ -4372,7 +4692,6 @@ function init() {
4372
4692
  wirePreviewKeyboardNavigation();
4373
4693
  }
4374
4694
  window.addEventListener("resize", () => {
4375
- updateFixedPreviewScale();
4376
4695
  scheduleLayoutRefresh();
4377
4696
  });
4378
4697
  if (document.fonts?.ready) {