@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.
Files changed (55) hide show
  1. package/dist/{build-DqfKmw9h.js → build-4wOJF1l4.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
  4. package/dist/{config-CN7J0RDO.js → config-evLWCV1-.js} +378 -222
  5. package/dist/{dev-jWxtWHAG.js → dev-BUr0S-Ij.js} +1 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/locale/index.d.ts +24 -0
  8. package/dist/locale/index.js +1189 -0
  9. package/dist/{preview-CSA05Gfm.js → preview-DP_gIphz.js} +1 -1
  10. package/dist/types-BVvl_xup.d.ts +314 -0
  11. package/dist/vite/index.d.ts +2 -1
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +7 -1
  14. package/src/app/app.tsx +6 -2
  15. package/src/app/components/asset-view.tsx +87 -64
  16. package/src/app/components/click-nav-zones.tsx +4 -2
  17. package/src/app/components/inspector/comment-widget.tsx +9 -7
  18. package/src/app/components/inspector/inspect-overlay.tsx +79 -17
  19. package/src/app/components/inspector/inspector-panel.tsx +68 -39
  20. package/src/app/components/inspector/inspector-provider.tsx +185 -58
  21. package/src/app/components/inspector/save-bar.tsx +6 -5
  22. package/src/app/components/panel/save-card.tsx +12 -9
  23. package/src/app/components/pdf-progress-toast.tsx +11 -4
  24. package/src/app/components/player.tsx +7 -25
  25. package/src/app/components/present/control-bar.tsx +17 -10
  26. package/src/app/components/present/help-overlay.tsx +18 -17
  27. package/src/app/components/present/overview-grid.tsx +6 -9
  28. package/src/app/components/present/use-presenter-channel.ts +3 -10
  29. package/src/app/components/sidebar/folder-item.tsx +16 -9
  30. package/src/app/components/sidebar/icon-picker.tsx +4 -5
  31. package/src/app/components/sidebar/sidebar.tsx +87 -25
  32. package/src/app/components/slide-canvas.tsx +1 -10
  33. package/src/app/components/style-panel/design-provider.tsx +2 -6
  34. package/src/app/components/style-panel/style-panel.tsx +26 -18
  35. package/src/app/components/theme-toggle.tsx +7 -5
  36. package/src/app/components/thumbnail-rail.tsx +4 -2
  37. package/src/app/favicon.ico +0 -0
  38. package/src/app/lib/export-html.ts +1 -9
  39. package/src/app/lib/export-pdf.ts +0 -5
  40. package/src/app/lib/inspector/use-editor.ts +9 -7
  41. package/src/app/lib/print-ready.ts +0 -4
  42. package/src/app/lib/sdk.ts +1 -2
  43. package/src/app/lib/use-locale.ts +20 -0
  44. package/src/app/routes/home.tsx +90 -45
  45. package/src/app/routes/presenter.tsx +45 -25
  46. package/src/app/routes/slide.tsx +37 -24
  47. package/src/app/styles.css +28 -0
  48. package/src/app/virtual.d.ts +4 -0
  49. package/src/locale/en.ts +303 -0
  50. package/src/locale/format.ts +12 -0
  51. package/src/locale/index.ts +6 -0
  52. package/src/locale/ja.ts +307 -0
  53. package/src/locale/types.ts +323 -0
  54. package/src/locale/zh-cn.ts +303 -0
  55. 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
- textOp: { value: string } | null;
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
- origText: { value: string } | null;
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<void>;
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.textOp !== null || b.attrOps.size > 0) n++;
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
- const findAnchor = useCallback((line: number, column: number) => {
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
- return root?.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`) ?? null;
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
- textOp: null,
136
+ textOps: new Map(),
108
137
  attrOps: new Map(),
109
138
  origStyle: new Map(),
110
- origText: null,
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
- if (anchor && bucket.origText === null) {
125
- bucket.origText = { value: anchor.textContent ?? '' };
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.textOp = { value: op.value };
128
- if (anchor?.isConnected) anchor.textContent = op.value;
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 = { kind: 'text'; value: string | null; existed: boolean };
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
- if (bucket?.textOp) {
183
- snaps.push({ kind: 'text', value: bucket.textOp.value, existed: true });
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({ kind: 'text', value: anchor.textContent ?? '', existed: false });
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
- const anchor = findAnchor(line, column);
223
- const style = (anchor?.style ?? {}) as unknown as Record<string, string>;
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 (anchor?.isConnected) style[snap.key] = v;
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 (anchor?.isConnected) style[snap.key] = orig ?? '';
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.textOp = { value: snap.value ?? '' };
238
- if (anchor?.isConnected) anchor.textContent = snap.value ?? '';
286
+ bucket.textOps.set(snap.instanceId, { value: snap.value ?? '' });
287
+ if (textAnchor?.isConnected) textAnchor.textContent = snap.value ?? '';
239
288
  } else {
240
- bucket.textOp = null;
241
- if (anchor?.isConnected) anchor.textContent = bucket.origText?.value ?? '';
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 (anchor?.isConnected) anchor.setAttribute(snap.attr, op.previewUrl);
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 (anchor?.isConnected) {
252
- if (orig === null || orig === undefined) anchor.removeAttribute(snap.attr);
253
- else anchor.setAttribute(snap.attr, orig);
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.textOp === null && bucket.attrOps.size === 0) {
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
- const edits: Edit[] = [];
292
- for (const { line, column, styleOps, textOp, attrOps } of buckets.values()) {
293
- const list: EditOp[] = [];
294
- for (const [k, v] of styleOps) list.push({ kind: 'set-style', key: k, value: v });
295
- if (textOp !== null) list.push({ kind: 'set-text', value: textOp.value });
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
- list.push({
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 (list.length > 0) edits.push({ line, column, ops: list });
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
- pendingRef.current = new Map();
307
- setPendingCount(0);
308
- if (edits.length === 0) {
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(edits);
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 el = root?.querySelector<HTMLElement>(`[data-slide-loc="${b.line}:${b.column}"]`);
329
- if (!el) continue;
330
- const style = el.style as unknown as Record<string, string>;
331
- for (const [k, v] of b.origStyle) style[k] = v;
332
- if (b.origText !== null) el.textContent = b.origText.value;
333
- for (const [attr, value] of b.origAttrs) {
334
- if (value === null) el.removeAttribute(attr);
335
- else el.setAttribute(attr, value);
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
- if (bucket.textOp !== null && el.textContent !== bucket.textOp.value) {
376
- el.textContent = bucket.textOp.value;
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="Inspect"
587
+ title={t.inspector.inspect}
461
588
  >
462
589
  <Crosshair className="size-3.5" />
463
- <span className="hidden md:inline">Inspect</span>
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
- await Promise.all(tasks);
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={`${total} unsaved ${total === 1 ? 'change' : 'changes'}`}
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 = 'Saved',
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="Undo"
70
- title="Undo"
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="Redo"
81
- title="Redo"
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
- {savedLabel}
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
- Discard
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
- Saving
129
+ {t.common.saving}
127
130
  </>
128
131
  ) : (
129
132
  <>
130
133
  <Save className="size-3.5" />
131
- Save
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
- ? `Processing page ${progress.current.toString().padStart(2, '0')} of ${progress.total.toString().padStart(2, '0')}`
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
- ? 'Opening print dialog…'
11
- : 'Done';
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">Exporting PDF</p>
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>