@openpresentation/opf-editor 0.10.6 → 0.11.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.
Files changed (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
@@ -0,0 +1,134 @@
1
+ // Backgrounds (RR-06): every background form the schema has, with validation that explains itself.
2
+ // A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck
3
+ // or on one slide. `setBackground` is one undoable patch through the `backgrounds` switch, so the
4
+ // preview, export and Undo behave like every other dimension.
5
+ import { prepareDimensionSwitch, switchDimension } from "./switches.js";
6
+ import { fail } from "./edit-helpers.js";
7
+
8
+ export const BACKGROUND_TYPES = Object.freeze(["theme", "solid", "gradient", "image", "pattern"]);
9
+ export const THEME_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
10
+ export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "tile"]);
11
+ /** Scheme slots and roles a ColorRef may name (the schema's ColorRef enum), besides hex colors and `var:<id>`. */
12
+ export const COLOR_NAMES = Object.freeze([
13
+ "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "dark1", "dark2", "light1", "light2", "hyperlink", "followedHyperlink",
14
+ "primary", "secondary", "accent", "background", "surface", "text", "textSecondary",
15
+ ]);
16
+ /**
17
+ * The 54 DrawingML preset patterns (ECMA-376 ST_PresetPatternVal) by family. PPTX export writes them as
18
+ * native pattern fills; the schema also allows engine-defined ids, which `setBackground` accepts as text.
19
+ */
20
+ export const PATTERN_GROUPS = Object.freeze({
21
+ Percent: ["pct5", "pct10", "pct20", "pct25", "pct30", "pct40", "pct50", "pct60", "pct70", "pct75", "pct80", "pct90"],
22
+ "Horizontal and vertical": ["horz", "vert", "ltHorz", "ltVert", "dkHorz", "dkVert", "narHorz", "narVert", "dashHorz", "dashVert", "cross"],
23
+ Diagonal: ["dnDiag", "upDiag", "ltDnDiag", "ltUpDiag", "dkDnDiag", "dkUpDiag", "wdDnDiag", "wdUpDiag", "dashDnDiag", "dashUpDiag", "diagCross"],
24
+ "Checks, grids and bricks": ["smCheck", "lgCheck", "smGrid", "lgGrid", "dotGrid", "smConfetti", "lgConfetti", "horzBrick", "diagBrick"],
25
+ "Shapes and textures": ["solidDmnd", "openDmnd", "dotDmnd", "plaid", "sphere", "weave", "divot", "shingle", "wave", "trellis", "zigZag"],
26
+ });
27
+ export const PATTERN_PRESETS = Object.freeze(Object.values(PATTERN_GROUPS).flat());
28
+
29
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
30
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
31
+
32
+ /** Whether `value` is a ColorRef: a hex color, a scheme slot or role name, or `var:<id>`. */
33
+ export function isColorRef(value) {
34
+ return typeof value === "string" && (HEX.test(value) || COLOR_NAMES.includes(value) || /^var:[a-z][a-z0-9-]*$/.test(value));
35
+ }
36
+
37
+ function colorRef(value, what) {
38
+ if (!isColorRef(value)) throw fail("invalid-background", `${what} must be a hex color such as #1F2937, a scheme color such as accent1 or surface, or var:<id>.`, { value });
39
+ return typeof value === "string" && HEX.test(value) ? value.toUpperCase() : value;
40
+ }
41
+
42
+ function opacityOf(spec) {
43
+ if (spec.opacity === undefined || spec.opacity === null) return undefined;
44
+ if (typeof spec.opacity !== "number" || !(spec.opacity >= 0 && spec.opacity <= 1)) throw fail("invalid-background", "Background opacity is a number from 0 to 1.", { opacity: spec.opacity });
45
+ return spec.opacity;
46
+ }
47
+
48
+ /**
49
+ * Validate a background description and return the value to store: a theme slot or a plain hex color stays
50
+ * the shorthand string; everything else is the object form. Throws `invalid-background` with a sentence a
51
+ * person can act on. Gradient stops are sorted by position (at least two); radial gradients are not part
52
+ * of the schema.
53
+ */
54
+ export function normalizeBackground(spec) {
55
+ if (typeof spec === "string") {
56
+ if (THEME_BACKGROUND_SLOTS.includes(spec)) return spec;
57
+ if (HEX.test(spec)) return spec.toUpperCase();
58
+ throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}) or a hex color.`, { spec });
59
+ }
60
+ if (!isObject(spec)) throw fail("invalid-background", "Describe the background as { type, ... } or a theme slot / hex shorthand.", { spec });
61
+ const opacity = opacityOf(spec);
62
+ const withOpacity = (value) => (opacity === undefined ? value : { ...value, opacity });
63
+ switch (spec.type) {
64
+ case "theme":
65
+ if (!THEME_BACKGROUND_SLOTS.includes(spec.slot)) throw fail("invalid-background", `A theme background names a slot: ${THEME_BACKGROUND_SLOTS.join(", ")}.`, { spec });
66
+ return spec.slot;
67
+ case "solid": {
68
+ const color = colorRef(spec.color, "A solid background color");
69
+ return opacity === undefined && HEX.test(color) ? color : withOpacity({ type: "solid", color });
70
+ }
71
+ case "gradient": {
72
+ const gradient = spec.gradient;
73
+ if (!isObject(gradient) || !Array.isArray(gradient.stops) || gradient.stops.length < 2) throw fail("invalid-background", "A gradient needs at least two color stops.", { spec });
74
+ const stops = gradient.stops.map((stop, index) => {
75
+ if (!isObject(stop)) throw fail("invalid-background", `Stop ${index + 1} needs a color and a position.`, { spec });
76
+ const position = typeof stop.position === "string" && stop.position.trim() !== "" ? Number(stop.position) : stop.position;
77
+ if (typeof position !== "number" || !(position >= 0 && position <= 1)) throw fail("invalid-background", `Stop ${index + 1}: the position is a number from 0 (start) to 1 (end).`, { spec });
78
+ return { color: colorRef(stop.color, `Stop ${index + 1}: the color`), position };
79
+ });
80
+ stops.sort((a, b) => a.position - b.position);
81
+ const angle = gradient.angle === undefined || gradient.angle === null || gradient.angle === "" ? undefined : Number(gradient.angle);
82
+ if (angle !== undefined && !Number.isFinite(angle)) throw fail("invalid-background", "The gradient angle is a number of degrees (0 is left to right, 90 top to bottom).", { spec });
83
+ return withOpacity({ type: "gradient", gradient: { ...(angle === undefined ? {} : { angle }), stops } });
84
+ }
85
+ case "image": {
86
+ const image = spec.image;
87
+ if (!isObject(image) || typeof image.src !== "string" || !image.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
88
+ if (image.fit !== undefined && image.fit !== "" && !IMAGE_BACKGROUND_FITS.includes(image.fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
89
+ return withOpacity({ type: "image", image: { src: image.src.trim(), ...(image.fit ? { fit: image.fit } : {}) } });
90
+ }
91
+ case "pattern": {
92
+ const pattern = spec.pattern;
93
+ if (!isObject(pattern) || typeof pattern.preset !== "string" || !pattern.preset.trim()) throw fail("invalid-background", "A pattern background needs a preset.", { spec });
94
+ const out = { preset: pattern.preset.trim() };
95
+ if (pattern.foregroundColor) out.foregroundColor = colorRef(pattern.foregroundColor, "The pattern foreground color");
96
+ if (pattern.backgroundColor) out.backgroundColor = colorRef(pattern.backgroundColor, "The pattern background color");
97
+ return withOpacity({ type: "pattern", pattern: out });
98
+ }
99
+ default:
100
+ throw fail("invalid-background", `Background type is one of ${BACKGROUND_TYPES.join(", ")}.`, { spec });
101
+ }
102
+ }
103
+
104
+ /** Compute the patch that sets (or, for `null`, removes) the background, without touching a session. Options as `prepareDimensionSwitch` (`slideIndex`, `clearSlideOverrides`). */
105
+ export function prepareBackground(document, spec, options = {}) {
106
+ return prepareDimensionSwitch(document, "backgrounds", spec === null ? null : normalizeBackground(spec), options);
107
+ }
108
+
109
+ /** Set the background as one undoable transaction; `null` removes it so the theme's (or the deck's) shows again. */
110
+ export function setBackground(editor, spec, options = {}) {
111
+ return switchDimension(editor, "backgrounds", spec === null ? null : normalizeBackground(spec), options);
112
+ }
113
+
114
+ /**
115
+ * The background that applies at a scope as a flat description for a form:
116
+ * `{ type, slot?, color?, opacity?, angle?, stops?, src?, fit?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
117
+ * `type` is undefined when nothing is set; `scope` is "slide" when the slide sets its own, else "deck".
118
+ */
119
+ export function readBackground(document, { slideIndex } = {}) {
120
+ const own = slideIndex === undefined ? undefined : document.slides?.[slideIndex]?.design?.background;
121
+ const value = own !== undefined ? own : document.design?.background;
122
+ const scope = own !== undefined ? "slide" : "deck";
123
+ if (value === undefined) return { scope };
124
+ if (typeof value === "string") return THEME_BACKGROUND_SLOTS.includes(value) ? { type: "theme", slot: value, scope, value } : { type: "solid", color: value, scope, value };
125
+ if (!isObject(value)) return { scope, value };
126
+ const base = { type: value.type, opacity: value.opacity, scope, value };
127
+ if (value.type === "theme") return { ...base, slot: value.slot };
128
+ if (value.type === "solid") return { ...base, color: value.color };
129
+ if (value.type === "gradient") return { ...base, angle: value.gradient?.angle, stops: structuredClone(value.gradient?.stops ?? []) };
130
+ if (value.type === "image") return { ...base, src: value.image?.src, fit: value.image?.fit };
131
+ if (value.type === "pattern") return { ...base, preset: value.pattern?.preset, foregroundColor: value.pattern?.foregroundColor, backgroundColor: value.pattern?.backgroundColor };
132
+ return base;
133
+ }
134
+
@@ -0,0 +1,64 @@
1
+ import type { ConvertOptions } from "@openpresentation/opf/convert";
2
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
3
+
4
+ export type { ConvertOptions } from "@openpresentation/opf/convert";
5
+
6
+ export type BlockKind = "text" | "list" | "quote" | "metric" | "code" | "timeline" | "chart" | "table" | "image" | "video" | "group" | "metrics";
7
+ /** Display label of every content kind. */
8
+ export declare const BLOCK_KIND_LABELS: Readonly<Record<BlockKind, string>>;
9
+ /** Source kind to the kinds it can convert to (core's `CONTENT_CONVERSIONS`): text to list, quote, metric, code, timeline or table; list to text, timeline or table; quote, metric and code to text; timeline to text, list or table; chart to table; table to chart, list, timeline, text or a set of metric blocks; a group of metric blocks to a table. */
10
+ export declare const BLOCK_CONVERSIONS: Readonly<Record<string, readonly BlockKind[]>>;
11
+
12
+ export interface BlockContent {
13
+ path: string[];
14
+ /** True for a `blocks/N` block, false for a slide or region holding one content field. */
15
+ explicit: boolean;
16
+ owner: Record<string, unknown>;
17
+ /** The content field: text, items, bullets, chart, table, and so on. */
18
+ key: string;
19
+ /** The content kind (`items` and `bullets` are both "list"). */
20
+ kind: BlockKind;
21
+ content: unknown;
22
+ }
23
+ export interface BlockConversionTarget {
24
+ kind: BlockKind;
25
+ label: string;
26
+ available: boolean;
27
+ /** True when everything in the block carries over. */
28
+ lossless: boolean;
29
+ /** What the target cannot carry, for example "text formatting", "list nesting levels", "chart type". */
30
+ loss: string[];
31
+ /** Why the conversion is unavailable (available is false). */
32
+ reason?: string;
33
+ }
34
+ export interface PreparedBlockConversion {
35
+ document: unknown;
36
+ patches: JsonPatchOperation[];
37
+ path: string;
38
+ changed: boolean;
39
+ lossless: boolean;
40
+ loss: string[];
41
+ from: BlockKind;
42
+ to: BlockKind;
43
+ }
44
+ export interface BlockConversionChange extends Omit<EditorChange, "document" | "patches"> {
45
+ document: unknown;
46
+ patches: JsonPatchOperation[];
47
+ lossless: boolean;
48
+ loss: string[];
49
+ from: BlockKind;
50
+ to: BlockKind;
51
+ path: string;
52
+ changed: boolean;
53
+ }
54
+
55
+ /** The single content payload of the block at `path`, or undefined (image-only, groups and blocks with several fields have none to convert). */
56
+ export declare function readBlockContent(document: unknown, path: string | string[]): BlockContent | undefined;
57
+ /** The block that contains a selected path (slides.0.blocks.1.text maps to slides.0.blocks.1), or undefined. */
58
+ export declare function blockPathForSelection(document: unknown, selectedPath: string): string | undefined;
59
+ /** The kinds the block can convert to, each with its loss report or the reason it is unavailable. */
60
+ export declare function blockConversionTargets(document: unknown, path: string | string[]): BlockConversionTarget[];
61
+ /** Compute the conversion patch without touching a session. Throws `block-not-convertible` for an unsupported pair or content that does not fit. */
62
+ export declare function prepareBlockConversion(document: unknown, path: string | string[], kind: BlockKind, options?: ConvertOptions): PreparedBlockConversion;
63
+ /** Convert one block as a single undoable transaction. */
64
+ export declare function convertBlock(editor: EditorSession, path: string | string[], kind: BlockKind, meta?: Record<string, unknown>, options?: ConvertOptions): BlockConversionChange;
@@ -0,0 +1,142 @@
1
+ // Safe content-type conversion for one block (RR-06; RR-26 moved the converters into core).
2
+ // The pure converters live in `@openpresentation/opf/convert`: a conversion moves the block's own text into
3
+ // the new content type and never invents content, what a conversion cannot carry is reported in `loss`
4
+ // instead of being dropped silently, and a pair that has no meaningful mapping is refused. This module is the
5
+ // transaction around them: it finds the block, turns the converted payload into one guarded patch, validates
6
+ // the document, and applies it as one undoable step.
7
+ import { CONTENT_CONVERSIONS, CONTENT_KIND_LABELS, OPFConversionError, contentConversionTargets, convertContent, readContent } from "@openpresentation/opf/convert";
8
+ import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
9
+ import { listBlockContainers } from "./blocks.js";
10
+ import { fail } from "./edit-helpers.js";
11
+
12
+ /** Every content kind a block can hold, with its display label. */
13
+ export const BLOCK_KIND_LABELS = CONTENT_KIND_LABELS;
14
+ /** The conversion matrix: source kind to the kinds it can convert to (core's `CONTENT_CONVERSIONS`). */
15
+ export const BLOCK_CONVERSIONS = CONTENT_CONVERSIONS;
16
+
17
+ const refuse = (message, details) => fail("block-not-convertible", message, details);
18
+ const CHOOSE = "Choose a block that holds one text, list, quote, metric, code, timeline, chart or table payload, or a group of metric blocks.";
19
+
20
+ /** Run a core conversion; a refusal becomes the editor's `block-not-convertible`. */
21
+ function convertOwner(owner, kind, options) {
22
+ try {
23
+ return convertContent(owner, kind, options);
24
+ } catch (error) {
25
+ if (error instanceof OPFConversionError) throw refuse(error.message, { ...error.details, kind });
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The single content payload of the block at `path` (an explicit `blocks/N` block, or a slide or
32
+ * region that holds exactly one content field), or a group of metric blocks (a `blocks` array in a
33
+ * slide, region or group). Returns undefined when there is none.
34
+ */
35
+ export function readBlockContent(document, path) {
36
+ let parts;
37
+ try {
38
+ parts = splitOpfPath(path);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ const pointer = opfPathToJsonPointer(parts);
43
+ const explicit = parts.at(-2) === "blocks" && /^(0|[1-9][0-9]*)$/.test(parts.at(-1) ?? "");
44
+ const owner = getValueAtPath(document, parts);
45
+ if (!owner || typeof owner !== "object" || Array.isArray(owner)) return undefined;
46
+ if (Array.isArray(owner.blocks)) {
47
+ // Only a set of metrics converts as a group; any other group has nothing to offer.
48
+ if (!contentConversionTargets(owner).length) return undefined;
49
+ return { path: parts, explicit, owner, key: "blocks", kind: "group", content: owner.blocks };
50
+ }
51
+ if (!explicit) {
52
+ const implicit = listBlockContainers(document, { includeImplicit: true }).find((entry) => entry.path === pointer && entry.implicit);
53
+ if (!implicit || implicit.count !== 1) return undefined;
54
+ }
55
+ const info = readContent(owner);
56
+ if (!info) return undefined;
57
+ return { path: parts, explicit, owner, key: info.key, kind: info.kind, content: info.content };
58
+ }
59
+
60
+ /** Whether `path` is inside a block and, if so, that block's path. A selection such as slides.0.blocks.1.text maps to slides.0.blocks.1. */
61
+ export function blockPathForSelection(document, selectedPath) {
62
+ let parts;
63
+ try {
64
+ parts = splitOpfPath(selectedPath);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ for (let length = parts.length; length >= 2; length -= 1) {
69
+ const candidate = parts.slice(0, length);
70
+ if (candidate[0] !== "slides") return undefined;
71
+ const found = readBlockContent(document, candidate);
72
+ // A slide or region that holds one payload inline is a block only when the selection is that payload.
73
+ if (found && found.kind !== "group" && (found.explicit || parts[length] === found.key)) return candidate.join(".");
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * The nearest group of metric blocks around a selection (`slides.0.blocks.2.metric` finds `slides.0` when the
80
+ * slide's blocks are all metrics), or undefined. A group of metrics converts to a table as a whole.
81
+ */
82
+ export function metricGroupForSelection(document, selectedPath) {
83
+ let parts;
84
+ try {
85
+ parts = splitOpfPath(selectedPath);
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ if (parts[0] !== "slides") return undefined;
90
+ for (let length = parts.length; length >= 2; length -= 1) {
91
+ const candidate = parts.slice(0, length);
92
+ const found = readBlockContent(document, candidate);
93
+ if (found?.kind === "group") return candidate.join(".");
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * The kinds the block at `path` can convert to, each with whether the conversion keeps everything
100
+ * (`lossless`), what it cannot carry (`loss`), or why it is unavailable (`available: false`).
101
+ * Returns [] for a block with no convertible content (image, video, a group that is not a set of metrics, several fields).
102
+ * `options` are core's conversion options (`looseWhen`, `fences`, `headings`, `columns`, `delimiter`, `header`).
103
+ */
104
+ export function blockConversionTargets(document, path, options = {}) {
105
+ const found = readBlockContent(document, path);
106
+ return found ? contentConversionTargets(found.owner, options) : [];
107
+ }
108
+
109
+ /**
110
+ * Compute the patch that converts the block at `path` to `kind`, without touching a session.
111
+ * Throws `block-not-convertible` for a pair with no safe mapping or content that does not fit.
112
+ * `prepared.loss` lists what the target cannot carry; `lossless` is true when nothing is lost.
113
+ */
114
+ export function prepareBlockConversion(document, path, kind, options = {}) {
115
+ const found = readBlockContent(document, path);
116
+ if (!found) throw refuse(CHOOSE, { path });
117
+ const pointer = opfPathToJsonPointer(found.path);
118
+ const from = found.kind;
119
+ const result = convertOwner(found.owner, kind, options);
120
+ if (!result.changed) return { document: structuredClone(document), patches: [], path: found.path.join("."), changed: false, lossless: true, loss: [], from, to: kind };
121
+ const patches = [
122
+ { op: "test", path: pointer, value: structuredClone(found.owner) },
123
+ { op: "replace", path: pointer, value: result.payload },
124
+ ];
125
+ const next = applyJsonPatch(document, patches);
126
+ const validation = validateOpfDocument(next);
127
+ if (!validation.valid) throw fail("invalid-opf-edit", validation.errors[0]?.message ?? "The converted block is not valid OPF.", { issues: validation.errors, patches });
128
+ return { document: next, patches, path: found.path.join("."), changed: true, lossless: result.lossless, loss: result.loss, from, to: kind };
129
+ }
130
+
131
+ /**
132
+ * Convert one block to another content kind as a single undoable transaction. Returns the session
133
+ * change plus `lossless`, `loss`, `from` and `to`.
134
+ */
135
+ export function convertBlock(editor, path, kind, meta = {}, options = {}) {
136
+ if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
137
+ const prepared = prepareBlockConversion(editor.document, path, kind, options);
138
+ const summary = { lossless: prepared.lossless, loss: prepared.loss, from: prepared.from, to: prepared.to, path: prepared.path, changed: prepared.changed };
139
+ if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
140
+ const change = editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "block-conversion", blockPath: prepared.path, from: prepared.from, to: prepared.to });
141
+ return { ...change, ...summary };
142
+ }
package/dist/canvas.d.ts CHANGED
@@ -42,6 +42,7 @@ export interface CanvasEditorOptions {
42
42
  slideIndex?: number;
43
43
  /** Show keyboard-accessible dividers for resizing composition tracks. */
44
44
  layoutEditing?: boolean;
45
+ /** Render options. The canvas draws the document as authored (`variables: false`: a template's `{{tokens}}` stay visible, so inline edits never overwrite them); pass `variables` to draw resolved values instead. */
45
46
  renderOptions?: RenderSvgOptions;
46
47
  /**
47
48
  * A font gate (`createFontGate(registry)`). With one, the canvas never renders a document whose faces are still
@@ -56,6 +57,8 @@ export interface CanvasEditorOptions {
56
57
  * their properties on double-click.
57
58
  */
58
59
  textEntry?: "click" | "dblclick";
60
+ /** Picture tools (default true): a selected picture shows a "Crop picture" button that opens the crop and focal point layer. */
61
+ imageTools?: boolean;
59
62
  /** Optional empty host for property forms; defaults to a floating canvas panel. */
60
63
  propertiesContainer?: HTMLElement;
61
64
  onSelect?: (selection: {
@@ -88,6 +91,13 @@ export interface CanvasEditor {
88
91
  readonly slideIndex: number;
89
92
  readonly editingPath: string | null;
90
93
  readonly layoutEditing: boolean;
94
+ /** True while the crop layer is open. */
95
+ readonly cropping: boolean;
96
+ /**
97
+ * Open the crop layer for the picture at `path` (an image block, a slide's `image`, or `slides.N.design.slideImage`); `tool: "focus"`
98
+ * starts with the focal point. Apply writes one undoable change (see `@openpresentation/opf-editor/image-crop`). Resolves to whether it opened.
99
+ */
100
+ cropImage(path: string, options?: { tool?: "crop" | "focus" }): Promise<boolean>;
91
101
  setLayoutEditing(enabled: boolean): boolean;
92
102
  /** Open reorder and move-to-group controls for a complete block path. */
93
103
  openBlockMenu(path: string): void;
@@ -102,6 +112,12 @@ export interface CanvasEditor {
102
112
  cancel(): void;
103
113
  render(document?: unknown): void;
104
114
  setSlide(index: number): boolean;
115
+ /**
116
+ * Select the content at `path`, or the closest enclosing content the canvas can select (a list item selects its list),
117
+ * after showing the slide the path is on. Returns the selected path, or null when nothing is selectable there (speaker
118
+ * notes, deck fields) or the slide is not drawn yet (fonts loading). Moves keyboard focus only with `focus: true`.
119
+ */
120
+ reveal(path: string, options?: { focus?: boolean }): string | null;
105
121
  setRenderOptions(options: RenderSvgOptions): boolean;
106
122
  destroy(): void;
107
123
  }
package/dist/canvas.js CHANGED
@@ -20,6 +20,7 @@ import { createLayoutHandles } from "./layout-handles.js";
20
20
  import { createRichTextInput } from "./rich-text-input.js";
21
21
  import { textInputOffsetAtPoint } from "./text-pointer.js";
22
22
  import { createRichTextToolbar } from "./rich-text-toolbar.js";
23
+ import { createImageCropper } from "./image-cropper.js";
23
24
  import { FONTS_PENDING, fontsPendingError, whenFontsReady } from "./font-gate.js";
24
25
  export { getEditableFields } from "./canvas-fields.js";
25
26
  export { createFontGate, whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
@@ -69,7 +70,9 @@ export function createCanvasEditor(container, options = {}) {
69
70
  // loads them first and shows "Loading fonts…" meanwhile; without a gate every document renders at once, as before.
70
71
  const fonts = options.fonts;
71
72
  let slideIndex = options.slideIndex ?? 0,
72
- renderOptions = options.renderOptions ?? {},
73
+ // RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
74
+ // overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
75
+ renderOptions = { variables: false, ...(options.renderOptions ?? {}) },
73
76
  showToken = 0,
74
77
  fontsShown = false,
75
78
  active = null,
@@ -91,7 +94,7 @@ export function createCanvasEditor(container, options = {}) {
91
94
  overlay = doc.createElement("div"),
92
95
  notice = doc.createElement("div");
93
96
  root.className = "opf-canvas";
94
- root.style.cssText = "position:relative;width:100%;isolation:isolate";
97
+ root.style.cssText = "position:relative;width:100%;isolation:isolate;touch-action:manipulation";
95
98
  preview.className = "opf-canvas-preview";
96
99
  overlay.className = "opf-canvas-overlay";
97
100
  overlay.style.cssText = "position:absolute;inset:0;pointer-events:none";
@@ -121,6 +124,15 @@ export function createCanvasEditor(container, options = {}) {
121
124
  },
122
125
  onChange(path) { clearNotice(); options.onCommit?.({path, editor}); }
123
126
  });
127
+ // RR-25: picture tools. A selected picture shows a "Crop picture" button; `cropImage(path)` opens the same layer.
128
+ const imageCropper = options.imageTools === false ? null : createImageCropper(root, overlay, {
129
+ editor,
130
+ beforeOpen: () => commit(),
131
+ getImageElement: (path) => [...preview.querySelectorAll("image[data-opf-path]")].find((node) => node.getAttribute("data-opf-path") === path) ?? null,
132
+ report,
133
+ onCommit: (value) => { clearNotice(); options.onCommit?.({ ...value, editor }); },
134
+ onCancel: () => options.onCancel?.({}),
135
+ });
124
136
  const layoutHandles = createLayoutHandles(root, {
125
137
  editor, enabled: options.layoutEditing, render: renderFor, beforeEdit: commit,
126
138
  isTextEditing: () => !!active,
@@ -147,6 +159,7 @@ export function createCanvasEditor(container, options = {}) {
147
159
  }
148
160
  function choose(path) {
149
161
  selectedPath = path;
162
+ imageCropper?.sync(path);
150
163
  for (const node of targets())
151
164
  node.toggleAttribute(
152
165
  "data-canvas-selected",
@@ -279,11 +292,14 @@ export function createCanvasEditor(container, options = {}) {
279
292
  if (node.matches("g")) {
280
293
  const bounds = allocatedSelectionBox(node, item);
281
294
  const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
295
+ // RR-25: on a touch screen a text block of a few slide units is a few pixels tall, so the target grows (up to 24 slide units
296
+ // each way) until it is about 44 CSS pixels in each direction.
297
+ const padX = touchPad(svg, bounds.width), padY = touchPad(svg, bounds.height);
282
298
  for (const [key, value] of Object.entries({
283
- x: bounds.x - 4,
284
- y: bounds.y - 4,
285
- width: Math.max(8, bounds.width + 8),
286
- height: Math.max(8, bounds.height + 8),
299
+ x: bounds.x - padX,
300
+ y: bounds.y - padY,
301
+ width: Math.max(2 * padX, bounds.width + 2 * padX),
302
+ height: Math.max(2 * padY, bounds.height + 2 * padY),
287
303
  fill: "transparent",
288
304
  stroke: "transparent",
289
305
  "stroke-width": 1.5,
@@ -326,6 +342,7 @@ export function createCanvasEditor(container, options = {}) {
326
342
  }
327
343
  layoutHandles.update(document, geometry);
328
344
  blockControls.update(document, geometry);
345
+ imageCropper?.update();
329
346
  if (active?.kind === "text") positionInput();
330
347
  if (active?.kind === "rich-text") active.rich?.update();
331
348
  if (fontsShown) {
@@ -340,6 +357,13 @@ export function createCanvasEditor(container, options = {}) {
340
357
  draft: !!active || !!layoutHandles.editingPath,
341
358
  });
342
359
  }
360
+ const coarse = win.matchMedia?.("(pointer: coarse)");
361
+ function touchPad(svg, size) {
362
+ if (!coarse?.matches) return 4;
363
+ const box = svg.getBoundingClientRect(), view = svg.viewBox?.baseVal;
364
+ const scale = box.width && view?.width ? box.width / view.width : 0;
365
+ return scale ? Math.max(4, Math.min(24, (44 / scale - size) / 2)) : 4;
366
+ }
343
367
  // An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
344
368
  function deferDraft(draft) {
345
369
  if (!fonts?.pending(draft, renderOptions).length) return false;
@@ -903,6 +927,43 @@ export function createCanvasEditor(container, options = {}) {
903
927
  getTarget(path)?.focus();
904
928
  options.onCancel?.({ path });
905
929
  }
930
+ function setSlide(index) {
931
+ if (!Number.isInteger(index) || !editor.document.slides?.[index])
932
+ throw new RangeError("Slide index is out of range.");
933
+ if (index === slideIndex) {
934
+ if (!active) renderFor();
935
+ return true;
936
+ }
937
+ if (!commit()) return false;
938
+ richToolbar.hide();
939
+ imageCropper?.cancel();
940
+ slideIndex = index;
941
+ selectedPath = null;
942
+ imageCropper?.sync(null);
943
+ renderFor();
944
+ return true;
945
+ }
946
+ // RR-25: select the content at `path`, or the closest enclosing content that is a canvas target (a list item, a table
947
+ // cell or a quote part selects the list, table or quote it belongs to), after showing the slide the path is on. Returns
948
+ // the path that was selected, or null when the path is not on a slide that is drawn yet (fonts still loading) or no
949
+ // enclosing content is selectable (speaker notes, deck fields). It never moves keyboard focus unless `focus` is true.
950
+ function reveal(path, { focus = false } = {}) {
951
+ if (disposed || typeof path !== "string") return null;
952
+ const slide = /^slides\.(\d+)(?:\.|$)/.exec(path);
953
+ if (slide && Number(slide[1]) !== slideIndex && !setSlide(Number(slide[1]))) return null;
954
+ if (active && !commit()) return null;
955
+ const segments = path.split(".");
956
+ for (let length = segments.length; length >= 3; length--) {
957
+ const candidate = segments.slice(0, length).join(".");
958
+ const node = getTarget(candidate);
959
+ if (!node) continue;
960
+ choose(candidate);
961
+ node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
962
+ if (focus) node.focus?.({ preventScroll: true });
963
+ return candidate;
964
+ }
965
+ return null;
966
+ }
906
967
  const unsubscribe = editor.subscribe(() => {
907
968
  if (committing || layoutHandles.editingPath) return;
908
969
  if (active) {
@@ -925,6 +986,9 @@ export function createCanvasEditor(container, options = {}) {
925
986
  active?.rich?.update();
926
987
  });
927
988
  resize.observe(root);
989
+ // The on-screen keyboard shrinks the visual viewport; keep the field being typed in visible above it.
990
+ const keepEditVisible = () => { if (active?.input && coarse?.matches) active.input.scrollIntoView?.({ block: "center", inline: "nearest" }); };
991
+ win.visualViewport?.addEventListener("resize", keepEditVisible);
928
992
  root.addEventListener("pointerdown", () => { pointerTaken = null; }, true);
929
993
  root.addEventListener("keydown", (event) => {
930
994
  if (
@@ -961,6 +1025,13 @@ export function createCanvasEditor(container, options = {}) {
961
1025
  return active?.path ?? layoutHandles.editingPath ?? blockControls.editingPath ?? null;
962
1026
  },
963
1027
  get layoutEditing() { return layoutHandles.enabled; },
1028
+ /** True while the crop layer is open. */
1029
+ get cropping() { return !!imageCropper?.isOpen; },
1030
+ /** Open the crop layer for the picture at `path` (an image block, a slide's `image`, a slide image). `tool: "focus"` starts with the focal point. */
1031
+ cropImage(path, cropOptions) {
1032
+ if (!imageCropper) return Promise.resolve(false);
1033
+ return imageCropper.open(path, cropOptions);
1034
+ },
964
1035
  setLayoutEditing(enabled) {
965
1036
  if (!commit()) return false;
966
1037
  return layoutHandles.setEnabled(enabled);
@@ -981,24 +1052,12 @@ export function createCanvasEditor(container, options = {}) {
981
1052
  commit,
982
1053
  cancel,
983
1054
  render,
984
- setSlide(index) {
985
- if (!Number.isInteger(index) || !editor.document.slides?.[index])
986
- throw new RangeError("Slide index is out of range.");
987
- if (index === slideIndex) {
988
- if (!active) renderFor();
989
- return true;
990
- }
991
- if (!commit()) return false;
992
- richToolbar.hide();
993
- slideIndex = index;
994
- selectedPath = null;
995
- renderFor();
996
- return true;
997
- },
1055
+ setSlide,
1056
+ reveal,
998
1057
  setRenderOptions(next) {
999
1058
  if (!commit()) return false;
1000
1059
  richToolbar.hide();
1001
- renderOptions = next;
1060
+ renderOptions = { variables: false, ...next };
1002
1061
  renderFor();
1003
1062
  return true;
1004
1063
  },
@@ -1006,11 +1065,13 @@ export function createCanvasEditor(container, options = {}) {
1006
1065
  disposed = true;
1007
1066
  stopDrag?.();
1008
1067
  active?.rich?.destroy();
1068
+ imageCropper?.destroy();
1009
1069
  richToolbar.destroy();
1010
1070
  layoutHandles.destroy();
1011
1071
  blockControls.destroy();
1012
1072
  unsubscribe();
1013
1073
  resize.disconnect();
1074
+ win.visualViewport?.removeEventListener("resize", keepEditVisible);
1014
1075
  if (frame) win.cancelAnimationFrame(frame);
1015
1076
  root.remove();
1016
1077
  options.propertiesContainer?.replaceChildren();
@@ -0,0 +1,32 @@
1
+ import type { EditorSession } from "./index.js";
2
+ import type { GridAddress, GridCellEdit, GridChange, GridOptions, GridSortOptions, PreparedGridChange } from "./grid-model.js";
3
+
4
+ /** Set chart cells from text or typed values: names and categories are text, series cells are numbers read in the number format (blank is a gap, never 0). One patch; every problem is in `error.issues`. */
5
+ export declare function prepareChartCells(document: unknown, chartPath: string, edits: GridCellEdit[], options?: GridOptions): PreparedGridChange;
6
+ export declare function setChartCells(editor: EditorSession, chartPath: string, edits: GridCellEdit[], options?: GridOptions): GridChange;
7
+ export declare function prepareChartPaste(document: unknown, chartPath: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): PreparedGridChange;
8
+ export declare function pasteChartText(editor: EditorSession, chartPath: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): GridChange;
9
+ /** Insert `count` empty rows (categories) so the first is row `at`. */
10
+ export declare function prepareChartInsertRows(document: unknown, chartPath: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
11
+ export declare function insertChartRows(editor: EditorSession, chartPath: string, at: number, count?: number, options?: GridOptions): GridChange;
12
+ export declare function prepareChartDeleteRows(document: unknown, chartPath: string, indices: number[], options?: GridOptions): PreparedGridChange;
13
+ export declare function deleteChartRows(editor: EditorSession, chartPath: string, indices: number[], options?: GridOptions): GridChange;
14
+ export declare function prepareChartMoveRows(document: unknown, chartPath: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
15
+ export declare function moveChartRows(editor: EditorSession, chartPath: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
16
+ /** Insert `count` unnamed series so the first is column `at`. */
17
+ export declare function prepareChartInsertColumns(document: unknown, chartPath: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
18
+ export declare function insertChartColumns(editor: EditorSession, chartPath: string, at: number, count?: number, options?: GridOptions): GridChange;
19
+ export declare function prepareChartDeleteColumns(document: unknown, chartPath: string, indices: number[], options?: GridOptions): PreparedGridChange;
20
+ export declare function deleteChartColumns(editor: EditorSession, chartPath: string, indices: number[], options?: GridOptions): GridChange;
21
+ export declare function prepareChartMoveColumns(document: unknown, chartPath: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
22
+ export declare function moveChartColumns(editor: EditorSession, chartPath: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
23
+ /** Sort the rows (categories) by a column, stably and by type. */
24
+ export declare function prepareChartSort(document: unknown, chartPath: string, column: number, options?: GridSortOptions): PreparedGridChange;
25
+ export declare function sortChartRows(editor: EditorSession, chartPath: string, column: number, options?: GridSortOptions): GridChange;
26
+ /** Swap categories and series: the first column's values become the series names and each series becomes a row. Numeric categories become text. */
27
+ export declare function prepareChartTranspose(document: unknown, chartPath: string): PreparedGridChange;
28
+ export declare function transposeChart(editor: EditorSession, chartPath: string, options?: { meta?: Record<string, unknown> }): GridChange;
29
+ /** Rename a series: the label of data column `column` (1 or more). */
30
+ export declare function renameChartSeries(editor: EditorSession, chartPath: string, column: number, name: string, options?: { meta?: Record<string, unknown> }): GridChange;
31
+ /** Rename a category: the first-column label of body row `row`. An empty name leaves it blank. */
32
+ export declare function renameChartCategory(editor: EditorSession, chartPath: string, row: number, name: string, options?: { meta?: Record<string, unknown> }): GridChange;