@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.
- package/dist/{build-tLrkKUHr.js → build-CCZDC8eF.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-PwUHqZ_X.js → config-C7sZtiY2.js} +45 -18
- package/dist/{config-CfMThYN9.d.ts → config-D1bANimZ.d.ts} +1 -1
- package/dist/{dev-DpCIRbhT.js → dev-kLS_4CAI.js} +1 -1
- package/dist/{en-BDnM5zKJ.js → en-hyGpmL1O.js} +1 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +4 -13
- package/dist/{preview-BSGlM6Se.js → preview-DUkOjOx8.js} +1 -1
- package/dist/{types-B-KrjgX8.d.ts → types-Bvk1pM70.d.ts} +1 -4
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/create-theme/SKILL.md +1 -1
- package/src/app/components/inspector/comment-widget.tsx +16 -2
- package/src/app/components/player.tsx +12 -17
- package/src/app/components/sidebar/folder-item.tsx +7 -2
- package/src/app/components/sidebar/sidebar.tsx +87 -16
- package/src/app/lib/folders.ts +28 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +12 -1
- package/src/app/lib/use-click-page-navigation.ts +52 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/routes/home-shell.tsx +8 -0
- package/src/app/routes/home.tsx +1 -1
- package/src/app/routes/slide.tsx +77 -53
- package/src/locale/en.ts +1 -5
- package/src/locale/ja.ts +1 -5
- package/src/locale/types.ts +1 -5
- package/src/locale/zh-cn.ts +1 -5
- package/src/locale/zh-tw.ts +1 -5
- package/src/app/components/click-nav-zones.tsx +0 -36
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-
|
|
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-
|
|
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-
|
|
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))
|
|
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
|
-
|
|
1166
|
-
if (!t$3.
|
|
1167
|
-
const
|
|
1168
|
-
if (!
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -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-
|
|
2
|
-
import { OpenSlideConfig } from "./config-
|
|
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-
|
|
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";
|
package/dist/locale/index.d.ts
CHANGED
package/dist/locale/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { en } from "../en-
|
|
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: "上傳中…",
|
|
@@ -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;
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 ? (
|
package/src/app/lib/folders.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -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>
|
package/src/app/routes/slide.tsx
CHANGED
|
@@ -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
|
-
{/*
|
|
386
|
-
<div className="
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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="
|
|
920
|
-
<
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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: 'アップロード中…',
|
package/src/locale/types.ts
CHANGED
|
@@ -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;
|
package/src/locale/zh-cn.ts
CHANGED
|
@@ -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: '上传中…',
|
package/src/locale/zh-tw.ts
CHANGED
|
@@ -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
|
-
}
|