glideshow 0.1.2__py3-none-any.whl

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.
Files changed (103) hide show
  1. glideshow/__init__.py +15 -0
  2. glideshow/__main__.py +3 -0
  3. glideshow/agent.py +681 -0
  4. glideshow/build.py +54 -0
  5. glideshow/cli.py +320 -0
  6. glideshow/comments.py +35 -0
  7. glideshow/decks.py +230 -0
  8. glideshow/docs/agents.md +223 -0
  9. glideshow/docs/deck-format.md +125 -0
  10. glideshow/editor/.assetsignore +5 -0
  11. glideshow/editor/apple-touch-icon.png +0 -0
  12. glideshow/editor/data/font-metrics.json +1 -0
  13. glideshow/editor/data/fonts.json +1 -0
  14. glideshow/editor/data/icons.json +1 -0
  15. glideshow/editor/data/symbols.js +42 -0
  16. glideshow/editor/data/themes.js +15 -0
  17. glideshow/editor/deck/base.css +92 -0
  18. glideshow/editor/deck/present.js +222 -0
  19. glideshow/editor/deck/shapes.js +377 -0
  20. glideshow/editor/deck/template.html +37 -0
  21. glideshow/editor/editor.css +657 -0
  22. glideshow/editor/glideshow-1024.png +0 -0
  23. glideshow/editor/glideshow.svg +10 -0
  24. glideshow/editor/globals.d.ts +21 -0
  25. glideshow/editor/home.html +358 -0
  26. glideshow/editor/index.html +386 -0
  27. glideshow/editor/js/arrange.js +122 -0
  28. glideshow/editor/js/boot.js +66 -0
  29. glideshow/editor/js/boxes.js +25 -0
  30. glideshow/editor/js/comments.js +85 -0
  31. glideshow/editor/js/context-menus.js +93 -0
  32. glideshow/editor/js/dialogs.js +168 -0
  33. glideshow/editor/js/dock.js +22 -0
  34. glideshow/editor/js/draw.js +107 -0
  35. glideshow/editor/js/file.js +318 -0
  36. glideshow/editor/js/find.js +72 -0
  37. glideshow/editor/js/format-panel.js +101 -0
  38. glideshow/editor/js/geometry.js +153 -0
  39. glideshow/editor/js/guides.js +37 -0
  40. glideshow/editor/js/insert.js +73 -0
  41. glideshow/editor/js/keyboard.js +256 -0
  42. glideshow/editor/js/live.js +48 -0
  43. glideshow/editor/js/menubar.js +290 -0
  44. glideshow/editor/js/menus.js +93 -0
  45. glideshow/editor/js/motion.js +148 -0
  46. glideshow/editor/js/notes.js +63 -0
  47. glideshow/editor/js/objects.js +202 -0
  48. glideshow/editor/js/people.js +73 -0
  49. glideshow/editor/js/phone-comments.js +43 -0
  50. glideshow/editor/js/phone-edit.js +253 -0
  51. glideshow/editor/js/phone-home.js +51 -0
  52. glideshow/editor/js/phone-mode.js +73 -0
  53. glideshow/editor/js/phone-sheets.js +153 -0
  54. glideshow/editor/js/phone-touch.js +145 -0
  55. glideshow/editor/js/phone-view.js +150 -0
  56. glideshow/editor/js/pointer.js +407 -0
  57. glideshow/editor/js/render.js +353 -0
  58. glideshow/editor/js/shortcuts.js +67 -0
  59. glideshow/editor/js/slides.js +120 -0
  60. glideshow/editor/js/spacing.js +23 -0
  61. glideshow/editor/js/state.js +58 -0
  62. glideshow/editor/js/storage.js +250 -0
  63. glideshow/editor/js/text.js +366 -0
  64. glideshow/editor/js/theme-toggle.js +41 -0
  65. glideshow/editor/js/theme.js +173 -0
  66. glideshow/editor/js/toolbar-groups.js +109 -0
  67. glideshow/editor/js/toolbar.js +372 -0
  68. glideshow/editor/lib/browser-db.js +47 -0
  69. glideshow/editor/lib/library.js +612 -0
  70. glideshow/editor/lib/merge.js +174 -0
  71. glideshow/editor/lib/move-local.js +51 -0
  72. glideshow/editor/lib/onefile.js +108 -0
  73. glideshow/editor/lib/store-browser.js +75 -0
  74. glideshow/editor/lib/store-folder.js +109 -0
  75. glideshow/editor/local/sw.js +66 -0
  76. glideshow/editor/local/tsconfig.json +5 -0
  77. glideshow/editor/looks/glider.svg +9 -0
  78. glideshow/editor/looks/thermal-phone.css +144 -0
  79. glideshow/editor/looks/thermal.css +268 -0
  80. glideshow/editor/manifest.json +23 -0
  81. glideshow/editor/phone.css +345 -0
  82. glideshow/editor/share/share.css +124 -0
  83. glideshow/editor/share/share.js +256 -0
  84. glideshow/editor/share/speaker.html +88 -0
  85. glideshow/editor/tsconfig.json +6 -0
  86. glideshow/figure.py +33 -0
  87. glideshow/fonts.py +63 -0
  88. glideshow/gslides.py +1065 -0
  89. glideshow/history.py +210 -0
  90. glideshow/merge.py +210 -0
  91. glideshow/osutil.py +44 -0
  92. glideshow/pptx_export.py +207 -0
  93. glideshow/pptx_import.py +115 -0
  94. glideshow/publish.py +106 -0
  95. glideshow/render.py +288 -0
  96. glideshow/server.py +549 -0
  97. glideshow/service.py +65 -0
  98. glideshow/skills/glideshow/SKILL.md +85 -0
  99. glideshow-0.1.2.dist-info/METADATA +69 -0
  100. glideshow-0.1.2.dist-info/RECORD +103 -0
  101. glideshow-0.1.2.dist-info/WHEEL +4 -0
  102. glideshow-0.1.2.dist-info/entry_points.txt +2 -0
  103. glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
@@ -0,0 +1,256 @@
1
+ /* Comments on a shared deck (glideshow.org/p/…): a panel over the presented deck. The page that serves the deck sets
2
+ window.GLIDESHOW = {comments: <api url>, pass, me: {n} | null, signin: <url>, report: <url>, owner, title}; comments are
3
+ anchored to a slide (its number among all slides, stacks flattened, as in the editor), a point on it and the box there,
4
+ if any. The deck runs sandboxed, without the viewer's cookies, so the API is told who is commenting by `pass` (an
5
+ encrypted pass for this deck, cloud/share.js) and marks the viewer's own comments `mine`. */
6
+ (() => {
7
+ const G = window.GLIDESHOW; if (!G) return;
8
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
9
+ const deckEl = () => window.Reveal?.getRevealElement?.() || document.querySelector('.reveal:not(.gs-pic-reveal)'); // the deck itself, not a filmstrip picture
10
+ const leaves = () => window.Reveal?.getSlides?.() || // reveal.js's own list: its phone scroll view wraps the sections in pages of its own
11
+ [...(deckEl()?.querySelectorAll(':scope > .slides > section') || [])].flatMap(s => s.querySelector(':scope > section') ? [...s.querySelectorAll(':scope > section')] : [s]);
12
+ const curIndex = () => Math.max(0, leaves().indexOf(window.Reveal?.getCurrentSlide?.()));
13
+ /* A comment's slide: the one with its data-sid, wherever slides have moved since; else the number it was left on. */
14
+ const slideOf = c => { const i = c.sid ? leaves().findIndex(s => s.dataset.sid === c.sid) : -1; return i >= 0 ? i + 1 : c.slide; };
15
+ const goTo = n => { const s = leaves()[n - 1]; if (!s || !window.Reveal) return; const i = Reveal.getIndices(s); Reveal.slide(i.h, i.v); };
16
+ const ago = t => { const s = (Date.now() - Date.parse(t)) / 1000; return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); };
17
+ let list = [], open = false, showResolved = false, placing = null, draft = null, timer = /** @type {any} */ (0);
18
+
19
+ const btn = el('button', 'gs-toggle'); btn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M4 4h16v12H5.17L4 17.17V4m0-2c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2H4z"/></svg><span>Comments</span><b class="gs-n"></b>';
20
+ const panel = el('aside', 'gs-panel'); panel.hidden = true;
21
+ const X = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M18.3 5.71 12 12.01l-6.3-6.3-1.41 1.41 6.3 6.3-6.3 6.3 1.41 1.41 6.3-6.3 6.3 6.3 1.41-1.41-6.3-6.3 6.3-6.3z"/></svg>';
22
+ const hint = el('div', 'gs-hint', 'Click on the slide where the comment goes. Esc to cancel.'); hint.hidden = true;
23
+
24
+ /* ---- the filmstrip: every slide as a picture, down the left (a row along the bottom on a phone), the current one
25
+ ringed in canopy blue with its number, and its open comments counted; a click goes there. Open on a computer, shut
26
+ on a phone, by the button at the top left (for this visit: the sandboxed page keeps nothing). ---- */
27
+ const strip = el('nav', 'gs-strip'), stripBtn = el('button', 'gs-strip-toggle'); strip.setAttribute('aria-label', 'Slides');
28
+ stripBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm2 0v14h4V5H5zm6 0v14h8V5h-8z"/></svg>';
29
+ stripBtn.title = 'Slides';
30
+ let stripOn = innerWidth > 700;
31
+ try { const v = localStorage.getItem('glideshow-strip'); if (v) stripOn = v === '1'; } catch {}
32
+ const PIC = 120; // a picture's width
33
+ function buildStrip() {
34
+ const R = window.Reveal, W = R?.getConfig?.().width || 1920, H = R?.getConfig?.().height || 1080;
35
+ strip.style.setProperty('--gs-pic-h', Math.round(PIC * H / W) + 'px');
36
+ strip.replaceChildren(...leaves().map((s, i) => {
37
+ const cell = el('button', 'gs-cell'), pic = el('div', 'gs-pic'), wrap = el('div', 'reveal gs-pic-reveal'), slides = el('div', 'slides');
38
+ const c = /** @type {HTMLElement} */ (s.cloneNode(true));
39
+ [c, ...c.querySelectorAll('[id]')].forEach(e => e.removeAttribute('id'));
40
+ c.removeAttribute('hidden'); c.removeAttribute('aria-hidden'); c.className = c.className.replace(/\b(past|present|future|stack)\b/g, '');
41
+ c.style.cssText = 'display: block; position: absolute; inset: 0; width: 100%; height: 100%; transform: none; opacity: 1; visibility: visible;';
42
+ c.querySelectorAll('.fragment').forEach(f => f.classList.add('visible')); // as built in full
43
+ c.querySelectorAll('video').forEach(v => { const im = el('img', 'gs-still'); const p = v.getAttribute('poster'); if (p) im.src = p; v.replaceWith(im); });
44
+ c.querySelectorAll('iframe').forEach(f => f.replaceWith(el('div', 'gs-still')));
45
+ c.querySelectorAll('img').forEach(im => { im.loading = 'lazy'; });
46
+ c.querySelectorAll('.gs-marker, aside.notes').forEach(e => e.remove());
47
+ Object.assign(slides.style, { width: W + 'px', height: H + 'px', inset: '0', margin: '0', transform: 'none', pointerEvents: 'none' });
48
+ Object.assign(wrap.style, { width: W + 'px', height: H + 'px', transform: `scale(${PIC / W})` });
49
+ // the slide's background is a layer reveal.js draws apart from the slide (and the slideshow's letterbox is black)
50
+ const bg = R?.getSlideBackground?.(s), cs = bg && getComputedStyle(bg), clear = !cs || /^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor);
51
+ wrap.style.setProperty('background-color', clear ? (s.dataset.backgroundColor || '#fff') : cs.backgroundColor, 'important');
52
+ if (cs && cs.backgroundImage !== 'none') for (const k of ['background-image', 'background-size', 'background-position', 'background-repeat']) wrap.style.setProperty(k, cs.getPropertyValue(k), 'important');
53
+ slides.append(c); wrap.append(slides); pic.append(wrap);
54
+ cell.append(el('span', 'gs-num', String(i + 1)), pic, el('b', 'gs-cn'));
55
+ cell.title = `Slide ${i + 1}`; cell.onclick = () => goTo(i + 1);
56
+ return cell;
57
+ }));
58
+ markStrip();
59
+ }
60
+ function markStrip() {
61
+ const here = curIndex(), live = list.filter(c => !c.resolved);
62
+ [...strip.children].forEach((cell, i) => {
63
+ cell.classList.toggle('cur', i === here);
64
+ const n = G.me ? live.filter(c => c.slide === i + 1).length : 0; cell.querySelector('.gs-cn').textContent = n ? String(n) : '';
65
+ });
66
+ const cur = /** @type {HTMLElement} */ (strip.children[here]);
67
+ if (cur && stripOn) cur.scrollIntoView({ block: 'nearest', inline: 'nearest' });
68
+ }
69
+ /* shown when it's on, unless the comments panel is open too and the slide would be left too narrow between them (it
70
+ comes back when the panel closes) */
71
+ const stripShown = () => stripOn && !(open && innerWidth > 700 && innerWidth - 176 - 360 < 480);
72
+ function showStrip(on = !stripOn, remember = true) {
73
+ stripOn = on;
74
+ if (remember) try { localStorage.setItem('glideshow-strip', on ? '1' : '0'); } catch {}
75
+ relayout(); if (on) markStrip();
76
+ }
77
+ /* the deck's sticky notes (share.css shows them): a button beside the filmstrip's hides them, on the slide and in the
78
+ filmstrip, for this visit; there only when the deck has notes */
79
+ const notesBtn = el('button', 'gs-notes-toggle');
80
+ notesBtn.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10l6-6V5a2 2 0 0 0-2-2zM5 19V5h14v9h-5v5H5zm2-12h10v2H7V7zm0 4h6v2H7v-2z"/></svg>';
81
+ let notesOn = true;
82
+ try { notesOn = localStorage.getItem('glideshow-sticky') !== '0'; } catch {}
83
+ function showNotes(on = !notesOn, remember = true) {
84
+ notesOn = on; document.body.classList.toggle('gs-notes-off', !on); notesBtn.classList.toggle('on', on);
85
+ notesBtn.title = on ? 'Hide sticky notes' : 'Show sticky notes'; notesBtn.setAttribute('aria-pressed', String(on));
86
+ if (remember) try { localStorage.setItem('glideshow-sticky', on ? '1' : '0'); } catch {}
87
+ }
88
+ notesBtn.onclick = () => showNotes();
89
+ stripBtn.onclick = () => { if (open && !stripShown()) toggle(false); showStrip(open ? true : !stripOn); }; // (a tucked-away strip comes back, the panel giving way)
90
+ addEventListener('resize', () => { if (document.body.classList.contains('gs-strip-on') !== stripShown()) relayout(); });
91
+ document.body.append(btn, panel, hint, strip, stripBtn);
92
+ if (G.edit) { // whoever may edit gets an Edit button: the editor, for this deck (glideshow.org/e/…)
93
+ const edit = el('a', 'gs-edit'); edit.href = G.edit; edit.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg><span>Edit</span>';
94
+ document.body.append(edit);
95
+ }
96
+ panel.addEventListener('keydown', e => e.stopPropagation()); // typing is not for the slideshow
97
+
98
+ async function api(body) {
99
+ const headers = { 'X-Glideshow-View': G.pass || '', ...(body ? { 'X-Glideshow': '1', 'content-type': 'application/json' } : {}) };
100
+ const r = await fetch(G.comments, body ? { method: 'POST', headers, body: JSON.stringify(body) } : { headers, cache: 'no-store' });
101
+ const j = await r.json().catch(() => ({}));
102
+ if (Array.isArray(j.comments)) list = j.comments.map(c => ({ ...c, slide: slideOf(c) }));
103
+ return j;
104
+ }
105
+ const typing = () => [...panel.querySelectorAll('input:not([type=checkbox]), textarea')].some(i => i.value.trim() || i === document.activeElement);
106
+ const refresh = async () => { if (G.me && !typing()) { await api(); if (!typing()) render(); } }; // never under someone's fingers
107
+
108
+ function markers() {
109
+ document.querySelectorAll('.gs-marker').forEach(m => m.remove());
110
+ const s = leaves()[curIndex()], n = curIndex() + 1; if (!s || !open) return;
111
+ list.filter(c => c.slide === n && (showResolved || !c.resolved)).forEach((c, i) => {
112
+ const m = el('button', 'gs-marker' + (c.resolved ? ' done' : ''), String(i + 1)); m.style.left = c.x + 'px'; m.style.top = c.y + 'px'; m.title = c.text;
113
+ m.onclick = e => { e.stopPropagation(); panel.querySelector(`[data-id="${c.id}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); flash(c.id); };
114
+ s.append(m);
115
+ });
116
+ }
117
+ const flash = id => { const card = panel.querySelector(`[data-id="${id}"]`); card?.classList.add('flash'); setTimeout(() => card?.classList.remove('flash'), 900); };
118
+
119
+ function render() {
120
+ const live = list.filter(c => !c.resolved);
121
+ btn.querySelector('.gs-n').textContent = live.length || '';
122
+ panel.replaceChildren();
123
+ const head = el('header'); head.append(el('h2', '', 'Comments'), el('b', 'gs-n', live.length ? String(live.length) : ''));
124
+ const close = el('button', 'gs-x'); close.innerHTML = X; close.title = 'Close (Esc)'; close.onclick = () => toggle(false); head.append(close); panel.append(head);
125
+ const foot = el('div', 'gs-foot'), report = el('a', '', 'Report this deck'); // to glideshow.org's own report form (worker.js)
126
+ report.href = G.report || '/report'; report.target = '_blank'; report.rel = 'noopener'; foot.append(report);
127
+ if (!G.me) {
128
+ const p = el('p', 'gs-empty'); p.append('Sign in to see and add comments. ');
129
+ const a = el('a', '', 'Sign in'); a.href = G.signin; p.append(a); panel.append(p, foot); return;
130
+ }
131
+ const tools = el('div', 'gs-tools'), add = el('button', 'gs-primary', 'Add a comment'); add.onclick = startPlacing;
132
+ const res = el('label', 'gs-check'), cb = el('input'); cb.type = 'checkbox'; cb.checked = showResolved; cb.onchange = () => { showResolved = cb.checked; render(); }; res.append(cb, ' Show resolved');
133
+ tools.append(add, res); panel.append(tools);
134
+ if (draft) panel.append(composer());
135
+ const shown = list.filter(c => showResolved || !c.resolved).sort((a, b) => a.slide - b.slide || a.at.localeCompare(b.at));
136
+ if (!shown.length && !draft) panel.append(el('p', 'gs-empty', list.length ? 'All comments are resolved.' : 'No comments yet.'));
137
+ const here = curIndex() + 1, perSlide = {};
138
+ for (const c of shown) {
139
+ const k = (perSlide[c.slide] = (perSlide[c.slide] || 0) + 1);
140
+ const card = el('div', 'gs-card' + (c.resolved ? ' done' : '') + (c.slide === here ? ' here' : '')); card.dataset.id = c.id;
141
+ const top = el('div', 'gs-top'); top.append(el('span', 'gs-chip', `Slide ${c.slide} · ${k}`), el('span', 'gs-when', ago(c.at)));
142
+ card.append(top);
143
+ if (c.quote) card.append(el('div', 'gs-quote', c.quote));
144
+ card.append(el('div', 'gs-by', c.by?.n || c.by?.e || ''), el('div', 'gs-text', c.text));
145
+ for (const r of c.replies || []) {
146
+ const rr = el('div', 'gs-reply' + (r.event ? ' event' : '')); rr.append(el('span', 'gs-by', r.by?.n || r.by?.e || ''), ' ', el('span', '', r.text)); card.append(rr);
147
+ }
148
+ const acts = el('div', 'gs-acts');
149
+ const reply = el('input', 'gs-in'); reply.placeholder = 'Reply'; reply.gsKey = async e => { if (e.key === 'Enter' && reply.value.trim()) { await api({ op: 'reply', id: c.id, text: reply.value }); render(); } };
150
+ const resolve = el('button', 'gs-link', c.resolved ? 'Reopen' : 'Resolve'); resolve.onclick = async e => { e.stopPropagation(); await api({ op: 'resolve', id: c.id, resolved: !c.resolved }); render(); };
151
+ acts.append(reply, resolve);
152
+ if (c.mine) { const del = el('button', 'gs-link', 'Delete'); del.onclick = async e => { e.stopPropagation(); if (confirm('Delete this comment?')) { await api({ op: 'delete', id: c.id }); render(); } }; acts.append(del); }
153
+ card.append(acts);
154
+ card.onclick = e => { if (!e.target.closest('input, button')) { goTo(c.slide); } };
155
+ panel.append(card);
156
+ }
157
+ panel.append(foot);
158
+ markers(); markStrip();
159
+ }
160
+
161
+ function composer() {
162
+ const box = el('div', 'gs-card gs-new'), ta = el('textarea', 'gs-in'); ta.placeholder = `Comment on slide ${draft.slide}`; ta.rows = 3;
163
+ const row = el('div', 'gs-acts'), post = el('button', 'gs-primary', 'Comment'), cancel = el('button', 'gs-link', 'Cancel');
164
+ post.onclick = async () => { if (!ta.value.trim()) return; const j = await api({ op: 'add', ...draft, text: ta.value }); if (!j.error) { draft = null; render(); } };
165
+ cancel.onclick = () => { draft = null; render(); };
166
+ ta.gsKey = e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) post.click(); if (e.key === 'Escape') cancel.click(); };
167
+ if (draft.quote) box.append(el('div', 'gs-quote', draft.quote));
168
+ row.append(post, cancel); box.append(ta, row); setTimeout(() => ta.focus());
169
+ return box;
170
+ }
171
+
172
+ function startPlacing() { placing = true; hint.hidden = false; document.body.classList.add('gs-placing'); }
173
+ function stopPlacing() { placing = false; hint.hidden = true; document.body.classList.remove('gs-placing'); }
174
+ /* Esc puts away what's open: choosing a place for a comment, then the panel. The page that serves the deck calls this
175
+ from a key handler of its own that runs before the deck's (whose Esc leaves the slideshow); the one here is for a page
176
+ without it. */
177
+ window['gsEscape'] = () => { if (placing) { stopPlacing(); return true; } if (open && !typing()) { toggle(false); return true; } return false; };
178
+ addEventListener('keydown', e => { if (e.key === 'Escape' && window['gsEscape']()) { e.preventDefault(); e.stopImmediatePropagation(); } }, true);
179
+ addEventListener('click', e => {
180
+ if (!placing || e.target.closest('.gs-panel, .gs-toggle, .gs-strip, .gs-strip-toggle')) return;
181
+ const s = leaves()[curIndex()]; if (!s) return;
182
+ e.preventDefault(); e.stopPropagation();
183
+ const r = s.getBoundingClientRect(), k = s.offsetWidth / r.width;
184
+ const box = document.elementsFromPoint(e.clientX, e.clientY).map(x => x.closest?.('.box')).find(b => b && s.contains(b));
185
+ draft = { slide: curIndex() + 1, sid: leaves()[curIndex()]?.dataset.sid || '', x: (e.clientX - r.left) * k, y: (e.clientY - r.top) * k, box: box?.id || '', quote: (box?.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 120) };
186
+ stopPlacing(); render();
187
+ }, true);
188
+
189
+ /* With the panel or the filmstrip beside it the slide may be under reveal.js's 435 px, where it turns into its phone
190
+ scroll view (the slide blown up and cut off): not while either is open on a wide screen. */
191
+ let scrollWidth;
192
+ function relayout() {
193
+ document.body.classList.toggle('gs-strip-on', stripShown()); stripBtn.classList.toggle('on', stripShown());
194
+ const R = window.Reveal, squeezed = (open || stripShown()) && innerWidth > 700;
195
+ if (R?.configure && squeezed) { scrollWidth ??= R.getConfig().scrollActivationWidth; R.configure({ scrollActivationWidth: null }); }
196
+ else if (R?.configure && scrollWidth !== undefined) R.configure({ scrollActivationWidth: scrollWidth });
197
+ setTimeout(() => { R?.layout?.(); dispatchEvent(new Event('resize')); }, 50);
198
+ }
199
+ function toggle(on = !open) {
200
+ open = on; panel.hidden = !on; btn.classList.toggle('on', on); document.body.classList.toggle('gs-open', on);
201
+ clearInterval(timer); if (on) { refresh(); timer = setInterval(refresh, 20000); }
202
+ relayout(); render();
203
+ }
204
+ btn.onclick = () => toggle();
205
+ const wire = () => { buildStrip(); showStrip(stripOn, false); notesBtn.hidden = !deckEl()?.querySelector('.box.note'); document.body.append(notesBtn); showNotes(notesOn, false); window.Reveal?.on?.('slidechanged', () => { if (open) render(); else markStrip(); }); };
206
+ if (window.Reveal?.isReady?.()) wire(); else window.Reveal?.on?.('ready', wire);
207
+ if (G.me) api().then(render); else render();
208
+ })();
209
+
210
+ /* (as present.js does in decks saved since: a shared link's deck may have been saved before) */
211
+ /* reveal.js pauses a slide's videos when it is left and, coming back, plays only those marked data-autoplay themselves;
212
+ a Glideshow deck marks their box (.box[data-autoplay]), so they came back still (2026-10-21). Play them again whenever
213
+ their slide or fragment is shown. */
214
+ (function playShown() {
215
+ const R = window.Reveal; if (!R?.on || !R.isReady?.()) { setTimeout(playShown, 200); return; }
216
+ const play = () => R.getCurrentSlide()?.querySelectorAll('.box[data-autoplay] video, video[autoplay]').forEach(v => {
217
+ if (v.closest('.fragment:not(.visible)') || !v.paused) return;
218
+ v.play().catch(() => {});
219
+ });
220
+ R.on('slidechanged', play); R.on('fragmentshown', play); play();
221
+ })();
222
+
223
+ /* Speaker notes on a shared link (S, or ⋮ → Open speaker notes). The deck runs sandboxed, with no origin of its own, so
224
+ reveal.js's notes plugin opened a blank window it couldn't write (2026-10-22). Its `open` is this instead: Glideshow's
225
+ speaker page (share/speaker.html), a page of glideshow.org's own, kept in step by postMessage: the slide, its step and
226
+ its notes go there; Next / Previous come back. In the speaker page's two pictures (frames of this same deck) nothing of
227
+ this viewer shows. */
228
+ (function speaker() {
229
+ if (!window.GLIDESHOW) return;
230
+ if (window.top !== window || new URLSearchParams(location.search).has('speaker')) { document.documentElement.classList.add('gs-preview'); return; }
231
+ const R = window.Reveal; if (!R?.on || !R.isReady?.()) { setTimeout(speaker, 200); return; }
232
+ let win = /** @type {Window | null} */ (null);
233
+ const state = () => {
234
+ const i = R.getIndices(), all = R.getSlides(), cur = R.getCurrentSlide();
235
+ const notes = cur?.querySelector('aside.notes'), text = notes ? (notes.innerText || notes.textContent || '').trim() : (cur?.getAttribute('data-notes') || '');
236
+ return { h: i.h, v: i.v || 0, f: i.f ?? -1, n: all.indexOf(cur) + 1, total: all.length, last: R.isLastSlide() && !R.availableFragments().next, notes: text };
237
+ };
238
+ const send = () => { if (win && !win.closed) win.postMessage({ gs: 'speaker', state: state() }, '*'); };
239
+ const open = () => {
240
+ if (win && !win.closed) { win.focus(); send(); return; }
241
+ const deck = new URL(document.baseURI).pathname; // the link's address (with a signed-in viewer's pass, as the deck's <base> has it)
242
+ win = window.open('/share/speaker.html?deck=' + encodeURIComponent(deck), 'gs-speaker', 'width=1100,height=720');
243
+ };
244
+ addEventListener('message', e => {
245
+ if (!win || e.source !== win) return;
246
+ const m = e.data;
247
+ if (m?.gs === 'speaker-ready') send();
248
+ if (m?.gs === 'speaker-nav') { m.dir === 'prev' ? R.prev() : R.next(); }
249
+ });
250
+ ['slidechanged', 'fragmentshown', 'fragmenthidden'].forEach(ev => R.on(ev, send));
251
+ const plugin = R.getPlugin?.('notes'); if (plugin) plugin.open = open;
252
+ addEventListener('keydown', e => { // a deck saved before present.js sent S to the plugin's open: its own binding is reveal's
253
+ if (e.key?.toLowerCase() !== 's' || e.metaKey || e.ctrlKey || e.altKey || /** @type {any} */ (e.target)?.closest?.('input, textarea, [contenteditable]')) return;
254
+ e.preventDefault(); e.stopImmediatePropagation(); open();
255
+ }, true);
256
+ })();
@@ -0,0 +1,88 @@
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
+ <meta name="robots" content="noindex">
7
+ <title>Speaker notes · Glideshow</title>
8
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
9
+ <!-- Speaker view for a shared deck (glideshow.org/p/…). The deck runs sandboxed, with no origin of its own, so reveal.js's
10
+ notes plugin can't write its speaker window; share.js opens this page instead (?deck=<the link's address>) and keeps
11
+ it in step with postMessage: the slide, its step and its notes come from the deck, and Next / Previous go back to it.
12
+ The two pictures are the shared deck itself in frames, moved with reveal.js's own postMessage API. -->
13
+ <style>
14
+ :root { color-scheme: dark; --bg: #1f1f1f; --panel: #2a2a2a; --ink: #e8eaed; --muted: #9aa0a6; --accent: #8ab4f8; }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
17
+ body { display: grid; grid-template: auto 1fr / minmax(0, 3fr) minmax(0, 2fr); gap: 12px; padding: 12px; }
18
+ header { grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; }
19
+ header .n { font-weight: 600; } header .clock, header .timer { font-variant-numeric: tabular-nums; color: var(--muted); }
20
+ header .timer { color: var(--ink); font-size: 18px; } header .grow { flex: 1; }
21
+ button { height: 34px; padding: 0 14px; border: 0; border-radius: 17px; background: var(--panel); color: var(--ink); font: inherit; cursor: pointer; }
22
+ button:hover { background: #3c4043; } button.primary { background: var(--accent); color: #202124; }
23
+ .stage { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 6px; min-height: 0; }
24
+ .side { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); gap: 6px; min-height: 0; }
25
+ .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
26
+ .frame { position: relative; aspect-ratio: 16 / 9; background: #000; border-radius: 8px; overflow: hidden; max-height: 100%; }
27
+ .frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
28
+ .frame.end::after { content: 'End of the deck'; position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); background: #000; }
29
+ .notes { overflow: auto; background: var(--panel); border-radius: 8px; padding: 14px 16px; font-size: 22px; line-height: 1.45; white-space: pre-wrap; }
30
+ .notes:empty::before { content: 'No speaker notes on this slide.'; color: var(--muted); font-size: 16px; }
31
+ .gone { grid-column: 1 / -1; display: grid; place-items: center; color: var(--muted); }
32
+ @media (max-width: 760px) { body { grid-template: auto auto 1fr / 1fr; } .side { grid-template-rows: auto auto auto minmax(160px, 1fr); } }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <header>
37
+ <span class="n" id="n">Slide –</span>
38
+ <button id="prev" title="Previous (←)">Previous</button><button id="next" class="primary" title="Next (→ or space)">Next</button>
39
+ <span class="grow"></span>
40
+ <span class="timer" id="timer" title="Time since this window opened (click to restart)">0:00</span><span class="clock" id="clock"></span>
41
+ </header>
42
+ <section class="stage"><span class="label">Now</span><div class="frame" id="cur"></div></section>
43
+ <section class="side"><span class="label">Next</span><div class="frame" id="nxt"></div><span class="label">Notes</span><div class="notes" id="notes"></div></section>
44
+ <script>
45
+ (() => {
46
+ const $ = id => document.getElementById(id);
47
+ const deck = new URLSearchParams(location.search).get('deck') || '';
48
+ if (!/^\/(p|e)\/[^/]+\/[^/]+\/(~[\w.-]+\/)?$/.test(deck) || !window.opener) { // only a shared deck's own address, opened by that deck
49
+ document.body.innerHTML = '<p class="gone">Open speaker notes from a shared deck (press S while it is shown).</p>'; return;
50
+ }
51
+ const frame = (host) => { const f = document.createElement('iframe'); f.src = deck + '?speaker#/0'; f.setAttribute('tabindex', '-1'); host.append(f); return f; };
52
+ const cur = frame($('cur')), nxt = frame($('nxt'));
53
+ const tell = (f, method, args = []) => f.contentWindow?.postMessage(JSON.stringify({ method, args }), '*'); // reveal.js's postMessage API
54
+ let state = null;
55
+ const show = () => {
56
+ if (!state) return;
57
+ $('n').textContent = `Slide ${state.n} of ${state.total}`;
58
+ $('notes').textContent = state.notes || '';
59
+ tell(cur, 'slide', [state.h, state.v, state.f]);
60
+ tell(nxt, 'slide', [state.h, state.v, state.f]); tell(nxt, 'next');
61
+ $('nxt').classList.toggle('end', !!state.last);
62
+ };
63
+ [cur, nxt].forEach(f => f.addEventListener('load', () => setTimeout(show, 300)));
64
+ addEventListener('message', e => {
65
+ if (e.source !== window.opener) return;
66
+ let m = e.data; if (typeof m === 'string') { try { m = JSON.parse(m); } catch { return; } }
67
+ if (m?.gs === 'speaker' && m.state) { state = m.state; show(); }
68
+ });
69
+ const go = dir => window.opener?.postMessage({ gs: 'speaker-nav', dir }, '*');
70
+ $('prev').onclick = () => go('prev'); $('next').onclick = () => go('next');
71
+ addEventListener('keydown', e => {
72
+ if (['ArrowRight', 'ArrowDown', 'PageDown', ' '].includes(e.key)) { e.preventDefault(); go('next'); }
73
+ if (['ArrowLeft', 'ArrowUp', 'PageUp'].includes(e.key)) { e.preventDefault(); go('prev'); }
74
+ });
75
+ let t0 = Date.now();
76
+ $('timer').onclick = () => { t0 = Date.now(); };
77
+ const pad = n => String(n).padStart(2, '0');
78
+ setInterval(() => {
79
+ const s = Math.floor((Date.now() - t0) / 1000), h = Math.floor(s / 3600);
80
+ $('timer').textContent = (h ? `${h}:${pad(Math.floor(s / 60) % 60)}` : `${Math.floor(s / 60)}`) + ':' + pad(s % 60);
81
+ $('clock').textContent = new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
82
+ }, 1000);
83
+ window.opener.postMessage({ gs: 'speaker-ready' }, '*');
84
+ setInterval(() => { if (!window.opener || window.opener.closed) document.body.innerHTML = '<p class="gone">The deck was closed.</p>'; }, 2000);
85
+ })();
86
+ </script>
87
+ </body>
88
+ </html>
@@ -0,0 +1,6 @@
1
+ {
2
+ // Type-checks the editor's plain JavaScript (no build step): `npx -p typescript@5.6.3 tsc -p editor`.
3
+ // The editor's files are classic scripts sharing one global scope, so they are checked together, as the page loads them.
4
+ "compilerOptions": { "allowJs": true, "checkJs": true, "noEmit": true, "target": "es2022", "module": "es2022", "moduleResolution": "bundler", "lib": ["es2023", "dom", "dom.iterable"], "strict": false, "skipLibCheck": true },
5
+ "include": ["globals.d.ts", "data/*.js", "deck/shapes.js", "js/*.js", "share/*.js", "lib/*.js"]
6
+ }
glideshow/figure.py ADDED
@@ -0,0 +1,33 @@
1
+ """For a figure script: save a matplotlib figure for a slide, and beside it where its text is, so `glideshow check` can
2
+ see inside the picture (labels overlapping or cut off at its edge, text too small on the slide, missing glyphs).
3
+
4
+ from glideshow.figure import save
5
+ save(fig, "assets/curves.png") # assets/curves.png and assets/curves.png.json
6
+
7
+ Needs nothing but the matplotlib the script already uses."""
8
+ import json, re, warnings
9
+ from pathlib import Path
10
+
11
+
12
+ def save(fig, out, dpi: int = 384, **kw):
13
+ """fig.savefig(out) at 2× the slide's pixels (a figure 1 in wide fills 192 px of slide), then OUT.json: the figure's
14
+ size in inches and each visible piece of text: its words, size in pt and box as shares of the figure (0–1, from the
15
+ top left). Glyphs the font lacks are listed too (matplotlib draws them as empty boxes)."""
16
+ from matplotlib.text import Text
17
+ texts, draw = {}, Text.draw
18
+ def note(self, renderer, *a, **k): # each piece of text as it is drawn (so not the labels of ticks outside the axes' range)
19
+ done = draw(self, renderer, *a, **k)
20
+ if self.get_visible() and self.get_text().strip():
21
+ b, W, H = self.get_window_extent(renderer), fig.bbox.width, fig.bbox.height
22
+ if b.width > 0 and b.height > 0:
23
+ texts[id(self)] = {"text": self.get_text()[:80], "pt": round(self.get_fontsize(), 2),
24
+ "x": round(b.x0 / W, 4), "y": round(1 - b.y1 / H, 4), "w": round(b.width / W, 4), "h": round(b.height / H, 4)}
25
+ return done
26
+ with warnings.catch_warnings(record=True) as caught:
27
+ warnings.simplefilter("always")
28
+ Text.draw = note
29
+ try: fig.savefig(out, dpi=dpi, **kw)
30
+ finally: Text.draw = draw
31
+ missing = sorted({chr(int(m.group(1))) for w in caught if (m := re.search(r"Glyph (\d+)\b.*missing from", str(w.message)))})
32
+ w_in, h_in = fig.get_size_inches()
33
+ Path(str(out) + ".json").write_text(json.dumps({"inches": [round(w_in, 3), round(h_in, 3)], "texts": list(texts.values()), "missing": missing}))
glideshow/fonts.py ADDED
@@ -0,0 +1,63 @@
1
+ """Fonts for the font menu: those installed on this Mac and the Google Fonts catalogue (fetched monthly, snapshot offline)."""
2
+
3
+ import json, os, re, time, urllib.request
4
+ from pathlib import Path
5
+
6
+ from .decks import EDITOR
7
+ from .osutil import font_dirs
8
+
9
+ # Fonts installed on this computer, for the font menu's "Your fonts" (Linux keeps them in subfolders).
10
+ FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2"}
11
+
12
+
13
+ def local_fonts() -> list:
14
+ return sorted((f for d in font_dirs() if d.is_dir() for f in d.rglob("*") if f.suffix.lower() in FONT_EXTS and f.is_file()), key=lambda f: f.name.lower())
15
+
16
+
17
+
18
+ # The Google Fonts catalogue for the font menu: fetched once a month into ~/.glideshow/fonts.json, with editor/fonts.json
19
+ # (a snapshot) as the offline fallback. Rows: [family, category, styles, popularity(, weight)], where styles is a bit
20
+ # set of the faces available (1 = 400, 2 = 700, 4 = 400 italic, 8 = 700 italic) and weight the lightest upright
21
+ # face of a family without 400.
22
+ FONTS_CACHE = Path(os.environ.get("GLIDESHOW_CACHE") or Path.home() / ".glideshow") / "fonts.json" # GLIDESHOW_CACHE: tests use a temp dir
23
+ FONTS_URL = "https://fonts.google.com/metadata/fonts"
24
+ FONT_CATS = {"Sans Serif": "sans", "Serif": "serif", "Display": "display", "Handwriting": "handwriting", "Monospace": "mono"}
25
+
26
+
27
+ def distil_fonts(text: str) -> list:
28
+ meta = json.loads(text[text.index("{"):]) # the response starts with an anti-JSON-hijacking prefix
29
+ rows = []
30
+ for f in meta.get("familyMetadataList", []):
31
+ name = f.get("family", "")
32
+ if not re.fullmatch(r"[A-Za-z0-9 \-'.&+]{1,60}", name):
33
+ continue
34
+ faces = set((f.get("fonts") or {}).keys())
35
+ bits = sum(b for k, b in (("400", 1), ("700", 2), ("400i", 4), ("700i", 8)) if k in faces)
36
+ row = [name, FONT_CATS.get(f.get("category", ""), "display"), bits, int(f.get("popularity") or 9999)]
37
+ if not bits & 1:
38
+ ups = sorted(int(k) for k in faces if k.isdigit())
39
+ if not ups:
40
+ continue
41
+ row.append(ups[0])
42
+ rows.append(row)
43
+ return sorted(rows, key=lambda r: r[0].lower())
44
+
45
+
46
+ def font_catalogue() -> list:
47
+ try:
48
+ if time.time() - FONTS_CACHE.stat().st_mtime < 30 * 86400:
49
+ return json.loads(FONTS_CACHE.read_text())
50
+ except (OSError, ValueError):
51
+ pass
52
+ if os.environ.get("GLIDESHOW_OFFLINE"):
53
+ return json.loads((EDITOR / "data" / "fonts.json").read_text())
54
+ try:
55
+ with urllib.request.urlopen(FONTS_URL, timeout=8) as r:
56
+ rows = distil_fonts(r.read(20_000_000).decode("utf-8"))
57
+ if len(rows) > 500:
58
+ FONTS_CACHE.parent.mkdir(exist_ok=True)
59
+ FONTS_CACHE.write_text(json.dumps(rows, separators=(",", ":")))
60
+ return rows
61
+ except Exception:
62
+ pass
63
+ return json.loads((EDITOR / "data" / "fonts.json").read_text())