@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 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. Complete rich-text formatting, freeform drag/resize, 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.
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 select all of it. 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. Continuous rich-text typing with a mixed-style caret remains under development.
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
- const bounds = node.getBBox(),
158
- rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
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
- richToolbar.selectAll(path);
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
- return {hide,selectAll(path) {
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;
@@ -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.0",
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.0"
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
  }