@openpresentation/opf-editor 0.6.0 → 0.7.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,8 +1,40 @@
1
1
  # OPF Editor
2
2
 
3
+ 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
+
5
+ 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.
6
+
3
7
  Embeddable local editor primitives for Open Presentation Format documents. The package turns traced `@openpresentation/opf-render` SVG output into JSON-path-aware edits, validates OPF after each change, and records undo/redo as JSON Patch operations.
4
8
 
5
- Version 0.6.0 uses core 0.9.0 and renderer 0.7.0. Code source/metadata edits preserve line endings, literal tabs and cancellation/undo through accepted trace targets. Committed preview uses shared quote/code geometry; the active source textarea uses native browser editing. `paginateSlide` records a one-page readability-policy change in undo history, and an unchanged repeat is a no-op. Coordinated examples use PPTX 0.7.0 for exact code source/metadata recovery. Native formatting, font theme and geometry are not restored; quotes still import as editable text blocks with structure and readability-policy loss.
9
+ ### Reusable JSON control (0.7.0)
10
+
11
+ Version 0.7.0 exposes `mountJsonCodeEditor` from `@openpresentation/opf-editor/json-editor` and `getJsonFieldContext` / `replaceFieldOption` from `/json-options`. Earlier published version 0.6.0 does not include these exports. They extract the code editing and contextual choices already used on openpresentation.org, so hosts can reuse them without replacing their surrounding UI.
12
+
13
+ ```js
14
+ import { mountJsonCodeEditor } from '@openpresentation/opf-editor/json-editor';
15
+ const control = mountJsonCodeEditor(container, {
16
+ code: source,
17
+ label: 'OPF JSON document',
18
+ lineNumbers: true,
19
+ catalogs: loadedCatalogRecords,
20
+ onChange: nextSource => receiveDraft(nextSource),
21
+ onError: error => showError(error),
22
+ });
23
+ // After a preview edit, send the updated source back through the same control.
24
+ control.update(nextSource);
25
+ // On unmount:
26
+ control.destroy();
27
+ ```
28
+
29
+ The host owns the source draft, OPF validation, slide preview, persistence and session history. `onChange` includes invalid JSON while typing; keep the last valid preview until the draft validates. `update` applies external source without emitting `onChange` or creating a local undo step. It preserves unrelated edits through CodeMirror's history mapping; it is not a collaboration/conflict-resolution protocol. Importing either entrypoint does not require a DOM; mounting requires an existing browser container.
30
+
31
+ 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
+
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`.
34
+
35
+ 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
+
37
+ Version 0.7.0 uses core 0.10.0 and renderer 0.8.0. Code source/metadata edits preserve line endings, literal tabs and cancellation/undo through accepted trace targets. Committed preview uses shared quote/code geometry; the active source textarea uses native browser editing. `paginateSlide` records a one-page readability-policy change in undo history, and an unchanged repeat is a no-op. Coordinated examples use PPTX 0.8.0 for exact code source/metadata recovery. Native formatting, font theme and geometry are not restored; quotes still import as editable text blocks with structure and readability-policy loss.
6
38
 
7
39
  ## Scope
8
40
 
@@ -34,7 +66,7 @@ await canvas.ready;
34
66
 
35
67
  Load the same font bytes into the browser using `loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser` before mounting. The host owns font URLs, storage and collaboration. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
36
68
 
37
- These APIs were introduced in 0.1.0. Version 0.6.0 requires core 0.9.0 and renderer 0.7.0 for the canvas, including shared quote/code geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
69
+ These APIs were introduced in 0.1.0. Version 0.7.0 requires core 0.10.0 and renderer 0.8.0 for the canvas, including shared accepted geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
38
70
 
39
71
  The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Advanced shaping, freeform object positioning, all chart/media treatments, and cross-engine pixel identity remain work in progress. The Source dialog in the playground now provides a live JSON preview; changes are validated before committing.
40
72
 
@@ -174,7 +206,7 @@ This repo does not require an npm automation token when Trusted Publishing is co
174
206
 
175
207
  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.
176
208
 
177
- Version 0.6.0 requires `@openpresentation/opf@^0.9.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.7.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.
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.
178
210
 
179
211
  ## Local interactive demo
180
212
 
@@ -215,6 +247,10 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
215
247
 
216
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.
217
249
 
250
+ 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
+
252
+ Browser regression checks run with `npm run test:rich-input-browser -- artifacts/rich-input-browser.json measured` (or `estimated`). An optional consumer path after the mode selects an existing coordinated installed consumer; all browser runtime and font-preparation imports must then resolve inside that consumer. See [line-ending acceptance](docs/evidence/rich-input-line-endings-20260915/README.md) for scope and preserved failures.
253
+
218
254
  Headless applications and agents can import `formatRichTextRange`, `replaceRichTextRange`, and `richTextContent` from `@openpresentation/opf-editor/rich-text`. The helpers preserve surrounding run metadata and accept UTF-16 offsets on whole grapheme boundaries. A null format value removes an override. Apply the returned value through the editor session or validate the resulting document before saving.
219
255
 
220
256
 
@@ -5,6 +5,8 @@ import {
5
5
  splitOpfPath,
6
6
  opfPathToJsonPointer,
7
7
  } from "./index.js";
8
+ import {preserveTextLineEndings} from './text-input.js';
9
+ export {preserveTextLineEndings} from './text-input.js';
8
10
  /** Scalar fields and collection paths use JSON Pointer to preserve arbitrary object keys. */
9
11
  export function getEditableFields(value, path) {
10
12
  const fields = [],
@@ -50,18 +52,6 @@ export function parseCanvasValue(text, type, original) {
50
52
  return type === 'string' && typeof original === 'string' ? preserveTextLineEndings(original,String(text)) : text;
51
53
  }
52
54
 
53
- /** Textareas normalize line endings; retain unchanged source bytes around an edit. */
54
- export function preserveTextLineEndings(original, input) {
55
- const normalized=original.replace(/\r\n|\r/g,'\n'),next=input.replace(/\r\n|\r/g,'\n');
56
- if(normalized===next)return original;
57
- let prefix=0,suffix=0;
58
- while(prefix<normalized.length&&prefix<next.length&&normalized[prefix]===next[prefix])prefix++;
59
- while(suffix<normalized.length-prefix&&suffix<next.length-prefix&&normalized[normalized.length-1-suffix]===next[next.length-1-suffix])suffix++;
60
- const offsets=[0];
61
- for(let i=0;i<original.length;i++){if(original[i]==='\r'&&original[i+1]==='\n')i++;offsets.push(i+1);}
62
- const ending=/\r\n|\r|\n/.exec(original)?.[0]??'\n';
63
- return original.slice(0,offsets[prefix])+next.slice(prefix,next.length-suffix).replaceAll('\n',ending)+original.slice(offsets[normalized.length-suffix]);
64
- }
65
55
  export function createCanvasDraft(document, path, value) {
66
56
  const draft = applyJsonPatch(
67
57
  document,
package/dist/canvas.js CHANGED
@@ -137,6 +137,7 @@ export function createCanvasEditor(container, options = {}) {
137
137
  seen.has(path) ||
138
138
  node.getAttribute('data-opf-generated') === 'true' ||
139
139
  (node.hasAttribute('data-opf-code-container') && typeof value === 'string') ||
140
+ (node.hasAttribute('data-opf-metric-container') && ['string','number'].includes(typeof value)) ||
140
141
  (!item && !node.matches("g") && !node.matches("image")) ||
141
142
  value === undefined ||
142
143
  path === `slides.${slideIndex}` ||
@@ -159,7 +160,7 @@ export function createCanvasEditor(container, options = {}) {
159
160
  );
160
161
  if (node.matches("g")) {
161
162
  let bounds = node.getBBox();
162
- if (!bounds.width && !bounds.height && node.hasAttribute('data-opf-code-role')) bounds={x:Number(node.dataset.opfBoxX),y:Number(node.dataset.opfBoxY),width:Number(node.dataset.opfBoxWidth),height:Number(node.dataset.opfBoxHeight)};
163
+ if (!bounds.width && !bounds.height && (node.hasAttribute('data-opf-code-role')||node.hasAttribute('data-opf-metric-role')||node.hasAttribute('data-opf-source-text'))) bounds={x:Number(node.dataset.opfBoxX),y:Number(node.dataset.opfBoxY),width:Number(node.dataset.opfBoxWidth),height:Number(node.dataset.opfBoxHeight)};
163
164
  const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
164
165
  if (!bounds.width && !bounds.height && lines.length) bounds = {x:lines[0].x,y:lines[0].y,width:Number(node.getAttribute("data-opf-box-width"))||8,height:lines.reduce((sum,line)=>sum+line.height,0)};
165
166
  const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
@@ -261,11 +262,15 @@ export function createCanvasEditor(container, options = {}) {
261
262
  renderOptions,
262
263
  ).slides[slideIndex].geometry;
263
264
  const item = geometry.items.find((item) => item.path === active.path);
265
+ const metricLayout=geometry.items.find(item=>item.metricLayout?.parts.some(part=>part.path===active.path))?.metricLayout;
266
+ const metricPart=metricLayout?.parts.find(part=>part.path===active.path);
267
+ 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;
264
269
  const allText = target.querySelectorAll("text");
265
- const lineHeight =
270
+ const lineHeight = internalPart?.fit?.lineHeight ?? (
266
271
  allText.length > 1
267
272
  ? Number(allText[1].getAttribute("y")) - Number(text.getAttribute("y"))
268
- : fontSize * 1.22;
273
+ : fontSize * 1.22);
269
274
  const canvas = doc.createElement("canvas"),
270
275
  ctx = canvas.getContext("2d");
271
276
  ctx.font = `${font.fontStyle} ${font.fontWeight} ${fontSize}px ${font.fontFamily}`;
@@ -278,17 +283,18 @@ export function createCanvasEditor(container, options = {}) {
278
283
  const bounds = target.getBBox(),
279
284
  anchor = text.getAttribute("text-anchor");
280
285
  const tracedWidth = Number(target.getAttribute("data-opf-box-width"));
281
- const width = item?.box.width ?? (tracedWidth > 0 ? tracedWidth : Math.max(80, bounds.width + 20));
282
- const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
286
+ const width = internalPart?.box.width ?? item?.box.width ?? (tracedWidth > 0 ? tracedWidth : Math.max(80, bounds.width + 20));
287
+ const left = internalPart?.box.x ?? (anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x);
288
+ const visibleText=active.composing||active.isCode||active.isMetric;
283
289
  Object.assign(active.input.style, {
284
290
  fontFamily: font.fontFamily,
285
291
  fontSize: `${fontSize * scale}px`,
286
292
  fontWeight: font.fontWeight,
287
293
  fontStyle: font.fontStyle,
288
294
  lineHeight: `${lineHeight * scale}px`,
289
- color: active.composing || active.isCode ? font.fill : "transparent",
295
+ color: visibleText ? font.fill : "transparent",
290
296
  caretColor: font.fill,
291
- textAlign: anchor === "middle" ? "center" : anchor === "end" ? "right" : "left",
297
+ textAlign: timelinePart?.alignment ?? metricLayout?.alignment ?? (anchor === "middle" ? "center" : anchor === "end" ? "right" : "left"),
292
298
  width: `${width * scale}px`,
293
299
  height: `${Math.max(lineHeight, allText.length * lineHeight) * scale + 2}px`,
294
300
  minHeight: "0",
@@ -297,9 +303,9 @@ export function createCanvasEditor(container, options = {}) {
297
303
  active.input.style.left = `${svgRect.left - rootRect.left + (left - viewBox.x) * scale}px`;
298
304
  active.input.style.top = `${svgRect.top - rootRect.top + (y - viewBox.y) * scale}px`;
299
305
  // When the input supplies visible text, its selected text needs the same fill.
300
- active.input.style.setProperty('--opf-inline-selection-color',active.composing || active.isCode ? font.fill : 'transparent');
306
+ active.input.style.setProperty('--opf-inline-selection-color',visibleText ? font.fill : 'transparent');
301
307
  // Other text keeps the canonical SVG visible beneath the transparent input.
302
- target.style.opacity = active.composing || active.isCode ? "0" : "1";
308
+ target.style.opacity = visibleText ? "0" : "1";
303
309
  }
304
310
  function beginEdit(path) {
305
311
  if (disposed) return;
@@ -327,6 +333,8 @@ export function createCanvasEditor(container, options = {}) {
327
333
  input.value = String(value);
328
334
  input.setAttribute("aria-label", `Edit ${path.split(".").at(-1)} inline`);
329
335
  const isCode=text.hasAttribute('data-opf-code-role');
336
+ const isMetric=text.hasAttribute('data-opf-metric-role');
337
+ const isTimeline=!!text.closest('[data-opf-timeline-role]');
330
338
  input.spellcheck = !isCode;
331
339
  input.style.cssText =
332
340
  "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";
@@ -336,12 +344,15 @@ export function createCanvasEditor(container, options = {}) {
336
344
  base: JSON.stringify(value),
337
345
  originalValue:value,
338
346
  isCode,
347
+ isMetric,
339
348
  type: typeof value,
340
349
  input,
341
350
  valid: true,
342
351
  };
343
352
  overlay.append(input);
344
353
  if(isCode){input.style.background='#111827';input.style.tabSize='4';input.style.overflow='auto';}
354
+ if(isMetric){input.style.tabSize='4';input.style.overflow='auto';}
355
+ if(isTimeline)input.style.tabSize='4';
345
356
  // Only offer rich text where the canonical schema accepts TextRun[].
346
357
  if (typeof value === "string") {
347
358
  try {
@@ -362,7 +373,14 @@ export function createCanvasEditor(container, options = {}) {
362
373
  } catch { /* This scalar field does not accept rich text. */ }
363
374
  }
364
375
  positionInput();
365
- input.addEventListener("input", queueDraft);
376
+ input.addEventListener("input", () => {
377
+ // Compare each browser edit with the last canonical draft. Comparing
378
+ // only with the value at focus would normalize untouched mixed line
379
+ // endings between two edits in different parts of the same field.
380
+ if (active?.input===input && active.type==='string')
381
+ active.originalValue=parseCanvasValue(input.value,'string',active.originalValue);
382
+ queueDraft();
383
+ });
366
384
  input.addEventListener("compositionstart", () => {
367
385
  if (active) {
368
386
  active.composing = true;
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ function resolveCompositionOptions(document, slideIndex, options = {}) {
19
19
  const reference = slide.design?.fontScheme ?? document.design?.fontScheme ?? theme?.fontScheme ?? "roboto";
20
20
  const id = typeof reference === "string" ? reference : reference.id;
21
21
  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), ...options, layout, slideIndex };
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
23
  }
24
24
 
25
25
  export const packageName = "@openpresentation/opf-editor";
@@ -0,0 +1,39 @@
1
+ import type { JsonCatalogContext } from './json-options.js';
2
+
3
+ /** All offsets address the original source in UTF-16, including CRLF pairs. */
4
+ export interface JsonEditorApi {
5
+ getValue(): string;
6
+ getSelection(): [number, number];
7
+ hasFocus(): boolean;
8
+ focus(): void;
9
+ setSelection(from: number, to?: number): void;
10
+ /** Replace one source range atomically; boundaries inside CRLF are rejected. */
11
+ replace(from: number, to: number, text: string): void;
12
+ coordinates(): { left: number; top: number };
13
+ /** Explicit, undoable whitespace formatting; returns false for invalid JSON. */
14
+ format(): boolean;
15
+ }
16
+ export interface JsonCodeEditorOptions {
17
+ code: string;
18
+ label: string;
19
+ onChange(source: string): void;
20
+ invalid?: boolean;
21
+ describedBy?: string;
22
+ lineNumbers?: boolean;
23
+ catalogs?: JsonCatalogContext;
24
+ /** Overrides the built-in menu, allowing an existing host UI to keep its appearance. */
25
+ onOptions?(left?: number, top?: number): void;
26
+ onFocus?(): void;
27
+ onHoverField?(path: string | null): void;
28
+ onError?(error: unknown): void;
29
+ }
30
+ export interface JsonCodeEditor {
31
+ api: JsonEditorApi;
32
+ /** Apply external source without emitting onChange or adding a local undo step. */
33
+ update(code: string, invalid?: boolean, field?: string | null, describedBy?: string): void;
34
+ setCatalogs(catalogs: JsonCatalogContext): void;
35
+ openOptions(left?: number, top?: number): Promise<void>;
36
+ destroy(): void;
37
+ }
38
+ /** Mount after the host DOM exists. Importing this module does not require a DOM. */
39
+ export declare function mountJsonCodeEditor(parent: HTMLElement, options: JsonCodeEditorOptions): JsonCodeEditor;
@@ -0,0 +1,278 @@
1
+ import { Annotation, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction } from "@codemirror/state";
2
+ import { Decoration, EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
3
+ import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory, redo } from "@codemirror/commands";
4
+ import { HighlightStyle, bracketMatching, codeFolding, foldKeymap, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
5
+ import { json, jsonParseLinter } from "@codemirror/lang-json";
6
+ import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
7
+ import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
8
+ import { linter } from "@codemirror/lint";
9
+ import { tags } from "@lezer/highlight";
10
+ import { applyEdits, findNodeAtOffset, format, getNodePath, parseTree } from "jsonc-parser";
11
+ import {jsonSource,jsonSourceHistory,setJsonSource} from './json-source.js';
12
+ import {textInputMap,preferredLineEnding} from './text-input.js';
13
+ const external = Annotation.define();
14
+ const hoverField = StateEffect.define();
15
+ const activeField = StateField.define({ create: () => null, update: (value, tr) => tr.effects.reduce((value2, effect) => effect.is(hoverField) ? effect.value : value2, value) });
16
+ const highlightStyle = HighlightStyle.define([
17
+ { tag: tags.propertyName, class: "tok-key" },
18
+ { tag: tags.string, class: "tok-str" },
19
+ { tag: [tags.number, tags.bool, tags.null], class: "tok-num" },
20
+ { tag: tags.punctuation, class: "tok-punct" },
21
+ { tag: tags.bracket, class: "tok-brace" }
22
+ ]);
23
+ function indentation(source) {
24
+ const spaces = source.match(/^[\t ]+(?=\S)/gm) ?? [];
25
+ return spaces.some((value) => value.includes(" ")) ? " " : " ".repeat(spaces.length ? Math.min(8, ...spaces.map((value) => value.length)) : 2);
26
+ }
27
+ function sourceDecorations(state) {
28
+ const ranges = [];
29
+ const lines = /* @__PURE__ */ new Set();
30
+ const active = state.field(activeField);
31
+ function visit(node) {
32
+ if (node.type === "property" && node.children?.[1]) {
33
+ const key = node.children[0], path = getNodePath(node.children[1]);
34
+ const field = path.map((part, i) => typeof part === "number" ? `[${part}]` : `${i ? "." : ""}${part}`).join("");
35
+ ranges.push(Decoration.mark({ class: `code-key${field === active ? " is-active" : ""}`, attributes: { "data-field": field } }).range(key.offset, key.offset + key.length));
36
+ if (active && (field === active || field.startsWith(`${active}.`) || field.startsWith(`${active}[`))) {
37
+ const last = state.doc.lineAt(node.offset + node.length).number;
38
+ for (let line = state.doc.lineAt(key.offset).number; line <= last; line++) lines.add(state.doc.line(line).from);
39
+ }
40
+ }
41
+ node.children?.forEach(visit);
42
+ }
43
+ const root = parseTree(state.doc.toString());
44
+ if (root) visit(root);
45
+ for (const from of lines) ranges.push(Decoration.line({ class: "is-hover" }).range(from));
46
+ return Decoration.set(ranges, true);
47
+ }
48
+ const fieldDecorations = StateField.define({
49
+ create: sourceDecorations,
50
+ update: (value, tr) => tr.docChanged || tr.effects.some((effect) => effect.is(hoverField)) ? sourceDecorations(tr.state) : value,
51
+ provide: (field) => EditorView.decorations.from(field)
52
+ });
53
+ function nextStringItem(view) {
54
+ const { state } = view, { from, to } = state.selection.main;
55
+ if (from !== to || state.selection.ranges.length !== 1) return false;
56
+ const source = state.doc.toString();
57
+ try {
58
+ JSON.parse(source);
59
+ } catch {
60
+ return false;
61
+ }
62
+ const root = parseTree(source);
63
+ let node = findNodeAtOffset(root, Math.max(0, from - 1), true);
64
+ if (node?.type !== "string") {
65
+ node = findNodeAtOffset(root, Math.max(0, from - 2), true);
66
+ }
67
+ if (node?.type !== "string" || node.parent?.type !== "array" || !node.value) return false;
68
+ const end = node.offset + node.length;
69
+ if (from !== end - 1 && from !== end && !(from === end + 1 && source[end] === ",")) return false;
70
+ const siblings = node.parent.children ?? [], hasNext = siblings.indexOf(node) < siblings.length - 1;
71
+ const comma = /^\s*,/.exec(source.slice(end));
72
+ if (hasNext && !comma) return false;
73
+ const replaceTo = hasNext ? end + comma[0].length : end;
74
+ const line = state.doc.lineAt(node.offset);
75
+ const existingIndent = /^\s*/.exec(line.text)[0];
76
+ const parentLine = state.doc.lineAt(node.parent.offset);
77
+ const indent = line.number === parentLine.number ? /^\s*/.exec(parentLine.text)[0] + indentation(source) : existingIndent;
78
+ const inserted = `,
79
+ ${indent}""${hasNext ? "," : ""}`;
80
+ view.dispatch({
81
+ changes: { from: end, to: replaceTo, insert: inserted },
82
+ selection: { anchor: end + inserted.length - (hasNext ? 2 : 1) },
83
+ annotations: [Transaction.userEvent.of("input.complete"), isolateHistory.of("full")],
84
+ scrollIntoView: true
85
+ });
86
+ return true;
87
+ }
88
+ function skipDelimiter(view, delimiter) {
89
+ const { from, empty } = view.state.selection.main;
90
+ if (!empty || view.state.selection.ranges.length !== 1) return false;
91
+ const source = view.state.doc.toString();
92
+ if (source[from] !== delimiter) return false;
93
+ try {
94
+ JSON.parse(source);
95
+ } catch {
96
+ return false;
97
+ }
98
+ const node = findNodeAtOffset(parseTree(source), from, true);
99
+ if (delimiter === '"' && (node?.type !== "string" || from !== node.offset + node.length - 1)) return false;
100
+ if (delimiter === "," && node?.type === "string" && from < node.offset + node.length) return false;
101
+ view.dispatch({ selection: { anchor: from + 1 }, scrollIntoView: true });
102
+ return true;
103
+ }
104
+ function mountJsonCodeEditor(parent, options) {
105
+ const attributes = new Compartment(), indent = new Compartment();
106
+ let disposed=false, menu=null, catalogs=options.catalogs??{}, generation=0;
107
+ function closeOptions(){generation++;menu?.destroy();menu=null;}
108
+ async function openOptions(left,top){
109
+ closeOptions();
110
+ if(disposed)return;
111
+ if(options.onOptions){options.onOptions(left,top);return;}
112
+ const revision=generation,source=api.getValue(),selection=api.getSelection();
113
+ try{
114
+ const {mountJsonFieldMenu}=await import('./json-menu.js');
115
+ if(disposed||revision!==generation||source!==api.getValue()||selection.some((value,index)=>value!==api.getSelection()[index])||!api.hasFocus())return;
116
+ menu=mountJsonFieldMenu(parent,api,catalogs,left,top,()=>{menu=null;});
117
+ }catch(error){options.onError?.(error);}
118
+ }
119
+ let currentIndent = indentation(options.code);
120
+ const contentAttributes = (invalid = options.invalid, describedBy = options.describedBy) => EditorView.contentAttributes.of({
121
+ "aria-label": options.label,
122
+ "aria-invalid": String(Boolean(invalid)),
123
+ "aria-describedby": describedBy ?? "",
124
+ "aria-keyshortcuts": "Control+Space Meta+Space Alt+ArrowDown",
125
+ spellcheck: "false",
126
+ autocapitalize: "off",
127
+ autocorrect: "off"
128
+ });
129
+ const view = new EditorView({ parent, state: EditorState.create({ doc: options.code, extensions: [
130
+ json(),
131
+ jsonSource.init(()=>options.code),jsonSourceHistory,
132
+ indent.of(indentUnit.of(currentIndent)),
133
+ indentOnInput(),
134
+ history(),
135
+ drawSelection(),
136
+ bracketMatching(),
137
+ closeBrackets(),
138
+ codeFolding(),
139
+ search({ top: true }),
140
+ highlightSelectionMatches(),
141
+ linter(jsonParseLinter(), { delay: 500 }),
142
+ syntaxHighlighting(highlightStyle),
143
+ activeField,
144
+ fieldDecorations,
145
+ attributes.of(contentAttributes()),
146
+ options.lineNumbers ? lineNumbers() : [],
147
+ EditorView.lineWrapping,
148
+ // Some platforms report a lowercase key even with Shift held. Handle
149
+ // formatting before the standard Mod-f search binding normalizes the key.
150
+ Prec.high(EditorView.domEventHandlers({ keydown(event) {
151
+ // A lowercase "z" with Shift can match CodeMirror's unshifted undo
152
+ // binding first. Resolve redo before that fallback, even with older
153
+ // undo steps still available (otherwise it silently performs undo).
154
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "z") {
155
+ event.preventDefault();
156
+ redo(view);
157
+ return true;
158
+ }
159
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && !event.altKey && event.key.toLowerCase() === "f") {
160
+ event.preventDefault();
161
+ api.format();
162
+ return true;
163
+ }
164
+ return false;
165
+ } })),
166
+ keymap.of([
167
+ { key: "Enter", run: nextStringItem },
168
+ { key: '"', run: (view2) => skipDelimiter(view2, '"') },
169
+ { key: ",", run: (view2) => skipDelimiter(view2, ",") },
170
+ { key: "Mod-Space", run: () => {
171
+ void openOptions();
172
+ return true;
173
+ } },
174
+ { key: "Ctrl-Space", run: () => {
175
+ void openOptions();
176
+ return true;
177
+ } },
178
+ { key: "Alt-ArrowDown", run: () => {
179
+ void openOptions();
180
+ return true;
181
+ } },
182
+ indentWithTab,
183
+ ...closeBracketsKeymap,
184
+ ...defaultKeymap,
185
+ ...historyKeymap,
186
+ ...searchKeymap,
187
+ ...foldKeymap
188
+ ]),
189
+ EditorView.domEventHandlers({
190
+ click(event) {
191
+ if (view.state.selection.main.empty) void openOptions(event.clientX, event.clientY + 12);
192
+ },
193
+ paste(event){
194
+ if(!event.clipboardData||view.state.selection.ranges.length!==1)return false;
195
+ const text=event.clipboardData.getData('text/plain');
196
+ if(!text)return false;
197
+ event.preventDefault();api.replace(...api.getSelection(),text);return true;
198
+ },
199
+ focus() {
200
+ options.onFocus?.();
201
+ },
202
+ mousemove(event) {
203
+ options.onHoverField?.(event.target.closest("[data-field]")?.dataset.field ?? null);
204
+ },
205
+ mouseleave() {
206
+ options.onHoverField?.(null);
207
+ }
208
+ }),
209
+ EditorView.updateListener.of((update) => {
210
+ if(update.docChanged||update.selectionSet)closeOptions();
211
+ if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
212
+ })
213
+ ] }) });
214
+ const api = {
215
+ getValue: () => view.state.field(jsonSource),
216
+ getSelection: () => {const map=textInputMap(api.getValue());return [map.toSource(view.state.selection.main.from),map.toSource(view.state.selection.main.to)];},
217
+ hasFocus: () => view.hasFocus,
218
+ focus: () => view.focus(),
219
+ setSelection: (from, to=from) => {const map=textInputMap(api.getValue());view.dispatch({ selection: EditorSelection.single(map.toInput(from),map.toInput(to)), scrollIntoView: true });},
220
+ replace: (from, to, text) => {
221
+ const source=api.getValue(),map=textInputMap(source),a=map.toInput(from),b=map.toInput(to);
222
+ if(to<from||map.toSource(a)!==from||map.toSource(b)!==to)throw new RangeError('JSON edits must use source boundaries outside a CRLF pair.');
223
+ const insert=textInputMap(text).text,next=source.slice(0,from)+text+source.slice(to);
224
+ view.dispatch({changes:{from:a,to:b,insert},selection:{anchor:a+insert.length},effects:setJsonSource.of(next),
225
+ annotations:[Transaction.userEvent.of('input.complete'),isolateHistory.of('full')],scrollIntoView:true});
226
+ },
227
+ coordinates: () => {
228
+ const rect = view.coordsAtPos(view.state.selection.main.head) ?? parent.getBoundingClientRect();
229
+ return { left: rect.left, top: rect.bottom + 8 };
230
+ },
231
+ format: () => {
232
+ const source = api.getValue();
233
+ try {
234
+ JSON.parse(source);
235
+ } catch {
236
+ return false;
237
+ }
238
+ const unit = indentation(source);
239
+ const next = applyEdits(source, format(source, void 0, { insertSpaces: unit !== " ", tabSize: unit.length, eol: preferredLineEnding(source) }));
240
+ if (source !== next) api.replace(0, source.length, next);
241
+ return true;
242
+ }
243
+ };
244
+ return {
245
+ api,
246
+ openOptions,
247
+ setCatalogs(value){catalogs=value??{};closeOptions();},
248
+ update(code, invalid=false, field=null, describedBy) {
249
+ if(code!==api.getValue())closeOptions();
250
+ const current = view.state.doc.toString(), normalized=textInputMap(code).text;
251
+ const effects = [attributes.reconfigure(contentAttributes(invalid, describedBy)), hoverField.of(field),setJsonSource.of(code)];
252
+ const nextIndent = indentation(code);
253
+ if (nextIndent !== currentIndent) {
254
+ currentIndent = nextIndent;
255
+ effects.push(indent.reconfigure(indentUnit.of(nextIndent)));
256
+ }
257
+ if (current === normalized) {
258
+ view.dispatch({ effects });
259
+ return;
260
+ }
261
+ let from = 0, oldEnd = current.length, newEnd = normalized.length;
262
+ while (from < oldEnd && from < newEnd && current[from] === normalized[from]) from++;
263
+ while (oldEnd > from && newEnd > from && current[oldEnd - 1] === normalized[newEnd - 1]) {
264
+ oldEnd--;
265
+ newEnd--;
266
+ }
267
+ view.dispatch({
268
+ changes: { from, to: oldEnd, insert: normalized.slice(from, newEnd) },
269
+ effects,
270
+ annotations: [external.of(true), Transaction.addToHistory.of(false)]
271
+ });
272
+ },
273
+ destroy: () => {if(disposed)return;disposed=true;closeOptions();view.destroy();}
274
+ };
275
+ }
276
+ export {
277
+ mountJsonCodeEditor
278
+ };
@@ -0,0 +1,81 @@
1
+ import {getJsonFieldContext,replaceFieldOption} from './json-options.js';
2
+ let nextMenuId=0;
3
+
4
+ /** Optional DOM menu; hosts may provide their own onOptions renderer instead. */
5
+ export function mountJsonFieldMenu(parent,api,catalogs,left,top,onClose){
6
+ const doc=parent.ownerDocument,win=doc.defaultView,source=api.getValue(),selection=api.getSelection();
7
+ const context=getJsonFieldContext(source,selection[0],catalogs);
8
+ if(!context)return null;
9
+ const coordinates=api.coordinates();left??=coordinates.left;top??=coordinates.top;
10
+ const root=doc.createElement('div');root.className='json-field-menu';root.setAttribute('role','dialog');root.setAttribute('aria-label',`${context.label} options`);
11
+ root.style.cssText='position:fixed;z-index:10000;width:380px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;box-sizing:border-box;padding:12px;background:Canvas;color:CanvasText;border:1px solid GrayText;border-radius:8px;font:13px system-ui;box-shadow:0 8px 28px #0003';
12
+ const add=(tag,text,className,container=root)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(className)node.className=className;container.append(node);return node;};
13
+ const heading=add('div',undefined,'json-field-heading');add('strong',context.label,undefined,heading);
14
+ const closeButton=add('button','×',undefined,heading);closeButton.type='button';closeButton.setAttribute('aria-label','Close options');
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');}
23
+ const list=add('div',undefined,'json-field-options');list.setAttribute('role','listbox');list.setAttribute('aria-label',`Available ${context.label} options`);
24
+ let id;do{id=`opf-json-options-${++nextMenuId}`;}while(doc.getElementById(id));
25
+ list.id=id;search.setAttribute('aria-controls',id);list.style.cssText='max-height:280px;overflow:auto;margin-top:8px';
26
+ const error=add('p',undefined,'json-field-empty');error.setAttribute('role','alert');error.hidden=true;
27
+ if(isLayout){
28
+ const help=add('details',undefined,'json-field-catalog-help');add('summary','Where do these layouts come from?',undefined,help);
29
+ add('p','Standard OPF: included with OPF. Provided by app: extra records supplied by the host app. In this document: records in your JSON, which override matching IDs. External galleries are not automatically loaded.',undefined,help);
30
+ }
31
+ add('p',context.unloadedSource?'Using available records. External catalog URLs are not loaded here.':'Choose an option or press Escape to keep editing JSON.','json-field-footnote');
32
+ let selected=Math.max(0,context.options.findIndex(option=>option.value===context.value)),choices=[],disposed=false;
33
+ function close(restore=false){if(disposed)return;disposed=true;observer.disconnect();root.remove();doc.removeEventListener('pointerdown',outside);doc.removeEventListener('wheel',outside,true);doc.removeEventListener('touchmove',outside,true);win.removeEventListener('resize',resize);onClose();if(restore){api.focus();api.setSelection(...selection);}}
34
+ function outside(event){if(!root.contains(event.target))close();}
35
+ function resize(){close();}
36
+ function choose(option){
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();}
39
+ catch(cause){error.hidden=false;error.textContent=cause.message;}
40
+ }
41
+ function highlight(){
42
+ for(const [index,node] of [...list.querySelectorAll('[role="option"]')].entries()){
43
+ node.classList.toggle('is-highlighted',index===selected);node.style.outline=index===selected?'2px solid Highlight':'';
44
+ }
45
+ const active=list.querySelector(`[data-option-index="${selected}"]`);if(active?.getAttribute('role')==='option'){search.setAttribute('aria-activedescendant',active.id);active.scrollIntoView({block:'nearest'});}else search.removeAttribute('aria-activedescendant');
46
+ }
47
+ function render(){
48
+ 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':'';
52
+ list.replaceChildren();let group,container=list;
53
+ for(const [index,option] of choices.entries()){
54
+ 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
+ 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
+ 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';}
58
+ const meta=add('div',undefined,'json-field-meta',button);meta.style.cssText='display:flex;gap:12px;justify-content:space-between;flex-wrap:wrap';
59
+ add('code',String(option.value),undefined,meta);const origin=add('small',option.sourceLabel??option.source,undefined,meta);origin.title=option.sourceDescription??'';
60
+ if(option.description)add('p',option.description,undefined,button);
61
+ button.addEventListener('mouseenter',()=>{selected=index;highlight();});button.addEventListener('click',()=>choose(option));
62
+ }
63
+ 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
+ highlight();
66
+ }
67
+ 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
+ root.addEventListener('keydown',event=>{
71
+ if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close(true);}
72
+ 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();}
73
+ else if(event.target===search&&event.key==='Enter'&&choices[selected]){event.preventDefault();choose(choices[selected]);}
74
+ });
75
+ root.addEventListener('focusout',event=>{if(event.relatedTarget&&!root.contains(event.relatedTarget))close();});
76
+ closeButton.addEventListener('click',()=>close(true));doc.body.append(root);render();
77
+ function reposition(){const box=root.getBoundingClientRect();root.style.left=`${Math.max(8,Math.min(left,win.innerWidth-box.width-8))}px`;root.style.top=`${Math.max(8,Math.min(top,win.innerHeight-box.height-8))}px`;}
78
+ const observer=new win.ResizeObserver(reposition);observer.observe(root);reposition();
79
+ search.focus();doc.addEventListener('pointerdown',outside);doc.addEventListener('wheel',outside,{capture:true,passive:true});doc.addEventListener('touchmove',outside,{capture:true,passive:true});win.addEventListener('resize',resize);
80
+ return {destroy:()=>close()};
81
+ }
@@ -0,0 +1,33 @@
1
+ /** Already-loaded catalog records by kind. No remote catalog URLs are fetched. */
2
+ export type JsonCatalogContext = Readonly<Record<string, readonly Record<string, unknown>[]>>;
3
+ export type JsonOptionValue = string | number | boolean;
4
+ export interface JsonFieldOption {
5
+ value: JsonOptionValue;
6
+ label: string;
7
+ description?: string;
8
+ source: 'Built-in catalog' | 'Loaded catalog' | 'Document catalog' | 'Schema' | 'Current value';
9
+ suggested?: boolean;
10
+ /** User-facing provenance; source remains stable for existing consumers. */
11
+ sourceLabel?: string;
12
+ 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
+ related?: boolean;
17
+ }
18
+ export interface JsonFieldContext {
19
+ source: string;
20
+ path: (string | number)[];
21
+ /** Original-source UTF-16 token range, including surrounding JSON quotes. */
22
+ offset: number;
23
+ length: number;
24
+ value: JsonOptionValue;
25
+ label: string;
26
+ options: JsonFieldOption[];
27
+ catalog?: string;
28
+ unloadedSource: boolean;
29
+ }
30
+ /** Null for invalid JSON or a field with no categorical choices. */
31
+ 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. */
33
+ export declare function replaceFieldOption(context: JsonFieldContext, value: JsonOptionValue): string;
@@ -0,0 +1,156 @@
1
+ import { catalogKinds, catalogSchemaNames, schemas, validateCatalogRecord, validatePresentation } from "@openpresentation/opf";
2
+ import { getCatalogOptions } from "./index.js";
3
+ import { schemaAtPath, schemaVariants } from "./schema.js";
4
+ import { findNodeAtOffset, getNodePath, parseTree } from "jsonc-parser";
5
+ function catalogFor(description) {
6
+ return catalogKinds.find(kind => description.includes(`catalogs.${kind}`)
7
+ || new RegExp(`\\b${kind}['’]? catalog`).test(description));
8
+ }
9
+ // Compare declared placeholders, including multiplicity. This describes a layout
10
+ // contract, not a claim that arbitrary slide content will render without overflow.
11
+ function layoutTypes(record) {
12
+ if (!Array.isArray(record?.placeholders))
13
+ return undefined;
14
+ return record.placeholders.map(p => p.type).sort();
15
+ }
16
+ function placeholderSummary(types) {
17
+ if (!types)
18
+ return "Placeholders not specified";
19
+ if (!types.length)
20
+ 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"];
23
+ return [...new Set(types)].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map(type => {
24
+ const count = types.filter(value => value === type).length;
25
+ return `${labels[type] ?? type}${count > 1 ? ` × ${count}` : ""}`;
26
+ }).join(" + ");
27
+ }
28
+ const sourceDetails = {
29
+ "Built-in catalog": ["Standard OPF", "Included with OPF and available offline."],
30
+ "Loaded catalog": ["Provided by app", "Additional records supplied to the preview by this app; these are not fetched from a URL."],
31
+ "Document catalog": ["In this document", "Defined in this document’s catalogs. Takes precedence over app and standard records with the same ID."],
32
+ "Current value": ["Custom value", "The current value is not in the available catalogs."],
33
+ "Schema": ["OPF option", "Allowed by the OPF schema."],
34
+ };
35
+ const layoutGroups = ["Current layout", "Same placeholders", "Compatible placeholders", "Different counts", "Other layouts", "Unspecified placeholders"];
36
+ function describeOptions(choices, records, current, catalog) {
37
+ const currentRecord = records.get(String(current));
38
+ const currentTypes = layoutTypes(currentRecord);
39
+ const signature = (types) => JSON.stringify(types);
40
+ const compatible = (types) => types.map(type => type === "subtitle" ? "text" : type).sort();
41
+ for (const option of choices) {
42
+ const [label, description] = sourceDetails[option.source];
43
+ option.sourceLabel = label;
44
+ option.sourceDescription = description;
45
+ if (catalog !== "layouts")
46
+ continue;
47
+ const types = layoutTypes(option.value === current ? currentRecord : records.get(String(option.value)));
48
+ option.placeholders = placeholderSummary(types);
49
+ const same = types && currentTypes && signature(types) === signature(currentTypes);
50
+ const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
51
+ const sameKinds = types && currentTypes && signature([...new Set(compatible(types))]) === signature([...new Set(compatible(currentTypes))]);
52
+ option.layoutGroup = option.value === current ? "Current layout" : !types ? "Unspecified placeholders"
53
+ : same ? "Same placeholders" : equivalent ? "Compatible placeholders" : sameKinds ? "Different counts" : "Other layouts";
54
+ option.related = option.value === current || Boolean(same || equivalent);
55
+ option.suggested = Boolean(same || equivalent);
56
+ }
57
+ const priority = (option) => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
58
+ return choices.sort((a, b) => (catalog === "layouts"
59
+ ? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup)
60
+ : priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", { numeric: true }) || String(a.value).localeCompare(String(b.value), "en"));
61
+ }
62
+ // The installed schema identifies fields; a document field merely named "layout"
63
+ // inside arbitrary metadata is not mistaken for an OPF layout reference.
64
+ export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
65
+ let document;
66
+ try {
67
+ document = JSON.parse(source);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ const root = parseTree(source);
73
+ if (!root)
74
+ return null;
75
+ let node = findNodeAtOffset(root, position, true);
76
+ if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
77
+ node = node.parent.children[1];
78
+ if (node?.type === "object")
79
+ node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
80
+ if (!node || !["string", "number", "boolean"].includes(node.type))
81
+ return null;
82
+ const path = getNodePath(node);
83
+ if (!path.length)
84
+ return null;
85
+ const schema = schemaAtPath(document, path.map(String));
86
+ const variants = schemaVariants(schema);
87
+ const description = String(schema.description ?? "");
88
+ let catalog = catalogFor(description);
89
+ // Audience arrays inherit the catalog context from the array property.
90
+ if (!catalog && typeof path.at(-1) === "number")
91
+ catalog = catalogFor(String(schemaAtPath(document, path.slice(0, -1).map(String)).description ?? ""));
92
+ const options = new Map();
93
+ const records = new Map();
94
+ let unloadedSource = false;
95
+ if (catalog && typeof node.value === "string") {
96
+ const entries = document.catalogs;
97
+ const local = entries?.[catalog];
98
+ unloadedSource = Boolean(local?.source);
99
+ for (const option of getCatalogOptions(catalog)) {
100
+ records.set(option.id, option.record);
101
+ options.set(option.id, { value: option.id, label: option.label, description: typeof option.record.description === "string" ? option.record.description : undefined,
102
+ source: "Built-in catalog" });
103
+ }
104
+ for (const { value, source } of [...(Array.isArray(loadedCatalogs?.[catalog]) ? loadedCatalogs[catalog] : []).map(value => ({ value, source: "Loaded catalog" })),
105
+ ...(Array.isArray(local?.records) ? local.records : []).map(value => ({ value, source: "Document catalog" }))]) {
106
+ if (!value || typeof value !== "object")
107
+ continue;
108
+ const record = value;
109
+ if (typeof record.id !== "string")
110
+ continue;
111
+ // Inline records may omit the standalone document's $schema marker.
112
+ // Supply it only for validation; never change the authored record.
113
+ const schemaId = schemas[catalogSchemaNames[catalog]].$id;
114
+ if (!validateCatalogRecord(catalog, { $schema: schemaId, ...record }).valid) {
115
+ options.delete(record.id);
116
+ records.delete(record.id);
117
+ continue;
118
+ }
119
+ records.set(record.id, record);
120
+ options.set(record.id, { value: record.id, label: typeof record.name === "string" ? record.name : record.id,
121
+ description: typeof record.description === "string" ? record.description : undefined,
122
+ source });
123
+ }
124
+ }
125
+ else {
126
+ for (const variant of variants)
127
+ for (const value of variant.enum ?? []) {
128
+ if (["string", "number", "boolean"].includes(typeof value))
129
+ options.set(JSON.stringify(value), { value, label: String(value), source: "Schema" });
130
+ }
131
+ if (node.type === "boolean" && variants.some(variant => variant.type === "boolean" && variant.const === undefined)) {
132
+ for (const value of [true, false])
133
+ options.set(String(value), { value, label: String(value), source: "Schema" });
134
+ }
135
+ }
136
+ if (!options.size)
137
+ return null;
138
+ const choices = [...options.values()];
139
+ if (!choices.some(option => option.value === node.value))
140
+ choices.unshift({ value: node.value, label: String(node.value), source: "Current value" });
141
+ describeOptions(choices, records, node.value, catalog);
142
+ return { source, path, offset: node.offset, length: node.length, value: node.value,
143
+ label: String(path.at(-1) === "id" || typeof path.at(-1) === "number" ? path.at(-2) : path.at(-1)), options: choices, catalog, unloadedSource };
144
+ }
145
+ export function replaceFieldOption(context, value) {
146
+ if (!context.options.some(option => option.value === value))
147
+ 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);
149
+ const document = JSON.parse(next);
150
+ if (context.path.reduce((current, key) => current?.[key], document) !== value)
151
+ throw new Error("This field has a duplicate key. Resolve it in the JSON before choosing an option.");
152
+ const result = validatePresentation(document);
153
+ if (!result.valid && validatePresentation(JSON.parse(context.source)).valid)
154
+ throw new Error(result.errors[0]?.message ?? "This option is not valid here.");
155
+ return next;
156
+ }
@@ -0,0 +1,47 @@
1
+ import {MapMode,StateEffect,StateField} from '@codemirror/state';
2
+ import {invertedEffects} from '@codemirror/commands';
3
+ import {textInputMap,preferredLineEnding} from './text-input.js';
4
+
5
+ // CodeMirror uses LF positions internally. Keep authored source as a separate
6
+ // field, and store only removed newline spellings in its existing undo history.
7
+ export const setJsonSource=StateEffect.define();
8
+ const restoreEndings=StateEffect.define({map:(endings,changes)=>{
9
+ const mapped=endings.map(({position,ending})=>({position:changes.mapPos(position,1,MapMode.TrackDel),ending})).filter(item=>item.position!==null);
10
+ return mapped.length?mapped:undefined;
11
+ }});
12
+ export const jsonSource=StateField.define({
13
+ create:state=>state.doc.toString(),
14
+ update(source,tr){
15
+ const explicit=tr.effects.findLast(effect=>effect.is(setJsonSource));
16
+ if(explicit){
17
+ if(textInputMap(explicit.value).text!==tr.newDoc.toString())throw new Error('JSON source and code-editor document must agree.');
18
+ return explicit.value;
19
+ }
20
+ if(tr.docChanged){
21
+ const map=textInputMap(source),ending=preferredLineEnding(source),changes=[];
22
+ tr.changes.iterChanges((from,to,_fromB,_toB,insert)=>changes.push({from:map.toSource(from),to:map.toSource(to),insert:insert.toString().replaceAll('\n',ending)}));
23
+ for(const change of changes.reverse())source=source.slice(0,change.from)+change.insert+source.slice(change.to);
24
+ }
25
+ const endings=[...new Map(tr.effects.filter(effect=>effect.is(restoreEndings)).flatMap(effect=>effect.value).map(item=>[item.position,item])).values()];
26
+ if(endings.length){
27
+ const map=textInputMap(source);
28
+ for(const {position,ending} of endings.sort((a,b)=>b.position-a.position)){
29
+ if(tr.newDoc.sliceString(position,position+1)!=='\n')continue;
30
+ source=source.slice(0,map.toSource(position))+ending+source.slice(map.toSource(position+1));
31
+ }
32
+ }
33
+ return source;
34
+ },
35
+ });
36
+ export const jsonSourceHistory=invertedEffects.of(tr=>{
37
+ if(!tr.docChanged)return [];
38
+ const source=tr.startState.field(jsonSource),map=textInputMap(source),endings=[];
39
+ tr.changes.iterChanges((from,to)=>{
40
+ const removed=tr.startState.doc.sliceString(from,to);
41
+ for(let index=removed.indexOf('\n');index!==-1;index=removed.indexOf('\n',index+1)){
42
+ const position=from+index;
43
+ endings.push({position,ending:source.slice(map.toSource(position),map.toSource(position+1))});
44
+ }
45
+ });
46
+ return endings.length?[restoreEndings.of(endings)]:[];
47
+ });
@@ -1,4 +1,5 @@
1
1
  import {richTextContent, updateRichTextInput} from './rich-text.js';
2
+ import {textInputMap} from './text-input.js';
2
3
 
3
4
  /** Native input owns keyboard/IME; caret and selection use the canonical SVG glyphs. */
4
5
  export function createRichTextInput(root, overlay, {path, value, getTarget, onInput, onCommit, onCancel, onFormat, onError}) {
@@ -9,7 +10,7 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
9
10
  input.style.cssText='position:absolute;width:1px;padding:0;border:0;opacity:0;pointer-events:none;resize:none;overflow:hidden;z-index:3';
10
11
  marks.className='opf-rich-selection';marks.setAttribute('aria-hidden','true');marks.style.cssText='position:absolute;inset:0;pointer-events:none';
11
12
  actions.style.cssText='position:absolute;bottom:8px;left:8px;display:flex;gap:6px;pointer-events:auto;z-index:4';
12
- for(const [label,action] of [['Format selection',()=>onFormat(input.selectionStart,input.selectionEnd)],['Done',onCommit]]) {
13
+ for(const [label,action] of [['Format selection',()=>{const map=currentMap();onFormat(map.toSource(input.selectionStart),map.toSource(input.selectionEnd));}],['Done',onCommit]]) {
13
14
  const button=doc.createElement('button');button.type='button';button.textContent=label;
14
15
  button.style.cssText='padding:6px 10px;background:#fff;color:#574774;border:1px solid #d5cce5;border-radius:5px';
15
16
  button.onmousedown=event=>event.preventDefault();button.onclick=action;actions.append(button);
@@ -17,10 +18,11 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
17
18
  overlay.append(marks,input,actions);
18
19
  let current=structuredClone(value), composing=false, change=null, anchor=null, disposed=false, emptyAnchor=null;
19
20
  let history=[{value:structuredClone(current),start:0,end:input.value.length}], historyIndex=0, compositionBase=null;
21
+ const currentMap=()=>textInputMap(richTextContent(current));
20
22
  function remember() {
21
23
  history.splice(historyIndex+1);history.push({value:structuredClone(current),start:input.selectionStart,end:input.selectionEnd});historyIndex++;
22
24
  }
23
- function fragments() { return [...(getTarget(path)?.querySelectorAll('text[data-opf-text-start]')??[])]; }
25
+ function fragments() { return [...(getTarget(path)?.querySelectorAll('text[data-opf-text-start],tspan[data-opf-text-start]')??[])]; }
24
26
  function rect(node,start,end) {
25
27
  const range=doc.createRange();range.setStart(node.firstChild,start);range.setEnd(node.firstChild,end);return range.getBoundingClientRect();
26
28
  }
@@ -45,7 +47,7 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
45
47
  }
46
48
  function update() {
47
49
  if(disposed)return;marks.replaceChildren();
48
- const origin=root.getBoundingClientRect(),start=input.selectionStart,end=input.selectionEnd;
50
+ const map=currentMap(),origin=root.getBoundingClientRect(),start=map.toSource(input.selectionStart),end=map.toSource(input.selectionEnd);
49
51
  const append=(box,caret=false)=>{
50
52
  const mark=doc.createElement('div');mark.className=caret?'opf-rich-caret':'opf-rich-range';
51
53
  mark.style.cssText=`position:absolute;left:${box.left-origin.left}px;top:${box.top-origin.top}px;width:${caret?1.5:box.width}px;height:${Math.max(12,box.height)}px;background:${caret?(box.color??'#6551ba'):'#8975d955'}`;
@@ -78,13 +80,13 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
78
80
  function pointerDown(event) {
79
81
  if(event.button!==0||!getTarget(path)?.contains(event.target))return;
80
82
  event.preventDefault();event.stopPropagation();
81
- anchor=event.shiftKey?input.selectionStart:nearest(event);input.focus({preventScroll:true});
82
- const end=nearest(event);input.setSelectionRange(Math.min(anchor,end),Math.max(anchor,end),end<anchor?'backward':'forward');
83
+ const map=currentMap();anchor=event.shiftKey?map.toSource(input.selectionStart):nearest(event);input.focus({preventScroll:true});
84
+ const end=nearest(event);input.setSelectionRange(map.toInput(Math.min(anchor,end)),map.toInput(Math.max(anchor,end)),end<anchor?'backward':'forward');
83
85
  root.setPointerCapture(event.pointerId);update();
84
86
  }
85
87
  function pointerMove(event) {
86
- if(anchor===null)return;event.preventDefault();const end=nearest(event);
87
- input.setSelectionRange(Math.min(anchor,end),Math.max(anchor,end),end<anchor?'backward':'forward');update();
88
+ if(anchor===null)return;event.preventDefault();const end=nearest(event),map=currentMap();
89
+ input.setSelectionRange(map.toInput(Math.min(anchor,end)),map.toInput(Math.max(anchor,end)),end<anchor?'backward':'forward');update();
88
90
  }
89
91
  function pointerUp(event) {if(anchor!==null){anchor=null;if(root.hasPointerCapture(event.pointerId))root.releasePointerCapture(event.pointerId);}}
90
92
  function undo(direction) {
@@ -47,7 +47,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
47
47
  }
48
48
  function restore() {
49
49
  if (!selected) return;
50
- const nodes=[...root.querySelectorAll('text[data-opf-text-start]')].filter(node=>node.closest('[data-opf-path]')?.getAttribute('data-opf-path')===selected.path);
50
+ const nodes=[...root.querySelectorAll('text[data-opf-text-start],tspan[data-opf-text-start]')].filter(node=>node.closest('[data-opf-path]')?.getAttribute('data-opf-path')===selected.path);
51
51
  const a=nodes.find(node=>+node.dataset.opfTextStart<=selected.start&&+node.dataset.opfTextEnd>selected.start);
52
52
  const b=nodes.findLast(node=>+node.dataset.opfTextStart<selected.end&&+node.dataset.opfTextEnd>=selected.end);
53
53
  if (!a?.firstChild || !b?.firstChild) return;
@@ -101,8 +101,8 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
101
101
  }});
102
102
  function endpoint(node,offset) {
103
103
  if(node?.nodeType!==3) return null;
104
- const element=node.parentElement?.closest('text[data-opf-text-start]');
105
- if(!element||!root.contains(element)||element.hasAttribute('data-opf-code-role'))return null;
104
+ const element=node.parentElement?.closest('text[data-opf-text-start],tspan[data-opf-text-start]');
105
+ if(!element||!root.contains(element)||element.closest('[data-opf-code-role]'))return null;
106
106
  return {path:element.closest('[data-opf-path]')?.getAttribute('data-opf-path'),offset:Number(element.dataset.opfTextStart)+offset};
107
107
  }
108
108
  function selectionChanged() {
@@ -8,5 +8,5 @@ export declare function richTextContent(value:string|TextRun[]):string;
8
8
  /** Nonempty UTF-16 range on grapheme boundaries; null removes an override. */
9
9
  export declare function formatRichTextRange(value:string|TextRun[],start:number,end:number,format:TextRunFormat):TextRun[];
10
10
  export declare function replaceRichTextRange(value:string|TextRun[],start:number,end:number,replacement:string):TextRun[];
11
- /** Preserve run metadata across a native input change, on grapheme boundaries. */
11
+ /** Preserve run metadata and untouched source line endings. Change offsets use the textarea's LF-normalized UTF-16 text; inserted newlines follow the source's first ending. */
12
12
  export declare function updateRichTextInput(value:string|TextRun[],nextText:string,change?:{start:number;end:number;inputType?:string}):TextRun[];
package/dist/rich-text.js CHANGED
@@ -1,4 +1,6 @@
1
- /** UTF-16 ranges match DOM Selection; boundaries must be whole graphemes. */
1
+ import {textInputMap,preferredLineEnding} from './text-input.js';
2
+
3
+ /** UTF-16 ranges match the source; boundaries must be whole graphemes. */
2
4
  export function richTextContent(value) {
3
5
  return runs(value).map(run => typeof run === 'string' ? run : run.text).join('');
4
6
  }
@@ -72,8 +74,9 @@ export function replaceRichTextRange(value, start, end, replacement) {
72
74
 
73
75
  /** Map a native plain-text input change back onto styled runs without flattening them. */
74
76
  export function updateRichTextInput(value, nextText, change) {
75
- const previous = richTextContent(value);
76
77
  if (typeof nextText !== 'string') throw new TypeError('Input text must be a string.');
78
+ const source=richTextContent(value),mapping=textInputMap(source),previous=mapping.text;
79
+ nextText=nextText.replace(/\r\n|\r/g,'\n');
77
80
  if (previous === nextText) return structuredClone(runs(value));
78
81
  if (!nextText) return [piece(runs(value)[0] ?? "", "")];
79
82
  const segments = text => [...new Intl.Segmenter(undefined, {granularity:'grapheme'}).segment(text)].map(part => part.segment);
@@ -95,12 +98,12 @@ export function updateRichTextInput(value, nextText, change) {
95
98
  if (a >= 0 && b >= a && b <= previous.length && nextText.length >= left.length+right.length && nextText.startsWith(left) && nextText.endsWith(right)) {
96
99
  // Validate exact native selection boundaries before trusting their affinity.
97
100
  try {
98
- range(value,a,b,true);
101
+ range(value,mapping.toSource(a),mapping.toSource(b),true);
99
102
  start=a;end=b;replacement=nextText.slice(left.length,nextText.length-right.length);
100
103
  } catch { /* Native deletion can split a cluster; use the whole-grapheme diff. */ }
101
104
  }
102
105
  }
103
- const result = replaceRichTextRange(value,start,end,replacement), compact = [];
106
+ const result = replaceRichTextRange(value,mapping.toSource(start),mapping.toSource(end),replacement.replaceAll('\n',preferredLineEnding(source))), compact = [];
104
107
  const style = run => typeof run === 'string' ? null : Object.fromEntries(Object.entries(run).filter(([key]) => key !== 'text').sort(([a],[b]) => a.localeCompare(b)));
105
108
  for (const run of result) {
106
109
  const last = compact.at(-1);
@@ -0,0 +1,33 @@
1
+ /** Map textarea LF offsets to the original UTF-16 source, including CRLF pairs. */
2
+ export function textInputMap(source) {
3
+ const offsets=[0];
4
+ for(let i=0;i<source.length;i++) {
5
+ if(source[i]==='\r'&&source[i+1]==='\n')i++;
6
+ offsets.push(i+1);
7
+ }
8
+ return {
9
+ text:source.replace(/\r\n|\r/g,'\n'),
10
+ toSource(offset) {
11
+ if(!Number.isInteger(offset)||offset<0||offset>=offsets.length)throw new RangeError('Input offset is outside the text.');
12
+ return offsets[offset];
13
+ },
14
+ toInput(offset) {
15
+ if(!Number.isInteger(offset)||offset<0||offset>source.length)throw new RangeError('Source offset is outside the text.');
16
+ let lo=0,hi=offsets.length-1;
17
+ while(lo<hi){const mid=Math.floor((lo+hi)/2);if(offsets[mid]<offset)lo=mid+1;else hi=mid;}
18
+ return lo;
19
+ },
20
+ };
21
+ }
22
+
23
+ export const preferredLineEnding=source=>/\r\n|\r|\n/.exec(source)?.[0]??'\n';
24
+
25
+ /** Textareas normalize line endings; retain unchanged source bytes around an edit. */
26
+ export function preserveTextLineEndings(original, input) {
27
+ const mapping=textInputMap(original),normalized=mapping.text,next=input.replace(/\r\n|\r/g,'\n');
28
+ if(normalized===next)return original;
29
+ let prefix=0,suffix=0;
30
+ while(prefix<normalized.length&&prefix<next.length&&normalized[prefix]===next[prefix])prefix++;
31
+ while(suffix<normalized.length-prefix&&suffix<next.length-prefix&&normalized[normalized.length-1-suffix]===next[next.length-1-suffix])suffix++;
32
+ return original.slice(0,mapping.toSource(prefix))+next.slice(prefix,next.length-suffix).replaceAll('\n',preferredLineEnding(original))+original.slice(mapping.toSource(normalized.length-suffix));
33
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "engines": {
9
- "node": ">=20"
9
+ "node": "24.x"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -69,26 +69,48 @@
69
69
  "./layout": {
70
70
  "types": "./dist/layout.d.ts",
71
71
  "default": "./dist/layout.js"
72
+ },
73
+ "./json-editor": {
74
+ "types": "./dist/json-editor.d.ts",
75
+ "default": "./dist/json-editor.js"
76
+ },
77
+ "./json-options": {
78
+ "types": "./dist/json-options.d.ts",
79
+ "default": "./dist/json-options.js"
72
80
  }
73
81
  },
74
82
  "types": "./dist/index.d.ts",
75
83
  "sideEffects": false,
76
84
  "scripts": {
77
85
  "build": "node scripts/build.mjs",
78
- "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",
79
- "test": "npm run build && 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",
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",
80
88
  "validate": "node scripts/validate-package.mjs",
81
89
  "test:packed": "node test/packed-install.mjs",
82
90
  "prepack": "npm run build",
83
91
  "build:playground": "node scripts/build-playground.mjs",
84
92
  "test:playground": "npm run build:playground && node test/playground-pptx.mjs",
85
- "test:code-browser": "node test/code-browser.mjs"
93
+ "test:code-browser": "node test/code-browser.mjs",
94
+ "test:metric-browser": "node test/metric-browser.mjs",
95
+ "test:rich-input-browser": "node test/rich-input-browser.mjs",
96
+ "test:json": "npm run build && node test/json-source.mjs && node test/json-options.mjs",
97
+ "test:json-browser": "node test/json-editor-browser.mjs"
86
98
  },
87
99
  "dependencies": {
88
- "@openpresentation/opf": "^0.9.0"
100
+ "@codemirror/autocomplete": "6.20.3",
101
+ "@codemirror/commands": "6.11.0",
102
+ "@codemirror/lang-json": "6.0.2",
103
+ "@codemirror/language": "6.12.4",
104
+ "@codemirror/lint": "6.9.7",
105
+ "@codemirror/search": "6.7.2",
106
+ "@codemirror/state": "6.7.4",
107
+ "@codemirror/view": "6.43.11",
108
+ "@lezer/highlight": "1.2.3",
109
+ "@openpresentation/opf": "^0.10.0",
110
+ "jsonc-parser": "3.3.1"
89
111
  },
90
112
  "peerDependencies": {
91
- "@openpresentation/opf-render": "^0.7.0"
113
+ "@openpresentation/opf-render": "^0.8.0"
92
114
  },
93
115
  "peerDependenciesMeta": {
94
116
  "@openpresentation/opf-render": {
@@ -96,8 +118,8 @@
96
118
  }
97
119
  },
98
120
  "devDependencies": {
99
- "@openpresentation/opf-pptx": "0.7.0",
100
- "@openpresentation/opf-render": "0.7.0",
121
+ "@openpresentation/opf-pptx": "0.8.0",
122
+ "@openpresentation/opf-render": "0.8.0",
101
123
  "esbuild": "0.28.2",
102
124
  "playwright": "1.63.0"
103
125
  }