@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.
- package/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
- package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
- package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
- package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +82 -13
- package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
- package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +19 -4
- package/src/app/app.tsx +2 -0
- package/src/app/components/asset-view.tsx +111 -18
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +267 -25
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/components/sidebar/folder-item.tsx +14 -3
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/lib/assets.ts +21 -0
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/sdk.ts +2 -0
- package/src/app/lib/slides.ts +9 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +23 -2
- package/src/app/routes/home.tsx +101 -3
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +117 -39
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +28 -5
- package/src/locale/ja.ts +28 -5
- package/src/locale/types.ts +27 -1
- package/src/locale/zh-cn.ts +28 -6
- 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,
|
|
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 (
|
|
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
|
|
398
|
+
bucket.origTexts.set(instanceId, { value: readEditableText(anchor) });
|
|
173
399
|
}
|
|
174
|
-
bucket.textOps.set(instanceId, { value: op.value });
|
|
175
|
-
if (anchor.isConnected) anchor
|
|
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, {
|
|
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 = {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
289
|
-
|
|
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, {
|
|
300
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
686
|
+
for (const [id, op] of rangeStyleOps) {
|
|
378
687
|
pending.push({
|
|
379
688
|
key,
|
|
380
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
514
|
-
el
|
|
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
|
-
|
|
869
|
+
observer = new MutationObserver(replayAll);
|
|
529
870
|
observer.observe(root, { childList: true, subtree: true });
|
|
530
|
-
return () => observer
|
|
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="
|
|
72
|
-
<div className="flex min-h-full flex-col">
|
|
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
|
);
|