@open-slide/core 1.7.0 → 1.8.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.
Files changed (34) hide show
  1. package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
  4. package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
  5. package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
  6. package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +4 -13
  11. package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
  12. package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/create-theme/SKILL.md +1 -1
  17. package/src/app/components/inspector/comment-widget.tsx +16 -2
  18. package/src/app/components/player.tsx +12 -17
  19. package/src/app/components/sidebar/folder-item.tsx +7 -2
  20. package/src/app/components/sidebar/sidebar.tsx +87 -16
  21. package/src/app/lib/folders.ts +28 -0
  22. package/src/app/lib/inspector/fiber.test.ts +154 -0
  23. package/src/app/lib/inspector/fiber.ts +12 -1
  24. package/src/app/lib/use-click-page-navigation.ts +52 -0
  25. package/src/app/lib/use-is-mobile.ts +21 -0
  26. package/src/app/routes/home-shell.tsx +8 -0
  27. package/src/app/routes/home.tsx +1 -1
  28. package/src/app/routes/slide.tsx +77 -53
  29. package/src/locale/en.ts +1 -5
  30. package/src/locale/ja.ts +1 -5
  31. package/src/locale/types.ts +1 -5
  32. package/src/locale/zh-cn.ts +1 -5
  33. package/src/locale/zh-tw.ts +1 -5
  34. package/src/app/components/click-nav-zones.tsx +0 -36
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.js";
3
3
  import path from "node:path";
4
4
  import { build as build$1, mergeConfig } from "vite";
5
5
 
package/dist/cli/bin.js CHANGED
@@ -57,15 +57,15 @@ async function run(argv) {
57
57
  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)"));
58
58
  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").option("--no-skills-check", "skip the built-in skills drift check").action(async (flags) => {
59
59
  if (flags.skillsCheck !== false) await runSkillsDriftCheck(resolveBuiltinSkillsDir());
60
- const { dev } = await import("../dev-DpCIRbhT.js");
60
+ const { dev } = await import("../dev-kLS_4CAI.js");
61
61
  await dev(flags);
62
62
  });
63
63
  program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
64
- const { build } = await import("../build-tLrkKUHr.js");
64
+ const { build } = await import("../build-CCZDC8eF.js");
65
65
  await build(flags);
66
66
  });
67
67
  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) => {
68
- const { preview } = await import("../preview-BSGlM6Se.js");
68
+ const { preview } = await import("../preview-DUkOjOx8.js");
69
69
  await preview(flags);
70
70
  });
71
71
  program.command("sync:skills").description("Sync built-in skills from @open-slide/core into this workspace").option("--dry-run", "show what would change without writing").action(async (flags) => {
@@ -1157,21 +1157,20 @@ function findReferencedAssets(source, assetPaths) {
1157
1157
  if (!ast) return referenced;
1158
1158
  const wanted = new Set(assetPaths);
1159
1159
  const identToPath = new Map();
1160
+ const importLocals = new Set();
1160
1161
  for (const imp of findImports$1(ast)) {
1161
1162
  if (!imp.defaultIdent) continue;
1162
- if (wanted.has(imp.source)) identToPath.set(imp.defaultIdent, imp.source);
1163
+ if (!wanted.has(imp.source)) continue;
1164
+ identToPath.set(imp.defaultIdent, imp.source);
1165
+ for (const spec of imp.node.specifiers) if (t$3.isImportDefaultSpecifier(spec) && spec.local.name === imp.defaultIdent) importLocals.add(spec.local);
1163
1166
  }
1164
1167
  if (identToPath.size === 0) return referenced;
1165
- walkJsx(ast, (n) => {
1166
- if (!t$3.isJSXElement(n)) return;
1167
- const opening = n.openingElement;
1168
- if (!t$3.isJSXIdentifier(opening.name) || opening.name.name !== "img") return;
1169
- const src = findJsxAttr(opening, "src");
1170
- if (!src?.value || !t$3.isJSXExpressionContainer(src.value)) return;
1171
- const expr = src.value.expression;
1172
- if (!t$3.isIdentifier(expr)) return;
1173
- const p = identToPath.get(expr.name);
1174
- if (p) referenced.add(p);
1168
+ walkAll(ast, (n) => {
1169
+ if (!t$3.isIdentifier(n)) return;
1170
+ const p = identToPath.get(n.name);
1171
+ if (!p) return;
1172
+ if (importLocals.has(n)) return;
1173
+ referenced.add(p);
1175
1174
  });
1176
1175
  return referenced;
1177
1176
  }
@@ -2446,6 +2445,19 @@ function validateName(v) {
2446
2445
  if (trimmed.length < 1 || trimmed.length > 40) return null;
2447
2446
  return trimmed;
2448
2447
  }
2448
+ function validateReorder(v, current) {
2449
+ if (!Array.isArray(v) || v.length !== current.length) return null;
2450
+ const known = new Set(current.map((f) => f.id));
2451
+ const seen = new Set();
2452
+ const out = [];
2453
+ for (const id of v) {
2454
+ if (typeof id !== "string" || !FOLDER_ID_RE.test(id)) return null;
2455
+ if (!known.has(id) || seen.has(id)) return null;
2456
+ seen.add(id);
2457
+ out.push(id);
2458
+ }
2459
+ return out;
2460
+ }
2449
2461
  function validateIcon(v) {
2450
2462
  if (!v || typeof v !== "object") return null;
2451
2463
  const icon = v;
@@ -2513,6 +2525,18 @@ function registerFolderRoutes(server, ctx) {
2513
2525
  await writeManifest(ctx.manifestPath, manifest);
2514
2526
  return json$2(res, 200, { ok: true });
2515
2527
  }
2528
+ if (method === "PUT" && url.pathname === "/reorder") {
2529
+ const requestCheck = validateMutationRequest(req, { requireJsonBody: true });
2530
+ if (!requestCheck.ok) return json$2(res, requestCheck.status, { error: requestCheck.error });
2531
+ const body = await readBody$2(req);
2532
+ const manifest = await readManifest(ctx.manifestPath);
2533
+ const ids = validateReorder(body.ids, manifest.folders);
2534
+ if (!ids) return json$2(res, 400, { error: "invalid ids" });
2535
+ const byId = new Map(manifest.folders.map((f) => [f.id, f]));
2536
+ manifest.folders = ids.map((id) => byId.get(id));
2537
+ await writeManifest(ctx.manifestPath, manifest);
2538
+ return json$2(res, 200, { ok: true });
2539
+ }
2516
2540
  const idMatch = url.pathname.match(/^\/([^/]+)$/);
2517
2541
  if (idMatch) {
2518
2542
  const id = idMatch[1];
@@ -3334,19 +3358,22 @@ function injectLocTags(code) {
3334
3358
  for (const ins of insertions) next = next.slice(0, ins.offset) + ins.text + next.slice(ins.offset);
3335
3359
  return next;
3336
3360
  }
3361
+ function isSlideSourceFile(id, slidesRootPosix) {
3362
+ const filePath = id.split(/[?#]/)[0].replace(/\\/g, "/");
3363
+ if (!filePath.startsWith(`${slidesRootPosix}/`)) return false;
3364
+ if (!filePath.endsWith(".tsx")) return false;
3365
+ if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return false;
3366
+ const rel = filePath.slice(slidesRootPosix.length + 1);
3367
+ return rel.includes("/");
3368
+ }
3337
3369
  function locTagsPlugin(opts) {
3338
- const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides");
3370
+ const slidesRoot = path.resolve(opts.userCwd, opts.slidesDir ?? "slides").replace(/\\/g, "/");
3339
3371
  return {
3340
3372
  name: "open-slide:loc-tags",
3341
3373
  apply: "serve",
3342
3374
  enforce: "pre",
3343
3375
  transform(code, id) {
3344
- const filePath = id.split("?")[0];
3345
- if (!filePath.startsWith(slidesRoot + path.sep)) return null;
3346
- if (!filePath.endsWith(".tsx")) return null;
3347
- if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.tsx")) return null;
3348
- const rel = filePath.slice(slidesRoot.length + path.sep.length);
3349
- if (!rel.includes(path.sep)) return null;
3376
+ if (!isSlideSourceFile(id, slidesRoot)) return null;
3350
3377
  const next = injectLocTags(code);
3351
3378
  if (next === null) return null;
3352
3379
  return {
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-B-KrjgX8.js";
1
+ import { Locale } from "./types-Bvk1pM70.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
@@ -82,6 +82,7 @@ const en = {
82
82
  toastSlideMoveFailed: "Failed to move slide",
83
83
  toastFolderDeleted: "Deleted folder “{name}”",
84
84
  toastFolderDeleteFailed: "Failed to delete folder",
85
+ toastFolderReorderFailed: "Failed to reorder folders",
85
86
  pickIcon: "Pick icon"
86
87
  },
87
88
  slide: {
@@ -337,10 +338,6 @@ const en = {
337
338
  dark: "Dark",
338
339
  system: "System"
339
340
  },
340
- clickNav: {
341
- prevAria: "Previous page",
342
- nextAria: "Next page"
343
- },
344
341
  imagePlaceholder: {
345
342
  dropOverlay: "Drop image to use here",
346
343
  uploading: "Uploading…",
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Locale, Plural } from "./types-B-KrjgX8.js";
2
- import { OpenSlideConfig } from "./config-CfMThYN9.js";
1
+ import { Locale, Plural } from "./types-Bvk1pM70.js";
2
+ import { OpenSlideConfig } from "./config-D1bANimZ.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes } from "react";
4
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
5
5
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { en } from "./en-BDnM5zKJ.js";
1
+ import { en } from "./en-hyGpmL1O.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
3
  import { createContext, useContext, useRef, useState } from "react";
4
4
  import { toast } from "sonner";
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-B-KrjgX8.js";
1
+ import { Locale, Plural } from "../types-Bvk1pM70.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,4 +1,4 @@
1
- import { en } from "../en-BDnM5zKJ.js";
1
+ import { en } from "../en-hyGpmL1O.js";
2
2
 
3
3
  //#region src/locale/format.ts
4
4
  function format(template, vars) {
@@ -96,6 +96,7 @@ const ja = {
96
96
  toastSlideMoveFailed: "スライドの移動に失敗しました",
97
97
  toastFolderDeleted: "フォルダ「{name}」を削除しました",
98
98
  toastFolderDeleteFailed: "フォルダの削除に失敗しました",
99
+ toastFolderReorderFailed: "フォルダの並び替えに失敗しました",
99
100
  pickIcon: "アイコンを選択"
100
101
  },
101
102
  slide: {
@@ -351,10 +352,6 @@ const ja = {
351
352
  dark: "ダーク",
352
353
  system: "システム"
353
354
  },
354
- clickNav: {
355
- prevAria: "前のページ",
356
- nextAria: "次のページ"
357
- },
358
355
  imagePlaceholder: {
359
356
  dropOverlay: "ここにドロップして使用",
360
357
  uploading: "アップロード中…",
@@ -473,6 +470,7 @@ const zhCN = {
473
470
  toastSlideMoveFailed: "移动幻灯片失败",
474
471
  toastFolderDeleted: "已删除文件夹\"{name}\"",
475
472
  toastFolderDeleteFailed: "删除文件夹失败",
473
+ toastFolderReorderFailed: "文件夹排序失败",
476
474
  pickIcon: "选择图标"
477
475
  },
478
476
  slide: {
@@ -728,10 +726,6 @@ const zhCN = {
728
726
  dark: "深色",
729
727
  system: "系统"
730
728
  },
731
- clickNav: {
732
- prevAria: "上一页",
733
- nextAria: "下一页"
734
- },
735
729
  imagePlaceholder: {
736
730
  dropOverlay: "拖入图片以使用",
737
731
  uploading: "上传中…",
@@ -850,6 +844,7 @@ const zhTW = {
850
844
  toastSlideMoveFailed: "移動投影片失敗",
851
845
  toastFolderDeleted: "已刪除資料夾「{name}」",
852
846
  toastFolderDeleteFailed: "刪除資料夾失敗",
847
+ toastFolderReorderFailed: "資料夾排序失敗",
853
848
  pickIcon: "選擇圖示"
854
849
  },
855
850
  slide: {
@@ -1105,10 +1100,6 @@ const zhTW = {
1105
1100
  dark: "深色",
1106
1101
  system: "系統"
1107
1102
  },
1108
- clickNav: {
1109
- prevAria: "上一頁",
1110
- nextAria: "下一頁"
1111
- },
1112
1103
  imagePlaceholder: {
1113
1104
  dropOverlay: "拖入圖片以使用",
1114
1105
  uploading: "上傳中…",
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "./config-C7sZtiY2.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -90,6 +90,7 @@ type Locale = {
90
90
  /** template: "Deleted folder “{name}”" */
91
91
  toastFolderDeleted: string;
92
92
  toastFolderDeleteFailed: string;
93
+ toastFolderReorderFailed: string;
93
94
  pickIcon: string;
94
95
  };
95
96
  slide: {
@@ -365,10 +366,6 @@ type Locale = {
365
366
  dark: string;
366
367
  system: string;
367
368
  };
368
- clickNav: {
369
- prevAria: string;
370
- nextAria: string;
371
- };
372
369
  imagePlaceholder: {
373
370
  dropOverlay: string;
374
371
  uploading: string;
@@ -1,5 +1,5 @@
1
- import "../types-B-KrjgX8.js";
2
- import { OpenSlideConfig } from "../config-CfMThYN9.js";
1
+ import "../types-Bvk1pM70.js";
2
+ import { OpenSlideConfig } from "../config-D1bANimZ.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-PwUHqZ_X.js";
2
+ import { createViteConfig } from "../config-C7sZtiY2.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: create-theme
3
- description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces two paired files under `themes/`: `<id>.md` (palette, typography, layout, fixed Title/Footer components, motion) and `<id>.demo.tsx` (a runnable demo slide that the dev-UI Themes panel previews). Do NOT use for editing real slides — only for authoring the theme bundle.
3
+ description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces two paired files under `themes/` `<id>.md` (palette, typography, layout, fixed Title/Footer components, motion) and `<id>.demo.tsx` (a runnable demo slide that the dev-UI Themes panel previews). Do NOT use for editing real slides — only for authoring the theme bundle.
4
4
  ---
5
5
 
6
6
  # Create a slide theme
@@ -1,5 +1,5 @@
1
1
  import { MessageSquare, Trash2, X } from 'lucide-react';
2
- import { useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
3
  import { format, plural, useLocale } from '@/lib/use-locale';
4
4
  import { useInspector } from './inspector-provider';
5
5
 
@@ -8,9 +8,23 @@ export function CommentWidget() {
8
8
  const { comments, remove, error } = useInspector();
9
9
  const [open, setOpen] = useState(false);
10
10
  const count = comments.length;
11
+ const ref = useRef<HTMLDivElement>(null);
12
+
13
+ useEffect(() => {
14
+ if (!open) return;
15
+ const onPointerDown = (e: PointerEvent) => {
16
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
17
+ };
18
+ document.addEventListener('pointerdown', onPointerDown);
19
+ return () => document.removeEventListener('pointerdown', onPointerDown);
20
+ }, [open]);
11
21
 
12
22
  return (
13
- <div data-inspector-ui className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2">
23
+ <div
24
+ ref={ref}
25
+ data-inspector-ui
26
+ className="absolute right-4 bottom-4 z-20 flex flex-col items-end gap-2"
27
+ >
14
28
  {open && (
15
29
  <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
16
30
  <div className="flex items-center justify-between border-b px-3 py-2">
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useClickPageNavigation } from '@/lib/use-click-page-navigation';
2
3
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
3
4
  import { cn } from '@/lib/utils';
4
5
  import type { DesignSystem } from '../lib/design';
@@ -92,6 +93,15 @@ export function Player({
92
93
 
93
94
  const overlayActive = controls && (overviewOpen || helpOpen);
94
95
 
96
+ useClickPageNavigation({
97
+ ref: rootRef,
98
+ enabled: !overlayActive,
99
+ canPrev,
100
+ canNext,
101
+ onPrev: goPrev,
102
+ onNext: goNext,
103
+ });
104
+
95
105
  useWheelPageNavigation({
96
106
  ref: rootRef,
97
107
  enabled: !overlayActive,
@@ -308,23 +318,8 @@ export function Player({
308
318
  />
309
319
  </SlideCanvas>
310
320
 
311
- <button
312
- type="button"
313
- aria-label="Previous page"
314
- onClick={goPrev}
315
- disabled={!canPrev}
316
- className={cn('absolute inset-y-0 left-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
317
- />
318
- <button
319
- type="button"
320
- aria-label="Next page"
321
- onClick={goNext}
322
- disabled={!canNext}
323
- className={cn('absolute inset-y-0 right-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
324
- />
325
-
326
321
  {controls && (
327
- <>
322
+ <div data-osd-chrome style={{ display: 'contents' }}>
328
323
  <PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
329
324
  <PresentBlackoutOverlay mode={blackout} />
330
325
  <PresentJumpInput pageCount={pages.length} onJump={onIndexChange} />
@@ -358,7 +353,7 @@ export function Player({
358
353
  onSelect={onIndexChange}
359
354
  />
360
355
  <PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
361
- </>
356
+ </div>
362
357
  )}
363
358
  </div>
364
359
  );
@@ -181,9 +181,14 @@ export function FolderItem({
181
181
  </PopoverContent>
182
182
  </Popover>
183
183
  ) : (
184
- <span className="flex size-5 shrink-0 items-center justify-center">
184
+ <button
185
+ type="button"
186
+ onClick={onSelect}
187
+ aria-label={label}
188
+ className="flex size-5 shrink-0 items-center justify-center"
189
+ >
185
190
  <FolderIconChip icon={icon} />
186
- </span>
191
+ </button>
187
192
  )}
188
193
 
189
194
  {renaming && row.kind === 'folder' ? (
@@ -5,6 +5,7 @@ import { ThemeToggle } from '@/components/theme-toggle';
5
5
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
6
6
  import type { Folder, FolderIcon } from '@/lib/sdk';
7
7
  import { format, useLocale } from '@/lib/use-locale';
8
+ import { cn } from '@/lib/utils';
8
9
  import { FolderIconChip, FolderItem } from './folder-item';
9
10
  import { IconPicker, PRESET_COLORS } from './icon-picker';
10
11
 
@@ -12,6 +13,8 @@ export const DRAFT_ID = 'draft';
12
13
  export const THEMES_ID = '__themes__';
13
14
  export const ASSETS_ID = '__assets__';
14
15
 
16
+ export const FOLDER_DND_MIME = 'application/x-folder-id';
17
+
15
18
  export function Sidebar({
16
19
  folders,
17
20
  countFor,
@@ -25,6 +28,7 @@ export function Sidebar({
25
28
  onDelete,
26
29
  onDropToFolder,
27
30
  onDropToDraft,
31
+ onReorder,
28
32
  }: {
29
33
  folders: Folder[];
30
34
  countFor: (folderId: string | null) => number;
@@ -38,7 +42,23 @@ export function Sidebar({
38
42
  onDelete: (id: string) => void;
39
43
  onDropToFolder: (folderId: string, slideId: string) => void;
40
44
  onDropToDraft: (slideId: string) => void;
45
+ onReorder: (ids: string[]) => void;
41
46
  }) {
47
+ const [dragId, setDragId] = useState<string | null>(null);
48
+ const [dropTarget, setDropTarget] = useState<{ id: string; before: boolean } | null>(null);
49
+
50
+ const finishReorder = (toId: string, before: boolean) => {
51
+ const fromId = dragId;
52
+ setDragId(null);
53
+ setDropTarget(null);
54
+ if (!fromId || fromId === toId) return;
55
+ const ids = folders.map((f) => f.id);
56
+ if (!ids.includes(fromId) || !ids.includes(toId)) return;
57
+ const next = ids.filter((id) => id !== fromId);
58
+ next.splice(next.indexOf(toId) + (before ? 0 : 1), 0, fromId);
59
+ if (next.every((id, i) => id === ids[i])) return;
60
+ onReorder(next);
61
+ };
42
62
  const [creating, setCreating] = useState(false);
43
63
  const [newName, setNewName] = useState('');
44
64
  const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
@@ -139,22 +159,73 @@ export function Sidebar({
139
159
  </div>
140
160
 
141
161
  <div className="flex-1 overflow-y-auto px-2 pb-2">
142
- {folders.map((folder) => (
143
- <FolderItem
144
- key={folder.id}
145
- row={{
146
- kind: 'folder',
147
- folder,
148
- onRename: (name) => onRename(folder.id, name),
149
- onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
150
- onDelete: () => onDelete(folder.id),
151
- }}
152
- count={countFor(folder.id)}
153
- selected={selectedId === folder.id}
154
- onSelect={() => onSelect(folder.id)}
155
- onDropSlide={(slideId) => onDropToFolder(folder.id, slideId)}
156
- />
157
- ))}
162
+ {folders.map((folder) => {
163
+ const isDropTarget = dropTarget?.id === folder.id;
164
+ const before = isDropTarget && dropTarget.before;
165
+ const after = isDropTarget && !dropTarget.before;
166
+ return (
167
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop handle wraps the row
168
+ <div
169
+ key={folder.id}
170
+ className={cn(
171
+ 'relative',
172
+ before &&
173
+ 'before:absolute before:inset-x-2 before:-top-px before:h-[2px] before:rounded-full before:bg-brand',
174
+ after &&
175
+ 'after:absolute after:inset-x-2 after:-bottom-px after:h-[2px] after:rounded-full after:bg-brand',
176
+ dragId === folder.id && 'opacity-50',
177
+ )}
178
+ draggable={import.meta.env.DEV}
179
+ onDragStart={(e) => {
180
+ if (!import.meta.env.DEV) return;
181
+ e.dataTransfer.setData(FOLDER_DND_MIME, folder.id);
182
+ e.dataTransfer.effectAllowed = 'move';
183
+ setDragId(folder.id);
184
+ }}
185
+ onDragEnd={() => {
186
+ setDragId(null);
187
+ setDropTarget(null);
188
+ }}
189
+ onDragOver={(e) => {
190
+ if (!e.dataTransfer.types.includes(FOLDER_DND_MIME)) return;
191
+ e.preventDefault();
192
+ e.dataTransfer.dropEffect = 'move';
193
+ const rect = e.currentTarget.getBoundingClientRect();
194
+ const isBefore = e.clientY < rect.top + rect.height / 2;
195
+ if (!dropTarget || dropTarget.id !== folder.id || dropTarget.before !== isBefore) {
196
+ setDropTarget({ id: folder.id, before: isBefore });
197
+ }
198
+ }}
199
+ onDragLeave={(e) => {
200
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
201
+ if (dropTarget?.id === folder.id) setDropTarget(null);
202
+ }}
203
+ onDrop={(e) => {
204
+ const fromId = e.dataTransfer.getData(FOLDER_DND_MIME);
205
+ if (!fromId) return;
206
+ e.preventDefault();
207
+ e.stopPropagation();
208
+ const rect = e.currentTarget.getBoundingClientRect();
209
+ const isBefore = e.clientY < rect.top + rect.height / 2;
210
+ finishReorder(folder.id, isBefore);
211
+ }}
212
+ >
213
+ <FolderItem
214
+ row={{
215
+ kind: 'folder',
216
+ folder,
217
+ onRename: (name) => onRename(folder.id, name),
218
+ onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
219
+ onDelete: () => onDelete(folder.id),
220
+ }}
221
+ count={countFor(folder.id)}
222
+ selected={selectedId === folder.id}
223
+ onSelect={() => onSelect(folder.id)}
224
+ onDropSlide={(slideId) => onDropToFolder(folder.id, slideId)}
225
+ />
226
+ </div>
227
+ );
228
+ })}
158
229
 
159
230
  {import.meta.env.DEV &&
160
231
  (creating ? (
@@ -88,12 +88,22 @@ async function putAssign(slideId: string, folderId: string | null): Promise<void
88
88
  if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
89
89
  }
90
90
 
91
+ async function putReorder(ids: string[]): Promise<void> {
92
+ const res = await fetch('/__folders/reorder', {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ ids }),
96
+ });
97
+ if (!res.ok) throw new Error(`PUT /__folders/reorder ${res.status}`);
98
+ }
99
+
91
100
  export type UseFoldersResult = {
92
101
  manifest: FoldersManifest;
93
102
  loading: boolean;
94
103
  create: (name: string, icon: FolderIcon) => Promise<Folder>;
95
104
  update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
96
105
  remove: (id: string) => Promise<void>;
106
+ reorder: (ids: string[]) => Promise<void>;
97
107
  assign: (slideId: string, folderId: string | null) => Promise<void>;
98
108
  renameSlide: (slideId: string, name: string) => Promise<void>;
99
109
  duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
@@ -163,6 +173,23 @@ export function useFolders(): UseFoldersResult {
163
173
  [refresh],
164
174
  );
165
175
 
176
+ const reorder = useCallback(
177
+ async (ids: string[]) => {
178
+ const prev = manifest;
179
+ const byId = new Map(prev.folders.map((f) => [f.id, f]));
180
+ const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
181
+ if (next.length !== prev.folders.length) return;
182
+ setManifest({ ...prev, folders: next });
183
+ try {
184
+ await putReorder(ids);
185
+ } catch (err) {
186
+ setManifest(prev);
187
+ throw err;
188
+ }
189
+ },
190
+ [manifest],
191
+ );
192
+
166
193
  const assign = useCallback(
167
194
  async (slideId: string, folderId: string | null) => {
168
195
  await putAssign(slideId, folderId);
@@ -202,6 +229,7 @@ export function useFolders(): UseFoldersResult {
202
229
  create,
203
230
  update,
204
231
  remove,
232
+ reorder,
205
233
  assign,
206
234
  renameSlide,
207
235
  duplicateSlide,
@@ -0,0 +1,154 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { findSlideSource } from './fiber.ts';
3
+
4
+ class FakeHTMLElement {
5
+ dataset: Record<string, string> = {};
6
+ private closestSelf: FakeHTMLElement | null = null;
7
+ setClosestSelfForSlideLoc() {
8
+ this.closestSelf = this;
9
+ }
10
+ closest(selector: string): FakeHTMLElement | null {
11
+ if (selector === '[data-slide-loc]') return this.closestSelf;
12
+ return null;
13
+ }
14
+ }
15
+
16
+ type DebugSource = { fileName?: string; lineNumber?: number; columnNumber?: number };
17
+ type FakeFiber = {
18
+ return: FakeFiber | null;
19
+ stateNode?: unknown;
20
+ _debugSource?: DebugSource;
21
+ };
22
+
23
+ function makeEl(opts: { slideLoc?: string; fiber?: FakeFiber } = {}): FakeHTMLElement {
24
+ const el = new FakeHTMLElement();
25
+ if (opts.slideLoc) {
26
+ el.dataset.slideLoc = opts.slideLoc;
27
+ el.setClosestSelfForSlideLoc();
28
+ }
29
+ if (opts.fiber) {
30
+ (el as unknown as Record<string, FakeFiber>).__reactFiber$test = opts.fiber;
31
+ }
32
+ return el;
33
+ }
34
+
35
+ function makeFiber(opts: {
36
+ fileName?: string;
37
+ line?: number;
38
+ column?: number;
39
+ host?: boolean;
40
+ parent?: FakeFiber | null;
41
+ }): FakeFiber {
42
+ const source: DebugSource | undefined =
43
+ opts.fileName !== undefined
44
+ ? { fileName: opts.fileName, lineNumber: opts.line, columnNumber: opts.column }
45
+ : undefined;
46
+ return {
47
+ return: opts.parent ?? null,
48
+ stateNode: opts.host ? new FakeHTMLElement() : undefined,
49
+ _debugSource: source,
50
+ };
51
+ }
52
+
53
+ beforeAll(() => {
54
+ vi.stubGlobal('HTMLElement', FakeHTMLElement);
55
+ });
56
+
57
+ afterAll(() => {
58
+ vi.unstubAllGlobals();
59
+ });
60
+
61
+ describe('findSlideSource primary path', () => {
62
+ it('reads line:column from data-slide-loc', () => {
63
+ const el = makeEl({ slideLoc: '42:7' });
64
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
65
+ expect(hit).not.toBeNull();
66
+ expect(hit?.line).toBe(42);
67
+ expect(hit?.column).toBe(7);
68
+ expect(hit?.anchor).toBe(el as unknown as HTMLElement);
69
+ });
70
+ });
71
+
72
+ describe('findSlideSource fallback', () => {
73
+ it('matches a POSIX fileName', () => {
74
+ const fiber = makeFiber({
75
+ fileName: '/repo/slides/cover/index.tsx',
76
+ line: 10,
77
+ column: 4,
78
+ host: true,
79
+ });
80
+ const el = makeEl({ fiber });
81
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
82
+ expect(hit).not.toBeNull();
83
+ expect(hit?.line).toBe(10);
84
+ expect(hit?.column).toBe(4);
85
+ });
86
+
87
+ it('matches a Windows-backslash fileName', () => {
88
+ const fiber = makeFiber({
89
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx',
90
+ line: 11,
91
+ column: 2,
92
+ host: true,
93
+ });
94
+ const el = makeEl({ fiber });
95
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
96
+ expect(hit).not.toBeNull();
97
+ expect(hit?.line).toBe(11);
98
+ expect(hit?.column).toBe(2);
99
+ });
100
+
101
+ it('matches a fileName carrying an HMR ?t= query', () => {
102
+ const fiber = makeFiber({
103
+ fileName: '/repo/slides/cover/index.tsx?t=1700000000000',
104
+ line: 12,
105
+ column: 0,
106
+ host: true,
107
+ });
108
+ const el = makeEl({ fiber });
109
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
110
+ expect(hit).not.toBeNull();
111
+ expect(hit?.line).toBe(12);
112
+ });
113
+
114
+ it('matches a Windows fileName with an HMR query', () => {
115
+ const fiber = makeFiber({
116
+ fileName: 'C:\\repo\\slides\\cover\\index.tsx?t=1700000000000',
117
+ line: 13,
118
+ column: 1,
119
+ host: true,
120
+ });
121
+ const el = makeEl({ fiber });
122
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
123
+ expect(hit).not.toBeNull();
124
+ expect(hit?.line).toBe(13);
125
+ expect(hit?.column).toBe(1);
126
+ });
127
+
128
+ it('returns null when the fiber fileName points at a different slideId', () => {
129
+ const fiber = makeFiber({
130
+ fileName: '/repo/slides/other/index.tsx',
131
+ line: 10,
132
+ column: 4,
133
+ host: true,
134
+ });
135
+ const el = makeEl({ fiber });
136
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
137
+ expect(hit).toBeNull();
138
+ });
139
+
140
+ it('walks up the fiber chain until it finds a matching source', () => {
141
+ const parent = makeFiber({
142
+ fileName: '/repo/slides/cover/index.tsx',
143
+ line: 99,
144
+ column: 3,
145
+ host: true,
146
+ });
147
+ const leaf = makeFiber({ parent, host: true });
148
+ const el = makeEl({ fiber: leaf });
149
+ const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
150
+ expect(hit).not.toBeNull();
151
+ expect(hit?.line).toBe(99);
152
+ expect(hit?.column).toBe(3);
153
+ });
154
+ });
@@ -28,6 +28,12 @@ function getSource(fiber: FiberLike) {
28
28
  return fiber._debugSource ?? fiber.memoizedProps?.__source;
29
29
  }
30
30
 
31
+ // `_debugSource.fileName` may carry Vite's HMR query (`?t=…`) and, on
32
+ // Windows, backslash separators. Both break the naive `endsWith` match.
33
+ function normalizeDebugFileName(fileName: string): string {
34
+ return fileName.split(/[?#]/)[0].replace(/\\/g, '/');
35
+ }
36
+
31
37
  export function findSlideSource(
32
38
  el: HTMLElement,
33
39
  slideId: string,
@@ -58,7 +64,12 @@ export function findSlideSource(
58
64
  while (fiber) {
59
65
  const src = getSource(fiber);
60
66
  const isHost = fiber.stateNode instanceof HTMLElement;
61
- if (src?.fileName?.endsWith(needle) && src.lineNumber && (!opts?.hostOnly || isHost)) {
67
+ if (
68
+ src?.fileName &&
69
+ normalizeDebugFileName(src.fileName).endsWith(needle) &&
70
+ src.lineNumber &&
71
+ (!opts?.hostOnly || isHost)
72
+ ) {
62
73
  return {
63
74
  line: src.lineNumber,
64
75
  column: src.columnNumber ?? 0,
@@ -0,0 +1,52 @@
1
+ import { type RefObject, useEffect } from 'react';
2
+
3
+ // Clicks that land on (or inside) these never navigate — interactive slide
4
+ // content keeps its click, and present chrome is excluded via data-osd-chrome.
5
+ // Authors can opt any element out with a data-osd-interactive attribute.
6
+ const NAV_PASSTHROUGH =
7
+ 'a, button, input, textarea, select, label, summary, iframe, video, audio, embed, object, [role="button"], [role="link"], [contenteditable="true"], [data-osd-interactive], [data-osd-chrome]';
8
+
9
+ type UseClickPageNavigationOptions<T extends HTMLElement> = {
10
+ ref: RefObject<T>;
11
+ enabled?: boolean;
12
+ /** Fraction of the width on each side that navigates; the center is inert. */
13
+ edgeRatio?: number;
14
+ canPrev: boolean;
15
+ canNext: boolean;
16
+ onPrev: () => void;
17
+ onNext: () => void;
18
+ };
19
+
20
+ export function useClickPageNavigation<T extends HTMLElement>({
21
+ ref,
22
+ enabled = true,
23
+ edgeRatio = 0.3,
24
+ canPrev,
25
+ canNext,
26
+ onPrev,
27
+ onNext,
28
+ }: UseClickPageNavigationOptions<T>) {
29
+ useEffect(() => {
30
+ const el = ref.current;
31
+ if (!el || !enabled) return;
32
+
33
+ const onClick = (event: MouseEvent) => {
34
+ if (event.button !== 0 || event.defaultPrevented) return;
35
+ const target = event.target;
36
+ if (target instanceof HTMLElement && target.closest(NAV_PASSTHROUGH)) return;
37
+ if (window.getSelection()?.toString()) return;
38
+
39
+ const rect = el.getBoundingClientRect();
40
+ if (rect.width === 0) return;
41
+ const x = (event.clientX - rect.left) / rect.width;
42
+ if (x < edgeRatio) {
43
+ if (canPrev) onPrev();
44
+ } else if (x > 1 - edgeRatio) {
45
+ if (canNext) onNext();
46
+ }
47
+ };
48
+
49
+ el.addEventListener('click', onClick);
50
+ return () => el.removeEventListener('click', onClick);
51
+ }, [ref, enabled, edgeRatio, canPrev, canNext, onPrev, onNext]);
52
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ // Matches Tailwind's `md` breakpoint — below it the slide viewer hides desktop
4
+ // navigation chrome and relies on tap-to-navigate instead.
5
+ const QUERY = '(max-width: 767.98px)';
6
+
7
+ export function useIsMobile(): boolean {
8
+ const [mobile, setMobile] = useState(() => {
9
+ if (typeof window === 'undefined') return false;
10
+ return window.matchMedia(QUERY).matches;
11
+ });
12
+
13
+ useEffect(() => {
14
+ const mql = window.matchMedia(QUERY);
15
+ const onChange = (e: MediaQueryListEvent) => setMobile(e.matches);
16
+ mql.addEventListener('change', onChange);
17
+ return () => mql.removeEventListener('change', onChange);
18
+ }, []);
19
+
20
+ return mobile;
21
+ }
@@ -39,6 +39,7 @@ export function HomeShell() {
39
39
  create,
40
40
  update,
41
41
  remove,
42
+ reorder,
42
43
  assign,
43
44
  renameSlide,
44
45
  duplicateSlide,
@@ -147,6 +148,13 @@ export function HomeShell() {
147
148
  }}
148
149
  onDropToFolder={(folderId, slideId) => moveSlideWithToast(slideId, folderId)}
149
150
  onDropToDraft={(slideId) => moveSlideWithToast(slideId, null)}
151
+ onReorder={async (ids) => {
152
+ try {
153
+ await reorder(ids);
154
+ } catch {
155
+ toast.error(t.home.toastFolderReorderFailed);
156
+ }
157
+ }}
150
158
  />
151
159
  </div>
152
160
 
@@ -242,7 +242,7 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
242
242
  <button
243
243
  type="button"
244
244
  aria-label={`${t.home.sortLabel}: ${labels[value]}`}
245
- className="flex h-8 items-center gap-1.5 rounded-[6px] border border-border bg-background pl-2 pr-1.5 text-[12.5px] font-medium text-foreground outline-none hover:bg-muted focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
245
+ className="flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-border bg-background pl-2 pr-1.5 text-[12.5px] font-medium text-foreground outline-none hover:bg-muted focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
246
246
  >
247
247
  <FieldIcon k={value} className="size-3.5 text-muted-foreground" />
248
248
  <span>{labels[value]}</span>
@@ -10,7 +10,6 @@ import {
10
10
  Loader2,
11
11
  Maximize,
12
12
  MonitorSpeaker,
13
- Pencil,
14
13
  Play,
15
14
  } from 'lucide-react';
16
15
  import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -40,10 +39,11 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
40
39
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
41
40
  import { useFolders } from '@/lib/folders';
42
41
  import { useAgentSocketConnected } from '@/lib/use-agent-socket';
42
+ import { useClickPageNavigation } from '@/lib/use-click-page-navigation';
43
+ import { useIsMobile } from '@/lib/use-is-mobile';
43
44
  import { format, useLocale } from '@/lib/use-locale';
44
45
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
45
46
  import { cn } from '@/lib/utils';
46
- import { ClickNavZones } from '../components/click-nav-zones';
47
47
  import { NotesDrawer } from '../components/notes-drawer';
48
48
  import { PdfProgressToast } from '../components/pdf-progress-toast';
49
49
  import { openPresenterWindow, Player } from '../components/player';
@@ -348,7 +348,7 @@ export function Slide() {
348
348
  <SelectionReporter />
349
349
  <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
350
350
  {/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
351
- <header className="flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
351
+ <header className="relative flex h-12 shrink-0 items-center gap-2 border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
352
352
  <div className="flex shrink-0 items-center gap-1.5 md:gap-2">
353
353
  {showSlideBrowser && (
354
354
  <Button asChild variant="ghost" size="icon-sm" title={t.slide.home}>
@@ -382,14 +382,14 @@ export function Slide() {
382
382
  {import.meta.env.DEV && <AgentConnectedBadge />}
383
383
  </div>
384
384
 
385
- {/* Centered title the rail and mobile pill carry the page count. */}
386
- <div className="flex min-w-0 flex-1 justify-center px-2">
387
- <div className="min-w-0 max-w-[34rem]">
385
+ {/* Title centered to the viewport, not the leftover space between the side groups. */}
386
+ <div className="pointer-events-none absolute inset-x-0 flex justify-center px-2">
387
+ <div className="pointer-events-auto min-w-0 max-w-[34rem]">
388
388
  <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
389
389
  </div>
390
390
  </div>
391
391
 
392
- <div className="flex shrink-0 items-center gap-1">
392
+ <div className="ml-auto flex shrink-0 items-center gap-1">
393
393
  {view === 'slides' && (
394
394
  <button
395
395
  type="button"
@@ -581,7 +581,7 @@ export function Slide() {
581
581
  data-slide-id={slideId}
582
582
  className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
583
583
  >
584
- <SlideWheelNavigation
584
+ <SlideViewportNavigation
585
585
  targetRef={slideViewportRef}
586
586
  onPrev={() => goTo(index - 1)}
587
587
  onNext={() => goTo(index + 1)}
@@ -597,12 +597,6 @@ export function Slide() {
597
597
  disabled={prefersReducedMotion}
598
598
  />
599
599
  </SlideCanvas>
600
- <ClickNavZones
601
- onPrev={() => goTo(index - 1)}
602
- onNext={() => goTo(index + 1)}
603
- canPrev={index > 0}
604
- canNext={index < pageCount - 1}
605
- />
606
600
  <InspectOverlay />
607
601
  <SaveBar />
608
602
  {import.meta.env.DEV && <CommentWidget />}
@@ -814,7 +808,7 @@ function SelectionReporter() {
814
808
  return null;
815
809
  }
816
810
 
817
- function SlideWheelNavigation({
811
+ function SlideViewportNavigation({
818
812
  targetRef,
819
813
  onPrev,
820
814
  onNext,
@@ -828,6 +822,7 @@ function SlideWheelNavigation({
828
822
  canNext: boolean;
829
823
  }) {
830
824
  const { active } = useInspector();
825
+ const isMobile = useIsMobile();
831
826
 
832
827
  useWheelPageNavigation({
833
828
  ref: targetRef,
@@ -838,6 +833,19 @@ function SlideWheelNavigation({
838
833
  onNext,
839
834
  });
840
835
 
836
+ // Tap-to-navigate is a touch affordance — desktop has visible prev/next
837
+ // chrome, so it stays edge-only on small screens (matches the old md:hidden
838
+ // zones). Interactive slide content keeps its tap via the hook's passthrough.
839
+ useClickPageNavigation({
840
+ ref: targetRef,
841
+ enabled: isMobile && !active,
842
+ edgeRatio: 0.18,
843
+ canPrev,
844
+ canNext,
845
+ onPrev,
846
+ onNext,
847
+ });
848
+
841
849
  return null;
842
850
  }
843
851
 
@@ -890,49 +898,65 @@ function InlineTitleEditor({
890
898
 
891
899
  if (editing) {
892
900
  return (
893
- <div className="flex flex-1 items-center justify-center">
894
- <input
895
- ref={inputRef}
896
- value={value}
897
- disabled={saving}
898
- onChange={(e) => setValue(e.target.value)}
899
- onBlur={() => {
900
- if (!saving) commit();
901
- }}
902
- onKeyDown={(e) => {
903
- if (e.key === 'Enter') {
904
- e.preventDefault();
905
- commit();
906
- } else if (e.key === 'Escape') {
907
- e.preventDefault();
908
- cancel();
909
- }
910
- }}
911
- maxLength={80}
912
- className="min-w-0 max-w-[min(34rem,90%)] rounded-[5px] border border-foreground/30 bg-card px-2 py-0.5 text-center font-heading text-[13px] font-medium tracking-tight outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
913
- />
901
+ <div className="flex min-w-0 flex-1 items-center justify-center">
902
+ <div className="inline-grid max-w-full items-center">
903
+ <span
904
+ aria-hidden
905
+ className="invisible col-start-1 row-start-1 overflow-hidden whitespace-pre border border-transparent px-2 py-0.5 font-heading text-[13.5px] font-semibold tracking-[-0.01em]"
906
+ >
907
+ {value || ' '}
908
+ </span>
909
+ <input
910
+ ref={inputRef}
911
+ size={1}
912
+ value={value}
913
+ disabled={saving}
914
+ onChange={(e) => setValue(e.target.value)}
915
+ onBlur={() => {
916
+ if (!saving) commit();
917
+ }}
918
+ onKeyDown={(e) => {
919
+ if (e.key === 'Enter') {
920
+ e.preventDefault();
921
+ commit();
922
+ } else if (e.key === 'Escape') {
923
+ e.preventDefault();
924
+ cancel();
925
+ }
926
+ }}
927
+ maxLength={80}
928
+ className="col-start-1 row-start-1 w-full min-w-0 rounded-[5px] border border-foreground/30 bg-card px-2 py-0.5 text-center font-heading text-[13.5px] font-semibold tracking-[-0.01em] outline-none"
929
+ />
930
+ </div>
931
+ </div>
932
+ );
933
+ }
934
+
935
+ if (!import.meta.env.DEV) {
936
+ return (
937
+ <div className="flex min-w-0 items-baseline justify-center">
938
+ <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
939
+ {title}
940
+ </h1>
914
941
  </div>
915
942
  );
916
943
  }
917
944
 
918
945
  return (
919
- <div className="group/title flex min-w-0 items-baseline justify-center gap-1.5">
920
- <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
921
- {title}
922
- </h1>
923
- {import.meta.env.DEV && (
924
- <button
925
- type="button"
926
- onClick={() => setEditing(true)}
927
- aria-label={t.slide.renameSlide}
928
- className={cn(
929
- 'flex size-5 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground transition-opacity hover:bg-muted hover:text-foreground',
930
- 'opacity-0 group-hover/title:opacity-100 focus-visible:opacity-100',
931
- )}
932
- >
933
- <Pencil className="size-3" />
934
- </button>
935
- )}
946
+ <div className="flex min-w-0 items-center justify-center">
947
+ <button
948
+ type="button"
949
+ onClick={() => setEditing(true)}
950
+ aria-label={t.slide.renameSlide}
951
+ className={cn(
952
+ 'min-w-0 max-w-full cursor-text rounded-[5px] border border-transparent px-2 py-0.5 transition-colors',
953
+ 'hover:border-foreground/30 hover:bg-card focus-visible:border-foreground/30 focus-visible:bg-card focus-visible:outline-none',
954
+ )}
955
+ >
956
+ <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
957
+ {title}
958
+ </h1>
959
+ </button>
936
960
  </div>
937
961
  );
938
962
  }
package/src/locale/en.ts CHANGED
@@ -86,6 +86,7 @@ export const en: Locale = {
86
86
  toastSlideMoveFailed: 'Failed to move slide',
87
87
  toastFolderDeleted: 'Deleted folder “{name}”',
88
88
  toastFolderDeleteFailed: 'Failed to delete folder',
89
+ toastFolderReorderFailed: 'Failed to reorder folders',
89
90
  pickIcon: 'Pick icon',
90
91
  },
91
92
 
@@ -352,11 +353,6 @@ export const en: Locale = {
352
353
  system: 'System',
353
354
  },
354
355
 
355
- clickNav: {
356
- prevAria: 'Previous page',
357
- nextAria: 'Next page',
358
- },
359
-
360
356
  imagePlaceholder: {
361
357
  dropOverlay: 'Drop image to use here',
362
358
  uploading: 'Uploading…',
package/src/locale/ja.ts CHANGED
@@ -86,6 +86,7 @@ export const ja: Locale = {
86
86
  toastSlideMoveFailed: 'スライドの移動に失敗しました',
87
87
  toastFolderDeleted: 'フォルダ「{name}」を削除しました',
88
88
  toastFolderDeleteFailed: 'フォルダの削除に失敗しました',
89
+ toastFolderReorderFailed: 'フォルダの並び替えに失敗しました',
89
90
  pickIcon: 'アイコンを選択',
90
91
  },
91
92
 
@@ -356,11 +357,6 @@ export const ja: Locale = {
356
357
  system: 'システム',
357
358
  },
358
359
 
359
- clickNav: {
360
- prevAria: '前のページ',
361
- nextAria: '次のページ',
362
- },
363
-
364
360
  imagePlaceholder: {
365
361
  dropOverlay: 'ここにドロップして使用',
366
362
  uploading: 'アップロード中…',
@@ -90,6 +90,7 @@ export type Locale = {
90
90
  /** template: "Deleted folder “{name}”" */
91
91
  toastFolderDeleted: string;
92
92
  toastFolderDeleteFailed: string;
93
+ toastFolderReorderFailed: string;
93
94
  pickIcon: string;
94
95
  };
95
96
 
@@ -375,11 +376,6 @@ export type Locale = {
375
376
  system: string;
376
377
  };
377
378
 
378
- clickNav: {
379
- prevAria: string;
380
- nextAria: string;
381
- };
382
-
383
379
  imagePlaceholder: {
384
380
  dropOverlay: string;
385
381
  uploading: string;
@@ -86,6 +86,7 @@ export const zhCN: Locale = {
86
86
  toastSlideMoveFailed: '移动幻灯片失败',
87
87
  toastFolderDeleted: '已删除文件夹"{name}"',
88
88
  toastFolderDeleteFailed: '删除文件夹失败',
89
+ toastFolderReorderFailed: '文件夹排序失败',
89
90
  pickIcon: '选择图标',
90
91
  },
91
92
 
@@ -351,11 +352,6 @@ export const zhCN: Locale = {
351
352
  system: '系统',
352
353
  },
353
354
 
354
- clickNav: {
355
- prevAria: '上一页',
356
- nextAria: '下一页',
357
- },
358
-
359
355
  imagePlaceholder: {
360
356
  dropOverlay: '拖入图片以使用',
361
357
  uploading: '上传中…',
@@ -86,6 +86,7 @@ export const zhTW: Locale = {
86
86
  toastSlideMoveFailed: '移動投影片失敗',
87
87
  toastFolderDeleted: '已刪除資料夾「{name}」',
88
88
  toastFolderDeleteFailed: '刪除資料夾失敗',
89
+ toastFolderReorderFailed: '資料夾排序失敗',
89
90
  pickIcon: '選擇圖示',
90
91
  },
91
92
 
@@ -351,11 +352,6 @@ export const zhTW: Locale = {
351
352
  system: '系統',
352
353
  },
353
354
 
354
- clickNav: {
355
- prevAria: '上一頁',
356
- nextAria: '下一頁',
357
- },
358
-
359
355
  imagePlaceholder: {
360
356
  dropOverlay: '拖入圖片以使用',
361
357
  uploading: '上傳中…',
@@ -1,36 +0,0 @@
1
- import { useLocale } from '@/lib/use-locale';
2
- import { useInspector } from './inspector/inspector-provider';
3
-
4
- type Props = {
5
- onPrev: () => void;
6
- onNext: () => void;
7
- canPrev: boolean;
8
- canNext: boolean;
9
- };
10
-
11
- export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
12
- const { active } = useInspector();
13
- const t = useLocale();
14
- if (active) return null;
15
-
16
- return (
17
- <>
18
- <button
19
- type="button"
20
- aria-label={t.clickNav.prevAria}
21
- onClick={onPrev}
22
- disabled={!canPrev}
23
- data-inspector-ui
24
- className="absolute inset-y-0 left-0 z-20 w-[18%] min-w-12 md:hidden"
25
- />
26
- <button
27
- type="button"
28
- aria-label={t.clickNav.nextAria}
29
- onClick={onNext}
30
- disabled={!canNext}
31
- data-inspector-ui
32
- className="absolute inset-y-0 right-0 z-20 w-[18%] min-w-12 md:hidden"
33
- />
34
- </>
35
- );
36
- }