@open-slide/core 0.0.7 → 0.0.9

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 (32) hide show
  1. package/dist/{build-cUKUY4bh.js → build-pqF4W1Yi.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-DOcMmFJ7.js → config-CtwxMYv9.js} +375 -45
  4. package/dist/{dev-Brzmgu64.js → dev-CJX97uiy.js} +1 -1
  5. package/dist/{preview-Bf8iFXA-.js → preview-IuLPcL5y.js} +1 -1
  6. package/dist/vite/index.js +1 -1
  7. package/package.json +3 -1
  8. package/src/app/App.tsx +2 -0
  9. package/src/app/components/PdfProgressToast.tsx +23 -0
  10. package/src/app/components/Player.tsx +18 -3
  11. package/src/app/components/inspector/CommentWidget.tsx +1 -1
  12. package/src/app/components/inspector/InspectOverlay.tsx +81 -41
  13. package/src/app/components/inspector/InspectorPanel.tsx +805 -0
  14. package/src/app/components/inspector/InspectorProvider.tsx +199 -13
  15. package/src/app/components/inspector/SaveBar.tsx +77 -0
  16. package/src/app/components/ui/input.tsx +21 -0
  17. package/src/app/components/ui/label.tsx +24 -0
  18. package/src/app/components/ui/progress.tsx +31 -0
  19. package/src/app/components/ui/select.tsx +190 -0
  20. package/src/app/components/ui/slider.tsx +61 -0
  21. package/src/app/components/ui/sonner.tsx +38 -0
  22. package/src/app/components/ui/textarea.tsx +18 -0
  23. package/src/app/components/ui/toggle-group.tsx +83 -0
  24. package/src/app/components/ui/toggle.tsx +45 -0
  25. package/src/app/components/ui/tooltip.tsx +55 -0
  26. package/src/app/lib/export-pdf.ts +197 -0
  27. package/src/app/lib/inspector/fiber.ts +40 -5
  28. package/src/app/lib/inspector/useEditor.ts +61 -0
  29. package/src/app/lib/print-ready.ts +58 -0
  30. package/src/app/lib/useWheelPageNavigation.ts +92 -0
  31. package/src/app/routes/Slide.tsx +91 -6
  32. package/src/app/components/inspector/CommentPopover.tsx +0 -94
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DOcMmFJ7.js";
1
+ import { createViteConfig } from "./config-CtwxMYv9.js";
2
2
  import path from "node:path";
3
3
  import { build as build$1, mergeConfig } from "vite";
4
4
 
package/dist/cli/bin.js CHANGED
@@ -22,15 +22,15 @@ async function run(argv) {
22
22
  const program = new Command();
23
23
  program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
24
24
  program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
25
- const { dev } = await import("../dev-Brzmgu64.js");
25
+ const { dev } = await import("../dev-CJX97uiy.js");
26
26
  await dev(flags);
27
27
  });
28
28
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
29
- const { build } = await import("../build-cUKUY4bh.js");
29
+ const { build } = await import("../build-pqF4W1Yi.js");
30
30
  await build(flags);
31
31
  });
32
32
  program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
33
- const { preview } = await import("../preview-Bf8iFXA-.js");
33
+ const { preview } = await import("../preview-IuLPcL5y.js");
34
34
  await preview(flags);
35
35
  });
36
36
  await program.parseAsync(argv, { from: "user" });
@@ -9,6 +9,42 @@ import { parse } from "@babel/parser";
9
9
  import fg from "fast-glob";
10
10
  import { loadConfigFromFile } from "vite";
11
11
 
12
+ //#region src/vite/babel-walk.ts
13
+ const SKIP_KEYS = new Set([
14
+ "loc",
15
+ "start",
16
+ "end",
17
+ "type",
18
+ "extra",
19
+ "leadingComments",
20
+ "trailingComments",
21
+ "innerComments"
22
+ ]);
23
+ function walkJsx(ast, visit) {
24
+ let stopped = false;
25
+ const walk = (node) => {
26
+ if (stopped || !node || typeof node !== "object") return;
27
+ if (Array.isArray(node)) {
28
+ for (const c of node) walk(c);
29
+ return;
30
+ }
31
+ const n = node;
32
+ if (typeof n.type !== "string") return;
33
+ if (n.type === "JSXElement" || n.type === "JSXFragment") {
34
+ if (visit(n) === "stop") {
35
+ stopped = true;
36
+ return;
37
+ }
38
+ }
39
+ for (const key of Object.keys(n)) {
40
+ if (SKIP_KEYS.has(key)) continue;
41
+ walk(n[key]);
42
+ }
43
+ };
44
+ walk(ast);
45
+ }
46
+
47
+ //#endregion
12
48
  //#region src/vite/comments-plugin.ts
13
49
  const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
14
50
  const SLIDE_ID_RE$1 = /^[a-z0-9_-]+$/i;
@@ -86,42 +122,19 @@ function lineIndent(source, lineNumber) {
86
122
  const m = source.slice(start, start + 200).match(/^[ \t]*/);
87
123
  return m?.[0] ?? "";
88
124
  }
89
- /**
90
- * Walk the AST, collect every JSXElement/JSXFragment whose location encloses
91
- * the click point, ordered innermost-first.
92
- *
93
- * "Encloses" here is inclusive at the start (so a click on the opening `<`
94
- * counts as inside) and exclusive at the end. We deliberately don't trust
95
- * Babel's `_debugSource` line/column to be exact — HMR or upstream transforms
96
- * can shift it slightly — so we treat the click as a probe and pick the
97
- * tightest JSX container around it.
98
- */
99
125
  function findJsxAncestors(ast, line, column) {
100
126
  const hits = [];
101
- const walk = (node) => {
102
- if (!node || typeof node !== "object") return;
103
- if (Array.isArray(node)) {
104
- for (const c of node) walk(c);
105
- return;
106
- }
107
- const n = node;
108
- if (typeof n.type !== "string") return;
109
- if ((n.type === "JSXElement" || n.type === "JSXFragment") && n.loc) {
110
- const s = n.loc.start;
111
- const e = n.loc.end;
112
- const afterStart = line > s.line || line === s.line && column >= s.column;
113
- const beforeEnd = line < e.line || line === e.line && column < e.column;
114
- if (afterStart && beforeEnd) hits.push({
115
- node: n,
116
- size: n.end - n.start
117
- });
118
- }
119
- for (const key of Object.keys(n)) {
120
- if (key === "loc" || key === "start" || key === "end" || key === "type" || key === "extra" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
121
- walk(n[key]);
122
- }
123
- };
124
- walk(ast);
127
+ walkJsx(ast, (n) => {
128
+ if (!n.loc) return;
129
+ const s = n.loc.start;
130
+ const e = n.loc.end;
131
+ const afterStart = line > s.line || line === s.line && column >= s.column;
132
+ const beforeEnd = line < e.line || line === e.line && column < e.column;
133
+ if (afterStart && beforeEnd) hits.push({
134
+ node: n,
135
+ size: n.end - n.start
136
+ });
137
+ });
125
138
  hits.sort((a, b) => a.size - b.size);
126
139
  return hits.map((h) => h.node);
127
140
  }
@@ -146,16 +159,6 @@ function planInsertion(source, target) {
146
159
  }
147
160
  return null;
148
161
  }
149
- /**
150
- * Resolve a click on the slide page (line/col from React fiber's
151
- * `_debugSource`) to an in-source offset where we can safely splice a
152
- * `@slide-comment` marker.
153
- *
154
- * Strategy: parse the file, find every JSX container around the click, and
155
- * walk innermost → outermost looking for the first one we can insert *inside*
156
- * (i.e. not self-closing). Self-closing elements like `<img/>` get hoisted to
157
- * their nearest non-self-closing ancestor.
158
- */
159
162
  function findInsertion(source, line, column) {
160
163
  let ast;
161
164
  try {
@@ -181,6 +184,181 @@ function offsetToLine(source, offset) {
181
184
  for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
182
185
  return line;
183
186
  }
187
+ function parseSource(source) {
188
+ try {
189
+ return parse(source, {
190
+ sourceType: "module",
191
+ plugins: ["typescript", "jsx"],
192
+ errorRecovery: true
193
+ });
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+ function findInnermostJsxElement(source, line, column) {
199
+ const ast = parseSource(source);
200
+ if (!ast) return null;
201
+ const exact = findJsxByStart(ast, line, column);
202
+ if (exact) return exact;
203
+ const ancestors = findJsxAncestors(ast, line, column);
204
+ for (const n of ancestors) if (n.type === "JSXElement") return n;
205
+ return null;
206
+ }
207
+ function findJsxByStart(ast, line, column) {
208
+ let hit = null;
209
+ walkJsx(ast, (n) => {
210
+ if (n.type !== "JSXElement" || !n.loc) return;
211
+ const s = n.loc.start;
212
+ if (s.line === line && s.column === column) {
213
+ hit = n;
214
+ return "stop";
215
+ }
216
+ });
217
+ return hit;
218
+ }
219
+ function jsString(s) {
220
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
221
+ }
222
+ function findStyleAttr(opening) {
223
+ const attrs = opening.attributes ?? [];
224
+ for (const attr of attrs) {
225
+ if (attr.type !== "JSXAttribute") continue;
226
+ const name = attr.name;
227
+ if (name?.type === "JSXIdentifier" && name.name === "style") return attr;
228
+ }
229
+ return null;
230
+ }
231
+ function buildStyleSplice(source, element, ops) {
232
+ const opening = element.openingElement;
233
+ if (!opening) return { error: "no opening element" };
234
+ const existing = findStyleAttr(opening);
235
+ const style = new Map();
236
+ if (existing) {
237
+ const value = existing.value;
238
+ if (!value || value.type !== "JSXExpressionContainer") return { error: "style attribute has unsupported form" };
239
+ const expr = value.expression;
240
+ if (expr.type !== "ObjectExpression") return { error: "style is not a literal object" };
241
+ const properties = expr.properties;
242
+ for (const prop of properties) {
243
+ if (prop.type !== "ObjectProperty") return { error: "style contains spread or method" };
244
+ const p = prop;
245
+ if (p.computed) return { error: "style has computed key" };
246
+ let keyName = null;
247
+ if (p.key.type === "Identifier" && p.key.name) keyName = p.key.name;
248
+ else if (p.key.type === "StringLiteral" && typeof p.key.value === "string") keyName = p.key.value;
249
+ if (!keyName) return { error: "style has unsupported key" };
250
+ style.set(keyName, source.slice(p.value.start, p.value.end));
251
+ }
252
+ }
253
+ for (const op of ops) if (op.value === null) style.delete(op.key);
254
+ else style.set(op.key, jsString(op.value));
255
+ if (style.size === 0) {
256
+ if (!existing) return null;
257
+ let from = existing.start;
258
+ if (from > 0 && source[from - 1] === " ") from -= 1;
259
+ return {
260
+ from,
261
+ to: existing.end,
262
+ text: ""
263
+ };
264
+ }
265
+ const propsText = Array.from(style.entries()).map(([k, v]) => `${k}: ${v}`).join(", ");
266
+ const newAttr = `style={{ ${propsText} }}`;
267
+ if (existing) return {
268
+ from: existing.start,
269
+ to: existing.end,
270
+ text: newAttr
271
+ };
272
+ const name = opening.name;
273
+ return {
274
+ from: name.end,
275
+ to: name.end,
276
+ text: ` ${newAttr}`
277
+ };
278
+ }
279
+ function formatJsxText(value) {
280
+ if (/[{}<>]/.test(value) || /^\s|\s$/.test(value) || value === "") return `{${jsString(value)}}`;
281
+ return value;
282
+ }
283
+ function buildTextSplice(element, value) {
284
+ const children = element.children ?? [];
285
+ if (children.length === 0) return { error: "element has no children to edit" };
286
+ const meaningful = children.filter((c) => {
287
+ if (c.type === "JSXText") {
288
+ const v = c.value;
289
+ return v.trim() !== "";
290
+ }
291
+ return true;
292
+ });
293
+ if (meaningful.length !== 1) return { error: "element has complex children" };
294
+ const child = meaningful[0];
295
+ if (child.type === "JSXText") {
296
+ const first = children[0];
297
+ const last = children[children.length - 1];
298
+ return {
299
+ from: first.start,
300
+ to: last.end,
301
+ text: formatJsxText(value)
302
+ };
303
+ }
304
+ if (child.type === "JSXExpressionContainer") {
305
+ const expr = child.expression;
306
+ if (expr.type === "StringLiteral" || expr.type === "NumericLiteral") return {
307
+ from: child.start,
308
+ to: child.end,
309
+ text: `{${jsString(value)}}`
310
+ };
311
+ return { error: "element has dynamic expression child" };
312
+ }
313
+ return { error: "element has complex children" };
314
+ }
315
+ function applyEdit(source, line, column, ops) {
316
+ if (ops.length === 0) return {
317
+ ok: true,
318
+ source
319
+ };
320
+ const element = findInnermostJsxElement(source, line, column);
321
+ if (!element) return {
322
+ ok: false,
323
+ status: 422,
324
+ error: "no JSX element at location"
325
+ };
326
+ const splices = [];
327
+ const styleOps = ops.flatMap((op) => op.kind === "set-style" ? [{
328
+ key: op.key,
329
+ value: op.value
330
+ }] : []);
331
+ if (styleOps.length > 0) {
332
+ const result = buildStyleSplice(source, element, styleOps);
333
+ if (result && "error" in result) return {
334
+ ok: false,
335
+ status: 422,
336
+ error: result.error
337
+ };
338
+ if (result) splices.push(result);
339
+ }
340
+ for (const op of ops) {
341
+ if (op.kind !== "set-text") continue;
342
+ const result = buildTextSplice(element, op.value);
343
+ if ("error" in result) return {
344
+ ok: false,
345
+ status: 422,
346
+ error: result.error
347
+ };
348
+ splices.push(result);
349
+ }
350
+ if (splices.length === 0) return {
351
+ ok: true,
352
+ source
353
+ };
354
+ splices.sort((a, b) => b.from - a.from);
355
+ let next = source;
356
+ for (const sp of splices) next = next.slice(0, sp.from) + sp.text + next.slice(sp.to);
357
+ return {
358
+ ok: true,
359
+ source: next
360
+ };
361
+ }
184
362
  function commentsPlugin(opts) {
185
363
  const userCwd = opts.userCwd;
186
364
  const slidesDir = opts.slidesDir ?? "slides";
@@ -188,6 +366,77 @@ function commentsPlugin(opts) {
188
366
  name: "open-slide:comments",
189
367
  apply: "serve",
190
368
  configureServer(server) {
369
+ server.middlewares.use("/__edit", async (req, res, next) => {
370
+ const url = new URL(req.url ?? "/", "http://local");
371
+ const method = req.method ?? "GET";
372
+ if (method !== "POST") return next();
373
+ try {
374
+ if (url.pathname === "/") {
375
+ const body = await readBody$1(req);
376
+ const slideId = body.slideId ?? "";
377
+ const file = resolveSlidePath(userCwd, slidesDir, slideId);
378
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
379
+ if (!body.line || body.line < 1) return json$1(res, 400, { error: "invalid line" });
380
+ if (!Array.isArray(body.ops)) return json$1(res, 400, { error: "missing ops" });
381
+ let source;
382
+ try {
383
+ source = await fs.readFile(file, "utf8");
384
+ } catch {
385
+ return json$1(res, 404, { error: "slide not found" });
386
+ }
387
+ const result = applyEdit(source, body.line, body.column ?? 0, body.ops);
388
+ if (!result.ok) return json$1(res, result.status, { error: result.error });
389
+ const changed = result.source !== source;
390
+ if (changed) await fs.writeFile(file, result.source, "utf8");
391
+ return json$1(res, 200, {
392
+ ok: true,
393
+ changed
394
+ });
395
+ }
396
+ if (url.pathname === "/batch") {
397
+ const body = await readBody$1(req);
398
+ const slideId = body.slideId ?? "";
399
+ const file = resolveSlidePath(userCwd, slidesDir, slideId);
400
+ if (!file) return json$1(res, 400, { error: "invalid slideId" });
401
+ if (!Array.isArray(body.edits)) return json$1(res, 400, { error: "missing edits" });
402
+ let source;
403
+ try {
404
+ source = await fs.readFile(file, "utf8");
405
+ } catch {
406
+ return json$1(res, 404, { error: "slide not found" });
407
+ }
408
+ const original = source;
409
+ const results = [];
410
+ for (const edit of body.edits) {
411
+ if (!edit.line || edit.line < 1 || !Array.isArray(edit.ops)) {
412
+ results.push({
413
+ ok: false,
414
+ error: "invalid edit"
415
+ });
416
+ continue;
417
+ }
418
+ const r = applyEdit(source, edit.line, edit.column ?? 0, edit.ops);
419
+ if (r.ok) {
420
+ source = r.source;
421
+ results.push({ ok: true });
422
+ } else results.push({
423
+ ok: false,
424
+ error: r.error
425
+ });
426
+ }
427
+ const changed = source !== original;
428
+ if (changed) await fs.writeFile(file, source, "utf8");
429
+ return json$1(res, 200, {
430
+ ok: true,
431
+ changed,
432
+ results
433
+ });
434
+ }
435
+ return next();
436
+ } catch (err) {
437
+ json$1(res, 500, { error: String(err.message ?? err) });
438
+ }
439
+ });
191
440
  server.middlewares.use("/__comments", async (req, res, next) => {
192
441
  const url = new URL(req.url ?? "/", "http://local");
193
442
  const method = req.method ?? "GET";
@@ -564,6 +813,74 @@ function filesPlugin(opts) {
564
813
  };
565
814
  }
566
815
 
816
+ //#endregion
817
+ //#region src/vite/loc-tags-plugin.ts
818
+ function isHostJsxName(name) {
819
+ if (!name || typeof name !== "object") return false;
820
+ const n = name;
821
+ return n.type === "JSXIdentifier" && typeof n.name === "string" && /^[a-z]/.test(n.name);
822
+ }
823
+ function alreadyTagged(opening) {
824
+ const attrs = opening.attributes ?? [];
825
+ for (const attr of attrs) {
826
+ if (attr.type !== "JSXAttribute") continue;
827
+ const name = attr.name;
828
+ if (name?.type === "JSXIdentifier" && name.name === "data-slide-loc") return true;
829
+ }
830
+ return false;
831
+ }
832
+ function injectLocTags(code) {
833
+ let ast;
834
+ try {
835
+ ast = parse(code, {
836
+ sourceType: "module",
837
+ plugins: ["typescript", "jsx"],
838
+ errorRecovery: true
839
+ });
840
+ } catch {
841
+ return null;
842
+ }
843
+ const insertions = [];
844
+ walkJsx(ast, (node) => {
845
+ if (node.type !== "JSXElement") return;
846
+ const opening = node.openingElement;
847
+ if (!opening) return;
848
+ const name = opening.name;
849
+ if (!isHostJsxName(name)) return;
850
+ if (alreadyTagged(opening)) return;
851
+ const loc = node.loc;
852
+ if (!loc) return;
853
+ insertions.push({
854
+ offset: name.end,
855
+ text: ` data-slide-loc="${loc.start.line}:${loc.start.column}"`
856
+ });
857
+ });
858
+ if (insertions.length === 0) return null;
859
+ insertions.sort((a, b) => b.offset - a.offset);
860
+ let next = code;
861
+ for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
862
+ return next;
863
+ }
864
+ function locTagsPlugin(opts) {
865
+ const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
866
+ return {
867
+ name: "open-slide:loc-tags",
868
+ apply: "serve",
869
+ enforce: "pre",
870
+ transform(code, id) {
871
+ const filePath = id.split("?")[0];
872
+ if (!filePath.startsWith(slidesRoot + path.sep)) return null;
873
+ if (!filePath.endsWith(`${path.sep}index.tsx`)) return null;
874
+ const next = injectLocTags(code);
875
+ if (next === null) return null;
876
+ return {
877
+ code: next,
878
+ map: null
879
+ };
880
+ }
881
+ };
882
+ }
883
+
567
884
  //#endregion
568
885
  //#region src/vite/open-slide-plugin.ts
569
886
  const CONFIG_FILE = "open-slide.config.ts";
@@ -732,6 +1049,10 @@ async function createViteConfig(opts) {
732
1049
  root: APP_ROOT,
733
1050
  configFile: false,
734
1051
  plugins: [
1052
+ locTagsPlugin({
1053
+ userCwd,
1054
+ slidesDir
1055
+ }),
735
1056
  react(),
736
1057
  tailwindcss(),
737
1058
  openSlidePlugin({
@@ -758,7 +1079,16 @@ async function createViteConfig(opts) {
758
1079
  "tailwind-merge",
759
1080
  "class-variance-authority",
760
1081
  "emoji-picker-react"
761
- ]
1082
+ ],
1083
+ esbuildOptions: { plugins: [{
1084
+ name: "open-slide:virtual-externals",
1085
+ setup(build$1) {
1086
+ build$1.onResolve({ filter: /^virtual:open-slide\// }, (args) => ({
1087
+ path: args.path,
1088
+ external: true
1089
+ }));
1090
+ }
1091
+ }] }
762
1092
  },
763
1093
  server: {
764
1094
  port: config.port ?? 5173,
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DOcMmFJ7.js";
1
+ import { createViteConfig } from "./config-CtwxMYv9.js";
2
2
  import { createServer, mergeConfig } from "vite";
3
3
 
4
4
  //#region src/cli/dev.ts
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-DOcMmFJ7.js";
1
+ import { createViteConfig } from "./config-CtwxMYv9.js";
2
2
  import { mergeConfig, preview as preview$1 } from "vite";
3
3
 
4
4
  //#region src/cli/preview.ts
@@ -1,3 +1,3 @@
1
- import { createViteConfig } from "../config-DOcMmFJ7.js";
1
+ import { createViteConfig } from "../config-CtwxMYv9.js";
2
2
 
3
3
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,11 +53,13 @@
53
53
  "fast-glob": "^3.3.2",
54
54
  "fflate": "^0.8.2",
55
55
  "lucide-react": "^1.8.0",
56
+ "next-themes": "^0.4.6",
56
57
  "radix-ui": "^1.4.3",
57
58
  "react": "^18.3.1",
58
59
  "react-dom": "^18.3.1",
59
60
  "react-router-dom": "^6.26.2",
60
61
  "shadcn": "^4.3.0",
62
+ "sonner": "^2.0.7",
61
63
  "tailwind-merge": "^3.5.0",
62
64
  "tailwindcss": "^4.2.2",
63
65
  "tw-animate-css": "^1.4.0",
package/src/app/App.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
2
  import config from 'virtual:open-slide/config';
3
+ import { Toaster } from './components/ui/sonner';
3
4
  import { Home } from './routes/Home';
4
5
  import { Slide } from './routes/Slide';
5
6
 
@@ -11,6 +12,7 @@ export function App() {
11
12
  <Route path="/s/:slideId" element={<Slide />} />
12
13
  <Route path="*" element={<NotFound />} />
13
14
  </Routes>
15
+ <Toaster />
14
16
  </BrowserRouter>
15
17
  );
16
18
  }
@@ -0,0 +1,23 @@
1
+ import { Loader2 } from 'lucide-react';
2
+ import type { PdfExportProgress } from '../lib/export-pdf';
3
+ import { Progress } from './ui/progress';
4
+
5
+ export function PdfProgressToast({ progress }: { progress: PdfExportProgress }) {
6
+ const text =
7
+ progress.phase === 'processing'
8
+ ? `Processing slide ${progress.current} / ${progress.total}`
9
+ : progress.phase === 'printing'
10
+ ? 'Opening print dialog…'
11
+ : 'Done';
12
+
13
+ return (
14
+ <div className="flex w-80 items-start gap-3 rounded-md border bg-popover px-4 py-3 text-popover-foreground shadow-lg">
15
+ <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin text-primary" />
16
+ <div className="min-w-0 flex-1">
17
+ <p className="text-sm font-medium">Exporting PDF</p>
18
+ <p className="truncate text-xs text-muted-foreground">{text}</p>
19
+ <Progress value={Math.round(progress.percent)} className="mt-2 h-1.5" />
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -1,4 +1,5 @@
1
- import { useEffect, useRef } from 'react';
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
2
3
  import type { Page } from '../lib/sdk';
3
4
  import { SlideCanvas } from './SlideCanvas';
4
5
 
@@ -12,6 +13,20 @@ type Props = {
12
13
 
13
14
  export function Player({ pages, index, onIndexChange, onExit, allowExit = true }: Props) {
14
15
  const rootRef = useRef<HTMLDivElement>(null);
16
+ const goPrev = useCallback(() => {
17
+ if (index > 0) onIndexChange(index - 1);
18
+ }, [index, onIndexChange]);
19
+ const goNext = useCallback(() => {
20
+ if (index < pages.length - 1) onIndexChange(index + 1);
21
+ }, [index, pages.length, onIndexChange]);
22
+
23
+ useWheelPageNavigation({
24
+ ref: rootRef,
25
+ canPrev: index > 0,
26
+ canNext: index < pages.length - 1,
27
+ onPrev: goPrev,
28
+ onNext: goNext,
29
+ });
15
30
 
16
31
  useEffect(() => {
17
32
  const el = rootRef.current;
@@ -69,14 +84,14 @@ export function Player({ pages, index, onIndexChange, onExit, allowExit = true }
69
84
  <button
70
85
  type="button"
71
86
  aria-label="Previous page"
72
- onClick={() => index > 0 && onIndexChange(index - 1)}
87
+ onClick={goPrev}
73
88
  disabled={index === 0}
74
89
  className="absolute inset-y-0 left-0 z-10 w-[30%]"
75
90
  />
76
91
  <button
77
92
  type="button"
78
93
  aria-label="Next page"
79
- onClick={() => index < pages.length - 1 && onIndexChange(index + 1)}
94
+ onClick={goNext}
80
95
  disabled={index === pages.length - 1}
81
96
  className="absolute inset-y-0 right-0 z-10 w-[30%]"
82
97
  />
@@ -8,7 +8,7 @@ export function CommentWidget() {
8
8
  const count = comments.length;
9
9
 
10
10
  return (
11
- <div data-inspector-ui className="fixed right-4 bottom-4 z-40 flex flex-col items-end gap-2">
11
+ <div data-inspector-ui className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2">
12
12
  {open && (
13
13
  <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
14
14
  <div className="flex items-center justify-between border-b px-3 py-2">