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,290 @@
1
+ const FORMAT_MENU = [
2
+ { label: 'Text', icon: 'format_bold', items: [
3
+ { label: 'Bold', icon: 'format_bold', key: '⌘B', run: () => $('#ed-bold').click(), off: noSel },
4
+ { label: 'Italic', icon: 'format_italic', key: '⌘I', run: () => $('#ed-italic').click(), off: noSel },
5
+ { label: 'Underline', icon: 'format_underlined', key: '⌘U', run: () => $('#ed-underline').click(), off: noSel },
6
+ { label: 'Strikethrough', icon: 'strikethrough_s', key: '⇧⌘X', run: strike, off: noSel },
7
+ { label: 'Superscript', icon: 'superscript', key: '⌘.', run: superscript, off: noSel },
8
+ { label: 'Subscript', icon: 'subscript', key: '⌘,', run: subscript, off: noSel },
9
+ '-',
10
+ { label: 'Letter spacing', icon: 'format_letter_spacing', items: () => LETTER_SPACING.map(([label, v]) => ({ label, on: () => sel.size && curLs() === v, run: () => spanStyle('letter-spacing', v), off: noSel })) },
11
+ { label: 'Size', items: [
12
+ { label: 'Increase font size', key: '⇧⌘.', run: () => stepSize(1), off: noSel },
13
+ { label: 'Decrease font size', key: '⇧⌘,', run: () => stepSize(-1), off: noSel },
14
+ ] },
15
+ { label: 'Color', run: () => openPalette(document.querySelector('.swatch[data-for=color]')), off: noSel },
16
+ { label: 'Highlight color', run: () => openPalette(document.querySelector('.swatch[data-for=hilite]')), off: noSel },
17
+ { label: 'Capitalization', items: [['lowercase', 'lower'], ['UPPERCASE', 'upper'], ['Title Case', 'title']].map(([label, m]) => ({ label, run: () => recase(m), off: noSel })) },
18
+ { label: 'Columns', icon: 'view_column', items: () => [1, 2, 3].map(n => ({ label: ['One', 'Two', 'Three'][n - 1] + (n > 1 ? ' columns' : ' column'), on: () => sel.size && curCols() === n, run: () => setColumns(n), off: noSel })) },
19
+ ] },
20
+ { label: 'Align & indent', icon: 'format_align_left', items: () => [
21
+ ...ALIGNS.map(([v, icon, label, key]) => ({ label: label.replace(' align', ''), icon, key, on: () => sel.size && curAlign() === v, run: () => setAlign(v), off: noSel })),
22
+ '-',
23
+ ...VALIGNS.map(([v, icon, label]) => ({ label, icon, on: () => sel.size && curValign() === v, run: () => setValign(v), off: noSel })),
24
+ '-',
25
+ { label: 'Increase indent', icon: 'format_indent_increase', key: '⌘]', run: () => indentText(1), off: noSel },
26
+ { label: 'Decrease indent', icon: 'format_indent_decrease', key: '⌘[', run: () => indentText(-1), off: noSel },
27
+ ] },
28
+ { label: 'Line & paragraph spacing', icon: 'format_line_spacing', items: lhItems },
29
+ { label: 'Bullets & numbering', icon: 'format_list_bulleted', items: () => [
30
+ { label: 'Numbered list menu', icon: 'format_list_numbered', off: noSel, // as Slides: numbered first; indent lives in Align & indent
31
+ items: NUMBERS.map(([t, label]) => ({ label, run: () => setListStyle('ol', t) })) },
32
+ { label: 'Bulleted list menu', icon: 'format_list_bulleted', off: noSel,
33
+ items: BULLETS.map(([t, label]) => ({ label, style: 'letter-spacing: .1em', run: () => setListStyle('ul', t) })) },
34
+ ] },
35
+ '-',
36
+ { label: 'Image', icon: 'image', items: () => IMAGE_ITEMS(), off: () => !theImage() },
37
+ { label: 'Borders & lines', icon: 'border_style', items: () => [
38
+ { label: 'Border color', icon: 'border_color', run: () => openPalette(document.querySelector('.swatch[data-for=stroke]')), off: noSel },
39
+ { label: 'Border weight', icon: 'line_weight', items: () => [1, 2, 3, 4, 8, 12, 16, 24].map(v => ({ label: v + 'px', on: () => curSw() === v, run: () => { setVar('--sw', v + 'px'); commit(); } })), off: noSel },
40
+ ] },
41
+ '-',
42
+ { label: 'Format options', icon: 'format_paint', on: () => !fmt.hidden, run: () => setPanel(fmt.hidden) },
43
+ { label: 'Clear formatting', icon: 'format_clear', key: '⌘\\', run: clearFormat, off: noSel },
44
+ ];
45
+ // Online (glideshow.org, cloud/worker.js) there is no Mac behind the editor: what needs it is left out (mac: true).
46
+ const CLOUD = !!window.GLIDESHOW_CLOUD, onThisSide = list => list.filter(it => !(CLOUD && it.mac) && !(!CLOUD && it.online));
47
+ const MENUS = {
48
+ File: onThisSide([ // New / Open / Make a copy switch the server to another deck folder (siblings of this one unless a path is given)
49
+ { label: 'New', icon: 'add_box', run: () => askURL('New deck: folder name', name => switchDeck({ path: name, create: true }), 'untitled-' + new Date().toISOString().slice(0, 10)) },
50
+ { label: 'Open', icon: 'folder_open', items: async () => [...(await fetch('/api/recent').then(r => r.json()).catch(() => [])).map(d => ({ label: d.title, on: () => d.current, run: () => d.current || switchDeck({ path: d.path }), tip: d.path })),
51
+ '-', { label: 'Open a folder…', icon: 'folder', run: () => askURL('Open deck: folder path', p => switchDeck({ path: p })) }] },
52
+ { label: 'Open a deck file…', icon: 'upload_file', run: openDeckFile, online: true },
53
+ { label: 'Import slides', icon: 'upload_file', run: () => $('#ed-pptx-file').click(), mac: true },
54
+ { label: 'Import from Google Slides', icon: 'cloud_download', run: () => askURL('Import from Google Slides: link to the deck', importGoogle), mac: true },
55
+ { label: 'Make a copy', icon: 'file_copy', run: () => askURL('Make a copy: folder name', name => switchDeck({ copy: name }), deckName() + '-copy') },
56
+ '-',
57
+ { label: 'Download', icon: 'download', items: onThisSide([{ label: 'Glideshow deck, one file (.html)', run: downloadOneFile }, { label: 'PDF document (.pdf)', run: printDeck }, { label: 'PNG image (.png, current slide)', run: downloadSlidePNG }, { label: 'Microsoft PowerPoint (.pptx)', run: async () => { await flushSave(); setStatus('Making the PowerPoint file…'); location.href = '/api/pptx'; }, mac: true }, { label: 'Web page folder (.zip)', run: () => { location.href = '/api/zip'; }, mac: true }]) }, // the print dialog's Save as PDF
58
+ '-',
59
+ { label: 'Rename', icon: 'edit', run: () => { $('#ed-title').focus(); $('#ed-title').select(); } },
60
+ { label: 'Move to trash', icon: 'delete', run: trashDeck },
61
+ { label: 'Share… (publish to the web)', icon: 'public', run: () => showShare(true) },
62
+ { label: 'Version history', icon: 'history', run: () => showHistory(true) },
63
+
64
+ '-',
65
+ { label: 'Page setup', icon: 'aspect_ratio', run: pageSetup },
66
+ { label: 'Print preview', icon: 'plagiarism', run: async () => { await flushSave(); window.open('deck.html?print-pdf', '_blank'); } },
67
+ { label: 'Print', icon: 'print', key: '⌘P', run: printDeck },
68
+ ]),
69
+ Edit: [
70
+ { label: 'Undo', icon: 'undo', key: '⌘Z', run: undo, off: () => hptr <= 0 },
71
+ { label: 'Redo', icon: 'redo', key: '⌘Y', run: redo, off: () => hptr >= hist.length - 1 },
72
+ '-',
73
+ { label: 'Cut', icon: 'content_cut', key: '⌘X', run: () => document.execCommand('cut'), off: noText },
74
+ { label: 'Copy', icon: 'content_copy', key: '⌘C', run: () => document.execCommand('copy'), off: noText },
75
+ { label: 'Paste', icon: 'content_paste', key: '⌘V', run: () => setStatus('Press ⌘V to paste') },
76
+ { label: 'Paste without formatting', icon: 'content_paste_off', key: '⇧⌘V', run: () => setStatus('Press ⇧⌘V to paste without formatting') },
77
+ '-',
78
+ { label: 'Select all', icon: 'select_all', key: '⌘A', run: () => { stopEdit(); sel = new Set(pickable()); drawOverlay(); updateToolbar(); } },
79
+ { label: 'Delete', icon: 'delete', run: deleteSelection, off: noSel },
80
+ { label: 'Duplicate', icon: 'control_point_duplicate', key: '⌘D', run: duplicateSelection, off: noSel },
81
+ '-',
82
+ { label: 'Find and replace', icon: 'find_replace', key: '⇧⌘H', run: openFind },
83
+ ],
84
+ View: [ // Slides' order: Slideshow, Motion, Theme builder | Grid view, Show ruler | Guides, Snap to | notes, filmstrip | zoom
85
+ { label: 'Slideshow', icon: 'slideshow', key: '⌘↵', run: () => present() },
86
+ { label: 'Motion', icon: 'animation', key: '⌥⇧⌘B', on: () => !$('#ed-trans').hidden, run: () => setTransPanel($('#ed-trans').hidden) },
87
+ { label: 'Theme builder', icon: 'palette', run: () => enterThemeEdit() },
88
+ '-',
89
+ { label: 'Grid view', key: '⌥⌘1', on: () => gridOn, run: () => setGrid(!gridOn) },
90
+ { label: 'Show ruler', on: rulersShown, run: () => setRulersShown(!rulersShown()) },
91
+ '-',
92
+ { label: 'Guides', icon: 'grid_4x4', items: () => [
93
+ { label: 'Show guides', key: '⌃⌘E ⌃⌘G', on: guidesShown, run: () => setGuidesShown(!guidesShown()) },
94
+ '-',
95
+ { label: 'Add vertical guide', key: '⌃⌘E ⌃⌘V', run: () => addGuide('v') },
96
+ { label: 'Add horizontal guide', key: '⌃⌘E ⌃⌘H', run: () => addGuide('h') },
97
+ '-',
98
+ { label: 'Clear guides', run: () => setGuides([]), off: () => !userGuides().length },
99
+ ] },
100
+ { label: 'Snap to', icon: 'keyboard_tab', items: () => [{ label: 'Grid', on: snapGrid, run: () => store('slides-snap-grid', snapGrid() ? undefined : '1') },
101
+ { label: 'Guides', on: snapGuides, run: () => { store('slides-snap-guides', snapGuides() ? '0' : undefined); } }] },
102
+ '-',
103
+ { label: 'Show speaker notes', on: () => !document.body.classList.contains('notes-off'), run: () => setNotesVisible(document.body.classList.contains('notes-off')) },
104
+ { label: 'Show sticky notes', on: stickiesShown, run: () => setStickies(!stickiesShown()) },
105
+ { label: 'Show filmstrip', on: () => !document.body.classList.contains('film-off'), run: () => $('#ed-film-toggle').click() },
106
+ { label: 'Show insert bar', on: () => dockShown(), run: () => setDock(!dockShown()) }, // dock.js loads after this file
107
+ '-',
108
+ { label: 'Zoom menu', icon: 'zoom_in', items: () => zoomItems() },
109
+ { label: 'Full screen', icon: 'fullscreen', key: '⌃⇧F', on: () => document.body.classList.contains('compact'), run: toggleCompact },
110
+ { label: 'Dark mode', icon: 'dark_mode', on: darkOn, run: () => setDark(!darkOn()) }, // ours: Slides has none
111
+ ],
112
+ Insert: [
113
+ { label: 'Image', icon: 'image', items: [{ label: 'Upload from computer', icon: 'upload', run: () => $('#ed-file').click() }] },
114
+ { label: 'Text box', icon: 'text_fields', run: () => $('#ed-add-text').click() }, // Slides' order
115
+ { label: 'Shape', icon: 'shapes', items: shapeItems },
116
+ { label: 'Line', icon: 'pen_size_2', items: lineItems },
117
+ { label: 'Word art', icon: 'format_shapes', run: addWordArt },
118
+ { label: 'Icon', icon: 'emoji_symbols', run: () => showIcons(true) },
119
+ { label: 'Table', icon: 'table_chart', items: () => [{ el: tableGrid }] },
120
+ { label: 'Code block', icon: 'code', items: () => codeLangItems(addCode) },
121
+ { label: 'Equation', icon: 'function', run: addEquation },
122
+ { label: 'Note for AI', icon: 'sticky_note_2', run: () => { const b = addNote(cur, W - 620, 60, ''); sel = new Set([b]); startEdit(b); } },
123
+ '-',
124
+ { label: 'Video', icon: 'movie', items: [{ label: 'Upload from computer', icon: 'upload', run: () => $('#ed-video-file').click() }, { label: 'By URL (YouTube, Vimeo)', icon: 'link', run: () => askURL('Insert video by URL', addEmbed) }] },
125
+ { label: 'Embed', icon: 'web', run: () => askURL('Embed a web page', addEmbed) },
126
+ { label: 'Live box', icon: 'play_circle', run: () => addLive() }, // ours: a page with its own script, on the slide
127
+ '-',
128
+ { label: 'Special characters', icon: 'special_character', run: () => showChars(true) },
129
+ { label: 'Animation', icon: 'animation', run: () => { setTransPanel(true); addAnim(); }, off: noSel },
130
+ '-',
131
+ { label: 'Link', icon: 'link', key: '⌘K', run: linkText, off: () => !editing && sel.size !== 1 },
132
+ '-',
133
+ { label: 'New slide', icon: 'add', key: '⌃M', run: () => $('#ed-new-slide').click() },
134
+ ],
135
+ Format: FORMAT_MENU,
136
+ Slide: [
137
+ { label: 'New slide', icon: 'add', key: '⌃M', run: () => $('#ed-new-slide').click() },
138
+ { label: 'New slide with layout', icon: 'add_box', items: newSlideItems },
139
+
140
+ { label: 'New slide below', icon: 'vertical_align_bottom', key: '⇧⌘M', run: newSlideBelow },
141
+ '-',
142
+ { label: 'Duplicate slide', icon: 'control_point_duplicate', run: () => $('#ed-dup-slide').click() },
143
+ { label: 'Slide style (CSS)', icon: 'css', run: () => editSlideCSS() }, // ours: styles for this slide only
144
+ { label: 'Delete slide', icon: 'delete', run: () => $('#ed-del-slide').click() },
145
+ { label: 'Skip slide', icon: 'visibility_off', on: () => slides[cur]?.dataset.visibility === 'hidden', run: skipSlides },
146
+ { label: 'Move slide', icon: 'swap_vert', items: [
147
+ { label: 'Move slide up', icon: 'arrow_upward', key: '⌘↑', run: () => $('#ed-up-slide').click() },
148
+ { label: 'Move slide down', icon: 'arrow_downward', key: '⌘↓', run: () => $('#ed-down-slide').click() },
149
+ { label: 'Move slide to beginning', icon: 'vertical_align_top', key: '⇧⌘↑', run: () => moveSlides('start') },
150
+ { label: 'Move slide to end', icon: 'vertical_align_bottom', key: '⇧⌘↓', run: () => moveSlides('end') },
151
+ ] },
152
+ '-',
153
+ { label: 'Change background', icon: 'opacity', run: () => showBgDialog(true) },
154
+ { label: 'Apply layout', icon: 'view_quilt', items: layoutItems },
155
+ { label: 'Edit theme', icon: 'palette', run: enterThemeEdit },
156
+ { label: 'Change theme', icon: 'palette', on: () => !$('#ed-theme').hidden, run: () => setThemePanel($('#ed-theme').hidden) },
157
+ { label: 'Transition', icon: 'animation', on: () => !$('#ed-trans').hidden, run: () => setTransPanel($('#ed-trans').hidden) },
158
+ ],
159
+ Arrange: [
160
+ { label: 'Order', icon: 'flip_to_front', items: [
161
+ { label: 'Bring to front', icon: 'flip_to_front', key: '⇧⌘↑', run: toFront, off: noSel },
162
+ { label: 'Bring forward', key: '⌘↑', run: stepForward, off: noSel },
163
+ { label: 'Send backward', key: '⌘↓', run: stepBackward, off: noSel },
164
+ { label: 'Send to back', icon: 'flip_to_back', key: '⇧⌘↓', run: toBack, off: noSel },
165
+ ] },
166
+ { label: 'Align', icon: 'align_horizontal_left', items: [
167
+ { label: 'Left', icon: 'align_horizontal_left', run: () => alignSel('h', 0), off: noSel },
168
+ { label: 'Center', icon: 'align_horizontal_center', run: () => alignSel('h', .5), off: noSel },
169
+ { label: 'Right', icon: 'align_horizontal_right', run: () => alignSel('h', 1), off: noSel },
170
+ '-',
171
+ { label: 'Top', icon: 'align_vertical_top', run: () => alignSel('v', 0), off: noSel },
172
+ { label: 'Middle', icon: 'align_vertical_center', run: () => alignSel('v', .5), off: noSel },
173
+ { label: 'Bottom', icon: 'align_vertical_bottom', run: () => alignSel('v', 1), off: noSel },
174
+ ] },
175
+ { label: 'Distribute', icon: 'horizontal_distribute', items: [
176
+ { label: 'Horizontally', icon: 'horizontal_distribute', run: () => distributeSel('h'), off: () => sel.size < 3 },
177
+ { label: 'Vertically', icon: 'vertical_distribute', run: () => distributeSel('v'), off: () => sel.size < 3 },
178
+ ] },
179
+ { label: 'Center on page', icon: 'center_focus_strong', items: [
180
+ { label: 'Horizontally', run: () => centerOnPage('h'), off: noSel },
181
+ { label: 'Vertically', run: () => centerOnPage('v'), off: noSel },
182
+ ] },
183
+ { label: 'Rotate', icon: 'rotate_right', items: [
184
+ { label: 'Rotate clockwise 90°', icon: 'rotate_right', run: () => rotateSel(90), off: noSel },
185
+ { label: 'Rotate counter-clockwise 90°', icon: 'rotate_left', run: () => rotateSel(-90), off: noSel },
186
+ '-',
187
+ { label: 'Flip horizontally', icon: 'flip', run: () => flipSel('h'), off: noSel },
188
+ { label: 'Flip vertically', run: () => flipSel('v'), off: noSel },
189
+ ] },
190
+ '-',
191
+ { label: 'Group', icon: 'group_work', key: '⌥⌘G', run: groupSel, off: () => sel.size < 2 },
192
+ { label: 'Ungroup', key: '⇧⌥⌘G', run: ungroupSel, off: () => ![...sel].some(b => b.classList.contains('group')) },
193
+ '-',
194
+ { label: 'Flip line direction', icon: 'swap_horiz', run: flipLines, off: () => ![...sel].some(isLine) },
195
+ '-',
196
+ { label: 'Stack as a sequence', icon: 'stacks', run: () => stackSequence(), off: () => [...sel].filter(b => isMedia(b)).length < 2 }, // pictures shown one after another in one place
197
+ ],
198
+ Tools: [{ label: 'Spelling', icon: 'spellcheck', items: () => [{ label: 'Show spelling suggestions', key: '⌥⌘X', on: spellOn, run: () => setSpelling(!spellOn()) }] },
199
+ { label: 'Convert LaTeX shortcuts', icon: 'function', on: latexOn, run: () => store('slides-latex', latexOn() ? '0' : undefined) }],
200
+ Help: [
201
+ { label: 'Search the menus', icon: 'search', key: '⌥/', run: () => menuInput.focus() },
202
+ '-',
203
+ { label: 'Keyboard shortcuts', icon: 'keyboard', key: '⌘/', run: () => showShortcuts(true) },
204
+ ],
205
+ };
206
+ /* A collaborator's editor (glideshow.org/e/…): one deck of someone else's, so nothing about decks or
207
+ the library (new, open, copy, rename, trash, Share, history), and not its theme or page size, which stay the owner's. */
208
+ const COLLAB = !!(/** @type {any} */ (window.GLIDESHOW_CLOUD))?.collab;
209
+ if (COLLAB) {
210
+ const keep = (list, out) => list.filter(it => typeof it !== 'object' || !out.includes(it.label));
211
+ MENUS.File = MENUS.File.filter(it => typeof it === 'object' && ['Download', 'Print preview', 'Print'].includes(it.label));
212
+ for (const k of Object.keys(MENUS)) if (Array.isArray(MENUS[k])) MENUS[k] = keep(MENUS[k], ['Theme builder', 'Edit theme', 'Change theme', 'Page setup']);
213
+ }
214
+ const zoomItems = () => [
215
+ { label: 'Fit', key: '⌘0', on: () => !zoom, run: () => setZoom(null) },
216
+ ...ZOOMS.map(z => ({ label: z * 100 + '%', on: () => zoom === z, run: () => setZoom(z) })),
217
+ '-',
218
+ { label: 'Zoom in', icon: 'zoom_in', key: '⌘+', run: () => zoomStep(1) },
219
+ { label: 'Zoom out', icon: 'zoom_out', key: '⌘−', run: () => zoomStep(-1) },
220
+ ];
221
+ menuButton($('#ed-zoom'), () => zoomItems());
222
+ // Trackpad pinch (and ⌘/ctrl + wheel) zooms around the pointer.
223
+ stage.addEventListener('wheel', e => {
224
+ if (!e.ctrlKey && !e.metaKey) return;
225
+ e.preventDefault();
226
+ setZoom(fitZoom() * Math.exp(-e.deltaY / 100), e.clientX, e.clientY);
227
+ }, { passive: false });
228
+ const SLIDESHOW = [
229
+ { label: 'Present from this slide', icon: 'slideshow', key: '⌘↵', run: () => present() },
230
+ { label: 'Present from beginning', icon: 'first_page', key: '⇧⌘↵', run: () => present(0) },
231
+ { label: 'Present fullscreen', icon: 'fullscreen', run: () => present(cur, { fullscreen: true }) },
232
+ '-',
233
+ { label: 'Presenter view', icon: 'co_present', run: () => openDeckTab(cur, true) },
234
+ { label: 'Open in new tab', icon: 'open_in_new', run: () => openDeckTab(cur) },
235
+ { label: 'Open as a scrolling page', icon: 'view_day', run: () => window.open('deck.html?view=scroll', '_blank') }, // reveal's scroll view: reads like a page, good on a phone
236
+ ];
237
+ document.querySelectorAll('#ed-menubar [data-menu]').forEach(btn => {
238
+ btn.onclick = () => openAnchor === btn ? closeMenu() : openMenu(btn, MENUS[btn.dataset.menu]);
239
+ btn.onpointerenter = () => { if (openAnchor?.parentElement === btn.parentElement && openAnchor !== btn) openMenu(btn, MENUS[btn.dataset.menu]); };
240
+ });
241
+ $('#ed-present-menu').onclick = () => openAnchor === $('#ed-present-menu') ? closeMenu() : openMenu($('#ed-present-menu'), SLIDESHOW);
242
+
243
+ /* Toolbar groups that don't fit move, from the right, into the ⋯ popover (as Slides does). */
244
+ function layoutToolbar() {
245
+ const bar = $('#ed-toolbar'), pop = $('#ed-more-pop'), more = $('#ed-more-wrap');
246
+ [...pop.children].forEach(g => bar.insertBefore(g, more));
247
+ [...bar.querySelectorAll(':scope > .grp')].sort((a, b) => a.dataset.i - b.dataset.i).forEach(g => bar.insertBefore(g, more));
248
+ more.hidden = true;
249
+ const groups = [...bar.querySelectorAll(':scope > .grp')].filter(g => g.offsetWidth);
250
+ while (bar.scrollWidth > bar.clientWidth && groups.length > 1) { more.hidden = false; pop.prepend(groups.pop()); }
251
+ if (!pop.children.length) pop.classList.remove('open');
252
+ }
253
+ $('#ed-more').onclick = () => { const pop = $('#ed-more-pop'); pop.style.top = $('#ed-toolbar').getBoundingClientRect().bottom + 4 + 'px'; pop.classList.toggle('open'); };
254
+ addEventListener('resize', layoutToolbar);
255
+ document.fonts?.addEventListener('loadingdone', layoutToolbar); // icon and text widths change when web fonts arrive
256
+
257
+ /* Tooltips in Slides' style: a small dark pill under the control, label then shortcut. */
258
+ const tip = div('tip');
259
+ document.querySelectorAll('#ed-bar [title]').forEach(e => { e.dataset.tip = e.title; e.removeAttribute('title'); });
260
+ addEventListener('pointerover', e => {
261
+ const t = e.target.closest?.('[data-tip]');
262
+ if (!t || openAnchor === t) { tip.remove(); return; }
263
+ tip.textContent = t.dataset.tip; document.body.append(tip);
264
+ const r = t.getBoundingClientRect();
265
+ tip.style.left = Math.max(4, Math.min(r.left + r.width / 2 - tip.offsetWidth / 2, innerWidth - tip.offsetWidth - 4)) + 'px';
266
+ tip.style.top = r.bottom + 6 + 'px';
267
+ });
268
+ addEventListener('pointerdown', () => tip.remove(), true);
269
+
270
+ /* Search the menus (⌥/), as Slides: matches every menu item, including submenus, by label. */
271
+ const menuInput = $('#ed-menus input');
272
+ function allMenuItems() {
273
+ const out = [], walk = (items, path) => { const list = val(items); if (Array.isArray(list)) list.forEach(it => { if (it === '-' || !it.label) return; if (it.items) walk(it.items, path.concat(it.label)); else out.push({ ...it, path }); }); }; // a fetched submenu (File → Open's recent decks), headings and custom bodies aren't searched
274
+ Object.entries(MENUS).forEach(([name, items]) => walk(items, [name]));
275
+ return out;
276
+ }
277
+ menuInput.oninput = () => {
278
+ const q = menuInput.value.trim().toLowerCase();
279
+ if (!q) { closeMenu(); return; }
280
+ const hits = allMenuItems().filter(it => it.label.toLowerCase().includes(q) && !val(it.off)).slice(0, 12)
281
+ .map(it => ({ ...it, label: it.label, key: it.key || it.path.join(' › '), run: () => { menuInput.value = ''; menuInput.blur(); it.run(); } }));
282
+ openMenu($('#ed-menus'), hits.length ? hits : [{ label: 'No results', off: true, run() {} }]);
283
+ menuInput.focus();
284
+ };
285
+ menuInput.onkeydown = e => {
286
+ if (e.key === 'Enter') { e.preventDefault(); openMenuEl?.querySelector('.menu-item:not(:disabled)')?.click(); }
287
+ if (e.key === 'ArrowDown') { e.preventDefault(); openMenuEl?.querySelector('.menu-item:not(:disabled)')?.focus(); }
288
+ if (e.key === 'Escape') { menuInput.value = ''; closeMenu(); menuInput.blur(); }
289
+ };
290
+ addEventListener('keydown', e => { if (e.altKey && !e.metaKey && !e.ctrlKey && e.code === 'Slash') { e.preventDefault(); menuInput.focus(); } }, true);
@@ -0,0 +1,93 @@
1
+ /* ---------- menus ---------- */
2
+
3
+ /* A menu is a list of { label, key, run, off } items and '-' dividers, shown under its anchor. */
4
+ /* One Material-style menu for the menu bar, toolbar dropdowns and (later) context menus. Items are
5
+ { label, icon, key, run, off, on, items, style } or '-' for a divider; `items` (an array or a function
6
+ returning one) makes a submenu, shown with ▸. `on` puts a check in the icon column. Arrow keys, Enter
7
+ and Esc work while a menu is open. */
8
+ /* Shortcut labels as Google writes them: ⌘Z, ⌘+Shift+V, ⌘+Option+Shift+B, Ctrl+M, Option+/. */
9
+ function gkey(k) {
10
+ if (!k) return '';
11
+ if (/^⌃⌘\w ⌃⌘\w$/.test(k)) return k.replace(/⌃⌘/g, 'Ctrl+⌘'); // two-step chords, written as Slides does: Ctrl+⌘E Ctrl+⌘G
12
+ const mods = { '⌘': '⌘', '⌃': 'Ctrl', '⌥': 'Option', '⇧': 'Shift' }, has = c => k.includes(c);
13
+ const key = k.replace(/[⌘⌃⌥⇧]/g, '').replace('↵', 'Enter');
14
+ const list = ['⌘', '⌃', '⌥', '⇧'].filter(has).map(c => mods[c]);
15
+ if (!list.length) return key;
16
+ return list.length === 1 && list[0] === '⌘' ? '⌘' + key : list.join('+') + '+' + key;
17
+ }
18
+ let openMenuEl = null, openAnchor = null, subMenus = []; // open submenus, outermost first
19
+ const val = v => typeof v === 'function' ? v() : v;
20
+ const levelOf = m => m === openMenuEl ? 0 : subMenus.indexOf(m) + 1;
21
+ function closeSub(level = 0) { subMenus.splice(level).forEach(m => m.remove()); } // close submenus deeper than `level`
22
+ function closeMenu() { closeSub(); openMenuEl?.remove(); openAnchor?.classList.remove('open'); openMenuEl = openAnchor = null; }
23
+ function buildMenu(items) {
24
+ const m = div('menu');
25
+ m.addEventListener('mousedown', e => e.preventDefault()); // keep the text selection being formatted
26
+ for (const it of val(items)) {
27
+ if (it === '-') { m.append(div('menu-sep')); continue; }
28
+ if (it.head) { const h = div('menu-head'); h.textContent = it.head; m.append(h); continue; }
29
+ if (it.el) { m.append(it.el()); continue; } // a custom body, e.g. the table size grid
30
+ const row = document.createElement('button');
31
+ row.className = 'menu-item'; row.disabled = !!val(it.off);
32
+ row.innerHTML = '<span class="menu-icon icon"></span><span class="menu-label"></span><span class="menu-key"></span>';
33
+ row.children[0].textContent = val(it.on) ? 'check' : it.icon || '';
34
+ row.children[1].textContent = it.label;
35
+ if (it.cell) row.classList.add('cell-item');
36
+ if (it.tile) row.classList.add('tile-item');
37
+ if (it.svg) { row.children[1].innerHTML = it.svg; row.setAttribute('aria-label', it.label); row.dataset.label = it.label; } // a picture instead of words, as Slides' dash / line-end menus
38
+ if (it.style) row.children[1].style.cssText = it.style;
39
+ if (it.tip) row.dataset.tip = it.tip;
40
+ if (it.items) {
41
+ row.classList.add('has-sub'); row.children[2].className = 'menu-key icon'; row.children[2].textContent = 'arrow_right';
42
+ row.onpointerenter = row.onclick = () => openSub(row, it.items);
43
+ } else {
44
+ row.children[2].textContent = gkey(it.key);
45
+ row.onpointerenter = () => closeSub(levelOf(m));
46
+ row.onclick = () => { closeMenu(); it.run(); };
47
+ }
48
+ m.append(row);
49
+ }
50
+ if (val(items).every(it => it === '-' || it.svg || it.narrow || it.el)) m.classList.add('pics');
51
+ if (val(items).every(it => it.cell || it === '-')) m.classList.add('cell-grid'); // Slides' shape grids
52
+ if (val(items).every(it => it.tile)) { m.classList.add('tile-grid'); m.querySelectorAll('.tile-item').forEach((r, i) => r.children[1].insertAdjacentHTML('beforeend', `<span class="cap">${val(items)[i].label}</span>`)); } // layout pictures with names // picture-only menus are narrow, as Slides
53
+ return m;
54
+ }
55
+ function placeMenu(m, x, y, flipX) {
56
+ document.body.append(m);
57
+ if (x + m.offsetWidth > innerWidth - 8) x = flipX ?? innerWidth - m.offsetWidth - 8;
58
+ m.style.left = Math.max(8, x) + 'px'; m.style.top = Math.max(8, Math.min(y, innerHeight - m.offsetHeight - 8)) + 'px';
59
+ }
60
+ function openMenu(anchor, items, { up = false } = {}) {
61
+ closeMenu();
62
+ const m = buildMenu(items), r = anchor.getBoundingClientRect();
63
+ if (up) { document.body.append(m); placeMenu(m, r.left, r.top - m.offsetHeight - 6); } // the insert bar: menus open above it
64
+ else placeMenu(m, r.left, r.bottom + 2);
65
+ anchor.classList.add('open'); openMenuEl = m; openAnchor = anchor;
66
+ }
67
+
68
+ function openSub(row, items) {
69
+ const list = val(items);
70
+ if (list?.then) return list.then(l => row.isConnected && openSub(row, l)); // a fetched submenu (File → Open's recent decks)
71
+ const level = levelOf(row.parentElement);
72
+ if (subMenus[level]?.dataset.from === row.textContent) return;
73
+ closeSub(level);
74
+ const m = buildMenu(list), r = row.getBoundingClientRect();
75
+ m.dataset.from = row.textContent;
76
+ placeMenu(m, r.right - 2, r.top - 6, r.left - m.offsetWidth + 2);
77
+ subMenus.push(m);
78
+ }
79
+ addEventListener('pointerdown', e => {
80
+ if (openMenuEl && ![openMenuEl, ...subMenus, openAnchor].some(el => el?.contains(e.target))) closeMenu();
81
+ }, true);
82
+ addEventListener('keydown', e => {
83
+ if (!openMenuEl) return;
84
+ const menu = [...subMenus].reverse().find(m => m.contains(document.activeElement)) || openMenuEl, level = levelOf(menu);
85
+ const rows = [...menu.querySelectorAll('.menu-item:not(:disabled)')], i = rows.indexOf(document.activeElement);
86
+ const take = () => { e.preventDefault(); e.stopImmediatePropagation(); };
87
+ const back = () => { const from = menu.dataset.from, parent = level > 1 ? subMenus[level - 2] : openMenuEl; closeSub(level - 1); [...parent.children].find(r => r.textContent === from)?.focus(); };
88
+ if (e.key === 'Escape') { take(); if (level) back(); else closeMenu(); }
89
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { take(); rows[(i + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length]?.focus(); }
90
+ else if (e.key === 'ArrowRight' && document.activeElement?.classList.contains('has-sub')) { take(); document.activeElement.click(); subMenus.at(-1)?.querySelector('.menu-item:not(:disabled)')?.focus(); }
91
+ else if (e.key === 'ArrowLeft' && level) { take(); back(); }
92
+ else if (e.key === 'Enter' && i >= 0) { take(); rows[i].click(); }
93
+ }, true);
@@ -0,0 +1,148 @@
1
+ /* ---------- transitions (Slide → Transition) ---------- */
2
+
3
+ /* Slides' transitions mapped onto reveal.js, stored per slide as data-transition / data-transition-speed.
4
+ Dissolve and Fade both fade (written differently so the choice round-trips); Slide from left is ours. */
5
+ const TRANSITIONS = [['none', 'None'], ['fade', 'Dissolve'], ['fade-in fade-out', 'Fade'], ['slide', 'Slide from right'],
6
+ ['sl-left', 'Slide from left'], ['convex', 'Flip'], ['concave', 'Cube'], ['zoom', 'Gallery'], ['morph', 'Morph']];
7
+ const SPEEDS = ['slow', '', 'fast'];
8
+ /* Morph is reveal's Auto-Animate (Keynote's Magic Move): data-auto-animate on the slide, its speed as
9
+ data-auto-animate-duration. Boxes pair with the previous slide's by data-morph (Duplicate slide sets it), else by id. */
10
+ const MORPH_S = { slow: '1.4', fast: '0.5' };
11
+ const kindOf = s => s.hasAttribute('data-auto-animate') ? 'morph' : s.dataset.transition || 'none';
12
+ const speedOf = s => s.hasAttribute('data-auto-animate') ? Object.keys(MORPH_S).find(k => MORPH_S[k] === s.dataset.autoAnimateDuration) || '' : s.dataset.transitionSpeed || '';
13
+ const trans = $('#ed-trans');
14
+ $('#ed-t-kind').innerHTML = TRANSITIONS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
15
+ function setTransPanel(open) { trans.hidden = !open; if (open) { fmt.hidden = true; $('#ed-theme').hidden = true; $('#ed-comments').hidden = true; updateTransPanel(); } fit(); }
16
+ function updateTransPanel() {
17
+ if (trans.hidden || !slides[cur]) return;
18
+ renderAnims();
19
+ const s = slides[cur];
20
+ $('#ed-t-kind').value = kindOf(s);
21
+ $('#ed-t-speed').value = Math.max(0, SPEEDS.indexOf(speedOf(s)));
22
+ }
23
+ function writeTransition(s) {
24
+ const k = $('#ed-t-kind').value, sp = SPEEDS[+$('#ed-t-speed').value];
25
+ s.removeAttribute('data-auto-animate'); delete s.dataset.autoAnimateDuration;
26
+ if (k === 'none') { delete s.dataset.transition; delete s.dataset.transitionSpeed; return; }
27
+ if (k === 'morph') { delete s.dataset.transition; delete s.dataset.transitionSpeed; s.setAttribute('data-auto-animate', ''); if (sp) s.dataset.autoAnimateDuration = MORPH_S[sp]; return; }
28
+ s.dataset.transition = k;
29
+ if (sp) s.dataset.transitionSpeed = sp; else delete s.dataset.transitionSpeed;
30
+ }
31
+ $('#ed-t-kind').onchange = $('#ed-t-speed').onchange = () => { writeTransition(slides[cur]); commit(); playTransition(); renderAnims(); };
32
+ $('#ed-t-all').onclick = () => { slides.forEach(writeTransition); commit(); setStatus('Transition applied to all slides'); };
33
+ $('#ed-trans-close').onclick = () => setTransPanel(false);
34
+ $('#ed-transition').onclick = () => setTransPanel(trans.hidden);
35
+ /* Object animations, as reveal fragments in the order of data-fragment-index (Motion panel). */
36
+ const EFFECTS = [['appear', 'Appear'], ['fade-in', 'Fade in'], ['fade-right', 'Fly in from left'], ['fade-left', 'Fly in from right'],
37
+ ['fade-up', 'Fly in from bottom'], ['fade-down', 'Fly in from top'], ['zoom-in', 'Zoom in'],
38
+ // reveal's other fragment styles: out, dimmed, shown for one step only, and emphasis on something already there
39
+ ['fade-out', 'Fade out'], ['semi-fade-out', 'Dim'], ['fade-in-then-out', 'Fade in, then out'], ['fade-in-then-semi-out', 'Fade in, then dim'],
40
+ ['highlight-current-blue', 'Highlight while current'], ['grow', 'Grow'], ['shrink', 'Shrink'], ['strike', 'Strike through']];
41
+ const effectOf = b => EFFECTS.find(([c]) => b.classList.contains(c))?.[0] || 'fade-in';
42
+ const animBoxes = () => boxes().filter(b => b.classList.contains('fragment'))
43
+ .map((b, i) => [b, +(b.dataset.fragmentIndex ?? i), 'withPrevious' in b.dataset ? 1 : 0])
44
+ .sort((a, z) => a[1] - z[1] || a[2] - z[2]).map(x => x[0]); // within a step, the "with previous" ones follow
45
+ /* "With previous" shares the previous animation's step (same data-fragment-index); data-with-previous keeps the choice. */
46
+ const renumber = list => { let n = -1; list.forEach((b, i) => { if (!(i && 'withPrevious' in b.dataset)) n++; b.dataset.fragmentIndex = n; }); };
47
+ const openAnims = new Set();
48
+ function setEffect(b, eff) { b.classList.remove(...EFFECTS.map(e => e[0])); b.classList.add('fragment', eff); }
49
+ function addAnim() { // boxes animated together (several selected) share one click: the first on click, the rest with previous
50
+ const list = animBoxes();
51
+ [...sel].filter(b => !b.classList.contains('fragment')).forEach((b, i) => { setEffect(b, 'fade-in'); if (i) b.dataset.withPrevious = ''; list.push(b); });
52
+ renumber(list); commit(); renderAnims();
53
+ }
54
+ function removeAnim(b) {
55
+ b.classList.remove('fragment', ...EFFECTS.map(e => e[0])); delete b.dataset.fragmentIndex; delete b.dataset.withPrevious; delete b.dataset.build;
56
+ renumber(animBoxes()); commit(); renderAnims();
57
+ }
58
+ function moveAnim(from, to) { const list = animBoxes(); list.splice(to, 0, ...list.splice(from, 1)); renumber(list); commit(); renderAnims(); }
59
+ const animLabel = b => (content(b)?.textContent.trim().slice(0, 40)) || (isMedia(b) ? (b.classList.contains('video') ? 'Video' : 'Image') : b.dataset.shape ? b.dataset.shape[0].toUpperCase() + b.dataset.shape.slice(1) : 'Text box');
60
+ function renderAnims() {
61
+ const host = $('#ed-anim-list'); if (!host || trans.hidden) return;
62
+ const canAdd = [...sel].some(b => !b.classList.contains('fragment')), kind = slides[cur] ? kindOf(slides[cur]) : 'none';
63
+ $('#ed-anim-add').disabled = !canAdd;
64
+ $('#ed-anim-add').lastChild.textContent = canAdd || sel.size ? 'Add animation' : 'Select an object to animate'; // Slides' empty-state label
65
+ $('#ed-anim-play').disabled = kind === 'none' && !animBoxes().length;
66
+ trans.classList.toggle('no-trans', kind === 'none');
67
+ const t = TRANSITIONS.find(x => x[0] === kind); $('#ed-t-name').textContent = t ? t[1] : 'None';
68
+ host.replaceChildren(...animBoxes().map((b, i) => {
69
+ const open = sel.has(b) || openAnims.has(b), withPrev = 'withPrevious' in b.dataset;
70
+ const r = div('anim-row' + (sel.has(b) ? ' on' : '') + (open ? ' open' : '')); r.draggable = true; r.dataset.i = String(i);
71
+ r.innerHTML = `<div class="anim-head"><span class="n icon" title="Drag to reorder">drag_indicator</span><span class="icon chev">chevron_right</span>
72
+ <span class="eff"></span><span class="what"></span><button title="Delete animation"><span class="icon">delete</span></button></div>
73
+ <div class="anim-body"><select class="e">${EFFECTS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
74
+ <select class="t"><option value="click">On click</option><option value="with">With previous</option></select>
75
+ <label class="para"><input type="checkbox"> By paragraph</label></div>`;
76
+ r.querySelector('.eff').textContent = `${EFFECTS.find(e => e[0] === effectOf(b))[1]}${b.dataset.build === 'paragraph' ? ' by paragraph' : ''} (${withPrev ? 'With previous' : 'On click'})`;
77
+ r.querySelector('.what').textContent = animLabel(b);
78
+ const se = r.querySelector('select.e'), tr = r.querySelector('select.t'); se.value = effectOf(b); tr.value = withPrev ? 'with' : 'click';
79
+ se.onchange = () => { setEffect(b, se.value); commit(); renderAnims(); };
80
+ tr.onchange = () => { if (tr.value === 'with') b.dataset.withPrevious = ''; else delete b.dataset.withPrevious; renumber(animBoxes()); commit(); renderAnims(); };
81
+ const para = /** @type {HTMLInputElement} */ (r.querySelector('.para input')), textual = !isMedia(b) && !isStroke(b) && !!content(b)?.textContent.trim();
82
+ /** @type {HTMLElement} */ (r.querySelector('.para')).hidden = !textual; para.checked = b.dataset.build === 'paragraph'; // Slides' "By paragraph": one step per paragraph or bullet
83
+ para.onchange = () => { if (para.checked) b.dataset.build = 'paragraph'; else delete b.dataset.build; commit(); renderAnims(); };
84
+ r.querySelector('.anim-head button').onclick = () => removeAnim(b);
85
+ r.querySelector('.anim-head').onclick = e => {
86
+ if (e.target.closest('button')) return;
87
+ if (open && !sel.has(b)) openAnims.delete(b); else openAnims.add(b);
88
+ sel = new Set([b]); drawOverlay(); updateToolbar(); renderAnims();
89
+ };
90
+ r.ondragstart = e => e.dataTransfer.setData('text/x-anim', String(i));
91
+ r.ondragover = e => { e.preventDefault(); r.classList.add('drop'); };
92
+ r.ondragleave = () => r.classList.remove('drop');
93
+ r.ondrop = e => { e.preventDefault(); r.classList.remove('drop'); const from = +e.dataTransfer.getData('text/x-anim'); if (from !== i) moveAnim(from, i); };
94
+ return r;
95
+ }));
96
+ }
97
+ /* Preview: the slide's animations one after another on the canvas. */
98
+ async function playAnims() {
99
+ const kf = { appear: [{ opacity: 0 }, { opacity: 0, offset: .99 }, { opacity: 1 }], 'fade-in': [{ opacity: 0 }, { opacity: 1 }],
100
+ 'fade-right': [{ opacity: 0, translate: '-20% 0' }, { opacity: 1, translate: '0 0' }], 'fade-left': [{ opacity: 0, translate: '20% 0' }, { opacity: 1, translate: '0 0' }],
101
+ 'fade-up': [{ opacity: 0, translate: '0 20%' }, { opacity: 1, translate: '0 0' }], 'fade-down': [{ opacity: 0, translate: '0 -20%' }, { opacity: 1, translate: '0 0' }],
102
+ 'zoom-in': [{ opacity: 0, scale: .1 }, { opacity: 1, scale: 1 }], 'fade-out': [{ opacity: 1 }, { opacity: 0 }], 'semi-fade-out': [{ opacity: 1 }, { opacity: .5 }],
103
+ 'fade-in-then-out': [{ opacity: 0 }, { opacity: 1, offset: .3 }, { opacity: 1, offset: .7 }, { opacity: 0 }], 'fade-in-then-semi-out': [{ opacity: 0 }, { opacity: 1, offset: .4 }, { opacity: .5 }],
104
+ 'highlight-current-blue': [{ color: 'inherit' }, { color: '#1b6ac9' }, { color: 'inherit' }], grow: [{ scale: 1 }, { scale: 1.3 }, { scale: 1 }],
105
+ shrink: [{ scale: 1 }, { scale: .7 }, { scale: 1 }], strike: [{ textDecoration: 'none' }, { textDecoration: 'line-through' }] };
106
+ const list = animBoxes();
107
+ list.forEach(b => { b.style.visibility = 'hidden'; });
108
+ for (const b of list) { b.style.visibility = ''; await b.animate(kf[effectOf(b)], { duration: 500, easing: 'ease' }).finished; await new Promise(r => setTimeout(r, 250)); }
109
+ }
110
+ $('#ed-anim-add').onclick = addAnim;
111
+ $('#ed-anim-play').onclick = async () => { playTransition(); await new Promise(r => setTimeout(r, 300)); playAnims(); };
112
+
113
+ /* Preview the current slide's transition on the canvas (an approximation of reveal's). */
114
+ function playTransition() {
115
+ const s = slides[cur], k = kindOf(s), ms = { slow: 1200, fast: 400 }[speedOf(s)] || 800;
116
+ if (k === 'morph') return playMorph(s, (+s.dataset.autoAnimateDuration || 1) * 1000);
117
+ const frames = { fade: [{ opacity: 0 }, { opacity: 1 }], 'fade-in fade-out': [{ opacity: 0 }, { opacity: 1 }],
118
+ slide: [{ transform: 'translateX(100%)' }, { transform: 'none' }], 'sl-left': [{ transform: 'translateX(-100%)' }, { transform: 'none' }],
119
+ convex: [{ transform: 'perspective(2000px) rotateY(90deg)' }, { transform: 'none' }], concave: [{ transform: 'perspective(2000px) rotateY(-90deg)' }, { transform: 'none' }],
120
+ zoom: [{ transform: 'scale(.2)', opacity: 0 }, { transform: 'none', opacity: 1 }] }[k];
121
+ if (frames) s.animate(frames, { duration: ms, easing: 'cubic-bezier(.26,.86,.44,.985)' });
122
+ }
123
+
124
+ /* Morph's preview: each box that has a partner on the slide before moves from the partner's place and size; the rest fade in. */
125
+ function playMorph(s, ms) {
126
+ const prev = slides[slides.indexOf(s) - 1]; if (!prev) return;
127
+ const key = b => b.dataset.morph || b.id, before = new Map(boxes(prev).map(b => [key(b), b]));
128
+ boxes(s).filter(b => !parentBox(b)).forEach(b => {
129
+ const p = before.get(key(b)), at = ['left', 'top', 'width', 'height'];
130
+ if (!p) return b.animate([{ opacity: 0 }, { opacity: 1 }], { duration: ms, easing: 'ease' });
131
+ b.animate([Object.fromEntries(at.map(k => [k, p.style[k]])), Object.fromEntries(at.map(k => [k, b.style[k]]))], { duration: ms, easing: 'ease' });
132
+ });
133
+ }
134
+
135
+ /* Arrange → Stack as a sequence: the selected pictures in one place, shown one after another (a plot that updates in
136
+ place, as reveal's r-stack): the first fades out as the second appears, each middle one shows for its step only, the
137
+ last stays. */
138
+ function stackSequence() {
139
+ const pics = [...sel].filter(b => isMedia(b) && !parentBox(b)).sort((a, z) => boxes().indexOf(a) - boxes().indexOf(z));
140
+ if (pics.length < 2) return setStatus('Select two or more pictures to stack', 'error');
141
+ const g = geo(pics[0]), list = animBoxes().filter(b => !pics.includes(b));
142
+ pics.forEach(b => { place(b, { ...geo(b), x: g.x, y: g.y, w: g.w, h: g.w * geo(b).h / geo(b).w }); b.classList.remove('fragment', ...EFFECTS.map(e => e[0])); delete b.dataset.withPrevious; delete b.dataset.build; });
143
+ pics.slice(1).forEach((b, i) => {
144
+ setEffect(b, i === pics.length - 2 ? 'fade-in' : 'fade-in-then-out'); list.push(b);
145
+ if (i === 0) { setEffect(pics[0], 'fade-out'); pics[0].dataset.withPrevious = ''; list.push(pics[0]); }
146
+ });
147
+ renumber(list); commit(); render(); renderAnims(); setStatus(`${pics.length} pictures stacked: they show one after another`);
148
+ }
@@ -0,0 +1,63 @@
1
+ /* ---------- speaker notes ---------- */
2
+
3
+ /* The pane under the slide edits the slide's <aside class="notes"> (what reveal's speaker view shows).
4
+ Its height and whether it's shown are remembered per browser. */
5
+ const notesPane = $('#ed-notes'), store = (k, v) => { try { v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch {} };
6
+ const stored = k => { try { return localStorage.getItem(k); } catch { return null; } };
7
+ document.body.classList.toggle('ruler-off', !rulersShown());
8
+ /* Dark chrome: the stored choice, else the system's. */
9
+ const darkOn = () => { const v = stored('slides-dark'); return v ? v === '1' : matchMedia('(prefers-color-scheme: dark)').matches; };
10
+ function setDark(on) { store('slides-dark', on ? '1' : '0'); document.body.classList.toggle('dark', on); drawRulers(); }
11
+ document.body.classList.toggle('dark', darkOn());
12
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (!stored('slides-dark')) { document.body.classList.toggle('dark', darkOn()); drawRulers(); } });
13
+ /* Sticky notes (Notes for AI, .box.note) on the slide and in the filmstrip: View → Show sticky notes, remembered per
14
+ browser. Hidden, they can't be picked by Select all or a drag either (so nothing moves or deletes them unseen); the list
15
+ above the speaker notes still names them, and choosing one there, or adding a note, shows them again. */
16
+ const stickiesShown = () => stored('slides-stickies') !== '0';
17
+ function setStickies(/** @type {boolean} */ on) {
18
+ store('slides-stickies', on ? undefined : '0'); document.body.classList.toggle('stickies-off', !on);
19
+ if (!on && [...sel].some(b => b.classList.contains('note'))) { stopEdit(); sel = new Set([...sel].filter(b => !b.classList.contains('note'))); drawOverlay(); updateToolbar(); }
20
+ renderThumbs?.();
21
+ }
22
+ document.body.classList.toggle('stickies-off', !stickiesShown());
23
+ /** The boxes one can pick on this slide: all of them, but sticky notes only while they're shown. */
24
+ const pickable = () => boxes().filter(b => stickiesShown() || !b.classList.contains('note'));
25
+ /* This slide's notes for the AI, listed above the speaker notes (click one to select it). */
26
+ function showAINotes() {
27
+ const list = [...(slides[cur]?.querySelectorAll('.box.note') || [])], host = $('#ed-ai-notes');
28
+ host.hidden = !list.length;
29
+ host.replaceChildren(...list.map(b => { const c = div('ai-note'); c.innerHTML = '<span class="icon">sticky_note_2</span><span></span><small></small>';
30
+ c.children[1].textContent = b.textContent.trim() || '(empty)'; c.children[2].textContent = (b.dataset.time || '').replace('T', ' ');
31
+ c.onclick = () => { stopEdit(); if (!stickiesShown()) setStickies(true); sel = new Set([b]); drawOverlay(); updateToolbar(); }; return c; }));
32
+ }
33
+ function showNotes() {
34
+ showAINotes();
35
+ if (document.activeElement === notesPane && notesPane.dataset.slide === String(cur)) return; // don't move the caret
36
+ notesPane.innerHTML = slides[cur]?.querySelector(':scope > aside.notes')?.innerHTML.trim() || '';
37
+ notesPane.dataset.slide = cur;
38
+ }
39
+ notesPane.oninput = () => {
40
+ const s = slides[cur];
41
+ let a = s.querySelector(':scope > aside.notes');
42
+ const html = notesPane.textContent.trim() ? notesPane.innerHTML.trim() : '';
43
+ if (!html) { a?.remove(); } else { if (!a) { a = document.createElement('aside'); a.className = 'notes'; s.append(a); } a.innerHTML = html; }
44
+ scheduleSave();
45
+ };
46
+ notesPane.onblur = () => { pushHistory(); };
47
+ function setNotesVisible(on) { // collapsed to a thin bar by default, as Slides; the choice is remembered
48
+ document.body.classList.toggle('notes-off', !on);
49
+ store('slides-notes-off', on ? '0' : '1');
50
+ fit();
51
+ }
52
+ function setNotesHeight(h) {
53
+ notesPane.style.height = Math.max(48, Math.min(h, innerHeight * 0.6)) + 'px';
54
+ store('slides-notes-h', parseInt(notesPane.style.height));
55
+ fit();
56
+ }
57
+ $('#ed-splitter').addEventListener('pointerdown', e => {
58
+ const y0 = e.clientY, h0 = notesPane.offsetHeight;
59
+ if (document.body.classList.contains('notes-off')) setNotesVisible(true);
60
+ drag(e, ev => setNotesHeight(h0 - (ev.clientY - y0)), () => {});
61
+ });
62
+ if (stored('slides-notes-h')) notesPane.style.height = stored('slides-notes-h') + 'px';
63
+ if (stored('slides-notes-off') !== '0') document.body.classList.add('notes-off');