@openpresentation/opf-editor 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenPresentation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # OPF Editor
2
+
3
+ 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
+
5
+ ## Scope
6
+
7
+ - Package: `@openpresentation/opf-editor`
8
+ - Repository: `OpenPresentation/opf-editor`
9
+ - License: MIT
10
+ - Compatibility target: `@openpresentation/opf`
11
+ - Renderer relationship: built on `@openpresentation/opf-render` trace output
12
+ - Headless JSON edit bindings for renderer trace attributes
13
+ - Structured catalog controls that only commit known catalog IDs
14
+ - JSON Patch state transitions with inverse patches for undo/redo
15
+ - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
16
+
17
+ ## Runtime Policy
18
+
19
+ The package runtime must stay local and deterministic:
20
+
21
+ - No hosted service in the critical path
22
+ - No telemetry or hidden analytics
23
+ - No commercial SDK dependency in the critical path
24
+ - No required network calls
25
+ - Host applications own auth, storage, queues, analytics, collaboration, branding, and product workflow
26
+
27
+ ## Development
28
+
29
+ ```sh
30
+ npm ci
31
+ npm run build
32
+ npm run typecheck
33
+ npm test
34
+ npm run validate
35
+ ```
36
+
37
+ ## Headless Editing
38
+
39
+ ```js
40
+ import {
41
+ createEditorSession,
42
+ createSvgTraceBinding
43
+ } from "@openpresentation/opf-editor";
44
+ import { renderSvg } from "@openpresentation/opf-render";
45
+
46
+ const editor = createEditorSession(opfDocument, { rejectInvalid: true });
47
+ const svg = renderSvg(editor.document, { trace: true });
48
+
49
+ preview.innerHTML = svg;
50
+ const binding = createSvgTraceBinding(preview.querySelector("svg"), editor, {
51
+ onSelect(selection) {
52
+ inspector.open({
53
+ path: selection.path,
54
+ value: selection.value,
55
+ commit(value) {
56
+ binding.commit(selection.element, value);
57
+ }
58
+ });
59
+ }
60
+ });
61
+ ```
62
+
63
+ Every change returns JSON Patch operations plus inverse operations:
64
+
65
+ ```js
66
+ const change = editor.set("slides.0.title", "Quarterly plan");
67
+
68
+ change.patches;
69
+ // [{ op: "replace", path: "/slides/0/title", value: "Quarterly plan" }]
70
+
71
+ change.inversePatches;
72
+ // [{ op: "replace", path: "/slides/0/title", value: "Old title" }]
73
+
74
+ editor.undo();
75
+ editor.redo();
76
+ ```
77
+
78
+ `createSvgTraceBinding` only reads `data-opf-path`; it does not require a hosted service, a second renderer, storage, analytics, auth, or product chrome. Host applications decide how selected paths appear in their own inspector UI.
79
+
80
+ ## Catalog Controls
81
+
82
+ Catalog helpers commit catalog IDs from the OPF catalog registry instead of accepting freeform incompatible values:
83
+
84
+ ```js
85
+ import {
86
+ createCatalogSelect,
87
+ getCatalogOptions
88
+ } from "@openpresentation/opf-editor";
89
+
90
+ getCatalogOptions("themes").map((option) => option.id);
91
+
92
+ const themeSelect = createCatalogSelect(editor, {
93
+ path: "design.theme",
94
+ catalogKind: "themes",
95
+ label: "Theme"
96
+ });
97
+
98
+ toolbar.append(themeSelect);
99
+ ```
100
+
101
+ Invalid IDs throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
102
+
103
+ ## Optional React Bindings
104
+
105
+ React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
106
+
107
+ ```jsx
108
+ import { createOPFReactComponents } from "@openpresentation/opf-editor/react";
109
+
110
+ const { OPFTextInput, OPFCatalogSelect } = createOPFReactComponents(React);
111
+
112
+ function Inspector({ editor }) {
113
+ return (
114
+ <>
115
+ <OPFTextInput editor={editor} path="slides.0.title" label="Slide title" />
116
+ <OPFCatalogSelect editor={editor} path="design.theme" catalogKind="themes" label="Theme" />
117
+ </>
118
+ );
119
+ }
120
+ ```
121
+
122
+ ## Optional Svelte Bindings
123
+
124
+ Svelte bindings are isolated under `@openpresentation/opf-editor/svelte` as actions. They bind host-owned controls, so the package does not ship product chrome or require the Svelte runtime in the core entry point.
125
+
126
+ ```svelte
127
+ <script>
128
+ import { opfCatalogSelect, opfTextInput } from "@openpresentation/opf-editor/svelte";
129
+
130
+ export let editor;
131
+ </script>
132
+
133
+ <input use:opfTextInput={{ editor, path: "slides.0.title", label: "Slide title" }} />
134
+ <select use:opfCatalogSelect={{ editor, path: "design.theme", catalogKind: "themes", label: "Theme" }} />
135
+ ```
136
+
137
+ ## Release Lane
138
+
139
+ Public npm package publication is handled by `.github/workflows/release.yml` with npm provenance.
140
+
141
+ Required first-publish setup:
142
+
143
+ 1. An npm owner for the `@openpresentation` scope must run the first publish or reserve/grant the `@openpresentation/opf-editor` package.
144
+ 2. Configure npm Trusted Publishing for GitHub repository `OpenPresentation/opf-editor` and workflow `.github/workflows/release.yml`.
145
+ 3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
146
+
147
+ This repo does not require an npm automation token when Trusted Publishing is configured.
@@ -0,0 +1,178 @@
1
+ export declare const packageName = "@openpresentation/opf-editor";
2
+
3
+ export declare const releaseLane: Readonly<{
4
+ githubRepository: "OpenPresentation/opf-editor";
5
+ npmPackage: "@openpresentation/opf-editor";
6
+ compatibilityPackage: "@openpresentation/opf";
7
+ rendererPackage: "@openpresentation/opf-render";
8
+ }>;
9
+
10
+ export declare const runtimePolicy: Readonly<{
11
+ hostedServiceInCriticalPath: false;
12
+ telemetry: false;
13
+ commercialSdkInCriticalPath: false;
14
+ requiredNetworkCalls: false;
15
+ deterministicLocalExecution: true;
16
+ }>;
17
+
18
+ export type JsonPatchOperation =
19
+ | { op: "add"; path: string; value: unknown }
20
+ | { op: "replace"; path: string; value: unknown }
21
+ | { op: "remove"; path: string };
22
+
23
+ export interface OPFValidationSummary {
24
+ valid: boolean;
25
+ errors: unknown[];
26
+ result: unknown;
27
+ }
28
+
29
+ export interface EditorSnapshot {
30
+ document: unknown;
31
+ validation: OPFValidationSummary;
32
+ canUndo: boolean;
33
+ canRedo: boolean;
34
+ undoDepth: number;
35
+ redoDepth: number;
36
+ }
37
+
38
+ export interface EditorChange {
39
+ document: unknown;
40
+ patches: JsonPatchOperation[];
41
+ inversePatches?: JsonPatchOperation[];
42
+ redoPatches?: JsonPatchOperation[];
43
+ validation: OPFValidationSummary;
44
+ }
45
+
46
+ export interface EditorEvent {
47
+ type: "patch" | "undo" | "redo";
48
+ patches: JsonPatchOperation[];
49
+ inversePatches?: JsonPatchOperation[];
50
+ redoPatches?: JsonPatchOperation[];
51
+ validation: OPFValidationSummary;
52
+ meta?: Record<string, unknown>;
53
+ snapshot: EditorSnapshot;
54
+ }
55
+
56
+ export interface EditorSession {
57
+ readonly document: unknown;
58
+ readonly validation: OPFValidationSummary;
59
+ readonly canUndo: boolean;
60
+ readonly canRedo: boolean;
61
+ snapshot(): EditorSnapshot;
62
+ subscribe(listener: (event: EditorEvent) => void): () => void;
63
+ get(path: string | string[], fallback?: unknown): unknown;
64
+ set(path: string | string[], value: unknown, meta?: Record<string, unknown>): EditorChange;
65
+ setCatalog(path: string | string[], catalogKind: string, id: string, meta?: Record<string, unknown>): EditorChange;
66
+ applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>): EditorChange;
67
+ undo(meta?: Record<string, unknown>): EditorChange | null;
68
+ redo(meta?: Record<string, unknown>): EditorChange | null;
69
+ }
70
+
71
+ export interface CreateEditorSessionOptions {
72
+ validate?: (document: unknown) => unknown;
73
+ rejectInvalid?: boolean;
74
+ }
75
+
76
+ export interface CatalogOption {
77
+ id: string;
78
+ label: string;
79
+ record: Record<string, unknown>;
80
+ }
81
+
82
+ export interface CatalogOptionsInput {
83
+ presentation?: { catalogs?: Record<string, unknown> } | Record<string, unknown>;
84
+ catalogs?: Record<string, unknown>;
85
+ catalogSources?: Record<string, unknown>;
86
+ [key: string]: unknown;
87
+ }
88
+
89
+ export interface SvgTraceBinding {
90
+ elements: unknown[];
91
+ getPath(element: unknown): string;
92
+ getValue(elementOrPath: unknown, fallback?: unknown): unknown;
93
+ select(element: unknown, sourceEvent?: unknown): SvgTraceSelection;
94
+ commit(elementOrPath: unknown, value: unknown, meta?: Record<string, unknown>): EditorChange;
95
+ destroy(): void;
96
+ }
97
+
98
+ export interface SvgTraceSelection {
99
+ path: string;
100
+ pointer: string;
101
+ value: unknown;
102
+ element: unknown;
103
+ editor: EditorSession;
104
+ sourceEvent?: unknown;
105
+ }
106
+
107
+ export interface SvgTraceBindingOptions {
108
+ selector?: string;
109
+ interactive?: boolean;
110
+ onSelect?: (selection: SvgTraceSelection) => void;
111
+ }
112
+
113
+ export interface TextInputOptions {
114
+ path: string;
115
+ label?: string;
116
+ multiline?: boolean;
117
+ type?: string;
118
+ document?: { createElement(tagName: string): any };
119
+ }
120
+
121
+ export interface CatalogSelectOptions extends CatalogOptionsInput {
122
+ path: string;
123
+ catalogKind: string;
124
+ label?: string;
125
+ document?: { createElement(tagName: string): any };
126
+ }
127
+
128
+ export declare class OPFEditorError extends Error {
129
+ readonly code: string;
130
+ readonly details: Record<string, unknown>;
131
+ readonly path?: unknown;
132
+ readonly issues?: unknown[];
133
+ constructor(code: string, message: string, details?: Record<string, unknown>);
134
+ }
135
+
136
+ export declare function splitOpfPath(path: string | string[]): string[];
137
+
138
+ export declare function opfPathToJsonPointer(path: string | string[]): string;
139
+
140
+ export declare function jsonPointerToOpfPath(pointer: string): string;
141
+
142
+ export declare function getValueAtPath(document: unknown, path: string | string[], fallback?: unknown): unknown;
143
+
144
+ export declare function hasValueAtPath(document: unknown, path: string | string[]): boolean;
145
+
146
+ export declare function createValuePatch(document: unknown, path: string | string[], value: unknown): JsonPatchOperation[];
147
+
148
+ export declare function applyJsonPatch(document: unknown, operations: JsonPatchOperation[]): unknown;
149
+
150
+ export declare function invertJsonPatch(document: unknown, operations: JsonPatchOperation[]): JsonPatchOperation[];
151
+
152
+ export declare function validateOpfDocument(document: unknown, validator?: (document: unknown) => unknown): OPFValidationSummary;
153
+
154
+ export declare function createEditorSession(input: unknown, options?: CreateEditorSessionOptions): EditorSession;
155
+
156
+ export declare function createSvgTraceBinding(
157
+ root: { querySelectorAll(selector: string): Iterable<unknown>; matches?: (selector: string) => boolean },
158
+ editor: EditorSession,
159
+ options?: SvgTraceBindingOptions
160
+ ): SvgTraceBinding;
161
+
162
+ export declare function getCatalogRecords(catalogKind: string, options?: CatalogOptionsInput): Record<string, unknown>[];
163
+
164
+ export declare function getCatalogOptions(catalogKind: string, options?: CatalogOptionsInput): CatalogOption[];
165
+
166
+ export declare function assertCatalogId(catalogKind: string, id: string, options?: CatalogOptionsInput): string;
167
+
168
+ export declare function setCatalogId(
169
+ editor: EditorSession,
170
+ path: string | string[],
171
+ catalogKind: string,
172
+ id: string,
173
+ meta?: CatalogOptionsInput
174
+ ): EditorChange;
175
+
176
+ export declare function createTextInput(editor: EditorSession, options: TextInputOptions): any;
177
+
178
+ export declare function createCatalogSelect(editor: EditorSession, options: CatalogSelectOptions): any;
package/dist/index.js ADDED
@@ -0,0 +1,709 @@
1
+ import {
2
+ catalogKinds,
3
+ catalogs as bundledCatalogs,
4
+ validatePresentation
5
+ } from "@openpresentation/opf";
6
+
7
+ export const packageName = "@openpresentation/opf-editor";
8
+
9
+ export const releaseLane = Object.freeze({
10
+ githubRepository: "OpenPresentation/opf-editor",
11
+ npmPackage: "@openpresentation/opf-editor",
12
+ compatibilityPackage: "@openpresentation/opf",
13
+ rendererPackage: "@openpresentation/opf-render"
14
+ });
15
+
16
+ export const runtimePolicy = Object.freeze({
17
+ hostedServiceInCriticalPath: false,
18
+ telemetry: false,
19
+ commercialSdkInCriticalPath: false,
20
+ requiredNetworkCalls: false,
21
+ deterministicLocalExecution: true
22
+ });
23
+
24
+ const DATA_OPF_PATH = "data-opf-path";
25
+ const COMPONENT_ATTR = "data-opf-component";
26
+ const SELECT_EVENT = "opfselect";
27
+ const MISSING = Symbol("opf-editor.missing");
28
+
29
+ export class OPFEditorError extends Error {
30
+ constructor(code, message, details = {}) {
31
+ super(message);
32
+ this.name = "OPFEditorError";
33
+ this.code = code;
34
+ this.details = details;
35
+ if (details.path) this.path = details.path;
36
+ if (details.issues) this.issues = details.issues;
37
+ }
38
+ }
39
+
40
+ export function splitOpfPath(path) {
41
+ if (Array.isArray(path)) return path.map((segment) => String(segment));
42
+ if (typeof path !== "string") {
43
+ throw new OPFEditorError("invalid-path", "OPF path must be a string or segment array.", { path });
44
+ }
45
+ if (path === "") return [];
46
+ if (path.startsWith("/")) return parseJsonPointer(path);
47
+ return path.split(".").filter(Boolean);
48
+ }
49
+
50
+ export function opfPathToJsonPointer(path) {
51
+ const segments = splitOpfPath(path);
52
+ if (!segments.length) return "";
53
+ return `/${segments.map(escapePointerSegment).join("/")}`;
54
+ }
55
+
56
+ export function jsonPointerToOpfPath(pointer) {
57
+ return parseJsonPointer(pointer).join(".");
58
+ }
59
+
60
+ export function getValueAtPath(document, path, fallback) {
61
+ const value = readAtPath(document, splitOpfPath(path));
62
+ return value === MISSING ? fallback : value;
63
+ }
64
+
65
+ export function hasValueAtPath(document, path) {
66
+ return readAtPath(document, splitOpfPath(path)) !== MISSING;
67
+ }
68
+
69
+ export function createValuePatch(document, path, value) {
70
+ const pointer = normalizePatchPath(path);
71
+ return [
72
+ {
73
+ op: hasValueAtPath(document, pointer) ? "replace" : "add",
74
+ path: pointer,
75
+ value: clone(value)
76
+ }
77
+ ];
78
+ }
79
+
80
+ export function applyJsonPatch(document, operations) {
81
+ assertPatchOperations(operations);
82
+ let next = clone(document);
83
+ for (const operation of operations) {
84
+ next = applyJsonPatchOperation(next, normalizeOperation(operation));
85
+ }
86
+ return next;
87
+ }
88
+
89
+ export function invertJsonPatch(document, operations) {
90
+ assertPatchOperations(operations);
91
+ let current = clone(document);
92
+ const inverse = [];
93
+
94
+ for (const operation of operations.map(normalizeOperation)) {
95
+ const previous = readAtPath(current, parseJsonPointer(operation.path));
96
+ if (operation.op !== "add" && previous === MISSING) {
97
+ throw new OPFEditorError("patch-path-missing", `Cannot invert ${operation.op} for missing path ${operation.path}.`, {
98
+ path: operation.path,
99
+ operation
100
+ });
101
+ }
102
+ const addReplacedObjectValue = operation.op === "add" && previous !== MISSING && !pathParentIsArray(current, operation.path);
103
+ const inversePath = operation.op === "add" ? insertedPathForAdd(operation.path, previous, current) : operation.path;
104
+
105
+ current = applyJsonPatchOperation(current, operation);
106
+
107
+ if (addReplacedObjectValue) {
108
+ inverse.unshift({ op: "replace", path: operation.path, value: clone(previous) });
109
+ } else if (operation.op === "add") {
110
+ inverse.unshift({ op: "remove", path: inversePath });
111
+ } else if (operation.op === "replace") {
112
+ inverse.unshift({ op: "replace", path: operation.path, value: clone(previous) });
113
+ } else if (operation.op === "remove") {
114
+ inverse.unshift({ op: "add", path: operation.path, value: clone(previous) });
115
+ }
116
+ }
117
+
118
+ return inverse;
119
+ }
120
+
121
+ export function validateOpfDocument(document, validator = validatePresentation) {
122
+ const result = validator(document);
123
+ const valid = Boolean(result?.valid);
124
+ return {
125
+ valid,
126
+ errors: Array.isArray(result?.errors) ? result.errors : [],
127
+ result
128
+ };
129
+ }
130
+
131
+ export function createEditorSession(input, options = {}) {
132
+ let document = parseInput(input);
133
+ let validation = validateOpfDocument(document, options.validate ?? validatePresentation);
134
+ const undoStack = [];
135
+ const redoStack = [];
136
+ const listeners = new Set();
137
+ const rejectInvalid = Boolean(options.rejectInvalid);
138
+
139
+ function emit(event) {
140
+ const snapshot = editor.snapshot();
141
+ for (const listener of listeners) listener({ ...event, snapshot });
142
+ }
143
+
144
+ function commitPatch(operations, meta = {}) {
145
+ assertPatchOperations(operations);
146
+ const patches = operations.map(normalizeOperation);
147
+ const before = document;
148
+ const inversePatches = invertJsonPatch(before, patches);
149
+ const next = applyJsonPatch(before, patches);
150
+ const nextValidation = validateOpfDocument(next, options.validate ?? validatePresentation);
151
+
152
+ if ((meta.rejectInvalid ?? rejectInvalid) && !nextValidation.valid) {
153
+ throw new OPFEditorError("invalid-opf-edit", "OPF edit produced an invalid document.", {
154
+ issues: nextValidation.errors,
155
+ patches
156
+ });
157
+ }
158
+
159
+ document = next;
160
+ validation = nextValidation;
161
+ undoStack.push({ patches: clonePatchOperations(patches), inversePatches, meta });
162
+ redoStack.length = 0;
163
+ emit({
164
+ type: "patch",
165
+ patches: clonePatchOperations(patches),
166
+ inversePatches: clonePatchOperations(inversePatches),
167
+ validation,
168
+ meta
169
+ });
170
+
171
+ return {
172
+ document: clone(document),
173
+ patches: clonePatchOperations(patches),
174
+ inversePatches: clonePatchOperations(inversePatches),
175
+ validation
176
+ };
177
+ }
178
+
179
+ const editor = {
180
+ get document() {
181
+ return clone(document);
182
+ },
183
+ get validation() {
184
+ return validation;
185
+ },
186
+ get canUndo() {
187
+ return undoStack.length > 0;
188
+ },
189
+ get canRedo() {
190
+ return redoStack.length > 0;
191
+ },
192
+ snapshot() {
193
+ return {
194
+ document: clone(document),
195
+ validation,
196
+ canUndo: undoStack.length > 0,
197
+ canRedo: redoStack.length > 0,
198
+ undoDepth: undoStack.length,
199
+ redoDepth: redoStack.length
200
+ };
201
+ },
202
+ subscribe(listener) {
203
+ if (typeof listener !== "function") {
204
+ throw new OPFEditorError("invalid-listener", "Editor listener must be a function.");
205
+ }
206
+ listeners.add(listener);
207
+ return () => listeners.delete(listener);
208
+ },
209
+ get(path, fallback) {
210
+ return getValueAtPath(document, path, fallback);
211
+ },
212
+ set(path, value, meta = {}) {
213
+ return commitPatch(createValuePatch(document, path, value), {
214
+ ...meta,
215
+ path: normalizePatchPath(path)
216
+ });
217
+ },
218
+ setCatalog(path, catalogKind, id, meta = {}) {
219
+ return setCatalogId(editor, path, catalogKind, id, meta);
220
+ },
221
+ applyPatch: commitPatch,
222
+ undo(meta = {}) {
223
+ const entry = undoStack.pop();
224
+ if (!entry) return null;
225
+ document = applyJsonPatch(document, entry.inversePatches);
226
+ validation = validateOpfDocument(document, options.validate ?? validatePresentation);
227
+ redoStack.push(entry);
228
+ emit({
229
+ type: "undo",
230
+ patches: clonePatchOperations(entry.inversePatches),
231
+ redoPatches: clonePatchOperations(entry.patches),
232
+ validation,
233
+ meta
234
+ });
235
+ return {
236
+ document: clone(document),
237
+ patches: clonePatchOperations(entry.inversePatches),
238
+ redoPatches: clonePatchOperations(entry.patches),
239
+ validation
240
+ };
241
+ },
242
+ redo(meta = {}) {
243
+ const entry = redoStack.pop();
244
+ if (!entry) return null;
245
+ document = applyJsonPatch(document, entry.patches);
246
+ validation = validateOpfDocument(document, options.validate ?? validatePresentation);
247
+ undoStack.push(entry);
248
+ emit({
249
+ type: "redo",
250
+ patches: clonePatchOperations(entry.patches),
251
+ inversePatches: clonePatchOperations(entry.inversePatches),
252
+ validation,
253
+ meta
254
+ });
255
+ return {
256
+ document: clone(document),
257
+ patches: clonePatchOperations(entry.patches),
258
+ inversePatches: clonePatchOperations(entry.inversePatches),
259
+ validation
260
+ };
261
+ }
262
+ };
263
+
264
+ return editor;
265
+ }
266
+
267
+ export function createSvgTraceBinding(root, editor, options = {}) {
268
+ assertElementRoot(root);
269
+ assertEditorSession(editor);
270
+
271
+ const selector = options.selector ?? `[${DATA_OPF_PATH}]`;
272
+ const elements = traceElements(root, selector);
273
+ const listeners = [];
274
+
275
+ function select(element, sourceEvent) {
276
+ const path = opfPathForElement(element);
277
+ const detail = {
278
+ path,
279
+ pointer: opfPathToJsonPointer(path),
280
+ value: editor.get(path),
281
+ element,
282
+ editor,
283
+ sourceEvent
284
+ };
285
+
286
+ options.onSelect?.(detail);
287
+ dispatchOpfEvent(element, SELECT_EVENT, detail);
288
+ return detail;
289
+ }
290
+
291
+ function commit(elementOrPath, value, meta = {}) {
292
+ const path = typeof elementOrPath === "string" ? elementOrPath : opfPathForElement(elementOrPath);
293
+ return editor.set(path, value, {
294
+ ...meta,
295
+ source: meta.source ?? "svg-trace"
296
+ });
297
+ }
298
+
299
+ for (const element of elements) {
300
+ element.setAttribute?.("data-opf-bound", "true");
301
+ if (options.interactive !== false) {
302
+ ensureInteractiveTraceElement(element);
303
+
304
+ const onClick = (event) => select(element, event);
305
+ const onKeydown = (event) => {
306
+ if (event.key === "Enter" || event.key === " ") {
307
+ event.preventDefault?.();
308
+ select(element, event);
309
+ }
310
+ };
311
+
312
+ element.addEventListener?.("click", onClick);
313
+ element.addEventListener?.("keydown", onKeydown);
314
+ listeners.push([element, "click", onClick], [element, "keydown", onKeydown]);
315
+ }
316
+ }
317
+
318
+ return {
319
+ elements,
320
+ getPath: opfPathForElement,
321
+ getValue(elementOrPath, fallback) {
322
+ return editor.get(typeof elementOrPath === "string" ? elementOrPath : opfPathForElement(elementOrPath), fallback);
323
+ },
324
+ select,
325
+ commit,
326
+ destroy() {
327
+ for (const [element, type, listener] of listeners) {
328
+ element.removeEventListener?.(type, listener);
329
+ }
330
+ listeners.length = 0;
331
+ for (const element of elements) element.removeAttribute?.("data-opf-bound");
332
+ }
333
+ };
334
+ }
335
+
336
+ export function getCatalogRecords(catalogKind, options = {}) {
337
+ assertCatalogKind(catalogKind);
338
+ const presentationCatalog = options.presentation?.catalogs?.[catalogKind];
339
+ const explicitCatalog = options.catalogs?.[catalogKind] ?? options.catalogSources?.[catalogKind];
340
+
341
+ return firstRecords(presentationCatalog)
342
+ ?? firstRecords(explicitCatalog)
343
+ ?? firstRecords(bundledCatalogs[catalogKind])
344
+ ?? [];
345
+ }
346
+
347
+ export function getCatalogOptions(catalogKind, options = {}) {
348
+ return getCatalogRecords(catalogKind, options).map((record) => ({
349
+ id: record.id,
350
+ label: catalogLabel(record),
351
+ record
352
+ }));
353
+ }
354
+
355
+ export function assertCatalogId(catalogKind, id, options = {}) {
356
+ assertCatalogKind(catalogKind);
357
+ if (typeof id !== "string" || id.length === 0) {
358
+ throw new OPFEditorError("invalid-catalog-id", "Catalog control values must be non-empty catalog IDs.", {
359
+ catalogKind,
360
+ id
361
+ });
362
+ }
363
+
364
+ const optionsById = new Set(getCatalogOptions(catalogKind, options).map((option) => option.id));
365
+ if (!optionsById.has(id)) {
366
+ throw new OPFEditorError("unknown-catalog-id", `Unknown ${catalogKind} catalog ID: ${id}.`, {
367
+ catalogKind,
368
+ id
369
+ });
370
+ }
371
+
372
+ return id;
373
+ }
374
+
375
+ export function setCatalogId(editor, path, catalogKind, id, meta = {}) {
376
+ assertEditorSession(editor);
377
+ const catalogId = assertCatalogId(catalogKind, id, {
378
+ presentation: meta.presentation ?? editor.document,
379
+ catalogs: meta.catalogs,
380
+ catalogSources: meta.catalogSources
381
+ });
382
+ const current = editor.get(path);
383
+ const value = catalogReferenceValue(current, catalogId);
384
+ const { catalogs, catalogSources, presentation, ...commitMeta } = meta;
385
+ return editor.set(path, value, {
386
+ ...commitMeta,
387
+ catalogKind,
388
+ catalogId,
389
+ source: commitMeta.source ?? "catalog-control"
390
+ });
391
+ }
392
+
393
+ export function createTextInput(editor, options) {
394
+ assertEditorSession(editor);
395
+ const documentRef = resolveDomDocument(options?.document);
396
+ const input = documentRef.createElement(options?.multiline ? "textarea" : "input");
397
+ const path = options?.path;
398
+ if (!path) throw new OPFEditorError("missing-path", "Text input requires an OPF path.");
399
+
400
+ input.setAttribute(COMPONENT_ATTR, "text-input");
401
+ input.setAttribute(DATA_OPF_PATH, path);
402
+ if (!options?.multiline) input.setAttribute("type", options?.type ?? "text");
403
+ if (options?.label) input.setAttribute("aria-label", options.label);
404
+
405
+ const updateValue = () => {
406
+ const nextValue = editor.get(path, "");
407
+ input.value = nextValue === undefined || nextValue === null ? "" : String(nextValue);
408
+ };
409
+ const onInput = () => {
410
+ editor.set(path, input.value, { source: "text-input" });
411
+ };
412
+ const unsubscribe = editor.subscribe(updateValue);
413
+
414
+ input.addEventListener?.("input", onInput);
415
+ input.destroy = () => {
416
+ input.removeEventListener?.("input", onInput);
417
+ unsubscribe();
418
+ };
419
+ updateValue();
420
+ return input;
421
+ }
422
+
423
+ export function createCatalogSelect(editor, options) {
424
+ assertEditorSession(editor);
425
+ const documentRef = resolveDomDocument(options?.document);
426
+ const select = documentRef.createElement("select");
427
+ const path = options?.path;
428
+ const catalogKind = options?.catalogKind;
429
+ if (!path) throw new OPFEditorError("missing-path", "Catalog select requires an OPF path.");
430
+ assertCatalogKind(catalogKind);
431
+
432
+ select.setAttribute(COMPONENT_ATTR, "catalog-select");
433
+ select.setAttribute(DATA_OPF_PATH, path);
434
+ select.setAttribute("data-opf-catalog-kind", catalogKind);
435
+ if (options?.label) select.setAttribute("aria-label", options.label);
436
+
437
+ for (const option of getCatalogOptions(catalogKind, {
438
+ ...options,
439
+ presentation: options?.presentation ?? editor.document
440
+ })) {
441
+ const optionElement = documentRef.createElement("option");
442
+ optionElement.value = option.id;
443
+ optionElement.textContent = option.label;
444
+ select.appendChild(optionElement);
445
+ }
446
+
447
+ const updateValue = () => {
448
+ const current = editor.get(path);
449
+ select.value = typeof current === "object" && current ? current.id : current ?? "";
450
+ };
451
+ const onChange = () => {
452
+ setCatalogId(editor, path, catalogKind, select.value, {
453
+ catalogs: options?.catalogs,
454
+ catalogSources: options?.catalogSources,
455
+ presentation: options?.presentation,
456
+ source: "catalog-select"
457
+ });
458
+ };
459
+ const unsubscribe = editor.subscribe(updateValue);
460
+
461
+ select.addEventListener?.("change", onChange);
462
+ select.destroy = () => {
463
+ select.removeEventListener?.("change", onChange);
464
+ unsubscribe();
465
+ };
466
+ updateValue();
467
+ return select;
468
+ }
469
+
470
+ function parseInput(input) {
471
+ if (typeof input === "string") {
472
+ try {
473
+ return JSON.parse(input);
474
+ } catch (error) {
475
+ throw new OPFEditorError("invalid-json", "OPF input is not valid JSON.", {
476
+ cause: error instanceof Error ? error.message : String(error)
477
+ });
478
+ }
479
+ }
480
+
481
+ if (input instanceof Uint8Array) {
482
+ return parseInput(new TextDecoder().decode(input));
483
+ }
484
+
485
+ if (input && typeof input === "object") return clone(input);
486
+
487
+ throw new OPFEditorError("invalid-input", "OPF input must be a parsed object, JSON string, or Uint8Array.");
488
+ }
489
+
490
+ function clone(value) {
491
+ if (value === undefined) return undefined;
492
+ return JSON.parse(JSON.stringify(value));
493
+ }
494
+
495
+ function clonePatchOperations(operations) {
496
+ return operations.map((operation) => clone(operation));
497
+ }
498
+
499
+ function escapePointerSegment(segment) {
500
+ return String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
501
+ }
502
+
503
+ function unescapePointerSegment(segment) {
504
+ return segment.replaceAll("~1", "/").replaceAll("~0", "~");
505
+ }
506
+
507
+ function parseJsonPointer(pointer) {
508
+ if (pointer === "") return [];
509
+ if (typeof pointer !== "string" || !pointer.startsWith("/")) {
510
+ throw new OPFEditorError("invalid-json-pointer", "JSON Patch paths must be JSON Pointers.", { path: pointer });
511
+ }
512
+ return pointer.slice(1).split("/").map(unescapePointerSegment);
513
+ }
514
+
515
+ function normalizePatchPath(path) {
516
+ return typeof path === "string" && path.startsWith("/") ? path : opfPathToJsonPointer(path);
517
+ }
518
+
519
+ function normalizeOperation(operation) {
520
+ if (!operation || typeof operation !== "object") {
521
+ throw new OPFEditorError("invalid-patch-operation", "JSON Patch operation must be an object.", { operation });
522
+ }
523
+ if (!["add", "replace", "remove"].includes(operation.op)) {
524
+ throw new OPFEditorError("unsupported-patch-operation", `Unsupported JSON Patch operation: ${operation.op}.`, {
525
+ operation
526
+ });
527
+ }
528
+ const normalized = {
529
+ op: operation.op,
530
+ path: normalizePatchPath(operation.path)
531
+ };
532
+ if (operation.op !== "remove") normalized.value = clone(operation.value);
533
+ return normalized;
534
+ }
535
+
536
+ function assertPatchOperations(operations) {
537
+ if (!Array.isArray(operations)) {
538
+ throw new OPFEditorError("invalid-patch", "JSON Patch must be an array of operations.", { operations });
539
+ }
540
+ }
541
+
542
+ function readAtPath(document, segments) {
543
+ let current = document;
544
+ for (const segment of segments) {
545
+ if (Array.isArray(current)) {
546
+ if (segment === "-") return MISSING;
547
+ const index = Number(segment);
548
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) return MISSING;
549
+ current = current[index];
550
+ } else if (current && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, segment)) {
551
+ current = current[segment];
552
+ } else {
553
+ return MISSING;
554
+ }
555
+ }
556
+ return current;
557
+ }
558
+
559
+ function applyJsonPatchOperation(document, operation) {
560
+ const segments = parseJsonPointer(operation.path);
561
+ if (segments.length === 0) {
562
+ if (operation.op === "remove") return undefined;
563
+ return clone(operation.value);
564
+ }
565
+
566
+ const next = clone(document);
567
+ const parent = readAtPath(next, segments.slice(0, -1));
568
+ const key = segments.at(-1);
569
+
570
+ if (parent === MISSING || parent === null || typeof parent !== "object") {
571
+ throw new OPFEditorError("patch-parent-missing", `Patch parent does not exist for ${operation.path}.`, {
572
+ path: operation.path,
573
+ operation
574
+ });
575
+ }
576
+
577
+ if (Array.isArray(parent)) applyArrayOperation(parent, key, operation);
578
+ else applyObjectOperation(parent, key, operation);
579
+
580
+ return next;
581
+ }
582
+
583
+ function insertedPathForAdd(path, previous, document) {
584
+ if (previous !== MISSING || !path.endsWith("/-")) return path;
585
+ const parentPath = path.slice(0, -2);
586
+ const parent = readAtPath(document, parseJsonPointer(parentPath));
587
+ if (!Array.isArray(parent)) return path;
588
+ return `${parentPath}/${parent.length}`;
589
+ }
590
+
591
+ function pathParentIsArray(document, path) {
592
+ const segments = parseJsonPointer(path);
593
+ if (segments.length === 0) return false;
594
+ const parent = readAtPath(document, segments.slice(0, -1));
595
+ return Array.isArray(parent);
596
+ }
597
+
598
+ function applyArrayOperation(parent, key, operation) {
599
+ const index = key === "-" ? parent.length : Number(key);
600
+ if (!Number.isInteger(index) || index < 0 || index > parent.length) {
601
+ throw new OPFEditorError("invalid-array-index", `Invalid array index in patch path ${operation.path}.`, {
602
+ path: operation.path,
603
+ operation
604
+ });
605
+ }
606
+
607
+ if (operation.op === "add") {
608
+ parent.splice(index, 0, clone(operation.value));
609
+ return;
610
+ }
611
+
612
+ if (index >= parent.length) {
613
+ throw new OPFEditorError("patch-path-missing", `Patch path does not exist: ${operation.path}.`, {
614
+ path: operation.path,
615
+ operation
616
+ });
617
+ }
618
+
619
+ if (operation.op === "replace") parent[index] = clone(operation.value);
620
+ else parent.splice(index, 1);
621
+ }
622
+
623
+ function applyObjectOperation(parent, key, operation) {
624
+ if (operation.op === "add") {
625
+ parent[key] = clone(operation.value);
626
+ return;
627
+ }
628
+
629
+ if (!Object.prototype.hasOwnProperty.call(parent, key)) {
630
+ throw new OPFEditorError("patch-path-missing", `Patch path does not exist: ${operation.path}.`, {
631
+ path: operation.path,
632
+ operation
633
+ });
634
+ }
635
+
636
+ if (operation.op === "replace") parent[key] = clone(operation.value);
637
+ else delete parent[key];
638
+ }
639
+
640
+ function assertElementRoot(root) {
641
+ if (!root || typeof root !== "object" || typeof root.querySelectorAll !== "function") {
642
+ throw new OPFEditorError("invalid-root", "SVG trace binding requires a root element with querySelectorAll.");
643
+ }
644
+ }
645
+
646
+ function assertEditorSession(editor) {
647
+ if (!editor || typeof editor.get !== "function" || typeof editor.set !== "function" || typeof editor.subscribe !== "function") {
648
+ throw new OPFEditorError("invalid-editor", "Expected an editor session created by createEditorSession.");
649
+ }
650
+ }
651
+
652
+ function traceElements(root, selector) {
653
+ const elements = Array.from(root.querySelectorAll(selector));
654
+ if (typeof root.matches === "function" && root.matches(selector)) elements.unshift(root);
655
+ return elements;
656
+ }
657
+
658
+ function opfPathForElement(element) {
659
+ const path = element?.getAttribute?.(DATA_OPF_PATH);
660
+ if (!path) throw new OPFEditorError("missing-opf-path", "Element does not have a data-opf-path attribute.");
661
+ return path;
662
+ }
663
+
664
+ function ensureInteractiveTraceElement(element) {
665
+ if (!element.hasAttribute?.("tabindex")) element.setAttribute?.("tabindex", "0");
666
+ if (!element.hasAttribute?.("role")) element.setAttribute?.("role", "button");
667
+ }
668
+
669
+ function dispatchOpfEvent(element, type, detail) {
670
+ if (typeof element.dispatchEvent !== "function" || typeof globalThis.CustomEvent !== "function") return;
671
+ element.dispatchEvent(new CustomEvent(type, { bubbles: true, detail }));
672
+ }
673
+
674
+ function assertCatalogKind(catalogKind) {
675
+ if (!catalogKinds.includes(catalogKind)) {
676
+ throw new OPFEditorError("unknown-catalog-kind", `Unknown OPF catalog kind: ${catalogKind}.`, {
677
+ catalogKind
678
+ });
679
+ }
680
+ }
681
+
682
+ function firstRecords(value) {
683
+ if (Array.isArray(value)) return value.filter(isCatalogRecord);
684
+ if (Array.isArray(value?.records)) return value.records.filter(isCatalogRecord);
685
+ return null;
686
+ }
687
+
688
+ function isCatalogRecord(record) {
689
+ return Boolean(record && typeof record === "object" && typeof record.id === "string");
690
+ }
691
+
692
+ function catalogLabel(record) {
693
+ return String(record.name ?? record.title ?? record.label ?? record.id);
694
+ }
695
+
696
+ function catalogReferenceValue(current, id) {
697
+ if (current && typeof current === "object" && !Array.isArray(current)) {
698
+ return { ...current, id };
699
+ }
700
+ return id;
701
+ }
702
+
703
+ function resolveDomDocument(documentRef) {
704
+ const resolved = documentRef ?? globalThis.document;
705
+ if (!resolved || typeof resolved.createElement !== "function") {
706
+ throw new OPFEditorError("missing-document", "DOM components require a document-like object.");
707
+ }
708
+ return resolved;
709
+ }
@@ -0,0 +1,31 @@
1
+ import type { EditorSession, CatalogOption } from "./index.js";
2
+
3
+ export interface OPFTextInputProps {
4
+ editor: EditorSession;
5
+ path: string;
6
+ label?: string;
7
+ multiline?: boolean;
8
+ inputProps?: Record<string, unknown>;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export interface OPFCatalogSelectProps {
13
+ editor: EditorSession;
14
+ path: string;
15
+ catalogKind: string;
16
+ label?: string;
17
+ catalogOptions?: CatalogOption[];
18
+ selectProps?: Record<string, unknown>;
19
+ [key: string]: unknown;
20
+ }
21
+
22
+ export declare function createOPFReactComponents(React: {
23
+ createElement: (...args: unknown[]) => unknown;
24
+ useSyncExternalStore?: (...args: unknown[]) => unknown;
25
+ useState?: (...args: unknown[]) => unknown;
26
+ useEffect?: (...args: unknown[]) => unknown;
27
+ }): {
28
+ useEditorSnapshot(editor: EditorSession): unknown;
29
+ OPFTextInput(props: OPFTextInputProps): unknown;
30
+ OPFCatalogSelect(props: OPFCatalogSelectProps): unknown;
31
+ };
package/dist/react.js ADDED
@@ -0,0 +1,94 @@
1
+ import {
2
+ getCatalogOptions,
3
+ getValueAtPath,
4
+ setCatalogId
5
+ } from "./index.js";
6
+
7
+ export function createOPFReactComponents(React) {
8
+ if (!React || typeof React.createElement !== "function") {
9
+ throw new TypeError("createOPFReactComponents requires a React-compatible object.");
10
+ }
11
+
12
+ function useEditorSnapshot(editor) {
13
+ if (typeof React.useSyncExternalStore === "function") {
14
+ return React.useSyncExternalStore(
15
+ (notify) => editor.subscribe(notify),
16
+ () => editor.snapshot(),
17
+ () => editor.snapshot()
18
+ );
19
+ }
20
+
21
+ const [snapshot, setSnapshot] = React.useState(editor.snapshot());
22
+ React.useEffect(() => editor.subscribe(() => setSnapshot(editor.snapshot())), [editor]);
23
+ return snapshot;
24
+ }
25
+
26
+ function OPFTextInput({
27
+ editor,
28
+ path,
29
+ label,
30
+ multiline = false,
31
+ inputProps = {},
32
+ ...props
33
+ }) {
34
+ const snapshot = useEditorSnapshot(editor);
35
+ const value = getValueAtPath(snapshot.document, path, "");
36
+ const tag = multiline ? "textarea" : "input";
37
+ return React.createElement(tag, {
38
+ ...props,
39
+ ...inputProps,
40
+ "aria-label": label ?? props["aria-label"] ?? inputProps["aria-label"],
41
+ "data-opf-component": "text-input",
42
+ "data-opf-path": path,
43
+ type: multiline ? undefined : props.type ?? inputProps.type ?? "text",
44
+ value: value === undefined || value === null ? "" : String(value),
45
+ onChange(event) {
46
+ inputProps.onChange?.(event);
47
+ props.onChange?.(event);
48
+ if (!event.defaultPrevented) editor.set(path, event.currentTarget.value, { source: "react-text-input" });
49
+ }
50
+ });
51
+ }
52
+
53
+ function OPFCatalogSelect({
54
+ editor,
55
+ path,
56
+ catalogKind,
57
+ label,
58
+ catalogOptions,
59
+ selectProps = {},
60
+ ...props
61
+ }) {
62
+ const snapshot = useEditorSnapshot(editor);
63
+ const value = getValueAtPath(snapshot.document, path, "");
64
+ const currentId = value && typeof value === "object" ? value.id : value;
65
+ const options = catalogOptions ?? getCatalogOptions(catalogKind, { presentation: snapshot.document });
66
+
67
+ return React.createElement(
68
+ "select",
69
+ {
70
+ ...props,
71
+ ...selectProps,
72
+ "aria-label": label ?? props["aria-label"] ?? selectProps["aria-label"],
73
+ "data-opf-component": "catalog-select",
74
+ "data-opf-catalog-kind": catalogKind,
75
+ "data-opf-path": path,
76
+ value: currentId ?? "",
77
+ onChange(event) {
78
+ selectProps.onChange?.(event);
79
+ props.onChange?.(event);
80
+ if (!event.defaultPrevented) {
81
+ setCatalogId(editor, path, catalogKind, event.currentTarget.value, { source: "react-catalog-select" });
82
+ }
83
+ }
84
+ },
85
+ options.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.label))
86
+ );
87
+ }
88
+
89
+ return {
90
+ useEditorSnapshot,
91
+ OPFTextInput,
92
+ OPFCatalogSelect
93
+ };
94
+ }
@@ -0,0 +1,29 @@
1
+ import type { CatalogOptionsInput, EditorSession, SvgTraceBindingOptions } from "./index.js";
2
+
3
+ export interface OPFTextInputActionParams {
4
+ editor: EditorSession;
5
+ path: string;
6
+ label?: string;
7
+ }
8
+
9
+ export interface OPFCatalogSelectActionParams extends CatalogOptionsInput {
10
+ editor: EditorSession;
11
+ path: string;
12
+ catalogKind: string;
13
+ label?: string;
14
+ }
15
+
16
+ export interface OPFTraceActionParams extends SvgTraceBindingOptions {
17
+ editor: EditorSession;
18
+ }
19
+
20
+ export interface SvelteAction {
21
+ update(params: unknown): void;
22
+ destroy(): void;
23
+ }
24
+
25
+ export declare function opfTextInput(node: any, params: OPFTextInputActionParams): SvelteAction;
26
+
27
+ export declare function opfCatalogSelect(node: any, params: OPFCatalogSelectActionParams): SvelteAction;
28
+
29
+ export declare function opfTrace(node: any, params: OPFTraceActionParams): SvelteAction;
package/dist/svelte.js ADDED
@@ -0,0 +1,112 @@
1
+ import {
2
+ createSvgTraceBinding,
3
+ getCatalogOptions,
4
+ setCatalogId
5
+ } from "./index.js";
6
+
7
+ export function opfTextInput(node, params) {
8
+ let cleanup = () => {};
9
+
10
+ function connect(nextParams) {
11
+ cleanup();
12
+ const { editor, path, label } = nextParams;
13
+ node.setAttribute?.("data-opf-component", "text-input");
14
+ node.setAttribute?.("data-opf-path", path);
15
+ if (label) node.setAttribute?.("aria-label", label);
16
+
17
+ const sync = () => {
18
+ const value = editor.get(path, "");
19
+ node.value = value === undefined || value === null ? "" : String(value);
20
+ };
21
+ const onInput = () => editor.set(path, node.value, { source: "svelte-text-input" });
22
+ const unsubscribe = editor.subscribe(sync);
23
+
24
+ node.addEventListener?.("input", onInput);
25
+ sync();
26
+ cleanup = () => {
27
+ node.removeEventListener?.("input", onInput);
28
+ unsubscribe();
29
+ };
30
+ }
31
+
32
+ connect(params);
33
+
34
+ return {
35
+ update: connect,
36
+ destroy() {
37
+ cleanup();
38
+ }
39
+ };
40
+ }
41
+
42
+ export function opfCatalogSelect(node, params) {
43
+ let cleanup = () => {};
44
+
45
+ function connect(nextParams) {
46
+ cleanup();
47
+ const { editor, path, catalogKind, label } = nextParams;
48
+ node.setAttribute?.("data-opf-component", "catalog-select");
49
+ node.setAttribute?.("data-opf-path", path);
50
+ node.setAttribute?.("data-opf-catalog-kind", catalogKind);
51
+ if (label) node.setAttribute?.("aria-label", label);
52
+ replaceOptions(node, getCatalogOptions(catalogKind, {
53
+ catalogs: nextParams.catalogs,
54
+ catalogSources: nextParams.catalogSources,
55
+ presentation: nextParams.presentation ?? editor.document
56
+ }));
57
+
58
+ const sync = () => {
59
+ const value = editor.get(path);
60
+ node.value = value && typeof value === "object" ? value.id : value ?? "";
61
+ };
62
+ const onChange = () => setCatalogId(editor, path, catalogKind, node.value, {
63
+ catalogs: nextParams.catalogs,
64
+ catalogSources: nextParams.catalogSources,
65
+ presentation: nextParams.presentation,
66
+ source: "svelte-catalog-select"
67
+ });
68
+ const unsubscribe = editor.subscribe(sync);
69
+
70
+ node.addEventListener?.("change", onChange);
71
+ sync();
72
+ cleanup = () => {
73
+ node.removeEventListener?.("change", onChange);
74
+ unsubscribe();
75
+ };
76
+ }
77
+
78
+ connect(params);
79
+
80
+ return {
81
+ update: connect,
82
+ destroy() {
83
+ cleanup();
84
+ }
85
+ };
86
+ }
87
+
88
+ export function opfTrace(node, params) {
89
+ let binding = createSvgTraceBinding(node, params.editor, params);
90
+
91
+ return {
92
+ update(nextParams) {
93
+ binding.destroy();
94
+ binding = createSvgTraceBinding(node, nextParams.editor, nextParams);
95
+ },
96
+ destroy() {
97
+ binding.destroy();
98
+ }
99
+ };
100
+ }
101
+
102
+ function replaceOptions(node, options) {
103
+ while (node.firstChild) node.removeChild(node.firstChild);
104
+
105
+ const documentRef = node.ownerDocument ?? globalThis.document;
106
+ for (const option of options) {
107
+ const optionElement = documentRef.createElement("option");
108
+ optionElement.value = option.id;
109
+ optionElement.textContent = option.label;
110
+ node.appendChild(optionElement);
111
+ }
112
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@openpresentation/opf-editor",
3
+ "version": "0.0.1",
4
+ "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "private": false,
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/OpenPresentation/opf-editor.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/OpenPresentation/opf-editor/issues"
17
+ },
18
+ "homepage": "https://github.com/OpenPresentation/opf-editor#readme",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./react": {
33
+ "types": "./dist/react.d.ts",
34
+ "default": "./dist/react.js"
35
+ },
36
+ "./svelte": {
37
+ "types": "./dist/svelte.d.ts",
38
+ "default": "./dist/svelte.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "types": "./dist/index.d.ts",
43
+ "sideEffects": false,
44
+ "scripts": {
45
+ "build": "node scripts/build.mjs",
46
+ "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",
47
+ "test": "npm run build && node test/smoke.mjs && node test/component-smoke.mjs",
48
+ "validate": "node scripts/validate-package.mjs",
49
+ "prepack": "npm run build"
50
+ },
51
+ "dependencies": {
52
+ "@openpresentation/opf": "^0.2.2"
53
+ },
54
+ "peerDependencies": {
55
+ "@openpresentation/opf-render": ">=0.0.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "@openpresentation/opf-render": {
59
+ "optional": true
60
+ }
61
+ }
62
+ }