@markdstage/markdstage 3.2.0 → 3.4.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.
@@ -1,7 +1,12 @@
1
1
  import { powerPointDashStyle, renderArchitectureBlock } from "./architecture.mjs";
2
+ import { architectureSnapshotToScene } from "./architecture-scene.mjs";
3
+ import { mermaidSvgToScene } from "./mermaid-scene.mjs";
4
+ import { captureSvgTree, sceneToSvg } from "./scene-svg.mjs";
5
+ import { sceneToPptxElements } from "./scene-pptx.mjs";
2
6
  import { attachArchitectureEditor } from "./architecture-editor.mjs";
3
7
  import {
4
8
  DEFAULT_THEME,
9
+ mermaidThemeVariables,
5
10
  normalizeTheme,
6
11
  parseFrontMatter,
7
12
  } from "./theme.mjs";
@@ -74,11 +79,6 @@ function localAssetUrl(path, documentRef = document) {
74
79
  // The deck theme is chosen by the agent (load_deck `theme`) and delivered via
75
80
  // /state; slide front matter may override it unless the deck theme was explicit.
76
81
  // Anything unrecognized falls back to the default so a slide is never unstyled.
77
- const MERMAID_THEME = {
78
- dark: "dark",
79
- light: "default",
80
- microsoft: "neutral",
81
- };
82
82
  const SIZE_MODES = new Set(["auto", "normal", "large", "xlarge"]);
83
83
  const DEFAULT_SIZE_MODE = "auto";
84
84
  let deckTheme = DEFAULT_THEME;
@@ -88,7 +88,7 @@ let customThemeMeta = null;
88
88
  // Bumped on every render so a late mermaid finish from a previous slide can't
89
89
  // reveal a newer, still-rendering one.
90
90
  let renderToken = 0;
91
- let lastMermaidTheme = null;
91
+ let lastMermaidThemeVariables = null;
92
92
  let pptxFallbackSequence = 0;
93
93
  const pptxFallbackCaptureElements = new Map();
94
94
  // Editing mode is available only in normal view, not presenter or print mode.
@@ -426,13 +426,14 @@ function collectDeckLayout(rendered) {
426
426
  function updateFixedPreviewWarning() {
427
427
  const warning = document.getElementById("layoutWarning");
428
428
  const button = document.getElementById("navFixedPreview");
429
- if (!fixedPreviewMode || !layoutTarget) {
429
+ const empty = document.body.classList.contains("markdstage-empty");
430
+ if (!fixedPreviewMode || !layoutTarget || empty) {
430
431
  document.body.classList.remove("fixed-preview-overflow");
431
432
  if (warning) {
432
433
  warning.hidden = true;
433
434
  warning.textContent = "";
434
435
  }
435
- if (button) button.dataset.state = fixedPreviewMode ? "active" : "";
436
+ if (button) button.dataset.state = fixedPreviewMode && !empty ? "active" : "";
436
437
  syncMoreControls();
437
438
  return;
438
439
  }
@@ -564,9 +565,12 @@ function applySyntaxHighlighting(root) {
564
565
  // --- mermaid ---------------------------------------------------------------
565
566
  // Render every <pre class="mermaid"> in `scope` to SVG. Resilient: a slide with
566
567
  // no diagrams, a missing library, or an invalid diagram must never leave the
567
- // slide blank, so the body is always revealed in the end. The mermaid theme is
568
- // matched to the slide theme, re-initialized only when it actually changes.
569
- function runMermaid(scope, theme, token, revealWhenDone = true) {
568
+ // slide blank, so the body is always revealed in the end. Mermaid's palette is
569
+ // derived from the rendered deck's theme custom properties (see
570
+ // theme.mjs#mermaidThemeVariables) so diagrams match the slide instead of
571
+ // picking the closest built-in Mermaid theme, and it is re-initialized only
572
+ // when the resolved colors actually change.
573
+ function runMermaid(scope, deckEl, token, revealWhenDone = true) {
570
574
  // Only the latest render may lift the loading veil; a stale finish is ignored.
571
575
  const reveal = () => {
572
576
  if (revealWhenDone && token === renderToken) {
@@ -579,13 +583,30 @@ function runMermaid(scope, theme, token, revealWhenDone = true) {
579
583
  return Promise.resolve();
580
584
  }
581
585
  try {
582
- const wanted = MERMAID_THEME[theme] || "neutral";
583
- if (wanted !== lastMermaidTheme) {
584
- window.mermaid.initialize({ startOnLoad: false, theme: wanted, securityLevel: "strict" });
585
- lastMermaidTheme = wanted;
586
+ const themeVariables = mermaidThemeVariables(getComputedStyle(deckEl));
587
+ const serializedThemeVariables = JSON.stringify(themeVariables);
588
+ if (serializedThemeVariables !== lastMermaidThemeVariables) {
589
+ window.mermaid.initialize({
590
+ startOnLoad: false,
591
+ theme: "base",
592
+ themeVariables,
593
+ securityLevel: "strict",
594
+ });
595
+ lastMermaidThemeVariables = serializedThemeVariables;
586
596
  }
587
597
  return Promise.resolve(window.mermaid.run({ nodes }))
588
598
  .catch((e) => console.error("Mermaid render failed", e))
599
+ .then(() => {
600
+ for (const [index, host] of [...nodes].entries()) {
601
+ const source = host.querySelector("svg");
602
+ if (!source || source.hasAttribute("data-scene-backend")) continue;
603
+ try {
604
+ renderMermaidScene(source, deckEl, index);
605
+ } catch (e) {
606
+ console.error("Mermaid scene render failed", e);
607
+ }
608
+ }
609
+ })
589
610
  .finally(reveal);
590
611
  } catch (e) {
591
612
  console.error("Mermaid init failed", e);
@@ -594,6 +615,79 @@ function runMermaid(scope, theme, token, revealWhenDone = true) {
594
615
  }
595
616
  }
596
617
 
618
+ 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
+ });
625
+ 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
+ };
638
+ const slots = new Map();
639
+ try {
640
+ for (const [index, node] of scene.nodes.entries()) {
641
+ const exactSource = result.sourceElements?.get(node.sourcePath) ||
642
+ mermaidElementForSourcePath(svg, node.sourcePath);
643
+ const owner = scene.nodes.find((candidate) => {
644
+ if (candidate === node || !node.sourcePath.startsWith(`${candidate.sourcePath}.`)) return false;
645
+ const candidateSource = result.sourceElements?.get(candidate.sourcePath) ||
646
+ mermaidElementForSourcePath(svg, candidate.sourcePath);
647
+ return candidateSource && (!exactSource || candidateSource.contains(exactSource));
648
+ });
649
+ if (owner) {
650
+ node.meta = { ...node.meta, svgOwner: owner.sourcePath };
651
+ continue;
652
+ }
653
+ const source = exactSource ||
654
+ mermaidFallbackElementForBounds(svg, deck, node.bounds);
655
+ if (!source) throw new Error(`Mermaid SVG source unavailable: ${node.sourcePath}`);
656
+ if (slots.has(source)) {
657
+ node.meta = { ...node.meta, svgOwner: slots.get(source) };
658
+ continue;
659
+ }
660
+ node.meta = { ...node.meta, svg: captureSvgTree(source, { computedStyle }) };
661
+ slots.set(source, index);
662
+ }
663
+ for (const [source, index] of slots) {
664
+ const ancestor = [...slots.keys()].find((candidate) => candidate !== source && candidate.contains(source));
665
+ if (ancestor) {
666
+ scene.nodes[index].meta.svgOwner = slots.get(ancestor);
667
+ slots.delete(source);
668
+ }
669
+ }
670
+ } catch (error) {
671
+ // A new Mermaid structure must stay visible even if its source mapping is
672
+ // not yet understood. Preserve the full safe artwork and report the reason.
673
+ const reason = `mermaid-svg-source-fallback: ${error.message}`;
674
+ console.warn(reason);
675
+ scene = { ...scene, nodes: [{
676
+ kind: "fallback", sourcePath: "svg", z: 0,
677
+ bounds: { x: 0, y: 0, width: scene.width, height: scene.height },
678
+ capability: { pptx: "fallback", reason }, reason,
679
+ meta: { svg: captureSvgTree(svg, { computedStyle }) },
680
+ }] };
681
+ slots.clear();
682
+ slots.set(svg, 0);
683
+ }
684
+ const template = slots.has(svg)
685
+ ? { sceneNode: slots.get(svg) }
686
+ : captureSvgTree(svg, { slots, computedStyle });
687
+ scene.meta = { ...scene.meta, svgRoot: template };
688
+ svg.replaceWith(sceneToSvg(scene, { document, template }));
689
+ }
690
+
597
691
  // --- slide rendering -------------------------------------------------------
598
692
  function moveLeadingSlideTitle(header, bodyEl, specialLayout) {
599
693
  if (specialLayout) return null;
@@ -826,7 +920,7 @@ function renderSlide(markdown) {
826
920
  const images = waitForImages(slide.deck).then(() => {
827
921
  if (token === renderToken) scheduleLayoutRefresh();
828
922
  });
829
- const mermaid = runMermaid(slide.bodyEl, slide.theme, token, false).finally(() => {
923
+ const mermaid = runMermaid(slide.bodyEl, slide.deck, token, false).finally(() => {
830
924
  if (token === renderToken) scheduleLayoutRefresh();
831
925
  });
832
926
  Promise.all([mermaid, images]).finally(() => {
@@ -1666,51 +1760,7 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1666
1760
  width: roundedMetric(object.width * scale),
1667
1761
  height: roundedMetric(object.height * scale),
1668
1762
  });
1669
- const mapColorFields = (object) => {
1670
- const mapped = { ...object };
1671
- const mapParagraphs = (paragraphs) =>
1672
- paragraphs.map((paragraph) => ({
1673
- ...paragraph,
1674
- runs: paragraph.runs.map((run) => ({
1675
- ...run,
1676
- color: resolveModelColor(run.color, deck),
1677
- fontFace: getComputedStyle(svg).fontFamily
1678
- .split(",")[0]
1679
- .trim()
1680
- .replace(/^["']|["']$/g, ""),
1681
- fontSize: roundedMetric(run.fontSize * scale),
1682
- bold: Number(run.fontWeight) >= 600,
1683
- })),
1684
- }));
1685
- if (mapped.dash !== undefined) mapped.dash = powerPointDashStyle(mapped.dash);
1686
- for (const key of ["fill", "stroke", "color"]) {
1687
- if (key in mapped) mapped[key] = resolveModelColor(mapped[key], deck);
1688
- }
1689
- if (Array.isArray(mapped.paragraphs)) {
1690
- mapped.paragraphs = mapParagraphs(mapped.paragraphs);
1691
- }
1692
- if (mapped.text?.paragraphs) {
1693
- mapped.text = {
1694
- ...mapped.text,
1695
- paragraphs: mapParagraphs(mapped.text.paragraphs),
1696
- };
1697
- }
1698
- if (mapped.textInsets) {
1699
- mapped.textInsets = Object.fromEntries(
1700
- Object.entries(mapped.textInsets).map(([key, value]) => [
1701
- key,
1702
- roundedMetric(value * scale),
1703
- ]),
1704
- );
1705
- }
1706
- return mapped;
1707
- };
1708
- const fallbacks = snapshot.fallbacks.map((fallback) => ({
1709
- ...fallback,
1710
- path: `architecture[${blockIndex}].${fallback.path}`,
1711
- ...mapBounds(fallback),
1712
- }));
1713
- const elements = [];
1763
+ const fallbacks = [];
1714
1764
  const architectureGroups = [...wrapper.querySelectorAll("[data-architecture-type]")];
1715
1765
  const findById = (id) =>
1716
1766
  architectureGroups.find((element) => element.getAttribute("data-architecture-id") === id);
@@ -1741,13 +1791,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1741
1791
  width: bounds.width,
1742
1792
  height: bounds.height,
1743
1793
  },
1744
- architecture: {
1745
- kind: "icon-picture",
1746
- id: icon.id,
1747
- sourcePath: icon.sourcePath,
1748
- order: icon.order,
1749
- z: icon.z,
1750
- },
1751
1794
  });
1752
1795
  }
1753
1796
  }
@@ -1776,10 +1819,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1776
1819
  width: bounds.width,
1777
1820
  height: bounds.height,
1778
1821
  },
1779
- architecture: {
1780
- ...object.architecture,
1781
- kind: "image-picture",
1782
- },
1783
1822
  });
1784
1823
  }
1785
1824
  }
@@ -1819,15 +1858,6 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1819
1858
  ),
1820
1859
  );
1821
1860
  }
1822
- const foregroundElement = (layer) => ({
1823
- type: "image",
1824
- src: layer.src,
1825
- alt: layer.alt,
1826
- fit: "fill",
1827
- opacity: 1,
1828
- ...layer.bounds,
1829
- architecture: layer.architecture,
1830
- });
1831
1861
  if (!foregroundReady) {
1832
1862
  fallbacks.push(
1833
1863
  pptxFallback(
@@ -1841,84 +1871,25 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1841
1871
  }
1842
1872
 
1843
1873
  for (const sourceObject of snapshot.objects) {
1844
- const object = mapColorFields({
1845
- ...sourceObject,
1846
- ...mapBounds(sourceObject),
1847
- ...(sourceObject.points
1848
- ? {
1849
- points: sourceObject.points.map((point) => ({
1850
- x: roundedMetric(originX + point.x * scale),
1851
- y: roundedMetric(originY + point.y * scale),
1852
- })),
1853
- }
1854
- : {}),
1855
- ...(sourceObject.strokeWidth !== undefined
1856
- ? { strokeWidth: roundedMetric(sourceObject.strokeWidth * scale) }
1857
- : {}),
1858
- ...(sourceObject.cornerRadius !== undefined
1859
- ? { cornerRadius: roundedMetric(sourceObject.cornerRadius * scale) }
1860
- : {}),
1861
- });
1862
- if (object.type === "shape") {
1863
- const opacity = Number.isFinite(object.opacity) ? object.opacity : 1;
1864
- if (object.text?.paragraphs) {
1865
- object.text = {
1866
- ...object.text,
1867
- paragraphs: object.text.paragraphs.map((paragraph) => ({
1868
- ...paragraph,
1869
- runs: paragraph.runs.map((run) => ({
1870
- ...run,
1871
- opacity: (Number.isFinite(run.opacity) ? run.opacity : 1) * opacity,
1872
- })),
1873
- })),
1874
- };
1875
- }
1876
- }
1877
- if (object.type === "image") {
1878
- const layer = foregroundLayers.get(`image:${object.architecture.id}`);
1874
+ if (sourceObject.type === "image") {
1875
+ const layer = foregroundLayers.get(`image:${sourceObject.architecture.id}`);
1879
1876
  fallbacks.push({
1880
1877
  type: "architecture-image",
1881
- path: `architecture[${blockIndex}].${object.architecture.sourcePath}`,
1882
- reason: foregroundReady && layer
1878
+ path: `architecture[${blockIndex}].${sourceObject.architecture.sourcePath}`,
1879
+ reason: layer
1883
1880
  ? "architecture-image-rendered-as-foreground-picture"
1884
1881
  : "architecture-image-rendered-as-artwork",
1885
1882
  ...mapBounds(sourceObject),
1886
- ...(foregroundReady && layer ? { artwork: false } : {}),
1883
+ ...(layer ? { artwork: false } : {}),
1887
1884
  });
1888
- if (foregroundReady && layer) elements.push(foregroundElement(layer));
1889
- continue;
1890
1885
  }
1891
- elements.push(object);
1892
- if (object.type === "shape" && object.architecture?.kind === "node" && sourceObject.icon) {
1893
- const layer = foregroundLayers.get(`icon:${object.architecture.id}`);
1894
- if (foregroundReady && layer) elements.push(foregroundElement(layer));
1895
- }
1896
- }
1897
- for (const icon of snapshot.icons || []) {
1898
- const layer = foregroundLayers.get(`icon:${icon.id}`);
1899
- fallbacks.push({
1900
- type: "architecture-icon",
1901
- path: `architecture[${blockIndex}].${icon.sourcePath}`,
1902
- reason: foregroundReady && layer
1903
- ? "icon-rendered-as-foreground-picture"
1904
- : "icon-rendered-as-artwork",
1905
- icon: icon.icon,
1906
- ...mapBounds(icon),
1907
- ...(foregroundReady && layer ? { artwork: false } : {}),
1908
- });
1909
- }
1910
- for (const sourceObject of snapshot.objects) {
1911
1886
  const architecture = sourceObject.architecture;
1912
1887
  if (!architecture) continue;
1913
1888
  if (architecture.kind === "group" || architecture.kind === "node") {
1914
1889
  const group = findById(architecture.id);
1915
1890
  if (!group) continue;
1916
1891
  [...group.children]
1917
- .filter((child) =>
1918
- foregroundReady
1919
- ? child.matches("rect, ellipse, text")
1920
- : child.matches("text"),
1921
- )
1892
+ .filter((child) => child.matches("rect, ellipse, text"))
1922
1893
  .forEach((child) => child.setAttribute("data-pptx-native", sourceObject.type));
1923
1894
  } else if (architecture.kind === "connector") {
1924
1895
  architectureGroups
@@ -1940,13 +1911,24 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1940
1911
  .forEach((label) => label.setAttribute("data-pptx-native", sourceObject.type));
1941
1912
  }
1942
1913
  }
1943
- if (foregroundReady) {
1944
- foregroundCandidates.forEach((candidate) => {
1945
- if (foregroundLayers.has(candidate.key)) {
1946
- candidate.source.setAttribute("data-pptx-native", "image");
1947
- }
1914
+ for (const icon of snapshot.icons || []) {
1915
+ const layer = foregroundLayers.get(`icon:${icon.id}`);
1916
+ fallbacks.push({
1917
+ type: "architecture-icon",
1918
+ path: `architecture[${blockIndex}].${icon.sourcePath}`,
1919
+ reason: layer
1920
+ ? "icon-rendered-as-foreground-picture"
1921
+ : "icon-rendered-as-artwork",
1922
+ icon: icon.icon,
1923
+ ...mapBounds(icon),
1924
+ ...(layer ? { artwork: false } : {}),
1948
1925
  });
1949
1926
  }
1927
+ foregroundCandidates.forEach((candidate) => {
1928
+ if (foregroundLayers.has(candidate.key)) {
1929
+ candidate.source.setAttribute("data-pptx-native", "image");
1930
+ }
1931
+ });
1950
1932
  if (snapshot.routing.degraded) {
1951
1933
  fallbacks.push(
1952
1934
  pptxFallback(
@@ -1957,7 +1939,190 @@ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1957
1939
  ),
1958
1940
  );
1959
1941
  }
1960
- return { elements, fallbacks };
1942
+
1943
+ const fontFace = getComputedStyle(svg).fontFamily
1944
+ .split(",")[0]
1945
+ .trim()
1946
+ .replace(/^["']|["']$/g, "");
1947
+ const { scene } = architectureSnapshotToScene(snapshot, {
1948
+ path: `architecture[${blockIndex}]`,
1949
+ resolveColor: (value) => resolveModelColor(value, deck),
1950
+ resolveDash: powerPointDashStyle,
1951
+ resolveImage: (entry, kind) => {
1952
+ const key = kind === "icon-picture"
1953
+ ? `icon:${entry.id}`
1954
+ : `image:${entry.architecture?.id || entry.id}`;
1955
+ return foregroundLayers.get(key) || "";
1956
+ },
1957
+ fontFace,
1958
+ scale,
1959
+ originX,
1960
+ originY,
1961
+ });
1962
+ const mapped = sceneToPptxElements(scene, {
1963
+ pathPrefix: `architecture[${blockIndex}]`,
1964
+ emitPath: false,
1965
+ emitZOrder: false,
1966
+ });
1967
+ return { elements: mapped.elements, fallbacks: [...fallbacks, ...mapped.fallbacks] };
1968
+ }
1969
+
1970
+ function unprefixScenePath(path, prefix) {
1971
+ return typeof path === "string" && path.startsWith(`${prefix}.`)
1972
+ ? path.slice(prefix.length + 1)
1973
+ : path;
1974
+ }
1975
+
1976
+ function mermaidEdgeLabelElement(svg, id) {
1977
+ if (!id) return null;
1978
+ const root = svg.querySelector("g.root");
1979
+ const labels = root ? [...root.querySelectorAll(":scope > g.edgeLabels > g.edgeLabel")] : [];
1980
+ return labels.find((label) => {
1981
+ const labelGroup = label.querySelector(":scope > g.label");
1982
+ return labelGroup?.getAttribute("data-id") === id;
1983
+ }) || null;
1984
+ }
1985
+
1986
+ function mermaidElementForSourcePath(svg, sourcePath) {
1987
+ const root = svg.querySelector("g.root");
1988
+ if (!root) return sourcePath === "svg" ? svg : null;
1989
+ const indexed = /^(nodes|clusters|edges)\[(\d+)\]$/.exec(sourcePath || "");
1990
+ if (indexed) {
1991
+ const [, kind, rawIndex] = indexed;
1992
+ const index = Number(rawIndex);
1993
+ const selectors = {
1994
+ nodes: ":scope > g.nodes > g.node",
1995
+ clusters: ":scope > g.clusters > g.cluster",
1996
+ edges: ":scope > g.edgePaths > path.flowchart-link",
1997
+ };
1998
+ return [...root.querySelectorAll(selectors[kind])][index] || null;
1999
+ }
2000
+ if (sourcePath === "svg") return svg;
2001
+ return null;
2002
+ }
2003
+
2004
+ function mermaidFallbackElementForBounds(svg, deck, bounds) {
2005
+ const ignored = new Set(["defs", "desc", "filter", "linearGradient", "marker", "metadata", "script", "style"]);
2006
+ const containerClasses = new Set(["root", "clusters", "edgePaths", "edgeLabels", "edgeLabel", "label", "nodes"]);
2007
+ const candidates = [...svg.querySelectorAll("circle, ellipse, foreignObject, g, image, line, path, polygon, polyline, rect, text, use")]
2008
+ .filter((candidate) => {
2009
+ if (candidate.closest("[data-pptx-native]")) return false;
2010
+ if (ignored.has(String(candidate.localName || candidate.tagName).toLowerCase())) return false;
2011
+ if (Array.from(candidate.classList || []).some((name) => containerClasses.has(name))) return false;
2012
+ const rect = candidate.getBoundingClientRect();
2013
+ return rect.width > 0 && rect.height > 0;
2014
+ });
2015
+ return candidates.find((candidate) => {
2016
+ const candidateBounds = fallbackBounds(candidate, deck);
2017
+ return (
2018
+ Math.abs(candidateBounds.x - bounds.x) <= 1 &&
2019
+ Math.abs(candidateBounds.y - bounds.y) <= 1 &&
2020
+ Math.abs(candidateBounds.width - bounds.width) <= 1 &&
2021
+ Math.abs(candidateBounds.height - bounds.height) <= 1
2022
+ );
2023
+ }) || null;
2024
+ }
2025
+
2026
+ function markMermaidNativeElements(svg, mappedElements, pathPrefix, sourceElements) {
2027
+ for (const element of mappedElements) {
2028
+ const sourcePath = unprefixScenePath(element.path, pathPrefix);
2029
+ let source = sourceElements?.get(sourcePath) || mermaidElementForSourcePath(svg, sourcePath);
2030
+ let ownerPath = sourcePath;
2031
+ while (!source && sourceElements && ownerPath.includes(".")) {
2032
+ ownerPath = ownerPath.slice(0, ownerPath.lastIndexOf("."));
2033
+ source = sourceElements.get(ownerPath);
2034
+ }
2035
+ if (source && !source.hasAttribute("data-pptx-native")) {
2036
+ const nativeKind = element.type === "shape" ? "shape" : element.type;
2037
+ source.setAttribute("data-pptx-native", nativeKind);
2038
+ }
2039
+ if (!sourceElements && element.type === "connector") {
2040
+ const id = element.mermaid?.id || source?.getAttribute("data-id") || source?.getAttribute("id") || "";
2041
+ mermaidEdgeLabelElement(svg, id)?.setAttribute("data-pptx-native", "text");
2042
+ }
2043
+ }
2044
+ }
2045
+
2046
+ function mermaidWholeElementFallbackRequired(scene, diagnostics) {
2047
+ const nodes = Array.isArray(scene?.nodes) ? scene.nodes : [];
2048
+ const reason = nodes[0]?.reason || diagnostics.find((entry) => entry?.reason)?.reason || "";
2049
+ return (
2050
+ nodes.length === 1 &&
2051
+ nodes[0]?.kind === "fallback" &&
2052
+ nodes[0]?.sourcePath === "svg" &&
2053
+ (
2054
+ reason.startsWith("mermaid-scene-adapter-failed:") ||
2055
+ reason.startsWith("mermaid-scene-limit-exceeded:") ||
2056
+ reason === "unsupported-mermaid-svg-structure"
2057
+ )
2058
+ );
2059
+ }
2060
+
2061
+ function collectMermaidObjects(element, deck, blockIndex) {
2062
+ const svg = element.querySelector("svg");
2063
+ if (!svg) {
2064
+ return {
2065
+ elements: [],
2066
+ fallbacks: [pptxFallback("mermaid", element, deck, "mermaid-rendered-as-artwork")],
2067
+ };
2068
+ }
2069
+ const pathPrefix = `mermaid[${blockIndex}]`;
2070
+ const { scene, diagnostics, sourceElements } = mermaidSvgToScene(svg, {
2071
+ path: pathPrefix,
2072
+ deck,
2073
+ resolveColor: (value) => resolveModelColor(value, deck),
2074
+ includeSourceElements: true,
2075
+ });
2076
+ if (mermaidWholeElementFallbackRequired(scene, diagnostics)) {
2077
+ return {
2078
+ elements: [],
2079
+ fallbacks: [pptxFallback("mermaid", element, deck, "mermaid-rendered-as-artwork")],
2080
+ };
2081
+ }
2082
+ const mapped = sceneToPptxElements(scene, {
2083
+ pathPrefix,
2084
+ groupPreset: "rect",
2085
+ zOrderBase: Number(element.dataset.pptxZOrder),
2086
+ });
2087
+ markMermaidNativeElements(svg, mapped.elements, pathPrefix, sourceElements);
2088
+ const fallbackNodes = new Map(
2089
+ scene.nodes
2090
+ .filter((node) => node.kind === "fallback")
2091
+ .map((node) => [node.sourcePath, node]),
2092
+ );
2093
+ const fallbacks = mapped.fallbacks.map((fallback) => {
2094
+ const sourcePath = fallback.sourcePath || unprefixScenePath(fallback.path, pathPrefix);
2095
+ const node = fallbackNodes.get(sourcePath);
2096
+ const source =
2097
+ sourceElements?.get(sourcePath) ||
2098
+ mermaidElementForSourcePath(svg, sourcePath) ||
2099
+ mermaidFallbackElementForBounds(svg, deck, fallback) ||
2100
+ 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;
2105
+ const captured = pptxFallback("mermaid", source, deck, fallback.reason, {
2106
+ captureElement: source,
2107
+ includeDescendants: true,
2108
+ artwork: fallback.artwork,
2109
+ padding,
2110
+ });
2111
+ return {
2112
+ ...captured,
2113
+ path: fallback.path,
2114
+ sourcePath,
2115
+ reason: fallback.reason,
2116
+ x: fallback.x,
2117
+ y: fallback.y,
2118
+ width: fallback.width,
2119
+ height: fallback.height,
2120
+ zOrder: fallback.zOrder,
2121
+ ...(node?.id ? { id: node.id } : {}),
2122
+ ...(fallback.artwork === false ? { artwork: false } : {}),
2123
+ };
2124
+ });
2125
+ return { elements: mapped.elements, fallbacks };
1961
2126
  }
1962
2127
 
1963
2128
  async function collectPptxSlide(slide, index) {
@@ -2064,10 +2229,17 @@ async function collectPptxSlide(slide, index) {
2064
2229
  });
2065
2230
  }
2066
2231
  });
2067
- deck.querySelectorAll("pre.mermaid").forEach((element) => {
2232
+ for (const [blockIndex, element] of [...deck.querySelectorAll("pre.mermaid")].entries()) {
2068
2233
  const covered = [...fallbackRoots].some((root) => root === element || root.contains(element));
2069
- if (!covered) addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2070
- });
2234
+ if (covered) continue;
2235
+ try {
2236
+ const mermaid = collectMermaidObjects(element, deck, blockIndex);
2237
+ elements.push(...mermaid.elements);
2238
+ fallbacks.push(...mermaid.fallbacks);
2239
+ } catch (_) {
2240
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork");
2241
+ }
2242
+ }
2071
2243
 
2072
2244
  const insideFallback = (element) =>
2073
2245
  [...fallbackRoots].some((root) => root === element || root.contains(element));
@@ -2079,6 +2251,7 @@ async function collectPptxSlide(slide, index) {
2079
2251
  (element) =>
2080
2252
  !insideFallback(element) &&
2081
2253
  !element.closest(".architecture-diagram") &&
2254
+ !element.closest("pre.mermaid, .mermaid") &&
2082
2255
  !element.closest("table") &&
2083
2256
  !(element.matches("p") && element.closest("blockquote, li")),
2084
2257
  );
@@ -2475,7 +2648,7 @@ async function renderPptxDeck(
2475
2648
  }
2476
2649
  const token = ++renderToken;
2477
2650
  for (const slide of rendered) {
2478
- await runMermaid(slide.bodyEl, slide.theme, token, false);
2651
+ await runMermaid(slide.bodyEl, slide.deck, token, false);
2479
2652
  }
2480
2653
  await waitForImages(stage);
2481
2654
  await afterLayout();
@@ -2590,7 +2763,7 @@ async function renderPrintDeck(
2590
2763
  }
2591
2764
  }
2592
2765
  for (const slide of rendered) {
2593
- await runMermaid(slide.bodyEl, slide.theme, renderToken, false);
2766
+ await runMermaid(slide.bodyEl, slide.deck, renderToken, false);
2594
2767
  }
2595
2768
  await waitForImages(stage);
2596
2769
  await afterLayout();
@@ -2669,7 +2842,7 @@ async function renderCaptureSlide(
2669
2842
  }
2670
2843
 
2671
2844
  const token = ++renderToken;
2672
- await runMermaid(slide.bodyEl, slide.theme, token, false);
2845
+ await runMermaid(slide.bodyEl, slide.deck, token, false);
2673
2846
  await waitForImages(stage);
2674
2847
  await afterLayout();
2675
2848
 
@@ -2790,8 +2963,10 @@ let pptxExportAvailable = false;
2790
2963
  let markdownImportAvailable = false;
2791
2964
  let presenterViewOpen = false;
2792
2965
  let presenterViewRequested = false;
2793
- let pdfExportPending = false;
2794
- let pptxExportPending = false;
2966
+ let exportPending = false;
2967
+ let exportNotificationTimer = null;
2968
+ let exportNotificationRemaining = 0;
2969
+ let exportNotificationStarted = 0;
2795
2970
 
2796
2971
  // Derive a short overview title from a slide fragment: first heading, else first
2797
2972
  // non-empty body line, trimmed. Mirrors the skill's title rule.
@@ -2846,6 +3021,7 @@ async function fetchDeck() {
2846
3021
  function setArchitectureEditMode(enabled) {
2847
3022
  const next = Boolean(enabled) && architectureEditAvailable && !presenterMode;
2848
3023
  if (next === architectureEditMode) return false;
3024
+ if (next && fixedPreviewMode) setFixedPreviewMode(false);
2849
3025
  architectureEditMode = next;
2850
3026
  document.body.classList.toggle("architecture-edit-mode", next);
2851
3027
  updateArchitectureEditButton(next);
@@ -3450,93 +3626,109 @@ function updatePresenterButton(running, message = "") {
3450
3626
  syncMoreControls();
3451
3627
  }
3452
3628
 
3453
- async function exportPdfFromCanvas() {
3454
- if (!pdfExportAvailable || pdfExportPending || pptxExportPending) return;
3455
- pdfExportPending = true;
3456
- const button = document.getElementById("navExport");
3457
- const pptxButton = document.getElementById("navExportPptx");
3458
- const status = document.getElementById("exportStatus");
3459
- if (button) button.disabled = true;
3460
- if (pptxButton) pptxButton.disabled = true;
3461
- if (status) status.textContent = "Saving PDF.";
3629
+ function pauseExportNotification() {
3630
+ if (exportNotificationTimer === null) return;
3631
+ clearTimeout(exportNotificationTimer);
3632
+ exportNotificationTimer = null;
3633
+ exportNotificationRemaining = Math.max(
3634
+ 0,
3635
+ exportNotificationRemaining - (performance.now() - exportNotificationStarted),
3636
+ );
3637
+ }
3462
3638
 
3463
- try {
3464
- const response = await fetch("./export", {
3465
- method: "POST",
3466
- headers: { Accept: "application/json" },
3467
- cache: "no-store",
3468
- });
3469
- const data = await response.json().catch(() => ({}));
3470
- if (!response.ok) {
3471
- throw new Error(data.message || `PDF export failed (${response.status}).`);
3472
- }
3473
- const filename = data.path ? data.path.split(/[\\/]/).pop() : "PDF";
3474
- const message = `Saved ${filename}.`;
3475
- if (status) status.textContent = message;
3476
- if (button) {
3477
- button.dataset.state = "active";
3478
- button.title = message;
3479
- }
3480
- syncMoreControls();
3481
- } catch (error) {
3482
- const message = error?.message || "Could not save the PDF.";
3483
- console.error("PDF export failed", error);
3484
- if (status) status.textContent = message;
3485
- if (button) {
3486
- button.dataset.state = "error";
3487
- button.title = message;
3488
- }
3489
- syncMoreControls();
3490
- } finally {
3491
- pdfExportPending = false;
3492
- if (button) button.disabled = false;
3493
- if (pptxButton) pptxButton.disabled = false;
3639
+ function dismissExportNotification() {
3640
+ const notification = document.getElementById("exportNotification");
3641
+ const restoreFocus = notification.contains(document.activeElement);
3642
+ pauseExportNotification();
3643
+ exportNotificationRemaining = 0;
3644
+ notification.hidden = true;
3645
+ document.getElementById("exportStatus").textContent = "";
3646
+ document.getElementById("exportErrorStatus").textContent = "";
3647
+ if (restoreFocus) {
3648
+ const more = document.getElementById("navMore");
3649
+ (more.getClientRects().length ? more : document.body).focus();
3494
3650
  }
3495
3651
  }
3496
3652
 
3497
- async function exportPptxFromCanvas() {
3498
- if (!pptxExportAvailable || pdfExportPending || pptxExportPending) return;
3499
- pptxExportPending = true;
3500
- const button = document.getElementById("navExportPptx");
3653
+ function resumeExportNotification() {
3654
+ const notification = document.getElementById("exportNotification");
3655
+ if (
3656
+ notification.hidden ||
3657
+ notification.dataset.state !== "success" ||
3658
+ notification.matches(":hover, :focus-within") ||
3659
+ exportNotificationTimer !== null
3660
+ ) {
3661
+ return;
3662
+ }
3663
+ exportNotificationStarted = performance.now();
3664
+ exportNotificationTimer = setTimeout(dismissExportNotification, exportNotificationRemaining);
3665
+ }
3666
+
3667
+ function showExportNotification(state, message, path = "") {
3668
+ pauseExportNotification();
3669
+ const notification = document.getElementById("exportNotification");
3670
+ const location = document.getElementById("exportNotificationPath");
3671
+ notification.dataset.state = state;
3672
+ document.getElementById("exportNotificationMessage").textContent = message;
3673
+ location.textContent = path ? `Saved to: ${path}` : "";
3674
+ location.hidden = !path;
3675
+ document.getElementById("exportNotificationClose").hidden = state === "pending";
3676
+ notification.hidden = false;
3677
+ const announcement = path ? `${message} Saved to: ${path}` : message;
3678
+ document.getElementById("exportStatus").textContent = state === "error" ? "" : announcement;
3679
+ document.getElementById("exportErrorStatus").textContent = state === "error" ? announcement : "";
3680
+ exportNotificationRemaining = state === "success" ? 8000 : 0;
3681
+ resumeExportNotification();
3682
+ }
3683
+
3684
+ async function exportFromCanvas(format) {
3685
+ const isPdf = format === "pdf";
3686
+ if (exportPending || !(isPdf ? pdfExportAvailable : pptxExportAvailable)) return;
3687
+ exportPending = true;
3688
+ const label = isPdf ? "PDF" : "PowerPoint";
3501
3689
  const pdfButton = document.getElementById("navExport");
3502
- const status = document.getElementById("exportStatus");
3503
- if (button) button.disabled = true;
3504
- if (pdfButton) pdfButton.disabled = true;
3505
- if (status) status.textContent = "Saving editable PowerPoint.";
3690
+ const pptxButton = document.getElementById("navExportPptx");
3691
+ const button = isPdf ? pdfButton : pptxButton;
3692
+ const idleTitle = button.title;
3693
+ pdfButton.disabled = true;
3694
+ pptxButton.disabled = true;
3695
+ delete button.dataset.state;
3696
+ button.title = `Saving ${label}.`;
3697
+ showExportNotification("pending", `Saving ${label}...`);
3698
+ syncMoreControls();
3506
3699
 
3507
3700
  try {
3508
- const response = await fetch("./export-pptx", {
3701
+ const response = await fetch(isPdf ? "./export" : "./export-pptx", {
3509
3702
  method: "POST",
3510
3703
  headers: { Accept: "application/json" },
3511
3704
  cache: "no-store",
3512
3705
  });
3513
- const data = await response.json().catch(() => ({}));
3514
- if (!response.ok) {
3515
- throw new Error(data.message || `PowerPoint export failed (${response.status}).`);
3706
+ const data = await response.json();
3707
+ if (!response.ok || data?.ok !== true) {
3708
+ throw new Error(
3709
+ (typeof data?.message === "string" && data.message) ||
3710
+ `${label} export failed (${response.status}).`,
3711
+ );
3516
3712
  }
3517
- const filename = data.path ? data.path.split(/[\\/]/).pop() : "PowerPoint";
3518
- const fallback =
3519
- data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
3520
- const message = `Saved ${filename}.${fallback}`;
3521
- if (status) status.textContent = message;
3522
- if (button) {
3523
- button.dataset.state = "active";
3524
- button.title = message;
3713
+ if (typeof data.path !== "string" || !data.path.trim() || !data.path.split(/[\\/]/).pop()) {
3714
+ throw new Error(`${label} export returned an invalid save location.`);
3525
3715
  }
3526
- syncMoreControls();
3716
+ const filename = data.path.split(/[\\/]/).pop();
3717
+ const fallback =
3718
+ !isPdf && data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
3719
+ const message = `${label} saved: ${filename}.${fallback}`;
3720
+ showExportNotification("success", message, data.path);
3527
3721
  } catch (error) {
3528
- const message = error?.message || "Could not save the PowerPoint presentation.";
3529
- console.error("PowerPoint export failed", error);
3530
- if (status) status.textContent = message;
3531
- if (button) {
3532
- button.dataset.state = "error";
3533
- button.title = message;
3534
- }
3535
- syncMoreControls();
3722
+ const message = `Could not save ${label}. ${error?.message || "Export failed."}`;
3723
+ console.error(`${label} export failed`, error);
3724
+ showExportNotification("error", message);
3536
3725
  } finally {
3537
- pptxExportPending = false;
3538
- if (button) button.disabled = false;
3539
- if (pdfButton) pdfButton.disabled = false;
3726
+ exportPending = false;
3727
+ pdfButton.disabled = false;
3728
+ pptxButton.disabled = false;
3729
+ delete button.dataset.state;
3730
+ button.title = idleTitle;
3731
+ syncMoreControls();
3540
3732
  }
3541
3733
  }
3542
3734
 
@@ -3982,8 +4174,8 @@ function wireControls() {
3982
4174
  bind("navPresent", openPresenterWindow, { closeMore: true });
3983
4175
  bind("navPresenterView", openPresenterView, { closeMore: true });
3984
4176
  bind("navFixedPreview", toggleFixedPreviewMode, { closeMore: true });
3985
- bind("navExport", exportPdfFromCanvas, { closeMore: true });
3986
- bind("navExportPptx", exportPptxFromCanvas, { closeMore: true });
4177
+ bind("navExport", () => exportFromCanvas("pdf"), { closeMore: true });
4178
+ bind("navExportPptx", () => exportFromCanvas("pptx"), { closeMore: true });
3987
4179
  bind("navImport", toggleImportPicker, { closeMore: true });
3988
4180
  bind("navSourceMode", toggleSourceMode, { closeMore: true });
3989
4181
  bind("overviewClose", closeOverview);
@@ -3995,6 +4187,13 @@ function wireControls() {
3995
4187
  bind("presenterToggleButton", togglePresenterWindow);
3996
4188
  bind("presenterReturnButton", closePresenterView);
3997
4189
 
4190
+ const exportNotification = document.getElementById("exportNotification");
4191
+ document.getElementById("exportNotificationClose").addEventListener("click", dismissExportNotification);
4192
+ exportNotification.addEventListener("pointerenter", pauseExportNotification);
4193
+ exportNotification.addEventListener("pointerleave", resumeExportNotification);
4194
+ exportNotification.addEventListener("focusin", pauseExportNotification);
4195
+ exportNotification.addEventListener("focusout", () => queueMicrotask(resumeExportNotification));
4196
+
3998
4197
  const importFilter = document.getElementById("importFilter");
3999
4198
  if (importFilter) {
4000
4199
  importFilter.addEventListener("input", renderImportList);
@@ -4155,6 +4354,17 @@ function init() {
4155
4354
  requestArchitectureEditMode(true);
4156
4355
  }
4157
4356
 
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
+ ) {
4365
+ setFixedPreviewMode(true);
4366
+ }
4367
+
4158
4368
  updateArchitectureEditButton();
4159
4369
  if (!previewMode) wireControls();
4160
4370
  else if (navigationEnabled) {