@openpresentation/opf-editor 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -3
- package/dist/canvas-fields.js +2 -12
- package/dist/canvas.js +32 -11
- package/dist/index.js +1 -1
- package/dist/json-editor.d.ts +39 -0
- package/dist/json-editor.js +278 -0
- package/dist/json-menu.js +81 -0
- package/dist/json-options.d.ts +37 -0
- package/dist/json-options.js +193 -0
- package/dist/json-source.js +47 -0
- package/dist/layout-placeholders.js +56 -0
- package/dist/rich-text-input.js +9 -7
- package/dist/rich-text-toolbar.js +3 -3
- package/dist/rich-text.d.ts +1 -1
- package/dist/rich-text.js +7 -4
- package/dist/text-input.js +33 -0
- package/package.json +31 -9
package/README.md
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
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.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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
3
9
|
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
10
|
|
|
5
|
-
|
|
11
|
+
### Reusable JSON control (0.7.0)
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { mountJsonCodeEditor } from '@openpresentation/opf-editor/json-editor';
|
|
17
|
+
const control = mountJsonCodeEditor(container, {
|
|
18
|
+
code: source,
|
|
19
|
+
label: 'OPF JSON document',
|
|
20
|
+
lineNumbers: true,
|
|
21
|
+
catalogs: loadedCatalogRecords,
|
|
22
|
+
onChange: nextSource => receiveDraft(nextSource),
|
|
23
|
+
onError: error => showError(error),
|
|
24
|
+
});
|
|
25
|
+
// After a preview edit, send the updated source back through the same control.
|
|
26
|
+
control.update(nextSource);
|
|
27
|
+
// On unmount:
|
|
28
|
+
control.destroy();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
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
42
|
|
|
7
43
|
## Scope
|
|
8
44
|
|
|
@@ -34,7 +70,7 @@ await canvas.ready;
|
|
|
34
70
|
|
|
35
71
|
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
72
|
|
|
37
|
-
These APIs were introduced in 0.1.0. Version 0.
|
|
73
|
+
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
74
|
|
|
39
75
|
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
76
|
|
|
@@ -174,7 +210,7 @@ This repo does not require an npm automation token when Trusted Publishing is co
|
|
|
174
210
|
|
|
175
211
|
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
212
|
|
|
177
|
-
Version 0.
|
|
213
|
+
Version 0.7.1 requires `@openpresentation/opf@^0.10.1`. The optional renderer peer requires `@openpresentation/opf-render@^0.8.1`. 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
214
|
|
|
179
215
|
## Local interactive demo
|
|
180
216
|
|
|
@@ -215,6 +251,10 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
|
|
|
215
251
|
|
|
216
252
|
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
253
|
|
|
254
|
+
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.
|
|
255
|
+
|
|
256
|
+
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.
|
|
257
|
+
|
|
218
258
|
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
259
|
|
|
220
260
|
|
package/dist/canvas-fields.js
CHANGED
|
@@ -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
|
@@ -136,11 +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') ||
|
|
141
|
+
(node.hasAttribute('data-opf-metric-container') && ['string','number'].includes(typeof value)) ||
|
|
140
142
|
(!item && !node.matches("g") && !node.matches("image")) ||
|
|
141
143
|
value === undefined ||
|
|
142
144
|
path === `slides.${slideIndex}` ||
|
|
143
|
-
path.includes(".design.")
|
|
145
|
+
(path.includes(".design.")&&!node.closest('[data-opf-furniture-editable="true"]'))
|
|
144
146
|
)
|
|
145
147
|
continue;
|
|
146
148
|
// Prefer the containing group over its duplicate text/shape trace attributes.
|
|
@@ -159,7 +161,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
159
161
|
);
|
|
160
162
|
if (node.matches("g")) {
|
|
161
163
|
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)};
|
|
164
|
+
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
165
|
const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
|
|
164
166
|
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
167
|
const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
@@ -261,11 +263,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
261
263
|
renderOptions,
|
|
262
264
|
).slides[slideIndex].geometry;
|
|
263
265
|
const item = geometry.items.find((item) => item.path === active.path);
|
|
266
|
+
const metricLayout=geometry.items.find(item=>item.metricLayout?.parts.some(part=>part.path===active.path))?.metricLayout;
|
|
267
|
+
const metricPart=metricLayout?.parts.find(part=>part.path===active.path);
|
|
268
|
+
const timelinePart=geometry.items.find(item=>item.timelineLayout?.parts.some(part=>part.path===active.path))?.timelineLayout?.parts.find(part=>part.path===active.path);
|
|
269
|
+
const furniturePart=geometry.furniture?.parts.find(part=>part.type==='text'&&part.path===active.path);
|
|
270
|
+
const internalPart=metricPart??timelinePart??furniturePart;
|
|
264
271
|
const allText = target.querySelectorAll("text");
|
|
265
|
-
const lineHeight =
|
|
272
|
+
const lineHeight = internalPart?.fit?.lineHeight ?? (
|
|
266
273
|
allText.length > 1
|
|
267
274
|
? Number(allText[1].getAttribute("y")) - Number(text.getAttribute("y"))
|
|
268
|
-
: fontSize * 1.22;
|
|
275
|
+
: fontSize * 1.22);
|
|
269
276
|
const canvas = doc.createElement("canvas"),
|
|
270
277
|
ctx = canvas.getContext("2d");
|
|
271
278
|
ctx.font = `${font.fontStyle} ${font.fontWeight} ${fontSize}px ${font.fontFamily}`;
|
|
@@ -278,17 +285,18 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
278
285
|
const bounds = target.getBBox(),
|
|
279
286
|
anchor = text.getAttribute("text-anchor");
|
|
280
287
|
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;
|
|
288
|
+
const width = internalPart?.box.width ?? item?.box.width ?? (tracedWidth > 0 ? tracedWidth : Math.max(80, bounds.width + 20));
|
|
289
|
+
const left = internalPart?.box.x ?? (anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x);
|
|
290
|
+
const visibleText=active.composing||active.isCode||active.isMetric;
|
|
283
291
|
Object.assign(active.input.style, {
|
|
284
292
|
fontFamily: font.fontFamily,
|
|
285
293
|
fontSize: `${fontSize * scale}px`,
|
|
286
294
|
fontWeight: font.fontWeight,
|
|
287
295
|
fontStyle: font.fontStyle,
|
|
288
296
|
lineHeight: `${lineHeight * scale}px`,
|
|
289
|
-
color:
|
|
297
|
+
color: visibleText ? font.fill : "transparent",
|
|
290
298
|
caretColor: font.fill,
|
|
291
|
-
textAlign: anchor === "middle" ? "center" : anchor === "end" ? "right" : "left",
|
|
299
|
+
textAlign: furniturePart?.alignment ?? timelinePart?.alignment ?? metricLayout?.alignment ?? (anchor === "middle" ? "center" : anchor === "end" ? "right" : "left"),
|
|
292
300
|
width: `${width * scale}px`,
|
|
293
301
|
height: `${Math.max(lineHeight, allText.length * lineHeight) * scale + 2}px`,
|
|
294
302
|
minHeight: "0",
|
|
@@ -297,9 +305,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
297
305
|
active.input.style.left = `${svgRect.left - rootRect.left + (left - viewBox.x) * scale}px`;
|
|
298
306
|
active.input.style.top = `${svgRect.top - rootRect.top + (y - viewBox.y) * scale}px`;
|
|
299
307
|
// When the input supplies visible text, its selected text needs the same fill.
|
|
300
|
-
active.input.style.setProperty('--opf-inline-selection-color',
|
|
308
|
+
active.input.style.setProperty('--opf-inline-selection-color',visibleText ? font.fill : 'transparent');
|
|
301
309
|
// Other text keeps the canonical SVG visible beneath the transparent input.
|
|
302
|
-
target.style.opacity =
|
|
310
|
+
target.style.opacity = visibleText ? "0" : "1";
|
|
303
311
|
}
|
|
304
312
|
function beginEdit(path) {
|
|
305
313
|
if (disposed) return;
|
|
@@ -327,6 +335,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
327
335
|
input.value = String(value);
|
|
328
336
|
input.setAttribute("aria-label", `Edit ${path.split(".").at(-1)} inline`);
|
|
329
337
|
const isCode=text.hasAttribute('data-opf-code-role');
|
|
338
|
+
const isMetric=text.hasAttribute('data-opf-metric-role');
|
|
339
|
+
const isTimeline=!!text.closest('[data-opf-timeline-role]');
|
|
340
|
+
const isFurniture=!!text.closest('[data-opf-furniture-editable="true"]');
|
|
330
341
|
input.spellcheck = !isCode;
|
|
331
342
|
input.style.cssText =
|
|
332
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";
|
|
@@ -336,12 +347,15 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
336
347
|
base: JSON.stringify(value),
|
|
337
348
|
originalValue:value,
|
|
338
349
|
isCode,
|
|
350
|
+
isMetric,
|
|
339
351
|
type: typeof value,
|
|
340
352
|
input,
|
|
341
353
|
valid: true,
|
|
342
354
|
};
|
|
343
355
|
overlay.append(input);
|
|
344
356
|
if(isCode){input.style.background='#111827';input.style.tabSize='4';input.style.overflow='auto';}
|
|
357
|
+
if(isMetric){input.style.tabSize='4';input.style.overflow='auto';}
|
|
358
|
+
if(isTimeline||isFurniture)input.style.tabSize='4';
|
|
345
359
|
// Only offer rich text where the canonical schema accepts TextRun[].
|
|
346
360
|
if (typeof value === "string") {
|
|
347
361
|
try {
|
|
@@ -362,7 +376,14 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
362
376
|
} catch { /* This scalar field does not accept rich text. */ }
|
|
363
377
|
}
|
|
364
378
|
positionInput();
|
|
365
|
-
input.addEventListener("input",
|
|
379
|
+
input.addEventListener("input", () => {
|
|
380
|
+
// Compare each browser edit with the last canonical draft. Comparing
|
|
381
|
+
// only with the value at focus would normalize untouched mixed line
|
|
382
|
+
// endings between two edits in different parts of the same field.
|
|
383
|
+
if (active?.input===input && active.type==='string')
|
|
384
|
+
active.originalValue=parseCanvasValue(input.value,'string',active.originalValue);
|
|
385
|
+
queueDraft();
|
|
386
|
+
});
|
|
366
387
|
input.addEventListener("compositionstart", () => {
|
|
367
388
|
if (active) {
|
|
368
389
|
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, presentation:document, ...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,fieldOptionEdit} 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{const edit=fieldOptionEdit(context,option.value);api.replace(edit.from,edit.to,edit.insert);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,37 @@
|
|
|
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
|
+
/** Declared kinds in layout order, used to create missing blank content. */
|
|
17
|
+
placeholderTypes?: string[];
|
|
18
|
+
related?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface JsonFieldContext {
|
|
21
|
+
source: string;
|
|
22
|
+
path: (string | number)[];
|
|
23
|
+
/** Original-source UTF-16 token range, including surrounding JSON quotes. */
|
|
24
|
+
offset: number;
|
|
25
|
+
length: number;
|
|
26
|
+
value: JsonOptionValue;
|
|
27
|
+
label: string;
|
|
28
|
+
options: JsonFieldOption[];
|
|
29
|
+
catalog?: string;
|
|
30
|
+
unloadedSource: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Null for invalid JSON or a field with no categorical choices. */
|
|
33
|
+
export declare function getJsonFieldContext(source: string, position: number, loadedCatalogs?: JsonCatalogContext): JsonFieldContext | null;
|
|
34
|
+
/** Replace the token and add missing layout payloads. Hosts must reject stale sources. */
|
|
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};
|
|
@@ -0,0 +1,193 @@
|
|
|
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 { applyEdits, findNodeAtLocation, findNodeAtOffset, getNodePath, modify, parseTree } from "jsonc-parser";
|
|
5
|
+
import { populateLayoutPlaceholders } from "./layout-placeholders.js";
|
|
6
|
+
function catalogFor(description) {
|
|
7
|
+
return catalogKinds.find(kind => description.includes(`catalogs.${kind}`)
|
|
8
|
+
|| new RegExp(`\\b${kind}['’]? catalog`).test(description));
|
|
9
|
+
}
|
|
10
|
+
// Compare declared placeholders, including multiplicity. This describes a layout
|
|
11
|
+
// contract, not a claim that arbitrary slide content will render without overflow.
|
|
12
|
+
function layoutTypes(record) {
|
|
13
|
+
if (!Array.isArray(record?.placeholders))
|
|
14
|
+
return undefined;
|
|
15
|
+
return record.placeholders.map(p => p.type).sort();
|
|
16
|
+
}
|
|
17
|
+
function placeholderSummary(types) {
|
|
18
|
+
if (!types)
|
|
19
|
+
return "Placeholders not specified";
|
|
20
|
+
if (!types.length)
|
|
21
|
+
return "No placeholders";
|
|
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"];
|
|
24
|
+
return [...new Set(types)].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map(type => {
|
|
25
|
+
const count = types.filter(value => value === type).length;
|
|
26
|
+
return `${labels[type] ?? type}${count > 1 ? ` × ${count}` : ""}`;
|
|
27
|
+
}).join(" + ");
|
|
28
|
+
}
|
|
29
|
+
const sourceDetails = {
|
|
30
|
+
"Built-in catalog": ["Standard OPF", "Included with OPF and available offline."],
|
|
31
|
+
"Loaded catalog": ["Provided by app", "Additional records supplied to the preview by this app; these are not fetched from a URL."],
|
|
32
|
+
"Document catalog": ["In this document", "Defined in this document’s catalogs. Takes precedence over app and standard records with the same ID."],
|
|
33
|
+
"Current value": ["Custom value", "The current value is not in the available catalogs."],
|
|
34
|
+
"Schema": ["OPF option", "Allowed by the OPF schema."],
|
|
35
|
+
};
|
|
36
|
+
const layoutGroups = ["Current layout", "Same placeholders", "Compatible placeholders", "Different counts", "Other layouts", "Unspecified placeholders"];
|
|
37
|
+
function describeOptions(choices, records, current, catalog) {
|
|
38
|
+
const currentRecord = records.get(String(current));
|
|
39
|
+
const currentTypes = layoutTypes(currentRecord);
|
|
40
|
+
const signature = (types) => JSON.stringify(types);
|
|
41
|
+
const compatible = (types) => types.map(type => type === "subtitle" ? "text" : type).sort();
|
|
42
|
+
for (const option of choices) {
|
|
43
|
+
const [label, description] = sourceDetails[option.source];
|
|
44
|
+
option.sourceLabel = label;
|
|
45
|
+
option.sourceDescription = description;
|
|
46
|
+
if (catalog !== "layouts")
|
|
47
|
+
continue;
|
|
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);
|
|
50
|
+
option.placeholders = placeholderSummary(types);
|
|
51
|
+
const same = types && currentTypes && signature(types) === signature(currentTypes);
|
|
52
|
+
const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
|
|
53
|
+
const sameKinds = types && currentTypes && signature([...new Set(compatible(types))]) === signature([...new Set(compatible(currentTypes))]);
|
|
54
|
+
option.layoutGroup = option.value === current ? "Current layout" : !types ? "Unspecified placeholders"
|
|
55
|
+
: same ? "Same placeholders" : equivalent ? "Compatible placeholders" : sameKinds ? "Different counts" : "Other layouts";
|
|
56
|
+
option.related = option.value === current || Boolean(same || equivalent);
|
|
57
|
+
option.suggested = Boolean(same || equivalent);
|
|
58
|
+
}
|
|
59
|
+
const priority = (option) => option.source === "Document catalog" ? 2 : option.source === "Loaded catalog" ? 1 : 0;
|
|
60
|
+
return choices.sort((a, b) => (catalog === "layouts"
|
|
61
|
+
? layoutGroups.indexOf(a.layoutGroup) - layoutGroups.indexOf(b.layoutGroup)
|
|
62
|
+
: priority(b) - priority(a)) || a.label.localeCompare(b.label, "en", { numeric: true }) || String(a.value).localeCompare(String(b.value), "en"));
|
|
63
|
+
}
|
|
64
|
+
// The installed schema identifies fields; a document field merely named "layout"
|
|
65
|
+
// inside arbitrary metadata is not mistaken for an OPF layout reference.
|
|
66
|
+
export function getJsonFieldContext(source, position, loadedCatalogs = {}) {
|
|
67
|
+
let document;
|
|
68
|
+
try {
|
|
69
|
+
document = JSON.parse(source);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const root = parseTree(source);
|
|
75
|
+
if (!root)
|
|
76
|
+
return null;
|
|
77
|
+
let node = findNodeAtOffset(root, position, true);
|
|
78
|
+
if (node?.parent?.type === "property" && node.parent.children?.[0] === node)
|
|
79
|
+
node = node.parent.children[1];
|
|
80
|
+
if (node?.type === "object")
|
|
81
|
+
node = node.children?.find(property => property.children?.[0]?.value === "id")?.children?.[1];
|
|
82
|
+
if (!node || !["string", "number", "boolean"].includes(node.type))
|
|
83
|
+
return null;
|
|
84
|
+
const path = getNodePath(node);
|
|
85
|
+
if (!path.length)
|
|
86
|
+
return null;
|
|
87
|
+
const schema = schemaAtPath(document, path.map(String));
|
|
88
|
+
const variants = schemaVariants(schema);
|
|
89
|
+
const description = String(schema.description ?? "");
|
|
90
|
+
let catalog = catalogFor(description);
|
|
91
|
+
// Audience arrays inherit the catalog context from the array property.
|
|
92
|
+
if (!catalog && typeof path.at(-1) === "number")
|
|
93
|
+
catalog = catalogFor(String(schemaAtPath(document, path.slice(0, -1).map(String)).description ?? ""));
|
|
94
|
+
const options = new Map();
|
|
95
|
+
const records = new Map();
|
|
96
|
+
let unloadedSource = false;
|
|
97
|
+
if (catalog && typeof node.value === "string") {
|
|
98
|
+
const entries = document.catalogs;
|
|
99
|
+
const local = entries?.[catalog];
|
|
100
|
+
unloadedSource = Boolean(local?.source);
|
|
101
|
+
for (const option of getCatalogOptions(catalog)) {
|
|
102
|
+
records.set(option.id, option.record);
|
|
103
|
+
options.set(option.id, { value: option.id, label: option.label, description: typeof option.record.description === "string" ? option.record.description : undefined,
|
|
104
|
+
source: "Built-in catalog" });
|
|
105
|
+
}
|
|
106
|
+
for (const { value, source } of [...(Array.isArray(loadedCatalogs?.[catalog]) ? loadedCatalogs[catalog] : []).map(value => ({ value, source: "Loaded catalog" })),
|
|
107
|
+
...(Array.isArray(local?.records) ? local.records : []).map(value => ({ value, source: "Document catalog" }))]) {
|
|
108
|
+
if (!value || typeof value !== "object")
|
|
109
|
+
continue;
|
|
110
|
+
const record = value;
|
|
111
|
+
if (typeof record.id !== "string")
|
|
112
|
+
continue;
|
|
113
|
+
// Inline records may omit the standalone document's $schema marker.
|
|
114
|
+
// Supply it only for validation; never change the authored record.
|
|
115
|
+
const schemaId = schemas[catalogSchemaNames[catalog]].$id;
|
|
116
|
+
if (!validateCatalogRecord(catalog, { $schema: schemaId, ...record }).valid) {
|
|
117
|
+
options.delete(record.id);
|
|
118
|
+
records.delete(record.id);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
records.set(record.id, record);
|
|
122
|
+
options.set(record.id, { value: record.id, label: typeof record.name === "string" ? record.name : record.id,
|
|
123
|
+
description: typeof record.description === "string" ? record.description : undefined,
|
|
124
|
+
source });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
for (const variant of variants)
|
|
129
|
+
for (const value of variant.enum ?? []) {
|
|
130
|
+
if (["string", "number", "boolean"].includes(typeof value))
|
|
131
|
+
options.set(JSON.stringify(value), { value, label: String(value), source: "Schema" });
|
|
132
|
+
}
|
|
133
|
+
if (node.type === "boolean" && variants.some(variant => variant.type === "boolean" && variant.const === undefined)) {
|
|
134
|
+
for (const value of [true, false])
|
|
135
|
+
options.set(String(value), { value, label: String(value), source: "Schema" });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!options.size)
|
|
139
|
+
return null;
|
|
140
|
+
const choices = [...options.values()];
|
|
141
|
+
if (!choices.some(option => option.value === node.value))
|
|
142
|
+
choices.unshift({ value: node.value, label: String(node.value), source: "Current value" });
|
|
143
|
+
describeOptions(choices, records, node.value, catalog);
|
|
144
|
+
return { source, path, offset: node.offset, length: node.length, value: node.value,
|
|
145
|
+
label: String(path.at(-1) === "id" || typeof path.at(-1) === "number" ? path.at(-2) : path.at(-1)), options: choices, catalog, unloadedSource };
|
|
146
|
+
}
|
|
147
|
+
export function replaceFieldOption(context, value) {
|
|
148
|
+
if (!context.options.some(option => option.value === value))
|
|
149
|
+
throw new Error("This choice is no longer available.");
|
|
150
|
+
let next = context.source.slice(0, context.offset) + JSON.stringify(value) + context.source.slice(context.offset + context.length);
|
|
151
|
+
const document = JSON.parse(next);
|
|
152
|
+
if (context.path.reduce((current, key) => current?.[key], document) !== value)
|
|
153
|
+
throw new Error("This field has a duplicate key. Resolve it in the JSON before choosing an option.");
|
|
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));
|
|
161
|
+
if (!result.valid && validatePresentation(JSON.parse(context.source)).valid)
|
|
162
|
+
throw new Error(result.errors[0]?.message ?? "This option is not valid here.");
|
|
163
|
+
return next;
|
|
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,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
|
+
});
|
|
@@ -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
|
+
}
|
package/dist/rich-text-input.js
CHANGED
|
@@ -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.
|
|
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() {
|
package/dist/rich-text.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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": "
|
|
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
|
-
"@
|
|
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.1",
|
|
110
|
+
"jsonc-parser": "3.3.1"
|
|
89
111
|
},
|
|
90
112
|
"peerDependencies": {
|
|
91
|
-
"@openpresentation/opf-render": "^0.
|
|
113
|
+
"@openpresentation/opf-render": "^0.8.1"
|
|
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.
|
|
100
|
-
"@openpresentation/opf-render": "0.
|
|
121
|
+
"@openpresentation/opf-pptx": "0.8.1",
|
|
122
|
+
"@openpresentation/opf-render": "0.8.1",
|
|
101
123
|
"esbuild": "0.28.2",
|
|
102
124
|
"playwright": "1.63.0"
|
|
103
125
|
}
|