@open-slide/core 0.0.6 → 0.0.7
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-cUKUY4bh.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-DF58h0l4.js → config-DOcMmFJ7.js} +173 -35
- package/dist/config-SXL5qIl6.d.ts +12 -0
- package/dist/{dev-h-rxb3xY.js → dev-Brzmgu64.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{preview-lskE0s8A.js → preview-Bf8iFXA-.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 +6 -4
- 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/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 +99 -49
- 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-Brzmgu64.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-cUKUY4bh.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-Bf8iFXA-.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
|
|
@@ -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.7",
|
|
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
|
+
}
|
|
@@ -7,9 +7,10 @@ type Props = {
|
|
|
7
7
|
index: number;
|
|
8
8
|
onIndexChange: (index: number) => void;
|
|
9
9
|
onExit: () => void;
|
|
10
|
+
allowExit?: boolean;
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
13
|
+
export function Player({ pages, index, onIndexChange, onExit, allowExit = true }: Props) {
|
|
13
14
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
14
15
|
|
|
15
16
|
useEffect(() => {
|
|
@@ -24,12 +25,13 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
24
25
|
}, []);
|
|
25
26
|
|
|
26
27
|
useEffect(() => {
|
|
28
|
+
if (!allowExit) return;
|
|
27
29
|
const onFsChange = () => {
|
|
28
30
|
if (!document.fullscreenElement) onExit();
|
|
29
31
|
};
|
|
30
32
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
31
33
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
32
|
-
}, [onExit]);
|
|
34
|
+
}, [onExit, allowExit]);
|
|
33
35
|
|
|
34
36
|
useEffect(() => {
|
|
35
37
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -45,7 +47,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
45
47
|
e.preventDefault();
|
|
46
48
|
if (index > 0) onIndexChange(index - 1);
|
|
47
49
|
} else if (e.key === 'Escape') {
|
|
48
|
-
onExit();
|
|
50
|
+
if (allowExit) onExit();
|
|
49
51
|
} else if (e.key === 'Home') {
|
|
50
52
|
onIndexChange(0);
|
|
51
53
|
} else if (e.key === 'End') {
|
|
@@ -54,7 +56,7 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
54
56
|
};
|
|
55
57
|
window.addEventListener('keydown', onKey);
|
|
56
58
|
return () => window.removeEventListener('keydown', onKey);
|
|
57
|
-
}, [index, pages.length, onIndexChange, onExit]);
|
|
59
|
+
}, [index, pages.length, onIndexChange, onExit, allowExit]);
|
|
58
60
|
|
|
59
61
|
const PageComp = pages[index];
|
|
60
62
|
|
|
@@ -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,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,10 +1,17 @@
|
|
|
1
|
-
import { ChevronLeft, Download, Loader2, Pencil, Play } from 'lucide-react';
|
|
1
|
+
import { ChevronLeft, Download, FileCode2, Loader2, Pencil, Play } from 'lucide-react';
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
4
|
+
import config from 'virtual:open-slide/config';
|
|
4
5
|
import { CommentWidget } from '@/components/inspector/CommentWidget';
|
|
5
6
|
import { InspectOverlay } from '@/components/inspector/InspectOverlay';
|
|
6
7
|
import { InspectorProvider, InspectToggleButton } from '@/components/inspector/InspectorProvider';
|
|
7
|
-
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { Button, buttonVariants } from '@/components/ui/button';
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '@/components/ui/dropdown-menu';
|
|
8
15
|
import { Separator } from '@/components/ui/separator';
|
|
9
16
|
import { useFolders } from '@/lib/folders';
|
|
10
17
|
import { cn } from '@/lib/utils';
|
|
@@ -16,6 +23,8 @@ import { exportSlideAsHtml } from '../lib/export-html';
|
|
|
16
23
|
import type { SlideModule } from '../lib/sdk';
|
|
17
24
|
import { loadSlide } from '../lib/slides';
|
|
18
25
|
|
|
26
|
+
const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
|
|
27
|
+
|
|
19
28
|
export function Slide() {
|
|
20
29
|
const { slideId = '' } = useParams();
|
|
21
30
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -82,9 +91,11 @@ export function Slide() {
|
|
|
82
91
|
if (error) {
|
|
83
92
|
return (
|
|
84
93
|
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
{showSlideBrowser && (
|
|
95
|
+
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
|
96
|
+
← Home
|
|
97
|
+
</Link>
|
|
98
|
+
)}
|
|
88
99
|
<h2 className="mt-4 text-xl font-semibold text-foreground">Failed to load slide</h2>
|
|
89
100
|
<pre className="mt-4 overflow-auto rounded-md border bg-card p-4 text-xs whitespace-pre-wrap shadow-sm">
|
|
90
101
|
{error}
|
|
@@ -104,9 +115,11 @@ export function Slide() {
|
|
|
104
115
|
if (pageCount === 0) {
|
|
105
116
|
return (
|
|
106
117
|
<div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
{showSlideBrowser && (
|
|
119
|
+
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
|
120
|
+
← Home
|
|
121
|
+
</Link>
|
|
122
|
+
)}
|
|
110
123
|
<h2 className="mt-4 text-xl font-semibold text-foreground">Empty slide</h2>
|
|
111
124
|
<p className="mt-2 text-sm">
|
|
112
125
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
|
@@ -120,6 +133,18 @@ export function Slide() {
|
|
|
120
133
|
);
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
if (!showSlideUi) {
|
|
137
|
+
return (
|
|
138
|
+
<Player
|
|
139
|
+
pages={pages}
|
|
140
|
+
index={index}
|
|
141
|
+
onIndexChange={goTo}
|
|
142
|
+
onExit={() => {}}
|
|
143
|
+
allowExit={false}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
if (playing) {
|
|
124
149
|
return (
|
|
125
150
|
<Player pages={pages} index={index} onIndexChange={goTo} onExit={() => setPlaying(false)} />
|
|
@@ -132,48 +157,73 @@ export function Slide() {
|
|
|
132
157
|
return (
|
|
133
158
|
<InspectorProvider slideId={slideId}>
|
|
134
159
|
<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
|
-
className="px-2 md:px-3"
|
|
148
|
-
disabled={exporting}
|
|
149
|
-
onClick={async () => {
|
|
150
|
-
if (!slide || exporting) return;
|
|
151
|
-
setExporting(true);
|
|
152
|
-
try {
|
|
153
|
-
await exportSlideAsHtml(slide, slideId);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.error('[open-slide] export failed', err);
|
|
156
|
-
} finally {
|
|
157
|
-
setExporting(false);
|
|
158
|
-
}
|
|
159
|
-
}}
|
|
160
|
-
title="Download as HTML"
|
|
161
|
-
>
|
|
162
|
-
{exporting ? (
|
|
163
|
-
<Loader2 className="size-4 animate-spin" />
|
|
164
|
-
) : (
|
|
165
|
-
<Download className="size-4" />
|
|
160
|
+
<header className="relative flex shrink-0 items-center justify-between border-b bg-card px-3 py-2 md:px-5 md:py-3">
|
|
161
|
+
<div className="flex items-center gap-2 md:gap-3">
|
|
162
|
+
{showSlideBrowser && (
|
|
163
|
+
<>
|
|
164
|
+
<Button asChild variant="ghost" size="sm" className="px-2 md:px-3">
|
|
165
|
+
<Link to="/">
|
|
166
|
+
<ChevronLeft className="size-4" />
|
|
167
|
+
<span className="hidden md:inline">Home</span>
|
|
168
|
+
</Link>
|
|
169
|
+
</Button>
|
|
170
|
+
<Separator orientation="vertical" className="hidden h-5 md:block" />
|
|
171
|
+
</>
|
|
166
172
|
)}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
|
|
176
|
+
<div className="pointer-events-auto min-w-0 max-w-[min(32rem,calc(100vw-20rem))]">
|
|
177
|
+
<InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="flex items-center gap-1.5">
|
|
182
|
+
{allowHtmlDownload && (
|
|
183
|
+
<DropdownMenu>
|
|
184
|
+
<DropdownMenuTrigger
|
|
185
|
+
type="button"
|
|
186
|
+
disabled={exporting}
|
|
187
|
+
aria-label="Download"
|
|
188
|
+
title="Download"
|
|
189
|
+
className={cn(buttonVariants({ variant: 'outline', size: 'icon-sm' }))}
|
|
190
|
+
>
|
|
191
|
+
{exporting ? (
|
|
192
|
+
<Loader2 className="size-4 animate-spin" />
|
|
193
|
+
) : (
|
|
194
|
+
<Download className="size-4" />
|
|
195
|
+
)}
|
|
196
|
+
</DropdownMenuTrigger>
|
|
197
|
+
<DropdownMenuContent align="end" className="min-w-[180px]">
|
|
198
|
+
<DropdownMenuItem
|
|
199
|
+
disabled={exporting}
|
|
200
|
+
onSelect={async () => {
|
|
201
|
+
if (!slide || exporting) return;
|
|
202
|
+
setExporting(true);
|
|
203
|
+
try {
|
|
204
|
+
await exportSlideAsHtml(slide, slideId);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error('[open-slide] export failed', err);
|
|
207
|
+
} finally {
|
|
208
|
+
setExporting(false);
|
|
209
|
+
}
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<FileCode2 />
|
|
213
|
+
Download HTML
|
|
214
|
+
</DropdownMenuItem>
|
|
215
|
+
</DropdownMenuContent>
|
|
216
|
+
</DropdownMenu>
|
|
217
|
+
)}
|
|
218
|
+
<InspectToggleButton />
|
|
219
|
+
<Button size="sm" onClick={() => setPlaying(true)} className="px-2 md:px-3">
|
|
220
|
+
<Play className="size-4" />
|
|
221
|
+
<span className="hidden md:inline">Play</span>
|
|
222
|
+
<kbd className="ml-1 hidden rounded bg-primary-foreground/20 px-1 text-[10px] md:inline">
|
|
223
|
+
F
|
|
224
|
+
</kbd>
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
177
227
|
</header>
|
|
178
228
|
|
|
179
229
|
<div className="flex min-h-0 flex-1">
|
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
|
+
}
|