@openpresentation/opf-editor 0.8.0 → 0.10.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 +48 -13
- package/dist/blocks.d.ts +2 -0
- package/dist/blocks.js +33 -0
- package/dist/canvas.d.ts +9 -0
- package/dist/canvas.js +33 -4
- package/dist/exact-source.js +318 -0
- package/dist/font-defaults.js +25 -0
- package/dist/galleries.js +48 -8
- package/dist/index.d.ts +8 -2
- package/dist/index.js +15 -4
- package/dist/layout.d.ts +1 -1
- package/dist/layout.js +1 -1
- package/dist/switches.d.ts +77 -0
- package/dist/switches.js +344 -0
- package/dist/transfer.js +2 -1
- package/package.json +50 -12
package/dist/galleries.js
CHANGED
|
@@ -117,6 +117,53 @@ export async function loadOpfGallery(input, options = {}) {
|
|
|
117
117
|
})),
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
+
const FONT_SCHEME_ENUMS = {
|
|
121
|
+
type: ["sans-serif", "serif", "monospace"],
|
|
122
|
+
app: ["PowerPoint", "Google Slides"],
|
|
123
|
+
languageFamily: ["latin", "ea", "cs"],
|
|
124
|
+
};
|
|
125
|
+
// A Font role object ({ family, weight?, style?, letterSpacing? }), or undefined.
|
|
126
|
+
function fontRole(value) {
|
|
127
|
+
if (typeof value === "string") return value ? { family: value } : undefined;
|
|
128
|
+
if (!value || typeof value !== "object" || typeof value.family !== "string")
|
|
129
|
+
return undefined;
|
|
130
|
+
const role = { family: value.family };
|
|
131
|
+
if (typeof value.weight === "number" && Number.isFinite(value.weight))
|
|
132
|
+
role.weight = value.weight;
|
|
133
|
+
if (value.style === "normal" || value.style === "italic")
|
|
134
|
+
role.style = value.style;
|
|
135
|
+
if (
|
|
136
|
+
typeof value.letterSpacing === "number" &&
|
|
137
|
+
Number.isFinite(value.letterSpacing)
|
|
138
|
+
)
|
|
139
|
+
role.letterSpacing = value.letterSpacing;
|
|
140
|
+
return role;
|
|
141
|
+
}
|
|
142
|
+
// Keep every font-scheme role that the catalog record schema defines: the OOXML
|
|
143
|
+
// pair and `code`. Gallery `heading`/`body` (string or Font object) map onto
|
|
144
|
+
// major/minor only, never role objects, so a later inline design.fontScheme
|
|
145
|
+
// major/minor override still wins (resolveFontFamilies checks roles before the
|
|
146
|
+
// pair). `accent` is not part of the record schema and is dropped.
|
|
147
|
+
function fontSchemeRecord(source, id) {
|
|
148
|
+
const record = {
|
|
149
|
+
$schema: "https://openpresentation.org/schema/opf-font-scheme/v1",
|
|
150
|
+
id,
|
|
151
|
+
name: source.name ?? id,
|
|
152
|
+
major:
|
|
153
|
+
typeof source.major === "string"
|
|
154
|
+
? source.major
|
|
155
|
+
: fontRole(source.heading)?.family,
|
|
156
|
+
minor:
|
|
157
|
+
typeof source.minor === "string"
|
|
158
|
+
? source.minor
|
|
159
|
+
: fontRole(source.body)?.family,
|
|
160
|
+
};
|
|
161
|
+
for (const [field, values] of Object.entries(FONT_SCHEME_ENUMS))
|
|
162
|
+
if (values.includes(source[field])) record[field] = source[field];
|
|
163
|
+
const code = fontRole(source.code);
|
|
164
|
+
if (code) record.code = code;
|
|
165
|
+
return record;
|
|
166
|
+
}
|
|
120
167
|
function attachDefinition(document, descriptor) {
|
|
121
168
|
const source = descriptor.metadata?.source,
|
|
122
169
|
category = descriptor.category ?? descriptor.metadata?.category;
|
|
@@ -161,14 +208,7 @@ function attachDefinition(document, descriptor) {
|
|
|
161
208
|
}
|
|
162
209
|
: {}),
|
|
163
210
|
};
|
|
164
|
-
} else if (kind === "fontSchemes")
|
|
165
|
-
record = {
|
|
166
|
-
$schema: "https://openpresentation.org/schema/opf-font-scheme/v1",
|
|
167
|
-
id,
|
|
168
|
-
name: source.name ?? id,
|
|
169
|
-
major: source.major ?? source.heading,
|
|
170
|
-
minor: source.minor ?? source.body,
|
|
171
|
-
};
|
|
211
|
+
} else if (kind === "fontSchemes") record = fontSchemeRecord(source, id);
|
|
172
212
|
else {
|
|
173
213
|
record = {
|
|
174
214
|
...source,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
|
|
2
2
|
import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
|
|
3
|
+
/** Reported when a font-scheme id matches no inline or bundled record; the default font scheme (`aptos`) is the base. */
|
|
4
|
+
export interface FontSchemeDiagnostic { code: "unresolved-font-scheme"; path: string; message: string; id: string; fallback: string }
|
|
5
|
+
export interface EditorDiagnosticOptions { onDiagnostic?: (diagnostic: FontSchemeDiagnostic) => void }
|
|
3
6
|
export declare const packageName = "@openpresentation/opf-editor";
|
|
4
7
|
|
|
5
8
|
export declare const releaseLane: Readonly<{
|
|
@@ -58,8 +61,8 @@ export interface EditorEvent {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export interface EditorSession {
|
|
61
|
-
paginateSlide(slideIndex: number, options?: PaginationOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
62
|
-
composeSlide(slideIndex: number, options?: ComposeSlideOptions): SlideComposition;
|
|
64
|
+
paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
65
|
+
composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
|
|
63
66
|
setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
64
67
|
readonly document: unknown;
|
|
65
68
|
readonly validation: OPFValidationSummary;
|
|
@@ -159,6 +162,9 @@ export declare function invertJsonPatch(document: unknown, operations: JsonPatch
|
|
|
159
162
|
|
|
160
163
|
export declare function validateOpfDocument(document: unknown, validator?: (document: unknown) => unknown): OPFValidationSummary;
|
|
161
164
|
|
|
165
|
+
/** Font families the slide resolves to (slide, deck, theme, then the shared default scheme). */
|
|
166
|
+
export declare function resolveSlideFonts(document: unknown, slideIndex?: number, options?: EditorDiagnosticOptions): { heading?: string; body?: string; code?: string; [role: string]: string | undefined };
|
|
167
|
+
|
|
162
168
|
export declare function createEditorSession(input: unknown, options?: CreateEditorSessionOptions): EditorSession;
|
|
163
169
|
|
|
164
170
|
export declare function createSvgTraceBinding(
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { paginateSlide } from "@openpresentation/opf/pagination";
|
|
2
2
|
import { collectReservedPresentationIds } from "./presentation-ids.js";
|
|
3
|
+
import { DEFAULT_FONT_SCHEME, resolveFontSchemeReference } from "./font-defaults.js";
|
|
3
4
|
import { composeSlide, resolveCanvasDimensions, resolveFontFamilies } from "@openpresentation/opf/composition";
|
|
4
5
|
import {
|
|
5
6
|
catalogKinds,
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
7
8
|
validatePresentation
|
|
8
9
|
} from "@openpresentation/opf";
|
|
9
10
|
|
|
10
|
-
function resolveCompositionOptions(document, slideIndex, options = {}) {
|
|
11
|
+
function resolveCompositionOptions(document, slideIndex, { onDiagnostic, ...options } = {}) {
|
|
11
12
|
const slide = document.slides?.[slideIndex];
|
|
12
13
|
if (!Number.isInteger(slideIndex) || !slide) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
|
|
13
14
|
const inline = document.catalogs?.layouts?.records ?? [];
|
|
@@ -17,12 +18,22 @@ function resolveCompositionOptions(document, slideIndex, options = {}) {
|
|
|
17
18
|
const themeId = typeof themeReference === "string" ? themeReference : themeReference.id;
|
|
18
19
|
const theme = document.catalogs?.themes?.records?.find(record => record.id === themeId)
|
|
19
20
|
?? bundledCatalogs.themes.find(record => record.id === themeId);
|
|
20
|
-
const reference = slide.design?.fontScheme ?? document.design?.fontScheme ?? theme?.fontScheme ??
|
|
21
|
-
const
|
|
22
|
-
const fontScheme =
|
|
21
|
+
const reference = slide.design?.fontScheme ?? document.design?.fontScheme ?? theme?.fontScheme ?? DEFAULT_FONT_SCHEME;
|
|
22
|
+
const fontPath = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme` : document.design?.fontScheme !== undefined ? "design.fontScheme" : slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
|
|
23
|
+
const { scheme: fontScheme, diagnostic } = resolveFontSchemeReference(reference, id => document.catalogs?.fontSchemes?.records?.find(record=>record.id===id) ?? bundledCatalogs.fontSchemes.find(record=>record.id===id), fontPath);
|
|
24
|
+
if (diagnostic) onDiagnostic?.(diagnostic);
|
|
23
25
|
return { ...resolveCanvasDimensions(slide.design?.dimensions ?? document.design?.dimensions ?? theme?.dimensions), fonts:resolveFontFamilies(fontScheme), contentAlignment:slide.design?.contentAlignment??document.design?.contentAlignment, titleAlignment:slide.design?.titleAlignment??document.design?.titleAlignment, contentBox:slide.design?.contentBox??document.design?.contentBox, presentation:document, ...options, layout, slideIndex };
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The font families a slide resolves to (`heading`, `body`, `code`), by the same slide, deck,
|
|
30
|
+
* theme, default order that composition, pagination and export use. Hosts read this after a
|
|
31
|
+
* font-scheme or theme switch to show the fonts the preview now uses.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveSlideFonts(document, slideIndex = 0, options = {}) {
|
|
34
|
+
return resolveCompositionOptions(document, slideIndex, options).fonts;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
export const packageName = "@openpresentation/opf-editor";
|
|
27
38
|
|
|
28
39
|
export const releaseLane = Object.freeze({
|
package/dist/layout.d.ts
CHANGED
|
@@ -5,6 +5,6 @@ export declare function prepareTrackResize(document:unknown,flow:ComposedFlow,bo
|
|
|
5
5
|
document:unknown;patches:JsonPatchOperation[];composition:Composition;fraction:number;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,createContentBlock} from './blocks.js';
|
|
8
|
+
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,prepareBlockReplace,createContentBlock} from './blocks.js';
|
|
9
9
|
|
|
10
10
|
export type {PreparedBlockChange,ContentBlockKind} from './blocks.js';
|
package/dist/layout.js
CHANGED
|
@@ -31,4 +31,4 @@ export function prepareTrackResize(document, flow, boundary, fraction) {
|
|
|
31
31
|
return {document:next,patches,composition,fraction:share};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,createContentBlock} from './blocks.js';
|
|
34
|
+
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,prepareBlockReplace,createContentBlock} from './blocks.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** The 14 pptx.gallery dimensions. */
|
|
4
|
+
export declare const SWITCH_DIMENSIONS: readonly [
|
|
5
|
+
"layouts",
|
|
6
|
+
"color-schemes",
|
|
7
|
+
"font-schemes",
|
|
8
|
+
"languages",
|
|
9
|
+
"backgrounds",
|
|
10
|
+
"narratives",
|
|
11
|
+
"charts",
|
|
12
|
+
"themes",
|
|
13
|
+
"audiences",
|
|
14
|
+
"tones",
|
|
15
|
+
"socials",
|
|
16
|
+
"headers-footers",
|
|
17
|
+
"blocks",
|
|
18
|
+
"image-treatments",
|
|
19
|
+
];
|
|
20
|
+
export type SwitchDimension = (typeof SWITCH_DIMENSIONS)[number];
|
|
21
|
+
|
|
22
|
+
export interface DimensionSwitchOptions {
|
|
23
|
+
/** Required for layouts and charts; for design dimensions it scopes the switch to one slide (default: the deck). */
|
|
24
|
+
slideIndex?: number;
|
|
25
|
+
/** blocks: the complete block (`slides.0.blocks.1`) or one-payload slide/region to replace. charts: the block holding the chart (default: the slide's first chart). */
|
|
26
|
+
path?: string | string[];
|
|
27
|
+
/** A gallery item's catalog record, added inline in the same transaction when the id is not already defined. */
|
|
28
|
+
record?: Record<string, unknown> & { id: string };
|
|
29
|
+
/** Extra caller-loaded catalog records by kind. */
|
|
30
|
+
catalogs?: Record<string, unknown>;
|
|
31
|
+
catalogSources?: Record<string, unknown>;
|
|
32
|
+
/** themes: also write the theme's color scheme, font scheme, background and dimensions to the deck design (default true). */
|
|
33
|
+
bundle?: boolean;
|
|
34
|
+
/** Deck-scope design switches: remove slide-level values that would hide the switch. */
|
|
35
|
+
clearSlideOverrides?: boolean;
|
|
36
|
+
/** socials: which document field holds the handle (default "speaker") and, for an array, which entry (default 0). */
|
|
37
|
+
owner?: "speaker" | "organization";
|
|
38
|
+
index?: number;
|
|
39
|
+
/** blocks: media source for the image and video kinds. */
|
|
40
|
+
source?: string;
|
|
41
|
+
/** Session change metadata (switchDimension only). */
|
|
42
|
+
meta?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DimensionSwitchValue =
|
|
46
|
+
| string
|
|
47
|
+
| string[]
|
|
48
|
+
| { platform: string; handle: string }
|
|
49
|
+
| { header?: unknown; footer?: unknown }
|
|
50
|
+
| { slideImage?: unknown; imageFill?: string | null }
|
|
51
|
+
| Record<string, unknown>;
|
|
52
|
+
|
|
53
|
+
export interface PreparedDimensionSwitch {
|
|
54
|
+
dimension: SwitchDimension;
|
|
55
|
+
scope: "deck" | "slide" | "block";
|
|
56
|
+
slideIndex?: number;
|
|
57
|
+
document: unknown;
|
|
58
|
+
patches: JsonPatchOperation[];
|
|
59
|
+
changed: boolean;
|
|
60
|
+
/** Slides whose own design hides a deck-level switch. */
|
|
61
|
+
shadowed: number[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DimensionSwitchChange extends Omit<EditorChange, "document" | "patches"> {
|
|
65
|
+
document: unknown;
|
|
66
|
+
patches: JsonPatchOperation[];
|
|
67
|
+
dimension: SwitchDimension;
|
|
68
|
+
scope: "deck" | "slide" | "block";
|
|
69
|
+
slideIndex?: number;
|
|
70
|
+
changed: boolean;
|
|
71
|
+
shadowed: number[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Compute and validate the patch for one dimension without changing any session. */
|
|
75
|
+
export declare function prepareDimensionSwitch(document: unknown, dimension: SwitchDimension, value: DimensionSwitchValue, options?: DimensionSwitchOptions): PreparedDimensionSwitch;
|
|
76
|
+
/** Apply one dimension switch to a session as a single undoable transaction. */
|
|
77
|
+
export declare function switchDimension(editor: EditorSession, dimension: SwitchDimension, value: DimensionSwitchValue, options?: DimensionSwitchOptions): DimensionSwitchChange;
|
package/dist/switches.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Dimension switches (FF-16, font-fidelity-everywhere). One operation per pptx.gallery
|
|
2
|
+
// dimension that turns "switch this dimension to X" into a validated JSON Patch, and applies
|
|
3
|
+
// it to an editor session as one undoable transaction. The preview recomposes and the PPTX
|
|
4
|
+
// export follows from the same document, so no dimension needs a special refresh path.
|
|
5
|
+
import { catalogSchemaNames, catalogs as bundledCatalogs, schemas } from "@openpresentation/opf";
|
|
6
|
+
import {
|
|
7
|
+
OPFEditorError,
|
|
8
|
+
createValuePatch,
|
|
9
|
+
applyJsonPatch,
|
|
10
|
+
getValueAtPath,
|
|
11
|
+
opfPathToJsonPointer,
|
|
12
|
+
splitOpfPath,
|
|
13
|
+
validateOpfDocument,
|
|
14
|
+
} from "./index.js";
|
|
15
|
+
import { createContentBlock, prepareBlockReplace } from "./blocks.js";
|
|
16
|
+
import { populateLayoutPlaceholders } from "./layout-placeholders.js";
|
|
17
|
+
|
|
18
|
+
/** The 14 pptx.gallery dimensions (gallery-support.md), in the gallery's order. */
|
|
19
|
+
export const SWITCH_DIMENSIONS = Object.freeze([
|
|
20
|
+
"layouts",
|
|
21
|
+
"color-schemes",
|
|
22
|
+
"font-schemes",
|
|
23
|
+
"languages",
|
|
24
|
+
"backgrounds",
|
|
25
|
+
"narratives",
|
|
26
|
+
"charts",
|
|
27
|
+
"themes",
|
|
28
|
+
"audiences",
|
|
29
|
+
"tones",
|
|
30
|
+
"socials",
|
|
31
|
+
"headers-footers",
|
|
32
|
+
"blocks",
|
|
33
|
+
"image-treatments",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Catalog kind behind each catalog-backed dimension.
|
|
37
|
+
const CATALOG_KIND = Object.freeze({
|
|
38
|
+
layouts: "layouts",
|
|
39
|
+
"color-schemes": "colorSchemes",
|
|
40
|
+
"font-schemes": "fontSchemes",
|
|
41
|
+
languages: "languages",
|
|
42
|
+
narratives: "narratives",
|
|
43
|
+
themes: "themes",
|
|
44
|
+
audiences: "audiences",
|
|
45
|
+
tones: "tones",
|
|
46
|
+
socials: "socialPlatforms",
|
|
47
|
+
charts: "chartTypes",
|
|
48
|
+
});
|
|
49
|
+
// Top-level document field for the simple metadata dimensions.
|
|
50
|
+
const ROOT_FIELD = Object.freeze({ languages: "language", narratives: "narrative", tones: "tone", audiences: "audience" });
|
|
51
|
+
// `design` keys for the design dimensions. A key set to `null` in the value is removed.
|
|
52
|
+
const DESIGN_KEYS = Object.freeze({
|
|
53
|
+
"color-schemes": ["colorScheme"],
|
|
54
|
+
"font-schemes": ["fontScheme"],
|
|
55
|
+
backgrounds: ["background"],
|
|
56
|
+
"headers-footers": ["header", "footer"],
|
|
57
|
+
"image-treatments": ["slideImage", "imageFill"],
|
|
58
|
+
});
|
|
59
|
+
const REGION = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
|
|
60
|
+
const BLOCK_KINDS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group", "image", "video"];
|
|
61
|
+
|
|
62
|
+
function fail(code, message, details) {
|
|
63
|
+
return new OPFEditorError(code, message, details);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function same(a, b) {
|
|
67
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
68
|
+
}
|
|
69
|
+
function canonical(value) {
|
|
70
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
71
|
+
if (value && typeof value === "object")
|
|
72
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function recordList(source) {
|
|
77
|
+
const records = Array.isArray(source) ? source : source?.records;
|
|
78
|
+
return Array.isArray(records) ? records.filter((record) => record && typeof record.id === "string") : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Inline document records first (they override), then caller-loaded records, then the
|
|
82
|
+
// bundled catalog. Unlike getCatalogRecords, an inline catalog never hides bundled ids.
|
|
83
|
+
function findCatalogRecord(document, kind, id, options) {
|
|
84
|
+
return (
|
|
85
|
+
recordList(document.catalogs?.[kind]).find((record) => record.id === id) ??
|
|
86
|
+
recordList(options.catalogs?.[kind] ?? options.catalogSources?.[kind]).find((record) => record.id === id) ??
|
|
87
|
+
(options.record?.id === id ? options.record : undefined) ??
|
|
88
|
+
bundledCatalogs[kind]?.find((record) => record.id === id)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireCatalogId(document, dimension, id, options) {
|
|
93
|
+
const kind = CATALOG_KIND[dimension];
|
|
94
|
+
if (typeof id !== "string" || id.length === 0)
|
|
95
|
+
throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog id.`, { dimension, id });
|
|
96
|
+
const record = findCatalogRecord(document, kind, id, options);
|
|
97
|
+
if (!record) throw fail("unknown-catalog-id", `Unknown ${kind} catalog ID: ${id}.`, { dimension, catalogKind: kind, id });
|
|
98
|
+
return record;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A gallery item supplies its own record; add it inline in the same transaction when the
|
|
102
|
+
// document and the bundled catalog do not already define the id.
|
|
103
|
+
function catalogRecordPatches(document, dimension, options) {
|
|
104
|
+
const record = options.record;
|
|
105
|
+
if (!record) return [];
|
|
106
|
+
const kind = CATALOG_KIND[dimension];
|
|
107
|
+
if (recordList(document.catalogs?.[kind]).some((entry) => entry.id === record.id) || bundledCatalogs[kind]?.some((entry) => entry.id === record.id)) return [];
|
|
108
|
+
const value = { $schema: schemas[catalogSchemaNames[kind]].$id, ...record };
|
|
109
|
+
if (!document.catalogs || typeof document.catalogs !== "object") return [{ op: "add", path: "/catalogs", value: { [kind]: { records: [value] } } }];
|
|
110
|
+
if (!document.catalogs[kind] || typeof document.catalogs[kind] !== "object")
|
|
111
|
+
return [{ op: "add", path: `/catalogs/${kind}`, value: { records: [value] } }];
|
|
112
|
+
if (!Array.isArray(document.catalogs[kind].records)) return [{ op: "add", path: `/catalogs/${kind}/records`, value: [value] }];
|
|
113
|
+
return [{ op: "add", path: `/catalogs/${kind}/records/-`, value }];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function slideAt(document, slideIndex, what = "this switch") {
|
|
117
|
+
if (!Number.isInteger(slideIndex) || !document.slides?.[slideIndex])
|
|
118
|
+
throw fail("slide-index-out-of-range", `Choose an existing slide for ${what}.`, { slideIndex });
|
|
119
|
+
return document.slides[slideIndex];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rootPatch(document, field, value) {
|
|
123
|
+
return same(document[field], value) ? [] : createValuePatch(document, [field], value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Set (value) or remove (null) design keys at deck or slide scope.
|
|
127
|
+
function designPatches(document, base, entries) {
|
|
128
|
+
const design = getValueAtPath(document, base.length ? [...base, "design"] : ["design"]);
|
|
129
|
+
const at = (key) => opfPathToJsonPointer([...base, "design", key]);
|
|
130
|
+
const set = Object.entries(entries).filter(([, value]) => value !== undefined && value !== null);
|
|
131
|
+
if (!design || typeof design !== "object" || Array.isArray(design))
|
|
132
|
+
return set.length ? [{ op: "add", path: opfPathToJsonPointer([...base, "design"]), value: structuredClone(Object.fromEntries(set)) }] : [];
|
|
133
|
+
const patches = [];
|
|
134
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
135
|
+
if (value === undefined) continue;
|
|
136
|
+
const present = Object.hasOwn(design, key);
|
|
137
|
+
if (value === null) {
|
|
138
|
+
if (present) patches.push({ op: "remove", path: at(key) });
|
|
139
|
+
} else if (!present) patches.push({ op: "add", path: at(key), value: structuredClone(value) });
|
|
140
|
+
else if (!same(design[key], value)) patches.push({ op: "replace", path: at(key), value: structuredClone(value) });
|
|
141
|
+
}
|
|
142
|
+
return patches;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A deck-level switch does nothing for a slide that carries its own value for that key.
|
|
146
|
+
function shadowedSlides(document, keys) {
|
|
147
|
+
return (document.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findChartOwner(document, slideIndex) {
|
|
151
|
+
const visit = (node, path, depth) => {
|
|
152
|
+
if (!node || typeof node !== "object" || Array.isArray(node) || depth > 32) return undefined;
|
|
153
|
+
if (node.chart && typeof node.chart === "object") return path;
|
|
154
|
+
if (Array.isArray(node.blocks))
|
|
155
|
+
for (const [index, block] of node.blocks.entries()) {
|
|
156
|
+
const found = visit(block, [...path, "blocks", String(index)], depth + 1);
|
|
157
|
+
if (found) return found;
|
|
158
|
+
}
|
|
159
|
+
for (const [key, value] of Object.entries(node)) {
|
|
160
|
+
if (!REGION.test(key)) continue;
|
|
161
|
+
const found = visit(value, [...path, key], depth + 1);
|
|
162
|
+
if (found) return found;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
};
|
|
166
|
+
return visit(document.slides[slideIndex], ["slides", String(slideIndex)], 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function socialsPatches(document, value, options) {
|
|
170
|
+
const owner = options.owner ?? "speaker";
|
|
171
|
+
if (!["speaker", "organization"].includes(owner)) throw fail("invalid-switch-value", "Socials owner must be 'speaker' or 'organization'.", { owner });
|
|
172
|
+
if (!value || typeof value !== "object" || typeof value.platform !== "string" || typeof value.handle !== "string" || !value.handle)
|
|
173
|
+
throw fail("invalid-switch-value", "Switch socials to { platform, handle } with a non-empty handle.", { value });
|
|
174
|
+
requireCatalogId(document, "socials", value.platform, options);
|
|
175
|
+
const host = document[owner];
|
|
176
|
+
const index = Array.isArray(host) ? (options.index ?? 0) : undefined;
|
|
177
|
+
const target = Array.isArray(host) ? host[index] : host;
|
|
178
|
+
if (!target || typeof target !== "object")
|
|
179
|
+
throw fail("missing-owner", `Add the ${owner} to the document before setting their socials.`, { owner });
|
|
180
|
+
const base = Array.isArray(host) ? [owner, String(index)] : [owner];
|
|
181
|
+
const socials = target.socials;
|
|
182
|
+
const patches = catalogRecordPatches(document, "socials", options);
|
|
183
|
+
if (!socials || typeof socials !== "object") return [...patches, { op: "add", path: opfPathToJsonPointer([...base, "socials"]), value: { [value.platform]: value.handle } }];
|
|
184
|
+
if (socials[value.platform] === value.handle) return patches;
|
|
185
|
+
return [...patches, { op: Object.hasOwn(socials, value.platform) ? "replace" : "add", path: opfPathToJsonPointer([...base, "socials", value.platform]), value: value.handle }];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function blockValue(value, options) {
|
|
189
|
+
if (typeof value === "string") {
|
|
190
|
+
if (!BLOCK_KINDS.includes(value)) throw fail("invalid-switch-value", `Unknown content block kind: ${value}.`, { value });
|
|
191
|
+
return createContentBlock(value, { source: options.source });
|
|
192
|
+
}
|
|
193
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw fail("invalid-switch-value", "Switch blocks to a content kind or a block object.", { value });
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compute the patch that switches one dimension, without touching any session. The candidate
|
|
199
|
+
* document is validated; an invalid result throws unless the input was already invalid.
|
|
200
|
+
*/
|
|
201
|
+
export function prepareDimensionSwitch(document, dimension, value, options = {}) {
|
|
202
|
+
if (!SWITCH_DIMENSIONS.includes(dimension))
|
|
203
|
+
throw fail("unknown-dimension", `Unknown dimension: ${dimension}. Use one of ${SWITCH_DIMENSIONS.join(", ")}.`, { dimension });
|
|
204
|
+
if (!document || typeof document !== "object") throw fail("invalid-input", "Switch requires an OPF document object.");
|
|
205
|
+
if (options.record) {
|
|
206
|
+
const ids = dimension === "audiences" ? [value].flat() : dimension === "socials" ? [value?.platform] : [value];
|
|
207
|
+
if (!CATALOG_KIND[dimension] || !ids.includes(options.record.id))
|
|
208
|
+
throw fail("record-id-mismatch", `The supplied record id must equal a switched ${dimension} id.`, { dimension, id: options.record.id });
|
|
209
|
+
}
|
|
210
|
+
const before = validateOpfDocument(document);
|
|
211
|
+
const scopeIndex = options.slideIndex;
|
|
212
|
+
let patches = [];
|
|
213
|
+
let scope = "deck";
|
|
214
|
+
let shadowed = [];
|
|
215
|
+
let slideIndex;
|
|
216
|
+
|
|
217
|
+
if (dimension === "layouts") {
|
|
218
|
+
slideIndex = scopeIndex;
|
|
219
|
+
slideAt(document, slideIndex, "a layout switch");
|
|
220
|
+
scope = "slide";
|
|
221
|
+
const record = requireCatalogId(document, dimension, value, options);
|
|
222
|
+
patches = catalogRecordPatches(document, dimension, options);
|
|
223
|
+
const withRecord = patches.length ? applyJsonPatch(document, patches) : document;
|
|
224
|
+
if (withRecord.slides[slideIndex].layout !== value) {
|
|
225
|
+
const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], value);
|
|
226
|
+
const swapped = applyJsonPatch(withRecord, layoutOps);
|
|
227
|
+
const types = Array.isArray(record.placeholders) ? record.placeholders.map((placeholder) => placeholder.type) : undefined;
|
|
228
|
+
// Add the blank payloads the layout declares, as the JSON editor's layout choice does.
|
|
229
|
+
const populated = types ? populateLayoutPlaceholders(swapped, slideIndex, types) : swapped;
|
|
230
|
+
patches = same(populated.slides[slideIndex], swapped.slides[slideIndex])
|
|
231
|
+
? [...patches, ...layoutOps]
|
|
232
|
+
: [...patches, { op: "replace", path: `/slides/${slideIndex}`, value: populated.slides[slideIndex] }];
|
|
233
|
+
}
|
|
234
|
+
} else if (dimension === "charts") {
|
|
235
|
+
slideIndex = scopeIndex;
|
|
236
|
+
slideAt(document, slideIndex, "a chart switch");
|
|
237
|
+
scope = "slide";
|
|
238
|
+
requireCatalogId(document, dimension, value, options);
|
|
239
|
+
const owner = options.path ? splitOpfPath(options.path) : findChartOwner(document, slideIndex);
|
|
240
|
+
if (options.path && (owner[0] !== "slides" || owner[1] !== String(slideIndex)))
|
|
241
|
+
throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
|
|
242
|
+
const chart = owner && getValueAtPath(document, [...owner, "chart"]);
|
|
243
|
+
if (!chart || typeof chart !== "object") throw fail("chart-not-found", "This slide has no chart to switch. Insert a chart block first.", { slideIndex, path: options.path });
|
|
244
|
+
patches = [...catalogRecordPatches(document, dimension, options), ...(chart.type === value ? [] : createValuePatch(document, [...owner, "chart", "type"], value))];
|
|
245
|
+
} else if (dimension === "blocks") {
|
|
246
|
+
if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
|
|
247
|
+
const block = blockValue(value, options);
|
|
248
|
+
const change = prepareBlockReplace(document, options.path, block);
|
|
249
|
+
patches = change.changed ? change.patches : [];
|
|
250
|
+
const parts = splitOpfPath(options.path);
|
|
251
|
+
if (parts[0] === "slides") slideIndex = Number(parts[1]);
|
|
252
|
+
scope = "block";
|
|
253
|
+
} else if (dimension === "socials") {
|
|
254
|
+
patches = socialsPatches(document, value, options);
|
|
255
|
+
} else if (ROOT_FIELD[dimension]) {
|
|
256
|
+
const field = ROOT_FIELD[dimension];
|
|
257
|
+
let next = value;
|
|
258
|
+
if (dimension === "audiences") {
|
|
259
|
+
next = Array.isArray(value) ? value : [value];
|
|
260
|
+
if (!next.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog id.", { value });
|
|
261
|
+
for (const id of next) requireCatalogId(document, dimension, id, options);
|
|
262
|
+
} else requireCatalogId(document, dimension, value, options);
|
|
263
|
+
patches = [...catalogRecordPatches(document, dimension, options), ...rootPatch(document, field, next)];
|
|
264
|
+
} else {
|
|
265
|
+
// Design dimensions: deck scope by default, or one slide with options.slideIndex.
|
|
266
|
+
let entries;
|
|
267
|
+
if (dimension === "themes") {
|
|
268
|
+
const record = requireCatalogId(document, dimension, value, options);
|
|
269
|
+
entries = { theme: value };
|
|
270
|
+
if (options.bundle !== false) {
|
|
271
|
+
// The gallery's theme snippet writes the whole bundle, so an explicit deck choice
|
|
272
|
+
// does not keep the previous theme's fonts, colors or background.
|
|
273
|
+
for (const key of ["colorScheme", "fontScheme", "background", "dimensions"]) if (record[key] !== undefined) entries[key] = record[key];
|
|
274
|
+
}
|
|
275
|
+
patches = catalogRecordPatches(document, dimension, options);
|
|
276
|
+
} else if (dimension === "color-schemes" || dimension === "font-schemes") {
|
|
277
|
+
requireCatalogId(document, dimension, value, options);
|
|
278
|
+
entries = { [DESIGN_KEYS[dimension][0]]: value };
|
|
279
|
+
patches = catalogRecordPatches(document, dimension, options);
|
|
280
|
+
} else {
|
|
281
|
+
// A background is an object or a shorthand string (theme slot or hex color); the schema
|
|
282
|
+
// validates the candidate document, so a string that is neither is rejected below.
|
|
283
|
+
const valid = dimension === "backgrounds" ? typeof value === "string" || (Boolean(value) && typeof value === "object" && !Array.isArray(value)) : Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
284
|
+
if (!valid)
|
|
285
|
+
throw fail("invalid-switch-value", `Switch ${dimension} to ${dimension === "backgrounds" ? "a background object or shorthand string" : "an object with " + DESIGN_KEYS[dimension].join(" and ")}.`, { value });
|
|
286
|
+
if (dimension === "backgrounds") entries = { background: value };
|
|
287
|
+
else {
|
|
288
|
+
const unknown = Object.keys(value).filter((key) => !DESIGN_KEYS[dimension].includes(key));
|
|
289
|
+
if (unknown.length) throw fail("invalid-switch-value", `Unknown ${dimension} field: ${unknown[0]}.`, { value });
|
|
290
|
+
entries = Object.fromEntries(DESIGN_KEYS[dimension].filter((key) => value[key] !== undefined).map((key) => [key, value[key]]));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let base = [];
|
|
294
|
+
if (scopeIndex !== undefined) {
|
|
295
|
+
slideAt(document, scopeIndex, `a ${dimension} switch`);
|
|
296
|
+
base = ["slides", String(scopeIndex)];
|
|
297
|
+
scope = "slide";
|
|
298
|
+
slideIndex = scopeIndex;
|
|
299
|
+
}
|
|
300
|
+
patches = [...patches, ...designPatches(document, base, entries)];
|
|
301
|
+
if (scope === "deck") {
|
|
302
|
+
const keys = Object.entries(entries).filter(([, entry]) => entry !== undefined).map(([key]) => key);
|
|
303
|
+
shadowed = shadowedSlides(document, keys);
|
|
304
|
+
if (options.clearSlideOverrides)
|
|
305
|
+
for (const index of shadowed)
|
|
306
|
+
for (const key of keys)
|
|
307
|
+
if (document.slides[index].design?.[key] !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", key]) });
|
|
308
|
+
if (options.clearSlideOverrides) shadowed = [];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const next = patches.length ? applyJsonPatch(document, patches) : document;
|
|
313
|
+
if (patches.length) {
|
|
314
|
+
const validation = validateOpfDocument(next);
|
|
315
|
+
if (!validation.valid && before.valid)
|
|
316
|
+
throw fail("invalid-opf-edit", validation.errors[0]?.message ?? "This switch produces an invalid document.", { issues: validation.errors, patches });
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
dimension,
|
|
320
|
+
scope,
|
|
321
|
+
...(slideIndex !== undefined ? { slideIndex } : {}),
|
|
322
|
+
document: structuredClone(next),
|
|
323
|
+
patches,
|
|
324
|
+
changed: patches.length > 0,
|
|
325
|
+
shadowed,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Switch one dimension of the session's document as a single undoable transaction. Returns the
|
|
331
|
+
* editor change plus `dimension`, `scope`, `changed` and `shadowed` (slides whose own design
|
|
332
|
+
* hides a deck-level switch). Switching to the current value commits nothing.
|
|
333
|
+
*/
|
|
334
|
+
export function switchDimension(editor, dimension, value, options = {}) {
|
|
335
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
|
|
336
|
+
throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
337
|
+
const { meta, ...switchOptions } = options;
|
|
338
|
+
const prepared = prepareDimensionSwitch(editor.document, dimension, value, switchOptions);
|
|
339
|
+
const { document, patches, ...summary } = prepared;
|
|
340
|
+
void document;
|
|
341
|
+
if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
342
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "dimension-switch", dimension, scope: prepared.scope });
|
|
343
|
+
return { ...change, ...summary };
|
|
344
|
+
}
|
package/dist/transfer.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./index.js";
|
|
7
7
|
import { catalogs } from "@openpresentation/opf";
|
|
8
8
|
import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
|
|
9
|
+
import { DEFAULT_FONT_SCHEME } from "./font-defaults.js";
|
|
9
10
|
export const MAX_OPF_BYTES = 20 * 1024 * 1024;
|
|
10
11
|
const clone = (value) => structuredClone(value);
|
|
11
12
|
export function assertOpf(document) {
|
|
@@ -136,7 +137,7 @@ export function prepareOpfImport(
|
|
|
136
137
|
...design,
|
|
137
138
|
theme: design.theme ?? "minimal",
|
|
138
139
|
colorScheme: design.colorScheme ?? theme.colorScheme ?? "cool-horizon",
|
|
139
|
-
fontScheme: design.fontScheme ?? theme.fontScheme ??
|
|
140
|
+
fontScheme: design.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
|
|
140
141
|
dimensions: design.dimensions ?? theme.dimensions ?? "widescreen",
|
|
141
142
|
background: design.background ?? theme.background ?? "#FFFFFF",
|
|
142
143
|
header: design.header ?? false,
|