@openpresentation/opf-editor 0.10.1 → 0.10.3

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 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.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.
5
+ Version 0.10.3 passes the host's render options (`catalogs`) through the font gate to the registry, so layouts and font schemes that only the host's catalogs know load their faces, and asks the script and vendored-face loaders separately; it keeps the 0.10.2 requirements and gates nothing new on renderers before 0.11.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
 
@@ -80,6 +80,9 @@ Fonts load before pixels (FF-41). A document can need faces the browser registry
80
80
  import { createCanvasEditor, createFontGate } from '@openpresentation/opf-editor/canvas';
81
81
 
82
82
  const fonts = createFontGate(fontRegistry); // a registry without the lazy loaders gates nothing
83
+ // The canvas gives the gate its current renderOptions (catalogs, ...), so layouts and font schemes only your catalogs know
84
+ // resolve in the registry too (renderer 0.11.5+). For your own gate calls: fonts.pending(document, renderOptions), or
85
+ // createFontGate(fontRegistry, { renderOptions: () => currentRenderOptions }).
83
86
  const canvas = createCanvasEditor(container, { document, fonts, renderOptions: { textMeasurement: fontRegistry.textMeasurement } });
84
87
  // Other renders of your own: fonts.run(document, { ready: draw, failed: showError, loading: showSpinner })
85
88
  ```
@@ -88,6 +91,21 @@ Note that `session.composeSlide()` and `session.paginateSlide()` measure with th
88
91
 
89
92
  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.
90
93
 
94
+ ### Entering text editing
95
+
96
+ 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.
97
+
98
+ The gesture is configurable:
99
+
100
+ ```js
101
+ createCanvasEditor(container, { document, textEntry: 'click' }); // default: one click enters
102
+ createCanvasEditor(container, { document, textEntry: 'dblclick' }); // one click selects, a double-click enters
103
+ ```
104
+
105
+ `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.
106
+
107
+ 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.
108
+
91
109
  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.
92
110
 
93
111
  ## Runtime Policy
@@ -259,7 +277,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
259
277
 
260
278
  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.
261
279
 
262
- Version 0.10.1 requires `@openpresentation/opf@^0.11.2`. 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.
280
+ Version 0.10.3 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.5 (face-level lazy fonts; the gate passes render options, which older renderers ignore) 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.
263
281
 
264
282
  ## Local interactive demo
265
283
 
@@ -298,7 +316,7 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
298
316
 
299
317
  ### Rich text on the canvas
300
318
 
301
- Select rendered rich text to format it, or double-click a rich text block to type. 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: double-click to type, drag to select, 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.
319
+ 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.
302
320
 
303
321
  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.
304
322
 
@@ -322,7 +340,7 @@ Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresen
322
340
 
323
341
  ## Styled and merged table cells
324
342
 
325
- 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. Double-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.
343
+ 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.
326
344
 
327
345
  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.
328
346
 
package/dist/canvas.d.ts CHANGED
@@ -3,10 +3,10 @@ import type { RenderSvgOptions } from "@openpresentation/opf-render";
3
3
  import type { SlideComposition } from "@openpresentation/opf/composition";
4
4
  /** Faces a document needs (script packages and vendored files) that are not loaded yet, and the way to load them. */
5
5
  export interface FontGate {
6
- /** Synchronous. Empty means the document can render now. Never throws. */
7
- pending(document: unknown): string[];
6
+ /** Synchronous. Empty means the document can render now. Never throws. `renderOptions` are the options the host renders with (`catalogs`, ...); the canvas passes its current ones. */
7
+ pending(document: unknown, renderOptions?: RenderSvgOptions): string[];
8
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>;
9
+ ensure(document: unknown, options?: { signal?: AbortSignal; renderOptions?: RenderSvgOptions }): Promise<void>;
10
10
  /** `whenFontsReady` on this gate. */
11
11
  run(document: unknown, handlers?: FontGateHandlers): Promise<void>;
12
12
  }
@@ -16,11 +16,17 @@ export interface FontGateHandlers {
16
16
  loading?: (pending: string[]) => void;
17
17
  ready?: () => void;
18
18
  failed?: (error: Error) => void;
19
+ /** The options the host renders with (`catalogs`, ...), passed to the gate. */
20
+ renderOptions?: RenderSvgOptions;
19
21
  }
20
22
  export declare const FONTS_PENDING: "fonts-pending";
21
23
  export declare const FONTS_UNAVAILABLE: "fonts-unavailable";
22
24
  /** Wrap a browser font registry (`loadBrowserFontRegistry`). Registries without the lazy loaders gate nothing. */
23
- export declare function createFontGate(registry: object): FontGate;
25
+ /**
26
+ * `options.renderOptions` (an object, or a function called on every use) are the default render options passed to the registry
27
+ * (`catalogs`, ...), under a call's own; the canvas passes its current `renderOptions` itself, so a gate needs none.
28
+ */
29
+ export declare function createFontGate(registry: object, options?: { renderOptions?: RenderSvgOptions | (() => RenderSvgOptions | undefined) }): FontGate;
24
30
  /**
25
31
  * Ensure fonts for a document, then run `ready` (synchronously when nothing is pending). The one
26
32
  * "ensure fonts, then render" helper: load failures and exceptions from `ready` go to `failed`.
@@ -43,6 +49,13 @@ export interface CanvasEditorOptions {
43
49
  * imports, dimension switches, slide changes, drafts). Without one every document renders at once.
44
50
  */
45
51
  fonts?: Pick<FontGate, "pending" | "ensure">;
52
+ /**
53
+ * How a pointer enters text editing. "click" (default): a single press on editable text starts editing with the caret at the
54
+ * pressed character, and a press-drag selects a range. "dblclick": a click selects, a double-click enters with the caret at the
55
+ * pointer. Keyboard entry (Enter, Space, F2) and `beginEdit(path)` always select all text. Non-text targets always open
56
+ * their properties on double-click.
57
+ */
58
+ textEntry?: "click" | "dblclick";
46
59
  /** Optional empty host for property forms; defaults to a floating canvas panel. */
47
60
  propertiesContainer?: HTMLElement;
48
61
  onSelect?: (selection: {
@@ -81,6 +94,7 @@ export interface CanvasEditor {
81
94
  /** Add starter content to a slide/group/region, converting implicit content when needed. */
82
95
  openInsertMenu(containerPath?: string,index?: number): void;
83
96
  select(path: string): void;
97
+ /** Start editing with all text selected (keyboard entry). Pointer entry places the caret at the pointer instead. */
84
98
  beginEdit(path: string): void;
85
99
  /** Open structured controls, including rich run fields. */
86
100
  editProperties(path: string): boolean;
package/dist/canvas.js CHANGED
@@ -18,6 +18,7 @@ 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";
22
23
  import { FONTS_PENDING, fontsPendingError, whenFontsReady } from "./font-gate.js";
23
24
  export { getEditableFields } from "./canvas-fields.js";
@@ -75,7 +76,16 @@ export function createCanvasEditor(container, options = {}) {
75
76
  selectedPath = null,
76
77
  disposed = false,
77
78
  frame = 0,
78
- 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".');
79
89
  const root = doc.createElement("div"),
80
90
  preview = doc.createElement("div"),
81
91
  overlay = doc.createElement("div"),
@@ -153,11 +163,11 @@ export function createCanvasEditor(container, options = {}) {
153
163
  // A synchronous render or validation of a document whose faces are still loading fails with a clear "fonts-pending"
154
164
  // error and starts the load, so the next attempt succeeds. It never draws glyphs the registry cannot provide.
155
165
  function requireFonts(document) {
156
- const pending = fonts?.pending(document) ?? [];
166
+ const pending = fonts?.pending(document, renderOptions) ?? [];
157
167
  if (!pending.length) return;
158
168
  notice.hidden = false;
159
169
  notice.textContent = pendingMessage;
160
- fonts.ensure(document).then(
170
+ fonts.ensure(document, { renderOptions }).then(
161
171
  () => { if (!disposed && notice.textContent === pendingMessage) clearNotice(); },
162
172
  (error) => { if (!disposed) report(error); },
163
173
  );
@@ -196,6 +206,7 @@ export function createCanvasEditor(container, options = {}) {
196
206
  if (disposed) return;
197
207
  const token = ++showToken;
198
208
  return whenFontsReady(fonts, target ?? editor.document, {
209
+ renderOptions,
199
210
  isCurrent: () => !disposed && token === showToken,
200
211
  loading: (pending) => fontsState("loading", pending),
201
212
  ready: () => {
@@ -211,7 +222,7 @@ export function createCanvasEditor(container, options = {}) {
211
222
  }
212
223
  // Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
213
224
  function renderFor(document) {
214
- if (fonts?.pending(document ?? editor.document).length) show(document);
225
+ if (fonts?.pending(document ?? editor.document, renderOptions).length) show(document);
215
226
  else render(document);
216
227
  }
217
228
  function render(document = editor.document) {
@@ -283,19 +294,29 @@ export function createCanvasEditor(container, options = {}) {
283
294
  rect.classList.add("opf-selection");
284
295
  node.prepend(rect);
285
296
  }
297
+ node.addEventListener("pointerdown", (event) => pointerDown(event, path));
298
+ // Some browsers still run mousedown's focus and text-selection defaults after a handled pointerdown.
299
+ node.addEventListener("mousedown", (event) => { if (pointerTaken === path) event.preventDefault(); });
286
300
  node.addEventListener("click", (event) => {
287
301
  event.stopPropagation();
288
302
  if (event.target.closest("a")) event.preventDefault();
303
+ // A mouse or pen press already entered editing on pointerdown; this click only completes that gesture.
304
+ if (pointerTaken === path) { pointerTaken = null; return; }
289
305
  if (active && active.path !== path && !commit()) return;
290
- choose(path);
306
+ // A touch tap has no pointerdown entry (a touch press may start a scroll): it enters on the click itself.
307
+ const kind = editKind(path);
308
+ if (textEntry === "click" && event.detail > 0 && (kind === "text" || kind === "rich-text")) startEdit(path, { point: event });
309
+ else choose(path);
291
310
  });
292
311
  node.addEventListener("dblclick", (event) => {
293
312
  event.preventDefault();
294
313
  event.stopPropagation();
295
- beginEdit(path);
314
+ const kind = editKind(path);
315
+ startEdit(path, kind === "text" || kind === "rich-text" ? { point: event } : undefined);
296
316
  });
297
317
  node.addEventListener("keydown", (event) => {
298
- if (event.key === "Enter" || event.key === " ") {
318
+ // Keyboard entry replaces: all text is selected.
319
+ if (event.key === "Enter" || event.key === " " || event.key === "F2") {
299
320
  event.preventDefault();
300
321
  event.stopPropagation();
301
322
  beginEdit(path);
@@ -321,11 +342,11 @@ export function createCanvasEditor(container, options = {}) {
321
342
  }
322
343
  // An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
323
344
  function deferDraft(draft) {
324
- if (!fonts?.pending(draft).length) return false;
345
+ if (!fonts?.pending(draft, renderOptions).length) return false;
325
346
  active.valid = false;
326
347
  notice.hidden = false;
327
348
  notice.textContent = pendingMessage;
328
- fonts.ensure(draft).then(
349
+ fonts.ensure(draft, { renderOptions }).then(
329
350
  () => {
330
351
  if (disposed) return;
331
352
  if (notice.textContent === pendingMessage) clearNotice();
@@ -434,7 +455,78 @@ export function createCanvasEditor(container, options = {}) {
434
455
  // Other text keeps the canonical SVG visible beneath the transparent input.
435
456
  target.style.opacity = visibleText ? "0" : "1";
436
457
  }
458
+ // What editing a target opens: inline text ("text"), the rich-text input ("rich-text"), or the properties form.
459
+ function editKind(path) {
460
+ const value = editor.get(path), target = getTarget(path);
461
+ if (value === undefined || !target) return null;
462
+ if ((typeof value === "string" || typeof value === "number") && target.querySelector("text") && !/\.(image|video)$/.test(path))
463
+ return "text";
464
+ if (Array.isArray(value) && target.hasAttribute("data-opf-rich-text")) return "rich-text";
465
+ return "properties";
466
+ }
467
+ // Textarea offset nearest to a client point, from the traced SVG glyphs (see text-pointer.js).
468
+ function textOffsetAt(edit, clientX, clientY) {
469
+ let source = edit.input.value;
470
+ try {
471
+ if (edit.type === "string") source = parseCanvasValue(edit.input.value, "string", edit.originalValue);
472
+ } catch { /* fall back to the raw input text */ }
473
+ return textInputOffsetAtPoint({
474
+ doc, win, target: getTarget(edit.path), path: edit.path, source, input: edit.input, clientX, clientY,
475
+ });
476
+ }
477
+ function setRange(input, anchor, focus) {
478
+ input.setSelectionRange(Math.min(anchor, focus), Math.max(anchor, focus), focus < anchor ? "backward" : "forward");
479
+ }
480
+ // Press-drag in plain text selects from the press point to the pointer, like PowerPoint. The textarea is not under the
481
+ // pointer when the press lands on the SVG glyphs, so the range is tracked here.
482
+ function trackDrag(edit, anchor, event) {
483
+ stopDrag?.();
484
+ const id = event.pointerId;
485
+ const move = (next) => {
486
+ if (next.pointerId !== id) return;
487
+ if (active !== edit || !next.buttons) return stop();
488
+ setRange(edit.input, anchor, textOffsetAt(edit, next.clientX, next.clientY));
489
+ };
490
+ const end = (next) => { if (next.pointerId === id) stop(); };
491
+ const stop = () => {
492
+ win.removeEventListener("pointermove", move);
493
+ win.removeEventListener("pointerup", end);
494
+ win.removeEventListener("pointercancel", end);
495
+ if (stopDrag === stop) stopDrag = null;
496
+ if (active === edit) edit.input.focus({ preventScroll: true });
497
+ };
498
+ win.addEventListener("pointermove", move);
499
+ win.addEventListener("pointerup", end);
500
+ win.addEventListener("pointercancel", end);
501
+ stopDrag = stop;
502
+ }
503
+ // A mouse or pen press on editable text enters editing at that character (touch enters from its click instead).
504
+ function pointerDown(event, path) {
505
+ if (handledPointer === event) return;
506
+ handledPointer = event;
507
+ if (textEntry !== "click" || disposed || event.button !== 0 || event.pointerType === "touch") return;
508
+ const edit = active?.path === path ? active : null;
509
+ if (edit ? edit.kind !== "text" : !["text", "rich-text"].includes(editKind(path))) return;
510
+ // Own the gesture: no native focus change, text selection or drag while the caret is placed.
511
+ event.preventDefault();
512
+ event.stopPropagation();
513
+ pointerTaken = path;
514
+ if (edit) {
515
+ const at = textOffsetAt(edit, event.clientX, event.clientY);
516
+ setRange(edit.input, at, at);
517
+ edit.input.focus({ preventScroll: true });
518
+ trackDrag(edit, at, event);
519
+ return;
520
+ }
521
+ if (active && !commit()) return;
522
+ startEdit(path, { point: event, drag: true });
523
+ }
437
524
  function beginEdit(path) {
525
+ startEdit(path);
526
+ }
527
+ // entry: undefined selects all text (keyboard and programmatic entry); {point} places the caret at a client point;
528
+ // {point, drag} also selects while the pressed pointer moves.
529
+ function startEdit(path, entry) {
438
530
  if (disposed) return;
439
531
  if (active && active.path === path) return;
440
532
  if (active && !commit()) return;
@@ -535,12 +627,16 @@ export function createCanvasEditor(container, options = {}) {
535
627
  input.addEventListener("blur", () => {
536
628
  if (active?.input === input && !active.composing) commit();
537
629
  });
538
- input.focus();
539
- input.select();
630
+ input.focus({ preventScroll: !!entry?.point });
631
+ if (entry?.point) {
632
+ const at = textOffsetAt(active, entry.point.clientX, entry.point.clientY);
633
+ setRange(input, at, at);
634
+ if (entry.drag) trackDrag(active, at, entry.point);
635
+ } else input.select();
540
636
  } else if (Array.isArray(value) && target?.hasAttribute("data-opf-rich-text")) {
541
637
  active = {kind:"rich-text",path,base:JSON.stringify(value),valid:true};
542
638
  active.rich = createRichTextInput(root, overlay, {
543
- path, value, getTarget,
639
+ path, value, getTarget, selectAll: !entry?.point,
544
640
  onInput: queueDraft, onCommit: commit, onCancel: cancel, onError: report,
545
641
  onFormat(start,end) {
546
642
  if (!commit()) return;
@@ -548,6 +644,7 @@ export function createCanvasEditor(container, options = {}) {
548
644
  },
549
645
  });
550
646
  active.input = active.rich.input;
647
+ if (entry?.point) active.rich.pointerStart(entry.point);
551
648
  } else openProperties(path, value);
552
649
  }
553
650
  function propertyPatches(edit) {
@@ -828,6 +925,7 @@ export function createCanvasEditor(container, options = {}) {
828
925
  active?.rich?.update();
829
926
  });
830
927
  resize.observe(root);
928
+ root.addEventListener("pointerdown", () => { pointerTaken = null; }, true);
831
929
  root.addEventListener("keydown", (event) => {
832
930
  if (
833
931
  (event.metaKey || event.ctrlKey) &&
@@ -906,6 +1004,7 @@ export function createCanvasEditor(container, options = {}) {
906
1004
  },
907
1005
  destroy() {
908
1006
  disposed = true;
1007
+ stopDrag?.();
909
1008
  active?.rich?.destroy();
910
1009
  richToolbar.destroy();
911
1010
  layoutHandles.destroy();
package/dist/font-gate.js CHANGED
@@ -16,39 +16,56 @@ const MAX_ROUNDS = 4;
16
16
  * Wrap a browser font registry (`loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser`).
17
17
  * Registries without the lazy loaders (renderer 0.10.0 and older) gate nothing and keep the fonts they have.
18
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,
19
+ * - `pending(document, renderOptions?)` lists the faces (script packages and vendored files) the document needs that are
20
+ * not loaded. Synchronous; empty means the document can render now. Never throws: a document the renderer rejects for
21
+ * other reasons reports nothing pending and fails with its own validation message when rendered. Script faces and
22
+ * vendored faces are asked separately, so one that cannot be answered does not hide the other.
23
+ * - `ensure(document, { signal, renderOptions })` loads vendored faces first (script fallback depends on them), then script faces,
23
24
  * and repeats until nothing is pending. It makes a bounded number of rounds and rejects with a `fonts-unavailable`
24
25
  * error (the registry's error is its `cause`); it never retries by itself, a later call is a new attempt.
25
26
  * - `run(document, handlers)` is `whenFontsReady` on this gate.
27
+ * - `renderOptions` (per call, or `createFontGate(registry, { renderOptions })` as an object or a getter) are the options the
28
+ * host renders with (`catalogs`, ...). They are passed to the registry, so a layout or font scheme that only the host's
29
+ * catalogs know resolves the same way there (renderer 0.11.5 and newer; older renderers ignore them).
26
30
  */
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
- }
31
+ export function createFontGate(registry, gateOptions = {}) {
32
+ // FF-41: the render options (`catalogs` above all) the document resolves with. The registry resolves a document the way the
33
+ // host renders it only when it gets the same options, so every call passes them: the gate's defaults (an object, or a
34
+ // function called each time, for options that change like `canvas.setRenderOptions`) under the call's own.
35
+ const defaults = () => (typeof gateOptions.renderOptions === "function" ? gateOptions.renderOptions() : gateOptions.renderOptions) ?? {};
36
+ const optionsFor = (renderOptions) => ({ ...defaults(), ...renderOptions });
37
+ // Each source answers on its own: a document one loader cannot resolve (an unresolvable layout throws in the renderer's
38
+ // lazy font loader) must not hide what the other still reports pending.
39
+ const sources = (document, renderOptions) => {
40
+ const options = optionsFor(renderOptions);
41
+ let scripts = [], lazy = [];
42
+ try { scripts = registry?.pendingScripts?.(document, options) ?? []; } catch { /* the renderer reports the document itself when it renders */ }
43
+ try { lazy = (registry?.pendingLazyFonts?.(document, options) ?? []).map((face) => face?.file ?? String(face)); } catch { /* likewise */ }
44
+ return { lazy, scripts };
45
+ };
46
+ const pending = (document, renderOptions) => {
47
+ const { lazy, scripts } = sources(document, renderOptions);
48
+ return [...lazy, ...scripts];
36
49
  };
37
50
  const gate = {
38
51
  pending,
39
52
  async ensure(document, options = {}) {
40
- const { signal } = options;
53
+ const { signal, renderOptions } = options;
54
+ const call = { ...optionsFor(renderOptions), signal };
41
55
  try {
42
- for (let round = 0; round < MAX_ROUNDS && pending(document).length; round++) {
56
+ for (let round = 0; round < MAX_ROUNDS; round++) {
57
+ const { lazy, scripts } = sources(document, renderOptions);
58
+ if (!lazy.length && !scripts.length) break;
43
59
  signal?.throwIfAborted?.();
44
- await registry.ensureLazyFonts?.(document, { signal });
45
- await registry.ensureScripts?.(document, { signal });
60
+ // Vendored faces first (script fallback depends on them). A source with nothing pending is not asked to load.
61
+ if (lazy.length) await registry.ensureLazyFonts?.(document, call);
62
+ if (scripts.length) await registry.ensureScripts?.(document, call);
46
63
  }
47
64
  } catch (cause) {
48
65
  if (signal?.aborted) throw cause;
49
66
  throw fontsUnavailable(cause?.message ?? String(cause), cause);
50
67
  }
51
- const left = pending(document);
68
+ const left = pending(document, renderOptions);
52
69
  if (left.length) throw fontsUnavailable(`${left.join(", ")} did not finish loading.`);
53
70
  },
54
71
  };
@@ -75,10 +92,11 @@ export function fontsPendingError(pending) {
75
92
  * With nothing pending, `ready` runs synchronously, so documents in already-loaded fonts render at once. Otherwise
76
93
  * `loading(pending)` runs, the faces load and `ready` runs after them. `isCurrent()` is checked after the wait, so a
77
94
  * 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)`;
95
+ * (they are rethrown when there is no `failed`). `renderOptions` are passed to the gate. `gate` is any object with
96
+ * `pending(document, renderOptions)` and `ensure(document, { renderOptions })`;
79
97
  * without one the document is treated as ready. Resolves once `ready` or `failed` has run.
80
98
  */
81
- export function whenFontsReady(gate, document, { isCurrent = () => true, loading, ready, failed } = {}) {
99
+ export function whenFontsReady(gate, document, { isCurrent = () => true, loading, ready, failed, renderOptions } = {}) {
82
100
  const finish = () => {
83
101
  try {
84
102
  ready?.();
@@ -87,13 +105,13 @@ export function whenFontsReady(gate, document, { isCurrent = () => true, loading
87
105
  failed(error);
88
106
  }
89
107
  };
90
- const left = gate?.pending?.(document) ?? [];
108
+ const left = gate?.pending?.(document, renderOptions) ?? [];
91
109
  if (!left.length) {
92
110
  finish();
93
111
  return Promise.resolve();
94
112
  }
95
113
  loading?.(left);
96
- return Promise.resolve(gate.ensure(document)).then(
114
+ return Promise.resolve(gate.ensure(document, { renderOptions })).then(
97
115
  () => {
98
116
  if (isCurrent()) finish();
99
117
  },
@@ -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
- function pointerDown(event) {
81
- if(event.button!==0||!getTarget(path)?.contains(event.target))return;
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();anchor=event.shiftKey?map.toSource(input.selectionStart):nearest(event);input.focus({preventScroll:true});
84
- const end=nearest(event);input.setSelectionRange(map.toInput(Math.min(anchor,end)),map.toInput(Math.max(anchor,end)),end<anchor?'backward':'forward');
85
- root.setPointerCapture(event.pointerId);update();
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.1",
3
+ "version": "0.10.3",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -87,8 +87,8 @@
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 && 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",
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",
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/font-gate-registry.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 && node test/font-gate-registry.mjs",
92
92
  "validate": "node scripts/validate-package.mjs",
93
93
  "test:packed": "node test/packed-install.mjs",
94
94
  "prepack": "npm run build",
@@ -98,6 +98,7 @@
98
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",
@@ -157,7 +158,7 @@
157
158
  "@expo-google-fonts/noto-serif-hebrew": "0.4.2",
158
159
  "@expo-google-fonts/noto-serif-tibetan": "0.4.2",
159
160
  "@openpresentation/opf-pptx": "^0.11.1",
160
- "@openpresentation/opf-render": "^0.11.2",
161
+ "@openpresentation/opf-render": "^0.11.5",
161
162
  "esbuild": "0.28.2",
162
163
  "playwright": "1.63.0"
163
164
  }