@openpresentation/opf-editor 0.1.0 → 0.1.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 +2 -2
- package/dist/canvas.js +30 -11
- package/dist/rich-text-input.js +117 -0
- package/dist/rich-text-toolbar.js +7 -2
- package/dist/rich-text.d.ts +2 -0
- package/dist/rich-text.js +42 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Load the same font bytes into the browser using `loadBrowserFontRegistry` from `
|
|
|
34
34
|
|
|
35
35
|
These APIs are included in version 0.1.0 and require core 0.4.0 and renderer 0.1.0 for the canvas. 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.
|
|
36
36
|
|
|
37
|
-
The canvas retains canonical SVG glyphs while a transparent native input supplies the caret.
|
|
37
|
+
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.
|
|
38
38
|
|
|
39
39
|
## Runtime Policy
|
|
40
40
|
|
|
@@ -207,7 +207,7 @@ Slide insertion does not merge root speakers, organizations, or narrative metada
|
|
|
207
207
|
|
|
208
208
|
### Rich text on the canvas
|
|
209
209
|
|
|
210
|
-
Select rendered rich text to format it, or double-click a rich text block to
|
|
210
|
+
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, 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.
|
|
211
211
|
|
|
212
212
|
Headless applications and agents can import `formatRichTextRange`, `replaceRichTextRange`, and `richTextContent` from `@openpresentation/opf-editor/rich-text`. The helpers preserve surrounding run metadata and accept UTF-16 offsets on whole grapheme boundaries. A null format value removes an override. Apply the returned value through the editor session or validate the resulting document before saving.
|
|
213
213
|
|
package/dist/canvas.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./canvas-fields.js";
|
|
18
18
|
import { createBlockControls } from "./block-controls.js";
|
|
19
19
|
import { createLayoutHandles } from "./layout-handles.js";
|
|
20
|
+
import { createRichTextInput } from "./rich-text-input.js";
|
|
20
21
|
import { createRichTextToolbar } from "./rich-text-toolbar.js";
|
|
21
22
|
export { getEditableFields } from "./canvas-fields.js";
|
|
22
23
|
|
|
@@ -64,6 +65,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
64
65
|
notice.textContent = "";
|
|
65
66
|
};
|
|
66
67
|
const richToolbar = createRichTextToolbar(root, { editor, report, beforeChange: commit,
|
|
68
|
+
onTyping: beginEdit,
|
|
67
69
|
onProperties(path) { if (commit()) openProperties(path, editor.get(path)); },
|
|
68
70
|
validateChange(path, value) {
|
|
69
71
|
renderSvg(createCanvasDraft(editor.document, path, value), {...renderOptions, slideIndex});
|
|
@@ -142,7 +144,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
142
144
|
// Prefer the containing group over its duplicate text/shape trace attributes.
|
|
143
145
|
if (
|
|
144
146
|
!node.matches("g,image") ||
|
|
145
|
-
(node.matches("g") && !node.querySelector("text,image,rect,path"))
|
|
147
|
+
(node.matches("g") && !node.querySelector("text,image,rect,path") && !node.hasAttribute("data-opf-rich-text"))
|
|
146
148
|
)
|
|
147
149
|
continue;
|
|
148
150
|
seen.add(path);
|
|
@@ -154,8 +156,10 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
154
156
|
`Edit ${path.split(".").at(-1)}: ${["string", "number"].includes(typeof value) ? String(value).slice(0, 80) : "content properties"}`,
|
|
155
157
|
);
|
|
156
158
|
if (node.matches("g")) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
let bounds = node.getBBox();
|
|
160
|
+
const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
|
|
161
|
+
if (!bounds.width && !bounds.height && lines.length) bounds = {x:lines[0].x,y:lines[0].y,width:Number(node.getAttribute("data-opf-box-width"))||8,height:lines.reduce((sum,line)=>sum+line.height,0)};
|
|
162
|
+
const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
159
163
|
for (const [key, value] of Object.entries({
|
|
160
164
|
x: bounds.x - 4,
|
|
161
165
|
y: bounds.y - 4,
|
|
@@ -194,6 +198,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
194
198
|
layoutHandles.update(document, geometry);
|
|
195
199
|
blockControls.update(document, geometry);
|
|
196
200
|
if (active?.kind === "text") positionInput();
|
|
201
|
+
if (active?.kind === "rich-text") active.rich?.update();
|
|
197
202
|
options.onRender?.({
|
|
198
203
|
document,
|
|
199
204
|
slideIndex,
|
|
@@ -223,7 +228,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
223
228
|
return;
|
|
224
229
|
}
|
|
225
230
|
try {
|
|
226
|
-
const value = parseCanvasValue(active.input.value, active.type);
|
|
231
|
+
const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type);
|
|
227
232
|
const draft = createCanvasDraft(editor.document, active.path, value);
|
|
228
233
|
clearNotice();
|
|
229
234
|
active.valid = true;
|
|
@@ -376,7 +381,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
376
381
|
input.focus();
|
|
377
382
|
input.select();
|
|
378
383
|
} else if (Array.isArray(value) && target?.hasAttribute("data-opf-rich-text")) {
|
|
379
|
-
|
|
384
|
+
active = {kind:"rich-text",path,base:JSON.stringify(value),valid:true};
|
|
385
|
+
active.rich = createRichTextInput(root, overlay, {
|
|
386
|
+
path, value, getTarget,
|
|
387
|
+
onInput: queueDraft, onCommit: commit, onCancel: cancel, onError: report,
|
|
388
|
+
onFormat(start,end) {
|
|
389
|
+
if (!commit()) return;
|
|
390
|
+
if(start===end)richToolbar.selectAll(path);else richToolbar.selectRange(path,start,end);
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
active.input = active.rich.input;
|
|
380
394
|
} else openProperties(path, value);
|
|
381
395
|
}
|
|
382
396
|
function propertyPatches(edit) {
|
|
@@ -565,7 +579,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
565
579
|
if (blockControls.editingPath) return false;
|
|
566
580
|
if (!committing && layoutHandles.editingPath) return layoutHandles.commit();
|
|
567
581
|
if (committing || !active) return true;
|
|
568
|
-
if (active.composing) return false;
|
|
582
|
+
if (active.composing || active.rich?.composing) return false;
|
|
569
583
|
if (JSON.stringify(editor.get(active.path)) !== active.base) {
|
|
570
584
|
report(
|
|
571
585
|
new Error(
|
|
@@ -578,13 +592,14 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
578
592
|
const edit = active;
|
|
579
593
|
try {
|
|
580
594
|
let value;
|
|
581
|
-
if (edit.kind === "text")
|
|
595
|
+
if (edit.kind === "rich-text") value = edit.rich.value;
|
|
596
|
+
else if (edit.kind === "text")
|
|
582
597
|
value = parseCanvasValue(edit.input.value, edit.type);
|
|
583
598
|
else value = propertyDraft(edit);
|
|
584
|
-
if (edit.kind === "text")
|
|
585
|
-
createCanvasDraft(editor.document, edit.path, value);
|
|
599
|
+
if (edit.kind === "text" || edit.kind === "rich-text")
|
|
600
|
+
renderSvg(createCanvasDraft(editor.document, edit.path, value), {...renderOptions,slideIndex});
|
|
586
601
|
committing = true;
|
|
587
|
-
if (edit.kind === "text" && JSON.stringify(value) !== edit.base)
|
|
602
|
+
if ((edit.kind === "text" || edit.kind === "rich-text") && JSON.stringify(value) !== edit.base)
|
|
588
603
|
editor.set(edit.path, value, { source: "canvas", rejectInvalid: true });
|
|
589
604
|
if (edit.kind === "properties") {
|
|
590
605
|
const patches = propertyPatches(edit).filter(
|
|
@@ -600,6 +615,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
600
615
|
}
|
|
601
616
|
committing = false;
|
|
602
617
|
active = null;
|
|
618
|
+
edit.rich?.destroy();
|
|
603
619
|
overlay.replaceChildren();
|
|
604
620
|
options.propertiesContainer?.replaceChildren();
|
|
605
621
|
clearNotice();
|
|
@@ -617,8 +633,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
617
633
|
if (blockControls.editingPath) { blockControls.cancel(); render(); return; }
|
|
618
634
|
blockControls.cancel();
|
|
619
635
|
if (layoutHandles.editingPath) { layoutHandles.cancel(clear); return; }
|
|
620
|
-
const path = active?.path;
|
|
636
|
+
const path = active?.path, rich = active?.rich;
|
|
621
637
|
active = null;
|
|
638
|
+
rich?.destroy();
|
|
622
639
|
if (frame) {
|
|
623
640
|
win.cancelAnimationFrame(frame);
|
|
624
641
|
frame = 0;
|
|
@@ -654,6 +671,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
654
671
|
});
|
|
655
672
|
const resize = new win.ResizeObserver(() => {
|
|
656
673
|
if (active?.kind === "text") positionInput();
|
|
674
|
+
active?.rich?.update();
|
|
657
675
|
});
|
|
658
676
|
resize.observe(root);
|
|
659
677
|
root.addEventListener("keydown", (event) => {
|
|
@@ -734,6 +752,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
734
752
|
},
|
|
735
753
|
destroy() {
|
|
736
754
|
disposed = true;
|
|
755
|
+
active?.rich?.destroy();
|
|
737
756
|
richToolbar.destroy();
|
|
738
757
|
layoutHandles.destroy();
|
|
739
758
|
blockControls.destroy();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {richTextContent, updateRichTextInput} from './rich-text.js';
|
|
2
|
+
|
|
3
|
+
/** Native input owns keyboard/IME; caret and selection use the canonical SVG glyphs. */
|
|
4
|
+
export function createRichTextInput(root, overlay, {path, value, getTarget, onInput, onCommit, onCancel, onFormat, onError}) {
|
|
5
|
+
const doc=root.ownerDocument, win=doc.defaultView;
|
|
6
|
+
const input=doc.createElement('textarea'), marks=doc.createElement('div'), actions=doc.createElement('div');
|
|
7
|
+
input.className='opf-rich-input';input.value=richTextContent(value);
|
|
8
|
+
input.setAttribute('aria-label','Edit rich text inline');input.spellcheck=true;
|
|
9
|
+
input.style.cssText='position:absolute;width:1px;padding:0;border:0;opacity:0;pointer-events:none;resize:none;overflow:hidden;z-index:3';
|
|
10
|
+
marks.className='opf-rich-selection';marks.setAttribute('aria-hidden','true');marks.style.cssText='position:absolute;inset:0;pointer-events:none';
|
|
11
|
+
actions.style.cssText='position:absolute;bottom:8px;left:8px;display:flex;gap:6px;pointer-events:auto;z-index:4';
|
|
12
|
+
for(const [label,action] of [['Format selection',()=>onFormat(input.selectionStart,input.selectionEnd)],['Done',onCommit]]) {
|
|
13
|
+
const button=doc.createElement('button');button.type='button';button.textContent=label;
|
|
14
|
+
button.style.cssText='padding:6px 10px;background:#fff;color:#574774;border:1px solid #d5cce5;border-radius:5px';
|
|
15
|
+
button.onmousedown=event=>event.preventDefault();button.onclick=action;actions.append(button);
|
|
16
|
+
}
|
|
17
|
+
overlay.append(marks,input,actions);
|
|
18
|
+
let current=structuredClone(value), composing=false, change=null, anchor=null, disposed=false, emptyAnchor=null;
|
|
19
|
+
let history=[{value:structuredClone(current),start:0,end:input.value.length}], historyIndex=0, compositionBase=null;
|
|
20
|
+
function remember() {
|
|
21
|
+
history.splice(historyIndex+1);history.push({value:structuredClone(current),start:input.selectionStart,end:input.selectionEnd});historyIndex++;
|
|
22
|
+
}
|
|
23
|
+
function fragments() { return [...(getTarget(path)?.querySelectorAll('text[data-opf-text-start]')??[])]; }
|
|
24
|
+
function rect(node,start,end) {
|
|
25
|
+
const range=doc.createRange();range.setStart(node.firstChild,start);range.setEnd(node.firstChild,end);return range.getBoundingClientRect();
|
|
26
|
+
}
|
|
27
|
+
function boundaries() {
|
|
28
|
+
const result=[];
|
|
29
|
+
for(const node of fragments()) {
|
|
30
|
+
if(!node.firstChild)continue;
|
|
31
|
+
const start=Number(node.dataset.opfTextStart),text=node.textContent;
|
|
32
|
+
for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(text)) {
|
|
33
|
+
const box=rect(node,part.index,part.index+part.segment.length),rtl=win.getComputedStyle(node).direction==='rtl';
|
|
34
|
+
result.push({offset:start+part.index,x:rtl?box.right:box.left,y:box.top,height:box.height,node});
|
|
35
|
+
result.push({offset:start+part.index+part.segment.length,x:rtl?box.left:box.right,y:box.top,height:box.height,node});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const target=getTarget(path),matrix=target?.getScreenCTM();
|
|
39
|
+
if(matrix)for(const line of JSON.parse(target.dataset.opfRichLines??'[]')) {
|
|
40
|
+
if(line.start!==line.end)continue;
|
|
41
|
+
const point=new win.DOMPoint(line.x,line.y).matrixTransform(matrix);
|
|
42
|
+
result.push({offset:line.start,x:point.x,y:point.y,height:line.height*Math.hypot(matrix.c,matrix.d)});
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function update() {
|
|
47
|
+
if(disposed)return;marks.replaceChildren();
|
|
48
|
+
const origin=root.getBoundingClientRect(),start=input.selectionStart,end=input.selectionEnd;
|
|
49
|
+
const append=(box,caret=false)=>{
|
|
50
|
+
const mark=doc.createElement('div');mark.className=caret?'opf-rich-caret':'opf-rich-range';
|
|
51
|
+
mark.style.cssText=`position:absolute;left:${box.left-origin.left}px;top:${box.top-origin.top}px;width:${caret?1.5:box.width}px;height:${Math.max(12,box.height)}px;background:${caret?(box.color??'#6551ba'):'#8975d955'}`;
|
|
52
|
+
marks.append(mark);
|
|
53
|
+
};
|
|
54
|
+
if(start!==end)for(const node of fragments()) {
|
|
55
|
+
const a=Math.max(0,start-Number(node.dataset.opfTextStart)),b=Math.min(node.textContent.length,end-Number(node.dataset.opfTextStart));
|
|
56
|
+
if(a<b&&node.firstChild)append(rect(node,a,b));
|
|
57
|
+
}
|
|
58
|
+
const points=boundaries();
|
|
59
|
+
if(points.length)emptyAnchor=points[0];
|
|
60
|
+
const offset=input.selectionDirection==='backward'?start:end;
|
|
61
|
+
// Prefer the beginning of the next fragment at wrapped line boundaries.
|
|
62
|
+
const point=points.findLast(p=>p.offset===offset)??points.reduce((best,p)=>!best||Math.abs(p.offset-offset)<Math.abs(best.offset-offset)?p:best,null);
|
|
63
|
+
const target=emptyAnchor?{left:emptyAnchor.x,top:emptyAnchor.y,width:1,height:emptyAnchor.height}:getTarget(path)?.getBoundingClientRect();
|
|
64
|
+
const box=point?{left:point.x,top:point.y,width:1,height:point.height,color:point.node?win.getComputedStyle(point.node).fill:undefined}:target;
|
|
65
|
+
if(box) {
|
|
66
|
+
if(start===end)append(box,true);
|
|
67
|
+
input.style.left=`${box.left-origin.left}px`;input.style.top=`${box.top-origin.top}px`;input.style.height=`${Math.max(20,box.height)}px`;
|
|
68
|
+
if(point?.node)input.style.font=win.getComputedStyle(point.node).font;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function nearest(event) {
|
|
72
|
+
return boundaries().reduce((best,p)=>{
|
|
73
|
+
const vertical=Math.max(p.y-event.clientY,0,event.clientY-p.y-p.height);
|
|
74
|
+
const distance=vertical*vertical*100+Math.pow(p.x-event.clientX,2);
|
|
75
|
+
return !best||distance<best.distance?{...p,distance}:best;
|
|
76
|
+
},null)?.offset??0;
|
|
77
|
+
}
|
|
78
|
+
function pointerDown(event) {
|
|
79
|
+
if(event.button!==0||!getTarget(path)?.contains(event.target))return;
|
|
80
|
+
event.preventDefault();event.stopPropagation();
|
|
81
|
+
anchor=event.shiftKey?input.selectionStart:nearest(event);input.focus({preventScroll:true});
|
|
82
|
+
const end=nearest(event);input.setSelectionRange(Math.min(anchor,end),Math.max(anchor,end),end<anchor?'backward':'forward');
|
|
83
|
+
root.setPointerCapture(event.pointerId);update();
|
|
84
|
+
}
|
|
85
|
+
function pointerMove(event) {
|
|
86
|
+
if(anchor===null)return;event.preventDefault();const end=nearest(event);
|
|
87
|
+
input.setSelectionRange(Math.min(anchor,end),Math.max(anchor,end),end<anchor?'backward':'forward');update();
|
|
88
|
+
}
|
|
89
|
+
function pointerUp(event) {if(anchor!==null){anchor=null;if(root.hasPointerCapture(event.pointerId))root.releasePointerCapture(event.pointerId);}}
|
|
90
|
+
function undo(direction) {
|
|
91
|
+
const next=historyIndex+direction;if(next<0||next>=history.length)return;
|
|
92
|
+
historyIndex=next;const saved=history[next];current=structuredClone(saved.value);input.value=richTextContent(current);
|
|
93
|
+
input.setSelectionRange(saved.start,saved.end);onInput(current);update();
|
|
94
|
+
}
|
|
95
|
+
input.addEventListener('beforeinput',event=>{
|
|
96
|
+
if(event.inputType==='historyUndo'||event.inputType==='historyRedo'){event.preventDefault();undo(event.inputType==='historyUndo'?-1:1);return;}
|
|
97
|
+
change={start:input.selectionStart,end:input.selectionEnd,inputType:event.inputType};
|
|
98
|
+
if(!composing){history[historyIndex].start=input.selectionStart;history[historyIndex].end=input.selectionEnd;}
|
|
99
|
+
});
|
|
100
|
+
input.addEventListener('input',()=>{
|
|
101
|
+
try{current=updateRichTextInput(current,input.value,change);change=null;if(!composing)remember();onInput(current);update();}
|
|
102
|
+
catch(error){input.value=richTextContent(current);change=null;onError(error);}
|
|
103
|
+
});
|
|
104
|
+
input.addEventListener('compositionstart',()=>{composing=true;compositionBase=JSON.stringify(current);});
|
|
105
|
+
input.addEventListener('compositionend',()=>{composing=false;if(JSON.stringify(current)!==compositionBase)remember();onInput(current);update();});
|
|
106
|
+
input.addEventListener('keydown',event=>{
|
|
107
|
+
if(event.isComposing||composing)return;
|
|
108
|
+
if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='z'){event.preventDefault();event.stopPropagation();undo(event.shiftKey?1:-1);}
|
|
109
|
+
else if(event.key==='Escape'){event.preventDefault();event.stopPropagation();onCancel();}
|
|
110
|
+
else if(event.key==='Enter'&&(event.metaKey||event.ctrlKey)){event.preventDefault();onCommit();}
|
|
111
|
+
});
|
|
112
|
+
input.addEventListener('select',update);input.addEventListener('keyup',update);
|
|
113
|
+
input.addEventListener('blur',()=>{if(!disposed&&!composing)onCommit();});
|
|
114
|
+
root.addEventListener('pointerdown',pointerDown,true);root.addEventListener('pointermove',pointerMove);root.addEventListener('pointerup',pointerUp);root.addEventListener('pointercancel',pointerUp);
|
|
115
|
+
input.focus({preventScroll:true});input.select();update();
|
|
116
|
+
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();}};
|
|
117
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {richTextContent, formatRichTextRange, replaceRichTextRange} from './rich-text.js';
|
|
2
2
|
|
|
3
3
|
/** Native SVG selection stays on the exact rendered glyphs; no HTML reflow. */
|
|
4
|
-
export function createRichTextToolbar(root, {editor, report, beforeChange, onProperties, validateChange, onChange}) {
|
|
4
|
+
export function createRichTextToolbar(root, {editor, report, beforeChange, onProperties, onTyping, validateChange, onChange}) {
|
|
5
5
|
const doc = root.ownerDocument, win = doc.defaultView;
|
|
6
6
|
const toolbar = doc.createElement('div');
|
|
7
7
|
toolbar.className = 'opf-rich-toolbar';
|
|
@@ -89,6 +89,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
|
|
|
89
89
|
replacement.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();try{apply(null,replacement.value);}catch(error){report(error);}}};
|
|
90
90
|
button('Replace text',()=>apply(null,replacement.value));
|
|
91
91
|
button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','color','fontSize','fontFamily','link'].map(key=>[key,null]))));
|
|
92
|
+
button('Edit text',()=>{const path=selected?.path;hide();if(path)onTyping?.(path);});
|
|
92
93
|
button('Edit runs',()=>{const path=selected?.path;hide();if(path)onProperties?.(path);});
|
|
93
94
|
button('Done',()=>{hide();win.getSelection()?.removeAllRanges();});
|
|
94
95
|
const status=doc.createElement('span');status.style.fontSize='11px';toolbar.append(status);
|
|
@@ -115,7 +116,11 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
|
|
|
115
116
|
doc.addEventListener('selectionchange',selectionChanged);
|
|
116
117
|
const unsubscribe=editor.subscribe(()=>{if(!restoring)hide();});
|
|
117
118
|
hide();
|
|
118
|
-
|
|
119
|
+
function selectRange(path,start,end) {
|
|
120
|
+
const value=editor.get(path);if(!Array.isArray(value)||start>=end)return false;
|
|
121
|
+
selected={path,start,end,base:JSON.stringify(value)};restore();sync();return true;
|
|
122
|
+
}
|
|
123
|
+
return {hide,selectRange,selectAll(path) {
|
|
119
124
|
const value=editor.get(path);if(!Array.isArray(value))return false;
|
|
120
125
|
const end=richTextContent(value).length;if(!end)return false;
|
|
121
126
|
selected={path,start:0,end,base:JSON.stringify(value)};restore();sync();return true;
|
package/dist/rich-text.d.ts
CHANGED
|
@@ -8,3 +8,5 @@ export declare function richTextContent(value:string|TextRun[]):string;
|
|
|
8
8
|
/** Nonempty UTF-16 range on grapheme boundaries; null removes an override. */
|
|
9
9
|
export declare function formatRichTextRange(value:string|TextRun[],start:number,end:number,format:TextRunFormat):TextRun[];
|
|
10
10
|
export declare function replaceRichTextRange(value:string|TextRun[],start:number,end:number,replacement:string):TextRun[];
|
|
11
|
+
/** Preserve run metadata across a native input change, on grapheme boundaries. */
|
|
12
|
+
export declare function updateRichTextInput(value:string|TextRun[],nextText:string,change?:{start:number;end:number;inputType?:string}):TextRun[];
|
package/dist/rich-text.js
CHANGED
|
@@ -69,3 +69,45 @@ export function replaceRichTextRange(value, start, end, replacement) {
|
|
|
69
69
|
if (!inserted && replacement) result.push(replacement);
|
|
70
70
|
return result.length ? result : [''];
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/** Map a native plain-text input change back onto styled runs without flattening them. */
|
|
74
|
+
export function updateRichTextInput(value, nextText, change) {
|
|
75
|
+
const previous = richTextContent(value);
|
|
76
|
+
if (typeof nextText !== 'string') throw new TypeError('Input text must be a string.');
|
|
77
|
+
if (previous === nextText) return structuredClone(runs(value));
|
|
78
|
+
if (!nextText) return [piece(runs(value)[0] ?? "", "")];
|
|
79
|
+
const segments = text => [...new Intl.Segmenter(undefined, {granularity:'grapheme'}).segment(text)].map(part => part.segment);
|
|
80
|
+
const before = segments(previous), after = segments(nextText);
|
|
81
|
+
let prefix = 0, suffix = 0;
|
|
82
|
+
while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++;
|
|
83
|
+
while (suffix < before.length-prefix && suffix < after.length-prefix && before[before.length-1-suffix] === after[after.length-1-suffix]) suffix++;
|
|
84
|
+
let start = before.slice(0,prefix).join('').length;
|
|
85
|
+
let end = previous.length-before.slice(before.length-suffix).join('').length;
|
|
86
|
+
let replacement = after.slice(prefix,after.length-suffix).join('');
|
|
87
|
+
if (change && Number.isInteger(change.start) && Number.isInteger(change.end)) {
|
|
88
|
+
let a = change.start, b = change.end;
|
|
89
|
+
const removed = previous.length-nextText.length;
|
|
90
|
+
if (a === b && removed > 0) {
|
|
91
|
+
if (/Backward$/.test(change.inputType ?? '')) a -= removed;
|
|
92
|
+
else if (/Forward$/.test(change.inputType ?? '')) b += removed;
|
|
93
|
+
}
|
|
94
|
+
const left = previous.slice(0,a), right = previous.slice(b);
|
|
95
|
+
if (a >= 0 && b >= a && b <= previous.length && nextText.length >= left.length+right.length && nextText.startsWith(left) && nextText.endsWith(right)) {
|
|
96
|
+
// Validate exact native selection boundaries before trusting their affinity.
|
|
97
|
+
try {
|
|
98
|
+
range(value,a,b,true);
|
|
99
|
+
start=a;end=b;replacement=nextText.slice(left.length,nextText.length-right.length);
|
|
100
|
+
} catch { /* Native deletion can split a cluster; use the whole-grapheme diff. */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const result = replaceRichTextRange(value,start,end,replacement), compact = [];
|
|
104
|
+
const style = run => typeof run === 'string' ? null : Object.fromEntries(Object.entries(run).filter(([key]) => key !== 'text').sort(([a],[b]) => a.localeCompare(b)));
|
|
105
|
+
for (const run of result) {
|
|
106
|
+
const last = compact.at(-1);
|
|
107
|
+
if (last !== undefined && JSON.stringify(style(last)) === JSON.stringify(style(run))) {
|
|
108
|
+
if (typeof last === 'string') compact[compact.length-1] += run;
|
|
109
|
+
else last.text += run.text;
|
|
110
|
+
} else compact.push(run);
|
|
111
|
+
}
|
|
112
|
+
return compact;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -81,14 +81,17 @@
|
|
|
81
81
|
"prepack": "npm run build"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@openpresentation/opf": "^0.4.
|
|
84
|
+
"@openpresentation/opf": "^0.4.1"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
|
-
"@openpresentation/opf-render": "^0.1.0"
|
|
87
|
+
"@openpresentation/opf-render": "^0.1.1 || ^0.2.0"
|
|
88
88
|
},
|
|
89
89
|
"peerDependenciesMeta": {
|
|
90
90
|
"@openpresentation/opf-render": {
|
|
91
91
|
"optional": true
|
|
92
92
|
}
|
|
93
|
+
},
|
|
94
|
+
"devDependencies": {
|
|
95
|
+
"@openpresentation/opf-render": "0.2.0"
|
|
93
96
|
}
|
|
94
97
|
}
|