@openpresentation/opf-editor 0.10.1 → 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 +19 -4
- package/dist/canvas.d.ts +8 -0
- package/dist/canvas.js +105 -7
- package/dist/rich-text-input.js +26 -9
- package/dist/text-pointer.js +180 -0
- package/package.json +3 -2
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.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
|
|
|
@@ -88,6 +88,21 @@ Note that `session.composeSlide()` and `session.paginateSlide()` measure with th
|
|
|
88
88
|
|
|
89
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.
|
|
90
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
|
+
|
|
91
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.
|
|
92
107
|
|
|
93
108
|
## Runtime Policy
|
|
@@ -259,7 +274,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
|
|
|
259
274
|
|
|
260
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.
|
|
261
276
|
|
|
262
|
-
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.
|
|
263
278
|
|
|
264
279
|
## Local interactive demo
|
|
265
280
|
|
|
@@ -298,7 +313,7 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
|
|
|
298
313
|
|
|
299
314
|
### Rich text on the canvas
|
|
300
315
|
|
|
301
|
-
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.
|
|
302
317
|
|
|
303
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.
|
|
304
319
|
|
|
@@ -322,7 +337,7 @@ Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresen
|
|
|
322
337
|
|
|
323
338
|
## Styled and merged table cells
|
|
324
339
|
|
|
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.
|
|
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.
|
|
326
341
|
|
|
327
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.
|
|
328
343
|
|
package/dist/canvas.d.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface CanvasEditorOptions {
|
|
|
43
43
|
* imports, dimension switches, slide changes, drafts). Without one every document renders at once.
|
|
44
44
|
*/
|
|
45
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";
|
|
46
53
|
/** Optional empty host for property forms; defaults to a floating canvas panel. */
|
|
47
54
|
propertiesContainer?: HTMLElement;
|
|
48
55
|
onSelect?: (selection: {
|
|
@@ -81,6 +88,7 @@ export interface CanvasEditor {
|
|
|
81
88
|
/** Add starter content to a slide/group/region, converting implicit content when needed. */
|
|
82
89
|
openInsertMenu(containerPath?: string,index?: number): void;
|
|
83
90
|
select(path: string): void;
|
|
91
|
+
/** Start editing with all text selected (keyboard entry). Pointer entry places the caret at the pointer instead. */
|
|
84
92
|
beginEdit(path: string): void;
|
|
85
93
|
/** Open structured controls, including rich run fields. */
|
|
86
94
|
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"),
|
|
@@ -283,19 +293,29 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
283
293
|
rect.classList.add("opf-selection");
|
|
284
294
|
node.prepend(rect);
|
|
285
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(); });
|
|
286
299
|
node.addEventListener("click", (event) => {
|
|
287
300
|
event.stopPropagation();
|
|
288
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; }
|
|
289
304
|
if (active && active.path !== path && !commit()) return;
|
|
290
|
-
|
|
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);
|
|
291
309
|
});
|
|
292
310
|
node.addEventListener("dblclick", (event) => {
|
|
293
311
|
event.preventDefault();
|
|
294
312
|
event.stopPropagation();
|
|
295
|
-
|
|
313
|
+
const kind = editKind(path);
|
|
314
|
+
startEdit(path, kind === "text" || kind === "rich-text" ? { point: event } : undefined);
|
|
296
315
|
});
|
|
297
316
|
node.addEventListener("keydown", (event) => {
|
|
298
|
-
|
|
317
|
+
// Keyboard entry replaces: all text is selected.
|
|
318
|
+
if (event.key === "Enter" || event.key === " " || event.key === "F2") {
|
|
299
319
|
event.preventDefault();
|
|
300
320
|
event.stopPropagation();
|
|
301
321
|
beginEdit(path);
|
|
@@ -434,7 +454,78 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
434
454
|
// Other text keeps the canonical SVG visible beneath the transparent input.
|
|
435
455
|
target.style.opacity = visibleText ? "0" : "1";
|
|
436
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
|
+
}
|
|
437
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) {
|
|
438
529
|
if (disposed) return;
|
|
439
530
|
if (active && active.path === path) return;
|
|
440
531
|
if (active && !commit()) return;
|
|
@@ -535,12 +626,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
535
626
|
input.addEventListener("blur", () => {
|
|
536
627
|
if (active?.input === input && !active.composing) commit();
|
|
537
628
|
});
|
|
538
|
-
input.focus();
|
|
539
|
-
|
|
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();
|
|
540
635
|
} else if (Array.isArray(value) && target?.hasAttribute("data-opf-rich-text")) {
|
|
541
636
|
active = {kind:"rich-text",path,base:JSON.stringify(value),valid:true};
|
|
542
637
|
active.rich = createRichTextInput(root, overlay, {
|
|
543
|
-
path, value, getTarget,
|
|
638
|
+
path, value, getTarget, selectAll: !entry?.point,
|
|
544
639
|
onInput: queueDraft, onCommit: commit, onCancel: cancel, onError: report,
|
|
545
640
|
onFormat(start,end) {
|
|
546
641
|
if (!commit()) return;
|
|
@@ -548,6 +643,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
548
643
|
},
|
|
549
644
|
});
|
|
550
645
|
active.input = active.rich.input;
|
|
646
|
+
if (entry?.point) active.rich.pointerStart(entry.point);
|
|
551
647
|
} else openProperties(path, value);
|
|
552
648
|
}
|
|
553
649
|
function propertyPatches(edit) {
|
|
@@ -828,6 +924,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
828
924
|
active?.rich?.update();
|
|
829
925
|
});
|
|
830
926
|
resize.observe(root);
|
|
927
|
+
root.addEventListener("pointerdown", () => { pointerTaken = null; }, true);
|
|
831
928
|
root.addEventListener("keydown", (event) => {
|
|
832
929
|
if (
|
|
833
930
|
(event.metaKey || event.ctrlKey) &&
|
|
@@ -906,6 +1003,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
906
1003
|
},
|
|
907
1004
|
destroy() {
|
|
908
1005
|
disposed = true;
|
|
1006
|
+
stopDrag?.();
|
|
909
1007
|
active?.rich?.destroy();
|
|
910
1008
|
richToolbar.destroy();
|
|
911
1009
|
layoutHandles.destroy();
|
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,7 +87,7 @@
|
|
|
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",
|
|
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
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",
|
|
@@ -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",
|