@openpresentation/opf-editor 0.10.0 → 0.10.2
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 +31 -4
- package/dist/canvas.d.ts +45 -0
- package/dist/canvas.js +212 -22
- package/dist/font-gate.js +107 -0
- package/dist/rich-text-input.js +26 -9
- package/dist/text-pointer.js +180 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
|
|
4
4
|
|
|
5
|
-
Version 0.10.0 requires `@openpresentation/opf` ^0.11.2, renderer ^0.11.0, and PPTX ^0.11.0 (install them together so cover centering resolves the same core everywhere; renderer 0.11.0 also activates the playground's automatic script fonts and lazy Intos fonts). Version 0.9.0 required `@openpresentation/opf` ^0.11.1, renderer ^0.10.0, and PPTX ^0.10.0. Composition and slide transfer fall back to the shared `aptos` font scheme and report unknown scheme ids through `onDiagnostic`; gallery apply keeps every font-scheme role.
|
|
5
|
+
Version 0.10.2 adds pointer caret entry on the canvas (one click puts the caret where you click; new `textEntry` option; keyboard entry still selects all) and keeps the 0.10.1 and 0.10.0 requirements. Version 0.10.1 adds the opt-in font gate (`createFontGate`, FF-41) and keeps the 0.10.0 requirements; develop and test it against renderer 0.11.2 and PPTX 0.11.1. Version 0.10.0 requires `@openpresentation/opf` ^0.11.2, renderer ^0.11.0, and PPTX ^0.11.0 (install them together so cover centering resolves the same core everywhere; renderer 0.11.0 also activates the playground's automatic script fonts and lazy Intos fonts). Version 0.9.0 required `@openpresentation/opf` ^0.11.1, renderer ^0.10.0, and PPTX ^0.10.0. Composition and slide transfer fall back to the shared `aptos` font scheme and report unknown scheme ids through `onDiagnostic`; gallery apply keeps every font-scheme role.
|
|
6
6
|
|
|
7
7
|
Version 0.8.0 required `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
|
|
8
8
|
|
|
@@ -74,8 +74,35 @@ await canvas.ready;
|
|
|
74
74
|
|
|
75
75
|
Load the same font bytes into the browser using `loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser` before mounting. The host owns font URLs, storage and collaboration. For non-Latin documents, pass the pinned script pack's location as `scriptBaseUrl` and call `fonts.ensureScripts(document)` after edits (renderer with `scripts: 'auto'`, FF-19): only the faces for the scripts a document draws are fetched, once, hash-verified, for example Noto Sans JP for Japanese; a Latin-only document fetches none. The playground does this from `./script-fonts/`, which `npm run build:playground` fills with the pinned faces. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
|
|
76
76
|
|
|
77
|
+
Fonts load before pixels (FF-41). A document can need faces the browser registry has not loaded yet: script faces for the languages it draws and vendored preview faces for the font families it resolves (Intos for Aptos, Open Sans, Barlow). Pass a font gate as `fonts` and the canvas never renders such a document early: on every path (editor changes, undo and redo, imports, dimension switches, slide changes, in-progress edits) it shows "Loading fonts…", loads the faces and then renders. A load that fails is reported through `onFonts`/`onError` and offers a retry button; nothing retries by itself, and the failed document is not drawn.
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { createCanvasEditor, createFontGate } from '@openpresentation/opf-editor/canvas';
|
|
81
|
+
|
|
82
|
+
const fonts = createFontGate(fontRegistry); // a registry without the lazy loaders gates nothing
|
|
83
|
+
const canvas = createCanvasEditor(container, { document, fonts, renderOptions: { textMeasurement: fontRegistry.textMeasurement } });
|
|
84
|
+
// Other renders of your own: fonts.run(document, { ready: draw, failed: showError, loading: showSpinner })
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Note that `session.composeSlide()` and `session.paginateSlide()` measure with the plain `textMeasurement` you pass, which is strict: for a document with a script the design font lacks (Japanese under Aptos) pass `createScriptTextMeasurement(fontRegistry.textMeasurement, resolveScriptFonts(document, { slideIndex }))` from `@openpresentation/opf-render/fonts`, as `renderSvg` does internally.
|
|
88
|
+
|
|
77
89
|
These APIs were introduced in 0.1.0. Version 0.7.0 requires core 0.10.0 and renderer 0.8.0 for the canvas, including shared accepted geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
|
|
78
90
|
|
|
91
|
+
### Entering text editing
|
|
92
|
+
|
|
93
|
+
One click puts the caret where you click, the way PowerPoint and Google Slides do. Hover outlines a text target; a single press (mouse, pen, or a touch tap) on editable text selects the box, starts inline editing and places the caret at the nearest character boundary to the pointer (correct for wrapped, multi-line, centered and right-aligned text). Press and drag selects the range from the press point to the release point. While editing, a native double-click selects a word, a triple-click a line or paragraph, and a click elsewhere in the text moves the caret; clicking a different text target commits the current edit (an invalid edit still refuses) and enters the new one in the same click. Enter, Space or F2 on a focused target enters with **all** text selected (the keyboard replace convention); `canvas.beginEdit(path)` does the same. Escape leaves editing and keeps the box selected. Images, charts and other non-text targets are unchanged: a click selects, a double-click opens their properties. Layout handles and block controls keep their own pointer handling, and a text press never moves the box.
|
|
94
|
+
|
|
95
|
+
The gesture is configurable:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
createCanvasEditor(container, { document, textEntry: 'click' }); // default: one click enters
|
|
99
|
+
createCanvasEditor(container, { document, textEntry: 'dblclick' }); // one click selects, a double-click enters
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`textEntry: 'dblclick'` keeps the older two-step gesture (select, then double-click) but a double-click now also places the caret at the pointer instead of selecting all text. Hosts and tests that used `dblclick()` and then relied on all text being selected should switch to keyboard entry (focus the target and press Enter) or select explicitly; `locator.dblclick()` on the default canvas now places the caret and then selects the word under it.
|
|
103
|
+
|
|
104
|
+
Plain-text carets are resolved from the rendered SVG glyphs: each line carries its exact source range (`data-opf-source-start`/`-end` or `data-opf-text-start`/`-end`), so collapsed spaces at wraps, tabs, CRLF sources and bidi isolate marks map to offsets of the input value rather than to glyph indexes. A line whose text does not match its traced source range falls back to a hidden copy of the positioned textarea. Rich text uses its own pointer-to-offset mapping. Mouse and pen enter on press; a touch tap enters on the tap, so scrolling with a finger never starts editing. Right-to-left and CJK use the same per-glyph mapping; real operating-system IME and bidi caret behaviour are not verified here.
|
|
105
|
+
|
|
79
106
|
The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Advanced shaping, freeform object positioning, all chart/media treatments, and cross-engine pixel identity remain work in progress. The Source dialog in the playground now provides a live JSON preview; changes are validated before committing.
|
|
80
107
|
|
|
81
108
|
## Runtime Policy
|
|
@@ -247,7 +274,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
|
|
|
247
274
|
|
|
248
275
|
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
249
276
|
|
|
250
|
-
Version 0.10.
|
|
277
|
+
Version 0.10.2 requires `@openpresentation/opf@^0.11.2` (unchanged since 0.10.0). The optional renderer peer requires `@openpresentation/opf-render@^0.11.0` (the font gate gates nothing on a registry without the lazy loaders). Development and coordinated playground export use renderer 0.11.2 and `@openpresentation/opf-pptx@^0.11.1`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
251
278
|
|
|
252
279
|
## Local interactive demo
|
|
253
280
|
|
|
@@ -286,7 +313,7 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
|
|
|
286
313
|
|
|
287
314
|
### Rich text on the canvas
|
|
288
315
|
|
|
289
|
-
Select rendered rich text to format it, or
|
|
316
|
+
Select rendered rich text to format it, or click a rich text block to type (see *Entering text editing*). The toolbar supports character styles, point size, font family, hex color, scheme slots/roles, `var:<id>` references, links, scripts, and selected-text replacement. Plain text payloads offer **Format text** during inline editing. **Edit runs** opens the structured fields; `canvas.editProperties(path)` does the same programmatically. Each action is validated and undoable. Version 0.1.1 supports direct mixed-style typing: click to type, drag to select, double-click for a word, triple-click for a paragraph, use Format selection for styles, and commit with Done or Ctrl/Cmd+Enter. Escape cancels. Native input and composition events preserve run metadata; the canonical SVG supplies glyph/caret geometry. The whole session commits as one undo step, with draft undo/redo available while typing. Empty-line caret geometry requires renderer 0.1.1 or later; cross-engine and real operating-system IME verification remain open.
|
|
290
317
|
|
|
291
318
|
Version 0.7.0: rich input maps the textarea's LF-normalized offsets back to the original source. Typing preserves untouched CRLF/CR endings and surrounding run metadata; newly inserted newlines use the first source line-ending style. The `updateRichTextInput` change offsets are native textarea offsets, while formatting and replacement helpers continue to use original source offsets.
|
|
292
319
|
|
|
@@ -310,7 +337,7 @@ Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresen
|
|
|
310
337
|
|
|
311
338
|
## Styled and merged table cells
|
|
312
339
|
|
|
313
|
-
Define cells with `{value, style, rowSpan, colSpan}` and place explicit `null` at covered positions. Inline editing follows the anchor's `.value` path, preserving its style and merge geometry.
|
|
340
|
+
Define cells with `{value, style, rowSpan, colSpan}` and place explicit `null` at covered positions. Inline editing follows the anchor's `.value` path, preserving its style and merge geometry. Click a value, or focus it and press Enter, to edit; the existing text-formatting controls support scalar promotion, rich runs and partial selection. Covered positions are not separate editable targets.
|
|
314
341
|
|
|
315
342
|
The core schema rejects overlapping/out-of-bounds spans and hidden content. Model patches can change a span and remove a row atomically; undo restores the complete operation. Styling or restructuring a table remains available through JSON/model edits and the existing structured property forms.
|
|
316
343
|
|
package/dist/canvas.d.ts
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
import type { EditorSession } from "./index.js";
|
|
2
2
|
import type { RenderSvgOptions } from "@openpresentation/opf-render";
|
|
3
3
|
import type { SlideComposition } from "@openpresentation/opf/composition";
|
|
4
|
+
/** Faces a document needs (script packages and vendored files) that are not loaded yet, and the way to load them. */
|
|
5
|
+
export interface FontGate {
|
|
6
|
+
/** Synchronous. Empty means the document can render now. Never throws. */
|
|
7
|
+
pending(document: unknown): string[];
|
|
8
|
+
/** Load every pending face (vendored faces first, then script faces). Rejects with a `fonts-unavailable` error; never retries by itself. */
|
|
9
|
+
ensure(document: unknown, options?: { signal?: AbortSignal }): Promise<void>;
|
|
10
|
+
/** `whenFontsReady` on this gate. */
|
|
11
|
+
run(document: unknown, handlers?: FontGateHandlers): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface FontGateHandlers {
|
|
14
|
+
/** Return false once the document was superseded; the late result is then dropped. */
|
|
15
|
+
isCurrent?: () => boolean;
|
|
16
|
+
loading?: (pending: string[]) => void;
|
|
17
|
+
ready?: () => void;
|
|
18
|
+
failed?: (error: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare const FONTS_PENDING: "fonts-pending";
|
|
21
|
+
export declare const FONTS_UNAVAILABLE: "fonts-unavailable";
|
|
22
|
+
/** Wrap a browser font registry (`loadBrowserFontRegistry`). Registries without the lazy loaders gate nothing. */
|
|
23
|
+
export declare function createFontGate(registry: object): FontGate;
|
|
24
|
+
/**
|
|
25
|
+
* Ensure fonts for a document, then run `ready` (synchronously when nothing is pending). The one
|
|
26
|
+
* "ensure fonts, then render" helper: load failures and exceptions from `ready` go to `failed`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function whenFontsReady(
|
|
29
|
+
gate: Pick<FontGate, "pending" | "ensure"> | undefined,
|
|
30
|
+
document: unknown,
|
|
31
|
+
handlers?: FontGateHandlers,
|
|
32
|
+
): Promise<void>;
|
|
4
33
|
export interface CanvasEditorOptions {
|
|
5
34
|
editor?: EditorSession;
|
|
6
35
|
document?: unknown;
|
|
@@ -8,6 +37,19 @@ export interface CanvasEditorOptions {
|
|
|
8
37
|
/** Show keyboard-accessible dividers for resizing composition tracks. */
|
|
9
38
|
layoutEditing?: boolean;
|
|
10
39
|
renderOptions?: RenderSvgOptions;
|
|
40
|
+
/**
|
|
41
|
+
* A font gate (`createFontGate(registry)`). With one, the canvas never renders a document whose faces are still
|
|
42
|
+
* loading: it shows "Loading fonts…", loads them and then renders, on every path (editor changes, undo/redo,
|
|
43
|
+
* imports, dimension switches, slide changes, drafts). Without one every document renders at once.
|
|
44
|
+
*/
|
|
45
|
+
fonts?: Pick<FontGate, "pending" | "ensure">;
|
|
46
|
+
/**
|
|
47
|
+
* How a pointer enters text editing. "click" (default): a single press on editable text starts editing with the caret at the
|
|
48
|
+
* pressed character, and a press-drag selects a range. "dblclick": a click selects, a double-click enters with the caret at the
|
|
49
|
+
* pointer. Keyboard entry (Enter, Space, F2) and `beginEdit(path)` always select all text. Non-text targets always open
|
|
50
|
+
* their properties on double-click.
|
|
51
|
+
*/
|
|
52
|
+
textEntry?: "click" | "dblclick";
|
|
11
53
|
/** Optional empty host for property forms; defaults to a floating canvas panel. */
|
|
12
54
|
propertiesContainer?: HTMLElement;
|
|
13
55
|
onSelect?: (selection: {
|
|
@@ -24,6 +66,8 @@ export interface CanvasEditorOptions {
|
|
|
24
66
|
onCommit?: (event: { path: string; editor: EditorSession }) => void;
|
|
25
67
|
onCancel?: (event: { path?: string }) => void;
|
|
26
68
|
onError?: (error: Error) => void;
|
|
69
|
+
/** Font loading progress of the canvas: `loading`, `error` (a face could not be loaded; the canvas offers a retry) and `ready`. */
|
|
70
|
+
onFonts?: (event: { state: "loading"; pending: string[] } | { state: "error"; error: Error } | { state: "ready" }) => void;
|
|
27
71
|
onRender?: (event: {
|
|
28
72
|
document: unknown;
|
|
29
73
|
slideIndex: number;
|
|
@@ -44,6 +88,7 @@ export interface CanvasEditor {
|
|
|
44
88
|
/** Add starter content to a slide/group/region, converting implicit content when needed. */
|
|
45
89
|
openInsertMenu(containerPath?: string,index?: number): void;
|
|
46
90
|
select(path: string): void;
|
|
91
|
+
/** Start editing with all text selected (keyboard entry). Pointer entry places the caret at the pointer instead. */
|
|
47
92
|
beginEdit(path: string): void;
|
|
48
93
|
/** Open structured controls, including rich run fields. */
|
|
49
94
|
editProperties(path: string): boolean;
|
package/dist/canvas.js
CHANGED
|
@@ -18,8 +18,11 @@ import {
|
|
|
18
18
|
import { createBlockControls } from "./block-controls.js";
|
|
19
19
|
import { createLayoutHandles } from "./layout-handles.js";
|
|
20
20
|
import { createRichTextInput } from "./rich-text-input.js";
|
|
21
|
+
import { textInputOffsetAtPoint } from "./text-pointer.js";
|
|
21
22
|
import { createRichTextToolbar } from "./rich-text-toolbar.js";
|
|
23
|
+
import { FONTS_PENDING, fontsPendingError, whenFontsReady } from "./font-gate.js";
|
|
22
24
|
export { getEditableFields } from "./canvas-fields.js";
|
|
25
|
+
export { createFontGate, whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
|
|
23
26
|
|
|
24
27
|
/** Allocated placeholder or internal-part bounds for the selection outline, not glyph ink. */
|
|
25
28
|
export function allocatedSelectionBox(node, item) {
|
|
@@ -62,13 +65,27 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
62
65
|
const editor =
|
|
63
66
|
options.editor ??
|
|
64
67
|
createEditorSession(options.document, { rejectInvalid: true });
|
|
68
|
+
// `options.fonts` is a font gate (createFontGate): a document whose faces are not loaded yet is never rendered. The canvas
|
|
69
|
+
// loads them first and shows "Loading fonts…" meanwhile; without a gate every document renders at once, as before.
|
|
70
|
+
const fonts = options.fonts;
|
|
65
71
|
let slideIndex = options.slideIndex ?? 0,
|
|
66
72
|
renderOptions = options.renderOptions ?? {},
|
|
73
|
+
showToken = 0,
|
|
74
|
+
fontsShown = false,
|
|
67
75
|
active = null,
|
|
68
76
|
selectedPath = null,
|
|
69
77
|
disposed = false,
|
|
70
78
|
frame = 0,
|
|
71
|
-
committing = false
|
|
79
|
+
committing = false,
|
|
80
|
+
pointerTaken = null,
|
|
81
|
+
handledPointer = null,
|
|
82
|
+
stopDrag = null;
|
|
83
|
+
// How a pointer enters text editing: "click" (default; PowerPoint / Google Slides) puts the caret at the pressed
|
|
84
|
+
// character on a single press and lets a press-drag select a range; "dblclick" keeps the older double-click gesture
|
|
85
|
+
// but still places the caret at the pointer. Keyboard entry (Enter, Space, F2) always selects all text.
|
|
86
|
+
const textEntry = options.textEntry ?? "click";
|
|
87
|
+
if (textEntry !== "click" && textEntry !== "dblclick")
|
|
88
|
+
throw new RangeError('textEntry must be "click" or "dblclick".');
|
|
72
89
|
const root = doc.createElement("div"),
|
|
73
90
|
preview = doc.createElement("div"),
|
|
74
91
|
overlay = doc.createElement("div"),
|
|
@@ -100,12 +117,12 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
100
117
|
onTyping: beginEdit,
|
|
101
118
|
onProperties(path) { if (commit()) openProperties(path, editor.get(path)); },
|
|
102
119
|
validateChange(path, value) {
|
|
103
|
-
|
|
120
|
+
validateRender(createCanvasDraft(editor.document, path, value));
|
|
104
121
|
},
|
|
105
122
|
onChange(path) { clearNotice(); options.onCommit?.({path, editor}); }
|
|
106
123
|
});
|
|
107
124
|
const layoutHandles = createLayoutHandles(root, {
|
|
108
|
-
editor, enabled: options.layoutEditing, render, beforeEdit: commit,
|
|
125
|
+
editor, enabled: options.layoutEditing, render: renderFor, beforeEdit: commit,
|
|
109
126
|
isTextEditing: () => !!active,
|
|
110
127
|
clearError: clearNotice, onError: report,
|
|
111
128
|
onDraft: value => options.onDraft?.(value),
|
|
@@ -113,9 +130,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
113
130
|
onCancel: value => { clearNotice(); options.onCancel?.(value); },
|
|
114
131
|
});
|
|
115
132
|
const blockControls = createBlockControls(root, {
|
|
116
|
-
editor, render, beforeEdit: () => {if(!commit())return false;richToolbar.hide();return true;}, enabled: () => layoutHandles.enabled,
|
|
133
|
+
editor, render: renderFor, beforeEdit: () => {if(!commit())return false;richToolbar.hide();return true;}, enabled: () => layoutHandles.enabled,
|
|
117
134
|
isEditing: () => !!active || !!layoutHandles.editingPath, slideIndex: () => slideIndex,
|
|
118
|
-
validate: document =>
|
|
135
|
+
validate: document => validateRender(document),
|
|
119
136
|
clearError: clearNotice, onError: report,
|
|
120
137
|
onMove: path => choose(splitOpfPath(path).join(".")),
|
|
121
138
|
onCommit: value => options.onCommit?.(value),
|
|
@@ -142,8 +159,75 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
142
159
|
editor,
|
|
143
160
|
});
|
|
144
161
|
}
|
|
162
|
+
const pendingMessage = "Loading fonts for this document…";
|
|
163
|
+
// A synchronous render or validation of a document whose faces are still loading fails with a clear "fonts-pending"
|
|
164
|
+
// error and starts the load, so the next attempt succeeds. It never draws glyphs the registry cannot provide.
|
|
165
|
+
function requireFonts(document) {
|
|
166
|
+
const pending = fonts?.pending(document) ?? [];
|
|
167
|
+
if (!pending.length) return;
|
|
168
|
+
notice.hidden = false;
|
|
169
|
+
notice.textContent = pendingMessage;
|
|
170
|
+
fonts.ensure(document).then(
|
|
171
|
+
() => { if (!disposed && notice.textContent === pendingMessage) clearNotice(); },
|
|
172
|
+
(error) => { if (!disposed) report(error); },
|
|
173
|
+
);
|
|
174
|
+
throw fontsPendingError(pending);
|
|
175
|
+
}
|
|
176
|
+
function validateRender(document) {
|
|
177
|
+
requireFonts(document);
|
|
178
|
+
return renderSvg(document, {...renderOptions, slideIndex});
|
|
179
|
+
}
|
|
180
|
+
function fontsState(kind, detail) {
|
|
181
|
+
fontsShown = true;
|
|
182
|
+
const size = preview.firstElementChild?.getBoundingClientRect?.().height || 0;
|
|
183
|
+
layoutHandles.hide();
|
|
184
|
+
blockControls.hide();
|
|
185
|
+
richToolbar.hide();
|
|
186
|
+
const box = doc.createElement("div");
|
|
187
|
+
box.className = "opf-canvas-fonts";
|
|
188
|
+
box.setAttribute("role", kind === "error" ? "alert" : "status");
|
|
189
|
+
box.style.cssText = `box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:${Math.max(160, Math.round(size))}px;padding:16px;text-align:center;font:14px/1.4 system-ui;background:rgba(127,127,127,.12);border-radius:4px`;
|
|
190
|
+
const message = doc.createElement("span");
|
|
191
|
+
message.textContent = kind === "error" ? detail.message : "Loading fonts…";
|
|
192
|
+
box.append(message);
|
|
193
|
+
if (kind === "error") {
|
|
194
|
+
const retry = doc.createElement("button");
|
|
195
|
+
retry.type = "button";
|
|
196
|
+
retry.textContent = "Retry loading fonts";
|
|
197
|
+
retry.addEventListener("click", () => show());
|
|
198
|
+
box.append(retry);
|
|
199
|
+
}
|
|
200
|
+
preview.replaceChildren(box);
|
|
201
|
+
if (kind === "error") options.onError?.(detail);
|
|
202
|
+
options.onFonts?.(kind === "error" ? { state: "error", error: detail } : { state: "loading", pending: detail });
|
|
203
|
+
}
|
|
204
|
+
// Render a document the canvas did not draw itself (an editor change, a slide or option change) once its fonts are loaded.
|
|
205
|
+
function show(target) {
|
|
206
|
+
if (disposed) return;
|
|
207
|
+
const token = ++showToken;
|
|
208
|
+
return whenFontsReady(fonts, target ?? editor.document, {
|
|
209
|
+
isCurrent: () => !disposed && token === showToken,
|
|
210
|
+
loading: (pending) => fontsState("loading", pending),
|
|
211
|
+
ready: () => {
|
|
212
|
+
try {
|
|
213
|
+
render(target);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error?.code === FONTS_PENDING) show(target);
|
|
216
|
+
else report(error);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
failed: (error) => fontsState("error", error),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
|
|
223
|
+
function renderFor(document) {
|
|
224
|
+
if (fonts?.pending(document ?? editor.document).length) show(document);
|
|
225
|
+
else render(document);
|
|
226
|
+
}
|
|
145
227
|
function render(document = editor.document) {
|
|
146
228
|
if (disposed) return;
|
|
229
|
+
requireFonts(document);
|
|
230
|
+
showToken++;
|
|
147
231
|
const slides = document.slides ?? [];
|
|
148
232
|
slideIndex = Math.max(0, Math.min(slideIndex, slides.length - 1));
|
|
149
233
|
const svgText = renderSvg(document, {
|
|
@@ -209,19 +293,29 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
209
293
|
rect.classList.add("opf-selection");
|
|
210
294
|
node.prepend(rect);
|
|
211
295
|
}
|
|
296
|
+
node.addEventListener("pointerdown", (event) => pointerDown(event, path));
|
|
297
|
+
// Some browsers still run mousedown's focus and text-selection defaults after a handled pointerdown.
|
|
298
|
+
node.addEventListener("mousedown", (event) => { if (pointerTaken === path) event.preventDefault(); });
|
|
212
299
|
node.addEventListener("click", (event) => {
|
|
213
300
|
event.stopPropagation();
|
|
214
301
|
if (event.target.closest("a")) event.preventDefault();
|
|
302
|
+
// A mouse or pen press already entered editing on pointerdown; this click only completes that gesture.
|
|
303
|
+
if (pointerTaken === path) { pointerTaken = null; return; }
|
|
215
304
|
if (active && active.path !== path && !commit()) return;
|
|
216
|
-
|
|
305
|
+
// A touch tap has no pointerdown entry (a touch press may start a scroll): it enters on the click itself.
|
|
306
|
+
const kind = editKind(path);
|
|
307
|
+
if (textEntry === "click" && event.detail > 0 && (kind === "text" || kind === "rich-text")) startEdit(path, { point: event });
|
|
308
|
+
else choose(path);
|
|
217
309
|
});
|
|
218
310
|
node.addEventListener("dblclick", (event) => {
|
|
219
311
|
event.preventDefault();
|
|
220
312
|
event.stopPropagation();
|
|
221
|
-
|
|
313
|
+
const kind = editKind(path);
|
|
314
|
+
startEdit(path, kind === "text" || kind === "rich-text" ? { point: event } : undefined);
|
|
222
315
|
});
|
|
223
316
|
node.addEventListener("keydown", (event) => {
|
|
224
|
-
|
|
317
|
+
// Keyboard entry replaces: all text is selected.
|
|
318
|
+
if (event.key === "Enter" || event.key === " " || event.key === "F2") {
|
|
225
319
|
event.preventDefault();
|
|
226
320
|
event.stopPropagation();
|
|
227
321
|
beginEdit(path);
|
|
@@ -233,6 +327,10 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
233
327
|
blockControls.update(document, geometry);
|
|
234
328
|
if (active?.kind === "text") positionInput();
|
|
235
329
|
if (active?.kind === "rich-text") active.rich?.update();
|
|
330
|
+
if (fontsShown) {
|
|
331
|
+
fontsShown = false;
|
|
332
|
+
options.onFonts?.({ state: "ready" });
|
|
333
|
+
}
|
|
236
334
|
options.onRender?.({
|
|
237
335
|
document,
|
|
238
336
|
slideIndex,
|
|
@@ -241,6 +339,22 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
241
339
|
draft: !!active || !!layoutHandles.editingPath,
|
|
242
340
|
});
|
|
243
341
|
}
|
|
342
|
+
// An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
|
|
343
|
+
function deferDraft(draft) {
|
|
344
|
+
if (!fonts?.pending(draft).length) return false;
|
|
345
|
+
active.valid = false;
|
|
346
|
+
notice.hidden = false;
|
|
347
|
+
notice.textContent = pendingMessage;
|
|
348
|
+
fonts.ensure(draft).then(
|
|
349
|
+
() => {
|
|
350
|
+
if (disposed) return;
|
|
351
|
+
if (notice.textContent === pendingMessage) clearNotice();
|
|
352
|
+
if (active) queueDraft();
|
|
353
|
+
},
|
|
354
|
+
(error) => { if (!disposed) report(error); },
|
|
355
|
+
);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
244
358
|
function queueDraft() {
|
|
245
359
|
if (frame) win.cancelAnimationFrame(frame);
|
|
246
360
|
frame = win.requestAnimationFrame(() => {
|
|
@@ -249,6 +363,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
249
363
|
if (active.kind === "properties") {
|
|
250
364
|
try {
|
|
251
365
|
const draft = propertyDraft(active);
|
|
366
|
+
if (deferDraft(draft)) return;
|
|
252
367
|
clearNotice();
|
|
253
368
|
render(draft);
|
|
254
369
|
options.onDraft?.({
|
|
@@ -264,6 +379,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
264
379
|
try {
|
|
265
380
|
const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type, active.originalValue);
|
|
266
381
|
const draft = createCanvasDraft(editor.document, active.path, value);
|
|
382
|
+
if (deferDraft(draft)) return;
|
|
267
383
|
clearNotice();
|
|
268
384
|
active.valid = true;
|
|
269
385
|
active.draft = draft;
|
|
@@ -338,7 +454,78 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
338
454
|
// Other text keeps the canonical SVG visible beneath the transparent input.
|
|
339
455
|
target.style.opacity = visibleText ? "0" : "1";
|
|
340
456
|
}
|
|
457
|
+
// What editing a target opens: inline text ("text"), the rich-text input ("rich-text"), or the properties form.
|
|
458
|
+
function editKind(path) {
|
|
459
|
+
const value = editor.get(path), target = getTarget(path);
|
|
460
|
+
if (value === undefined || !target) return null;
|
|
461
|
+
if ((typeof value === "string" || typeof value === "number") && target.querySelector("text") && !/\.(image|video)$/.test(path))
|
|
462
|
+
return "text";
|
|
463
|
+
if (Array.isArray(value) && target.hasAttribute("data-opf-rich-text")) return "rich-text";
|
|
464
|
+
return "properties";
|
|
465
|
+
}
|
|
466
|
+
// Textarea offset nearest to a client point, from the traced SVG glyphs (see text-pointer.js).
|
|
467
|
+
function textOffsetAt(edit, clientX, clientY) {
|
|
468
|
+
let source = edit.input.value;
|
|
469
|
+
try {
|
|
470
|
+
if (edit.type === "string") source = parseCanvasValue(edit.input.value, "string", edit.originalValue);
|
|
471
|
+
} catch { /* fall back to the raw input text */ }
|
|
472
|
+
return textInputOffsetAtPoint({
|
|
473
|
+
doc, win, target: getTarget(edit.path), path: edit.path, source, input: edit.input, clientX, clientY,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
function setRange(input, anchor, focus) {
|
|
477
|
+
input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward");
|
|
478
|
+
}
|
|
479
|
+
// Press-drag in plain text selects from the press point to the pointer, like PowerPoint. The textarea is not under the
|
|
480
|
+
// pointer when the press lands on the SVG glyphs, so the range is tracked here.
|
|
481
|
+
function trackDrag(edit, anchor, event) {
|
|
482
|
+
stopDrag?.();
|
|
483
|
+
const id = event.pointerId;
|
|
484
|
+
const move = (next) => {
|
|
485
|
+
if (next.pointerId !== id) return;
|
|
486
|
+
if (active !== edit || !next.buttons) return stop();
|
|
487
|
+
setRange(edit.input, anchor, textOffsetAt(edit, next.clientX, next.clientY));
|
|
488
|
+
};
|
|
489
|
+
const end = (next) => { if (next.pointerId === id) stop(); };
|
|
490
|
+
const stop = () => {
|
|
491
|
+
win.removeEventListener("pointermove", move);
|
|
492
|
+
win.removeEventListener("pointerup", end);
|
|
493
|
+
win.removeEventListener("pointercancel", end);
|
|
494
|
+
if (stopDrag === stop) stopDrag = null;
|
|
495
|
+
if (active === edit) edit.input.focus({ preventScroll: true });
|
|
496
|
+
};
|
|
497
|
+
win.addEventListener("pointermove", move);
|
|
498
|
+
win.addEventListener("pointerup", end);
|
|
499
|
+
win.addEventListener("pointercancel", end);
|
|
500
|
+
stopDrag = stop;
|
|
501
|
+
}
|
|
502
|
+
// A mouse or pen press on editable text enters editing at that character (touch enters from its click instead).
|
|
503
|
+
function pointerDown(event, path) {
|
|
504
|
+
if (handledPointer === event) return;
|
|
505
|
+
handledPointer = event;
|
|
506
|
+
if (textEntry !== "click" || disposed || event.button !== 0 || event.pointerType === "touch") return;
|
|
507
|
+
const edit = active?.path === path ? active : null;
|
|
508
|
+
if (edit ? edit.kind !== "text" : !["text", "rich-text"].includes(editKind(path))) return;
|
|
509
|
+
// Own the gesture: no native focus change, text selection or drag while the caret is placed.
|
|
510
|
+
event.preventDefault();
|
|
511
|
+
event.stopPropagation();
|
|
512
|
+
pointerTaken = path;
|
|
513
|
+
if (edit) {
|
|
514
|
+
const at = textOffsetAt(edit, event.clientX, event.clientY);
|
|
515
|
+
setRange(edit.input, at, at);
|
|
516
|
+
edit.input.focus({ preventScroll: true });
|
|
517
|
+
trackDrag(edit, at, event);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (active && !commit()) return;
|
|
521
|
+
startEdit(path, { point: event, drag: true });
|
|
522
|
+
}
|
|
341
523
|
function beginEdit(path) {
|
|
524
|
+
startEdit(path);
|
|
525
|
+
}
|
|
526
|
+
// entry: undefined selects all text (keyboard and programmatic entry); {point} places the caret at a client point;
|
|
527
|
+
// {point, drag} also selects while the pressed pointer moves.
|
|
528
|
+
function startEdit(path, entry) {
|
|
342
529
|
if (disposed) return;
|
|
343
530
|
if (active && active.path === path) return;
|
|
344
531
|
if (active && !commit()) return;
|
|
@@ -439,12 +626,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
439
626
|
input.addEventListener("blur", () => {
|
|
440
627
|
if (active?.input === input && !active.composing) commit();
|
|
441
628
|
});
|
|
442
|
-
input.focus();
|
|
443
|
-
|
|
629
|
+
input.focus({ preventScroll: !!entry?.point });
|
|
630
|
+
if (entry?.point) {
|
|
631
|
+
const at = textOffsetAt(active, entry.point.clientX, entry.point.clientY);
|
|
632
|
+
setRange(input, at, at);
|
|
633
|
+
if (entry.drag) trackDrag(active, at, entry.point);
|
|
634
|
+
} else input.select();
|
|
444
635
|
} else if (Array.isArray(value) && target?.hasAttribute("data-opf-rich-text")) {
|
|
445
636
|
active = {kind:"rich-text",path,base:JSON.stringify(value),valid:true};
|
|
446
637
|
active.rich = createRichTextInput(root, overlay, {
|
|
447
|
-
path, value, getTarget,
|
|
638
|
+
path, value, getTarget, selectAll: !entry?.point,
|
|
448
639
|
onInput: queueDraft, onCommit: commit, onCancel: cancel, onError: report,
|
|
449
640
|
onFormat(start,end) {
|
|
450
641
|
if (!commit()) return;
|
|
@@ -452,6 +643,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
452
643
|
},
|
|
453
644
|
});
|
|
454
645
|
active.input = active.rich.input;
|
|
646
|
+
if (entry?.point) active.rich.pointerStart(entry.point);
|
|
455
647
|
} else openProperties(path, value);
|
|
456
648
|
}
|
|
457
649
|
function propertyPatches(edit) {
|
|
@@ -659,7 +851,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
659
851
|
value = parseCanvasValue(edit.input.value, edit.type, edit.originalValue);
|
|
660
852
|
else value = propertyDraft(edit);
|
|
661
853
|
if (edit.kind === "text" || edit.kind === "rich-text")
|
|
662
|
-
|
|
854
|
+
validateRender(createCanvasDraft(editor.document, edit.path, value));
|
|
663
855
|
committing = true;
|
|
664
856
|
if ((edit.kind === "text" || edit.kind === "rich-text") && JSON.stringify(value) !== edit.base)
|
|
665
857
|
editor.set(edit.path, value, { source: "canvas", rejectInvalid: true });
|
|
@@ -725,17 +917,14 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
725
917
|
queueDraft();
|
|
726
918
|
return;
|
|
727
919
|
}
|
|
728
|
-
|
|
729
|
-
render();
|
|
730
|
-
} catch (error) {
|
|
731
|
-
report(error);
|
|
732
|
-
}
|
|
920
|
+
show();
|
|
733
921
|
});
|
|
734
922
|
const resize = new win.ResizeObserver(() => {
|
|
735
923
|
if (active?.kind === "text") positionInput();
|
|
736
924
|
active?.rich?.update();
|
|
737
925
|
});
|
|
738
926
|
resize.observe(root);
|
|
927
|
+
root.addEventListener("pointerdown", () => { pointerTaken = null; }, true);
|
|
739
928
|
root.addEventListener("keydown", (event) => {
|
|
740
929
|
if (
|
|
741
930
|
(event.metaKey || event.ctrlKey) &&
|
|
@@ -748,7 +937,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
748
937
|
}
|
|
749
938
|
});
|
|
750
939
|
try {
|
|
751
|
-
|
|
940
|
+
renderFor();
|
|
752
941
|
} catch (error) {
|
|
753
942
|
unsubscribe();
|
|
754
943
|
resize.disconnect();
|
|
@@ -759,7 +948,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
759
948
|
throw error;
|
|
760
949
|
}
|
|
761
950
|
const ready = Promise.resolve(doc.fonts?.ready).then(() => {
|
|
762
|
-
if (!disposed && !active)
|
|
951
|
+
if (!disposed && !active) show();
|
|
763
952
|
});
|
|
764
953
|
return {
|
|
765
954
|
editor,
|
|
@@ -795,25 +984,26 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
795
984
|
if (!Number.isInteger(index) || !editor.document.slides?.[index])
|
|
796
985
|
throw new RangeError("Slide index is out of range.");
|
|
797
986
|
if (index === slideIndex) {
|
|
798
|
-
if (!active)
|
|
987
|
+
if (!active) renderFor();
|
|
799
988
|
return true;
|
|
800
989
|
}
|
|
801
990
|
if (!commit()) return false;
|
|
802
991
|
richToolbar.hide();
|
|
803
992
|
slideIndex = index;
|
|
804
993
|
selectedPath = null;
|
|
805
|
-
|
|
994
|
+
renderFor();
|
|
806
995
|
return true;
|
|
807
996
|
},
|
|
808
997
|
setRenderOptions(next) {
|
|
809
998
|
if (!commit()) return false;
|
|
810
999
|
richToolbar.hide();
|
|
811
1000
|
renderOptions = next;
|
|
812
|
-
|
|
1001
|
+
renderFor();
|
|
813
1002
|
return true;
|
|
814
1003
|
},
|
|
815
1004
|
destroy() {
|
|
816
1005
|
disposed = true;
|
|
1006
|
+
stopDrag?.();
|
|
817
1007
|
active?.rich?.destroy();
|
|
818
1008
|
richToolbar.destroy();
|
|
819
1009
|
layoutHandles.destroy();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Fonts before pixels. A document can need faces the browser font registry has not loaded yet: script faces for the
|
|
2
|
+
// languages it draws (Japanese, Arabic, ...) and vendored preview faces for the font families it resolves (Intos for
|
|
3
|
+
// Aptos, Open Sans, Barlow, ...). The renderer cannot draw such a document until those faces are loaded (a deck that
|
|
4
|
+
// sets Aptos and contains Japanese fails with "Intos Display cannot display U+65E5"), so nothing may render or measure
|
|
5
|
+
// a new document before `ensure` resolves. This module is the one gate every document-replacing path goes through.
|
|
6
|
+
|
|
7
|
+
/** Error code of a document whose required faces are still loading. */
|
|
8
|
+
export const FONTS_PENDING = "fonts-pending";
|
|
9
|
+
/** Error code of a document whose required faces could not be loaded. */
|
|
10
|
+
export const FONTS_UNAVAILABLE = "fonts-unavailable";
|
|
11
|
+
|
|
12
|
+
// A load that cannot finish (a face the registry never reports as loaded) stops after this many rounds.
|
|
13
|
+
const MAX_ROUNDS = 4;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a browser font registry (`loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser`).
|
|
17
|
+
* Registries without the lazy loaders (renderer 0.10.0 and older) gate nothing and keep the fonts they have.
|
|
18
|
+
*
|
|
19
|
+
* - `pending(document)` lists the faces (script packages and vendored files) the document needs that are not loaded.
|
|
20
|
+
* Synchronous; empty means the document can render now. Never throws: a document the renderer rejects for other
|
|
21
|
+
* reasons reports nothing pending and fails with its own validation message when rendered.
|
|
22
|
+
* - `ensure(document, { signal })` loads vendored faces first (script fallback depends on them), then script faces,
|
|
23
|
+
* and repeats until nothing is pending. It makes a bounded number of rounds and rejects with a `fonts-unavailable`
|
|
24
|
+
* error (the registry's error is its `cause`); it never retries by itself, a later call is a new attempt.
|
|
25
|
+
* - `run(document, handlers)` is `whenFontsReady` on this gate.
|
|
26
|
+
*/
|
|
27
|
+
export function createFontGate(registry) {
|
|
28
|
+
const pending = (document) => {
|
|
29
|
+
try {
|
|
30
|
+
const scripts = registry?.pendingScripts?.(document) ?? [];
|
|
31
|
+
const lazy = (registry?.pendingLazyFonts?.(document) ?? []).map((face) => face?.file ?? String(face));
|
|
32
|
+
return [...lazy, ...scripts];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const gate = {
|
|
38
|
+
pending,
|
|
39
|
+
async ensure(document, options = {}) {
|
|
40
|
+
const { signal } = options;
|
|
41
|
+
try {
|
|
42
|
+
for (let round = 0; round < MAX_ROUNDS && pending(document).length; round++) {
|
|
43
|
+
signal?.throwIfAborted?.();
|
|
44
|
+
await registry.ensureLazyFonts?.(document, { signal });
|
|
45
|
+
await registry.ensureScripts?.(document, { signal });
|
|
46
|
+
}
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
if (signal?.aborted) throw cause;
|
|
49
|
+
throw fontsUnavailable(cause?.message ?? String(cause), cause);
|
|
50
|
+
}
|
|
51
|
+
const left = pending(document);
|
|
52
|
+
if (left.length) throw fontsUnavailable(`${left.join(", ")} did not finish loading.`);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
gate.run = (document, handlers) => whenFontsReady(gate, document, handlers);
|
|
56
|
+
return gate;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fontsUnavailable(message, cause) {
|
|
60
|
+
const error = new Error(`Fonts for this document could not be loaded: ${message}`, cause ? { cause } : undefined);
|
|
61
|
+
error.code = FONTS_UNAVAILABLE;
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The error a synchronous render raises when it is asked to draw a document whose faces are pending. */
|
|
66
|
+
export function fontsPendingError(pending) {
|
|
67
|
+
const error = new Error("Loading fonts for this document…");
|
|
68
|
+
error.code = FONTS_PENDING;
|
|
69
|
+
error.pending = pending;
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ensure fonts for a document, then run `ready`: the one "ensure fonts, then render" helper.
|
|
75
|
+
* With nothing pending, `ready` runs synchronously, so documents in already-loaded fonts render at once. Otherwise
|
|
76
|
+
* `loading(pending)` runs, the faces load and `ready` runs after them. `isCurrent()` is checked after the wait, so a
|
|
77
|
+
* result for a superseded document is dropped. A load failure, and an exception thrown by `ready`, go to `failed(error)`
|
|
78
|
+
* (they are rethrown when there is no `failed`). `gate` is any object with `pending(document)` and `ensure(document)`;
|
|
79
|
+
* without one the document is treated as ready. Resolves once `ready` or `failed` has run.
|
|
80
|
+
*/
|
|
81
|
+
export function whenFontsReady(gate, document, { isCurrent = () => true, loading, ready, failed } = {}) {
|
|
82
|
+
const finish = () => {
|
|
83
|
+
try {
|
|
84
|
+
ready?.();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (!failed) throw error;
|
|
87
|
+
failed(error);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const left = gate?.pending?.(document) ?? [];
|
|
91
|
+
if (!left.length) {
|
|
92
|
+
finish();
|
|
93
|
+
return Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
loading?.(left);
|
|
96
|
+
return Promise.resolve(gate.ensure(document)).then(
|
|
97
|
+
() => {
|
|
98
|
+
if (isCurrent()) finish();
|
|
99
|
+
},
|
|
100
|
+
(error) => {
|
|
101
|
+
if (isCurrent()) {
|
|
102
|
+
if (!failed) throw error;
|
|
103
|
+
failed(error);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
package/dist/rich-text-input.js
CHANGED
|
@@ -2,7 +2,7 @@ import {richTextContent, updateRichTextInput} from './rich-text.js';
|
|
|
2
2
|
import {textInputMap} from './text-input.js';
|
|
3
3
|
|
|
4
4
|
/** Native input owns keyboard/IME; caret and selection use the canonical SVG glyphs. */
|
|
5
|
-
export function createRichTextInput(root, overlay, {path, value, getTarget, onInput, onCommit, onCancel, onFormat, onError}) {
|
|
5
|
+
export function createRichTextInput(root, overlay, {path, value, getTarget, onInput, onCommit, onCancel, onFormat, onError, selectAll=true}) {
|
|
6
6
|
const doc=root.ownerDocument, win=doc.defaultView;
|
|
7
7
|
const input=doc.createElement('textarea'), marks=doc.createElement('div'), actions=doc.createElement('div');
|
|
8
8
|
input.className='opf-rich-input';input.value=richTextContent(value);
|
|
@@ -16,7 +16,7 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
|
|
|
16
16
|
button.onmousedown=event=>event.preventDefault();button.onclick=action;actions.append(button);
|
|
17
17
|
}
|
|
18
18
|
overlay.append(marks,input,actions);
|
|
19
|
-
let current=structuredClone(value), composing=false, change=null, anchor=null, disposed=false, emptyAnchor=null;
|
|
19
|
+
let current=structuredClone(value), composing=false, change=null, anchor=null, disposed=false, emptyAnchor=null, lastDown=null;
|
|
20
20
|
let history=[{value:structuredClone(current),start:0,end:input.value.length}], historyIndex=0, compositionBase=null;
|
|
21
21
|
const currentMap=()=>textInputMap(richTextContent(current));
|
|
22
22
|
function remember() {
|
|
@@ -77,12 +77,29 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
|
|
|
77
77
|
return !best||distance<best.distance?{...p,distance}:best;
|
|
78
78
|
},null)?.offset??0;
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The pointer never reaches the hidden input, so double/triple click (word/paragraph) are counted here.
|
|
81
|
+
function selectUnit(offset,count) {
|
|
82
|
+
const text=input.value,at=Math.max(0,Math.min(text.length,offset));
|
|
83
|
+
let a=at,b=at;
|
|
84
|
+
if(count>=3){a=at>0?text.lastIndexOf('\n',at-1)+1:0;b=text.indexOf('\n',at);if(b<0)b=text.length;}
|
|
85
|
+
else {
|
|
86
|
+
const parts=[...new Intl.Segmenter(undefined,{granularity:'word'}).segment(text)],hit=parts.find(p=>p.index<=at&&at<p.index+p.segment.length)??parts.at(-1);
|
|
87
|
+
if(hit){a=hit.index;b=hit.index+hit.segment.length;}
|
|
88
|
+
}
|
|
89
|
+
input.setSelectionRange(a,b,'forward');
|
|
90
|
+
}
|
|
91
|
+
function pointerDown(event,entry=false) {
|
|
92
|
+
if(!entry&&(event.button!==0||!getTarget(path)?.contains(event.target)))return;
|
|
82
93
|
event.preventDefault();event.stopPropagation();
|
|
83
|
-
const map=currentMap()
|
|
84
|
-
const
|
|
85
|
-
|
|
94
|
+
const map=currentMap(),repeat=!entry&&!event.shiftKey&&lastDown&&event.timeStamp-lastDown.time<500&&Math.hypot(event.clientX-lastDown.x,event.clientY-lastDown.y)<5;
|
|
95
|
+
const count=repeat?lastDown.count+1:1;lastDown={time:event.timeStamp,x:event.clientX,y:event.clientY,count};
|
|
96
|
+
anchor=event.shiftKey&&!entry?map.toSource(input.selectionStart):nearest(event);input.focus({preventScroll:true});
|
|
97
|
+
const end=nearest(event);
|
|
98
|
+
if(count>1){anchor=null;selectUnit(map.toInput(end),count);update();return;}
|
|
99
|
+
input.setSelectionRange(map.toInput(Math.min(anchor,end)),map.toInput(Math.max(anchor,end)),end<anchor?'backward':'forward');
|
|
100
|
+
// A press keeps extending the range while it moves; a completed click (entry from a tap or double-click) does not.
|
|
101
|
+
if(event.buttons&&Number.isInteger(event.pointerId))root.setPointerCapture(event.pointerId);else anchor=null;
|
|
102
|
+
update();
|
|
86
103
|
}
|
|
87
104
|
function pointerMove(event) {
|
|
88
105
|
if(anchor===null)return;event.preventDefault();const end=nearest(event),map=currentMap();
|
|
@@ -114,6 +131,6 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
|
|
|
114
131
|
input.addEventListener('select',update);input.addEventListener('keyup',update);
|
|
115
132
|
input.addEventListener('blur',()=>{if(!disposed&&!composing)onCommit();});
|
|
116
133
|
root.addEventListener('pointerdown',pointerDown,true);root.addEventListener('pointermove',pointerMove);root.addEventListener('pointerup',pointerUp);root.addEventListener('pointercancel',pointerUp);
|
|
117
|
-
input.focus({preventScroll:true});input.select();update();
|
|
118
|
-
return {input,update,get value(){return structuredClone(current);},get composing(){return composing;},destroy(){disposed=true;root.removeEventListener('pointerdown',pointerDown,true);root.removeEventListener('pointermove',pointerMove);root.removeEventListener('pointerup',pointerUp);root.removeEventListener('pointercancel',pointerUp);marks.remove();input.remove();actions.remove();}};
|
|
134
|
+
input.focus({preventScroll:true});if(selectAll)input.select();update();
|
|
135
|
+
return {input,update,pointerStart:event=>pointerDown(event,true),get value(){return structuredClone(current);},get composing(){return composing;},destroy(){disposed=true;root.removeEventListener('pointerdown',pointerDown,true);root.removeEventListener('pointermove',pointerMove);root.removeEventListener('pointerup',pointerUp);root.removeEventListener('pointercancel',pointerUp);marks.remove();input.remove();actions.remove();}};
|
|
119
136
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {textInputMap} from './text-input.js';
|
|
2
|
+
|
|
3
|
+
// Pointer -> caret mapping for the plain-text inline input.
|
|
4
|
+
//
|
|
5
|
+
// The user clicks the canonical SVG glyphs; the native textarea supplies the caret. Offsets are resolved from the SVG
|
|
6
|
+
// text geometry (each rendered line carries its exact source range in `data-opf-source-start/end` or
|
|
7
|
+
// `data-opf-text-start/end`) and converted to textarea offsets with `textInputMap`, so CRLF sources, tabs, collapsed
|
|
8
|
+
// whitespace at wraps and bidi isolate marks all resolve to the input value's offsets, never to a glyph index. A line
|
|
9
|
+
// whose text does not match its traced source range falls back to a hidden mirror of the positioned textarea.
|
|
10
|
+
const SEGMENTER = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter(undefined, {granularity: 'grapheme'}) : null;
|
|
11
|
+
const STRONG_RTL = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/u;
|
|
12
|
+
const STRONG_LTR = /[\p{L}\p{N}]/u;
|
|
13
|
+
const ISOLATE = /[\u2066-\u2069]/u;
|
|
14
|
+
const graphemes = (text) => SEGMENTER ? [...SEGMENTER.segment(text)].map((part) => ({index: part.index, text: part.segment})) : [...text].map((char, i, all) => ({index: all.slice(0, i).join('').length, text: char}));
|
|
15
|
+
|
|
16
|
+
const numeric = (node, ...names) => {
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const value = node.getAttribute(name);
|
|
19
|
+
if (value !== null && value !== '' && Number.isFinite(Number(value))) return Number(value);
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const rangeOf = (node) => {
|
|
25
|
+
const start = numeric(node, 'data-opf-source-start', 'data-opf-text-start'), end = numeric(node, 'data-opf-source-end', 'data-opf-text-end');
|
|
26
|
+
return start === null || end === null || end < start ? null : {node, start, end};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rendered text lines of a target with their source ranges, in document order. A line is a traced `text` element; its
|
|
31
|
+
* segments are the traced `tspan`s inside it (tabs, script or direction runs) or the element itself.
|
|
32
|
+
*/
|
|
33
|
+
function traceLines(target, path) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
for (const node of target.querySelectorAll('text')) {
|
|
36
|
+
const own = node.getAttribute('data-opf-path');
|
|
37
|
+
if (own !== null && own !== path) continue;
|
|
38
|
+
const line = rangeOf(node);
|
|
39
|
+
if (!line) continue;
|
|
40
|
+
const nested = [...node.querySelectorAll('tspan')].map(rangeOf).filter(Boolean);
|
|
41
|
+
lines.push({node, start: line.start, end: line.end, segments: nested.length ? nested : [line]});
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Source offset of every UTF-16 index of a rendered line, or null when the line is not its traced source slice. */
|
|
47
|
+
function alignLine(text, source, start, end) {
|
|
48
|
+
const slice = source.slice(start, end), positions = [];
|
|
49
|
+
let at = 0;
|
|
50
|
+
for (let i = 0; i < text.length; i++) {
|
|
51
|
+
if (slice[at] === text[i]) { positions.push(start + at); at++; }
|
|
52
|
+
else if (ISOLATE.test(text[i])) positions.push(start + at);
|
|
53
|
+
else return null;
|
|
54
|
+
}
|
|
55
|
+
return at === slice.length ? positions : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isRtl(win, node, segment) {
|
|
59
|
+
if (STRONG_RTL.test(segment)) return true;
|
|
60
|
+
if (STRONG_LTR.test(segment)) return false;
|
|
61
|
+
return win.getComputedStyle(node).direction === 'rtl';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Caret boundaries of one traced line: `{offset, x}` in client pixels, offsets in source coordinates. */
|
|
65
|
+
function lineBoundaries(doc, win, line, source) {
|
|
66
|
+
const result = [], range = doc.createRange();
|
|
67
|
+
for (const segment of line.segments) {
|
|
68
|
+
const {node} = segment, text = node.textContent, positions = alignLine(text, source, segment.start, segment.end);
|
|
69
|
+
if (!positions) return null;
|
|
70
|
+
if (!text) {
|
|
71
|
+
const matrix = node.getScreenCTM?.();
|
|
72
|
+
if (!matrix) return null;
|
|
73
|
+
result.push({offset: segment.start, x: new win.DOMPoint(Number(node.getAttribute('x')) || 0, 0).matrixTransform(matrix).x});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const child = node.firstChild;
|
|
77
|
+
if (child?.nodeType !== 3) return null;
|
|
78
|
+
for (const part of graphemes(text)) {
|
|
79
|
+
// Directional isolate marks are zero-width and not source characters; their rects are not caret positions.
|
|
80
|
+
if (ISOLATE.test(part.text)) continue;
|
|
81
|
+
range.setStart(child, part.index);
|
|
82
|
+
range.setEnd(child, part.index + part.text.length);
|
|
83
|
+
const box = range.getBoundingClientRect(), rtl = isRtl(win, node, part.text), after = part.index + part.text.length;
|
|
84
|
+
result.push({offset: positions[part.index], x: rtl ? box.right : box.left});
|
|
85
|
+
result.push({offset: after < positions.length ? positions[after] : segment.end, x: rtl ? box.left : box.right});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return result.length ? result : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Vertical centre of a rendered line in client pixels. */
|
|
92
|
+
function lineCentre(win, line) {
|
|
93
|
+
const box = line.node.getBoundingClientRect();
|
|
94
|
+
if (box.height > 0 || box.width > 0) return box.top + box.height / 2;
|
|
95
|
+
const matrix = line.node.getScreenCTM?.(), size = Number(line.node.getAttribute('font-size')) || 16;
|
|
96
|
+
if (!matrix) return Infinity;
|
|
97
|
+
const point = new win.DOMPoint(0, (Number(line.node.getAttribute('y')) || 0) - size * 0.3).matrixTransform(matrix);
|
|
98
|
+
return point.y;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nearestOffset(points, clientX) {
|
|
102
|
+
let best = null, distance = Infinity;
|
|
103
|
+
for (const point of points) {
|
|
104
|
+
const gap = Math.abs(point.x - clientX);
|
|
105
|
+
if (gap < distance) { best = point; distance = gap; }
|
|
106
|
+
}
|
|
107
|
+
return best?.offset ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function traceOffset({doc, win, target, path, source, clientX, clientY}) {
|
|
111
|
+
const lines = traceLines(target, path);
|
|
112
|
+
if (!lines.length) return null;
|
|
113
|
+
let line = null, gap = Infinity;
|
|
114
|
+
for (const candidate of lines) {
|
|
115
|
+
const distance = Math.abs(lineCentre(win, candidate) - clientY);
|
|
116
|
+
if (distance < gap) { line = candidate; gap = distance; }
|
|
117
|
+
}
|
|
118
|
+
if (!line) return null;
|
|
119
|
+
const points = lineBoundaries(doc, win, line, source);
|
|
120
|
+
return points ? nearestOffset(points, clientX) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Nearest caret offset in a hidden copy of the positioned textarea (line breaks, wrapping and alignment included). */
|
|
124
|
+
function mirrorOffset({doc, win, input, clientX, clientY}) {
|
|
125
|
+
const parent = input.parentNode, host = parent?.getBoundingClientRect?.();
|
|
126
|
+
if (!parent || !host) return null;
|
|
127
|
+
const box = input.getBoundingClientRect(), style = win.getComputedStyle(input), mirror = doc.createElement('div');
|
|
128
|
+
mirror.setAttribute('aria-hidden', 'true');
|
|
129
|
+
mirror.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;margin:0;padding:0;border:0;overflow:hidden;box-sizing:border-box;white-space:pre-wrap;overflow-wrap:break-word;left:${box.left - host.left}px;top:${box.top - host.top}px;width:${box.width}px`;
|
|
130
|
+
for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontStretch', 'fontKerning', 'fontVariantLigatures', 'lineHeight', 'letterSpacing', 'wordSpacing', 'textAlign', 'tabSize', 'direction', 'textTransform', 'textIndent'])
|
|
131
|
+
mirror.style[name] = style[name];
|
|
132
|
+
const value = input.value;
|
|
133
|
+
mirror.textContent = value;
|
|
134
|
+
parent.append(mirror);
|
|
135
|
+
try {
|
|
136
|
+
const child = mirror.firstChild;
|
|
137
|
+
if (!child) return 0;
|
|
138
|
+
const range = doc.createRange(), flat = [], align = style.textAlign;
|
|
139
|
+
const lineStart = align === 'right' || align === 'end' ? box.right : align === 'center' ? (box.left + box.right) / 2 : box.left;
|
|
140
|
+
const pitch = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
|
|
141
|
+
for (const part of graphemes(value)) {
|
|
142
|
+
range.setStart(child, part.index);
|
|
143
|
+
range.setEnd(child, part.index + part.text.length);
|
|
144
|
+
const rect = range.getBoundingClientRect(), rtl = isRtl(win, mirror, part.text), newline = part.text === '\n' || part.text === '\r\n';
|
|
145
|
+
if (!rect.height && !rect.width) continue;
|
|
146
|
+
const cy = rect.top + rect.height / 2;
|
|
147
|
+
flat.push({offset: part.index, x: rtl ? rect.right : rect.left, cy});
|
|
148
|
+
if (!newline) flat.push({offset: part.index + part.text.length, x: rtl ? rect.left : rect.right, cy});
|
|
149
|
+
}
|
|
150
|
+
if (value.endsWith('\n')) {
|
|
151
|
+
const last = flat.findLast((point) => point.offset === value.length - 1);
|
|
152
|
+
flat.push({offset: value.length, x: lineStart, cy: (last?.cy ?? box.top) + pitch});
|
|
153
|
+
}
|
|
154
|
+
let cy = null, gap = Infinity;
|
|
155
|
+
for (const point of flat) {
|
|
156
|
+
const distance = Math.abs(point.cy - clientY);
|
|
157
|
+
if (distance < gap - 0.5) { cy = point.cy; gap = distance; }
|
|
158
|
+
}
|
|
159
|
+
const row = flat.filter((point) => Math.abs(point.cy - cy) <= pitch / 2);
|
|
160
|
+
return nearestOffset(row, clientX);
|
|
161
|
+
} finally {
|
|
162
|
+
mirror.remove();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Textarea offset (LF-normalized, the coordinates `selectionStart` uses) nearest to a client point.
|
|
168
|
+
* `source` is the canonical string the SVG was rendered from; `target` is the traced canvas target of `path`.
|
|
169
|
+
*/
|
|
170
|
+
export function textInputOffsetAtPoint({doc, win, target, path, source, input, clientX, clientY}) {
|
|
171
|
+
const map = textInputMap(source);
|
|
172
|
+
const clamp = (offset) => Math.max(0, Math.min(input.value.length, offset));
|
|
173
|
+
let sourceOffset = null;
|
|
174
|
+
try {
|
|
175
|
+
if (target) sourceOffset = traceOffset({doc, win, target, path, source, clientX, clientY});
|
|
176
|
+
} catch { sourceOffset = null; }
|
|
177
|
+
if (sourceOffset !== null && sourceOffset >= 0 && sourceOffset <= source.length) return clamp(map.toInput(sourceOffset));
|
|
178
|
+
const mirrored = mirrorOffset({doc, win, input, clientX, clientY});
|
|
179
|
+
return clamp(mirrored ?? input.value.length);
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -87,17 +87,18 @@
|
|
|
87
87
|
"sideEffects": false,
|
|
88
88
|
"scripts": {
|
|
89
89
|
"build": "node scripts/build.mjs",
|
|
90
|
-
"typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/presentation-ids.js && node --check src/font-defaults.js && node --check src/color-authoring.js && node --check test/presentation-ids.mjs && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs && node --check examples/pptx-controls.js && node --check scripts/build-playground.mjs && node --check test/playground-pptx.mjs && node --check test/playground-script-fonts.mjs && node --check test/playground-lazy-fonts.mjs && node --check src/text-input.js && node --check src/rich-text.js && node --check src/rich-text-input.js && node --check src/rich-text-toolbar.js && node --check test/rich-text.mjs && node --check test/rich-input-browser.mjs && node --check src/json-editor.js && node --check src/json-menu.js && node --check src/json-options.js && node --check src/json-source.js && node --check src/switches.js && node --check test/switches.mjs && node --check test/switches-export.mjs && node --check test/placeholder-selection.mjs && node --check test/selection-browser.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs && node --check src/exact-source.js && node --check test/exact-source.mjs && node --check test/playground-source.mjs",
|
|
91
|
-
"test": "npm run build && npm run check:font-hotlinks && node test/accepted-text.mjs && node test/smoke.mjs && node test/component-smoke.mjs && node test/canvas-fields.mjs && node test/transfer.mjs && node test/gallery-font-roles.mjs && node test/default-font-scheme.mjs && node test/presentation-ids.mjs && node test/schema.mjs && node test/rich-text.mjs && node test/layout.mjs && node test/blocks.mjs && node test/styled-table.mjs && node test/json-source.mjs && node test/json-options.mjs && node test/switches.mjs && node test/switches-export.mjs && node test/placeholder-selection.mjs && node test/exact-source.mjs",
|
|
90
|
+
"typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/presentation-ids.js && node --check src/font-defaults.js && node --check src/color-authoring.js && node --check test/presentation-ids.mjs && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs && node --check examples/pptx-controls.js && node --check scripts/build-playground.mjs && node --check test/playground-pptx.mjs && node --check test/playground-script-fonts.mjs && node --check test/playground-lazy-fonts.mjs && node --check src/text-input.js && node --check src/rich-text.js && node --check src/rich-text-input.js && node --check src/rich-text-toolbar.js && node --check test/rich-text.mjs && node --check test/rich-input-browser.mjs && node --check src/json-editor.js && node --check src/json-menu.js && node --check src/json-options.js && node --check src/json-source.js && node --check src/switches.js && node --check test/switches.mjs && node --check test/switches-export.mjs && node --check test/placeholder-selection.mjs && node --check test/selection-browser.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs && node --check src/exact-source.js && node --check test/exact-source.mjs && node --check test/playground-source.mjs && node --check src/font-gate.js && node --check test/font-gate.mjs && node --check test/playground-ensure-fonts.mjs && node --check test/canvas-fonts-browser.mjs && node --check src/text-pointer.js && node --check test/click-entry-browser.mjs",
|
|
91
|
+
"test": "npm run build && npm run check:font-hotlinks && node test/accepted-text.mjs && node test/smoke.mjs && node test/component-smoke.mjs && node test/canvas-fields.mjs && node test/transfer.mjs && node test/gallery-font-roles.mjs && node test/default-font-scheme.mjs && node test/presentation-ids.mjs && node test/schema.mjs && node test/rich-text.mjs && node test/layout.mjs && node test/blocks.mjs && node test/styled-table.mjs && node test/json-source.mjs && node test/json-options.mjs && node test/switches.mjs && node test/switches-export.mjs && node test/placeholder-selection.mjs && node test/exact-source.mjs && node test/font-gate.mjs",
|
|
92
92
|
"validate": "node scripts/validate-package.mjs",
|
|
93
93
|
"test:packed": "node test/packed-install.mjs",
|
|
94
94
|
"prepack": "npm run build",
|
|
95
95
|
"check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
|
|
96
96
|
"check:font-hotlinks:built": "node scripts/check-font-hotlinks.mjs --built dist artifacts/playground",
|
|
97
97
|
"build:playground": "node scripts/build-playground.mjs",
|
|
98
|
-
"test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-source.mjs",
|
|
98
|
+
"test:playground": "npm run build:playground && npm run check:font-hotlinks:built && node test/playground-pptx.mjs && node test/playground-script-fonts.mjs artifacts/playground-script-fonts && node test/playground-lazy-fonts.mjs artifacts/playground-lazy-fonts && node test/playground-source.mjs && node test/playground-ensure-fonts.mjs artifacts/playground-ensure-fonts && node test/canvas-fonts-browser.mjs artifacts/canvas-fonts",
|
|
99
99
|
"test:code-browser": "node test/code-browser.mjs",
|
|
100
100
|
"test:selection-browser": "node test/selection-browser.mjs",
|
|
101
|
+
"test:click-entry-browser": "node test/click-entry-browser.mjs",
|
|
101
102
|
"test:metric-browser": "node test/metric-browser.mjs",
|
|
102
103
|
"test:rich-input-browser": "node test/rich-input-browser.mjs",
|
|
103
104
|
"test:json": "npm run build && node test/json-source.mjs && node test/json-options.mjs",
|
|
@@ -156,8 +157,8 @@
|
|
|
156
157
|
"@expo-google-fonts/noto-sans-thai": "0.4.2",
|
|
157
158
|
"@expo-google-fonts/noto-serif-hebrew": "0.4.2",
|
|
158
159
|
"@expo-google-fonts/noto-serif-tibetan": "0.4.2",
|
|
159
|
-
"@openpresentation/opf-pptx": "^0.11.
|
|
160
|
-
"@openpresentation/opf-render": "^0.11.
|
|
160
|
+
"@openpresentation/opf-pptx": "^0.11.1",
|
|
161
|
+
"@openpresentation/opf-render": "^0.11.2",
|
|
161
162
|
"esbuild": "0.28.2",
|
|
162
163
|
"playwright": "1.63.0"
|
|
163
164
|
}
|