@hajimism/dek 0.2.0 → 0.2.1
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/cli/serve.ts +1 -1
- package/src/core/chrome.ts +3 -1
- package/src/core/document.ts +17 -2
- package/src/core/html.ts +99 -14
- package/src/core/page.ts +1 -0
- package/src/runtime/annotate-notes.ts +207 -0
- package/src/runtime/annotate-pick.ts +148 -0
- package/src/runtime/annotate-place.ts +46 -0
- package/src/runtime/annotate-view.ts +738 -0
- package/src/runtime/annotate.ts +15 -0
- package/src/runtime/player.ts +15 -7
- package/src/server/dev.ts +2 -1
- package/src/server/pages.ts +2 -1
- package/src/server/routes.ts +9 -3
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
import { PAGE_ID } from "../core/page.ts";
|
|
3
|
+
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
4
|
+
import {
|
|
5
|
+
formatNotes,
|
|
6
|
+
type Note,
|
|
7
|
+
type NoteTarget,
|
|
8
|
+
readNotes,
|
|
9
|
+
reanchor,
|
|
10
|
+
sortNotes,
|
|
11
|
+
writeNotes,
|
|
12
|
+
} from "./annotate-notes.ts";
|
|
13
|
+
import {
|
|
14
|
+
candidatesAt,
|
|
15
|
+
currentStop,
|
|
16
|
+
describeElement,
|
|
17
|
+
SOURCE_ATTR,
|
|
18
|
+
slideBox,
|
|
19
|
+
slidePoint,
|
|
20
|
+
windowPoint,
|
|
21
|
+
} from "./annotate-pick.ts";
|
|
22
|
+
import { placePopup } from "./annotate-place.ts";
|
|
23
|
+
import { slideSelector } from "./live.ts";
|
|
24
|
+
import { isLetterKey, isTextEntry } from "./step.ts";
|
|
25
|
+
|
|
26
|
+
type Point = { x: number; y: number };
|
|
27
|
+
|
|
28
|
+
/** One element picked for the note being written, and where the click that picked it was. */
|
|
29
|
+
type Pick = { el: Element; at: Point };
|
|
30
|
+
|
|
31
|
+
// In English whatever the deck's language, like the notes the view hands over.
|
|
32
|
+
const LABELS = {
|
|
33
|
+
placeholder: "What should change?",
|
|
34
|
+
add: "Add",
|
|
35
|
+
save: "Save",
|
|
36
|
+
cancel: "Cancel",
|
|
37
|
+
delete: "Delete",
|
|
38
|
+
copy: "Copy",
|
|
39
|
+
clear: "Clear all notes",
|
|
40
|
+
undo: "Undo",
|
|
41
|
+
cleared: (n: number) => `Cleared ${n} ${n === 1 ? "note" : "notes"}`,
|
|
42
|
+
close: "Leave annotate mode (a)",
|
|
43
|
+
near: "near",
|
|
44
|
+
addHint: "⌘/Ctrl-click adds more",
|
|
45
|
+
notes: (n: number) => `${n} ${n === 1 ? "note" : "notes"}`,
|
|
46
|
+
copied: (n: number) => `Copied ${n} ${n === 1 ? "note" : "notes"}`,
|
|
47
|
+
manual: "Press ⌘C or Ctrl+C to copy",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Line icons, drawn in the text's color. */
|
|
51
|
+
const ICON = {
|
|
52
|
+
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>',
|
|
53
|
+
trash:
|
|
54
|
+
'<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/>',
|
|
55
|
+
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const icon = (name: keyof typeof ICON): string =>
|
|
59
|
+
`<svg viewBox="0 0 24 24" aria-hidden="true">${ICON[name]}</svg>`;
|
|
60
|
+
|
|
61
|
+
// An inspector's look: dark panels, a hairline outline, and names in monospace, as the notes
|
|
62
|
+
// write them. Over the deck and everything the page draws; the popup and the bar on top.
|
|
63
|
+
const STYLE = `
|
|
64
|
+
:host { all: initial; --accent: #378add; --ink: #f4f4f5; --muted: #a1a1aa; --faint: #71717a; --panel: #18181b; --raised: #27272a; --well: #09090b; --line: rgb(255 255 255 / 0.1); }
|
|
65
|
+
[hidden] { display: none !important; }
|
|
66
|
+
* { box-sizing: border-box; margin: 0; font: 12px/1.45 system-ui, -apple-system, sans-serif; }
|
|
67
|
+
.mono, .mono *, kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
68
|
+
svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
|
69
|
+
button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; }
|
|
70
|
+
button:hover { background: var(--raised); color: var(--ink); }
|
|
71
|
+
button:disabled { opacity: 0.35; cursor: default; background: none; }
|
|
72
|
+
kbd { padding: 0 4px; border: 1px solid var(--line); border-radius: 4px; color: var(--faint); }
|
|
73
|
+
[data-part=layer] { position: fixed; z-index: 2147483000; cursor: crosshair; }
|
|
74
|
+
[data-part=hover], [data-part=pick] { position: fixed; z-index: 2147483001; pointer-events: none; outline: 1.5px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
|
75
|
+
[data-part=pick] { background: rgb(55 138 221 / 0.12); }
|
|
76
|
+
[data-part=hover] span { position: absolute; left: -2px; bottom: 100%; margin-bottom: 5px; padding: 2px 6px; border-radius: 4px; background: #0c447c; color: #e6f1fb; white-space: nowrap; }
|
|
77
|
+
[data-part=hover] i { font-style: normal; color: #85b7eb; }
|
|
78
|
+
[data-part=marker] { position: fixed; z-index: 2147483002; min-width: 24px; height: 24px; padding: 0 6px; justify-content: center; transform: translate(-2px, calc(-100% + 2px)); border: 2px solid #fff; border-radius: 12px 12px 12px 2px; background: var(--accent); color: #fff; font-weight: 600; font-size: 11px; box-shadow: 0 2px 6px rgb(0 0 0 / 0.25); }
|
|
79
|
+
[data-part=marker]:hover { background: #185fa5; color: #fff; }
|
|
80
|
+
[data-part=popup], [data-part=pill], [data-part=tray] { background: var(--panel); color: var(--ink); border: 1px solid var(--line); box-shadow: 0 12px 32px rgb(0 0 0 / 0.35); }
|
|
81
|
+
[data-part=popup] { position: fixed; z-index: 2147483003; width: 340px; padding: 10px; border-radius: 12px; }
|
|
82
|
+
[data-part=choices] { display: grid; gap: 4px; margin-bottom: 8px; }
|
|
83
|
+
[data-part=path], [data-part=near] { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
|
|
84
|
+
[data-part=path] > span, [data-part=near] > span { color: #52525b; }
|
|
85
|
+
[data-part=choices] button { padding: 2px 6px; border-radius: 5px; }
|
|
86
|
+
[data-part=choices] button[aria-pressed=true] { background: var(--raised); color: var(--ink); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
87
|
+
[data-part=picked] { list-style: none; padding: 0 2px; margin-bottom: 8px; color: var(--faint); }
|
|
88
|
+
[data-part=picked] li { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
89
|
+
textarea { display: block; width: 100%; min-height: 3.6em; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--well); color: var(--ink); resize: vertical; outline: none; }
|
|
90
|
+
textarea:focus { border-color: var(--accent); }
|
|
91
|
+
textarea::placeholder { color: var(--faint); }
|
|
92
|
+
[data-part=actions] { display: flex; align-items: center; gap: 4px; margin-top: 8px; }
|
|
93
|
+
[data-part=hint] { margin-right: auto; padding-left: 2px; color: var(--faint); }
|
|
94
|
+
[data-part=delete] { margin-right: auto; color: #f09595; }
|
|
95
|
+
[data-part=popup] { transition: opacity 120ms; }
|
|
96
|
+
[data-part=popup][data-through] { opacity: 0.3; pointer-events: none; }
|
|
97
|
+
[data-part=add] { background: var(--raised); color: var(--ink); }
|
|
98
|
+
[data-part=add]:hover { background: #3f3f46; }
|
|
99
|
+
[data-part=bar] { position: fixed; z-index: 2147483003; display: grid; justify-items: center; gap: 8px; transform: translate(-50%, -100%); }
|
|
100
|
+
[data-part=pill] { display: flex; align-items: center; gap: 2px; padding: 4px; border-radius: 999px; }
|
|
101
|
+
[data-part=pill] button { border-radius: 999px; padding: 5px 10px; }
|
|
102
|
+
[data-part=pill] button[data-part=clear], [data-part=pill] button[data-part=close] { padding: 5px 7px; }
|
|
103
|
+
[data-part=count] { padding: 0 10px; color: var(--muted); white-space: nowrap; }
|
|
104
|
+
[data-part=clear]:hover { color: #f09595; }
|
|
105
|
+
[data-part=divider] { width: 1px; height: 16px; margin: 0 6px; background: var(--line); }
|
|
106
|
+
[data-part=copy] { background: var(--raised); color: var(--ink); }
|
|
107
|
+
[data-part=tray] { display: grid; gap: 6px; max-width: calc(100vw - 32px); padding: 8px; border-radius: 12px; }
|
|
108
|
+
[data-part=manual] { width: min(460px, calc(100vw - 48px)); min-height: 9em; resize: vertical; }
|
|
109
|
+
[data-part=report] { display: flex; align-items: center; gap: 8px; padding: 0 4px; }
|
|
110
|
+
[data-part=status] { color: var(--muted); }
|
|
111
|
+
[data-part=undo] { margin-left: auto; padding: 3px 10px; color: var(--ink); background: var(--raised); }
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Annotate mode: point at elements of the slide on screen, write what should change, and copy
|
|
116
|
+
* the notes as one block for an agent, each element named by the file and line it is written
|
|
117
|
+
* at. It runs only on the dev server's pages for the speaker, beside the player, and shows
|
|
118
|
+
* nothing until `a` or the presenter bar's button turns it on: the page may be on the projector.
|
|
119
|
+
*/
|
|
120
|
+
export function createAnnotateView(options: { deck: string; slides: PresenterSlide[] }): void {
|
|
121
|
+
const { deck, slides } = options;
|
|
122
|
+
const deckEl = document.getElementById(PAGE_ID.deck);
|
|
123
|
+
const stageEl = document.getElementById(PAGE_ID.currentStage);
|
|
124
|
+
if (!deckEl || !stageEl) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const storageKey = `dek-annotate:${deck}`;
|
|
128
|
+
const toggleEl = document.getElementById(PAGE_ID.annotateToggle);
|
|
129
|
+
|
|
130
|
+
const host = document.createElement("div");
|
|
131
|
+
host.id = "dek-annotate";
|
|
132
|
+
const root = host.attachShadow({ mode: "open" });
|
|
133
|
+
root.innerHTML = `<style>${STYLE}</style>
|
|
134
|
+
<div data-part="layer" hidden></div>
|
|
135
|
+
<div data-part="hover" class="mono" hidden></div>
|
|
136
|
+
<div data-part="picks"></div>
|
|
137
|
+
<div data-part="markers"></div>
|
|
138
|
+
<form data-part="popup" hidden>
|
|
139
|
+
<div data-part="choices" class="mono">
|
|
140
|
+
<nav data-part="path" aria-label="Elements under the click"></nav>
|
|
141
|
+
<div data-part="near" aria-label="Elements near the click"></div>
|
|
142
|
+
</div>
|
|
143
|
+
<ul data-part="picked" class="mono"></ul>
|
|
144
|
+
<textarea placeholder="${LABELS.placeholder}" aria-label="${LABELS.placeholder}"></textarea>
|
|
145
|
+
<div data-part="actions">
|
|
146
|
+
<span data-part="hint" class="mono">${LABELS.addHint}</span>
|
|
147
|
+
<button type="button" data-part="delete">${LABELS.delete}</button>
|
|
148
|
+
<button type="button" data-part="cancel">${LABELS.cancel} <kbd>esc</kbd></button>
|
|
149
|
+
<button type="submit" data-part="add"><span>${LABELS.add}</span> <kbd>↵</kbd></button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
<div data-part="bar" hidden>
|
|
153
|
+
<div data-part="tray" hidden>
|
|
154
|
+
<textarea data-part="manual" class="mono" readonly hidden></textarea>
|
|
155
|
+
<div data-part="report">
|
|
156
|
+
<p data-part="status" role="status"></p>
|
|
157
|
+
<button type="button" data-part="undo" hidden>${LABELS.undo}</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div data-part="pill">
|
|
161
|
+
<button type="button" data-part="copy">${icon("copy")}${LABELS.copy}</button>
|
|
162
|
+
<span data-part="count"></span>
|
|
163
|
+
<button type="button" data-part="clear" aria-label="${LABELS.clear}" title="${LABELS.clear}">${icon("trash")}</button>
|
|
164
|
+
<span data-part="divider" aria-hidden="true"></span>
|
|
165
|
+
<button type="button" data-part="close" aria-label="${LABELS.close}" title="${LABELS.close}">${icon("close")}</button>
|
|
166
|
+
</div>
|
|
167
|
+
</div>`;
|
|
168
|
+
document.body.append(host);
|
|
169
|
+
|
|
170
|
+
const part = <T extends HTMLElement = HTMLElement>(name: string): T =>
|
|
171
|
+
root.querySelector(`[data-part=${name}]`) as T;
|
|
172
|
+
const layer = part("layer");
|
|
173
|
+
const hover = part("hover");
|
|
174
|
+
const popup = part<HTMLFormElement>("popup");
|
|
175
|
+
const field = popup.querySelector("textarea") as HTMLTextAreaElement;
|
|
176
|
+
const manual = part<HTMLTextAreaElement>("manual");
|
|
177
|
+
const status = part("status");
|
|
178
|
+
const tray = part("tray");
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Say above the bar how Copy or Clear went: with the text to copy by hand when Copy could not,
|
|
182
|
+
* and with Undo after Clear. Clear is undone rather than confirmed, and only until something
|
|
183
|
+
* else is said here, a note is written, or the mode is left.
|
|
184
|
+
*/
|
|
185
|
+
function report(text: string, extra: { byHand?: string; undo?: Note[] } = {}): void {
|
|
186
|
+
status.textContent = text;
|
|
187
|
+
manual.hidden = extra.byHand === undefined;
|
|
188
|
+
manual.value = extra.byHand ?? "";
|
|
189
|
+
cleared = extra.undo;
|
|
190
|
+
part("undo").hidden = cleared === undefined;
|
|
191
|
+
tray.hidden = text === "";
|
|
192
|
+
}
|
|
193
|
+
const hitStyle = document.createElement("style");
|
|
194
|
+
// A decorative arrow often takes no pointer; while annotating, every element of the deck does.
|
|
195
|
+
// The rail's copies carry the attribute too, so the rule stays inside the deck.
|
|
196
|
+
hitStyle.textContent = `#${PAGE_ID.deck} [${SOURCE_ATTR}] { pointer-events: auto !important; }`;
|
|
197
|
+
|
|
198
|
+
let on = false;
|
|
199
|
+
let notes = load();
|
|
200
|
+
/** What the last Clear took away, while Undo can still bring it back. */
|
|
201
|
+
let cleared: Note[] | undefined;
|
|
202
|
+
let picks: Pick[] = [];
|
|
203
|
+
let choiceButtons = new Map<Element, HTMLButtonElement>();
|
|
204
|
+
let editing: Note | undefined;
|
|
205
|
+
let hovered: Element | undefined;
|
|
206
|
+
let frame: number | undefined;
|
|
207
|
+
// Hit testing tries dozens of points; the pointer is followed at most once a frame.
|
|
208
|
+
let hoverTested = false;
|
|
209
|
+
let hoverPending: Point | undefined;
|
|
210
|
+
|
|
211
|
+
function load(): Note[] {
|
|
212
|
+
try {
|
|
213
|
+
return readNotes(sessionStorage.getItem(storageKey));
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function save(): void {
|
|
220
|
+
try {
|
|
221
|
+
sessionStorage.setItem(storageKey, writeNotes(notes));
|
|
222
|
+
} catch {
|
|
223
|
+
// Storage blocked: the notes last as long as the page.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The slide on screen, its beat, and its section in the deck. */
|
|
228
|
+
function onScreen(): { slug: string; step: string; slide: Element | null } {
|
|
229
|
+
const stop = currentStop(location.hash, slides);
|
|
230
|
+
const slug = slides[stop.slideIndex]?.slug ?? "";
|
|
231
|
+
return { slug, step: stop.step, slide: document.querySelector(slideSelector(slug)) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function setOn(next: boolean): void {
|
|
235
|
+
on = next;
|
|
236
|
+
toggleEl?.setAttribute("aria-pressed", String(on));
|
|
237
|
+
layer.hidden = !on;
|
|
238
|
+
part("bar").hidden = !on;
|
|
239
|
+
report("");
|
|
240
|
+
if (on) {
|
|
241
|
+
document.head.append(hitStyle);
|
|
242
|
+
tick();
|
|
243
|
+
} else {
|
|
244
|
+
hitStyle.remove();
|
|
245
|
+
closePopup();
|
|
246
|
+
hovered = undefined;
|
|
247
|
+
if (frame !== undefined) {
|
|
248
|
+
cancelAnimationFrame(frame);
|
|
249
|
+
frame = undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
rebuild();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Follow what moves on the slide while the mode is on: entrances, morphs, a resized window. */
|
|
256
|
+
function tick(): void {
|
|
257
|
+
hoverTested = false;
|
|
258
|
+
if (hoverPending) {
|
|
259
|
+
hoverAt(hoverPending);
|
|
260
|
+
}
|
|
261
|
+
layout();
|
|
262
|
+
frame = requestAnimationFrame(tick);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function hoverAt(at: Point): void {
|
|
266
|
+
hoverTested = true;
|
|
267
|
+
hoverPending = undefined;
|
|
268
|
+
const [first] = candidates(at);
|
|
269
|
+
if (first !== hovered) {
|
|
270
|
+
hovered = first;
|
|
271
|
+
rebuild();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function elementsAt(x: number, y: number): Element[] {
|
|
276
|
+
return document.elementsFromPoint(x, y);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function candidates(at: Point): Element[] {
|
|
280
|
+
const { slide } = onScreen();
|
|
281
|
+
return slide ? candidatesAt(at, slide, elementsAt) : [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** An element as a note names it: its name, its text, and its line, as Copy writes them. */
|
|
285
|
+
function label(target: { name: string; text: string; source: string }): string {
|
|
286
|
+
const text = target.text === "" ? "" : ` ${JSON.stringify(target.text)}`;
|
|
287
|
+
return `${target.name}${text} :${target.source.split(":")[0]}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function describe(el: Element): { name: string; text: string; source: string } {
|
|
291
|
+
return describeElement(el, el.closest(".slide") ?? el);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function place(
|
|
295
|
+
el: HTMLElement,
|
|
296
|
+
rect: { left: number; top: number; width: number; height: number },
|
|
297
|
+
): void {
|
|
298
|
+
el.style.left = `${rect.left}px`;
|
|
299
|
+
el.style.top = `${rect.top}px`;
|
|
300
|
+
el.style.width = `${rect.width}px`;
|
|
301
|
+
el.style.height = `${rect.height}px`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Put every outline and marker where its element is now. */
|
|
305
|
+
function layout(): void {
|
|
306
|
+
if (!on) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const stage = stageEl?.getBoundingClientRect() ?? new DOMRect();
|
|
310
|
+
place(layer, stage);
|
|
311
|
+
// The bar sits at the foot of the slide, whatever the rail or the presenter view take.
|
|
312
|
+
const bar = part("bar");
|
|
313
|
+
bar.style.left = `${stage.left + stage.width / 2}px`;
|
|
314
|
+
bar.style.top = `${stage.bottom - 12}px`;
|
|
315
|
+
hover.hidden = !hovered;
|
|
316
|
+
if (hovered) {
|
|
317
|
+
place(hover, hovered.getBoundingClientRect());
|
|
318
|
+
}
|
|
319
|
+
const outlines = part("picks").children;
|
|
320
|
+
picks.forEach((pick, index) => {
|
|
321
|
+
const outline = outlines[index];
|
|
322
|
+
if (outline instanceof HTMLElement) {
|
|
323
|
+
place(outline, pick.el.getBoundingClientRect());
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
for (const marker of part("markers").querySelectorAll<HTMLElement>("[data-part=marker]")) {
|
|
327
|
+
const tip = pinAt(notes[Number(marker.dataset.note)]);
|
|
328
|
+
marker.hidden = !tip;
|
|
329
|
+
if (tip) {
|
|
330
|
+
marker.style.left = `${tip.x}px`;
|
|
331
|
+
marker.style.top = `${tip.y}px`;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Where a note's pin points: the top right corner of its first element, or the place clicked
|
|
338
|
+
* when the note is about a place on the slide rather than an element.
|
|
339
|
+
*/
|
|
340
|
+
function pinAt(note: Note | undefined): Point | undefined {
|
|
341
|
+
const anchor = anchorOf(note);
|
|
342
|
+
const point = note?.targets[0]?.point;
|
|
343
|
+
if (!anchor) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
if (point && anchor.matches(".slide")) {
|
|
347
|
+
return windowPoint(point, deckEl as HTMLElement);
|
|
348
|
+
}
|
|
349
|
+
const rect = anchor.getBoundingClientRect();
|
|
350
|
+
return { x: rect.right, y: rect.top };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The element a note's marker sits on: its first element, on the slide on screen. */
|
|
354
|
+
function anchorOf(note: Note | undefined): Element | null {
|
|
355
|
+
const { slug, slide } = onScreen();
|
|
356
|
+
const first = note?.targets[0];
|
|
357
|
+
if (!note || note.stale || note.slug !== slug || !first || !slide) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
return slide.getAttribute(SOURCE_ATTR) === first.source
|
|
361
|
+
? slide
|
|
362
|
+
: slide.querySelector(`[${SOURCE_ATTR}="${first.source}"]`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Redraw what the state is made of: the count, the picks, the choices, and the markers. */
|
|
366
|
+
function rebuild(): void {
|
|
367
|
+
part("count").textContent = LABELS.notes(notes.length);
|
|
368
|
+
part<HTMLButtonElement>("copy").disabled = notes.length === 0;
|
|
369
|
+
part<HTMLButtonElement>("clear").disabled = notes.length === 0;
|
|
370
|
+
part("picks").replaceChildren(
|
|
371
|
+
...picks.map(() => {
|
|
372
|
+
const outline = document.createElement("div");
|
|
373
|
+
outline.dataset.part = "pick";
|
|
374
|
+
return outline;
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
const last = picks.at(-1)?.el;
|
|
378
|
+
for (const [el, button] of choiceButtons) {
|
|
379
|
+
button.setAttribute("aria-pressed", String(el === last));
|
|
380
|
+
}
|
|
381
|
+
const picked = editing ? editing.targets : picks.map((pick) => describe(pick.el));
|
|
382
|
+
part("picked").replaceChildren(
|
|
383
|
+
...picked.map((target) => {
|
|
384
|
+
const item = document.createElement("li");
|
|
385
|
+
item.textContent = label(target);
|
|
386
|
+
return item;
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
// Numbered as Copy numbers them, so a marker and its note in the chat say the same number.
|
|
390
|
+
const markers = on
|
|
391
|
+
? sortNotes(slides, notes).flatMap((note, index) => {
|
|
392
|
+
if (!anchorOf(note)) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
const marker = document.createElement("button");
|
|
396
|
+
marker.type = "button";
|
|
397
|
+
marker.dataset.part = "marker";
|
|
398
|
+
marker.dataset.note = String(notes.indexOf(note));
|
|
399
|
+
marker.textContent = String(index + 1);
|
|
400
|
+
marker.addEventListener("click", () => edit(note));
|
|
401
|
+
return [marker];
|
|
402
|
+
})
|
|
403
|
+
: [];
|
|
404
|
+
part("markers").replaceChildren(...markers);
|
|
405
|
+
hover.replaceChildren();
|
|
406
|
+
if (hovered) {
|
|
407
|
+
// Its name, its size on the slide, and its line, as a browser's inspector shows them.
|
|
408
|
+
const { name, source } = describe(hovered);
|
|
409
|
+
const { width, height } = slideBox(hovered, deckEl as HTMLElement);
|
|
410
|
+
const tag = document.createElement("span");
|
|
411
|
+
const size = document.createElement("i");
|
|
412
|
+
size.textContent = `${width}×${height}`;
|
|
413
|
+
tag.append(`${name} `, size, ` :${source.split(":")[0]}`);
|
|
414
|
+
hover.append(tag);
|
|
415
|
+
}
|
|
416
|
+
layout();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function openPopup(): void {
|
|
420
|
+
popup.hidden = false;
|
|
421
|
+
part("delete").hidden = editing === undefined;
|
|
422
|
+
part("hint").hidden = editing !== undefined;
|
|
423
|
+
(part("add").firstElementChild as HTMLElement).textContent = editing ? LABELS.save : LABELS.add;
|
|
424
|
+
placeOpenPopup();
|
|
425
|
+
field.focus();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Put the popup beside what the note is about, so it never hides what is pointed at. */
|
|
429
|
+
function placeOpenPopup(): void {
|
|
430
|
+
const rects = editing
|
|
431
|
+
? [anchorOf(editing)?.getBoundingClientRect()].filter((rect) => rect !== undefined)
|
|
432
|
+
: picks.map((pick) => pick.el.getBoundingClientRect());
|
|
433
|
+
if (rects.length === 0) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const picked = {
|
|
437
|
+
left: Math.min(...rects.map((rect) => rect.left)),
|
|
438
|
+
top: Math.min(...rects.map((rect) => rect.top)),
|
|
439
|
+
right: Math.max(...rects.map((rect) => rect.right)),
|
|
440
|
+
bottom: Math.max(...rects.map((rect) => rect.bottom)),
|
|
441
|
+
};
|
|
442
|
+
const { width, height } = popup.getBoundingClientRect();
|
|
443
|
+
const at = placePopup(picked, { width, height }, { width: innerWidth, height: innerHeight });
|
|
444
|
+
popup.style.left = `${at.left}px`;
|
|
445
|
+
popup.style.top = `${at.top}px`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function closePopup(): void {
|
|
449
|
+
popup.hidden = true;
|
|
450
|
+
picks = [];
|
|
451
|
+
showChoices([]);
|
|
452
|
+
editing = undefined;
|
|
453
|
+
field.value = "";
|
|
454
|
+
rebuild();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Offer the elements under a click: the one it hit and what that is inside, as a path from the
|
|
459
|
+
* slide in, and apart from it whatever else is under or near the click. None once put away.
|
|
460
|
+
*/
|
|
461
|
+
function showChoices(found: Element[]): void {
|
|
462
|
+
const [first] = found;
|
|
463
|
+
const path: Element[] = [];
|
|
464
|
+
for (let el: Element | null = first ?? null; el; el = el.parentElement) {
|
|
465
|
+
if (found.includes(el)) {
|
|
466
|
+
path.unshift(el);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const near = found.filter((el) => !path.includes(el));
|
|
470
|
+
choiceButtons = new Map();
|
|
471
|
+
const chips = (elements: Element[], between: string): Node[] =>
|
|
472
|
+
elements.flatMap((el, index) => {
|
|
473
|
+
const button = document.createElement("button");
|
|
474
|
+
button.type = "button";
|
|
475
|
+
button.textContent = describe(el).name;
|
|
476
|
+
button.title = label(describe(el));
|
|
477
|
+
button.addEventListener("click", () => choose(el));
|
|
478
|
+
choiceButtons.set(el, button);
|
|
479
|
+
return index === 0 ? [button] : [separator(between), button];
|
|
480
|
+
});
|
|
481
|
+
// A path reads from the slide in; what is near is only a list.
|
|
482
|
+
part("path").replaceChildren(...chips(path, "›"));
|
|
483
|
+
part("near").replaceChildren(
|
|
484
|
+
...(near.length > 0 ? [separator(LABELS.near), ...chips(near, "·")] : []),
|
|
485
|
+
);
|
|
486
|
+
part("near").hidden = near.length === 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function separator(text: string): HTMLElement {
|
|
490
|
+
const span = document.createElement("span");
|
|
491
|
+
span.textContent = text;
|
|
492
|
+
span.setAttribute("aria-hidden", "true");
|
|
493
|
+
return span;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Make `el`, one of the elements under the last click, the one that click picked. */
|
|
497
|
+
function choose(el: Element): void {
|
|
498
|
+
const last = picks.at(-1);
|
|
499
|
+
if (last) {
|
|
500
|
+
picks = [...picks.slice(0, -1), { el, at: last.at }];
|
|
501
|
+
}
|
|
502
|
+
rebuild();
|
|
503
|
+
placeOpenPopup();
|
|
504
|
+
field.focus();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function pickAt(at: Point, adding: boolean): void {
|
|
508
|
+
const found = candidates(at);
|
|
509
|
+
const [first] = found;
|
|
510
|
+
if (!first) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (editing) {
|
|
514
|
+
closePopup();
|
|
515
|
+
}
|
|
516
|
+
if (adding && !popup.hidden) {
|
|
517
|
+
const already = picks.some((pick) => pick.el === first);
|
|
518
|
+
picks = already ? picks.filter((pick) => pick.el !== first) : [...picks, { el: first, at }];
|
|
519
|
+
if (picks.length === 0) {
|
|
520
|
+
closePopup();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
picks = [{ el: first, at }];
|
|
525
|
+
}
|
|
526
|
+
showChoices(found);
|
|
527
|
+
rebuild();
|
|
528
|
+
openPopup();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function edit(note: Note): void {
|
|
532
|
+
closePopup();
|
|
533
|
+
editing = note;
|
|
534
|
+
field.value = note.text;
|
|
535
|
+
openPopup();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function snapshot(pick: Pick, slide: Element): NoteTarget {
|
|
539
|
+
const box = slideBox(pick.el, deckEl as HTMLElement);
|
|
540
|
+
const target: NoteTarget = { ...describeElement(pick.el, slide), box };
|
|
541
|
+
return pick.el === slide
|
|
542
|
+
? { ...target, point: slidePoint(pick.at, deckEl as HTMLElement) }
|
|
543
|
+
: target;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function submit(): void {
|
|
547
|
+
const text = field.value;
|
|
548
|
+
if (editing) {
|
|
549
|
+
const target = editing;
|
|
550
|
+
notes = notes.map((note) => (note === target ? { ...note, text } : note));
|
|
551
|
+
} else {
|
|
552
|
+
const { slug, step, slide } = onScreen();
|
|
553
|
+
if (!slide || picks.length === 0) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
notes = [...notes, { slug, step, targets: picks.map((pick) => snapshot(pick, slide)), text }];
|
|
557
|
+
}
|
|
558
|
+
if (cleared) {
|
|
559
|
+
report("");
|
|
560
|
+
}
|
|
561
|
+
save();
|
|
562
|
+
closePopup();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function remove(): void {
|
|
566
|
+
const target = editing;
|
|
567
|
+
notes = notes.filter((note) => note !== target);
|
|
568
|
+
save();
|
|
569
|
+
closePopup();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function copy(): Promise<void> {
|
|
573
|
+
const text = formatNotes(deck, slides, notes);
|
|
574
|
+
try {
|
|
575
|
+
// Missing on a page served over plain HTTP, such as the LAN address.
|
|
576
|
+
if (!navigator.clipboard) {
|
|
577
|
+
throw new Error("no clipboard");
|
|
578
|
+
}
|
|
579
|
+
await navigator.clipboard.writeText(text);
|
|
580
|
+
report(LABELS.copied(notes.length));
|
|
581
|
+
} catch {
|
|
582
|
+
report(LABELS.manual, { byHand: text });
|
|
583
|
+
manual.focus();
|
|
584
|
+
manual.select();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Match the notes on a slide an edit replaced to its elements as they are now; a note that
|
|
590
|
+
* cannot be matched keeps what it said, marked as written before the edit.
|
|
591
|
+
*/
|
|
592
|
+
function follow(slug: string): void {
|
|
593
|
+
const slide = document.querySelector(slideSelector(slug));
|
|
594
|
+
if (!slide) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const present = [slide, ...slide.querySelectorAll(`[${SOURCE_ATTR}]`)].map((el) =>
|
|
598
|
+
describeElement(el, slide),
|
|
599
|
+
);
|
|
600
|
+
notes = notes.map((note) => {
|
|
601
|
+
if (note.slug !== slug) {
|
|
602
|
+
return note;
|
|
603
|
+
}
|
|
604
|
+
const found = note.targets.map((target) => reanchor(target, present));
|
|
605
|
+
if (found.some((el) => el === undefined)) {
|
|
606
|
+
return { ...note, stale: true };
|
|
607
|
+
}
|
|
608
|
+
const { stale: _, ...rest } = note;
|
|
609
|
+
return {
|
|
610
|
+
...rest,
|
|
611
|
+
targets: note.targets.map((target, index) => ({ ...target, ...found[index] })),
|
|
612
|
+
};
|
|
613
|
+
});
|
|
614
|
+
save();
|
|
615
|
+
rebuild();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Holding Cmd or Ctrl to add an element lets clicks through the popup, which may lie over it.
|
|
619
|
+
// Heard on the way down, before a field of the view keeps its keys to itself.
|
|
620
|
+
const through = (event: KeyboardEvent): void => {
|
|
621
|
+
if (event.key === "Meta" || event.key === "Control") {
|
|
622
|
+
popup.toggleAttribute("data-through", event.type === "keydown");
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
addEventListener("keydown", through, true);
|
|
626
|
+
addEventListener("keyup", through, true);
|
|
627
|
+
addEventListener("blur", () => popup.removeAttribute("data-through"));
|
|
628
|
+
|
|
629
|
+
// The player hears every key too; `a` and Escape are not among its own.
|
|
630
|
+
document.addEventListener("keydown", (event) => {
|
|
631
|
+
if (isTextEntry(event.target)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (isLetterKey(event, "a")) {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
setOn(!on);
|
|
637
|
+
} else if (event.key === "Escape" && on) {
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
if (popup.hidden) {
|
|
640
|
+
setOn(false);
|
|
641
|
+
} else {
|
|
642
|
+
closePopup();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
// Keys typed into the note are the note's: the player hears a key from inside this view as
|
|
647
|
+
// one on the view's host, which is not a text field, and would move the deck on a space.
|
|
648
|
+
root.addEventListener("keydown", (event) => {
|
|
649
|
+
if (!(event instanceof KeyboardEvent) || !isTextEntry(event.target)) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
event.stopPropagation();
|
|
653
|
+
if (event.target !== field) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
submit();
|
|
659
|
+
} else if (event.key === "Escape") {
|
|
660
|
+
event.preventDefault();
|
|
661
|
+
closePopup();
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
popup.addEventListener("submit", (event) => {
|
|
665
|
+
event.preventDefault();
|
|
666
|
+
submit();
|
|
667
|
+
});
|
|
668
|
+
layer.addEventListener("pointermove", (event) => {
|
|
669
|
+
const at = { x: event.clientX, y: event.clientY };
|
|
670
|
+
if (hoverTested) {
|
|
671
|
+
hoverPending = at;
|
|
672
|
+
} else {
|
|
673
|
+
hoverAt(at);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
layer.addEventListener("pointerleave", () => {
|
|
677
|
+
hoverPending = undefined;
|
|
678
|
+
hovered = undefined;
|
|
679
|
+
rebuild();
|
|
680
|
+
});
|
|
681
|
+
layer.addEventListener("click", (event) => {
|
|
682
|
+
pickAt({ x: event.clientX, y: event.clientY }, event.metaKey || event.ctrlKey);
|
|
683
|
+
});
|
|
684
|
+
const act = (name: string, fn: () => void): void => {
|
|
685
|
+
part(name).addEventListener("click", (event) => {
|
|
686
|
+
// A button left focused would take the next space, which the deck is for.
|
|
687
|
+
(event.currentTarget as HTMLElement).blur();
|
|
688
|
+
fn();
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
act("cancel", closePopup);
|
|
692
|
+
act("delete", remove);
|
|
693
|
+
act("copy", () => void copy());
|
|
694
|
+
act("clear", () => {
|
|
695
|
+
const gone = notes;
|
|
696
|
+
notes = [];
|
|
697
|
+
save();
|
|
698
|
+
rebuild();
|
|
699
|
+
report(LABELS.cleared(gone.length), { undo: gone });
|
|
700
|
+
});
|
|
701
|
+
act("undo", () => {
|
|
702
|
+
notes = cleared ?? notes;
|
|
703
|
+
save();
|
|
704
|
+
rebuild();
|
|
705
|
+
report("");
|
|
706
|
+
});
|
|
707
|
+
act("close", () => setOn(false));
|
|
708
|
+
toggleEl?.addEventListener("click", () => setOn(!on));
|
|
709
|
+
addEventListener("hashchange", () => {
|
|
710
|
+
if (!popup.hidden && !editing) {
|
|
711
|
+
closePopup();
|
|
712
|
+
}
|
|
713
|
+
rebuild();
|
|
714
|
+
});
|
|
715
|
+
// A live update replaces a slide's section whole; its notes are matched to the new one.
|
|
716
|
+
new MutationObserver((records) => {
|
|
717
|
+
const slugs = new Set(
|
|
718
|
+
records.flatMap((record) =>
|
|
719
|
+
[...record.addedNodes].flatMap((node) =>
|
|
720
|
+
node instanceof Element && node.matches(".slide")
|
|
721
|
+
? [node.getAttribute("data-slug") ?? ""]
|
|
722
|
+
: [],
|
|
723
|
+
),
|
|
724
|
+
),
|
|
725
|
+
);
|
|
726
|
+
// What was picked for the note being written went with the old section.
|
|
727
|
+
if (picks.some((pick) => !pick.el.isConnected)) {
|
|
728
|
+
closePopup();
|
|
729
|
+
}
|
|
730
|
+
for (const slug of slugs) {
|
|
731
|
+
follow(slug);
|
|
732
|
+
}
|
|
733
|
+
}).observe(deckEl, { childList: true });
|
|
734
|
+
for (const slug of new Set(notes.map((note) => note.slug))) {
|
|
735
|
+
follow(slug);
|
|
736
|
+
}
|
|
737
|
+
rebuild();
|
|
738
|
+
}
|