pdf-presenter 1.0.0 → 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-zhtw.md +23 -1
- package/README.md +28 -1
- package/dist/pdf-presenter.js +345 -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
|
@@ -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
|
+
}
|