@open-slide/core 0.0.6 → 0.0.8
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/README.md +84 -0
- package/dist/{build-BCORlVF3.js → build-CXY2DSzy.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-DF58h0l4.js → config-BYTf0qVz.js} +183 -36
- package/dist/config-SXL5qIl6.d.ts +12 -0
- package/dist/{dev-h-rxb3xY.js → dev-BxCKugi3.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{preview-lskE0s8A.js → preview-C1F-rHfx.js} +1 -1
- package/dist/vite/index.d.ts +2 -7
- package/dist/vite/index.js +1 -1
- package/package.json +2 -1
- package/src/app/App.tsx +14 -1
- package/src/app/components/Player.tsx +24 -7
- package/src/app/components/ThumbnailRail.tsx +3 -0
- package/src/app/components/inspector/CommentWidget.tsx +33 -19
- package/src/app/components/sidebar/FolderItem.tsx +1 -1
- package/src/app/components/sidebar/Sidebar.tsx +1 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +1 -0
- package/src/app/lib/folders.ts +16 -5
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +0 -1
- package/src/app/lib/useWheelPageNavigation.ts +92 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/main.tsx +1 -0
- package/src/app/routes/Home.tsx +3 -1
- package/src/app/routes/Slide.tsx +142 -51
- package/src/app/virtual.d.ts +11 -1
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @open-slide/core
|
|
2
|
+
|
|
3
|
+
Runtime and CLI for [open-slide](https://github.com/1weiho/open-slide) — a React-based slide framework where you write slides and the framework handles the Vite/React stack, layout, navigation, hot reload, and fullscreen play mode.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @open-slide/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Most users get this installed automatically by running `npx @open-slide/cli init`. Use this package directly only if you're wiring up an existing workspace by hand.
|
|
12
|
+
|
|
13
|
+
## What's inside
|
|
14
|
+
|
|
15
|
+
- **Runtime** — home page, slide viewer, thumbnail rail, keyboard navigation, and fullscreen presenter mode. Every slide renders into a fixed **1920×1080** canvas; the framework scales it.
|
|
16
|
+
- **Vite plugin** — discovers `slides/<id>/index.{tsx,jsx,ts,js}`, exposes them via virtual modules, and reloads when slides are added or removed.
|
|
17
|
+
- **CLI** — `open-slide dev | build | preview` so workspaces never need to touch Vite, React, or tsconfig directly.
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
Once installed, the `open-slide` bin is available in the workspace:
|
|
22
|
+
|
|
23
|
+
| Command | Description |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `open-slide dev` | Start the dev server. Flags: `-p, --port <port>`, `--host [host]`, `--open`. |
|
|
26
|
+
| `open-slide build` | Build a static site. Flags: `--out-dir <dir>` (defaults to `dist`). |
|
|
27
|
+
| `open-slide preview` | Preview the production build. Flags: `-p, --port <port>`, `--host [host]`, `--open`. |
|
|
28
|
+
|
|
29
|
+
## Config
|
|
30
|
+
|
|
31
|
+
Create `open-slide.config.ts` in the workspace root (all fields optional):
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import type { OpenSlideConfig } from '@open-slide/core';
|
|
35
|
+
|
|
36
|
+
const openSlideConfig: OpenSlideConfig = {
|
|
37
|
+
slidesDir: 'slides',
|
|
38
|
+
port: 5173,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default openSlideConfig;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Authoring slides
|
|
45
|
+
|
|
46
|
+
Slides live under `slides/<kebab-case-id>/index.tsx` and default-export an array of `Page` components:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import type { Page } from '@open-slide/core';
|
|
50
|
+
|
|
51
|
+
const Cover: Page = () => (
|
|
52
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
53
|
+
<h1 className="text-[120px] font-bold">Hello, open-slide</h1>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const pages: Page[] = [Cover];
|
|
58
|
+
export default pages;
|
|
59
|
+
|
|
60
|
+
export const meta = { title: 'Hello' };
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Exports
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import {
|
|
67
|
+
CANVAS_WIDTH, // 1920
|
|
68
|
+
CANVAS_HEIGHT, // 1080
|
|
69
|
+
type Page,
|
|
70
|
+
type SlideMeta,
|
|
71
|
+
type SlideModule,
|
|
72
|
+
type OpenSlideConfig,
|
|
73
|
+
} from '@open-slide/core';
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The Vite plugin is exposed under a subpath for advanced setups:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { createViteConfig } from '@open-slide/core/vite';
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
package/dist/cli/bin.js
CHANGED
|
@@ -22,15 +22,15 @@ async function run(argv) {
|
|
|
22
22
|
const program = new Command();
|
|
23
23
|
program.name("open-slide").description("Author slides — we handle the Vite/React stack.").version(version, "-v, --version", "print version").helpOption("-h, --help", "show help").showHelpAfterError(chalk.dim("(run `open-slide --help` for usage)"));
|
|
24
24
|
program.command("dev").description("Start the dev server").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
|
|
25
|
-
const { dev } = await import("../dev-
|
|
25
|
+
const { dev } = await import("../dev-BxCKugi3.js");
|
|
26
26
|
await dev(flags);
|
|
27
27
|
});
|
|
28
28
|
program.command("build").description("Build a static site").option("--out-dir <dir>", "output directory (defaults to `dist`)").action(async (flags) => {
|
|
29
|
-
const { build } = await import("../build-
|
|
29
|
+
const { build } = await import("../build-CXY2DSzy.js");
|
|
30
30
|
await build(flags);
|
|
31
31
|
});
|
|
32
32
|
program.command("preview").description("Preview the production build").addOption(new Option("-p, --port <port>", "port to listen on").argParser(parsePort)).addOption(new Option("--host [host]", "expose on the network (optional host)")).option("--open", "open the browser on start").action(async (flags) => {
|
|
33
|
-
const { preview } = await import("../preview-
|
|
33
|
+
const { preview } = await import("../preview-C1F-rHfx.js");
|
|
34
34
|
await preview(flags);
|
|
35
35
|
});
|
|
36
36
|
await program.parseAsync(argv, { from: "user" });
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import fs
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import tailwindcss from "@tailwindcss/vite";
|
|
6
6
|
import react from "@vitejs/plugin-react";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { parse } from "@babel/parser";
|
|
8
9
|
import fg from "fast-glob";
|
|
10
|
+
import { loadConfigFromFile } from "vite";
|
|
9
11
|
|
|
10
12
|
//#region src/vite/comments-plugin.ts
|
|
11
13
|
const MARKER_RE = /\{\/\*\s*@slide-comment\s+id="(c-[a-f0-9]+)"\s+ts="([^"]+)"\s+text="([A-Za-z0-9_-]+={0,2})"\s*\*\/\}/g;
|
|
@@ -70,31 +72,115 @@ function parseMarkers(source) {
|
|
|
70
72
|
function newId() {
|
|
71
73
|
return `c-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
|
|
72
74
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
function lineToOffset(source, line) {
|
|
76
|
+
let off = 0;
|
|
77
|
+
for (let l = 1; l < line; l++) {
|
|
78
|
+
const nl = source.indexOf("\n", off);
|
|
79
|
+
if (nl === -1) return source.length;
|
|
80
|
+
off = nl + 1;
|
|
81
|
+
}
|
|
82
|
+
return off;
|
|
83
|
+
}
|
|
84
|
+
function lineIndent(source, lineNumber) {
|
|
85
|
+
const start = lineToOffset(source, lineNumber);
|
|
86
|
+
const m = source.slice(start, start + 200).match(/^[ \t]*/);
|
|
87
|
+
return m?.[0] ?? "";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Walk the AST, collect every JSXElement/JSXFragment whose location encloses
|
|
91
|
+
* the click point, ordered innermost-first.
|
|
92
|
+
*
|
|
93
|
+
* "Encloses" here is inclusive at the start (so a click on the opening `<`
|
|
94
|
+
* counts as inside) and exclusive at the end. We deliberately don't trust
|
|
95
|
+
* Babel's `_debugSource` line/column to be exact — HMR or upstream transforms
|
|
96
|
+
* can shift it slightly — so we treat the click as a probe and pick the
|
|
97
|
+
* tightest JSX container around it.
|
|
98
|
+
*/
|
|
99
|
+
function findJsxAncestors(ast, line, column) {
|
|
100
|
+
const hits = [];
|
|
101
|
+
const walk = (node) => {
|
|
102
|
+
if (!node || typeof node !== "object") return;
|
|
103
|
+
if (Array.isArray(node)) {
|
|
104
|
+
for (const c of node) walk(c);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const n = node;
|
|
108
|
+
if (typeof n.type !== "string") return;
|
|
109
|
+
if ((n.type === "JSXElement" || n.type === "JSXFragment") && n.loc) {
|
|
110
|
+
const s = n.loc.start;
|
|
111
|
+
const e = n.loc.end;
|
|
112
|
+
const afterStart = line > s.line || line === s.line && column >= s.column;
|
|
113
|
+
const beforeEnd = line < e.line || line === e.line && column < e.column;
|
|
114
|
+
if (afterStart && beforeEnd) hits.push({
|
|
115
|
+
node: n,
|
|
116
|
+
size: n.end - n.start
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const key of Object.keys(n)) {
|
|
120
|
+
if (key === "loc" || key === "start" || key === "end" || key === "type" || key === "extra" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
|
|
121
|
+
walk(n[key]);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(ast);
|
|
125
|
+
hits.sort((a, b) => a.size - b.size);
|
|
126
|
+
return hits.map((h) => h.node);
|
|
127
|
+
}
|
|
128
|
+
function planInsertion(source, target) {
|
|
129
|
+
if (target.type === "JSXFragment") {
|
|
130
|
+
const opening = target.openingFragment;
|
|
131
|
+
if (!opening) return null;
|
|
132
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
133
|
+
return {
|
|
134
|
+
offset: opening.end,
|
|
135
|
+
indent: `${lineIndent(source, startLine)} `
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (target.type === "JSXElement") {
|
|
139
|
+
const opening = target.openingElement;
|
|
140
|
+
if (!opening || opening.selfClosing) return null;
|
|
141
|
+
const startLine = target.loc?.start.line ?? 1;
|
|
142
|
+
return {
|
|
143
|
+
offset: opening.end,
|
|
144
|
+
indent: `${lineIndent(source, startLine)} `
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
79
148
|
}
|
|
80
149
|
/**
|
|
81
|
-
*
|
|
150
|
+
* Resolve a click on the slide page (line/col from React fiber's
|
|
151
|
+
* `_debugSource`) to an in-source offset where we can safely splice a
|
|
152
|
+
* `@slide-comment` marker.
|
|
82
153
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
154
|
+
* Strategy: parse the file, find every JSX container around the click, and
|
|
155
|
+
* walk innermost → outermost looking for the first one we can insert *inside*
|
|
156
|
+
* (i.e. not self-closing). Self-closing elements like `<img/>` get hoisted to
|
|
157
|
+
* their nearest non-self-closing ancestor.
|
|
87
158
|
*/
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
159
|
+
function findInsertion(source, line, column) {
|
|
160
|
+
let ast;
|
|
161
|
+
try {
|
|
162
|
+
ast = parse(source, {
|
|
163
|
+
sourceType: "module",
|
|
164
|
+
plugins: ["typescript", "jsx"],
|
|
165
|
+
errorRecovery: true
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const col = column ?? 0;
|
|
171
|
+
const ancestors = findJsxAncestors(ast, line, col);
|
|
172
|
+
if (ancestors.length === 0) return null;
|
|
173
|
+
for (const node of ancestors) {
|
|
174
|
+
const plan = planInsertion(source, node);
|
|
175
|
+
if (plan) return plan;
|
|
176
|
+
}
|
|
96
177
|
return null;
|
|
97
178
|
}
|
|
179
|
+
function offsetToLine(source, offset) {
|
|
180
|
+
let line = 1;
|
|
181
|
+
for (let i = 0; i < offset && i < source.length; i++) if (source[i] === "\n") line++;
|
|
182
|
+
return line;
|
|
183
|
+
}
|
|
98
184
|
function commentsPlugin(opts) {
|
|
99
185
|
const userCwd = opts.userCwd;
|
|
100
186
|
const slidesDir = opts.slidesDir ?? "slides";
|
|
@@ -131,22 +217,21 @@ function commentsPlugin(opts) {
|
|
|
131
217
|
} catch {
|
|
132
218
|
return json$1(res, 404, { error: "slide not found" });
|
|
133
219
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
if (idx === null) return json$1(res, 422, { error: `could not find a safe JSX boundary near line ${body.line}. Try clicking a different element.` });
|
|
137
|
-
const indent = lines[idx].match(/^\s*/)?.[0] ?? "";
|
|
220
|
+
const plan = findInsertion(source, body.line, body.column);
|
|
221
|
+
if (!plan) return json$1(res, 422, { error: `could not find a JSX container around line ${body.line}. Try clicking a different element.` });
|
|
138
222
|
const id = newId();
|
|
139
223
|
const ts = new Date().toISOString();
|
|
140
224
|
const payload = b64urlEncode(JSON.stringify({
|
|
141
225
|
note: body.text,
|
|
142
226
|
hint: body.hint
|
|
143
227
|
}));
|
|
144
|
-
const marker =
|
|
145
|
-
|
|
146
|
-
await fs.writeFile(file,
|
|
228
|
+
const marker = `\n${plan.indent}{/* @slide-comment id="${id}" ts="${ts}" text="${payload}" */}`;
|
|
229
|
+
const next$1 = source.slice(0, plan.offset) + marker + source.slice(plan.offset);
|
|
230
|
+
await fs.writeFile(file, next$1, "utf8");
|
|
231
|
+
const markerLine = offsetToLine(next$1, plan.offset + 1);
|
|
147
232
|
return json$1(res, 200, {
|
|
148
233
|
id,
|
|
149
|
-
line:
|
|
234
|
+
line: markerLine
|
|
150
235
|
});
|
|
151
236
|
}
|
|
152
237
|
if (method === "DELETE" && url.pathname.startsWith("/")) {
|
|
@@ -481,8 +566,26 @@ function filesPlugin(opts) {
|
|
|
481
566
|
|
|
482
567
|
//#endregion
|
|
483
568
|
//#region src/vite/open-slide-plugin.ts
|
|
569
|
+
const CONFIG_FILE = "open-slide.config.ts";
|
|
484
570
|
const SLIDES_VMOD = "virtual:open-slide/slides";
|
|
485
571
|
const CONFIG_VMOD = "virtual:open-slide/config";
|
|
572
|
+
const FOLDERS_VMOD = "virtual:open-slide/folders";
|
|
573
|
+
async function readFoldersManifest(file) {
|
|
574
|
+
try {
|
|
575
|
+
const raw = await fs.readFile(file, "utf8");
|
|
576
|
+
const parsed = JSON.parse(raw);
|
|
577
|
+
return {
|
|
578
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders : [],
|
|
579
|
+
assignments: parsed.assignments && typeof parsed.assignments === "object" ? parsed.assignments : {}
|
|
580
|
+
};
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err.code === "ENOENT") return {
|
|
583
|
+
folders: [],
|
|
584
|
+
assignments: {}
|
|
585
|
+
};
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
486
589
|
function resolved(id) {
|
|
487
590
|
return `\0${id}`;
|
|
488
591
|
}
|
|
@@ -526,6 +629,7 @@ function openSlidePlugin(opts) {
|
|
|
526
629
|
const { userCwd, config } = opts;
|
|
527
630
|
const slidesDir = config.slidesDir ?? "slides";
|
|
528
631
|
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
632
|
+
const foldersManifestPath = path.join(slidesRoot, ".folders.json");
|
|
529
633
|
let isDev = false;
|
|
530
634
|
return {
|
|
531
635
|
name: "open-slide",
|
|
@@ -536,6 +640,7 @@ function openSlidePlugin(opts) {
|
|
|
536
640
|
resolveId(id) {
|
|
537
641
|
if (id === SLIDES_VMOD) return resolved(SLIDES_VMOD);
|
|
538
642
|
if (id === CONFIG_VMOD) return resolved(CONFIG_VMOD);
|
|
643
|
+
if (id === FOLDERS_VMOD) return resolved(FOLDERS_VMOD);
|
|
539
644
|
return null;
|
|
540
645
|
},
|
|
541
646
|
async load(id) {
|
|
@@ -543,7 +648,27 @@ function openSlidePlugin(opts) {
|
|
|
543
648
|
const files = await findSlides(userCwd, slidesDir);
|
|
544
649
|
return generateSlidesModule(files, slidesRoot, isDev);
|
|
545
650
|
}
|
|
546
|
-
if (id === resolved(CONFIG_VMOD))
|
|
651
|
+
if (id === resolved(CONFIG_VMOD)) {
|
|
652
|
+
const userBuild = config.build ?? {};
|
|
653
|
+
const buildResolved = isDev ? {
|
|
654
|
+
showSlideBrowser: true,
|
|
655
|
+
showSlideUi: true,
|
|
656
|
+
allowHtmlDownload: true
|
|
657
|
+
} : {
|
|
658
|
+
showSlideBrowser: userBuild.showSlideBrowser ?? true,
|
|
659
|
+
showSlideUi: userBuild.showSlideUi ?? true,
|
|
660
|
+
allowHtmlDownload: userBuild.allowHtmlDownload ?? true
|
|
661
|
+
};
|
|
662
|
+
const resolvedConfig = {
|
|
663
|
+
...config,
|
|
664
|
+
build: buildResolved
|
|
665
|
+
};
|
|
666
|
+
return `export default ${JSON.stringify(resolvedConfig)};\n`;
|
|
667
|
+
}
|
|
668
|
+
if (id === resolved(FOLDERS_VMOD)) {
|
|
669
|
+
const manifest = await readFoldersManifest(foldersManifestPath);
|
|
670
|
+
return `export default ${JSON.stringify(manifest)};\n`;
|
|
671
|
+
}
|
|
547
672
|
return null;
|
|
548
673
|
},
|
|
549
674
|
configureServer(server) {
|
|
@@ -559,18 +684,31 @@ function openSlidePlugin(opts) {
|
|
|
559
684
|
server.watcher.on("unlink", (p) => {
|
|
560
685
|
if (p.startsWith(slidesRoot)) reload();
|
|
561
686
|
});
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
687
|
+
const invalidateFolders = () => {
|
|
688
|
+
const mod = server.moduleGraph.getModuleById(resolved(FOLDERS_VMOD));
|
|
689
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
690
|
+
};
|
|
691
|
+
server.watcher.add(foldersManifestPath);
|
|
692
|
+
server.watcher.on("change", (p) => {
|
|
693
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
694
|
+
});
|
|
695
|
+
server.watcher.on("add", (p) => {
|
|
696
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
697
|
+
});
|
|
698
|
+
server.watcher.on("unlink", (p) => {
|
|
699
|
+
if (p === foldersManifestPath) invalidateFolders();
|
|
565
700
|
});
|
|
566
701
|
}
|
|
567
702
|
};
|
|
568
703
|
}
|
|
569
704
|
async function loadUserConfig(userCwd) {
|
|
570
|
-
const file = path.join(userCwd,
|
|
705
|
+
const file = path.join(userCwd, CONFIG_FILE);
|
|
571
706
|
if (!existsSync(file)) return {};
|
|
572
|
-
const
|
|
573
|
-
|
|
707
|
+
const loaded = await loadConfigFromFile({
|
|
708
|
+
command: "serve",
|
|
709
|
+
mode: "development"
|
|
710
|
+
}, file, userCwd, "silent");
|
|
711
|
+
return loaded?.config ?? {};
|
|
574
712
|
}
|
|
575
713
|
|
|
576
714
|
//#endregion
|
|
@@ -620,7 +758,16 @@ async function createViteConfig(opts) {
|
|
|
620
758
|
"tailwind-merge",
|
|
621
759
|
"class-variance-authority",
|
|
622
760
|
"emoji-picker-react"
|
|
623
|
-
]
|
|
761
|
+
],
|
|
762
|
+
esbuildOptions: { plugins: [{
|
|
763
|
+
name: "open-slide:virtual-externals",
|
|
764
|
+
setup(build$1) {
|
|
765
|
+
build$1.onResolve({ filter: /^virtual:open-slide\// }, (args) => ({
|
|
766
|
+
path: args.path,
|
|
767
|
+
external: true
|
|
768
|
+
}));
|
|
769
|
+
}
|
|
770
|
+
}] }
|
|
624
771
|
},
|
|
625
772
|
server: {
|
|
626
773
|
port: config.port ?? 5173,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
type OpenSlideBuildConfig = {
|
|
3
|
+
showSlideBrowser?: boolean;
|
|
4
|
+
showSlideUi?: boolean;
|
|
5
|
+
allowHtmlDownload?: boolean;
|
|
6
|
+
};
|
|
7
|
+
type OpenSlideConfig = {
|
|
8
|
+
slidesDir?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
build?: OpenSlideBuildConfig;
|
|
11
|
+
}; //#endregion
|
|
12
|
+
export { OpenSlideConfig };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { OpenSlideConfig } from "./config-SXL5qIl6.js";
|
|
1
2
|
import { ComponentType } from "react";
|
|
2
3
|
|
|
3
4
|
//#region src/app/lib/sdk.d.ts
|
|
4
5
|
type Page = ComponentType;
|
|
5
6
|
type SlideMeta = {
|
|
6
7
|
title?: string;
|
|
7
|
-
theme?: 'light' | 'dark';
|
|
8
8
|
};
|
|
9
9
|
type SlideModule = {
|
|
10
10
|
default: Page[];
|
|
@@ -12,4 +12,4 @@ type SlideModule = {
|
|
|
12
12
|
};
|
|
13
13
|
declare const CANVAS_WIDTH = 1920;
|
|
14
14
|
declare const CANVAS_HEIGHT = 1080; //#endregion
|
|
15
|
-
export { CANVAS_HEIGHT, CANVAS_WIDTH, Page, SlideMeta, SlideModule };
|
|
15
|
+
export { CANVAS_HEIGHT, CANVAS_WIDTH, OpenSlideConfig, Page, SlideMeta, SlideModule };
|
package/dist/vite/index.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import { OpenSlideConfig } from "../config-SXL5qIl6.js";
|
|
1
2
|
import { InlineConfig } from "vite";
|
|
2
3
|
|
|
3
|
-
//#region src/vite/open-slide-plugin.d.ts
|
|
4
|
-
type OpenSlideConfig = {
|
|
5
|
-
title?: string;
|
|
6
|
-
slidesDir?: string;
|
|
7
|
-
port?: number;
|
|
8
|
-
}; //#endregion
|
|
9
4
|
//#region src/vite/config.d.ts
|
|
10
5
|
type CreateViteConfigOptions = {
|
|
11
6
|
userCwd: string;
|
|
@@ -15,4 +10,4 @@ type CreateViteConfigOptions = {
|
|
|
15
10
|
declare function createViteConfig(opts: CreateViteConfigOptions): Promise<InlineConfig>;
|
|
16
11
|
|
|
17
12
|
//#endregion
|
|
18
|
-
export {
|
|
13
|
+
export { createViteConfig };
|
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@babel/parser": "^7.29.2",
|
|
44
45
|
"@fontsource-variable/geist": "^5.2.8",
|
|
45
46
|
"@tailwindcss/vite": "^4.2.2",
|
|
46
47
|
"@vitejs/plugin-react": "^4.3.3",
|
package/src/app/App.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
2
|
+
import config from 'virtual:open-slide/config';
|
|
2
3
|
import { Home } from './routes/Home';
|
|
3
4
|
import { Slide } from './routes/Slide';
|
|
4
5
|
|
|
@@ -6,9 +7,21 @@ export function App() {
|
|
|
6
7
|
return (
|
|
7
8
|
<BrowserRouter>
|
|
8
9
|
<Routes>
|
|
9
|
-
<Route path="/" element={<Home />} />
|
|
10
|
+
<Route path="/" element={config.build.showSlideBrowser ? <Home /> : <NotFound />} />
|
|
10
11
|
<Route path="/s/:slideId" element={<Slide />} />
|
|
12
|
+
<Route path="*" element={<NotFound />} />
|
|
11
13
|
</Routes>
|
|
12
14
|
</BrowserRouter>
|
|
13
15
|
);
|
|
14
16
|
}
|
|
17
|
+
|
|
18
|
+
function NotFound() {
|
|
19
|
+
return (
|
|
20
|
+
<div className="grid h-screen place-items-center bg-background px-6 text-center">
|
|
21
|
+
<div>
|
|
22
|
+
<p className="font-mono text-sm tracking-widest text-muted-foreground uppercase">404</p>
|
|
23
|
+
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Page not found</h1>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
|
|
2
3
|
import type { Page } from '../lib/sdk';
|
|
3
4
|
import { SlideCanvas } from './SlideCanvas';
|
|
4
5
|
|
|
@@ -7,10 +8,25 @@ type Props = {
|
|
|
7
8
|
index: number;
|
|
8
9
|
onIndexChange: (index: number) => void;
|
|
9
10
|
onExit: () => void;
|
|
11
|
+
allowExit?: boolean;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
|
-
export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
14
|
+
export function Player({ pages, index, onIndexChange, onExit, allowExit = true }: Props) {
|
|
13
15
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const goPrev = useCallback(() => {
|
|
17
|
+
if (index > 0) onIndexChange(index - 1);
|
|
18
|
+
}, [index, onIndexChange]);
|
|
19
|
+
const goNext = useCallback(() => {
|
|
20
|
+
if (index < pages.length - 1) onIndexChange(index + 1);
|
|
21
|
+
}, [index, pages.length, onIndexChange]);
|
|
22
|
+
|
|
23
|
+
useWheelPageNavigation({
|
|
24
|
+
ref: rootRef,
|
|
25
|
+
canPrev: index > 0,
|
|
26
|
+
canNext: index < pages.length - 1,
|
|
27
|
+
onPrev: goPrev,
|
|
28
|
+
onNext: goNext,
|
|
29
|
+
});
|
|
14
30
|
|
|
15
31
|
useEffect(() => {
|
|
16
32
|
const el = rootRef.current;
|
|
@@ -24,12 +40,13 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
24
40
|
}, []);
|
|
25
41
|
|
|
26
42
|
useEffect(() => {
|
|
43
|
+
if (!allowExit) return;
|
|
27
44
|
const onFsChange = () => {
|
|
28
45
|
if (!document.fullscreenElement) onExit();
|
|
29
46
|
};
|
|
30
47
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
31
48
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
32
|
-
}, [onExit]);
|
|
49
|
+
}, [onExit, allowExit]);
|
|
33
50
|
|
|
34
51
|
useEffect(() => {
|
|
35
52
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -45,7 +62,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
45
62
|
e.preventDefault();
|
|
46
63
|
if (index > 0) onIndexChange(index - 1);
|
|
47
64
|
} else if (e.key === 'Escape') {
|
|
48
|
-
onExit();
|
|
65
|
+
if (allowExit) onExit();
|
|
49
66
|
} else if (e.key === 'Home') {
|
|
50
67
|
onIndexChange(0);
|
|
51
68
|
} else if (e.key === 'End') {
|
|
@@ -54,7 +71,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
54
71
|
};
|
|
55
72
|
window.addEventListener('keydown', onKey);
|
|
56
73
|
return () => window.removeEventListener('keydown', onKey);
|
|
57
|
-
}, [index, pages.length, onIndexChange, onExit]);
|
|
74
|
+
}, [index, pages.length, onIndexChange, onExit, allowExit]);
|
|
58
75
|
|
|
59
76
|
const PageComp = pages[index];
|
|
60
77
|
|
|
@@ -67,14 +84,14 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
67
84
|
<button
|
|
68
85
|
type="button"
|
|
69
86
|
aria-label="Previous page"
|
|
70
|
-
onClick={
|
|
87
|
+
onClick={goPrev}
|
|
71
88
|
disabled={index === 0}
|
|
72
89
|
className="absolute inset-y-0 left-0 z-10 w-[30%]"
|
|
73
90
|
/>
|
|
74
91
|
<button
|
|
75
92
|
type="button"
|
|
76
93
|
aria-label="Next page"
|
|
77
|
-
onClick={
|
|
94
|
+
onClick={goNext}
|
|
78
95
|
disabled={index === pages.length - 1}
|
|
79
96
|
className="absolute inset-y-0 right-0 z-10 w-[30%]"
|
|
80
97
|
/>
|
|
@@ -18,6 +18,7 @@ const THUMB_HEIGHT = CANVAS_HEIGHT * THUMB_SCALE;
|
|
|
18
18
|
export function ThumbnailRail({ pages, current, onSelect }: Props) {
|
|
19
19
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
20
20
|
|
|
21
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
activeRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
23
24
|
}, [current]);
|
|
@@ -29,7 +30,9 @@ export function ThumbnailRail({ pages, current, onSelect }: Props) {
|
|
|
29
30
|
const active = i === current;
|
|
30
31
|
return (
|
|
31
32
|
<button
|
|
33
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
|
|
32
34
|
key={i}
|
|
35
|
+
type="button"
|
|
33
36
|
ref={active ? activeRef : undefined}
|
|
34
37
|
onClick={() => onSelect(i)}
|
|
35
38
|
aria-label={`Go to page ${i + 1}`}
|
|
@@ -8,9 +8,9 @@ export function CommentWidget() {
|
|
|
8
8
|
const count = comments.length;
|
|
9
9
|
|
|
10
10
|
return (
|
|
11
|
-
<div data-inspector-ui className="fixed right-4 bottom-4 z-40">
|
|
11
|
+
<div data-inspector-ui className="fixed right-4 bottom-4 z-40 flex flex-col items-end gap-2">
|
|
12
12
|
{open && (
|
|
13
|
-
<div className="
|
|
13
|
+
<div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
|
|
14
14
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
|
15
15
|
<span className="text-xs font-semibold">
|
|
16
16
|
{count} comment{count === 1 ? '' : 's'}
|
|
@@ -29,24 +29,38 @@ export function CommentWidget() {
|
|
|
29
29
|
No comments yet. Toggle Inspect and click a slide element.
|
|
30
30
|
</p>
|
|
31
31
|
) : (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
</div>
|
|
39
|
-
<button
|
|
40
|
-
type="button"
|
|
41
|
-
onClick={() => remove(c.id)}
|
|
42
|
-
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
|
|
43
|
-
title="Delete"
|
|
32
|
+
<>
|
|
33
|
+
<ul className="max-h-72 overflow-auto">
|
|
34
|
+
{comments.map((c) => (
|
|
35
|
+
<li
|
|
36
|
+
key={c.id}
|
|
37
|
+
className="flex items-start gap-2 border-b px-3 py-2 last:border-0"
|
|
44
38
|
>
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
<div className="min-w-0 flex-1">
|
|
40
|
+
<div className="text-[10px] font-mono text-muted-foreground">
|
|
41
|
+
line {c.line}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="mt-0.5 text-xs break-words">{c.note}</div>
|
|
44
|
+
</div>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={() => remove(c.id)}
|
|
48
|
+
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
|
|
49
|
+
title="Delete"
|
|
50
|
+
>
|
|
51
|
+
<Trash2 className="size-3.5" />
|
|
52
|
+
</button>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
|
|
57
|
+
Run{' '}
|
|
58
|
+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
|
|
59
|
+
/apply-comments
|
|
60
|
+
</code>{' '}
|
|
61
|
+
in your agent to apply these.
|
|
62
|
+
</div>
|
|
63
|
+
</>
|
|
50
64
|
)}
|
|
51
65
|
</div>
|
|
52
66
|
)}
|
|
@@ -91,6 +91,7 @@ export function FolderItem({
|
|
|
91
91
|
};
|
|
92
92
|
|
|
93
93
|
return (
|
|
94
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
|
|
94
95
|
<div
|
|
95
96
|
className={cn(
|
|
96
97
|
'group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
|
@@ -125,7 +126,6 @@ export function FolderItem({
|
|
|
125
126
|
|
|
126
127
|
{renaming && row.kind === 'folder' ? (
|
|
127
128
|
<input
|
|
128
|
-
autoFocus
|
|
129
129
|
value={draftName}
|
|
130
130
|
onChange={(e) => setDraftName(e.target.value)}
|
|
131
131
|
onBlur={commitRename}
|
|
@@ -22,7 +22,7 @@ export function Sidebar({
|
|
|
22
22
|
countFor: (folderId: string | null) => number;
|
|
23
23
|
selectedId: string;
|
|
24
24
|
onSelect: (id: string) => void;
|
|
25
|
-
onCreate: (name: string, icon: FolderIcon) => Promise<Folder> |
|
|
25
|
+
onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
|
|
26
26
|
onRename: (id: string, name: string) => void;
|
|
27
27
|
onChangeIcon: (id: string, icon: FolderIcon) => void;
|
|
28
28
|
onDelete: (id: string) => void;
|
|
@@ -86,7 +86,6 @@ export function Sidebar({
|
|
|
86
86
|
{creating ? (
|
|
87
87
|
<div className="mt-1 flex items-center gap-2 rounded-md border border-dashed bg-background px-2 py-1.5">
|
|
88
88
|
<input
|
|
89
|
-
autoFocus
|
|
90
89
|
value={newName}
|
|
91
90
|
onChange={(e) => setNewName(e.target.value)}
|
|
92
91
|
onBlur={commitCreate}
|
|
Binary file
|
package/src/app/index.html
CHANGED
package/src/app/lib/folders.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import buildManifest from 'virtual:open-slide/folders';
|
|
2
3
|
import type { Folder, FolderIcon, FoldersManifest } from './sdk';
|
|
3
4
|
|
|
4
5
|
const EMPTY: FoldersManifest = { folders: [], assignments: {} };
|
|
5
6
|
|
|
6
7
|
async function getManifest(): Promise<FoldersManifest> {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// In dev the manifest is mutable: read live from the plugin endpoint so
|
|
9
|
+
// edits made in the sidebar reflect immediately. In a static build there
|
|
10
|
+
// is no server, so fall back to the bundled snapshot from the virtual
|
|
11
|
+
// module (populated at build time from slides/.folders.json).
|
|
12
|
+
if (import.meta.env.DEV) {
|
|
13
|
+
const res = await fetch('/__folders');
|
|
14
|
+
if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
|
|
15
|
+
const raw = (await res.json()) as Partial<FoldersManifest>;
|
|
16
|
+
return {
|
|
17
|
+
folders: raw.folders ?? [],
|
|
18
|
+
assignments: raw.assignments ?? {},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
10
21
|
return {
|
|
11
|
-
folders:
|
|
12
|
-
assignments:
|
|
22
|
+
folders: buildManifest.folders ?? [],
|
|
23
|
+
assignments: buildManifest.assignments ?? {},
|
|
13
24
|
};
|
|
14
25
|
}
|
|
15
26
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from './sdk.ts';
|
|
3
|
+
|
|
4
|
+
describe('canvas constants', () => {
|
|
5
|
+
it('targets a 1920x1080 canvas', () => {
|
|
6
|
+
expect(CANVAS_WIDTH).toBe(1920);
|
|
7
|
+
expect(CANVAS_HEIGHT).toBe(1080);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('preserves a 16:9 aspect ratio', () => {
|
|
11
|
+
expect(CANVAS_WIDTH / CANVAS_HEIGHT).toBeCloseTo(16 / 9);
|
|
12
|
+
});
|
|
13
|
+
});
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const WHEEL_PAGE_THRESHOLD_PX = 12;
|
|
4
|
+
const WHEEL_NAV_COOLDOWN_MS = 100;
|
|
5
|
+
const WHEEL_GESTURE_IDLE_MS = 80;
|
|
6
|
+
|
|
7
|
+
type UseWheelPageNavigationOptions<T extends HTMLElement> = {
|
|
8
|
+
ref: RefObject<T>;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
canPrev: boolean;
|
|
11
|
+
canNext: boolean;
|
|
12
|
+
onPrev: () => void;
|
|
13
|
+
onNext: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function useWheelPageNavigation<T extends HTMLElement>({
|
|
17
|
+
ref,
|
|
18
|
+
enabled = true,
|
|
19
|
+
canPrev,
|
|
20
|
+
canNext,
|
|
21
|
+
onPrev,
|
|
22
|
+
onNext,
|
|
23
|
+
}: UseWheelPageNavigationOptions<T>) {
|
|
24
|
+
const accumulatedDeltaRef = useRef(0);
|
|
25
|
+
const lastWheelAtRef = useRef(0);
|
|
26
|
+
const lastNavigateAtRef = useRef(0);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const el = ref.current;
|
|
30
|
+
if (!el || !enabled) return;
|
|
31
|
+
|
|
32
|
+
const onWheel = (event: WheelEvent) => {
|
|
33
|
+
if (event.defaultPrevented || event.ctrlKey || shouldIgnoreWheelTarget(event.target)) return;
|
|
34
|
+
|
|
35
|
+
const deltaY = normalizeDeltaY(event);
|
|
36
|
+
if (Math.abs(deltaY) <= Math.abs(normalizeDeltaX(event))) return;
|
|
37
|
+
|
|
38
|
+
const now = performance.now();
|
|
39
|
+
if (now - lastWheelAtRef.current > WHEEL_GESTURE_IDLE_MS) {
|
|
40
|
+
accumulatedDeltaRef.current = 0;
|
|
41
|
+
}
|
|
42
|
+
lastWheelAtRef.current = now;
|
|
43
|
+
|
|
44
|
+
if (now - lastNavigateAtRef.current < WHEEL_NAV_COOLDOWN_MS) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
accumulatedDeltaRef.current += deltaY;
|
|
50
|
+
if (Math.abs(accumulatedDeltaRef.current) < WHEEL_PAGE_THRESHOLD_PX) {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const direction = Math.sign(accumulatedDeltaRef.current);
|
|
56
|
+
accumulatedDeltaRef.current = 0;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
|
|
59
|
+
if (direction > 0 && canNext) {
|
|
60
|
+
lastNavigateAtRef.current = now;
|
|
61
|
+
onNext();
|
|
62
|
+
} else if (direction < 0 && canPrev) {
|
|
63
|
+
lastNavigateAtRef.current = now;
|
|
64
|
+
onPrev();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
69
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
70
|
+
}, [ref, enabled, canPrev, canNext, onPrev, onNext]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeDeltaY(event: WheelEvent) {
|
|
74
|
+
return normalizeWheelDelta(event.deltaY, event.deltaMode);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeDeltaX(event: WheelEvent) {
|
|
78
|
+
return normalizeWheelDelta(event.deltaX, event.deltaMode);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeWheelDelta(delta: number, deltaMode: number) {
|
|
82
|
+
if (deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * 16;
|
|
83
|
+
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * 800;
|
|
84
|
+
return delta;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shouldIgnoreWheelTarget(target: EventTarget | null) {
|
|
88
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
89
|
+
return Boolean(
|
|
90
|
+
target.closest('input, textarea, select, [contenteditable="true"], [data-wheel-nav-ignore]'),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { cn } from './utils.ts';
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('joins multiple class names', () => {
|
|
6
|
+
expect(cn('a', 'b', 'c')).toBe('a b c');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('drops falsy values', () => {
|
|
10
|
+
expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('flattens arrays and conditional objects from clsx', () => {
|
|
14
|
+
expect(cn(['a', 'b'], { c: true, d: false })).toBe('a b c');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lets later tailwind classes override earlier ones', () => {
|
|
18
|
+
expect(cn('p-2', 'p-4')).toBe('p-4');
|
|
19
|
+
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves classes that target different properties', () => {
|
|
23
|
+
expect(cn('p-2', 'm-4')).toBe('p-2 m-4');
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/app/main.tsx
CHANGED
package/src/app/routes/Home.tsx
CHANGED
|
@@ -49,7 +49,8 @@ export function Home() {
|
|
|
49
49
|
for (const id of slideIds) {
|
|
50
50
|
const folderId = manifest.assignments[id];
|
|
51
51
|
if (folderId && known.has(folderId)) {
|
|
52
|
-
|
|
52
|
+
byFolder[folderId] ??= [];
|
|
53
|
+
byFolder[folderId].push(id);
|
|
53
54
|
} else {
|
|
54
55
|
draft.push(id);
|
|
55
56
|
}
|
|
@@ -245,6 +246,7 @@ function SlideCard({
|
|
|
245
246
|
|
|
246
247
|
return (
|
|
247
248
|
<>
|
|
249
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag source wraps an interactive Link */}
|
|
248
250
|
<div
|
|
249
251
|
draggable
|
|
250
252
|
onDragStart={(e) => {
|
package/src/app/routes/Slide.tsx
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import config from 'virtual:open-slide/config';
|
|
2
|
+
import { ChevronLeft, Download, FileCode2, Loader2, Pencil, Play } from 'lucide-react';
|
|
3
|
+
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
4
5
|
import { CommentWidget } from '@/components/inspector/CommentWidget';
|
|
5
6
|
import { InspectOverlay } from '@/components/inspector/InspectOverlay';
|
|
6
|
-
import {
|
|
7
|
-
|
|
7
|
+
import {
|
|
8
|
+
InspectorProvider,
|
|
9
|
+
InspectToggleButton,
|
|
10
|
+
useInspector,
|
|
11
|
+
} from '@/components/inspector/InspectorProvider';
|
|
12
|
+
import { Button, buttonVariants } from '@/components/ui/button';
|
|
13
|
+
import {
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuContent,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuTrigger,
|
|
18
|
+
} from '@/components/ui/dropdown-menu';
|
|
8
19
|
import { Separator } from '@/components/ui/separator';
|
|
9
20
|
import { useFolders } from '@/lib/folders';
|
|
21
|
+
import { useWheelPageNavigation } from '@/lib/useWheelPageNavigation';
|
|
10
22
|
import { cn } from '@/lib/utils';
|
|
11
23
|
import { ClickNavZones } from '../components/ClickNavZones';
|
|
12
24
|
import { Player } from '../components/Player';
|
|
@@ -16,6 +28,8 @@ import { exportSlideAsHtml } from '../lib/export-html';
|
|
|
16
28
|
import type { SlideModule } from '../lib/sdk';
|
|
17
29
|
import { loadSlide } from '../lib/slides';
|
|
18
30
|
|
|
31
|
+
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
|
|
32
|
+
|
|
19
33
|
export function Slide() {
|
|
20
34
|
const { slideId = '' } = useParams();
|
|
21
35
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -24,6 +38,7 @@ export function Slide() {
|
|
|
24
38
|
const [playing, setPlaying] = useState(false);
|
|
25
39
|
const [exporting, setExporting] = useState(false);
|
|
26
40
|
const { renameSlide } = useFolders();
|
|
41
|
+
const slideViewportRef = useRef<HTMLElement>(null);
|
|
27
42
|
|
|
28
43
|
useEffect(() => {
|
|
29
44
|
let cancelled = false;
|
|
@@ -82,9 +97,11 @@ export function Slide() {
|
|
|
82
97
|
if (error) {
|
|
83
98
|
return (
|
|
84
99
|
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
{showSlideBrowser && (
|
|
101
|
+
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
|
102
|
+
← Home
|
|
103
|
+
</Link>
|
|
104
|
+
)}
|
|
88
105
|
<h2 className="mt-4 text-xl font-semibold text-foreground">Failed to load slide</h2>
|
|
89
106
|
<pre className="mt-4 overflow-auto rounded-md border bg-card p-4 text-xs whitespace-pre-wrap shadow-sm">
|
|
90
107
|
{error}
|
|
@@ -104,9 +121,11 @@ export function Slide() {
|
|
|
104
121
|
if (pageCount === 0) {
|
|
105
122
|
return (
|
|
106
123
|
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
{showSlideBrowser && (
|
|
125
|
+
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
|
126
|
+
← Home
|
|
127
|
+
</Link>
|
|
128
|
+
)}
|
|
110
129
|
<h2 className="mt-4 text-xl font-semibold text-foreground">Empty slide</h2>
|
|
111
130
|
<p className="mt-2 text-sm">
|
|
112
131
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
|
@@ -120,6 +139,18 @@ export function Slide() {
|
|
|
120
139
|
);
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
if (!showSlideUi) {
|
|
143
|
+
return (
|
|
144
|
+
<Player
|
|
145
|
+
pages={pages}
|
|
146
|
+
index={index}
|
|
147
|
+
onIndexChange={goTo}
|
|
148
|
+
onExit={() => {}}
|
|
149
|
+
allowExit={false}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
if (playing) {
|
|
124
155
|
return (
|
|
125
156
|
<Player pages={pages} index={index} onIndexChange={goTo} onExit={() => setPlaying(false)} />
|
|
@@ -132,48 +163,73 @@ export function Slide() {
|
|
|
132
163
|
return (
|
|
133
164
|
<InspectorProvider slideId={slideId}>
|
|
134
165
|
<div className="flex h-screen flex-col overflow-hidden bg-background">
|
|
135
|
-
<header className="flex shrink-0 items-center
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
<header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
|
|
167
|
+
<div className="flex items-center gap-2 md:gap-3">
|
|
168
|
+
{showSlideBrowser && (
|
|
169
|
+
<>
|
|
170
|
+
<Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
|
|
171
|
+
<Link to="/">
|
|
172
|
+
<ChevronLeft className="size-4" />
|
|
173
|
+
<span className="hidden md:inline">Home</span>
|
|
174
|
+
</Link>
|
|
175
|
+
</Button>
|
|
176
|
+
<Separator orientation="vertical" className="hidden h-5 md:block" />
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
|
|
182
|
+
<div className="pointer-events-auto min-w-0 max-w-[min(32rem,calc(100vw-20rem))]">
|
|
183
|
+
<InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div className="flex items-center gap-1.5">
|
|
188
|
+
{allowHtmlDownload && (
|
|
189
|
+
<DropdownMenu>
|
|
190
|
+
<DropdownMenuTrigger
|
|
191
|
+
type="button"
|
|
192
|
+
disabled={exporting}
|
|
193
|
+
aria-label="Download"
|
|
194
|
+
title="Download"
|
|
195
|
+
className={cn(buttonVariants({ variant: 'outline', size: 'icon-sm' }))}
|
|
196
|
+
>
|
|
197
|
+
{exporting ? (
|
|
198
|
+
<Loader2 className="size-4 animate-spin" />
|
|
199
|
+
) : (
|
|
200
|
+
<Download className="size-4" />
|
|
201
|
+
)}
|
|
202
|
+
</DropdownMenuTrigger>
|
|
203
|
+
<DropdownMenuContent align="end" className="min-w-[180px]">
|
|
204
|
+
<DropdownMenuItem
|
|
205
|
+
disabled={exporting}
|
|
206
|
+
onSelect={async () => {
|
|
207
|
+
if (!slide || exporting) return;
|
|
208
|
+
setExporting(true);
|
|
209
|
+
try {
|
|
210
|
+
await exportSlideAsHtml(slide, slideId);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error('[open-slide] export failed', err);
|
|
213
|
+
} finally {
|
|
214
|
+
setExporting(false);
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<FileCode2 />
|
|
219
|
+
Download HTML
|
|
220
|
+
</DropdownMenuItem>
|
|
221
|
+
</DropdownMenuContent>
|
|
222
|
+
</DropdownMenu>
|
|
166
223
|
)}
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
</Button>
|
|
224
|
+
<InspectToggleButton />
|
|
225
|
+
<Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
|
|
226
|
+
<Play className="size-4" />
|
|
227
|
+
<span className="hidden md:inline">Play</span>
|
|
228
|
+
<kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
|
|
229
|
+
F
|
|
230
|
+
</kbd>
|
|
231
|
+
</Button>
|
|
232
|
+
</div>
|
|
177
233
|
</header>
|
|
178
234
|
|
|
179
235
|
<div className="flex min-h-0 flex-1">
|
|
@@ -181,9 +237,17 @@ export function Slide() {
|
|
|
181
237
|
<ThumbnailRail pages={pages} current={index} onSelect={goTo} />
|
|
182
238
|
</div>
|
|
183
239
|
<main
|
|
240
|
+
ref={slideViewportRef}
|
|
184
241
|
data-inspector-root
|
|
185
242
|
className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:p-8"
|
|
186
243
|
>
|
|
244
|
+
<SlideWheelNavigation
|
|
245
|
+
targetRef={slideViewportRef}
|
|
246
|
+
onPrev={() => goTo(index - 1)}
|
|
247
|
+
onNext={() => goTo(index + 1)}
|
|
248
|
+
canPrev={index > 0}
|
|
249
|
+
canNext={index < pageCount - 1}
|
|
250
|
+
/>
|
|
187
251
|
<SlideCanvas>
|
|
188
252
|
<CurrentPage />
|
|
189
253
|
</SlideCanvas>
|
|
@@ -206,6 +270,33 @@ export function Slide() {
|
|
|
206
270
|
);
|
|
207
271
|
}
|
|
208
272
|
|
|
273
|
+
function SlideWheelNavigation({
|
|
274
|
+
targetRef,
|
|
275
|
+
onPrev,
|
|
276
|
+
onNext,
|
|
277
|
+
canPrev,
|
|
278
|
+
canNext,
|
|
279
|
+
}: {
|
|
280
|
+
targetRef: RefObject<HTMLElement>;
|
|
281
|
+
onPrev: () => void;
|
|
282
|
+
onNext: () => void;
|
|
283
|
+
canPrev: boolean;
|
|
284
|
+
canNext: boolean;
|
|
285
|
+
}) {
|
|
286
|
+
const { active } = useInspector();
|
|
287
|
+
|
|
288
|
+
useWheelPageNavigation({
|
|
289
|
+
ref: targetRef,
|
|
290
|
+
enabled: !active,
|
|
291
|
+
canPrev,
|
|
292
|
+
canNext,
|
|
293
|
+
onPrev,
|
|
294
|
+
onNext,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
209
300
|
function InlineTitleEditor({
|
|
210
301
|
title,
|
|
211
302
|
onSubmit,
|
package/src/app/virtual.d.ts
CHANGED
|
@@ -6,9 +6,19 @@ declare module 'virtual:open-slide/slides' {
|
|
|
6
6
|
|
|
7
7
|
declare module 'virtual:open-slide/config' {
|
|
8
8
|
const config: {
|
|
9
|
-
title?: string;
|
|
10
9
|
slidesDir?: string;
|
|
11
10
|
port?: number;
|
|
11
|
+
build: {
|
|
12
|
+
showSlideBrowser: boolean;
|
|
13
|
+
showSlideUi: boolean;
|
|
14
|
+
allowHtmlDownload: boolean;
|
|
15
|
+
};
|
|
12
16
|
};
|
|
13
17
|
export default config;
|
|
14
18
|
}
|
|
19
|
+
|
|
20
|
+
declare module 'virtual:open-slide/folders' {
|
|
21
|
+
import type { FoldersManifest } from './lib/sdk';
|
|
22
|
+
const manifest: FoldersManifest;
|
|
23
|
+
export default manifest;
|
|
24
|
+
}
|