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,153 @@
|
|
|
1
|
+
/* ---------- geometry ---------- */
|
|
2
|
+
|
|
3
|
+
function geo(b) {
|
|
4
|
+
const s = b.style;
|
|
5
|
+
return { x: parseFloat(s.left) || 0, y: parseFloat(s.top) || 0, w: parseFloat(s.width) || 0, h: parseFloat(s.height) || 0 };
|
|
6
|
+
}
|
|
7
|
+
function setGeo(b, g) {
|
|
8
|
+
if (isGroup(b)) { // a group's children keep their place relative to it, scaled with it
|
|
9
|
+
const o = geo(b), sx = o.w ? g.w / o.w : 1, sy = o.h ? g.h / o.h : 1;
|
|
10
|
+
if (sx !== 1 || sy !== 1) kids(b).forEach(c => { const q = geo(c); setGeo(c, { x: q.x * sx, y: q.y * sy, w: q.w * sx, h: q.h * sy }); });
|
|
11
|
+
}
|
|
12
|
+
b.style.left = r1(g.x) + 'px'; b.style.top = r1(g.y) + 'px';
|
|
13
|
+
b.style.width = r1(g.w) + 'px'; b.style.height = r1(g.h) + 'px';
|
|
14
|
+
if (b.dataset.shape) renderShape(b);
|
|
15
|
+
if (b.dataset.autofit === 'shrink') fitText(b);
|
|
16
|
+
}
|
|
17
|
+
function place(el, g) { Object.assign(el.style, { left: g.x + 'px', top: g.y + 'px', width: g.w + 'px', height: g.h + 'px' }); }
|
|
18
|
+
/* Rotation lives in the box's inline transform (rotate about the centre); 0 means no transform. */
|
|
19
|
+
const rot = b => parseFloat((b.style.transform.match(/rotate\((-?[\d.]+)deg\)/) || [])[1]) || 0;
|
|
20
|
+
/* The resize cursor for a handle, turned with the box as in Slides (rotation, then any flip). */
|
|
21
|
+
function resizeCursor(b, dir) {
|
|
22
|
+
const [fx, fy] = flipXY(b);
|
|
23
|
+
let a = { n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315 }[dir] + rot(b);
|
|
24
|
+
if (fx < 0) a = -a;
|
|
25
|
+
if (fy < 0) a = 180 - a;
|
|
26
|
+
return ['ns', 'nesw', 'ew', 'nwse'][Math.round((((a % 180) + 180) % 180) / 45) % 4] + '-resize';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Mirroring from data-flip: -1 on a flipped axis (the CSS `scale` applied after the box's rotate). */
|
|
30
|
+
const flipXY = b => [(b.dataset.flip || '').includes('h') ? -1 : 1, (b.dataset.flip || '').includes('v') ? -1 : 1];
|
|
31
|
+
|
|
32
|
+
/* Axis-aligned bounds of a box as drawn, i.e. after rotation (used for snapping and marquee). */
|
|
33
|
+
function aabb(b) {
|
|
34
|
+
const g = geo(b), t = rot(b) * Math.PI / 180;
|
|
35
|
+
if (!t) return g;
|
|
36
|
+
const c = Math.abs(Math.cos(t)), s = Math.abs(Math.sin(t));
|
|
37
|
+
const w = g.w * c + g.h * s, h = g.w * s + g.h * c;
|
|
38
|
+
return { x: g.x + g.w / 2 - w / 2, y: g.y + g.h / 2 - h / 2, w, h };
|
|
39
|
+
}
|
|
40
|
+
function setRot(b, deg) {
|
|
41
|
+
deg = Math.round(((deg % 360) + 360) % 360 * 10) / 10;
|
|
42
|
+
b.style.transform = deg ? `rotate(${deg}deg)` : '';
|
|
43
|
+
shadeBox(b);
|
|
44
|
+
if (!b.getAttribute('style')?.trim()) b.removeAttribute('style');
|
|
45
|
+
}
|
|
46
|
+
function bbox(gs) {
|
|
47
|
+
const x = Math.min(...gs.map(g => g.x)), y = Math.min(...gs.map(g => g.y));
|
|
48
|
+
return { x, y, w: Math.max(...gs.map(g => g.x + g.w)) - x, h: Math.max(...gs.map(g => g.y + g.h)) - y };
|
|
49
|
+
}
|
|
50
|
+
function toCanvas(e) {
|
|
51
|
+
const r = frame.getBoundingClientRect();
|
|
52
|
+
return { x: (e.clientX - r.left) / scale, y: (e.clientY - r.top) / scale };
|
|
53
|
+
}
|
|
54
|
+
/* Zoom as in Slides: null = fit the window; otherwise a factor where 1 (100%) shows the slide 960px
|
|
55
|
+
wide, like a 10-inch Slides page. The stage scrolls when the slide is bigger than it. */
|
|
56
|
+
let zoom = null;
|
|
57
|
+
const ZOOMS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
58
|
+
function fit() {
|
|
59
|
+
const r = stage.getBoundingClientRect();
|
|
60
|
+
const pad = PHONE ? 30 : 64; // the phone fits the slide 15 from the sides (03-edit.png)
|
|
61
|
+
scale = zoom ? zoom * 960 / W : Math.max(0.05, Math.min((r.width - pad) / W, (r.height - pad) / H));
|
|
62
|
+
frame.style.width = W * scale + 'px'; frame.style.height = H * scale + 'px';
|
|
63
|
+
canvas.style.transform = `scale(${scale})`;
|
|
64
|
+
stage.classList.toggle('zoomed', !!zoom);
|
|
65
|
+
$('#ed-zoom .zoom-label').textContent = zoom ? Math.round(zoom * 100) + '%' : 'Fit';
|
|
66
|
+
drawOverlay(); drawGuides(); drawRulers();
|
|
67
|
+
}
|
|
68
|
+
/* Rulers, as Slides: inches (1 in = 192 canvas px) with 1/8 in ticks and numbers at whole inches, 0 at the slide's
|
|
69
|
+
top-left, a baseline along the slide, the selection marked by three blue ticks, and a blue mark following the pointer. Dragging out of
|
|
70
|
+
the top ruler makes a horizontal guide, out of the left one a vertical guide. View → Show ruler hides them. */
|
|
71
|
+
let rulerPtr = null, rulerQueued = false;
|
|
72
|
+
const rulersShown = () => stored('slides-ruler-off') !== '1';
|
|
73
|
+
function setRulersShown(on) { store('slides-ruler-off', on ? undefined : '1'); document.body.classList.toggle('ruler-off', !on); drawRulers(); }
|
|
74
|
+
function queueRulers() { if (!rulerQueued) { rulerQueued = true; requestAnimationFrame(() => { rulerQueued = false; drawRulers(); }); } }
|
|
75
|
+
function drawRulers() {
|
|
76
|
+
if (!rulersShown() || !slides.length) return;
|
|
77
|
+
const css = getComputedStyle(document.body), tok = k => css.getPropertyValue(k).trim(); // body: dark mode overrides the tokens there
|
|
78
|
+
const fr = frame.getBoundingClientRect(), dpr = devicePixelRatio || 1, step = 192 * scale / 8;
|
|
79
|
+
const span = sel.size && !themeStash ? bbox([...sel].map(aabb)) : null;
|
|
80
|
+
for (const horiz of [true, false]) {
|
|
81
|
+
const cv = $(horiz ? '#ed-ruler-h' : '#ed-ruler-v'), r = cv.getBoundingClientRect(), w = r.width, h = r.height;
|
|
82
|
+
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
|
|
83
|
+
const g = cv.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h);
|
|
84
|
+
const o = horiz ? fr.left - r.left : fr.top - r.top, len = (horiz ? W : H) * scale, along = horiz ? w : h, across = horiz ? h : w;
|
|
85
|
+
g.strokeStyle = '#9aa0a6'; g.fillStyle = '#444'; g.font = `11px ${tok('--font')}`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineWidth = 1;
|
|
86
|
+
g.beginPath(); // the 1 px baseline along the slide's side
|
|
87
|
+
if (horiz) { g.moveTo(o, across - 0.5); g.lineTo(o + len, across - 0.5); } else { g.moveTo(across - 0.5, o); g.lineTo(across - 0.5, o + len); }
|
|
88
|
+
const last = (horiz ? W : H) / 192 * 8;
|
|
89
|
+
for (let i = Math.floor(-o / step); i <= Math.ceil((along - o) / step); i++) {
|
|
90
|
+
const p = Math.round(o + i * step) + 0.5;
|
|
91
|
+
if (i % 8 === 0) { // a whole inch: its number, as Slides (none at 0 or at the slide's far edge)
|
|
92
|
+
if (i > 0 && i < last) { g.save(); g.translate(horiz ? p : across / 2 - 2, horiz ? across / 2 - 2 : p); if (!horiz) g.rotate(-Math.PI / 2); g.fillText(String(i / 8), 0, 0); g.restore(); }
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const t = i % 4 === 0 ? 8 : i % 2 === 0 ? 5 : 3; // ½ in 8 px, ¼ in 5 px, ⅛ in 3 px
|
|
96
|
+
if (horiz) { g.moveTo(p, across - t); g.lineTo(p, across); } else { g.moveTo(across - t, p); g.lineTo(across, p); }
|
|
97
|
+
}
|
|
98
|
+
g.stroke();
|
|
99
|
+
if (span) { // the selection: three 2 px blue ticks at its start, centre and end
|
|
100
|
+
const a0 = o + (horiz ? span.x : span.y) * scale, l = (horiz ? span.w : span.h) * scale;
|
|
101
|
+
g.fillStyle = tok('--blue');
|
|
102
|
+
for (const p of [a0, a0 + l / 2, a0 + l]) horiz ? g.fillRect(Math.round(p) - 1, 0, 2, across) : g.fillRect(0, Math.round(p) - 1, across, 2);
|
|
103
|
+
}
|
|
104
|
+
if (rulerPtr) {
|
|
105
|
+
const p = Math.round(horiz ? rulerPtr.x - r.left : rulerPtr.y - r.top) + 0.5;
|
|
106
|
+
if (p > 0 && p < along) { g.strokeStyle = tok('--blue'); g.beginPath(); if (horiz) { g.moveTo(p, 0); g.lineTo(p, across); } else { g.moveTo(0, p); g.lineTo(across, p); } g.stroke(); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
$('#ed-stage').addEventListener('pointermove', e => { rulerPtr = { x: e.clientX, y: e.clientY }; queueRulers(); });
|
|
111
|
+
$('#ed-stage').addEventListener('pointerleave', () => { rulerPtr = null; queueRulers(); });
|
|
112
|
+
$('#ed-stage').addEventListener('scroll', queueRulers);
|
|
113
|
+
/* Drag a guide out of a ruler, as Slides. It starts only once the pointer has left the ruler by a few pixels, so a click
|
|
114
|
+
on the ruler does nothing; while dragging the line is blue with where it is (inches, as the ruler counts), and it is
|
|
115
|
+
kept only if let go over the slide. */
|
|
116
|
+
for (const [id, d] of [['#ed-ruler-h', 'h'], ['#ed-ruler-v', 'v']]) {
|
|
117
|
+
$(id).title = d === 'h' ? 'Drag down onto the slide for a guide' : 'Drag right onto the slide for a guide';
|
|
118
|
+
$(id).addEventListener('pointerdown', e => {
|
|
119
|
+
if (e.button !== 0) return;
|
|
120
|
+
const start = d === 'h' ? e.clientY : e.clientX, k = 1 / scale;
|
|
121
|
+
let r = /** @type {HTMLElement | null} */ (null), label = /** @type {HTMLElement | null} */ (null);
|
|
122
|
+
const at = ev => { const p = toCanvas(ev); return d === 'v' ? p.x : p.y; };
|
|
123
|
+
const on = v => v >= 0 && v <= (d === 'v' ? W : H);
|
|
124
|
+
const show = v => {
|
|
125
|
+
place(r, d === 'v' ? { x: v - 4 * k, y: 0, w: 8 * k, h: H } : { x: 0, y: v - 4 * k, w: W, h: 8 * k });
|
|
126
|
+
label.textContent = on(v) ? `${(v / 192).toFixed(2)} in` : 'Let go to cancel';
|
|
127
|
+
r.classList.toggle('off', !on(v));
|
|
128
|
+
};
|
|
129
|
+
drag(e, ev => {
|
|
130
|
+
if (!r) {
|
|
131
|
+
if (Math.abs((d === 'h' ? ev.clientY : ev.clientX) - start) < 6) return; // not yet: a click, or a wobble on the ruler
|
|
132
|
+
setGuidesShown(true);
|
|
133
|
+
r = div('rule new ' + d); r.style.setProperty('--k', k + 'px'); label = div('rule-at'); r.append(label); $('#ed-rules').append(r);
|
|
134
|
+
}
|
|
135
|
+
show(at(ev));
|
|
136
|
+
}, ev => { if (!r) return; const v = at(ev); r.remove(); if (on(v)) setGuides([...userGuides(), [d, v]]); });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/* Set the zoom, keeping the slide point under (cx, cy) (or the stage centre) where it was. */
|
|
140
|
+
function setZoom(z, cx, cy) {
|
|
141
|
+
const sr = stage.getBoundingClientRect();
|
|
142
|
+
cx ??= sr.left + sr.width / 2; cy ??= sr.top + sr.height / 2;
|
|
143
|
+
const p = toCanvas({ clientX: cx, clientY: cy });
|
|
144
|
+
zoom = z && Math.max(0.25, Math.min(4, z));
|
|
145
|
+
fit();
|
|
146
|
+
const fr = frame.getBoundingClientRect();
|
|
147
|
+
stage.scrollLeft += fr.left + p.x * scale - cx; stage.scrollTop += fr.top + p.y * scale - cy;
|
|
148
|
+
}
|
|
149
|
+
const fitZoom = () => scale * W / 960;
|
|
150
|
+
const zoomStep = dir => {
|
|
151
|
+
const z = fitZoom();
|
|
152
|
+
setZoom(dir > 0 ? ZOOMS.find(v => v > z + 0.01) ?? 2 : [...ZOOMS].reverse().find(v => v < z - 0.01) ?? 0.5);
|
|
153
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* View → Guides, as Slides: user guides across the slide, saved with the deck as data-guides="v960 h540" on
|
|
2
|
+
the .slides element (px on the 1920×1080 canvas). Drag one to move it, drag it off the slide to remove it;
|
|
3
|
+
dragged objects snap to them. Shown or hidden per viewer (remembered), never drawn in the deck itself. */
|
|
4
|
+
const userGuides = () => (deckDoc?.querySelector('.slides')?.dataset.guides || '').split(/\s+/).filter(Boolean).map(t => [t[0], +t.slice(1)]).filter(([d, v]) => 'vh'.includes(d) && isFinite(v));
|
|
5
|
+
const guidesShown = () => { try { return localStorage.getItem('slides-guides-off') !== '1'; } catch { return true; } };
|
|
6
|
+
const snapGuides = () => { try { return localStorage.getItem('slides-snap-guides') !== '0'; } catch { return true; } };
|
|
7
|
+
function setGuides(list) {
|
|
8
|
+
const el = deckDoc.querySelector('.slides');
|
|
9
|
+
if (list.length) el.dataset.guides = list.map(([d, v]) => d + Math.round(v)).join(' '); else delete el.dataset.guides;
|
|
10
|
+
drawGuides(); scheduleSave();
|
|
11
|
+
}
|
|
12
|
+
function setGuidesShown(on) { store('slides-guides-off', on ? undefined : '1'); drawGuides(); }
|
|
13
|
+
function addGuide(d) { setGuidesShown(true); setGuides([...userGuides(), [d, d === 'v' ? W / 2 : H / 2]]); }
|
|
14
|
+
function drawGuides() {
|
|
15
|
+
const host = $('#ed-rules'); host.replaceChildren();
|
|
16
|
+
if (!guidesShown()) return;
|
|
17
|
+
const k = 1 / scale;
|
|
18
|
+
userGuides().forEach(([d, v], i) => {
|
|
19
|
+
const r = div('rule ' + d); r.style.setProperty('--k', k + 'px');
|
|
20
|
+
place(r, d === 'v' ? { x: v - 4 * k, y: 0, w: 8 * k, h: H } : { x: 0, y: v - 4 * k, w: W, h: 8 * k });
|
|
21
|
+
r.addEventListener('pointerdown', e => {
|
|
22
|
+
e.stopPropagation(); e.preventDefault();
|
|
23
|
+
const list = userGuides();
|
|
24
|
+
drag(e, ev => { const p = toCanvas(ev); list[i] = [d, d === 'v' ? p.x : p.y]; place(r, d === 'v' ? { x: p.x - 4 * k, y: 0, w: 8 * k, h: H } : { x: 0, y: p.y - 4 * k, w: W, h: 8 * k }); },
|
|
25
|
+
() => { const v2 = list[i][1]; setGuides(v2 < 0 || v2 > (d === 'v' ? W : H) ? list.filter((_, j) => j !== i) : list); });
|
|
26
|
+
});
|
|
27
|
+
host.append(r);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
let guideChord = 0; // ⌃⌘E starts Slides' two-step guide shortcuts
|
|
31
|
+
addEventListener('keydown', e => {
|
|
32
|
+
if (!(e.ctrlKey && e.metaKey) || inField(e.target)) return;
|
|
33
|
+
if (e.code === 'KeyE') { e.preventDefault(); guideChord = Date.now(); return; }
|
|
34
|
+
if (Date.now() - guideChord > 2000) return;
|
|
35
|
+
const run = { KeyG: () => setGuidesShown(!guidesShown()), KeyV: () => addGuide('v'), KeyH: () => addGuide('h') }[e.code];
|
|
36
|
+
if (run) { e.preventDefault(); e.stopImmediatePropagation(); guideChord = 0; run(); }
|
|
37
|
+
}, true);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Code blocks: Insert → Code block → language makes a box with <pre><code class="language-…">, coloured by shapes.js'
|
|
2
|
+
highlightCode; right-click → Code language changes it. */
|
|
3
|
+
const CODE_LANGS = [['python', 'Python'], ['javascript', 'JavaScript'], ['typescript', 'TypeScript'], ['bash', 'Bash'], ['json', 'JSON'],
|
|
4
|
+
['cpp', 'C++'], ['rust', 'Rust'], ['go', 'Go'], ['sql', 'SQL'], ['r', 'R'], ['julia', 'Julia'], ['plaintext', 'Plain text']];
|
|
5
|
+
const CODE_SAMPLE = { python: 'def loss(logits, y):\n return ce(logits, y) # mean over the batch', javascript: 'const sum = xs => xs.reduce((a, b) => a + b, 0);' };
|
|
6
|
+
function addCode(lang) {
|
|
7
|
+
const b = makeBox({ x: 360, y: 340, w: 1200, h: 400 }); b.dataset.valign = 'top';
|
|
8
|
+
const pre = document.createElement('pre'), code = document.createElement('code');
|
|
9
|
+
code.className = 'language-' + lang; code.textContent = CODE_SAMPLE[lang] || '// code'; pre.append(code);
|
|
10
|
+
content(b).style.fontSize = '40px'; content(b).style.textAlign = 'left'; content(b).append(pre);
|
|
11
|
+
insert(b); highlightCode(b);
|
|
12
|
+
}
|
|
13
|
+
/* Tables, as Slides: Insert → Table → a size grid (columns × rows); a `.box.table` holds a plain <table>. Cells are edited
|
|
14
|
+
in place (Tab / ⇧Tab move between them, Tab in the last cell adds a row); right-click in a cell inserts or deletes rows
|
|
15
|
+
and columns, deletes the table or evens out the rows / columns. */
|
|
16
|
+
function tableGrid() {
|
|
17
|
+
const g = div('table-grid'), cap = div('table-cap'), N = 10;
|
|
18
|
+
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
|
|
19
|
+
const cell = div('tg-cell'); cell.dataset.r = String(r); cell.dataset.c = String(c);
|
|
20
|
+
cell.onpointerenter = () => { g.querySelectorAll('.tg-cell').forEach(e => e.classList.toggle('on', +e.dataset.r <= r && +e.dataset.c <= c)); cap.textContent = `${c + 1} x ${r + 1}`; };
|
|
21
|
+
cell.onclick = () => { closeMenu(); addTable(c + 1, r + 1); };
|
|
22
|
+
g.append(cell);
|
|
23
|
+
}
|
|
24
|
+
const wrap = div('table-pick'); cap.textContent = '1 x 1'; wrap.append(g, cap); return wrap;
|
|
25
|
+
}
|
|
26
|
+
function addTable(cols, rows) {
|
|
27
|
+
const w = Math.min(1600, cols * 320), h = rows * 96;
|
|
28
|
+
const b = makeBox({ x: (W - w) / 2, y: (H - h) / 2, w, h, cls: 'table' }); b.dataset.valign = 'top';
|
|
29
|
+
const t = document.createElement('table');
|
|
30
|
+
for (let r = 0; r < rows; r++) { const tr = t.insertRow(); for (let c = 0; c < cols; c++) tr.insertCell(); }
|
|
31
|
+
const c = content(b); c.style.padding = '0'; c.style.fontSize = `${Math.round(14 * PT * 100) / 100}px`; c.style.textAlign = 'left'; c.append(t);
|
|
32
|
+
insert(b);
|
|
33
|
+
}
|
|
34
|
+
const cellAt = () => getSelection().anchorNode?.parentElement?.closest?.('td, th') || (getSelection().anchorNode?.closest?.('td, th'));
|
|
35
|
+
function focusCell(td) { const r = document.createRange(); r.selectNodeContents(td); r.collapse(false); const s = getSelection(); s.removeAllRanges(); s.addRange(r); lastRange = r.cloneRange(); }
|
|
36
|
+
/** @param {any} td a table cell */
|
|
37
|
+
function tableOp(op, td = cellAt()) {
|
|
38
|
+
if (!td || !editing) return;
|
|
39
|
+
const tr = /** @type {HTMLTableRowElement} */ (td.parentElement), t = td.closest('table'), ci = td.cellIndex, ri = tr.rowIndex, rows = [...t.rows];
|
|
40
|
+
const blankRow = () => { const n = t.insertRow(-1); n.remove(); for (let i = 0; i < tr.cells.length; i++) n.insertCell(); return n; };
|
|
41
|
+
if (op === 'row-above') tr.before(blankRow());
|
|
42
|
+
if (op === 'row-below') tr.after(blankRow());
|
|
43
|
+
if (op === 'col-left' || op === 'col-right') rows.forEach(r => r.insertCell(op === 'col-left' ? ci : ci + 1));
|
|
44
|
+
if (op === 'del-row') { if (rows.length > 1) tr.remove(); else return tableOp('del-table', td); }
|
|
45
|
+
if (op === 'del-col') { if (tr.cells.length > 1) rows.forEach(r => r.cells[ci]?.remove()); else return tableOp('del-table', td); }
|
|
46
|
+
if (op === 'del-table') { const b = editing; stopEdit(); b.remove(); sel.clear(); drawOverlay(); commit(); return; }
|
|
47
|
+
if (op === 'dist-rows') rows.forEach(r => r.style.removeProperty('height'));
|
|
48
|
+
if (op === 'dist-cols') rows.forEach(r => [...r.cells].forEach(c => c.style.removeProperty('width')));
|
|
49
|
+
const keep = t.rows[Math.min(ri, t.rows.length - 1)]?.cells[Math.min(ci, t.rows[0].cells.length - 1)];
|
|
50
|
+
if (keep) focusCell(keep);
|
|
51
|
+
fitHeight(editing);
|
|
52
|
+
}
|
|
53
|
+
const tableItems = () => [
|
|
54
|
+
{ label: 'Insert row above', icon: 'add', run: () => tableOp('row-above') }, { label: 'Insert row below', icon: 'add', run: () => tableOp('row-below') },
|
|
55
|
+
{ label: 'Insert column left', icon: 'add', run: () => tableOp('col-left') }, { label: 'Insert column right', icon: 'add', run: () => tableOp('col-right') },
|
|
56
|
+
'-',
|
|
57
|
+
{ label: 'Delete row', icon: 'delete', run: () => tableOp('del-row') }, { label: 'Delete column', icon: 'delete', run: () => tableOp('del-col') },
|
|
58
|
+
{ label: 'Delete table', icon: 'delete', run: () => tableOp('del-table') },
|
|
59
|
+
'-',
|
|
60
|
+
{ label: 'Distribute rows', icon: 'vertical_distribute', run: () => tableOp('dist-rows') }, { label: 'Distribute columns', icon: 'horizontal_distribute', run: () => tableOp('dist-cols') },
|
|
61
|
+
];
|
|
62
|
+
/* Insert → Equation: a box holding display-math source; KaTeX typesets it, double-click edits the source. */
|
|
63
|
+
function addEquation() {
|
|
64
|
+
const b = makeBox({ x: 460, y: 440, w: 1000, h: 200 });
|
|
65
|
+
content(b).style.fontSize = '48px'; content(b).style.textAlign = 'center'; content(b).textContent = '\\[ e^{i\\pi} + 1 = 0 \\]';
|
|
66
|
+
insert(b); renderMath(content(b));
|
|
67
|
+
}
|
|
68
|
+
const codeOf = b => b && content(b).querySelector('pre code');
|
|
69
|
+
function setCodeLang(lang) {
|
|
70
|
+
for (const b of sel) { const c = codeOf(b); if (!c) continue; c.className = 'language-' + lang; c.textContent = c.textContent; delete c.dataset.highlighted; highlightCode(b); }
|
|
71
|
+
commit();
|
|
72
|
+
}
|
|
73
|
+
const codeLangItems = (run, on) => CODE_LANGS.map(([v, label]) => ({ label, run: () => run(v), on: on && (() => on(v)) }));
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/* ---------- clipboard, drop, keyboard ---------- */
|
|
2
|
+
|
|
3
|
+
const inField = t => t?.closest?.('input, select, textarea, #ed-notes');
|
|
4
|
+
|
|
5
|
+
/* Slides, as Slides: ⌘C / ⌘X with the filmstrip focused copy the selected slides; ⌘V pastes them after the current one
|
|
6
|
+
(with the filmstrip or the slide focused). Their files go along by the deck they came from (/library/<id>/…): pasted
|
|
7
|
+
into another deck, each picture or video is copied into it. */
|
|
8
|
+
const slideClip = () => SLIDE_CLIP + JSON.stringify({ deck: deckId, slides: (picked.size ? pickedIdx() : [cur]).map(i => sectionHTML(slides[i], true)) });
|
|
9
|
+
addEventListener('copy', e => {
|
|
10
|
+
if (!editing && !inField(e.target) && filmFocused()) { e.clipboardData.setData('text/plain', slideClip()); e.preventDefault(); return; }
|
|
11
|
+
if (editing || inField(e.target) || !sel.size) return;
|
|
12
|
+
e.clipboardData.setData('text/plain', CLIP + JSON.stringify([...sel].map(b => cleanBox(b).outerHTML)));
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
});
|
|
15
|
+
addEventListener('cut', e => {
|
|
16
|
+
if (!editing && !inField(e.target) && filmFocused()) { e.clipboardData.setData('text/plain', slideClip()); e.preventDefault(); deleteSlides(); return; }
|
|
17
|
+
if (editing || inField(e.target) || !sel.size) return;
|
|
18
|
+
e.clipboardData.setData('text/plain', CLIP + JSON.stringify([...sel].map(b => cleanBox(b).outerHTML)));
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
deleteSelection();
|
|
21
|
+
});
|
|
22
|
+
async function pasteSlides(json) {
|
|
23
|
+
let clip; try { clip = JSON.parse(json); } catch { return; }
|
|
24
|
+
const t = document.createElement('template'), from = clip.deck && clip.deck !== deckId ? clip.deck : '';
|
|
25
|
+
const made = new Map(); // a file of the other deck → its copy here
|
|
26
|
+
const bring = async src => { // a file of the deck the slides came from, copied into this one
|
|
27
|
+
if (!from || !src || /^(data|blob|https?):|^\//i.test(src)) return src;
|
|
28
|
+
if (!made.has(src)) made.set(src, (async () => {
|
|
29
|
+
const r = await fetch(`/library/${from}/${src.replace(/[?#].*$/, '')}`); if (!r.ok) return src;
|
|
30
|
+
const ext = (src.match(/\.(\w+)(?:[?#]|$)/) || [])[1] || 'bin';
|
|
31
|
+
return (await (await fetch('/api/upload?ext=' + ext, { method: 'POST', body: await r.blob() })).json()).src || src;
|
|
32
|
+
})());
|
|
33
|
+
return made.get(src);
|
|
34
|
+
};
|
|
35
|
+
if (from) setStatus('Copying the slides\u2019 pictures…', 'sync');
|
|
36
|
+
const copies = [];
|
|
37
|
+
for (const h of clip.slides || []) {
|
|
38
|
+
t.innerHTML = h; const s = t.content.querySelector('section'); if (!s) continue;
|
|
39
|
+
s.removeAttribute('data-sid'); s.removeAttribute('data-ed-stack');
|
|
40
|
+
boxes(s).forEach(b => { b.id = uid(); });
|
|
41
|
+
for (const el of s.querySelectorAll('[src], [poster]')) for (const a of ['src', 'poster']) { const v = el.getAttribute(a); if (v) el.setAttribute(a, await bring(v)); }
|
|
42
|
+
for (const el of s.querySelectorAll('[style*="url("]')) {
|
|
43
|
+
let st = el.getAttribute('style');
|
|
44
|
+
for (const m of [...st.matchAll(/url\((['"]?)([^'")]+)\1\)/g)]) st = st.replace(m[2], await bring(m[2]));
|
|
45
|
+
el.setAttribute('style', st);
|
|
46
|
+
}
|
|
47
|
+
copies.push(s);
|
|
48
|
+
}
|
|
49
|
+
if (!copies.length) return;
|
|
50
|
+
slideOp(() => {
|
|
51
|
+
const at = (picked.size ? Math.max(...pickedIdx()) : cur) + 1;
|
|
52
|
+
slides.splice(at, 0, ...copies); cur = slides.indexOf(copies.at(-1)); picked = new Set(copies);
|
|
53
|
+
});
|
|
54
|
+
setStatus(`Pasted ${copies.length} slide${copies.length > 1 ? 's' : ''}`, 'content_paste');
|
|
55
|
+
}
|
|
56
|
+
/* Pasted HTML keeps its formatting (⇧⌘V pastes plain text instead), minus anything active or structural. */
|
|
57
|
+
let plainPaste = false;
|
|
58
|
+
function cleanPastedHTML(html) {
|
|
59
|
+
const t = document.createElement('template'); t.innerHTML = html;
|
|
60
|
+
t.content.querySelectorAll('script, style, meta, link, title, iframe, object, embed, form, input, button').forEach(e => e.remove());
|
|
61
|
+
t.content.querySelectorAll('*').forEach(e => [...e.attributes].forEach(a => { if (!['style', 'href'].includes(a.name)) e.removeAttribute(a.name); }));
|
|
62
|
+
return t.innerHTML;
|
|
63
|
+
}
|
|
64
|
+
addEventListener('paste', async e => {
|
|
65
|
+
const plain = plainPaste; plainPaste = false;
|
|
66
|
+
if (inField(e.target) && !editing) return;
|
|
67
|
+
const cd = e.clipboardData;
|
|
68
|
+
// A file copied in Finder sometimes shows up only in `items`, not in `files`.
|
|
69
|
+
let files = [...cd.files];
|
|
70
|
+
if (!files.length) files = [...cd.items].filter(i => i.kind === 'file').map(i => i.getAsFile()).filter(Boolean);
|
|
71
|
+
files = files.filter(f => f.type.startsWith('image/') || isVideoFile(f));
|
|
72
|
+
if (files.length) {
|
|
73
|
+
e.preventDefault(); stopEdit();
|
|
74
|
+
for (const f of files) await (isVideoFile(f) ? addVideo(f) : addImage(f));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const text = e.clipboardData.getData('text/plain'), html = e.clipboardData.getData('text/html');
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
if (text.startsWith(SLIDE_CLIP)) { stopEdit(); await pasteSlides(text.slice(SLIDE_CLIP.length)); return; } // slides, even while typing
|
|
80
|
+
if (editing) {
|
|
81
|
+
if (html && !plain) document.execCommand('insertHTML', false, cleanPastedHTML(html));
|
|
82
|
+
else document.execCommand('insertText', false, text);
|
|
83
|
+
fitHeight(editing);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (text.startsWith(CLIP)) {
|
|
87
|
+
const t = document.createElement('template');
|
|
88
|
+
const taken = boxes().map(geo);
|
|
89
|
+
const pasted = JSON.parse(text.slice(CLIP.length)).map(h => {
|
|
90
|
+
t.innerHTML = h;
|
|
91
|
+
const b = t.content.firstElementChild;
|
|
92
|
+
b.id = uid();
|
|
93
|
+
const g = geo(b);
|
|
94
|
+
while (taken.some(o => o.x === g.x && o.y === g.y)) { g.x += 30; g.y += 30; }
|
|
95
|
+
setGeo(b, g);
|
|
96
|
+
return b;
|
|
97
|
+
});
|
|
98
|
+
insert(...pasted);
|
|
99
|
+
} else if (/^https?:\/\/\S+$/.test(text.trim())) { // a pasted web address: insert the picture it points to (fetched into assets/), as Slides
|
|
100
|
+
const url = text.trim(); setStatus('Fetching image…');
|
|
101
|
+
const r = await fetch('/api/fetch', { method: 'POST', body: JSON.stringify({ url }) }).then(r => r.json()).catch(() => ({}));
|
|
102
|
+
setStatus('');
|
|
103
|
+
if (r.src) await addImageSrc(r.src); else addText(url);
|
|
104
|
+
} else if (text.trim()) {
|
|
105
|
+
addText(text.trim());
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
stage.addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('drop-hint'); });
|
|
110
|
+
stage.addEventListener('dragleave', () => document.body.classList.remove('drop-hint'));
|
|
111
|
+
stage.addEventListener('drop', async e => {
|
|
112
|
+
e.preventDefault(); document.body.classList.remove('drop-hint');
|
|
113
|
+
const at = toCanvas(e);
|
|
114
|
+
for (const f of [...e.dataTransfer.files].filter(f => f.type.startsWith('image/') || isVideoFile(f))) await (isVideoFile(f) ? addVideo(f, at) : addImage(f, at));
|
|
115
|
+
for (const f of [...e.dataTransfer.files].filter(f => /\.pptx$/i.test(f.name))) await importPPTX(f);
|
|
116
|
+
for (const f of [...e.dataTransfer.files].filter(f => /\.(ttf|otf|woff2?)$/i.test(f.name))) { // a font file: into the deck, and onto the selection
|
|
117
|
+
const fam = await useFontFile({ query: 'name=' + encodeURIComponent(f.name), body: f });
|
|
118
|
+
if (fam && sel.size) setFont(`'${fam}', sans-serif`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function deleteSelection() {
|
|
123
|
+
if (!sel.size) return;
|
|
124
|
+
sel.forEach(b => b.remove()); sel.clear();
|
|
125
|
+
drawOverlay(); commit();
|
|
126
|
+
}
|
|
127
|
+
function duplicateSelection() {
|
|
128
|
+
if (!sel.size) return;
|
|
129
|
+
insert(...[...sel].map(b => {
|
|
130
|
+
const c = cleanBox(b); c.id = uid();
|
|
131
|
+
const g = geo(b); setGeo(c, { ...g, x: g.x + 30, y: g.y + 30 });
|
|
132
|
+
return c;
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
addEventListener('keydown', e => {
|
|
137
|
+
const mod = e.metaKey || e.ctrlKey, k = e.key.toLowerCase();
|
|
138
|
+
if (mod && e.shiftKey && (e.key === '.' || e.key === ',' || e.key === '>' || e.key === '<') && sel.size) {
|
|
139
|
+
e.preventDefault(); stepSize(/[.>]/.test(e.key) ? 1 : -1); return;
|
|
140
|
+
}
|
|
141
|
+
if (mod && e.shiftKey && (e.code === 'Digit8' || e.code === 'Digit7') && (editing || sel.size === 1)) {
|
|
142
|
+
e.preventDefault(); listText(e.code === 'Digit8' ? 'insertUnorderedList' : 'insertOrderedList'); return;
|
|
143
|
+
}
|
|
144
|
+
if (mod && !(e.metaKey && e.ctrlKey) && !e.shiftKey && k === 'k' && (editing || sel.size === 1)) { e.preventDefault(); linkText(); return; }
|
|
145
|
+
if (mod && e.key === '\\' && sel.size) { e.preventDefault(); clearFormat(); return; }
|
|
146
|
+
if (mod && !e.shiftKey && !e.altKey && !(e.metaKey && e.ctrlKey) && (k === 'b' || k === 'i' || k === 'u') && (editing || sel.size)) { // ⌘B ⌘I ⌘U, selection or whole boxes (⌃⌘B / ⌃⌘I resize)
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
({ b: $('#ed-bold'), i: $('#ed-italic'), u: $('#ed-underline') })[k].click();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (mod && e.shiftKey && 'lerj'.includes(k) && k && sel.size) { e.preventDefault(); setAlign({ l: 'left', e: 'center', r: 'right', j: 'justify' }[k]); return; }
|
|
152
|
+
if (mod && e.shiftKey && k === 'x' && sel.size) { e.preventDefault(); strike(); return; }
|
|
153
|
+
if (mod && !e.shiftKey && (e.key === '.' || e.key === ',') && sel.size) { e.preventDefault(); (e.key === '.' ? superscript : subscript)(); return; }
|
|
154
|
+
if (mod && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && sel.size && !editing) { e.preventDefault(); (e.key === 'ArrowUp' ? toFront : toBack)(); return; }
|
|
155
|
+
if (mod && e.shiftKey && !e.altKey && k === 'm' && !editing) { e.preventDefault(); newSlideBelow(); return; } // ⇧⌘M
|
|
156
|
+
if (((mod && !e.ctrlKey) || (e.ctrlKey && !e.metaKey)) && !e.shiftKey && !e.altKey && k === 'm' && !editing) { e.preventDefault(); $('#ed-new-slide').click(); return; } // Slides on Mac: ⌃M (⌘M too)
|
|
157
|
+
if (mod && e.altKey && !e.shiftKey && e.code === 'KeyC') { e.preventDefault(); copyFormat(); return; }
|
|
158
|
+
if (mod && e.altKey && !e.shiftKey && e.code === 'KeyV') { e.preventDefault(); pasteFormat(); return; }
|
|
159
|
+
if (mod && e.shiftKey && !e.altKey && k === 'v' && (editing || !inField(e.target))) { // ⇧⌘V: Chrome on Mac has no such key (its own is ⌥⇧⌘V), so read the clipboard
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
navigator.clipboard?.readText().then(t => { plainPaste = true; const ev = new Event('paste', { bubbles: true }); ev.clipboardData = { files: [], items: [], getData: f => f === 'text/plain' ? t : '' }; (editing || document.body).dispatchEvent(ev); }).catch(() => {});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (mod && e.altKey && e.shiftKey && e.code === 'KeyB') { e.preventDefault(); setTransPanel($('#ed-trans').hidden); return; }
|
|
166
|
+
if (mod && e.altKey && e.shiftKey && e.code === 'KeyS') { e.preventDefault(); setNotesVisible(document.body.classList.contains('notes-off')); return; }
|
|
167
|
+
if (e.ctrlKey && e.shiftKey && !e.metaKey && e.code === 'KeyF') { e.preventDefault(); toggleCompact(); return; }
|
|
168
|
+
if (mod && !e.shiftKey && !e.altKey && k === 's') { e.preventDefault(); saveNow(); return; }
|
|
169
|
+
if (mod && !e.shiftKey && !e.altKey && k === 'p') { e.preventDefault(); printDeck(); return; }
|
|
170
|
+
if (mod && e.key === 'Enter') { e.preventDefault(); present(e.shiftKey ? 0 : cur); return; }
|
|
171
|
+
if (mod && ((k === 'f' && !e.shiftKey) || (k === 'h' && e.shiftKey))) { e.preventDefault(); openFind(); return; }
|
|
172
|
+
if (mod && e.altKey && e.code === 'KeyX') { e.preventDefault(); setSpelling(!spellOn()); return; }
|
|
173
|
+
if (mod && e.altKey && e.shiftKey && e.code === 'KeyF') { e.preventDefault(); stopEdit(); thumbs.focus({ preventScroll: true }); return; } // move to filmstrip
|
|
174
|
+
if (mod && e.key === '/') { e.preventDefault(); showShortcuts(shortcutsDlg.hidden); return; }
|
|
175
|
+
if (mod && !e.altKey && (e.key === '=' || e.key === '+' || e.key === '-' || e.key === '0')) {
|
|
176
|
+
e.preventDefault(); e.key === '0' ? setZoom(null) : zoomStep(e.key === '-' ? -1 : 1); return;
|
|
177
|
+
}
|
|
178
|
+
if (mod && !e.shiftKey && (e.key === ']' || e.key === '[') && (editing || sel.size)) { e.preventDefault(); indentText(e.key === ']' ? 1 : -1); return; }
|
|
179
|
+
if (editing && e.key === 'Tab' && !cellAt()) { e.preventDefault(); indentText(e.shiftKey ? -1 : 1); return; }
|
|
180
|
+
if (editing) {
|
|
181
|
+
if (e.key === 'Tab' && cellAt()) { // Tab / ⇧Tab: next / previous cell; Tab in the last cell adds a row
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
const cells = [...cellAt().closest('table').querySelectorAll('td, th')], i = cells.indexOf(cellAt()) + (e.shiftKey ? -1 : 1);
|
|
184
|
+
if (i >= cells.length) { tableOp('row-below', cells.at(-1)); const t = cells[0].closest('table'); focusCell(t.rows[t.rows.length - 1].cells[0]); }
|
|
185
|
+
else if (i >= 0) focusCell(cells[i]);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (e.key === 'Enter' && !e.shiftKey && getSelection().anchorNode?.parentElement?.closest('pre')) { e.preventDefault(); document.execCommand('insertText', false, '\n'); return; } // a new line inside a code block
|
|
189
|
+
if (e.key === 'Enter' && editing.dataset.shape === 'wordart') { e.preventDefault(); const b = editing; stopEdit(); sel = new Set([b]); drawOverlay(); return; } // word art is one line
|
|
190
|
+
if (e.key === 'Escape') { e.preventDefault(); const b = editing; stopEdit(); if (b.isConnected) { sel = new Set([b]); drawOverlay(); } }
|
|
191
|
+
if (mod && k === 'z' && !e.shiftKey && content(editing).innerHTML === editStart) { e.preventDefault(); undo(); return; } // nothing typed yet: undo the last move, as Slides
|
|
192
|
+
return; // everything else is normal typing (⌘B/⌘I/⌘Z work natively)
|
|
193
|
+
|
|
194
|
+
}
|
|
195
|
+
if (inField(e.target)) return;
|
|
196
|
+
if (filmFocused() && !e.altKey) { // the filmstrip has focus: keys act on slides, as Slides
|
|
197
|
+
const step = { ArrowUp: -1, ArrowDown: 1, PageUp: -1, PageDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];
|
|
198
|
+
if (mod && /^Arrow/.test(e.key)) { const up = e.key === 'ArrowUp' || e.key === 'ArrowLeft'; e.preventDefault(); moveSlides(e.shiftKey ? (up ? 'start' : 'end') : (up ? 'up' : 'down')); return; } // ⌘↑ or ⌘←, as Slides' list
|
|
199
|
+
if (!mod && (step || e.key === 'Home' || e.key === 'End')) {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
const i = e.key === 'Home' ? 0 : e.key === 'End' ? slides.length - 1 : cur + step;
|
|
202
|
+
if (e.shiftKey) extendTo(i); else go(i);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!mod && (e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault(); deleteSlides(); return; }
|
|
206
|
+
if (mod && k === 'a') { e.preventDefault(); picked = new Set(slides); slideAnchor = 0; renderThumbs(); return; }
|
|
207
|
+
if (!mod && e.key === 'Enter') { e.preventDefault(); thumbs.blur(); return; }
|
|
208
|
+
}
|
|
209
|
+
if (mod && k === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); return; }
|
|
210
|
+
if (mod && k === 'y') { e.preventDefault(); redo(); return; }
|
|
211
|
+
if (mod && !e.shiftKey && !e.altKey && !e.ctrlKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && sel.size) { // one layer forward / back
|
|
212
|
+
e.preventDefault(); (e.key === 'ArrowUp' ? stepForward : stepBackward)(); return;
|
|
213
|
+
}
|
|
214
|
+
if (e.metaKey && e.ctrlKey && sel.size && RESIZE_KEYS[e.code]) { // ⌃⌘B/I/K grow, ⌃⌘W/J shrink, from the centre
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
const [dw, dh] = RESIZE_KEYS[e.code];
|
|
217
|
+
sel.forEach(b => { const g = geo(b), w = Math.max(20, g.w + dw), h = Math.max(isLine(b) ? 0 : 20, g.h + dh); setGeo(b, { x: g.x + (g.w - w) / 2, y: g.y + (g.h - h) / 2, w, h }); fitHeight(b); });
|
|
218
|
+
drawOverlay(); commit(); return;
|
|
219
|
+
}
|
|
220
|
+
if (mod && e.altKey && e.code === 'KeyG') { e.preventDefault(); if (e.shiftKey) ungroupSel(); else groupSel(); return; } // ⌥⌘G / ⇧⌥⌘G
|
|
221
|
+
if (mod && k === 'd') { e.preventDefault(); if (filmFocused()) $('#ed-dup-slide').click(); else duplicateSelection(); return; }
|
|
222
|
+
if (mod && k === 'a') { e.preventDefault(); sel = new Set(pickable()); drawOverlay(); updateToolbar(); return; }
|
|
223
|
+
if (mod && !e.shiftKey && !e.altKey && 'biu'.includes(k) && k && sel.size) { // whole selected boxes; while editing the browser does it
|
|
224
|
+
e.preventDefault(); $({ b: '#ed-bold', i: '#ed-italic', u: '#ed-underline' }[k]).click(); return;
|
|
225
|
+
}
|
|
226
|
+
if (mod) { if (claimed(e)) e.preventDefault(); return; } // a Slides key with nothing to act on still never reaches Chrome (⌘I opened Mail)
|
|
227
|
+
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); deleteSelection(); return; }
|
|
228
|
+
if (cropping && (e.key === 'Escape' || e.key === 'Enter')) { e.preventDefault(); endCrop(); return; }
|
|
229
|
+
if (e.key === 'Escape' && scope && sel.size === 1 && scope.contains([...sel][0])) { e.preventDefault(); sel = new Set([scope]); scope = null; drawOverlay(); updateToolbar(); return; } // out of the group
|
|
230
|
+
if (e.key === 'Escape') { e.preventDefault(); if (!sel.size) thumbs.focus({ preventScroll: true }); sel.clear(); drawOverlay(); updateToolbar(); return; } // Esc with nothing selected: to the filmstrip
|
|
231
|
+
if (e.key === 'Enter' && sel.size === 1) { e.preventDefault(); startEdit([...sel][0]); return; }
|
|
232
|
+
if (e.key === 'Tab') { // select next / previous object, as Slides
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
const all = boxes(); if (!all.length) return;
|
|
235
|
+
const i = sel.size ? all.indexOf([...sel].at(-1)) : (e.shiftKey ? 0 : -1);
|
|
236
|
+
sel = new Set([all[(i + (e.shiftKey ? -1 : 1) + all.length) % all.length]]); drawOverlay(); updateToolbar(); return;
|
|
237
|
+
}
|
|
238
|
+
if (e.altKey && sel.size && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { // ⌥←/→ rotate 15°, with ⇧ 1°
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
const d = (e.key === 'ArrowRight' ? 1 : -1) * (e.shiftKey ? 1 : 15);
|
|
241
|
+
sel.forEach(b => { if (!isLine(b)) setRot(b, rot(b) + d); }); drawOverlay(); commit(); return;
|
|
242
|
+
}
|
|
243
|
+
const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
244
|
+
if (arrows[e.key]) {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
if (!sel.size) { go(cur + (arrows[e.key][0] || arrows[e.key][1])); return; }
|
|
247
|
+
const step = e.shiftKey ? 10 : 1, [ax, ay] = arrows[e.key];
|
|
248
|
+
sel.forEach(b => { const g = geo(b); setGeo(b, { ...g, x: g.x + ax * step, y: g.y + ay * step }); });
|
|
249
|
+
drawOverlay(); commit();
|
|
250
|
+
}
|
|
251
|
+
if (e.key === 'PageDown' || e.key === 'PageUp') { e.preventDefault(); go(cur + (e.key === 'PageDown' ? 1 : -1)); }
|
|
252
|
+
});
|
|
253
|
+
/* Modifier keys docs/shortcuts.md gives the editor, by e.code; ⌘C / ⌘X / ⌘V / ⌘L / ⌘R / ⌘W stay the browser's. */
|
|
254
|
+
const CLAIMED = { meta: 'KeyB KeyI KeyU KeyK KeyD KeyM KeyY KeyZ KeyA KeyS KeyP KeyF Backslash Period Comma BracketLeft BracketRight Slash ArrowUp ArrowDown Enter',
|
|
255
|
+
shift: 'KeyX KeyL KeyE KeyR KeyJ KeyH KeyM Digit7 Digit8 Period Comma ArrowUp ArrowDown Enter KeyZ', alt: 'KeyC KeyV KeyG KeyX', ctrl: 'KeyB KeyI KeyK KeyW KeyJ' };
|
|
256
|
+
const claimed = e => (CLAIMED[e.metaKey && e.ctrlKey ? 'ctrl' : e.altKey ? 'alt' : e.shiftKey ? 'shift' : 'meta'] || '').split(' ').includes(e.code);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* ---------- working beside an agent ---------- */
|
|
2
|
+
/* An agent (or anything) editing deck.html while the editor is open: the reload shows what changed, the agent can ask
|
|
3
|
+
what you are looking at (`glideshow selection`), and your own change is on disk before you switch to its window. */
|
|
4
|
+
|
|
5
|
+
/* After a reload from disk: slides that changed flash in the filmstrip, the first one is shown if the slide on show
|
|
6
|
+
didn't change, and the changed boxes on it are outlined for a moment. */
|
|
7
|
+
function showOutsideChanges(before) {
|
|
8
|
+
const old = new Set(before.map(normHTML)), changed = slides.map((s, i) => old.has(normHTML(sectionHTML(s))) ? -1 : i).filter(i => i >= 0);
|
|
9
|
+
if (!changed.length) { setStatus('Reloaded from disk', 'cloud_sync'); return; }
|
|
10
|
+
if (!changed.includes(cur)) go(changed[0]);
|
|
11
|
+
const list = changed.map(i => i + 1);
|
|
12
|
+
setStatus(`${remoteBy ? remoteBy + ' changed' : 'Changed on disk:'} slide${list.length > 1 ? 's' : ''} ${list.length > 6 ? list.slice(0, 6).join(', ') + '…' : list.join(', ')}`, 'cloud_sync');
|
|
13
|
+
requestAnimationFrame(() => {
|
|
14
|
+
for (const i of changed) thumbs.children[i]?.animate([{ boxShadow: '0 0 0 3px #fbbc04' }, { boxShadow: '0 0 0 3px #fbbc04', offset: .6 }, { boxShadow: '0 0 0 0 transparent' }], { duration: 2400 });
|
|
15
|
+
const was = new Set(Array.from(before[cur] ? new DOMParser().parseFromString(before[cur], 'text/html').querySelectorAll('section > .box') : [], b => normHTML(cleanBox(b).outerHTML)));
|
|
16
|
+
const fresh = boxes().filter(b => !was.has(normHTML(cleanBox(b).outerHTML)));
|
|
17
|
+
$('#ed-flash').replaceChildren(...fresh.map(b => { const o = div('flash-box'); place(o, aabb(b)); o.style.setProperty('--k', 1 / scale + 'px'); return o; }));
|
|
18
|
+
setTimeout(() => $('#ed-flash').replaceChildren(), 2400);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* What you are looking at, for an agent working beside you: the slide on show and the boxes selected (with their text),
|
|
23
|
+
sent to the server whenever it changes. `glideshow selection` prints it; docs/agents.md tells agents to read it when you
|
|
24
|
+
say "this". */
|
|
25
|
+
let lastSelection = '';
|
|
26
|
+
function reportSelection() {
|
|
27
|
+
if (typeof deckDoc === 'undefined' || !deckDoc || !slides.length) return;
|
|
28
|
+
const picked = [...sel].filter(b => b.isConnected), text = editing ? String(getSelection()) : '';
|
|
29
|
+
const key = JSON.stringify([cur, picked.map(b => b.id), editing?.id || '', text, slides.length]);
|
|
30
|
+
if (key === lastSelection) return;
|
|
31
|
+
lastSelection = key;
|
|
32
|
+
const all = boxes(), [h, v] = hv(cur);
|
|
33
|
+
const body = { slide: cur + 1, h, v, slides: slides.length, title: deckDoc.title,
|
|
34
|
+
boxes: picked.map(b => { const g = geo(b); return { id: b.id, index: all.indexOf(b), shape: b.dataset.shape || (b.classList.contains('image') ? 'image' : 'text'),
|
|
35
|
+
text: (content(b)?.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 300), x: Math.round(g.x), y: Math.round(g.y), w: Math.round(g.w), h: Math.round(g.h) }; }),
|
|
36
|
+
editing: editing?.id || '', selectedText: text.slice(0, 1000) };
|
|
37
|
+
fetch('/api/selection', { method: 'POST', body: JSON.stringify(body) }).catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
setInterval(reportSelection, 400);
|
|
40
|
+
|
|
41
|
+
/* Leaving the window (to the terminal, say): what you changed goes to disk now, text being typed included, so an
|
|
42
|
+
agent that writes next starts from it. */
|
|
43
|
+
addEventListener('blur', async () => {
|
|
44
|
+
if (themeStash || histView) return;
|
|
45
|
+
if (editing && content(editing).innerHTML !== editStart) { commit(); editStart = content(editing).innerHTML; }
|
|
46
|
+
await flushSave();
|
|
47
|
+
});
|
|
48
|
+
document.addEventListener('visibilitychange', () => { if (document.hidden) flushSave(); });
|