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,202 @@
1
+ /* Insert → Word art, as Slides: one line of text drawn as filled (and optionally outlined) letters stretched to the
2
+ box; it starts in editing with "Word art" selected, and Enter finishes. */
3
+ function addWordArt() {
4
+ stopEdit();
5
+ const b = makeBox({ x: 560, y: 440, w: 800, h: 200, shape: 'wordart', html: 'Word art' });
6
+ b.style.setProperty('--fill', '#000000');
7
+ insert(b); renderShape(b); startEdit(b);
8
+ }
9
+ function addShape(shape, g, extra = {}) {
10
+ const line = /line$|^elbow$|^curve$|^path$/.test(shape), bent = /^(elbow|curve|path)$/.test(shape);
11
+ const b = makeBox(g || { x: 660, y: line && !bent ? 540 : 340, w: 600, h: line && !bent ? 0 : 400, shape });
12
+ b.dataset.shape = shape; Object.assign(b.dataset, extra.data || {}); if (extra.rot) setRot(b, extra.rot);
13
+ if (line) { b.style.setProperty('--stroke', '#333333'); b.style.setProperty('--sw', '4px'); }
14
+ else { // Slides' current default shape: light grey, 1pt grey outline, 14pt text
15
+ b.style.setProperty('--fill', '#eeeeee'); b.style.setProperty('--stroke', '#595959'); b.style.setProperty('--sw', `${Math.round(PT * 1000) / 1000}px`); // 1pt dark grey, as Slides
16
+ content(b).style.fontSize = Math.round(14 * PT * 100) / 100 + 'px';
17
+ }
18
+ renderShape(b);
19
+ insert(b);
20
+ return b;
21
+ }
22
+
23
+ /* Pictures larger than a slide can show are stored smaller, as Slides does: at most 2560 px on the long side (a 1920-wide
24
+ slide, zoomed in a little), same format. A 12-megapixel phone photo goes from about 4 MB to well under 1 MB, and the
25
+ editor decodes a sixth of the pixels. GIFs (animation), SVGs and anything that wouldn't get smaller stay as they are;
26
+ the photo's own rotation is kept (createImageBitmap applies it). */
27
+ const MAX_PICTURE = 2560;
28
+ /** @param {Blob} file */
29
+ async function shrinkPicture(file) {
30
+ if (!/^image\/(jpeg|png|webp)$/.test(file.type)) return file;
31
+ let bmp; try { bmp = await createImageBitmap(file); } catch { return file; }
32
+ const k = MAX_PICTURE / Math.max(bmp.width, bmp.height);
33
+ if (k >= 1) { bmp.close(); return file; }
34
+ const c = document.createElement('canvas'); c.width = Math.round(bmp.width * k); c.height = Math.round(bmp.height * k);
35
+ const x = c.getContext('2d'); x.imageSmoothingQuality = 'high'; x.drawImage(bmp, 0, 0, c.width, c.height); bmp.close();
36
+ const out = await new Promise(r => c.toBlob(r, file.type, 0.9));
37
+ return out && out.size < file.size ? out : file;
38
+ }
39
+
40
+ /* Replace image (keeps the box's place and size, as Slides) and Reset size (back to the picture's own proportions,
41
+ fitted as on insertion and kept centred). */
42
+ async function uploadImage(file) {
43
+ file = await shrinkPicture(file);
44
+ const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('svg+xml', 'svg');
45
+ return (await (await fetch('/api/upload?ext=' + ext, { method: 'POST', body: file })).json()).src;
46
+ }
47
+ async function replaceImage(b, file) {
48
+ const img = b.querySelector('.content > img'); if (!img) return;
49
+ setStatus('Uploading image…'); img.src = await uploadImage(file); setStatus(''); renderThumbs(); commit();
50
+ }
51
+ async function resetImageSize(b) {
52
+ const img = b.querySelector('.content > img'); if (!img) return;
53
+ await img.decode().catch(() => {});
54
+ let w = img.naturalWidth || 800, h = img.naturalHeight || 600; const k = Math.min(1, 1200 / w, 800 / h); w *= k; h *= k;
55
+ const g = geo(b); setGeo(b, { x: g.x + g.w / 2 - w / 2, y: g.y + g.h / 2 - h / 2, w, h }); drawOverlay(); commit();
56
+ }
57
+ $('#ed-replace-image').onclick = () => $('#ed-replace-file').click();
58
+ $('#ed-crop').onclick = () => cropping ? endCrop() : enterCrop([...sel][0]);
59
+ $('#ed-replace-file').onchange = async e => { const f = e.target.files[0], b = [...sel][0]; e.target.value = ''; if (f && b) await replaceImage(b, f); };
60
+
61
+ /* Videos, as Slides: MP4 / WebM / MOV copied to assets/, shown as a <video> in a .box.video. The editor shows the
62
+ poster (its first frame, captured on insert) with a play badge; decks play it with native controls, and reveal.js
63
+ starts it when the slide appears if it has data-autoplay. Format options → Video playback sets autoplay, mute, loop. */
64
+ async function videoPoster(src) {
65
+ const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src;
66
+ await new Promise(r => { v.onloadeddata = r; v.onerror = r; setTimeout(r, 4000); });
67
+ if (!v.videoWidth) return null;
68
+ v.currentTime = Math.min(0.1, v.duration || 0); await new Promise(r => { v.onseeked = r; setTimeout(r, 1500); });
69
+ const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight; cv.getContext('2d').drawImage(v, 0, 0);
70
+ const blob = await new Promise(r => cv.toBlob(r, 'image/jpeg', 0.85)).then(b => b && shrinkPicture(b)); // a 4K video's frame is a big picture too
71
+ return blob ? { poster: (await (await fetch('/api/upload?ext=jpg', { method: 'POST', body: blob })).json()).src, w: v.videoWidth, h: v.videoHeight } : null;
72
+ }
73
+ async function addVideo(file, at) {
74
+ const mb = file.size / 1e6;
75
+ setStatus(mb > 50 ? `Uploading a large video (${Math.round(mb)} MB): the deck folder grows by that much…` : 'Uploading video…');
76
+ const ext = /quicktime/.test(file.type) ? 'mov' : (file.type.split('/')[1] || 'mp4');
77
+ const { src } = await (await fetch('/api/upload?ext=' + ext, { method: 'POST', body: file })).json();
78
+ const p = await videoPoster(src);
79
+ let w = p?.w || 1280, h = p?.h || 720; const k = Math.min(1, 1200 / w, 800 / h); w *= k; h *= k;
80
+ const b = makeBox({ x: (at?.x ?? W / 2) - w / 2, y: (at?.y ?? H / 2) - h / 2, w, h, cls: 'video', html: `<video src="${src}"${p ? ` poster="${p.poster}"` : ''} controls playsinline preload="metadata"></video>` });
81
+ insert(b);
82
+ setStatus(mb > 50 ? `Large video (${Math.round(mb)} MB) added` : '');
83
+ }
84
+ /* Web embeds (Insert → Embed, Video → By URL): a `.box.embed` holding an <iframe>. YouTube / Vimeo page addresses
85
+ become their player addresses, as Slides does for YouTube. */
86
+ function embedURL(u) {
87
+ const yt = u.match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([\w-]{11})/), vm = u.match(/vimeo\.com\/(\d+)/);
88
+ return yt ? `https://www.youtube.com/embed/${yt[1]}` : vm ? `https://player.vimeo.com/video/${vm[1]}` : u;
89
+ }
90
+ function addEmbed(url) {
91
+ if (!/^https?:\/\//.test(url)) return setStatus('An embed needs a web address (https://…)', 'error');
92
+ const src = embedURL(url), video = src !== url, w = 1280, h = video ? 720 : 800;
93
+ const b = makeBox({ x: (W - w) / 2, y: (H - h) / 2, w, h, cls: 'embed', html: `<iframe src="${src.replace(/"/g, '&quot;')}" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen loading="lazy"></iframe>` });
94
+ insert(b);
95
+ }
96
+ /* Live boxes (Insert → Live box): a `.box.live` running a page of its own (an .html file in assets/) in a sandboxed
97
+ iframe: its script runs, but it can't reach the deck, the editor, files or accounts (no allow-same-origin, and the
98
+ servers send assets' HTML with a sandbox policy too). Double-click one to edit its page. */
99
+ const LIVE_START = `<!doctype html>
100
+ <html><body style="margin:0;overflow:hidden;background:#fff">
101
+ <canvas id="c"></canvas>
102
+ <script>
103
+ // A live box: this page runs on the slide, in its own sandbox. Replace it with your demo.
104
+ const c = document.getElementById('c'), x = c.getContext('2d');
105
+ const fit = () => { c.width = innerWidth; c.height = innerHeight; }; addEventListener('resize', fit); fit();
106
+ let t = 0;
107
+ (function draw() {
108
+ t += 0.02; x.clearRect(0, 0, c.width, c.height);
109
+ for (let i = 0; i < 12; i++) {
110
+ const a = t + i * Math.PI / 6, r = Math.min(c.width, c.height) * 0.35;
111
+ x.beginPath(); x.arc(c.width / 2 + Math.cos(a) * r, c.height / 2 + Math.sin(2 * a) * r * 0.5, 14, 0, 7);
112
+ x.fillStyle = 'hsl(' + i * 30 + ' 70% 55%)'; x.fill();
113
+ }
114
+ requestAnimationFrame(draw);
115
+ })();
116
+ </script>
117
+ </body></html>
118
+ `;
119
+ const liveFrame = src => `<iframe src="${src.replace(/"/g, '&quot;')}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" title="Live box"></iframe>`;
120
+ const saveLivePage = async text => (await (await fetch('/api/upload?ext=html', { method: 'POST', body: new Blob([text], { type: 'text/html' }) })).json()).src;
121
+ async function addLive() {
122
+ const src = await saveLivePage(LIVE_START), w = 1280, h = 720;
123
+ if (!src) return setStatus("Couldn't save the live box's page", 'error');
124
+ insert(makeBox({ x: (W - w) / 2, y: (H - h) / 2, w, h, cls: 'live', html: liveFrame(src) }));
125
+ }
126
+ /* A dialog for a block of code: a live box's page, a slide's style. */
127
+ const liveDlg = $('#ed-livedlg');
128
+ function askCode(title, help, value, done) {
129
+ closeMenu(); $('#ed-code-title').textContent = title; $('#ed-code-help').textContent = help; $('#ed-live-code').value = value;
130
+ liveDlg.hidden = false; $('#ed-live-code').focus();
131
+ $('#ed-live-ok').onclick = () => { liveDlg.hidden = true; done($('#ed-live-code').value); };
132
+ }
133
+ async function editLive(b) {
134
+ const f = b.querySelector('iframe'), src = f?.getAttribute('src') || '';
135
+ const text = src ? await fetch(src).then(r => r.ok ? r.text() : '').catch(() => '') : f?.getAttribute('srcdoc') || '';
136
+ askCode('Live box', "A page of its own, with its script, running on the slide in a sandbox: it can draw, animate and respond to the mouse, but can't reach the deck, your files or your accounts.", text, async page => {
137
+ const next = await saveLivePage(page); if (!next) return setStatus("Couldn't save the live box's page", 'error');
138
+ content(b).innerHTML = liveFrame(next); commit(); render();
139
+ });
140
+ }
141
+ /* Slide → Slide style: CSS for this slide only, kept in the slide as <style class="slide-css" media="not all"> (inert as
142
+ written; the editor and the slideshow apply it scoped to the slide). */
143
+ const slideCSS = s => s?.querySelector(':scope > style')?.textContent.trim() || '';
144
+ function editSlideCSS() {
145
+ const s = slides[cur];
146
+ askCode('Slide style', 'CSS for this slide only, e.g. .box > .content { font-family: Georgia, serif; } or .box[data-role="title"] > .content { color: #2f6bff; }', slideCSS(s), css => {
147
+ let st = s.querySelector(':scope > style');
148
+ if (!css.trim()) st?.remove();
149
+ else { if (!st) { st = document.createElement('style'); s.append(st); } st.className = 'slide-css'; st.media = 'not all'; st.textContent = '\n' + css.trim() + '\n'; }
150
+ commit(); render();
151
+ });
152
+ }
153
+ $('#ed-live-cancel').onclick = liveDlg.querySelector('.sc-close').onclick = () => { liveDlg.hidden = true; };
154
+ $('#ed-live-code').addEventListener('keydown', e => { if (e.key === 'Tab') { e.preventDefault(); document.execCommand('insertText', false, ' '); } e.stopPropagation(); }); // a code box: Tab indents, keys stay here
155
+ const urlDlg = $('#ed-urldlg');
156
+ function askURL(title, done, value = '') {
157
+ closeMenu(); $('#ed-url-title').textContent = title; $('#ed-url-in').value = value; urlDlg.hidden = false; $('#ed-url-in').focus(); $('#ed-url-in').select();
158
+ urlDlg.onsubmit = () => { const v = $('#ed-url-in').value.trim(); urlDlg.hidden = true; if (v) done(v); };
159
+ }
160
+ $('#ed-url-ok').onclick = () => urlDlg.onsubmit?.();
161
+ $('#ed-url-cancel').onclick = urlDlg.querySelector('.sc-close').onclick = () => { urlDlg.hidden = true; };
162
+ $('#ed-url-in').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); urlDlg.onsubmit?.(); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); urlDlg.hidden = true; } };
163
+ const isVideoFile = f => /^video\/(mp4|webm|quicktime)$/.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name || '');
164
+
165
+ /* Crop, as Slides: double-click a picture (or the toolbar Crop button) and drag its black marks; the box shrinks and
166
+ the picture stays where it is. ↵, Esc or a click elsewhere ends. Stored as data-crop (see cropBox in shapes.js). */
167
+ let cropping = null;
168
+ const cropOf = b => { const v = (b.dataset.crop || '0 0 0 0').split(/\s+/).map(Number); return { t: v[0] || 0, r: v[1] || 0, b: v[2] || 0, l: v[3] || 0 }; };
169
+ function enterCrop(b) { if (!b?.querySelector('.content > img')) return; stopEdit(); cropping = b; sel = new Set([b]); drawOverlay(); updateToolbar(); $('#ed-crop')?.classList.add('on'); }
170
+ function endCrop() { if (!cropping) return; cropping = null; $('#ed-crop')?.classList.remove('on'); drawOverlay(); }
171
+ function startCrop(e, dir) {
172
+ const b = cropping, g0 = geo(b), c0 = cropOf(b), p0 = toCanvas(e);
173
+ const fw = g0.w / (1 - c0.l - c0.r), fh = g0.h / (1 - c0.t - c0.b), X0 = g0.x - c0.l * fw, Y0 = g0.y - c0.t * fh; // the whole picture
174
+ busy = true;
175
+ drag(e, ev => {
176
+ const p = toCanvas(ev), dx = (p.x - p0.x) / fw, dy = (p.y - p0.y) / fh, c = { ...c0 }, min = 0.05;
177
+ if (dir.includes('w')) c.l = Math.max(0, Math.min(1 - c.r - min, c0.l + dx));
178
+ if (dir.includes('e')) c.r = Math.max(0, Math.min(1 - c.l - min, c0.r - dx));
179
+ if (dir.includes('n')) c.t = Math.max(0, Math.min(1 - c.b - min, c0.t + dy));
180
+ if (dir.includes('s')) c.b = Math.max(0, Math.min(1 - c.t - min, c0.b - dy));
181
+ const r3 = v => Math.round(v * 1000) / 1000;
182
+ if (c.t || c.r || c.b || c.l) b.dataset.crop = [c.t, c.r, c.b, c.l].map(r3).join(' '); else delete b.dataset.crop;
183
+ setGeo(b, { x: X0 + c.l * fw, y: Y0 + c.t * fh, w: (1 - c.l - c.r) * fw, h: (1 - c.t - c.b) * fh }); cropBox(b); drawOverlay();
184
+ }, () => { busy = false; commit(); });
185
+ }
186
+
187
+ async function addImage(file, at) {
188
+ setStatus('Uploading image…');
189
+ file = await shrinkPicture(file);
190
+ const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('svg+xml', 'svg');
191
+ const r = await fetch('/api/upload?ext=' + ext, { method: 'POST', body: file });
192
+ const { src } = await r.json();
193
+ await addImageSrc(src, at);
194
+ }
195
+ async function addImageSrc(src, at) { // a picture already in assets/, sized as Slides inserts one
196
+ const img = new Image(); img.src = src;
197
+ await img.decode().catch(() => {});
198
+ let w = img.naturalWidth || 800, h = img.naturalHeight || 600;
199
+ const k = Math.min(1, 1200 / w, 800 / h); w *= k; h *= k;
200
+ const b = makeBox({ x: (at?.x ?? W / 2) - w / 2, y: (at?.y ?? H / 2) - h / 2, w, h, cls: 'image', html: `<img src="${src}" alt="">` });
201
+ insert(b);
202
+ }
@@ -0,0 +1,73 @@
1
+ /* ---------- who else is here (glideshow.org) ---------- */
2
+ /* Everyone else with this deck open: their initials by Share (a click goes to their slide), a dot with their initials
3
+ on the slide each is on in the filmstrip, and a ring in their colour round the boxes each has selected, with their
4
+ name on the one they are typing in. That box is theirs while they type: others can still select and move it, but
5
+ not type in it (startEdit asks typingIn). It all comes over the live WebSocket (boot.js live; cloud/library.js
6
+ tellPeople); without one (the Mac, a library in the browser) there is nobody else to show. */
7
+ /** @type {{id: string, k: string, n: string, sid: string, box: string, sel: string[]}[]} */
8
+ let people = [];
9
+ let meId = '', hereKey = '';
10
+ const PERSON_COLORS = ['#e8710a', '#1e8e3e', '#a142f4', '#d01884', '#129eaf', '#c5221f', '#7b5800', '#b06000']; // (no blue: that's your own selection)
11
+ const personColor = k => PERSON_COLORS[parseInt(hashStr(k), 36) % PERSON_COLORS.length];
12
+ const initials = n => (String(n).trim().split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('') || '?').toUpperCase();
13
+ /* Others: not this window, nor another window of the same person (one person, one face). */
14
+ const others = () => { const mine = people.find(p => p.id === meId)?.k, seen = new Set(); return people.filter(p => p.id !== meId && p.k !== mine && !seen.has(p.k) && seen.add(p.k)); };
15
+ /** The person typing in box `b` on the slide on show, if someone else is. */
16
+ const typingIn = b => others().find(p => p.box && p.box === b?.id && p.sid === slides[cur]?.dataset.sid);
17
+
18
+ function setPeople(list) { people = Array.isArray(list) ? list : []; drawPeople(); }
19
+
20
+ /* Where this editor is (the slide on show, the boxes selected, the one being typed in), sent when it changes. */
21
+ function sendHere() {
22
+ if (!liveSocket || liveSocket.readyState !== 1 || !meId || !slides.length) return;
23
+ const here = { sid: slides[cur]?.dataset.sid || '', box: editing?.id || '', sel: [...sel].filter(b => b.isConnected && b.id).map(b => b.id).slice(0, 20) };
24
+ const key = JSON.stringify(here);
25
+ if (key === hereKey) return;
26
+ hereKey = key;
27
+ try { liveSocket.send(JSON.stringify({ here })); } catch {}
28
+ }
29
+
30
+ function drawPeople() {
31
+ const list = others(), bar = $('#ed-people');
32
+ bar.hidden = !list.length;
33
+ bar.replaceChildren(...list.map(p => {
34
+ const b = document.createElement('button'), at = slides.findIndex(s => s.dataset.sid === p.sid);
35
+ b.className = 'person'; b.textContent = initials(p.n); b.style.setProperty('--c', personColor(p.k));
36
+ b.dataset.tip = p.n + (at >= 0 ? ` · slide ${at + 1}` : '');
37
+ b.setAttribute('aria-label', b.dataset.tip);
38
+ b.onclick = () => { const i = slides.findIndex(s => s.dataset.sid === p.sid); if (i >= 0) go(i); };
39
+ return b;
40
+ }));
41
+ drawPeopleThumbs(); drawPeopleOn();
42
+ }
43
+ /* The filmstrip: a dot for each person on the slide they're on. */
44
+ function drawPeopleThumbs() {
45
+ thumbs.querySelectorAll('.thumb-people').forEach(e => e.remove());
46
+ const by = new Map();
47
+ for (const p of others()) { const i = slides.findIndex(s => s.dataset.sid === p.sid); if (i >= 0) by.set(i, [...(by.get(i) || []), p]); }
48
+ for (const [i, ps] of by) {
49
+ const t = thumbs.children[i]; if (!t) continue;
50
+ const row = div('thumb-people');
51
+ row.append(...ps.map(p => { const d = div('person'); d.textContent = initials(p.n); d.title = p.n; d.style.setProperty('--c', personColor(p.k)); return d; }));
52
+ t.append(row);
53
+ }
54
+ }
55
+ /* The slide on show: a ring round each box someone else has selected; the box they type in also carries their name. */
56
+ function drawPeopleOn() {
57
+ const layer = $('#ed-people-on'), s = slides[cur];
58
+ if (!layer || !people.length && !layer.childElementCount) return;
59
+ layer.replaceChildren();
60
+ if (!s) return;
61
+ const rc = canvas.getBoundingClientRect(), k = 1 / scale;
62
+ for (const p of others()) {
63
+ if (p.sid !== s.dataset.sid) continue;
64
+ for (const id of new Set([...(p.sel || []), ...(p.box ? [p.box] : [])])) {
65
+ const b = [...s.querySelectorAll('.box')].find(x => x.id === id); if (!b) continue;
66
+ const r = b.getBoundingClientRect(), o = div('person-ring' + (id === p.box ? ' typing' : ''));
67
+ place(o, { x: (r.left - rc.left) * k, y: (r.top - rc.top) * k, w: r.width * k, h: r.height * k });
68
+ o.style.setProperty('--c', personColor(p.k)); o.style.setProperty('--k', k + 'px');
69
+ if (id === p.box) { const tag = div('person-tag'); tag.textContent = p.n; o.append(tag); }
70
+ layer.append(o);
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,43 @@
1
+ /* ---------- phone: comments and share ---------- */
2
+ /* Adding a comment, as 15-comments.png: the top bar gives way to a "⊗ Cancel" pill, the object the comment is on stays
3
+ outlined, a card says "Tap any object to re-anchor your comment" (a tap on another object or on nothing moves it),
4
+ and a bar at the bottom holds @ and an "Add a new comment" field with send. The comments themselves (comments.js)
5
+ open full screen, and so does Share (file.js), in the app's dark look. */
6
+ if (PHONE) (() => {
7
+ const el = (tag, cls, html = '') => Object.assign(document.createElement(tag), { className: cls, innerHTML: html });
8
+ const root = document.documentElement;
9
+ const cancel = el('button', 'ph-cm-cancel', '<span class="icon">cancel</span><span>Cancel</span>');
10
+ const hint = el('div', 'ph-cm-hint'), bar = el('div', 'ph-cm-bar'), at = el('button', 'ph-icon-btn ph-cm-at', '<span class="icon">alternate_email</span>');
11
+ const field = el('div', 'ph-cm-field'), input = /** @type {HTMLTextAreaElement} */ (el('textarea', 'ph-cm-input')), send = el('button', 'ph-icon-btn ph-cm-send', '<span class="icon">send</span>');
12
+ hint.textContent = 'Tap any object to re-anchor your comment';
13
+ input.rows = 1; input.placeholder = 'Add a new comment'; at.title = 'Mention someone'; send.title = 'Send';
14
+ field.append(input, send); bar.append(at, field);
15
+ document.body.append(cancel, hint, bar);
16
+
17
+ const anchor = b => { // the comment goes on b (its top-right corner) or, with no b, the slide's top-left
18
+ const g = b && geo(b);
19
+ Object.assign(cmDraft, { box: b?.id || '', x: g ? g.x + g.w : 80, y: g ? g.y + 8 : 80, quote: (b ? content(b)?.innerText || '' : '').replace(/\s+/g, ' ').trim().slice(0, 120) });
20
+ sel = b ? new Set([b]) : new Set(); drawOverlay();
21
+ };
22
+ function stop() { cmDraft = null; root.classList.remove('ph-commenting'); input.value = ''; input.blur(); send.disabled = true; drawOverlay(); }
23
+ window['addComment'] = () => {
24
+ if (!root.classList.contains('ph-edit')) phoneState('edit');
25
+ stopEdit(); phoneSheet.close();
26
+ cmDraft = { slide: cur + 1 }; anchor([...sel][0]);
27
+ root.classList.add('ph-commenting'); send.disabled = true;
28
+ setTimeout(() => input.focus(), 50);
29
+ };
30
+ input.oninput = () => { send.disabled = !input.value.trim(); };
31
+ input.onkeydown = e => e.stopPropagation(); // typing isn't a shortcut
32
+ send.onclick = () => { if (!input.value.trim() || !cmDraft) return; const d = cmDraft, text = input.value; stop(); cmFetch({ op: 'add', ...d, text }); };
33
+ cancel.onclick = stop;
34
+ at.onclick = () => { input.focus(); input.setRangeText('@', input.selectionStart, input.selectionEnd, 'end'); input.oninput(null); };
35
+ stage.addEventListener('pointerdown', e => { // while commenting, a tap re-anchors it
36
+ if (!root.classList.contains('ph-commenting')) return;
37
+ e.stopImmediatePropagation(); e.preventDefault();
38
+ anchor(topBox(/** @type {Element} */ (e.target)));
39
+ }, true);
40
+
41
+ /* the comments panel and Share: full screen, with ✕ to close (the logic stays the desktop's) */
42
+ const panelClose = $('#ed-cm-close'); if (panelClose) panelClose.title = 'Close';
43
+ })();
@@ -0,0 +1,253 @@
1
+ /* ---------- phone: the editing screen ---------- */
2
+ /* The Slides app's editor: a top bar (back; undo, redo, present, share, +, comments, ⋯) over the slide fitted 15 from
3
+ the sides, and the filmstrip at the bottom (the current slide's cell blue, a new-slide button at its right). With
4
+ something selected, back becomes ✓ (done: deselect) and share leaves the bar. */
5
+ if (PHONE) (() => {
6
+ const el = (tag, cls, html = '') => Object.assign(document.createElement(tag), { className: cls, innerHTML: html });
7
+ const iconBtn = (icon, title, run) => { const b = el('button', 'ph-icon-btn', `<span class="icon">${icon}</span>`); b.title = title; b.onclick = run; return b; };
8
+ const bar = $('#ph-top .ph-bar-edit');
9
+
10
+ /* ---- the top bar ---- */
11
+ const back = iconBtn('arrow_back_ios_new', 'Back to the slides', () => phoneState('view'));
12
+ const done = iconBtn('check', 'Done', () => { stopEdit(); sel.clear(); drawOverlay(); updateToolbar(); });
13
+ done.classList.add('ph-done');
14
+ const share = iconBtn('person_add', 'Share', () => showShare());
15
+ const rails = () => document.documentElement.classList.contains('ph-rails');
16
+ /* a menu from a button: under it, or in the side rails (landscape) beside it, to its left */
17
+ const menuAt = (btn, items) => {
18
+ openMenu(btn, items);
19
+ const r = btn.getBoundingClientRect();
20
+ if (rails() && openMenuEl) placeMenu(openMenuEl, r.left - openMenuEl.offsetWidth - 8, r.top + r.height / 2 - openMenuEl.offsetHeight / 2);
21
+ };
22
+ const more = e => menuAt(e.currentTarget, [
23
+ { label: 'Find', run: () => openFind() }, { label: 'Share', run: () => showShare() },
24
+ ...(document.documentElement.dataset.look === 'thermal' ? [{ label: document.body.classList.contains('dark') ? 'Light mode' : 'Dark mode', run: () => window['setDark']?.(!document.body.classList.contains('dark')) }] : []),
25
+ { label: 'Desktop version', run: () => { location.href = '/edit?phone=0'; } }]);
26
+ bar.append(back, done, el('span', 'ph-gap'), iconBtn('undo', 'Undo', () => stepBack(undo)), iconBtn('redo', 'Redo', () => stepBack(redo)),
27
+ iconBtn('play_arrow', 'Present', () => phonePresent()), share,
28
+ iconBtn('add', 'Insert', () => phoneInsert()),
29
+ iconBtn('comment', 'Comments', () => setCommentsPanel(true)),
30
+ iconBtn('more_horiz', 'More', more));
31
+ /* undo and redo select what they changed on the slide, as the app does (r08) */
32
+ const saved = i => { // the current slide's boxes in history step i, by id (as saved: no rendering in them)
33
+ const t = document.createElement('template'); t.innerHTML = hist[i]?.html[cur] || '';
34
+ const sig = b => { const c = b.querySelector(':scope > .content'); return [...b.attributes, ...(c?.attributes || [])].map(x => x.name + '=' + x.value).join('\n') + '\n' + (c?.textContent || ''); }; // what an edit changes, not what rendering adds (highlighting, maths)
35
+ return new Map([...t.content.querySelectorAll('.box[id]')].map(b => [b.id, sig(b)]));
36
+ };
37
+ function stepBack(fn) {
38
+ const at = cur, was = hptr, before = saved(hptr);
39
+ fn();
40
+ if (cur !== at || hptr === was) return;
41
+ const now = saved(hptr), changed = boxes().filter(b => b.id && now.has(b.id) && before.get(b.id) !== now.get(b.id));
42
+ if (changed.length) { sel = new Set(changed); drawOverlay(); updateToolbar(); }
43
+ }
44
+ const picking = () => sel.size > 0 || !!editing;
45
+ const drawOverlay0 = drawOverlay; // the bars follow the selection, which redraws the overlay whenever it changes
46
+ window['drawOverlay'] = () => { drawOverlay0(); bar.classList.toggle('picking', picking()); document.documentElement.classList.toggle('ph-picking', picking()); showTools(); };
47
+
48
+ /* ---- the filmstrip: cells of 98 with an 80 wide thumbnail and the number under it ---- */
49
+ const film = el('div', 'ph-film'), strip = el('div', 'ph-film-strip'), row = el('div', 'ph-film-cells'), add = iconBtn('library_add', 'New slide', () => $('#ed-new-slide').click());
50
+ add.classList.add('ph-film-new'); film.id = 'ph-film';
51
+ // New slide: the app keeps it at the strip's right end; Thermal makes it a tile right after the last slide, scrolling with them
52
+ strip.append(row); film.append(strip); (document.documentElement.dataset.look === 'thermal' ? strip : film).append(add); document.body.append(film);
53
+ const pics = new WeakMap(); // slide → { key, node }: a copy of its filmstrip picture, remade when the slide changes
54
+ function pic(s) {
55
+ const key = s.outerHTML, hit = pics.get(s);
56
+ if (hit?.key === key) return hit.node;
57
+ const node = /** @type {HTMLElement} */ (thumbView(s).cloneNode(true));
58
+ pics.set(s, { key, node }); return node;
59
+ }
60
+ function fillFilm() {
61
+ if (!slides.length) return;
62
+ while (row.children.length > slides.length) row.lastChild.remove();
63
+ while (row.children.length < slides.length) {
64
+ const c = el('button', 'ph-cell', '<div class="ph-thumb"></div><span class="ph-num"></span>'), i = row.children.length;
65
+ c.onclick = () => { if (!lifted) cellTap([...row.children].indexOf(c)); };
66
+ c.onpointerdown = e => press(e, c);
67
+ row.append(c); void i;
68
+ }
69
+ [...row.children].forEach((c, i) => {
70
+ const t = c.firstElementChild, p = pic(slides[i]);
71
+ if (t.firstChild !== p) t.replaceChildren(p);
72
+ /** @type {HTMLElement} */ (t).style.height = 80 * H / W + 'px';
73
+ c.lastElementChild.textContent = String(i + 1); c.classList.toggle('cur', i === cur); c.classList.toggle('picked', slideMode && picked.has(slides[i]));
74
+ });
75
+ const c = /** @type {HTMLElement} */ (row.children[cur]);
76
+ if (!c) return;
77
+ if (rails()) { if (c.offsetTop < strip.scrollTop || c.offsetTop + c.offsetHeight > strip.scrollTop + strip.clientHeight) strip.scrollTo({ top: c.offsetTop - 8 }); } // the strip runs down the left
78
+ else if (c.offsetLeft < strip.scrollLeft || c.offsetLeft + c.offsetWidth > strip.scrollLeft + strip.clientWidth) strip.scrollTo({ left: c.offsetLeft - 8 });
79
+ }
80
+ const renderThumbs0 = renderThumbs;
81
+ window['renderThumbs'] = () => { renderThumbs0(); fillFilm(); count.textContent = String(picked.size); };
82
+
83
+ /* ---- reorder: a long press lifts a slide's picture (×1.2), it follows the finger along the strip, the others make
84
+ room, and letting go puts the slide there (r05) ---- */
85
+ let lifted = null;
86
+ function press(e, c) {
87
+ const x0 = e.clientX, y0 = e.clientY, from = [...row.children].indexOf(c), down = rails(), pitch = down ? c.offsetHeight : 98; // along the strip: across, or down the side rail
88
+ let timer = window.setTimeout(lift, 400), at = from, ghost = null;
89
+ const cells = () => /** @type {HTMLElement[]} */ ([...row.children]);
90
+ function lift() {
91
+ timer = 0; lifted = c; c.classList.add('lifting'); film.classList.add('dragging');
92
+ ghost = /** @type {HTMLElement} */ (c.querySelector('.ph-thumb').cloneNode(true)); ghost.className = 'ph-thumb ph-lifted';
93
+ document.body.append(ghost); place_(x0, y0);
94
+ }
95
+ const place_ = (x, y) => { ghost.style.left = x - 49 + 'px'; ghost.style.top = y - 28 + 'px'; };
96
+ function move(ev) {
97
+ if (timer) { if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > 8) end(); return; } // moved first: a scroll, not a lift
98
+ ev.preventDefault(); place_(ev.clientX, ev.clientY);
99
+ const r = strip.getBoundingClientRect();
100
+ const along = down ? ev.clientY - r.top + strip.scrollTop - cells()[0].offsetTop : ev.clientX - r.left + strip.scrollLeft, t = down ? 'translateY' : 'translateX';
101
+ at = Math.max(0, Math.min(slides.length - 1, Math.floor(along / pitch)));
102
+ cells().forEach((x, i) => { x.style.transform = i === from ? '' : i > from && i <= at ? `${t}(-${pitch}px)` : i < from && i >= at ? `${t}(${pitch}px)` : ''; });
103
+ }
104
+ function end() {
105
+ clearTimeout(timer);
106
+ removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('pointercancel', up); strip.removeEventListener('touchmove', hold);
107
+ }
108
+ function up() {
109
+ end(); if (!lifted) return;
110
+ ghost.remove(); c.classList.remove('lifting'); film.classList.remove('dragging'); cells().forEach(x => { x.style.transform = ''; });
111
+ if (at !== from) slideOp(() => { const s = slides.splice(from, 1)[0]; slides.splice(at, 0, s); cur = at; picked = new Set([s]); });
112
+ setTimeout(() => { lifted = null; }); // after the click that follows the lift
113
+ }
114
+ const hold = ev => { if (lifted) ev.preventDefault(); }; // while a slide is lifted the strip doesn't scroll
115
+ addEventListener('pointermove', move); addEventListener('pointerup', up); addEventListener('pointercancel', up);
116
+ strip.addEventListener('touchmove', hold, { passive: false });
117
+ }
118
+
119
+ /* ---- slide mode (11-slide-selected.png, r05): a tap on the current slide's cell picks it; the top bar turns into
120
+ ✕, the number picked, and the slide actions; taps on other cells add or drop them. (How the app enters it isn't
121
+ captured yet.) ---- */
122
+ let slideMode = false, clip = [];
123
+ const slideBar = el('div', 'ph-bar ph-bar-slide'), count = el('span', 'ph-count');
124
+ const act = (icon, title, run) => { const b = iconBtn(icon, title, run); b.classList.add('ph-act'); return b; };
125
+ const copySlides = () => { clip = pickedIdx().map(i => slides[i].cloneNode(true)); };
126
+ slideBar.append(iconBtn('close', 'Done', () => setSlideMode(false)), count, el('span', 'ph-gap'),
127
+ act('content_cut', 'Cut', () => { copySlides(); deleteSlides(); }),
128
+ act('content_copy', 'Copy', copySlides),
129
+ act('content_paste', 'Paste', () => { if (!clip.length) return; slideOp(() => { const copies = clip.map(s => { const c = /** @type {HTMLElement} */ (s.cloneNode(true)); boxes(c).forEach(b => { b.id = uid(); }); return c; }); const at = pickedIdx().at(-1) + 1; slides.splice(at, 0, ...copies); cur = at + copies.length - 1; picked = new Set(copies); }); }),
130
+ act('delete', 'Delete', () => deleteSlides()),
131
+ act('select_all', 'Select all', () => { picked = new Set(slides); renderThumbs(); }),
132
+ act('visibility_off', 'Skip slide', () => skipSlides()),
133
+ act('format_color_fill', 'Change background', () => showBgDialog(true)),
134
+ act('view_quilt', 'Layout', () => phoneSheet.open({ title: 'Layout', build(b) { b.append(layoutGrid(name => { phoneSheet.close(); slideOp(() => pickedIdx().forEach(i => applyLayout(slides[i], name))); })); } })));
135
+ bar.after(slideBar);
136
+ function setSlideMode(on) {
137
+ slideMode = on; document.documentElement.classList.toggle('ph-slide', on);
138
+ if (on) { stopEdit(); sel.clear(); drawOverlay(); } else picked = new Set([slides[cur]]);
139
+ renderThumbs();
140
+ }
141
+ function cellTap(j) {
142
+ if (slideMode) { // add or drop it; the last one can't go
143
+ const s = slides[j];
144
+ if (picked.has(s) && picked.size > 1) { picked.delete(s); if (j === cur) go(pickedIdx()[0], true); else renderThumbs(); }
145
+ else if (!picked.has(s)) { picked.add(s); go(j, true); }
146
+ return;
147
+ }
148
+ if (j === cur) setSlideMode(true); else go(j);
149
+ }
150
+ const phoneState0 = phoneState;
151
+ phoneState = st => { if (slideMode) setSlideMode(false); phoneState0(st); };
152
+ stage.addEventListener('pointerdown', () => { if (slideMode) setSlideMode(false); }, true); // touching the slide leaves slide mode
153
+
154
+ /* ---- Add slide (12-new-slide-layouts.png): the layouts in two columns, full screen ---- */
155
+ const PHT = { title: 'Double tap to add title', sub: 'Double tap to add subtitle', body: 'Double tap to add text' };
156
+ function layoutPic(lay) { // the layout's placeholders as the app draws them: outlined boxes with their prompt
157
+ const list = theme().layouts?.[lay[0]] || lay[2];
158
+ return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><rect width="${W}" height="${H}" fill="#fff"/>` +
159
+ (lay[0] === 'section-description' ? `<rect x="${W / 2}" width="${W / 2}" height="${H}" fill="#eee"/>` : '') +
160
+ list.map(([role, x, y, w, h, pt, , ta]) => {
161
+ const text = lay[0] === 'big-number' && role === 'title' ? 'xx%' : PHT[role], fs = Math.min(pt * 2.67, (w - 40) / (text.length * .5)), tx = ta === 'center' ? x + w / 2 : x + 20, anchor = ta === 'center' ? 'middle' : 'start';
162
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="#9aa0a6" stroke-width="4"/>` +
163
+ `<text x="${tx}" y="${y + Math.min(h - 10, fs * 1.2)}" font-size="${fs}" text-anchor="${anchor}" fill="${role === 'title' ? '#000' : '#5f6368'}" font-family="Arial">${text}</text>`;
164
+ }).join('') + '</svg>';
165
+ }
166
+ function layoutGrid(pick) {
167
+ const g = el('div', 'ph-layouts');
168
+ for (const lay of LAYOUTS) {
169
+ const t = el('button', 'ph-layout', layoutPic(lay) + '<span></span>'); t.dataset.layout = lay[0];
170
+ t.querySelector('span').textContent = lay[1]; t.onclick = () => pick(lay[0]); g.append(t);
171
+ }
172
+ return g;
173
+ }
174
+ const addSlide = el('div', 'ph-addslide'); addSlide.id = 'ph-addslide'; addSlide.hidden = true;
175
+ const head = el('div', 'ph-addslide-head', '<span>Add slide</span>'), shut = iconBtn('close', 'Close', () => { addSlide.hidden = true; });
176
+ head.append(shut);
177
+ addSlide.append(head, layoutGrid(name => { addSlide.hidden = true; newSlideWith(name); }));
178
+ document.body.append(addSlide);
179
+ add.onclick = () => { addSlide.hidden = false; addSlide.lastElementChild.scrollTop = 0; };
180
+
181
+ /* ---- the toolbar under the filmstrip (above the keyboard while typing): text, or a shape's fill and border ---- */
182
+ const tools = el('div', 'ph-tools'), textTools = el('div', 'ph-tools-text'), shapeTools = el('div', 'ph-tools-shape');
183
+ tools.id = 'ph-tools';
184
+ const toggle = (icon, title, id) => { const b = iconBtn(icon, title, () => { $(id).click(); syncTools(); }); b.dataset.mirror = id; return b; };
185
+ const swatch = (icon, title, what) => { // a colour button with the colour under it; it opens the colour panel (phone-sheets.js)
186
+ const b = iconBtn(icon, title, () => phoneColors(what)); b.dataset.for = what; b.classList.add('ph-swatch'); b.append(el('i', 'ph-bar-color')); return b;
187
+ };
188
+ const align = (a, icon, title) => { const b = iconBtn(icon, title, () => { setAlign(a); syncTools(); }); b.dataset.align = a; return b; };
189
+ // in the side rails the three align buttons are one, which opens them as a menu (the column has room for fewer buttons)
190
+ const alignOne = iconBtn('format_align_left', 'Align', e => menuAt(e.currentTarget, [['left', 'Left'], ['center', 'Center'], ['right', 'Right']].map(([a, label]) => ({ label, on: () => curAlign() === a, run: () => { setAlign(a); syncTools(); } }))));
191
+ alignOne.classList.add('ph-rail-only', 'ph-align-one');
192
+ textTools.append(toggle('format_bold', 'Bold', '#ed-bold'), toggle('format_italic', 'Italic', '#ed-italic'), toggle('format_underlined', 'Underline', '#ed-underline'),
193
+ swatch('format_color_text', 'Text color', 'color'), swatch('ink_highlighter', 'Highlight color', 'hilite'), el('i', 'ph-sep'),
194
+ align('left', 'format_align_left', 'Left'), align('center', 'format_align_center', 'Center'), align('right', 'format_align_right', 'Right'), alignOne);
195
+ shapeTools.append(swatch('format_color_fill', 'Fill color', 'fill'), swatch('border_color', 'Border color', 'stroke'), iconBtn('line_weight', 'Border weight', e => menuAt(e.currentTarget, [1, 2, 3, 4, 8, 12, 16, 24].map(v => ({ label: v + 'px', on: () => curSw() === v, run: () => { setVar('--sw', v + 'px'); commit(); } })))));
196
+ // the side rails' format column: ✓ (done) on top, and Cut, Copy, Paste, Delete in its ⋯ (the edit menu's, phone-touch.js)
197
+ const railDone = iconBtn('check', 'Done', () => done.click()), railEdit = iconBtn('more_horiz', 'Cut, copy, paste, delete', e => menuAt(e.currentTarget, phoneClipActions().map(([label, run]) => ({ label, run }))));
198
+ railDone.classList.add('ph-rail-only', 'ph-done'); railEdit.classList.add('ph-rail-only');
199
+ tools.append(railDone, textTools, shapeTools, railEdit);
200
+ film.after(tools);
201
+ const kind = () => {
202
+ const b = sel.size === 1 ? [...sel][0] : null;
203
+ if (editing) return 'text';
204
+ if (!b || isMedia(b) || isGroup(b)) return '';
205
+ if (isLine(b)) return 'shape';
206
+ return b.dataset.shape ? 'shape' : 'text';
207
+ };
208
+ function syncTools() {
209
+ textTools.querySelectorAll('[data-mirror]').forEach(b => b.classList.toggle('on', $(/** @type {HTMLElement} */ (b).dataset.mirror).classList.contains('on')));
210
+ const a = sel.size ? curAlign() : '';
211
+ textTools.querySelectorAll('[data-align]').forEach(b => b.classList.toggle('on', /** @type {HTMLElement} */ (b).dataset.align === a));
212
+ alignOne.querySelector('.icon').textContent = `format_align_${['center', 'right'].includes(a) ? a : 'left'}`;
213
+ tools.querySelectorAll('.ph-swatch').forEach(b => { /** @type {HTMLElement} */ (b.querySelector('.ph-bar-color')).style.background = $('#ed-' + /** @type {HTMLElement} */ (b).dataset.for).value; });
214
+ }
215
+ function showTools() {
216
+ const k = kind(), root = document.documentElement;
217
+ root.classList.toggle('ph-tools-text', k === 'text'); root.classList.toggle('ph-tools-shape', k === 'shape');
218
+ root.classList.toggle('ph-typing', !!editing);
219
+ if (k) syncTools();
220
+ if (phoneLayout('float')) requestAnimationFrame(floatTools);
221
+ }
222
+ /* layout "float": the format bar sits just above the selection (below it when there's no room), not at the bottom */
223
+ function floatTools() {
224
+ const box = ui.querySelector('.sel-box') || (editing && editing), r = box?.getBoundingClientRect();
225
+ if (!r || editing || !kind() || rails()) { tools.style.top = tools.style.left = ''; return; } // the side rails keep it in its column
226
+ const top = $('#ph-top').getBoundingClientRect().bottom, w = tools.offsetWidth, h = tools.offsetHeight, knob = 56;
227
+ let y = r.top - knob - h; if (y < top + 8) y = r.bottom + 16;
228
+ tools.style.top = Math.min(y, innerHeight - h - 8) + 'px';
229
+ tools.style.left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8)) + 'px';
230
+ }
231
+ /* layout "thumb": the deck's title on top, the actions in a pill above the filmstrip, where a thumb reaches */
232
+ if (phoneLayout('thumb')) {
233
+ const dock = el('div', 'ph-dock'), title = el('span', 'ph-doc-title');
234
+ const name = () => { title.textContent = $('#ed-title').value || 'Untitled presentation'; };
235
+ $('#ed-title').addEventListener('input', name);
236
+ const renderThumbs1 = renderThumbs; window['renderThumbs'] = () => { renderThumbs1(); name(); }; // the title arrives with the deck
237
+ ['Undo', 'Redo', 'Insert', 'Present', 'Comments'].forEach(t => dock.append(bar.querySelector(`[title="${t}"]`)));
238
+ bar.querySelector('.ph-gap').replaceWith(title);
239
+ film.before(dock);
240
+ /* in landscape, side rails: no top bar; the filmstrip runs down the left, and the pill stands
241
+ on the right with back (✓ with a selection) on top and ⋯ below; a text box or shape selected turns the column into
242
+ its format controls (#ph-tools). phone.css lays it out under html.ph-rails. */
243
+ const railBack = iconBtn('arrow_back', 'Back to the slides', () => back.click()), railDone2 = iconBtn('check', 'Done', () => done.click()), railMore = iconBtn('more_horiz', 'More', more);
244
+ railBack.classList.add('ph-rail-only', 'ph-rail-back'); railDone2.classList.add('ph-rail-only', 'ph-done'); railMore.classList.add('ph-rail-only');
245
+ dock.prepend(railBack, railDone2); dock.append(railMore);
246
+ const wide = matchMedia('(orientation: landscape)');
247
+ const setRails = () => { document.documentElement.classList.toggle('ph-rails', wide.matches); showTools(); fillFilm(); };
248
+ wide.addEventListener('change', setRails); setRails();
249
+ }
250
+ new ResizeObserver(() => fit()).observe(stage); // the keyboard and typing change the canvas's size; a toolbar alone doesn't
251
+ const updateToolbar0 = updateToolbar;
252
+ window['updateToolbar'] = () => { updateToolbar0(); if (kind()) syncTools(); };
253
+ })();