@textcortex/slidewise 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@textcortex/slidewise",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Embeddable React PPTX editor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,6 +34,34 @@
34
34
  --slidewise-bar-bg: var(--app-bg);
35
35
  --slidewise-accent: var(--accent);
36
36
 
37
+ /* Per-surface background tokens. Map onto internal vars by default so
38
+ existing theme styling is preserved. Hosts override any subset (or
39
+ pass the `surfaces` prop on <Slidewise.Root>) to retint individual
40
+ regions independently. */
41
+ --slidewise-bg-app: var(--app-bg);
42
+ --slidewise-bg-topbar: var(--app-bg);
43
+ --slidewise-bg-rail: var(--rail-bg);
44
+ --slidewise-bg-rail-item: transparent;
45
+ --slidewise-bg-rail-item-active: var(--accent-soft);
46
+ --slidewise-bg-canvas-frame: transparent;
47
+ --slidewise-bg-canvas-from: var(--canvas-bg-from);
48
+ --slidewise-bg-canvas-to: var(--canvas-bg-to);
49
+ --slidewise-bg-bottom-toolbar: var(--toolbar-bg);
50
+ --slidewise-bg-right-panel: var(--rail-bg);
51
+ --slidewise-bg-menu: var(--menu-bg);
52
+ --slidewise-bg-tooltip: var(--menu-bg);
53
+ --slidewise-bg-popover: var(--menu-bg);
54
+ --slidewise-bg-dialog: var(--menu-bg);
55
+ --slidewise-bg-slide: #ffffff;
56
+ --slidewise-bg-selection: var(--accent-soft);
57
+ --slidewise-bg-hover: var(--hover);
58
+ --slidewise-bg-active: var(--active);
59
+ --slidewise-bg-input: var(--input-bg);
60
+ --slidewise-bg-button: transparent;
61
+ --slidewise-bg-button-hover: var(--hover);
62
+ --slidewise-bg-pill: var(--smart-grad);
63
+ --slidewise-bg-unsaved-badge: rgba(232, 80, 76, 0.12);
64
+
37
65
  /* Layout backgrounds */
38
66
  --app-bg: #ffffff;
39
67
  --rail-bg: #fafafb;
@@ -126,6 +154,12 @@
126
154
  --canvas-bg-from: #181c38;
127
155
  --canvas-bg-to: #0c1027;
128
156
 
157
+ /* Dark-mode overrides for the public --slidewise-bg-* surface tokens.
158
+ Hosts can still override any one of these from a higher specificity
159
+ selector or via the `surfaces` prop. */
160
+ --slidewise-bg-slide: #ffffff;
161
+ --slidewise-bg-unsaved-badge: rgba(232, 80, 76, 0.18);
162
+
129
163
  /* Charcoal-purple surface kit from textcortex/platform#7428 — neutral
130
164
  dark base, faint inset highlight, layered shadow that lifts to a
131
165
  subtle plum tint on hover. */
@@ -4,6 +4,7 @@ import {
4
4
  type SlidewiseRootHandle,
5
5
  type SlidewiseRootProps,
6
6
  type HistoryState,
7
+ type SelectionSnapshot,
7
8
  } from "./compound/SlidewiseRoot";
8
9
  import { TopBar } from "./compound/topbar";
9
10
  import {
@@ -14,6 +15,8 @@ import {
14
15
  CanvasFrame,
15
16
  } from "./compound/parts";
16
17
  import type { SlidewiseIcons } from "./compound/IconContext";
18
+ import type { SlidewiseLabels } from "./compound/LabelsContext";
19
+ import type { SlidewiseSurfaces } from "./compound/SurfacesContext";
17
20
  import type { Deck } from "@/lib/types";
18
21
  import "./SlidewiseEditor.css";
19
22
 
@@ -40,6 +43,18 @@ export interface SlidewiseEditorProps {
40
43
  * Undo and Redo buttons reactively without polling `api.canUndo()`.
41
44
  */
42
45
  onHistoryChange?: (state: HistoryState) => void;
46
+ /** Fires when the active slide changes (user click, programmatic goToSlide). */
47
+ onActiveSlideChange?: (slideId: string) => void;
48
+ /** Fires when the selected element ids change. */
49
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
50
+ /** Fires when the canvas zoom level changes. */
51
+ onZoomChange?: (scale: number) => void;
52
+ /** Fires immediately before the host's `onSave` is invoked. */
53
+ onSaveStart?: () => void;
54
+ /** Fires after the host's `onSave` resolves successfully. */
55
+ onSaveSuccess?: () => void;
56
+ /** Fires when the host's `onSave` throws. The error still propagates. */
57
+ onSaveError?: (err: Error) => void;
43
58
  /** Reserved for future use; not enforced yet. */
44
59
  readOnly?: boolean;
45
60
  /** "light" or "dark"; defaults to "light". Ignored after first render. */
@@ -59,6 +74,17 @@ export interface SlidewiseEditorProps {
59
74
  * bundled lucide-react icons.
60
75
  */
61
76
  icons?: SlidewiseIcons;
77
+ /**
78
+ * User-visible string overrides for i18n. Pass any subset; missing
79
+ * entries fall back to English defaults.
80
+ */
81
+ labels?: SlidewiseLabels;
82
+ /**
83
+ * Per-surface background overrides, equivalent to setting the
84
+ * `--slidewise-bg-*` CSS variables. See `SlidewiseSurfaces` for the full
85
+ * list of keys.
86
+ */
87
+ surfaces?: SlidewiseSurfaces;
62
88
  /** Extra class names appended to the editor root. */
63
89
  className?: string;
64
90
  /** Inline style applied to the editor root. */
@@ -97,6 +123,12 @@ export const SlidewiseEditor = forwardRef<
97
123
  onExport,
98
124
  onDirtyChange,
99
125
  onHistoryChange,
126
+ onActiveSlideChange,
127
+ onSelectionChange,
128
+ onZoomChange,
129
+ onSaveStart,
130
+ onSaveSuccess,
131
+ onSaveError,
100
132
  readOnly,
101
133
  theme,
102
134
  initialSlideId,
@@ -104,6 +136,8 @@ export const SlidewiseEditor = forwardRef<
104
136
  showBottomToolbar = true,
105
137
  fontFamily,
106
138
  icons,
139
+ labels,
140
+ surfaces,
107
141
  className,
108
142
  style,
109
143
  },
@@ -116,11 +150,19 @@ export const SlidewiseEditor = forwardRef<
116
150
  onExport,
117
151
  onDirtyChange,
118
152
  onHistoryChange,
153
+ onActiveSlideChange,
154
+ onSelectionChange,
155
+ onZoomChange,
156
+ onSaveStart,
157
+ onSaveSuccess,
158
+ onSaveError,
119
159
  readOnly,
120
160
  theme,
121
161
  initialSlideId,
122
162
  fontFamily,
123
163
  icons,
164
+ labels,
165
+ surfaces,
124
166
  className,
125
167
  style,
126
168
  };
@@ -10,7 +10,10 @@ import { SlidewiseEditor, type SlidewiseEditorHandle } from "./SlidewiseEditor";
10
10
  import { parsePptx, serializeDeck } from "@/lib/pptx";
11
11
  import type { Deck } from "@/lib/types";
12
12
  import type { SlidewiseIcons } from "./compound/IconContext";
13
- import type { HistoryState } from "./compound/SlidewiseRoot";
13
+ import type { SlidewiseLabels } from "./compound/LabelsContext";
14
+ import type { SlidewiseSurfaces } from "./compound/SurfacesContext";
15
+ import { DEFAULT_LABELS } from "./compound/LabelsContext";
16
+ import type { HistoryState, SelectionSnapshot } from "./compound/SlidewiseRoot";
14
17
 
15
18
  export interface SlidewiseFileEditorProps {
16
19
  /**
@@ -55,6 +58,18 @@ export interface SlidewiseFileEditorProps {
55
58
  * `api.canUndo()` / `api.canRedo()`.
56
59
  */
57
60
  onHistoryChange?: (state: HistoryState) => void;
61
+ /** Fires when the active slide changes (user click, programmatic goToSlide). */
62
+ onActiveSlideChange?: (slideId: string) => void;
63
+ /** Fires when the selected element ids change. */
64
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
65
+ /** Fires when the canvas zoom level changes. */
66
+ onZoomChange?: (scale: number) => void;
67
+ /** Fires immediately before the host's `saveBlob` is invoked. */
68
+ onSaveStart?: () => void;
69
+ /** Fires after `saveBlob` resolves successfully. */
70
+ onSaveSuccess?: () => void;
71
+ /** Fires when `saveBlob` throws. The error still propagates. */
72
+ onSaveError?: (err: Error) => void;
58
73
  /**
59
74
  * Fires when `loadBlob` or `parse` throws on mount. The default render
60
75
  * still shows an in-editor "Could not open file" message, but hosts that
@@ -79,6 +94,17 @@ export interface SlidewiseFileEditorProps {
79
94
  * editor's chrome with your own icon set.
80
95
  */
81
96
  icons?: SlidewiseIcons;
97
+ /**
98
+ * User-visible string overrides for i18n. Pass any subset; missing
99
+ * entries fall back to English defaults. The "Unsaved changes" badge
100
+ * and the loading / load-error messages also key off this table.
101
+ */
102
+ labels?: SlidewiseLabels;
103
+ /**
104
+ * Per-surface background overrides; equivalent to setting the
105
+ * `--slidewise-bg-*` CSS variables.
106
+ */
107
+ surfaces?: SlidewiseSurfaces;
82
108
  className?: string;
83
109
  style?: CSSProperties;
84
110
  /**
@@ -114,6 +140,37 @@ export interface SlidewiseFileEditorApi {
114
140
  * handles typical typing/drag bursts automatically.
115
141
  */
116
142
  endCoalesce(): void;
143
+
144
+ /** Switch the active slide. No-op when `slideId` is not in the deck. */
145
+ goToSlide(slideId: string): void;
146
+ /** Advance to the next slide. No-op past the last slide. */
147
+ nextSlide(): void;
148
+ /** Step back to the previous slide. No-op past the first. */
149
+ prevSlide(): void;
150
+
151
+ /** Zoom out by one step. */
152
+ zoomOut(): void;
153
+ /** Zoom in by one step. */
154
+ zoomIn(): void;
155
+ /** Set absolute zoom (1 = 100%); clamped to [0.1, 4]. */
156
+ setZoom(scale: number): void;
157
+
158
+ /**
159
+ * Insert a blank slide after `afterId`, or at the end. Returns the new
160
+ * slide's id. The new slide becomes active.
161
+ */
162
+ addSlide(afterId?: string): string | null;
163
+ /**
164
+ * Duplicate `slideId`. Returns the new slide's id, or `null` if not
165
+ * found. The duplicate becomes active.
166
+ */
167
+ duplicateSlide(slideId: string): string | null;
168
+ /** Delete `slideId`. No-op when the deck would be left empty. */
169
+ deleteSlide(slideId: string): void;
170
+
171
+ /** Current selection snapshot (slide id + selected element ids). */
172
+ getSelection(): SelectionSnapshot;
173
+
117
174
  /** Live deck snapshot. Hosts use this for header badges (slide count, etc.). */
118
175
  getDeck(): Deck | null;
119
176
  getInitialSha256(): string | null;
@@ -138,12 +195,20 @@ export const SlidewiseFileEditor = forwardRef<
138
195
  onDirtyChange,
139
196
  onLoadError,
140
197
  onHistoryChange,
198
+ onActiveSlideChange,
199
+ onSelectionChange,
200
+ onZoomChange,
201
+ onSaveStart,
202
+ onSaveSuccess,
203
+ onSaveError,
141
204
  theme,
142
205
  initialSlideId,
143
206
  showTopBar,
144
207
  showBottomToolbar,
145
208
  fontFamily,
146
209
  icons,
210
+ labels,
211
+ surfaces,
147
212
  className,
148
213
  style,
149
214
  parse = parsePptx,
@@ -211,6 +276,23 @@ export const SlidewiseFileEditor = forwardRef<
211
276
  getHistorySize: () =>
212
277
  editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
213
278
  endCoalesce: () => editorRef.current?.endCoalesce(),
279
+ goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
280
+ nextSlide: () => editorRef.current?.nextSlide(),
281
+ prevSlide: () => editorRef.current?.prevSlide(),
282
+ zoomIn: () => editorRef.current?.zoomIn(),
283
+ zoomOut: () => editorRef.current?.zoomOut(),
284
+ setZoom: (scale: number) => editorRef.current?.setZoom(scale),
285
+ addSlide: (afterId?: string) =>
286
+ editorRef.current?.addSlide(afterId) ?? null,
287
+ duplicateSlide: (slideId: string) =>
288
+ editorRef.current?.duplicateSlide(slideId) ?? null,
289
+ deleteSlide: (slideId: string) =>
290
+ editorRef.current?.deleteSlide(slideId),
291
+ getSelection: () =>
292
+ editorRef.current?.getSelection() ?? {
293
+ slideId: state.deck.slides[0]?.id ?? "",
294
+ elementIds: [],
295
+ },
214
296
  getDeck: () => editorRef.current?.getDeck() ?? state.deck,
215
297
  getInitialSha256: () => initialSha256,
216
298
  };
@@ -241,6 +323,24 @@ export const SlidewiseFileEditor = forwardRef<
241
323
  getHistorySize: () =>
242
324
  editorRef.current?.getHistorySize() ?? { undo: 0, redo: 0 },
243
325
  endCoalesce: () => editorRef.current?.endCoalesce(),
326
+ goToSlide: (slideId: string) => editorRef.current?.goToSlide(slideId),
327
+ nextSlide: () => editorRef.current?.nextSlide(),
328
+ prevSlide: () => editorRef.current?.prevSlide(),
329
+ zoomIn: () => editorRef.current?.zoomIn(),
330
+ zoomOut: () => editorRef.current?.zoomOut(),
331
+ setZoom: (scale: number) => editorRef.current?.setZoom(scale),
332
+ addSlide: (afterId?: string) =>
333
+ editorRef.current?.addSlide(afterId) ?? null,
334
+ duplicateSlide: (slideId: string) =>
335
+ editorRef.current?.duplicateSlide(slideId) ?? null,
336
+ deleteSlide: (slideId: string) =>
337
+ editorRef.current?.deleteSlide(slideId),
338
+ getSelection: () =>
339
+ editorRef.current?.getSelection() ?? {
340
+ slideId:
341
+ state.status === "ready" ? state.deck.slides[0]?.id ?? "" : "",
342
+ elementIds: [],
343
+ },
244
344
  getDeck: () =>
245
345
  state.status === "ready"
246
346
  ? editorRef.current?.getDeck() ?? state.deck
@@ -253,15 +353,18 @@ export const SlidewiseFileEditor = forwardRef<
253
353
  if (state.status === "loading") {
254
354
  return (
255
355
  <div style={{ ...frameStyle, ...style }} className={className}>
256
- <div style={messageStyle}>Loading…</div>
356
+ <div style={messageStyle}>
357
+ {labels?.fileLoading ?? DEFAULT_LABELS.fileLoading}
358
+ </div>
257
359
  </div>
258
360
  );
259
361
  }
260
362
  if (state.status === "error") {
363
+ const fmt = labels?.fileLoadError ?? DEFAULT_LABELS.fileLoadError;
261
364
  return (
262
365
  <div style={{ ...frameStyle, ...style }} className={className}>
263
366
  <div style={{ ...messageStyle, color: "#E8504C" }}>
264
- Could not open file: {state.error.message}
367
+ {fmt(state.error.message)}
265
368
  </div>
266
369
  </div>
267
370
  );
@@ -279,6 +382,8 @@ export const SlidewiseFileEditor = forwardRef<
279
382
  showBottomToolbar={showBottomToolbar}
280
383
  fontFamily={fontFamily}
281
384
  icons={icons}
385
+ labels={labels}
386
+ surfaces={surfaces}
282
387
  onChange={(next) => {
283
388
  onChangeRef.current?.(next);
284
389
  }}
@@ -287,12 +392,22 @@ export const SlidewiseFileEditor = forwardRef<
287
392
  onDirtyChangeRef.current?.(d);
288
393
  }}
289
394
  onHistoryChange={onHistoryChange}
395
+ onActiveSlideChange={onActiveSlideChange}
396
+ onSelectionChange={onSelectionChange}
397
+ onZoomChange={onZoomChange}
398
+ onSaveStart={onSaveStart}
399
+ onSaveSuccess={onSaveSuccess}
400
+ onSaveError={onSaveError}
290
401
  onSave={async (next) => {
291
402
  const blob = await serialize(next);
292
403
  await saveBlob(blob);
293
404
  }}
294
405
  />
295
- {dirty && <UnsavedBadge />}
406
+ {dirty && (
407
+ <UnsavedBadge
408
+ label={labels?.unsavedBadge ?? DEFAULT_LABELS.unsavedBadge}
409
+ />
410
+ )}
296
411
  </div>
297
412
  );
298
413
  });
@@ -313,7 +428,7 @@ const messageStyle: CSSProperties = {
313
428
  color: "#5b6178",
314
429
  };
315
430
 
316
- function UnsavedBadge() {
431
+ function UnsavedBadge({ label }: { label: string }) {
317
432
  return (
318
433
  <div
319
434
  style={{
@@ -321,7 +436,8 @@ function UnsavedBadge() {
321
436
  top: 12,
322
437
  right: 12,
323
438
  padding: "4px 10px",
324
- background: "rgba(232, 80, 76, 0.12)",
439
+ background:
440
+ "var(--slidewise-bg-unsaved-badge, rgba(232, 80, 76, 0.12))",
325
441
  color: "#E8504C",
326
442
  borderRadius: 999,
327
443
  fontFamily: "Inter, system-ui, sans-serif",
@@ -332,7 +448,7 @@ function UnsavedBadge() {
332
448
  pointerEvents: "none",
333
449
  }}
334
450
  >
335
- Unsaved changes
451
+ {label}
336
452
  </div>
337
453
  );
338
454
  }
@@ -0,0 +1,121 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Every user-visible string in the editor's chrome. Pass any subset on
5
+ * `<Slidewise.Root labels={...}>` to localise; missing entries fall back
6
+ * to the English defaults. Use this prop together with `icons` to fully
7
+ * skin Slidewise for non-English deployments.
8
+ *
9
+ * Single-string entries are used both as the button's visible label AND
10
+ * as its `aria-label`. Where the two need to diverge (the Save button
11
+ * cycles through three states; the theme toggle shows different labels
12
+ * per theme), nested keys are provided.
13
+ */
14
+ export interface SlidewiseLabels {
15
+ // TopBar buttons (visible text)
16
+ save?: { idle?: string; saving?: string; saved?: string };
17
+ play?: string;
18
+ stop?: string;
19
+ export?: string;
20
+ undo?: string;
21
+ redo?: string;
22
+ themeToggle?: { toDark?: string; toLight?: string };
23
+
24
+ // Pills + status indicators
25
+ smart?: string;
26
+ unsavedBadge?: string;
27
+
28
+ // Inputs
29
+ titleAriaLabel?: string;
30
+
31
+ // SlideRail (reserved for the future SlideRail compound subparts)
32
+ addSlide?: string;
33
+ duplicateSlide?: string;
34
+ deleteSlide?: string;
35
+
36
+ // Errors
37
+ fileLoadError?: (msg: string) => string;
38
+ fileLoading?: string;
39
+ }
40
+
41
+ /**
42
+ * The fully-resolved label table — every key present, no optionals. Internal
43
+ * components read this directly so they never have to check for undefined.
44
+ */
45
+ export interface ResolvedLabels {
46
+ save: { idle: string; saving: string; saved: string };
47
+ play: string;
48
+ stop: string;
49
+ export: string;
50
+ undo: string;
51
+ redo: string;
52
+ themeToggle: { toDark: string; toLight: string };
53
+ smart: string;
54
+ unsavedBadge: string;
55
+ titleAriaLabel: string;
56
+ addSlide: string;
57
+ duplicateSlide: string;
58
+ deleteSlide: string;
59
+ fileLoadError: (msg: string) => string;
60
+ fileLoading: string;
61
+ }
62
+
63
+ export const DEFAULT_LABELS: ResolvedLabels = {
64
+ save: { idle: "Save", saving: "Saving…", saved: "Saved" },
65
+ play: "Play",
66
+ stop: "Stop",
67
+ export: "Export",
68
+ undo: "Undo",
69
+ redo: "Redo",
70
+ themeToggle: { toDark: "Dark mode", toLight: "Light mode" },
71
+ smart: "Smart",
72
+ unsavedBadge: "Unsaved changes",
73
+ titleAriaLabel: "Deck title",
74
+ addSlide: "Add slide",
75
+ duplicateSlide: "Duplicate slide",
76
+ deleteSlide: "Delete slide",
77
+ fileLoadError: (msg) => `Could not open file: ${msg}`,
78
+ fileLoading: "Loading…",
79
+ };
80
+
81
+ function mergeLabels(
82
+ overrides: SlidewiseLabels | undefined
83
+ ): ResolvedLabels {
84
+ if (!overrides) return DEFAULT_LABELS;
85
+ return {
86
+ save: { ...DEFAULT_LABELS.save, ...overrides.save },
87
+ play: overrides.play ?? DEFAULT_LABELS.play,
88
+ stop: overrides.stop ?? DEFAULT_LABELS.stop,
89
+ export: overrides.export ?? DEFAULT_LABELS.export,
90
+ undo: overrides.undo ?? DEFAULT_LABELS.undo,
91
+ redo: overrides.redo ?? DEFAULT_LABELS.redo,
92
+ themeToggle: { ...DEFAULT_LABELS.themeToggle, ...overrides.themeToggle },
93
+ smart: overrides.smart ?? DEFAULT_LABELS.smart,
94
+ unsavedBadge: overrides.unsavedBadge ?? DEFAULT_LABELS.unsavedBadge,
95
+ titleAriaLabel: overrides.titleAriaLabel ?? DEFAULT_LABELS.titleAriaLabel,
96
+ addSlide: overrides.addSlide ?? DEFAULT_LABELS.addSlide,
97
+ duplicateSlide: overrides.duplicateSlide ?? DEFAULT_LABELS.duplicateSlide,
98
+ deleteSlide: overrides.deleteSlide ?? DEFAULT_LABELS.deleteSlide,
99
+ fileLoadError: overrides.fileLoadError ?? DEFAULT_LABELS.fileLoadError,
100
+ fileLoading: overrides.fileLoading ?? DEFAULT_LABELS.fileLoading,
101
+ };
102
+ }
103
+
104
+ const LabelsContext = createContext<ResolvedLabels>(DEFAULT_LABELS);
105
+
106
+ export function LabelsProvider({
107
+ labels,
108
+ children,
109
+ }: {
110
+ labels: SlidewiseLabels | undefined;
111
+ children: ReactNode;
112
+ }) {
113
+ const resolved = useMemo(() => mergeLabels(labels), [labels]);
114
+ return (
115
+ <LabelsContext.Provider value={resolved}>{children}</LabelsContext.Provider>
116
+ );
117
+ }
118
+
119
+ export function useLabels(): ResolvedLabels {
120
+ return useContext(LabelsContext);
121
+ }