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,366 @@
1
+ /* ---------- text editing ---------- */
2
+
3
+ /* Enter edit mode. With a point (a double-click), the caret goes there, as Slides; otherwise (Enter, a new
4
+ box) all the text is selected. */
5
+ function startEdit(b, pt) {
6
+ if (isMedia(b) || isStroke(b) || isGroup(b)) return;
7
+ const who = typingIn(b); // someone else is typing in it (people.js): theirs until they stop
8
+ if (who) { setStatus(`${who.n} is typing in this box`, 'edit_off'); return; }
9
+ editing = b; sel = new Set([b]);
10
+ const c = content(b);
11
+ unrenderMath(c); // edit the source
12
+ editStart = c.innerHTML;
13
+ c.contentEditable = 'true'; c.spellcheck = spellOn();
14
+ editFloor = parseFloat(b.dataset.minh) || geo(b).h;
15
+ c.oninput = e => { if (e.inputType === 'insertText' && /^[ \u00a0]$/.test(e.data || '') && latexOn()) latexShortcut(); if (b.dataset.placeholder) markPlaceholder(c); fitHeight(b); };
16
+ c.focus();
17
+ let r = pt && caretAt(pt.x, pt.y);
18
+ if (!r || !c.contains(r.startContainer)) { r = document.createRange(); r.selectNodeContents(c); }
19
+ const s = getSelection(); s.removeAllRanges(); s.addRange(r);
20
+ drawOverlay(); updateToolbar();
21
+ }
22
+ /* LaTeX shortcuts while typing (Tools → Convert LaTeX shortcuts, on unless turned off): after a space, `\alpha` becomes α
23
+ (symbols.js' table), `^2` / `^{10}` a superscript and `_i` / `_{ij}` a subscript; the space stays. */
24
+ const latexOn = () => stored('slides-latex') !== '0';
25
+ function latexShortcut() {
26
+ const s = getSelection(); if (!s.rangeCount || !s.isCollapsed) return;
27
+ const n = s.anchorNode, o = s.anchorOffset; if (n?.nodeType !== 3) return;
28
+ const t = /** @type {Text} */ (n), before = t.data.slice(0, o - 1), sp = t.data[o - 1];
29
+ let m = before.match(/\\([A-Za-z]+)$/);
30
+ if (m && LATEX_SYMBOLS[m[1]]) {
31
+ t.replaceData(o - 1 - m[0].length, m[0].length, LATEX_SYMBOLS[m[1]]);
32
+ s.collapse(n, o - m[0].length + LATEX_SYMBOLS[m[1]].length); return;
33
+ }
34
+ m = before.match(/([\^_])(?:\{([^{}]+)\}|([A-Za-z0-9+\-=]+))$/);
35
+ if (!m) return;
36
+ const el = document.createElement(m[1] === '^' ? 'sup' : 'sub'); el.textContent = m[2] ?? m[3];
37
+ const tail = t.splitText(o - 1 - m[0].length); // tail starts at ^ / _
38
+ tail.deleteData(0, m[0].length); // tail is now the space and what followed
39
+ tail.before(el);
40
+ s.collapse(tail, 1);
41
+ }
42
+ function stopEdit() {
43
+ if (!editing) return;
44
+ const b = editing, c = content(b);
45
+ c.removeAttribute('contenteditable'); c.removeAttribute('spellcheck'); c.oninput = null;
46
+ editing = null; lastRange = null;
47
+ getSelection().removeAllRanges();
48
+ if (!b.dataset.shape && !b.dataset.placeholder && !isMedia(b) && !c.textContent.trim() && !c.querySelector('img')) {
49
+ b.remove(); sel.delete(b); // empty text boxes disappear, as in Google Slides
50
+ }
51
+ if (b.dataset.shape === 'wordart') renderShape(b); // redraw the outlined text
52
+ c.querySelectorAll('pre code[data-highlighted]').forEach(code => { code.textContent = code.textContent; delete code.dataset.highlighted; code.classList.remove('hljs'); }); // recolour edited code
53
+ highlightCode(c); renderMath(c);
54
+ drawOverlay();
55
+ if (c.innerHTML !== editStart || !b.isConnected) commit(); else { updateToolbar(); maybeReload(); }
56
+ }
57
+ /* Text boxes grow to fit their text while typing and shrink back, never below the height the
58
+ user dragged (data-minh; for boxes without it, the height at the start of editing). */
59
+ /* Text boxes resize to fit their text unless data-autofit="none"; shapes only with data-autofit="resize". */
60
+ function fitHeight(b) {
61
+ if (isMedia(b) || isLine(b) || isGroup(b)) return;
62
+ if (b.dataset.autofit === 'shrink') { fitText(b); return; }
63
+ if (b.dataset.shape ? b.dataset.autofit !== 'resize' : b.dataset.autofit === 'none') return;
64
+ const c = content(b), g = geo(b);
65
+ const floor = editing === b ? editFloor : (parseFloat(b.dataset.minh) || g.h);
66
+ c.style.height = 'auto';
67
+ const h = Math.max(floor, c.offsetHeight);
68
+ c.style.height = '';
69
+ if (h !== g.h) { setGeo(b, { ...g, h }); drawOverlay(); }
70
+ }
71
+ document.addEventListener('selectionchange', () => {
72
+ if (!editing) return;
73
+ const s = getSelection();
74
+ if (s.rangeCount && content(editing).contains(s.anchorNode)) lastRange = s.getRangeAt(0).cloneRange();
75
+ });
76
+
77
+ /* Apply a text style: to the selected characters while editing, otherwise to whole boxes. */
78
+ function styleText(cmd, val, whole) {
79
+ const s = editing && editSelection();
80
+ if (s && s.rangeCount && !s.isCollapsed) {
81
+ document.execCommand('styleWithCSS', false, 'true');
82
+ document.execCommand(cmd, false, val);
83
+ lastRange = s.rangeCount ? s.getRangeAt(0).cloneRange() : lastRange; // don't wait for selectionchange
84
+ fitHeight(editing);
85
+ return;
86
+ }
87
+ for (const b of sel) { whole(content(b)); fitHeight(b); }
88
+ commit();
89
+ }
90
+
91
+ /* A CSS property on the selected text while editing, else on whole boxes' .content. Each selected run of text takes it
92
+ (its own span, reused when it already has one), so a selection across paragraphs leaves them paragraphs and sizing
93
+ twice doesn't nest spans. A paragraph the selection covers takes it too: an imported paragraph's own font-size
94
+ would otherwise keep its lines as tall as before. */
95
+ function spanStyle(prop, value) {
96
+ const s = editing && editSelection();
97
+ if (s && s.rangeCount && !s.isCollapsed) {
98
+ const r = s.getRangeAt(0), c = content(editing);
99
+ const covers = (/** @type {Element} */ el) => { // no selected-out text before or after it inside el
100
+ const a = document.createRange(), z = document.createRange();
101
+ a.setStart(el, 0); a.setEnd(r.startContainer, r.startOffset); z.setStart(r.endContainer, r.endOffset); z.setEnd(el, el.childNodes.length);
102
+ return !a.toString() && !z.toString();
103
+ };
104
+ const blocks = [...c.querySelectorAll('div, p, li, h1, h2, h3, h4, h5, h6, blockquote')].filter(e => r.intersectsNode(e) && e.textContent && covers(e));
105
+ const parts = /** @type {[Text, number, number][]} */ ([]), it = document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
106
+ for (let n; (n = /** @type {Text} */ (it.nextNode()));) {
107
+ if (!n.data || !r.intersectsNode(n)) continue;
108
+ const a = n === r.startContainer ? r.startOffset : 0, z = n === r.endContainer ? r.endOffset : n.data.length;
109
+ if (z > a) parts.push([n, a, z]);
110
+ }
111
+ const runs = parts.map(([n, a, z]) => { // measured first, then split
112
+ if (z < n.data.length) n.splitText(z);
113
+ const t = a ? n.splitText(a) : n, p = /** @type {HTMLElement} */ (t.parentElement);
114
+ if (p !== c && p.tagName === 'SPAN' && p.childNodes.length === 1) { p.style.setProperty(prop, value); return t; }
115
+ const span = document.createElement('span'); span.style.setProperty(prop, value); t.replaceWith(span); span.append(t); return t;
116
+ });
117
+ for (const b of blocks) { b.querySelectorAll('[style]').forEach(e => /** @type {HTMLElement} */ (e).style.removeProperty(prop)); /** @type {HTMLElement} */ (b).style.setProperty(prop, value); }
118
+ c.querySelectorAll('span[style=""]').forEach(e => e.replaceWith(...e.childNodes));
119
+ c.querySelectorAll('[style=""]').forEach(e => e.removeAttribute('style'));
120
+ if (runs.length) { r.setStart(runs[0], 0); r.setEnd(runs.at(-1), runs.at(-1).data.length); }
121
+ s.removeAllRanges(); s.addRange(r); lastRange = r.cloneRange();
122
+ fitHeight(editing); return;
123
+ }
124
+ for (const b of sel) { const c = content(b); if (value) c.style.setProperty(prop, value); else c.style.removeProperty(prop); fitHeight(b); }
125
+ commit();
126
+ }
127
+ /* Capitalization, as Slides: rewrites the characters (not CSS), on the selection while editing or on whole boxes. */
128
+ function recase(mode) {
129
+ const fix = (t, prev) => mode === 'lower' ? t.toLowerCase() : mode === 'upper' ? t.toUpperCase()
130
+ : t.toLowerCase().replace(/(^|[^\p{L}\p{N}'’])(\p{L})/gu, (m, sp, ch, i) => sp + (i === 0 && !sp && /[\p{L}\p{N}'’]$/u.test(prev) ? ch : ch.toUpperCase()));
131
+ const walk = (root, range) => {
132
+ let prev = '';
133
+ const it = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
134
+ for (let n; (n = /** @type {Text} */ (it.nextNode()));) {
135
+ if (range && !range.intersectsNode(n)) continue;
136
+ const a = range && n === range.startContainer ? range.startOffset : 0, z = range && n === range.endContainer ? range.endOffset : n.data.length;
137
+ n.data = n.data.slice(0, a) + fix(n.data.slice(a, z), prev || n.data.slice(0, a)) + n.data.slice(z); prev = n.data.slice(0, z);
138
+ }
139
+ };
140
+ const s = editing && editSelection();
141
+ if (s && s.rangeCount && !s.isCollapsed) { walk(content(editing), s.getRangeAt(0)); return; }
142
+ for (const b of sel) { walk(content(b)); fitHeight(b); }
143
+ commit();
144
+ }
145
+ const LETTER_SPACING = [['Very tight', '-0.05em'], ['Tight', '-0.025em'], ['Normal', ''], ['Loose', '0.05em'], ['Very loose', '0.1em']];
146
+ const curLs = () => [...sel][0] ? content([...sel][0]).style.letterSpacing : '';
147
+ /* Run an execCommand over a whole box's text without entering edit mode. */
148
+ function onWholeText(b, fn) {
149
+ const c = content(b), s = getSelection(), r = document.createRange();
150
+ c.contentEditable = 'true'; r.selectNodeContents(c); s.removeAllRanges(); s.addRange(r);
151
+ document.execCommand('styleWithCSS', false, 'true'); fn();
152
+ c.removeAttribute('contenteditable'); s.removeAllRanges();
153
+ }
154
+ /* Highlight (background on text spans), on the selection while editing or on whole boxes. 'transparent' = none. */
155
+ function hilite(color) {
156
+ const tidy = c => c.querySelectorAll('[style]').forEach(e => {
157
+ if (e.style.backgroundColor === 'transparent' || e.style.backgroundColor === 'rgba(0, 0, 0, 0)') e.style.backgroundColor = '';
158
+ if (!e.getAttribute('style')) { e.removeAttribute('style'); if (e.tagName === 'SPAN') e.replaceWith(...e.childNodes); }
159
+ });
160
+ // Chrome ignores hiliteColor('transparent'), so "None" clears backgrounds directly.
161
+ const clear = (c, r) => { c.querySelectorAll('[style]').forEach(e => { if (!r || r.intersectsNode(e)) e.style.backgroundColor = ''; }); tidy(c); };
162
+ if (editing && lastRange && !lastRange.collapsed) {
163
+ if (color === 'transparent') { clear(content(editing), lastRange); scheduleSave(); } else { styleText('hiliteColor', color); tidy(content(editing)); }
164
+ return;
165
+ }
166
+ for (const b of sel) {
167
+ if (isMedia(b) || isLine(b)) continue;
168
+ if (color === 'transparent') clear(content(b)); else onWholeText(b, () => document.execCommand('hiliteColor', false, color));
169
+ tidy(content(b));
170
+ }
171
+ commit();
172
+ }
173
+
174
+ /* Paint format: copy the selected box's (or selected text's) style, then click a box or select text to apply
175
+ it. Double-clicking the button keeps it on until Esc or another click on the button. */
176
+ let paint = null;
177
+ const TEXT_KEYS = ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecoration', 'color', 'textAlign', 'lineHeight', 'padding'];
178
+ function captureStyle() {
179
+ const b = editing || [...sel][0];
180
+ if (!b) return null;
181
+ const fromText = editing && lastRange && !lastRange.collapsed;
182
+ const node = fromText ? lastRange.startContainer : content(b).querySelector('span, b, i, u, a') || content(b);
183
+ const el = node.nodeType === 1 ? node : node.parentElement, cs = getComputedStyle(el);
184
+ return {
185
+ text: { bold: +cs.fontWeight >= 600, italic: cs.fontStyle === 'italic', underline: cs.textDecorationLine.includes('underline'),
186
+ strike: cs.textDecorationLine.includes('line-through'), color: toHex(cs.color, '#222222'), font: cs.fontFamily,
187
+ hilite: el.closest('[style*="background"]')?.style.backgroundColor || 'transparent' },
188
+ box: fromText ? null : {
189
+ vars: Object.fromEntries(['--fill', '--stroke', '--sw'].map(k => [k, b.style.getPropertyValue(k)])),
190
+ shadow: 'shadow' in b.dataset, valign: b.dataset.valign,
191
+ content: Object.fromEntries(TEXT_KEYS.map(k => [k, content(b).style[k]])),
192
+ },
193
+ };
194
+ }
195
+ function startPaint(locked) {
196
+ const style = captureStyle();
197
+ if (!style) return;
198
+ paint = { ...style, locked };
199
+ document.body.classList.add('painting'); $('#ed-paint-format').classList.add('on');
200
+ }
201
+ /* ⌥⌘C / ⌥⌘V: copy the selection's formatting, apply it to another selection (paint format without clicking). */
202
+ let copiedFormat = null;
203
+ function copyFormat() { copiedFormat = captureStyle(); if (copiedFormat) setStatus('Formatting copied'); }
204
+ function pasteFormat() {
205
+ if (!copiedFormat) return;
206
+ const was = paint; paint = copiedFormat;
207
+ const s = editing && editSelection();
208
+ if (s && !s.isCollapsed) { paintSelection(); scheduleSave(); }
209
+ else { sel.forEach(paintBox); drawOverlay(); commit(); }
210
+ paint = was;
211
+ }
212
+ function endPaint() { paint = null; document.body.classList.remove('painting'); $('#ed-paint-format').classList.remove('on'); }
213
+ function paintBox(b) {
214
+ const { box, text } = paint;
215
+ if (box) {
216
+ for (const [k, v] of Object.entries(box.vars)) if (v) b.style.setProperty(k, v); else b.style.removeProperty(k);
217
+ if (box.shadow) b.dataset.shadow = ''; else delete b.dataset.shadow;
218
+ if (box.valign) b.dataset.valign = box.valign; else delete b.dataset.valign;
219
+ if (!isMedia(b) && !isLine(b)) Object.assign(content(b).style, box.content);
220
+ if (b.dataset.shape) renderShape(b);
221
+ shadeBox(b);
222
+ } else if (!isMedia(b) && !isLine(b)) {
223
+ const c = content(b).style;
224
+ c.fontWeight = text.bold ? 'bold' : ''; c.fontStyle = text.italic ? 'italic' : ''; c.color = text.color; c.fontFamily = text.font;
225
+ c.textDecoration = [text.underline && 'underline', text.strike && 'line-through'].filter(Boolean).join(' ');
226
+ }
227
+ fitHeight(b);
228
+ }
229
+ function paintSelection() {
230
+ const t = paint.text, set = (cmd, want) => { if (document.queryCommandState(cmd) !== want) document.execCommand(cmd); };
231
+ document.execCommand('styleWithCSS', false, 'true');
232
+ set('bold', t.bold); set('italic', t.italic); set('underline', t.underline); set('strikeThrough', t.strike);
233
+ document.execCommand('foreColor', false, t.color); document.execCommand('fontName', false, t.font);
234
+ document.execCommand('hiliteColor', false, t.hilite);
235
+ const s = getSelection(); if (s.rangeCount) lastRange = s.getRangeAt(0).cloneRange();
236
+ fitHeight(editing);
237
+ }
238
+ $('#ed-paint-format').onclick = () => { if (paint && !paint.locked) endPaint(); else if (!paint) startPaint(false); };
239
+ $('#ed-paint-format').ondblclick = () => { endPaint(); startPaint(true); };
240
+ addEventListener('keydown', e => { if (paint && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); endPaint(); } }, true);
241
+ addEventListener('pointerup', () => { // painting onto text selected inside the box being edited
242
+ if (!paint || !editing) return;
243
+ const s = getSelection();
244
+ if (s.rangeCount && !s.isCollapsed && content(editing).contains(s.anchorNode)) { paintSelection(); if (!paint.locked) endPaint(); }
245
+ });
246
+
247
+ /* Toggle a list: on the selection while editing, otherwise on the whole box (edit, select all, apply). */
248
+ function listText(cmd) {
249
+ const b = editing || (sel.size === 1 ? [...sel][0] : null);
250
+ if (!b || isMedia(b) || isLine(b)) return;
251
+ const was = !!editing;
252
+ if (!was) startEdit(b);
253
+ else if (lastRange) { const s = getSelection(); s.removeAllRanges(); s.addRange(lastRange); }
254
+ document.execCommand(cmd);
255
+ // Chrome copies computed font-family / transparent backgrounds inline when unlisting; drop the no-ops.
256
+ content(b).querySelectorAll('[style]').forEach(e => {
257
+ if (e.style.backgroundColor === 'rgba(0, 0, 0, 0)') e.style.backgroundColor = '';
258
+ const f = e.style.fontFamily;
259
+ if (f) { const was = getComputedStyle(e).fontFamily; e.style.fontFamily = ''; if (getComputedStyle(e).fontFamily !== was) e.style.fontFamily = f; }
260
+ if (!e.getAttribute('style')) e.removeAttribute('style');
261
+ });
262
+ // Chrome puts the list inside an <a> that wrapped the whole text; move the link onto each item instead.
263
+ content(b).querySelectorAll('a > ul, a > ol').forEach(list => {
264
+ const a = list.parentElement;
265
+ list.querySelectorAll(':scope > li').forEach(li => { const l = a.cloneNode(false); l.append(...li.childNodes); li.append(l); });
266
+ a.replaceWith(...a.childNodes);
267
+ });
268
+ const s = getSelection(); if (s.rangeCount) lastRange = s.getRangeAt(0).cloneRange();
269
+ fitHeight(b);
270
+ if (!was) stopEdit();
271
+ }
272
+
273
+ /* The selection to act on while editing: the live one if it's in the box, else the last one seen there
274
+ (a toolbar or menu click can move it). */
275
+ function editSelection() {
276
+ const s = getSelection();
277
+ if (!(s.rangeCount && content(editing).contains(s.anchorNode)) && lastRange) { s.removeAllRanges(); s.addRange(lastRange); }
278
+ return s;
279
+ }
280
+
281
+ /* Indent / outdent, as Slides: inside a list it nests or un-nests the items (Tab / ⇧Tab, ⌘] / ⌘[);
282
+ for plain text it steps the box's left padding. Without a text selection it acts on whole boxes. */
283
+ const INDENT = 48;
284
+ function indentText(dir) {
285
+ const cmd = dir > 0 ? 'indent' : 'outdent';
286
+ const pad = b => { const c = content(b), p = parseFloat(getComputedStyle(c).paddingLeft); c.style.paddingLeft = Math.max(16, p + dir * INDENT) + 'px'; };
287
+ if (editing) {
288
+ const s = editSelection();
289
+ const at = s.anchorNode?.nodeType === 1 ? s.anchorNode : s.anchorNode?.parentElement;
290
+ if (at?.closest('li')) { document.execCommand('styleWithCSS', false, 'false'); document.execCommand(cmd); if (s.rangeCount) lastRange = s.getRangeAt(0).cloneRange(); }
291
+ else pad(editing);
292
+ fitHeight(editing); scheduleSave(); return;
293
+ }
294
+ for (const b of sel) {
295
+ if (isMedia(b) || isLine(b)) continue;
296
+ if (content(b).querySelector('li')) onWholeText(b, () => { document.execCommand('styleWithCSS', false, 'false'); document.execCommand(cmd); });
297
+ else pad(b);
298
+ fitHeight(b);
299
+ }
300
+ commit();
301
+ }
302
+
303
+ /* List styles from Format → Bullets & numbering: set list-style-type on the list(s) — the one holding the
304
+ caret while editing, otherwise every list of that kind in the selected boxes (making one if needed). */
305
+ const BULLETS = [['disc', '● ○ ■'], ['circle', '○'], ['square', '■'], ['"➤ "', '➤'], ['"✓ "', '✓'], ['"– "', '–']];
306
+ const NUMBERS = [['decimal', '1. 2. 3.'], ['lower-alpha', 'a. b. c.'], ['lower-roman', 'i. ii. iii.'], ['upper-alpha', 'A. B. C.'], ['upper-roman', 'I. II. III.']];
307
+ function setListStyle(tag, type) {
308
+ const cmd = tag === 'ul' ? 'insertUnorderedList' : 'insertOrderedList';
309
+ const style = l => { if (['disc', 'decimal'].includes(type)) l.style.listStyleType = ''; else l.style.listStyleType = type; if (!l.getAttribute('style')) l.removeAttribute('style'); };
310
+ if (editing) {
311
+ const s = editSelection();
312
+ const at = () => (s.anchorNode?.nodeType === 1 ? s.anchorNode : s.anchorNode?.parentElement)?.closest('ul, ol');
313
+ if (at()?.tagName.toLowerCase() !== tag) listText(cmd);
314
+ const l = at(); if (l) style(l);
315
+ scheduleSave(); return;
316
+ }
317
+ for (const b of sel) {
318
+ if (isMedia(b) || isLine(b)) continue;
319
+ if (!content(b).querySelector(tag)) { sel = new Set([b]); listText(cmd); }
320
+ content(b).querySelectorAll(tag).forEach(l => { if (!l.parentElement.closest(tag)) style(l); });
321
+ }
322
+ commit();
323
+ }
324
+
325
+ /* ⌘K: link the selected text (whole box if not editing). An empty URL removes the link. */
326
+ function linkText() {
327
+ const b = editing || (sel.size === 1 ? [...sel][0] : null);
328
+ if (!b || isMedia(b) || isLine(b)) return;
329
+ const was = !!editing;
330
+ if (!was) startEdit(b);
331
+ else if (lastRange) { const s = getSelection(); s.removeAllRanges(); s.addRange(lastRange); }
332
+ const s = getSelection();
333
+ if (!s.rangeCount || s.isCollapsed) { if (!was) stopEdit(); return; }
334
+ const range = s.getRangeAt(0).cloneRange();
335
+ const cur = s.anchorNode?.parentElement?.closest('a')?.getAttribute('href') || '';
336
+ const url = prompt('Link URL', cur || 'https://');
337
+ if (url === null) { if (!was) stopEdit(); return; }
338
+ s.removeAllRanges(); s.addRange(range); // the prompt may have cleared the selection
339
+ if (url.trim() && url.trim() !== 'https://') document.execCommand('createLink', false, url.trim());
340
+ else document.execCommand('unlink');
341
+ if (s.rangeCount) lastRange = s.getRangeAt(0).cloneRange();
342
+ if (!was) stopEdit();
343
+ }
344
+
345
+ /* ⌘\: clear formatting. On a selection: the browser's removeFormat + unlink. On whole boxes: drop the
346
+ text styles set on .content and unwrap every inline styling element, keeping lists and line breaks. */
347
+ function clearFormat() {
348
+ if (editing && lastRange && !lastRange.collapsed) {
349
+ const s = getSelection(); s.removeAllRanges(); s.addRange(lastRange);
350
+ document.execCommand('removeFormat'); document.execCommand('unlink');
351
+ if (s.rangeCount) lastRange = s.getRangeAt(0).cloneRange();
352
+ fitHeight(editing);
353
+ return;
354
+ }
355
+ for (const b of sel) {
356
+ if (isMedia(b) || isLine(b)) continue;
357
+ const c = content(b);
358
+ ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'textDecoration', 'verticalAlign', 'color', 'lineHeight', 'textAlign'].forEach(k => { c.style[k] = ''; });
359
+ if (!c.getAttribute('style')) c.removeAttribute('style');
360
+ c.querySelectorAll('span, b, strong, i, em, u, s, strike, sup, sub, a, font').forEach(e => e.replaceWith(...e.childNodes));
361
+ c.querySelectorAll('[style]').forEach(e => e.removeAttribute('style'));
362
+ c.normalize();
363
+ fitHeight(b);
364
+ }
365
+ commit();
366
+ }
@@ -0,0 +1,41 @@
1
+ /* The light / dark switch (a sun or moon button in the top bar, and View → Dark mode): the new theme spreads from the
2
+ button in a circle (the View Transitions API), the icon turning as it goes. Instant where the browser lacks it or the
3
+ system asks for reduced motion. The editor already has darkOn / setDark (js/notes.js); the home screen gets them here.
4
+ The choice is the same one in both, remembered in this browser (slides-dark), else the system's. */
5
+ if (typeof setDark !== 'function') { // the home screen
6
+ const key = 'slides-dark', saved = () => { try { return localStorage.getItem(key); } catch { return null; } };
7
+ const sys = matchMedia('(prefers-color-scheme: dark)');
8
+ window['darkOn'] = () => { const v = saved(); return v ? v === '1' : sys.matches; };
9
+ window['setDark'] = on => { try { localStorage.setItem(key, on ? '1' : '0'); } catch {} document.body.classList.toggle('dark', on); };
10
+ document.body.classList.toggle('dark', darkOn());
11
+ sys.addEventListener('change', () => { if (!saved()) document.body.classList.toggle('dark', darkOn()); });
12
+ }
13
+ {
14
+ const btn = document.createElement('button');
15
+ btn.id = 'theme-toggle'; btn.type = 'button';
16
+ const label = () => {
17
+ const dark = document.body.classList.contains('dark'), tip = dark ? 'Light mode' : 'Dark mode';
18
+ btn.innerHTML = `<span class="icon">${dark ? 'light_mode' : 'dark_mode'}</span>`;
19
+ btn.setAttribute('aria-label', tip); btn.dataset.tip = tip; if (!document.querySelector('#ed-bar')) btn.title = tip; // the editor's tooltips read data-tip
20
+ };
21
+ const setDark0 = setDark;
22
+ window['setDark'] = on => {
23
+ const apply = () => { setDark0(on); label(); };
24
+ if (on === document.body.classList.contains('dark')) return apply();
25
+ if (!document.startViewTransition || !navigator.userActivation?.isActive || matchMedia('(prefers-reduced-motion: reduce)').matches || !btn.getClientRects().length) return apply(); // animated only for a click (button or menu); a script's call is immediate
26
+ const r = btn.getBoundingClientRect(), x = r.left + r.width / 2, y = r.top + r.height / 2;
27
+ const end = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
28
+ document.documentElement.classList.add('theme-turning');
29
+ const t = document.startViewTransition(apply);
30
+ t.ready.then(() => document.documentElement.animate(
31
+ { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${end}px at ${x}px ${y}px)`] },
32
+ { duration: 700, easing: 'cubic-bezier(.65, 0, .35, 1)', pseudoElement: '::view-transition-new(root)' })).catch(() => {});
33
+ t.finished.finally(() => document.documentElement.classList.remove('theme-turning'));
34
+ };
35
+ btn.onclick = () => setDark(!document.body.classList.contains('dark'));
36
+ label();
37
+ if (document.querySelector('#ed-comments-btn')) { btn.className = 'icon-btn'; document.querySelector('#ed-comments-btn').after(btn); }
38
+ else { // the home screen: in the top bar's last item (.hm-end), between GitHub and More
39
+ btn.className = 'hm-tool'; document.querySelector('#hm-app-more').before(btn);
40
+ }
41
+ }
@@ -0,0 +1,173 @@
1
+ /* Themes, as Slides' Themes panel: a deck font, title / body sizes and colours, and a background, kept as CSS variables in a
2
+ marked block of <style id="deck-theme"> (the rest of that style stays the author's). Text without its own font / colour /
3
+ size inherits them; layout placeholders size from them; slides still on the old theme background get the new one. */
4
+ const THEMES = window.THEMES; // editor/themes.js, shared with the home screen's template gallery
5
+ const THEME_RE = /\/\* slides-theme (\{.*?\}) \*\/[\s\S]*?\/\* end slides-theme \*\/\n?/;
6
+ function theme() {
7
+ const m = (deckDoc?.querySelector('#deck-theme')?.textContent || '').match(THEME_RE);
8
+ let t;
9
+ try { t = m ? { ...THEMES[0], ...JSON.parse(m[1]) } : { ...THEMES[0], implicit: true }; } catch { t = { ...THEMES[0], implicit: true }; }
10
+ // a size that didn't measure (saved as null by an older Edit theme) falls back to Simple Light's, so placeholders never collapse
11
+ for (const k of ['titleSize', 'bodySize']) if (!(t[k] > 0)) t[k] = THEMES[0][k];
12
+ if (t.layouts) t.layouts = Object.fromEntries(Object.entries(t.layouts).filter(([, bs]) => bs.every(b => b[5] > 0)));
13
+ return t;
14
+ }
15
+ const themeCSS = t => `/* slides-theme ${JSON.stringify({ name: t.name, font: t.font, titleSize: t.titleSize, bodySize: t.bodySize, title: t.title, text: t.text, bg: t.bg, ...(t.layouts ? { layouts: t.layouts } : {}) })} */
16
+ .slides { --th-title-size: ${Math.round(t.titleSize * PT * 100) / 100}px; --th-body-size: ${Math.round(t.bodySize * PT * 100) / 100}px; }
17
+ .box > .content { font-family: ${t.font}; color: ${t.text}; }
18
+ .box[data-role="title"] > .content { color: ${t.title}; }
19
+ /* end slides-theme */
20
+ `;
21
+ function setTheme(t) {
22
+ const old = theme(), el = deckDoc.querySelector('#deck-theme') || deckDoc.head.appendChild(Object.assign(deckDoc.createElement('style'), { id: 'deck-theme' }));
23
+ const css = el.textContent.replace(THEME_RE, '');
24
+ el.textContent = themeCSS(t) + css.replace(/^\n+/, '\n');
25
+ $('#ed-deck-theme-live').textContent = el.textContent;
26
+ for (const sl of slides) if (!sl.dataset.backgroundColor || sl.dataset.backgroundColor.toLowerCase() === old.bg.toLowerCase()) sl.dataset.backgroundColor = t.bg;
27
+ render(); commit(); scheduleSave(); updateThemePanel();
28
+ }
29
+ function setThemePanel(open) { $('#ed-theme').hidden = !open; if (open) { fmt.hidden = true; $('#ed-trans').hidden = true; $('#ed-comments').hidden = true; updateThemePanel(); } fit(); }
30
+ function updateThemePanel() {
31
+ if ($('#ed-theme').hidden) return;
32
+ const t = theme();
33
+ $('#ed-theme-list').replaceChildren(...THEMES.map(p => {
34
+ const c = div('theme-card' + (p.name === t.name && !t.implicit || (t.implicit && p === THEMES[0]) ? ' on' : ''));
35
+ c.innerHTML = `<div class="theme-pic" style="background:${p.bg};font-family:${p.font.replace(/"/g, "'")}"><b style="color:${p.title}">Click to add title</b><small style="color:${p.text}">Click to add subtitle</small></div><span></span>`;
36
+ c.lastChild.textContent = p.name; c.onclick = () => setTheme({ ...p }); return c;
37
+ }));
38
+ F2('font').replaceChildren(...FONTS.filter(f => f[0]).map(([v, l]) => Object.assign(document.createElement('option'), { value: v, textContent: l })));
39
+ F2('font').value = t.font; F2('title-size').value = t.titleSize; F2('body-size').value = t.bodySize; F2('title').value = t.title; F2('text').value = t.text; F2('bg').value = t.bg;
40
+ }
41
+ const F2 = id => $('#ed-th-' + id);
42
+ for (const [id, key, num] of [['font', 'font'], ['title-size', 'titleSize', 1], ['body-size', 'bodySize', 1], ['title', 'title'], ['text', 'text'], ['bg', 'bg']])
43
+ F2(id).onchange = () => setTheme({ ...theme(), name: 'Custom', [key]: num ? Math.max(4, +F2(id).value || 0) : F2(id).value });
44
+ $('#ed-theme-btn').onclick = () => setThemePanel($('#ed-theme').hidden);
45
+ $('#ed-layout-btn').onclick = () => openAnchor === $('#ed-layout-btn') ? closeMenu() : openMenu($('#ed-layout-btn'), layoutItems); // the layout grid, as Slides' toolbar
46
+ $('#ed-theme-close').onclick = () => setThemePanel(false);
47
+
48
+ /* Slide → Edit theme, as Slides' theme builder: the filmstrip becomes the theme (master) and its layouts, each an editable
49
+ slide of placeholders. Move, resize and style them with the usual tools; on leaving, the layouts' geometry and sizes and the
50
+ master's title / body size and colour are written to the theme. Reapply to all re-lays the deck's slides of that layout. */
51
+ let themeStash = null, reapply = new Set();
52
+ const SAMPLE = { title: 'Click to edit theme title style', sub: 'Click to edit theme subtitle style', body: 'Click to edit theme text style' };
53
+ function enterThemeEdit() {
54
+ if (themeStash) return;
55
+ stopEdit(); closeMenu(); setGrid(false);
56
+ const t = theme(), keep = slides[cur]?.dataset.layout;
57
+ themeStash = { slides, cur }; reapply = new Set();
58
+ const master = blankSlide(); master.dataset.edTheme = 'master'; master.dataset.backgroundColor = t.bg;
59
+ for (const [role, y, h] of [['title', 93, 122], ['body', 242, 721]]) {
60
+ const b = makeBox({ x: 65, y, w: 1790, h, html: role === 'title' ? SAMPLE.title : '<ul><li>First level</li><li>Second level</li></ul>' });
61
+ b.dataset.role = String(role); b.dataset.valign = 'top'; content(b).style.fontSize = Math.round((role === 'title' ? t.titleSize : t.bodySize) * PT * 100) / 100 + 'px';
62
+ if (role === 'title') content(b).style.color = t.title; master.append(b);
63
+ }
64
+ const lays = LAYOUTS.filter(l => l[0] !== 'blank').map(l => {
65
+ const s = blankSlide(l[0]); s.dataset.edTheme = String(l[0]);
66
+ boxes(s).forEach(b => { content(b).textContent = SAMPLE[b.dataset.role] || SAMPLE.body; delete b.dataset.placeholder; });
67
+ return s;
68
+ });
69
+ slides = [master, ...lays]; cur = Math.max(0, slides.findIndex(s => s.dataset.edTheme === keep));
70
+ document.body.classList.add('theme-mode'); $('#ed-theme-bar').hidden = false; sel.clear(); render(); fit();
71
+ }
72
+ function exitThemeEdit() {
73
+ if (!themeStash) return;
74
+ stopEdit();
75
+ // font sizes are measured, and only the shown slide is in the page: measure the others from a hidden copy on the canvas
76
+ const shown = s => { if (s.isConnected) return s; const c = s.cloneNode(true); c.classList.add('measure'); c.style.visibility = 'hidden'; canvas.append(c); return c; };
77
+ const t = theme(), px = b => parseFloat(getComputedStyle(content(b)).fontSize), r1 = v => Math.round(v * 10) / 10, layouts = { ...(t.layouts || {}) };
78
+ const master = shown(slides.find(s => s.dataset.edTheme === 'master')), mt = boxes(master).find(b => b.dataset.role === 'title'), mb = boxes(master).find(b => b.dataset.role === 'body');
79
+ const next = { ...t, titleSize: mt ? r1(px(mt) / PT) : t.titleSize, bodySize: mb ? r1(px(mb) / PT) : t.bodySize, title: mt ? toHex(getComputedStyle(content(mt)).color, t.title) : t.title };
80
+ for (const s of slides.filter(s => s.dataset.edTheme && s.dataset.edTheme !== 'master').map(shown)) {
81
+ layouts[s.dataset.edTheme] = boxes(s).filter(b => b.dataset.role).map(b => { const g = geo(b), c = content(b), [ts, bs] = [next.titleSize, next.bodySize];
82
+
83
+ // sizes are kept relative to the master's title / body sizes, so a later theme size change still scales them
84
+ const pt = px(b) / PT, rel = b.dataset.role === 'title' ? pt / ts * 28 : pt / bs * 18;
85
+ return [b.dataset.role, Math.round(g.x), Math.round(g.y), Math.round(g.w), Math.round(g.h), r1(rel), b.dataset.valign || 'middle', c.style.textAlign || 'left']; });
86
+ }
87
+ canvas.querySelectorAll(':scope > section.measure').forEach(c => c.remove());
88
+ slides = themeStash.slides; cur = themeStash.cur; themeStash = null;
89
+
90
+ document.body.classList.remove('theme-mode'); $('#ed-theme-bar').hidden = true;
91
+ next.layouts = layouts; next.name = t.name === 'Custom' || t.implicit ? 'Custom' : t.name;
92
+ setTheme(next);
93
+ for (const name of reapply) slides.filter(s => s.dataset.layout === name).forEach(s => applyLayout(s, name));
94
+ if (reapply.size) { render(); commit(); }
95
+ fit();
96
+ }
97
+ function updateThemeBar() {
98
+ if (!themeStash) return;
99
+ const s = slides[cur], name = s?.dataset.edTheme, lay = LAYOUTS.find(l => l[0] === name), used = themeStash.slides.filter(x => x.dataset.layout === name).length;
100
+ $('#ed-theme-what').innerHTML = `Editing: <b></b> <span class="muted"></span>`;
101
+ $('#ed-theme-what b').textContent = `${theme().name} - ${name === 'master' ? 'Theme' : lay?.[1] || ''}`;
102
+ $('#ed-theme-what span').textContent = name === 'master' ? '' : `(Used by ${used} slide${used === 1 ? '' : 's'})`;
103
+ $('#ed-theme-reapply').hidden = name === 'master';
104
+ }
105
+ $('#ed-theme-done').onclick = exitThemeEdit;
106
+ $('#ed-theme-reapply').onclick = () => { const n = slides[cur]?.dataset.edTheme; if (n) { reapply.add(n); setStatus('Will reapply this layout to its slides on closing'); } };
107
+
108
+ /* Layouts, as Slides' Simple Light theme: placeholder text boxes (data-placeholder, grey "Click to add title" while empty,
109
+ kept when left empty, hidden in decks until filled) at the theme's positions on the 1920×1080 canvas. */
110
+ const PH = { title: 'Click to add title', sub: 'Click to add subtitle', body: 'Click to add text' };
111
+ const LAYOUTS = [
112
+ ['title', 'Title slide', [['title', 65, 152, 1790, 431, 52, 'bottom', 'center'], ['sub', 65, 590, 1790, 166, 28, 'top', 'center']]],
113
+ ['section', 'Section header', [['title', 65, 452, 1790, 172, 36, 'middle', 'center']]],
114
+ ['title-body', 'Title and body', [['title', 65, 93, 1790, 122, 28, 'top', 'left'], ['body', 65, 242, 1790, 721, 18, 'top', 'left']]],
115
+ ['two-columns', 'Title and two columns', [['title', 65, 93, 1790, 122, 28, 'top', 'left'], ['body', 65, 242, 841, 721, 18, 'top', 'left'], ['body', 1013, 242, 841, 721, 18, 'top', 'left']]],
116
+ ['title-only', 'Title only', [['title', 65, 93, 1790, 122, 28, 'top', 'left']]],
117
+ ['one-column', 'One column text', [['title', 65, 117, 600, 160, 24, 'bottom', 'left'], ['body', 65, 292, 600, 590, 18, 'top', 'left']]],
118
+ ['main-point', 'Main point', [['title', 65, 169, 1545, 742, 48, 'middle', 'left']]],
119
+ ['section-description', 'Section title and description', [['title', 108, 259, 812, 311, 42, 'bottom', 'left'], ['sub', 108, 589, 812, 259, 21, 'top', 'left'], ['body', 1037, 152, 815, 776, 18, 'middle', 'left']]],
120
+ ['caption', 'Caption', [['body', 65, 888, 1260, 127, 14, 'middle', 'left']]],
121
+ ['big-number', 'Big number', [['title', 65, 232, 1790, 413, 120, 'bottom', 'center'], ['body', 65, 662, 1790, 273, 18, 'top', 'center']]],
122
+ ['blank', 'Blank', []],
123
+ ];
124
+ function applyLayout(s, name) {
125
+ const lay = LAYOUTS.find(l => l[0] === name); if (!lay) return;
126
+ const boxesOf = theme().layouts?.[name] || lay[2]; // edited in Slide → Edit theme, else Simple Light's
127
+ const old = boxes(s).filter(b => b.dataset.placeholder), kept = { title: [], sub: [], body: [] };
128
+ old.forEach(b => { const r = b.dataset.role || 'body'; if (content(b).textContent.trim()) kept[r].push(content(b).innerHTML); b.remove(); });
129
+ for (const [role, x, y, w, h, pt, va, ta] of boxesOf) {
130
+ const b = makeBox({ x, y, w, h, html: kept[role].shift() || '' });
131
+ b.dataset.placeholder = PH[role]; b.dataset.role = String(role); if (va !== 'middle') b.dataset.valign = va;
132
+ // sizes follow the theme: Title and body's title / body sizes are 28 / 18 pt, other layouts scale from them
133
+ const [v, base] = role === 'title' ? ['--th-title-size', 28] : ['--th-body-size', 18];
134
+ Object.assign(content(b).style, { fontSize: `calc(var(${v}, ${Math.round(base * PT * 100) / 100}px) * ${Math.round(pt / base * 1000) / 1000})`, textAlign: ta });
135
+ s.insertBefore(b, s.querySelector(':scope > .box:not([data-placeholder])'));
136
+ }
137
+ s.dataset.layout = name;
138
+ }
139
+ /* In Thermal a layout's picture is a wireframe rather than Slides' outlines and prompts: its title a canopy-blue bar
140
+ (a large one for a big number), a subtitle a shorter grey bar, body text a few soft lines, aligned as the layout
141
+ aligns them; coloured by thermal.css (.lp-*). */
142
+ function layoutWire(lay) {
143
+ const bar = (x, y, w, h, cls) => `<rect class="${cls}" x="${Math.round(x)}" y="${Math.round(y)}" width="${Math.round(w)}" height="${Math.round(h)}" rx="${Math.round(h / 2)}"/>`;
144
+ const at = (x, w, bw, align) => align === 'center' ? x + (w - bw) / 2 : x + 40;
145
+ const parts = lay[2].map(([role, x, y, w, h, pt, valign, align]) => {
146
+ if (role === 'title') {
147
+ const big = lay[0] === 'big-number', bh = big ? Math.min(h - 40, 260) : Math.min(h - 30, pt >= 40 ? 120 : 84), bw = big ? Math.min(w * .32, 560) : Math.min(w - 80, w * (lay[0] === 'main-point' ? .7 : .55));
148
+ const by = valign === 'bottom' ? y + h - bh - 20 : valign === 'middle' ? y + (h - bh) / 2 : y + 18;
149
+ return bar(at(x, w, bw, align), by, bw, bh, 'lp-title');
150
+ }
151
+ if (role === 'sub') { const bw = Math.min(w - 80, w * .4), bh = Math.min(h - 30, 56); return bar(at(x, w, bw, align), valign === 'top' ? y + 22 : y + (h - bh) / 2, bw, bh, 'lp-sub'); }
152
+ const n = Math.max(1, Math.min(4, Math.floor((h - 40) / 110))), lh = 40, step = Math.min(110, (h - 40) / n), widths = [.86, .72, .8, .6];
153
+ const top = valign === 'middle' ? y + (h - n * step) / 2 + (step - lh) / 2 : y + 30;
154
+ return `<rect class="lp-box" x="${x}" y="${y}" width="${w}" height="${h}" rx="28"/>` + widths.slice(0, n).map((f, i) => bar(at(x, w, (w - 80) * f, align), top + i * step, (w - 80) * f, lh, 'lp-line')).join('');
155
+ });
156
+ return `<svg class="lp" viewBox="0 0 1920 1080" width="140" height="79"><rect class="lp-bg" width="1920" height="1080"/>` +
157
+ (lay[0] === 'section-description' ? '<rect class="lp-pane" x="960" width="960" height="1080"/>' : '') + parts.join('') + '</svg>';
158
+ }
159
+ const layoutPic = lay => document.documentElement.dataset.look === 'thermal' ? layoutWire(lay) : `<svg viewBox="0 0 1920 1080" width="120" height="68"><rect width="1920" height="1080" fill="#fff" stroke="#c7c7c7" stroke-width="12"/>` +
160
+ (lay[0] === 'section-description' ? '<rect x="960" width="960" height="1080" fill="#eee"/>' : '') +
161
+ lay[2].map(([role, x, y, w, h]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="#9aa0a6" stroke-width="8"/>`
162
+ + `<text x="${x + 30}" y="${y + Math.min(h / 2 + 30, 110)}" font-size="${role === 'title' ? 90 : 60}" fill="#5f6368" font-family="Arial">${role === 'title' ? 'Click to add title' : ''}</text>`).join('') + '</svg>';
163
+ const layoutItems = () => LAYOUTS.map(lay => ({ label: lay[1], tile: true, svg: layoutPic(lay), on: () => slides[cur]?.dataset.layout === lay[0],
164
+ run: () => slideOp(() => applyLayout(slides[cur], lay[0])) }));
165
+
166
+ function addText(text = 'Text', at) {
167
+ const w = 800, h = 160;
168
+ const b = makeBox({ x: (at?.x ?? W / 2) - w / 2, y: (at?.y ?? H / 2) - h / 2, w, h });
169
+ b.dataset.minh = String(h);
170
+ content(b).textContent = text;
171
+ insert(b);
172
+ return b;
173
+ }