@open-slide/core 1.0.4 → 1.0.6
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-DqfKmw9h.js → build-4wOJF1l4.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
- package/dist/{config-CN7J0RDO.js → config-evLWCV1-.js} +378 -222
- package/dist/{dev-jWxtWHAG.js → dev-BUr0S-Ij.js} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +1189 -0
- package/dist/{preview-CSA05Gfm.js → preview-DP_gIphz.js} +1 -1
- package/dist/types-BVvl_xup.d.ts +314 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -1
- package/src/app/app.tsx +6 -2
- package/src/app/components/asset-view.tsx +87 -64
- package/src/app/components/click-nav-zones.tsx +4 -2
- package/src/app/components/inspector/comment-widget.tsx +9 -7
- package/src/app/components/inspector/inspect-overlay.tsx +79 -17
- package/src/app/components/inspector/inspector-panel.tsx +68 -39
- package/src/app/components/inspector/inspector-provider.tsx +185 -58
- package/src/app/components/inspector/save-bar.tsx +6 -5
- package/src/app/components/panel/save-card.tsx +12 -9
- package/src/app/components/pdf-progress-toast.tsx +11 -4
- package/src/app/components/player.tsx +7 -25
- package/src/app/components/present/control-bar.tsx +17 -10
- package/src/app/components/present/help-overlay.tsx +18 -17
- package/src/app/components/present/overview-grid.tsx +6 -9
- package/src/app/components/present/use-presenter-channel.ts +3 -10
- package/src/app/components/sidebar/folder-item.tsx +16 -9
- package/src/app/components/sidebar/icon-picker.tsx +4 -5
- package/src/app/components/sidebar/sidebar.tsx +87 -25
- package/src/app/components/slide-canvas.tsx +1 -10
- package/src/app/components/style-panel/design-provider.tsx +2 -6
- package/src/app/components/style-panel/style-panel.tsx +26 -18
- package/src/app/components/theme-toggle.tsx +7 -5
- package/src/app/components/thumbnail-rail.tsx +4 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/lib/export-html.ts +1 -9
- package/src/app/lib/export-pdf.ts +0 -5
- package/src/app/lib/inspector/use-editor.ts +9 -7
- package/src/app/lib/print-ready.ts +0 -4
- package/src/app/lib/sdk.ts +1 -2
- package/src/app/lib/use-locale.ts +20 -0
- package/src/app/routes/home.tsx +90 -45
- package/src/app/routes/presenter.tsx +45 -25
- package/src/app/routes/slide.tsx +37 -24
- package/src/app/styles.css +28 -0
- package/src/app/virtual.d.ts +4 -0
- package/src/locale/en.ts +303 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +307 -0
- package/src/locale/types.ts +323 -0
- package/src/locale/zh-cn.ts +303 -0
- package/src/locale/zh-tw.ts +303 -0
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
useState,
|
|
11
11
|
} from 'react';
|
|
12
|
+
import { toast } from 'sonner';
|
|
12
13
|
import { useHistory } from '@/components/history-provider';
|
|
13
14
|
import { Button } from '@/components/ui/button';
|
|
14
15
|
import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
|
|
15
|
-
import { type Edit, type EditOp, useEditor } from '@/lib/inspector/use-editor';
|
|
16
|
+
import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
|
|
17
|
+
import { useLocale } from '@/lib/use-locale';
|
|
16
18
|
|
|
17
19
|
export type SelectedTarget = {
|
|
18
20
|
line: number;
|
|
@@ -26,15 +28,25 @@ type Bucket = {
|
|
|
26
28
|
line: number;
|
|
27
29
|
column: number;
|
|
28
30
|
styleOps: Map<string, string | null>;
|
|
29
|
-
|
|
31
|
+
// Text edits are scoped per DOM instance: a reused component renders
|
|
32
|
+
// the same JSX `<h2>{title}</h2>` at multiple call sites with the same
|
|
33
|
+
// `data-slide-loc`, but each call site's prop literal is independent.
|
|
34
|
+
// Style/attr ops stay shared because they edit the JSX definition.
|
|
35
|
+
textOps: Map<string /* instanceId */, { value: string }>;
|
|
30
36
|
attrOps: Map<string, AssetAttrOp>;
|
|
31
37
|
// Pre-edit snapshot of the DOM, captured the first time we touch
|
|
32
38
|
// each style key / text / attribute. Used by `cancelEdits` to revert.
|
|
33
39
|
origStyle: Map<string, string>;
|
|
34
|
-
|
|
40
|
+
origTexts: Map<string /* instanceId */, { value: string }>;
|
|
35
41
|
origAttrs: Map<string, string | null>;
|
|
36
42
|
};
|
|
37
43
|
|
|
44
|
+
const INSTANCE_ID_ATTR = 'data-slide-instance-id';
|
|
45
|
+
|
|
46
|
+
function readInstanceId(el: HTMLElement): string | null {
|
|
47
|
+
return el.getAttribute(INSTANCE_ID_ATTR);
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
type InspectorCtx = {
|
|
39
51
|
slideId: string;
|
|
40
52
|
active: boolean;
|
|
@@ -48,7 +60,7 @@ type InspectorCtx = {
|
|
|
48
60
|
selected: SelectedTarget | null;
|
|
49
61
|
setSelected: (s: SelectedTarget | null) => void;
|
|
50
62
|
applyEdit: (line: number, column: number, ops: EditOp[]) => Promise<void>;
|
|
51
|
-
applyEdits: (edits: Edit[]) => Promise<
|
|
63
|
+
applyEdits: (edits: Edit[]) => Promise<EditResult[]>;
|
|
52
64
|
// Mutate the DOM optimistically, snapshot the pre-edit values, and
|
|
53
65
|
// remember the ops. `commitEdits` (manual Save or auto-flush on
|
|
54
66
|
// close) is what actually writes to disk; `cancelEdits` reverts.
|
|
@@ -75,22 +87,39 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
75
87
|
const history = useHistory();
|
|
76
88
|
|
|
77
89
|
const pendingRef = useRef<Map<string, Bucket>>(new Map());
|
|
90
|
+
const instanceCounterRef = useRef(0);
|
|
78
91
|
const [pendingCount, setPendingCount] = useState(0);
|
|
79
92
|
const [committing, setCommitting] = useState(false);
|
|
93
|
+
const t = useLocale();
|
|
94
|
+
|
|
95
|
+
const ensureInstanceId = useCallback((el: HTMLElement): string => {
|
|
96
|
+
const existing = el.getAttribute(INSTANCE_ID_ATTR);
|
|
97
|
+
if (existing) return existing;
|
|
98
|
+
const next = `inst-${++instanceCounterRef.current}`;
|
|
99
|
+
el.setAttribute(INSTANCE_ID_ATTR, next);
|
|
100
|
+
return next;
|
|
101
|
+
}, []);
|
|
80
102
|
|
|
81
103
|
const refreshCount = useCallback(() => {
|
|
82
104
|
let n = 0;
|
|
83
105
|
for (const b of pendingRef.current.values()) {
|
|
84
|
-
if (b.styleOps.size > 0 || b.
|
|
106
|
+
if (b.styleOps.size > 0 || b.textOps.size > 0 || b.attrOps.size > 0) n++;
|
|
85
107
|
}
|
|
86
108
|
setPendingCount(n);
|
|
87
109
|
}, []);
|
|
88
110
|
|
|
89
111
|
// Find the live anchor for a buffered loc. Used by history undo/redo
|
|
90
|
-
// since the original `anchor` reference may have unmounted.
|
|
91
|
-
|
|
112
|
+
// since the original `anchor` reference may have unmounted. With an
|
|
113
|
+
// instance id, prefer the matching DOM node so per-instance text edits
|
|
114
|
+
// round-trip onto the right element.
|
|
115
|
+
const findAnchor = useCallback((line: number, column: number, instanceId?: string) => {
|
|
92
116
|
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
93
|
-
|
|
117
|
+
if (!root) return null;
|
|
118
|
+
if (instanceId) {
|
|
119
|
+
const byInstance = root.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`);
|
|
120
|
+
if (byInstance) return byInstance;
|
|
121
|
+
}
|
|
122
|
+
return root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
|
|
94
123
|
}, []);
|
|
95
124
|
|
|
96
125
|
// Mutate bucket + DOM without recording history. Shared by `bufferOps`
|
|
@@ -104,10 +133,10 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
104
133
|
line,
|
|
105
134
|
column,
|
|
106
135
|
styleOps: new Map(),
|
|
107
|
-
|
|
136
|
+
textOps: new Map(),
|
|
108
137
|
attrOps: new Map(),
|
|
109
138
|
origStyle: new Map(),
|
|
110
|
-
|
|
139
|
+
origTexts: new Map(),
|
|
111
140
|
origAttrs: new Map(),
|
|
112
141
|
};
|
|
113
142
|
pendingRef.current.set(key, bucket);
|
|
@@ -121,11 +150,16 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
121
150
|
bucket.styleOps.set(op.key, op.value);
|
|
122
151
|
if (anchor?.isConnected) style[op.key] = op.value ?? '';
|
|
123
152
|
} else if (op.kind === 'set-text') {
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
// Reused JSX renders multiple DOM nodes with the same
|
|
154
|
+
// `data-slide-loc` but distinct call-site literals; without an
|
|
155
|
+
// anchor we can't tell which instance to route to, so skip.
|
|
156
|
+
if (!anchor) continue;
|
|
157
|
+
const instanceId = ensureInstanceId(anchor);
|
|
158
|
+
if (!bucket.origTexts.has(instanceId)) {
|
|
159
|
+
bucket.origTexts.set(instanceId, { value: anchor.textContent ?? '' });
|
|
126
160
|
}
|
|
127
|
-
bucket.
|
|
128
|
-
if (anchor
|
|
161
|
+
bucket.textOps.set(instanceId, { value: op.value });
|
|
162
|
+
if (anchor.isConnected) anchor.textContent = op.value;
|
|
129
163
|
} else if (op.kind === 'set-attr-asset') {
|
|
130
164
|
if (anchor && !bucket.origAttrs.has(op.attr)) {
|
|
131
165
|
bucket.origAttrs.set(
|
|
@@ -139,14 +173,19 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
139
173
|
}
|
|
140
174
|
refreshCount();
|
|
141
175
|
},
|
|
142
|
-
[refreshCount],
|
|
176
|
+
[refreshCount, ensureInstanceId],
|
|
143
177
|
);
|
|
144
178
|
|
|
145
179
|
// Pre-edit snapshot for history: capture the *currently effective* value of
|
|
146
180
|
// each touched field so undo can restore exactly the prior state, including
|
|
147
181
|
// the case where the bucket already had a buffered edit before this op.
|
|
148
182
|
type StyleSnap = { kind: 'style'; key: string; value: string | null; existed: boolean };
|
|
149
|
-
type TextSnap = {
|
|
183
|
+
type TextSnap = {
|
|
184
|
+
kind: 'text';
|
|
185
|
+
instanceId: string;
|
|
186
|
+
value: string | null;
|
|
187
|
+
existed: boolean;
|
|
188
|
+
};
|
|
150
189
|
type AttrSnap = {
|
|
151
190
|
kind: 'attr';
|
|
152
191
|
attr: string;
|
|
@@ -179,10 +218,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
179
218
|
});
|
|
180
219
|
}
|
|
181
220
|
} else if (op.kind === 'set-text') {
|
|
182
|
-
|
|
183
|
-
|
|
221
|
+
const instanceId = ensureInstanceId(anchor);
|
|
222
|
+
const existing = bucket?.textOps.get(instanceId);
|
|
223
|
+
if (existing) {
|
|
224
|
+
snaps.push({ kind: 'text', instanceId, value: existing.value, existed: true });
|
|
184
225
|
} else {
|
|
185
|
-
snaps.push({
|
|
226
|
+
snaps.push({
|
|
227
|
+
kind: 'text',
|
|
228
|
+
instanceId,
|
|
229
|
+
value: anchor.textContent ?? '',
|
|
230
|
+
existed: false,
|
|
231
|
+
});
|
|
186
232
|
}
|
|
187
233
|
} else if (op.kind === 'set-attr-asset') {
|
|
188
234
|
const prev = bucket?.attrOps.get(op.attr);
|
|
@@ -209,7 +255,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
209
255
|
}
|
|
210
256
|
return snaps;
|
|
211
257
|
},
|
|
212
|
-
[],
|
|
258
|
+
[ensureInstanceId],
|
|
213
259
|
);
|
|
214
260
|
|
|
215
261
|
// Restore the snapshotted values to bucket + DOM. Mirrors the bucket-empty
|
|
@@ -219,43 +265,47 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
219
265
|
const key = `${line}:${column}`;
|
|
220
266
|
const bucket = pendingRef.current.get(key);
|
|
221
267
|
if (!bucket) return;
|
|
222
|
-
|
|
223
|
-
|
|
268
|
+
// Style/attr snaps share the loc-level anchor (first match);
|
|
269
|
+
// text snaps look up their per-instance node below.
|
|
270
|
+
const sharedAnchor = findAnchor(line, column);
|
|
271
|
+
const sharedStyle = (sharedAnchor?.style ?? {}) as unknown as Record<string, string>;
|
|
224
272
|
for (const snap of snaps) {
|
|
225
273
|
if (snap.kind === 'style') {
|
|
226
274
|
if (snap.existed) {
|
|
227
275
|
const v = snap.value ?? '';
|
|
228
276
|
bucket.styleOps.set(snap.key, snap.value);
|
|
229
|
-
if (
|
|
277
|
+
if (sharedAnchor?.isConnected) sharedStyle[snap.key] = v;
|
|
230
278
|
} else {
|
|
231
279
|
bucket.styleOps.delete(snap.key);
|
|
232
280
|
const orig = bucket.origStyle.get(snap.key);
|
|
233
|
-
if (
|
|
281
|
+
if (sharedAnchor?.isConnected) sharedStyle[snap.key] = orig ?? '';
|
|
234
282
|
}
|
|
235
283
|
} else if (snap.kind === 'text') {
|
|
284
|
+
const textAnchor = findAnchor(line, column, snap.instanceId);
|
|
236
285
|
if (snap.existed) {
|
|
237
|
-
bucket.
|
|
238
|
-
if (
|
|
286
|
+
bucket.textOps.set(snap.instanceId, { value: snap.value ?? '' });
|
|
287
|
+
if (textAnchor?.isConnected) textAnchor.textContent = snap.value ?? '';
|
|
239
288
|
} else {
|
|
240
|
-
bucket.
|
|
241
|
-
|
|
289
|
+
bucket.textOps.delete(snap.instanceId);
|
|
290
|
+
const orig = bucket.origTexts.get(snap.instanceId);
|
|
291
|
+
if (textAnchor?.isConnected) textAnchor.textContent = orig?.value ?? '';
|
|
242
292
|
}
|
|
243
293
|
} else if (snap.kind === 'attr') {
|
|
244
294
|
if (snap.source === 'op') {
|
|
245
295
|
const op = snap.value as AssetAttrOp;
|
|
246
296
|
bucket.attrOps.set(snap.attr, op);
|
|
247
|
-
if (
|
|
297
|
+
if (sharedAnchor?.isConnected) sharedAnchor.setAttribute(snap.attr, op.previewUrl);
|
|
248
298
|
} else {
|
|
249
299
|
bucket.attrOps.delete(snap.attr);
|
|
250
300
|
const orig = bucket.origAttrs.get(snap.attr);
|
|
251
|
-
if (
|
|
252
|
-
if (orig === null || orig === undefined)
|
|
253
|
-
else
|
|
301
|
+
if (sharedAnchor?.isConnected) {
|
|
302
|
+
if (orig === null || orig === undefined) sharedAnchor.removeAttribute(snap.attr);
|
|
303
|
+
else sharedAnchor.setAttribute(snap.attr, orig);
|
|
254
304
|
}
|
|
255
305
|
}
|
|
256
306
|
}
|
|
257
307
|
}
|
|
258
|
-
if (bucket.styleOps.size === 0 && bucket.
|
|
308
|
+
if (bucket.styleOps.size === 0 && bucket.textOps.size === 0 && bucket.attrOps.size === 0) {
|
|
259
309
|
pendingRef.current.delete(key);
|
|
260
310
|
}
|
|
261
311
|
refreshCount();
|
|
@@ -288,35 +338,96 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
288
338
|
const commitEdits = useCallback(async () => {
|
|
289
339
|
const buckets = pendingRef.current;
|
|
290
340
|
if (buckets.size === 0) return;
|
|
291
|
-
|
|
292
|
-
for
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
341
|
+
// Each bucket flattens to one Edit per text instance plus one Edit
|
|
342
|
+
// for the shared style/attr ops. We track which entries in `pending`
|
|
343
|
+
// belong to which bucket so a per-edit failure can clear just the
|
|
344
|
+
// landed pieces while leaving the rest buffered for retry.
|
|
345
|
+
type PendingItem = {
|
|
346
|
+
key: string;
|
|
347
|
+
edit: Edit;
|
|
348
|
+
onSuccess: (bucket: Bucket) => void;
|
|
349
|
+
};
|
|
350
|
+
const pending: PendingItem[] = [];
|
|
351
|
+
for (const [key, bucket] of buckets) {
|
|
352
|
+
const { line, column, styleOps, textOps, attrOps, origTexts } = bucket;
|
|
353
|
+
// Shared edit (style + asset attrs) — one per bucket.
|
|
354
|
+
const sharedOps: EditOp[] = [];
|
|
355
|
+
for (const [k, v] of styleOps) sharedOps.push({ kind: 'set-style', key: k, value: v });
|
|
296
356
|
for (const [attr, op] of attrOps) {
|
|
297
|
-
|
|
357
|
+
sharedOps.push({
|
|
298
358
|
kind: 'set-attr-asset',
|
|
299
359
|
attr,
|
|
300
360
|
assetPath: op.assetPath,
|
|
301
361
|
previewUrl: op.previewUrl,
|
|
302
362
|
});
|
|
303
363
|
}
|
|
304
|
-
if (
|
|
364
|
+
if (sharedOps.length > 0) {
|
|
365
|
+
pending.push({
|
|
366
|
+
key,
|
|
367
|
+
edit: { line, column, ops: sharedOps },
|
|
368
|
+
onSuccess: (b) => {
|
|
369
|
+
b.styleOps.clear();
|
|
370
|
+
b.attrOps.clear();
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// Per-instance text edits — one Edit per call site, each with its
|
|
375
|
+
// own prevText so the server can disambiguate among siblings.
|
|
376
|
+
for (const [instanceId, textOp] of textOps) {
|
|
377
|
+
const orig = origTexts.get(instanceId);
|
|
378
|
+
pending.push({
|
|
379
|
+
key,
|
|
380
|
+
edit: {
|
|
381
|
+
line,
|
|
382
|
+
column,
|
|
383
|
+
ops: [{ kind: 'set-text', value: textOp.value, prevText: orig?.value }],
|
|
384
|
+
},
|
|
385
|
+
onSuccess: (b) => {
|
|
386
|
+
b.textOps.delete(instanceId);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
305
390
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
391
|
+
if (pending.length === 0) {
|
|
392
|
+
pendingRef.current = new Map();
|
|
393
|
+
setPendingCount(0);
|
|
309
394
|
history.clear();
|
|
310
395
|
return;
|
|
311
396
|
}
|
|
312
397
|
setCommitting(true);
|
|
313
398
|
try {
|
|
314
|
-
await applyEdits(
|
|
399
|
+
const results = await applyEdits(pending.map((p) => p.edit));
|
|
400
|
+
const failures: string[] = [];
|
|
401
|
+
for (let i = 0; i < results.length; i++) {
|
|
402
|
+
const item = pending[i];
|
|
403
|
+
const r = results[i];
|
|
404
|
+
const bucket = pendingRef.current.get(item.key);
|
|
405
|
+
if (r.ok) {
|
|
406
|
+
if (bucket) {
|
|
407
|
+
item.onSuccess(bucket);
|
|
408
|
+
if (
|
|
409
|
+
bucket.styleOps.size === 0 &&
|
|
410
|
+
bucket.textOps.size === 0 &&
|
|
411
|
+
bucket.attrOps.size === 0
|
|
412
|
+
) {
|
|
413
|
+
pendingRef.current.delete(item.key);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
failures.push(`line ${item.edit.line}: ${r.error ?? 'edit failed'}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
refreshCount();
|
|
421
|
+
if (failures.length > 0) toast.error(`${t.inspector.saveFailed} ${failures.join('; ')}`);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
424
|
+
toast.error(`${t.inspector.saveFailed} ${msg}`);
|
|
425
|
+
throw err;
|
|
315
426
|
} finally {
|
|
316
427
|
setCommitting(false);
|
|
317
428
|
history.clear();
|
|
318
429
|
}
|
|
319
|
-
}, [applyEdits, history]);
|
|
430
|
+
}, [applyEdits, history, refreshCount, t]);
|
|
320
431
|
|
|
321
432
|
const cancelEdits = useCallback(() => {
|
|
322
433
|
if (pendingRef.current.size === 0) {
|
|
@@ -325,14 +436,20 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
325
436
|
}
|
|
326
437
|
const root = document.querySelector<HTMLElement>('[data-inspector-root]');
|
|
327
438
|
for (const b of pendingRef.current.values()) {
|
|
328
|
-
const
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
439
|
+
const sharedEl = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
|
|
440
|
+
if (sharedEl) {
|
|
441
|
+
const style = sharedEl.style as unknown as Record<string, string>;
|
|
442
|
+
for (const [k, v] of b.origStyle) style[k] = v;
|
|
443
|
+
for (const [attr, value] of b.origAttrs) {
|
|
444
|
+
if (value === null) sharedEl.removeAttribute(attr);
|
|
445
|
+
else sharedEl.setAttribute(attr, value);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Each text edit has its own anchor — locate by instance id.
|
|
449
|
+
for (const [instanceId, orig] of b.origTexts) {
|
|
450
|
+
const textEl =
|
|
451
|
+
root?.querySelector<HTMLElement>(`[${INSTANCE_ID_ATTR}="${instanceId}"]`) ?? null;
|
|
452
|
+
if (textEl?.isConnected) textEl.textContent = orig.value;
|
|
336
453
|
}
|
|
337
454
|
}
|
|
338
455
|
pendingRef.current = new Map();
|
|
@@ -341,7 +458,9 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
341
458
|
}, [history]);
|
|
342
459
|
|
|
343
460
|
// Auto-flush on inspector close and on route unmount so toggling
|
|
344
|
-
// off or navigating away doesn't drop buffered edits.
|
|
461
|
+
// off or navigating away doesn't drop buffered edits. Failures are
|
|
462
|
+
// surfaced via toast inside `commitEdits`; the catch here only
|
|
463
|
+
// swallows the rethrown rejection.
|
|
345
464
|
const commitRef = useRef(commitEdits);
|
|
346
465
|
commitRef.current = commitEdits;
|
|
347
466
|
useEffect(() => {
|
|
@@ -372,8 +491,15 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
372
491
|
const v = value ?? '';
|
|
373
492
|
if (style[key] !== v) style[key] = v;
|
|
374
493
|
}
|
|
375
|
-
|
|
376
|
-
|
|
494
|
+
// Text replays per-instance: only the originally clicked DOM node
|
|
495
|
+
// (stamped with its `data-slide-instance-id`) gets the buffered
|
|
496
|
+
// value, so siblings of a reused component aren't clobbered.
|
|
497
|
+
const instanceId = readInstanceId(el);
|
|
498
|
+
if (instanceId) {
|
|
499
|
+
const textOp = bucket.textOps.get(instanceId);
|
|
500
|
+
if (textOp && el.textContent !== textOp.value) {
|
|
501
|
+
el.textContent = textOp.value;
|
|
502
|
+
}
|
|
377
503
|
}
|
|
378
504
|
for (const [attr, op] of bucket.attrOps) {
|
|
379
505
|
if (el.getAttribute(attr) !== op.previewUrl) el.setAttribute(attr, op.previewUrl);
|
|
@@ -449,6 +575,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
449
575
|
}
|
|
450
576
|
|
|
451
577
|
export function InspectToggleButton() {
|
|
578
|
+
const t = useLocale();
|
|
452
579
|
const { active, toggle } = useInspector();
|
|
453
580
|
if (import.meta.env.PROD) return null;
|
|
454
581
|
return (
|
|
@@ -457,10 +584,10 @@ export function InspectToggleButton() {
|
|
|
457
584
|
variant={active ? 'default' : 'ghost'}
|
|
458
585
|
onClick={toggle}
|
|
459
586
|
data-inspector-ui
|
|
460
|
-
title=
|
|
587
|
+
title={t.inspector.inspect}
|
|
461
588
|
>
|
|
462
589
|
<Crosshair className="size-3.5" />
|
|
463
|
-
<span className="hidden md:inline">
|
|
590
|
+
<span className="hidden md:inline">{t.inspector.inspect}</span>
|
|
464
591
|
</Button>
|
|
465
592
|
);
|
|
466
593
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { useHistory } from '@/components/history-provider';
|
|
2
2
|
import { SaveCard } from '@/components/panel/save-card';
|
|
3
3
|
import { useDesignPanelState } from '@/components/style-panel/design-provider';
|
|
4
|
+
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
4
5
|
import { useInspector } from './inspector-provider';
|
|
5
6
|
|
|
6
|
-
// Single save card for both inspector edits and design-token edits.
|
|
7
|
-
// Counts the design draft as one unit; the user sees one combined
|
|
8
|
-
// "N unsaved changes" pill. Save/Discard fan out to both providers.
|
|
9
7
|
export function SaveBar() {
|
|
10
8
|
const insp = useInspector();
|
|
11
9
|
const design = useDesignPanelState();
|
|
12
10
|
const history = useHistory();
|
|
11
|
+
const t = useLocale();
|
|
13
12
|
|
|
14
13
|
const inspectorCount = insp.pendingCount;
|
|
15
14
|
const designCount = design.dirty ? 1 : 0;
|
|
@@ -22,7 +21,9 @@ export function SaveBar() {
|
|
|
22
21
|
const tasks: Promise<void>[] = [];
|
|
23
22
|
if (inspectorCount > 0) tasks.push(Promise.resolve(insp.commitEdits()));
|
|
24
23
|
if (designCount > 0) tasks.push(Promise.resolve(design.commit()));
|
|
25
|
-
|
|
24
|
+
// Each provider surfaces its own errors via toast; swallow here so
|
|
25
|
+
// one failure doesn't reject the combined save.
|
|
26
|
+
await Promise.all(tasks).catch(() => {});
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
const onDiscard = () => {
|
|
@@ -37,7 +38,7 @@ export function SaveBar() {
|
|
|
37
38
|
committing={committing}
|
|
38
39
|
onSave={onSave}
|
|
39
40
|
onDiscard={onDiscard}
|
|
40
|
-
unsavedLabel={
|
|
41
|
+
unsavedLabel={format(plural(total, t.inspector.unsavedChanges), { count: total })}
|
|
41
42
|
onUndo={history.undo}
|
|
42
43
|
onRedo={history.redo}
|
|
43
44
|
canUndo={history.canUndo}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Check, Loader2, Redo2, Save, Undo2 } from 'lucide-react';
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { useLocale } from '@/lib/use-locale';
|
|
4
5
|
|
|
5
6
|
type SaveCardProps = {
|
|
6
7
|
dirty: boolean;
|
|
@@ -25,14 +26,16 @@ export function SaveCard({
|
|
|
25
26
|
onSave,
|
|
26
27
|
onDiscard,
|
|
27
28
|
unsavedLabel,
|
|
28
|
-
savedLabel
|
|
29
|
+
savedLabel,
|
|
29
30
|
uiAttr,
|
|
30
31
|
onUndo,
|
|
31
32
|
onRedo,
|
|
32
33
|
canUndo = false,
|
|
33
34
|
canRedo = false,
|
|
34
35
|
}: SaveCardProps) {
|
|
36
|
+
const t = useLocale();
|
|
35
37
|
const [justSaved, setJustSaved] = useState(false);
|
|
38
|
+
const resolvedSavedLabel = savedLabel ?? t.common.saved;
|
|
36
39
|
|
|
37
40
|
useEffect(() => {
|
|
38
41
|
if (!justSaved) return;
|
|
@@ -66,8 +69,8 @@ export function SaveCard({
|
|
|
66
69
|
className="text-muted-foreground hover:text-foreground"
|
|
67
70
|
onClick={onUndo}
|
|
68
71
|
disabled={committing || !canUndo}
|
|
69
|
-
aria-label=
|
|
70
|
-
title=
|
|
72
|
+
aria-label={t.common.undo}
|
|
73
|
+
title={t.common.undo}
|
|
71
74
|
>
|
|
72
75
|
<Undo2 className="size-3.5" />
|
|
73
76
|
</Button>
|
|
@@ -77,8 +80,8 @@ export function SaveCard({
|
|
|
77
80
|
className="text-muted-foreground hover:text-foreground"
|
|
78
81
|
onClick={onRedo}
|
|
79
82
|
disabled={committing || !canRedo}
|
|
80
|
-
aria-label=
|
|
81
|
-
title=
|
|
83
|
+
aria-label={t.common.redo}
|
|
84
|
+
title={t.common.redo}
|
|
82
85
|
>
|
|
83
86
|
<Redo2 className="size-3.5" />
|
|
84
87
|
</Button>
|
|
@@ -90,7 +93,7 @@ export function SaveCard({
|
|
|
90
93
|
{justSaved ? (
|
|
91
94
|
<span className="flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
92
95
|
<Check className="size-3.5 text-[oklch(0.55_0.13_165)]" strokeWidth={2.5} />
|
|
93
|
-
{
|
|
96
|
+
{resolvedSavedLabel}
|
|
94
97
|
</span>
|
|
95
98
|
) : dirty || committing ? (
|
|
96
99
|
<span className="inline-flex items-center gap-1.5 px-2.5 text-[12px] font-medium text-foreground">
|
|
@@ -109,7 +112,7 @@ export function SaveCard({
|
|
|
109
112
|
onClick={onDiscard}
|
|
110
113
|
disabled={committing || !dirty}
|
|
111
114
|
>
|
|
112
|
-
|
|
115
|
+
{t.common.discard}
|
|
113
116
|
</Button>
|
|
114
117
|
)}
|
|
115
118
|
{(dirty || committing) && (
|
|
@@ -123,12 +126,12 @@ export function SaveCard({
|
|
|
123
126
|
{committing ? (
|
|
124
127
|
<>
|
|
125
128
|
<Loader2 className="size-3.5 animate-spin" />
|
|
126
|
-
|
|
129
|
+
{t.common.saving}
|
|
127
130
|
</>
|
|
128
131
|
) : (
|
|
129
132
|
<>
|
|
130
133
|
<Save className="size-3.5" />
|
|
131
|
-
|
|
134
|
+
{t.common.save}
|
|
132
135
|
</>
|
|
133
136
|
)}
|
|
134
137
|
</Button>
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { Loader2 } from 'lucide-react';
|
|
2
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
2
3
|
import type { PdfExportProgress } from '../lib/export-pdf';
|
|
3
4
|
import { Progress } from './ui/progress';
|
|
4
5
|
|
|
5
6
|
export function PdfProgressToast({ progress }: { progress: PdfExportProgress }) {
|
|
7
|
+
const t = useLocale();
|
|
6
8
|
const text =
|
|
7
9
|
progress.phase === 'processing'
|
|
8
|
-
?
|
|
10
|
+
? format(t.pdfToast.processing, {
|
|
11
|
+
current: progress.current.toString().padStart(2, '0'),
|
|
12
|
+
total: progress.total.toString().padStart(2, '0'),
|
|
13
|
+
})
|
|
9
14
|
: progress.phase === 'printing'
|
|
10
|
-
?
|
|
11
|
-
:
|
|
15
|
+
? t.pdfToast.printing
|
|
16
|
+
: t.pdfToast.done;
|
|
12
17
|
|
|
13
18
|
return (
|
|
14
19
|
<div className="flex w-80 items-start gap-3 rounded-[8px] border border-border bg-popover px-3.5 py-3 text-popover-foreground shadow-floating">
|
|
15
20
|
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-brand" />
|
|
16
21
|
<div className="min-w-0 flex-1">
|
|
17
|
-
<p className="font-heading text-[12.5px] font-semibold tracking-tight">
|
|
22
|
+
<p className="font-heading text-[12.5px] font-semibold tracking-tight">
|
|
23
|
+
{t.pdfToast.title}
|
|
24
|
+
</p>
|
|
18
25
|
<p className="truncate font-mono text-[10.5px] tracking-[0.04em] text-muted-foreground">
|
|
19
26
|
{text}
|
|
20
27
|
</p>
|