@open-slide/core 0.0.3 → 0.0.5
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-BCORlVF3.js +16 -0
- package/dist/cli/bin.js +25 -36
- package/dist/{config-g-uy_P5U.js → config-DF58h0l4.js} +132 -5
- package/dist/dev-h-rxb3xY.js +19 -0
- package/dist/preview-lskE0s8A.js +17 -0
- package/dist/vite/index.js +1 -1
- package/package.json +5 -2
- package/src/app/components/ClickNavZones.tsx +34 -0
- package/src/app/components/Player.tsx +25 -3
- package/src/app/components/ThumbnailRail.tsx +8 -0
- package/src/app/components/inspector/CommentPopover.tsx +3 -11
- package/src/app/components/inspector/InspectOverlay.tsx +16 -4
- package/src/app/components/inspector/InspectorProvider.tsx +8 -1
- package/src/app/components/sidebar/FolderItem.tsx +1 -4
- package/src/app/components/ui/dialog.tsx +141 -0
- package/src/app/components/ui/dropdown-menu.tsx +41 -70
- package/src/app/components/ui/popover.tsx +22 -37
- package/src/app/components/ui/tabs.tsx +26 -36
- package/src/app/lib/export-html.ts +320 -0
- package/src/app/lib/folders.ts +40 -4
- package/src/app/lib/sdk.ts +1 -3
- package/src/app/routes/Home.tsx +453 -65
- package/src/app/routes/Slide.tsx +151 -38
- package/dist/build-Cav2jYyI.js +0 -14
- package/dist/dev-CFmlBbLh.js +0 -14
- package/dist/preview-CotwHU_d.js +0 -12
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createViteConfig } from "./config-DF58h0l4.js";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { build as build$1, mergeConfig } from "vite";
|
|
4
|
+
|
|
5
|
+
//#region src/cli/build.ts
|
|
6
|
+
async function build(opts = {}) {
|
|
7
|
+
const base = await createViteConfig({
|
|
8
|
+
userCwd: process.cwd(),
|
|
9
|
+
mode: "build"
|
|
10
|
+
});
|
|
11
|
+
const config = mergeConfig(base, { build: { ...opts.outDir !== void 0 ? { outDir: path.resolve(process.cwd(), opts.outDir) } : {} } });
|
|
12
|
+
await build$1(config);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
//#endregion
|
|
16
|
+
export { build };
|
package/dist/cli/bin.js
CHANGED
|
@@ -1,57 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import chalk from "chalk";
|
|
2
3
|
import { readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
5
7
|
|
|
6
8
|
//#region src/cli/run.ts
|
|
7
|
-
const HELP = `open-slide — author slides, we handle the Vite/React stack
|
|
8
|
-
|
|
9
|
-
Usage:
|
|
10
|
-
open-slide dev Start dev server
|
|
11
|
-
open-slide build Build a static site
|
|
12
|
-
open-slide preview Preview the production build
|
|
13
|
-
open-slide --help Show this message
|
|
14
|
-
open-slide --version Print version
|
|
15
|
-
`;
|
|
16
9
|
async function readVersion() {
|
|
17
10
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
11
|
const pkgPath = path.resolve(here, "..", "..", "package.json");
|
|
19
12
|
const raw = await readFile(pkgPath, "utf8");
|
|
20
13
|
return JSON.parse(raw).version;
|
|
21
14
|
}
|
|
15
|
+
function parsePort(value) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`Invalid port: ${value}`);
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
22
20
|
async function run(argv) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await build();
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (cmd === "preview") {
|
|
43
|
-
const { preview } = await import("../preview-CotwHU_d.js");
|
|
44
|
-
await preview();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
process.stderr.write(`Unknown command: ${cmd}\n\n${HELP}`);
|
|
48
|
-
process.exit(1);
|
|
21
|
+
const version = await readVersion();
|
|
22
|
+
const program = new Command();
|
|
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
|
+
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-h-rxb3xY.js");
|
|
26
|
+
await dev(flags);
|
|
27
|
+
});
|
|
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-BCORlVF3.js");
|
|
30
|
+
await build(flags);
|
|
31
|
+
});
|
|
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-lskE0s8A.js");
|
|
34
|
+
await preview(flags);
|
|
35
|
+
});
|
|
36
|
+
await program.parseAsync(argv, { from: "user" });
|
|
49
37
|
}
|
|
50
38
|
|
|
51
39
|
//#endregion
|
|
52
40
|
//#region src/cli/bin.ts
|
|
53
41
|
run(process.argv.slice(2)).catch((err) => {
|
|
54
|
-
|
|
42
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
43
|
+
process.stderr.write(`${chalk.red("error:")} ${message}\n`);
|
|
55
44
|
process.exit(1);
|
|
56
45
|
});
|
|
57
46
|
|
|
@@ -179,7 +179,7 @@ function commentsPlugin(opts) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
//#endregion
|
|
182
|
-
//#region src/vite/
|
|
182
|
+
//#region src/vite/files-plugin.ts
|
|
183
183
|
const FOLDER_ID_RE = /^f-[a-f0-9]{8}$/;
|
|
184
184
|
const SLIDE_ID_RE = /^[a-z0-9_-]+$/i;
|
|
185
185
|
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -236,6 +236,90 @@ function validateName(v) {
|
|
|
236
236
|
if (trimmed.length < 1 || trimmed.length > 40) return null;
|
|
237
237
|
return trimmed;
|
|
238
238
|
}
|
|
239
|
+
function validateSlideName(v) {
|
|
240
|
+
if (typeof v !== "string") return null;
|
|
241
|
+
const trimmed = v.trim();
|
|
242
|
+
if (trimmed.length < 1 || trimmed.length > 80) return null;
|
|
243
|
+
return trimmed;
|
|
244
|
+
}
|
|
245
|
+
async function rmSlideDir(slidesRoot, slideId) {
|
|
246
|
+
if (!SLIDE_ID_RE.test(slideId)) return false;
|
|
247
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
248
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return false;
|
|
249
|
+
try {
|
|
250
|
+
await fs.rm(dir, {
|
|
251
|
+
recursive: true,
|
|
252
|
+
force: true
|
|
253
|
+
});
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function resolveSlideEntry(slidesRoot, slideId) {
|
|
260
|
+
if (!SLIDE_ID_RE.test(slideId)) return null;
|
|
261
|
+
const dir = path.resolve(slidesRoot, slideId);
|
|
262
|
+
if (!dir.startsWith(slidesRoot + path.sep)) return null;
|
|
263
|
+
return path.join(dir, "index.tsx");
|
|
264
|
+
}
|
|
265
|
+
function escapeSingleQuoted(s) {
|
|
266
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Rewrite (or insert) the `title` field in the slide module's `export const meta`.
|
|
270
|
+
*
|
|
271
|
+
* Strategy:
|
|
272
|
+
* 1. Find `export const meta` and brace-match its object literal.
|
|
273
|
+
* 2. If the object already has a `title: '...'` entry, replace the literal.
|
|
274
|
+
* 3. If the object exists but has no title, inject a new `title: '...'` line
|
|
275
|
+
* as the first property (preserving the author's surrounding indentation).
|
|
276
|
+
* 4. If there is no `meta` export at all, insert a fresh one right before
|
|
277
|
+
* `export default`.
|
|
278
|
+
*
|
|
279
|
+
* Returns the rewritten source, or `null` if the file shape was too surprising
|
|
280
|
+
* to touch safely (e.g. `export default` missing when we'd need to inject meta).
|
|
281
|
+
*/
|
|
282
|
+
function updateMetaTitleInSource(source, title) {
|
|
283
|
+
const newLiteral = `'${escapeSingleQuoted(title)}'`;
|
|
284
|
+
const metaStart = source.search(/export\s+const\s+meta\b/);
|
|
285
|
+
if (metaStart !== -1) {
|
|
286
|
+
const eqIdx = source.indexOf("=", metaStart);
|
|
287
|
+
if (eqIdx === -1) return null;
|
|
288
|
+
const openBrace = source.indexOf("{", eqIdx);
|
|
289
|
+
if (openBrace === -1) return null;
|
|
290
|
+
let depth = 0;
|
|
291
|
+
let closeBrace = -1;
|
|
292
|
+
for (let i = openBrace; i < source.length; i++) {
|
|
293
|
+
const ch = source[i];
|
|
294
|
+
if (ch === "{") depth++;
|
|
295
|
+
else if (ch === "}") {
|
|
296
|
+
depth--;
|
|
297
|
+
if (depth === 0) {
|
|
298
|
+
closeBrace = i;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (closeBrace === -1) return null;
|
|
304
|
+
const body = source.slice(openBrace + 1, closeBrace);
|
|
305
|
+
const titleRe = /(^|[\s,{])(title\s*:\s*)(['"`])((?:\\.|(?!\3).)*)\3/;
|
|
306
|
+
const match = body.match(titleRe);
|
|
307
|
+
if (match) {
|
|
308
|
+
const newBody = body.replace(titleRe, `${match[1]}${match[2]}${newLiteral}`);
|
|
309
|
+
return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace);
|
|
310
|
+
}
|
|
311
|
+
const firstIndentMatch = body.match(/\n([ \t]+)\S/);
|
|
312
|
+
const indent = firstIndentMatch ? firstIndentMatch[1] : " ";
|
|
313
|
+
const trimmedBody = body.replace(/^\s*\n?/, "");
|
|
314
|
+
const needsSeparator = trimmedBody.trim().length > 0;
|
|
315
|
+
const insertion$1 = `\n${indent}title: ${newLiteral}${needsSeparator ? "," : ""}`;
|
|
316
|
+
return source.slice(0, openBrace + 1) + insertion$1 + body + source.slice(closeBrace);
|
|
317
|
+
}
|
|
318
|
+
const exportDefaultIdx = source.search(/export\s+default\b/);
|
|
319
|
+
if (exportDefaultIdx === -1) return null;
|
|
320
|
+
const insertion = `export const meta: SlideMeta = { title: ${newLiteral} };\n\n`;
|
|
321
|
+
return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx);
|
|
322
|
+
}
|
|
239
323
|
function validateIcon(v) {
|
|
240
324
|
if (!v || typeof v !== "object") return null;
|
|
241
325
|
const icon = v;
|
|
@@ -256,22 +340,65 @@ function validateIcon(v) {
|
|
|
256
340
|
}
|
|
257
341
|
return null;
|
|
258
342
|
}
|
|
259
|
-
function
|
|
343
|
+
function filesPlugin(opts) {
|
|
260
344
|
const userCwd = opts.userCwd;
|
|
261
345
|
const slidesDir = opts.slidesDir ?? "slides";
|
|
262
346
|
const slidesRoot = path.resolve(userCwd, slidesDir);
|
|
263
347
|
const manifestPath = path.join(slidesRoot, ".folders.json");
|
|
264
348
|
return {
|
|
265
|
-
name: "open-slide:
|
|
349
|
+
name: "open-slide:files",
|
|
266
350
|
apply: "serve",
|
|
267
351
|
configureServer(server) {
|
|
268
352
|
server.watcher.add(manifestPath);
|
|
269
353
|
server.watcher.on("change", (p) => {
|
|
270
354
|
if (p === manifestPath) server.ws.send({
|
|
271
355
|
type: "custom",
|
|
272
|
-
event: "open-slide:
|
|
356
|
+
event: "open-slide:files-changed"
|
|
273
357
|
});
|
|
274
358
|
});
|
|
359
|
+
server.middlewares.use("/__slides", async (req, res, next) => {
|
|
360
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
361
|
+
const method = req.method ?? "GET";
|
|
362
|
+
try {
|
|
363
|
+
const idMatch = url.pathname.match(/^\/([^/]+)$/);
|
|
364
|
+
if (!idMatch) return next();
|
|
365
|
+
const slideId = idMatch[1];
|
|
366
|
+
if (!SLIDE_ID_RE.test(slideId)) return json(res, 400, { error: "invalid slideId" });
|
|
367
|
+
if (method === "PATCH") {
|
|
368
|
+
const body = await readBody(req);
|
|
369
|
+
const name = validateSlideName(body.name);
|
|
370
|
+
if (!name) return json(res, 400, { error: "invalid name" });
|
|
371
|
+
const entry = resolveSlideEntry(slidesRoot, slideId);
|
|
372
|
+
if (!entry) return json(res, 400, { error: "invalid slideId" });
|
|
373
|
+
let source;
|
|
374
|
+
try {
|
|
375
|
+
source = await fs.readFile(entry, "utf8");
|
|
376
|
+
} catch {
|
|
377
|
+
return json(res, 404, { error: "slide not found" });
|
|
378
|
+
}
|
|
379
|
+
const updated = updateMetaTitleInSource(source, name);
|
|
380
|
+
if (updated === null) return json(res, 422, { error: "could not locate a safe place to write meta.title in index.tsx" });
|
|
381
|
+
if (updated !== source) await fs.writeFile(entry, updated, "utf8");
|
|
382
|
+
server.ws.send({ type: "full-reload" });
|
|
383
|
+
return json(res, 200, {
|
|
384
|
+
ok: true,
|
|
385
|
+
slideId,
|
|
386
|
+
name
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
if (method === "DELETE") {
|
|
390
|
+
const removed = await rmSlideDir(slidesRoot, slideId);
|
|
391
|
+
if (!removed) return json(res, 404, { error: "slide not found" });
|
|
392
|
+
const manifest = await readManifest(manifestPath);
|
|
393
|
+
delete manifest.assignments[slideId];
|
|
394
|
+
await writeManifest(manifestPath, manifest);
|
|
395
|
+
return json(res, 200, { ok: true });
|
|
396
|
+
}
|
|
397
|
+
return next();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
json(res, 500, { error: String(err.message ?? err) });
|
|
400
|
+
}
|
|
401
|
+
});
|
|
275
402
|
server.middlewares.use("/__folders", async (req, res, next) => {
|
|
276
403
|
const url = new URL(req.url ?? "/", "http://local");
|
|
277
404
|
const method = req.method ?? "GET";
|
|
@@ -477,7 +604,7 @@ async function createViteConfig(opts) {
|
|
|
477
604
|
userCwd,
|
|
478
605
|
slidesDir
|
|
479
606
|
}),
|
|
480
|
-
|
|
607
|
+
filesPlugin({
|
|
481
608
|
userCwd,
|
|
482
609
|
slidesDir
|
|
483
610
|
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createViteConfig } from "./config-DF58h0l4.js";
|
|
2
|
+
import { createServer, mergeConfig } from "vite";
|
|
3
|
+
|
|
4
|
+
//#region src/cli/dev.ts
|
|
5
|
+
async function dev(opts = {}) {
|
|
6
|
+
const base = await createViteConfig({ userCwd: process.cwd() });
|
|
7
|
+
const config = mergeConfig(base, { server: {
|
|
8
|
+
...opts.port !== void 0 ? { port: opts.port } : {},
|
|
9
|
+
...opts.host !== void 0 ? { host: opts.host } : {},
|
|
10
|
+
...opts.open !== void 0 ? { open: opts.open } : {}
|
|
11
|
+
} });
|
|
12
|
+
const server = await createServer(config);
|
|
13
|
+
await server.listen();
|
|
14
|
+
server.printUrls();
|
|
15
|
+
server.bindCLIShortcuts({ print: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
//#endregion
|
|
19
|
+
export { dev };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createViteConfig } from "./config-DF58h0l4.js";
|
|
2
|
+
import { mergeConfig, preview as preview$1 } from "vite";
|
|
3
|
+
|
|
4
|
+
//#region src/cli/preview.ts
|
|
5
|
+
async function preview(opts = {}) {
|
|
6
|
+
const base = await createViteConfig({ userCwd: process.cwd() });
|
|
7
|
+
const config = mergeConfig(base, { preview: {
|
|
8
|
+
...opts.port !== void 0 ? { port: opts.port } : {},
|
|
9
|
+
...opts.host !== void 0 ? { host: opts.host } : {},
|
|
10
|
+
...opts.open !== void 0 ? { open: opts.open } : {}
|
|
11
|
+
} });
|
|
12
|
+
const server = await preview$1(config);
|
|
13
|
+
server.printUrls();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
export { preview };
|
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.5",
|
|
4
4
|
"description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsdown",
|
|
27
|
-
"
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
28
|
"prepack": "pnpm build"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
@@ -44,10 +44,13 @@
|
|
|
44
44
|
"@fontsource-variable/geist": "^5.2.8",
|
|
45
45
|
"@tailwindcss/vite": "^4.2.2",
|
|
46
46
|
"@vitejs/plugin-react": "^4.3.3",
|
|
47
|
+
"chalk": "^5.3.0",
|
|
47
48
|
"class-variance-authority": "^0.7.1",
|
|
48
49
|
"clsx": "^2.1.1",
|
|
50
|
+
"commander": "^12.1.0",
|
|
49
51
|
"emoji-picker-react": "^4.18.0",
|
|
50
52
|
"fast-glob": "^3.3.2",
|
|
53
|
+
"fflate": "^0.8.2",
|
|
51
54
|
"lucide-react": "^1.8.0",
|
|
52
55
|
"radix-ui": "^1.4.3",
|
|
53
56
|
"react": "^18.3.1",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useInspector } from './inspector/InspectorProvider';
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
onPrev: () => void;
|
|
5
|
+
onNext: () => void;
|
|
6
|
+
canPrev: boolean;
|
|
7
|
+
canNext: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
|
|
11
|
+
const { active } = useInspector();
|
|
12
|
+
if (active) return null;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
aria-label="Previous page"
|
|
19
|
+
onClick={onPrev}
|
|
20
|
+
disabled={!canPrev}
|
|
21
|
+
data-inspector-ui
|
|
22
|
+
className="absolute inset-y-0 left-0 z-20 w-[18%] min-w-12"
|
|
23
|
+
/>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
aria-label="Next page"
|
|
27
|
+
onClick={onNext}
|
|
28
|
+
disabled={!canNext}
|
|
29
|
+
data-inspector-ui
|
|
30
|
+
className="absolute inset-y-0 right-0 z-20 w-[18%] min-w-12"
|
|
31
|
+
/>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -33,10 +33,15 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
33
33
|
|
|
34
34
|
useEffect(() => {
|
|
35
35
|
const onKey = (e: KeyboardEvent) => {
|
|
36
|
-
if (
|
|
36
|
+
if (
|
|
37
|
+
e.key === 'ArrowRight' ||
|
|
38
|
+
e.key === 'ArrowDown' ||
|
|
39
|
+
e.key === ' ' ||
|
|
40
|
+
e.key === 'PageDown'
|
|
41
|
+
) {
|
|
37
42
|
e.preventDefault();
|
|
38
43
|
if (index < pages.length - 1) onIndexChange(index + 1);
|
|
39
|
-
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
|
44
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
40
45
|
e.preventDefault();
|
|
41
46
|
if (index > 0) onIndexChange(index - 1);
|
|
42
47
|
} else if (e.key === 'Escape') {
|
|
@@ -54,8 +59,25 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
|
|
|
54
59
|
const PageComp = pages[index];
|
|
55
60
|
|
|
56
61
|
return (
|
|
57
|
-
<div
|
|
62
|
+
<div
|
|
63
|
+
ref={rootRef}
|
|
64
|
+
className="relative flex h-screen w-screen items-center justify-center bg-black"
|
|
65
|
+
>
|
|
58
66
|
<SlideCanvas flat>{PageComp ? <PageComp /> : null}</SlideCanvas>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label="Previous page"
|
|
70
|
+
onClick={() => index > 0 && onIndexChange(index - 1)}
|
|
71
|
+
disabled={index === 0}
|
|
72
|
+
className="absolute inset-y-0 left-0 z-10 w-[30%]"
|
|
73
|
+
/>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
aria-label="Next page"
|
|
77
|
+
onClick={() => index < pages.length - 1 && onIndexChange(index + 1)}
|
|
78
|
+
disabled={index === pages.length - 1}
|
|
79
|
+
className="absolute inset-y-0 right-0 z-10 w-[30%]"
|
|
80
|
+
/>
|
|
59
81
|
</div>
|
|
60
82
|
);
|
|
61
83
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
1
2
|
import { cn } from '@/lib/utils';
|
|
2
3
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
3
4
|
import type { Page } from '../lib/sdk';
|
|
@@ -15,6 +16,12 @@ const THUMB_SCALE = THUMB_WIDTH / CANVAS_WIDTH;
|
|
|
15
16
|
const THUMB_HEIGHT = CANVAS_HEIGHT * THUMB_SCALE;
|
|
16
17
|
|
|
17
18
|
export function ThumbnailRail({ pages, current, onSelect }: Props) {
|
|
19
|
+
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
activeRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
23
|
+
}, [current]);
|
|
24
|
+
|
|
18
25
|
return (
|
|
19
26
|
<ScrollArea className="h-full border-r bg-card">
|
|
20
27
|
<aside className="flex flex-col gap-2.5 p-3">
|
|
@@ -23,6 +30,7 @@ export function ThumbnailRail({ pages, current, onSelect }: Props) {
|
|
|
23
30
|
return (
|
|
24
31
|
<button
|
|
25
32
|
key={i}
|
|
33
|
+
ref={active ? activeRef : undefined}
|
|
26
34
|
onClick={() => onSelect(i)}
|
|
27
35
|
aria-label={`Go to page ${i + 1}`}
|
|
28
36
|
aria-current={active ? 'true' : undefined}
|
|
@@ -6,7 +6,7 @@ const POPOVER_W = 320;
|
|
|
6
6
|
const POPOVER_H = 180;
|
|
7
7
|
|
|
8
8
|
export function CommentPopover() {
|
|
9
|
-
const { pending, setPending, add } = useInspector();
|
|
9
|
+
const { pending, setPending, add, cancel } = useInspector();
|
|
10
10
|
const [text, setText] = useState('');
|
|
11
11
|
const [submitting, setSubmitting] = useState(false);
|
|
12
12
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -16,14 +16,6 @@ export function CommentPopover() {
|
|
|
16
16
|
taRef.current?.focus();
|
|
17
17
|
}, []);
|
|
18
18
|
|
|
19
|
-
useEffect(() => {
|
|
20
|
-
const onKey = (e: KeyboardEvent) => {
|
|
21
|
-
if (e.key === 'Escape') setPending(null);
|
|
22
|
-
};
|
|
23
|
-
window.addEventListener('keydown', onKey);
|
|
24
|
-
return () => window.removeEventListener('keydown', onKey);
|
|
25
|
-
}, [setPending]);
|
|
26
|
-
|
|
27
19
|
if (!pending) return null;
|
|
28
20
|
|
|
29
21
|
const left = clamp(pending.clickX + 12, 8, window.innerWidth - POPOVER_W - 8);
|
|
@@ -55,7 +47,7 @@ export function CommentPopover() {
|
|
|
55
47
|
<button
|
|
56
48
|
type="button"
|
|
57
49
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
58
|
-
onClick={
|
|
50
|
+
onClick={cancel}
|
|
59
51
|
>
|
|
60
52
|
✕
|
|
61
53
|
</button>
|
|
@@ -77,7 +69,7 @@ export function CommentPopover() {
|
|
|
77
69
|
<div className="mt-2 flex items-center justify-end gap-2">
|
|
78
70
|
<button
|
|
79
71
|
type="button"
|
|
80
|
-
onClick={
|
|
72
|
+
onClick={cancel}
|
|
81
73
|
className="rounded border px-2 py-1 text-xs hover:bg-muted"
|
|
82
74
|
>
|
|
83
75
|
Cancel
|
|
@@ -6,7 +6,7 @@ import { useInspector } from './InspectorProvider';
|
|
|
6
6
|
type Highlight = { rect: DOMRect; hit: SlideSourceHit };
|
|
7
7
|
|
|
8
8
|
export function InspectOverlay() {
|
|
9
|
-
const { active, slideId, pending, setPending } = useInspector();
|
|
9
|
+
const { active, slideId, pending, setPending, cancel } = useInspector();
|
|
10
10
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
11
11
|
const [hover, setHover] = useState<Highlight | null>(null);
|
|
12
12
|
|
|
@@ -16,6 +16,14 @@ export function InspectOverlay() {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
const onKey = (e: KeyboardEvent) => {
|
|
20
|
+
if (e.key === 'Escape') {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
e.stopPropagation();
|
|
23
|
+
cancel();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
const onMove = (e: PointerEvent) => {
|
|
20
28
|
if (pending) return;
|
|
21
29
|
const el = pickElement(e.clientX, e.clientY);
|
|
@@ -34,23 +42,26 @@ export function InspectOverlay() {
|
|
|
34
42
|
if (!hit) return;
|
|
35
43
|
e.preventDefault();
|
|
36
44
|
e.stopPropagation();
|
|
45
|
+
const anchorRect = hit.anchor.getBoundingClientRect();
|
|
37
46
|
setPending({
|
|
38
47
|
line: hit.line,
|
|
39
48
|
column: hit.column,
|
|
40
|
-
anchorRect
|
|
49
|
+
anchorRect,
|
|
41
50
|
clickX: e.clientX,
|
|
42
51
|
clickY: e.clientY,
|
|
43
52
|
});
|
|
44
|
-
setHover(
|
|
53
|
+
setHover({ rect: anchorRect, hit });
|
|
45
54
|
};
|
|
46
55
|
|
|
47
56
|
window.addEventListener('pointermove', onMove, true);
|
|
48
57
|
window.addEventListener('click', onClick, true);
|
|
58
|
+
window.addEventListener('keydown', onKey, true);
|
|
49
59
|
return () => {
|
|
50
60
|
window.removeEventListener('pointermove', onMove, true);
|
|
51
61
|
window.removeEventListener('click', onClick, true);
|
|
62
|
+
window.removeEventListener('keydown', onKey, true);
|
|
52
63
|
};
|
|
53
|
-
}, [active, slideId, pending, setPending]);
|
|
64
|
+
}, [active, slideId, pending, setPending, cancel]);
|
|
54
65
|
|
|
55
66
|
if (!active) return null;
|
|
56
67
|
|
|
@@ -88,6 +99,7 @@ function pickElement(x: number, y: number): HTMLElement | null {
|
|
|
88
99
|
for (const el of stack) {
|
|
89
100
|
if (!(el instanceof HTMLElement)) continue;
|
|
90
101
|
if (el.closest('[data-inspector-ui]')) continue;
|
|
102
|
+
if (!el.closest('[data-inspector-root]')) continue;
|
|
91
103
|
return el;
|
|
92
104
|
}
|
|
93
105
|
return null;
|
|
@@ -15,6 +15,7 @@ type InspectorCtx = {
|
|
|
15
15
|
slideId: string;
|
|
16
16
|
active: boolean;
|
|
17
17
|
toggle: () => void;
|
|
18
|
+
cancel: () => void;
|
|
18
19
|
comments: SlideComment[];
|
|
19
20
|
error: string | null;
|
|
20
21
|
refetch: () => Promise<void>;
|
|
@@ -44,11 +45,17 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
44
45
|
});
|
|
45
46
|
}, []);
|
|
46
47
|
|
|
48
|
+
const cancel = useCallback(() => {
|
|
49
|
+
setActive(false);
|
|
50
|
+
setPending(null);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
47
53
|
const value = useMemo<InspectorCtx>(
|
|
48
54
|
() => ({
|
|
49
55
|
slideId,
|
|
50
56
|
active,
|
|
51
57
|
toggle,
|
|
58
|
+
cancel,
|
|
52
59
|
comments,
|
|
53
60
|
error,
|
|
54
61
|
refetch,
|
|
@@ -57,7 +64,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
|
|
|
57
64
|
pending,
|
|
58
65
|
setPending,
|
|
59
66
|
}),
|
|
60
|
-
[slideId, active, toggle, comments, error, refetch, add, remove, pending],
|
|
67
|
+
[slideId, active, toggle, cancel, comments, error, refetch, add, remove, pending],
|
|
61
68
|
);
|
|
62
69
|
|
|
63
70
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
@@ -114,10 +114,7 @@ export function FolderItem({
|
|
|
114
114
|
</button>
|
|
115
115
|
</PopoverTrigger>
|
|
116
116
|
<PopoverContent side="right" align="start" className="w-auto p-2">
|
|
117
|
-
<IconPicker
|
|
118
|
-
value={row.folder.icon}
|
|
119
|
-
onChange={(next) => row.onChangeIcon(next)}
|
|
120
|
-
/>
|
|
117
|
+
<IconPicker value={row.folder.icon} onChange={(next) => row.onChangeIcon(next)} />
|
|
121
118
|
</PopoverContent>
|
|
122
119
|
</Popover>
|
|
123
120
|
) : (
|