@open-slide/core 1.3.0 → 1.5.0

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.
Files changed (45) hide show
  1. package/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
  4. package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
  5. package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +82 -13
  11. package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +19 -4
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +111 -18
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +267 -25
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/panel/panel-shell.tsx +5 -3
  23. package/src/app/components/player.tsx +25 -5
  24. package/src/app/components/present/control-bar.tsx +12 -0
  25. package/src/app/components/present/laser-pointer.tsx +3 -4
  26. package/src/app/components/present/progress-bar.tsx +4 -4
  27. package/src/app/components/sidebar/folder-item.tsx +14 -3
  28. package/src/app/components/sidebar/sidebar.tsx +10 -0
  29. package/src/app/lib/assets.ts +21 -0
  30. package/src/app/lib/export-pdf.ts +6 -0
  31. package/src/app/lib/inspector/use-editor.ts +9 -1
  32. package/src/app/lib/sdk.ts +2 -0
  33. package/src/app/lib/slides.ts +9 -0
  34. package/src/app/lib/use-slide-module.ts +48 -0
  35. package/src/app/routes/assets.tsx +9 -0
  36. package/src/app/routes/home-shell.tsx +23 -2
  37. package/src/app/routes/home.tsx +101 -3
  38. package/src/app/routes/presenter.tsx +2 -20
  39. package/src/app/routes/slide.tsx +117 -39
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +28 -5
  42. package/src/locale/ja.ts +28 -5
  43. package/src/locale/types.ts +27 -1
  44. package/src/locale/zh-cn.ts +28 -6
  45. package/src/locale/zh-tw.ts +28 -6
@@ -24,21 +24,33 @@ export type SelectedTarget = {
24
24
  };
25
25
 
26
26
  type AssetAttrOp = { assetPath: string; previewUrl: string };
27
+ type Sequenced<T> = T & { seq: number };
28
+ type StyleOp = { value: string | null; prevText?: string };
29
+ type TextRangeStyleOp = {
30
+ instanceId: string;
31
+ start: number;
32
+ end: number;
33
+ key: string;
34
+ value: string | null;
35
+ prevText?: string;
36
+ };
27
37
 
28
38
  type Bucket = {
29
39
  line: number;
30
40
  column: number;
31
- styleOps: Map<string, string | null>;
41
+ styleOps: Map<string, Sequenced<StyleOp>>;
42
+ rangeStyleOps: Map<string, Sequenced<TextRangeStyleOp>>;
32
43
  // Text edits are scoped per DOM instance: a reused component renders
33
44
  // the same JSX `<h2>{title}</h2>` at multiple call sites with the same
34
45
  // `data-slide-loc`, but each call site's prop literal is independent.
35
46
  // Style/attr ops stay shared because they edit the JSX definition.
36
- textOps: Map<string /* instanceId */, { value: string }>;
37
- attrOps: Map<string, AssetAttrOp>;
47
+ textOps: Map<string /* instanceId */, Sequenced<{ value: string }>>;
48
+ attrOps: Map<string, Sequenced<AssetAttrOp>>;
38
49
  // Pre-edit snapshot of the DOM, captured the first time we touch
39
50
  // each style key / text / attribute. Used by `cancelEdits` to revert.
40
51
  origStyle: Map<string, string>;
41
52
  origTexts: Map<string /* instanceId */, { value: string }>;
53
+ origHtmls: Map<string /* instanceId */, string>;
42
54
  origAttrs: Map<string, string | null>;
43
55
  };
44
56
 
@@ -48,6 +60,186 @@ function readInstanceId(el: HTMLElement): string | null {
48
60
  return el.getAttribute(INSTANCE_ID_ATTR);
49
61
  }
50
62
 
63
+ type DomTextPart = { node: Text | HTMLBRElement; current: string };
64
+
65
+ function readEditableText(el: HTMLElement): string {
66
+ const parts: DomTextPart[] = [];
67
+ collectDomTextParts(el, parts);
68
+ return parts.map((part) => part.current).join('');
69
+ }
70
+
71
+ function collectDomTextParts(node: Node, out: DomTextPart[]): void {
72
+ const parts: DomTextPart[] = [];
73
+ collectDomTextPartsRaw(node, parts);
74
+ out.push(...normalizeDomTextParts(parts));
75
+ }
76
+
77
+ function collectDomTextPartsRaw(node: Node, out: DomTextPart[]): void {
78
+ for (const child of Array.from(node.childNodes)) {
79
+ if (child instanceof Text) {
80
+ const current = renderedTextNodeValue(child);
81
+ if (current) out.push({ node: child, current });
82
+ } else if (child instanceof HTMLBRElement) {
83
+ out.push({ node: child, current: '\n' });
84
+ } else if (child instanceof HTMLElement) {
85
+ collectDomTextPartsRaw(child, out);
86
+ }
87
+ }
88
+ }
89
+
90
+ function normalizeDomTextParts(parts: DomTextPart[]): DomTextPart[] {
91
+ return parts.flatMap((part, index) => {
92
+ if (part.current === '\n') return [part];
93
+ let current = part.current;
94
+ if (parts[index - 1]?.current === '\n') current = current.replace(/^\s+/, '');
95
+ if (parts[index + 1]?.current === '\n') current = current.replace(/\s+$/, '');
96
+ return current ? [{ ...part, current }] : [];
97
+ });
98
+ }
99
+
100
+ function renderedTextNodeValue(node: Text): string {
101
+ const whiteSpace = node.parentElement ? getComputedStyle(node.parentElement).whiteSpace : '';
102
+ if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
103
+ return node.data;
104
+ }
105
+ return node.data.replace(/\s+/g, ' ');
106
+ }
107
+
108
+ function textDiff(prevText: string, nextText: string) {
109
+ let start = 0;
110
+ while (
111
+ start < prevText.length &&
112
+ start < nextText.length &&
113
+ prevText[start] === nextText[start]
114
+ ) {
115
+ start += 1;
116
+ }
117
+
118
+ let prevEnd = prevText.length;
119
+ let nextEnd = nextText.length;
120
+ while (prevEnd > start && nextEnd > start && prevText[prevEnd - 1] === nextText[nextEnd - 1]) {
121
+ prevEnd -= 1;
122
+ nextEnd -= 1;
123
+ }
124
+
125
+ return { start, end: prevEnd, value: nextText.slice(start, nextEnd) };
126
+ }
127
+
128
+ function textFragment(value: string): DocumentFragment {
129
+ const fragment = document.createDocumentFragment();
130
+ const lines = value.split('\n');
131
+ for (let i = 0; i < lines.length; i++) {
132
+ if (lines[i]) fragment.append(document.createTextNode(lines[i]));
133
+ if (i < lines.length - 1) fragment.append(document.createElement('br'));
134
+ }
135
+ return fragment;
136
+ }
137
+
138
+ function replaceDomTextPart(part: DomTextPart, value: string) {
139
+ if (part.node instanceof Text && !value.includes('\n')) {
140
+ part.node.data = value;
141
+ return;
142
+ }
143
+ const fragment = textFragment(value);
144
+ part.node.replaceWith(fragment);
145
+ }
146
+
147
+ function setEditableText(el: HTMLElement, value: string) {
148
+ const parts: DomTextPart[] = [];
149
+ collectDomTextParts(el, parts);
150
+ const current = parts.map((part) => part.current).join('');
151
+ if (current === value) return;
152
+ if (parts.length === 0) {
153
+ el.replaceChildren(textFragment(value));
154
+ return;
155
+ }
156
+
157
+ const diff = textDiff(current, value);
158
+ let offset = 0;
159
+ let inserted = false;
160
+ for (const part of parts) {
161
+ const partStart = offset;
162
+ const partEnd = partStart + part.current.length;
163
+ offset = partEnd;
164
+
165
+ const overlaps = diff.start < partEnd && diff.end > partStart;
166
+ const insertsHere =
167
+ diff.start === diff.end && !inserted && diff.start >= partStart && diff.start <= partEnd;
168
+ if (!overlaps && !insertsHere) continue;
169
+
170
+ if (part.node instanceof Text) {
171
+ const localStart = Math.max(diff.start, partStart) - partStart;
172
+ const localEnd = overlaps ? Math.min(diff.end, partEnd) - partStart : localStart;
173
+ replaceDomTextPart(
174
+ part,
175
+ `${part.current.slice(0, localStart)}${inserted ? '' : diff.value}${part.current.slice(localEnd)}`,
176
+ );
177
+ } else if (overlaps) {
178
+ replaceDomTextPart(part, inserted ? '' : diff.value);
179
+ } else {
180
+ const fragment = textFragment(diff.value);
181
+ if (diff.start === partStart) part.node.before(fragment);
182
+ else part.node.after(fragment);
183
+ }
184
+
185
+ inserted = true;
186
+ }
187
+
188
+ if (!inserted && diff.start === diff.end && diff.start === offset) {
189
+ el.append(textFragment(diff.value));
190
+ }
191
+ }
192
+
193
+ function rangeStyleKey(
194
+ instanceId: string,
195
+ op: { start: number; end: number; key: string },
196
+ ): string {
197
+ return `${instanceId}:${op.start}:${op.end}:${op.key}`;
198
+ }
199
+
200
+ function applyDomTextRangeStyle(
201
+ el: HTMLElement,
202
+ op: Pick<TextRangeStyleOp, 'start' | 'end' | 'key' | 'value'>,
203
+ ) {
204
+ const value = op.value ?? resetValueForRangeStyle(op.key);
205
+ if (value === null) return;
206
+ const parts: DomTextPart[] = [];
207
+ collectDomTextParts(el, parts);
208
+ let offset = 0;
209
+ for (const part of parts) {
210
+ const partStart = offset;
211
+ const partEnd = partStart + part.current.length;
212
+ offset = partEnd;
213
+ if (!(part.node instanceof Text)) continue;
214
+ const selectedStart = Math.max(op.start, partStart);
215
+ const selectedEnd = Math.min(op.end, partEnd);
216
+ if (selectedStart >= selectedEnd) continue;
217
+
218
+ const localStart = selectedStart - partStart;
219
+ const localEnd = selectedEnd - partStart;
220
+ const before = part.current.slice(0, localStart);
221
+ const selected = part.current.slice(localStart, localEnd);
222
+ const after = part.current.slice(localEnd);
223
+ const span = document.createElement('span');
224
+ (span.style as unknown as Record<string, string>)[op.key] = value;
225
+ span.textContent = selected;
226
+ part.node.replaceWith(document.createTextNode(before), span, document.createTextNode(after));
227
+ }
228
+ }
229
+
230
+ function resetValueForRangeStyle(key: string): string | null {
231
+ if (key === 'fontWeight') return '400';
232
+ if (key === 'fontStyle') return 'normal';
233
+ return null;
234
+ }
235
+
236
+ function replayDomTextRangeStyles(el: HTMLElement, html: string, ops: TextRangeStyleOp[]) {
237
+ const preview = document.createElement('span');
238
+ preview.innerHTML = html;
239
+ for (const op of ops) applyDomTextRangeStyle(preview, op);
240
+ if (el.innerHTML !== preview.innerHTML) el.innerHTML = preview.innerHTML;
241
+ }
242
+
51
243
  type InspectorCtx = {
52
244
  slideId: string;
53
245
  active: boolean;
@@ -90,6 +282,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
90
282
 
91
283
  const pendingRef = useRef<Map<string, Bucket>>(new Map());
92
284
  const instanceCounterRef = useRef(0);
285
+ const pendingSeqRef = useRef(0);
93
286
  const [pendingCount, setPendingCount] = useState(0);
94
287
  const [committing, setCommitting] = useState(false);
95
288
  const [cropTarget, setCropTarget] = useState<{
@@ -116,7 +309,14 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
116
309
  const refreshCount = useCallback(() => {
117
310
  let n = 0;
118
311
  for (const b of pendingRef.current.values()) {
119
- if (b.styleOps.size > 0 || b.textOps.size > 0 || b.attrOps.size > 0) n++;
312
+ if (
313
+ b.styleOps.size > 0 ||
314
+ b.rangeStyleOps.size > 0 ||
315
+ b.textOps.size > 0 ||
316
+ b.attrOps.size > 0
317
+ ) {
318
+ n++;
319
+ }
120
320
  }
121
321
  setPendingCount(n);
122
322
  }, []);
@@ -146,22 +346,48 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
146
346
  line,
147
347
  column,
148
348
  styleOps: new Map(),
349
+ rangeStyleOps: new Map(),
149
350
  textOps: new Map(),
150
351
  attrOps: new Map(),
151
352
  origStyle: new Map(),
152
353
  origTexts: new Map(),
354
+ origHtmls: new Map(),
153
355
  origAttrs: new Map(),
154
356
  };
155
357
  pendingRef.current.set(key, bucket);
156
358
  }
157
359
  const style = (anchor?.style ?? {}) as unknown as Record<string, string>;
158
360
  for (const op of ops) {
361
+ const seq = ++pendingSeqRef.current;
159
362
  if (op.kind === 'set-style') {
160
363
  if (anchor && !bucket.origStyle.has(op.key)) {
161
364
  bucket.origStyle.set(op.key, style[op.key] ?? '');
162
365
  }
163
- bucket.styleOps.set(op.key, op.value);
366
+ bucket.styleOps.set(op.key, { value: op.value, prevText: op.prevText, seq });
164
367
  if (anchor?.isConnected) style[op.key] = op.value ?? '';
368
+ } else if (op.kind === 'set-text-range-style') {
369
+ if (!anchor) continue;
370
+ const instanceId = ensureInstanceId(anchor);
371
+ if (!bucket.origHtmls.has(instanceId)) bucket.origHtmls.set(instanceId, anchor.innerHTML);
372
+ const nextOp: Sequenced<TextRangeStyleOp> = {
373
+ instanceId,
374
+ start: op.start,
375
+ end: op.end,
376
+ key: op.key,
377
+ value: op.value,
378
+ prevText: op.prevText ?? readEditableText(anchor),
379
+ seq,
380
+ };
381
+ bucket.rangeStyleOps.set(rangeStyleKey(instanceId, op), nextOp);
382
+ if (anchor.isConnected) {
383
+ replayDomTextRangeStyles(
384
+ anchor,
385
+ bucket.origHtmls.get(instanceId) ?? anchor.innerHTML,
386
+ Array.from(bucket.rangeStyleOps.values()).filter(
387
+ (item) => item.instanceId === instanceId,
388
+ ),
389
+ );
390
+ }
165
391
  } else if (op.kind === 'set-text') {
166
392
  // Reused JSX renders multiple DOM nodes with the same
167
393
  // `data-slide-loc` but distinct call-site literals; without an
@@ -169,10 +395,10 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
169
395
  if (!anchor) continue;
170
396
  const instanceId = ensureInstanceId(anchor);
171
397
  if (!bucket.origTexts.has(instanceId)) {
172
- bucket.origTexts.set(instanceId, { value: anchor.textContent ?? '' });
398
+ bucket.origTexts.set(instanceId, { value: readEditableText(anchor) });
173
399
  }
174
- bucket.textOps.set(instanceId, { value: op.value });
175
- if (anchor.isConnected) anchor.textContent = op.value;
400
+ bucket.textOps.set(instanceId, { value: op.value, seq });
401
+ if (anchor.isConnected) setEditableText(anchor, op.value);
176
402
  } else if (op.kind === 'set-attr-asset') {
177
403
  if (anchor && !bucket.origAttrs.has(op.attr)) {
178
404
  bucket.origAttrs.set(
@@ -180,7 +406,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
180
406
  anchor.hasAttribute(op.attr) ? anchor.getAttribute(op.attr) : null,
181
407
  );
182
408
  }
183
- bucket.attrOps.set(op.attr, { assetPath: op.assetPath, previewUrl: op.previewUrl });
409
+ bucket.attrOps.set(op.attr, {
410
+ assetPath: op.assetPath,
411
+ previewUrl: op.previewUrl,
412
+ seq,
413
+ });
184
414
  if (anchor?.isConnected) anchor.setAttribute(op.attr, op.previewUrl);
185
415
  }
186
416
  }
@@ -192,7 +422,19 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
192
422
  // Pre-edit snapshot for history: capture the *currently effective* value of
193
423
  // each touched field so undo can restore exactly the prior state, including
194
424
  // the case where the bucket already had a buffered edit before this op.
195
- type StyleSnap = { kind: 'style'; key: string; value: string | null; existed: boolean };
425
+ type StyleSnap = {
426
+ kind: 'style';
427
+ key: string;
428
+ value: Sequenced<StyleOp> | string | null;
429
+ existed: boolean;
430
+ };
431
+ type RangeStyleSnap = {
432
+ kind: 'range-style';
433
+ id: string;
434
+ instanceId: string;
435
+ value: Sequenced<TextRangeStyleOp> | null;
436
+ existed: boolean;
437
+ };
196
438
  type TextSnap = {
197
439
  kind: 'text';
198
440
  instanceId: string;
@@ -202,10 +444,10 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
202
444
  type AttrSnap = {
203
445
  kind: 'attr';
204
446
  attr: string;
205
- value: AssetAttrOp | string | null;
447
+ value: Sequenced<AssetAttrOp> | string | null;
206
448
  source: 'op' | 'orig' | 'dom-missing' | 'dom-present';
207
449
  };
208
- type Snap = StyleSnap | TextSnap | AttrSnap;
450
+ type Snap = StyleSnap | RangeStyleSnap | TextSnap | AttrSnap;
209
451
 
210
452
  const snapshotForOps = useCallback(
211
453
  (line: number, column: number, anchor: HTMLElement, ops: EditOp[]): Snap[] => {
@@ -215,11 +457,12 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
215
457
  const snaps: Snap[] = [];
216
458
  for (const op of ops) {
217
459
  if (op.kind === 'set-style') {
218
- if (bucket?.styleOps.has(op.key)) {
460
+ const existing = bucket?.styleOps.get(op.key);
461
+ if (existing) {
219
462
  snaps.push({
220
463
  kind: 'style',
221
464
  key: op.key,
222
- value: bucket.styleOps.get(op.key) ?? null,
465
+ value: { ...existing },
223
466
  existed: true,
224
467
  });
225
468
  } else {
@@ -230,6 +473,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
230
473
  existed: false,
231
474
  });
232
475
  }
476
+ } else if (op.kind === 'set-text-range-style') {
477
+ const instanceId = ensureInstanceId(anchor);
478
+ const id = rangeStyleKey(instanceId, op);
479
+ const existing = bucket?.rangeStyleOps.get(id);
480
+ snaps.push({
481
+ kind: 'range-style',
482
+ id,
483
+ instanceId,
484
+ value: existing ? { ...existing } : null,
485
+ existed: !!existing,
486
+ });
233
487
  } else if (op.kind === 'set-text') {
234
488
  const instanceId = ensureInstanceId(anchor);
235
489
  const existing = bucket?.textOps.get(instanceId);
@@ -239,7 +493,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
239
493
  snaps.push({
240
494
  kind: 'text',
241
495
  instanceId,
242
- value: anchor.textContent ?? '',
496
+ value: readEditableText(anchor),
243
497
  existed: false,
244
498
  });
245
499
  }
@@ -285,28 +539,52 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
285
539
  for (const snap of snaps) {
286
540
  if (snap.kind === 'style') {
287
541
  if (snap.existed) {
288
- const v = snap.value ?? '';
289
- bucket.styleOps.set(snap.key, snap.value);
542
+ const prev =
543
+ typeof snap.value === 'object' && snap.value !== null
544
+ ? snap.value
545
+ : { value: snap.value };
546
+ const v = prev.value ?? '';
547
+ bucket.styleOps.set(snap.key, { ...prev, seq: ++pendingSeqRef.current });
290
548
  if (sharedAnchor?.isConnected) sharedStyle[snap.key] = v;
291
549
  } else {
292
550
  bucket.styleOps.delete(snap.key);
293
551
  const orig = bucket.origStyle.get(snap.key);
294
552
  if (sharedAnchor?.isConnected) sharedStyle[snap.key] = orig ?? '';
295
553
  }
554
+ } else if (snap.kind === 'range-style') {
555
+ const textAnchor = findAnchor(line, column, snap.instanceId);
556
+ if (snap.existed && snap.value) {
557
+ bucket.rangeStyleOps.set(snap.id, { ...snap.value, seq: ++pendingSeqRef.current });
558
+ } else {
559
+ bucket.rangeStyleOps.delete(snap.id);
560
+ }
561
+ const html = bucket.origHtmls.get(snap.instanceId);
562
+ if (textAnchor?.isConnected && html !== undefined) {
563
+ replayDomTextRangeStyles(
564
+ textAnchor,
565
+ html,
566
+ Array.from(bucket.rangeStyleOps.values()).filter(
567
+ (op) => op.instanceId === snap.instanceId,
568
+ ),
569
+ );
570
+ }
296
571
  } else if (snap.kind === 'text') {
297
572
  const textAnchor = findAnchor(line, column, snap.instanceId);
298
573
  if (snap.existed) {
299
- bucket.textOps.set(snap.instanceId, { value: snap.value ?? '' });
300
- if (textAnchor?.isConnected) textAnchor.textContent = snap.value ?? '';
574
+ bucket.textOps.set(snap.instanceId, {
575
+ value: snap.value ?? '',
576
+ seq: ++pendingSeqRef.current,
577
+ });
578
+ if (textAnchor?.isConnected) setEditableText(textAnchor, snap.value ?? '');
301
579
  } else {
302
580
  bucket.textOps.delete(snap.instanceId);
303
581
  const orig = bucket.origTexts.get(snap.instanceId);
304
- if (textAnchor?.isConnected) textAnchor.textContent = orig?.value ?? '';
582
+ if (textAnchor?.isConnected) setEditableText(textAnchor, orig?.value ?? '');
305
583
  }
306
584
  } else if (snap.kind === 'attr') {
307
585
  if (snap.source === 'op') {
308
- const op = snap.value as AssetAttrOp;
309
- bucket.attrOps.set(snap.attr, op);
586
+ const op = snap.value as Sequenced<AssetAttrOp>;
587
+ bucket.attrOps.set(snap.attr, { ...op, seq: ++pendingSeqRef.current });
310
588
  if (sharedAnchor?.isConnected) sharedAnchor.setAttribute(snap.attr, op.previewUrl);
311
589
  } else {
312
590
  bucket.attrOps.delete(snap.attr);
@@ -318,7 +596,12 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
318
596
  }
319
597
  }
320
598
  }
321
- if (bucket.styleOps.size === 0 && bucket.textOps.size === 0 && bucket.attrOps.size === 0) {
599
+ if (
600
+ bucket.styleOps.size === 0 &&
601
+ bucket.rangeStyleOps.size === 0 &&
602
+ bucket.textOps.size === 0 &&
603
+ bucket.attrOps.size === 0
604
+ ) {
322
605
  pendingRef.current.delete(key);
323
606
  }
324
607
  refreshCount();
@@ -328,6 +611,11 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
328
611
 
329
612
  const bufferOps = useCallback(
330
613
  (line: number, column: number, anchor: HTMLElement, ops: EditOp[]) => {
614
+ const instanceId = ops.some(
615
+ (op) => op.kind === 'set-text' || op.kind === 'set-text-range-style',
616
+ )
617
+ ? ensureInstanceId(anchor)
618
+ : undefined;
331
619
  const snaps = snapshotForOps(line, column, anchor, ops);
332
620
  applyOpsRaw(line, column, anchor, ops);
333
621
  const first = ops[0];
@@ -342,45 +630,79 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
342
630
  history.record({
343
631
  coalesceKey,
344
632
  undo: () => restoreSnapshot(line, column, snaps),
345
- redo: () => applyOpsRaw(line, column, findAnchor(line, column), ops),
633
+ redo: () => applyOpsRaw(line, column, findAnchor(line, column, instanceId), ops),
346
634
  });
347
635
  },
348
- [applyOpsRaw, snapshotForOps, restoreSnapshot, findAnchor, history],
636
+ [applyOpsRaw, snapshotForOps, restoreSnapshot, findAnchor, history, ensureInstanceId],
349
637
  );
350
638
 
351
639
  const commitEdits = useCallback(async () => {
352
640
  const buckets = pendingRef.current;
353
641
  if (buckets.size === 0) return;
354
- // Each bucket flattens to one Edit per text instance plus one Edit
355
- // for the shared style/attr ops. We track which entries in `pending`
356
- // belong to which bucket so a per-edit failure can clear just the
357
- // landed pieces while leaving the rest buffered for retry.
358
642
  type PendingItem = {
359
643
  key: string;
644
+ seq: number;
360
645
  edit: Edit;
361
646
  onSuccess: (bucket: Bucket) => void;
362
647
  };
363
648
  const pending: PendingItem[] = [];
364
649
  for (const [key, bucket] of buckets) {
365
- const { line, column, styleOps, textOps, attrOps, origTexts } = bucket;
366
- // Shared edit (style + asset attrs) — one per bucket.
367
- const sharedOps: EditOp[] = [];
368
- for (const [k, v] of styleOps) sharedOps.push({ kind: 'set-style', key: k, value: v });
650
+ const { line, column, styleOps, rangeStyleOps, textOps, attrOps, origTexts } = bucket;
651
+ for (const [k, op] of styleOps) {
652
+ pending.push({
653
+ key,
654
+ seq: op.seq,
655
+ edit: {
656
+ line,
657
+ column,
658
+ ops: [{ kind: 'set-style', key: k, value: op.value, prevText: op.prevText }],
659
+ },
660
+ onSuccess: (b) => {
661
+ b.styleOps.delete(k);
662
+ },
663
+ });
664
+ }
369
665
  for (const [attr, op] of attrOps) {
370
- sharedOps.push({
371
- kind: 'set-attr-asset',
372
- attr,
373
- assetPath: op.assetPath,
374
- previewUrl: op.previewUrl,
666
+ pending.push({
667
+ key,
668
+ seq: op.seq,
669
+ edit: {
670
+ line,
671
+ column,
672
+ ops: [
673
+ {
674
+ kind: 'set-attr-asset',
675
+ attr,
676
+ assetPath: op.assetPath,
677
+ previewUrl: op.previewUrl,
678
+ },
679
+ ],
680
+ },
681
+ onSuccess: (b) => {
682
+ b.attrOps.delete(attr);
683
+ },
375
684
  });
376
685
  }
377
- if (sharedOps.length > 0) {
686
+ for (const [id, op] of rangeStyleOps) {
378
687
  pending.push({
379
688
  key,
380
- edit: { line, column, ops: sharedOps },
689
+ seq: op.seq,
690
+ edit: {
691
+ line,
692
+ column,
693
+ ops: [
694
+ {
695
+ kind: 'set-text-range-style',
696
+ start: op.start,
697
+ end: op.end,
698
+ key: op.key,
699
+ value: op.value,
700
+ prevText: op.prevText,
701
+ },
702
+ ],
703
+ },
381
704
  onSuccess: (b) => {
382
- b.styleOps.clear();
383
- b.attrOps.clear();
705
+ b.rangeStyleOps.delete(id);
384
706
  },
385
707
  });
386
708
  }
@@ -390,6 +712,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
390
712
  const orig = origTexts.get(instanceId);
391
713
  pending.push({
392
714
  key,
715
+ seq: textOp.seq,
393
716
  edit: {
394
717
  line,
395
718
  column,
@@ -401,6 +724,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
401
724
  });
402
725
  }
403
726
  }
727
+ pending.sort((a, b) => a.seq - b.seq);
404
728
  if (pending.length === 0) {
405
729
  pendingRef.current = new Map();
406
730
  setPendingCount(0);
@@ -420,6 +744,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
420
744
  item.onSuccess(bucket);
421
745
  if (
422
746
  bucket.styleOps.size === 0 &&
747
+ bucket.rangeStyleOps.size === 0 &&
423
748
  bucket.textOps.size === 0 &&
424
749
  bucket.attrOps.size === 0
425
750
  ) {
@@ -459,10 +784,15 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
459
784
  }
460
785
  }
461
786
  // Each text edit has its own anchor — locate by instance id.
787
+ for (const [instanceId, html] of b.origHtmls) {
788
+ const textEl =
789
+ root?.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`) ?? null;
790
+ if (textEl?.isConnected) textEl.innerHTML = html;
791
+ }
462
792
  for (const [instanceId, orig] of b.origTexts) {
463
793
  const textEl =
464
794
  root?.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`) ?? null;
465
- if (textEl?.isConnected) textEl.textContent = orig.value;
795
+ if (textEl?.isConnected) setEditableText(textEl, orig.value);
466
796
  }
467
797
  }
468
798
  pendingRef.current = new Map();
@@ -500,8 +830,8 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
500
830
  const bucket = pendingRef.current.get(loc);
501
831
  if (!bucket) return;
502
832
  const style = el.style as unknown as Record<string, string>;
503
- for (const [key, value] of bucket.styleOps) {
504
- const v = value ?? '';
833
+ for (const [key, op] of bucket.styleOps) {
834
+ const v = op.value ?? '';
505
835
  if (style[key] !== v) style[key] = v;
506
836
  }
507
837
  // Text replays per-instance: only the originally clicked DOM node
@@ -509,9 +839,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
509
839
  // value, so siblings of a reused component aren't clobbered.
510
840
  const instanceId = readInstanceId(el);
511
841
  if (instanceId) {
842
+ const html = bucket.origHtmls.get(instanceId);
843
+ if (html !== undefined) {
844
+ replayDomTextRangeStyles(
845
+ el,
846
+ html,
847
+ Array.from(bucket.rangeStyleOps.values()).filter((op) => op.instanceId === instanceId),
848
+ );
849
+ }
512
850
  const textOp = bucket.textOps.get(instanceId);
513
- if (textOp && el.textContent !== textOp.value) {
514
- el.textContent = textOp.value;
851
+ if (textOp && readEditableText(el) !== textOp.value) {
852
+ setEditableText(el, textOp.value);
515
853
  }
516
854
  }
517
855
  for (const [attr, op] of bucket.attrOps) {
@@ -519,15 +857,18 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
519
857
  }
520
858
  };
521
859
 
860
+ let observer: MutationObserver | null = null;
522
861
  const replayAll = () => {
523
862
  if (pendingRef.current.size === 0) return;
863
+ observer?.disconnect();
524
864
  root.querySelectorAll<HTMLElement>('[data-slide-loc]').forEach(applyBuffered);
865
+ observer?.observe(root, { childList: true, subtree: true });
525
866
  };
526
867
 
527
868
  replayAll();
528
- const observer = new MutationObserver(replayAll);
869
+ observer = new MutationObserver(replayAll);
529
870
  observer.observe(root, { childList: true, subtree: true });
530
- return () => observer.disconnect();
871
+ return () => observer?.disconnect();
531
872
  }, []);
532
873
 
533
874
  const toggle = useCallback(() => {
@@ -68,10 +68,12 @@ export function PanelShell({
68
68
  {header}
69
69
  </header>
70
70
  {banner}
71
- <ScrollArea className="flex flex-1 flex-col">
72
- <div className="flex min-h-full flex-col">{children}</div>
71
+ <ScrollArea className="min-h-0 flex-1">
72
+ <div className="flex min-h-full flex-col">
73
+ {children}
74
+ {footer && <div className="mt-auto border-t border-hairline">{footer}</div>}
75
+ </div>
73
76
  </ScrollArea>
74
- {footer && <div className="shrink-0 border-t border-hairline">{footer}</div>}
75
77
  </div>
76
78
  </aside>
77
79
  );