@markdstage/markdstage 3.3.0 → 3.8.0

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,
@@ -426,13 +429,14 @@ function collectDeckLayout(rendered) {
426
429
  function updateFixedPreviewWarning() {
427
430
  const warning = document.getElementById("layoutWarning");
428
431
  const button = document.getElementById("navFixedPreview");
429
- if (!fixedPreviewMode || !layoutTarget) {
432
+ const empty = document.body.classList.contains("markdstage-empty");
433
+ if (!fixedPreviewMode || !layoutTarget || empty) {
430
434
  document.body.classList.remove("fixed-preview-overflow");
431
435
  if (warning) {
432
436
  warning.hidden = true;
433
437
  warning.textContent = "";
434
438
  }
435
- if (button) button.dataset.state = fixedPreviewMode ? "active" : "";
439
+ if (button) button.dataset.state = fixedPreviewMode && !empty ? "active" : "";
436
440
  syncMoreControls();
437
441
  return;
438
442
  }
@@ -582,13 +586,19 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
582
586
  return Promise.resolve();
583
587
  }
584
588
  try {
585
- const themeVariables = mermaidThemeVariables(getComputedStyle(deckEl));
589
+ const sources = [...nodes].map((node) => node.textContent || "");
590
+ const themeVariables = mermaidThemeVariables(
591
+ getComputedStyle(deckEl),
592
+ (value) => resolveModelColor(value, deckEl),
593
+ );
594
+ const c4ThemeVariables = mermaidC4ThemeVariables(themeVariables);
586
595
  const serializedThemeVariables = JSON.stringify(themeVariables);
587
596
  if (serializedThemeVariables !== lastMermaidThemeVariables) {
588
597
  window.mermaid.initialize({
589
598
  startOnLoad: false,
590
599
  theme: "base",
591
600
  themeVariables,
601
+ c4: c4ThemeVariables,
592
602
  securityLevel: "strict",
593
603
  });
594
604
  lastMermaidThemeVariables = serializedThemeVariables;
@@ -600,11 +610,60 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
600
610
  const source = host.querySelector("svg");
601
611
  if (!source || source.hasAttribute("data-scene-backend")) continue;
602
612
  try {
613
+ repairC4ThemeDefaults(source, sources[index], themeVariables);
603
614
  renderMermaidScene(source, deckEl, index);
604
615
  } catch (e) {
605
616
  console.error("Mermaid scene render failed", e);
606
617
  }
607
618
  }
619
+
620
+ // Mermaid 11.15.0 does not expose theme variables for C4 boundary/relation
621
+ // paint or its arrow markers. Repair only those known renderer defaults, and
622
+ // leave diagrams with explicit C4 style updates untouched so source colors
623
+ // remain authoritative.
624
+ function repairC4ThemeDefaults(svg, source, themeVariables) {
625
+ if (svg.getAttribute("aria-roledescription") !== "c4") return;
626
+ const commands = source.replace(/^\s*%%.*$/gm, "");
627
+ const hasElementStyles = /\bUpdateElementStyle\s*\(/i.test(commands);
628
+ const hasRelationStyles = /\bUpdateRelStyle\s*\(/i.test(commands);
629
+ const boundaryGroups = new Set(
630
+ [...svg.querySelectorAll('rect[stroke-dasharray="7.0,7.0"], rect[fill="none"]')]
631
+ .map((element) => element.parentElement),
632
+ );
633
+ const relationGroups = new Set(
634
+ [...svg.querySelectorAll("[marker-end], [marker-start]")].map((element) => element.parentElement),
635
+ );
636
+ const boundaryPaint = new Set(["#444", "#444444", "rgb(68, 68, 68)"]);
637
+ const relationPaint = new Set(["#444", "#444444", "rgb(68, 68, 68)"]);
638
+ const setDefaultPaint = (element, property, defaults, value) => {
639
+ const current = element.getAttribute(property)?.trim().toLowerCase();
640
+ if (!defaults.has(current)) return;
641
+ element.setAttribute(property, value);
642
+ if (element.style?.getPropertyValue(property)) {
643
+ element.style.setProperty(property, value);
644
+ }
645
+ };
646
+
647
+ for (const element of svg.querySelectorAll("rect, line, path, text")) {
648
+ const parent = element.parentElement;
649
+ if (!hasElementStyles && boundaryGroups.has(parent)) {
650
+ setDefaultPaint(element, "stroke", boundaryPaint, themeVariables.lineColor);
651
+ setDefaultPaint(element, "fill", boundaryPaint, themeVariables.textColor);
652
+ }
653
+ if (!hasRelationStyles && relationGroups.has(parent)) {
654
+ setDefaultPaint(element, "stroke", relationPaint, themeVariables.lineColor);
655
+ setDefaultPaint(element, "fill", relationPaint, themeVariables.textColor);
656
+ }
657
+ }
658
+ if (!hasRelationStyles) {
659
+ for (const marker of svg.querySelectorAll("marker")) {
660
+ for (const element of marker.querySelectorAll("path, polygon, line")) {
661
+ setDefaultPaint(element, "stroke", new Set(["black", "#000", "#000000", "rgb(0, 0, 0)"]), themeVariables.lineColor);
662
+ setDefaultPaint(element, "fill", new Set([undefined, "black", "#000", "#000000", "rgb(0, 0, 0)"]), themeVariables.lineColor);
663
+ }
664
+ }
665
+ }
666
+ }
608
667
  })
609
668
  .finally(reveal);
610
669
  } catch (e) {
@@ -614,26 +673,28 @@ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
614
673
  }
615
674
  }
616
675
 
676
+ function withoutMermaidLoadingVeil(callback) {
677
+ const loading = document.body.classList.contains("mermaid-loading");
678
+ if (loading) document.body.classList.remove("mermaid-loading");
679
+ try {
680
+ return callback();
681
+ } finally {
682
+ if (loading) document.body.classList.add("mermaid-loading");
683
+ }
684
+ }
685
+
617
686
  function renderMermaidScene(svg, deck, blockIndex) {
618
- const result = mermaidSvgToScene(svg, {
619
- path: `mermaid[${blockIndex}]`,
620
- deck,
621
- includeSourceElements: true,
622
- resolveColor: (value) => resolveModelColor(value, deck),
623
- });
687
+ const result = withoutMermaidLoadingVeil(() =>
688
+ mermaidSvgToScene(svg, {
689
+ path: `mermaid[${blockIndex}]`,
690
+ deck,
691
+ includeSourceElements: true,
692
+ resolveColor: (value) => resolveModelColor(value, deck),
693
+ }),
694
+ );
624
695
  let { scene } = result;
625
- const computedStyle = (element) => {
626
- const style = getComputedStyle(element);
627
- return {
628
- getPropertyValue(property) {
629
- // The loading veil is inherited by every SVG descendant, not diagram style.
630
- if (property === "visibility" && document.body.classList.contains("mermaid-loading")) {
631
- return element.style?.visibility || element.getAttribute("visibility") || "";
632
- }
633
- return style.getPropertyValue(property);
634
- },
635
- };
636
- };
696
+ const capture = (element, options) =>
697
+ withoutMermaidLoadingVeil(() => captureSvgTree(element, options));
637
698
  const slots = new Map();
638
699
  try {
639
700
  for (const [index, node] of scene.nodes.entries()) {
@@ -656,7 +717,7 @@ function renderMermaidScene(svg, deck, blockIndex) {
656
717
  node.meta = { ...node.meta, svgOwner: slots.get(source) };
657
718
  continue;
658
719
  }
659
- node.meta = { ...node.meta, svg: captureSvgTree(source, { computedStyle }) };
720
+ node.meta = { ...node.meta, svg: capture(source) };
660
721
  slots.set(source, index);
661
722
  }
662
723
  for (const [source, index] of slots) {
@@ -675,14 +736,14 @@ function renderMermaidScene(svg, deck, blockIndex) {
675
736
  kind: "fallback", sourcePath: "svg", z: 0,
676
737
  bounds: { x: 0, y: 0, width: scene.width, height: scene.height },
677
738
  capability: { pptx: "fallback", reason }, reason,
678
- meta: { svg: captureSvgTree(svg, { computedStyle }) },
739
+ meta: { svg: capture(svg) },
679
740
  }] };
680
741
  slots.clear();
681
742
  slots.set(svg, 0);
682
743
  }
683
744
  const template = slots.has(svg)
684
745
  ? { sceneNode: slots.get(svg) }
685
- : captureSvgTree(svg, { slots, computedStyle });
746
+ : capture(svg, { slots });
686
747
  scene.meta = { ...scene.meta, svgRoot: template };
687
748
  svg.replaceWith(sceneToSvg(scene, { document, template }));
688
749
  }
@@ -697,7 +758,12 @@ function moveLeadingSlideTitle(header, bodyEl, specialLayout) {
697
758
  return title;
698
759
  }
699
760
 
700
- function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
761
+ function createSlide(
762
+ markdown,
763
+ fallbackTheme,
764
+ themeLocked = deckThemeLocked,
765
+ { backgroundImage: backgroundOverride } = {},
766
+ ) {
701
767
  const placeholder = !nonEmpty(markdown);
702
768
  const md = placeholder ? PLACEHOLDER : markdown;
703
769
  const { meta, body: rawBody } = splitFrontMatter(md);
@@ -720,6 +786,11 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
720
786
  // as well as <html> so print mode can render differently themed pages together.
721
787
  const theme = normalizeTheme(themeLocked ? fallbackTheme : meta.theme || fallbackTheme);
722
788
  const themeMetadata = theme === "custom" ? customThemeMeta : null;
789
+ const backgroundImage = parseSlideBackground(backgroundOverride ?? meta["background-image"]);
790
+ const backgroundUrl = backgroundImage
791
+ ? localAssetUrl(backgroundImage.replace(/^\/assets\//, "/background-assets/")
792
+ .split("/").map(encodeURIComponent).join("/"))
793
+ : "";
723
794
 
724
795
  const deck = document.createElement("div");
725
796
  deck.className = "deck";
@@ -731,13 +802,18 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
731
802
  if (placeholder) deck.classList.add("markdstage-placeholder");
732
803
  if (sizeMode !== "auto") setSizeLevel(deck, sizeMode);
733
804
 
805
+ const background = themeImage(
806
+ backgroundUrl
807
+ ? { image: backgroundUrl }
808
+ : resolveThemeBackground(themeMetadata, layout),
809
+ titleSlide ? "slide-background theme-cover-background" : "slide-background",
810
+ { decorative: true },
811
+ );
812
+ if (background) {
813
+ deck.classList.add("has-slide-background");
814
+ deck.appendChild(background);
815
+ }
734
816
  if (titleSlide) {
735
- const background = themeImage(
736
- themeMetadata?.cover?.background,
737
- "theme-cover-background",
738
- { decorative: true },
739
- );
740
- if (background) deck.appendChild(background);
741
817
  const logo = themeImage(themeMetadata?.cover?.logo, "theme-cover-logo");
742
818
  if (logo) deck.appendChild(logo);
743
819
  }
@@ -869,6 +945,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
869
945
  deck,
870
946
  bodyEl,
871
947
  theme,
948
+ backgroundImage,
872
949
  sizeMode,
873
950
  titleSlide,
874
951
  sectionSlide,
@@ -916,9 +993,15 @@ function renderSlide(markdown) {
916
993
  // that the slide is fully painted, and PDF export and the visual regression
917
994
  // suite rely on it. Revealing while an architecture icon under `assets/` is
918
995
  // still loading would capture a half-drawn slide.
919
- const images = waitForImages(slide.deck).then(() => {
920
- if (token === renderToken) scheduleLayoutRefresh();
921
- });
996
+ const images = waitForImages(slide.deck)
997
+ .then(() => {
998
+ if (token === renderToken) scheduleLayoutRefresh();
999
+ })
1000
+ .catch((error) => {
1001
+ if (token !== renderToken) return;
1002
+ console.error(error.message);
1003
+ showExportNotification("error", error.message);
1004
+ });
922
1005
  const mermaid = runMermaid(slide.bodyEl, slide.deck, token, false).finally(() => {
923
1006
  if (token === renderToken) scheduleLayoutRefresh();
924
1007
  });
@@ -960,7 +1043,14 @@ function waitForImages(root) {
960
1043
  }),
961
1044
  );
962
1045
  }
963
- return Promise.all(pending);
1046
+ return Promise.all(pending).then(() => {
1047
+ const failedBackground = [...root.querySelectorAll("img.slide-background")].find(
1048
+ (image) => !image.naturalWidth,
1049
+ );
1050
+ if (failedBackground) {
1051
+ throw new Error(`Could not load slide background: ${failedBackground.getAttribute("src")}`);
1052
+ }
1053
+ });
964
1054
  }
965
1055
 
966
1056
  async function reportOutputStatus(token, status, error = "", layout = null) {
@@ -2032,7 +2122,7 @@ function markMermaidNativeElements(svg, mappedElements, pathPrefix, sourceElemen
2032
2122
  source = sourceElements.get(ownerPath);
2033
2123
  }
2034
2124
  if (source && !source.hasAttribute("data-pptx-native")) {
2035
- const nativeKind = element.type === "shape" ? "shape" : element.type;
2125
+ const nativeKind = element.mermaid?.nativeMask || (element.type === "shape" ? "shape" : element.type);
2036
2126
  source.setAttribute("data-pptx-native", nativeKind);
2037
2127
  }
2038
2128
  if (!sourceElements && element.type === "connector") {
@@ -2097,10 +2187,17 @@ function collectMermaidObjects(element, deck, blockIndex) {
2097
2187
  mermaidElementForSourcePath(svg, sourcePath) ||
2098
2188
  mermaidFallbackElementForBounds(svg, deck, fallback) ||
2099
2189
  svg;
2100
- const sourceBounds = source.getBoundingClientRect();
2101
- const padding = ["path", "line", "polyline"].includes(source.localName)
2102
- ? Math.max(1, (fallback.width - sourceBounds.width) / 2, (fallback.height - sourceBounds.height) / 2)
2103
- : 0;
2190
+ const sourceBounds = fallbackBounds(source, deck, 0, true);
2191
+ const inferredPadding = Math.max(
2192
+ 0,
2193
+ (fallback.width - sourceBounds.width) / 2,
2194
+ (fallback.height - sourceBounds.height) / 2,
2195
+ );
2196
+ const geometryPadding = ["path", "line", "polyline"].includes(source.localName)
2197
+ ? Math.max(1, inferredPadding)
2198
+ : inferredPadding;
2199
+ const effectPadding = subtreeEffectPaintPadding(source);
2200
+ const padding = Math.max(geometryPadding, effectPadding);
2104
2201
  const captured = pptxFallback("mermaid", source, deck, fallback.reason, {
2105
2202
  captureElement: source,
2106
2203
  includeDescendants: true,
@@ -2112,10 +2209,14 @@ function collectMermaidObjects(element, deck, blockIndex) {
2112
2209
  path: fallback.path,
2113
2210
  sourcePath,
2114
2211
  reason: fallback.reason,
2115
- x: fallback.x,
2116
- y: fallback.y,
2117
- width: fallback.width,
2118
- height: fallback.height,
2212
+ ...(effectPadding
2213
+ ? {}
2214
+ : {
2215
+ x: fallback.x,
2216
+ y: fallback.y,
2217
+ width: fallback.width,
2218
+ height: fallback.height,
2219
+ }),
2119
2220
  zOrder: fallback.zOrder,
2120
2221
  ...(node?.id ? { id: node.id } : {}),
2121
2222
  ...(fallback.artwork === false ? { artwork: false } : {}),
@@ -2124,7 +2225,7 @@ function collectMermaidObjects(element, deck, blockIndex) {
2124
2225
  return { elements: mapped.elements, fallbacks };
2125
2226
  }
2126
2227
 
2127
- async function collectPptxSlide(slide, index) {
2228
+ async function collectPptxSlide(slide, index, options = {}) {
2128
2229
  const { deck } = slide;
2129
2230
  assignPptxPaintOrder(deck);
2130
2231
  const elements = [];
@@ -2231,6 +2332,12 @@ async function collectPptxSlide(slide, index) {
2231
2332
  for (const [blockIndex, element] of [...deck.querySelectorAll("pre.mermaid")].entries()) {
2232
2333
  const covered = [...fallbackRoots].some((root) => root === element || root.contains(element));
2233
2334
  if (covered) continue;
2335
+ if (options.mermaidImageFallback === true) {
2336
+ // The scroll container clips the diagram; SVG definitions have no painted bounds.
2337
+ // The capture pipeline trims transparent space inside this safe envelope.
2338
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2339
+ continue;
2340
+ }
2234
2341
  try {
2235
2342
  const mermaid = collectMermaidObjects(element, deck, blockIndex);
2236
2343
  elements.push(...mermaid.elements);
@@ -2495,7 +2602,7 @@ async function collectPptxSlide(slide, index) {
2495
2602
  for (const image of deck.querySelectorAll("img")) {
2496
2603
  if (
2497
2604
  image.closest(".architecture-diagram") ||
2498
- image.classList.contains("theme-cover-background") ||
2605
+ image.classList.contains("slide-background") ||
2499
2606
  image.classList.contains("theme-cover-logo") ||
2500
2607
  insideFallback(image)
2501
2608
  ) {
@@ -2596,20 +2703,20 @@ async function collectPptxSlide(slide, index) {
2596
2703
  };
2597
2704
  }
2598
2705
 
2599
- function createPptxLayoutTemplate(theme, layout) {
2706
+ function createPptxLayoutTemplate(theme, layout, id = `${theme}:${layout}`, backgroundImage) {
2600
2707
  const markdown = `---
2601
2708
  layout: ${layout}
2602
2709
  theme: ${theme}
2603
2710
  ---
2604
2711
  `;
2605
- const slide = createSlide(markdown, theme, true);
2712
+ const slide = createSlide(markdown, theme, true, { backgroundImage });
2606
2713
  if (layout === "backcover") {
2607
2714
  slide.deck
2608
2715
  .querySelectorAll(".theme-backcover-logo, .theme-backcover-copyright")
2609
2716
  .forEach((element) => element.remove());
2610
2717
  }
2611
2718
  slide.deck.classList.add("pptx-layout-template");
2612
- slide.deck.dataset.pptxLayoutId = `${theme}:${layout}`;
2719
+ slide.deck.dataset.pptxLayoutId = id;
2613
2720
  return slide;
2614
2721
  }
2615
2722
 
@@ -2619,6 +2726,7 @@ async function renderPptxDeck(
2619
2726
  customCss = "",
2620
2727
  themeMetadata = null,
2621
2728
  themeLocked = false,
2729
+ options = {},
2622
2730
  ) {
2623
2731
  deckTheme = normalizeTheme(theme);
2624
2732
  deckThemeLocked = Boolean(themeLocked);
@@ -2654,7 +2762,7 @@ async function renderPptxDeck(
2654
2762
 
2655
2763
  const pptxSlides = [];
2656
2764
  for (const [index, slide] of rendered.entries()) {
2657
- pptxSlides.push(await collectPptxSlide(slide, index));
2765
+ pptxSlides.push(await collectPptxSlide(slide, index, options));
2658
2766
  }
2659
2767
  const themes = [...new Set(rendered.map((slide) => slide.theme))];
2660
2768
  const layoutTemplates = themes.flatMap((slideTheme) =>
@@ -2665,6 +2773,26 @@ async function renderPptxDeck(
2665
2773
  slide: createPptxLayoutTemplate(slideTheme, layout),
2666
2774
  })),
2667
2775
  );
2776
+ // Per-slide overrides need their own layout artwork, not a background shared
2777
+ // with every slide of the same theme and layout.
2778
+ const backgroundLayouts = new Map();
2779
+ for (const [index, slide] of rendered.entries()) {
2780
+ if (!slide.backgroundImage) continue;
2781
+ const model = pptxSlides[index];
2782
+ const key = JSON.stringify([slide.theme, model.layout, slide.backgroundImage]);
2783
+ let id = backgroundLayouts.get(key);
2784
+ if (!id) {
2785
+ id = `${model.layoutId}:background-${backgroundLayouts.size + 1}`;
2786
+ backgroundLayouts.set(key, id);
2787
+ layoutTemplates.push({
2788
+ id,
2789
+ name: model.layout,
2790
+ theme: slide.theme,
2791
+ slide: createPptxLayoutTemplate(slide.theme, model.layout, id, slide.backgroundImage),
2792
+ });
2793
+ }
2794
+ model.layoutId = id;
2795
+ }
2668
2796
  stage.append(...layoutTemplates.map((layout) => layout.slide.deck));
2669
2797
  await waitForImages(stage);
2670
2798
  await afterLayout();
@@ -2682,7 +2810,7 @@ async function renderPptxDeck(
2682
2810
  masters: themes.map((slideTheme) => ({
2683
2811
  id: slideTheme,
2684
2812
  theme: slideTheme,
2685
- layoutIds: PPTX_LAYOUT_NAMES.map((layout) => `${slideTheme}:${layout}`),
2813
+ layoutIds: pptxLayouts.filter((layout) => layout.theme === slideTheme).map((layout) => layout.id),
2686
2814
  })),
2687
2815
  layouts: pptxLayouts,
2688
2816
  slides: pptxSlides,
@@ -2720,6 +2848,7 @@ async function initPptx(params) {
2720
2848
  data.customThemeCss,
2721
2849
  data.customThemeMeta,
2722
2850
  data.themeLocked,
2851
+ { mermaidImageFallback: data.mermaidImageFallback === true },
2723
2852
  );
2724
2853
  await reportOutputStatus(token, "ready", "", output.layout);
2725
2854
  } catch (error) {
@@ -3020,6 +3149,7 @@ async function fetchDeck() {
3020
3149
  function setArchitectureEditMode(enabled) {
3021
3150
  const next = Boolean(enabled) && architectureEditAvailable && !presenterMode;
3022
3151
  if (next === architectureEditMode) return false;
3152
+ if (next && fixedPreviewMode) setFixedPreviewMode(false);
3023
3153
  architectureEditMode = next;
3024
3154
  document.body.classList.toggle("architecture-edit-mode", next);
3025
3155
  updateArchitectureEditButton(next);
@@ -3679,9 +3809,45 @@ function showExportNotification(state, message, path = "") {
3679
3809
  resumeExportNotification();
3680
3810
  }
3681
3811
 
3682
- async function exportFromCanvas(format) {
3812
+ let pptxOptionsPending = false;
3813
+
3814
+ async function requestPptxExport() {
3815
+ const dialog = document.getElementById("pptxExportDialog");
3816
+ if (exportPending || pptxOptionsPending || dialog.open || !pptxExportAvailable) return;
3817
+ pptxOptionsPending = true;
3818
+ let hasMermaid;
3819
+ try {
3820
+ const response = await fetch("./deck", { cache: "no-store" });
3821
+ if (!response.ok) throw new Error(`Could not load the export deck (${response.status}).`);
3822
+ const data = await response.json();
3823
+ if (!Array.isArray(data?.slides) || !data.slides.every((slide) => typeof slide === "string")) {
3824
+ throw new Error("The export deck is invalid.");
3825
+ }
3826
+ const slides = [...data.slides];
3827
+ if (navMode === "adhoc") slides[Math.max(0, Math.min(navIndex, slides.length - 1))] = lastMarkdown;
3828
+ hasMermaid = slides.some((markdown) => {
3829
+ const { body } = splitFrontMatter(markdown);
3830
+ const content = document.createElement("div");
3831
+ content.innerHTML = window.DOMPurify.sanitize(window.marked.parse(stripSpeakerNotes(body)));
3832
+ return codeBlocksForLanguage(content, "mermaid").length > 0 || Boolean(content.querySelector(".mermaid"));
3833
+ });
3834
+ } catch (error) {
3835
+ showExportNotification("error", `Could not save PowerPoint. ${error.message}`);
3836
+ return;
3837
+ } finally {
3838
+ pptxOptionsPending = false;
3839
+ }
3840
+ if (hasMermaid) {
3841
+ document.getElementById("pptxExportForm").reset();
3842
+ dialog.showModal();
3843
+ } else {
3844
+ await exportFromCanvas("pptx");
3845
+ }
3846
+ }
3847
+
3848
+ async function exportFromCanvas(format, { mermaidImageFallback = false } = {}) {
3683
3849
  const isPdf = format === "pdf";
3684
- if (exportPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3850
+ if (exportPending || pptxOptionsPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3685
3851
  exportPending = true;
3686
3852
  const label = isPdf ? "PDF" : "PowerPoint";
3687
3853
  const pdfButton = document.getElementById("navExport");
@@ -3698,7 +3864,11 @@ async function exportFromCanvas(format) {
3698
3864
  try {
3699
3865
  const response = await fetch(isPdf ? "./export" : "./export-pptx", {
3700
3866
  method: "POST",
3701
- headers: { Accept: "application/json" },
3867
+ headers: {
3868
+ Accept: "application/json",
3869
+ ...(isPdf ? {} : { "Content-Type": "application/json" }),
3870
+ },
3871
+ ...(isPdf ? {} : { body: JSON.stringify({ mermaidImageFallback }) }),
3702
3872
  cache: "no-store",
3703
3873
  });
3704
3874
  const data = await response.json();
@@ -4173,7 +4343,7 @@ function wireControls() {
4173
4343
  bind("navPresenterView", openPresenterView, { closeMore: true });
4174
4344
  bind("navFixedPreview", toggleFixedPreviewMode, { closeMore: true });
4175
4345
  bind("navExport", () => exportFromCanvas("pdf"), { closeMore: true });
4176
- bind("navExportPptx", () => exportFromCanvas("pptx"), { closeMore: true });
4346
+ bind("navExportPptx", requestPptxExport, { closeMore: true });
4177
4347
  bind("navImport", toggleImportPicker, { closeMore: true });
4178
4348
  bind("navSourceMode", toggleSourceMode, { closeMore: true });
4179
4349
  bind("overviewClose", closeOverview);
@@ -4185,6 +4355,29 @@ function wireControls() {
4185
4355
  bind("presenterToggleButton", togglePresenterWindow);
4186
4356
  bind("presenterReturnButton", closePresenterView);
4187
4357
 
4358
+ const pptxDialog = document.getElementById("pptxExportDialog");
4359
+ pptxDialog.addEventListener("keydown", (event) => {
4360
+ if (event.key !== "Tab") return;
4361
+ const controls = [...pptxDialog.querySelectorAll("input:checked, button:not(:disabled)")];
4362
+ const first = controls[0];
4363
+ const last = controls.at(-1);
4364
+ if (event.shiftKey && document.activeElement === first) {
4365
+ event.preventDefault();
4366
+ last.focus();
4367
+ } else if (!event.shiftKey && document.activeElement === last) {
4368
+ event.preventDefault();
4369
+ first.focus();
4370
+ }
4371
+ });
4372
+ document.getElementById("pptxExportCancel").addEventListener("click", () => pptxDialog.close());
4373
+ pptxDialog.addEventListener("close", () => document.getElementById("navMore").focus());
4374
+ document.getElementById("pptxExportForm").addEventListener("submit", (event) => {
4375
+ event.preventDefault();
4376
+ const mermaidImageFallback = new FormData(event.currentTarget).get("mermaidOutput") === "images";
4377
+ pptxDialog.close();
4378
+ exportFromCanvas("pptx", { mermaidImageFallback });
4379
+ });
4380
+
4188
4381
  const exportNotification = document.getElementById("exportNotification");
4189
4382
  document.getElementById("exportNotificationClose").addEventListener("click", dismissExportNotification);
4190
4383
  exportNotification.addEventListener("pointerenter", pauseExportNotification);
@@ -4240,6 +4433,7 @@ function wireControls() {
4240
4433
 
4241
4434
  document.addEventListener("keydown", (e) => {
4242
4435
  if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
4436
+ if (pptxDialog.open) return;
4243
4437
  const t = e.target;
4244
4438
  // Keep Esc active while an input has focus; otherwise filtering in the import
4245
4439
  // dialog could leave the user unable to close it.
@@ -4352,6 +4546,17 @@ function init() {
4352
4546
  requestArchitectureEditMode(true);
4353
4547
  }
4354
4548
 
4549
+ // Canvas and CLI preview start on the fixed 16:9 output surface. Presenter
4550
+ // views keep their purpose-built layouts, and the control still lets users
4551
+ // switch back to the responsive canvas layout.
4552
+ if (
4553
+ !presenterMode &&
4554
+ !presenterViewRequested &&
4555
+ params.get("responsive") !== "1"
4556
+ ) {
4557
+ setFixedPreviewMode(true);
4558
+ }
4559
+
4355
4560
  updateArchitectureEditButton();
4356
4561
  if (!previewMode) wireControls();
4357
4562
  else if (navigationEnabled) {