pdf-presenter 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,12 +24,17 @@
24
24
 
25
25
  > Lightweight CLI that serves a PDF as browser slides with a full presenter mode — speaker notes, next-slide preview, pause-able timer, audio recording with per-slide timeline metadata, and resizable panes. No markdown conversion, no native dependencies. Point it at a PDF and go.
26
26
 
27
+ <p align="center">
28
+ <img src="assets/presenter-preview.png" alt="Presenter view — current slide, speaker notes, next slide preview, and timer" width="720" />
29
+ </p>
30
+
27
31
  [繁體中文版 →](./README-zhtw.md)
28
32
 
29
33
  ```bash
30
34
  npx pdf-presenter slides.pdf # serve & open browser
31
35
  npx pdf-presenter -gn slides.pdf # generate a notes template
32
36
  npx pdf-presenter slides.pdf -t 20 # 20-minute countdown
37
+ npx pdf-presenter ./slides/ # browse a directory of PDFs
33
38
  ```
34
39
 
35
40
  ---
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync6, readFileSync } from "fs";
5
- import { basename as basename3, relative as relative2 } from "path";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync2, statSync as statSync3 } from "fs";
5
+ import { basename as basename4, relative as relative2, resolve as resolve5 } from "path";
6
6
  import { Command, Option } from "commander";
7
7
  import open from "open";
8
8
 
@@ -13,8 +13,8 @@ import { basename, relative } from "path";
13
13
 
14
14
  // src/utils.ts
15
15
  import { createServer } from "net";
16
- import { extname, resolve } from "path";
17
- import { existsSync, statSync } from "fs";
16
+ import { extname, join, resolve } from "path";
17
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
18
18
  function resolvePdfPath(input) {
19
19
  const abs = resolve(input);
20
20
  if (!existsSync(abs)) {
@@ -31,6 +31,31 @@ function resolvePdfPath(input) {
31
31
  function notesPathFor(pdfPath) {
32
32
  return pdfPath.replace(/\.pdf$/i, ".notes.json");
33
33
  }
34
+ function listPdfsInDirectory(dirPath) {
35
+ return readdirSync(dirPath).filter((f) => extname(f).toLowerCase() === ".pdf").sort((a, b) => a.localeCompare(b));
36
+ }
37
+ function getPdfNotesStatus(dirPath, pdfName) {
38
+ const notesPath = notesPathFor(join(dirPath, pdfName));
39
+ const result = {
40
+ pdfName,
41
+ hasNotes: false,
42
+ filledCount: 0,
43
+ totalSlides: 0
44
+ };
45
+ if (!existsSync(notesPath)) return result;
46
+ try {
47
+ const raw = JSON.parse(readFileSync(notesPath, "utf8"));
48
+ result.hasNotes = true;
49
+ result.totalSlides = raw.meta?.totalSlides ?? 0;
50
+ const notes = raw.notes ?? {};
51
+ result.filledCount = Object.values(notes).filter(
52
+ (n) => typeof n.note === "string" && n.note.trim().length > 0
53
+ ).length;
54
+ } catch {
55
+ result.hasNotes = true;
56
+ }
57
+ return result;
58
+ }
34
59
  async function findAvailablePort(startPort, maxAttempts = 10) {
35
60
  for (let i = 0; i < maxAttempts; i++) {
36
61
  const port = startPort + i;
@@ -229,19 +254,21 @@ function resolvePdfjsDir(callerUrl) {
229
254
 
230
255
  // src/server/html-render.ts
231
256
  import { readFile as readFile2 } from "fs/promises";
232
- import { basename as basename2, join } from "path";
233
- async function renderHtml(uiDir, file, config) {
234
- const raw = await readFile2(join(uiDir, file), "utf8");
235
- const meta = {
257
+ import { basename as basename2, join as join2 } from "path";
258
+ async function renderHtml(uiDir, file, meta) {
259
+ const raw = await readFile2(join2(uiDir, file), "utf8");
260
+ return raw.replace(
261
+ "<!--PDF_PRESENTER_CONFIG-->",
262
+ `<script id="pdf-presenter-config" type="application/json">${JSON.stringify(meta)}</script>`
263
+ );
264
+ }
265
+ function singlePdfMeta(config) {
266
+ return {
236
267
  pdfUrl: "/slides.pdf",
237
268
  notesUrl: "/notes.json",
238
269
  pdfName: basename2(config.pdfPath),
239
270
  timerMinutes: config.timerMinutes ?? null
240
271
  };
241
- return raw.replace(
242
- "<!--PDF_PRESENTER_CONFIG-->",
243
- `<script id="pdf-presenter-config" type="application/json">${JSON.stringify(meta)}</script>`
244
- );
245
272
  }
246
273
 
247
274
  // src/server/notes-store.ts
@@ -400,7 +427,7 @@ async function handleNotesRoutes(req, res, url, deps) {
400
427
 
401
428
  // src/server/routes/recording.ts
402
429
  import { writeFile as writeFile3, mkdir } from "fs/promises";
403
- import { dirname as dirname2, join as join2 } from "path";
430
+ import { dirname as dirname2, join as join3 } from "path";
404
431
  async function handleRecordingRoutes(req, res, url, deps) {
405
432
  const pathname = url.pathname;
406
433
  const method = req.method ?? "GET";
@@ -436,9 +463,9 @@ async function handleRecordingRoutes(req, res, url, deps) {
436
463
  );
437
464
  return "handled";
438
465
  }
439
- const outDir = join2(dirname2(deps.pdfPath), "recordings");
466
+ const outDir = join3(dirname2(deps.pdfPath), "recordings");
440
467
  await mkdir(outDir, { recursive: true });
441
- const outPath = join2(outDir, filenameParam);
468
+ const outPath = join3(outDir, filenameParam);
442
469
  const text = JSON.stringify(body, null, 2) + "\n";
443
470
  await writeFile3(outPath, text, "utf8");
444
471
  send(
@@ -485,9 +512,9 @@ async function handleRecordingRoutes(req, res, url, deps) {
485
512
  );
486
513
  return "handled";
487
514
  }
488
- const outDir = join2(dirname2(deps.pdfPath), "recordings");
515
+ const outDir = join3(dirname2(deps.pdfPath), "recordings");
489
516
  await mkdir(outDir, { recursive: true });
490
- const outPath = join2(outDir, filenameParam);
517
+ const outPath = join3(outDir, filenameParam);
491
518
  await writeFile3(outPath, bytes);
492
519
  send(
493
520
  res,
@@ -557,13 +584,135 @@ async function handleStaticRoutes(req, res, url, deps) {
557
584
  }
558
585
  return "pass";
559
586
  }
587
+ async function handleSharedAssetRoutes(_req, res, url, deps) {
588
+ const pathname = url.pathname;
589
+ const method = _req.method ?? "GET";
590
+ if (method !== "GET" && method !== "HEAD") return "pass";
591
+ if (pathname.startsWith("/assets/pdfjs/")) {
592
+ const rel = pathname.slice("/assets/pdfjs/".length);
593
+ const safe = resolve3(deps.pdfjsDir, rel);
594
+ if (!safe.startsWith(deps.pdfjsDir + "/") && safe !== deps.pdfjsDir) {
595
+ send(res, 403, "Forbidden");
596
+ return "handled";
597
+ }
598
+ if (!existsSync5(safe)) {
599
+ notFound(res);
600
+ return "handled";
601
+ }
602
+ streamFile(res, safe, contentTypeFor(safe));
603
+ return "handled";
604
+ }
605
+ if (pathname.startsWith("/assets/")) {
606
+ const rel = pathname.slice("/assets/".length);
607
+ const safe = resolve3(deps.uiDir, rel);
608
+ if (!safe.startsWith(deps.uiDir + "/") && safe !== deps.uiDir) {
609
+ send(res, 403, "Forbidden");
610
+ return "handled";
611
+ }
612
+ if (!existsSync5(safe)) {
613
+ notFound(res);
614
+ return "handled";
615
+ }
616
+ streamFile(res, safe, contentTypeFor(safe));
617
+ return "handled";
618
+ }
619
+ return "pass";
620
+ }
621
+
622
+ // src/server/routes/listing.ts
623
+ async function handleListingRoutes(req, res, url, deps) {
624
+ const pathname = url.pathname;
625
+ const method = req.method ?? "GET";
626
+ if (method !== "GET" && method !== "HEAD") return "pass";
627
+ if (pathname === "/" || pathname === "/list") {
628
+ send(res, 200, deps.listingHtml, MIME[".html"]);
629
+ return "handled";
630
+ }
631
+ if (pathname === "/api/listing") {
632
+ const pdfNames = listPdfsInDirectory(deps.dirPath);
633
+ const statuses = pdfNames.map(
634
+ (name) => getPdfNotesStatus(deps.dirPath, name)
635
+ );
636
+ send(res, 200, JSON.stringify(statuses), MIME[".json"]);
637
+ return "handled";
638
+ }
639
+ return "pass";
640
+ }
641
+
642
+ // src/server/routes/pdf-proxy.ts
643
+ import { existsSync as existsSync6 } from "fs";
644
+ import { basename as basename3, join as join4, resolve as resolve4 } from "path";
645
+ var cache = /* @__PURE__ */ new Map();
646
+ async function getOrCreateCache(pdfName, deps) {
647
+ let entry = cache.get(pdfName);
648
+ if (entry) return entry;
649
+ const pdfPath = join4(deps.dirPath, pdfName);
650
+ const notesPath = notesPathFor(pdfPath);
651
+ const prefix = `/pdf/${encodeURIComponent(pdfName)}`;
652
+ const meta = {
653
+ pdfUrl: `${prefix}/slides.pdf`,
654
+ notesUrl: `${prefix}/notes.json`,
655
+ pdfName: basename3(pdfPath),
656
+ timerMinutes: deps.timerMinutes ?? null,
657
+ listUrl: "/list"
658
+ };
659
+ const [audienceHtml, presenterHtml] = await Promise.all([
660
+ renderHtml(deps.uiDir, "audience.html", meta),
661
+ renderHtml(deps.uiDir, "presenter.html", meta)
662
+ ]);
663
+ entry = {
664
+ audienceHtml,
665
+ presenterHtml,
666
+ updateNotes: createNotesUpdater(notesPath)
667
+ };
668
+ cache.set(pdfName, entry);
669
+ return entry;
670
+ }
671
+ async function handlePdfProxyRoutes(req, res, url, deps) {
672
+ const match = url.pathname.match(/^\/pdf\/([^/]+)(\/.*)?$/);
673
+ if (!match) return "pass";
674
+ const pdfName = decodeURIComponent(match[1]);
675
+ const subPath = match[2] || "/";
676
+ if (!isSafeFilename(pdfName)) {
677
+ notFound(res);
678
+ return "handled";
679
+ }
680
+ const pdfPath = join4(deps.dirPath, pdfName);
681
+ const safePath = resolve4(deps.dirPath, pdfName);
682
+ if (!safePath.startsWith(deps.dirPath + "/") || !existsSync6(pdfPath)) {
683
+ notFound(res);
684
+ return "handled";
685
+ }
686
+ const notesPath = notesPathFor(pdfPath);
687
+ const perPdf = await getOrCreateCache(pdfName, deps);
688
+ const subUrl = new URL(subPath + url.search, `http://localhost`);
689
+ if (await handleRecordingRoutes(req, res, subUrl, { pdfPath }) === "handled")
690
+ return "handled";
691
+ if (await handleNotesRoutes(req, res, subUrl, {
692
+ notesPath,
693
+ updateNotes: perPdf.updateNotes
694
+ }) === "handled")
695
+ return "handled";
696
+ if (await handleStaticRoutes(req, res, subUrl, {
697
+ audienceHtml: perPdf.audienceHtml,
698
+ presenterHtml: perPdf.presenterHtml,
699
+ pdfPath,
700
+ notesPath,
701
+ uiDir: deps.uiDir,
702
+ pdfjsDir: deps.pdfjsDir
703
+ }) === "handled")
704
+ return "handled";
705
+ notFound(res);
706
+ return "handled";
707
+ }
560
708
 
561
709
  // src/server.ts
562
710
  async function startServer(config) {
563
711
  const uiDir = resolveUiDir(import.meta.url);
564
712
  const pdfjsDir = resolvePdfjsDir(import.meta.url);
565
- const audienceHtml = await renderHtml(uiDir, "audience.html", config);
566
- const presenterHtml = await renderHtml(uiDir, "presenter.html", config);
713
+ const meta = singlePdfMeta(config);
714
+ const audienceHtml = await renderHtml(uiDir, "audience.html", meta);
715
+ const presenterHtml = await renderHtml(uiDir, "presenter.html", meta);
567
716
  const updateNotes = createNotesUpdater(config.notesPath);
568
717
  const handler = async (req, res) => {
569
718
  const url = new URL(req.url ?? "/", `http://localhost:${config.port}`);
@@ -616,11 +765,68 @@ async function startServer(config) {
616
765
  })
617
766
  };
618
767
  }
768
+ async function startDirectoryServer(config) {
769
+ const { basename: basename5 } = await import("path");
770
+ const uiDir = resolveUiDir(import.meta.url);
771
+ const pdfjsDir = resolvePdfjsDir(import.meta.url);
772
+ const listingHtml = await renderHtml(uiDir, "listing.html", {
773
+ dirName: basename5(config.dirPath)
774
+ });
775
+ const handler = async (req, res) => {
776
+ const url = new URL(req.url ?? "/", `http://localhost:${config.port}`);
777
+ try {
778
+ if (await handleListingRoutes(req, res, url, {
779
+ dirPath: config.dirPath,
780
+ listingHtml
781
+ }) === "handled")
782
+ return;
783
+ if (await handlePdfProxyRoutes(req, res, url, {
784
+ dirPath: config.dirPath,
785
+ uiDir,
786
+ pdfjsDir,
787
+ timerMinutes: config.timerMinutes
788
+ }) === "handled")
789
+ return;
790
+ if (await handleSharedAssetRoutes(req, res, url, {
791
+ uiDir,
792
+ pdfjsDir
793
+ }) === "handled")
794
+ return;
795
+ notFound(res);
796
+ } catch (err) {
797
+ const msg = err instanceof Error ? err.message : String(err);
798
+ if (!res.headersSent) send(res, 500, `Internal Server Error: ${msg}`);
799
+ else res.end();
800
+ }
801
+ };
802
+ const server = createServer2((req, res) => {
803
+ handler(req, res).catch((err) => {
804
+ const msg = err instanceof Error ? err.message : String(err);
805
+ if (!res.headersSent) {
806
+ res.writeHead(500, { "Content-Type": "text/plain" });
807
+ }
808
+ res.end(`Internal Server Error: ${msg}`);
809
+ });
810
+ });
811
+ await new Promise((resolveP, rejectP) => {
812
+ server.once("error", rejectP);
813
+ server.listen(config.port, "127.0.0.1", () => {
814
+ server.off("error", rejectP);
815
+ resolveP();
816
+ });
817
+ });
818
+ return {
819
+ port: config.port,
820
+ stop: () => new Promise((resolveP, rejectP) => {
821
+ server.close((err) => err ? rejectP(err) : resolveP());
822
+ })
823
+ };
824
+ }
619
825
 
620
826
  // package.json
621
827
  var package_default = {
622
828
  name: "pdf-presenter",
623
- version: "1.0.1",
829
+ version: "1.2.0",
624
830
  description: "Lightweight CLI that serves PDF slides in the browser with a full presenter mode (speaker notes, next slide preview, timer, audio recording).",
625
831
  type: "module",
626
832
  bin: {
@@ -683,12 +889,17 @@ async function runCli(argv) {
683
889
  const program = new Command();
684
890
  program.name("pdf-presenter").description(
685
891
  "Serve a PDF as browser slides with a full presenter mode (notes, next preview, timer)."
686
- ).version(VERSION, "-v, --version").argument("<file>", "Path to the PDF file").addOption(new Option("-p, --port <port>", "Server port").default("3000")).option("--no-open", "Don't auto-open browser").option("--presenter", "Open directly in presenter mode", false).option("-n, --notes <path>", "Path to notes JSON file").option("-t, --timer <minutes>", "Countdown timer in minutes").option(
892
+ ).version(VERSION, "-v, --version").argument("<file>", "Path to a PDF file or a directory of PDFs").addOption(new Option("-p, --port <port>", "Server port").default("3000")).option("--no-open", "Don't auto-open browser").option("--presenter", "Open directly in presenter mode", false).option("-n, --notes <path>", "Path to notes JSON file").option("-t, --timer <minutes>", "Countdown timer in minutes").option(
687
893
  "-gn, --generate-presenter-note-template",
688
894
  "Generate a notes template JSON next to the PDF",
689
895
  false
690
896
  ).option("--force", "Overwrite existing notes file when used with -gn", false).action(async (file, options) => {
691
897
  try {
898
+ const abs = resolve5(file);
899
+ if (existsSync7(abs) && statSync3(abs).isDirectory()) {
900
+ await runDirectoryServe(abs, options);
901
+ return;
902
+ }
692
903
  const pdfPath = resolvePdfPath(file);
693
904
  if (options.generatePresenterNoteTemplate) {
694
905
  await runGenerate(pdfPath, { force: !!options.force });
@@ -709,13 +920,13 @@ Error: ${msg}
709
920
  async function runGenerate(pdfPath, opts) {
710
921
  try {
711
922
  const result = await generateNotesTemplate(pdfPath, opts);
712
- const rel = relative2(process.cwd(), result.notesPath) || basename3(result.notesPath);
923
+ const rel = relative2(process.cwd(), result.notesPath) || basename4(result.notesPath);
713
924
  process.stdout.write(
714
925
  `
715
926
  \u2705 Generated ${rel} (${result.totalSlides} slides)
716
927
 
717
928
  Edit the "note" fields in the JSON file, then run:
718
- pdf-presenter ${relative2(process.cwd(), pdfPath) || basename3(pdfPath)}
929
+ pdf-presenter ${relative2(process.cwd(), pdfPath) || basename4(pdfPath)}
719
930
 
720
931
  `
721
932
  );
@@ -757,7 +968,7 @@ async function runServe(pdfPath, options) {
757
968
  Audience: ${url}
758
969
  Presenter: ${presenterUrl}
759
970
 
760
- PDF: ${basename3(pdfPath)}
971
+ PDF: ${basename4(pdfPath)}
761
972
  Notes: ${notesInfo}
762
973
  ` + (timerMinutes !== void 0 ? ` Timer: ${formatMinutes(timerMinutes)}
763
974
  ` : "") + `
@@ -783,24 +994,69 @@ Received ${signal}, shutting down...
783
994
  process.on("SIGINT", () => void shutdown("SIGINT"));
784
995
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
785
996
  }
997
+ async function runDirectoryServe(dirPath, options) {
998
+ const startPort = Number.parseInt(options.port, 10);
999
+ if (!Number.isFinite(startPort) || startPort <= 0) {
1000
+ throw new Error(`Invalid --port value: ${options.port}`);
1001
+ }
1002
+ let timerMinutes;
1003
+ if (options.timer !== void 0) {
1004
+ const t = Number.parseFloat(options.timer);
1005
+ if (!Number.isFinite(t) || t <= 0) {
1006
+ throw new Error(`Invalid --timer value: ${options.timer}`);
1007
+ }
1008
+ timerMinutes = t;
1009
+ }
1010
+ const port = await findAvailablePort(startPort);
1011
+ const server = await startDirectoryServer({ dirPath, port, timerMinutes });
1012
+ const url = `http://localhost:${port}`;
1013
+ process.stdout.write(
1014
+ `
1015
+ \u{1F3AF} pdf-presenter v${VERSION} (directory mode)
1016
+
1017
+ Listing: ${url}/list
1018
+
1019
+ Directory: ${basename4(dirPath)}/
1020
+
1021
+ Press Ctrl+C to stop.
1022
+
1023
+ `
1024
+ );
1025
+ if (options.open) {
1026
+ open(`${url}/list`).catch(() => {
1027
+ });
1028
+ }
1029
+ const shutdown = async (signal) => {
1030
+ process.stdout.write(`
1031
+ Received ${signal}, shutting down...
1032
+ `);
1033
+ try {
1034
+ await server.stop();
1035
+ } catch {
1036
+ }
1037
+ process.exit(0);
1038
+ };
1039
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1040
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1041
+ }
786
1042
  function resolveMaybeExisting(path) {
787
1043
  return path;
788
1044
  }
789
1045
  function describeNotes(notesPath) {
790
- if (!existsSync6(notesPath)) {
791
- return `${basename3(notesPath)} (not found \u2014 using empty notes)`;
1046
+ if (!existsSync7(notesPath)) {
1047
+ return `${basename4(notesPath)} (not found \u2014 using empty notes)`;
792
1048
  }
793
1049
  try {
794
- const raw = readFileSync(notesPath, "utf8");
1050
+ const raw = readFileSync2(notesPath, "utf8");
795
1051
  const parsed = JSON.parse(raw);
796
1052
  const total = parsed.meta?.totalSlides ?? 0;
797
1053
  const filled = Object.values(parsed.notes ?? {}).filter(
798
1054
  (e) => typeof e.note === "string" && e.note.trim() !== ""
799
1055
  ).length;
800
1056
  const suffix = total > 0 ? ` (${filled}/${total} slides have notes)` : "";
801
- return `${basename3(notesPath)}${suffix}`;
1057
+ return `${basename4(notesPath)}${suffix}`;
802
1058
  } catch {
803
- return `${basename3(notesPath)} (invalid JSON \u2014 using empty notes)`;
1059
+ return `${basename4(notesPath)} (invalid JSON \u2014 using empty notes)`;
804
1060
  }
805
1061
  }
806
1062
  function formatMinutes(minutes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pdf-presenter",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight CLI that serves PDF slides in the browser with a full presenter mode (speaker notes, next slide preview, timer, audio recording).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,13 +3,15 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>pdf-presenter — Audience</title>
6
+ <title>Audience</title>
7
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
7
8
  <link rel="stylesheet" href="/assets/presenter.css" />
8
9
  <!--PDF_PRESENTER_CONFIG-->
9
10
  </head>
10
11
  <body class="audience">
11
12
  <div id="stage" class="stage">
12
13
  <canvas id="slide-canvas"></canvas>
14
+ <div id="laser-dot" class="laser-dot hidden" aria-hidden="true"></div>
13
15
  <div id="black-overlay" class="overlay hidden"></div>
14
16
  <div id="freeze-indicator" class="freeze-indicator hidden">FROZEN</div>
15
17
  <div id="status" class="status"></div>
@@ -13,6 +13,7 @@ export async function initAudience() {
13
13
  const pdf = await loadDocument(config.pdfUrl);
14
14
  const total = pdf.numPages;
15
15
  const canvas = document.getElementById("slide-canvas");
16
+ const laserDot = document.getElementById("laser-dot");
16
17
  const blackOverlay = document.getElementById("black-overlay");
17
18
  const freezeIndicator = document.getElementById("freeze-indicator");
18
19
  const status = document.getElementById("status");
@@ -44,6 +45,20 @@ export async function initAudience() {
44
45
  blackOverlay.classList.toggle("hidden", !on);
45
46
  }
46
47
 
48
+ function setCursor(msg) {
49
+ if (!laserDot) return;
50
+ if (msg.hidden) {
51
+ laserDot.classList.add("hidden");
52
+ return;
53
+ }
54
+ const rect = canvas.getBoundingClientRect();
55
+ const x = rect.left + msg.x * rect.width;
56
+ const y = rect.top + msg.y * rect.height;
57
+ laserDot.style.left = `${x}px`;
58
+ laserDot.style.top = `${y}px`;
59
+ laserDot.classList.remove("hidden");
60
+ }
61
+
47
62
  channel.addEventListener("message", (ev) => {
48
63
  const msg = ev.data;
49
64
  if (!msg || typeof msg !== "object") return;
@@ -57,6 +72,9 @@ export async function initAudience() {
57
72
  case "black":
58
73
  setBlack(!!msg.value);
59
74
  break;
75
+ case "cursor":
76
+ setCursor(msg);
77
+ break;
60
78
  case "hello":
61
79
  channel.postMessage({ type: "audience-ready" });
62
80
  break;
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#f5a524" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20"/><path d="M21 3v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3"/><path d="m7 21 5-6 5 6"/><path d="M12 15v6"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#7fd4a0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h20"/><path d="M21 3v11a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V3"/><path d="m7 21 5-6 5 6"/><path d="M12 15v6"/></svg>
@@ -0,0 +1,66 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>PDF Presenter</title>
7
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
8
+ <!--PDF_PRESENTER_CONFIG-->
9
+ <style>
10
+ * { box-sizing: border-box; }
11
+ html, body {
12
+ margin: 0; padding: 0; height: 100%;
13
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", sans-serif;
14
+ background: #0d1117; color: #e6edf3;
15
+ }
16
+ .container { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
17
+ h1 { font-size: 20px; font-weight: 600; margin: 0 0 8px; }
18
+ .subtitle { font-size: 13px; color: #8b949e; margin-bottom: 24px; }
19
+ .pdf-list { display: flex; flex-direction: column; gap: 8px; }
20
+ .pdf-card {
21
+ display: flex; align-items: center; gap: 16px;
22
+ background: #161b22; border: 1px solid #21262d; border-radius: 8px;
23
+ padding: 14px 18px; transition: border-color 0.15s;
24
+ }
25
+ .pdf-card:hover { border-color: #388bfd44; }
26
+ .pdf-name {
27
+ flex: 1; min-width: 0; font-size: 14px; font-weight: 500;
28
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
29
+ }
30
+ .badge {
31
+ font-size: 11px; font-weight: 500; padding: 3px 8px; border-radius: 12px;
32
+ white-space: nowrap; flex-shrink: 0;
33
+ }
34
+ .badge-ready { background: #238636; color: #fff; }
35
+ .badge-partial { background: #9e6a03; color: #fff; }
36
+ .badge-empty { background: #30363d; color: #8b949e; }
37
+ .badge-none { background: #21262d; color: #484f58; }
38
+ .slide-count { font-size: 11px; color: #8b949e; white-space: nowrap; flex-shrink: 0; }
39
+ .actions { display: flex; gap: 6px; flex-shrink: 0; }
40
+ .btn {
41
+ appearance: none; background: #1d222a; color: #d7dbe3;
42
+ border: 1px solid #2d333b; border-radius: 4px;
43
+ padding: 6px 12px; font-size: 12px; font-family: inherit;
44
+ cursor: pointer; text-decoration: none; transition: background 0.1s;
45
+ }
46
+ .btn:hover { background: #252b35; border-color: #3a4049; }
47
+ .btn-primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
48
+ .btn-primary:hover { background: #388bfd; }
49
+ .empty-state {
50
+ text-align: center; padding: 64px 24px; color: #8b949e; font-size: 14px;
51
+ }
52
+ .loading { text-align: center; padding: 64px 24px; color: #8b949e; }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <div class="container">
57
+ <h1>PDF Presenter</h1>
58
+ <div class="subtitle" id="dir-path"></div>
59
+ <div id="list" class="pdf-list"><div class="loading">Loading...</div></div>
60
+ </div>
61
+ <script type="module">
62
+ import { initListing } from "/assets/listing.js";
63
+ initListing();
64
+ </script>
65
+ </body>
66
+ </html>
@@ -0,0 +1,49 @@
1
+ /** @typedef {{ pdfName: string; hasNotes: boolean; filledCount: number; totalSlides: number }} PdfStatus */
2
+
3
+ function readConfig() {
4
+ const el = document.getElementById("pdf-presenter-config");
5
+ return el ? JSON.parse(el.textContent) : {};
6
+ }
7
+
8
+ /** @param {PdfStatus} s */
9
+ function badgeHtml(s) {
10
+ if (!s.hasNotes) return `<span class="badge badge-none">No notes</span>`;
11
+ if (s.filledCount === 0) return `<span class="badge badge-empty">Empty template</span>`;
12
+ if (s.filledCount >= s.totalSlides) return `<span class="badge badge-ready">Ready</span>`;
13
+ return `<span class="badge badge-partial">${s.filledCount}/${s.totalSlides}</span>`;
14
+ }
15
+
16
+ /** @param {PdfStatus} s */
17
+ function cardHtml(s) {
18
+ const encoded = encodeURIComponent(s.pdfName);
19
+ const slides = s.totalSlides > 0 ? `<span class="slide-count">${s.totalSlides} slides</span>` : "";
20
+ return `<div class="pdf-card">
21
+ <span class="pdf-name" title="${s.pdfName}">${s.pdfName}</span>
22
+ ${badgeHtml(s)}
23
+ ${slides}
24
+ <span class="actions">
25
+ <a class="btn btn-primary" href="/pdf/${encoded}/presenter">Open</a>
26
+ <a class="btn" href="/pdf/${encoded}/">Audience</a>
27
+ </span>
28
+ </div>`;
29
+ }
30
+
31
+ export async function initListing() {
32
+ const config = readConfig();
33
+ const dirEl = document.getElementById("dir-path");
34
+ if (dirEl && config.dirName) dirEl.textContent = config.dirName;
35
+
36
+ const listEl = document.getElementById("list");
37
+ try {
38
+ const res = await fetch("/api/listing");
39
+ /** @type {PdfStatus[]} */
40
+ const pdfs = await res.json();
41
+ if (pdfs.length === 0) {
42
+ listEl.innerHTML = `<div class="empty-state">No PDF files found in this directory.</div>`;
43
+ return;
44
+ }
45
+ listEl.innerHTML = pdfs.map(cardHtml).join("");
46
+ } catch (err) {
47
+ listEl.innerHTML = `<div class="empty-state">Failed to load listing: ${err.message}</div>`;
48
+ }
49
+ }
@@ -0,0 +1,76 @@
1
+ // Cursor sync: mirrors the presenter's mouse position over the Current
2
+ // canvas to the audience view as a red laser dot. Coordinates are sent
3
+ // normalized ([0,1]) over the existing BroadcastChannel and throttled
4
+ // via requestAnimationFrame so a fast drag won't spam messages.
5
+
6
+ export function createCursorSync({ canvas, channel }) {
7
+ let enabled = false;
8
+ let pending = null; // {x, y} | null
9
+ let rafId = 0;
10
+ let hiddenPosted = true;
11
+
12
+ function postPending() {
13
+ rafId = 0;
14
+ if (!enabled) return;
15
+ if (!pending) return;
16
+ channel.postMessage({ type: "cursor", x: pending.x, y: pending.y });
17
+ hiddenPosted = false;
18
+ pending = null;
19
+ }
20
+
21
+ function schedulePost(x, y) {
22
+ pending = { x, y };
23
+ if (rafId) return;
24
+ rafId = requestAnimationFrame(postPending);
25
+ }
26
+
27
+ function onMove(ev) {
28
+ if (!enabled) return;
29
+ const rect = canvas.getBoundingClientRect();
30
+ if (rect.width <= 0 || rect.height <= 0) return;
31
+ const x = (ev.clientX - rect.left) / rect.width;
32
+ const y = (ev.clientY - rect.top) / rect.height;
33
+ if (x < 0 || x > 1 || y < 0 || y > 1) {
34
+ postHide();
35
+ return;
36
+ }
37
+ schedulePost(x, y);
38
+ }
39
+
40
+ function postHide() {
41
+ if (hiddenPosted) return;
42
+ pending = null;
43
+ if (rafId) {
44
+ cancelAnimationFrame(rafId);
45
+ rafId = 0;
46
+ }
47
+ channel.postMessage({ type: "cursor", hidden: true });
48
+ hiddenPosted = true;
49
+ }
50
+
51
+ function onLeave() {
52
+ postHide();
53
+ }
54
+
55
+ function setEnabled(on) {
56
+ if (on === enabled) return;
57
+ enabled = on;
58
+ if (enabled) {
59
+ canvas.addEventListener("mousemove", onMove);
60
+ canvas.addEventListener("mouseleave", onLeave);
61
+ } else {
62
+ canvas.removeEventListener("mousemove", onMove);
63
+ canvas.removeEventListener("mouseleave", onLeave);
64
+ postHide();
65
+ }
66
+ }
67
+
68
+ return {
69
+ setEnabled,
70
+ toggle: () => {
71
+ setEnabled(!enabled);
72
+ return enabled;
73
+ },
74
+ isEnabled: () => enabled,
75
+ };
76
+ }
@@ -0,0 +1,222 @@
1
+ // Grid view overlay: thumbnail grid for fast slide navigation.
2
+ // Thumbnails are rendered lazily on first open and cached for the session.
3
+ // Keyboard: arrow keys move selection, Enter picks, Esc closes.
4
+
5
+ // Render at 2x the min-column width so tiles stay crisp when the grid
6
+ // auto-expands columns wider than the minimum. Display size is driven
7
+ // by CSS (width:100%, height:auto), so the canvas keeps its natural ratio.
8
+ const THUMB_RENDER_WIDTH = 480;
9
+ const CONTAINER_ID = "grid-overlay";
10
+
11
+ export function createGridView({ pdf, total, getCurrentSlide, onSelect }) {
12
+ let overlayEl = null;
13
+ let gridEl = null;
14
+ let tileEls = []; // index 0 => slide 1
15
+ const thumbCache = new Map(); // pageNumber -> HTMLImageElement
16
+ let selected = 1;
17
+ let open = false;
18
+ let renderToken = 0;
19
+ let aspectsLoaded = false;
20
+
21
+ // Pre-compute each page's aspect ratio and pin it onto the tile BEFORE
22
+ // any images load. Without this, CSS Grid with definite-height container
23
+ // recomputes row heights as images decode, shrinking tiles to squeeze all
24
+ // rows into view. Fetching page viewports is cheap — no rendering, just
25
+ // metadata from pdf.js.
26
+ async function pinAspects() {
27
+ if (aspectsLoaded) return;
28
+ aspectsLoaded = true;
29
+ const pagePromises = [];
30
+ for (let i = 1; i <= total; i++) pagePromises.push(pdf.getPage(i));
31
+ const pages = await Promise.all(pagePromises);
32
+ for (let i = 0; i < pages.length; i++) {
33
+ const vp = pages[i].getViewport({ scale: 1 });
34
+ const wrap = tileEls[i].querySelector(".grid-thumb");
35
+ wrap.style.aspectRatio = `${vp.width} / ${vp.height}`;
36
+ }
37
+ }
38
+
39
+ function build() {
40
+ overlayEl = document.createElement("div");
41
+ overlayEl.id = CONTAINER_ID;
42
+ overlayEl.className = "grid-overlay hidden";
43
+ overlayEl.setAttribute("role", "dialog");
44
+ overlayEl.setAttribute("aria-modal", "true");
45
+ overlayEl.setAttribute("aria-label", "Slide grid");
46
+
47
+ const header = document.createElement("div");
48
+ header.className = "grid-header";
49
+ const title = document.createElement("div");
50
+ title.className = "grid-title";
51
+ title.textContent = "All Slides";
52
+ const hint = document.createElement("div");
53
+ hint.className = "grid-hint";
54
+ hint.innerHTML =
55
+ '<span>← ↑ → ↓</span> move <span>Enter</span> jump <span>Esc</span> close';
56
+ header.appendChild(title);
57
+ header.appendChild(hint);
58
+
59
+ gridEl = document.createElement("div");
60
+ gridEl.className = "grid-tiles";
61
+
62
+ for (let i = 1; i <= total; i++) {
63
+ const tile = document.createElement("button");
64
+ tile.type = "button";
65
+ tile.className = "grid-tile";
66
+ tile.dataset.slide = String(i);
67
+
68
+ // Wrap content in a plain block div so the <button> itself never acts
69
+ // as a flex/grid container — button UA styles collapse flex children
70
+ // with percentage-width replaced elements in some browsers.
71
+ const inner = document.createElement("div");
72
+ inner.className = "grid-tile-inner";
73
+
74
+ const canvasWrap = document.createElement("div");
75
+ canvasWrap.className = "grid-thumb";
76
+
77
+ const label = document.createElement("div");
78
+ label.className = "grid-label";
79
+ label.textContent = String(i);
80
+
81
+ inner.appendChild(canvasWrap);
82
+ inner.appendChild(label);
83
+ tile.appendChild(inner);
84
+ tile.addEventListener("click", () => pick(i));
85
+ tile.addEventListener("mouseenter", () => setSelected(i, false));
86
+ gridEl.appendChild(tile);
87
+ tileEls.push(tile);
88
+ }
89
+
90
+ overlayEl.appendChild(header);
91
+ overlayEl.appendChild(gridEl);
92
+ overlayEl.addEventListener("click", (ev) => {
93
+ if (ev.target === overlayEl) close();
94
+ });
95
+ document.body.appendChild(overlayEl);
96
+ }
97
+
98
+ async function renderThumb(pageNumber) {
99
+ if (thumbCache.has(pageNumber)) return thumbCache.get(pageNumber);
100
+ const page = await pdf.getPage(pageNumber);
101
+ const unscaled = page.getViewport({ scale: 1 });
102
+ const scale = THUMB_RENDER_WIDTH / unscaled.width;
103
+ const viewport = page.getViewport({ scale });
104
+ const canvas = document.createElement("canvas");
105
+ canvas.width = Math.floor(viewport.width);
106
+ canvas.height = Math.floor(viewport.height);
107
+ const ctx = canvas.getContext("2d");
108
+ await page.render({ canvasContext: ctx, viewport }).promise;
109
+ // <img> is a proper replaced element — width:100% / height:auto always
110
+ // yields the image's natural aspect ratio.
111
+ const img = new Image(canvas.width, canvas.height);
112
+ img.src = canvas.toDataURL("image/png");
113
+ img.decoding = "async";
114
+ img.alt = `Slide ${pageNumber}`;
115
+ thumbCache.set(pageNumber, img);
116
+ return img;
117
+ }
118
+
119
+ async function fillTiles() {
120
+ const token = ++renderToken;
121
+ // Render in two passes: selected tile first so it shows up instantly,
122
+ // then the rest in order. Each await checks the token so a close()
123
+ // during rendering aborts cleanly.
124
+ const order = [selected];
125
+ for (let i = 1; i <= total; i++) if (i !== selected) order.push(i);
126
+ for (const n of order) {
127
+ if (token !== renderToken) return;
128
+ const tile = tileEls[n - 1];
129
+ const wrap = tile.querySelector(".grid-thumb");
130
+ if (wrap.firstChild) continue;
131
+ try {
132
+ const canvas = await renderThumb(n);
133
+ if (token !== renderToken) return;
134
+ if (!wrap.firstChild) wrap.appendChild(canvas);
135
+ } catch {
136
+ // ignore render failures — tile stays blank
137
+ }
138
+ }
139
+ }
140
+
141
+ function setSelected(n, scroll = true) {
142
+ if (n < 1 || n > total) return;
143
+ selected = n;
144
+ for (const t of tileEls) t.classList.remove("selected");
145
+ const tile = tileEls[n - 1];
146
+ tile.classList.add("selected");
147
+ if (scroll) tile.scrollIntoView({ block: "nearest", behavior: "smooth" });
148
+ }
149
+
150
+ function pick(n) {
151
+ close();
152
+ onSelect(n);
153
+ }
154
+
155
+ function columnsPerRow() {
156
+ if (!gridEl || !tileEls.length) return 1;
157
+ const first = tileEls[0].getBoundingClientRect();
158
+ const gridRect = gridEl.getBoundingClientRect();
159
+ const gapX = 12;
160
+ const cols = Math.max(
161
+ 1,
162
+ Math.floor((gridRect.width + gapX) / (first.width + gapX)),
163
+ );
164
+ return cols;
165
+ }
166
+
167
+ function onKey(ev) {
168
+ if (!open) return;
169
+ const cols = columnsPerRow();
170
+ if (ev.key === "Escape") {
171
+ ev.preventDefault();
172
+ close();
173
+ } else if (ev.key === "Enter") {
174
+ ev.preventDefault();
175
+ pick(selected);
176
+ } else if (ev.key === "ArrowRight") {
177
+ ev.preventDefault();
178
+ setSelected(Math.min(total, selected + 1));
179
+ } else if (ev.key === "ArrowLeft") {
180
+ ev.preventDefault();
181
+ setSelected(Math.max(1, selected - 1));
182
+ } else if (ev.key === "ArrowDown") {
183
+ ev.preventDefault();
184
+ setSelected(Math.min(total, selected + cols));
185
+ } else if (ev.key === "ArrowUp") {
186
+ ev.preventDefault();
187
+ setSelected(Math.max(1, selected - cols));
188
+ } else if (ev.key === "Home") {
189
+ ev.preventDefault();
190
+ setSelected(1);
191
+ } else if (ev.key === "End") {
192
+ ev.preventDefault();
193
+ setSelected(total);
194
+ }
195
+ }
196
+
197
+ function openView() {
198
+ if (!overlayEl) build();
199
+ open = true;
200
+ overlayEl.classList.remove("hidden");
201
+ setSelected(getCurrentSlide(), true);
202
+ // Fire and forget — pins each tile's box geometry to its slide aspect,
203
+ // then kicks off rendering. fillTiles runs in parallel; it's safe because
204
+ // the tiles already exist and we only update wrap.style.aspectRatio.
205
+ pinAspects().then(fillTiles);
206
+ window.addEventListener("keydown", onKey, true);
207
+ }
208
+
209
+ function close() {
210
+ if (!open) return;
211
+ open = false;
212
+ renderToken++;
213
+ overlayEl.classList.add("hidden");
214
+ window.removeEventListener("keydown", onKey, true);
215
+ }
216
+
217
+ return {
218
+ open: openView,
219
+ close,
220
+ isOpen: () => open,
221
+ };
222
+ }
@@ -16,6 +16,8 @@ import { wireImportExport } from "./modules/import-export.js";
16
16
  import { createRecordingDialog } from "./modules/recording-dialog.js";
17
17
  import { createRecorder } from "./modules/recording.js";
18
18
  import { createResizableLayout } from "./modules/resizable-layout.js";
19
+ import { createGridView } from "./modules/grid-view.js";
20
+ import { createCursorSync } from "./modules/cursor-sync.js";
19
21
 
20
22
  export async function initPresenter() {
21
23
  const config = readConfig();
@@ -31,6 +33,23 @@ export async function initPresenter() {
31
33
  const notesHint = document.getElementById("notes-hint");
32
34
  const counter = document.getElementById("counter");
33
35
  const timerEl = document.getElementById("timer");
36
+ const pdfNameEl = document.getElementById("pdf-name");
37
+ const gridBtn = document.getElementById("grid-btn");
38
+ const cursorSyncBtn = document.getElementById("cursor-sync-btn");
39
+
40
+ if (pdfNameEl && config.pdfName) {
41
+ pdfNameEl.textContent = config.pdfName;
42
+ pdfNameEl.title = config.pdfName;
43
+ }
44
+
45
+ // Show "back to list" button when launched from directory mode
46
+ if (config.listUrl) {
47
+ const backBtn = document.getElementById("back-to-list");
48
+ if (backBtn) {
49
+ backBtn.href = config.listUrl;
50
+ backBtn.classList.remove("hidden");
51
+ }
52
+ }
34
53
 
35
54
  const channel = new BroadcastChannel(CHANNEL_NAME);
36
55
  let currentSlide = 1;
@@ -125,6 +144,8 @@ export async function initPresenter() {
125
144
  }
126
145
  return;
127
146
  }
147
+ // Grid view owns the keyboard while open (arrows / Enter / Esc).
148
+ if (gridView.isOpen()) return;
128
149
  if (ev.key === "ArrowRight" || ev.key === "PageDown" || ev.key === " ") {
129
150
  ev.preventDefault();
130
151
  show(currentSlide + 1);
@@ -141,6 +162,13 @@ export async function initPresenter() {
141
162
  toggleBlack();
142
163
  } else if (ev.key === "r" || ev.key === "R") {
143
164
  timer.reset();
165
+ } else if (ev.key === "g" || ev.key === "G") {
166
+ ev.preventDefault();
167
+ gridView.open();
168
+ } else if (ev.key === "l" || ev.key === "L") {
169
+ ev.preventDefault();
170
+ cursorSync.toggle();
171
+ updateCursorSyncBtn();
144
172
  }
145
173
  });
146
174
 
@@ -154,6 +182,32 @@ export async function initPresenter() {
154
182
  : null;
155
183
  const timer = createTimer({ timerEl, resetBtnEl: timerResetBtn, countdownMs });
156
184
 
185
+ // ---- Grid view ----
186
+ const gridView = createGridView({
187
+ pdf,
188
+ total,
189
+ getCurrentSlide: () => currentSlide,
190
+ onSelect: (n) => show(n),
191
+ });
192
+ if (gridBtn) gridBtn.addEventListener("click", () => gridView.open());
193
+
194
+ // ---- Cursor sync ----
195
+ const cursorSync = createCursorSync({ canvas: currentCanvas, channel });
196
+ function updateCursorSyncBtn() {
197
+ if (!cursorSyncBtn) return;
198
+ const on = cursorSync.isEnabled();
199
+ cursorSyncBtn.textContent = `◉ Cursor sync: ${on ? "on" : "off"}`;
200
+ cursorSyncBtn.setAttribute("aria-pressed", String(on));
201
+ cursorSyncBtn.classList.toggle("active", on);
202
+ }
203
+ if (cursorSyncBtn) {
204
+ cursorSyncBtn.addEventListener("click", () => {
205
+ cursorSync.toggle();
206
+ updateCursorSyncBtn();
207
+ });
208
+ }
209
+ updateCursorSyncBtn();
210
+
157
211
  // ---- Resizable layout dividers ----
158
212
  createResizableLayout({
159
213
  layoutEl: document.querySelector(".layout"),
@@ -667,3 +667,188 @@ button.timer:focus-visible {
667
667
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
668
668
  font-size: 9px;
669
669
  }
670
+
671
+ /* ==========================================================================
672
+ Meta: filename + view row (grid + cursor sync)
673
+ ========================================================================== */
674
+
675
+ .meta > .pdf-name {
676
+ grid-column: 1 / -1;
677
+ font-size: 11px;
678
+ color: #9aa3b0;
679
+ white-space: nowrap;
680
+ overflow: hidden;
681
+ text-overflow: ellipsis;
682
+ font-variant-numeric: tabular-nums;
683
+ padding: 2px 0 0;
684
+ }
685
+
686
+ .meta > .view-row {
687
+ grid-column: 1 / -1;
688
+ display: flex;
689
+ gap: 6px;
690
+ }
691
+
692
+ .meta > .view-row > .btn {
693
+ flex: 1;
694
+ text-align: center;
695
+ text-decoration: none;
696
+ }
697
+
698
+ .meta > .view-row > .hidden {
699
+ display: none;
700
+ }
701
+
702
+ #cursor-sync-btn.active {
703
+ background: #1f3a28;
704
+ border-color: #3d7a52;
705
+ color: #d4f0de;
706
+ }
707
+
708
+ /* ==========================================================================
709
+ Grid view overlay
710
+ ========================================================================== */
711
+
712
+ .grid-overlay {
713
+ position: fixed;
714
+ inset: 0;
715
+ background: rgba(6, 8, 11, 0.94);
716
+ z-index: 2000;
717
+ display: flex;
718
+ flex-direction: column;
719
+ padding: 24px 28px 28px;
720
+ overflow: hidden;
721
+ }
722
+
723
+ .grid-overlay.hidden {
724
+ display: none;
725
+ }
726
+
727
+ .grid-header {
728
+ display: flex;
729
+ align-items: baseline;
730
+ justify-content: space-between;
731
+ margin-bottom: 14px;
732
+ padding-bottom: 12px;
733
+ border-bottom: 1px solid #23272e;
734
+ }
735
+
736
+ .grid-title {
737
+ font-size: 14px;
738
+ text-transform: uppercase;
739
+ letter-spacing: 0.18em;
740
+ color: #c0c6d0;
741
+ }
742
+
743
+ .grid-hint {
744
+ font-size: 11px;
745
+ color: #7a8291;
746
+ }
747
+
748
+ .grid-hint span {
749
+ display: inline-block;
750
+ padding: 1px 6px;
751
+ margin: 0 2px 0 8px;
752
+ border: 1px solid #2d323b;
753
+ border-radius: 3px;
754
+ color: #9aa3b0;
755
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
756
+ font-size: 10px;
757
+ }
758
+
759
+ .grid-tiles {
760
+ flex: 1;
761
+ overflow-y: auto;
762
+ display: grid;
763
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
764
+ /* max-content on implicit rows prevents the grid from recomputing row
765
+ heights against the container when images decode — rows stay at the
766
+ size their aspect-ratio-pinned .grid-thumb asks for. */
767
+ grid-auto-rows: max-content;
768
+ gap: 14px;
769
+ align-content: start;
770
+ padding: 4px 4px 12px;
771
+ }
772
+
773
+ .grid-tile {
774
+ appearance: none;
775
+ display: block;
776
+ width: 100%;
777
+ margin: 0;
778
+ padding: 0;
779
+ background: #15181d;
780
+ border: 2px solid #23272e;
781
+ border-radius: 6px;
782
+ cursor: pointer;
783
+ overflow: hidden;
784
+ text-align: left;
785
+ font-family: inherit;
786
+ transition: border-color 0.1s ease, transform 0.1s ease;
787
+ }
788
+
789
+ .grid-tile-inner {
790
+ display: block;
791
+ }
792
+
793
+ .grid-tile:hover {
794
+ border-color: #3a4049;
795
+ }
796
+
797
+ .grid-tile.selected {
798
+ border-color: #7fd4a0;
799
+ transform: translateY(-1px);
800
+ box-shadow: 0 6px 18px rgba(127, 212, 160, 0.15);
801
+ }
802
+
803
+ .grid-thumb {
804
+ display: block;
805
+ width: 100%;
806
+ /* aspect-ratio is set inline by grid-view.js pinAspects() per slide,
807
+ with a sane fallback so the box has some height before pages load. */
808
+ aspect-ratio: 16 / 9;
809
+ background: #000;
810
+ }
811
+
812
+ .grid-thumb img,
813
+ .grid-thumb canvas {
814
+ display: block;
815
+ width: 100%;
816
+ height: 100%;
817
+ object-fit: contain;
818
+ }
819
+
820
+ .grid-label {
821
+ padding: 6px 10px;
822
+ font-size: 11px;
823
+ color: #9aa3b0;
824
+ text-align: left;
825
+ font-variant-numeric: tabular-nums;
826
+ background: #11141a;
827
+ border-top: 1px solid #1e2229;
828
+ }
829
+
830
+ .grid-tile.selected .grid-label {
831
+ color: #7fd4a0;
832
+ }
833
+
834
+ /* ==========================================================================
835
+ Audience: laser dot (cursor sync)
836
+ ========================================================================== */
837
+
838
+ .laser-dot {
839
+ position: fixed;
840
+ width: 14px;
841
+ height: 14px;
842
+ margin-left: -7px;
843
+ margin-top: -7px;
844
+ border-radius: 50%;
845
+ background: #ff3a3a;
846
+ box-shadow: 0 0 12px 4px rgba(255, 58, 58, 0.55),
847
+ 0 0 2px 1px rgba(255, 255, 255, 0.6) inset;
848
+ pointer-events: none;
849
+ z-index: 1500;
850
+ }
851
+
852
+ .laser-dot.hidden {
853
+ display: none;
854
+ }
@@ -3,7 +3,8 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>pdf-presenter — Presenter</title>
6
+ <title>Presenter</title>
7
+ <link rel="icon" type="image/svg+xml" href="/assets/favicon-presenter.svg" />
7
8
  <link rel="stylesheet" href="/assets/presenter.css" />
8
9
  <!--PDF_PRESENTER_CONFIG-->
9
10
  </head>
@@ -77,6 +78,25 @@
77
78
  >↺</button>
78
79
  </div>
79
80
  <div class="counter" id="counter">– / –</div>
81
+ <div class="pdf-name" id="pdf-name" title=""></div>
82
+ <div class="view-row">
83
+ <a id="back-to-list" class="btn hidden" href="/list">← All PDFs</a>
84
+ <button
85
+ type="button"
86
+ id="grid-btn"
87
+ class="btn"
88
+ title="Show slide grid (G)"
89
+ aria-label="Show slide grid"
90
+ >⊞ Grid</button>
91
+ <button
92
+ type="button"
93
+ id="cursor-sync-btn"
94
+ class="btn"
95
+ title="Toggle cursor sync (L)"
96
+ aria-label="Toggle cursor sync"
97
+ aria-pressed="false"
98
+ >◉ Cursor sync: off</button>
99
+ </div>
80
100
  <div class="notes-actions">
81
101
  <button type="button" id="export-notes" class="btn">Export speaker note</button>
82
102
  <button type="button" id="load-notes" class="btn">Load speaker note</button>
@@ -102,6 +122,8 @@
102
122
  </div>
103
123
  <div class="hints">
104
124
  <span>← →</span> nav
125
+ <span>G</span> grid
126
+ <span>L</span> cursor sync
105
127
  <span>F</span> freeze
106
128
  <span>B</span> black
107
129
  <span>R</span> reset timer