@markdstage/markdstage 0.1.3 → 2.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.
@@ -90,16 +90,22 @@ let lastMermaidTheme = null;
90
90
  // Editing mode is available only in normal view, not presenter or print mode.
91
91
  // Print mode returns early in init, so presenterMode is the effective branch here.
92
92
  let architectureEditMode = false;
93
+ let architectureEditAvailable = false;
93
94
  let architectureDetailedEdit = false;
95
+ let architectureDetailedEditTarget = "";
94
96
  let presenterMode = false;
95
97
  let previewMode = false;
96
98
  let previewOffset = 0;
97
99
  let navigationEnabled = true;
98
100
  let fixedPreviewMode = false;
101
+ let moreControlsOpen = false;
99
102
  // Markdown for the most recently rendered slide, retained for editing-mode rerenders.
100
103
  let lastMarkdown = "";
101
104
  // Editing UI attached to the rendered slide; destroyed on every rerender.
102
105
  let architectureEditors = [];
106
+ // Serialize saves from every Architecture block so each request uses the deck
107
+ // version returned by the previous save.
108
+ let architectureSaveQueue = Promise.resolve();
103
109
  // `layoutTarget` is the slide currently on screen (cover and back cover
104
110
  // included); `autoSize` says whether it also takes part in the font auto-fit.
105
111
  let layoutTarget = null;
@@ -422,12 +428,14 @@ function updateFixedPreviewWarning() {
422
428
  warning.textContent = "";
423
429
  }
424
430
  if (button) button.dataset.state = fixedPreviewMode ? "active" : "";
431
+ syncMoreControls();
425
432
  return;
426
433
  }
427
434
 
428
435
  const diagnostic = collectSlideLayout(layoutTarget, navIndex);
429
436
  document.body.classList.toggle("fixed-preview-overflow", diagnostic.pdfClipped);
430
437
  if (button) button.dataset.state = diagnostic.pdfClipped ? "error" : "active";
438
+ syncMoreControls();
431
439
  if (!warning) return;
432
440
  if (!diagnostic.pdfClipped) {
433
441
  warning.hidden = true;
@@ -701,13 +709,15 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
701
709
  host.className = "architecture-edit-host";
702
710
  host.setAttribute("data-architecture-block", String(blockIndex));
703
711
  target.replaceWith(host);
712
+ const editorRenderToken = renderToken;
704
713
  const editor = attachArchitectureEditor(host, {
705
714
  source,
706
715
  documentRef: document,
707
716
  canOpenDetail: architectureDetailedEdit,
708
717
  onOpenDetail: () => openDetailedArchitectureEditor(slideIndex, blockIndex),
709
718
  // Return the save result to the editor; omitting it makes failures look successful.
710
- onCommit: (next) => saveArchitectureBlock(slideIndex, blockIndex, next),
719
+ onCommit: (next) =>
720
+ saveArchitectureBlock(slideIndex, blockIndex, next, editorRenderToken),
711
721
  });
712
722
  if (!editor) {
713
723
  // Do not edit invalid DSL; fall back to the standard error display.
@@ -759,6 +769,7 @@ function createSlide(markdown, fallbackTheme, themeLocked = deckThemeLocked) {
759
769
  sizeMode,
760
770
  titleSlide,
761
771
  sectionSlide,
772
+ centerSlide,
762
773
  backcoverSlide,
763
774
  title: meta.title || meta.deck || "Slide",
764
775
  };
@@ -771,11 +782,11 @@ function renderSlide(markdown) {
771
782
  architectureEditors.forEach((editor) => editor.destroy());
772
783
  architectureEditors = [];
773
784
  applyCustomThemeCss(customThemeCss);
785
+ const token = ++renderToken;
774
786
  const slide = createSlide(markdown, deckTheme);
775
787
  document.title = slide.title;
776
788
  document.documentElement.setAttribute("data-theme", slide.theme);
777
789
 
778
- const token = ++renderToken;
779
790
  document.body.classList.add("mermaid-loading");
780
791
  document.getElementById("stage").replaceChildren(slide.deck);
781
792
  if (layoutFrame) {
@@ -857,6 +868,990 @@ async function reportOutputStatus(token, status, error = "", layout = null) {
857
868
  if (!response.ok) throw new Error(`Could not report output status (${response.status}).`);
858
869
  }
859
870
 
871
+ const PPTX_RASTER_IMAGE = /\.(?:png|jpe?g|gif)(?:$|[?#])/i;
872
+
873
+ function normalizeCssColor(value) {
874
+ const text = String(value || "").trim();
875
+ if (!text || text === "none" || text === "transparent") return null;
876
+ const shortHex = text.match(/^#([\da-f])([\da-f])([\da-f])$/i);
877
+ if (shortHex) {
878
+ return `#${shortHex.slice(1).map((part) => part.repeat(2)).join("")}`.toUpperCase();
879
+ }
880
+ const hex = text.match(/^#([\da-f]{6})(?:[\da-f]{2})?$/i);
881
+ if (hex) return `#${text.slice(1).toUpperCase()}`;
882
+ const rgb = text.match(
883
+ /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i,
884
+ );
885
+ if (!rgb) return text;
886
+ if (rgb[4] === "0" || rgb[4] === "0%") return null;
887
+ const channels = rgb
888
+ .slice(1, 4)
889
+ .map((part) => Math.max(0, Math.min(255, Math.round(Number(part)))));
890
+ if (rgb[4] !== undefined) {
891
+ const alpha = rgb[4].endsWith("%") ? Number.parseFloat(rgb[4]) / 100 : Number(rgb[4]);
892
+ if (Number.isFinite(alpha) && alpha < 1) {
893
+ return `rgba(${channels.join(", ")}, ${Math.max(0, alpha)})`;
894
+ }
895
+ }
896
+ return `#${channels
897
+ .map((part) => part.toString(16).padStart(2, "0"))
898
+ .join("")
899
+ .toUpperCase()}`;
900
+ }
901
+
902
+ function resolveModelColor(value, context) {
903
+ if (!value || value === "none" || value === "transparent") return null;
904
+ const probe = document.createElement("span");
905
+ probe.style.color = String(value);
906
+ probe.style.position = "absolute";
907
+ probe.style.visibility = "hidden";
908
+ context.appendChild(probe);
909
+ const resolved = normalizeCssColor(getComputedStyle(probe).color);
910
+ probe.remove();
911
+ return resolved;
912
+ }
913
+
914
+ function relativeBounds(element, deck) {
915
+ const rect = element.getBoundingClientRect();
916
+ const slide = deck.getBoundingClientRect();
917
+ return {
918
+ x: roundedMetric(rect.left - slide.left),
919
+ y: roundedMetric(rect.top - slide.top),
920
+ width: roundedMetric(rect.width),
921
+ height: roundedMetric(rect.height),
922
+ };
923
+ }
924
+
925
+ function textContentBounds(element, deck) {
926
+ const range = document.createRange();
927
+ range.selectNodeContents(element);
928
+ const rect = range.getBoundingClientRect();
929
+ if (rect.width <= 0 || rect.height <= 0) return relativeBounds(element, deck);
930
+ const slide = deck.getBoundingClientRect();
931
+ return {
932
+ x: roundedMetric(rect.left - slide.left),
933
+ y: roundedMetric(rect.top - slide.top),
934
+ width: roundedMetric(rect.width),
935
+ height: roundedMetric(rect.height),
936
+ };
937
+ }
938
+
939
+ function rasterImageSupported(image) {
940
+ const source = image.currentSrc || image.getAttribute("src") || "";
941
+ if (["cover", "none"].includes(getComputedStyle(image).objectFit)) return false;
942
+ if (/^data:image\/(?:png|jpeg|gif)[;,]/i.test(source)) return true;
943
+ try {
944
+ const url = new URL(source, window.location.href);
945
+ return url.origin === window.location.origin && PPTX_RASTER_IMAGE.test(url.href);
946
+ } catch (_) {
947
+ return false;
948
+ }
949
+ }
950
+
951
+ function runStyle(element) {
952
+ const style = getComputedStyle(element);
953
+ const numericWeight = Number.parseInt(style.fontWeight, 10);
954
+ return {
955
+ fontFace: style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/g, ""),
956
+ fontSize: roundedMetric(parseFloat(style.fontSize)),
957
+ bold: Number.isFinite(numericWeight) ? numericWeight >= 600 : style.fontWeight === "bold",
958
+ italic: style.fontStyle !== "normal",
959
+ underline: style.textDecorationLine.includes("underline"),
960
+ color: normalizeCssColor(style.color),
961
+ };
962
+ }
963
+
964
+ function collectTextRuns(root, { omitNestedLists = false } = {}) {
965
+ const runs = [];
966
+ const append = (text, element) => {
967
+ if (!text) return;
968
+ const anchor = element.closest("a[href]");
969
+ const run = {
970
+ text,
971
+ ...runStyle(element),
972
+ ...(anchor ? { href: anchor.getAttribute("href") || anchor.href } : {}),
973
+ };
974
+ const previous = runs.at(-1);
975
+ const previousStyle = previous && { ...previous, text: undefined };
976
+ const nextStyle = { ...run, text: undefined };
977
+ if (previous && JSON.stringify(previousStyle) === JSON.stringify(nextStyle)) {
978
+ previous.text += text;
979
+ } else {
980
+ runs.push(run);
981
+ }
982
+ };
983
+ const visit = (node) => {
984
+ if (node.nodeType === Node.TEXT_NODE) {
985
+ append(node.nodeValue || "", node.parentElement || root);
986
+ return;
987
+ }
988
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
989
+ if (node.tagName === "BR") {
990
+ append("\n", node.parentElement || root);
991
+ return;
992
+ }
993
+ if (
994
+ node !== root &&
995
+ (node.matches("pre, table, .architecture-diagram, .architecture-error, .mermaid") ||
996
+ (omitNestedLists && node.matches("ul, ol")))
997
+ ) {
998
+ return;
999
+ }
1000
+ node.childNodes.forEach(visit);
1001
+ };
1002
+ root.childNodes.forEach(visit);
1003
+ return runs;
1004
+ }
1005
+
1006
+ function pptxAlignment(value) {
1007
+ if (value === "center" || value === "right" || value === "justify") return value;
1008
+ if (value === "end") return "right";
1009
+ return "left";
1010
+ }
1011
+
1012
+ function paragraphFor(element, options = {}) {
1013
+ const style = getComputedStyle(element);
1014
+ const runs = collectTextRuns(element, options);
1015
+ return {
1016
+ alignment: pptxAlignment(style.textAlign),
1017
+ lineHeight: roundedMetric(parseFloat(style.lineHeight)),
1018
+ runs,
1019
+ ...(options.level !== undefined ? { level: options.level } : {}),
1020
+ ...(options.bullet ? { bullet: options.bullet } : {}),
1021
+ };
1022
+ }
1023
+
1024
+ function renderedTextLineCount(element) {
1025
+ const range = document.createRange();
1026
+ range.selectNodeContents(element);
1027
+ const tops = [];
1028
+ for (const rect of range.getClientRects()) {
1029
+ if (rect.width <= 0 || rect.height <= 0) continue;
1030
+ if (!tops.some((top) => Math.abs(top - rect.top) < 1)) tops.push(rect.top);
1031
+ }
1032
+ return Math.max(1, tops.length);
1033
+ }
1034
+
1035
+ function unsupportedEffects(element) {
1036
+ const style = getComputedStyle(element);
1037
+ const effects = [];
1038
+ if (style.textShadow && style.textShadow !== "none") effects.push("text-shadow");
1039
+ if (style.boxShadow && style.boxShadow !== "none") effects.push("box-shadow");
1040
+ if (style.filter && style.filter !== "none") effects.push("filter");
1041
+ if (style.backdropFilter && style.backdropFilter !== "none") effects.push("backdrop-filter");
1042
+ if (style.mixBlendMode && style.mixBlendMode !== "normal") effects.push("mix-blend-mode");
1043
+ if (style.transform && style.transform !== "none") effects.push("transform");
1044
+ if (Number(style.opacity) < 1) effects.push("opacity");
1045
+ return effects;
1046
+ }
1047
+
1048
+ function effectFallbackRoot(element) {
1049
+ return element.closest(
1050
+ "p, li, blockquote, table, img, h1, h2, h3, h4, h5, h6, .kicker, .slide-title, .theme-backcover-logo-text, .theme-backcover-copyright, .body, header, footer",
1051
+ );
1052
+ }
1053
+
1054
+ function pptxFallback(type, element, deck, reason) {
1055
+ return {
1056
+ type,
1057
+ path: elementPath(element, deck),
1058
+ reason,
1059
+ ...relativeBounds(element, deck),
1060
+ };
1061
+ }
1062
+
1063
+ function preserveBoxShadow(element, deck) {
1064
+ const style = getComputedStyle(element);
1065
+ if (!style.boxShadow || style.boxShadow === "none") return;
1066
+ const bounds = relativeBounds(element, deck);
1067
+ const decoration = document.createElement("div");
1068
+ decoration.className = "pptx-effect-fallback";
1069
+ Object.assign(decoration.style, {
1070
+ position: "absolute",
1071
+ left: `${bounds.x}px`,
1072
+ top: `${bounds.y}px`,
1073
+ width: `${bounds.width}px`,
1074
+ height: `${bounds.height}px`,
1075
+ borderRadius: style.borderRadius,
1076
+ boxShadow: style.boxShadow,
1077
+ pointerEvents: "none",
1078
+ });
1079
+ decoration.setAttribute("aria-hidden", "true");
1080
+ deck.appendChild(decoration);
1081
+ }
1082
+
1083
+ function blobDataUrl(blob) {
1084
+ return new Promise((resolve, reject) => {
1085
+ const reader = new FileReader();
1086
+ reader.addEventListener("load", () => resolve(String(reader.result || "")), {
1087
+ once: true,
1088
+ });
1089
+ reader.addEventListener(
1090
+ "error",
1091
+ () => reject(reader.error || new Error("Could not encode Architecture artwork.")),
1092
+ { once: true },
1093
+ );
1094
+ reader.readAsDataURL(blob);
1095
+ });
1096
+ }
1097
+
1098
+ function freezeSvgPaint(source, clone) {
1099
+ const sourceNodes = [source, ...source.querySelectorAll("*")];
1100
+ const cloneNodes = [clone, ...clone.querySelectorAll("*")];
1101
+ sourceNodes.forEach((node, index) => {
1102
+ const target = cloneNodes[index];
1103
+ if (!target) return;
1104
+ const style = getComputedStyle(node);
1105
+ for (const property of ["fill", "stroke", "color"]) {
1106
+ if (style[property]) target.setAttribute(property, style[property]);
1107
+ }
1108
+ });
1109
+ }
1110
+
1111
+ async function inlineSvgImageSources(root) {
1112
+ for (const image of root.querySelectorAll("image")) {
1113
+ const href = image.getAttribute("href") || image.getAttribute("xlink:href");
1114
+ if (!href || href.startsWith("data:")) continue;
1115
+ const response = await fetch(new URL(href, window.location.href), { cache: "no-store" });
1116
+ if (!response.ok) {
1117
+ throw new Error(`Could not load Architecture image artwork (${response.status}).`);
1118
+ }
1119
+ const dataUrl = await blobDataUrl(await response.blob());
1120
+ image.setAttribute("href", dataUrl);
1121
+ image.removeAttribute("xlink:href");
1122
+ }
1123
+ }
1124
+
1125
+ async function architectureForegroundPng(svg, sources, width, height, crop) {
1126
+ if (!sources.length) return "";
1127
+ const clone = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1128
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
1129
+ const viewBox = svg.viewBox?.baseVal;
1130
+ clone.setAttribute("viewBox", svg.getAttribute("viewBox") || "0 0 1 1");
1131
+ clone.setAttribute("width", String(viewBox?.width || 1));
1132
+ clone.setAttribute("height", String(viewBox?.height || 1));
1133
+ clone.setAttribute("preserveAspectRatio", "xMidYMid meet");
1134
+ const defs = svg.querySelector(":scope > defs");
1135
+ if (defs) clone.appendChild(defs.cloneNode(true));
1136
+ for (const source of sources) {
1137
+ const copy = source.cloneNode(true);
1138
+ freezeSvgPaint(source, copy);
1139
+ const ownOpacityValue = Number(getComputedStyle(source).opacity);
1140
+ const ownOpacity = Number.isFinite(ownOpacityValue) ? ownOpacityValue : 1;
1141
+ const node = source.closest('[data-architecture-type="node"]');
1142
+ const parentOpacityAttribute =
1143
+ node && node !== source ? node.getAttribute("opacity") : null;
1144
+ const parentOpacityValue =
1145
+ parentOpacityAttribute === null ? 1 : Number(parentOpacityAttribute);
1146
+ const parentOpacity = Number.isFinite(parentOpacityValue) ? parentOpacityValue : 1;
1147
+ copy.setAttribute("opacity", String(ownOpacity * parentOpacity));
1148
+ clone.appendChild(copy);
1149
+ }
1150
+ await inlineSvgImageSources(clone);
1151
+ const markup = new XMLSerializer().serializeToString(clone);
1152
+ const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml" }));
1153
+ try {
1154
+ const image = new Image();
1155
+ image.src = url;
1156
+ await image.decode();
1157
+ const canvas = document.createElement("canvas");
1158
+ canvas.width = Math.max(1, Math.ceil(width));
1159
+ canvas.height = Math.max(1, Math.ceil(height));
1160
+ const context = canvas.getContext("2d");
1161
+ if (!context) throw new Error("Could not create Architecture artwork canvas.");
1162
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
1163
+ if (!crop) return canvas.toDataURL("image/png");
1164
+ const cropped = document.createElement("canvas");
1165
+ cropped.width = Math.max(1, Math.ceil(crop.width));
1166
+ cropped.height = Math.max(1, Math.ceil(crop.height));
1167
+ const croppedContext = cropped.getContext("2d");
1168
+ if (!croppedContext) throw new Error("Could not crop Architecture artwork.");
1169
+ croppedContext.drawImage(
1170
+ canvas,
1171
+ crop.x,
1172
+ crop.y,
1173
+ crop.width,
1174
+ crop.height,
1175
+ 0,
1176
+ 0,
1177
+ cropped.width,
1178
+ cropped.height,
1179
+ );
1180
+ return cropped.toDataURL("image/png");
1181
+ } finally {
1182
+ URL.revokeObjectURL(url);
1183
+ }
1184
+ }
1185
+
1186
+ async function collectArchitectureObjects(wrapper, deck, blockIndex) {
1187
+ const snapshot = wrapper.__presentationPptxSnapshot;
1188
+ const svg = wrapper.querySelector("svg.architecture-svg");
1189
+ if (!snapshot || !svg) {
1190
+ return {
1191
+ elements: [],
1192
+ fallbacks: [pptxFallback("architecture", wrapper, deck, "architecture-model-unavailable")],
1193
+ };
1194
+ }
1195
+ const svgRect = svg.getBoundingClientRect();
1196
+ const deckRect = deck.getBoundingClientRect();
1197
+ const scale = Math.min(
1198
+ svgRect.width / snapshot.canvas.width,
1199
+ svgRect.height / snapshot.canvas.height,
1200
+ );
1201
+ const usedWidth = snapshot.canvas.width * scale;
1202
+ const usedHeight = snapshot.canvas.height * scale;
1203
+ const originX = svgRect.left - deckRect.left + (svgRect.width - usedWidth) / 2;
1204
+ const originY = svgRect.top - deckRect.top + (svgRect.height - usedHeight) / 2;
1205
+ const mapBounds = (object) => ({
1206
+ x: roundedMetric(originX + object.x * scale),
1207
+ y: roundedMetric(originY + object.y * scale),
1208
+ width: roundedMetric(object.width * scale),
1209
+ height: roundedMetric(object.height * scale),
1210
+ });
1211
+ const mapColorFields = (object) => {
1212
+ const mapped = { ...object };
1213
+ const mapParagraphs = (paragraphs) =>
1214
+ paragraphs.map((paragraph) => ({
1215
+ ...paragraph,
1216
+ runs: paragraph.runs.map((run) => ({
1217
+ ...run,
1218
+ color: resolveModelColor(run.color, deck),
1219
+ fontFace: getComputedStyle(svg).fontFamily
1220
+ .split(",")[0]
1221
+ .trim()
1222
+ .replace(/^["']|["']$/g, ""),
1223
+ fontSize: roundedMetric(run.fontSize * scale),
1224
+ bold: Number(run.fontWeight) >= 600,
1225
+ })),
1226
+ }));
1227
+ if (mapped.dash !== undefined) mapped.dash = mapped.dash ? "dash" : "solid";
1228
+ for (const key of ["fill", "stroke", "color"]) {
1229
+ if (key in mapped) mapped[key] = resolveModelColor(mapped[key], deck);
1230
+ }
1231
+ if (Array.isArray(mapped.paragraphs)) {
1232
+ mapped.paragraphs = mapParagraphs(mapped.paragraphs);
1233
+ }
1234
+ if (mapped.text?.paragraphs) {
1235
+ mapped.text = {
1236
+ ...mapped.text,
1237
+ paragraphs: mapParagraphs(mapped.text.paragraphs),
1238
+ };
1239
+ }
1240
+ if (mapped.textInsets) {
1241
+ mapped.textInsets = Object.fromEntries(
1242
+ Object.entries(mapped.textInsets).map(([key, value]) => [
1243
+ key,
1244
+ roundedMetric(value * scale),
1245
+ ]),
1246
+ );
1247
+ }
1248
+ return mapped;
1249
+ };
1250
+ const fallbacks = snapshot.fallbacks.map((fallback) => ({
1251
+ ...fallback,
1252
+ path: `architecture[${blockIndex}].${fallback.path}`,
1253
+ ...mapBounds(fallback),
1254
+ }));
1255
+ const elements = [];
1256
+ const architectureGroups = [...wrapper.querySelectorAll("[data-architecture-type]")];
1257
+ const findById = (id) =>
1258
+ architectureGroups.find((element) => element.getAttribute("data-architecture-id") === id);
1259
+ const foregroundCandidates = [];
1260
+ for (const icon of snapshot.icons || []) {
1261
+ const group = findById(icon.id);
1262
+ const source = group?.querySelector("[data-architecture-icon]");
1263
+ if (source) {
1264
+ const rawBounds = mapBounds(icon);
1265
+ const padding = Math.max(
1266
+ 0,
1267
+ Math.min(2, rawBounds.x - originX, rawBounds.y - originY),
1268
+ );
1269
+ const bounds = {
1270
+ x: rawBounds.x - padding,
1271
+ y: rawBounds.y - padding,
1272
+ width: rawBounds.width + padding * 2,
1273
+ height: rawBounds.height + padding * 2,
1274
+ };
1275
+ foregroundCandidates.push({
1276
+ key: `icon:${icon.id}`,
1277
+ source,
1278
+ alt: `${icon.icon} icon`,
1279
+ bounds,
1280
+ crop: {
1281
+ x: bounds.x - originX,
1282
+ y: bounds.y - originY,
1283
+ width: bounds.width,
1284
+ height: bounds.height,
1285
+ },
1286
+ architecture: {
1287
+ kind: "icon-picture",
1288
+ id: icon.id,
1289
+ sourcePath: icon.sourcePath,
1290
+ order: icon.order,
1291
+ z: icon.z,
1292
+ },
1293
+ });
1294
+ }
1295
+ }
1296
+ for (const object of snapshot.objects.filter((entry) => entry.type === "image")) {
1297
+ const source = findById(object.architecture.id);
1298
+ if (source) {
1299
+ const rawBounds = mapBounds(object);
1300
+ const padding = Math.max(
1301
+ 0,
1302
+ Math.min(2, rawBounds.x - originX, rawBounds.y - originY),
1303
+ );
1304
+ const bounds = {
1305
+ x: rawBounds.x - padding,
1306
+ y: rawBounds.y - padding,
1307
+ width: rawBounds.width + padding * 2,
1308
+ height: rawBounds.height + padding * 2,
1309
+ };
1310
+ foregroundCandidates.push({
1311
+ key: `image:${object.architecture.id}`,
1312
+ source,
1313
+ alt: `${object.architecture.id} image`,
1314
+ bounds,
1315
+ crop: {
1316
+ x: bounds.x - originX,
1317
+ y: bounds.y - originY,
1318
+ width: bounds.width,
1319
+ height: bounds.height,
1320
+ },
1321
+ architecture: {
1322
+ ...object.architecture,
1323
+ kind: "image-picture",
1324
+ },
1325
+ });
1326
+ }
1327
+ }
1328
+ const foregroundLayers = new Map();
1329
+ const expectedForegrounds =
1330
+ (snapshot.icons?.length || 0) +
1331
+ snapshot.objects.filter((object) => object.type === "image").length;
1332
+ let foregroundReady = foregroundCandidates.length === expectedForegrounds;
1333
+ try {
1334
+ if (foregroundReady) {
1335
+ const generated = await Promise.all(
1336
+ foregroundCandidates.map(async (candidate) => ({
1337
+ ...candidate,
1338
+ src: await architectureForegroundPng(
1339
+ svg,
1340
+ [candidate.source],
1341
+ usedWidth,
1342
+ usedHeight,
1343
+ candidate.crop,
1344
+ ),
1345
+ })),
1346
+ );
1347
+ generated.forEach((layer) => foregroundLayers.set(layer.key, layer));
1348
+ } else {
1349
+ throw new Error("Architecture foreground source was not found.");
1350
+ }
1351
+ } catch (error) {
1352
+ foregroundReady = false;
1353
+ foregroundLayers.clear();
1354
+ fallbacks.push(
1355
+ pptxFallback(
1356
+ "architecture-foreground",
1357
+ wrapper,
1358
+ deck,
1359
+ `foreground-picture-failed: ${error?.message || "unknown error"}`,
1360
+ ),
1361
+ );
1362
+ }
1363
+ const foregroundElement = (layer) => ({
1364
+ type: "image",
1365
+ src: layer.src,
1366
+ alt: layer.alt,
1367
+ fit: "fill",
1368
+ opacity: 1,
1369
+ ...layer.bounds,
1370
+ architecture: layer.architecture,
1371
+ });
1372
+ if (!foregroundReady) {
1373
+ fallbacks.push(
1374
+ pptxFallback(
1375
+ "architecture",
1376
+ wrapper,
1377
+ deck,
1378
+ "architecture-rendered-as-artwork-after-foreground-failure",
1379
+ ),
1380
+ );
1381
+ return { elements: [], fallbacks };
1382
+ }
1383
+
1384
+ for (const sourceObject of snapshot.objects) {
1385
+ const object = mapColorFields({
1386
+ ...sourceObject,
1387
+ ...mapBounds(sourceObject),
1388
+ ...(sourceObject.points
1389
+ ? {
1390
+ points: sourceObject.points.map((point) => ({
1391
+ x: roundedMetric(originX + point.x * scale),
1392
+ y: roundedMetric(originY + point.y * scale),
1393
+ })),
1394
+ }
1395
+ : {}),
1396
+ ...(sourceObject.strokeWidth !== undefined
1397
+ ? { strokeWidth: roundedMetric(sourceObject.strokeWidth * scale) }
1398
+ : {}),
1399
+ ...(sourceObject.cornerRadius !== undefined
1400
+ ? { cornerRadius: roundedMetric(sourceObject.cornerRadius * scale) }
1401
+ : {}),
1402
+ });
1403
+ if (object.type === "shape") {
1404
+ const opacity = Number.isFinite(object.opacity) ? object.opacity : 1;
1405
+ if (object.text?.paragraphs) {
1406
+ object.text = {
1407
+ ...object.text,
1408
+ paragraphs: object.text.paragraphs.map((paragraph) => ({
1409
+ ...paragraph,
1410
+ runs: paragraph.runs.map((run) => ({
1411
+ ...run,
1412
+ opacity: (Number.isFinite(run.opacity) ? run.opacity : 1) * opacity,
1413
+ })),
1414
+ })),
1415
+ };
1416
+ }
1417
+ }
1418
+ if (object.type === "image") {
1419
+ const layer = foregroundLayers.get(`image:${object.architecture.id}`);
1420
+ fallbacks.push({
1421
+ type: "architecture-image",
1422
+ path: `architecture[${blockIndex}].${object.architecture.sourcePath}`,
1423
+ reason: foregroundReady && layer
1424
+ ? "architecture-image-rendered-as-foreground-picture"
1425
+ : "architecture-image-rendered-as-artwork",
1426
+ ...mapBounds(sourceObject),
1427
+ });
1428
+ if (foregroundReady && layer) elements.push(foregroundElement(layer));
1429
+ continue;
1430
+ }
1431
+ elements.push(object);
1432
+ if (object.type === "shape" && object.architecture?.kind === "node" && sourceObject.icon) {
1433
+ const layer = foregroundLayers.get(`icon:${object.architecture.id}`);
1434
+ if (foregroundReady && layer) elements.push(foregroundElement(layer));
1435
+ }
1436
+ }
1437
+ for (const icon of snapshot.icons || []) {
1438
+ const layer = foregroundLayers.get(`icon:${icon.id}`);
1439
+ fallbacks.push({
1440
+ type: "architecture-icon",
1441
+ path: `architecture[${blockIndex}].${icon.sourcePath}`,
1442
+ reason: foregroundReady && layer
1443
+ ? "icon-rendered-as-foreground-picture"
1444
+ : "icon-rendered-as-artwork",
1445
+ icon: icon.icon,
1446
+ ...mapBounds(icon),
1447
+ });
1448
+ }
1449
+ for (const sourceObject of snapshot.objects) {
1450
+ const architecture = sourceObject.architecture;
1451
+ if (!architecture) continue;
1452
+ if (architecture.kind === "group" || architecture.kind === "node") {
1453
+ const group = findById(architecture.id);
1454
+ if (!group) continue;
1455
+ [...group.children]
1456
+ .filter((child) =>
1457
+ foregroundReady
1458
+ ? child.matches("rect, ellipse, text")
1459
+ : child.matches("text"),
1460
+ )
1461
+ .forEach((child) => child.setAttribute("data-pptx-native", sourceObject.type));
1462
+ } else if (architecture.kind === "connector") {
1463
+ architectureGroups
1464
+ .filter(
1465
+ (element) =>
1466
+ element.getAttribute("data-architecture-type") === "connector" &&
1467
+ element.getAttribute("data-architecture-order") === String(architecture.order),
1468
+ )
1469
+ .forEach((group) =>
1470
+ [...group.children]
1471
+ .filter((child) => child.matches("path"))
1472
+ .forEach((child) => child.setAttribute("data-pptx-native", "connector")),
1473
+ );
1474
+ } else if (architecture.kind.startsWith("connector-label")) {
1475
+ wrapper
1476
+ .querySelectorAll(
1477
+ `[data-architecture-connector-label][data-architecture-label-layer]`,
1478
+ )
1479
+ .forEach((label) => label.setAttribute("data-pptx-native", sourceObject.type));
1480
+ }
1481
+ }
1482
+ if (foregroundReady) {
1483
+ foregroundCandidates.forEach((candidate) => {
1484
+ if (foregroundLayers.has(candidate.key)) {
1485
+ candidate.source.setAttribute("data-pptx-native", "image");
1486
+ }
1487
+ });
1488
+ }
1489
+ if (snapshot.routing.degraded) {
1490
+ fallbacks.push(
1491
+ pptxFallback(
1492
+ "architecture-routing",
1493
+ wrapper,
1494
+ deck,
1495
+ "routing-warning-rendered-as-artwork",
1496
+ ),
1497
+ );
1498
+ }
1499
+ return { elements, fallbacks };
1500
+ }
1501
+
1502
+ async function collectPptxSlide(slide, index) {
1503
+ const { deck } = slide;
1504
+ const elements = [];
1505
+ const fallbacks = [];
1506
+ const fallbackRoots = new Set();
1507
+ const addFallback = (type, element, reason) => {
1508
+ if (fallbackRoots.has(element)) return;
1509
+ fallbackRoots.add(element);
1510
+ fallbacks.push(pptxFallback(type, element, deck, reason));
1511
+ };
1512
+
1513
+ for (const element of deck.querySelectorAll("header *, .body, .body *, footer *")) {
1514
+ if (element.closest("pre, .architecture-diagram, .architecture-error")) continue;
1515
+ const effects = unsupportedEffects(element).filter((effect) => effect !== "box-shadow");
1516
+ if (!effects.length) continue;
1517
+ const root = effectFallbackRoot(element);
1518
+ if (root) {
1519
+ addFallback(
1520
+ "effect",
1521
+ root,
1522
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1523
+ );
1524
+ }
1525
+ }
1526
+
1527
+ deck.querySelectorAll("pre.mermaid").forEach((element) =>
1528
+ addFallback("mermaid", element, "mermaid-rendered-as-artwork"),
1529
+ );
1530
+ deck.querySelectorAll("pre:not(.mermaid)").forEach((element) =>
1531
+ addFallback("code", element, "code-block-rendered-as-artwork"),
1532
+ );
1533
+ deck.querySelectorAll(".architecture-error").forEach((element) =>
1534
+ addFallback("architecture", element, "architecture-error-rendered-as-artwork"),
1535
+ );
1536
+ deck
1537
+ .querySelectorAll(
1538
+ ".body div:not(.architecture-diagram):not(.architecture-error):not(.architecture-routing-warning), .body section, .body article, .body aside, .body details, .body video, .body audio, .body iframe, .body canvas, .body object, .body embed",
1539
+ )
1540
+ .forEach((element) => {
1541
+ if (!element.closest(".architecture-diagram")) {
1542
+ addFallback("html", element, "arbitrary-html-rendered-as-artwork");
1543
+ }
1544
+ });
1545
+
1546
+ const insideFallback = (element) =>
1547
+ [...fallbackRoots].some((root) => root === element || root.contains(element));
1548
+ const textCandidates = [
1549
+ ...deck.querySelectorAll(
1550
+ ".kicker, .slide-title, .body h1, .body h2, .body h3, .body h4, .body h5, .body h6, .body p, .body blockquote, .body li, footer > span, .theme-backcover-logo-text, .theme-backcover-copyright",
1551
+ ),
1552
+ ].filter(
1553
+ (element) =>
1554
+ !insideFallback(element) &&
1555
+ !element.closest(".architecture-diagram") &&
1556
+ !element.closest("table") &&
1557
+ !(element.matches("p") && element.closest("blockquote, li")),
1558
+ );
1559
+ for (const element of textCandidates) {
1560
+ const list = element.matches("li")
1561
+ ? [...element.querySelectorAll(":scope > ul, :scope > ol")]
1562
+ : [];
1563
+ const parentList = element.matches("li") ? element.parentElement : null;
1564
+ const actualLevel = element.matches("li")
1565
+ ? Math.max(0, [...element.closest(".body").querySelectorAll("ul, ol")].filter((candidate) =>
1566
+ candidate.contains(element),
1567
+ ).length - 1)
1568
+ : undefined;
1569
+ const paragraph = paragraphFor(element, {
1570
+ omitNestedLists: list.length > 0,
1571
+ level: actualLevel,
1572
+ bullet: parentList
1573
+ ? {
1574
+ type: parentList.tagName === "OL" ? "number" : "bullet",
1575
+ character:
1576
+ parentList.tagName === "OL"
1577
+ ? `${Number(parentList.getAttribute("start") || 1) + [...parentList.children].indexOf(element)}.`
1578
+ : "•",
1579
+ ...(parentList.tagName === "OL"
1580
+ ? { start: Number(parentList.getAttribute("start") || 1) + [...parentList.children].indexOf(element) }
1581
+ : {}),
1582
+ }
1583
+ : undefined,
1584
+ });
1585
+ if (!paragraph.runs.some((run) => run.text.trim())) continue;
1586
+ elements.push({
1587
+ type: "text",
1588
+ path: elementPath(element, deck),
1589
+ ...(element.classList.contains("kicker")
1590
+ ? textContentBounds(element, deck)
1591
+ : relativeBounds(element, deck)),
1592
+ paragraphs: [paragraph],
1593
+ opacity: Number(getComputedStyle(element).opacity) || 1,
1594
+ ...(element.matches("h1, h2, .slide-title") && renderedTextLineCount(element) === 1
1595
+ ? { textWrap: "none" }
1596
+ : {}),
1597
+ });
1598
+ element.setAttribute("data-pptx-native", "text");
1599
+ }
1600
+
1601
+ for (const table of deck.querySelectorAll(".body table")) {
1602
+ if (insideFallback(table)) continue;
1603
+ const descendantEffects = new Set();
1604
+ for (const descendant of table.querySelectorAll("*")) {
1605
+ for (const effect of unsupportedEffects(descendant)) descendantEffects.add(effect);
1606
+ }
1607
+ if (descendantEffects.size) {
1608
+ addFallback(
1609
+ "effect",
1610
+ table,
1611
+ `element-rendered-as-artwork: ${[...descendantEffects].join(", ")}`,
1612
+ );
1613
+ continue;
1614
+ }
1615
+ const effects = unsupportedEffects(table);
1616
+ if (parseFloat(getComputedStyle(table).borderRadius) > 0) effects.push("border-radius");
1617
+ const artworkEffects = effects.filter(
1618
+ (effect) => effect !== "box-shadow" && effect !== "border-radius",
1619
+ );
1620
+ if (artworkEffects.length) {
1621
+ addFallback(
1622
+ "effect",
1623
+ table,
1624
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1625
+ );
1626
+ continue;
1627
+ }
1628
+ const domRows = [...table.rows];
1629
+ const columnCount = domRows[0]?.cells.length || 0;
1630
+ if (
1631
+ !columnCount ||
1632
+ domRows.some(
1633
+ (row) =>
1634
+ row.cells.length !== columnCount ||
1635
+ [...row.cells].some((cell) => cell.colSpan !== 1 || cell.rowSpan !== 1),
1636
+ )
1637
+ ) {
1638
+ addFallback("table", table, "merged-table-rendered-as-artwork");
1639
+ continue;
1640
+ }
1641
+ const rows = domRows.map((row) => ({
1642
+ height: roundedMetric(row.getBoundingClientRect().height),
1643
+ cells: [...row.cells].map((cell) => {
1644
+ const style = getComputedStyle(cell);
1645
+ return {
1646
+ ...relativeBounds(cell, deck),
1647
+ header: cell.tagName === "TH",
1648
+ colspan: cell.colSpan,
1649
+ rowspan: cell.rowSpan,
1650
+ fill: normalizeCssColor(style.backgroundColor),
1651
+ color: normalizeCssColor(style.color),
1652
+ stroke: normalizeCssColor(style.borderColor),
1653
+ strokeWidth: roundedMetric(parseFloat(style.borderWidth)) || 1,
1654
+ alignment: pptxAlignment(style.textAlign),
1655
+ paragraphs: [paragraphFor(cell)],
1656
+ };
1657
+ }),
1658
+ }));
1659
+ elements.push({
1660
+ type: "table",
1661
+ path: elementPath(table, deck),
1662
+ ...relativeBounds(table, deck),
1663
+ rows,
1664
+ });
1665
+ if (effects.length) {
1666
+ fallbacks.push(
1667
+ pptxFallback(
1668
+ "effect",
1669
+ table,
1670
+ deck,
1671
+ `native-table-approximates: ${effects.join(", ")}`,
1672
+ ),
1673
+ );
1674
+ if (effects.includes("box-shadow")) preserveBoxShadow(table, deck);
1675
+ }
1676
+ table.setAttribute("data-pptx-native", "table");
1677
+ }
1678
+
1679
+ for (const image of deck.querySelectorAll("img")) {
1680
+ if (
1681
+ image.closest(".architecture-diagram") ||
1682
+ image.classList.contains("theme-cover-background") ||
1683
+ insideFallback(image)
1684
+ ) {
1685
+ continue;
1686
+ }
1687
+ if (!rasterImageSupported(image)) {
1688
+ const fit = getComputedStyle(image).objectFit;
1689
+ addFallback(
1690
+ "image",
1691
+ image,
1692
+ ["cover", "none"].includes(fit) ? "unsupported-image-fit" : "unsupported-image-format",
1693
+ );
1694
+ continue;
1695
+ }
1696
+ const style = getComputedStyle(image);
1697
+ const effects = unsupportedEffects(image);
1698
+ if (parseFloat(style.borderRadius) > 0) effects.push("border-radius");
1699
+ const artworkEffects = effects.filter(
1700
+ (effect) => effect !== "box-shadow" && effect !== "border-radius",
1701
+ );
1702
+ if (artworkEffects.length) {
1703
+ addFallback(
1704
+ "effect",
1705
+ image,
1706
+ `element-rendered-as-artwork: ${effects.join(", ")}`,
1707
+ );
1708
+ continue;
1709
+ }
1710
+ elements.push({
1711
+ type: "image",
1712
+ path: elementPath(image, deck),
1713
+ ...relativeBounds(image, deck),
1714
+ src: image.currentSrc || image.src,
1715
+ alt: image.alt || "",
1716
+ fit: style.objectFit || "contain",
1717
+ opacity: Number(style.opacity) || 1,
1718
+ naturalWidth: image.naturalWidth,
1719
+ naturalHeight: image.naturalHeight,
1720
+ });
1721
+ image.setAttribute("data-pptx-native", "image");
1722
+ if (effects.length) {
1723
+ fallbacks.push(
1724
+ pptxFallback(
1725
+ "effect",
1726
+ image,
1727
+ deck,
1728
+ `native-image-approximates: ${effects.join(", ")}`,
1729
+ ),
1730
+ );
1731
+ }
1732
+ }
1733
+
1734
+ const architectureWrappers = [...deck.querySelectorAll(".architecture-diagram")];
1735
+ for (const [blockIndex, wrapper] of architectureWrappers.entries()) {
1736
+ if (insideFallback(wrapper)) continue;
1737
+ const architecture = await collectArchitectureObjects(wrapper, deck, blockIndex);
1738
+ elements.push(...architecture.elements);
1739
+ fallbacks.push(...architecture.fallbacks);
1740
+ }
1741
+
1742
+ const layout = slide.titleSlide
1743
+ ? "title"
1744
+ : slide.sectionSlide
1745
+ ? "section"
1746
+ : slide.centerSlide
1747
+ ? "center"
1748
+ : slide.backcoverSlide
1749
+ ? "backcover"
1750
+ : "standard";
1751
+ const visibleTitle = deck.querySelector("h1, h2")?.textContent?.trim();
1752
+ return {
1753
+ index,
1754
+ layout,
1755
+ theme: slide.theme,
1756
+ title: visibleTitle || slide.title,
1757
+ width: OUTPUT_WIDTH,
1758
+ height: OUTPUT_HEIGHT,
1759
+ elements,
1760
+ fallbacks,
1761
+ };
1762
+ }
1763
+
1764
+ async function renderPptxDeck(
1765
+ slides,
1766
+ theme,
1767
+ customCss = "",
1768
+ themeMetadata = null,
1769
+ themeLocked = false,
1770
+ ) {
1771
+ deckTheme = normalizeTheme(theme);
1772
+ deckThemeLocked = Boolean(themeLocked);
1773
+ customThemeMeta = themeMetadata && typeof themeMetadata === "object" ? themeMetadata : null;
1774
+ applyCustomThemeCss(customCss);
1775
+ document.documentElement.setAttribute("data-theme", deckTheme);
1776
+ document.body.classList.add("pptx-mode", "fixed-output-mode", "mermaid-loading");
1777
+ const rendered = slides.map((markdown) => createSlide(markdown, deckTheme));
1778
+ const stage = document.getElementById("stage");
1779
+ stage.replaceChildren(...rendered.map((slide) => slide.deck));
1780
+ document.title = rendered[0]?.title || "MarkdStage";
1781
+
1782
+ if (document.fonts?.ready) await document.fonts.ready;
1783
+ await afterLayout();
1784
+ for (const slide of rendered) {
1785
+ if (
1786
+ slide.sizeMode === "auto" &&
1787
+ !slide.titleSlide &&
1788
+ !slide.sectionSlide &&
1789
+ !slide.backcoverSlide
1790
+ ) {
1791
+ applyAutoSize(slide.deck, slide.bodyEl);
1792
+ }
1793
+ }
1794
+ const token = ++renderToken;
1795
+ for (const slide of rendered) {
1796
+ await runMermaid(slide.bodyEl, slide.theme, token, false);
1797
+ }
1798
+ await waitForImages(stage);
1799
+ await afterLayout();
1800
+
1801
+ const pptxSlides = [];
1802
+ for (const [index, slide] of rendered.entries()) {
1803
+ pptxSlides.push(await collectPptxSlide(slide, index));
1804
+ }
1805
+ const model = {
1806
+ version: 1,
1807
+ width: OUTPUT_WIDTH,
1808
+ height: OUTPUT_HEIGHT,
1809
+ slides: pptxSlides,
1810
+ };
1811
+ window.__presentationPptxModel = JSON.parse(JSON.stringify(model));
1812
+ document.body.classList.add("pptx-artwork-mode");
1813
+ document.body.setAttribute("data-pptx-artwork", "ready");
1814
+ document.body.classList.remove("mermaid-loading");
1815
+ document.documentElement.setAttribute("data-pptx-ready", "true");
1816
+ return {
1817
+ model: window.__presentationPptxModel,
1818
+ layout: collectDeckLayout(rendered),
1819
+ };
1820
+ }
1821
+
1822
+ async function initPptx(params) {
1823
+ const token = params.get("token") || "";
1824
+ if (!token) throw new Error("Missing PowerPoint export token.");
1825
+ try {
1826
+ const response = await fetch(`./export-data?token=${encodeURIComponent(token)}`, {
1827
+ cache: "no-store",
1828
+ });
1829
+ if (!response.ok) throw new Error(`Could not load PowerPoint export data (${response.status}).`);
1830
+ const data = await response.json();
1831
+ if (
1832
+ !Array.isArray(data.slides) ||
1833
+ data.slides.length === 0 ||
1834
+ !data.slides.every((slide) => typeof slide === "string")
1835
+ ) {
1836
+ throw new Error("PowerPoint export data does not contain a valid deck.");
1837
+ }
1838
+ const output = await renderPptxDeck(
1839
+ data.slides,
1840
+ data.theme,
1841
+ data.customThemeCss,
1842
+ data.customThemeMeta,
1843
+ data.themeLocked,
1844
+ );
1845
+ await reportOutputStatus(token, "ready", "", output.layout);
1846
+ } catch (error) {
1847
+ const message = error?.message || "PowerPoint rendering failed.";
1848
+ console.error(message);
1849
+ document.body.classList.remove("mermaid-loading");
1850
+ document.documentElement.setAttribute("data-pptx-error", "true");
1851
+ await reportOutputStatus(token, "error", message).catch(() => {});
1852
+ }
1853
+ }
1854
+
860
1855
  async function renderPrintDeck(
861
1856
  slides,
862
1857
  theme,
@@ -1048,6 +2043,13 @@ function reportCaptureBootstrapFailure(error) {
1048
2043
  document.documentElement.setAttribute("data-capture-error", "true");
1049
2044
  }
1050
2045
 
2046
+ function reportPptxBootstrapFailure(error) {
2047
+ const message = error?.message || "PowerPoint rendering failed.";
2048
+ console.error(message);
2049
+ document.body.classList.remove("mermaid-loading");
2050
+ document.documentElement.setAttribute("data-pptx-error", "true");
2051
+ }
2052
+
1051
2053
  // --- live update -----------------------------------------------------------
1052
2054
  // /state is the single source of truth for *what to show* (latest slide markdown
1053
2055
  // + a monotonic version + the deck position). SSE is just a low-latency "version
@@ -1066,13 +2068,21 @@ let importOpen = false;
1066
2068
  let importPending = false;
1067
2069
  let importFiles = [];
1068
2070
  let sourceBacked = false;
2071
+ let sourceModeAvailable = false;
1069
2072
  let sourceMode = "snapshot";
1070
2073
  let sourceWatchStatus = "inactive";
1071
2074
  let sourceWatchError = "";
1072
2075
  let presenterRequestPending = false;
1073
2076
  let presenterRunning = false;
2077
+ let presenterWindowAvailable = false;
2078
+ let presenterViewAvailable = false;
2079
+ let pdfExportAvailable = false;
2080
+ let pptxExportAvailable = false;
2081
+ let markdownImportAvailable = false;
1074
2082
  let presenterViewOpen = false;
2083
+ let presenterViewRequested = false;
1075
2084
  let pdfExportPending = false;
2085
+ let pptxExportPending = false;
1076
2086
 
1077
2087
  // Derive a short overview title from a slide fragment: first heading, else first
1078
2088
  // non-empty body line, trimmed. Mirrors the skill's title rule.
@@ -1119,7 +2129,7 @@ async function fetchDeck() {
1119
2129
  * early in init and never reaches this code. Return true only when the state changes.
1120
2130
  */
1121
2131
  function setArchitectureEditMode(enabled) {
1122
- const next = Boolean(enabled) && !presenterMode;
2132
+ const next = Boolean(enabled) && architectureEditAvailable && !presenterMode;
1123
2133
  if (next === architectureEditMode) return false;
1124
2134
  architectureEditMode = next;
1125
2135
  document.body.classList.toggle("architecture-edit-mode", next);
@@ -1130,10 +2140,11 @@ function setArchitectureEditMode(enabled) {
1130
2140
  function updateArchitectureEditButton(enabled = architectureEditMode) {
1131
2141
  const button = document.getElementById("navEdit");
1132
2142
  if (!button) return;
1133
- button.hidden = presenterMode;
2143
+ button.hidden = presenterMode || !architectureEditAvailable;
1134
2144
  button.dataset.state = enabled && !presenterMode ? "active" : "";
1135
2145
  button.title = enabled ? "Exit shape editing mode" : "Shape editing mode";
1136
2146
  button.setAttribute("aria-label", button.title);
2147
+ syncMoreControls();
1137
2148
  }
1138
2149
 
1139
2150
  function sourceWatchErrorMessage(code) {
@@ -1149,10 +2160,11 @@ function updateSourceModeButton() {
1149
2160
  const button = document.getElementById("navSourceMode");
1150
2161
  const status = document.getElementById("sourceStatus");
1151
2162
  if (!button) return;
1152
- button.hidden = presenterMode || !sourceBacked;
2163
+ button.hidden = presenterMode || !sourceBacked || !sourceModeAvailable;
1153
2164
  if (!sourceBacked) {
1154
2165
  button.dataset.state = "";
1155
2166
  if (status) status.textContent = "";
2167
+ syncMoreControls();
1156
2168
  return;
1157
2169
  }
1158
2170
  if (sourceMode === "live" && sourceWatchStatus === "error") {
@@ -1161,6 +2173,7 @@ function updateSourceModeButton() {
1161
2173
  button.title = `${message}. Click to pin the display to the loaded snapshot`;
1162
2174
  button.setAttribute("aria-label", button.title);
1163
2175
  if (status) status.textContent = message;
2176
+ syncMoreControls();
1164
2177
  return;
1165
2178
  }
1166
2179
  const live = sourceMode === "live";
@@ -1174,6 +2187,7 @@ function updateSourceModeButton() {
1174
2187
  ? "Slides refresh automatically when Markdown is saved"
1175
2188
  : "Markdown retains the display from the loaded snapshot";
1176
2189
  }
2190
+ syncMoreControls();
1177
2191
  }
1178
2192
 
1179
2193
  async function requestSourceMode(mode) {
@@ -1197,7 +2211,7 @@ async function requestSourceMode(mode) {
1197
2211
  }
1198
2212
 
1199
2213
  async function toggleSourceMode() {
1200
- if (presenterMode || !sourceBacked) return;
2214
+ if (presenterMode || !sourceBacked || !sourceModeAvailable) return;
1201
2215
  await requestSourceMode(sourceMode === "live" ? "snapshot" : "live");
1202
2216
  }
1203
2217
 
@@ -1218,7 +2232,7 @@ async function requestArchitectureEditMode(enabled) {
1218
2232
  }
1219
2233
 
1220
2234
  async function toggleArchitectureEditMode() {
1221
- if (presenterMode) return;
2235
+ if (presenterMode || !architectureEditAvailable) return;
1222
2236
  await requestArchitectureEditMode(!architectureEditMode);
1223
2237
  await fetchState();
1224
2238
  }
@@ -1230,7 +2244,23 @@ async function toggleArchitectureEditMode() {
1230
2244
  * Always return save success or failure to the caller. Swallowing it would make
1231
2245
  * an unsaved edit look successful, recreating the silent-ignore behavior fixed in Phase 5.
1232
2246
  */
1233
- async function saveArchitectureBlock(index, block, source) {
2247
+ function saveArchitectureBlock(index, block, source, editorRenderToken) {
2248
+ const pending = architectureSaveQueue
2249
+ .catch(() => {})
2250
+ .then(() => {
2251
+ if (editorRenderToken !== renderToken) {
2252
+ return {
2253
+ ok: false,
2254
+ message: "The displayed deck was replaced. Select the diagram again",
2255
+ };
2256
+ }
2257
+ return saveArchitectureBlockNow(index, block, source);
2258
+ });
2259
+ architectureSaveQueue = pending;
2260
+ return pending;
2261
+ }
2262
+
2263
+ async function saveArchitectureBlockNow(index, block, source) {
1234
2264
  let res;
1235
2265
  try {
1236
2266
  res = await fetch("./edit", {
@@ -1283,6 +2313,9 @@ async function saveArchitectureBlock(index, block, source) {
1283
2313
  }
1284
2314
 
1285
2315
  async function openDetailedArchitectureEditor(index, block) {
2316
+ const pendingWindow =
2317
+ architectureDetailedEditTarget === "window" ? window.open("", "_blank") : null;
2318
+ if (pendingWindow) pendingWindow.opener = null;
1286
2319
  let response;
1287
2320
  try {
1288
2321
  response = await fetch("./architecture-editor/open", {
@@ -1291,10 +2324,22 @@ async function openDetailedArchitectureEditor(index, block) {
1291
2324
  body: JSON.stringify({ index, block }),
1292
2325
  });
1293
2326
  } catch (_) {
2327
+ pendingWindow?.close();
1294
2328
  return { ok: false, message: "Could not connect to the server." };
1295
2329
  }
1296
2330
  const result = await response.json().catch(() => ({}));
1297
- if (response.ok && result.ok === true) return result;
2331
+ if (response.ok && result.ok === true) {
2332
+ if (typeof result.url === "string" && result.url) {
2333
+ if (pendingWindow) pendingWindow.location.replace(result.url);
2334
+ else if (!window.open(result.url, "_blank", "noopener")) {
2335
+ return { ok: false, message: "Allow pop-ups to open the Architecture Editor." };
2336
+ }
2337
+ } else {
2338
+ pendingWindow?.close();
2339
+ }
2340
+ return result;
2341
+ }
2342
+ pendingWindow?.close();
1298
2343
  if (result.error === "source_not_available") {
1299
2344
  return {
1300
2345
  ok: false,
@@ -1320,10 +2365,29 @@ async function fetchState() {
1320
2365
  data.customThemeMeta && typeof data.customThemeMeta === "object"
1321
2366
  ? data.customThemeMeta
1322
2367
  : null;
2368
+ if (typeof data.presenterWindowAvailable === "boolean") {
2369
+ presenterWindowAvailable = data.presenterWindowAvailable;
2370
+ }
2371
+ if (typeof data.presenterViewAvailable === "boolean") {
2372
+ presenterViewAvailable = data.presenterViewAvailable;
2373
+ }
2374
+ if (typeof data.pdfExportAvailable === "boolean") {
2375
+ pdfExportAvailable = data.pdfExportAvailable;
2376
+ }
2377
+ if (typeof data.pptxExportAvailable === "boolean") {
2378
+ pptxExportAvailable = data.pptxExportAvailable;
2379
+ }
2380
+ if (typeof data.markdownImportAvailable === "boolean") {
2381
+ markdownImportAvailable = data.markdownImportAvailable;
2382
+ }
1323
2383
  if (typeof data.presenterRunning === "boolean") {
1324
2384
  updatePresenterButton(data.presenterRunning);
1325
2385
  }
2386
+ updateHostActionButtons();
1326
2387
  if (typeof data.sourceBacked === "boolean") sourceBacked = data.sourceBacked;
2388
+ if (typeof data.sourceModeAvailable === "boolean") {
2389
+ sourceModeAvailable = data.sourceModeAvailable;
2390
+ }
1327
2391
  sourceMode = data.sourceMode === "live" ? "live" : "snapshot";
1328
2392
  sourceWatchStatus =
1329
2393
  data.sourceWatchStatus === "watching" || data.sourceWatchStatus === "error"
@@ -1331,6 +2395,14 @@ async function fetchState() {
1331
2395
  : "inactive";
1332
2396
  sourceWatchError = typeof data.sourceWatchError === "string" ? data.sourceWatchError : "";
1333
2397
  updateSourceModeButton();
2398
+ const editAvailabilityChanged =
2399
+ typeof data.architectureEditAvailable === "boolean" &&
2400
+ data.architectureEditAvailable !== architectureEditAvailable;
2401
+ if (typeof data.architectureEditAvailable === "boolean") {
2402
+ architectureEditAvailable = data.architectureEditAvailable;
2403
+ }
2404
+ architectureDetailedEditTarget =
2405
+ data.architectureDetailedEditTarget === "window" ? "window" : "canvas";
1334
2406
  const detailedEditChanged =
1335
2407
  typeof data.architectureDetailedEdit === "boolean" &&
1336
2408
  data.architectureDetailedEdit !== architectureDetailedEdit;
@@ -1338,7 +2410,15 @@ async function fetchState() {
1338
2410
  architectureDetailedEdit = data.architectureDetailedEdit;
1339
2411
  }
1340
2412
  // Editing-mode changes do not increment the version, so process them before the version guard.
2413
+ let availabilityDisabledEditMode = false;
2414
+ if (editAvailabilityChanged) {
2415
+ if (!architectureEditAvailable) {
2416
+ availabilityDisabledEditMode = setArchitectureEditMode(false);
2417
+ }
2418
+ updateArchitectureEditButton();
2419
+ }
1341
2420
  if (
2421
+ availabilityDisabledEditMode ||
1342
2422
  (typeof data.architectureEdit === "boolean" &&
1343
2423
  setArchitectureEditMode(data.architectureEdit)) ||
1344
2424
  (architectureEditMode && detailedEditChanged)
@@ -1360,6 +2440,10 @@ async function fetchState() {
1360
2440
  navMode = data.mode === "adhoc" ? "adhoc" : "deck";
1361
2441
  renderSlide(typeof data.markdown === "string" ? data.markdown : "");
1362
2442
  updateNav();
2443
+ if (presenterViewRequested) {
2444
+ presenterViewRequested = false;
2445
+ openPresenterView();
2446
+ }
1363
2447
  }
1364
2448
 
1365
2449
  // --- navigation ------------------------------------------------------------
@@ -1391,7 +2475,7 @@ function goToIndex(i) {
1391
2475
  }
1392
2476
 
1393
2477
  async function setPresenterRunning(running) {
1394
- if (presenterRequestPending) return;
2478
+ if (!presenterWindowAvailable || presenterRequestPending) return;
1395
2479
  presenterRequestPending = true;
1396
2480
  const button = document.getElementById("navPresent");
1397
2481
  const status = document.getElementById("presentStatus");
@@ -1431,6 +2515,7 @@ async function setPresenterRunning(running) {
1431
2515
  button.dataset.state = "error";
1432
2516
  button.title = message;
1433
2517
  }
2518
+ syncMoreControls();
1434
2519
  } finally {
1435
2520
  presenterRequestPending = false;
1436
2521
  if (button) button.disabled = false;
@@ -1445,6 +2530,82 @@ function togglePresenterWindow() {
1445
2530
  return setPresenterRunning(!presenterRunning);
1446
2531
  }
1447
2532
 
2533
+ function visibleMoreControlButtons() {
2534
+ const panel = document.getElementById("navMorePanel");
2535
+ if (!panel) return [];
2536
+ return [...panel.querySelectorAll(".nav-more-item")].filter((button) => !button.hidden);
2537
+ }
2538
+
2539
+ function setMoreControlsOpen(enabled, { focusFirst = false, restoreFocus = false } = {}) {
2540
+ const nav = document.getElementById("nav");
2541
+ const trigger = document.getElementById("navMore");
2542
+ const panel = document.getElementById("navMorePanel");
2543
+ if (!nav || !trigger || !panel) return;
2544
+
2545
+ const next = Boolean(enabled) && !nav.classList.contains("nav-empty") && !trigger.hidden;
2546
+ moreControlsOpen = next;
2547
+ trigger.setAttribute("aria-expanded", next ? "true" : "false");
2548
+ panel.hidden = nav.classList.contains("nav-empty") ? !markdownImportAvailable : !next;
2549
+
2550
+ if (next && focusFirst) {
2551
+ requestAnimationFrame(() => visibleMoreControlButtons()[0]?.focus());
2552
+ } else if (!next && restoreFocus) {
2553
+ trigger.focus();
2554
+ }
2555
+ }
2556
+
2557
+ function toggleMoreControls() {
2558
+ setMoreControlsOpen(!moreControlsOpen, { focusFirst: !moreControlsOpen });
2559
+ }
2560
+
2561
+ function syncMoreControls() {
2562
+ const nav = document.getElementById("nav");
2563
+ const trigger = document.getElementById("navMore");
2564
+ const panel = document.getElementById("navMorePanel");
2565
+ if (!nav || !trigger || !panel) return;
2566
+
2567
+ panel.querySelectorAll(".nav-more-group").forEach((group) => {
2568
+ group.hidden = ![...group.querySelectorAll(".nav-more-item")].some(
2569
+ (button) => !button.hidden,
2570
+ );
2571
+ });
2572
+
2573
+ const buttons = visibleMoreControlButtons();
2574
+ const hasError = buttons.some((button) => button.dataset.state === "error");
2575
+ const hasActive = buttons.some((button) => button.dataset.state === "active");
2576
+ trigger.dataset.state = hasError ? "error" : hasActive ? "active" : "";
2577
+ trigger.title = hasError
2578
+ ? "More controls (attention required)"
2579
+ : hasActive
2580
+ ? "More controls (an option is active)"
2581
+ : "More controls";
2582
+ trigger.setAttribute("aria-label", trigger.title);
2583
+
2584
+ const empty = nav.classList.contains("nav-empty");
2585
+ trigger.hidden = empty || buttons.length === 0;
2586
+ if (trigger.hidden) moreControlsOpen = false;
2587
+ trigger.setAttribute("aria-expanded", moreControlsOpen ? "true" : "false");
2588
+ panel.hidden = empty ? !markdownImportAvailable : !moreControlsOpen;
2589
+ }
2590
+
2591
+ function updateHostActionButtons() {
2592
+ const present = document.getElementById("navPresent");
2593
+ if (present) present.hidden = presenterMode || !presenterWindowAvailable;
2594
+ const presenterView = document.getElementById("navPresenterView");
2595
+ if (presenterView) presenterView.hidden = presenterMode || !presenterViewAvailable;
2596
+ const presenterToggle = document.getElementById("presenterToggleButton");
2597
+ if (presenterToggle) presenterToggle.hidden = !presenterWindowAvailable;
2598
+ const exportButton = document.getElementById("navExport");
2599
+ if (exportButton) exportButton.hidden = presenterMode || !pdfExportAvailable;
2600
+ const pptxButton = document.getElementById("navExportPptx");
2601
+ if (pptxButton) pptxButton.hidden = presenterMode || !pptxExportAvailable;
2602
+ const importButton = document.getElementById("navImport");
2603
+ if (importButton) importButton.hidden = presenterMode || !markdownImportAvailable;
2604
+ if (!presenterViewAvailable && presenterViewOpen) closePresenterView();
2605
+ if (!markdownImportAvailable && importOpen) closeImportPicker();
2606
+ syncMoreControls();
2607
+ }
2608
+
1448
2609
  function updatePresenterButton(running, message = "") {
1449
2610
  presenterRunning = running;
1450
2611
  const button = document.getElementById("navPresent");
@@ -1459,14 +2620,17 @@ function updatePresenterButton(running, message = "") {
1459
2620
  toggle.textContent = running ? "End presentation" : "Start presentation";
1460
2621
  toggle.dataset.state = running ? "active" : "";
1461
2622
  }
2623
+ syncMoreControls();
1462
2624
  }
1463
2625
 
1464
2626
  async function exportPdfFromCanvas() {
1465
- if (pdfExportPending) return;
2627
+ if (!pdfExportAvailable || pdfExportPending || pptxExportPending) return;
1466
2628
  pdfExportPending = true;
1467
2629
  const button = document.getElementById("navExport");
2630
+ const pptxButton = document.getElementById("navExportPptx");
1468
2631
  const status = document.getElementById("exportStatus");
1469
2632
  if (button) button.disabled = true;
2633
+ if (pptxButton) pptxButton.disabled = true;
1470
2634
  if (status) status.textContent = "Saving PDF.";
1471
2635
 
1472
2636
  try {
@@ -1486,6 +2650,7 @@ async function exportPdfFromCanvas() {
1486
2650
  button.dataset.state = "active";
1487
2651
  button.title = message;
1488
2652
  }
2653
+ syncMoreControls();
1489
2654
  } catch (error) {
1490
2655
  const message = error?.message || "Could not save the PDF.";
1491
2656
  console.error("PDF export failed", error);
@@ -1494,9 +2659,57 @@ async function exportPdfFromCanvas() {
1494
2659
  button.dataset.state = "error";
1495
2660
  button.title = message;
1496
2661
  }
2662
+ syncMoreControls();
1497
2663
  } finally {
1498
2664
  pdfExportPending = false;
1499
2665
  if (button) button.disabled = false;
2666
+ if (pptxButton) pptxButton.disabled = false;
2667
+ }
2668
+ }
2669
+
2670
+ async function exportPptxFromCanvas() {
2671
+ if (!pptxExportAvailable || pdfExportPending || pptxExportPending) return;
2672
+ pptxExportPending = true;
2673
+ const button = document.getElementById("navExportPptx");
2674
+ const pdfButton = document.getElementById("navExport");
2675
+ const status = document.getElementById("exportStatus");
2676
+ if (button) button.disabled = true;
2677
+ if (pdfButton) pdfButton.disabled = true;
2678
+ if (status) status.textContent = "Saving editable PowerPoint.";
2679
+
2680
+ try {
2681
+ const response = await fetch("./export-pptx", {
2682
+ method: "POST",
2683
+ headers: { Accept: "application/json" },
2684
+ cache: "no-store",
2685
+ });
2686
+ const data = await response.json().catch(() => ({}));
2687
+ if (!response.ok) {
2688
+ throw new Error(data.message || `PowerPoint export failed (${response.status}).`);
2689
+ }
2690
+ const filename = data.path ? data.path.split(/[\\/]/).pop() : "PowerPoint";
2691
+ const fallback =
2692
+ data.fallbackCount > 0 ? ` ${data.fallbackCount} fallback item(s) preserved.` : "";
2693
+ const message = `Saved ${filename}.${fallback}`;
2694
+ if (status) status.textContent = message;
2695
+ if (button) {
2696
+ button.dataset.state = "active";
2697
+ button.title = message;
2698
+ }
2699
+ syncMoreControls();
2700
+ } catch (error) {
2701
+ const message = error?.message || "Could not save the PowerPoint presentation.";
2702
+ console.error("PowerPoint export failed", error);
2703
+ if (status) status.textContent = message;
2704
+ if (button) {
2705
+ button.dataset.state = "error";
2706
+ button.title = message;
2707
+ }
2708
+ syncMoreControls();
2709
+ } finally {
2710
+ pptxExportPending = false;
2711
+ if (button) button.disabled = false;
2712
+ if (pdfButton) pdfButton.disabled = false;
1500
2713
  }
1501
2714
  }
1502
2715
 
@@ -1512,6 +2725,7 @@ function setFixedPreviewMode(enabled) {
1512
2725
  ? "Return to responsive canvas layout"
1513
2726
  : "Preview PDF layout at 16:9";
1514
2727
  }
2728
+ syncMoreControls();
1515
2729
  if (fixedPreviewMode) {
1516
2730
  updateFixedPreviewScale();
1517
2731
  } else {
@@ -1529,10 +2743,10 @@ function toggleFixedPreviewMode() {
1529
2743
  function updateNav() {
1530
2744
  const nav = document.getElementById("nav");
1531
2745
  if (!nav) return;
1532
- // Outside presenter view, show only the load button even when no deck exists,
1533
- // allowing Markdown import before any slide has been loaded.
2746
+ // Outside presenter view, show only the load button when the host supports
2747
+ // Markdown import before any slide has been loaded.
1534
2748
  const empty = navTotal <= 0;
1535
- nav.hidden = previewMode || (empty && presenterMode);
2749
+ nav.hidden = previewMode || (empty && (presenterMode || !markdownImportAvailable));
1536
2750
  nav.classList.toggle("nav-empty", empty);
1537
2751
  const counter = document.getElementById("navCounter");
1538
2752
  if (counter) {
@@ -1546,10 +2760,11 @@ function updateNav() {
1546
2760
  if (next) next.disabled = navMode === "deck" && navIndex >= navTotal - 1;
1547
2761
  highlightOverview();
1548
2762
  updatePresenterView();
2763
+ syncMoreControls();
1549
2764
  }
1550
2765
 
1551
2766
  function openPresenterView() {
1552
- if (presenterMode || navTotal <= 0) return;
2767
+ if (presenterMode || !presenterViewAvailable || navTotal <= 0) return;
1553
2768
  presenterViewOpen = true;
1554
2769
  document.body.classList.add("presenter-view-mode");
1555
2770
  const view = document.getElementById("presenterView");
@@ -1570,7 +2785,7 @@ function closePresenterView() {
1570
2785
  document.body.classList.remove("presenter-view-mode");
1571
2786
  const view = document.getElementById("presenterView");
1572
2787
  if (view) view.hidden = true;
1573
- document.getElementById("navPresenterView")?.focus();
2788
+ document.getElementById("navMore")?.focus();
1574
2789
  }
1575
2790
 
1576
2791
  function updatePresenterView() {
@@ -1770,7 +2985,7 @@ async function importMarkdown(path) {
1770
2985
  }
1771
2986
 
1772
2987
  function openImportPicker() {
1773
- if (presenterMode) return;
2988
+ if (presenterMode || !markdownImportAvailable) return;
1774
2989
  importOpen = true;
1775
2990
  const el = document.getElementById("importPicker");
1776
2991
  if (el) el.hidden = false;
@@ -1894,10 +3109,11 @@ function wirePreviewKeyboardNavigation() {
1894
3109
  }
1895
3110
 
1896
3111
  function wireControls() {
1897
- const bind = (id, fn) => {
3112
+ const bind = (id, fn, { closeMore = false } = {}) => {
1898
3113
  const el = document.getElementById(id);
1899
3114
  if (!el) return;
1900
3115
  el.addEventListener("click", () => {
3116
+ if (closeMore) setMoreControlsOpen(false);
1901
3117
  fn();
1902
3118
  // Drop focus so a follow-up Space/Enter doesn't re-trigger the button on
1903
3119
  // top of the global keyboard handler.
@@ -1906,14 +3122,16 @@ function wireControls() {
1906
3122
  };
1907
3123
  bind("navPrev", goPrev);
1908
3124
  bind("navNext", goNext);
1909
- bind("navEdit", toggleArchitectureEditMode);
1910
- bind("navPresent", openPresenterWindow);
1911
- bind("navPresenterView", openPresenterView);
1912
- bind("navFixedPreview", toggleFixedPreviewMode);
1913
- bind("navExport", exportPdfFromCanvas);
1914
- bind("navImport", toggleImportPicker);
1915
- bind("navSourceMode", toggleSourceMode);
1916
3125
  bind("navList", toggleOverview);
3126
+ bind("navMore", toggleMoreControls);
3127
+ bind("navEdit", toggleArchitectureEditMode, { closeMore: true });
3128
+ bind("navPresent", openPresenterWindow, { closeMore: true });
3129
+ bind("navPresenterView", openPresenterView, { closeMore: true });
3130
+ bind("navFixedPreview", toggleFixedPreviewMode, { closeMore: true });
3131
+ bind("navExport", exportPdfFromCanvas, { closeMore: true });
3132
+ bind("navExportPptx", exportPptxFromCanvas, { closeMore: true });
3133
+ bind("navImport", toggleImportPicker, { closeMore: true });
3134
+ bind("navSourceMode", toggleSourceMode, { closeMore: true });
1917
3135
  bind("overviewClose", closeOverview);
1918
3136
  bind("importClose", closeImportPicker);
1919
3137
  bind("presenterPrevButton", goPrev);
@@ -1941,6 +3159,14 @@ function wireControls() {
1941
3159
  });
1942
3160
  }
1943
3161
 
3162
+ document.addEventListener("pointerdown", (e) => {
3163
+ if (!moreControlsOpen) return;
3164
+ const tools = document.getElementById("navTools");
3165
+ const panel = document.getElementById("navMorePanel");
3166
+ if (tools?.contains(e.target) || panel?.contains(e.target)) return;
3167
+ setMoreControlsOpen(false);
3168
+ });
3169
+
1944
3170
  wirePointerNavigation();
1945
3171
 
1946
3172
  // The iframe must be focused to receive key events; grab focus up front and
@@ -1963,16 +3189,23 @@ function wireControls() {
1963
3189
  e.preventDefault();
1964
3190
  return;
1965
3191
  }
3192
+ if (e.key === "Escape" && moreControlsOpen) {
3193
+ setMoreControlsOpen(false, { restoreFocus: true });
3194
+ e.preventDefault();
3195
+ return;
3196
+ }
1966
3197
  if (handleSlideNavigationKey(e)) return;
1967
3198
  if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
1968
3199
  switch (e.key) {
1969
3200
  case "o":
1970
3201
  case "O":
3202
+ setMoreControlsOpen(false);
1971
3203
  toggleOverview();
1972
3204
  e.preventDefault();
1973
3205
  break;
1974
3206
  case "i":
1975
3207
  case "I":
3208
+ setMoreControlsOpen(false);
1976
3209
  toggleImportPicker();
1977
3210
  e.preventDefault();
1978
3211
  break;
@@ -2014,6 +3247,11 @@ function init() {
2014
3247
  } catch (_) {}
2015
3248
 
2016
3249
  const params = new URLSearchParams(window.location.search);
3250
+ presenterViewRequested = params.get("presenter") === "1";
3251
+ if (params.get("pptx") === "1") {
3252
+ initPptx(params).catch(reportPptxBootstrapFailure);
3253
+ return;
3254
+ }
2017
3255
  if (params.get("capture") === "1") {
2018
3256
  initCapture(params).catch(reportCaptureBootstrapFailure);
2019
3257
  return;