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 +5 -0
- package/dist/pdf-presenter.js +285 -29
- package/package.json +1 -1
- package/src/ui/audience.html +3 -1
- package/src/ui/audience.js +18 -0
- package/src/ui/favicon-presenter.svg +1 -0
- package/src/ui/favicon.svg +1 -0
- package/src/ui/listing.html +66 -0
- package/src/ui/listing.js +49 -0
- package/src/ui/modules/cursor-sync.js +76 -0
- package/src/ui/modules/grid-view.js +222 -0
- package/src/ui/presenter-main.js +54 -0
- package/src/ui/presenter.css +185 -0
- package/src/ui/presenter.html +23 -1
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
|
---
|
package/dist/pdf-presenter.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { basename as
|
|
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,
|
|
234
|
-
const raw = await readFile2(
|
|
235
|
-
|
|
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
|
|
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 =
|
|
466
|
+
const outDir = join3(dirname2(deps.pdfPath), "recordings");
|
|
440
467
|
await mkdir(outDir, { recursive: true });
|
|
441
|
-
const outPath =
|
|
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 =
|
|
515
|
+
const outDir = join3(dirname2(deps.pdfPath), "recordings");
|
|
489
516
|
await mkdir(outDir, { recursive: true });
|
|
490
|
-
const outPath =
|
|
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
|
|
566
|
-
const
|
|
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
|
|
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
|
|
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) ||
|
|
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) ||
|
|
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: ${
|
|
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 (!
|
|
791
|
-
return `${
|
|
1046
|
+
if (!existsSync7(notesPath)) {
|
|
1047
|
+
return `${basename4(notesPath)} (not found \u2014 using empty notes)`;
|
|
792
1048
|
}
|
|
793
1049
|
try {
|
|
794
|
-
const raw =
|
|
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 `${
|
|
1057
|
+
return `${basename4(notesPath)}${suffix}`;
|
|
802
1058
|
} catch {
|
|
803
|
-
return `${
|
|
1059
|
+
return `${basename4(notesPath)} (invalid JSON \u2014 using empty notes)`;
|
|
804
1060
|
}
|
|
805
1061
|
}
|
|
806
1062
|
function formatMinutes(minutes) {
|
package/package.json
CHANGED
package/src/ui/audience.html
CHANGED
|
@@ -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>
|
|
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>
|
package/src/ui/audience.js
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/presenter-main.js
CHANGED
|
@@ -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"),
|
package/src/ui/presenter.css
CHANGED
|
@@ -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
|
+
}
|
package/src/ui/presenter.html
CHANGED
|
@@ -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>
|
|
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
|