@openpresentation/opf-editor 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # OPF Editor
2
2
 
3
+ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
4
+
5
+ Version 0.8.0 requires `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
6
+
3
7
  Version 0.7.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
4
8
 
5
9
  Version 0.7.0 forwards effective title alignment and shared outline placement options through composition and explicit pagination. Use the same `textMeasurement` and `textRasterPadding` in canvas rendering and export. Accepted rich-text trace origins support selection, caret placement and undo. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md); native PowerPoint fidelity remains separate.
@@ -30,7 +34,9 @@ The host owns the source draft, OPF validation, slide preview, persistence and s
30
34
 
31
35
  The control provides indentation, paired quotes/brackets, folding shortcuts, search, undo/redo, JSON syntax diagnostics and explicit formatting (`Cmd/Ctrl+Shift+F`). Enter at the end of a nonempty string array item supplies indentation, quotes and commas. Other Enter positions use ordinary JSON indentation. Source offsets are UTF-16 in the original authored string. Existing CRLF, CR and LF spellings survive ordinary edits and undo; new typed lines use the first existing separator. Paste retains the text delivered by the browser clipboard event. Explicit formatting deliberately standardizes whitespace, preserving JSON values and numeric lexemes.
32
36
 
33
- Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Document catalog records override already-loaded records, which override built-ins; 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` replaces the host's loaded context and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI; the host can use the pure `/json-options` helpers, checking that `context.source` still matches before replacing its token with `control.api.replace`.
37
+ Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Document catalog records override already-loaded records, which override built-ins; 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` replaces the host's loaded context and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
38
+
39
+ 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.
34
40
 
35
41
  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.
36
42
 
@@ -206,7 +212,7 @@ This repo does not require an npm automation token when Trusted Publishing is co
206
212
 
207
213
  The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
208
214
 
209
- Version 0.7.0 requires `@openpresentation/opf@^0.10.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.8.0`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
215
+ Version 0.8.0 requires `@openpresentation/opf@^0.11.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.9.0`. Coordinated playground export uses `@openpresentation/opf-pptx@^0.9.0`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
210
216
 
211
217
  ## Local interactive demo
212
218
 
@@ -245,7 +251,7 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
245
251
 
246
252
  ### Rich text on the canvas
247
253
 
248
- Select rendered rich text to format it, or double-click a rich text block to type. The toolbar supports character styles, point size, font family, hex color, links, scripts, and selected-text replacement. Plain text payloads offer **Format text** during inline editing. **Edit runs** opens the structured fields; `canvas.editProperties(path)` does the same programmatically. Each action is validated and undoable. Version 0.1.1 supports direct mixed-style typing: double-click to type, drag to select, use Format selection for styles, and commit with Done or Ctrl/Cmd+Enter. Escape cancels. Native input and composition events preserve run metadata; the canonical SVG supplies glyph/caret geometry. The whole session commits as one undo step, with draft undo/redo available while typing. Empty-line caret geometry requires renderer 0.1.1 or later; cross-engine and real operating-system IME verification remain open.
254
+ Select rendered rich text to format it, or double-click a rich text block to type. The toolbar supports character styles, point size, font family, hex color, scheme slots/roles, `var:<id>` references, links, scripts, and selected-text replacement. Plain text payloads offer **Format text** during inline editing. **Edit runs** opens the structured fields; `canvas.editProperties(path)` does the same programmatically. Each action is validated and undoable. Version 0.1.1 supports direct mixed-style typing: double-click to type, drag to select, use Format selection for styles, and commit with Done or Ctrl/Cmd+Enter. Escape cancels. Native input and composition events preserve run metadata; the canonical SVG supplies glyph/caret geometry. The whole session commits as one undo step, with draft undo/redo available while typing. Empty-line caret geometry requires renderer 0.1.1 or later; cross-engine and real operating-system IME verification remain open.
249
255
 
250
256
  Version 0.7.0: rich input maps the textarea's LF-normalized offsets back to the original source. Typing preserves untouched CRLF/CR endings and surrounding run metadata; newly inserted newlines use the first source line-ending style. The `updateRichTextInput` change offsets are native textarea offsets, while formatting and replacement helpers continue to use original source offsets.
251
257
 
package/dist/canvas.js CHANGED
@@ -136,12 +136,13 @@ export function createCanvasEditor(container, options = {}) {
136
136
  if (
137
137
  seen.has(path) ||
138
138
  node.getAttribute('data-opf-generated') === 'true' ||
139
+ node.closest('[data-opf-furniture-generated="true"]') ||
139
140
  (node.hasAttribute('data-opf-code-container') && typeof value === 'string') ||
140
141
  (node.hasAttribute('data-opf-metric-container') && ['string','number'].includes(typeof value)) ||
141
142
  (!item && !node.matches("g") && !node.matches("image")) ||
142
143
  value === undefined ||
143
144
  path === `slides.${slideIndex}` ||
144
- path.includes(".design.")
145
+ (path.includes(".design.")&&!node.closest('[data-opf-furniture-editable="true"]'))
145
146
  )
146
147
  continue;
147
148
  // Prefer the containing group over its duplicate text/shape trace attributes.
@@ -265,7 +266,8 @@ export function createCanvasEditor(container, options = {}) {
265
266
  const metricLayout=geometry.items.find(item=>item.metricLayout?.parts.some(part=>part.path===active.path))?.metricLayout;
266
267
  const metricPart=metricLayout?.parts.find(part=>part.path===active.path);
267
268
  const timelinePart=geometry.items.find(item=>item.timelineLayout?.parts.some(part=>part.path===active.path))?.timelineLayout?.parts.find(part=>part.path===active.path);
268
- const internalPart=metricPart??timelinePart;
269
+ const furniturePart=geometry.furniture?.parts.find(part=>part.type==='text'&&part.path===active.path);
270
+ const internalPart=metricPart??timelinePart??furniturePart;
269
271
  const allText = target.querySelectorAll("text");
270
272
  const lineHeight = internalPart?.fit?.lineHeight ?? (
271
273
  allText.length > 1
@@ -294,7 +296,7 @@ export function createCanvasEditor(container, options = {}) {
294
296
  lineHeight: `${lineHeight * scale}px`,
295
297
  color: visibleText ? font.fill : "transparent",
296
298
  caretColor: font.fill,
297
- textAlign: timelinePart?.alignment ?? metricLayout?.alignment ?? (anchor === "middle" ? "center" : anchor === "end" ? "right" : "left"),
299
+ textAlign: furniturePart?.alignment ?? timelinePart?.alignment ?? metricLayout?.alignment ?? (anchor === "middle" ? "center" : anchor === "end" ? "right" : "left"),
298
300
  width: `${width * scale}px`,
299
301
  height: `${Math.max(lineHeight, allText.length * lineHeight) * scale + 2}px`,
300
302
  minHeight: "0",
@@ -335,6 +337,7 @@ export function createCanvasEditor(container, options = {}) {
335
337
  const isCode=text.hasAttribute('data-opf-code-role');
336
338
  const isMetric=text.hasAttribute('data-opf-metric-role');
337
339
  const isTimeline=!!text.closest('[data-opf-timeline-role]');
340
+ const isFurniture=!!text.closest('[data-opf-furniture-editable="true"]');
338
341
  input.spellcheck = !isCode;
339
342
  input.style.cssText =
340
343
  "position:absolute;pointer-events:auto;resize:none;border:0;outline:1px solid #8975d9;outline-offset:4px;margin:0;padding:0;background:transparent;overflow:hidden;min-height:0;box-shadow:none;border-radius:0;white-space:pre-wrap;overflow-wrap:break-word;box-sizing:border-box;z-index:2";
@@ -352,7 +355,7 @@ export function createCanvasEditor(container, options = {}) {
352
355
  overlay.append(input);
353
356
  if(isCode){input.style.background='#111827';input.style.tabSize='4';input.style.overflow='auto';}
354
357
  if(isMetric){input.style.tabSize='4';input.style.overflow='auto';}
355
- if(isTimeline)input.style.tabSize='4';
358
+ if(isTimeline||isFurniture)input.style.tabSize='4';
356
359
  // Only offer rich text where the canonical schema accepts TextRun[].
357
360
  if (typeof value === "string") {
358
361
  try {
@@ -0,0 +1,20 @@
1
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2
+
3
+ const SCHEME_SLOTS = new Set([
4
+ "accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
5
+ "dark1", "dark2", "light1", "light2", "hyperlink", "followedHyperlink",
6
+ ]);
7
+
8
+ const ROLE_KEYS = new Set([
9
+ "primary", "secondary", "accent", "background", "surface", "text", "textSecondary",
10
+ ]);
11
+
12
+ /** Whether a toolbar-authored text color matches documented ColorRef / TextRun forms. */
13
+ export function isAuthoringColorRef(value) {
14
+ if (value === null || value === undefined || value === "") return true;
15
+ if (typeof value !== "string") return false;
16
+ const ref = value.trim();
17
+ if (ref.startsWith("#")) return HEX.test(ref);
18
+ if (ref.startsWith("var:")) return /^var:[a-z][a-z0-9-]*$/.test(ref);
19
+ return SCHEME_SLOTS.has(ref) || ROLE_KEYS.has(ref);
20
+ }
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ export type JsonPatchOperation =
26
26
  export interface OPFValidationSummary {
27
27
  valid: boolean;
28
28
  errors: unknown[];
29
+ warnings: unknown[];
29
30
  result: unknown;
30
31
  }
31
32
 
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { paginateSlide } from "@openpresentation/opf/pagination";
2
+ import { collectReservedPresentationIds } from "./presentation-ids.js";
2
3
  import { composeSlide, resolveCanvasDimensions, resolveFontFamilies } from "@openpresentation/opf/composition";
3
4
  import {
4
5
  catalogKinds,
@@ -19,7 +20,7 @@ function resolveCompositionOptions(document, slideIndex, options = {}) {
19
20
  const reference = slide.design?.fontScheme ?? document.design?.fontScheme ?? theme?.fontScheme ?? "roboto";
20
21
  const id = typeof reference === "string" ? reference : reference.id;
21
22
  const fontScheme = {...(document.catalogs?.fontSchemes?.records?.find(record=>record.id===id) ?? bundledCatalogs.fontSchemes.find(record=>record.id===id)),...(typeof reference === "object" ? reference : {})};
22
- return { ...resolveCanvasDimensions(slide.design?.dimensions ?? document.design?.dimensions ?? theme?.dimensions), fonts:resolveFontFamilies(fontScheme), contentAlignment:slide.design?.contentAlignment??document.design?.contentAlignment, titleAlignment:slide.design?.titleAlignment??document.design?.titleAlignment, contentBox:slide.design?.contentBox??document.design?.contentBox, ...options, layout, slideIndex };
23
+ return { ...resolveCanvasDimensions(slide.design?.dimensions ?? document.design?.dimensions ?? theme?.dimensions), fonts:resolveFontFamilies(fontScheme), contentAlignment:slide.design?.contentAlignment??document.design?.contentAlignment, titleAlignment:slide.design?.titleAlignment??document.design?.titleAlignment, contentBox:slide.design?.contentBox??document.design?.contentBox, presentation:document, ...options, layout, slideIndex };
23
24
  }
24
25
 
25
26
  export const packageName = "@openpresentation/opf-editor";
@@ -142,6 +143,7 @@ export function validateOpfDocument(document, validator = validatePresentation)
142
143
  return {
143
144
  valid,
144
145
  errors: Array.isArray(result?.errors) ? result.errors : [],
146
+ warnings: Array.isArray(result?.warnings) ? result.warnings : [],
145
147
  result
146
148
  };
147
149
  }
@@ -243,7 +245,10 @@ export function createEditorSession(input, options = {}) {
243
245
  },
244
246
  paginateSlide(slideIndex, options = {}, meta = {}) {
245
247
  const resolved = resolveCompositionOptions(document, slideIndex, options);
246
- const pagination = paginateSlide(document.slides[slideIndex], { ...resolved, reservedIds: document.slides.map(slide => slide.id).filter(Boolean) });
248
+ const pagination = paginateSlide(document.slides[slideIndex], {
249
+ ...resolved,
250
+ reservedIds: collectReservedPresentationIds(document),
251
+ });
247
252
  // Pagination can persist a readability policy without adding a page. Commit
248
253
  // that change too, so preview/export use the same minimum that was evaluated.
249
254
  if (pagination.slides.length === 1 && jsonEqual(pagination.slides[0], document.slides[slideIndex])) return { change: null, pagination };
package/dist/json-menu.js CHANGED
@@ -1,4 +1,4 @@
1
- import {getJsonFieldContext,replaceFieldOption} from './json-options.js';
1
+ import {getJsonFieldContext,fieldOptionEdit} from './json-options.js';
2
2
  let nextMenuId=0;
3
3
 
4
4
  /** Optional DOM menu; hosts may provide their own onOptions renderer instead. */
@@ -35,7 +35,7 @@ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
35
35
  function resize(){close();}
36
36
  function choose(option){
37
37
  if(api.getValue()!==source){close();return;}
38
- try{replaceFieldOption(context,option.value);api.replace(context.offset,context.offset+context.length,JSON.stringify(option.value));close();api.focus();}
38
+ try{const edit=fieldOptionEdit(context,option.value);api.replace(edit.from,edit.to,edit.insert);close();api.focus();}
39
39
  catch(cause){error.hidden=false;error.textContent=cause.message;}
40
40
  }
41
41
  function highlight(){
@@ -13,6 +13,8 @@ export interface JsonFieldOption {
13
13
  layoutGroup?: 'Current layout' | 'Same placeholders' | 'Compatible placeholders' | 'Different counts' | 'Other layouts' | 'Unspecified placeholders';
14
14
  /** Declared placeholder types and counts, not a rendering guarantee. */
15
15
  placeholders?: string;
16
+ /** Declared kinds in layout order, used to create missing blank content. */
17
+ placeholderTypes?: string[];
16
18
  related?: boolean;
17
19
  }
18
20
  export interface JsonFieldContext {
@@ -29,5 +31,7 @@ export interface JsonFieldContext {
29
31
  }
30
32
  /** Null for invalid JSON or a field with no categorical choices. */
31
33
  export declare function getJsonFieldContext(source: string, position: number, loadedCatalogs?: JsonCatalogContext): JsonFieldContext | null;
32
- /** Replace only the token. Hosts must reject a context whose source is stale. */
34
+ /** Replace the token and add missing layout payloads. Hosts must reject stale sources. */
33
35
  export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
36
+ /** Minimal single replacement for atomic source-editor undo. */
37
+ export declare function fieldOptionEdit(context: JsonFieldContext, value: JsonOptionValue): {from:number;to:number;insert:string};
@@ -1,7 +1,8 @@
1
1
  import { catalogKinds, catalogSchemaNames, schemas, validateCatalogRecord, validatePresentation } from "@openpresentation/opf";
2
2
  import { getCatalogOptions } from "./index.js";
3
3
  import { schemaAtPath, schemaVariants } from "./schema.js";
4
- import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
4
+ import { applyEdits, findNodeAtLocation, findNodeAtOffset, getNodePath, modify, parseTree } from "jsonc-parser";
5
+ import { populateLayoutPlaceholders } from "./layout-placeholders.js";
5
6
  function catalogFor(description) {
6
7
  return catalogKinds.find(kind => description.includes(`catalogs.${kind}`)
7
8
  || new RegExp(`\\b${kind}['’]? catalog`).test(description));
@@ -18,8 +19,8 @@ function placeholderSummary(types) {
18
19
  return "Placeholders not specified";
19
20
  if (!types.length)
20
21
  return "No placeholders";
21
- const labels = { title: "Title", subtitle: "Subtitle", text: "Text", list: "List", picture: "Image", chart: "Chart", table: "Table", code: "Code", media: "Media" };
22
- const order = ["title", "subtitle", "text", "list", "picture", "chart", "table", "code", "media"];
22
+ const labels = { title: "Title", subtitle: "Subtitle", text: "Text", metric: "Metric", list: "List", picture: "Image", chart: "Chart", table: "Table", code: "Code", media: "Video", quote: "Quote", timeline: "Timeline", tag: "Tag", diagram: "Image" };
23
+ const order = ["title", "subtitle", "text", "metric", "list", "picture", "chart", "table", "code", "media", "quote", "timeline", "tag", "diagram"];
23
24
  return [...new Set(types)].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map(type => {
24
25
  const count = types.filter(value => value === type).length;
25
26
  return `${labels[type] ?? type}${count > 1 ? ` × ${count}` : ""}`;
@@ -45,6 +46,7 @@ function describeOptions(choices, records, current, catalog) {
45
46
  if (catalog !== "layouts")
46
47
  continue;
47
48
  const types = layoutTypes(option.value === current ? currentRecord : records.get(String(option.value)));
49
+ option.placeholderTypes = records.get(String(option.value))?.placeholders?.map(placeholder => placeholder.type);
48
50
  option.placeholders = placeholderSummary(types);
49
51
  const same = types && currentTypes && signature(types) === signature(currentTypes);
50
52
  const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
@@ -145,12 +147,47 @@ export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
145
147
  export function replaceFieldOption(context, value) {
146
148
  if (!context.options.some(option => option.value === value))
147
149
  throw new Error("This choice is no longer available.");
148
- const next = context.source.slice(0, context.offset) + JSON.stringify(value) + context.source.slice(context.offset + context.length);
150
+ let next = context.source.slice(0, context.offset) + JSON.stringify(value) + context.source.slice(context.offset + context.length);
149
151
  const document = JSON.parse(next);
150
152
  if (context.path.reduce((current, key) => current?.[key], document) !== value)
151
153
  throw new Error("This field has a duplicate key. Resolve it in the JSON before choosing an option.");
152
- const result = validatePresentation(document);
154
+ const types = context.options.find(option => option.value === value).placeholderTypes;
155
+ if (value !== context.value && types && context.catalog === 'layouts' && context.path.length === 3
156
+ && context.path[0] === 'slides' && context.path[2] === 'layout' && validatePresentation(document).valid) {
157
+ const populated = populateLayoutPlaceholders(document, context.path[1], types);
158
+ next = updateSource(next, document, populated);
159
+ }
160
+ const result = validatePresentation(JSON.parse(next));
153
161
  if (!result.valid && validatePresentation(JSON.parse(context.source)).valid)
154
162
  throw new Error(result.errors[0]?.message ?? "This option is not valid here.");
155
163
  return next;
156
164
  }
165
+
166
+ // Keep existing scalar spellings and unrelated source bytes when adding slots.
167
+ function updateSource(source, before, after) {
168
+ const indent = source.match(/^[\t ]+(?=\S)/m)?.[0] ?? ' ';
169
+ const formattingOptions = {insertSpaces: !indent.includes('\t'), tabSize: indent.length, eol: source.includes('\r\n') ? '\r\n' : '\n'};
170
+ function visit(a, b, path) {
171
+ if (JSON.stringify(a) === JSON.stringify(b)) return;
172
+ if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(a) && !Array.isArray(b)) {
173
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) visit(a[key], b[key], [...path, key]);
174
+ } else if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
175
+ b.forEach((value, index) => visit(a[index], value, [...path, index]));
176
+ } else {
177
+ const node = findNodeAtLocation(parseTree(source), path);
178
+ if (node && b !== undefined) source = source.slice(0, node.offset) + JSON.stringify(b) + source.slice(node.offset + node.length);
179
+ else source = applyEdits(source, modify(source, path, b, {formattingOptions}));
180
+ }
181
+ }
182
+ visit(before, after, []);
183
+ return source;
184
+ }
185
+
186
+ /** One source edit so layout changes and new slots share one undo step. */
187
+ export function fieldOptionEdit(context, value) {
188
+ const next = replaceFieldOption(context, value), source = context.source;
189
+ let from = 0, to = source.length, end = next.length;
190
+ while (from < to && from < end && source[from] === next[from]) from++;
191
+ while (to > from && end > from && source[to - 1] === next[end - 1]) { to--; end--; }
192
+ return {from, to, insert: next.slice(from, end)};
193
+ }
@@ -0,0 +1,56 @@
1
+ import {listBlockContainers,prepareBlockInsert} from './blocks.js';
2
+
3
+ const payloadFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
4
+ const headingFields=new Set(['title','subtitle','tag']);
5
+ const fields={text:'text',list:'items',picture:'image',media:'video',diagram:'image',chart:'chart',table:'table',code:'code',metric:'metric',quote:'quote',timeline:'timeline'};
6
+ const region=key=>/^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/.test(key);
7
+
8
+ function emptyPayload(field){
9
+ if(field==='items')return [];
10
+ if(field==='chart')return {type:'bar',data:{columns:['',''],rows:[['',null]]}};
11
+ if(field==='table')return {rows:[]};
12
+ if(field==='metric')return {value:'',label:''};
13
+ if(field==='quote')return {text:'',attribution:''};
14
+ if(field==='timeline')return {events:[{when:'',what:''}]};
15
+ return '';
16
+ }
17
+
18
+ /** Supply authored slots without replacing content, metadata or region placement. */
19
+ export function populateLayoutPlaceholders(document,slideIndex,types){
20
+ let next=structuredClone(document),slide=next.slides[slideIndex];
21
+ const counts=new Map();
22
+ function count(host){
23
+ if(!host||typeof host!=='object')return;
24
+ if(Array.isArray(host.blocks)){host.blocks.forEach(count);return;}
25
+ for(const field of payloadFields)if(Object.hasOwn(host,field)){
26
+ // Bullet text occupies a text slot; rich-text runs are one payload.
27
+ const key=field==='bullets'?'text':field;
28
+ counts.set(key,(counts.get(key)??0)+1);
29
+ }
30
+ }
31
+ const regions=Object.keys(slide).filter(region);
32
+ if(regions.length)regions.forEach(key=>count(slide[key]));else count(slide);
33
+ for(const type of types){
34
+ if(headingFields.has(type)){
35
+ if(!Object.hasOwn(slide,type))slide[type]='';
36
+ continue;
37
+ }
38
+ const field=fields[type];
39
+ if(!field)continue;
40
+ const remaining=counts.get(field)??0;
41
+ if(remaining){counts.set(field,remaining-1);continue;}
42
+ const value=emptyPayload(field);
43
+ // Distinct implicit root payloads can coexist. Repeated kinds need blocks.
44
+ if(!regions.length&&!Array.isArray(slide.blocks)&&!slide.type&&!Object.hasOwn(slide,field))slide[field]=value;
45
+ else {
46
+ // Keep positioned content in its region. Append to the last outer region,
47
+ // rather than creating root blocks that would hide the existing regions.
48
+ const path=regions.length?`/slides/${slideIndex}/${regions.at(-1)}`:`/slides/${slideIndex}`;
49
+ const containers=listBlockContainers(next,{slideIndex,includeImplicit:true});
50
+ if(!containers.some(container=>container.path===path))throw new Error('This slide has no editable content container for the new placeholder.');
51
+ next=prepareBlockInsert(next,path,{[field]:value}).document;
52
+ slide=next.slides[slideIndex];
53
+ }
54
+ }
55
+ return next;
56
+ }
@@ -0,0 +1,68 @@
1
+ /** Shared slide and content-payload id collection (matches @openpresentation/opf pagination). */
2
+
3
+ const promotedRegionKeys = [
4
+ "left", "center", "right", "left+center", "center+right", "left+center+right",
5
+ "top", "middle", "bottom", "top+middle", "middle+bottom", "top+middle+bottom",
6
+ "top:left", "top:center", "top:right", "top:left+center", "top:center+right", "top:left+center+right",
7
+ "middle:left", "middle:center", "middle:right", "middle:left+center", "middle:center+right", "middle:left+center+right",
8
+ "bottom:left", "bottom:center", "bottom:right", "bottom:left+center", "bottom:center+right", "bottom:left+center+right",
9
+ "top+middle:left", "top+middle:center", "top+middle:right", "top+middle:left+center", "top+middle:center+right", "top+middle:left+center+right",
10
+ "middle+bottom:left", "middle+bottom:center", "middle+bottom:right", "middle+bottom:left+center", "middle+bottom:center+right", "middle+bottom:left+center+right",
11
+ "top+middle+bottom:left", "top+middle+bottom:center", "top+middle+bottom:right", "top+middle+bottom:left+center", "top+middle+bottom:center+right", "top+middle+bottom:left+center+right",
12
+ ];
13
+
14
+ const MAX_COMPOSITION_DEPTH = 8;
15
+
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ export function visitContentPayloads(slide, slidePath, visit) {
21
+ const walk = (payload, path, depth, ancestors) => {
22
+ visit(payload, path);
23
+ if (!Array.isArray(payload.blocks) || depth >= MAX_COMPOSITION_DEPTH || ancestors.includes(payload)) return;
24
+ const nested = [...ancestors, payload];
25
+ payload.blocks.forEach((block, index) => {
26
+ if (isRecord(block)) walk(block, path ? `${path}.blocks.${index}` : `blocks.${index}`, depth + 1, nested);
27
+ });
28
+ };
29
+ if (Array.isArray(slide?.blocks)) {
30
+ slide.blocks.forEach((block, index) => {
31
+ if (isRecord(block)) walk(block, slidePath ? `${slidePath}.blocks.${index}` : `blocks.${index}`, 0, []);
32
+ });
33
+ }
34
+ for (const key of promotedRegionKeys) {
35
+ const region = slide?.[key];
36
+ if (isRecord(region)) walk(region, slidePath ? `${slidePath}.${key}` : key, 0, []);
37
+ }
38
+ }
39
+
40
+ export function slideIds(slide) {
41
+ const ids = typeof slide?.id === "string" ? [slide.id] : [];
42
+ if (isRecord(slide)) {
43
+ visitContentPayloads(slide, "", (payload) => {
44
+ if (typeof payload.id === "string") ids.push(payload.id);
45
+ });
46
+ }
47
+ return ids;
48
+ }
49
+
50
+ export function collectReservedPresentationIds(document) {
51
+ const ids = [];
52
+ for (const slide of document.slides ?? []) ids.push(...slideIds(slide));
53
+ return ids.filter(Boolean);
54
+ }
55
+
56
+ function remapNodeId(node, ids) {
57
+ if (typeof node.id !== "string") return;
58
+ const base = node.id;
59
+ let suffix = 2;
60
+ while (ids.has(node.id)) node.id = `${base}-${suffix++}`;
61
+ ids.add(node.id);
62
+ }
63
+
64
+ /** Remap slide and payload ids when inserting slides (shared document namespace). */
65
+ export function remapSlideTreeIds(slide, ids) {
66
+ remapNodeId(slide, ids);
67
+ visitContentPayloads(slide, "", (payload) => remapNodeId(payload, ids));
68
+ }
@@ -1,4 +1,5 @@
1
1
  import {richTextContent, formatRichTextRange, replaceRichTextRange} from './rich-text.js';
2
+ import { isAuthoringColorRef } from './color-authoring.js';
2
3
 
3
4
  /** Native SVG selection stays on the exact rendered glyphs; no HTML reflow. */
4
5
  export function createRichTextToolbar(root, {editor, report, beforeChange, onProperties, onTyping, validateChange, onChange}) {
@@ -80,7 +81,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
80
81
  toolbar.append(input);return input;
81
82
  }
82
83
  const color=field('Text color');
83
- color.onchange=()=>{try {if(color.value&&!/^#(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/i.test(color.value))throw new Error('Use a hex text color, such as #2563eb.');apply({color:color.value||null});}catch(error){report(error);}};
84
+ color.onchange=()=>{try {if(color.value&&!isAuthoringColorRef(color.value))throw new Error('Use a hex color, scheme slot or role, or var:<id> (for example #2563eb, accent2, text, or var:risk).');apply({color:color.value||null});}catch(error){report(error);}};
84
85
  const size=field('Font size (pt)','number');size.min='0.1';size.step='0.5';size.style.width='80px';
85
86
  size.onchange=()=>{try{apply({fontSize:size.value?Number(size.value):null});}catch(error){report(error);}};
86
87
  const family=field('Font family');family.onchange=()=>{try{apply({fontFamily:family.value||null});}catch(error){report(error);}};
@@ -17,8 +17,10 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
17
17
  function check(){
18
18
  const validation=validateOpfDocument(draft);
19
19
  apply.disabled=!dirty||!validation.valid;
20
- status.textContent=dirty?'Draft changes · apply to save':'No pending changes';
21
- showError(validation.valid?'':validation.errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`).join('\n'));
20
+ const warningNote=validation.warnings?.length?` · ${validation.warnings.length} warning${validation.warnings.length===1?'':'s'}`:'';
21
+ status.textContent=dirty?`Draft changes · apply to save${warningNote}`:`No pending changes${warningNote}`;
22
+ const issues=[...(validation.valid?[]:validation.errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`)),...(validation.warnings?.slice(0,2).map(warning=>`${warning.path||'/'}: ${warning.message}`)??[])];
23
+ showError(issues.join('\n'));
22
24
  if(validation.valid)try{onDraft?.({document:structuredClone(draft),path:selected,dirty});}catch(error){showError(`Preview: ${error.message}`);onError?.(error);}
23
25
  }
24
26
  function mutate(operations,redraw=true){draft=applyJsonPatch(draft,operations);dirty=JSON.stringify(draft)!==base;if(redraw)render();check();}
package/dist/transfer.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  validateOpfDocument,
6
6
  } from "./index.js";
7
7
  import { catalogs } from "@openpresentation/opf";
8
+ import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
8
9
  export const MAX_OPF_BYTES = 20 * 1024 * 1024;
9
10
  const clone = (value) => structuredClone(value);
10
11
  export function assertOpf(document) {
@@ -241,14 +242,8 @@ export function prepareOpfImport(
241
242
  ? `asset:${assetMap.get(asset.slice(6))}`
242
243
  : asset;
243
244
  }
244
- const ids = new Set(document.slides.map((slide) => slide.id).filter(Boolean));
245
- for (const slide of rewritten.slides)
246
- if (slide.id) {
247
- const base = slide.id;
248
- let n = 2;
249
- while (ids.has(slide.id)) slide.id = `${base}-${n++}`;
250
- ids.add(slide.id);
251
- }
245
+ const ids = new Set(collectReservedPresentationIds(document));
246
+ for (const slide of rewritten.slides) remapSlideTreeIds(slide, ids);
252
247
  const index = Math.max(0, Math.min(document.slides.length, slideIndex + 1));
253
248
  document.slides.splice(index, 0, ...rewritten.slides);
254
249
  assertOpf(document);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -83,8 +83,8 @@
83
83
  "sideEffects": false,
84
84
  "scripts": {
85
85
  "build": "node scripts/build.mjs",
86
- "typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs && node --check examples/pptx-controls.js && node --check scripts/build-playground.mjs && node --check test/playground-pptx.mjs && node --check src/text-input.js && node --check src/rich-text.js && node --check src/rich-text-input.js && node --check test/rich-text.mjs && node --check test/rich-input-browser.mjs && node --check src/json-editor.js && node --check src/json-menu.js && node --check src/json-options.js && node --check src/json-source.js",
87
- "test": "npm run build && node test/accepted-text.mjs && node test/smoke.mjs && node test/component-smoke.mjs && node test/canvas-fields.mjs && node test/transfer.mjs && node test/schema.mjs && node test/rich-text.mjs && node test/layout.mjs && node test/blocks.mjs && node test/styled-table.mjs && node test/json-source.mjs && node test/json-options.mjs",
86
+ "typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/presentation-ids.js && node --check src/color-authoring.js && node --check test/presentation-ids.mjs && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs && node --check examples/pptx-controls.js && node --check scripts/build-playground.mjs && node --check test/playground-pptx.mjs && node --check src/text-input.js && node --check src/rich-text.js && node --check src/rich-text-input.js && node --check src/rich-text-toolbar.js && node --check test/rich-text.mjs && node --check test/rich-input-browser.mjs && node --check src/json-editor.js && node --check src/json-menu.js && node --check src/json-options.js && node --check src/json-source.js",
87
+ "test": "npm run build && node test/accepted-text.mjs && node test/smoke.mjs && node test/component-smoke.mjs && node test/canvas-fields.mjs && node test/transfer.mjs && node test/presentation-ids.mjs && node test/schema.mjs && node test/rich-text.mjs && node test/layout.mjs && node test/blocks.mjs && node test/styled-table.mjs && node test/json-source.mjs && node test/json-options.mjs",
88
88
  "validate": "node scripts/validate-package.mjs",
89
89
  "test:packed": "node test/packed-install.mjs",
90
90
  "prepack": "npm run build",
@@ -106,11 +106,11 @@
106
106
  "@codemirror/state": "6.7.4",
107
107
  "@codemirror/view": "6.43.11",
108
108
  "@lezer/highlight": "1.2.3",
109
- "@openpresentation/opf": "^0.10.0",
109
+ "@openpresentation/opf": "^0.11.0",
110
110
  "jsonc-parser": "3.3.1"
111
111
  },
112
112
  "peerDependencies": {
113
- "@openpresentation/opf-render": "^0.8.0"
113
+ "@openpresentation/opf-render": "^0.9.0"
114
114
  },
115
115
  "peerDependenciesMeta": {
116
116
  "@openpresentation/opf-render": {
@@ -118,8 +118,8 @@
118
118
  }
119
119
  },
120
120
  "devDependencies": {
121
- "@openpresentation/opf-pptx": "0.8.0",
122
- "@openpresentation/opf-render": "0.8.0",
121
+ "@openpresentation/opf-pptx": "^0.9.0",
122
+ "@openpresentation/opf-render": "^0.9.0",
123
123
  "esbuild": "0.28.2",
124
124
  "playwright": "1.63.0"
125
125
  }