pindeck 0.0.0-stage → 2.8.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.
@@ -0,0 +1,963 @@
1
+ /* Editor bridge, injected into the deck page by the editor server (never saved to the deck).
2
+
3
+ Modes (?editor=...):
4
+ main the visible slide in the editor: navigation, selection, drag and resize, text editing, pins
5
+ measure hidden 1280x720 copy used to measure any slide without moving the user's view
6
+ shot headless screenshot of one slide, with pin markers
7
+ dump headless measurement; writes the result into the DOM for --dump-dom */
8
+
9
+ (function () {
10
+ const params = new URLSearchParams(location.search);
11
+ const MODE = params.get("editor");
12
+ // A deck describes itself with <meta name="pindeck:footer-top"> (no footer check without it) and
13
+ // <meta name="pindeck:locked"> (brand elements that cannot be selected or moved).
14
+ const deckMeta = (n) => document.querySelector(`meta[name="pindeck:${n}"]`)?.content?.trim();
15
+ const FOOTER_TOP = deckMeta("footer-top") ? +deckMeta("footer-top") : null;
16
+ const LOCKED_SEL = deckMeta("locked") || ".slide-foot, .logo";
17
+ const host = () => (window.parent !== window ? window.parent.__editorHost : null);
18
+ let W = 1280, H = 720;
19
+
20
+ /* ---- Styles for editor-only overlays ---------------------------------------- */
21
+ const css = document.createElement("style");
22
+ // --u = screen pixels per slide pixel, inverted: overlays keep a constant on-screen size at any zoom
23
+ css.textContent = `
24
+ .__pin { position:absolute; z-index:1000; width:calc(26px*var(--u,1)); height:calc(26px*var(--u,1));
25
+ margin:calc(-13px*var(--u,1)) 0 0 calc(-13px*var(--u,1)); border-radius:50%;
26
+ background:#ED8C01; color:#fff; font:700 calc(14px*var(--u,1))/calc(26px*var(--u,1)) system-ui, sans-serif; text-align:center;
27
+ box-shadow:0 0 0 calc(2px*var(--u,1)) #fff, 0 calc(2px*var(--u,1)) calc(6px*var(--u,1)) rgba(0,0,0,.45);
28
+ cursor:pointer; pointer-events:auto; }
29
+ .__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--u,1)) dashed #ED8C01;
30
+ outline-offset:calc(2px*var(--u,1)); background:rgba(237,140,1,.06); }
31
+ .__hover span { position:absolute; left:calc(-2px*var(--u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
32
+ font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
33
+ border-radius:calc(3px*var(--u,1)); }
34
+ .__hover span.above { top:calc(-21px*var(--u,1)); } .__hover span.below { bottom:calc(-21px*var(--u,1)); }
35
+ .__hover.locked { outline-color:#888; background:rgba(0,0,0,.04); }
36
+ .__hover.locked span { background:#888; }
37
+ .__flash { position:absolute; z-index:998; pointer-events:none; outline:calc(3px*var(--u,1)) solid #ED8C01;
38
+ outline-offset:calc(3px*var(--u,1)); animation:__flash 1.4s ease-out forwards; }
39
+ @keyframes __flash { 0%,60% { opacity:1 } 100% { opacity:0 } }
40
+ html.__pinmode .reveal .slides section, html.__pinmode .reveal .slides section * { cursor:crosshair !important; }
41
+ html.__pinmode .reveal .slides section .__pin { cursor:pointer !important; }
42
+ .__hover.sel { outline:calc(1px*var(--u,1)) solid #1a73e8; outline-offset:0; background:none; }
43
+ .__hover.sel span { background:#1a73e8; }
44
+ .__sel { position:absolute; z-index:1001; pointer-events:none; outline:calc(1.5px*var(--u,1)) solid #1a73e8; }
45
+ .__sel i { position:absolute; width:calc(9px*var(--u,1)); height:calc(9px*var(--u,1)); margin:calc(-5px*var(--u,1)) 0 0 calc(-5px*var(--u,1));
46
+ background:#fff; border:calc(1.5px*var(--u,1)) solid #1a73e8; box-sizing:border-box; pointer-events:auto; }
47
+ .__sel i[data-h="nw"] { left:0; top:0; cursor:nwse-resize; } .__sel i[data-h="n"] { left:50%; top:0; cursor:ns-resize; }
48
+ .__sel i[data-h="ne"] { left:100%; top:0; cursor:nesw-resize; } .__sel i[data-h="e"] { left:100%; top:50%; cursor:ew-resize; }
49
+ .__sel i[data-h="se"] { left:100%; top:100%; cursor:nwse-resize; } .__sel i[data-h="s"] { left:50%; top:100%; cursor:ns-resize; }
50
+ .__sel i[data-h="sw"] { left:0; top:100%; cursor:nesw-resize; } .__sel i[data-h="w"] { left:0; top:50%; cursor:ew-resize; }
51
+ .__sel.readonly i { display:none; }
52
+ .__sel-label { position:absolute; left:calc(-1.5px*var(--u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
53
+ font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
54
+ border-radius:calc(3px*var(--u,1)); font-variant-numeric:tabular-nums; }
55
+ .__sel-label.above { top:calc(-21px*var(--u,1)); } .__sel-label.below { bottom:calc(-21px*var(--u,1)); }
56
+ .__guide { position:absolute; z-index:1002; pointer-events:none; background:#e5399d; }
57
+ .__guide.v { top:0; height:100%; width:calc(1px*var(--u,1)); }
58
+ .__guide.h { left:0; width:100%; height:calc(1px*var(--u,1)); }
59
+ html.__selmode .reveal .slides section * { cursor:default; }
60
+ html.__selmode .reveal .slides section .__selected, html.__selmode.__dragging * { cursor:move !important; }
61
+ html.__selmode .reveal .slides section .__sel i { cursor:inherit; }
62
+ .__editing { outline:calc(2px*var(--u,1)) solid #1a73e8 !important; outline-offset:calc(3px*var(--u,1));
63
+ background:rgba(26,115,232,.04); cursor:text !important; caret-color:#1a73e8; }
64
+ .__editing * { cursor:text !important; }
65
+ .__hidden-banner { position:absolute; z-index:997; top:calc(26px*var(--u,1)); right:calc(26px*var(--u,1)); pointer-events:none;
66
+ background:#ED8C01; color:#fff; font:600 calc(12px*var(--u,1))/calc(22px*var(--u,1)) system-ui, sans-serif;
67
+ padding:0 calc(10px*var(--u,1)); border-radius:calc(11px*var(--u,1)); box-shadow:0 calc(2px*var(--u,1)) calc(8px*var(--u,1)) rgba(0,0,0,.25); }
68
+ .__edit-hint { position:absolute; z-index:1003; pointer-events:none; white-space:nowrap; background:#1a73e8; color:#fff;
69
+ font:600 calc(11px*var(--u,1))/calc(17px*var(--u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--u,1));
70
+ border-radius:calc(3px*var(--u,1)); }`;
71
+ document.head.appendChild(css);
72
+
73
+ /* ---- Geometry ------------------------------------------------------------------ */
74
+ const slides = () => Reveal.getSlides();
75
+ const indexOf = (sec) => slides().indexOf(sec);
76
+ const r1 = (v) => Math.round(v);
77
+ function frame(sec) {
78
+ const r = sec.getBoundingClientRect();
79
+ return { r, s: r.width / W || 1 };
80
+ }
81
+ function boxOf(el, sec) {
82
+ const { r, s } = frame(sec);
83
+ const b = el.getBoundingClientRect();
84
+ return { x: r1((b.left - r.left) / s), y: r1((b.top - r.top) / s), w: r1(b.width / s), h: r1(b.height / s) };
85
+ }
86
+ const byEid = (eid, root = document) => root.querySelector(`[data-eid="${CSS.escape(eid)}"]`);
87
+
88
+ /* Visible text of an element, without KaTeX's hidden MathML copy. */
89
+ function textOf(el, ownOnly) {
90
+ if (el.tagName === "IMG") return el.getAttribute("alt") || (el.getAttribute("src") || "").split("/").pop();
91
+ let out = "";
92
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
93
+ acceptNode(n) {
94
+ const p = n.parentElement;
95
+ if (!p || p.closest(".katex-mathml, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
96
+ if (ownOnly) { const owner = p.closest("[data-eid]"); if (owner !== el) return NodeFilter.FILTER_REJECT; }
97
+ return NodeFilter.FILTER_ACCEPT;
98
+ },
99
+ });
100
+ while (walker.nextNode()) {
101
+ const n = walker.currentNode;
102
+ out += n.nodeValue + (n.parentElement.closest(".katex") ? "" : " "); // KaTeX splits math into one span per glyph
103
+ }
104
+ return out.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
105
+ }
106
+ function fontOf(el) {
107
+ const cs = getComputedStyle(el);
108
+ return `${Math.round(parseFloat(cs.fontSize))}px${+cs.fontWeight >= 600 ? " bold" : ""}`;
109
+ }
110
+ function slideInfo(sec, i = indexOf(sec)) {
111
+ const head = sec.querySelector("h1, h2, h3");
112
+ let title = "", sub = "";
113
+ if (head) {
114
+ const subEl = head.querySelector(".sub");
115
+ sub = subEl ? textOf(subEl) : "";
116
+ title = textOf(head);
117
+ if (sub && title.endsWith(sub)) title = title.slice(0, -sub.length).trim();
118
+ }
119
+ const kind = [...sec.classList].find((c) => /-slide$/.test(c)) || "content";
120
+ const hidden = sec.getAttribute("data-visibility") === "hidden";
121
+ return { index: i, eid: sec.dataset.eid || `#${i + 1}`, title: title || sec.dataset.eid || `Slide ${i + 1}`, sub, kind,
122
+ hasFooter: !!sec.querySelector(".slide-foot"), hidden };
123
+ }
124
+ function info() {
125
+ return { slides: slides().map((s, i) => slideInfo(s, i)), current: indexOf(Reveal.getCurrentSlide()) };
126
+ }
127
+
128
+ /* ---- Layout measurement ------------------------------------------------------------- */
129
+ function measureSection(sec) {
130
+ const si = slideInfo(sec);
131
+ const els = [sec, ...sec.querySelectorAll("[data-eid]")];
132
+ const elements = [], meta = [];
133
+ for (const el of els) {
134
+ const cs = getComputedStyle(el);
135
+ const b = boxOf(el, sec);
136
+ let depth = 0;
137
+ for (let p = el.parentElement; p && p !== sec.parentElement; p = p.parentElement) if (p.dataset?.eid) depth++;
138
+ const hidden = cs.display === "none" || cs.visibility === "hidden" || +cs.opacity === 0 || (b.w === 0 && b.h === 0);
139
+ const leaf = !el.querySelector("[data-eid]");
140
+ const own = el === sec ? "" : textOf(el, !leaf);
141
+ const tag = el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !/^(fragment|visible|current-fragment|present|past|future)$/.test(c)).join(".") : "");
142
+ const e = { eid: el.dataset.eid || si.eid, tag: tag.replace(/\.$/, ""), depth, ...b };
143
+ if (own && el.tagName !== "IMG" && el !== sec) e.font = fontOf(el);
144
+ if (own) e.text = own;
145
+ if (hidden) e.hidden = true;
146
+ if (el !== sec && el.matches(LOCKED_SEL)) e.locked = true;
147
+ elements.push(e);
148
+ meta.push({ el, e, hidden, cs, inSvg: !!el.closest("svg") && el.tagName.toLowerCase() !== "svg" });
149
+ }
150
+
151
+ /* What an element draws, for the checks below: a plain container (no text of its own, no
152
+ background, border or shadow) draws only its children, so the empty part of a box stretched by
153
+ flex or a fixed height (a slide-body filling the slide down to the references, say) does not
154
+ count as an overlap. The layout listing keeps the real boxes. */
155
+ const MEDIA = /^(IMG|SVG|VIDEO|AUDIO|CANVAS|IFRAME|OBJECT|TABLE|PICTURE)$/i;
156
+ const paints = (cs) => cs.backgroundImage !== "none" || !/^(transparent|rgba\(.*,\s*0\))$/.test(cs.backgroundColor) ||
157
+ cs.boxShadow !== "none" || ["Top", "Right", "Bottom", "Left"].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== "none");
158
+ const drawn = new Map(); // el -> { x, y, w, h }
159
+ for (const { el, e, hidden, cs } of [...meta].reverse()) { // children before their parents
160
+ if (hidden) continue;
161
+ const box = { x: e.x, y: e.y, w: e.w, h: e.h };
162
+ if (el !== sec && !textOf(el, true) && !MEDIA.test(el.tagName) && !paints(cs)) {
163
+ const kids = meta.filter((m) => drawn.has(m.el) && m.el.parentElement?.closest("[data-eid]") === el).map((m) => drawn.get(m.el));
164
+ if (kids.length) {
165
+ const x0 = Math.min(...kids.map((k) => k.x)), y0 = Math.min(...kids.map((k) => k.y));
166
+ const x1 = Math.max(...kids.map((k) => k.x + k.w)), y1 = Math.max(...kids.map((k) => k.y + k.h));
167
+ Object.assign(box, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
168
+ }
169
+ }
170
+ drawn.set(el, box);
171
+ }
172
+
173
+ const issues = [];
174
+ const rel = (eid) => (eid.startsWith(si.eid + "/") ? eid.slice(si.eid.length) : eid);
175
+ const flagged = new Set();
176
+ const underFlagged = (el) => { for (let p = el.parentElement; p; p = p.parentElement) if (flagged.has(p)) return true; return false; };
177
+ for (const { el, e, hidden, cs } of meta) {
178
+ if (el === sec || hidden || e.locked) continue;
179
+ const d = drawn.get(el);
180
+ if (d.x < -1 || d.y < -1 || d.x + d.w > W + 1 || d.y + d.h > H + 1) {
181
+ if (!underFlagged(el)) issues.push(`${rel(e.eid)} extends outside the slide (box x=${d.x}..${d.x + d.w}, y=${d.y}..${d.y + d.h})`);
182
+ flagged.add(el);
183
+ } else if (si.hasFooter && FOOTER_TOP != null && d.y + d.h > FOOTER_TOP + 1 && d.y < H) {
184
+ if (!underFlagged(el)) issues.push(`${rel(e.eid)} reaches y=${d.y + d.h}, below the footer rule (y=${FOOTER_TOP})`);
185
+ flagged.add(el);
186
+ }
187
+ const clips = /(hidden|clip|auto|scroll)/.test(cs.overflowX + cs.overflowY);
188
+ if (clips && (el.scrollHeight > el.clientHeight + 2 || el.scrollWidth > el.clientWidth + 2))
189
+ issues.push(`${rel(e.eid)} clips its content (${Math.max(el.scrollHeight - el.clientHeight, el.scrollWidth - el.clientWidth)} px hidden)`);
190
+ }
191
+ // Overlaps between elements that are not nested in each other (SVG groups excluded: they overlap by design)
192
+ const cand = meta.filter((m) => m.el !== sec && !m.hidden && !m.e.locked && !m.inSvg && m.e.w * m.e.h > 0)
193
+ .map((m) => ({ ...m, e: { ...m.e, ...drawn.get(m.el) } })); // the overlap test uses what each element draws
194
+ let overlaps = 0;
195
+ const reported = []; // [el, el] pairs already reported
196
+ for (let i = 0; i < cand.length && overlaps < 8; i++) {
197
+ for (let j = i + 1; j < cand.length && overlaps < 8; j++) {
198
+ const a = cand[i], b = cand[j];
199
+ if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
200
+ const ix = Math.min(a.e.x + a.e.w, b.e.x + b.e.w) - Math.max(a.e.x, b.e.x);
201
+ const iy = Math.min(a.e.y + a.e.h, b.e.y + b.e.h) - Math.max(a.e.y, b.e.y);
202
+ if (ix <= 0 || iy <= 0) continue;
203
+ const area = ix * iy, small = Math.min(a.e.w * a.e.h, b.e.w * b.e.h);
204
+ if (area < 100 || area < 0.05 * small) continue;
205
+ // skip pairs whose ancestors already overlap (report the outermost pair only)
206
+ const pa = a.el.parentElement?.closest("[data-eid]"), pb = b.el.parentElement?.closest("[data-eid]");
207
+ if (pa && pb && pa !== pb && !pa.contains(pb) && !pb.contains(pa) && pa !== sec && pb !== sec) continue;
208
+ // ... and pairs inside a reported pair (the bullets of a slide-body that overlaps the references)
209
+ if (reported.some(([p, q]) => (p.contains(a.el) && q.contains(b.el)) || (p.contains(b.el) && q.contains(a.el)))) continue;
210
+ reported.push([a.el, b.el]);
211
+ issues.push(`${rel(a.e.eid)} overlaps ${rel(b.e.eid)} (${ix} x ${iy} px)`);
212
+ overlaps++;
213
+ }
214
+ }
215
+ return { slide: si, width: W, height: H, footerTop: FOOTER_TOP, elements, issues };
216
+ }
217
+ // Layout is computed synchronously on read, so no timer is needed (timers are throttled in background tabs).
218
+ const nextFrame = () => Promise.resolve();
219
+
220
+ /* ---- Navigation ------------------------------------------------------------------------ */
221
+ function goto(index) {
222
+ const sec = slides()[index];
223
+ if (!sec) return;
224
+ const ix = Reveal.getIndices(sec);
225
+ Reveal.slide(ix.h, ix.v);
226
+ }
227
+ function indices(index) { const sec = slides()[index]; return sec ? Reveal.getIndices(sec) : { h: 0, v: 0 }; }
228
+
229
+ /* ---- Pins and hover ---------------------------------------------------------------------- */
230
+ let mode = "select", pins = [], editable = true; // mode: "select" (drag, resize) or "pin"
231
+ const tagOf = (el) => el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !c.startsWith("__")).join(".") : "");
232
+ function wordAt(x, y, el) {
233
+ let node, offset;
234
+ if (document.caretPositionFromPoint) {
235
+ const p = document.caretPositionFromPoint(x, y);
236
+ if (p) { node = p.offsetNode; offset = p.offset; }
237
+ } else if (document.caretRangeFromPoint) {
238
+ const r = document.caretRangeFromPoint(x, y);
239
+ if (r) { node = r.startContainer; offset = r.startOffset; }
240
+ }
241
+ if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml")) return null;
242
+ const s = node.nodeValue;
243
+ const isW = (c) => /[\p{L}\p{N}_\-.,%×]/u.test(c);
244
+ let a = offset, b = offset;
245
+ while (a > 0 && isW(s[a - 1])) a--;
246
+ while (b < s.length && isW(s[b])) b++;
247
+ const w = s.slice(a, b).replace(/[.,]+$/, "");
248
+ return w || null;
249
+ }
250
+ function targetOf(t) {
251
+ if (!t || !t.closest) return null;
252
+ const sec = t.closest(".reveal .slides section");
253
+ if (!sec || t.closest(".__pin, .__hover, .__sel, .__guide")) return null;
254
+ const foot = t.closest(".slide-foot");
255
+ const el = foot ? sec : t.closest("[data-eid]") || sec;
256
+ const locked = foot ? "footer, set by brand.css (locked)" : el !== sec && el.matches(LOCKED_SEL) ? "positioned by brand.css (locked)" : null;
257
+ return { sec: el === sec ? sec : el.closest("section"), el, locked, foot };
258
+ }
259
+ function onPointerDown(e) {
260
+ if (MODE !== "main" || e.button !== 0) return;
261
+ if (mode === "select") return selectPointerDown(e);
262
+ e.preventDefault(); e.stopPropagation();
263
+ }
264
+ function onClick(e) {
265
+ if (MODE !== "main") return;
266
+ e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
267
+ if (mode !== "pin") return;
268
+ const marker = e.target.closest?.(".__pin");
269
+ if (marker) { host()?.removePin(+marker.dataset.n); return; }
270
+ const t = targetOf(e.target);
271
+ if (!t) return;
272
+ const { r, s } = frame(t.sec);
273
+ const point = { x: r1((e.clientX - r.left) / s), y: r1((e.clientY - r.top) / s) };
274
+ const box = t.foot ? boxOf(t.foot, t.sec) : boxOf(t.el, t.sec);
275
+ const at = { fx: box.w ? Math.min(1, Math.max(0, (point.x - box.x) / box.w)) : 0.5, fy: box.h ? Math.min(1, Math.max(0, (point.y - box.y) / box.h)) : 0.5 };
276
+ const cellEl = e.target.closest?.("td, th");
277
+ const textEl = e.target.nodeType === 1 && e.target.closest(".__pin") == null ? e.target : t.el;
278
+ const pin = {
279
+ slide: slideInfo(t.sec), eid: t.el.dataset.eid || slideInfo(t.sec).eid,
280
+ tag: t.foot ? "footer.slide-foot" : tagOf(t.el),
281
+ text: textOf(t.foot || t.el).slice(0, 200), box, point, at,
282
+ word: wordAt(e.clientX, e.clientY, t.foot || t.el), font: fontOf(textEl),
283
+ cell: cellEl ? { row: cellEl.parentElement.rowIndex, col: cellEl.cellIndex, text: textOf(cellEl) } : null,
284
+ locked: t.locked,
285
+ };
286
+ host()?.addPin(pin);
287
+ }
288
+ let hoverBox = null;
289
+ function onMove(e) {
290
+ if (MODE !== "main" || drag || editing) return hideHover();
291
+ if (mode === "select") return selectHover(e);
292
+ const t = targetOf(e.target);
293
+ if (!t) return hideHover();
294
+ if (!hoverBox) { hoverBox = document.createElement("div"); hoverBox.className = "__hover"; hoverBox.appendChild(document.createElement("span")); }
295
+ const target = t.foot || t.el;
296
+ const b = boxOf(target, t.sec);
297
+ if (hoverBox.parentElement !== t.sec) t.sec.appendChild(hoverBox);
298
+ Object.assign(hoverBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
299
+ hoverBox.classList.toggle("locked", !!t.locked);
300
+ hoverBox.classList.remove("sel");
301
+ const eid = t.foot ? "footer (locked)" : t.el.dataset.eid || "";
302
+ hoverBox.firstChild.textContent = (t.el === t.sec && !t.foot ? "slide " : "") + eid.replace(/^[^/]+\//, "/") + (t.locked && !t.foot ? " (locked)" : "");
303
+ hoverBox.firstChild.className = b.y < 30 ? "below" : "above";
304
+ }
305
+ function hideHover() { hoverBox?.remove(); }
306
+
307
+ function renderPins(list) {
308
+ pins = list || [];
309
+ document.querySelectorAll(".__pin").forEach((p) => p.remove());
310
+ for (const p of pins) {
311
+ const sec = byEid(p.slide.eid);
312
+ if (!sec || sec.tagName !== "SECTION") continue;
313
+ let x = p.point.x, y = p.point.y;
314
+ const el = p.eid !== p.slide.eid ? byEid(p.eid, sec) : null;
315
+ if (el && el.getClientRects().length) {
316
+ const b = boxOf(el, sec);
317
+ x = b.x + p.at.fx * b.w; y = b.y + p.at.fy * b.h;
318
+ }
319
+ const m = document.createElement("div");
320
+ m.className = "__pin";
321
+ m.dataset.n = p.n;
322
+ m.textContent = p.n;
323
+ m.title = "Click to remove pin " + p.n;
324
+ Object.assign(m.style, { left: x + "px", top: y + "px" });
325
+ sec.appendChild(m);
326
+ }
327
+ }
328
+ function highlight(eid) {
329
+ const el = byEid(eid);
330
+ if (!el) return;
331
+ const sec = el.tagName === "SECTION" ? el : el.closest("section");
332
+ if (sec !== Reveal.getCurrentSlide()) return;
333
+ const b = boxOf(el, sec);
334
+ const f = document.createElement("div");
335
+ f.className = "__flash";
336
+ Object.assign(f.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
337
+ sec.appendChild(f);
338
+ setTimeout(() => f.remove(), 1500);
339
+ }
340
+
341
+ /* ---- Select, drag and resize --------------------------------------------------------------------
342
+ Moves are stored as the CSS `translate` property and sizes as width/height, inline on the element.
343
+ `translate` works the same for elements in the flow and absolutely positioned ones, and leaves the
344
+ layout around the element alone. The host writes the change into index.html. */
345
+ let sel = null, selBox = null, drag = null;
346
+ const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
347
+ const KEEP_ASPECT = new Set(["img", "svg", "video", "canvas", "iframe"]);
348
+
349
+ function selectableOf(t) {
350
+ const tt = targetOf(t);
351
+ if (!tt) return null;
352
+ if (tt.foot || tt.locked) return { locked: tt.locked || "footer, set by brand.css (locked)", el: tt.foot || tt.el, sec: tt.sec };
353
+ let el = tt.el;
354
+ const svg = el.closest("svg");
355
+ if (svg && svg !== el && svg.dataset.eid) el = svg; // parts of an inline SVG move with the SVG
356
+ // inline boxes cannot be translated or sized: take the nearest block with an id
357
+ while (el && el !== tt.sec && getComputedStyle(el).display === "inline") el = el.parentElement.closest("[data-eid]");
358
+ if (!el || el === tt.sec) return null;
359
+ return { el, sec: tt.sec };
360
+ }
361
+ function translateOf(el) {
362
+ const v = getComputedStyle(el).translate;
363
+ if (!v || v === "none") return [0, 0];
364
+ const [x = "0", y = "0"] = v.split(/\s+/);
365
+ return [parseFloat(x) || 0, parseFloat(y) || 0];
366
+ }
367
+ const fmtTranslate = (x, y) => (r1(x) || r1(y) ? `${r1(x)}px ${r1(y)}px` : "");
368
+ const nameOf = (el, sec) => { const e = el.dataset.eid || ""; return e.startsWith(sec.dataset.eid + "/") ? e.slice(sec.dataset.eid.length) : e; };
369
+
370
+ function drawSel() {
371
+ if (!sel || !sel.el.isConnected) return removeSel();
372
+ if (editing) return;
373
+ if (!selBox) {
374
+ selBox = document.createElement("div");
375
+ selBox.className = "__sel";
376
+ selBox.innerHTML = '<span class="__sel-label"></span>' + HANDLES.map((h) => `<i data-h="${h}"></i>`).join("");
377
+ }
378
+ if (selBox.parentElement !== sel.sec) sel.sec.appendChild(selBox);
379
+ const b = boxOf(sel.el, sel.sec);
380
+ Object.assign(selBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
381
+ selBox.classList.toggle("readonly", !editable);
382
+ const lab = selBox.firstChild;
383
+ lab.textContent = `${nameOf(sel.el, sel.sec)} ` + (drag?.kind === "move" ? `x ${b.x} y ${b.y}` : `${b.w} × ${b.h}`);
384
+ lab.className = "__sel-label " + (b.y < 30 ? "below" : "above");
385
+ }
386
+ function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected")); }
387
+ function select(el, sec, notify = true) {
388
+ removeSel();
389
+ sel = el ? { el, sec } : null;
390
+ if (sel) { sel.el.classList.add("__selected"); drawSel(); }
391
+ if (notify) host()?.selectionChanged(sel ? selectionInfo() : null);
392
+ }
393
+ function selectionInfo() {
394
+ const { el, sec } = sel;
395
+ return { eid: el.dataset.eid, slide: slideInfo(sec), tag: tagOf(el), box: boxOf(el, sec), text: textOf(el).slice(0, 120) };
396
+ }
397
+ function selectEid(eid) {
398
+ const el = eid && byEid(eid);
399
+ const sec = el?.closest("section");
400
+ if (!el || sec !== Reveal.getCurrentSlide()) return select(null, null);
401
+ select(el, sec);
402
+ }
403
+
404
+ function selectHover(e) {
405
+ const t = selectableOf(e.target);
406
+ if (!t || (sel && t.el === sel.el)) return hideHover();
407
+ if (!hoverBox) { hoverBox = document.createElement("div"); hoverBox.className = "__hover"; hoverBox.appendChild(document.createElement("span")); }
408
+ const b = boxOf(t.el, t.sec);
409
+ if (hoverBox.parentElement !== t.sec) t.sec.appendChild(hoverBox);
410
+ Object.assign(hoverBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
411
+ hoverBox.classList.toggle("locked", !!t.locked);
412
+ hoverBox.classList.toggle("sel", !t.locked);
413
+ hoverBox.firstChild.textContent = t.locked ? (t.el.classList.contains("slide-foot") ? "footer (locked)" : nameOf(t.el, t.sec) + " (locked)") : nameOf(t.el, t.sec);
414
+ hoverBox.firstChild.className = b.y < 30 ? "below" : "above";
415
+ }
416
+
417
+ /* Snap targets: slide edges and centre, the footer rule, and the edges and centres of other elements */
418
+ function snapTargets(sec, el) {
419
+ const xs = [0, W / 2, W], ys = [0, H / 2, H];
420
+ if (FOOTER_TOP != null && sec.querySelector(".slide-foot")) ys.push(FOOTER_TOP);
421
+ for (const o of sec.querySelectorAll("[data-eid]")) {
422
+ if (o === el || el.contains(o) || o.contains(el)) continue;
423
+ if (o.closest("svg") && o.tagName.toLowerCase() !== "svg") continue;
424
+ if (!o.getClientRects().length) continue;
425
+ const b = boxOf(o, sec);
426
+ if (!b.w || !b.h) continue;
427
+ xs.push(b.x, b.x + b.w / 2, b.x + b.w);
428
+ ys.push(b.y, b.y + b.h / 2, b.y + b.h);
429
+ }
430
+ return { xs, ys };
431
+ }
432
+ function snapTo(values, targets, tol) {
433
+ let best = null;
434
+ for (const v of values) for (const t of targets) {
435
+ const d = t - v;
436
+ if (Math.abs(d) <= tol && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, at: t };
437
+ }
438
+ return best;
439
+ }
440
+ function showGuides(sec, xs, ys) {
441
+ clearGuides();
442
+ for (const [list, cls, prop] of [[xs, "v", "left"], [ys, "h", "top"]]) {
443
+ for (const v of new Set(list.map(Math.round))) {
444
+ const g = document.createElement("div");
445
+ g.className = "__guide " + cls;
446
+ g.style[prop] = v + "px";
447
+ sec.appendChild(g);
448
+ }
449
+ }
450
+ }
451
+ function clearGuides() { document.querySelectorAll(".__guide").forEach((g) => g.remove()); }
452
+
453
+ let lastDown = { t: 0, x: 0, y: 0, el: null };
454
+ function selectPointerDown(e) {
455
+ if (editing) {
456
+ if (editing.el.contains(e.target)) return; // let the browser place the caret
457
+ finishEdit(true);
458
+ }
459
+ e.preventDefault(); e.stopPropagation();
460
+ hideHover();
461
+ // double click: edit the text under the pointer
462
+ const now = performance.now();
463
+ const dbl = now - lastDown.t < 450 && Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < 6 && lastDown.el === e.target;
464
+ lastDown = { t: dbl ? 0 : now, x: e.clientX, y: e.clientY, el: e.target };
465
+ if (dbl && !e.target.closest?.(".__sel i")) return editAt(e.target, e.clientX, e.clientY);
466
+ const handle = e.target.closest?.(".__sel i[data-h]");
467
+ if (!handle) {
468
+ if (e.target.closest?.(".__pin")) return; // pin markers are removed in pin mode only
469
+ const t = selectableOf(e.target);
470
+ if (!t) return select(null, null);
471
+ if (t.locked) {
472
+ select(null, null);
473
+ host()?.toast(`${t.el.classList.contains("slide-foot") ? "The footer is" : "This element is"} set by brand.css and locked.`);
474
+ return;
475
+ }
476
+ if (!sel || sel.el !== t.el) select(t.el, t.sec);
477
+ }
478
+ if (!editable) { if (handle) host()?.toast("Claude is working. Moving and resizing resume when it has finished."); return; }
479
+ const el = sel.el, cs = getComputedStyle(el);
480
+ const { s } = frame(sel.sec);
481
+ const [tx, ty] = translateOf(el);
482
+ drag = {
483
+ kind: handle ? "resize" : "move", handle: handle?.dataset.h, x0: e.clientX, y0: e.clientY, s, started: !!handle,
484
+ b: boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
485
+ maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec, el),
486
+ before: { translate: el.style.translate, width: el.style.width, height: el.style.height, "max-width": el.style.maxWidth, "max-height": el.style.maxHeight },
487
+ };
488
+ document.documentElement.setPointerCapture?.(e.pointerId);
489
+ }
490
+ function onDragMove(e) {
491
+ if (!drag || !sel) return;
492
+ let dx = (e.clientX - drag.x0) / drag.s, dy = (e.clientY - drag.y0) / drag.s;
493
+ if (!drag.started) {
494
+ if (Math.hypot(dx * drag.s, dy * drag.s) < 4) return;
495
+ drag.started = true;
496
+ document.documentElement.classList.add("__dragging");
497
+ }
498
+ e.preventDefault();
499
+ const tol = 6 / drag.s, snap = !e.altKey, { b, targets } = drag, el = sel.el;
500
+ const gx = [], gy = [];
501
+ if (drag.kind === "move") {
502
+ if (e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
503
+ if (snap && !(e.shiftKey && dx === 0)) {
504
+ const sx = snapTo([b.x + dx, b.x + dx + b.w / 2, b.x + dx + b.w], targets.xs, tol);
505
+ if (sx) { dx += sx.d; gx.push(sx.at); }
506
+ }
507
+ if (snap && !(e.shiftKey && dy === 0)) {
508
+ const sy = snapTo([b.y + dy, b.y + dy + b.h / 2, b.y + dy + b.h], targets.ys, tol);
509
+ if (sy) { dy += sy.d; gy.push(sy.at); }
510
+ }
511
+ el.style.translate = fmtTranslate(drag.tx + dx, drag.ty + dy);
512
+ drag.label = `Moved ${nameOf(el, sel.sec)} by ${r1(dx)}, ${r1(dy)} px`;
513
+ } else {
514
+ const h = drag.handle;
515
+ let w = b.w, ht = b.h, ox = 0, oy = 0; // ox/oy: how far the left/top edge moves
516
+ if (h.includes("e")) w = b.w + dx;
517
+ if (h.includes("w")) { w = b.w - dx; ox = dx; }
518
+ if (h.includes("s")) ht = b.h + dy;
519
+ if (h.includes("n")) { ht = b.h - dy; oy = dy; }
520
+ if (snap) {
521
+ let t;
522
+ if (h.includes("e") && (t = snapTo([b.x + w], targets.xs, tol))) { w += t.d; gx.push(t.at); }
523
+ if (h.includes("w") && (t = snapTo([b.x + ox], targets.xs, tol))) { ox += t.d; w -= t.d; gx.push(t.at); }
524
+ if (h.includes("s") && (t = snapTo([b.y + ht], targets.ys, tol))) { ht += t.d; gy.push(t.at); }
525
+ if (h.includes("n") && (t = snapTo([b.y + oy], targets.ys, tol))) { oy += t.d; ht -= t.d; gy.push(t.at); }
526
+ }
527
+ // images and other media keep their aspect ratio (Shift toggles)
528
+ const keep = KEEP_ASPECT.has(el.tagName.toLowerCase()) !== e.shiftKey && b.w > 0 && b.h > 0;
529
+ if (keep) {
530
+ const ratio = b.w / b.h;
531
+ const useW = h.length === 1 ? /[ew]/.test(h) : Math.abs(w / b.w - 1) >= Math.abs(ht / b.h - 1);
532
+ if (useW) { const nh = w / ratio; if (h.includes("n")) oy += ht - nh; ht = nh; }
533
+ else { const nw = ht * ratio; if (h.includes("w")) ox += w - nw; w = nw; }
534
+ }
535
+ w = Math.max(8, w); ht = Math.max(8, ht);
536
+ const setW = keep || /[ew]/.test(h), setH = keep || /[ns]/.test(h);
537
+ // style width/height exclude padding and border for content-box elements
538
+ if (setW) { el.style.width = r1(w - (b.w - drag.cw)) + "px"; if (drag.maxW !== "none") el.style.maxWidth = "none"; }
539
+ if (setH) { el.style.height = r1(ht - (b.h - drag.ch)) + "px"; if (drag.maxH !== "none") el.style.maxHeight = "none"; }
540
+ el.style.translate = fmtTranslate(drag.tx + ox, drag.ty + oy);
541
+ drag.label = `Resized ${nameOf(el, sel.sec)} to ${r1(w)} × ${r1(ht)} px`;
542
+ }
543
+ drawSel();
544
+ showGuides(sel.sec, gx, gy);
545
+ renderPins(pins);
546
+ }
547
+ function onDragEnd() {
548
+ if (!drag) return;
549
+ const d = drag;
550
+ drag = null;
551
+ document.documentElement.classList.remove("__dragging");
552
+ clearGuides();
553
+ if (!sel) return;
554
+ drawSel();
555
+ if (d.started) commit(d.before, d.label);
556
+ }
557
+ function commit(before, label) {
558
+ const el = sel.el, set = {};
559
+ const now = { translate: el.style.translate, width: el.style.width, height: el.style.height,
560
+ "max-width": el.style.maxWidth, "max-height": el.style.maxHeight };
561
+ for (const k of Object.keys(now)) if (now[k] !== before[k]) set[k] = now[k] || null;
562
+ if (Object.keys(set).length) host()?.commitEdit({ eid: el.dataset.eid, set }, label);
563
+ host()?.selectionChanged(selectionInfo());
564
+ }
565
+ function nudge(dx, dy) {
566
+ if (!sel) return;
567
+ if (!editable) return host()?.toast("Claude is working. Moving and resizing resume when it has finished.");
568
+ const el = sel.el;
569
+ const [tx, ty] = translateOf(el);
570
+ el.style.translate = fmtTranslate(tx + dx, ty + dy);
571
+ drawSel();
572
+ renderPins(pins);
573
+ host()?.commitEdit({ eid: el.dataset.eid, set: { translate: el.style.translate || null } },
574
+ { nudge: true, name: nameOf(el, sel.sec), dx, dy });
575
+ host()?.selectionChanged(selectionInfo());
576
+ }
577
+ function setMode(m) {
578
+ if (editing) finishEdit(true);
579
+ mode = m;
580
+ document.documentElement.classList.toggle("__pinmode", mode === "pin");
581
+ document.documentElement.classList.toggle("__selmode", mode === "select");
582
+ hideHover();
583
+ if (mode !== "select" && sel) select(null, null);
584
+ }
585
+
586
+ /* ---- Text editing ------------------------------------------------------------------------------
587
+ Double-click (or Enter on a selection) edits the element in place. While editing, the element
588
+ shows its source HTML, so math appears as TeX (\( ... \)) and is saved exactly as typed.
589
+ Enter or a click elsewhere saves, Esc cancels, Shift+Enter adds a line break, ⌘B / ⌘I format.
590
+ In a bullet, Enter splits it at the caret and continues in a new bullet; Enter on an empty new
591
+ bullet removes it and stops. */
592
+ let editing = null, chainDirty = false; // chainDirty: bullets were split; re-render when editing stops
593
+ const TEXT_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "dt", "dd", "td", "th", "caption",
594
+ "figcaption", "blockquote", "pre", "button", "span", "small", "label"]);
595
+ const hasOwnText = (el) => [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
596
+ const isTextual = (el) => TEXT_TAGS.has(el.tagName.toLowerCase()) || hasOwnText(el) || !!el.querySelector(".katex");
597
+
598
+ /* What a double-click on t would edit: the element with an id around it, or a table cell. */
599
+ function textTargetOf(t) {
600
+ // KaTeX draws roots, wide accents and arrows as SVG: those belong to the equation, not to a diagram
601
+ if (t.closest?.("svg") && !t.closest(".katex")) return { why: "Text inside a diagram cannot be edited here yet. Pin it and ask Claude." };
602
+ const tt = targetOf(t);
603
+ if (!tt) return null;
604
+ if (tt.foot || tt.locked) return { why: `${tt.foot ? "The footer is" : "This element is"} set by brand.css and locked.` };
605
+ const cell = t.closest?.("td, th");
606
+ if (cell && cell.parentElement?.dataset.eid && tt.sec.contains(cell))
607
+ return { el: cell, sec: tt.sec, eid: cell.parentElement.dataset.eid, path: [cell.cellIndex] };
608
+ const el = tt.el;
609
+ if (el === tt.sec) return null;
610
+ if (!isTextual(el)) return { why: "Double-click directly on the text you want to edit." };
611
+ return { el, sec: tt.sec, eid: el.dataset.eid, path: [] };
612
+ }
613
+ function editAt(target, x, y) {
614
+ const t = textTargetOf(target);
615
+ if (!t) return;
616
+ if (t.why) return host()?.toast(t.why);
617
+ // a double-click on an equation opens the text with that equation's TeX selected
618
+ const k = target.closest?.(".katex");
619
+ const math = k && t.el.contains(k) ? [...t.el.querySelectorAll(".katex")].indexOf(k) : -1;
620
+ startEdit(t, x, y, math >= 0 ? { math } : {});
621
+ }
622
+ function editSelection(opts) {
623
+ if (!sel) return false;
624
+ const t = textTargetOf(sel.el);
625
+ if (!t) return false;
626
+ if (t.why) { host()?.toast(t.why); return true; }
627
+ startEdit(t, null, null, opts);
628
+ return true;
629
+ }
630
+
631
+ async function startEdit(t, x, y, opts = {}) {
632
+ if (!editable) return host()?.toast("Claude is working. Editing resumes when it has finished.");
633
+ if (editing) await finishEdit(true);
634
+ const src = opts.src ?? await host()?.textSource(t.eid, t.path);
635
+ if (src == null || !t.el.isConnected) return;
636
+ const el = t.el;
637
+ if (!sel || sel.el !== el) select(el.dataset.eid ? el : null, t.sec);
638
+ removeSel();
639
+ hideHover();
640
+ editing = { ...t, rendered: el.innerHTML };
641
+ el.innerHTML = src;
642
+ editing.baseline = el.innerHTML;
643
+ if (!el.textContent.trim() && !el.querySelector("img, svg, br")) el.appendChild(document.createElement("br")); // room for the caret
644
+ editing.orig = new WeakSet(el.querySelectorAll("*"));
645
+ el.contentEditable = "true";
646
+ el.spellcheck = true;
647
+ el.classList.add("__editing");
648
+ try { document.execCommand("styleWithCSS", false, false); } catch {}
649
+ el.addEventListener("keydown", onEditKey);
650
+ el.addEventListener("paste", onEditPaste);
651
+ el.addEventListener("blur", onEditBlur);
652
+ el.focus({ preventScroll: true });
653
+ if (!(opts.math >= 0 && selectMath(el, opts.math))) placeCaret(el, x, y, opts.caret);
654
+ showEditHint(true);
655
+ host()?.editingChanged(true);
656
+ }
657
+ /* Selects the TeX between the delimiters of the n-th equation (\( \) or \[ \]) in the source shown */
658
+ function selectMath(el, n) {
659
+ const nodes = [], w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
660
+ let text = "";
661
+ for (let node; (node = w.nextNode());) { nodes.push({ node, at: text.length }); text += node.nodeValue; }
662
+ const m = [...text.matchAll(/\\\(([\s\S]*?)\\\)|\\\[([\s\S]*?)\\\]/g)][n];
663
+ if (!m) return false;
664
+ const where = (i) => { const hit = nodes.findLast((x) => x.at <= i); return [hit.node, i - hit.at]; };
665
+ const tex = m[1] ?? m[2], lead = tex.length - tex.trimStart().length;
666
+ const start = m.index + 2 + lead, end = m.index + 2 + tex.trimEnd().length;
667
+ const r = document.createRange();
668
+ r.setStart(...where(start));
669
+ r.setEnd(...where(end));
670
+ const s = getSelection();
671
+ s.removeAllRanges();
672
+ s.addRange(r);
673
+ return true;
674
+ }
675
+ function placeCaret(el, x, y, where) {
676
+ const s = getSelection();
677
+ let r = null;
678
+ if (x != null) {
679
+ if (document.caretRangeFromPoint) r = document.caretRangeFromPoint(x, y);
680
+ else if (document.caretPositionFromPoint) {
681
+ const p = document.caretPositionFromPoint(x, y);
682
+ if (p) { r = document.createRange(); r.setStart(p.offsetNode, p.offset); }
683
+ }
684
+ }
685
+ if (where === "placeholder") { // select the first piece of text, so typing replaces it
686
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { acceptNode: (n) => (n.nodeValue.trim() ? 1 : 3) });
687
+ const first = w.nextNode();
688
+ if (first) { r = document.createRange(); r.selectNodeContents(first); }
689
+ }
690
+ if (!r || !el.contains(r.startContainer)) { r = document.createRange(); r.selectNodeContents(el); r.collapse(where === "start"); }
691
+ s.removeAllRanges();
692
+ s.addRange(r);
693
+ }
694
+ function showEditHint(on) {
695
+ document.querySelectorAll(".__edit-hint").forEach((h) => h.remove());
696
+ if (!on || !editing) return;
697
+ const b = boxOf(editing.el, editing.sec);
698
+ const h = document.createElement("div");
699
+ h.className = "__edit-hint";
700
+ h.textContent = "Editing · Enter to save · Esc to cancel · Shift+Enter new line";
701
+ Object.assign(h.style, b.y < 30 ? { left: b.x + "px", top: `calc(${b.y + b.h}px + 6px*var(--u,1))` }
702
+ : { left: b.x + "px", top: `calc(${b.y}px - 22px*var(--u,1))` });
703
+ editing.sec.appendChild(h);
704
+ }
705
+ function onEditKey(e) {
706
+ e.stopPropagation(); // keep Reveal and the editor shortcuts out of typing
707
+ if (e.key === "Escape") { e.preventDefault(); finishEdit(false); }
708
+ else if (e.key === "Enter" && !e.shiftKey) {
709
+ e.preventDefault();
710
+ if (editing?.el.tagName === "LI" && !editing.path.length) splitBullet(); else finishEdit(true);
711
+ }
712
+ else if (e.key === "Enter") { e.preventDefault(); document.execCommand("insertLineBreak"); }
713
+ }
714
+ function onEditPaste(e) {
715
+ e.preventDefault(); // plain text only: no fonts or colours from elsewhere
716
+ const text = e.clipboardData?.getData("text/plain") || "";
717
+ document.execCommand("insertText", false, text.replace(/\s*\n\s*/g, " "));
718
+ }
719
+ function onEditBlur() { if (editing) finishEdit(true); }
720
+
721
+ /* The browser's editing adds <b>/<i>, styled spans and stray <br>s; turn them into clean HTML. */
722
+ function cleanEdited(el, orig) {
723
+ const rename = (node, tag) => {
724
+ const n = document.createElement(tag);
725
+ while (node.firstChild) n.appendChild(node.firstChild);
726
+ node.replaceWith(n);
727
+ };
728
+ const unwrap = (node) => node.replaceWith(...node.childNodes);
729
+ for (const n of [...el.querySelectorAll("*")]) {
730
+ if (orig.has(n)) continue;
731
+ const tag = n.tagName.toLowerCase();
732
+ if (tag === "b") rename(n, "strong");
733
+ else if (tag === "i") rename(n, "em");
734
+ else if (tag === "font" || (tag === "span" && !n.className && !n.dataset.eid)) unwrap(n);
735
+ else n.removeAttribute("style");
736
+ }
737
+ while (el.lastChild && el.lastChild.nodeName === "BR" && !orig.has(el.lastChild)) el.lastChild.remove();
738
+ el.normalize();
739
+ return el.innerHTML.replace(/\u00a0/g, "&nbsp;");
740
+ }
741
+ function teardownEdit(ed) {
742
+ const el = ed.el;
743
+ el.removeEventListener("keydown", onEditKey);
744
+ el.removeEventListener("paste", onEditPaste);
745
+ el.removeEventListener("blur", onEditBlur);
746
+ el.removeAttribute("contenteditable");
747
+ el.removeAttribute("spellcheck");
748
+ el.classList.remove("__editing");
749
+ showEditHint(false);
750
+ }
751
+ const isBlank = (el) => !el.textContent.trim() && !el.querySelector("img, svg, video, canvas");
752
+
753
+ async function finishEdit(save) {
754
+ if (!editing) return;
755
+ const ed = editing;
756
+ editing = null;
757
+ const el = ed.el;
758
+ el.removeEventListener("blur", onEditBlur); // removing the element below would blur it again
759
+ teardownEdit(ed);
760
+ getSelection().removeAllRanges();
761
+ host()?.editingChanged(false);
762
+ const eid = ed.path.length ? ed.eid : el.dataset.eid || ed.eid;
763
+ if (!save) el.innerHTML = ed.rendered; // Esc: back to the content before this edit
764
+ // a new bullet left empty is removed again
765
+ if (ed.isNew && isBlank(el)) {
766
+ el.remove();
767
+ if (sel?.el === el) select(null, null);
768
+ host()?.commitRemove(eid);
769
+ chainDirty = false;
770
+ return;
771
+ }
772
+ const html = save ? cleanEdited(el, ed.orig) : null;
773
+ if (!save || html === ed.baseline) {
774
+ el.innerHTML = ed.rendered; // back to the rendered version (math as KaTeX)
775
+ if (chainDirty) { chainDirty = false; host()?.refresh(); }
776
+ else if (sel) drawSel();
777
+ return;
778
+ }
779
+ chainDirty = false;
780
+ const label = `Edited text of ${ed.path.length ? `${nameOf(el.parentElement, ed.sec)} cell ${ed.path[0] + 1}` : nameOf(el, ed.sec)}`;
781
+ host()?.commitText({ eid, path: ed.path, html, label, text: textOf(el).slice(0, 300) });
782
+ }
783
+
784
+ /* A free id for a new bullet on the same slide ("summary/li-4"); the server double-checks. */
785
+ function newBulletEid(el) {
786
+ const prefix = el.closest("section").dataset.eid;
787
+ const used = new Set([...document.querySelectorAll("[data-eid]")].map((e) => e.dataset.eid));
788
+ let id = `${prefix}/li`, k = 2;
789
+ while (used.has(id)) id = `${prefix}/li-${k++}`;
790
+ return id;
791
+ }
792
+ function splitBullet() {
793
+ const ed = editing, el = ed.el, s = getSelection();
794
+ if (!s.rangeCount) return finishEdit(true);
795
+ const r = s.getRangeAt(0);
796
+ if (!r.collapsed) r.deleteContents();
797
+ if (ed.isNew && isBlank(el)) return finishEdit(true); // Enter on an empty new bullet: done
798
+ // everything from the caret to the end of this bullet's own text (a sub-list stays where it is)
799
+ const tail = document.createRange();
800
+ tail.setStart(r.startContainer, r.startOffset);
801
+ const subList = [...el.children].find((c) => c.tagName === "UL" || c.tagName === "OL");
802
+ if (subList) tail.setEndBefore(subList); else tail.setEnd(el, el.childNodes.length);
803
+ const frag = tail.extractContents();
804
+ const li = document.createElement("li");
805
+ const cls = [...el.classList].filter((c) => !c.startsWith("__") && c !== "visible" && c !== "current-fragment").join(" ");
806
+ if (cls) li.className = cls;
807
+ li.appendChild(frag);
808
+ el.after(li);
809
+ // no stray spaces at the split
810
+ const trim = (node, end) => {
811
+ const w = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
812
+ const all = [];
813
+ while (w.nextNode()) all.push(w.currentNode);
814
+ const t = end ? all.at(-1) : all[0];
815
+ if (t) t.nodeValue = end ? t.nodeValue.replace(/\s+$/, "") : t.nodeValue.replace(/^\s+/, "");
816
+ };
817
+ if (!subList) trim(el, true);
818
+ trim(li, false);
819
+ const html = cleanEdited(el, ed.orig);
820
+ const newHtml = cleanEdited(li, ed.orig);
821
+ const newEid = newBulletEid(el);
822
+ li.dataset.eid = newEid;
823
+ editing = null;
824
+ teardownEdit(ed);
825
+ host()?.editingChanged(false);
826
+ chainDirty = true;
827
+ host()?.commitSplit({ eid: ed.eid, html, newHtml, newEid, cls, text: textOf(el).slice(0, 200) },
828
+ (real) => { if (real && real !== newEid) li.dataset.eid = real; });
829
+ startEdit({ el: li, sec: ed.sec, eid: newEid, path: [], isNew: true }, null, null, { src: newHtml, caret: "start" });
830
+ }
831
+
832
+ /* ---- Stylesheet hot swap (no reload, no flicker) -------------------------------------------- */
833
+ function reloadCss(files) {
834
+ const links = [...document.querySelectorAll('link[rel="stylesheet"]')];
835
+ return Promise.all(links.filter((l) => files.some((f) => new URL(l.href).pathname.endsWith("/" + f))).map((l) =>
836
+ new Promise((resolve) => {
837
+ const n = l.cloneNode();
838
+ const u = new URL(l.href);
839
+ u.searchParams.set("v", Date.now());
840
+ n.href = u.href;
841
+ n.onload = n.onerror = () => { l.remove(); resolve(); setTimeout(() => { renderPins(pins); if (sel) drawSel(); }, 0); };
842
+ l.after(n);
843
+ })));
844
+ }
845
+
846
+ /* ---- Start ------------------------------------------------------------------------------------- */
847
+ async function start() {
848
+ W = Reveal.getConfig().width; H = Reveal.getConfig().height;
849
+ const base = { transition: "none", backgroundTransition: "none", fragments: false };
850
+ const setUnit = () => document.documentElement.style.setProperty("--u", String(1 / (Reveal.getScale() || 1)));
851
+ Reveal.on("resize", setUnit);
852
+ if (MODE === "main") Reveal.configure({ ...base });
853
+ else Reveal.configure({ ...base, margin: 0, keyboard: false });
854
+ setUnit();
855
+ await document.fonts.ready;
856
+
857
+ if (MODE === "shot" || MODE === "dump") {
858
+ const sec = byEid(params.get("slide") || "") || slides()[0];
859
+ goto(indexOf(sec));
860
+ await nextFrame();
861
+ if (MODE === "shot") {
862
+ try { renderPins(JSON.parse(params.get("pins") || "[]").map((p) => ({ ...p, slide: { eid: sec.dataset.eid } }))); } catch {}
863
+ } else {
864
+ let out;
865
+ try { out = measureSection(sec); } catch (err) { out = { error: String(err) }; }
866
+ const s = document.createElement("script");
867
+ s.id = "__editor_layout"; s.type = "application/json";
868
+ s.textContent = JSON.stringify(out).replace(/</g, "\\u003c");
869
+ document.body.appendChild(s);
870
+ }
871
+ return;
872
+ }
873
+
874
+ const api = {
875
+ info, goto, indices, highlight, reloadCss,
876
+ setMode, selectEid, nudge, editSelection,
877
+ // a new slide: select its title and open it for typing
878
+ editTitle() {
879
+ const sec = Reveal.getCurrentSlide();
880
+ const h = sec?.querySelector("h1[data-eid], h2[data-eid], h3[data-eid]");
881
+ if (!h || mode !== "select") return;
882
+ select(h, sec);
883
+ editSelection({ caret: "placeholder" });
884
+ },
885
+ finishEdit: () => finishEdit(true),
886
+ isEditing: () => !!editing,
887
+ deselect() { select(null, null); },
888
+ hasSelection: () => !!sel,
889
+ setEditable(on) { editable = !!on; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
890
+ renderPins,
891
+ async measure(index) {
892
+ goto(index);
893
+ await nextFrame();
894
+ return measureSection(slides()[index]);
895
+ },
896
+ };
897
+ window.__editorBridge = api;
898
+
899
+ if (MODE === "main") {
900
+ document.addEventListener("pointerdown", onPointerDown, true);
901
+ document.addEventListener("click", onClick, true);
902
+ document.addEventListener("mousemove", onMove, true);
903
+ document.addEventListener("mouseleave", hideHover);
904
+ // files dropped onto a slide become chat attachments, with the drop position for Claude
905
+ const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
906
+ document.addEventListener("dragover", (e) => { if (hasFiles(e)) { e.preventDefault(); host()?.dragOver(true); } });
907
+ document.addEventListener("dragleave", (e) => { if (hasFiles(e) && !e.relatedTarget) host()?.dragOver(false); });
908
+ document.addEventListener("drop", (e) => {
909
+ if (!hasFiles(e)) return;
910
+ e.preventDefault();
911
+ host()?.dragOver(false);
912
+ let at = null;
913
+ const t = targetOf(document.elementFromPoint(e.clientX, e.clientY));
914
+ if (t) {
915
+ const { r, s } = frame(t.sec);
916
+ at = { slide: slideInfo(t.sec), point: { x: r1((e.clientX - r.left) / s), y: r1((e.clientY - r.top) / s) },
917
+ eid: t.el !== t.sec ? t.el.dataset.eid : null };
918
+ }
919
+ host()?.dropFiles(e.dataTransfer.files, at);
920
+ });
921
+ document.documentElement.addEventListener("pointermove", onDragMove);
922
+ document.documentElement.addEventListener("pointerup", onDragEnd);
923
+ document.documentElement.addEventListener("pointercancel", onDragEnd);
924
+ window.addEventListener("keydown", (e) => {
925
+ if (e.target.closest?.("input, textarea, [contenteditable]")) return;
926
+ const k = e.key, meta = e.metaKey || e.ctrlKey;
927
+ if (sel && mode === "select" && !meta && (k === "Delete" || k === "Backspace")) {
928
+ e.preventDefault(); e.stopImmediatePropagation();
929
+ host()?.requestDelete(sel.el.dataset.eid);
930
+ return;
931
+ }
932
+ // arrows nudge the selection (Shift: 10 px) instead of changing slides
933
+ if (sel && mode === "select" && !meta && k.startsWith("Arrow")) {
934
+ e.preventDefault(); e.stopImmediatePropagation();
935
+ const step = e.shiftKey ? 10 : 1;
936
+ nudge(k === "ArrowLeft" ? -step : k === "ArrowRight" ? step : 0, k === "ArrowUp" ? -step : k === "ArrowDown" ? step : 0);
937
+ return;
938
+ }
939
+ if ("pPvVhH".includes(k) && !meta || k === "Escape" || (k === "Enter" && !e.shiftKey) ||
940
+ (meta && (k === "z" || k === "Z" || k === "k"))) {
941
+ e.preventDefault(); e.stopImmediatePropagation();
942
+ host()?.key(k, { meta, shift: e.shiftKey });
943
+ }
944
+ }, true);
945
+ Reveal.on("slidechanged", () => { if (editing) finishEdit(true); hideHover(); if (sel) select(null, null); host()?.slideChanged(indexOf(Reveal.getCurrentSlide())); });
946
+ Reveal.on("resize", () => { if (sel) drawSel(); });
947
+ setMode("select");
948
+ // a banner on hidden slides, so nobody edits one without noticing (only in the editor view)
949
+ for (const sec of slides()) {
950
+ if (sec.getAttribute("data-visibility") !== "hidden") continue;
951
+ const b = document.createElement("div");
952
+ b.className = "__hidden-banner";
953
+ b.textContent = "Hidden slide · skipped when presenting and in PDFs";
954
+ sec.appendChild(b);
955
+ }
956
+ const startAt = +params.get("start");
957
+ if (startAt) goto(startAt);
958
+ }
959
+ host()?.bridgeReady(MODE, api, window);
960
+ }
961
+ if (typeof Reveal === "undefined") return;
962
+ if (Reveal.isReady()) start(); else Reveal.on("ready", start);
963
+ })();