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.
- glideshow/__init__.py +15 -0
- glideshow/__main__.py +3 -0
- glideshow/agent.py +681 -0
- glideshow/build.py +54 -0
- glideshow/cli.py +320 -0
- glideshow/comments.py +35 -0
- glideshow/decks.py +230 -0
- glideshow/docs/agents.md +223 -0
- glideshow/docs/deck-format.md +125 -0
- glideshow/editor/.assetsignore +5 -0
- glideshow/editor/apple-touch-icon.png +0 -0
- glideshow/editor/data/font-metrics.json +1 -0
- glideshow/editor/data/fonts.json +1 -0
- glideshow/editor/data/icons.json +1 -0
- glideshow/editor/data/symbols.js +42 -0
- glideshow/editor/data/themes.js +15 -0
- glideshow/editor/deck/base.css +92 -0
- glideshow/editor/deck/present.js +222 -0
- glideshow/editor/deck/shapes.js +377 -0
- glideshow/editor/deck/template.html +37 -0
- glideshow/editor/editor.css +657 -0
- glideshow/editor/glideshow-1024.png +0 -0
- glideshow/editor/glideshow.svg +10 -0
- glideshow/editor/globals.d.ts +21 -0
- glideshow/editor/home.html +358 -0
- glideshow/editor/index.html +386 -0
- glideshow/editor/js/arrange.js +122 -0
- glideshow/editor/js/boot.js +66 -0
- glideshow/editor/js/boxes.js +25 -0
- glideshow/editor/js/comments.js +85 -0
- glideshow/editor/js/context-menus.js +93 -0
- glideshow/editor/js/dialogs.js +168 -0
- glideshow/editor/js/dock.js +22 -0
- glideshow/editor/js/draw.js +107 -0
- glideshow/editor/js/file.js +318 -0
- glideshow/editor/js/find.js +72 -0
- glideshow/editor/js/format-panel.js +101 -0
- glideshow/editor/js/geometry.js +153 -0
- glideshow/editor/js/guides.js +37 -0
- glideshow/editor/js/insert.js +73 -0
- glideshow/editor/js/keyboard.js +256 -0
- glideshow/editor/js/live.js +48 -0
- glideshow/editor/js/menubar.js +290 -0
- glideshow/editor/js/menus.js +93 -0
- glideshow/editor/js/motion.js +148 -0
- glideshow/editor/js/notes.js +63 -0
- glideshow/editor/js/objects.js +202 -0
- glideshow/editor/js/people.js +73 -0
- glideshow/editor/js/phone-comments.js +43 -0
- glideshow/editor/js/phone-edit.js +253 -0
- glideshow/editor/js/phone-home.js +51 -0
- glideshow/editor/js/phone-mode.js +73 -0
- glideshow/editor/js/phone-sheets.js +153 -0
- glideshow/editor/js/phone-touch.js +145 -0
- glideshow/editor/js/phone-view.js +150 -0
- glideshow/editor/js/pointer.js +407 -0
- glideshow/editor/js/render.js +353 -0
- glideshow/editor/js/shortcuts.js +67 -0
- glideshow/editor/js/slides.js +120 -0
- glideshow/editor/js/spacing.js +23 -0
- glideshow/editor/js/state.js +58 -0
- glideshow/editor/js/storage.js +250 -0
- glideshow/editor/js/text.js +366 -0
- glideshow/editor/js/theme-toggle.js +41 -0
- glideshow/editor/js/theme.js +173 -0
- glideshow/editor/js/toolbar-groups.js +109 -0
- glideshow/editor/js/toolbar.js +372 -0
- glideshow/editor/lib/browser-db.js +47 -0
- glideshow/editor/lib/library.js +612 -0
- glideshow/editor/lib/merge.js +174 -0
- glideshow/editor/lib/move-local.js +51 -0
- glideshow/editor/lib/onefile.js +108 -0
- glideshow/editor/lib/store-browser.js +75 -0
- glideshow/editor/lib/store-folder.js +109 -0
- glideshow/editor/local/sw.js +66 -0
- glideshow/editor/local/tsconfig.json +5 -0
- glideshow/editor/looks/glider.svg +9 -0
- glideshow/editor/looks/thermal-phone.css +144 -0
- glideshow/editor/looks/thermal.css +268 -0
- glideshow/editor/manifest.json +23 -0
- glideshow/editor/phone.css +345 -0
- glideshow/editor/share/share.css +124 -0
- glideshow/editor/share/share.js +256 -0
- glideshow/editor/share/speaker.html +88 -0
- glideshow/editor/tsconfig.json +6 -0
- glideshow/figure.py +33 -0
- glideshow/fonts.py +63 -0
- glideshow/gslides.py +1065 -0
- glideshow/history.py +210 -0
- glideshow/merge.py +210 -0
- glideshow/osutil.py +44 -0
- glideshow/pptx_export.py +207 -0
- glideshow/pptx_import.py +115 -0
- glideshow/publish.py +106 -0
- glideshow/render.py +288 -0
- glideshow/server.py +549 -0
- glideshow/service.py +65 -0
- glideshow/skills/glideshow/SKILL.md +85 -0
- glideshow-0.1.2.dist-info/METADATA +69 -0
- glideshow-0.1.2.dist-info/RECORD +103 -0
- glideshow-0.1.2.dist-info/WHEEL +4 -0
- glideshow-0.1.2.dist-info/entry_points.txt +2 -0
- glideshow-0.1.2.dist-info/licenses/LICENSE +662 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/* ---------- rendering ---------- */
|
|
2
|
+
|
|
3
|
+
function render() {
|
|
4
|
+
showNotes();
|
|
5
|
+
queueMicrotask(() => updateTransPanel?.());
|
|
6
|
+
queueMicrotask(refreshFind); // highlights follow the shown slide
|
|
7
|
+
canvas.querySelector(':scope > section')?.remove();
|
|
8
|
+
const s = slides[cur];
|
|
9
|
+
if (scope && !s.contains(scope)) scope = null;
|
|
10
|
+
s.querySelectorAll(':scope > style').forEach(x => { x.classList.add('slide-css'); x.media = 'not all'; }); // a slide's style applies to it only (below), never to the editor
|
|
11
|
+
canvas.prepend(s);
|
|
12
|
+
paintBg(frame, s);
|
|
13
|
+
($('#ed-slide-css') || document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ed-slide-css' }))).textContent = slideCSS(s) ? `#ed-canvas > section { ${slideCSS(s)} }` : '';
|
|
14
|
+
s.querySelectorAll('img, video').forEach(freshSrc); s.querySelectorAll('video').forEach(stillPoster);
|
|
15
|
+
s.querySelectorAll('.box').forEach(b => { if (b.dataset.shape) renderShape(b); shadeBox(b); cropBox(b); fitText(b); });
|
|
16
|
+
highlightCode(s); renderMath(s);
|
|
17
|
+
s.querySelectorAll('.box[data-placeholder] > .content').forEach(markPlaceholder);
|
|
18
|
+
drawOverlay(); renderThumbs(); updateToolbar(); updateThemeBar();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* An unfilled placeholder carries its prompt (data-ph, drawn by editor.css): no text, or only the lone <br> a deleted line
|
|
22
|
+
leaves. Marked here, not matched in CSS, which can't see text: "line<br>line" also has a <br> as its only element. */
|
|
23
|
+
function markPlaceholder(c) {
|
|
24
|
+
if (!c.textContent && (!c.firstChild || c.innerHTML === '<br>')) c.dataset.ph = c.parentElement.dataset.placeholder;
|
|
25
|
+
else delete c.dataset.ph;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Animated GIFs play on the slide but sit still in the filmstrip, as Slides: a thumbnail shows the first frame. The frame
|
|
29
|
+
is drawn once the GIF is decoded (Safari fires load before a large GIF's first frame is ready, and drew it blank); a
|
|
30
|
+
GIF that fails is tried again on the next redraw; after a change on disk the GIF comes again (freshSrc, below), as it
|
|
31
|
+
may have been rewritten under the same name, or caught half-written. */
|
|
32
|
+
const gifStills = new Map();
|
|
33
|
+
function stillGif(img) {
|
|
34
|
+
const src = img.getAttribute('src') || '';
|
|
35
|
+
if (!/\.gif(\?|$)|^data:image\/gif/i.test(src)) return;
|
|
36
|
+
if (gifStills.has(src)) { if (gifStills.get(src)) img.src = gifStills.get(src); return; }
|
|
37
|
+
gifStills.set(src, null);
|
|
38
|
+
const im = new Image();
|
|
39
|
+
im.src = src;
|
|
40
|
+
im.decode().then(() => { // drawImage takes the first frame of a freshly loaded GIF
|
|
41
|
+
if (!im.naturalWidth) throw new Error('no picture');
|
|
42
|
+
const cv = document.createElement('canvas'); cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
|
43
|
+
cv.getContext('2d').drawImage(im, 0, 0); gifStills.set(src, cv.toDataURL()); thumbEpoch++; renderThumbs();
|
|
44
|
+
}).catch(() => { if (gifStills.get(src) === null) gifStills.delete(src); });
|
|
45
|
+
}
|
|
46
|
+
/* After a change on disk the pictures are fetched again (?gsv=n on the slide in the editor; cleanBox takes it off, so the
|
|
47
|
+
deck never has it): a picture rewritten under the same name (a plot made again, a GIF) would otherwise come from the
|
|
48
|
+
page's image cache, as it was. */
|
|
49
|
+
let imgGen = 0;
|
|
50
|
+
/* Online the deck comes with each file's version (lib/library.js /api/deck): after a change elsewhere only the files whose
|
|
51
|
+
version changed are fetched again, each with a mark of its own (2026-10-21: every agent commit refetched every video of
|
|
52
|
+
a 30-video deck, 20,000+ requests an hour). Without versions (the Mac), all of them, as before. */
|
|
53
|
+
let fileVers = /** @type {Record<string, string> | null} */ (null);
|
|
54
|
+
const fileGen = new Map();
|
|
55
|
+
const fileOf = (/** @type {string} */ src) => plainSrc(src).split('#')[0].split('?')[0].replace(/^(\.\/|\/deck\/)/, '');
|
|
56
|
+
const plainSrc = (/** @type {string} */ src) => src.replace(/[?&]gsv=\d+(?=#|$)/, '');
|
|
57
|
+
/** A file of the deck's, as it is now: the URL with this session's fetch-again mark (a media fragment, #t=…, kept last). */
|
|
58
|
+
function fresh(/** @type {string} */ src) {
|
|
59
|
+
if (!src || /^(data|blob|https?):/i.test(src)) return src;
|
|
60
|
+
const n = imgGen + (fileGen.get(fileOf(src)) || 0);
|
|
61
|
+
if (!n) return plainSrc(src);
|
|
62
|
+
const [path, hash] = plainSrc(src).split(/(?=#)/);
|
|
63
|
+
return `${path}${path.includes('?') ? '&' : '?'}gsv=${n}${hash || ''}`;
|
|
64
|
+
}
|
|
65
|
+
function freshSrc(/** @type {Element} */ el) {
|
|
66
|
+
for (const a of ['src', 'poster']) { const v = el.getAttribute(a); if (v && fresh(v) !== v) el.setAttribute(a, fresh(v)); }
|
|
67
|
+
}
|
|
68
|
+
/* Videos sit still in the filmstrip, as a picture: the poster, or else the video's first frame, taken once (a <video> in
|
|
69
|
+
every thumbnail is heavy, and Safari draws one without a poster black). On the slide a video without a poster shows
|
|
70
|
+
that frame until it plays (as its poster in the editor only: data-ed-poster, which cleanBox takes off). */
|
|
71
|
+
const videoStills = new Map(); // src → a picture's data URL; null while it's taken; '' when it can't be (another site's video)
|
|
72
|
+
function videoStill(/** @type {string} */ src) {
|
|
73
|
+
if (!src) return '';
|
|
74
|
+
if (videoStills.has(src)) return videoStills.get(src);
|
|
75
|
+
videoStills.set(src, null);
|
|
76
|
+
const v = document.createElement('video'); v.muted = true; v.playsInline = true; v.preload = 'auto';
|
|
77
|
+
v.src = src.includes('#') ? src : src + '#t=0.001'; // a frame at the start (or the trim's), which Safari then draws
|
|
78
|
+
const grab = () => {
|
|
79
|
+
try {
|
|
80
|
+
const cv = document.createElement('canvas'); cv.width = v.videoWidth; cv.height = v.videoHeight;
|
|
81
|
+
cv.getContext('2d').drawImage(v, 0, 0); videoStills.set(src, cv.toDataURL('image/jpeg', .85));
|
|
82
|
+
} catch { videoStills.set(src, ''); }
|
|
83
|
+
v.removeAttribute('src'); v.load();
|
|
84
|
+
thumbEpoch++; renderThumbs(); canvas.querySelectorAll('video').forEach(stillPoster);
|
|
85
|
+
};
|
|
86
|
+
v.addEventListener('loadeddata', () => v.seeking ? v.addEventListener('seeked', grab, { once: true }) : grab(), { once: true });
|
|
87
|
+
v.addEventListener('error', () => videoStills.set(src, ''), { once: true });
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
function stillPoster(/** @type {HTMLVideoElement} */ v) {
|
|
91
|
+
if (v.getAttribute('poster') && !v.dataset.edPoster) return;
|
|
92
|
+
const still = videoStill(v.getAttribute('src') || '');
|
|
93
|
+
if (still) { v.poster = still; v.dataset.edPoster = '1'; }
|
|
94
|
+
}
|
|
95
|
+
function stillVideo(/** @type {HTMLVideoElement} */ v) { // in a thumbnail: the video as a picture
|
|
96
|
+
const img = document.createElement('img'), pic = (!v.dataset.edPoster && v.getAttribute('poster')) || videoStill(v.getAttribute('src') || '');
|
|
97
|
+
img.alt = ''; img.style.cssText = 'display: block; width: 100%; height: 100%; object-fit: contain; background: #000;';
|
|
98
|
+
if (pic) img.src = pic;
|
|
99
|
+
v.replaceWith(img);
|
|
100
|
+
}
|
|
101
|
+
/* Thumbnail pictures are rebuilt only for slides whose HTML changed (a 150-slide deck re-renders in well under a frame). */
|
|
102
|
+
const thumbViews = new WeakMap();
|
|
103
|
+
let thumbEpoch = 0, thumbCSS = 0; // bumped when something outside the slide's HTML changes how thumbnails draw (GIF stills)
|
|
104
|
+
function thumbView(s) {
|
|
105
|
+
const key = `${s.outerHTML}\0${gridOn ? gridW : THUMB_W}\0${thumbEpoch}`, hit = thumbViews.get(s);
|
|
106
|
+
if (hit?.key === key) return hit.view;
|
|
107
|
+
const view = div('thumb-view reveal');
|
|
108
|
+
const inner = div('thumb-canvas slides');
|
|
109
|
+
inner.style.transform = `scale(${(gridOn ? gridW : THUMB_W) / W})`;
|
|
110
|
+
paintBg(inner, s);
|
|
111
|
+
const c = s.cloneNode(true);
|
|
112
|
+
c.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
|
|
113
|
+
c.querySelectorAll('.box[data-shape]').forEach(renderShape);
|
|
114
|
+
c.querySelectorAll('.box[data-shadow]').forEach(shadeBox);
|
|
115
|
+
c.querySelectorAll('.box[data-crop]').forEach(cropBox);
|
|
116
|
+
c.querySelectorAll('img').forEach(i => { freshSrc(i); stillGif(i); });
|
|
117
|
+
c.querySelectorAll('video').forEach(v => { freshSrc(v); stillVideo(v); });
|
|
118
|
+
c.querySelectorAll('.box.live > .content').forEach(x => { x.innerHTML = '<div class="live-still"><span class="icon">play_circle</span></div>'; }); // a thumbnail doesn't run the demo
|
|
119
|
+
c.querySelectorAll('img').forEach(i => { i.loading = 'lazy'; i.decoding = 'async'; }); // pictures fetched and decoded as thumbnails come into view
|
|
120
|
+
inner.append(c); view.append(inner);
|
|
121
|
+
if (slideCSS(s)) { inner.dataset.cssScope = String(++thumbCSS); view.append(Object.assign(document.createElement('style'), { textContent: `.thumb-canvas[data-css-scope="${thumbCSS}"] > section { ${slideCSS(s)} }` })); }
|
|
122
|
+
if (s.dataset.visibility === 'hidden') { // reveal skips these; Slides veils the thumbnail behind a crossed-out eye
|
|
123
|
+
const veil = div('skipped'); veil.innerHTML = '<span class="icon">visibility_off</span>'; veil.dataset.tip = 'Skipped in slideshow'; view.append(veil);
|
|
124
|
+
}
|
|
125
|
+
thumbViews.set(s, { key, view });
|
|
126
|
+
return view;
|
|
127
|
+
}
|
|
128
|
+
const thumbEls = new WeakMap(), thumbSlide = new WeakMap(); // slide → its filmstrip entry, updated in place; and back
|
|
129
|
+
/* A redrawn thumbnail replaces the old one once its pictures are decoded (half a second at most), so the strip never
|
|
130
|
+
shows a slide without its pictures for a moment. The newest redraw wins if several are waiting. */
|
|
131
|
+
function swapView(t, view) {
|
|
132
|
+
const imgs = [...view.querySelectorAll('img')];
|
|
133
|
+
if (imgs.every(i => i.complete && i.naturalWidth)) return t.children[1].replaceWith(view); // no pictures, or already loaded
|
|
134
|
+
t.dataset.next = String((+(t.dataset.next || 0)) + 1); const mine = t.dataset.next;
|
|
135
|
+
imgs.forEach(i => { i.loading = 'eager'; });
|
|
136
|
+
Promise.race([Promise.all(imgs.map(i => i.decode().catch(() => {}))), new Promise(r => setTimeout(r, 500))])
|
|
137
|
+
.then(() => { if (t.dataset.next === mine && t.children[1] !== view) t.children[1]?.replaceWith(view); });
|
|
138
|
+
}
|
|
139
|
+
/* Stacks draw as a tree, as a file browser: sub-slides smaller and indented under a rail from their main slide, which
|
|
140
|
+
has a ▾ / ▸ to fold them (a folded main slide shows "+2"). Grid view keeps one cell per main slide, as reveal's
|
|
141
|
+
overview: a stack's sub-slides sit in its main slide's cell, below it, so only that row grows. The DOM stays one
|
|
142
|
+
thumbnail per slide in order (keys, drag). Folds are remembered per deck (localStorage), by main-slide number. */
|
|
143
|
+
const foldKey = () => 'slides-folded:' + deckPath;
|
|
144
|
+
const foldedH = () => { try { return new Set(JSON.parse(stored(foldKey()) || '[]')); } catch { return new Set(); } };
|
|
145
|
+
function setFolded(h, on) { const f = foldedH(); on ? f.add(h) : f.delete(h); store(foldKey(), f.size ? JSON.stringify([...f]) : undefined); }
|
|
146
|
+
const subsOf = i => { let n = 0; while (slides[i + n + 1] && isSub(i + n + 1)) n++; return n; };
|
|
147
|
+
function renderThumbs() {
|
|
148
|
+
picked = new Set([...picked].filter(s => slides.includes(s))); if (slides[cur]) picked.add(slides[cur]);
|
|
149
|
+
if (isSub(cur) && foldedH().has(hv(cur)[0])) setFolded(hv(cur)[0], false); // never hide the slide being edited
|
|
150
|
+
const fold = foldedH();
|
|
151
|
+
thumbs.style.setProperty("--grid-w", gridW + "px");
|
|
152
|
+
const cols = Math.max(1, Math.floor((thumbs.clientWidth - 80 + 24) / (gridW + 24)));
|
|
153
|
+
thumbs.style.setProperty('--cols', cols);
|
|
154
|
+
let hh = -1, vv = 0;
|
|
155
|
+
const live = new Set(slides), taken = new Set();
|
|
156
|
+
// a slide that is new to the strip (an undo's restored copy, a reload) takes over the entry of a slide that left at its
|
|
157
|
+
// place, so the old picture stays up until the new one is ready (swapView)
|
|
158
|
+
const reuse = i => { const o = thumbs.children[i]; return o && !live.has(thumbSlide.get(o)) && !taken.has(o) ? o : null; };
|
|
159
|
+
const els = slides.map((s, i) => {
|
|
160
|
+
if (isSub(i)) vv++; else { hh++; vv = 0; }
|
|
161
|
+
const t = thumbEls.get(s) || reuse(i) || div('thumb'); thumbEls.set(s, t); thumbSlide.set(t, s); taken.add(t);
|
|
162
|
+
const n = vv ? 0 : subsOf(i), folded = fold.has(hh);
|
|
163
|
+
t.className = 'thumb' + (i === cur ? ' active' : '') + (picked.has(s) ? ' picked' : '') + (vv ? ' sub' : '')
|
|
164
|
+
+ (vv && !(slides[i + 1] && isSub(i + 1)) ? ' stack-last' : '') + (vv && folded ? ' folded' : '');
|
|
165
|
+
t.style.gridColumn = gridOn ? (hh % cols) + 1 : ''; t.style.gridRow = gridOn ? Math.floor(hh / cols) + 1 : '';
|
|
166
|
+
t.style.setProperty('--v', vv);
|
|
167
|
+
const num = div('num'); num.textContent = s.dataset.edTheme ? '' : vv ? `${hh + 1}.${vv}` : String(hh + 1); // reveal's convention: main slide 3, its sub-slides 3.1, 3.2; theme slides unnumbered
|
|
168
|
+
if (n) {
|
|
169
|
+
const f = document.createElement('button'); f.className = 'fold icon'; f.textContent = folded ? 'arrow_right' : 'arrow_drop_down';
|
|
170
|
+
f.dataset.tip = folded ? `Show ${n} sub-slide${n > 1 ? 's' : ''}` : 'Hide sub-slides'; num.append(f);
|
|
171
|
+
if (folded) { const c = document.createElement('span'); c.className = 'fold-count'; c.textContent = '+' + n; num.append(c); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const trans = s.dataset.transition && s.dataset.transition !== 'none' || s.hasAttribute('data-auto-animate'), anims = s.querySelector('.fragment, [data-build]');
|
|
175
|
+
if (trans || anims) { // Slides marks slides with a transition or animated objects under the number
|
|
176
|
+
const m = document.createElement('span'); m.className = 'icon has-trans'; m.textContent = 'animation';
|
|
177
|
+
m.dataset.tip = trans && anims ? 'Transition and animations' : trans ? 'Transition' : 'Animations'; num.append(m);
|
|
178
|
+
}
|
|
179
|
+
const view = thumbView(s);
|
|
180
|
+
if (t.firstChild?.outerHTML !== num.outerHTML) (t.firstChild ? t.firstChild.replaceWith(num) : t.append(num));
|
|
181
|
+
if (t.children[1] !== view) (t.children[1] ? swapView(t, view) : t.append(view));
|
|
182
|
+
t.ondblclick = () => { if (gridOn) { setGrid(false); go(i); } }; // grid view: double-click opens the slide
|
|
183
|
+
t.onpointerdown = e => { if (e.target.closest('.fold')) return; if (e.button === 0 && !e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); startThumbDrag(e, i); } }; // no text selection while dragging
|
|
184
|
+
t.onclick = e => { // ⇧-click extends the slide selection, ⌘-click toggles; the filmstrip then has keyboard focus
|
|
185
|
+
if (e.target.closest('.fold')) { const h = hv(i)[0]; setFolded(h, !foldedH().has(h)); renderThumbs(); return; }
|
|
186
|
+
|
|
187
|
+
if (thumbDragged) { thumbDragged = false; return; }
|
|
188
|
+
|
|
189
|
+
if (e.shiftKey) extendTo(i); else if (e.metaKey || e.ctrlKey) toggleSlide(i); else go(i);
|
|
190
|
+
thumbs.focus({ preventScroll: true });
|
|
191
|
+
};
|
|
192
|
+
return t;
|
|
193
|
+
});
|
|
194
|
+
// only the entries that changed move: re-inserting every thumbnail made the whole strip blink
|
|
195
|
+
const keep = new Set(els);
|
|
196
|
+
[...thumbs.children].forEach(c => { if (!keep.has(c)) c.remove(); });
|
|
197
|
+
els.forEach((t, i) => { if (thumbs.children[i] !== t) thumbs.insertBefore(t, thumbs.children[i] || null); });
|
|
198
|
+
thumbs.children[cur]?.scrollIntoView({ block: 'nearest' });
|
|
199
|
+
drawPeople(); // who else is here, on which slide (people.js)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Overlay elements for a box inside groups live in frames that repeat the groups' position and rotation. */
|
|
203
|
+
function frameFor(b) {
|
|
204
|
+
const gs = []; for (let p = parentBox(b); p; p = parentBox(p)) gs.unshift(p);
|
|
205
|
+
let host = ui;
|
|
206
|
+
for (const g of gs) { const f = div('sel-frame'); place(f, geo(g)); f.style.transform = g.style.transform; f.style.scale = flipXY(g).join(' '); host.append(f); host = f; }
|
|
207
|
+
return host;
|
|
208
|
+
}
|
|
209
|
+
function drawOverlay() {
|
|
210
|
+
ui.replaceChildren(); queueRulers(); drawPeopleOn();
|
|
211
|
+
const k = 1 / scale;
|
|
212
|
+
if (scope?.isConnected) { // the entered group: a dashed outline, as Slides
|
|
213
|
+
const o = div('sel-box scope'); place(o, geo(scope)); o.style.transform = scope.style.transform; o.style.setProperty('--k', k + 'px'); frameFor(scope).append(o);
|
|
214
|
+
}
|
|
215
|
+
for (const b of sel) {
|
|
216
|
+
const o = div('sel-box' + (b === editing ? ' editing' : '') + (sel.size > 1 ? ' thin' : ''));
|
|
217
|
+
place(o, geo(b)); o.style.setProperty('--k', k + 'px'); // stroke = 1 screen px (box-shadow: borders get rounded)
|
|
218
|
+
o.style.transform = b.style.transform; o.style.scale = flipXY(b).join(' ');
|
|
219
|
+
frameFor(b).append(o);
|
|
220
|
+
if (isGroup(b) && b !== editing) for (const m of kids(b)) { // Slides outlines each member of a selected group
|
|
221
|
+
const t = div('sel-box thin'); place(t, geo(m)); t.style.setProperty('--k', k + 'px'); t.style.transform = m.style.transform; frameFor(m).append(t);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (editing || !sel.size) return;
|
|
225
|
+
if (sel.size > 1) { // Slides: one box with resize handles around the whole selection (no rotation knob)
|
|
226
|
+
const g = bbox([...sel].map(aabb)), wrap = div('sel-wrap'), box = div('sel-box');
|
|
227
|
+
place(wrap, g); place(box, { x: 0, y: 0, w: g.w, h: g.h }); box.style.setProperty('--k', k + 'px'); wrap.append(box);
|
|
228
|
+
const cur = { n: 'ns', s: 'ns', e: 'ew', w: 'ew', ne: 'nesw', sw: 'nesw', nw: 'nwse', se: 'nwse' };
|
|
229
|
+
addHandles(wrap, g, ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'], d => cur[d] + '-resize', k, false);
|
|
230
|
+
ui.append(wrap); return;
|
|
231
|
+
}
|
|
232
|
+
const b = [...sel][0], g = geo(b), sz = 10 * k; // Slides: 10px round corner handles, pill-shaped side handles
|
|
233
|
+
const wrap = div('sel-wrap'); place(wrap, g); wrap.style.transform = b.style.transform; wrap.style.scale = flipXY(b).join(' '); // handles rotate and flip with the box
|
|
234
|
+
const dirs = !isLine(b) ? ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] : b.dataset.dir === 'up' ? ['sw', 'ne'] : ['nw', 'se'];
|
|
235
|
+
if (cropping === b) { // Slides' crop marks: black L corners and bars, dragged to cut the picture
|
|
236
|
+
for (const d of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
|
|
237
|
+
const h = div('handle crop ' + d); h.dataset.dir = d; h.dataset.crop = '';
|
|
238
|
+
const hx = d.includes('w') ? 0 : d.includes('e') ? g.w : g.w / 2, hy = d.includes('n') ? 0 : d.includes('s') ? g.h : g.h / 2, L = 16 * k;
|
|
239
|
+
h.style.cssText = `left:${hx - (d.includes('w') ? 0 : d.includes('e') ? L : L / 2)}px;top:${hy - (d.includes('n') ? 0 : d.includes('s') ? L : L / 2)}px;width:${L}px;height:${L}px;--k:${k}px;cursor:${resizeCursor(b, d)}`;
|
|
240
|
+
wrap.append(h);
|
|
241
|
+
}
|
|
242
|
+
frameFor(b).append(wrap); return;
|
|
243
|
+
}
|
|
244
|
+
addHandles(wrap, g, dirs, d => resizeCursor(b, d), k, isLine(b));
|
|
245
|
+
if (isLine(b)) [b.dataset.from, b.dataset.to].forEach((at, i) => at && wrap.querySelector(`.handle[data-dir="${dirs[i]}"]`)?.classList.add('linked')); // attached ends: filled, as Slides
|
|
246
|
+
const host = frameFor(b);
|
|
247
|
+
(shapeAdjust(b) || []).forEach((adj, i) => { // Slides' yellow diamonds, where the OOXML preset puts its handles
|
|
248
|
+
const a = div('handle adjust'), as = 10 * k; a.dataset.i = i;
|
|
249
|
+
a.style.cssText = `left:${adj.x - as / 2}px;top:${adj.y - as / 2}px;width:${as}px;height:${as}px;--k:${k}px`;
|
|
250
|
+
wrap.append(a);
|
|
251
|
+
});
|
|
252
|
+
if (!isLine(b)) {
|
|
253
|
+
const L = (PHONE ? 33 : 18) * k, rs = (PHONE ? 15 : 16) * k; // a 16 px filled knob with a ↻ glyph on an 18 px stem (the phone: 15 px, centred 40 above)
|
|
254
|
+
const stem = div('rot-stem'); stem.style.cssText = `left:${g.w / 2 - k / 2}px;top:${-L}px;width:${k}px;height:${L}px`;
|
|
255
|
+
const r = div('handle rotate');
|
|
256
|
+
r.style.cssText = `left:${g.w / 2 - rs / 2}px;top:${-L - rs}px;width:${rs}px;height:${rs}px;--k:${k}px;font-size:${12 * k}px`;
|
|
257
|
+
r.innerHTML = '<span class="icon">refresh</span>';
|
|
258
|
+
wrap.append(stem, r);
|
|
259
|
+
}
|
|
260
|
+
host.append(wrap);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function addHandles(wrap, g, dirs, cursor, k, line) {
|
|
264
|
+
const sz = 12 * k; // measured on Google Slides: ~13 px corner circles, 22 × 8 px side pills
|
|
265
|
+
// a box too small on screen for the middle handles as well as room to grab it: those go, as in Slides (corners resize)
|
|
266
|
+
if (!line) dirs = dirs.filter(d => !(('ns'.includes(d) && d.length === 1 && g.w / k < 50) || ('ew'.includes(d) && d.length === 1 && g.h / k < 40)));
|
|
267
|
+
for (const d of dirs) {
|
|
268
|
+
const hx = d.includes('w') ? 0 : d.includes('e') ? g.w : g.w / 2;
|
|
269
|
+
const hy = d.includes('n') ? 0 : d.includes('s') ? g.h : g.h / 2;
|
|
270
|
+
const side = d.length === 1 && !line && !PHONE, [w, h2] = !side ? [sz, sz] : 'ns'.includes(d) ? [22 * k, 8 * k] : [8 * k, 22 * k];
|
|
271
|
+
const h = div('handle' + (side ? ' side' : '') + (busy && d === dragDir ? ' on' : '')); h.dataset.dir = d; // the one being dragged is solid
|
|
272
|
+
h.style.cssText = `left:${hx - w / 2}px;top:${hy - h2 / 2}px;width:${w}px;height:${h2}px;--k:${k}px;cursor:${cursor(d)}`;
|
|
273
|
+
wrap.append(h);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* Pink alignment lines, as in Slides. When a line comes from another box (ox/oy), also show the
|
|
278
|
+
gap between that box and the moving one (m) along the line, with its distance in px. */
|
|
279
|
+
/* Slides' alignment lines: 1 px, dark red, running from the farthest box on that line to the moving one (across the
|
|
280
|
+
slide only for the slide's own edges / centre and user guides, which have no box); no distance numbers. */
|
|
281
|
+
function showGuides(gx, gy, m, ox, oy, others = []) {
|
|
282
|
+
guides.querySelectorAll('.guide, .measure, .measure-label, .space').forEach(e => e.remove());
|
|
283
|
+
const k = 1 / scale, on = (o, key, size, at) => [o[key], o[key] + o[size] / 2, o[key] + o[size]].some(v => Math.abs(v - at) < 0.5);
|
|
284
|
+
const span = (at, key, size, ckey, csize) => { // the extent along the line of every box sitting on it, plus m
|
|
285
|
+
const hit = m ? others.filter(o => on(o, key, size, at)).concat([m]) : [];
|
|
286
|
+
return hit.length > 1 ? [Math.min(...hit.map(o => o[ckey])), Math.max(...hit.map(o => o[ckey] + o[csize]))] : null;
|
|
287
|
+
};
|
|
288
|
+
if (gx != null) { const sp = span(gx, 'x', 'w', 'y', 'h') || [0, H], l = div('guide'); place(l, { x: gx - k / 2, y: sp[0], w: k, h: sp[1] - sp[0] }); guides.append(l); }
|
|
289
|
+
if (gy != null) { const sp = span(gy, 'y', 'h', 'x', 'w') || [0, W], l = div('guide'); place(l, { x: sp[0], y: gy - k / 2, w: sp[1] - sp[0], h: k }); guides.append(l); }
|
|
290
|
+
}
|
|
291
|
+
function sizeMark(o, key) { // a teal bracket along the bottom (width) or right side (height) of a box of matching size
|
|
292
|
+
const k = 1 / scale, r = g => { const e = div('space'); place(e, g); guides.append(e); };
|
|
293
|
+
if (key === 'w') { const y = o.y + o.h + 10 * k; r({ x: o.x, y: y - k / 2, w: o.w, h: k }); r({ x: o.x - k / 2, y: y - 4 * k, w: k, h: 8 * k }); r({ x: o.x + o.w - k / 2, y: y - 4 * k, w: k, h: 8 * k }); }
|
|
294
|
+
else { const x = o.x + o.w + 10 * k; r({ x: x - k / 2, y: o.y, w: k, h: o.h }); r({ x: x - 4 * k, y: o.y - k / 2, w: 8 * k, h: k }); r({ x: x - 4 * k, y: o.y + o.h - k / 2, w: 8 * k, h: k }); }
|
|
295
|
+
}
|
|
296
|
+
function measure(dir, at, a0, a1, b0, b1) {
|
|
297
|
+
const [from, to] = a0 >= b1 ? [b1, a0] : b0 >= a1 ? [a1, b0] : [];
|
|
298
|
+
if (from == null || to - from < 1) return;
|
|
299
|
+
const k = 1 / scale, line = div('measure'), label = div('measure-label');
|
|
300
|
+
place(line, dir === 'v' ? { x: at - k / 2, y: from, w: k, h: to - from } : { x: from, y: at - k / 2, w: to - from, h: k });
|
|
301
|
+
label.textContent = String(Math.round(to - from));
|
|
302
|
+
label.style.cssText = `font-size:${11 * k}px;padding:${1 * k}px ${4 * k}px;border-radius:${2 * k}px;` +
|
|
303
|
+
(dir === 'v' ? `left:${at + 4 * k}px;top:${(from + to) / 2}px;translate:0 -50%` : `left:${(from + to) / 2}px;top:${at + 4 * k}px;translate:-50% 0`);
|
|
304
|
+
const caps = [from, to].map(t => { const c = div('measure'); place(c, dir === 'v' ? { x: at - 4 * k, y: t - k / 2, w: 8 * k, h: k } : { x: t - k / 2, y: at - 4 * k, w: k, h: 8 * k }); return c; });
|
|
305
|
+
guides.append(line, ...caps, label);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const toHex = (() => {
|
|
309
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
310
|
+
return (v, def) => {
|
|
311
|
+
if (!v || v === 'transparent' || v === 'none') return def;
|
|
312
|
+
ctx.fillStyle = def; ctx.fillStyle = v.trim();
|
|
313
|
+
const f = String(ctx.fillStyle), m = f.match(/^rgba\((\d+), (\d+), (\d+)/); // translucent colours come back as rgba(): keep the colour
|
|
314
|
+
return f.startsWith('#') ? f : m ? '#' + m.slice(1, 4).map(n => (+n).toString(16).padStart(2, '0')).join('') : def;
|
|
315
|
+
};
|
|
316
|
+
})();
|
|
317
|
+
/* Alpha (0..1) of a CSS colour, and a colour with a new alpha as #rrggbbaa (#rrggbb when opaque). */
|
|
318
|
+
const alphaOf = v => {
|
|
319
|
+
v = (v || '').trim();
|
|
320
|
+
const hex = v.match(/^#[0-9a-f]{6}([0-9a-f]{2})$/i); if (hex) return parseInt(hex[1], 16) / 255;
|
|
321
|
+
const rgba = v.match(/rgba\([^)]*,\s*([\d.]+)\)/); return rgba ? +rgba[1] : 1;
|
|
322
|
+
};
|
|
323
|
+
const withAlpha = (hex, a) => a >= 1 ? hex : hex + Math.round(a * 255).toString(16).padStart(2, '0');
|
|
324
|
+
|
|
325
|
+
function updateToolbar() {
|
|
326
|
+
const b = [...sel][0];
|
|
327
|
+
document.body.classList.toggle('has-sel', !!b);
|
|
328
|
+
// As Slides, images and lines have no text or fill controls, and a mixed selection shows only what all kinds share
|
|
329
|
+
const kind = x => isStroke(x) ? 'line' : isMedia(x) ? 'image' : isGroup(x) ? 'group' : 'text', kinds = new Set([...sel].map(kind));
|
|
330
|
+
document.body.classList.toggle('sel-notext', !!b && (!kinds.has('text') || kinds.size > 1));
|
|
331
|
+
document.body.classList.toggle('sel-line', !!b && [...sel].every(isLine));
|
|
332
|
+
document.body.classList.toggle('sel-image', sel.size === 1 && !!b?.classList.contains('image')); // Replace image button // line start / end buttons
|
|
333
|
+
layoutToolbar();
|
|
334
|
+
$('#ed-undo').disabled = hptr <= 0;
|
|
335
|
+
$('#ed-redo').disabled = hptr >= hist.length - 1;
|
|
336
|
+
$('#ed-bg').value = toHex(slides[cur]?.dataset.backgroundColor, '#ffffff');
|
|
337
|
+
if (!b) return;
|
|
338
|
+
if (isGroup(b)) { paintBars(); updatePanel(); renderAnims?.(); return; } // a group: no text or fill state
|
|
339
|
+
const c = content(b), cs = getComputedStyle(c);
|
|
340
|
+
$('#ed-fill').value = toHex(b.style.getPropertyValue('--fill'), '#4a86e8');
|
|
341
|
+
$('#ed-stroke').value = toHex(b.style.getPropertyValue('--stroke'), '#333333');
|
|
342
|
+
$('#ed-color').value = toHex(cs.color, '#222222');
|
|
343
|
+
paintBars();
|
|
344
|
+
updatePanel();
|
|
345
|
+
renderAnims?.();
|
|
346
|
+
showTextStyle(b);
|
|
347
|
+
$('#ed-bold').classList.toggle('on', c.style.fontWeight === 'bold');
|
|
348
|
+
$('#ed-italic').classList.toggle('on', c.style.fontStyle === 'italic');
|
|
349
|
+
$('#ed-underline').classList.toggle('on', c.style.textDecoration === 'underline');
|
|
350
|
+
$('#ed-ul').classList.toggle('on', !!c.querySelector(':scope > ul'));
|
|
351
|
+
$('#ed-ol').classList.toggle('on', !!c.querySelector(':scope > ol'));
|
|
352
|
+
$('#ed-align .icon').textContent = ALIGNS.find(a => a[0] === curAlign())?.[1] || 'format_align_left';
|
|
353
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* ---------- keyboard shortcuts dialog (⌘/) ---------- */
|
|
2
|
+
|
|
3
|
+
/* Grouped as Slides' dialog; lists only what this editor does. */
|
|
4
|
+
const SHORTCUTS = {
|
|
5
|
+
'Common actions': [['Search the menus', '⌥/'], ['Undo', '⌘Z'], ['Redo', '⇧⌘Z or ⌘Y'], ['Copy', '⌘C'], ['Cut', '⌘X'], ['Paste', '⌘V'], ['Paste without formatting', '⇧⌘V'],
|
|
6
|
+
['Copy formatting', '⌥⌘C'], ['Paste formatting', '⌥⌘V'], ['Duplicate (slide when the filmstrip has focus)', '⌘D'], ['Delete', '⌫'],
|
|
7
|
+
['Select all', '⌘A'], ['Insert or edit link', '⌘K'], ['New slide', '⌃M or ⌘M'], ['New sub-slide below', '⇧⌘M'], ['Find', '⌘F'], ['Find and replace', '⇧⌘H'],
|
|
8
|
+
['Save', '⌘S'], ['Print', '⌘P'], ['Hide menus', '⌃⇧F'], ['Show speaker notes', '⌥⇧⌘S'], ['Show spelling suggestions', '⌥⌘X'], ['Grid view', '⌥⌘1'], ['Insert comment', '⌥⌘M'], ['Keyboard shortcuts', '⌘/']],
|
|
9
|
+
'Text formatting': [['Bold', '⌘B'], ['Italic', '⌘I'], ['Underline', '⌘U'], ['Strikethrough', '⇧⌘X'], ['Superscript', '⌘.'],
|
|
10
|
+
['Subscript', '⌘,'], ['Increase font size', '⇧⌘.'], ['Decrease font size', '⇧⌘,'], ['Clear formatting', '⌘\\']],
|
|
11
|
+
'Paragraph formatting': [['Bulleted list', '⇧⌘8'], ['Numbered list', '⇧⌘7'], ['Increase / decrease indent', '⌘] / ⌘['], ['Indent in a list', 'Tab / ⇧Tab'],
|
|
12
|
+
['Align left / center / right', '⇧⌘L / ⇧⌘E / ⇧⌘R'], ['Justify', '⇧⌘J']],
|
|
13
|
+
'Objects': [['Select next / previous object', 'Tab / ⇧Tab'], ['Bring forward / send backward', '⌘↑ / ⌘↓'],
|
|
14
|
+
['Rotate 15° / 1°', '⌥← ⌥→ / ⇧⌥← ⇧⌥→'], ['Larger horizontally / vertically / both', '⌃⌘B / ⌃⌘I / ⌃⌘K'],
|
|
15
|
+
['Smaller horizontally / both', '⌃⌘W / ⌃⌘J'], ['Resize from the centre', '⌥ + resize']],
|
|
16
|
+
'Actions': [['Edit text in the selected box', '↵'], ['Finish editing / deselect', 'Esc'], ['Nudge', '← ↑ → ↓'], ['Nudge 10 px', '⇧ + arrows'],
|
|
17
|
+
['Group / ungroup', '⌥⌘G / ⇧⌥⌘G'], ['Bring to front', '⇧⌘↑'], ['Send to back', '⇧⌘↓'], ['Constrain a drag', '⇧ + drag'], ['Drag a copy', '⌥ + drag'],
|
|
18
|
+
['Drag without snapping', '⌘ + drag'], ['Rotate in 15° steps', '⇧ + rotate'], ['Resize freely / keep proportions', '⇧ + resize']],
|
|
19
|
+
'Filmstrip (click a slide first)': [['Previous / next slide', '↑ ↓ or PgUp PgDn'], ['First / last slide', 'Home / End'],
|
|
20
|
+
['Move slides up / down', '⌘↑ or ⌘← / ⌘↓ or ⌘→'], ['Move slides to beginning / end', '⇧⌘↑ or ⇧⌘← / ⇧⌘↓ or ⇧⌘→'],
|
|
21
|
+
['Extend / toggle slide selection', '⇧-click, ⇧↑↓ / ⌘-click'],
|
|
22
|
+
['Delete / duplicate selected slides', '⌫ / ⌘D'], ['Select all slides', '⌘A'], ['Back to the slide / to the filmstrip', '⏎ / Esc'], ['Move to filmstrip', '⌥⇧⌘F']],
|
|
23
|
+
'Navigation': [['Next / previous slide (nothing selected)', '↓ / ↑'], ['Next / previous slide', 'Page Down / Page Up'],
|
|
24
|
+
['Zoom in', '⌘+'], ['Zoom out', '⌘−'], ['Fit', '⌘0']],
|
|
25
|
+
'Presenting': [['Present from this slide', '⌘↵'], ['Present from beginning', '⇧⌘↵'], ['Next / previous slide', '→ / ← or N / P'], ['Up / down in a stack', '↓ / ↑'],
|
|
26
|
+
['Next / back in reading order', 'Space / ⇧Space or PgDn / PgUp'], ['First / last slide', 'Home / End'], ['Go to slide n', 'n then ↵'],
|
|
27
|
+
['Black / white screen', 'B / W'], ['Laser pointer', 'L'], ['Full screen', 'F'], ['Speaker view', 'S'], ['Note for the AI (presenting from the editor)', 'M'], ['Exit', 'Esc']],
|
|
28
|
+
};
|
|
29
|
+
/* Keys drawn as keycaps, Google-style: ⇧⌘V → [⌘][Shift][V]; alternatives joined with "or"; "+ drag" stays text. */
|
|
30
|
+
function keycaps(k) {
|
|
31
|
+
const esc = t => t.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
32
|
+
const cap = t => `<kbd>${esc(t)}</kbd>`;
|
|
33
|
+
const combo = t => {
|
|
34
|
+
if (/ \+ /.test(t)) return t.split(' + ').map(x => /^[⌘⌃⌥⇧]+$/.test(x) ? combo(x) : esc(x)).join(' + ');
|
|
35
|
+
if (/^[A-Za-z][a-z]+( [A-Za-z]+)?$/.test(t) && !/^[A-Z]$/.test(t)) return cap(t); // Esc, Tab, Home, Page Down
|
|
36
|
+
const mods = ['⌘', '⌃', '⌥', '⇧'].filter(m => t.includes(m)).map(m => ({ '⌘': '⌘', '⌃': 'Ctrl', '⌥': 'Option', '⇧': 'Shift' })[m]);
|
|
37
|
+
const rest = t.replace(/[⌘⌃⌥⇧]/g, '').replace('↵', 'Enter').trim();
|
|
38
|
+
return [...mods, ...(rest ? rest.split(' ') : [])].map(cap).join('');
|
|
39
|
+
};
|
|
40
|
+
return k.split(/ or | \/ /).map(t => t.split(' then ').map(combo).join(' then ')).join(' <span class="or">or</span> ');
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
const shortcutsDlg = $('#ed-shortcuts');
|
|
44
|
+
function renderShortcuts(q = '') {
|
|
45
|
+
q = q.trim().toLowerCase();
|
|
46
|
+
const body = shortcutsDlg.querySelector('.sc-body');
|
|
47
|
+
body.replaceChildren(...Object.entries(SHORTCUTS).flatMap(([cat, rows]) => {
|
|
48
|
+
const hits = rows.filter(([label, key]) => !q || label.toLowerCase().includes(q) || key.toLowerCase().includes(q) || cat.toLowerCase().includes(q));
|
|
49
|
+
if (!hits.length) return [];
|
|
50
|
+
const sec = div('sc-section'), h = document.createElement('h3'); h.textContent = cat; sec.append(h);
|
|
51
|
+
for (const [label, key] of hits) {
|
|
52
|
+
const r = div('sc-row'), l = document.createElement('span'), k = document.createElement('span'); k.className = 'keys';
|
|
53
|
+
l.textContent = label; k.innerHTML = keycaps(key); r.append(l, k); sec.append(r);
|
|
54
|
+
}
|
|
55
|
+
return [sec];
|
|
56
|
+
}));
|
|
57
|
+
if (!body.children.length) { const e = div('sc-empty'); e.textContent = 'No shortcuts match'; body.append(e); }
|
|
58
|
+
}
|
|
59
|
+
function showShortcuts(on) {
|
|
60
|
+
closeMenu();
|
|
61
|
+
shortcutsDlg.hidden = !on;
|
|
62
|
+
if (on) { const i = shortcutsDlg.querySelector('input'); i.value = ''; renderShortcuts(); i.focus(); }
|
|
63
|
+
}
|
|
64
|
+
shortcutsDlg.querySelector('input').oninput = e => renderShortcuts(e.target.value);
|
|
65
|
+
shortcutsDlg.querySelector('.sc-close').onclick = () => showShortcuts(false);
|
|
66
|
+
shortcutsDlg.addEventListener('pointerdown', e => { if (e.target === shortcutsDlg) showShortcuts(false); });
|
|
67
|
+
addEventListener('keydown', e => { if (!shortcutsDlg.hidden && e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); showShortcuts(false); } }, true);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* ---------- slides ---------- */
|
|
2
|
+
|
|
3
|
+
/* Slides picked in the filmstrip (always including the current one); slide operations act on all of them. */
|
|
4
|
+
let picked = new Set(), slideAnchor = 0;
|
|
5
|
+
function go(i, keepPicked) {
|
|
6
|
+
stopEdit();
|
|
7
|
+
cur = Math.max(0, Math.min(slides.length - 1, i));
|
|
8
|
+
if (!keepPicked) { picked = new Set([slides[cur]]); slideAnchor = cur; }
|
|
9
|
+
sel.clear(); render();
|
|
10
|
+
}
|
|
11
|
+
const pickedIdx = () => slides.map((s, i) => picked.has(s) ? i : -1).filter(i => i >= 0);
|
|
12
|
+
function extendTo(i) {
|
|
13
|
+
i = Math.max(0, Math.min(slides.length - 1, i));
|
|
14
|
+
const [a, b] = [Math.min(slideAnchor, i), Math.max(slideAnchor, i)];
|
|
15
|
+
picked = new Set(slides.slice(a, b + 1)); go(i, true);
|
|
16
|
+
}
|
|
17
|
+
function toggleSlide(i) {
|
|
18
|
+
const s = slides[i];
|
|
19
|
+
if (picked.has(s) && picked.size > 1) { picked.delete(s); go(slides.indexOf([...picked][0]), true); }
|
|
20
|
+
else { picked.add(s); slideAnchor = i; go(i, true); }
|
|
21
|
+
}
|
|
22
|
+
/* Drag thumbnails to reorder, as Slides: the picked slides (or the one grabbed) follow the pointer as a ghost and
|
|
23
|
+
drop between thumbnails at the blue line. Dropped between two slides of one stack, they join it. */
|
|
24
|
+
let thumbDragged = false;
|
|
25
|
+
function startThumbDrag(e, i) {
|
|
26
|
+
const t0 = thumbs.children[i], x0 = e.clientX, y0 = e.clientY;
|
|
27
|
+
let ghost = null, line = null, at = null;
|
|
28
|
+
const move = ev => {
|
|
29
|
+
if (!ghost && Math.hypot(ev.clientX - x0, ev.clientY - y0) < 5) return;
|
|
30
|
+
if (!ghost) {
|
|
31
|
+
if (!picked.has(slides[i])) { picked = new Set([slides[i]]); }
|
|
32
|
+
ghost = t0.querySelector('.thumb-view').cloneNode(true); ghost.className += ' thumb-ghost'; document.body.append(ghost);
|
|
33
|
+
line = div('thumb-drop'); thumbs.append(line); document.body.classList.add('thumb-dragging'); t0.classList.add('dragged');
|
|
34
|
+
}
|
|
35
|
+
Object.assign(ghost.style, { left: ev.clientX - 40 + 'px', top: ev.clientY - 20 + 'px' });
|
|
36
|
+
const ts = [...thumbs.querySelectorAll('.thumb')];
|
|
37
|
+
at = ts.findIndex(t => { const r = t.getBoundingClientRect(); return gridOn ? ev.clientY < r.bottom && (ev.clientY < r.top || ev.clientX < r.left + r.width / 2) : ev.clientY < r.top + r.height / 2; }); if (at < 0) at = ts.length;
|
|
38
|
+
const ref = ts[at] || ts.at(-1), r = ref.getBoundingClientRect(), tr = thumbs.getBoundingClientRect();
|
|
39
|
+
if (gridOn) { // a vertical line before the target thumbnail (or after the last)
|
|
40
|
+
Object.assign(line.style, { left: (ts[at] ? r.left - 8 : r.right + 8) - tr.left + 'px', top: r.top - tr.top + thumbs.scrollTop + 'px', height: r.height + 'px' });
|
|
41
|
+
} else line.style.top = (ts[at] ? r.top - 8 : r.bottom + 8) - tr.top + thumbs.scrollTop + 'px';
|
|
42
|
+
};
|
|
43
|
+
const cancel = e => { if (e.key !== 'Escape' || !ghost) return; e.preventDefault(); e.stopImmediatePropagation(); end(); ghost = null; }; // Esc drops nothing
|
|
44
|
+
const end = () => { removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('keydown', cancel, true);
|
|
45
|
+
if (ghost) { ghost.remove(); line.remove(); document.body.classList.remove('thumb-dragging'); t0.classList.remove('dragged'); } };
|
|
46
|
+
addEventListener('keydown', cancel, true);
|
|
47
|
+
const up = () => {
|
|
48
|
+
removeEventListener('pointermove', move); removeEventListener('pointerup', up); removeEventListener('keydown', cancel, true);
|
|
49
|
+
if (!ghost) return;
|
|
50
|
+
ghost.remove(); line.remove(); document.body.classList.remove('thumb-dragging'); t0.classList.remove('dragged'); thumbDragged = true; setTimeout(() => { thumbDragged = false; }); // only the click that ends this drag is ignored
|
|
51
|
+
dropSlides(at);
|
|
52
|
+
};
|
|
53
|
+
addEventListener('pointermove', move); addEventListener('pointerup', up);
|
|
54
|
+
}
|
|
55
|
+
function dropSlides(at) {
|
|
56
|
+
slideOp(() => {
|
|
57
|
+
const block = slides.filter(s => picked.has(s)), keep = slides[cur];
|
|
58
|
+
const before = slides.slice(0, at).filter(s => !picked.has(s)), after = slides.slice(at).filter(s => !picked.has(s));
|
|
59
|
+
const stack = before.at(-1)?.dataset.edStack;
|
|
60
|
+
block.forEach(s => { if (stack && after[0]?.dataset.edStack === stack) s.dataset.edStack = stack; else delete s.dataset.edStack; });
|
|
61
|
+
slides = [...before, ...block, ...after];
|
|
62
|
+
cur = slides.indexOf(picked.has(keep) ? keep : block[0]);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/* View → Grid view (⌘⌥1), as Slides: every slide as a large thumbnail in a grid in place of the slide editor; click / ⌘ / ⇧
|
|
66
|
+
select, drag reorders, double-click opens; − / + at the bottom change the size. */
|
|
67
|
+
let gridOn = false, gridW = 300;
|
|
68
|
+
function setGrid(on) {
|
|
69
|
+
stopEdit(); gridOn = on; document.body.classList.toggle('grid-mode', on);
|
|
70
|
+
$('#ed-grid-toggle .icon').textContent = on ? 'view_sidebar' : 'grid_view'; $('#ed-grid-toggle').title = on ? 'Back to the slide (⌘⌥1)' : 'Grid view (⌘⌥1)';
|
|
71
|
+
renderThumbs(); if (!on) fit();
|
|
72
|
+
}
|
|
73
|
+
$('#ed-grid-toggle').onclick = () => setGrid(!gridOn);
|
|
74
|
+
addEventListener('resize', () => { if (gridOn) renderThumbs(); }); // the grid's column count
|
|
75
|
+
|
|
76
|
+
$('#ed-grid-in').onclick = () => { gridW = Math.min(520, gridW + 60); renderThumbs(); };
|
|
77
|
+
$('#ed-grid-out').onclick = () => { gridW = Math.max(160, gridW - 60); renderThumbs(); };
|
|
78
|
+
addEventListener('keydown', e => { if (e.metaKey && e.altKey && e.code === 'Digit1') { e.preventDefault(); setGrid(!gridOn); } }, true);
|
|
79
|
+
|
|
80
|
+
function slideOp(fn) { stopEdit(); fn(); sel.clear(); render(); commit(); }
|
|
81
|
+
function dupSlides() {
|
|
82
|
+
slideOp(() => {
|
|
83
|
+
const idx = pickedIdx(), copies = idx.map(i => { const c = slides[i].cloneNode(true); boxes(c).forEach(b => { b.dataset.morph ||= b.id; b.id = uid(); }); return c; }); // data-morph: Morph pairs each copy with its original
|
|
84
|
+
slides.splice(idx.at(-1) + 1, 0, ...copies);
|
|
85
|
+
cur = slides.indexOf(copies.at(-1)); picked = new Set(copies);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function deleteSlides() {
|
|
89
|
+
slideOp(() => {
|
|
90
|
+
const idx = pickedIdx(); if (idx.length >= slides.length) idx.shift(); // always keep one slide
|
|
91
|
+
if (!idx.length) return;
|
|
92
|
+
idx.reverse().forEach(i => { slides[i].remove(); slides.splice(i, 1); });
|
|
93
|
+
cur = Math.min(idx.at(-1), slides.length - 1); picked = new Set([slides[cur]]);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/* Move the picked slides as a block: 'up' / 'down' by one, or to the 'start' / 'end'. */
|
|
97
|
+
function moveSlides(to) {
|
|
98
|
+
slideOp(() => {
|
|
99
|
+
const idx = pickedIdx(), block = idx.map(i => slides[i]), rest = slides.filter(s => !picked.has(s)), first = idx[0];
|
|
100
|
+
const at = { up: first - 1, down: first + 1, start: 0, end: rest.length }[to];
|
|
101
|
+
const keep = slides[cur];
|
|
102
|
+
slides = [...rest.slice(0, Math.max(0, Math.min(rest.length, at))), ...block, ...rest.slice(Math.max(0, Math.min(rest.length, at)))];
|
|
103
|
+
cur = slides.indexOf(keep);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* A new main slide after the current stack: Title and body after a Title slide, else the current slide's layout (as
|
|
108
|
+
Slides); the toolbar's + ▾ and Slide → New slide with layout pick one. */
|
|
109
|
+
function newSlideWith(layout) {
|
|
110
|
+
slideOp(() => { let i = cur; while (slides[i + 1] && isSub(i + 1)) i++; slides.splice(i + 1, 0, blankSlide(layout)); cur = i + 1; picked = new Set([slides[cur]]); }); // only the new slide is picked
|
|
111
|
+
}
|
|
112
|
+
$('#ed-new-slide').onclick = () => { const l = slides[cur]?.dataset.layout; newSlideWith(l === 'title' ? 'title-body' : l); };
|
|
113
|
+
const newSlideItems = () => LAYOUTS.map(([name, label]) => ({ label, run: () => newSlideWith(name) }));
|
|
114
|
+
function newSlideBelow() { // ⇧⌘M: a sub-slide under the current one (reveal.js vertical stack; not in Google Slides)
|
|
115
|
+
slideOp(() => { const s = slides[cur], n = blankSlide(); s.dataset.edStack ||= uid(); n.dataset.edStack = s.dataset.edStack; slides.splice(cur + 1, 0, n); cur++; picked = new Set([n]); });
|
|
116
|
+
}
|
|
117
|
+
$('#ed-dup-slide').onclick = dupSlides;
|
|
118
|
+
$('#ed-del-slide').onclick = deleteSlides;
|
|
119
|
+
$('#ed-up-slide').onclick = () => moveSlides('up');
|
|
120
|
+
$('#ed-down-slide').onclick = () => moveSlides('down');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const noSel = () => !sel.size, noText = () => !sel.size && !editing;
|
|
2
|
+
/* Line spacing as Slides means it: "Single" is the font's natural line height (about 1.15 × the size for Arial),
|
|
3
|
+
so Slides' value v is CSS line-height v × 1.15. Older decks keep their CSS values; the menu ticks the nearest. */
|
|
4
|
+
const NATURAL_LH = 1.15;
|
|
5
|
+
const lhItems = () => /** @type {any[]} */ (/** @type {[number, string][]} */ ([[1, 'Single'], [1.15, '1.15'], [1.5, '1.5'], [2, 'Double']]).map(([v, label]) => ({
|
|
6
|
+
label, on: () => Math.abs(parseFloat(curLh()) / NATURAL_LH - v) < 0.04, off: noSel,
|
|
7
|
+
run: () => { sel.forEach(b => { content(b).style.lineHeight = String(Math.round(v * NATURAL_LH * 1000) / 1000); fitHeight(b); }); commit(); },
|
|
8
|
+
}))).concat('-', ['before', 'after'].map(side => ({
|
|
9
|
+
label: `${spaceOn(side) ? 'Remove' : 'Add'} space ${side} paragraph`, off: noSel,
|
|
10
|
+
run: () => { const on = spaceOn(side); sel.forEach(b => { const c = content(b); blockify(c); if (on) c.style.removeProperty(`--space-${side}`); else c.style.setProperty(`--space-${side}`, PARA_SPACE); fitHeight(b); }); commit(); },
|
|
11
|
+
})), '-', { label: 'Custom spacing', run: () => showSpaceDialog(true), off: noSel });
|
|
12
|
+
/* Paragraph spacing, as Slides' "Add space before/after paragraph": 10pt on every paragraph of the box, stored as
|
|
13
|
+
--space-before / --space-after on .content (base.css turns them into block margins). */
|
|
14
|
+
const PARA_SPACE = `${Math.round(10 * PT * 100) / 100}px`;
|
|
15
|
+
const spaceOn = side => !!([...sel][0] && content([...sel][0]).style.getPropertyValue(`--space-${side}`));
|
|
16
|
+
/* Bare text before the first block (contenteditable leaves the first line unwrapped) becomes a <div> so it is a paragraph too. */
|
|
17
|
+
function blockify(c) {
|
|
18
|
+
const lead = [];
|
|
19
|
+
for (const n of c.childNodes) { if (n.nodeType === 1 && getComputedStyle(n).display !== 'inline') break; lead.push(n); }
|
|
20
|
+
if (!lead.some(n => n.textContent.trim())) return;
|
|
21
|
+
const d = document.createElement('div'); c.insertBefore(d, lead[0]); lead.forEach(n => d.appendChild(n));
|
|
22
|
+
if (d.lastChild?.nodeName === 'BR' && d.nextSibling) d.lastChild.remove();
|
|
23
|
+
}
|