@markdstage/markdstage 3.4.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,10 +67,10 @@
67
67
  <h2>Elements</h2>
68
68
  <button class="editor-panel-close" type="button" data-panel-close="elements" aria-label="Close Elements">×</button>
69
69
  </div>
70
- <div id="elementTree" class="element-tree" role="tree"></div>
70
+ <div id="elementTree" class="element-tree" role="tree" aria-label="Diagram elements" aria-multiselectable="true"></div>
71
71
  </aside>
72
72
 
73
- <section id="viewport" class="editor-viewport" aria-label="Drawing area">
73
+ <section id="viewport" class="editor-viewport" aria-label="Drawing area" tabindex="-1">
74
74
  <div id="canvasSurface" class="canvas-surface"></div>
75
75
  </section>
76
76
 
@@ -6,7 +6,8 @@ MarkdStage canvas and for AI systems that generate themes.
6
6
  ## Minimum setup
7
7
 
8
8
  Themes can be managed by folder. Put only custom properties in the CSS, and
9
- define optional cover and back-cover assets in `theme.json` in the same folder.
9
+ define optional slide backgrounds, cover and back-cover assets in `theme.json`
10
+ in the same folder.
10
11
 
11
12
  ```markdown
12
13
  ---
@@ -113,7 +114,8 @@ supporting brand colors.
113
114
  `--section-bg` and `--print-section-bg` are used as CSS `background` values.
114
115
  They support solid colors and multiple comma-separated gradients. Theme-file
115
116
  security restrictions prohibit `url()`. Section dividers have no image, logo,
116
- or icon settings in `theme.json`.
117
+ or icon settings in `theme.json`; use per-slide `background-image` for a section
118
+ background image.
117
119
 
118
120
  ## theme.json
119
121
 
@@ -124,6 +126,11 @@ paths relative to `theme.json` using the `assets/...` form.
124
126
  {
125
127
  "$schema": "../../.github/extensions/markdstage/schema/theme-metadata-v1.schema.json",
126
128
  "version": 1,
129
+ "background": { "image": "assets/common.png" },
130
+ "layouts": {
131
+ "default": { "background": { "image": "assets/default.webp" } },
132
+ "center": { "background": { "image": "assets/center.jpg" } }
133
+ },
127
134
  "cover": {
128
135
  "background": { "image": "assets/cover.svg" },
129
136
  "logo": { "image": "assets/logo.svg", "alt": "Example" }
@@ -135,15 +142,65 @@ paths relative to `theme.json` using the `assets/...` form.
135
142
  }
136
143
  ```
137
144
 
138
- - `cover.background` is decorative, so `alt` is optional.
145
+ - `background`, `layouts.default.background`, `layouts.center.background`, and
146
+ `cover.background` are decorative `{ "image": "assets/...", "alt": "..." }`
147
+ entries; `alt` is optional.
148
+ - The common root `background` applies only to standard (`default`) and `center`
149
+ layouts. Each layout's own `background` takes precedence over the common one.
150
+ - `title` continues to use `cover.background`; `section` and `backcover` do not
151
+ inherit the common background. Existing cover and back-cover logos are unchanged.
139
152
  - Logo `alt` text is required.
140
- - Supported formats are SVG / PNG / WebP / JPEG, with a 2 MiB limit per file.
153
+ - Supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`, with a
154
+ 2 MiB limit per file.
141
155
  - Absolute paths, external URLs, `..`, and symbolic links outside the theme
142
156
  folder are rejected.
143
157
  - If an existing `theme.json` is invalid, loading returns an error rather than
144
158
  silently falling back to CSS only.
145
159
  - Slide-front-matter `logo` / `copyright` values override back-cover metadata.
146
160
 
161
+ ### Per-slide background images
162
+
163
+ Every theme (`dark`, `light`, `microsoft`, and `custom`) and every layout,
164
+ including `title`, `section`, and `backcover`, accepts this front matter:
165
+
166
+ ```markdown
167
+ ---
168
+ layout: center
169
+ background-image: /assets/background.png
170
+ ---
171
+
172
+ ## One decision
173
+ ```
174
+
175
+ The `assets/background.png` form without the leading slash is also accepted.
176
+ This override applies only to that slide, even in the file's initial front
177
+ matter. Paths use the normal image lookup: first `assets/` beside the source
178
+ Markdown, then workspace-root `assets/`. Without a source name, only the
179
+ workspace root is searched. These are deck assets, not theme-folder assets.
180
+ Only `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg` files of at most 2 MiB are
181
+ accepted. Remote URLs and `data:` URLs are not allowed.
182
+ Write literal filenames, including spaces and percent signs, with `/`
183
+ separators. Source paths are not URL-decoded: `%20` names those three literal
184
+ characters, not a space. Query strings, fragments, `.` / `..` segments, and
185
+ backslashes are rejected. Resolved files must remain inside their assets
186
+ folder and workspace, including when symbolic links are involved.
187
+
188
+ Background selection, from highest to lowest priority:
189
+
190
+ | Layout | Image selection |
191
+ | --- | --- |
192
+ | Standard (`default`) | Slide `background-image` → `layouts.default.background` → root `background` → existing background |
193
+ | `center` | Slide `background-image` → `layouts.center.background` → root `background` → existing background |
194
+ | `title` | Slide `background-image` → `cover.background` → existing cover background |
195
+ | `section` / `backcover` | Slide `background-image` → existing layout background |
196
+
197
+ Images are centered and cropped to fill the slide (`object-fit: cover`) behind
198
+ content, diagrams, and logos, with the existing background color or gradient
199
+ underneath. No extra overlay or per-layout color properties are introduced.
200
+ Only an absent setting triggers fallback: an invalid value, missing file, or
201
+ oversized image is an error, not a reason to silently substitute another image.
202
+ Omitting all image settings preserves CSS-only themes and existing output.
203
+
147
204
  ### Sizing and spacing
148
205
 
149
206
  You may change `--deck-pad-y`, `--deck-pad-x`, `--slide-h1-size`,
@@ -24,7 +24,8 @@ const META_COMMENT = /^[ \t]*#/;
24
24
  // - layout: Inheritance would make every page a cover or back cover. The leading
25
25
  // front matter is also the first slide's front matter, so it still applies there.
26
26
  // - page: Sequence numbers are slide-specific; a deck-wide value has no meaning.
27
- const NON_INHERITED_KEYS = new Set(["layout", "page"]);
27
+ // - background-image: An explicit image overrides only its own slide's background.
28
+ const NON_INHERITED_KEYS = new Set(["layout", "page", "background-image"]);
28
29
 
29
30
  // Layouts that do not receive automatic page numbers (cover, section divider, and back cover).
30
31
  const UNNUMBERED_LAYOUTS = new Set(["title", "section", "backcover"]);
@@ -177,8 +178,8 @@ export function splitMarkdownDeck(text) {
177
178
  * Convert a Markdown file to slide fragments the extension can render directly.
178
179
  *
179
180
  * - Inherit shared deck front matter on each slide; slide-level values take precedence.
180
- * - Do not inherit `layout`. Leading file front matter also belongs to the first
181
- * slide, so it still applies there (for example, `layout: title`).
181
+ * - Do not inherit `layout` or `background-image`. Leading file front matter also
182
+ * belongs to the first slide, so these values still apply there.
182
183
  * - Add `page` / `total` automatically only when neither the deck nor slide
183
184
  * specifies them. Do not display numbers on covers, section dividers, or back
184
185
  * covers, although they still participate in sequence numbering.
@@ -190,8 +191,11 @@ export function buildDeckSlides(text) {
190
191
  const merged = slides.map((slide, i) => {
191
192
  const meta = new Map();
192
193
  for (const [key, entry] of deckMeta) {
193
- // Leading file front matter also belongs to the first slide, so include its layout.
194
- if (NON_INHERITED_KEYS.has(key) && !(i === 0 && key === "layout")) continue;
194
+ // Preserve the first slide's layout and background, but never inherit page numbers.
195
+ if (
196
+ NON_INHERITED_KEYS.has(key) &&
197
+ !(i === 0 && (key === "layout" || key === "background-image"))
198
+ ) continue;
195
199
  meta.set(key, entry);
196
200
  }
197
201
  for (const [key, entry] of slide.meta) meta.set(key, entry);
@@ -9,6 +9,7 @@ import {
9
9
  resolveRawElement,
10
10
  serializeArchitecture,
11
11
  } from "./architecture-edit.mjs";
12
+ import { architectureContract } from "./architecture-contract.mjs";
12
13
 
13
14
  const HISTORY_LIMIT = 200;
14
15
  const COORDINATE_MIN = -4000;
@@ -143,8 +144,50 @@ function setNested(target, path, value) {
143
144
  }
144
145
  }
145
146
 
146
- function remapCloneIds(raw, element) {
147
- const copy = clone(element);
147
+ function operationRoots(entries) {
148
+ return entries.filter((entry) => !entries.some(
149
+ (ancestor) => ancestor.element.type === "group" &&
150
+ entry.sourcePath.startsWith(`${ancestor.sourcePath}.children[`),
151
+ ));
152
+ }
153
+
154
+ function updateProperty(element, path, value) {
155
+ setNested(element, path, value);
156
+ if (element.type === "connector" && path === "routing" && value !== "polyline") {
157
+ delete element.points;
158
+ }
159
+ }
160
+
161
+ function supportsBatchProperty(element, path) {
162
+ if (typeof path !== "string") return false;
163
+ const [field, nested, ...rest] = path.split(".");
164
+ if (["id", "type", "children", "parent", "layout"].includes(field)) return false;
165
+ const properties = architectureContract.elements[element.type]?.properties;
166
+ if (!properties || !Object.hasOwn(properties, field)) return false;
167
+ if (nested === undefined) return true;
168
+ return field === "style" && rest.length === 0 &&
169
+ Object.hasOwn(architectureContract.definitions.style.properties, nested);
170
+ }
171
+
172
+ function finiteValues(value) {
173
+ if (typeof value === "number") return Number.isFinite(value);
174
+ if (value && typeof value === "object") return Object.values(value).every(finiteValues);
175
+ return true;
176
+ }
177
+
178
+ function sharedDisplacement(boxes, dx, dy) {
179
+ return {
180
+ dx: round(clamp(dx,
181
+ Math.max(...boxes.map((box) => COORDINATE_MIN - box.x)),
182
+ Math.min(...boxes.map((box) => COORDINATE_MAX - box.x)))),
183
+ dy: round(clamp(dy,
184
+ Math.max(...boxes.map((box) => COORDINATE_MIN - box.y)),
185
+ Math.min(...boxes.map((box) => COORDINATE_MAX - box.y)))),
186
+ };
187
+ }
188
+
189
+ function remapCloneIds(raw, elements) {
190
+ const copies = clone(elements);
148
191
  const mapping = new Map();
149
192
  const reserved = new Set(rawEntries(raw).map((entry) => entry.element.id).filter(Boolean));
150
193
  const reserveId = (base) => {
@@ -170,7 +213,7 @@ function remapCloneIds(raw, element) {
170
213
  }
171
214
  if (Array.isArray(item.children)) item.children.forEach(reserve);
172
215
  };
173
- reserve(copy);
216
+ copies.forEach(reserve);
174
217
  const updateConnectors = (item) => {
175
218
  if (!item || typeof item !== "object") return;
176
219
  if (item.type === "connector") {
@@ -179,8 +222,8 @@ function remapCloneIds(raw, element) {
179
222
  }
180
223
  if (Array.isArray(item.children)) item.children.forEach(updateConnectors);
181
224
  };
182
- updateConnectors(copy);
183
- return copy;
225
+ copies.forEach(updateConnectors);
226
+ return copies;
184
227
  }
185
228
 
186
229
  export function createArchitectureDocument(source, options = {}) {
@@ -207,6 +250,9 @@ export function createArchitectureDocument(source, options = {}) {
207
250
  }
208
251
 
209
252
  function commit(raw, reason, details = {}) {
253
+ if (JSON.stringify(raw) === JSON.stringify(current().raw)) {
254
+ return reject("unchanged", details);
255
+ }
210
256
  const sourceText = serializeArchitecture(raw);
211
257
  let next;
212
258
  try {
@@ -233,6 +279,30 @@ export function createArchitectureDocument(source, options = {}) {
233
279
  return commit(raw, reason, details);
234
280
  }
235
281
 
282
+ function selection(raw, refs) {
283
+ if (!Array.isArray(refs)) return reject("invalid-selection", { refs: [] });
284
+ if (!refs.length) return reject("empty-selection", { refs: [] });
285
+ const entries = [];
286
+ const seen = new Set();
287
+ for (const ref of refs) {
288
+ const entry = typeof ref === "string" ? rawEntry(raw, ref) : null;
289
+ if (!entry) return reject("unknown", { refs, invalidRef: ref });
290
+ if (seen.has(entry.element)) continue;
291
+ seen.add(entry.element);
292
+ entries.push(entry);
293
+ }
294
+ return { entries, refs: entries.map((entry) => entry.ref) };
295
+ }
296
+
297
+ function mutateMany(reason, refs, mutator) {
298
+ const edited = mutate(reason, (raw) => {
299
+ const selected = selection(raw, refs);
300
+ if (selected.ok === false) return selected;
301
+ return mutator(raw, selected);
302
+ });
303
+ return { refs: Array.isArray(refs) ? refs : [], ...edited };
304
+ }
305
+
236
306
  function describe(ref) {
237
307
  const element = modelElement(current().model, ref);
238
308
  if (!element) return { found: false, movable: false, reason: "unknown", ref };
@@ -266,14 +336,28 @@ export function createArchitectureDocument(source, options = {}) {
266
336
  return mutate("element-updated", (raw) => {
267
337
  const entry = rawEntry(raw, ref);
268
338
  if (!entry) return reject("unknown", { ref });
269
- setNested(entry.element, path, value);
270
- if (entry.element.type === "connector" && path === "routing" && value !== "polyline") {
271
- delete entry.element.points;
272
- }
339
+ updateProperty(entry.element, path, value);
273
340
  return { ref: entry.element.id || entry.sourcePath, path, value };
274
341
  });
275
342
  }
276
343
 
344
+ function setElements(refs, path, value) {
345
+ return mutateMany("elements-updated", refs, (_raw, selected) => {
346
+ if (!finiteValues(value)) return reject("invalid-value", { refs: selected.refs, path });
347
+ for (const entry of selected.entries) {
348
+ if (!supportsBatchProperty(entry.element, path)) {
349
+ return reject("unsupported-property", { refs: selected.refs, path });
350
+ }
351
+ if (["x", "y", "width", "height"].includes(path)) {
352
+ const placement = describePlacement(current().model, entry.element.id);
353
+ if (!placement.movable) return { ...placement, ok: false, refs: selected.refs };
354
+ }
355
+ }
356
+ for (const entry of selected.entries) updateProperty(entry.element, path, value);
357
+ return { refs: selected.refs, path, value };
358
+ });
359
+ }
360
+
277
361
  function renameElement(ref, id) {
278
362
  const next = String(id || "").trim();
279
363
  if (!ID_PATTERN.test(next)) return reject("invalid-id", { id: next });
@@ -311,6 +395,34 @@ export function createArchitectureDocument(source, options = {}) {
311
395
  });
312
396
  }
313
397
 
398
+ function moveMany(refs, dx, dy) {
399
+ return mutateMany("moved", refs, (_raw, selected) => {
400
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) {
401
+ return reject("invalid-displacement", { refs: selected.refs });
402
+ }
403
+ const roots = operationRoots(selected.entries)
404
+ .filter((entry) => entry.element.type !== "connector");
405
+ if (!roots.length) return reject("connector-only", { refs: selected.refs });
406
+ const boxes = [];
407
+ for (const entry of roots) {
408
+ const placement = describePlacement(current().model, entry.element.id);
409
+ if (!placement.movable) return { ...placement, ok: false, refs: selected.refs };
410
+ const element = modelElement(current().model, entry.ref);
411
+ boxes.push({
412
+ x: round(element.x - placement.origin.x),
413
+ y: round(element.y - placement.origin.y),
414
+ });
415
+ }
416
+ const delta = sharedDisplacement(boxes, dx, dy);
417
+ if (!delta.dx && !delta.dy) return reject("unchanged", { refs: selected.refs, ...delta });
418
+ roots.forEach((entry, index) => {
419
+ entry.element.x = round(boxes[index].x + delta.dx);
420
+ entry.element.y = round(boxes[index].y + delta.dy);
421
+ });
422
+ return { refs: selected.refs, ...delta };
423
+ });
424
+ }
425
+
314
426
  function resize(ref, box) {
315
427
  const element = modelElement(current().model, ref);
316
428
  if (!element || element.type === "connector") return reject("unknown", { ref });
@@ -452,6 +564,19 @@ export function createArchitectureDocument(source, options = {}) {
452
564
  });
453
565
  }
454
566
 
567
+ function removeMany(refs) {
568
+ return mutateMany("elements-deleted", refs, (raw, selected) => {
569
+ const roots = operationRoots(selected.entries);
570
+ const ids = new Set();
571
+ roots.forEach((entry) => collectIds(entry.element, ids));
572
+ for (const entry of roots) {
573
+ entry.items.splice(entry.items.indexOf(entry.element), 1);
574
+ }
575
+ if (ids.size) removeReferencingConnectors(raw.elements, ids);
576
+ return { refs: [], removedIds: [...ids] };
577
+ });
578
+ }
579
+
455
580
  function duplicate(ref) {
456
581
  return mutate("element-duplicated", (raw) => {
457
582
  const entry = rawEntry(raw, ref);
@@ -459,7 +584,7 @@ export function createArchitectureDocument(source, options = {}) {
459
584
  const copy =
460
585
  entry.element.type === "connector"
461
586
  ? clone(entry.element)
462
- : remapCloneIds(raw, entry.element);
587
+ : remapCloneIds(raw, [entry.element])[0];
463
588
  if (typeof copy.x === "number") copy.x = clamp(copy.x + 24, COORDINATE_MIN, COORDINATE_MAX);
464
589
  if (typeof copy.y === "number") copy.y = clamp(copy.y + 24, COORDINATE_MIN, COORDINATE_MAX);
465
590
  entry.items.splice(entry.index + 1, 0, copy);
@@ -468,6 +593,36 @@ export function createArchitectureDocument(source, options = {}) {
468
593
  });
469
594
  }
470
595
 
596
+ function duplicateMany(refs) {
597
+ return mutateMany("elements-duplicated", refs, (raw, selected) => {
598
+ const roots = operationRoots(selected.entries);
599
+ const copies = remapCloneIds(raw, roots.map((entry) => entry.element));
600
+ const boxes = roots.map((entry) => {
601
+ if (entry.element.type === "connector") return null;
602
+ const placement = describePlacement(current().model, entry.element.id);
603
+ if (!placement.movable) return null;
604
+ const element = modelElement(current().model, entry.ref);
605
+ return {
606
+ x: round(element.x - placement.origin.x),
607
+ y: round(element.y - placement.origin.y),
608
+ };
609
+ });
610
+ const movableBoxes = boxes.filter(Boolean);
611
+ const delta = movableBoxes.length ? sharedDisplacement(movableBoxes, 24, 24) : { dx: 0, dy: 0 };
612
+ roots.forEach((entry, index) => {
613
+ const copy = copies[index];
614
+ if (boxes[index]) {
615
+ copy.x = round(boxes[index].x + delta.dx);
616
+ copy.y = round(boxes[index].y + delta.dy);
617
+ }
618
+ entry.items.splice(entry.items.indexOf(entry.element) + 1, 0, copy);
619
+ });
620
+ // Source paths belong to the final arrays, not the insertion-time indexes.
621
+ const inserted = new Map(rawEntries(raw).map((entry) => [entry.element, entry.ref]));
622
+ return { refs: copies.map((copy) => inserted.get(copy)), ...delta };
623
+ });
624
+ }
625
+
471
626
  function reorder(ref, delta) {
472
627
  return mutate("element-reordered", (raw) => {
473
628
  const entry = rawEntry(raw, ref);
@@ -569,15 +724,19 @@ export function createArchitectureDocument(source, options = {}) {
569
724
  describe,
570
725
  setRoot,
571
726
  setElement,
727
+ setElements,
572
728
  renameElement,
573
729
  move,
730
+ moveMany,
574
731
  resize,
575
732
  addNode,
576
733
  addGroup,
577
734
  addImage,
578
735
  addConnector,
579
736
  remove,
737
+ removeMany,
580
738
  duplicate,
739
+ duplicateMany,
581
740
  reorder,
582
741
  reparent,
583
742
  releaseLayout,
@@ -67,7 +67,7 @@
67
67
  </button>
68
68
  <button id="navExportPptx" class="nav-more-item" type="button" title="Save as editable PowerPoint" aria-label="Save as editable PowerPoint" hidden>
69
69
  <span class="nav-more-icon" aria-hidden="true">P</span>
70
- <span class="nav-more-label">Export PowerPoint</span>
70
+ <span class="nav-more-label">Export PowerPoint&#8230;</span>
71
71
  </button>
72
72
  </section>
73
73
  </div>
@@ -85,6 +85,29 @@
85
85
  </section>
86
86
  <div id="layoutWarning" class="layout-warning" role="status" aria-live="polite" hidden></div>
87
87
 
88
+ <dialog id="pptxExportDialog" class="pptx-export-dialog" aria-labelledby="pptxExportTitle">
89
+ <form id="pptxExportForm">
90
+ <h2 id="pptxExportTitle">Export PowerPoint</h2>
91
+ <fieldset>
92
+ <legend>Mermaid diagrams</legend>
93
+ <label class="pptx-export-choice">
94
+ <input type="radio" name="mermaidOutput" value="shapes" checked autofocus aria-describedby="pptxShapesDescription">
95
+ <span>Editable shapes</span>
96
+ </label>
97
+ <p id="pptxShapesDescription">Edit shapes and text in PowerPoint. Unsupported details become images.</p>
98
+ <label class="pptx-export-choice">
99
+ <input type="radio" name="mermaidOutput" value="images" aria-describedby="pptxImagesDescription">
100
+ <span>Images</span>
101
+ </label>
102
+ <p id="pptxImagesDescription">Keep each diagram as one image. Text and lines within it cannot be edited individually.</p>
103
+ </fieldset>
104
+ <div class="pptx-export-actions">
105
+ <button id="pptxExportCancel" class="presenter-button" type="button">Cancel</button>
106
+ <button class="presenter-button pptx-export-submit" type="submit">Export</button>
107
+ </div>
108
+ </form>
109
+ </dialog>
110
+
88
111
  <section id="presenterView" class="presenter-view" hidden aria-label="Presenter view">
89
112
  <header class="presenter-view-head">
90
113
  <h1>Presenter view</h1>
@@ -93,12 +116,12 @@
93
116
  <div class="presenter-previews">
94
117
  <section class="presenter-preview presenter-preview-current" aria-labelledby="presenterCurrentLabel">
95
118
  <h2 id="presenterCurrentLabel">Current slide</h2>
96
- <iframe id="presenterCurrent" class="presenter-frame" title="Current slide"></iframe>
119
+ <div id="presenterCurrentViewport" class="presenter-frame slide-viewport"></div>
97
120
  </section>
98
121
  <div class="presenter-sidebar">
99
122
  <section class="presenter-preview" aria-labelledby="presenterNextLabel">
100
123
  <h2 id="presenterNextLabel">Next slide</h2>
101
- <iframe id="presenterNext" class="presenter-frame" title="Next slide"></iframe>
124
+ <div id="presenterNextViewport" class="presenter-frame slide-viewport"></div>
102
125
  <p id="presenterNextEmpty" class="presenter-next-empty" hidden>There is no next slide</p>
103
126
  </section>
104
127
  <section class="presenter-notes" aria-labelledby="presenterNotesLabel">