@openpresentation/opf-editor 0.18.1 → 0.19.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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec
29
29
 
30
30
  ```js
31
31
  import { createEditorSession, saveDocument } from "@openpresentation/opf-editor";
32
- import { defaultCatalog, catalogDisplay } from "@openpresentation/opf/catalog"; // the host app's choice, not the library's
32
+ import { gallery as defaultCatalog, catalogDisplay } from "@openpresentation/gallery"; // the host app's choice, not the library's
33
33
 
34
34
  const editor = createEditorSession(doc, { catalogs: [defaultCatalog, acmeCatalog] }); // the first is the host default
35
35
  editor.catalogs; // the frozen list; editor.setCatalogs([...]) replaces it (one "catalogs" event)
@@ -79,7 +79,7 @@ The control provides indentation, paired quotes/brackets, folding shortcuts, sea
79
79
 
80
80
  Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Records the document embeds come first, then those of the catalogs the host passes as `catalogs` (`Catalog[]`, the session's registered list); there are no built-in records. The actual schema supplies enums and booleans. Prose and extension fields do not gain menus just because their keys resemble schema fields. External catalog URLs are not fetched. `setCatalogs(list)` replaces the host's catalogs and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
81
81
 
82
- In version 0.7.1, layout choices add blank payloads for missing declared placeholders, preserving existing content. Repeated slots use separate blocks; subtitle slots use `subtitle`, and metric slots use `{ "metric": { "value": "", "label": "" } }`. Charts receive the smallest valid data scaffold rather than invented numeric values. Existing positioned content stays in its regions; additional body slots append inside the last region. Non-layout options still change only their value. Custom menus must apply the full source returned by `replaceFieldOption`, or use `fieldOptionEdit(context, value)` and pass its `from`, `to`, and `insert` to `control.api.replace` as one undoable edit. Always check that `context.source` still matches the editor first. Version 0.7.0 only changed the selected token. Version 0.7.1 requires core 0.10.1 for metric, quote and timeline placeholder contracts.
82
+ In OPF 0.19, layout choices change only the template reference and preserve authored content. Empty regions remain available for explicit insertion; choosing a template does not invent blank payloads. The JSON field menu groups templates by intent and describes their supported regions. Hosts apply `replaceFieldOption` or `fieldOptionEdit(context, value)` as one undoable edit and check that `context.source` is still current.
83
83
 
84
84
  Run `npm run test:json` and `npm run test:json-browser` for the focused model and offline browser checks. The browser runner also accepts a report path followed by a clean installed consumer directory. These checks cover the JSON control; renderer, native PowerPoint and production adoption remain separate acceptance steps.
85
85
 
@@ -342,7 +342,7 @@ The playground's Content tab mounts the block-level and slide-structure actions
342
342
 
343
343
  ### Pickers: options, chart types and current values
344
344
 
345
- `listSwitchOptions(presentation, dimension, { catalogs, vocabularies })` lists what a dimension offers: a catalog dimension lists core's `catalogRecords` (the document's embedded records first, then the host catalogs'), each with the reference to write; `charts`, `socials` and `languages` list core's vocabularies, labelled from `vocabularies` (`catalogDisplay` from `@openpresentation/opf/catalog`, supplied by the host). `compatibleChartTypes(presentation, { slideIndex, path })` lists the chart types the chart's inline data can use as it is, by data shape: the first column labels the categories and each further column is a series (how the renderers read it), a type with N series needs exactly N value columns, except that stacked types and the combo chart take that many or more (combo: two or more), and the single-series, distribution and geographic types are offered only where they fit. Switching a combo chart to another type removes its `line`, `secondaryAxis` and secondary axis title. It is data-shape compatibility, not a claim that an engine draws the type. `currentSwitchValue(presentation, dimension, { slideIndex })` reads the value back as `{ value, scope }`.
345
+ `listSwitchOptions(presentation, dimension, { catalogs, vocabularies })` lists what a dimension offers: a catalog dimension lists core's `catalogRecords` (the document's embedded records first, then the host catalogs'), each with the reference to write; `charts`, `socials` and `languages` list core's vocabularies, labelled from `vocabularies` (`catalogDisplay` from `@openpresentation/gallery`, supplied by the host). `compatibleChartTypes(presentation, { slideIndex, path })` lists the chart types the chart's inline data can use as it is, by data shape: the first column labels the categories and each further column is a series (how the renderers read it), a type with N series needs exactly N value columns, except that stacked types and the combo chart take that many or more (combo: two or more), and the single-series, distribution and geographic types are offered only where they fit. Switching a combo chart to another type removes its `line`, `secondaryAxis` and secondary axis title. It is data-shape compatibility, not a claim that an engine draws the type. `currentSwitchValue(presentation, dimension, { slideIndex })` reads the value back as `{ value, scope }`.
346
346
 
347
347
  ## Design options (RR-06)
348
348
 
@@ -443,7 +443,7 @@ Table alt text (FA-27): `setTableAlt(editor, tablePath, { alt: "North America le
443
443
  ```js
444
444
  import { addSlide, duplicateSlides, removeSlides, moveSlides, moveSlidesBy, setHidden, addSection, renameSection, removeSection, moveSection, setSection, listSections } from "@openpresentation/opf-editor/slides";
445
445
 
446
- addSlide(editor, { at: 2, layout: "list-2x" }); // the layout's placeholders become empty slots, as when switching a slide's layout
446
+ addSlide(editor, { at: 2, layout: "agenda" }); // OPF 0.19: an adaptive template accepts content in named regions
447
447
  duplicateSlides(editor, [1, 3]); // copies follow the last selected slide, with fresh ids (also for content ids)
448
448
  removeSlides(editor, [4]); // a deck keeps at least one slide: cannot-remove-all-slides
449
449
  moveSlides(editor, [0, 1], 5); // `to` is the drop gap in the original order (0 to the slide count)
@@ -466,6 +466,10 @@ const manager = createSlideManager(document.querySelector("#slide-list"), {
466
466
  editor.subscribe(() => manager.render());
467
467
  ```
468
468
 
469
+ OPF 0.19 layouts have a grid (`areas`) and named content regions. The slide manager's `openLayoutPicker("apply")` groups the 28 built-ins by intent and applies one to the current slide; `openLayoutPicker()` adds a slide. Pass `renderLayoutPreview(presentation, slideIndex, choice)` to render the current slide with each candidate through the shared renderer. The picker shows the record's `preview` when supplied, otherwise a diagram of its areas. It retains content when switching.
470
+
471
+ `@openpresentation/opf-editor/custom-layout-editor` exports `openCustomLayoutEditor(editor)`, the playground's 12 by 12 grid dialog. Draw named rectangular areas, choose the content kinds, role and flow of each body region, and save the record under `catalogs.custom.layouts` in one undoable transaction. The headless `layoutFromGrid`, `prepareCustomLayout`, `saveCustomLayout` and `pinBlockToRegion` operations are in `/layout-templates`. Canvas Arrange mode shows every template region; empty ones say “Drop content here”, and a top-level block can be dragged or chosen by keyboard for a validated region pin. A full or incompatible region refuses the edit instead of silently ignoring the pin. The host registers the 0.19 catalog with the session; the editor ships no catalog data.
472
+
469
473
  `@openpresentation/opf-editor/outline` and `/outline-view` edit the deck as an outline: a row per slide title, subtitle, text paragraph and list item, and a read-only row for content that is not text (a chart, a table, an image) or text that carries formatting, so nothing is flattened away. Typing commits as one change when the row loses focus or on Enter. Enter adds a line (after a title: a slide), Alt+Up and Alt+Down move a bullet with the bullets nested under it, or a slide with its text, Alt+Shift+Right demotes and Alt+Shift+Left promotes. Demoting a bullet nests it (`level`); promoting a top-level bullet turns it into a new slide that takes the bullets after it; demoting a plain slide makes it a bullet of the slide before it (refused, with the reason, when that would drop content such as notes, blocks or a design). Backspace on an empty line removes it. Tab keeps moving focus, so the outline is no keyboard trap.
470
474
 
471
475
  ## Data grid (RR-24)
@@ -510,7 +514,7 @@ const controls = createDesignControls(designPanel, {
510
514
  getSlideIndex: () => slideIndex,
511
515
  getSelectedPath: () => selectedPath,
512
516
  sections: ["look", "background", "header-footer", "brand", "layout-options", "info", "catalog"],
513
- vocabularies: catalogDisplay, // from @openpresentation/opf/catalog, in the host app
517
+ vocabularies: catalogDisplay, // from @openpresentation/gallery, in the host app
514
518
  });
515
519
  const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "image", "table", "slide-content"], onSelectPath: select });
516
520
  controls.refresh(); // when the slide or the selection changes
@@ -774,7 +778,7 @@ The canvas can show draggable, keyboard-accessible dividers for root and nested
774
778
 
775
779
  ### Move complete blocks
776
780
 
777
- When the slide's layout record has placeholder groups (FA-26, `{ "type": "group", "composition", "placeholders" }`), Arrange also outlines every slot of the record at its cell (`geometry.slots`): groups, filled regions and labelled empty regions. The slots belong to the record, so they are shown, not edited; the slide's content in them keeps its own selection and handles. Layout pickers and the JSON field menu describe such a record as `title, column (text, text), chart` and compare layouts by their leaf regions.
781
+ For v2 templates, Arrange draws editable region targets from `geometry.regions`, including empty and collapsed regions. Region menus insert accepted content and move compatible blocks with a region pin through the editor session, preserving validation and undo. Nested content groups retain their own composition handles; layout records contain no v1 slots.
778
782
 
779
783
  Arrange mode also shows numbered block handles. Drag to reorder siblings, use arrow keys for earlier/later, or click a handle to choose an existing destination group/slide and insertion position. The move preserves the whole block, including rich text, table/chart data, and nested groups. Each move is validated, preflighted by the renderer, and undoable. Parent weights stay with layout positions; moves that leave an empty container or create a containment cycle are rejected.
780
784
 
@@ -787,3 +791,5 @@ Define cells with `{value, style, rowSpan, colSpan}` and place explicit `null` a
787
791
  The core schema rejects overlapping/out-of-bounds spans and hidden content. Model patches can change a span and remove a row atomically; undo restores the complete operation. Styling or restructuring a table remains available through JSON/model edits and the existing structured property forms.
788
792
 
789
793
  `test/styled-table.mjs` covers model behavior. The core repository's `scripts/build-rich-table-browser.mjs` builds both rich and styled browser fixtures. Browser verification uses actual loaded Roboto fonts and covers merged typing, scalar formatting, partial selection, empty values, cancellation and undo. Native PowerPoint appearance is a separate converter verification boundary.
794
+
795
+ Final 0.19 local acceptance uses core `e21c472d88057d9d90cfa507fe91b89063f3f29a`, gallery `e0a7eee2dfe996e680b34a907cabfe41905e6d99`, renderer `9fbddcb78dbebcb87355922ca92af7d26c24c48d` and PPTX `006e8a7df9c19e85b230a27f19794e5356cdac78`, installed or linked explicitly. Gallery 2 is a direct host/example/test import. Its development dependency declaration and registry lock reconciliation, along with final core/renderer/PPTX release ranges and packed-consumer checks, remain held for the actual dependency releases. Local candidate checks do not establish a published dependency graph.
@@ -122,6 +122,20 @@ export function createBlockControls(root, options) {
122
122
  node.ondragend=()=>{cancel();options.render();};layer.append(node);
123
123
  });
124
124
  }
125
+ // Core's template flows are region-local and do not expose a root flow handle. A compact grip on each
126
+ // top-level bound block lets the user drag it to another region without changing the flat block order.
127
+ for(const item of geometry.items??[]){
128
+ if(!item.region)continue;
129
+ const match=new RegExp(`^slides\\.${options.slideIndex()}\\.blocks\\.(\\d+)$`).exec(item.path);
130
+ if(!match)continue;
131
+ const index=Number(match[1]),path=`/slides/${options.slideIndex()}/blocks/${index}`;
132
+ const node=doc.createElement('button');node.type='button';node.draggable=true;node.dataset.blockPath=path;
133
+ node.textContent=`${index+1} ⋮⋮`;node.setAttribute('aria-label',`Move block ${index+1} from ${item.region} to another region`);
134
+ node.title='Drag to a region marked Drop content here';
135
+ node.style.cssText=`position:absolute;pointer-events:auto;cursor:grab;left:calc(${item.box.x/geometry.width*100}% + 3px);top:calc(${item.box.y/geometry.height*100}% + 3px);padding:2px 5px;background:#f6f3fc;color:#675394;border:1px solid #cfc4e7;border-radius:4px;font:11px system-ui`;
136
+ node.ondragstart=event=>{if(!options.beforeEdit()){event.preventDefault();return;}event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',path);};
137
+ layer.append(node);
138
+ }
125
139
  }
126
140
  function over(event){
127
141
  if(!drag)return;
package/dist/canvas.d.ts CHANGED
@@ -34,7 +34,7 @@ export interface CanvasEditorOptions {
34
34
  editor?: EditorSession;
35
35
  presentation?: unknown;
36
36
  /**
37
- * The host's catalogs (core `Catalog[]`, for example `[defaultCatalog]` from `@openpresentation/opf/catalog`) for the session the
37
+ * The host's catalogs (core `Catalog[]`, for example `[gallery]` from `@openpresentation/gallery`) for the session the
38
38
  * canvas creates from `presentation`. With `editor`, register them on that session instead (`createEditorSession(doc, { catalogs })`);
39
39
  * `renderOptions.catalogs` adds catalogs for drawing only, merged after the session's.
40
40
  */
package/dist/canvas.js CHANGED
@@ -144,6 +144,7 @@ export function createCanvasEditor(container, options = {}) {
144
144
  });
145
145
  const layoutHandles = createLayoutHandles(root, {
146
146
  editor, enabled: options.layoutEditing, render: renderFor, beforeEdit: commit,
147
+ slideIndex: () => slideIndex,
147
148
  isTextEditing: () => !!active,
148
149
  clearError: clearNotice, onError: report,
149
150
  onDraft: value => options.onDraft?.(value),
package/dist/catalogs.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Catalogs (FA-23, OPF 0.15). The editor never ships or imports catalog data: the host registers its catalogs (`Catalog[]`,
2
- // the shape core's `CatalogOptions.catalogs` takes, for example `defaultCatalog` from `@openpresentation/opf/catalog`), on the
2
+ // the shape core's `CatalogOptions.catalogs` takes, for example `gallery` from `@openpresentation/gallery`), on the
3
3
  // session (`createEditorSession(doc, { catalogs })`) or per call. `mergeCatalogs` is the one function that turns those into the
4
4
  // one list the editor hands to core (resolution, validation, embedding, copying, updates), to the renderer and to the exporter.
5
5
  // Pickers list core's `catalogRecords` and write the reference it gives (`id` or `name:id`); saving and exporting embed every
@@ -23,7 +23,7 @@ export function mergeCatalogs(...lists) {
23
23
  for (const catalog of Array.isArray(list) ? list : [list]) {
24
24
  if (catalog === undefined || catalog === null) continue;
25
25
  if (!isObject(catalog) || typeof catalog.source !== "string" || catalog.source === "")
26
- throw new OPFEditorError("invalid-catalogs", "A registered catalog is an object with a non-empty source, such as defaultCatalog from @openpresentation/opf/catalog.", { catalog });
26
+ throw new OPFEditorError("invalid-catalogs", "A registered catalog is an object with a non-empty source, such as gallery from @openpresentation/gallery.", { catalog });
27
27
  if (sources.has(catalog.source)) continue;
28
28
  sources.add(catalog.source);
29
29
  merged.push(catalog);
@@ -0,0 +1,7 @@
1
+ import type { EditorSession } from "./index.js";
2
+
3
+ export declare function openCustomLayoutEditor(editor: EditorSession, options?: {
4
+ document?: Document;
5
+ onSave?: (layout: { id: string; record: Record<string, unknown> }) => void;
6
+ onClose?: () => void;
7
+ }): { dialog: HTMLDialogElement; close(): void };
@@ -0,0 +1,105 @@
1
+ // A small, local grid authoring dialog for OPF 0.19 layout templates.
2
+ import { LAYOUT_REGION_FLOWS, LAYOUT_REGION_KINDS, LAYOUT_REGION_ROLES, layoutFromGrid, saveCustomLayout } from "./layout-templates.js";
3
+
4
+ const element = (doc, tag, text) => { const node = doc.createElement(tag); if (text !== undefined) node.textContent = text; return node; };
5
+ const field = (doc, title, input) => { const label = element(doc, "label", title); label.className = "opf-grid-field"; label.append(input); return label; };
6
+ const select = (doc, values, current) => {
7
+ const input = element(doc, "select");
8
+ for (const value of values) { const option = element(doc, "option", value); option.value = value; input.append(option); }
9
+ input.value = current;
10
+ return input;
11
+ };
12
+
13
+ /** Draw rectangular areas, set each region's accepted kinds, role and flow, then save one undoable custom record. */
14
+ export function openCustomLayoutEditor(editor, options = {}) {
15
+ const doc = options.document ?? globalThis.document;
16
+ const dialog = element(doc, "dialog");
17
+ dialog.className = "opf-sm-dialog opf-grid-dialog";
18
+ const heading = element(doc, "h2", "Create a custom layout");
19
+ heading.id = "opf-grid-title";
20
+ dialog.setAttribute("aria-labelledby", heading.id);
21
+ const error = element(doc, "p"); error.setAttribute("role", "alert"); error.className = "opf-sm-error";
22
+ const id = element(doc, "input"); id.placeholder = "roadmap-split"; id.pattern = "[a-z][a-z0-9-]*"; id.required = true;
23
+ const name = element(doc, "input"); name.placeholder = "Roadmap split"; name.required = true;
24
+ const summary = element(doc, "input"); summary.placeholder = "When to use this layout";
25
+ const width = element(doc, "input"); width.type = "number"; width.min = "1"; width.max = "12"; width.value = "3";
26
+ const height = element(doc, "input"); height.type = "number"; height.min = "1"; height.max = "12"; height.value = "3";
27
+ const paint = element(doc, "input"); paint.value = "content"; paint.placeholder = "content or . to erase";
28
+ const grid = element(doc, "div"); grid.className = "opf-custom-layout-grid"; grid.setAttribute("aria-label", "Layout cells");
29
+ const regionsPanel = element(doc, "div"); regionsPanel.className = "opf-grid-regions";
30
+ const hint = element(doc, "p", "Choose an area name, then drag across cells to draw a rectangle. Use title for the heading and . to clear cells."); hint.className = "field-help";
31
+ const body = element(doc, "div"); body.className = "opf-grid-body";
32
+ body.append(field(doc, "Layout ID", id), field(doc, "Name", name), field(doc, "Summary", summary));
33
+ const dimensions = element(doc, "div"); dimensions.className = "opf-grid-dimensions";
34
+ dimensions.append(field(doc, "Columns", width), field(doc, "Rows", height), field(doc, "Paint area", paint));
35
+ body.append(dimensions, hint, grid, regionsPanel, error);
36
+ const cancel = element(doc, "button", "Cancel"); cancel.type = "button"; cancel.className = "secondary";
37
+ const save = element(doc, "button", "Save layout"); save.type = "submit"; save.className = "primary";
38
+ const actions = element(doc, "div"); actions.className = "opf-sm-actions"; actions.append(cancel, save);
39
+ const form = element(doc, "form"); form.append(heading, body, actions); dialog.append(form);
40
+ let cells = [["title", "title", "title"], ["content", "content", "content"], ["content", "content", "content"]];
41
+ const settings = new Map();
42
+ let down = null;
43
+ const opener = doc.activeElement;
44
+ const regionNames = () => [...new Set(cells.flat())].filter((value) => value !== "." && value !== "title" && value !== "subtitle");
45
+ function resize() {
46
+ const cols = Number(width.value), rows = Number(height.value);
47
+ if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || cols > 12 || rows < 1 || rows > 12) return;
48
+ cells = Array.from({ length: rows }, (_, y) => Array.from({ length: cols }, (_, x) => cells[y]?.[x] ?? "."));
49
+ render();
50
+ }
51
+ function draw(from, to) {
52
+ const value = paint.value.trim() || ".";
53
+ if (value !== "." && !/^[a-z][a-z0-9-]*$/.test(value)) { error.textContent = "Use lowercase area names with letters, digits and hyphens."; return; }
54
+ for (let y = Math.min(from.y, to.y); y <= Math.max(from.y, to.y); y++)
55
+ for (let x = Math.min(from.x, to.x); x <= Math.max(from.x, to.x); x++) cells[y][x] = value;
56
+ error.textContent = ""; render();
57
+ grid.querySelector(`[data-x="${to.x}"][data-y="${to.y}"]`)?.focus();
58
+ }
59
+ function render() {
60
+ grid.replaceChildren(); grid.style.gridTemplateColumns = `repeat(${cells[0].length}, minmax(38px, 1fr))`;
61
+ cells.forEach((row, y) => row.forEach((area, x) => {
62
+ const cell = element(doc, "button", area); cell.type = "button"; cell.className = "opf-grid-cell"; cell.dataset.area = area;
63
+ cell.setAttribute("aria-label", `Row ${y + 1}, column ${x + 1}: ${area === "." ? "empty" : area}`);
64
+ cell.onpointerdown = (event) => { if (event.button !== 0) return; down = { x, y }; cell.setPointerCapture?.(event.pointerId); };
65
+ cell.onpointerup = (event) => { if (!down) return; const target = doc.elementFromPoint?.(event.clientX, event.clientY)?.closest?.(".opf-grid-cell"); const last = target ? { x: Number(target.dataset.x), y: Number(target.dataset.y) } : { x, y }; draw(down, last); down = null; };
66
+ cell.dataset.x = String(x); cell.dataset.y = String(y);
67
+ cell.onkeydown = (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); draw({ x, y }, { x, y }); } };
68
+ grid.append(cell);
69
+ }));
70
+ regionsPanel.replaceChildren();
71
+ for (const area of regionNames()) {
72
+ const setting = settings.get(area) ?? { accepts: new Set(["text"]), role: "primary", flow: "auto" };
73
+ settings.set(area, setting);
74
+ const section = element(doc, "fieldset"); section.className = "opf-grid-region";
75
+ section.append(element(doc, "legend", area));
76
+ const kinds = element(doc, "div"); kinds.className = "opf-grid-kinds";
77
+ for (const kind of LAYOUT_REGION_KINDS) {
78
+ const check = element(doc, "input"); check.type = "checkbox"; check.value = kind; check.checked = setting.accepts.has(kind);
79
+ check.onchange = () => { if (check.checked) setting.accepts.add(kind); else setting.accepts.delete(kind); };
80
+ const label = element(doc, "label", kind); label.prepend(check); kinds.append(label);
81
+ }
82
+ const role = select(doc, LAYOUT_REGION_ROLES, setting.role); role.onchange = () => { setting.role = role.value; };
83
+ const flow = select(doc, LAYOUT_REGION_FLOWS, setting.flow); flow.onchange = () => { setting.flow = flow.value; };
84
+ section.append(element(doc, "span", "Accepts"), kinds, field(doc, "Role", role), field(doc, "Flow", flow)); regionsPanel.append(section);
85
+ }
86
+ }
87
+ width.onchange = resize; height.onchange = resize;
88
+ cancel.onclick = () => dialog.close();
89
+ form.onsubmit = (event) => {
90
+ event.preventDefault();
91
+ try {
92
+ const regions = Object.fromEntries(regionNames().map((area) => {
93
+ const setting = settings.get(area);
94
+ return [area, { accepts: [...setting.accepts], role: setting.role, flow: setting.flow }];
95
+ }));
96
+ const layout = layoutFromGrid({ id: id.value.trim(), name: name.value, summary: summary.value, cells, regions });
97
+ saveCustomLayout(editor, layout);
98
+ options.onSave?.(layout);
99
+ dialog.close();
100
+ } catch (cause) { error.textContent = cause?.issues?.[0]?.message ?? cause.message; }
101
+ };
102
+ dialog.onclose = () => { dialog.remove(); opener?.focus?.(); options.onClose?.(); };
103
+ doc.body.append(dialog); render(); dialog.showModal(); id.focus();
104
+ return { dialog, close: () => dialog.close() };
105
+ }
@@ -22,7 +22,7 @@ export interface DesignControlsOptions {
22
22
  scope?: "deck" | "slide";
23
23
  /** Host catalogs for the pickers, merged after the session's registered ones. */
24
24
  catalogs?: readonly Catalog[];
25
- /** Display metadata for the engine vocabularies (languages, chart types, social platforms): `catalogDisplay` from `@openpresentation/opf/catalog`. */
25
+ /** Display metadata for the engine vocabularies (languages, chart types, social platforms): `catalogDisplay` from `@openpresentation/gallery`. */
26
26
  vocabularies?: SwitchVocabularies;
27
27
  /** Called after every committed change (never for a refused one). */
28
28
  onChange?: (change: unknown) => void;
@@ -99,7 +99,7 @@ const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
99
99
  * slide and selection; call `refresh()` when they change), `sections` (default: all), `scope`
100
100
  * ("deck" or "slide", the initial "Applies to" choice), `catalogs` (host catalogs, `Catalog[]`, merged after the session's
101
101
  * registered ones), `vocabularies` (display metadata for languages, chart types and social platforms: `catalogDisplay`
102
- * from `@openpresentation/opf/catalog`), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
102
+ * from `@openpresentation/gallery`), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
103
103
  * after a content conversion or replacement with the path to keep selected (the block, or the inline
104
104
  * payload field), because the host's old selection path may no longer exist.
105
105
  */
@@ -471,7 +471,7 @@ export function createDesignControls(container, options = {}) {
471
471
  });
472
472
 
473
473
  const layout = selectField("layout", "Layout of this slide", {
474
- help: "Adds the blank placeholders the layout declares and keeps your content.",
474
+ help: "Arranges this slide's content in the chosen layout's regions.",
475
475
  onChange: (value) => run(() => switchDimension(editor, "layouts", value, { slideIndex: getSlide(), ...catalogOptions() }), `Layout changed to ${layout.select.selectedOptions[0]?.textContent ?? value}.`),
476
476
  });
477
477
  body.append(layout.wrap);
package/dist/galleries.js CHANGED
@@ -184,19 +184,10 @@ function attachDefinition(presentation, descriptor, gallerySource) {
184
184
  groups[group][kind] ??= {};
185
185
  let record;
186
186
  if (kind === "layouts") {
187
- // A gallery layout item is a layout record: its placeholders, design hints and composition are the
188
- // record's own. Only an item that declares no placeholders gets the default title and text.
189
- const placeholders = Array.isArray(source.placeholders)
190
- ? source.placeholders
191
- .filter((placeholder) => typeof placeholder?.type === "string")
192
- .map((placeholder) => structuredClone(placeholder))
193
- : [{ type: "title" }, { type: "text" }];
194
- record = {
195
- name: source.label ?? source.name ?? id,
196
- ...(isPlain(source.design) ? { design: structuredClone(source.design) } : {}),
197
- placeholders,
198
- ...(isPlain(source.composition) ? { composition: structuredClone(source.composition) } : {}),
199
- };
187
+ if (!Array.isArray(source.areas)) return;
188
+ record = {name: source.label ?? source.name ?? id};
189
+ for (const key of ['summary', 'description', 'areas', 'columns', 'rows', 'regions', 'overflowRegion', 'design', 'composition', 'tags', 'preview'])
190
+ if (source[key] !== undefined) record[key] = structuredClone(source[key]);
200
191
  } else if (kind === "fontSchemes") record = fontSchemeRecord(source, id);
201
192
  else record = embeddedRecord(source);
202
193
  groups[group][kind][id] = record;
package/dist/index.d.ts CHANGED
@@ -102,7 +102,7 @@ export interface EditorSession {
102
102
  export interface CreateEditorSessionOptions {
103
103
  /** Refuse every edit whose result has an error finding (the default per edit is `meta.rejectInvalid`). */
104
104
  rejectInvalid?: boolean;
105
- /** The host's catalogs (for example `[defaultCatalog]` from `@openpresentation/opf/catalog`): resolution, validation, pickers, embedding. */
105
+ /** The host's catalogs (for example `[gallery]` from `@openpresentation/gallery`): resolution, validation, pickers, embedding. */
106
106
  catalogs?: readonly Catalog[];
107
107
  }
108
108
 
package/dist/json-menu.js CHANGED
@@ -13,13 +13,8 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
13
13
  const heading=add('div',undefined,'json-field-heading');add('strong',context.label,undefined,heading);
14
14
  const closeButton=add('button','×',undefined,heading);closeButton.type='button';closeButton.setAttribute('aria-label','Close options');
15
15
  const search=add('input');search.type='search';search.placeholder='Search options…';search.autocomplete='off';search.setAttribute('role','combobox');search.setAttribute('aria-label',`Search ${context.label} options`);search.setAttribute('aria-expanded','true');
16
- const isLayout=context.catalog==='layouts',relatedCount=context.options.filter(option=>option.related).length;
17
- let similar=isLayout&&relatedCount>1;
18
- const filters=isLayout?add('div',undefined,'json-field-filters'):null;
19
- const similarButton=filters?add('button',`Similar (${relatedCount})`,undefined,filters):null;
20
- const allButton=filters?add('button',`All layouts (${context.options.length})`,undefined,filters):null;
21
- for(const button of [similarButton,allButton].filter(Boolean)){button.type='button';button.style.cssText='padding:6px;margin:4px 4px 4px 0';}
22
- if(isLayout){search.placeholder='Search all layouts or placeholders…';add('p','Same types and counts first. Text and subtitle slots are compatible.','json-field-hint');}
16
+ const isLayout=context.catalog==='layouts';
17
+ if(isLayout){search.placeholder='Search layouts or regions…';add('p','Layouts are grouped by intent. Changing a layout preserves your content.','json-field-hint');}
23
18
  const list=add('div',undefined,'json-field-options');list.setAttribute('role','listbox');list.setAttribute('aria-label',`Available ${context.label} options`);
24
19
  let id;do{id=`opf-json-options-${++nextMenuId}`;}while(doc.getElementById(id));
25
20
  list.id=id;search.setAttribute('aria-controls',id);list.style.cssText='max-height:280px;overflow:auto;margin-top:8px';
@@ -46,27 +41,22 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
46
41
  }
47
42
  function render(){
48
43
  const query=search.value.trim().toLowerCase();
49
- choices=context.options.filter(option=>query?`${option.label} ${option.value} ${option.description??''} ${option.placeholders??''}`.toLowerCase().includes(query):!similar||option.related);
50
- similarButton?.setAttribute('aria-pressed',String(similar&&!query));allButton?.setAttribute('aria-pressed',String(!similar||Boolean(query)));
51
- for(const button of [similarButton,allButton].filter(Boolean))button.style.outline=button.getAttribute('aria-pressed')==='true'?'2px solid Highlight':'';
44
+ choices=context.options.filter(option=>query?`${option.label} ${option.value} ${option.description??''} ${option.regions??''}`.toLowerCase().includes(query):true);
52
45
  list.replaceChildren();let group,container=list;
53
46
  for(const [index,option] of choices.entries()){
54
47
  if(isLayout&&option.layoutGroup!==group){group=option.layoutGroup;container=add('div',undefined,undefined,list);container.setAttribute('role','group');container.setAttribute('aria-label',group);const label=add('div',group,'json-field-group',container);label.setAttribute('aria-hidden','true');label.style.cssText='font-size:11px;font-weight:600;padding:8px 8px 4px;color:GrayText';}
55
48
  const button=add('button',undefined,undefined,container);button.dataset.optionIndex=String(index);button.type='button';button.tabIndex=-1;button.id=`${id}-${index}`;button.setAttribute('role','option');button.setAttribute('aria-selected',String(option.value===context.value));button.style.cssText='display:block;text-align:left;width:100%;padding:8px;border:0;background:transparent;color:inherit';
56
49
  const label=add('span',undefined,undefined,button);add('strong',option.label,undefined,label);if(option.value===context.value)add('span',' ✓',undefined,label);
57
- if(option.placeholders){const summary=add('div',option.placeholders,'json-field-placeholders',button);summary.style.cssText='margin:4px 0';}
50
+ if(option.regions){const summary=add('div',option.regions,'json-field-regions',button);summary.style.cssText='margin:4px 0';}
58
51
  const meta=add('div',undefined,'json-field-meta',button);meta.style.cssText='display:flex;gap:12px;justify-content:space-between;flex-wrap:wrap';
59
52
  add('code',String(option.value),undefined,meta);const origin=add('small',option.sourceLabel??option.source,undefined,meta);origin.title=option.sourceDescription??'';
60
53
  if(option.description)add('p',option.description,undefined,button);
61
54
  button.addEventListener('mouseenter',()=>{selected=index;highlight();});button.addEventListener('click',()=>choose(option));
62
55
  }
63
56
  if(!choices.length)add('p','No matching options. You can still type a custom value in the JSON.','json-field-empty',list);
64
- if(similar&&!query&&relatedCount===1)add('p','No other available layouts share these placeholders. Try All layouts.','json-field-empty',list);
65
57
  highlight();
66
58
  }
67
59
  search.addEventListener('input',()=>{selected=0;render();});
68
- similarButton?.addEventListener('click',()=>{similar=true;search.value='';selected=0;render();});
69
- allButton?.addEventListener('click',()=>{similar=false;search.value='';selected=0;render();});
70
60
  root.addEventListener('keydown',event=>{
71
61
  if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close(true);}
72
62
  else if(event.target===search&&['ArrowDown','ArrowUp'].includes(event.key)){event.preventDefault();selected=Math.max(0,Math.min(choices.length-1,selected+(event.key==='ArrowDown'?1:-1)));highlight();}
@@ -6,16 +6,12 @@ export interface JsonFieldOption {
6
6
  label: string;
7
7
  description?: string;
8
8
  source: 'Loaded catalog' | 'Document catalog' | 'Schema' | 'Current value';
9
- suggested?: boolean;
10
9
  /** User-facing provenance; source remains stable for existing consumers. */
11
10
  sourceLabel?: string;
12
11
  sourceDescription?: string;
13
- layoutGroup?: 'Current layout' | 'Same placeholders' | 'Compatible placeholders' | 'Different counts' | 'Other layouts' | 'Unspecified placeholders';
14
- /** Declared placeholder types and counts, not a rendering guarantee. */
15
- placeholders?: string;
16
- /** Declared kinds in layout order, used to create missing blank content. */
17
- placeholderTypes?: string[];
18
- related?: boolean;
12
+ layoutGroup?: 'Current layout' | 'Openers and closers' | 'Text' | 'Groups of items' | 'Numbers' | 'Visuals' | 'Mixed' | 'Custom layouts';
13
+ /** Region names, accepted kinds and flow from the v2 template. */
14
+ regions?: string;
19
15
  }
20
16
  export interface JsonFieldContext {
21
17
  source: string;
@@ -31,7 +27,7 @@ export interface JsonFieldContext {
31
27
  }
32
28
  /** Null for invalid JSON or a field with no categorical choices. */
33
29
  export declare function getJsonFieldContext(source: string, position: number, loadedCatalogs?: JsonCatalogContext): JsonFieldContext | null;
34
- /** Replace the token and add missing layout payloads. Hosts must reject stale sources. */
30
+ /** Replace only the selected reference token, preserving content. Hosts must reject stale sources. */
35
31
  export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
36
32
  /** Minimal single replacement for atomic source-editor undo. */
37
33
  export declare function fieldOptionEdit(context: JsonFieldContext, value: JsonOptionValue): {from:number;to:number;insert:string};
@@ -1,8 +1,8 @@
1
1
  import { checkFormat, firstErrorMessage } from "./checks.js";
2
2
  import { listCatalogRecords } from "./catalogs.js";
3
3
  import { schemaAtPath, schemaVariants } from "./schema.js";
4
- import { applyEdits, findNodeAtLocation, findNodeAtOffset, getNodePath, modify, parseTree } from "jsonc-parser";
5
- import { layoutSlotSummary, layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
4
+ import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
5
+ import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
6
6
  // The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
7
7
  const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
8
8
  const DESIGN_REFERENCES = { theme: "themes", colorScheme: "colorSchemes", fontScheme: "fontSchemes" };
@@ -19,59 +19,25 @@ function catalogAtPath(path) {
19
19
  if (tail.length === 5 && tail[0] === "catalogs" && tail[2] === "themes" && (key === "colorScheme" || key === "fontScheme")) return DESIGN_REFERENCES[key];
20
20
  return undefined;
21
21
  }
22
- // Compare declared placeholders, including multiplicity. This describes a layout
23
- // contract, not a claim that arbitrary slide content will render without overflow.
24
- // FA-26: a record's placeholder groups compare by their leaf regions, the slots its content fills.
25
- function layoutTypes(record) {
26
- return layoutSlotTypes(record)?.sort();
27
- }
28
- function placeholderSummary(types) {
29
- if (!types)
30
- return "Placeholders not specified";
31
- if (!types.length)
32
- return "No placeholders";
33
- const labels = { title: "Title", subtitle: "Subtitle", tag: "Tag", text: "Text", list: "List", image: "Image", video: "Video", chart: "Chart", table: "Table", code: "Code", metric: "Metric", quote: "Quote", timeline: "Timeline" };
34
- const order = ["title", "subtitle", "tag", "text", "list", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"];
35
- return [...new Set(types)].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map(type => {
36
- const count = types.filter(value => value === type).length;
37
- return `${labels[type] ?? type}${count > 1 ? ` × ${count}` : ""}`;
38
- }).join(" + ");
39
- }
40
22
  const sourceDetails = {
41
23
  "Loaded catalog": ["Provided by app", "Records from a catalog this app registered; nothing is fetched from a URL. Saving embeds the record in the document."],
42
24
  "Document catalog": ["In this document", "Embedded in this document’s catalogs. Takes precedence over the app’s records with the same reference."],
43
25
  "Current value": ["Custom value", "The current value is not in the available catalogs."],
44
26
  "Schema": ["OPF option", "Allowed by the OPF schema."],
45
27
  };
46
- const layoutGroups = ["Current layout", "Same placeholders", "Compatible placeholders", "Different counts", "Other layouts", "Unspecified placeholders"];
28
+ const layoutGroups = ["Current layout", ...LAYOUT_INTENT_GROUPS.map(group => group.name), "Custom layouts"];
47
29
  function describeOptions(choices, records, current, catalog) {
48
- const currentRecord = records.get(String(current));
49
- const currentTypes = layoutTypes(currentRecord);
50
- const signature = (types) => JSON.stringify(types);
51
- const compatible = (types) => types.map(type => type === "subtitle" ? "text" : type).sort();
52
30
  for (const option of choices) {
53
31
  const [label, description] = sourceDetails[option.source];
54
32
  option.sourceLabel = label;
55
33
  option.sourceDescription = description;
56
- if (catalog !== "layouts")
57
- continue;
58
- const types = layoutTypes(option.value === current ? currentRecord : records.get(String(option.value)));
59
- const record = records.get(String(option.value));
60
- option.placeholderTypes = layoutSlotTypes(record);
61
- // A record with placeholder groups also shows how its slots nest.
62
- option.placeholders = placeholderSummary(types) + (record?.placeholders?.some?.(entry => entry?.type === "group") ? ` · ${layoutSlotSummary(record)}` : "");
63
- const same = types && currentTypes && signature(types) === signature(currentTypes);
64
- const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
65
- const sameKinds = types && currentTypes && signature([...new Set(compatible(types))]) === signature([...new Set(compatible(currentTypes))]);
66
- option.layoutGroup = option.value === current ? "Current layout" : !types ? "Unspecified placeholders"
67
- : same ? "Same placeholders" : equivalent ? "Compatible placeholders" : sameKinds ? "Different counts" : "Other layouts";
68
- option.related = option.value === current || Boolean(same || equivalent);
69
- option.suggested = Boolean(same || equivalent);
34
+ if (catalog !== "layouts") continue;
35
+ const id = String(option.value).replace(/^default:/, "");
36
+ option.regions = layoutRegionSummary(records.get(String(option.value)));
37
+ option.layoutGroup = option.value === current ? "Current layout" : LAYOUT_INTENT_GROUPS.find(group => group.ids.includes(id))?.name ?? "Custom layouts";
70
38
  }
71
- const priority = (option) => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
72
- return choices.sort((a, b) => (catalog === "layouts"
73
- ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup)
74
- : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", { numeric: true }) || String(a.value).localeCompare(String(b.value), "en"));
39
+ const priority = option => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
40
+ return choices.sort((a, b) => (catalog === "layouts" ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup) : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", {numeric: true}) || String(a.value).localeCompare(String(b.value), "en"));
75
41
  }
76
42
  // The installed schema identifies fields; a document field merely named "layout"
77
43
  // inside arbitrary metadata is not mistaken for an OPF layout reference.
@@ -142,39 +108,13 @@ export function replaceFieldOption(context, value) {
142
108
  const presentation = JSON.parse(next);
143
109
  if (context.path.reduce((current, key) => current?.[key], presentation) !== value)
144
110
  throw new Error("This field has a duplicate key. Resolve it in the JSON before choosing an option.");
145
- const types = context.options.find(option => option.value === value).placeholderTypes;
146
- if (value !== context.value && types && context.catalog === 'layouts' && context.path.length === 3
147
- && context.path[0] === 'slides' && context.path[2] === 'layout' && checkFormat(presentation).valid) {
148
- const populated = populateLayoutPlaceholders(presentation, context.path[1], types);
149
- next = updateSource(next, presentation, populated);
150
- }
151
111
  const result = checkFormat(JSON.parse(next));
152
112
  if (!result.valid && checkFormat(JSON.parse(context.source)).valid)
153
113
  throw new Error(firstErrorMessage(result, "This option is not valid here."));
154
114
  return next;
155
115
  }
156
116
 
157
- // Keep existing scalar spellings and unrelated source bytes when adding slots.
158
- function updateSource(source, before, after) {
159
- const indent = source.match(/^[\t ]+(?=\S)/m)?.[0] ?? ' ';
160
- const formattingOptions = {insertSpaces: !indent.includes('\t'), tabSize: indent.length, eol: source.includes('\r\n') ? '\r\n' : '\n'};
161
- function visit(a, b, path) {
162
- if (JSON.stringify(a) === JSON.stringify(b)) return;
163
- if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
164
- for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) visit(a[key], b[key], [...path, key]);
165
- } else if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
166
- b.forEach((value, index) => visit(a[index], value, [...path, index]));
167
- } else {
168
- const node = findNodeAtLocation(parseTree(source), path);
169
- if (node && b !== undefined) source = source.slice(0, node.offset) + JSON.stringify(b) + source.slice(node.offset + node.length);
170
- else source = applyEdits(source, modify(source, path, b, {formattingOptions}));
171
- }
172
- }
173
- visit(before, after, []);
174
- return source;
175
- }
176
-
177
- /** One source edit so layout changes and new slots share one undo step. */
117
+ /** One source edit for an undoable reference change. */
178
118
  export function fieldOptionEdit(context, value) {
179
119
  const next = replaceFieldOption(context, value), source = context.source;
180
120
  let from = 0, to = source.length, end = next.length;
@@ -1,5 +1,7 @@
1
1
  import {prepareTrackResize} from './layout.js';
2
2
  import {getValueAtPath} from './index.js';
3
+ import {pinBlockToRegion} from './layout-templates.js';
4
+ import {regionAccepts} from '@openpresentation/opf/composition';
3
5
 
4
6
  export function createLayoutHandles(root, options) {
5
7
  const doc=root.ownerDocument,win=doc.defaultView,editor=options.editor;
@@ -60,17 +62,48 @@ export function createLayoutHandles(root, options) {
60
62
  lastGeometry=geometry;
61
63
  layer.replaceChildren();
62
64
  if(!enabled||options.isTextEditing())return;
63
- // FA-26: the slots of a layout record with placeholder groups, drawn under the handles while arranging: each group and
64
- // region as a dashed outline at its cell, deeper groups inset, an empty region labelled with its kind. They are the
65
- // record's, not the document's, so they are not editable here; the content in a slot keeps its own selection.
66
- for(const slot of geometry.slots??[]){
65
+ // 0.19 template regions are drop targets even when core collapsed their empty cells.
66
+ // Source blocks remain flat; dropping writes only their region pin.
67
+ for(const region of geometry.regions??[]){
67
68
  const node=doc.createElement('div');
68
- node.dataset.layoutSlot=slot.path;node.dataset.layoutSlotType=slot.type;node.setAttribute('aria-hidden','true');
69
- const group=slot.type==='group',empty=!group&&!slot.content;
70
- node.title=group?`Layout group (${slot.path})`:`${empty?'Empty ':''}${slot.type} slot (${slot.path})`;
71
- const inset=group?Math.min(4,slot.depth*2):0;
72
- node.style.cssText=`position:absolute;pointer-events:none;box-sizing:border-box;left:calc(${slot.box.x/geometry.width*100}% + ${inset}px);top:calc(${slot.box.y/geometry.height*100}% + ${inset}px);width:calc(${slot.box.width/geometry.width*100}% - ${2*inset}px);height:calc(${slot.box.height/geometry.height*100}% - ${2*inset}px);border:1px dashed ${group?'#8170d5':'#8a78d7aa'};border-radius:4px;${empty?'background:#8a78d714;':''}`;
73
- if(empty){const label=doc.createElement('span');label.textContent=`Empty ${slot.type} slot`;label.style.cssText='position:absolute;left:6px;top:4px;font:11px system-ui;color:#675394';node.append(label);}
69
+ node.className='opf-region-drop';node.dataset.opfRegion=region.name;
70
+ node.tabIndex=0;node.setAttribute('role','button');
71
+ node.title=`${region.name}: ${region.accepts.join(', ')}`;
72
+ node.setAttribute('aria-label',`${region.name} region. Accepts ${region.accepts.join(', ')}. Drop content here.`);
73
+ const box=region.areaBox??region.box;
74
+ node.style.cssText=`position:absolute;pointer-events:auto;box-sizing:border-box;left:${box.x/geometry.width*100}%;top:${box.y/geometry.height*100}%;width:${box.width/geometry.width*100}%;height:${Math.max(box.height/geometry.height*100,4)}%;border:1px dashed #8170d5;border-radius:4px;background:#8170d510;z-index:1;`;
75
+ const label=doc.createElement('span');label.textContent=region.content.length?region.name:'Drop content here';
76
+ label.style.cssText='position:absolute;left:5px;top:3px;padding:2px 4px;background:#fff;color:#675394;font:11px system-ui;border-radius:3px;pointer-events:none';node.append(label);
77
+ node.addEventListener('dragover',event=>{
78
+ if(![...(event.dataTransfer?.types??[])].includes('text/plain'))return;
79
+ event.preventDefault();event.stopPropagation();event.dataTransfer.dropEffect='move';node.dataset.dropActive='true';
80
+ });
81
+ node.addEventListener('dragleave',()=>{delete node.dataset.dropActive;});
82
+ const place=(blockIndex)=>{
83
+ if(!options.beforeEdit())return;
84
+ try{
85
+ const block=editor.presentation.slides[options.slideIndex()]?.blocks?.[blockIndex];
86
+ if(!regionAccepts(region,block))throw new Error(`${region.name} does not accept this block.`);
87
+ pinBlockToRegion(editor,options.slideIndex(),blockIndex,region.name);
88
+ options.onCommit?.({path:`slides.${options.slideIndex()}.blocks.${blockIndex}`,editor});
89
+ }catch(error){options.onError(error);}
90
+ };
91
+ node.addEventListener('drop',event=>{
92
+ event.preventDefault();event.stopPropagation();delete node.dataset.dropActive;
93
+ const path=event.dataTransfer?.getData('text/plain')??'';
94
+ const match=/^\/slides\/(\d+)\/blocks\/(\d+)$/.exec(path);
95
+ if(match&&Number(match[1])===options.slideIndex())place(Number(match[2]));
96
+ });
97
+ const menu=()=>{
98
+ const old=node.querySelector('[data-region-menu]');if(old){old.remove();return;}
99
+ const panel=doc.createElement('div');panel.dataset.regionMenu='';
100
+ panel.style.cssText='position:absolute;left:4px;top:24px;min-width:130px;max-height:160px;overflow:auto;padding:4px;background:#fff;border:1px solid #8170d5;border-radius:4px;box-shadow:0 4px 12px #0002;z-index:9';
101
+ const blocks=editor.presentation.slides[options.slideIndex()]?.blocks??[];
102
+ blocks.forEach((block,index)=>{if(!regionAccepts(region,block))return;const choice=doc.createElement('button');choice.type='button';choice.textContent=`Block ${index+1}`;choice.style.cssText='display:block;width:100%;padding:4px;text-align:left;border:0;background:#fff;color:#473b66';choice.onclick=event=>{event.stopPropagation();place(index);};panel.append(choice);});
103
+ if(!panel.children.length)panel.textContent='No compatible blocks';node.append(panel);panel.querySelector('button')?.focus();
104
+ };
105
+ node.addEventListener('click',event=>{if(event.target===node||event.target===label)menu();});
106
+ node.addEventListener('keydown',event=>{if(event.key==='Escape'){node.querySelector('[data-region-menu]')?.remove();event.preventDefault();node.focus();}else if(event.target===node&&(event.key==='Enter'||event.key===' ')){event.preventDefault();menu();}});
74
107
  layer.append(node);
75
108
  }
76
109
  for(const flow of geometry.flows??[]){
@@ -0,0 +1,24 @@
1
+ import type { EditorSession, EditorChange, JsonPatchOperation } from "./index.js";
2
+
3
+ export interface LayoutChoice { id: string; label: string; record?: Record<string, unknown> }
4
+ export interface LayoutIntentGroup { name: string; ids: readonly string[] }
5
+ export declare const LAYOUT_INTENT_GROUPS: readonly LayoutIntentGroup[];
6
+ export declare const LAYOUT_REGION_KINDS: readonly string[];
7
+ export declare const LAYOUT_REGION_ROLES: readonly string[];
8
+ export declare const LAYOUT_REGION_FLOWS: readonly string[];
9
+ export declare function groupLayoutChoices<T extends LayoutChoice>(choices: T[]): { name: string; choices: T[] }[];
10
+ export interface GridLayoutInput {
11
+ id: string;
12
+ name: string;
13
+ summary?: string;
14
+ cells: string[][];
15
+ regions: Record<string, { accepts: string[]; role?: string; flow?: string }>;
16
+ columns?: number[];
17
+ rows?: (number | "auto")[];
18
+ }
19
+ export declare function layoutFromGrid(input: GridLayoutInput): { id: string; record: Record<string, unknown> };
20
+ export declare function prepareCustomLayout(presentation: any, layout: { id: string; record: Record<string, unknown> }): { presentation: any; patches: JsonPatchOperation[]; id: string };
21
+ export declare function saveCustomLayout(editor: EditorSession, layout: { id: string; record: Record<string, unknown> }): EditorChange;
22
+ export declare function pinBlockToRegion(editor: EditorSession, slideIndex: number, blockIndex: number, region: string): EditorChange;
23
+
24
+ export declare function layoutRegionSummary(record: unknown): string;
@@ -0,0 +1,106 @@
1
+ // OPF 0.19 layout authoring. The catalog owns records; this module owns only editor operations.
2
+ import { resolveSlideContext } from "@openpresentation/opf";
3
+ import { bindRegions, layoutStructure, layoutTemplate } from "@openpresentation/opf/composition";
4
+ import { applyJsonPatch, createValuePatch, opfPathToJsonPointer } from "./index.js";
5
+ import { checkedDocument, fail } from "./edit-helpers.js";
6
+ import { checkFormat } from "./checks.js";
7
+
8
+ export const LAYOUT_INTENT_GROUPS = Object.freeze([
9
+ { name: "Openers and closers", ids: ["cover", "section", "agenda", "statement", "closing"] },
10
+ { name: "Text", ids: ["text", "list", "quote", "two-column"] },
11
+ { name: "Groups of items", ids: ["pillars", "comparison", "process", "timeline", "faq"] },
12
+ { name: "Numbers", ids: ["metrics", "scorecard"] },
13
+ { name: "Visuals", ids: ["image", "image-beside", "gallery", "chart", "chart-beside", "table", "table-beside", "code"] },
14
+ { name: "Mixed", ids: ["hero", "dashboard", "team", "logos"] },
15
+ ]);
16
+
17
+ export const LAYOUT_REGION_KINDS = Object.freeze(["text", "list", "image", "video", "chart", "table", "code", "metric", "quote", "timeline", "group"]);
18
+ export const LAYOUT_REGION_ROLES = Object.freeze(["primary", "secondary", "media", "supporting"]);
19
+ export const LAYOUT_REGION_FLOWS = Object.freeze(["none", "grid", "column", "auto"]);
20
+ const RESERVED = new Set(["title", "subtitle", "tag", "auto", "left", "center", "right", "top", "middle", "bottom"]);
21
+ const NAME = /^[a-z][a-z0-9-]*$/;
22
+
23
+ /** Group registered layouts by the 28 built-in intents, then any deck or host layouts. */
24
+ export function groupLayoutChoices(choices) {
25
+ const byId = new Map(choices.map((choice) => [choice.id, choice]));
26
+ const used = new Set();
27
+ const groups = LAYOUT_INTENT_GROUPS.map(({ name, ids }) => ({
28
+ name, choices: ids.map((id) => byId.get(id)).filter((choice) => { if (!choice || used.has(choice.id)) return false; used.add(choice.id); return true; }),
29
+ })).filter((group) => group.choices.length);
30
+ const custom = choices.filter((choice) => !used.has(choice.id));
31
+ if (custom.length) groups.push({ name: "Custom layouts", choices: custom });
32
+ return groups;
33
+ }
34
+
35
+ /** Build the exact v2 record from a rectangular painted grid. `cells` is a row-major array of names or `.`. */
36
+ export function layoutFromGrid({ id, name, summary, cells, regions, columns, rows }) {
37
+ if (!NAME.test(id) || id === "auto") throw fail("layout-id", "Choose a kebab-case layout id other than auto.");
38
+ if (typeof name !== "string" || !name.trim()) throw fail("layout-name", "Name the custom layout.");
39
+ if (!Array.isArray(cells) || cells.length < 1 || cells.length > 12 || !cells.every((row) => Array.isArray(row) && row.length === cells[0].length && row.length >= 1 && row.length <= 12))
40
+ throw fail("layout-grid", "Draw a rectangular grid of at most 12 by 12 cells.");
41
+ const areas = cells.map((row) => row.join(" "));
42
+ const names = new Set(cells.flat().filter((cell) => cell !== "."));
43
+ for (const area of names) if (!NAME.test(area) || (RESERVED.has(area) && area !== "title" && area !== "subtitle")) throw fail("layout-area", `Invalid area name: ${area}.`);
44
+ if (names.has("subtitle") && !names.has("title")) throw fail("layout-area", "A subtitle area needs a title area.");
45
+ const body = [...names].filter((area) => area !== "title" && area !== "subtitle");
46
+ for (const area of body) {
47
+ const points = cells.flatMap((row, y) => row.map((cell, x) => cell === area ? [x, y] : null).filter(Boolean));
48
+ const xs = points.map(([x]) => x), ys = points.map(([, y]) => y);
49
+ if (points.length !== (Math.max(...xs) - Math.min(...xs) + 1) * (Math.max(...ys) - Math.min(...ys) + 1))
50
+ throw fail("layout-area", `Area ${area} must fill one rectangle.`);
51
+ }
52
+ if (body.length < 1) throw fail("layout-region", "Draw at least one content region.");
53
+ const selected = {};
54
+ for (const area of body) {
55
+ const input = regions?.[area];
56
+ if (!input || !Array.isArray(input.accepts) || !input.accepts.length || input.accepts.some((kind) => !LAYOUT_REGION_KINDS.includes(kind)))
57
+ throw fail("layout-region", `Choose accepted content kinds for ${area}.`);
58
+ if (input.role && !LAYOUT_REGION_ROLES.includes(input.role)) throw fail("layout-region", `Choose a valid role for ${area}.`);
59
+ if (input.flow && !LAYOUT_REGION_FLOWS.includes(input.flow)) throw fail("layout-region", `Choose a valid flow for ${area}.`);
60
+ selected[area] = { accepts: [...new Set(input.accepts)], ...(input.role ? { role: input.role } : {}), ...(input.flow ? { flow: input.flow } : {}) };
61
+ }
62
+ const record = { name: name.trim(), areas, regions: selected };
63
+ if (summary?.trim()) record.summary = summary.trim();
64
+ if (columns) record.columns = columns;
65
+ if (rows) record.rows = rows;
66
+ return { id, record };
67
+ }
68
+
69
+ /** Prepare one validated catalog edit. Existing custom ids are never silently overwritten. */
70
+ export function prepareCustomLayout(presentation, layout) {
71
+ const { id, record } = layout;
72
+ if (!NAME.test(id) || id === "auto") throw fail("layout-id", "Choose a kebab-case layout id other than auto.");
73
+ if (presentation.catalogs?.custom?.layouts?.[id]) throw fail("layout-exists", `A custom layout named ${id} already exists.`);
74
+ const custom = presentation.catalogs?.custom ?? {};
75
+ const next = { ...presentation, catalogs: { ...presentation.catalogs, custom: { ...custom, layouts: { ...custom.layouts, [id]: record } } } };
76
+ const patches = [{ op: "replace", path: "", value: next }];
77
+ checkedDocument(presentation, patches, checkFormat(presentation));
78
+ return { presentation: next, patches, id };
79
+ }
80
+
81
+ export function saveCustomLayout(editor, layout) {
82
+ const prepared = prepareCustomLayout(editor.presentation, layout);
83
+ return editor.applyPatch(prepared.patches, { source: "layout-grid", rejectInvalid: true });
84
+ }
85
+
86
+ /** Pin a top-level block to a template region; the session validates the complete document. */
87
+ export function pinBlockToRegion(editor, slideIndex, blockIndex, region) {
88
+ const slide = editor.presentation.slides?.[slideIndex];
89
+ if (!slide || !Array.isArray(slide.blocks) || !slide.blocks[blockIndex]) throw fail("layout-block", "Choose a top-level content block.");
90
+ if (typeof region !== "string" || !region) throw fail("layout-region", "Choose a region.");
91
+ const path = ["slides", String(slideIndex), "blocks", String(blockIndex), "region"];
92
+ const patches = createValuePatch(editor.presentation, opfPathToJsonPointer(path), region);
93
+ const candidate = applyJsonPatch(editor.presentation, patches);
94
+ const context = resolveSlideContext(candidate, slideIndex, { catalogs: editor.catalogs });
95
+ const binding = bindRegions(candidate.slides[slideIndex], context.options.layout, { slideIndex });
96
+ const expected = `slides.${slideIndex}.blocks.${blockIndex}`;
97
+ if (!binding.regions.some((entry) => entry.name === region && entry.blocks.some((block) => block.path === expected && block.pinned)))
98
+ throw fail("layout-region", `The ${region} region cannot accept this block or is full.`);
99
+ return editor.applyPatch(patches, { source: "layout-region", rejectInvalid: true });
100
+ }
101
+
102
+ /** Region structure of a supported v2 template. */
103
+ export function layoutRegionSummary(record) {
104
+ if (!Array.isArray(record?.areas)) return "";
105
+ try { layoutTemplate(record); return layoutStructure(record); } catch { return "Invalid region template"; }
106
+ }
@@ -13,6 +13,8 @@ export interface SlideManagerOptions {
13
13
  setSlideIndex: (index: number) => void;
14
14
  /** The thumbnail of one slide as an HTML string (usually the renderer's SVG). Cache it: it is called for every card on every render. */
15
15
  renderThumbnail?: (presentation: unknown, index: number) => string;
16
+ /** Traced SVG preview of the selected layout using the current slide's content. */
17
+ renderLayoutPreview?: (presentation: unknown, slideIndex: number, choice: { id: string; label: string; record?: Record<string, any> }) => string;
16
18
  /** `"navigator"` (a vertical list, the default) or `"sorter"` (a grid with arrow keys in both directions). */
17
19
  variant?: "navigator" | "sorter";
18
20
  /** An element to fill with the Duplicate, Delete, Hide and More buttons. */
@@ -34,7 +36,7 @@ export interface SlideManager {
34
36
  getSelection(): number[];
35
37
  setSelection(indices: number[]): void;
36
38
  focus(index: number): void;
37
- openLayoutPicker(): void;
39
+ openLayoutPicker(mode?: "add" | "apply"): void;
38
40
  /** Collapse or expand a section (an index from `listSections`): view state only, nothing is written to the document. Returns false for an unknown section. */
39
41
  setSectionCollapsed(sectionIndex: number, collapsed?: boolean): boolean;
40
42
  /** Run an action on the selection: duplicate, delete, hide, move-up, move-down, move-start, move-end, split, merge, select-all. */
@@ -7,7 +7,9 @@ import {
7
7
  addSection, addSlide, duplicateSlides, hasSections, listSections, moveSection, moveSlides, moveSlidesBy, removeSection, removeSlides, renameSection, setHidden, setSection, slideTitle,
8
8
  } from "./slides.js";
9
9
  import { listSwitchOptions } from "./switches.js";
10
- import { layoutSlotSummary } from "./layout-placeholders.js";
10
+ import { layoutRegionSummary } from "./layout-templates.js";
11
+ import { groupLayoutChoices } from "./layout-templates.js";
12
+ import { switchDimension } from "./switches.js";
11
13
 
12
14
  let instances = 0;
13
15
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -490,7 +492,7 @@ export function createSlideManager(container, options) {
490
492
  function layoutChoices() {
491
493
  return options.layoutOptions ? options.layoutOptions(deck()) : listSwitchOptions(deck(), "layouts", { catalogs: editor.catalogs });
492
494
  }
493
- function openLayoutPicker() {
495
+ function openLayoutPicker(mode = "add") {
494
496
  const choices = layoutChoices();
495
497
  if (!choices.length) { announce("No layouts are available"); return; }
496
498
  const opener = doc.activeElement;
@@ -498,28 +500,48 @@ export function createSlideManager(container, options) {
498
500
  const filter = el("input", { type: "search", id: `${id}-layout-filter`, placeholder: "Filter layouts", autocomplete: "off" });
499
501
  const select = el("select", { id: `${id}-layout-list`, size: "9", "aria-label": "Layouts" });
500
502
  const detail = el("p", { class: "field-help", id: `${id}-layout-detail`, "aria-live": "polite" });
503
+ const preview = el("div", { class: "opf-layout-preview" });
504
+ const template = el("div", { class: "opf-layout-template", role: "img", "aria-label": "Layout template" });
505
+ const currentPreview = el("div", { class: "opf-layout-current", role: "img", "aria-label": "Layout with the current slide content" });
506
+ preview.append(el("div", { class: "opf-layout-preview-item" }, el("strong", { text: "Template" }), template), el("div", { class: "opf-layout-preview-item" }, el("strong", { text: "Your slide" }), currentPreview));
501
507
  const where = el("select", { id: `${id}-layout-where` },
502
508
  el("option", { value: "after", text: "After the current slide" }), el("option", { value: "end", text: "At the end" }));
503
509
  const fill = () => {
504
510
  const needle = filter.value.trim().toLowerCase();
505
- const shown = choices.filter((choice) => !needle || `${choice.label} ${choice.id}`.toLowerCase().includes(needle));
506
- select.replaceChildren(...shown.map((choice) => el("option", { value: choice.id, text: choice.label })));
511
+ const shown = choices.filter((choice) => !needle || `${choice.label} ${choice.id} ${choice.record?.summary ?? ""}`.toLowerCase().includes(needle));
512
+ select.replaceChildren(...groupLayoutChoices(shown).map((group) => el("optgroup", { label: group.name }, ...group.choices.map((choice) => el("option", { value: choice.id, text: choice.label })))));
507
513
  if (shown.length) select.selectedIndex = 0;
508
514
  describe();
509
515
  };
510
516
  const describe = () => {
511
517
  const choice = choices.find((entry) => entry.id === select.value);
512
- // FA-26: nested placeholder groups show with their mode, for example `title, column (text, text), chart`.
513
- const slots = layoutSlotSummary(choice?.record);
514
- detail.textContent = choice ? `${choice.record?.description ?? ""}${slots ? ` Slots: ${slots}.` : choice.record?.placeholders ? " An empty slide." : ""}`.trim() : "No layout matches.";
518
+ // Supported templates describe their region names, accepted kinds and flow.
519
+ const slots = layoutRegionSummary(choice?.record);
520
+ detail.textContent = choice ? `${choice.record?.summary ?? choice.record?.description ?? ""}${slots ? ` Regions: ${slots}.` : ""}`.trim() : "No layout matches.";
521
+ template.replaceChildren(); currentPreview.replaceChildren();
522
+ if (choice?.record?.preview && typeof choice.record.preview === "string") {
523
+ template.append(el("img", { src: choice.record.preview, alt: `${choice.label} template preview` }));
524
+ } else if (Array.isArray(choice?.record?.areas)) {
525
+ const diagram = el("div", { class: "opf-layout-diagram" });
526
+ diagram.style.gridTemplateAreas = choice.record.areas.map((row) => `"${row}"`).join(" ");
527
+ diagram.style.gridTemplateColumns = `repeat(${choice.record.areas[0]?.trim().split(/\s+/).length ?? 1}, 1fr)`;
528
+ const areas = [...new Set(choice.record.areas.flatMap((row) => row.trim().split(/\s+/)))].filter((area) => area !== ".");
529
+ for (const area of areas) { const part = el("span", { text: area }); part.style.gridArea = area; diagram.append(part); }
530
+ template.append(diagram);
531
+ }
532
+ if (choice && options.renderLayoutPreview) {
533
+ try { currentPreview.innerHTML = options.renderLayoutPreview(deck(), current(), choice) ?? ""; }
534
+ catch { currentPreview.textContent = "Preview unavailable for this content"; }
535
+ currentPreview.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
536
+ }
515
537
  };
516
538
  const form = el("form", { method: "dialog" },
517
- el("h2", { id: `${id}-layout-title`, text: "Add slide with layout" }),
539
+ el("h2", { id: `${id}-layout-title`, text: mode === "apply" ? "Choose a layout for this slide" : "Add slide with layout" }),
518
540
  el("label", { for: filter.id, text: "Layout" }), filter,
519
- select, detail,
520
- el("label", { for: where.id, text: "Where" }), where,
541
+ select, detail, preview,
542
+ mode === "apply" ? null : el("label", { for: where.id, text: "Where" }), mode === "apply" ? null : where,
521
543
  el("p", { class: "opf-sm-error", role: "alert" }),
522
- el("div", { class: "opf-sm-actions" }, el("button", { type: "button", class: "secondary", "data-cancel": "", text: "Cancel" }), el("button", { type: "submit", class: "primary", text: "Add slide" })));
544
+ el("div", { class: "opf-sm-actions" }, el("button", { type: "button", class: "secondary", "data-cancel": "", text: "Cancel" }), el("button", { type: "submit", class: "primary", text: mode === "apply" ? "Use layout" : "Add slide" })));
523
545
  dialog.append(form);
524
546
  doc.body.append(dialog);
525
547
  filter.addEventListener("input", fill);
@@ -531,9 +553,12 @@ export function createSlideManager(container, options) {
531
553
  if (!select.value) { form.querySelector(".opf-sm-error").textContent = "Choose a layout."; return; }
532
554
  try {
533
555
  const at = where.value === "end" ? count() : current() + 1;
534
- const change = addSlide(editor, { layout: select.value, at });
556
+ const change = mode === "apply"
557
+ ? switchDimension(editor, "layouts", select.value, { slideIndex: current() })
558
+ : addSlide(editor, { layout: select.value, at });
535
559
  dialog.close();
536
- show(change, `Added a slide with the ${choices.find((entry) => entry.id === select.value)?.label ?? select.value} layout at position ${change.index + 1}`);
560
+ if (mode === "apply") announce(`Layout changed to ${choices.find((entry) => entry.id === select.value)?.label ?? select.value}`);
561
+ else show(change, `Added a slide with the ${choices.find((entry) => entry.id === select.value)?.label ?? select.value} layout at position ${change.index + 1}`);
537
562
  } catch (error) { form.querySelector(".opf-sm-error").textContent = error.message; }
538
563
  });
539
564
  form.querySelector("[data-cancel]").addEventListener("click", () => dialog.close());
package/dist/slides.d.ts CHANGED
@@ -48,7 +48,7 @@ export type MoveSectionMode = "adopt" | "keep" | string | null;
48
48
  export interface AddSlideOptions {
49
49
  /** The index the slide takes (default: the end). */
50
50
  at?: number;
51
- /** A layout reference (`id` or `name:id`); the layout's placeholders are added as empty slots. */
51
+ /** A layout reference (`id` or `name:id`); content is preserved and empty regions remain available for insertion. */
52
52
  layout?: string;
53
53
  title?: string;
54
54
  text?: string;
package/dist/slides.js CHANGED
@@ -132,7 +132,7 @@ function newSlideId(presentation) {
132
132
 
133
133
  /**
134
134
  * Compute adding a slide. Options: `at` (the index the slide takes, default the end), `layout` (a layout
135
- * reference, `id` or `name:id`; the layout's placeholders are added as empty slots, exactly like switching a slide's layout),
135
+ * reference, `id` or `name:id`; existing content is preserved; empty regions remain available for explicit insertion),
136
136
  * `title`, `text`, `id`, `section` (default: the section of the slide before it), `slide` (a ready slide to
137
137
  * insert, for hosts that build their own), `catalogs`/`record`/`recordSource` for catalog lookup (as `prepareDimensionSwitch`).
138
138
  */
@@ -2,7 +2,7 @@ import type { ConvertOptions } from "@openpresentation/opf/convert";
2
2
  import type { Catalog } from "@openpresentation/opf";
3
3
  import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
4
4
 
5
- /** Display metadata for the engine vocabularies, in the shape of `catalogDisplay` (`@openpresentation/opf/catalog`): records by id, or a list of records with `id` (`bcp47` for a language). */
5
+ /** Display metadata for the engine vocabularies, in the shape of `catalogDisplay` (`@openpresentation/gallery`): records by id, or a list of records with `id` (`bcp47` for a language). */
6
6
  export interface SwitchVocabularies {
7
7
  languages?: unknown;
8
8
  chartTypes?: unknown;
package/dist/switches.js CHANGED
@@ -7,13 +7,12 @@
7
7
  // tones) resolve through core with the host's registered catalogs (`options.catalogs`, `Catalog[]`) and write the reference core
8
8
  // gives (`id` or `name:id`). Languages, chart types and social platforms are engine vocabularies, checked against core's
9
9
  // tables; their labels come from the host's display metadata (`options.vocabularies`, the shape of `catalogDisplay` from
10
- // `@openpresentation/opf/catalog`), never from data the editor ships.
10
+ // `@openpresentation/gallery`), never from data the editor ships.
11
11
  import { CHART_TYPES, LANGUAGES, SOCIAL_PLATFORMS, isXYChartType, parseReference, resolveChartData, resolveReference } from "@openpresentation/opf";
12
12
  import { chartOptionTarget } from "@openpresentation/opf/composition";
13
13
  import { createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
14
14
  import { createContentBlock, prepareBlockReplace } from "./blocks.js";
15
15
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
16
- import { layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
17
16
  import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
18
17
  import { checkFormat } from "./checks.js";
19
18
  import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
@@ -267,19 +266,11 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
267
266
  slideAt(presentation, slideIndex, "a layout switch");
268
267
  scope = "slide";
269
268
  const choice = catalogChoice(presentation, dimension, value, options);
270
- const record = choice.record;
271
269
  patches = choice.patches;
272
270
  const withRecord = patches.length ? applyJsonPatch(presentation, patches) : presentation;
273
271
  if (withRecord.slides[slideIndex].layout !== choice.reference) {
274
272
  const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], choice.reference);
275
- const swapped = applyJsonPatch(withRecord, layoutOps);
276
- // FA-26: a record's placeholder groups contribute their leaf regions, in reading order.
277
- const types = layoutSlotTypes(record);
278
- // Add the blank payloads the layout declares, as the JSON editor's layout choice does.
279
- const populated = types ? populateLayoutPlaceholders(swapped, slideIndex, types) : swapped;
280
- patches = same(populated.slides[slideIndex], swapped.slides[slideIndex])
281
- ? [...patches, ...layoutOps]
282
- : [...patches, { op: "replace", path: `/slides/${slideIndex}`, value: populated.slides[slideIndex] }];
273
+ patches = [...patches, ...layoutOps];
283
274
  }
284
275
  } else if (dimension === "charts") {
285
276
  slideIndex = scopeIndex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -174,6 +174,14 @@
174
174
  "types": "./dist/slide-manager.d.ts",
175
175
  "default": "./dist/slide-manager.js"
176
176
  },
177
+ "./layout-templates": {
178
+ "types": "./dist/layout-templates.d.ts",
179
+ "default": "./dist/layout-templates.js"
180
+ },
181
+ "./custom-layout-editor": {
182
+ "types": "./dist/custom-layout-editor.d.ts",
183
+ "default": "./dist/custom-layout-editor.js"
184
+ },
177
185
  "./outline-view": {
178
186
  "types": "./dist/outline-view.d.ts",
179
187
  "default": "./dist/outline-view.js"
@@ -222,7 +230,7 @@
222
230
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
223
231
  "check:font-hotlinks:built": "node scripts/check-font-hotlinks.mjs --built dist artifacts/playground",
224
232
  "build:playground": "node scripts/build-playground.mjs",
225
- "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/playground-download.mjs artifacts/playground-download",
233
+ "test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-base-fonts.mjs artifacts/playground-base-fonts && node test/playground-source.mjs && node test/design-controls-browser.mjs && node test/design-gaps-browser.mjs && node test/chart-options-browser.mjs && node test/persistence-browser.mjs && node test/slides-browser.mjs && node test/layout-templates-browser.mjs && node test/template-panel-browser.mjs && node test/numbering-panel-browser.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts && node test/find-replace-browser.mjs && node test/image-crop-browser.mjs && node test/mobile-browser.mjs && node test/review-panel-browser.mjs && node test/review-hook-browser.mjs && node test/playground-download.mjs artifacts/playground-download",
226
234
  "test:code-browser": "node test/code-browser.mjs",
227
235
  "test:selection-browser": "node test/selection-browser.mjs",
228
236
  "test:click-entry-browser": "node test/click-entry-browser.mjs",
@@ -258,11 +266,11 @@
258
266
  "@codemirror/state": "6.7.6",
259
267
  "@codemirror/view": "6.43.13",
260
268
  "@lezer/highlight": "1.2.5",
261
- "@openpresentation/opf": "^0.18.3",
269
+ "@openpresentation/opf": "^0.19.0",
262
270
  "jsonc-parser": "3.3.1"
263
271
  },
264
272
  "peerDependencies": {
265
- "@openpresentation/opf-render": "^0.18.1"
273
+ "@openpresentation/opf-render": "^0.19.1"
266
274
  },
267
275
  "peerDependenciesMeta": {
268
276
  "@openpresentation/opf-render": {
@@ -270,6 +278,7 @@
270
278
  }
271
279
  },
272
280
  "devDependencies": {
281
+ "@openpresentation/gallery": "^2.0.0",
273
282
  "@expo-google-fonts/arimo": "0.4.3",
274
283
  "@expo-google-fonts/caladea": "0.4.2",
275
284
  "@expo-google-fonts/cousine": "0.4.3",
@@ -314,8 +323,8 @@
314
323
  "@expo-google-fonts/roboto-mono": "0.4.2",
315
324
  "@expo-google-fonts/stix-two-math": "0.4.0",
316
325
  "@expo-google-fonts/tinos": "0.4.2",
317
- "@openpresentation/opf-pptx": "^0.18.1",
318
- "@openpresentation/opf-render": "^0.18.1",
326
+ "@openpresentation/opf-pptx": "^0.19.0",
327
+ "@openpresentation/opf-render": "^0.19.1",
319
328
  "@resvg/resvg-js": "2.6.2",
320
329
  "axe-core": "4.14.0",
321
330
  "esbuild": "0.28.2",
@@ -1,71 +0,0 @@
1
- import {layoutLeaves,layoutStructure} from '@openpresentation/opf/composition';
2
- import {listBlockContainers,prepareBlockInsert} from './blocks.js';
3
-
4
- /**
5
- * The slot kinds a layout record declares, in reading order: its headings and the leaf regions of its placeholder groups
6
- * (FA-26), the kinds a slide's flat content fills. Undefined when the record states no placeholders.
7
- */
8
- export function layoutSlotTypes(record){
9
- if(!Array.isArray(record?.placeholders))return undefined;
10
- try{return layoutLeaves(record).map(leaf=>leaf.type);}catch{return record.placeholders.filter(entry=>entry?.type!=='group').map(entry=>entry?.type);}
11
- }
12
- /** A one-line description of a record's slots, nested groups in parentheses: `title, column (text, text), chart`. */
13
- export function layoutSlotSummary(record){
14
- if(!Array.isArray(record?.placeholders))return '';
15
- try{return layoutStructure(record);}catch{return layoutSlotTypes(record).join(', ');}
16
- }
17
-
18
- const payloadFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
19
- const headingFields=new Set(['title','subtitle','tag']);
20
- const fields={text:'text',list:'items',image:'image',video:'video',chart:'chart',table:'table',code:'code',metric:'metric',quote:'quote',timeline:'timeline'};
21
- const region=key=>/^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/.test(key);
22
-
23
- function emptyPayload(field){
24
- if(field==='items')return [];
25
- if(field==='chart')return {type:'bar',data:{columns:['',''],rows:[['',null]]}};
26
- if(field==='table')return {rows:[]};
27
- if(field==='metric')return {value:'',label:''};
28
- if(field==='quote')return {text:'',attribution:''};
29
- if(field==='timeline')return {events:[{when:'',what:''}]};
30
- return '';
31
- }
32
-
33
- /** Supply authored slots without replacing content, metadata or region placement. */
34
- export function populateLayoutPlaceholders(presentation,slideIndex,types){
35
- let next=structuredClone(presentation),slide=next.slides[slideIndex];
36
- const counts=new Map();
37
- function count(host){
38
- if(!host||typeof host!=='object')return;
39
- if(Array.isArray(host.blocks)){host.blocks.forEach(count);return;}
40
- for(const field of payloadFields)if(Object.hasOwn(host,field)){
41
- // Bullet text occupies a text slot; rich-text runs are one payload.
42
- const key=field==='bullets'?'text':field;
43
- counts.set(key,(counts.get(key)??0)+1);
44
- }
45
- }
46
- const regions=Object.keys(slide).filter(region);
47
- if(regions.length)regions.forEach(key=>count(slide[key]));else count(slide);
48
- for(const type of types){
49
- if(headingFields.has(type)){
50
- if(!Object.hasOwn(slide,type))slide[type]='';
51
- continue;
52
- }
53
- const field=fields[type];
54
- if(!field)continue;
55
- const remaining=counts.get(field)??0;
56
- if(remaining){counts.set(field,remaining-1);continue;}
57
- const value=emptyPayload(field);
58
- // Distinct implicit root payloads can coexist. Repeated kinds need blocks.
59
- if(!regions.length&&!Array.isArray(slide.blocks)&&!slide.type&&!Object.hasOwn(slide,field))slide[field]=value;
60
- else {
61
- // Keep positioned content in its region. Append to the last outer region,
62
- // rather than creating root blocks that would hide the existing regions.
63
- const path=regions.length?`/slides/${slideIndex}/${regions.at(-1)}`:`/slides/${slideIndex}`;
64
- const containers=listBlockContainers(next,{slideIndex,includeImplicit:true});
65
- if(!containers.some(container=>container.path===path))throw new Error('This slide has no editable content container for the new placeholder.');
66
- next=prepareBlockInsert(next,path,{[field]:value}).presentation;
67
- slide=next.slides[slideIndex];
68
- }
69
- }
70
- return next;
71
- }