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,318 @@
1
+ /* Spelling: the browser's own spellcheck underlines words while editing text and notes (Tools → Spelling). */
2
+ const spellOn = () => stored('slides-spellcheck') !== 'off';
3
+ function setSpelling(on) {
4
+ store('slides-spellcheck', on ? undefined : 'off');
5
+ $('#ed-notes').spellcheck = on;
6
+ if (editing) { const c = content(editing); c.spellcheck = on; c.blur(); c.focus(); } // re-focus redraws the underlines
7
+ }
8
+ $('#ed-notes').spellcheck = spellOn();
9
+
10
+ /* The filmstrip has keyboard focus after a thumbnail click, until something else is clicked. */
11
+ thumbs.tabIndex = -1;
12
+ const filmFocused = () => document.activeElement === thumbs;
13
+
14
+ /* ⌃⇧F hides the title and menu rows (Slides' compact controls). */
15
+ function toggleCompact() { document.body.classList.toggle('compact'); fit(); }
16
+
17
+ /* ⌘S: saving is automatic; flush any pending save and confirm it, as Slides does. */
18
+ async function saveNow() {
19
+ if (saveTimer) { clearTimeout(saveTimer); await save(); } else setStatus('All changes saved', 'cloud_done');
20
+ $('#ed-status').classList.remove('flash'); void $('#ed-status').offsetWidth; $('#ed-status').classList.add('flash');
21
+ }
22
+ /* ⌘P: reveal's PDF print layout in a new tab, then the browser's print dialog. */
23
+ /* File → Download → PNG image: the current slide at 1920×1080, drawn by the browser itself (the slide's HTML and the
24
+ deck's CSS inside an SVG foreignObject, pictures inlined as data URLs). Web fonts fall back to system fonts. */
25
+ const toDataURL = async src => { try { const b = await (await fetch(src)).blob(); return await new Promise(r => { const f = new FileReader(); f.onload = () => r(f.result); f.readAsDataURL(b); }); } catch { return src; } };
26
+ async function slidePNG(s = slides[cur]) {
27
+ const c = s.cloneNode(true);
28
+ c.querySelectorAll('[contenteditable]').forEach(e => e.removeAttribute('contenteditable'));
29
+ c.querySelectorAll('aside.notes, .katex-mathml').forEach(e => e.remove()); // notes aren't drawn; KaTeX's MathML twin is only for screen readers
30
+ c.querySelectorAll('iframe').forEach(f => f.replaceWith(Object.assign(div(''), { style: 'width:100%;height:100%;background:#f1f3f4' })));
31
+ for (const img of c.querySelectorAll('img')) img.setAttribute('src', await toDataURL(img.src));
32
+ for (const v of c.querySelectorAll('video')) { const p = v.getAttribute('poster'); v.replaceWith(Object.assign(document.createElement('img'), { src: p ? await toDataURL(new URL(p, location.href).href) : '', style: 'width:100%;height:100%;object-fit:contain;background:#000' })); }
33
+ const bg = s.dataset.backgroundImage ? await toDataURL(new URL(s.dataset.backgroundImage, location.href).href) : '';
34
+ c.style.cssText += `;position:absolute;left:0;top:0;width:${W}px;height:${H}px;display:block;background:${s.dataset.backgroundColor || '#fff'}${bg ? ` url("${bg}") center/cover` : ''}`;
35
+ const css = BASE_CSS + '\n' + $('#ed-deck-theme-live').textContent;
36
+ const NS = 'http://www.w3.org/1999/xhtml', root = document.createElementNS(NS, 'div'), st = document.createElementNS(NS, 'style'), box = document.createElementNS(NS, 'div');
37
+ root.className = 'reveal'; root.style.cssText = `width:${W}px;height:${H}px;font-family:Arial,sans-serif`;
38
+ const katexCSS = c.querySelector('.katex') ? await fetch(document.querySelector('link[href*="katex"]')?.href || 'data:,').then(r => r.text()).catch(() => '') : ''; // its layout (fonts fall back)
39
+ st.textContent = BASE_CSS + '\n' + $('#ed-deck-theme-live').textContent + '\n' + katexCSS; // the serializer escapes it for XML
40
+ box.className = 'slides'; box.style.cssText = `position:relative;width:${W}px;height:${H}px`; box.append(c); root.append(st, box);
41
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><foreignObject width="100%" height="100%">${new XMLSerializer().serializeToString(root)}</foreignObject></svg>`;
42
+ const img = new Image(); img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
43
+ await img.decode();
44
+ const cv = Object.assign(document.createElement('canvas'), { width: W, height: H }); cv.getContext('2d').drawImage(img, 0, 0);
45
+ return new Promise(r => cv.toBlob(r, 'image/png'));
46
+ }
47
+ async function downloadSlidePNG() {
48
+ const blob = await slidePNG(), a = document.createElement('a');
49
+ a.href = URL.createObjectURL(blob); a.download = `${($('#ed-title').value.trim() || 'slide').replace(/[^\w -]+/g, '')} - ${cur + 1}.png`; a.click();
50
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
51
+ }
52
+ $('#ed-app-icon').onclick = async e => { e.preventDefault(); await flushSave(); if (!draftEdited()) return leaveDraft(); showDraftDlg(true); };
53
+ /* Leaving an untitled draft: it won't be saved, so ask, as Slides asks before discarding. */
54
+ const draftDlg = $('#ed-draftdlg');
55
+ function showDraftDlg(on) { draftDlg.hidden = !on; if (on) $('#ed-draft-name').focus(); }
56
+ async function leaveDraft() { if (isDraft) await fetch('/api/discard', { method: 'POST' }).catch(() => {}); location.href = '/'; }
57
+ $('#ed-draft-discard').onclick = () => leaveDraft();
58
+ $('#ed-draft-name').onclick = () => { showDraftDlg(false); $('#ed-title').focus(); };
59
+ draftDlg.querySelector('.sc-close').onclick = () => showDraftDlg(false);
60
+ addEventListener('beforeunload', e => { if (draftEdited()) e.preventDefault(); }); // the browser's own "Leave site?" when closing the window
61
+
62
+ async function trashDeck() { // File → Move to trash: the server opens another deck first, then the home screen shows what's left
63
+ if (!confirm('Move this deck to the Trash?')) return;
64
+ await flushSave();
65
+ const j = await (await fetch('/api/deckop', { method: 'POST', body: JSON.stringify({ path: deckPath, op: 'delete' }) })).json();
66
+ if (j.error) return setStatus(j.error, 'error');
67
+ location.href = '/';
68
+ }
69
+ /* File → Version history, as Slides: the deck's git commits (all, or only those that changed this slide); clicking one
70
+ shows that version read-only in the canvas and filmstrip, "Restore this version" brings it back as a new commit. */
71
+ let histView = null, histRev = null, histList = []; // while previewing: the real slides, and the version shown
72
+ if ($('#ed-merge-bar')) { // (a page from before the bar, kept by a cache, has none)
73
+ $('#ed-merge-close').onclick = () => { $('#ed-merge-bar').hidden = true; };
74
+ $('#ed-merge-history').onclick = () => { $('#ed-merge-bar').hidden = true; showHistory(true); };
75
+ }
76
+ async function showHistory(on) {
77
+ closeMenu();
78
+ $('#ed-history').hidden = !on;
79
+ if (on) { stopEdit(); fmt.hidden = true; $('#ed-trans').hidden = true; $('#ed-theme').hidden = true; $('#ed-comments').hidden = true; await listHistory(); } else endVersionPreview();
80
+ fit();
81
+ }
82
+ async function listHistory() {
83
+ const list = histList = await fetch('/api/history' + ($('#ed-hist-scope').value ? `?slide=${(histView?.cur ?? cur) + 1}` : '')).then(r => r.json()).catch(() => []);
84
+ const row = (title, sub, rev, who, url) => { const r = div('hist-row' + ((rev ?? null) === histRev ? ' on' : '')); r.innerHTML = '<b></b><small></small><i class="who"></i>'; r.firstChild.textContent = title; r.children[1].textContent = sub;
85
+ if (who) r.lastChild.textContent = who; else r.lastChild.remove(); // the commit's author, as Slides names the editor
86
+ if (rev) { const m = document.createElement('button'); m.className = 'hist-more icon'; m.textContent = 'more_vert'; m.title = 'More actions';
87
+ m.onclick = e => { e.stopPropagation(); openMenu(m, [{ label: 'Name this version', icon: 'edit', run: () => nameVersion(rev) }, { label: 'Restore this version', icon: 'restore', run: async () => { await previewVersion(rev); $('#ed-hist-restore').click(); } },
88
+ ...(url ? ['-', { label: 'View on GitHub', icon: 'open_in_new', run: () => window.open(url, '_blank', 'noopener') }] : [])]); }; // pushed commits, when origin is on GitHub
89
+ r.append(m); }
90
+ r.onclick = () => rev ? previewVersion(rev) : endVersionPreview(); return r; };
91
+ const fmtT = t => new Date(t * 1000).toLocaleString([], { month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' });
92
+ $('#ed-hist-list').replaceChildren(row('Current version', 'what you are editing', null), ...list.map(c => row(c.name || fmtT(c.time), c.name ? `${fmtT(c.time)} · ${c.message}` : c.message, c.rev, c.author, c.url)),
93
+
94
+ ...(list.length ? [] : [Object.assign(div('hist-none'), { textContent: 'No saved versions: this deck is not in a git repo (glideshow serve DECK --init).' })]));
95
+ $('#ed-hist-restore').hidden = !histRev;
96
+ }
97
+ async function previewVersion(rev) {
98
+ const { html } = await fetch('/api/version?rev=' + rev).then(r => r.json());
99
+ if (!histView) histView = { slides, cur };
100
+ histRev = rev; slides = parseSlides(new DOMParser().parseFromString(html, 'text/html'));
101
+ if ($('#ed-hist-highlight').checked && histList[histList.findIndex(c => c.rev === rev) + 1]) { // Highlight changes: outline what differs from the version before (the first version has none)
102
+ const i = histList.findIndex(c => c.rev === rev), prev = histList[i + 1] ? parseSlides(new DOMParser().parseFromString((await fetch('/api/version?rev=' + histList[i + 1].rev).then(r => r.json())).html, 'text/html')) : [];
103
+ const key = b => cleanBox(b).outerHTML;
104
+ slides.forEach((sl, k) => { const was = new Set(prev[k] ? [...prev[k].querySelectorAll(':scope > .box')].map(key) : []);
105
+ sl.querySelectorAll(':scope > .box').forEach(b => { if (!was.has(key(b))) b.classList.add('hist-changed'); }); });
106
+ }
107
+ cur = Math.min(histView.cur, slides.length - 1); sel.clear(); document.body.classList.add('history-mode'); render(); listHistory();
108
+ }
109
+ function endVersionPreview() {
110
+ if (!histView) return;
111
+ ({ slides, cur } = histView); histView = histRev = null; document.body.classList.remove('history-mode'); render(); if (!$('#ed-history').hidden) listHistory();
112
+ }
113
+ $('#ed-hist-close').onclick = () => showHistory(false);
114
+ $('#ed-hist-scope').onchange = listHistory;
115
+ $('#ed-hist-highlight').onchange = () => histRev && previewVersion(histRev);
116
+ async function nameVersion(rev) {
117
+ const c = histList.find(x => x.rev === rev), name = prompt('Name this version', c?.name || '');
118
+ if (name === null) return;
119
+ await fetch('/api/name', { method: 'POST', body: JSON.stringify({ rev, name }) }); listHistory();
120
+ }
121
+ $('#ed-hist-restore').onclick = async () => {
122
+ const rev = histRev; endVersionPreview(); await flushSave();
123
+ const j = await (await fetch('/api/restore', { method: 'POST', body: JSON.stringify({ rev }) })).json();
124
+ if (j.error) return setStatus(j.error, 'error');
125
+ await load(true); setStatus('Version restored', 'history'); listHistory();
126
+ };
127
+ /* File → Download → Glideshow deck (.html): the deck as one file, pictures and reveal.js inside (lib/onefile.js), that opens
128
+ and presents in any browser; File → Open a deck file… brings one back (online: lib/library.js /api/importdeck). */
129
+ const ONEFILE = '/editor/lib/onefile.js'; // an ES module, loaded when needed
130
+ async function downloadOneFile() {
131
+ await flushSave(); setStatus('Making the file…', 'download');
132
+ const { pack } = await import(ONEFILE);
133
+ const html = await pack(deckHTML(), async p => { const r = await fetch(p); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; }); // relative to the deck (<base>)
134
+ const a = document.createElement('a');
135
+ a.href = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
136
+ a.download = ($('#ed-title').value.trim() || deckName()).replace(/[\\/:*?"<>|]+/g, '-') + '.html';
137
+ a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 30000);
138
+ setStatus('Downloaded: open the file in any browser to present it', 'download');
139
+ }
140
+ function openDeckFile() {
141
+ const pick = Object.assign(document.createElement('input'), { type: 'file', accept: '.html,.htm,text/html' });
142
+ pick.onchange = async () => {
143
+ const f = pick.files?.[0]; if (!f) return;
144
+ setStatus('Bringing the deck in…', 'upload_file');
145
+ const { unpack, bringIn } = await import(ONEFILE), { html, files } = await unpack(await f.text());
146
+ if (!/<section[\s>]/.test(html)) return setStatus("That file isn't a slide deck.", 'error');
147
+ const j = await bringIn({ name: f.name.replace(/\.html?$/i, ''), folder: deckPath.split('/').slice(0, -1).join('/'), html, files });
148
+ if (j.error) return setStatus(j.error, 'error');
149
+ await switchDeck({ path: j.path });
150
+ };
151
+ pick.click();
152
+ }
153
+ async function switchDeck(req) {
154
+ await flushSave();
155
+ const j = await (await fetch('/api/open', { method: 'POST', body: JSON.stringify(req) })).json();
156
+ if (j.error) return setStatus(j.error, 'error');
157
+ location.reload();
158
+ }
159
+ let deckPath = '';
160
+ const deckName = () => deckPath.split('/').pop() || 'deck';
161
+ fetch('/api/recent').then(r => r.json()).then(l => { deckPath = l.find(d => d.current)?.path || ''; }).catch(() => {});
162
+ /* File → Page setup, as Slides: 16:9, 16:10, 4:3 or a custom size in inches (192 px each; the width stays 10 in for the
163
+ presets). Stored as data-size="WxH" on the deck's .slides (absent = 1920x1080); objects keep their positions. */
164
+ const PAGE_SIZES = [['16:9', 1920, 1080], ['16:10', 1920, 1200], ['4:3', 1920, 1440]];
165
+ function applySize() {
166
+ const m = (deckDoc?.querySelector('.slides')?.dataset.size || '').match(/^(\d+)x(\d+)$/);
167
+ [W, H] = m ? [+m[1], +m[2]] : [1920, 1080];
168
+ const r = document.documentElement.style;
169
+ r.setProperty('--slide-w', W + 'px'); r.setProperty('--slide-h', H + 'px'); r.setProperty('--slide-ar', `${W} / ${H}`);
170
+ }
171
+ function setPageSize(w, h) {
172
+ const el = deckDoc.querySelector('.slides');
173
+ if (w === 1920 && h === 1080) delete el.dataset.size; else el.dataset.size = `${Math.round(w)}x${Math.round(h)}`;
174
+ applySize(); fit(); thumbEpoch++; render(); scheduleSave();
175
+ }
176
+ function pageSetup() {
177
+ const cur = PAGE_SIZES.find(([, w, h]) => w === W && h === H)?.[0] || 'custom';
178
+ askURL('Page setup: 16:9, 16:10, 4:3, or width x height in inches (e.g. 10 x 7.5)', v => {
179
+ const p = PAGE_SIZES.find(([n]) => n === v.trim()), m = v.match(/^\s*([\d.]+)\s*[x×]\s*([\d.]+)\s*$/);
180
+ if (p) setPageSize(p[1], p[2]); else if (m && +m[1] > 0 && +m[2] > 0) setPageSize(+m[1] * 192, +m[2] * 192); else setStatus('Page size: 16:9, 16:10, 4:3 or W x H inches', 'error');
181
+ }, cur === 'custom' ? `${W / 192} x ${H / 192}` : String(cur));
182
+ }
183
+ /* File → Import slides (or drop a .pptx on the slide): the server converts it with pptx_import.py and the slides
184
+ are added after the current one. */
185
+ async function importPPTX(file) {
186
+ setStatus('Importing ' + file.name + '…');
187
+ const j = await (await fetch('/api/importpptx', { method: 'POST', body: file })).json();
188
+ insertImported(j, file.name);
189
+ }
190
+ /* File → Import from Google Slides: the server fetches the deck through the Slides API (after `glideshow import-google --login`)
191
+ and converts it; pictures land in assets/. Google Fonts the deck uses are added to #deck-fonts. */
192
+ async function importGoogle(url) {
193
+ setStatus('Importing from Google Slides…');
194
+ const j = await (await fetch('/api/importgslides', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) })).json();
195
+ if (!j.error && j.fonts?.length) await fontsReady().then(() => j.fonts.forEach(useGFont));
196
+ insertImported(j, j.title || 'Google Slides');
197
+ }
198
+ function insertImported(j, label) {
199
+ if (j.error) return setStatus('Import failed: ' + j.error.split('\n')[0], 'error');
200
+ const t = document.createElement('template'); t.innerHTML = j.slides.join('');
201
+ const added = [...t.content.children].filter(e => e.tagName === 'SECTION');
202
+ added.forEach(s => s.querySelectorAll('.box').forEach(b => { b.id ||= uid(); }));
203
+ slides.splice(cur + 1, 0, ...added); go(cur + 1); commit();
204
+ setStatus(`Imported ${added.length} slide${added.length === 1 ? '' : 's'} from ${label}` + (j.log?.length ? ` (${j.log.length} to check: see the terminal)` : ''));
205
+ if (j.log?.length) console.warn('Google Slides import:\n' + j.log.join('\n'));
206
+ }
207
+ $('#ed-pptx-file').onchange = e => { const f = e.target.files[0]; e.target.value = ''; if (f) importPPTX(f); };
208
+ async function printDeck() {
209
+ if (saveTimer) { clearTimeout(saveTimer); await save(); }
210
+ const w = window.open('deck.html?print-pdf', '_blank');
211
+ // print once reveal has laid out the pages and the fonts (KaTeX, Google Fonts, icons) are in; 4 s at most
212
+ w?.addEventListener('load', () => {
213
+ let done = false; const go = () => { if (!done) { done = true; w.document.fonts.ready.then(() => setTimeout(() => w.print(), 300)); } };
214
+ w.Reveal?.on?.('pdf-ready', go); setTimeout(go, 4000);
215
+ });
216
+ }
217
+
218
+ /* Toolbar extras from the fidelity pass: + new slide, print, select tool, Background, hide-menus ^, filmstrip toggle. */
219
+ $('#ed-new-slide-btn').onclick = () => $('#ed-new-slide').click();
220
+ menuButton($('#ed-new-slide-more'), newSlideItems);
221
+
222
+ $('#ed-print').onclick = printDeck;
223
+ $('#ed-select-tool').onclick = () => { stopEdit(); };
224
+ $('#ed-background').onclick = () => showBgDialog(true);
225
+ $('#ed-collapse').onclick = toggleCompact;
226
+ $('#ed-film-toggle').onclick = () => { document.body.classList.toggle('film-off'); store('slides-film-off', document.body.classList.contains('film-off') ? '1' : undefined); fit(); };
227
+ if (stored('slides-film-off')) document.body.classList.add('film-off');
228
+
229
+ /* Share (the button beside Slideshow, or File → Share): a link to the deck at glideshow.org/p/… (an entry in the library's
230
+ published.json; `glideshow publish`) that always shows its latest version; turning it off takes it down. Who can open it: anyone with the link, or only the people listed (they sign in with Google or GitHub);
231
+ it can expire on a date. */
232
+ const shareDlg = $('#ed-sharedlg');
233
+ async function showShare(on = true) {
234
+ closeMenu(); shareDlg.hidden = !on; if (!on) return;
235
+ $('#ed-share-title').textContent = deckDoc?.title || 'Untitled deck';
236
+ shareState(await fetch('/api/publish').then(r => r.json()).catch(() => ({ url: '' })));
237
+ }
238
+ function shareState({ url = '', inLibrary = true, error = '', busy = '', access = 'link', people = [], editors = [], expires = '' }) {
239
+ $('#ed-share-on').checked = !!url; $('#ed-share-on').disabled = !!busy || !inLibrary;
240
+ $('#ed-share-accessrow').hidden = !url; $('#ed-share-access').value = access; $('#ed-share-peoplerow').hidden = access !== 'people';
241
+ if (document.activeElement !== $('#ed-share-people')) { peopleField.list = people.slice(); peopleField.draw(); }
242
+ if (document.activeElement !== $('#ed-share-editors')) { editorsField.list = editors.slice(); editorsField.draw(); }
243
+ $('#ed-share-expires').value = expires; $('#ed-share-noexp').hidden = !expires;
244
+ $('#ed-share .icon').textContent = !url ? 'lock' : access === 'people' ? 'group' : 'public';
245
+ $('#ed-share-linkrow').hidden = !url; $('#ed-share-url').value = url;
246
+ $('#ed-share').classList.toggle('on', !!url);
247
+ const st = $('#ed-share-status'); st.classList.toggle('err', !!error);
248
+ st.textContent = error || busy || (!inLibrary ? (window.GLIDESHOW_CLOUD ? 'Give the presentation a title first.' : 'Only decks in your library (~/decks) can be shared.') : '');
249
+ }
250
+ async function publishDeck(op) {
251
+ await flushSave();
252
+ shareState({ url: op === 'unpublish' ? '' : $('#ed-share-url').value, busy: op === 'unpublish' ? 'Taking it down…' : 'Sharing…' });
253
+ const j = await fetch('/api/publish', { method: 'POST', body: JSON.stringify({ op }) }).then(r => r.json()).catch(e => ({ error: String(e) }));
254
+ shareState(j.error ? { url: op === 'unpublish' ? $('#ed-share-url').value : '', error: j.error } : j);
255
+ if (j.url && op === 'publish') $('#ed-share-status').textContent = j.access === 'people' ? 'Shared: the people listed can open it.' : 'Shared: anyone with the link can view it.';
256
+ }
257
+ /* Who can open it, and until when: the link stays the same. Changes go one at a time, in order (picking "Only people I
258
+ add" and then typing the names are two), so an earlier one never lands last; "Saved." waits for the last of them. */
259
+ let shareQueue = Promise.resolve(), sharePending = 0;
260
+ function shareSettings(change) {
261
+ sharePending++; $('#ed-share-status').classList.remove('err'); $('#ed-share-status').textContent = 'Saving…';
262
+ return (shareQueue = shareQueue.then(async () => {
263
+ const j = await fetch('/api/publish', { method: 'POST', body: JSON.stringify({ op: 'settings', ...change }) }).then(r => r.json()).catch(e => ({ error: String(e) }));
264
+ const last = !--sharePending;
265
+ if (j.error || last) shareState(j.error ? { url: $('#ed-share-url').value, error: j.error } : j);
266
+ if (!j.error && last) $('#ed-share-status').textContent = 'Saved.';
267
+ }));
268
+ }
269
+ $('#ed-share-access').onchange = e => { peopleField.take(); shareSettings({ access: e.target.value, people: peopleField.list.join(', ') }); };
270
+ /* People, each a pill (Who can open it, and Can edit): Enter, a comma, a space, Tab, a pasted list or leaving the field
271
+ turns what's typed into pills; a pill's × or Backspace in the empty field takes one off. Each change is saved at once.
272
+ Something that isn't an email address, @domain or GitHub username is kept, outlined red, so it can be seen and removed. */
273
+ const PERSON = /^(@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}|[^\s@,;]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}|[a-z0-9](-?[a-z0-9]){0,38})$/i;
274
+ function pillField(boxSel, inputSel, save, empty) {
275
+ const box = $(boxSel), input = $(inputSel), f = { list: /** @type {string[]} */ ([]), draw, take };
276
+ function draw() {
277
+ box.querySelectorAll('.person').forEach(c => c.remove());
278
+ f.list.forEach((who, i) => {
279
+ const chip = div('person' + (PERSON.test(who) ? '' : ' bad')), x = document.createElement('button');
280
+ chip.append(Object.assign(document.createElement('span'), { textContent: who }));
281
+ chip.title = PERSON.test(who) ? who : `${who}: not an email address, @domain or GitHub username`;
282
+ x.className = 'icon'; x.type = 'button'; x.textContent = 'close'; x.title = `Remove ${who}`;
283
+ x.onmousedown = e => e.preventDefault(); // the field keeps focus (and what's half typed in it)
284
+ x.onclick = e => { e.stopPropagation(); f.list.splice(i, 1); draw(); save(); input.focus(); };
285
+ chip.append(x); box.insertBefore(chip, input);
286
+ });
287
+ input.placeholder = f.list.length ? 'Add another' : empty;
288
+ }
289
+ /** What's typed into the field, as pills. @returns {boolean} whether the list changed */
290
+ function take() {
291
+ const typed = input.value.split(/[\s,;]+/).map(x => x.trim().toLowerCase()).filter(Boolean);
292
+ if (!typed.length) return false; // (no redraw: a click on a pill's × is under way when the field loses focus)
293
+ input.value = '';
294
+ const before = f.list.length;
295
+ for (const who of typed) if (!f.list.includes(who)) f.list.push(who);
296
+ draw(); return f.list.length !== before;
297
+ }
298
+ input.addEventListener('keydown', e => {
299
+ e.stopPropagation();
300
+ if (['Enter', ',', ';', ' ', 'Tab'].includes(e.key) && input.value.trim()) { if (e.key !== 'Tab') e.preventDefault(); if (take()) save(); }
301
+ else if (e.key === 'Backspace' && !input.value && f.list.length) { e.preventDefault(); f.list.pop(); draw(); save(); }
302
+ });
303
+ input.addEventListener('paste', () => setTimeout(() => { if (/[\s,;]/.test(input.value) && take()) save(); }));
304
+ input.addEventListener('blur', () => { if (take()) save(); });
305
+ box.addEventListener('mousedown', e => { if (e.target === e.currentTarget) { e.preventDefault(); input.focus(); } });
306
+ return f;
307
+ }
308
+ const peopleField = pillField('#ed-share-chips', '#ed-share-people', () => shareSettings({ people: peopleField.list.join(', ') }), 'Emails, @harvard.edu for everyone there, or GitHub usernames');
309
+ const editorsField = pillField('#ed-share-echips', '#ed-share-editors', () => shareSettings({ editors: editorsField.list.join(', ') }), 'Emails, @domain or GitHub usernames of people who can edit');
310
+ $('#ed-share-expires').onchange = e => shareSettings({ expires: e.target.value });
311
+ $('#ed-share-noexp').onclick = () => shareSettings({ expires: '' });
312
+ $('#ed-share').onclick = () => showShare(true);
313
+ $('#ed-share-on').onchange = e => publishDeck(e.target.checked ? 'publish' : 'unpublish');
314
+ $('#ed-share-copy').onclick = async () => { await navigator.clipboard?.writeText($('#ed-share-url').value).catch(() => {}); $('#ed-share-url').select(); $('#ed-share-status').textContent = 'Link copied.'; };
315
+
316
+ $('#ed-share-done').onclick = shareDlg.querySelector('.sc-close').onclick = () => showShare(false);
317
+ shareDlg.addEventListener('pointerdown', e => { if (e.target === shareDlg) showShare(false); });
318
+ addEventListener('keydown', e => { if (!shareDlg.hidden && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); showShare(false); } }, true);
@@ -0,0 +1,72 @@
1
+ /* ---------- find and replace (⌘F, ⇧⌘H) ---------- */
2
+
3
+ /* Searches the text of every box on every slide. Matches on the shown slide are highlighted with the CSS
4
+ Custom Highlight API, so the deck's markup is never touched. (A match split across formatting runs
5
+ isn't found.) */
6
+ let found = [], foundAt = 0;
7
+ /* Ignore diacritics maps each UTF-16 unit to its base letter (é → e), which keeps indices aligned. */
8
+ const baseChars = t => t.split('').map(ch => ch.normalize('NFD')[0]).join('');
9
+ function finder() {
10
+ const q = $('#ed-find-q').value, cs = $('#ed-find-case').checked, dia = $('#ed-find-dia').checked;
11
+ if (!q) return null;
12
+ let src = $('#ed-find-re').checked ? q.replace(/\\n/g, '\n').replace(/\\t/g, '\t') : q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ if (dia) src = baseChars(src);
14
+ try { return { re: new RegExp(src, 'g' + (cs ? '' : 'i')), dia }; } catch { return null; } // invalid regex: no matches
15
+ }
16
+ function search() {
17
+ const f = finder();
18
+ found = [];
19
+ if (f) slides.forEach((s, si) => boxes(s).forEach(b => {
20
+ const c = content(b); if (!c) return;
21
+ const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
22
+ for (let n; (n = /** @type {Text} */ (w.nextNode()));) {
23
+ const t = f.dia ? baseChars(n.data) : n.data;
24
+ for (const m of t.matchAll(f.re)) if (m[0].length) found.push({ si, b, n, i: m.index, len: m[0].length, m: [...m] });
25
+ }
26
+ }));
27
+ foundAt = Math.max(0, Math.min(foundAt, found.length - 1));
28
+ paintFound();
29
+ }
30
+ function paintFound() {
31
+ const q = $('#ed-find-q').value;
32
+ $('#ed-find .find-count').textContent = !q ? '' : found.length ? `${foundAt + 1} of ${found.length}` : 'No results';
33
+ if (!window.CSS?.highlights) return;
34
+ const range = f => { const r = new Range(); r.setStart(f.n, f.i); r.setEnd(f.n, f.i + f.len); return r; };
35
+ CSS.highlights.set('find', new Highlight(...found.filter(f => f.si === cur).map(range)));
36
+ const f = found[foundAt];
37
+ if (f && f.si === cur) CSS.highlights.set('find-current', new Highlight(range(f))); else CSS.highlights.delete('find-current');
38
+ }
39
+ function refreshFind() { if (!$('#ed-find').hidden) search(); }
40
+ function findStep(d) {
41
+ if (!found.length) return;
42
+ foundAt = (foundAt + d + found.length) % found.length;
43
+ if (found[foundAt].si !== cur) go(found[foundAt].si); else paintFound();
44
+ }
45
+ function replaceIn(matches, by) {
46
+ const re = $('#ed-find-re').checked;
47
+ const text = f => re ? by.replace(/\n/g, '\n').replace(/\t/g, '\t').replace(/\$(\d)/g, (_, d) => f.m[d] ?? '') : by;
48
+ for (const f of [...matches].sort((a, b) => a.n === b.n ? b.i - a.i : 0)) f.n.data = f.n.data.slice(0, f.i) + text(f) + f.n.data.slice(f.i + f.len);
49
+ boxes().filter(b => matches.some(f => f.b === b)).forEach(fitHeight);
50
+ commit();
51
+ }
52
+ function openFind() {
53
+ closeMenu();
54
+ const d = $('#ed-find'), q = $('#ed-find-q'), picked = editing ? getSelection().toString() : '';
55
+ d.hidden = false;
56
+ if (picked && !picked.includes('\n')) q.value = picked;
57
+ q.focus(); q.select(); foundAt = 0; search();
58
+ }
59
+ function closeFind() { $('#ed-find').hidden = true; CSS.highlights?.delete('find'); CSS.highlights?.delete('find-current'); }
60
+ $('#ed-find-q').oninput = () => { foundAt = 0; search(); };
61
+ $('#ed-find-case').onchange = $('#ed-find-re').onchange = $('#ed-find-dia').onchange = () => { foundAt = 0; search(); };
62
+ $('#ed-find-q').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); findStep(e.shiftKey ? -1 : 1); } };
63
+ $('#ed-find-next').onclick = () => findStep(1);
64
+ $('#ed-find-prev').onclick = () => findStep(-1);
65
+ $('#ed-find-one').onclick = () => { if (found[foundAt]) replaceIn([found[foundAt]], $('#ed-find-r').value); };
66
+ $('#ed-find-all').onclick = () => {
67
+ const n = found.length; if (!n) return;
68
+ replaceIn(found, $('#ed-find-r').value);
69
+ $('#ed-find .find-count').textContent = `Replaced ${n}`;
70
+ };
71
+ $('#ed-find .find-close').onclick = closeFind;
72
+ addEventListener('keydown', e => { if (!$('#ed-find').hidden && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeFind(); } }, true);
@@ -0,0 +1,101 @@
1
+ /* ---------- format options panel ---------- */
2
+
3
+ /* Right-hand panel as in Slides (Format → Format options). It edits the single selected box. */
4
+ const fmt = $('#ed-fmt'), F = id => $('#ed-f-' + id);
5
+ /* Slides shows geometry in inches: the 1920px-wide slide is 10 in. */
6
+ const PX_IN = 192, toIn = px => Math.round(px / PX_IN * 100) / 100, fromIn = v => v * PX_IN;
7
+ const fitMode = () => fmt.querySelector('[name=ed-f-fit]:checked')?.value;
8
+ function setPanel(open) { fmt.hidden = !open; if (open) { $('#ed-trans').hidden = true; $('#ed-theme').hidden = true; $('#ed-comments').hidden = true; } fit(); updatePanel(); }
9
+ function updatePanel() {
10
+ if (fmt.hidden) return;
11
+ const b = sel.size === 1 ? [...sel][0] : null;
12
+ fmt.querySelectorAll('input, select, button:not(#ed-fmt-close)').forEach(e => { e.disabled = !b; });
13
+ if (!b) return;
14
+ const g = geo(b), c = content(b), cs = getComputedStyle(c), centre = F('from').value === 'c';
15
+ F('w').value = toIn(g.w); F('h').value = toIn(g.h);
16
+ F('lock').checked = b.dataset.lock === 'aspect' || isMedia(b);
17
+ F('rot').value = rot(b);
18
+ F('x').value = toIn(centre ? g.x + g.w / 2 : g.x); F('y').value = toIn(centre ? g.y + g.h / 2 : g.y);
19
+ const mode = b.dataset.autofit || (b.dataset.shape ? 'none' : 'resize');
20
+ fmt.querySelectorAll('[name=ed-f-fit]').forEach(r => { r.checked = r.value === mode; });
21
+ ['pt', 'pb', 'pl', 'pr'].forEach((k, i) => { F(k).value = toIn(parseFloat(cs[['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'][i]])); });
22
+ F('shadow').checked = 'shadow' in b.dataset;
23
+ for (const [k, def] of Object.entries(SHADOW_UI)) { const v = b.dataset['shadow' + k[0].toUpperCase() + k.slice(1)] ?? def; F('sh-' + k).value = k === 'opacity' ? Math.round(v * 100) : v; if (F('sh-' + k + '-n')) F('sh-' + k + '-n').value = F('sh-' + k).value; }
24
+ const img = b.querySelector('.content > img'); fmt.classList.toggle('is-image', !!img);
25
+ const vid = b.querySelector('.content > video'); fmt.classList.toggle('is-video', !!vid);
26
+ if (vid) { const [, st, en] = trimOf(vid); F('v-start').value = st ? clock(st) : ''; F('v-end').value = en ? clock(en) : '';
27
+ F('v-auto').checked = vid.hasAttribute('data-autoplay'); F('v-mute').checked = vid.hasAttribute('muted'); F('v-loop').checked = vid.hasAttribute('loop'); }
28
+ if (img) { F('opacity').value = Math.round((img.style.opacity === '' ? 1 : +img.style.opacity) * 100); F('opacity-n').value = F('opacity').value; }
29
+ F('alt').value = b.querySelector('.content > img')?.alt || b.getAttribute('aria-label') || '';
30
+ }
31
+ const one = fn => () => { const b = [...sel][0]; if (sel.size !== 1) return; fn(b); drawOverlay(); commit(); };
32
+ F('w').onchange = one(b => {
33
+ const g = geo(b), w = Math.max(1, fromIn(+F('w').value) || g.w);
34
+ setGeo(b, { ...g, w, h: F('lock').checked && g.w ? g.h * w / g.w : g.h });
35
+ });
36
+ F('h').onchange = one(b => {
37
+ const g = geo(b), h = Math.max(0, Math.round(fromIn(+F('h').value || 0)));
38
+ setGeo(b, { ...g, h, w: F('lock').checked && g.h ? g.w * h / g.h : g.w });
39
+ if (!b.dataset.shape) b.dataset.minh = h;
40
+ });
41
+ F('lock').onchange = one(b => { if (F('lock').checked) b.dataset.lock = 'aspect'; else delete b.dataset.lock; });
42
+ F('rot').onchange = one(b => setRot(b, +F('rot').value || 0));
43
+ F('rotr').onclick = one(b => setRot(b, rot(b) + 90));
44
+ const flip = axis => one(b => {
45
+ const f = new Set(b.dataset.flip || ''); f.has(axis) ? f.delete(axis) : f.add(axis);
46
+ const v = ['h', 'v'].filter(a => f.has(a)).join('');
47
+ if (v) b.dataset.flip = v; else delete b.dataset.flip;
48
+ shadeBox(b);
49
+ });
50
+ F('fliph').onclick = flip('h'); F('flipv').onclick = flip('v');
51
+ F('x').onchange = F('y').onchange = one(b => {
52
+ const g = geo(b), c = F('from').value === 'c';
53
+ setGeo(b, { ...g, x: fromIn(+F('x').value) - (c ? g.w / 2 : 0), y: fromIn(+F('y').value) - (c ? g.h / 2 : 0) });
54
+ });
55
+ F('from').onchange = updatePanel;
56
+ F('fit').onchange = one(b => {
57
+ const v = fitMode();
58
+ if (v === (b.dataset.shape ? 'none' : 'resize')) delete b.dataset.autofit; else b.dataset.autofit = v;
59
+ fitHeight(b);
60
+ });
61
+ ['pt', 'pb', 'pl', 'pr'].forEach(k => { F(k).onchange = one(b => {
62
+ content(b).style.padding = ['pt', 'pr', 'pb', 'pl'].map(k => Math.round(Math.max(0, fromIn(+F(k).value || 0))) + 'px').join(' ');
63
+ fitHeight(b);
64
+ }); });
65
+ F('shadow').onclick = e => e.stopPropagation();
66
+ F('alt').onchange = one(b => { // alt text: the image's alt, or an aria-label on other boxes
67
+ const v = F('alt').value.trim(), img = b.querySelector('.content > img');
68
+ if (img) img.alt = v; else if (v) b.setAttribute('aria-label', v); else b.removeAttribute('aria-label');
69
+ });
70
+ /* Image → Adjustments → Opacity: the picture only (not its border or shadow), as an inline opacity on the <img>. */
71
+ const setImgOpacity = (b, v) => { const img = b.querySelector('.content > img'); if (!img) return; v = Math.max(0, Math.min(100, +v || 0)); img.style.opacity = v >= 100 ? '' : v / 100; if (!img.getAttribute('style')) img.removeAttribute('style'); F('opacity').value = F('opacity-n').value = v; };
72
+ F('opacity').oninput = () => { const b = [...sel][0]; if (b) setImgOpacity(b, F('opacity').value); };
73
+ F('opacity').onchange = F('opacity-n').onchange = one(b => setImgOpacity(b, document.activeElement === F('opacity-n') ? F('opacity-n').value : F('opacity').value));
74
+ F('adj-reset').onclick = one(b => setImgOpacity(b, 100));
75
+ /* Drop shadow controls, as Slides: changing one turns the shadow on. Defaults are left out of the deck. */
76
+ const SHADOW_UI = { color: '#000000', opacity: 0.3, angle: 90, distance: 6, blur: 8 };
77
+ for (const k of Object.keys(SHADOW_UI)) {
78
+ const key = 'shadow' + k[0].toUpperCase() + k.slice(1), apply = (b, raw) => {
79
+ const v = k === 'color' ? raw : k === 'opacity' ? Math.max(0, Math.min(100, +raw || 0)) / 100 : Math.max(0, +raw || 0);
80
+ if (String(v) === String(SHADOW_UI[k])) delete b.dataset[key]; else b.dataset[key] = v;
81
+ b.dataset.shadow = ''; F('shadow').checked = true; shadeBox(b);
82
+ };
83
+ F('sh-' + k).oninput = () => { const b = [...sel][0]; if (b) { apply(b, F('sh-' + k).value); if (F('sh-' + k + '-n')) F('sh-' + k + '-n').value = F('sh-' + k).value; } };
84
+ F('sh-' + k).onchange = one(b => apply(b, F('sh-' + k).value));
85
+ if (F('sh-' + k + '-n')) F('sh-' + k + '-n').onchange = one(b => { F('sh-' + k).value = F('sh-' + k + '-n').value; apply(b, F('sh-' + k + '-n').value); });
86
+ }
87
+ /* Video trim, as Slides' Start at / End at (m:ss or seconds): a #t=start,end media fragment on the src. */
88
+ const clock = t => { const s = Math.round(t % 60 * 10) / 10; return `${Math.floor(t / 60)}:${s < 10 ? '0' : ''}${s}`; };
89
+ const secs = v => { v = (v || '').trim(); if (!v) return 0; const p = v.split(':').map(Number); return p.some(isNaN) ? 0 : p.reduce((a, x) => a * 60 + x, 0); };
90
+ const trimOf = v => { const [base, frag = ''] = (v.getAttribute('src') || '').split('#'), m = frag.match(/^t=([\d.]*)(?:,([\d.]+))?$/); return [base, m ? +m[1] || 0 : 0, m && m[2] ? +m[2] : 0]; };
91
+ F('v-start').onchange = F('v-end').onchange = one(b => {
92
+ const v = b.querySelector('.content > video'); if (!v) return;
93
+ const [base] = trimOf(v), st = secs(F('v-start').value), en = secs(F('v-end').value);
94
+ v.setAttribute('src', base + (st || en ? `#t=${st}${en > st ? ',' + en : ''}` : ''));
95
+ });
96
+ for (const [id, attr] of [['v-auto', 'data-autoplay'], ['v-mute', 'muted'], ['v-loop', 'loop']])
97
+ F(id).onchange = one(b => { const v = b.querySelector('.content > video'); if (v) v.toggleAttribute(attr, F(id).checked); });
98
+ F('shadow').onchange = one(b => { if (F('shadow').checked) b.dataset.shadow = ''; else delete b.dataset.shadow; shadeBox(b); });
99
+ $('#ed-fmt-close').onclick = () => setPanel(false);
100
+ $('#ed-format-options').onclick = () => setPanel(fmt.hidden);
101
+ $('#ed-animate').onclick = () => { setTransPanel(true); addAnim(); };