@openpresentation/opf-editor 0.14.2 → 0.16.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 +93 -35
- package/dist/assets.js +1 -1
- package/dist/background-options.d.ts +10 -3
- package/dist/background-options.js +38 -13
- package/dist/canvas.d.ts +8 -1
- package/dist/canvas.js +25 -10
- package/dist/catalog-control.js +21 -0
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/checks.js +9 -7
- package/dist/content-actions.d.ts +2 -2
- package/dist/content-actions.js +3 -3
- package/dist/content-controls.js +9 -9
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +286 -82
- package/dist/design-options.d.ts +4 -4
- package/dist/design-options.js +6 -26
- package/dist/export.d.ts +8 -1
- package/dist/export.js +14 -4
- package/dist/find-replace.js +1 -0
- package/dist/font-gate.js +0 -11
- package/dist/galleries.js +32 -67
- package/dist/image-crop.js +8 -9
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.js +107 -51
- package/dist/json-editor.js +2 -2
- package/dist/json-menu.js +2 -2
- package/dist/json-options.d.ts +3 -3
- package/dist/json-options.js +37 -47
- package/dist/layout-handles.js +13 -0
- package/dist/layout-placeholders.js +15 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +6 -2
- package/dist/review-panel.js +8 -2
- package/dist/schema-inspector.js +1 -1
- package/dist/schema.js +1 -1
- package/dist/slide-manager.js +4 -2
- package/dist/slides.d.ts +1 -1
- package/dist/slides.js +6 -5
- package/dist/svelte.d.ts +1 -0
- package/dist/svelte.js +4 -4
- package/dist/switches.d.ts +31 -16
- package/dist/switches.js +194 -92
- package/dist/table-options.d.ts +6 -1
- package/dist/table-options.js +43 -0
- package/dist/transfer.d.ts +12 -1
- package/dist/transfer.js +87 -131
- package/package.json +22 -9
package/dist/canvas.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createEditorSession,
|
|
3
|
+
mergeCatalogs,
|
|
3
4
|
opfPathToJsonPointer,
|
|
4
5
|
applyJsonPatch,
|
|
5
6
|
getValueAtPath,
|
|
@@ -22,7 +23,7 @@ import { createRichTextInput } from "./rich-text-input.js";
|
|
|
22
23
|
import { textInputOffsetAtPoint } from "./text-pointer.js";
|
|
23
24
|
import { createRichTextToolbar } from "./rich-text-toolbar.js";
|
|
24
25
|
import { createImageCropper } from "./image-cropper.js";
|
|
25
|
-
import { FONTS_PENDING, fontGate, fontsPendingError,
|
|
26
|
+
import { FONTS_PENDING, fontGate, fontsPendingError, whenFontsReady } from "./font-gate.js";
|
|
26
27
|
import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
27
28
|
export { getEditableFields } from "./canvas-fields.js";
|
|
28
29
|
export { whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
|
|
@@ -67,12 +68,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
67
68
|
win = doc.defaultView;
|
|
68
69
|
const editor =
|
|
69
70
|
options.editor ??
|
|
70
|
-
createEditorSession(options.presentation, { rejectInvalid: true });
|
|
71
|
+
createEditorSession(options.presentation, { rejectInvalid: true, catalogs: options.catalogs });
|
|
71
72
|
// `options.fonts` is the renderer's fonts handle (`loadFonts()`): it measures the text and draws the faces. A document whose faces
|
|
72
73
|
// are not loaded yet is never rendered: the canvas has the handle load them first and shows "Loading fonts…" meanwhile. Without a
|
|
73
74
|
// handle every document renders at once, with core's portable text estimate.
|
|
74
75
|
const handle = options.fonts, fonts = fontGate(handle);
|
|
75
|
-
|
|
76
|
+
// The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
|
|
77
|
+
const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
|
|
78
|
+
// RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
|
|
79
|
+
// `@font-face` data would repeat megabytes of base64 in every redraw. Exports keep the default and carry the faces they draw.
|
|
80
|
+
const drawOptions = (extra) => ({ ...resolveOptions(), fonts: handle, embedFonts: false, ...extra });
|
|
76
81
|
let slideIndex = options.slideIndex ?? 0,
|
|
77
82
|
// RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
|
|
78
83
|
// overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
|
|
@@ -180,11 +185,11 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
180
185
|
// A synchronous render or validation of a document whose faces are still loading fails with a clear "fonts-pending"
|
|
181
186
|
// error and starts the load, so the next attempt succeeds. It never draws glyphs the registry cannot provide.
|
|
182
187
|
function requireFonts(presentation) {
|
|
183
|
-
const pending = fonts?.pending(presentation,
|
|
188
|
+
const pending = fonts?.pending(presentation, resolveOptions()) ?? [];
|
|
184
189
|
if (!pending.length) return;
|
|
185
190
|
notice.hidden = false;
|
|
186
191
|
notice.textContent = pendingMessage;
|
|
187
|
-
fonts.ensure(presentation, { renderOptions }).then(
|
|
192
|
+
fonts.ensure(presentation, { renderOptions: resolveOptions() }).then(
|
|
188
193
|
() => { if (!disposed && notice.textContent === pendingMessage) clearNotice(); },
|
|
189
194
|
(error) => { if (!disposed) report(error); },
|
|
190
195
|
);
|
|
@@ -223,7 +228,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
223
228
|
if (disposed) return;
|
|
224
229
|
const token = ++showToken;
|
|
225
230
|
return whenFontsReady(fonts, target ?? editor.presentation, {
|
|
226
|
-
renderOptions,
|
|
231
|
+
renderOptions: resolveOptions(),
|
|
227
232
|
isCurrent: () => !disposed && token === showToken,
|
|
228
233
|
loading: (pending) => fontsState("loading", pending),
|
|
229
234
|
ready: () => {
|
|
@@ -239,7 +244,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
239
244
|
}
|
|
240
245
|
// Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
|
|
241
246
|
function renderFor(presentation) {
|
|
242
|
-
if (fonts?.pending(presentation ?? editor.presentation,
|
|
247
|
+
if (fonts?.pending(presentation ?? editor.presentation, resolveOptions()).length) show(presentation);
|
|
243
248
|
else render(presentation);
|
|
244
249
|
}
|
|
245
250
|
function render(presentation = editor.presentation) {
|
|
@@ -253,6 +258,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
253
258
|
const svg = preview.querySelector("svg");
|
|
254
259
|
svg.setAttribute("role", "group");
|
|
255
260
|
svg.setAttribute("aria-label", "Editable slide");
|
|
261
|
+
// FA-30: the renderer labels its slide root a "slide" group named by the title; the canvas is named for editing instead, so it drops
|
|
262
|
+
// the roledescription rather than announce "Editable slide, slide".
|
|
263
|
+
svg.removeAttribute("aria-roledescription");
|
|
256
264
|
svg.removeAttribute("aria-labelledby");
|
|
257
265
|
const geometry = resolvePresentation(presentation, drawOptions()).slides[
|
|
258
266
|
slideIndex
|
|
@@ -283,6 +291,13 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
283
291
|
continue;
|
|
284
292
|
seen.add(path);
|
|
285
293
|
node.setAttribute("data-canvas-target", "");
|
|
294
|
+
// FA-30: the renderer hides decorative drawing and a picture or chart with an empty alt (aria-hidden), and names a chart with
|
|
295
|
+
// alt as an image group. An editing target is focusable, so neither it nor a group around it may be hidden or presentational:
|
|
296
|
+
// an ancestor image group (the chart's alt) stays named but becomes a plain group so the targets inside it are reachable.
|
|
297
|
+
for (let up = node; up && up !== svg; up = up.parentNode) {
|
|
298
|
+
up.removeAttribute("aria-hidden");
|
|
299
|
+
if (up !== node && up.getAttribute("role") === "img") up.setAttribute("role", "group");
|
|
300
|
+
}
|
|
286
301
|
node.setAttribute("role", "button");
|
|
287
302
|
node.setAttribute("tabindex", "0");
|
|
288
303
|
node.setAttribute(
|
|
@@ -366,11 +381,11 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
366
381
|
}
|
|
367
382
|
// An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
|
|
368
383
|
function deferDraft(draft) {
|
|
369
|
-
if (!fonts?.pending(draft,
|
|
384
|
+
if (!fonts?.pending(draft, resolveOptions()).length) return false;
|
|
370
385
|
active.valid = false;
|
|
371
386
|
notice.hidden = false;
|
|
372
387
|
notice.textContent = pendingMessage;
|
|
373
|
-
fonts.ensure(draft, { renderOptions }).then(
|
|
388
|
+
fonts.ensure(draft, { renderOptions: resolveOptions() }).then(
|
|
374
389
|
() => {
|
|
375
390
|
if (disposed) return;
|
|
376
391
|
if (notice.textContent === pendingMessage) clearNotice();
|
|
@@ -1020,7 +1035,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
1020
1035
|
get layoutEditing() { return layoutHandles.enabled; },
|
|
1021
1036
|
/** True while the crop layer is open. */
|
|
1022
1037
|
get cropping() { return !!imageCropper?.isOpen; },
|
|
1023
|
-
/** Open the crop layer for the picture at `path` (an image block
|
|
1038
|
+
/** Open the crop layer for the picture at `path` (an image block or a slide's `image`). `tool: "focus"` starts with the focal point. */
|
|
1024
1039
|
cropImage(path, cropOptions) {
|
|
1025
1040
|
if (!imageCropper) return Promise.resolve(false);
|
|
1026
1041
|
return imageCropper.open(path, cropOptions);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Framework-neutral event handling: rejected edits restore the committed value
|
|
2
|
+
// and reach the host callback and a bubbling opferror event.
|
|
3
|
+
export function commitCatalogControl(node, onError, sync, commit) {
|
|
4
|
+
try {
|
|
5
|
+
commit();
|
|
6
|
+
node.setCustomValidity?.("");
|
|
7
|
+
node.removeAttribute?.("aria-invalid");
|
|
8
|
+
node.removeAttribute?.("data-opf-error");
|
|
9
|
+
return true;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
sync();
|
|
12
|
+
node.setCustomValidity?.(error.message);
|
|
13
|
+
node.setAttribute?.("aria-invalid", "true");
|
|
14
|
+
node.setAttribute?.("data-opf-error", error.message);
|
|
15
|
+
onError?.(error);
|
|
16
|
+
const Event = node.ownerDocument?.defaultView?.CustomEvent;
|
|
17
|
+
if (Event) node.dispatchEvent?.(new Event("opferror", { detail: error, bubbles: true }));
|
|
18
|
+
node.reportValidity?.();
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Catalog, CatalogKind, CatalogRecordChange, EmbeddedRecord, UnresolvedReferenceDiagnostic, ValidationReport } from "@openpresentation/opf";
|
|
2
|
+
import type { CatalogOption, EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
3
|
+
|
|
4
|
+
/** One catalog list: the session's registration first, then each host list; the first catalog with a `source` wins. Throws `invalid-catalogs` for an entry without a source. */
|
|
5
|
+
export declare function mergeCatalogs(...lists: (readonly Catalog[] | Catalog | undefined | null)[]): readonly Catalog[];
|
|
6
|
+
/** The catalogs for one operation: `editor.catalogs`, then `options.catalogs`. */
|
|
7
|
+
export declare function catalogsFor(editor: EditorSession | undefined, options?: { catalogs?: readonly Catalog[] }): readonly Catalog[];
|
|
8
|
+
/** A record's picker label: its name, then its gallery label, then the id. */
|
|
9
|
+
export declare function catalogRecordLabel(record: Record<string, unknown> | undefined, id: string): string;
|
|
10
|
+
/** Core's `catalogRecords` as picker options: embedded records first, then registered ones; `id` is the reference to write. */
|
|
11
|
+
export declare function listCatalogRecords(presentation: unknown, kind: CatalogKind, options?: { catalogs?: readonly Catalog[] }): CatalogOption[];
|
|
12
|
+
|
|
13
|
+
export interface SavedDocument {
|
|
14
|
+
/** The self-contained document: every referenced record embedded once under its group, `x-*` stripped. */
|
|
15
|
+
document: unknown;
|
|
16
|
+
added: EmbeddedRecord[];
|
|
17
|
+
unresolved: UnresolvedReferenceDiagnostic[];
|
|
18
|
+
}
|
|
19
|
+
/** Core's `embed`: the document to save or hand on, which renders the same with no catalog registered. The input is not changed. */
|
|
20
|
+
export declare function prepareSave(presentation: unknown, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
|
|
21
|
+
/** `prepareSave` of the session's document with its registered catalogs (and `options.catalogs`). The session is not changed. */
|
|
22
|
+
export declare function saveDocument(editor: EditorSession, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
|
|
23
|
+
|
|
24
|
+
export interface CatalogUpdateCheck {
|
|
25
|
+
/** One per embedded record (under `default` or a named group) whose registered record differs. */
|
|
26
|
+
changes: CatalogRecordChange[];
|
|
27
|
+
/** RFC 6902; apply only after the author approves (`applyCatalogUpdate`). */
|
|
28
|
+
patch: JsonPatchOperation[];
|
|
29
|
+
}
|
|
30
|
+
/** The "Update from catalog" check (core's `updateFromCatalog`). Applies nothing. `refs` limits it to some references. */
|
|
31
|
+
export declare function checkCatalogUpdates(presentation: unknown, options?: { catalogs?: readonly Catalog[]; refs?: readonly { kind: CatalogKind; reference: string }[] }): CatalogUpdateCheck;
|
|
32
|
+
/** Apply the approved updates (`refs`) as ONE undoable step, recomputed against the session's current document. */
|
|
33
|
+
export declare function applyCatalogUpdate(
|
|
34
|
+
editor: EditorSession,
|
|
35
|
+
options: { refs: readonly { kind: CatalogKind; reference: string }[]; catalogs?: readonly Catalog[]; meta?: Record<string, unknown> },
|
|
36
|
+
): EditorChange & { changed: boolean; changes: CatalogRecordChange[] };
|
|
37
|
+
|
|
38
|
+
export declare const REFERENCE_FINDING_CODES: readonly ["opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"];
|
|
39
|
+
/** The reference and embedded-record findings (`REFERENCE_FINDING_CODES`) of a report. */
|
|
40
|
+
export declare function referenceFindings(validation: ValidationReport | undefined): ValidationReport["findings"];
|
|
41
|
+
|
|
42
|
+
export interface CatalogRecordRef { group: string; kind: CatalogKind; id: string }
|
|
43
|
+
/** The record a `opf/catalog-record-not-in-source` finding path names (`/catalogs/<group>/<kind>/<id>`). */
|
|
44
|
+
export declare function catalogRecordAt(path: string): CatalogRecordRef | undefined;
|
|
45
|
+
/** Move a record embedded under `default` or a named group into `catalogs.custom`, rewriting the references that resolved to it. */
|
|
46
|
+
export declare function prepareMoveToCustom(presentation: unknown, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[] }): { presentation: unknown; patches: JsonPatchOperation[]; changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };
|
|
47
|
+
/** `prepareMoveToCustom` as one undoable step on the session. */
|
|
48
|
+
export declare function moveToCustom(editor: EditorSession, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[]; meta?: Record<string, unknown> }): EditorChange & { changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };
|
package/dist/catalogs.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Catalogs (FA-23, OPF 0.15). The editor never ships or imports catalog data: the host registers its catalogs (`Catalog[]`,
|
|
2
|
+
// the shape core's `CatalogOptions.catalogs` takes, for example `defaultCatalog` from `@openpresentation/opf/catalog`), on the
|
|
3
|
+
// session (`createEditorSession(doc, { catalogs })`) or per call. `mergeCatalogs` is the one function that turns those into the
|
|
4
|
+
// one list the editor hands to core (resolution, validation, embedding, copying, updates), to the renderer and to the exporter.
|
|
5
|
+
// Pickers list core's `catalogRecords` and write the reference it gives (`id` or `name:id`); saving and exporting embed every
|
|
6
|
+
// referenced record with core's `embed`, so a saved document renders the same with no catalog registered.
|
|
7
|
+
import { catalogKinds, catalogRecords, embed, moveToCustom as coreMoveToCustom, updateFromCatalog } from "@openpresentation/opf";
|
|
8
|
+
import { OPFEditorError } from "./index.js";
|
|
9
|
+
|
|
10
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One catalog list from any number of lists (or single catalogs): the session's registration first, then each host list, in order.
|
|
14
|
+
* A catalog is identified by its `source`; the first catalog with a source wins and later ones with the same source are dropped,
|
|
15
|
+
* so the host default (the first entry) never moves. Entries that are not catalogs throw `invalid-catalogs` (core's
|
|
16
|
+
* `OPFCatalogsOptionError` code).
|
|
17
|
+
*/
|
|
18
|
+
export function mergeCatalogs(...lists) {
|
|
19
|
+
const merged = [];
|
|
20
|
+
const sources = new Set();
|
|
21
|
+
for (const list of lists) {
|
|
22
|
+
if (list === undefined || list === null) continue;
|
|
23
|
+
for (const catalog of Array.isArray(list) ? list : [list]) {
|
|
24
|
+
if (catalog === undefined || catalog === null) continue;
|
|
25
|
+
if (!isObject(catalog) || typeof catalog.source !== "string" || catalog.source === "")
|
|
26
|
+
throw new OPFEditorError("invalid-catalogs", "A registered catalog is an object with a non-empty source, such as defaultCatalog from @openpresentation/opf/catalog.", { catalog });
|
|
27
|
+
if (sources.has(catalog.source)) continue;
|
|
28
|
+
sources.add(catalog.source);
|
|
29
|
+
merged.push(catalog);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze(merged);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The catalogs for one operation: the session's registered list (when `editor` is given), then `options.catalogs`. */
|
|
36
|
+
export function catalogsFor(editor, options = {}) {
|
|
37
|
+
return mergeCatalogs(editor?.catalogs, options?.catalogs);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The label a picker shows for a record: its name, then its gallery label, then the id. */
|
|
41
|
+
export function catalogRecordLabel(record, id) {
|
|
42
|
+
const gallery = isObject(record?.["x-gallery"]) ? record["x-gallery"] : {};
|
|
43
|
+
for (const value of [record?.name, record?.title, record?.label, gallery.label, gallery.name]) if (typeof value === "string" && value) return value;
|
|
44
|
+
return String(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every record a picker can offer for `kind`, from core's `catalogRecords`: the document's embedded records first, then the
|
|
49
|
+
* registered catalogs'. Each entry is `{ id, reference, label, description, record, group, source, origin }`; `id` and
|
|
50
|
+
* `reference` are the reference to write (`id` or `name:id`). A reference is listed once.
|
|
51
|
+
*/
|
|
52
|
+
export function listCatalogRecords(presentation, kind, options = {}) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const entry of catalogRecords(presentation ?? {}, kind, { catalogs: mergeCatalogs(options.catalogs) })) {
|
|
56
|
+
if (seen.has(entry.reference)) continue;
|
|
57
|
+
seen.add(entry.reference);
|
|
58
|
+
const record = isObject(entry.record) ? entry.record : {};
|
|
59
|
+
out.push({
|
|
60
|
+
id: entry.reference,
|
|
61
|
+
reference: entry.reference,
|
|
62
|
+
label: catalogRecordLabel(record, entry.id),
|
|
63
|
+
...(typeof record.description === "string" ? { description: record.description } : {}),
|
|
64
|
+
record,
|
|
65
|
+
group: entry.group,
|
|
66
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
67
|
+
origin: entry.origin,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The document to save or hand on: core's `embed` adds every record the document references, once, under its catalog group
|
|
75
|
+
* (with the records those reference, `x-*` display metadata stripped), so it renders the same with no catalog registered.
|
|
76
|
+
* Returns `{ document, added, unresolved }`; the input is not changed.
|
|
77
|
+
*/
|
|
78
|
+
export function prepareSave(presentation, options = {}) {
|
|
79
|
+
const result = embed(presentation, { catalogs: mergeCatalogs(options.catalogs) });
|
|
80
|
+
return { document: result.document, added: result.added ?? [], unresolved: result.unresolved ?? [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `prepareSave` for the session's document, with the session's catalogs and `options.catalogs`. The session is not changed. */
|
|
84
|
+
export function saveDocument(editor, options = {}) {
|
|
85
|
+
assertSession(editor);
|
|
86
|
+
return prepareSave(editor.presentation, { catalogs: catalogsFor(editor, options) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The "Update from catalog" check: core's `updateFromCatalog` compares every record embedded under `default` or a named group
|
|
91
|
+
* (never `custom`) with the registered catalog's current record. Returns `{ changes, patch }`; nothing is applied. `refs` limits
|
|
92
|
+
* the check to some references (`[{ kind, reference }]`).
|
|
93
|
+
*/
|
|
94
|
+
export function checkCatalogUpdates(presentation, options = {}) {
|
|
95
|
+
const update = updateFromCatalog(presentation, mergeCatalogs(options.catalogs), options.refs);
|
|
96
|
+
return { changes: update.changes ?? [], patch: update.patch ?? [] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply an update the author approved, as ONE undoable step. `options.refs` names the approved changes (`[{ kind, reference }]`);
|
|
101
|
+
* the update is recomputed for exactly those against the session's current document, so a stale review never applies. Returns
|
|
102
|
+
* the session change plus `changes` (what was applied) and `changed`.
|
|
103
|
+
*/
|
|
104
|
+
export function applyCatalogUpdate(editor, options = {}) {
|
|
105
|
+
assertSession(editor);
|
|
106
|
+
if (!Array.isArray(options.refs) || !options.refs.length) throw new OPFEditorError("no-approved-updates", "Choose the catalog updates to apply.");
|
|
107
|
+
const update = checkCatalogUpdates(editor.presentation, { catalogs: catalogsFor(editor, options), refs: options.refs });
|
|
108
|
+
if (!update.patch.length) return { changed: false, changes: [], presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
109
|
+
const change = editor.applyPatch(update.patch, { ...options.meta, source: options.meta?.source ?? "catalog-update", catalogRecordEdit: "in-place", catalogUpdate: update.changes.map(({ kind, reference }) => ({ kind, reference })) });
|
|
110
|
+
return { ...change, changed: true, changes: update.changes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Core finding codes about catalog references and embedded records, which the editor surfaces next to its catalog controls:
|
|
115
|
+
* a reference that resolves nowhere, a prefix with no catalog group, and a record embedded under a catalog's group that the
|
|
116
|
+
* registered catalog does not publish (offer `moveToCustom`).
|
|
117
|
+
*/
|
|
118
|
+
export const REFERENCE_FINDING_CODES = Object.freeze(["opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"]);
|
|
119
|
+
|
|
120
|
+
/** The reference and embedded-record findings (`REFERENCE_FINDING_CODES`) of a validation report (the session's `validation`). */
|
|
121
|
+
export function referenceFindings(validation) {
|
|
122
|
+
return (validation?.findings ?? []).filter((finding) => REFERENCE_FINDING_CODES.includes(finding.ruleId ?? finding.code));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertSession(editor) {
|
|
126
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
|
|
127
|
+
throw new OPFEditorError("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The record an `opf/catalog-record-not-in-source` finding names, from its path (`/catalogs/<group>/<kind>/<id>` or the dotted
|
|
132
|
+
* form): `{ group, kind, id }`, or undefined for a path that names no embedded record.
|
|
133
|
+
*/
|
|
134
|
+
export function catalogRecordAt(path) {
|
|
135
|
+
const parts = typeof path === "string" ? (path.startsWith("/") ? path.slice(1).split("/").map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")) : path.split(".")) : [];
|
|
136
|
+
if (parts.length !== 4 || parts[0] !== "catalogs" || !catalogKinds.includes(parts[2])) return undefined;
|
|
137
|
+
return { group: parts[1], kind: parts[2], id: parts[3] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Move a record embedded under `default` or a named group into `catalogs.custom` (the fix the `opf/catalog-record-not-in-source`
|
|
144
|
+
* finding offers, and the fork an edit makes) with core's `moveToCustom`: the document owns it from then on. Every reference that
|
|
145
|
+
* resolved to it is rewritten to the custom id (`options.id`, else its id, `<id>-2` and up on a conflict), and references written
|
|
146
|
+
* inside it keep naming what they named. Returns `{ presentation, patches, changed, from, to, references }`.
|
|
147
|
+
*/
|
|
148
|
+
export function prepareMoveToCustom(presentation, target, options = {}) {
|
|
149
|
+
const { group, kind, id } = target ?? {};
|
|
150
|
+
const groups = isObject(presentation?.catalogs) ? presentation.catalogs : {};
|
|
151
|
+
const record = isObject(groups[group]?.[kind]) ? groups[group][kind][id] : undefined;
|
|
152
|
+
if (group === "custom" || record === undefined) throw new OPFEditorError("not-a-catalog-record", "Choose a record embedded under catalogs.default or a named catalog group.", { group, kind, id });
|
|
153
|
+
// Core's moveToCustom is the one implementation: references follow, the record's own references keep naming what they named.
|
|
154
|
+
const reference = group === "default" ? id : `${group}:${id}`;
|
|
155
|
+
const moved = coreMoveToCustom(presentation, { kind, reference }, { catalogs: mergeCatalogs(options.catalogs), ...(options.id ? { id: options.id } : {}) });
|
|
156
|
+
const patches = [{ op: "test", path: "/catalogs", value: structuredClone(presentation.catalogs) }, ...moved.patch];
|
|
157
|
+
return { presentation: moved.document, patches, changed: true, from: { group, kind, id }, to: { group: "custom", kind, id: moved.to.id }, references: moved.references ?? [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `prepareMoveToCustom` on the session's document, as ONE undoable step (`meta.source: "catalog-move-to-custom"`). `options.id` names
|
|
162
|
+
* the custom record (default: the record's id, `<id>-2` on a conflict).
|
|
163
|
+
*/
|
|
164
|
+
export function moveToCustom(editor, target, options = {}) {
|
|
165
|
+
assertSession(editor);
|
|
166
|
+
const prepared = prepareMoveToCustom(editor.presentation, target, { catalogs: catalogsFor(editor, options), ...(options.id ? { id: options.id } : {}) });
|
|
167
|
+
const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "catalog-move-to-custom", catalogRecordEdit: "in-place" });
|
|
168
|
+
return { ...change, changed: true, from: prepared.from, to: prepared.to };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The record kinds a user edits, which an edit forks into `catalogs.custom` when they live under a catalog's group. */
|
|
172
|
+
export const FORKED_RECORD_KINDS = Object.freeze(["layouts", "themes", "colorSchemes", "fontSchemes"]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Records under `catalogs.default` or a named group whose content an edit from `before` to `after` changed (present in both, not
|
|
176
|
+
* equal). An added or removed record is not an edit. Returns `[{ group, kind, id }]`.
|
|
177
|
+
*/
|
|
178
|
+
export function editedCatalogRecords(before, after) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const groupsBefore = isObject(before?.catalogs) ? before.catalogs : {};
|
|
181
|
+
const groupsAfter = isObject(after?.catalogs) ? after.catalogs : {};
|
|
182
|
+
for (const [group, value] of Object.entries(groupsAfter)) {
|
|
183
|
+
if (group === "custom" || !isObject(value) || !isObject(groupsBefore[group])) continue;
|
|
184
|
+
for (const kind of FORKED_RECORD_KINDS) {
|
|
185
|
+
const was = isObject(groupsBefore[group][kind]) ? groupsBefore[group][kind] : {};
|
|
186
|
+
const now = isObject(value[kind]) ? value[kind] : {};
|
|
187
|
+
for (const id of Object.keys(now)) if (Object.hasOwn(was, id) && !sameJson(was[id], now[id])) out.push({ group, kind, id });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The notice an editor shows after a fork: the record is the deck's own now and no longer receives catalog updates. */
|
|
194
|
+
export function forkNotice(fork) {
|
|
195
|
+
const label = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme" }[fork.from.kind] ?? fork.from.kind;
|
|
196
|
+
return `The ${label} ${fork.from.id} is now this presentation's own (${fork.to.id}); it no longer receives catalog updates.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Owner rule (FA-23): editing a record that lives under `catalogs.default` or a named group forks it into `catalogs.custom` instead
|
|
201
|
+
* of changing the catalog's record in place. Given the document before and after an edit, returns the document after the forks
|
|
202
|
+
* (each edited record moved to custom as `<id>-custom`, or `forkIds[<group>:<kind>:<id>]`, every reference rewritten, the
|
|
203
|
+
* original dropped) and `forks: [{ from, to }]`. Nothing to fork returns `after` and `[]`.
|
|
204
|
+
*/
|
|
205
|
+
export function forkEditedRecords(before, after, options = {}) {
|
|
206
|
+
let document = after;
|
|
207
|
+
const forks = [];
|
|
208
|
+
for (const target of editedCatalogRecords(before, after)) {
|
|
209
|
+
const custom = isObject(document.catalogs?.custom?.[target.kind]) ? document.catalogs.custom[target.kind] : {};
|
|
210
|
+
let id = options.forkIds?.[`${target.group}:${target.kind}:${target.id}`] ?? `${target.id}-custom`;
|
|
211
|
+
for (let n = 2; Object.hasOwn(custom, id); n += 1) id = `${target.id}-custom-${n}`;
|
|
212
|
+
const moved = prepareMoveToCustom(document, target, { catalogs: options.catalogs, id });
|
|
213
|
+
document = moved.presentation;
|
|
214
|
+
forks.push({ from: moved.from, to: moved.to });
|
|
215
|
+
}
|
|
216
|
+
return { document, forks };
|
|
217
|
+
}
|
package/dist/checks.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// The editor's one use of core's checker. Every edit is validated with `validate
|
|
2
|
-
// schema and semantic OPF rules, with no
|
|
3
|
-
//
|
|
1
|
+
// The editor's one use of core's checker. Every edit is validated with core's `validate` for the `format` category and the two
|
|
2
|
+
// catalog rules (`opf/unresolved-reference`, `opf/undeclared-catalog`, `opf/catalog-record-not-in-source`): the schema and semantic OPF rules, with no
|
|
3
|
+
// layout built, so it is cheap enough to run on every keystroke and undo. `catalogs` are the host's registered catalogs, so a
|
|
4
|
+
// reference that resolves in one of them is not reported. The full report (accessibility, layout, content) belongs to the Review
|
|
5
|
+
// panel, which runs it on idle (`./review.js`).
|
|
4
6
|
import { validate } from "@openpresentation/opf";
|
|
5
7
|
|
|
6
|
-
const
|
|
8
|
+
const EDIT_CATEGORIES = Object.freeze(["format", "opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"]);
|
|
7
9
|
|
|
8
|
-
/** Core's `ValidationReport` for the `format` category: `valid`, `findings`, `counts`, `schemaValid`. */
|
|
9
|
-
export function checkFormat(presentation) {
|
|
10
|
-
return validate(presentation,
|
|
10
|
+
/** Core's `ValidationReport` for the `format` category and the catalog reference rules: `valid`, `findings`, `counts`, `schemaValid`. */
|
|
11
|
+
export function checkFormat(presentation, options = {}) {
|
|
12
|
+
return validate(presentation, { only: EDIT_CATEGORIES, ...(options.catalogs?.length ? { catalogs: options.catalogs } : {}) });
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/** The error-severity findings of a report: what makes it invalid. */
|
|
@@ -72,9 +72,9 @@ export declare function ungroupBlock(editor: EditorSession, groupPath: string, m
|
|
|
72
72
|
export declare function placeBlocksInRegions(editor: EditorSession, slideIndex: number, regions: string[], meta?: Record<string, unknown>): ContentActionChange;
|
|
73
73
|
export declare function regionsAsBlocks(editor: EditorSession, slideIndex: number, meta?: Record<string, unknown>): ContentActionChange;
|
|
74
74
|
export declare function moveSlideRegion(editor: EditorSession, slideIndex: number, from: string, to: string, options?: { swap?: boolean }, meta?: Record<string, unknown>): ContentActionChange;
|
|
75
|
-
/** Move an image block into the slide's design as its
|
|
75
|
+
/** Move an image block into the slide's design as its background or watermark. */
|
|
76
76
|
export declare function moveImageToDesign(editor: EditorSession, blockPath: string, target: ImageTarget, options?: PromoteImageOptions, meta?: Record<string, unknown>): ContentActionChange;
|
|
77
|
-
/** Move a slide's own
|
|
77
|
+
/** Move a slide's own background image or watermark back into its content as an image block. */
|
|
78
78
|
export declare function moveImageToContent(editor: EditorSession, slideIndex: number, source: ImageTarget, options?: { index?: number; region?: string }, meta?: Record<string, unknown>): ContentActionChange;
|
|
79
79
|
|
|
80
80
|
export declare function prepareSplitSlide(presentation: unknown, slideIndex: number, options?: { at?: number[]; each?: boolean; repeatHeadings?: boolean }): PreparedContentAction;
|
package/dist/content-actions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Content actions (RR-26): list levels, grouping, regions, images between content and design, and slide
|
|
1
|
+
// Content actions (RR-26): list levels, grouping, regions, images between content and design (background or watermark), and slide
|
|
2
2
|
// split and merge, as editor transactions. The pure transforms live in core (`@openpresentation/opf/convert`);
|
|
3
3
|
// each action here finds its target in the document, calls the transform, turns the result into a guarded patch
|
|
4
4
|
// (`test` of what was read, then `replace`, or per-slide `remove` and `add`), validates the document and, in the
|
|
@@ -143,12 +143,12 @@ export function prepareMoveRegion(presentation, slideIndex, from, to, options =
|
|
|
143
143
|
slideParts(presentation, `slides.${slideIndex}`, "a slide");
|
|
144
144
|
return slideAction(presentation, slideIndex, (slide) => moveRegion(slide, from, to, options), undefined, options.validate !== false);
|
|
145
145
|
}
|
|
146
|
-
/** Move the image block at `blockPath` into the slide's design as
|
|
146
|
+
/** Move the image block at `blockPath` into the slide's design as its `background` or `watermark` (core's `promoteImage`). */
|
|
147
147
|
export function prepareImageToDesign(presentation, blockPath, target, options = {}) {
|
|
148
148
|
const { slideIndex, relative } = slideParts(presentation, blockPath, "an image block");
|
|
149
149
|
return slideAction(presentation, slideIndex, (slide) => promoteImage(slide, asNumbers(relative), target, options), `slides.${slideIndex}`, options.validate !== false);
|
|
150
150
|
}
|
|
151
|
-
/** Move a slide's own `
|
|
151
|
+
/** Move a slide's own `background` image or `watermark` back into its content as an image block (core's `demoteImage`). */
|
|
152
152
|
export function prepareImageToContent(presentation, slideIndex, source, options = {}) {
|
|
153
153
|
slideParts(presentation, `slides.${slideIndex}`, "a slide");
|
|
154
154
|
return slideAction(presentation, slideIndex, (slide) => demoteImage(slide, source, options), undefined, options.validate !== false);
|
package/dist/content-controls.js
CHANGED
|
@@ -36,20 +36,20 @@ export const REGION_LAYOUTS = Object.freeze({
|
|
|
36
36
|
4: [{ id: "grid", label: "Two by two", regions: ["top:left", "top:right", "bottom:left", "bottom:right"] }],
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Where an image block can go in the slide's design (OPF 0.15): its background (a full-bleed photo behind the content) or its
|
|
41
|
+
* watermark. Bleeding an image to one edge beside the content is not a move: it is the block's `placement` (image-options.js).
|
|
42
|
+
*/
|
|
40
43
|
export const IMAGE_DESTINATIONS = Object.freeze([
|
|
41
|
-
{ value: "
|
|
42
|
-
{ value: "slideImage:left", label: "Slide image, on the left", target: "slideImage", options: { position: "left" } },
|
|
43
|
-
{ value: "slideImage:top", label: "Slide image, along the top", target: "slideImage", options: { position: "top" } },
|
|
44
|
-
{ value: "slideImage:bottom", label: "Slide image, along the bottom", target: "slideImage", options: { position: "bottom" } },
|
|
45
|
-
{ value: "slideImage:background", label: "Slide image, full slide behind the content", target: "slideImage", options: { position: "background" } },
|
|
46
|
-
{ value: "background", label: "Slide background", target: "background", options: {} },
|
|
44
|
+
{ value: "background", label: "Slide background, full slide behind the content", target: "background", options: {} },
|
|
47
45
|
{ value: "watermark", label: "Watermark", target: "watermark", options: {} },
|
|
48
46
|
]);
|
|
49
47
|
|
|
50
48
|
const REGION_KEY = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
|
|
51
49
|
const regionKeysOfSlide = (slide) => Object.keys(slide).filter((key) => REGION_KEY.test(key));
|
|
52
|
-
const DESIGN_IMAGE_LABELS = {
|
|
50
|
+
const DESIGN_IMAGE_LABELS = { background: "background image", watermark: "watermark" };
|
|
51
|
+
// An image block's own framing fields (FA-22) do not stop it being "a block that holds only an image".
|
|
52
|
+
const IMAGE_BLOCK_FIELDS = new Set(["id", "extensions", "type", "fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"]);
|
|
53
53
|
const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
54
54
|
const lossText = (prepared) => (prepared.loss.length ? `Not carried over: ${prepared.loss.join(", ")}.` : "Nothing is lost.");
|
|
55
55
|
|
|
@@ -321,6 +321,6 @@ export function createContentControls(ctx) {
|
|
|
321
321
|
function isImageBlock(editor, blockPath) {
|
|
322
322
|
const block = editor.get(blockPath);
|
|
323
323
|
if (!block || typeof block !== "object" || Array.isArray(block)) return false;
|
|
324
|
-
const keys = Object.keys(block).filter((key) => key
|
|
324
|
+
const keys = Object.keys(block).filter((key) => !IMAGE_BLOCK_FIELDS.has(key));
|
|
325
325
|
return keys.length === 1 && keys[0] === "image";
|
|
326
326
|
}
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import type { Catalog } from "@openpresentation/opf";
|
|
1
2
|
import type { EditorSession } from "./index.js";
|
|
3
|
+
import type { SwitchVocabularies } from "./switches.js";
|
|
2
4
|
|
|
3
|
-
export type DesignControlSection = "look" | "background" | "
|
|
4
|
-
/**
|
|
5
|
+
export type DesignControlSection = "look" | "background" | "header-footer" | "brand" | "layout-options" | "info" | "selection" | "image" | "table" | "slide-content" | "catalog";
|
|
6
|
+
/**
|
|
7
|
+
* Every section, in panel order. `selection`, `image` (the selected image block's fit, focus, treatments and placement) and `table`
|
|
8
|
+
* follow the host's current selection; `slide-content` (RR-26: blocks and regions, images between content and design) follows the
|
|
9
|
+
* current slide; `catalog` lists the catalog reference findings and runs the explicit "Update from catalog".
|
|
10
|
+
*/
|
|
5
11
|
export declare const DESIGN_CONTROL_SECTIONS: readonly DesignControlSection[];
|
|
6
12
|
|
|
7
13
|
export interface DesignControlsOptions {
|
|
@@ -14,9 +20,10 @@ export interface DesignControlsOptions {
|
|
|
14
20
|
sections?: readonly DesignControlSection[];
|
|
15
21
|
/** Initial "Applies to" choice. */
|
|
16
22
|
scope?: "deck" | "slide";
|
|
17
|
-
/**
|
|
18
|
-
catalogs?:
|
|
19
|
-
|
|
23
|
+
/** Host catalogs for the pickers, merged after the session's registered ones. */
|
|
24
|
+
catalogs?: readonly Catalog[];
|
|
25
|
+
/** Display metadata for the engine vocabularies (languages, chart types, social platforms): `catalogDisplay` from `@openpresentation/opf/catalog`. */
|
|
26
|
+
vocabularies?: SwitchVocabularies;
|
|
20
27
|
/** Called after every committed change (never for a refused one). */
|
|
21
28
|
onChange?: (change: unknown) => void;
|
|
22
29
|
/** Called after a content conversion or replacement with the path to keep selected (the block, or the inline payload field), since the old selection path may no longer exist. */
|