@openpresentation/opf-editor 0.5.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 +39 -3
- package/dist/canvas-fields.js +5 -2
- package/dist/canvas.js +44 -14
- 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 +33 -0
- package/dist/json-options.js +156 -0
- package/dist/json-source.js +47 -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 +32 -9
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 = [],
|
|
@@ -36,7 +38,7 @@ export function getEditableFields(value, path) {
|
|
|
36
38
|
walk(value, root, []);
|
|
37
39
|
return { fields, arrays };
|
|
38
40
|
}
|
|
39
|
-
export function parseCanvasValue(text, type) {
|
|
41
|
+
export function parseCanvasValue(text, type, original) {
|
|
40
42
|
if (type === "number") {
|
|
41
43
|
if (!String(text).trim() || !Number.isFinite(Number(text)))
|
|
42
44
|
throw new Error("Enter a finite number.");
|
|
@@ -47,8 +49,9 @@ export function parseCanvasValue(text, type) {
|
|
|
47
49
|
if (text === "null") return null;
|
|
48
50
|
throw new Error("This field must be null.");
|
|
49
51
|
}
|
|
50
|
-
return text;
|
|
52
|
+
return type === 'string' && typeof original === 'string' ? preserveTextLineEndings(original,String(text)) : text;
|
|
51
53
|
}
|
|
54
|
+
|
|
52
55
|
export function createCanvasDraft(document, path, value) {
|
|
53
56
|
const draft = applyJsonPatch(
|
|
54
57
|
document,
|
package/dist/canvas.js
CHANGED
|
@@ -52,7 +52,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
52
52
|
notice.hidden = true;
|
|
53
53
|
const style = doc.createElement("style");
|
|
54
54
|
style.textContent =
|
|
55
|
-
".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:transparent}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
|
|
55
|
+
".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:var(--opf-inline-selection-color,transparent)}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
|
|
56
56
|
root.append(style, preview, overlay, notice);
|
|
57
57
|
container.replaceChildren(root);
|
|
58
58
|
const report = (error) => {
|
|
@@ -135,6 +135,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
135
135
|
const value = getValueAtPath(document, path);
|
|
136
136
|
if (
|
|
137
137
|
seen.has(path) ||
|
|
138
|
+
node.getAttribute('data-opf-generated') === 'true' ||
|
|
139
|
+
(node.hasAttribute('data-opf-code-container') && typeof value === 'string') ||
|
|
140
|
+
(node.hasAttribute('data-opf-metric-container') && ['string','number'].includes(typeof value)) ||
|
|
138
141
|
(!item && !node.matches("g") && !node.matches("image")) ||
|
|
139
142
|
value === undefined ||
|
|
140
143
|
path === `slides.${slideIndex}` ||
|
|
@@ -157,6 +160,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
157
160
|
);
|
|
158
161
|
if (node.matches("g")) {
|
|
159
162
|
let bounds = node.getBBox();
|
|
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)};
|
|
160
164
|
const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
|
|
161
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)};
|
|
162
166
|
const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
@@ -228,7 +232,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
228
232
|
return;
|
|
229
233
|
}
|
|
230
234
|
try {
|
|
231
|
-
const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type);
|
|
235
|
+
const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type, active.originalValue);
|
|
232
236
|
const draft = createCanvasDraft(editor.document, active.path, value);
|
|
233
237
|
clearNotice();
|
|
234
238
|
active.valid = true;
|
|
@@ -258,11 +262,15 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
258
262
|
renderOptions,
|
|
259
263
|
).slides[slideIndex].geometry;
|
|
260
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;
|
|
261
269
|
const allText = target.querySelectorAll("text");
|
|
262
|
-
const lineHeight =
|
|
270
|
+
const lineHeight = internalPart?.fit?.lineHeight ?? (
|
|
263
271
|
allText.length > 1
|
|
264
272
|
? Number(allText[1].getAttribute("y")) - Number(text.getAttribute("y"))
|
|
265
|
-
: fontSize * 1.22;
|
|
273
|
+
: fontSize * 1.22);
|
|
266
274
|
const canvas = doc.createElement("canvas"),
|
|
267
275
|
ctx = canvas.getContext("2d");
|
|
268
276
|
ctx.font = `${font.fontStyle} ${font.fontWeight} ${fontSize}px ${font.fontFamily}`;
|
|
@@ -275,17 +283,18 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
275
283
|
const bounds = target.getBBox(),
|
|
276
284
|
anchor = text.getAttribute("text-anchor");
|
|
277
285
|
const tracedWidth = Number(target.getAttribute("data-opf-box-width"));
|
|
278
|
-
const width = item?.box.width ?? (tracedWidth > 0 ? tracedWidth : Math.max(80, bounds.width + 20));
|
|
279
|
-
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;
|
|
280
289
|
Object.assign(active.input.style, {
|
|
281
290
|
fontFamily: font.fontFamily,
|
|
282
291
|
fontSize: `${fontSize * scale}px`,
|
|
283
292
|
fontWeight: font.fontWeight,
|
|
284
293
|
fontStyle: font.fontStyle,
|
|
285
294
|
lineHeight: `${lineHeight * scale}px`,
|
|
286
|
-
color:
|
|
295
|
+
color: visibleText ? font.fill : "transparent",
|
|
287
296
|
caretColor: font.fill,
|
|
288
|
-
textAlign: anchor === "middle" ? "center" : anchor === "end" ? "right" : "left",
|
|
297
|
+
textAlign: timelinePart?.alignment ?? metricLayout?.alignment ?? (anchor === "middle" ? "center" : anchor === "end" ? "right" : "left"),
|
|
289
298
|
width: `${width * scale}px`,
|
|
290
299
|
height: `${Math.max(lineHeight, allText.length * lineHeight) * scale + 2}px`,
|
|
291
300
|
minHeight: "0",
|
|
@@ -293,8 +302,10 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
293
302
|
});
|
|
294
303
|
active.input.style.left = `${svgRect.left - rootRect.left + (left - viewBox.x) * scale}px`;
|
|
295
304
|
active.input.style.top = `${svgRect.top - rootRect.top + (y - viewBox.y) * scale}px`;
|
|
296
|
-
//
|
|
297
|
-
|
|
305
|
+
// When the input supplies visible text, its selected text needs the same fill.
|
|
306
|
+
active.input.style.setProperty('--opf-inline-selection-color',visibleText ? font.fill : 'transparent');
|
|
307
|
+
// Other text keeps the canonical SVG visible beneath the transparent input.
|
|
308
|
+
target.style.opacity = visibleText ? "0" : "1";
|
|
298
309
|
}
|
|
299
310
|
function beginEdit(path) {
|
|
300
311
|
if (disposed) return;
|
|
@@ -321,18 +332,27 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
321
332
|
input.className = "opf-inline-input";
|
|
322
333
|
input.value = String(value);
|
|
323
334
|
input.setAttribute("aria-label", `Edit ${path.split(".").at(-1)} inline`);
|
|
324
|
-
|
|
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]');
|
|
338
|
+
input.spellcheck = !isCode;
|
|
325
339
|
input.style.cssText =
|
|
326
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";
|
|
327
341
|
active = {
|
|
328
342
|
kind: "text",
|
|
329
343
|
path,
|
|
330
344
|
base: JSON.stringify(value),
|
|
345
|
+
originalValue:value,
|
|
346
|
+
isCode,
|
|
347
|
+
isMetric,
|
|
331
348
|
type: typeof value,
|
|
332
349
|
input,
|
|
333
350
|
valid: true,
|
|
334
351
|
};
|
|
335
352
|
overlay.append(input);
|
|
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';
|
|
336
356
|
// Only offer rich text where the canonical schema accepts TextRun[].
|
|
337
357
|
if (typeof value === "string") {
|
|
338
358
|
try {
|
|
@@ -353,7 +373,14 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
353
373
|
} catch { /* This scalar field does not accept rich text. */ }
|
|
354
374
|
}
|
|
355
375
|
positionInput();
|
|
356
|
-
input.addEventListener("input",
|
|
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
|
+
});
|
|
357
384
|
input.addEventListener("compositionstart", () => {
|
|
358
385
|
if (active) {
|
|
359
386
|
active.composing = true;
|
|
@@ -366,7 +393,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
366
393
|
});
|
|
367
394
|
input.addEventListener("keydown", (event) => {
|
|
368
395
|
if (event.isComposing) return;
|
|
369
|
-
if (event.key ===
|
|
396
|
+
if (isCode && event.key === 'Tab' && !event.shiftKey) {
|
|
397
|
+
event.preventDefault();input.setRangeText('\t',input.selectionStart,input.selectionEnd,'end');input.dispatchEvent(new win.Event('input',{bubbles:true}));
|
|
398
|
+
} else if (event.key === "Escape") {
|
|
370
399
|
event.preventDefault();
|
|
371
400
|
event.stopPropagation();
|
|
372
401
|
cancel();
|
|
@@ -400,6 +429,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
400
429
|
value: parseCanvasValue(
|
|
401
430
|
field.type === "boolean" ? input.checked : input.value,
|
|
402
431
|
field.type,
|
|
432
|
+
field.value,
|
|
403
433
|
),
|
|
404
434
|
}));
|
|
405
435
|
}
|
|
@@ -594,7 +624,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
594
624
|
let value;
|
|
595
625
|
if (edit.kind === "rich-text") value = edit.rich.value;
|
|
596
626
|
else if (edit.kind === "text")
|
|
597
|
-
value = parseCanvasValue(edit.input.value, edit.type);
|
|
627
|
+
value = parseCanvasValue(edit.input.value, edit.type, edit.originalValue);
|
|
598
628
|
else value = propertyDraft(edit);
|
|
599
629
|
if (edit.kind === "text" || edit.kind === "rich-text")
|
|
600
630
|
renderSvg(createCanvasDraft(editor.document, edit.path, value), {...renderOptions,slideIndex});
|
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
|
+
});
|
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))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() {
|
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.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": "
|
|
9
|
+
"node": "24.x"
|
|
10
10
|
},
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
@@ -69,25 +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
|
-
"test:playground": "npm run build:playground && node test/playground-pptx.mjs"
|
|
92
|
+
"test:playground": "npm run build:playground && node test/playground-pptx.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"
|
|
85
98
|
},
|
|
86
99
|
"dependencies": {
|
|
87
|
-
"@
|
|
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"
|
|
88
111
|
},
|
|
89
112
|
"peerDependencies": {
|
|
90
|
-
"@openpresentation/opf-render": "^0.
|
|
113
|
+
"@openpresentation/opf-render": "^0.8.0"
|
|
91
114
|
},
|
|
92
115
|
"peerDependenciesMeta": {
|
|
93
116
|
"@openpresentation/opf-render": {
|
|
@@ -95,8 +118,8 @@
|
|
|
95
118
|
}
|
|
96
119
|
},
|
|
97
120
|
"devDependencies": {
|
|
98
|
-
"@openpresentation/opf-pptx": "0.
|
|
99
|
-
"@openpresentation/opf-render": "0.
|
|
121
|
+
"@openpresentation/opf-pptx": "0.8.0",
|
|
122
|
+
"@openpresentation/opf-render": "0.8.0",
|
|
100
123
|
"esbuild": "0.28.2",
|
|
101
124
|
"playwright": "1.63.0"
|
|
102
125
|
}
|