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,25 @@
1
+ /* ---------- creating boxes ---------- */
2
+
3
+ /** @param {{ x: any, y: any, w: any, h: any, shape?: string, html?: string, cls?: string }} o */
4
+ function makeBox({ x, y, w, h, shape, html = '', cls = '' }) {
5
+ const b = div(('box ' + cls).trim());
6
+ b.id = uid();
7
+ if (shape) b.dataset.shape = shape;
8
+ const c = div('content'); c.innerHTML = html; b.append(c);
9
+ // Slides' default text insets: 0.1 in left / right, 0.05 in top / bottom (base.css keeps 16 / 8 px, so older decks don't move)
10
+ if (!/\b(image|video)\b/.test(cls) && !/^(line|arrowline|elbow|curve|path)$/.test(shape || '')) c.style.padding = INSET;
11
+ setGeo(b, { x, y, w, h });
12
+ return b;
13
+ }
14
+ const INSET = "9.6px 19.2px"; // 0.05 in, 0.1 in at 192 px per inch
15
+ function insert(...bs) {
16
+ slides[cur].append(...bs);
17
+ sel = new Set(bs);
18
+ drawOverlay(); commit();
19
+ }
20
+ function blankSlide(layout) {
21
+ const s = document.createElement('section');
22
+ s.dataset.backgroundColor = theme().bg;
23
+ if (layout) applyLayout(s, layout);
24
+ return s;
25
+ }
@@ -0,0 +1,85 @@
1
+ /* ---------- comments ---------- */
2
+ /* Comments people left on a shared deck (glideshow.org/p/…), or added here: comments.json beside deck.html, through
3
+ /api/comments. The panel lists them (this slide's first); a pin marks each one on its slide; clicking one goes to its
4
+ slide and selects the box it was left on. ⌘⌥M (Slides' Insert comment) comments on the selection or the slide. */
5
+ const cmPanel = $('#ed-comments'), pins = $('#ed-pins');
6
+ let cmList = [], cmDraft = null;
7
+ const cmAgo = t => { const s = (Date.now() - Date.parse(t)) / 1000; return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)} min ago` : s < 86400 ? `${Math.floor(s / 3600)} h ago` : new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); };
8
+ const cmEl = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
9
+
10
+ async function cmFetch(body) {
11
+ const r = await fetch('/api/comments', body ? { method: 'POST', body: JSON.stringify(body) } : { cache: 'no-store' }).then(r => r.json()).catch(() => null);
12
+ if (Array.isArray(r?.comments)) cmList = r.comments;
13
+ if (r?.error && body) setStatus(r.error, 'error');
14
+ cmRender();
15
+ }
16
+ function setCommentsPanel(open) {
17
+ cmPanel.hidden = !open; $('#ed-comments-btn').classList.toggle('on', open);
18
+ if (open) { fmt.hidden = true; $('#ed-trans').hidden = true; $('#ed-theme').hidden = true; $('#ed-history').hidden = true; cmFetch(); }
19
+ fit(); drawPins();
20
+ }
21
+ function cmRender() {
22
+ const open = cmList.filter(c => !c.resolved), showDone = $('#ed-cm-resolved').checked;
23
+ $('#ed-comments-n').textContent = open.length || '';
24
+ drawPins();
25
+ if (cmPanel.hidden) return;
26
+ const here = cur + 1, list = cmList.filter(c => showDone || !c.resolved).sort((a, b) => +(b.slide === here) - +(a.slide === here) || a.slide - b.slide || a.at.localeCompare(b.at));
27
+ const out = [];
28
+ if (cmDraft) out.push(cmComposer());
29
+ for (const c of list) {
30
+ const card = cmEl('div', 'cm-card' + (cmN(c) === here ? ' here' : '') + (c.resolved ? ' done' : ''));
31
+ const top = cmEl('div', 'cm-top'); top.append(cmEl('span', '', `Slide ${cmN(c)}`), cmEl('span', '', cmAgo(c.at))); card.append(top);
32
+ if (c.quote) card.append(cmEl('div', 'cm-quote', c.quote));
33
+ card.append(cmEl('div', 'cm-by', c.by?.n || c.by?.e || ''), cmEl('div', 'cm-text', c.text));
34
+ for (const r of c.replies || []) { const d = cmEl('div', 'cm-reply' + (r.event ? ' event' : '')); d.append(cmEl('b', '', (r.by?.n || r.by?.e || '') + ' '), r.text); card.append(d); }
35
+ const acts = cmEl('div', 'cm-acts'), reply = cmEl('input'); reply.placeholder = 'Reply';
36
+ reply.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter' && reply.value.trim()) cmFetch({ op: 'reply', id: c.id, text: reply.value }); };
37
+ const res = cmEl('button', '', c.resolved ? 'Reopen' : 'Resolve'); res.onclick = e => { e.stopPropagation(); cmFetch({ op: 'resolve', id: c.id, resolved: !c.resolved }); };
38
+ const del = cmEl('button', '', 'Delete'); del.onclick = e => { e.stopPropagation(); if (confirm('Delete this comment and its replies?')) cmFetch({ op: 'delete', id: c.id }); };
39
+ acts.append(reply, res, del); card.append(acts);
40
+ card.onclick = e => { if (!e.target.closest('input, button, textarea')) cmShow(c); };
41
+ out.push(card);
42
+ }
43
+ if (!out.length) out.push(cmEl('div', 'cm-empty', cmList.length ? 'All comments are resolved.' : 'No comments yet. People you share the deck with can comment on it; you can add your own with Comment (⌘⌥M).'));
44
+ $('#ed-cm-list').replaceChildren(...out);
45
+ }
46
+ /* A comment's slide (1-based): the one with its data-sid, wherever slides have moved since; else the number it was left on. */
47
+ function cmN(c) { const i = c.sid ? slides.findIndex(s => s.dataset.sid === c.sid) : -1; return i >= 0 ? i + 1 : c.slide; }
48
+ function cmShow(c) { // its slide, with the box it was left on selected
49
+ if (cmN(c) - 1 !== cur) go(Math.min(slides.length - 1, cmN(c) - 1));
50
+ const b = c.box && boxes().find(x => x.id === c.box);
51
+ if (b) { sel = new Set([b]); render(); }
52
+ }
53
+ function cmComposer() {
54
+ const card = cmEl('div', 'cm-card here'), ta = cmEl('textarea'); ta.rows = 3; ta.placeholder = `Comment on slide ${cmDraft.slide}`;
55
+ const acts = cmEl('div', 'cm-acts'), ok = cmEl('button', '', 'Comment'), no = cmEl('button', '', 'Cancel');
56
+ ok.onclick = () => { if (ta.value.trim()) { const d = cmDraft; cmDraft = null; cmFetch({ op: 'add', ...d, text: ta.value }); } };
57
+ no.onclick = () => { cmDraft = null; cmRender(); };
58
+ ta.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ok.click(); if (e.key === 'Escape') no.click(); };
59
+ if (cmDraft.quote) card.append(cmEl('div', 'cm-quote', cmDraft.quote));
60
+ acts.append(ok, no); card.append(ta, acts); setTimeout(() => ta.focus());
61
+ return card;
62
+ }
63
+ function addComment() { // on the selected box (its top-right corner), else the slide's top-left
64
+ const b = [...sel][0], g = b && geo(b);
65
+ cmDraft = { slide: cur + 1, sid: slides[cur]?.dataset.sid || '', 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) };
66
+ if (cmPanel.hidden) setCommentsPanel(true); else cmRender();
67
+ }
68
+ function drawPins() {
69
+ const showDone = $('#ed-cm-resolved').checked, k = 1 / scale;
70
+ pins.replaceChildren(...cmList.filter(c => cmN(c) === cur + 1 && (showDone || !c.resolved)).map((c, i) => {
71
+ const p = cmEl('div', 'cm-pin', String(i + 1)); p.title = `${c.by?.n || ''}: ${c.text}`;
72
+ p.style.left = c.x + 'px'; p.style.top = c.y + 'px'; p.style.scale = k; p.style.transformOrigin = '0 100%';
73
+ p.onpointerdown = e => { e.stopPropagation(); if (cmPanel.hidden) setCommentsPanel(true); cmShow(c); };
74
+ return p;
75
+ }));
76
+ }
77
+ new MutationObserver(drawPins).observe(canvas, { childList: true }); // the slide on show changed
78
+ $('#ed-comments-btn').onclick = () => setCommentsPanel(cmPanel.hidden);
79
+ $('#ed-cm-close').onclick = () => setCommentsPanel(false);
80
+ $('#ed-cm-add').onclick = addComment;
81
+ $('#ed-cm-resolved').onchange = cmRender;
82
+ addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.altKey && e.code === 'KeyM') { e.preventDefault(); e.stopPropagation(); addComment(); } }, true);
83
+ const cmTyping = () => [...cmPanel.querySelectorAll('input:not([type=checkbox]), textarea')].some(i => i.value.trim() || i === document.activeElement);
84
+ setInterval(() => { if (!cmPanel.hidden && !cmDraft && !cmTyping()) cmFetch(); }, 20000); // new comments while it is open, never under someone's fingers
85
+ cmFetch();
@@ -0,0 +1,93 @@
1
+ /* Right-click menus, as Slides: on an object, on the empty slide, and on a filmstrip thumbnail. Items are
2
+ shared with the menu bar where they exist (Arrange submenus, Edit commands). */
3
+ const arrangeItem = label => MENUS.Arrange.find(it => typeof it === 'object' && it.label === label);
4
+ const editItem = label => MENUS.Edit.find(it => typeof it === 'object' && it.label === label);
5
+ function objectMenu() {
6
+ const one = sel.size === 1 ? [...sel][0] : null, text = one && !isLine(one) && !isGroup(one) && !isMedia(one);
7
+ return [
8
+ editItem('Cut'), editItem('Copy'), editItem('Paste'), editItem('Paste without formatting'), editItem('Delete'),
9
+ '-',
10
+ ...(text ? [{ label: 'Edit text', icon: 'format_shapes', run: () => startEdit(one) }] : []),
11
+ { label: 'Alt text', icon: 'accessibility_new', run: openAltText, off: () => sel.size !== 1 },
12
+ ...(codeOf(one) ? [{ label: 'Code language', icon: 'code', items: () => codeLangItems(setCodeLang, v => codeOf(one).classList.contains('language-' + v)) }] : []),
13
+ ...(/^(elbow|curve)$/.test(one?.dataset.shape) && one.dataset.bend ? [{ label: 'Reroute connector', icon: 'route', run: () => { delete one.dataset.bend; renderShape(one); drawOverlay(); commit(); } }] : []),
14
+ '-',
15
+ arrangeItem('Order'), arrangeItem('Rotate'), arrangeItem('Center on page'),
16
+ ...(sel.size > 1 ? [arrangeItem('Align'), arrangeItem('Distribute')] : []),
17
+ ...(sel.size > 1 ? [{ label: 'Group', icon: 'group_work', key: '⌥⌘G', run: groupSel }] : []),
18
+ ...([...sel].some(isGroup) ? [{ label: 'Ungroup', key: '⇧⌥⌘G', run: ungroupSel }] : []),
19
+ ...(one?.classList.contains('image') ? [] : ['-', { label: 'Link', icon: 'link', key: '⌘K', run: linkText, off: () => sel.size !== 1 }]), // Slides offers no Link on pictures
20
+ '-',
21
+ { label: 'Animate', icon: 'animation', run: () => $('#ed-animate').click() },
22
+ '-',
23
+ { label: 'Format options', icon: 'format_paint', run: () => setPanel(true) },
24
+ ...(one?.classList.contains('image') ? ['-', ...IMAGE_ITEMS(), { label: 'Set image as background', icon: 'wallpaper', run: () => imageToBackground(one) }] : []),
25
+ ];
26
+ }
27
+ const slideMenu = () => [
28
+ editItem('Cut'), editItem('Copy'), editItem('Paste'), editItem('Paste without formatting'), editItem('Delete'),
29
+ '-',
30
+ { label: 'Change background', icon: 'opacity', run: () => showBgDialog(true) },
31
+ { label: 'Apply layout', icon: 'view_quilt', items: layoutItems },
32
+ '-',
33
+ { label: 'Transition', icon: 'animation', run: () => setTransPanel(true) },
34
+ ];
35
+ const thumbMenu = () => [
36
+ { label: 'New slide', icon: 'add', key: '⌃M', run: () => $('#ed-new-slide').click() },
37
+ { label: 'Duplicate slide', icon: 'control_point_duplicate', run: () => $('#ed-dup-slide').click() },
38
+ { label: 'Delete', icon: 'delete', run: () => $('#ed-del-slide').click() },
39
+ { label: 'Skip slide', icon: 'visibility_off', on: () => slides[cur]?.dataset.visibility === 'hidden', run: skipSlides },
40
+ { label: 'Move slide', icon: 'swap_vert', items: () => [
41
+ { label: 'Move slide to beginning', icon: 'vertical_align_top', key: '⇧⌘↑', run: () => moveSlides('start') },
42
+ { label: 'Move slide to end', icon: 'vertical_align_bottom', key: '⇧⌘↓', run: () => moveSlides('end') },
43
+ ] },
44
+ '-',
45
+ { label: 'Change background', icon: 'opacity', run: () => showBgDialog(true) },
46
+ { label: 'Apply layout', icon: 'view_quilt', items: layoutItems },
47
+ '-',
48
+ { label: 'Transition', icon: 'animation', run: () => setTransPanel(true) },
49
+ ];
50
+ /* The picture commands, as Slides' Format → Image and the image right-click menu. Reset image drops crop, opacity
51
+ and size changes. Set image as background moves the picture onto the slide background. */
52
+ const theImage = () => sel.size === 1 && [...sel][0].classList.contains('image') ? [...sel][0] : null;
53
+ const IMAGE_ITEMS = () => [
54
+ { label: 'Crop image', icon: 'crop', run: () => enterCrop(theImage()), off: () => !theImage() },
55
+ { label: 'Replace image', icon: 'image', run: () => $('#ed-replace-file').click(), off: () => !theImage() },
56
+ { label: 'Reset image', icon: 'reset_image', run: () => resetImage(theImage()), off: () => !theImage() },
57
+ ];
58
+ function resetImage(b) {
59
+ if (!b) return;
60
+ delete b.dataset.crop; cropBox(b); const img = b.querySelector('.content > img'); if (img) { img.style.opacity = ''; if (!img.getAttribute('style')) img.removeAttribute('style'); }
61
+ resetImageSize(b);
62
+ }
63
+ function imageToBackground(b) {
64
+ const src = b.querySelector('.content > img')?.getAttribute('src'); if (!src) return;
65
+ slides[cur].dataset.backgroundImage = plainSrc(src); b.remove(); sel.clear(); render(); commit();
66
+ }
67
+ function openAltText() {
68
+ setPanel(true);
69
+ const d = [...fmt.querySelectorAll('details')].find(d => d.querySelector('summary').textContent.trim() === 'Alt Text');
70
+ if (d) { d.open = true; F('alt').focus(); }
71
+ }
72
+ function openContextMenu(e, items) {
73
+ e.preventDefault(); closeMenu();
74
+ const m = buildMenu(items); placeMenu(m, e.clientX, e.clientY); openMenuEl = m;
75
+ }
76
+ stage.addEventListener('contextmenu', e => {
77
+ if (editing && topBox(e.target) === editing && e.target.closest('td, th')) { // in a table cell: Slides' table menu
78
+ const td = e.target.closest('td, th'); if (!td.contains(getSelection().anchorNode)) focusCell(td);
79
+ openContextMenu(e, [editItem('Cut'), editItem('Copy'), editItem('Paste'), '-', ...tableItems()]); return;
80
+ }
81
+ if (editing && topBox(e.target) === editing) return; // the browser's own menu while editing text (spelling suggestions)
82
+ const b = topBox(e.target);
83
+ if (b && !sel.has(b)) { stopEdit(); sel = new Set([b]); drawOverlay(); updateToolbar(); }
84
+ if (!b && !e.target.closest('#ed-ui .handle, #ed-ui .sel-wrap')) { stopEdit(); sel.clear(); drawOverlay(); updateToolbar(); }
85
+ openContextMenu(e, sel.size ? objectMenu() : slideMenu());
86
+ });
87
+ thumbs.addEventListener('contextmenu', e => {
88
+ const t = e.target.closest('.thumb'); if (!t) return;
89
+ const i = [...thumbs.children].indexOf(t);
90
+ if (!picked.has(slides[i])) go(i);
91
+ thumbs.focus({ preventScroll: true });
92
+ openContextMenu(e, thumbMenu());
93
+ });
@@ -0,0 +1,168 @@
1
+ /* Slide → Change background: Slides' dialog with a colour, an image (reveal's data-background-image, cover), and ours:
2
+ a video (data-background-video, muted and looping) or a web page (data-background-iframe), then Reset. */
3
+ function paintBg(el, s) {
4
+ el.style.background = s.dataset.backgroundColor || '#fff';
5
+ if (s.dataset.backgroundImage) Object.assign(el.style, { backgroundImage: `url("${s.dataset.backgroundImage}")`, backgroundSize: 'cover', backgroundPosition: 'center' });
6
+ // a video or web page behind the slide: playing on the canvas, a still frame or a placeholder in thumbnails
7
+ const media = fresh(s.dataset.backgroundVideo || '') || s.dataset.backgroundIframe || '', live = el === frame, old = el.querySelector(':scope > .bg-media');
8
+ if (!media) return old?.remove();
9
+ if (old?.dataset.src === media + live) return;
10
+ const m = div('bg-media'); m.dataset.src = media + live;
11
+ if (s.dataset.backgroundVideo && !live) { // a thumbnail: the video's first frame, as a picture (render.js)
12
+ const still = videoStill(media); if (still) m.style.background = `center / cover no-repeat url("${still}")`;
13
+ } else if (s.dataset.backgroundVideo) {
14
+ const v = document.createElement('video'); v.src = media; v.muted = true; v.loop = true; v.playsInline = true; v.preload = 'metadata'; v.autoplay = true; m.append(v);
15
+ } else if (live) { const f = document.createElement('iframe'); f.src = media; f.setAttribute('sandbox', 'allow-scripts allow-same-origin'); f.loading = 'lazy'; m.append(f); }
16
+ else m.innerHTML = '<span class="icon">public</span>';
17
+ old ? old.replaceWith(m) : el.prepend(m);
18
+ }
19
+ const bgDlg = $('#ed-bgdlg');
20
+ function syncBgDialog() {
21
+ const s = slides[cur]; if (!s) return;
22
+ $('#ed-bg-color .chip').style.background = s.dataset.backgroundColor || '#fff';
23
+ $('#ed-bg-reset').disabled = !s.dataset.backgroundImage && !s.dataset.backgroundVideo && !s.dataset.backgroundIframe && /^(#fff(fff)?)?$/i.test(s.dataset.backgroundColor || '');
24
+ }
25
+ function showBgDialog(on) { closeMenu(); bgDlg.hidden = !on; if (on) syncBgDialog(); }
26
+ $('#ed-bg-color').onclick = () => {
27
+ if (openAnchor === $('#ed-bg-color')) return closeMenu();
28
+ openPalette($('#ed-bg-color')); const m = document.querySelector('.menu.palette'); if (m) m.style.zIndex = '60';
29
+ };
30
+ $('#ed-bg-image').onclick = () => $('#ed-bg-file').click();
31
+ $('#ed-bg-file').onchange = async e => {
32
+ let file = e.target.files[0]; e.target.value = ''; if (!file) return;
33
+ file = await shrinkPicture(file);
34
+ const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('svg+xml', 'svg');
35
+ const { src } = await (await fetch('/api/upload?ext=' + ext, { method: 'POST', body: file })).json();
36
+ slides[cur].dataset.backgroundImage = src; render(); syncBgDialog(); commit();
37
+ };
38
+ const clearBgMedia = s => { for (const k of ['backgroundImage', 'backgroundVideo', 'backgroundVideoLoop', 'backgroundVideoMuted', 'backgroundIframe', 'backgroundInteractive']) delete s.dataset[k]; };
39
+ $('#ed-bg-video').onclick = () => $('#ed-bg-vfile').click();
40
+ $('#ed-bg-vfile').onchange = async e => {
41
+ const file = e.target.files[0]; e.target.value = ''; if (!file) return;
42
+ const ext = (file.name.split('.').pop() || 'mp4').toLowerCase();
43
+ const { src } = await (await fetch('/api/upload?ext=' + ext, { method: 'POST', body: file })).json();
44
+ const s = slides[cur]; clearBgMedia(s); Object.assign(s.dataset, { backgroundVideo: src, backgroundVideoLoop: '', backgroundVideoMuted: '' });
45
+ render(); syncBgDialog(); commit();
46
+ };
47
+ $('#ed-bg-page').onclick = () => { showBgDialog(false); askURL('A web page as the background', url => {
48
+ if (!/^https:\/\//.test(url)) return setStatus('A background page needs an https:// address', 'error');
49
+ const s = slides[cur]; clearBgMedia(s); s.dataset.backgroundIframe = url; render(); commit();
50
+ }, slides[cur]?.dataset.backgroundIframe || ''); };
51
+ $('#ed-bg-reset').onclick = () => {
52
+ const s = slides[cur]; clearBgMedia(s); s.dataset.backgroundColor = '#ffffff';
53
+ render(); syncBgDialog(); commit();
54
+ };
55
+ $('#ed-bg-done').onclick = bgDlg.querySelector('.sc-close').onclick = () => showBgDialog(false);
56
+ $('#ed-bg-theme').onclick = () => { setTheme({ ...theme(), name: 'Custom', bg: slides[cur].dataset.backgroundColor || '#ffffff' }); showBgDialog(false); }; // Add to theme: every slide on the theme background follows
57
+ bgDlg.addEventListener('pointerdown', e => { if (e.target === bgDlg) showBgDialog(false); });
58
+ addEventListener('keydown', e => {
59
+ if (bgDlg.hidden || e.key !== 'Escape') return;
60
+ e.preventDefault(); e.stopImmediatePropagation();
61
+ if (document.querySelector('.menu')) closeMenu(); else showBgDialog(false);
62
+ }, true);
63
+
64
+ /* Custom spacing, as Slides: paragraph spacing before / after in pt and line spacing as Slides' multiple. */
65
+ const spaceDlg = $('#ed-spacedlg'), spTargets = [];
66
+ function showSpaceDialog(on) {
67
+ closeMenu(); spaceDlg.hidden = !on; if (!on) return;
68
+ spTargets.splice(0, spTargets.length, ...sel);
69
+ const c = content(spTargets[0]), pt = side => Math.round((parseFloat(c.style.getPropertyValue(`--space-${side}`)) || 0) / PT * 10) / 10;
70
+ F_sp('before').value = pt('before'); F_sp('after').value = pt('after');
71
+ F_sp('line').value = Math.round(parseFloat(curLh()) / NATURAL_LH * 100) / 100;
72
+ F_sp('line').focus(); F_sp('line').select();
73
+ }
74
+ const F_sp = k => $('#ed-sp-' + k);
75
+ function applySpacing() {
76
+ const line = parseFloat(F_sp('line').value);
77
+ spTargets.forEach(b => {
78
+ const c = content(b); blockify(c);
79
+ for (const side of ['before', 'after']) {
80
+ const v = Math.max(0, parseFloat(F_sp(side).value) || 0);
81
+ if (v) c.style.setProperty(`--space-${side}`, `${Math.round(v * PT * 100) / 100}px`); else c.style.removeProperty(`--space-${side}`);
82
+ }
83
+ if (line > 0) c.style.lineHeight = String(Math.round(line * NATURAL_LH * 1000) / 1000);
84
+ fitHeight(b);
85
+ });
86
+ showSpaceDialog(false); commit();
87
+ }
88
+ $('#ed-sp-apply').onclick = applySpacing;
89
+ $('#ed-sp-cancel').onclick = spaceDlg.querySelector('.sc-close').onclick = () => showSpaceDialog(false);
90
+ spaceDlg.addEventListener('pointerdown', e => { if (e.target === spaceDlg) showSpaceDialog(false); });
91
+ addEventListener('keydown', e => {
92
+ if (spaceDlg.hidden || !['Escape', 'Enter'].includes(e.key)) return;
93
+ e.preventDefault(); e.stopImmediatePropagation();
94
+ if (e.key === 'Enter') applySpacing(); else showSpaceDialog(false);
95
+ }, true);
96
+
97
+ /* Insert → Special characters, as Slides: a category grid, search (keyword, LaTeX command or codepoint) and the
98
+ recently used ones; a click inserts at the caret when editing, else into a new text box. */
99
+ const charsDlg = $('#ed-chars'), recentChars = () => { try { return JSON.parse(stored('slides-recent-chars')) || []; } catch { return []; } };
100
+ function showChars(on) {
101
+ closeMenu(); charsDlg.hidden = !on; if (!on) return;
102
+ const cat = $('#ed-chars-cat');
103
+ if (!cat.options.length) cat.append(...CHAR_CATEGORIES.map(([n], i) => Object.assign(document.createElement('option'), { value: i, textContent: n })));
104
+ $('#ed-chars-q').value = ''; fillChars(); $('#ed-chars-q').focus();
105
+ }
106
+ function charMatches(q) {
107
+ q = q.trim(); if (!q) return null;
108
+ const hex = q.match(/^(?:u\+|0x)?([0-9a-f]{4,5})$/i);
109
+ if (hex) return [String.fromCodePoint(parseInt(hex[1], 16))];
110
+ const cmd = q.replace(/^\\/, '');
111
+ const tex = Object.entries(LATEX_SYMBOLS).filter(([k]) => k.toLowerCase().startsWith(cmd.toLowerCase())).map(([, v]) => v);
112
+ const cats = CHAR_CATEGORIES.filter(([n]) => n.toLowerCase().includes(q.toLowerCase()) || n.toLowerCase().includes(q.toLowerCase().replace(/s$/, ''))).flatMap(([, c]) => c);
113
+ return [...new Set([...tex, ...cats, ...[...q].filter(ch => ch.codePointAt(0) > 127)])];
114
+ }
115
+ function fillChars() {
116
+ const grid = $('#ed-chars-grid'), found = charMatches($('#ed-chars-q').value);
117
+ const cell = ch => { const b = document.createElement('button'); b.textContent = ch; b.title = 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
118
+ b.onmouseenter = () => { $('#ed-chars-pick').innerHTML = ''; $('#ed-chars-pick').append(Object.assign(div(''), { textContent: ch }), Object.assign(document.createElement('small'), { textContent: b.title })); };
119
+ b.onmousedown = e => e.preventDefault(); b.onclick = () => insertChar(ch); return b; };
120
+ const sep = t => Object.assign(div('chars-sep'), { textContent: t });
121
+ const recent = recentChars();
122
+ grid.replaceChildren(...(found ? found.map(cell)
123
+ : [...(recent.length ? [sep('Recently used'), ...recent.map(cell), sep(CHAR_CATEGORIES[$('#ed-chars-cat').value][0])] : []), ...CHAR_CATEGORIES[$('#ed-chars-cat').value][1].map(cell)]));
124
+ $('#ed-chars-pick').textContent = '';
125
+ }
126
+ function insertChar(ch) {
127
+ store('slides-recent-chars', JSON.stringify([ch, ...recentChars().filter(c => c !== ch)].slice(0, 22)));
128
+ if (editing) { content(editing).focus(); editSelection(); document.execCommand('insertText', false, ch); fitHeight(editing); }
129
+ else { const b = addText(ch); sel = new Set([b]); drawOverlay(); commit(); }
130
+ fillChars();
131
+ }
132
+ $('#ed-chars-cat').onchange = fillChars;
133
+ $('#ed-chars-q').oninput = fillChars;
134
+ charsDlg.querySelector('.sc-close').onclick = () => showChars(false);
135
+ charsDlg.addEventListener('pointerdown', e => { if (e.target === charsDlg) showChars(false); });
136
+ addEventListener('keydown', e => { if (!charsDlg.hidden && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); showChars(false); } }, true);
137
+
138
+ /* Insert → Icon: the Material Symbols names (editor/data/icons.json) in a searchable grid. An icon is a box with
139
+ data-shape="icon" data-icon="<name>"; the deck loads just the glyphs it uses through <link id="deck-icons">. */
140
+ const iconsDlg = $('#ed-icons');
141
+ let iconNames = null;
142
+ async function showIcons(on) {
143
+ closeMenu(); iconsDlg.hidden = !on; if (!on) return;
144
+ iconNames ??= await fetch('/editor/data/icons.json').then(r => r.json()).catch(() => []);
145
+ $('#ed-icons-q').value = ''; fillIcons(); $('#ed-icons-q').focus();
146
+ }
147
+ function fillIcons() {
148
+ const q = $('#ed-icons-q').value.trim().toLowerCase().replace(/\s+/g, '_');
149
+ const hits = (q ? iconNames.filter(n => n.includes(q)).sort((a, b) => (a.startsWith(q) ? 0 : 1) - (b.startsWith(q) ? 0 : 1)) : iconNames).slice(0, 600);
150
+ $('#ed-icons-grid').replaceChildren(...hits.map(n => { const b = document.createElement('button'); b.className = 'icon'; b.textContent = n; b.dataset.tip = n.replace(/_/g, ' '); b.onclick = () => addIcon(n); return b; }));
151
+ }
152
+ function syncDeckIcons() { // one link with every icon the deck uses (Google wants icon_names sorted)
153
+ const names = [...new Set(slides.flatMap(s => [...s.querySelectorAll('.box[data-shape="icon"]')].map(b => b.dataset.icon)).filter(Boolean))].sort();
154
+ let l = deckDoc.querySelector('link#deck-icons');
155
+ if (!names.length) { l?.remove(); return; }
156
+ if (!l) { l = deckDoc.createElement('link'); l.id = 'deck-icons'; l.rel = 'stylesheet'; (deckDoc.querySelector('#deck-base') || deckDoc.head.lastChild).before(l); }
157
+ l.setAttribute('href', `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&icon_names=${names.join(',')}&display=block`);
158
+ }
159
+ function addIcon(name) {
160
+ showIcons(false);
161
+ const b = makeBox({ x: W / 2 - 96, y: H / 2 - 96, w: 192, h: 192, shape: 'icon' });
162
+ b.dataset.icon = name; b.style.setProperty('--fill', '#202124'); renderShape(b);
163
+ insert(b); syncDeckIcons(); scheduleSave();
164
+ }
165
+ $('#ed-icons-q').oninput = fillIcons;
166
+ iconsDlg.querySelector('.sc-close').onclick = () => showIcons(false);
167
+ iconsDlg.addEventListener('pointerdown', e => { if (e.target === iconsDlg) showIcons(false); });
168
+ addEventListener('keydown', e => { if (!iconsDlg.hidden && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); showIcons(false); } }, true);
@@ -0,0 +1,22 @@
1
+ /* The insert bar (View → Show insert bar): a floating pill at the bottom of the slide with the tools that create things,
2
+ as Figma Slides has, each calling the same action as the toolbar or Insert menu. Formatting stays at the top; the bar
3
+ never changes with the selection. Remembered in localStorage (on by default). */
4
+ const dock = $('#ed-dock');
5
+ const dockShown = () => stored('slides-dock') !== '0';
6
+ function setDock(on) { store('slides-dock', on ? undefined : '0'); dock.hidden = !on; }
7
+ dock.hidden = !dockShown();
8
+ const DOCK_DO = {
9
+ select: () => stopEdit(),
10
+ text: () => $('#ed-add-text').click(),
11
+ image: () => $('#ed-file').click(),
12
+ equation: addEquation,
13
+ note: () => { const b = addNote(cur, W - 620, 60, ''); sel = new Set([b]); startEdit(b); },
14
+ };
15
+ const DOCK_MENUS = { shape: () => shapeItems(), line: () => lineItems(), table: () => [{ el: tableGrid }], code: () => codeLangItems(addCode) };
16
+ dock.addEventListener('mousedown', e => { if (e.target.closest('button')) e.preventDefault(); }); // keep the focus where it is (as the toolbar)
17
+ dock.addEventListener('click', e => {
18
+ const b = e.target.closest('button'); if (!b) return;
19
+ const k = b.dataset.do;
20
+ if (DOCK_MENUS[k]) return openAnchor === b ? closeMenu() : openMenu(b, DOCK_MENUS[k](), { up: true });
21
+ closeMenu(); DOCK_DO[k]?.();
22
+ });
@@ -0,0 +1,107 @@
1
+ /* Shape picker, as Slides: Shapes / Arrows / Callouts / Equation, each a grid of outline pictures. Picking one
2
+ arms drawing: drag on the slide to size it (⇧ keeps it square), or click to drop a 1-inch one there. */
3
+ /** @type {any[]} */
4
+ const SHAPE_GROUPS = [
5
+ ['Shapes', 'crop_square', [['rect', 'Rectangle'], ['roundrect', 'Rounded rectangle'], ['snip1rect', 'Snip single corner rectangle'], ['snip2samerect', 'Snip same side corner rectangle'], ['snip2diagrect', 'Snip diagonal corner rectangle'], ['sniproundrect', 'Snip and round single corner rectangle'], ['round1rect', 'Round single corner rectangle'], ['round2samerect', 'Round same side corner rectangle'], ['round2diagrect', 'Round diagonal corner rectangle'], '-',
6
+ ['ellipse', 'Oval'], ['triangle', 'Triangle'], ['rttriangle', 'Right triangle'], ['parallelogram', 'Parallelogram'], ['trapezoid', 'Trapezoid'], ['diamond', 'Diamond'], ['pentagon', 'Regular pentagon'], ['hexagon', 'Hexagon'], ['heptagon', 'Heptagon'], ['octagon', 'Octagon'], ['decagon', 'Decagon'], ['dodecagon', 'Dodecagon'], ['pie', 'Pie'], ['arc', 'Arc'], ['teardrop', 'Teardrop'], ['chord', 'Chord'], ['frame', 'Frame'], ['halfframe', 'Half frame'], ['corner', 'L-shape'], ['diagstripe', 'Diagonal stripe'], ['cross', 'Cross'], ['plaque', 'Plaque'], ['can', 'Can'], ['cube', 'Cube'], ['bevel', 'Bevel'], ['donut', 'Donut'], ['nosmoking', '"No" symbol'], ['blockarc', 'Block arc'], ['foldedcorner', 'Folded corner'], ['smileyface', 'Smiley face'], ['heart', 'Heart'], ['lightningbolt', 'Lightning bolt'], ['sun', 'Sun'], ['moon', 'Moon'], ['cloud', 'Cloud'], '-',
7
+ ['flow-process', 'Flowchart: process'], ['flow-alternateprocess', 'Flowchart: alternate process'], ['flow-decision', 'Flowchart: decision'], ['flow-inputoutput', 'Flowchart: data'], ['flow-predefinedprocess', 'Flowchart: predefined process'], ['flow-internalstorage', 'Flowchart: internal storage'], ['flow-document', 'Flowchart: document'], ['flow-multidocument', 'Flowchart: multidocument'], ['flow-terminator', 'Flowchart: terminator'], ['flow-preparation', 'Flowchart: preparation'], ['flow-manualinput', 'Flowchart: manual input'], ['flow-manualoperation', 'Flowchart: manual operation'], ['flow-connector', 'Flowchart: connector'], ['flow-offpageconnector', 'Flowchart: off-page connector'], ['flow-punchedcard', 'Flowchart: card'], ['flow-punchedtape', 'Flowchart: punched tape'], ['flow-summingjunction', 'Flowchart: summing junction'], ['flow-or', 'Flowchart: or'], ['flow-collate', 'Flowchart: collate'], ['flow-sort', 'Flowchart: sort'], ['flow-extract', 'Flowchart: extract'], ['flow-merge', 'Flowchart: merge'], ['flow-onlinestorage', 'Flowchart: stored data'], ['flow-delay', 'Flowchart: delay'], ['flow-magnetictape', 'Flowchart: sequential access storage'], ['flow-magneticdisk', 'Flowchart: magnetic disk'], ['flow-magneticdrum', 'Flowchart: direct access storage'], ['flow-display', 'Flowchart: display']]],
8
+ ['Arrows', 'arrow_right_alt', [['arrow', 'Right arrow'], ['leftarrow', 'Left arrow'], ['uparrow', 'Up arrow'], ['downarrow', 'Down arrow'], ['leftrightarrow', 'Left-right arrow'],
9
+ ['updownarrow', 'Up-down arrow'], ['quadarrow', 'Quad arrow'], ['leftrightuparrow', 'Left-right-up arrow'], ['bentarrow', 'Bent arrow'], ['uturnarrow', 'U-turn arrow'],
10
+ ['leftuparrow', 'Left-up arrow'], ['bentuparrow', 'Bent-up arrow'], ['curvedarrow', 'Curved right arrow'], ['curvedleftarrow', 'Curved left arrow'], ['curveduparrow', 'Curved up arrow'],
11
+ ['curveddownarrow', 'Curved down arrow'], ['stripedarrow', 'Striped right arrow'], ['notchedarrow', 'Notched right arrow'], ['homeplate', 'Pentagon'], ['chevron', 'Chevron'],
12
+ ['rightarrowcallout', 'Right arrow callout'], ['downarrowcallout', 'Down arrow callout'], ['leftarrowcallout', 'Left arrow callout'], ['uparrowcallout', 'Up arrow callout'],
13
+ ['leftrightarrowcallout', 'Left-right arrow callout'], ['quadarrowcallout', 'Quad arrow callout'], ['circulararrow', 'Circular arrow']]],
14
+ ['Callouts', 'chat_bubble', [['explosion1', 'Explosion 1'], ['explosion2', 'Explosion 2'], ['star4', '4-point star'], ['star', '5-point star'], ['star6', '6-point star'], ['star7', '7-point star'],
15
+ ['star8', '8-point star'], ['star10', '10-point star'], ['star12', '12-point star'], ['star16', '16-point star'], ['star24', '24-point star'], ['star32', '32-point star'],
16
+ ['ribbonup', 'Up ribbon'], ['ribbon', 'Down ribbon'], ['curvedribbonup', 'Curved up ribbon'], ['curvedribbon', 'Curved down ribbon'], ['vscroll', 'Vertical scroll'], ['hscroll', 'Horizontal scroll'],
17
+ ['wave', 'Wave'], ['doublewave', 'Double wave'], '-',
18
+ ['callout', 'Rectangular callout'], ['roundcallout', 'Rounded rectangular callout'], ['ovalcallout', 'Oval callout'], ['cloudcallout', 'Cloud callout']]],
19
+ ['Equation', 'add', [['plus', 'Plus'], ['minus', 'Minus'], ['multiply', 'Multiply'], ['divide', 'Division'], ['equal', 'Equal'], ['notequal', 'Not equal'], ['brackets', 'Double bracket'], ['braces', 'Double brace'], ['leftbracket', 'Left bracket'], ['rightbracket', 'Right bracket'], ['leftbrace', 'Left brace'], ['rightbrace', 'Right brace']]],
20
+ ];
21
+ const shapePic = (shape, extra = {}) => {
22
+ const b = div('box'); b.dataset.shape = shape; Object.assign(b.dataset, extra.data || {});
23
+ const [pw, ph] = /^(arrow|leftarrow|leftrightarrow|stripedarrow|notchedarrow|homeplate|chevron)$/.test(shape) ? [20, 14] : /^(uparrow|downarrow|updownarrow)$/.test(shape) ? [14, 20] : /^(left|right)(bracket|brace)$/.test(shape) ? [7, 20] : [20, 18];
24
+ b.style.cssText = `width:${pw}px;height:${ph}px;--fill:transparent;--stroke:#1f1f1f;--sw:1.3px`;
25
+ renderShape(b); const svg = b.querySelector('svg');
26
+ const t = [extra.rot ? `rotate(${extra.rot} 10 10)` : '', extra.data?.flip ? 'translate(20 0) scale(-1 1)' : ''].join(' ');
27
+ svg.setAttribute('viewBox', `${pw / 2 - 12} ${ph / 2 - 13} 24 26`); svg.setAttribute('width', '22'); svg.setAttribute('height', '22');
28
+ if (t.trim()) svg.innerHTML = `<g transform="${t}">${svg.innerHTML}</g>`;
29
+ return svg.outerHTML.replace('class="shape"', 'class="shape-pic"');
30
+ };
31
+ const shapeItems = () => SHAPE_GROUPS.map(([label, icon, list]) => ({ label, icon, items: () => list.map(it => it === '-' ? it : (([v, l, extra]) => ({ label: l, cell: true, svg: shapePic(v, extra), run: () => armShape(v, extra) }))(it)) }));
32
+ let drawing = null; // the shape waiting to be drawn
33
+ function armShape(shape, extra) { stopEdit(); drawing = { shape, extra, pts: POINT_TOOLS.includes(shape) ? [] : null }; document.body.classList.add('drawing'); setStatus('Click or drag on the slide to draw'); }
34
+ function disarmShape() { drawing = null; document.body.classList.remove('drawing'); setStatus(''); }
35
+ stage.addEventListener('pointerdown', e => {
36
+ if (!drawing || e.button !== 0) return;
37
+ e.preventDefault(); e.stopImmediatePropagation();
38
+ if (drawing.pts) { // Curve / Polyline: each click adds a point, a double-click ends
39
+ const p = toCanvas(e), q = drawing.pts.at(-1);
40
+ if (!q || Math.hypot(p.x - q.x, p.y - q.y) * scale >= 6) drawing.pts.push(p); // the second click of a double-click adds nothing
41
+ drawPreview(drawing.pts); return;
42
+ }
43
+ const { shape, extra } = drawing, p0 = toCanvas(e); disarmShape();
44
+ if (shape === 'scribble') { // freehand: every pointer move, thinned to 3 screen px
45
+ const pts = [p0]; busy = true;
46
+ drag(e, ev => { const p = toCanvas(ev), q = pts.at(-1); if (Math.hypot(p.x - q.x, p.y - q.y) * scale >= 3) { pts.push(p); drawPreview(pts); } },
47
+ () => { busy = false; guides.querySelector('.draw-preview')?.remove(); finishPath(pts, true); });
48
+ return;
49
+ }
50
+ if (isLine({ dataset: { shape } })) { // a line or connector, from where the drag starts to where it ends; ends snap to connection points
51
+ const s0 = snapAnchor(p0), P = { ...(s0 ? s0.q : p0), mark: 'none', at: s0?.ref || '' }, mark = shape === 'arrowline' ? 'fill-arrow' : 'none';
52
+ const b = addShape(shape, { x: P.x, y: P.y, w: PX_IN, h: 0 });
53
+ busy = true;
54
+ drag(e, ev => {
55
+ let p = toCanvas(ev);
56
+ if (ev.shiftKey) { const a = Math.round(Math.atan2(p.y - P.y, p.x - P.x) / (Math.PI / 4)) * Math.PI / 4, r = Math.hypot(p.x - P.x, p.y - P.y); p = { x: P.x + Math.cos(a) * r, y: P.y + Math.sin(a) * r }; }
57
+ const sn = ev.metaKey ? null : snapAnchor(p, b);
58
+ setLineEnds(b, P, { ...(sn ? sn.q : p), mark, at: sn?.ref || '' }); drawOverlay();
59
+ }, () => { busy = false; guides.querySelectorAll('.conn').forEach(e => e.remove()); drawOverlay(); commit(); });
60
+ return;
61
+ }
62
+ const b = addShape(shape, { x: p0.x, y: p0.y, w: 1, h: 1 }, extra), k = 1 / scale;
63
+ let moved = false;
64
+ busy = true;
65
+ drag(e, ev => {
66
+ const p = toCanvas(ev); let w = p.x - p0.x, h = p.y - p0.y;
67
+ if (!moved && Math.hypot(w, h) < 4 * k) return;
68
+ moved = true;
69
+ if (ev.shiftKey) { const m = Math.max(Math.abs(w), Math.abs(h)); w = Math.sign(w || 1) * m; h = Math.sign(h || 1) * m; }
70
+ setGeo(b, { x: Math.min(p0.x, p0.x + w), y: Math.min(p0.y, p0.y + h), w: Math.abs(w), h: Math.abs(h) }); drawOverlay();
71
+ }, () => { busy = false; if (!moved) setGeo(b, { x: p0.x, y: p0.y, w: PX_IN, h: PX_IN }); drawOverlay(); commit(); });
72
+ }, true);
73
+ addEventListener('keydown', e => {
74
+ if (!drawing || (e.key !== 'Escape' && e.key !== 'Enter')) return;
75
+ e.preventDefault(); e.stopImmediatePropagation();
76
+ if (drawing.pts?.length >= 2) finishPoints(); else { guides.querySelector('.draw-preview')?.remove(); disarmShape(); }
77
+ }, true);
78
+ stage.addEventListener('pointermove', e => { if (drawing?.pts?.length) drawPreview([...drawing.pts, toCanvas(e)]); });
79
+ stage.addEventListener('dblclick', e => { if (drawing?.pts) { e.stopImmediatePropagation(); finishPoints(); } }, true); // a double-click ends a Curve / Polyline
80
+ /* Line tool, as Slides: every kind is drawn on the slide. Line / Arrow / connectors: drag from one end to the other
81
+ (a click makes a 1-inch line). Curve and Polyline: click each point, double-click or Enter to finish. Scribble: drag. */
82
+ const lineItems = () => [['line', 'Line', 'pen_size_2'], ['arrowline', 'Arrow', 'arrow_right_alt'], ['elbow', 'Elbow Connector', 'turn_right'], ['curve', 'Curved Connector', 'conversion_path'],
83
+ ['smooth', 'Curve', 'gesture'], ['polyline', 'Polyline', 'polyline'], ['scribble', 'Scribble', 'draw']].map(([v, label, icon]) => ({ label, icon, run: () => armShape(v) }));
84
+ const POINT_TOOLS = ['smooth', 'polyline'];
85
+ /* Points (canvas px) → a box with data-path in its own 0..1 units; smooth turns the points into a Catmull-Rom curve. */
86
+ function finishPath(pts, smooth) {
87
+ if (pts.length < 2) return;
88
+ const g = bbox(pts.map(p => ({ x: p.x, y: p.y, w: 0, h: 0 }))); g.w = Math.max(g.w, 1); g.h = Math.max(g.h, 1);
89
+ const u = p => `${Math.round((p.x - g.x) / g.w * 1000) / 1000},${Math.round((p.y - g.y) / g.h * 1000) / 1000}`;
90
+ let d = 'M' + u(pts[0]);
91
+ for (let i = 1; i < pts.length; i++) {
92
+ if (!smooth) { d += ' L' + u(pts[i]); continue; }
93
+ const p0 = pts[i - 2] || pts[i - 1], p1 = pts[i - 1], p2 = pts[i], p3 = pts[i + 1] || p2;
94
+ d += ` C${u({ x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 })} ${u({ x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 })} ${u(p2)}`;
95
+ }
96
+ const b = addShape('path', g); b.dataset.path = d; renderShape(b); drawOverlay(); commit();
97
+ }
98
+ function drawPreview(pts, smooth) { // the path being drawn, in canvas units
99
+ let svg = guides.querySelector('.draw-preview');
100
+ if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'draw-preview'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); guides.append(svg); }
101
+ svg.innerHTML = pts.length ? `<polyline points="${pts.map(p => p.x + ',' + p.y).join(' ')}" />` : '';
102
+ }
103
+ function finishPoints() {
104
+ const d = drawing; if (!d?.pts) return;
105
+ guides.querySelector('.draw-preview')?.remove(); disarmShape();
106
+ finishPath(d.pts, d.shape === 'smooth');
107
+ }