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,407 @@
|
|
|
1
|
+
/* ---------- pointer interaction ---------- */
|
|
2
|
+
|
|
3
|
+
function drag(e, move, up) {
|
|
4
|
+
e.preventDefault();
|
|
5
|
+
const mm = ev => move(ev);
|
|
6
|
+
const uu = ev => { removeEventListener('pointermove', mm); removeEventListener('pointerup', uu); up(ev); };
|
|
7
|
+
addEventListener('pointermove', mm);
|
|
8
|
+
addEventListener('pointerup', uu);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* What objects snap to: the slide's edges and centre, user guides, and other boxes' edges and centres. */
|
|
12
|
+
// Snap distances in screen px, measured on Slides: alignment snaps at 5 px
|
|
13
|
+
// and not at 7, equal spacing at 2 and not at 3, a line end onto a connection point from about 7.
|
|
14
|
+
const SNAP_PX = 6, SPACE_PX = 2.5, CONNECT_PX = 7, ROT_SNAP = 2;
|
|
15
|
+
function snapLines(others) {
|
|
16
|
+
const xs = [0, W / 2, W], ys = [0, H / 2, H];
|
|
17
|
+
if (snapGuides() && guidesShown()) for (const [d, v] of userGuides()) (d === 'v' ? xs : ys).push(v);
|
|
18
|
+
for (const o of others) { xs.push(o.x, o.x + o.w / 2, o.x + o.w); ys.push(o.y, o.y + o.h / 2, o.y + o.h); }
|
|
19
|
+
return { xs, ys };
|
|
20
|
+
}
|
|
21
|
+
/* View → Snap to → Grid: a 1/8 in grid (24 canvas px); moves put the top-left corner on it, resizes the dragged edge. */
|
|
22
|
+
const GRID = 24, snapGrid = () => stored('slides-snap-grid') === '1', onGrid = v => Math.round(v / GRID) * GRID;
|
|
23
|
+
function snap(g, others) {
|
|
24
|
+
if (snapGrid()) return { dx: onGrid(g.x) - g.x, dy: onGrid(g.y) - g.y };
|
|
25
|
+
const th = SNAP_PX / scale, { xs, ys } = snapLines(others);
|
|
26
|
+
const best = (cands, vals) => {
|
|
27
|
+
let m = null;
|
|
28
|
+
for (const c of cands) for (const v of vals) {
|
|
29
|
+
const d = c - v;
|
|
30
|
+
if (Math.abs(d) < th && (!m || Math.abs(d) < Math.abs(m.d))) m = { d, at: c };
|
|
31
|
+
}
|
|
32
|
+
return m;
|
|
33
|
+
};
|
|
34
|
+
const bx = best(xs, [g.x, g.x + g.w / 2, g.x + g.w]), by = best(ys, [g.y, g.y + g.h / 2, g.y + g.h]);
|
|
35
|
+
// which box (if any) the snapped line belongs to, for the distance labels: the nearest one along the line
|
|
36
|
+
const dist = (o, key, size) => Math.abs((o[key] + o[size] / 2) - (g[key] + g[size] / 2));
|
|
37
|
+
const owner = (at, key, size) => at == null ? null : others.filter(o => [o[key], o[key] + o[size] / 2, o[key] + o[size]].includes(at))
|
|
38
|
+
.sort((a, c) => dist(a, key === 'x' ? 'y' : 'x', key === 'x' ? 'h' : 'w') - dist(c, key === 'x' ? 'y' : 'x', key === 'x' ? 'h' : 'w'))[0] || null;
|
|
39
|
+
return { dx: bx?.d ?? 0, dy: by?.d ?? 0, gx: bx?.at, gy: by?.at, ox: owner(bx?.at, 'x', 'w'), oy: owner(by?.at, 'y', 'h') };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Equal spacing, as Slides' smart guides: along an axis, the boxes sharing a row (or column) with m.
|
|
43
|
+
Snaps m so its gap to a neighbour equals a gap already on the slide, or centres it between two
|
|
44
|
+
neighbours; returns the shift and the gap size, or null. */
|
|
45
|
+
const AXES = { x: ['x', 'w', 'y', 'h'], y: ['y', 'h', 'x', 'w'] };
|
|
46
|
+
function inRow(m, others, ax) {
|
|
47
|
+
const [p, s, q, t] = AXES[ax];
|
|
48
|
+
return others.filter(o => o[q] < m[q] + m[t] && o[q] + o[t] > m[q]).sort((a, b) => a[p] - b[p]);
|
|
49
|
+
}
|
|
50
|
+
function gapsOf(row, ax) {
|
|
51
|
+
const [p, s] = AXES[ax], out = [];
|
|
52
|
+
for (let i = 1; i < row.length; i++) { const g = row[i][p] - (row[i - 1][p] + row[i - 1][s]); if (g > 0) out.push(g); }
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function spaceSnap(m, others, ax) {
|
|
56
|
+
const [p, s] = AXES[ax], th = SPACE_PX / scale, row = inRow(m, others, ax), mid = m[p] + m[s] / 2;
|
|
57
|
+
const left = row.filter(o => o[p] + o[s] / 2 < mid).sort((a, b) => (b[p] + b[s]) - (a[p] + a[s]))[0];
|
|
58
|
+
const right = row.filter(o => o[p] + o[s] / 2 > mid).sort((a, b) => a[p] - b[p])[0];
|
|
59
|
+
const gaps = gapsOf(row, ax), cands = [];
|
|
60
|
+
if (left) gaps.forEach(g => cands.push([left[p] + left[s] + g - m[p], g]));
|
|
61
|
+
if (right) gaps.forEach(g => cands.push([right[p] - g - m[s] - m[p], g]));
|
|
62
|
+
if (left && right) { const g = (right[p] - left[p] - left[s] - m[s]) / 2; if (g > 0) cands.push([left[p] + left[s] + g - m[p], g]); }
|
|
63
|
+
const best = cands.filter(([d]) => Math.abs(d) < th).sort((a, b) => Math.abs(a[0]) - Math.abs(b[0]))[0];
|
|
64
|
+
return best ? { d: best[0], gap: best[1] } : null;
|
|
65
|
+
}
|
|
66
|
+
/* Teal brackets under every gap of that size in m's row, as the reference shows. */
|
|
67
|
+
function showSpacing(m, others, ax, gap) {
|
|
68
|
+
const [p, s, q, t] = AXES[ax], k = 1 / scale, row = inRow(m, others.concat([m]), ax).concat([m]).filter((o, i, a) => a.indexOf(o) === i).sort((a, b) => a[p] - b[p]);
|
|
69
|
+
const off = Math.max(...row.map(o => o[q] + o[t])) + 12 * k;
|
|
70
|
+
for (let i = 1; i < row.length; i++) {
|
|
71
|
+
const from = row[i - 1][p] + row[i - 1][s], to = row[i][p];
|
|
72
|
+
if (Math.abs(to - from - gap) > 0.5) continue;
|
|
73
|
+
const r = (a, b, c, d) => { const e = div('space'); place(e, ax === 'x' ? { x: a, y: b, w: c, h: d } : { x: b, y: a, w: d, h: c }); guides.append(e); };
|
|
74
|
+
r(from, off - k / 2, to - from, k); r(from - k / 2, off - 7 * k, k, 14 * k); r(to - k / 2, off - 7 * k, k, 14 * k);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Drag the selection. ⇧ constrains to horizontal/vertical, ⌥ at the start drags a copy and leaves
|
|
79
|
+
the original in place, ⌘ turns snapping off. */
|
|
80
|
+
function startMove(e, onClick) {
|
|
81
|
+
const p0 = toCanvas(e), copy = e.altKey;
|
|
82
|
+
let items = [...sel].map(b => [b, geo(b)]);
|
|
83
|
+
const b0 = bbox([...sel].map(aabb)), nested = [...sel].some(parentBox); // members of a group move in its frame, without slide snapping
|
|
84
|
+
const others = boxes().filter(b => copy || !sel.has(b)).map(aabb);
|
|
85
|
+
let moved = false;
|
|
86
|
+
busy = true;
|
|
87
|
+
drag(e, ev => {
|
|
88
|
+
const p = toCanvas(ev);
|
|
89
|
+
let dx = p.x - p0.x, dy = p.y - p0.y;
|
|
90
|
+
if (!moved && Math.hypot(dx, dy) * scale < 3) return;
|
|
91
|
+
if (!moved && copy) {
|
|
92
|
+
items = items.map(([b, g]) => { const c = cleanBox(b); c.id = uid(); b.after(c); if (c.dataset.shape) renderShape(c); return [c, g]; });
|
|
93
|
+
sel = new Set(items.map(i => i[0]));
|
|
94
|
+
}
|
|
95
|
+
if (!moved && !nested) for (const [b] of items) { const gh = div('drag-ghost'); place(gh, aabb(b)); gh.style.setProperty('--k', 1 / scale + 'px'); guides.append(gh); } // where it was, as Slides
|
|
96
|
+
moved = true;
|
|
97
|
+
const lock = ev.shiftKey ? (Math.abs(dx) >= Math.abs(dy) ? 'y' : 'x') : null;
|
|
98
|
+
if (lock === 'x') dx = 0; else if (lock === 'y') dy = 0;
|
|
99
|
+
if (!nested && !ev.metaKey && !ev.ctrlKey) {
|
|
100
|
+
const at = { ...b0, x: b0.x + dx, y: b0.y + dy }, s = snap(at, others);
|
|
101
|
+
const grid = snapGrid(), qx = !grid && spaceSnap(at, others, 'x'), qy = !grid && spaceSnap(at, others, 'y'); // equal spacing wins when it is the closer snap
|
|
102
|
+
const ex = qx && (s.gx == null || Math.abs(qx.d) <= Math.abs(s.dx)), ey = qy && (s.gy == null || Math.abs(qy.d) <= Math.abs(s.dy));
|
|
103
|
+
if (lock !== 'x') dx += ex ? qx.d : s.dx;
|
|
104
|
+
if (lock !== 'y') dy += ey ? qy.d : s.dy;
|
|
105
|
+
const m = { ...b0, x: b0.x + dx, y: b0.y + dy };
|
|
106
|
+
showGuides(lock === 'x' || ex ? null : s.gx, lock === 'y' || ey ? null : s.gy, m, s.ox, s.oy, others);
|
|
107
|
+
if (ex && lock !== 'x') showSpacing(m, others, 'x', qx.gap);
|
|
108
|
+
if (ey && lock !== 'y') showSpacing(m, others, 'y', qy.gap);
|
|
109
|
+
// equal size, as when resizing: every object as wide (or as tall) as the one moving is marked in teal with it
|
|
110
|
+
if (items.length === 1) for (const key of ['w', 'h']) { const same = others.filter(o => Math.abs(o[key] - m[key]) < 0.5); if (same.length) for (const o of [m, ...same]) sizeMark(o, key); }
|
|
111
|
+
} else showGuides();
|
|
112
|
+
for (const [b, g] of items) { setGeo(b, { ...g, x: g.x + dx, y: g.y + dy }); if (isLine(b)) { delete b.dataset.from; delete b.dataset.to; } }
|
|
113
|
+
followConnectors();
|
|
114
|
+
drawOverlay();
|
|
115
|
+
}, () => {
|
|
116
|
+
busy = false; showGuides(); guides.querySelectorAll('.drag-ghost').forEach(e => e.remove());
|
|
117
|
+
if (moved) { if (editing && sel.has(editing)) { const b = editing; editing = null; commit(); startEdit(b); } else commit(); } // a dragged editing box stays in edit mode
|
|
118
|
+
else if (onClick) onClick();
|
|
119
|
+
else if (sel.size === 1 && [...sel][0].dataset.placeholder && !content([...sel][0]).textContent.trim()) startEdit([...sel][0]); // Slides: one click on an empty placeholder starts typing
|
|
120
|
+
else maybeReload();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Resize a multi-selection from its bounding box: every object scales about the fixed side (or the
|
|
125
|
+
centre with ⌥); ⇧ on a corner keeps the proportions. Text sizes don't change, as Slides. */
|
|
126
|
+
function startGroupResize(e, dir) {
|
|
127
|
+
const items = [...sel].map(b => [b, geo(b)]), g0 = bbox([...sel].map(aabb)), p0 = toCanvas(e);
|
|
128
|
+
busy = true;
|
|
129
|
+
drag(e, ev => {
|
|
130
|
+
const p = toCanvas(ev), k2 = ev.altKey ? 2 : 1, dx = (p.x - p0.x) * k2, dy = (p.y - p0.y) * k2;
|
|
131
|
+
let sx = dir.includes('e') ? (g0.w + dx) / g0.w : dir.includes('w') ? (g0.w - dx) / g0.w : 1;
|
|
132
|
+
let sy = dir.includes('s') ? (g0.h + dy) / g0.h : dir.includes('n') ? (g0.h - dy) / g0.h : 1;
|
|
133
|
+
sx = Math.max(20 / g0.w, sx || 1); sy = Math.max(20 / g0.h, sy || 1);
|
|
134
|
+
if (dir.length === 2 && ev.shiftKey) sx = sy = Math.max(sx, sy);
|
|
135
|
+
const ox = ev.altKey ? g0.x + g0.w / 2 : dir.includes('w') ? g0.x + g0.w : g0.x;
|
|
136
|
+
const oy = ev.altKey ? g0.y + g0.h / 2 : dir.includes('n') ? g0.y + g0.h : g0.y;
|
|
137
|
+
for (const [b, o] of items) {
|
|
138
|
+
const cx = ox + (o.x + o.w / 2 - ox) * sx, cy = oy + (o.y + o.h / 2 - oy) * sy, w = o.w * sx, h = o.h * sy;
|
|
139
|
+
setGeo(b, { x: cx - w / 2, y: cy - h / 2, w, h });
|
|
140
|
+
}
|
|
141
|
+
drawOverlay();
|
|
142
|
+
}, () => { busy = false; boxes().filter(b => sel.has(b)).forEach(fitHeight); drawOverlay(); commit(); });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* A line's two ends, as Slides sees them: each has a marker (data-start / data-end) and may be attached to a shape's
|
|
146
|
+
connection point (data-from / data-to = "boxId:t|r|b|l"). The attributes always describe the left end as the start. */
|
|
147
|
+
const lineEnds = b => {
|
|
148
|
+
const g = geo(b), up = b.dataset.dir === 'up';
|
|
149
|
+
return [{ x: g.x, y: up ? g.y + g.h : g.y, mark: b.dataset.start || 'none', at: b.dataset.from || '' },
|
|
150
|
+
{ x: g.x + g.w, y: up ? g.y : g.y + g.h, mark: b.dataset.end || (b.dataset.shape === 'arrowline' ? 'fill-arrow' : 'none'), at: b.dataset.to || '' }];
|
|
151
|
+
};
|
|
152
|
+
function setLineEnds(b, P, Q) { // P, Q: {x, y, mark, at}, in any order
|
|
153
|
+
const [l, r] = P.x <= Q.x ? [P, Q] : [Q, P];
|
|
154
|
+
if (b.dataset.shape === 'arrowline') b.dataset.shape = 'line'; // ends are explicit from now on
|
|
155
|
+
const put = (k, v, none) => { if (v && v !== none) b.dataset[k] = v; else delete b.dataset[k]; };
|
|
156
|
+
put('start', l.mark, 'none'); put('end', r.mark, 'none'); put('from', l.at); put('to', r.at);
|
|
157
|
+
if (r.y < l.y) b.dataset.dir = 'up'; else delete b.dataset.dir;
|
|
158
|
+
setGeo(b, { x: l.x, y: Math.min(l.y, r.y), w: r.x - l.x, h: Math.abs(r.y - l.y) });
|
|
159
|
+
}
|
|
160
|
+
/* Connection points, as Slides: the middle of each side of a shape (turned with it). */
|
|
161
|
+
function anchorPoint(b, side) {
|
|
162
|
+
const g = geo(b), t = rot(b) * Math.PI / 180, [fx, fy] = flipXY(b);
|
|
163
|
+
const [ox, oy] = { t: [0, -g.h / 2], r: [g.w / 2, 0], b: [0, g.h / 2], l: [-g.w / 2, 0] }[side];
|
|
164
|
+
const x = ox * fx, y = oy * fy;
|
|
165
|
+
return { x: g.x + g.w / 2 + x * Math.cos(t) - y * Math.sin(t), y: g.y + g.h / 2 + x * Math.sin(t) + y * Math.cos(t) };
|
|
166
|
+
}
|
|
167
|
+
const anchorOf = ref => { const [id, side] = (ref || '').split(':'), b = id && document.getElementById(id); return b && slides[cur].contains(b) && 'trbl'.includes(side) ? anchorPoint(b, side) : null; };
|
|
168
|
+
/* The connection point near p (within 12 screen px), and dots on the shape under the pointer, as Slides shows while drawing. */
|
|
169
|
+
function snapAnchor(p, line) {
|
|
170
|
+
guides.querySelectorAll('.conn').forEach(e => e.remove());
|
|
171
|
+
const k = 1 / scale, near = boxes().filter(o => o !== line && !isStroke(o) && !isGroup(o)).filter(o => { const a = aabb(o); return p.x > a.x - 24 * k && p.x < a.x + a.w + 24 * k && p.y > a.y - 24 * k && p.y < a.y + a.h + 24 * k; });
|
|
172
|
+
let best = null;
|
|
173
|
+
for (const o of near) for (const side of 'trbl') {
|
|
174
|
+
const q = anchorPoint(o, side), d = Math.hypot(q.x - p.x, q.y - p.y), dot = div('conn');
|
|
175
|
+
place(dot, { x: q.x - 4 * k, y: q.y - 4 * k, w: 8 * k, h: 8 * k }); dot.style.setProperty('--k', k + 'px'); guides.append(dot);
|
|
176
|
+
if (d < CONNECT_PX * k && (!best || d < best.d)) best = { d, q, ref: o.id + ':' + side, dot };
|
|
177
|
+
}
|
|
178
|
+
best?.dot.classList.add('on');
|
|
179
|
+
return best;
|
|
180
|
+
}
|
|
181
|
+
/* Attached lines follow their shapes (after a move, resize or rotate) and lose attachments to deleted shapes. */
|
|
182
|
+
function followConnectors() {
|
|
183
|
+
for (const b of boxes().filter(x => isLine(x) && (x.dataset.from || x.dataset.to))) {
|
|
184
|
+
const [P, Q] = lineEnds(b), a = anchorOf(P.at), c = anchorOf(Q.at);
|
|
185
|
+
if (P.at && !a) P.at = ''; if (Q.at && !c) Q.at = '';
|
|
186
|
+
// only one end attached: the whole line moves with the shape, as Slides (the free end doesn't stay behind)
|
|
187
|
+
const shift = (E, to) => to && !(a && c) ? { ...E, x: E.x + to.x - (to === a ? P : Q).x, y: E.y + to.y - (to === a ? P : Q).y } : E;
|
|
188
|
+
setLineEnds(b, { ...shift(P, c), ...(a || {}) }, { ...shift(Q, a), ...(c || {}) });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Drag one end of a line, as Slides: the other end stays put and the line may swing past it (its box, data-dir and
|
|
193
|
+
the ends' markers / attachments follow). Near a shape the end snaps to its connection points. ⇧ keeps 45° steps. */
|
|
194
|
+
function startLineEnd(e, dir) {
|
|
195
|
+
const b = [...sel][0], [S, E] = lineEnds(b), movingEnd = dir === 'ne' || dir === 'se', fixed = movingEnd ? S : E, moving = movingEnd ? E : S;
|
|
196
|
+
busy = true;
|
|
197
|
+
drag(e, ev => {
|
|
198
|
+
let p = toCanvas(ev);
|
|
199
|
+
if (ev.shiftKey) { // 45° steps
|
|
200
|
+
const a = Math.round(Math.atan2(p.y - fixed.y, p.x - fixed.x) / (Math.PI / 4)) * Math.PI / 4, r = Math.hypot(p.x - fixed.x, p.y - fixed.y);
|
|
201
|
+
p = { x: fixed.x + Math.cos(a) * r, y: fixed.y + Math.sin(a) * r };
|
|
202
|
+
}
|
|
203
|
+
const snap = ev.metaKey ? null : snapAnchor(p, b);
|
|
204
|
+
setLineEnds(b, fixed, { ...moving, ...(snap ? snap.q : p), at: snap ? snap.ref : '' });
|
|
205
|
+
drawOverlay();
|
|
206
|
+
}, () => { busy = false; guides.querySelectorAll('.conn').forEach(e => e.remove()); commit(); });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* Drag a yellow handle (in the box's own frame, so rotated / flipped boxes work): its preset adj values follow the pointer. */
|
|
210
|
+
function startAdjust(e, i) {
|
|
211
|
+
const b = [...sel][0], g = geo(b), t = rot(b) * Math.PI / 180, [fx, fy] = flipXY(b), c = { x: g.x + g.w / 2, y: g.y + g.h / 2 };
|
|
212
|
+
busy = true;
|
|
213
|
+
drag(e, ev => {
|
|
214
|
+
const p = toCanvas(ev), dx = p.x - c.x, dy = p.y - c.y;
|
|
215
|
+
const lx = fx * (dx * Math.cos(t) + dy * Math.sin(t)) + g.w / 2, ly = fy * (-dx * Math.sin(t) + dy * Math.cos(t)) + g.h / 2;
|
|
216
|
+
Object.assign(b.dataset, shapeAdjust(b)[i].set(lx, ly)); renderShape(b); drawOverlay();
|
|
217
|
+
}, () => { busy = false; commit(); });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let dragDir = '';
|
|
221
|
+
function startResize(e, dir) {
|
|
222
|
+
dragDir = dir;
|
|
223
|
+
|
|
224
|
+
if (sel.size > 1) return startGroupResize(e, dir);
|
|
225
|
+
if (isLine([...sel][0])) return startLineEnd(e, dir);
|
|
226
|
+
const b = [...sel][0], g0 = geo(b), p0 = toCanvas(e), ratio = g0.w / (g0.h || 1);
|
|
227
|
+
const image = isMedia(b), line = isLine(b), min = line ? 0 : 20;
|
|
228
|
+
const t = rot(b) * Math.PI / 180, cos = Math.cos(t), sin = Math.sin(t), [fx, fy] = flipXY(b);
|
|
229
|
+
// The handle opposite the dragged one stays put: (sx, sy) is its side in box-local units of half-size.
|
|
230
|
+
const sx = dir.includes('w') ? 1 : dir.includes('e') ? -1 : 0, sy = dir.includes('n') ? 1 : dir.includes('s') ? -1 : 0;
|
|
231
|
+
const c0 = { x: g0.x + g0.w / 2, y: g0.y + g0.h / 2 };
|
|
232
|
+
const anchor = { x: c0.x + fx * (cos * sx * g0.w / 2 - sin * sy * g0.h / 2), y: c0.y + fy * (sin * sx * g0.w / 2 + cos * sy * g0.h / 2) };
|
|
233
|
+
busy = true;
|
|
234
|
+
drag(e, ev => {
|
|
235
|
+
const p = toCanvas(ev), wx = fx * (p.x - p0.x), wy = fy * (p.y - p0.y);
|
|
236
|
+
const dx = cos * wx + sin * wy, dy = -sin * wx + cos * wy; // pointer delta in the box's (unflipped, unrotated) frame
|
|
237
|
+
let w = g0.w, h = g0.h;
|
|
238
|
+
const k2 = ev.altKey ? 2 : 1; // ⌥: resize from the centre (both sides move)
|
|
239
|
+
if (dir.includes('e')) w = g0.w + dx * k2;
|
|
240
|
+
if (dir.includes('w')) w = g0.w - dx * k2;
|
|
241
|
+
if (dir.includes('s')) h = g0.h + dy * k2;
|
|
242
|
+
if (dir.includes('n')) h = g0.h - dy * k2;
|
|
243
|
+
w = Math.max(min, w); h = Math.max(min, h);
|
|
244
|
+
const keep = dir.length === 2 && g0.h > 0 && (image || b.dataset.lock === 'aspect') !== ev.shiftKey;
|
|
245
|
+
if (keep) { // images and locked boxes keep aspect unless ⇧; others keep it with ⇧
|
|
246
|
+
if (w / g0.w > h / g0.h) h = w / ratio; else w = h * ratio;
|
|
247
|
+
}
|
|
248
|
+
// Equal size, as Slides: an edge being dragged snaps the size to another box's, which is marked in teal;
|
|
249
|
+
// or, when closer, the dragged edge snaps to another box's edge / centre or the slide's (a red line, as when moving)
|
|
250
|
+
const same = { w: null, h: null }, line2 = { x: null, y: null };
|
|
251
|
+
if (snapGrid() && !t && !keep && !ev.altKey && !line && fx === 1 && fy === 1) { // the dragged edge lands on the grid
|
|
252
|
+
if (/[ew]/.test(dir)) w = dir.includes('e') ? onGrid(g0.x + w) - g0.x : g0.x + g0.w - onGrid(g0.x + g0.w - w);
|
|
253
|
+
if (/[ns]/.test(dir)) h = dir.includes('s') ? onGrid(g0.y + h) - g0.y : g0.y + g0.h - onGrid(g0.y + g0.h - h);
|
|
254
|
+
w = Math.max(min, w); h = Math.max(min, h);
|
|
255
|
+
} else if (!t && !keep && !ev.altKey && !ev.metaKey && !line) {
|
|
256
|
+
const others = boxes().filter(o => o !== b).map(aabb), th = SNAP_PX / scale, { xs, ys } = snapLines(others);
|
|
257
|
+
const near = (vals, v) => vals.reduce((m, c) => Math.abs(c - v) < Math.abs(m - v) ? c : m, Infinity);
|
|
258
|
+
for (const [d, key] of /** @type {[RegExp, string][]} */ ([[/[ew]/, 'w'], [/[ns]/, 'h']])) {
|
|
259
|
+
if (!d.test(dir)) continue;
|
|
260
|
+
const size = key === 'w' ? w : h, o = others.filter(o => Math.abs(o[key] - size) < th).sort((a, c) => Math.abs(a[key] - size) - Math.abs(c[key] - size))[0];
|
|
261
|
+
let best = o ? Math.abs(o[key] - size) : th;
|
|
262
|
+
if (fx === 1 && fy === 1) { // the moving edge, in slide units
|
|
263
|
+
const [lo, len, lines] = key === 'w' ? [g0.x, g0.w, xs] : [g0.y, g0.h, ys], grow = key === 'w' ? dir.includes('e') : dir.includes('s');
|
|
264
|
+
const edge = grow ? lo + size : lo + len - size, c = near(lines, edge);
|
|
265
|
+
if (Math.abs(c - edge) < best) { best = Math.abs(c - edge); const ns = grow ? c - lo : lo + len - c; if (key === 'w') w = ns; else h = ns; line2[key === 'w' ? 'x' : 'y'] = c; continue; }
|
|
266
|
+
}
|
|
267
|
+
if (o) { if (key === 'w') w = o.w; else h = o.h; same[key] = others.filter(x => Math.abs(x[key] - o[key]) < 0.5); }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const [cx, cy] = ev.altKey ? [c0.x, c0.y]
|
|
271
|
+
: [anchor.x - fx * (cos * sx * w / 2 - sin * sy * h / 2), anchor.y - fy * (sin * sx * w / 2 + cos * sy * h / 2)];
|
|
272
|
+
setGeo(b, { x: cx - w / 2, y: cy - h / 2, w, h });
|
|
273
|
+
showGuides(line2.x, line2.y);
|
|
274
|
+
for (const key of ['w', 'h']) if (same[key]) for (const o of [geo(b), ...same[key]]) sizeMark(o, key);
|
|
275
|
+
drawOverlay();
|
|
276
|
+
}, () => {
|
|
277
|
+
busy = false; showGuides();
|
|
278
|
+
if (!b.dataset.shape && !image && /[ns]/.test(dir)) b.dataset.minh = geo(b).h;
|
|
279
|
+
if (!image) fitHeight(b); // as Slides, a box that fits its text never ends shorter than the text: it grows back to it on release
|
|
280
|
+
commit();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* Drag the handle above the box to rotate it about its centre; ⇧ snaps to 15°. As Slides, the box stays put
|
|
285
|
+
while a grey outline shows the new angle with a degree label, and the rotation applies on release. */
|
|
286
|
+
function startRotate(e) {
|
|
287
|
+
const b = [...sel][0], g = geo(b), cx = g.x + g.w / 2, cy = g.y + g.h / 2;
|
|
288
|
+
const r0 = rot(b), p0 = toCanvas(e), a0 = Math.atan2(p0.y - cy, p0.x - cx), k = 1 / scale;
|
|
289
|
+
const ghost = b.cloneNode(true), label = div('rot-label');
|
|
290
|
+
ghost.removeAttribute('id'); ghost.classList.add('rot-ghost'); ghost.style.setProperty('--k', k);
|
|
291
|
+
Object.assign(label.style, { left: g.x + g.w - 6 * k + 'px', top: g.y + 2 * k + 'px', fontSize: 11 * k + 'px', translate: '-100%' }); // inside the top-right corner, as Slides
|
|
292
|
+
let deg = r0;
|
|
293
|
+
busy = true;
|
|
294
|
+
drag(e, ev => {
|
|
295
|
+
const p = toCanvas(ev);
|
|
296
|
+
const [fx, fy] = flipXY(b); // a mirrored box turns the other way
|
|
297
|
+
deg = r0 + fx * fy * (Math.atan2(p.y - cy, p.x - cx) - a0) * 180 / Math.PI;
|
|
298
|
+
if (ev.shiftKey) deg = Math.round(deg / 15) * 15;
|
|
299
|
+
else { const m = Math.round(deg / 45) * 45; if (Math.abs(deg - m) < ROT_SNAP) deg = m; } // clicks into 0°, 45°, 90°… as Slides (±2°)
|
|
300
|
+
setRot(ghost, deg); label.textContent = (PHONE ? Math.round(rot(ghost)) : rot(ghost)) + '°'; // the phone app shows whole degrees (r02)
|
|
301
|
+
if (!ghost.isConnected) guides.append(ghost, label);
|
|
302
|
+
}, () => { busy = false; ghost.remove(); label.remove(); if (deg !== r0) { setRot(b, deg); drawOverlay(); commit(); } });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function startMarquee(e) {
|
|
306
|
+
const p0 = toCanvas(e), m = div('marquee');
|
|
307
|
+
guides.append(m);
|
|
308
|
+
busy = true;
|
|
309
|
+
drag(e, ev => {
|
|
310
|
+
const p = toCanvas(ev);
|
|
311
|
+
const r = { x: Math.min(p0.x, p.x), y: Math.min(p0.y, p.y), w: Math.abs(p.x - p0.x), h: Math.abs(p.y - p0.y) };
|
|
312
|
+
place(m, r); m.style.borderWidth = 1 / scale + 'px';
|
|
313
|
+
sel = new Set(pickable().filter(b => {
|
|
314
|
+
const g = aabb(b);
|
|
315
|
+
return g.x < r.x + r.w && g.x + g.w > r.x && g.y < r.y + r.h && g.y + g.h > r.y;
|
|
316
|
+
}));
|
|
317
|
+
drawOverlay();
|
|
318
|
+
}, () => { busy = false; m.remove(); updateToolbar(); maybeReload(); });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
stage.addEventListener('pointerdown', e => {
|
|
322
|
+
if (e.button !== 0) return;
|
|
323
|
+
if (document.activeElement?.matches('input, select') || filmFocused()) document.activeElement.blur(); // keys now act on the canvas, not the filmstrip
|
|
324
|
+
const handle = e.target.closest('.handle');
|
|
325
|
+
const target = topBox(e.target);
|
|
326
|
+
if (paint && target && target !== editing && !handle) { // paint format: apply to the clicked box
|
|
327
|
+
e.preventDefault(); stopEdit();
|
|
328
|
+
paintBox(target); sel = new Set([target]); drawOverlay(); commit();
|
|
329
|
+
if (!paint.locked) endPaint();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (handle?.classList.contains('rotate')) return startRotate(e);
|
|
333
|
+
if (handle?.classList.contains('adjust')) return startAdjust(e, +handle.dataset.i);
|
|
334
|
+
if (handle && 'crop' in handle.dataset) return startCrop(e, handle.dataset.dir);
|
|
335
|
+
if (handle) return startResize(e, handle.dataset.dir);
|
|
336
|
+
if (cropping && topBox(e.target) !== cropping) endCrop();
|
|
337
|
+
const b = topBox(e.target);
|
|
338
|
+
if (scope && !(b && b !== scope && scope.contains(b))) scope = null; // a click outside the entered group leaves it
|
|
339
|
+
if (editing) {
|
|
340
|
+
if (b === editing) { // let the browser place the caret / select text
|
|
341
|
+
// An empty box (just the placeholder prompt) has no text to select, so pressing and dragging on it moves
|
|
342
|
+
// it, as Slides; a plain click keeps the caret. Dragging near the edge moves a box with text too, but only in
|
|
343
|
+
// its margin: over the text itself (a title's first letter sits a few px in) a drag selects.
|
|
344
|
+
const g = aabb(b), p = toCanvas(e), edge = 14 / scale;
|
|
345
|
+
const c = content(b), cr = c.getBoundingClientRect(), cs = getComputedStyle(c), k = cr.width / (c.offsetWidth || 1);
|
|
346
|
+
const overText = e.clientX >= cr.left + parseFloat(cs.paddingLeft) * k - 2 && e.clientX <= cr.right - parseFloat(cs.paddingRight) * k + 2
|
|
347
|
+
&& e.clientY >= cr.top + parseFloat(cs.paddingTop) * k - 2 && e.clientY <= cr.bottom - parseFloat(cs.paddingBottom) * k + 2;
|
|
348
|
+
const nearEdge = !overText && (p.x < g.x + edge || p.x > g.x + g.w - edge || p.y < g.y + edge || p.y > g.y + g.h - edge);
|
|
349
|
+
if (!content(b).textContent.trim() || nearEdge) {
|
|
350
|
+
e.preventDefault();
|
|
351
|
+
sel = new Set([b]);
|
|
352
|
+
const keep = b;
|
|
353
|
+
startMove(e, () => { if (editing === keep) content(keep).focus(); }); // not moved: stay in edit mode with the caret
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!e.target.closest('.content')) { // empty part of the box: caret to the end, as Slides
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
const r = document.createRange(); r.selectNodeContents(content(b)); r.collapse(false);
|
|
359
|
+
getSelection().removeAllRanges(); getSelection().addRange(r);
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
stopEdit();
|
|
364
|
+
}
|
|
365
|
+
if (b) {
|
|
366
|
+
if (e.shiftKey) {
|
|
367
|
+
sel.has(b) ? sel.delete(b) : sel.add(b);
|
|
368
|
+
drawOverlay(); updateToolbar(); e.preventDefault();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (!sel.has(b)) sel = new Set([b]);
|
|
372
|
+
drawOverlay(); updateToolbar();
|
|
373
|
+
startMove(e);
|
|
374
|
+
} else {
|
|
375
|
+
sel.clear(); drawOverlay(); updateToolbar();
|
|
376
|
+
startMarquee(e);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
/* Thin outline on the box under the pointer, as Slides does for unselected boxes. */
|
|
380
|
+
const hoverEl = div('hover-box');
|
|
381
|
+
canvas.addEventListener('pointermove', e => {
|
|
382
|
+
const b = busy ? null : topBox(e.target);
|
|
383
|
+
if (!b || sel.has(b) || b === editing) { hoverEl.remove(); return; }
|
|
384
|
+
place(hoverEl, geo(b)); hoverEl.style.transform = b.style.transform; hoverEl.style.scale = flipXY(b).join(' ');
|
|
385
|
+
hoverEl.style.setProperty('--k', 1 / scale + 'px');
|
|
386
|
+
guides.append(hoverEl);
|
|
387
|
+
});
|
|
388
|
+
canvas.addEventListener('pointerleave', () => hoverEl.remove());
|
|
389
|
+
canvas.addEventListener('pointerdown', () => hoverEl.remove());
|
|
390
|
+
|
|
391
|
+
const caretAt = (x, y) => {
|
|
392
|
+
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
|
|
393
|
+
const p = document.caretPositionFromPoint?.(x, y);
|
|
394
|
+
if (!p) return null;
|
|
395
|
+
const r = document.createRange(); r.setStart(p.offsetNode, p.offset); return r;
|
|
396
|
+
};
|
|
397
|
+
canvas.addEventListener('dblclick', e => {
|
|
398
|
+
const b = topBox(e.target);
|
|
399
|
+
if (b && b.classList.contains('image')) { enterCrop(b); return; } // double-click a picture: crop it, as Slides
|
|
400
|
+
if (b && b.classList.contains('live')) { editLive(b); return; } // a live box: its page
|
|
401
|
+
if (b && isGroup(b)) { // enter the group: the member under the pointer gets selected (Esc goes back out)
|
|
402
|
+
scope = b; const m = topBox(e.target);
|
|
403
|
+
if (m && m !== b) { sel = new Set([m]); drawOverlay(); updateToolbar(); }
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (b && b !== editing) startEdit(b, { x: e.clientX, y: e.clientY });
|
|
407
|
+
});
|