inkflow 0.1.0.dev0__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.
- inkflow/__init__.py +31 -0
- inkflow/cli.py +266 -0
- inkflow/content.py +227 -0
- inkflow/export.py +123 -0
- inkflow/layout.py +279 -0
- inkflow/manifest.py +145 -0
- inkflow/markdown.py +158 -0
- inkflow/ns.py +9 -0
- inkflow/pdf.html +19 -0
- inkflow/pipeline.py +244 -0
- inkflow/presenter.css +196 -0
- inkflow/presenter.html +50 -0
- inkflow/presenter.js +400 -0
- inkflow/server.py +380 -0
- inkflow/theme/layouts/center.svg +3 -0
- inkflow/theme/layouts/cover.svg +5 -0
- inkflow/theme/layouts/default.svg +4 -0
- inkflow/theme/layouts/end.svg +4 -0
- inkflow/theme/layouts/fact.svg +4 -0
- inkflow/theme/layouts/media-left.svg +5 -0
- inkflow/theme/layouts/media-right.svg +5 -0
- inkflow/theme/layouts/quote.svg +4 -0
- inkflow/theme/layouts/section.svg +4 -0
- inkflow/theme/layouts/statement.svg +3 -0
- inkflow/theme/layouts/two-cols-header.svg +5 -0
- inkflow/theme/layouts/two-cols.svg +5 -0
- inkflow/theme/main.svg +3 -0
- inkflow/theme/numbered-main.svg +6 -0
- inkflow/theme/showcase/deck.py +18 -0
- inkflow/theme/showcase/slides/01-cover.md +7 -0
- inkflow/theme/showcase/slides/02-section.md +7 -0
- inkflow/theme/showcase/slides/03-default.md +11 -0
- inkflow/theme/showcase/slides/04-center.md +8 -0
- inkflow/theme/showcase/slides/05-two-cols.md +17 -0
- inkflow/theme/showcase/slides/06-two-cols-header.md +13 -0
- inkflow/theme/showcase/slides/07-fact.md +7 -0
- inkflow/theme/showcase/slides/08-quote.md +7 -0
- inkflow/theme/showcase/slides/09-statement.md +3 -0
- inkflow/theme/showcase/slides/10-media-left.md +15 -0
- inkflow/theme/showcase/slides/11-media-right.md +16 -0
- inkflow/theme/showcase/slides/12-end.md +3 -0
- inkflow/theme/styles.css +55 -0
- inkflow/tui.py +128 -0
- inkflow-0.1.0.dev0.dist-info/METADATA +12 -0
- inkflow-0.1.0.dev0.dist-info/RECORD +48 -0
- inkflow-0.1.0.dev0.dist-info/WHEEL +4 -0
- inkflow-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- inkflow-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
inkflow/presenter.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// ── Injected by server ──
|
|
2
|
+
const INITIAL_SLIDES = __SLIDES_JSON__;
|
|
3
|
+
const INITIAL_TRANSITIONS = __TRANSITIONS_JSON__;
|
|
4
|
+
const WS_PORT = __WS_PORT__;
|
|
5
|
+
const INITIAL_ERROR = __ERROR_JSON__;
|
|
6
|
+
|
|
7
|
+
// ── State ──
|
|
8
|
+
let slides = INITIAL_SLIDES;
|
|
9
|
+
let transitions = INITIAL_TRANSITIONS;
|
|
10
|
+
let slideIndex = 0;
|
|
11
|
+
let step = 0;
|
|
12
|
+
let _maxStepCache = null;
|
|
13
|
+
let gotoMode = false;
|
|
14
|
+
let gotoBuffer = '';
|
|
15
|
+
|
|
16
|
+
// ── DOM refs ──
|
|
17
|
+
const stage = document.getElementById('stage');
|
|
18
|
+
const slideInfo = document.getElementById('slide-info');
|
|
19
|
+
const stepInfo = document.getElementById('step-info');
|
|
20
|
+
const wsDot = document.getElementById('ws-dot');
|
|
21
|
+
const wsLabel = document.getElementById('ws-label');
|
|
22
|
+
const curtain = document.getElementById('curtain');
|
|
23
|
+
const help = document.getElementById('help');
|
|
24
|
+
const errorOverlay = document.getElementById('error-overlay');
|
|
25
|
+
const errorMsg = document.getElementById('error-msg');
|
|
26
|
+
|
|
27
|
+
// ── Helpers ──
|
|
28
|
+
function maxStep() {
|
|
29
|
+
if (_maxStepCache !== null) return _maxStepCache;
|
|
30
|
+
let m = 0;
|
|
31
|
+
stage.querySelectorAll('[data-step]').forEach(el => {
|
|
32
|
+
const s = +el.getAttribute('data-step');
|
|
33
|
+
if (s > m) m = s;
|
|
34
|
+
});
|
|
35
|
+
return (_maxStepCache = m);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function syncURL() {
|
|
39
|
+
const search = step > 0 ? `?clicks=${step}` : '';
|
|
40
|
+
try {
|
|
41
|
+
history.replaceState(null, '', `/${slideIndex + 1}${search}`);
|
|
42
|
+
} catch (_) {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readURL() {
|
|
46
|
+
const seg = window.location.pathname.replace(/^\//, '');
|
|
47
|
+
const n = parseInt(seg, 10);
|
|
48
|
+
if (!isNaN(n) && n >= 1 && n <= slides.length) slideIndex = n - 1;
|
|
49
|
+
const clicks = parseInt(new URLSearchParams(window.location.search).get('clicks') ?? '0', 10);
|
|
50
|
+
if (!isNaN(clicks) && clicks >= 0) step = clicks;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function updateStatus() {
|
|
54
|
+
slideInfo.textContent = gotoMode
|
|
55
|
+
? `g: ${gotoBuffer}_`
|
|
56
|
+
: `${slideIndex + 1} / ${slides.length}`;
|
|
57
|
+
stepInfo.textContent = `step ${step}`;
|
|
58
|
+
syncURL();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Toggle .active on already-loaded SVG elements — triggers CSS transitions.
|
|
62
|
+
// Never touches innerHTML, so transitions fire correctly.
|
|
63
|
+
function applyStep() {
|
|
64
|
+
stage.querySelectorAll('[data-step]').forEach(el =>
|
|
65
|
+
el.classList.toggle('active', +el.getAttribute('data-step') <= step)
|
|
66
|
+
);
|
|
67
|
+
updateStatus();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Morph transition ──
|
|
71
|
+
// Drives matched IDs via a rAF loop that sets SVG geometry attributes directly in
|
|
72
|
+
// SVG user units — no CSS px ↔ SVG unit conversion, no coordinate space ambiguity.
|
|
73
|
+
// Unmatched new elements fade in; unmatched old elements disappear immediately.
|
|
74
|
+
|
|
75
|
+
function _geomAttrs(el) {
|
|
76
|
+
const g = k => parseFloat(el.getAttribute(k) ?? '0');
|
|
77
|
+
switch (el.tagName.toLowerCase()) {
|
|
78
|
+
case 'rect': return { x: g('x'), y: g('y'), width: g('width'), height: g('height'), rx: g('rx') };
|
|
79
|
+
case 'circle': return { cx: g('cx'), cy: g('cy'), r: g('r') };
|
|
80
|
+
case 'ellipse': return { cx: g('cx'), cy: g('cy'), rx: g('rx'), ry: g('ry') };
|
|
81
|
+
default: return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _parseHexColor(s) {
|
|
86
|
+
const h = (s ?? '').replace('#', '');
|
|
87
|
+
if (h.length === 3) return h.split('').map(c => parseInt(c + c, 16));
|
|
88
|
+
if (h.length === 6) return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function _lerpColor(a, b, t) {
|
|
93
|
+
const ca = _parseHexColor(a), cb = _parseHexColor(b);
|
|
94
|
+
if (!ca || !cb) return t < 0.5 ? a : b;
|
|
95
|
+
return '#' + ca.map((c, i) => Math.round(c + (cb[i] - c) * t).toString(16).padStart(2, '0')).join('');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _ease(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2; }
|
|
99
|
+
|
|
100
|
+
function morphSlide(duration, then) {
|
|
101
|
+
const ms = duration * 1000;
|
|
102
|
+
|
|
103
|
+
// 1. Snapshot old elements in SVG user units before swap
|
|
104
|
+
const fromMap = new Map();
|
|
105
|
+
stage.querySelectorAll('[id]').forEach(el =>
|
|
106
|
+
fromMap.set(el.id, {
|
|
107
|
+
tag: el.tagName.toLowerCase(),
|
|
108
|
+
geom: _geomAttrs(el),
|
|
109
|
+
fill: el.getAttribute('fill'),
|
|
110
|
+
stroke: el.getAttribute('stroke'),
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// 2. Swap to new slide
|
|
115
|
+
stage.innerHTML = slides[slideIndex];
|
|
116
|
+
_maxStepCache = null;
|
|
117
|
+
const newSvg = stage.querySelector('svg');
|
|
118
|
+
if (!newSvg) { updateStatus(); if (then) then(); return; }
|
|
119
|
+
updateStatus();
|
|
120
|
+
|
|
121
|
+
// 3. Build task list; snap morph elements to old positions before first paint
|
|
122
|
+
const tasks = [];
|
|
123
|
+
const seenIds = new Set();
|
|
124
|
+
newSvg.querySelectorAll('[id]').forEach(el => {
|
|
125
|
+
seenIds.add(el.id);
|
|
126
|
+
const from = fromMap.get(el.id);
|
|
127
|
+
const toGeom = _geomAttrs(el);
|
|
128
|
+
if (from && from.geom && toGeom && from.tag === el.tagName.toLowerCase()) {
|
|
129
|
+
// Capture target color before overwriting with old values
|
|
130
|
+
const toFill = el.getAttribute('fill');
|
|
131
|
+
const toStroke = el.getAttribute('stroke');
|
|
132
|
+
// Snap to old geometry so first paint shows old position
|
|
133
|
+
for (const [k, v] of Object.entries(from.geom)) el.setAttribute(k, v);
|
|
134
|
+
if (from.fill) el.setAttribute('fill', from.fill);
|
|
135
|
+
if (from.stroke) el.setAttribute('stroke', from.stroke);
|
|
136
|
+
tasks.push({ type: 'morph', el, from, toGeom, toFill, toStroke });
|
|
137
|
+
} else if (!from) {
|
|
138
|
+
// New element — fade in
|
|
139
|
+
el.style.opacity = '0';
|
|
140
|
+
tasks.push({ type: 'fade', el, toOpacity: parseFloat(el.getAttribute('opacity') ?? '1') });
|
|
141
|
+
}
|
|
142
|
+
// matched but unmorphable (text, group, path) → instant cut, leave as-is
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Exit elements: had geometry on old slide, absent from new — ghost them in and fade out
|
|
146
|
+
for (const [id, from] of fromMap) {
|
|
147
|
+
if (seenIds.has(id) || !from.geom) continue;
|
|
148
|
+
const ghost = document.createElementNS('http://www.w3.org/2000/svg', from.tag);
|
|
149
|
+
for (const [k, v] of Object.entries(from.geom)) ghost.setAttribute(k, v);
|
|
150
|
+
if (from.fill) ghost.setAttribute('fill', from.fill);
|
|
151
|
+
if (from.stroke) ghost.setAttribute('stroke', from.stroke);
|
|
152
|
+
newSvg.appendChild(ghost);
|
|
153
|
+
tasks.push({ type: 'exit', el: ghost });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. Drive animation via requestAnimationFrame
|
|
157
|
+
const t0 = performance.now();
|
|
158
|
+
function frame(now) {
|
|
159
|
+
const raw = Math.min((now - t0) / ms, 1);
|
|
160
|
+
const e = _ease(raw);
|
|
161
|
+
for (const task of tasks) {
|
|
162
|
+
if (task.type === 'morph') {
|
|
163
|
+
for (const k of Object.keys(task.toGeom))
|
|
164
|
+
task.el.setAttribute(k, task.from.geom[k] + (task.toGeom[k] - task.from.geom[k]) * e);
|
|
165
|
+
if (task.from.fill && task.toFill) task.el.setAttribute('fill', _lerpColor(task.from.fill, task.toFill, e));
|
|
166
|
+
if (task.from.stroke && task.toStroke) task.el.setAttribute('stroke', _lerpColor(task.from.stroke, task.toStroke, e));
|
|
167
|
+
} else if (task.type === 'exit') {
|
|
168
|
+
task.el.style.opacity = String(1 - _ease(Math.min(raw / 0.7, 1)));
|
|
169
|
+
} else {
|
|
170
|
+
task.el.style.opacity = String(_ease(Math.max(0, Math.min((raw - 0.3) / 0.5, 1))) * task.toOpacity);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (raw < 1) { requestAnimationFrame(frame); return; }
|
|
174
|
+
// Restore final attribute state cleanly
|
|
175
|
+
for (const task of tasks) {
|
|
176
|
+
if (task.type === 'morph') {
|
|
177
|
+
for (const [k, v] of Object.entries(task.toGeom)) task.el.setAttribute(k, v);
|
|
178
|
+
if (task.toFill) task.el.setAttribute('fill', task.toFill);
|
|
179
|
+
if (task.toStroke) task.el.setAttribute('stroke', task.toStroke);
|
|
180
|
+
} else if (task.type === 'exit') {
|
|
181
|
+
task.el.remove();
|
|
182
|
+
} else {
|
|
183
|
+
task.el.style.opacity = '';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (then) then();
|
|
187
|
+
}
|
|
188
|
+
requestAnimationFrame(frame);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const TRANSITIONS = {
|
|
192
|
+
morph(swap, t, then) {
|
|
193
|
+
if (t.duration > 0 && slides.length) { morphSlide(t.duration, then); return; }
|
|
194
|
+
swap(); if (then) then();
|
|
195
|
+
},
|
|
196
|
+
crossfade(swap, t, then) {
|
|
197
|
+
if (t.duration <= 0) { swap(); if (then) then(); return; }
|
|
198
|
+
stage.style.transition = `opacity ${t.duration}s ease`;
|
|
199
|
+
stage.style.opacity = '0';
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
swap();
|
|
202
|
+
requestAnimationFrame(() => { stage.style.opacity = '1'; if (then) then(); });
|
|
203
|
+
}, t.duration * 1000);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Replace innerHTML with new slide content. Does NOT call applyStep() —
|
|
208
|
+
// elements start in their pre-transition state so the next advance() triggers
|
|
209
|
+
// a real animated transition. Optional `then` runs after content is swapped.
|
|
210
|
+
// Pass `transition` to override the destination slide's declared transition (used
|
|
211
|
+
// when navigating backward so the outgoing slide's transition plays in reverse).
|
|
212
|
+
function loadSlide(then = null, transition = null) {
|
|
213
|
+
const swap = () => {
|
|
214
|
+
stage.innerHTML = slides.length ? slides[slideIndex] : '<p style="color:var(--accent);padding:2rem">No slides.</p>';
|
|
215
|
+
_maxStepCache = null;
|
|
216
|
+
updateStatus();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const t = transition ?? transitions[slideIndex] ?? { type: 'cut', duration: 0 };
|
|
220
|
+
const handler = TRANSITIONS[t.type];
|
|
221
|
+
if (handler) { handler(swap, t, then); return; }
|
|
222
|
+
|
|
223
|
+
stage.style.transition = 'none';
|
|
224
|
+
stage.style.opacity = '1';
|
|
225
|
+
swap();
|
|
226
|
+
if (then) then();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Navigation ──
|
|
230
|
+
function advance() {
|
|
231
|
+
if (step < maxStep()) {
|
|
232
|
+
step++;
|
|
233
|
+
applyStep();
|
|
234
|
+
} else if (slideIndex < slides.length - 1) {
|
|
235
|
+
slideIndex++;
|
|
236
|
+
step = 0;
|
|
237
|
+
loadSlide();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function retreat() {
|
|
242
|
+
if (step > 0) {
|
|
243
|
+
step--;
|
|
244
|
+
applyStep();
|
|
245
|
+
} else if (slideIndex > 0) {
|
|
246
|
+
const t = transitions[slideIndex];
|
|
247
|
+
slideIndex--;
|
|
248
|
+
step = 0;
|
|
249
|
+
loadSlide(() => { step = maxStep(); applyStep(); }, t);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function nextSlide() {
|
|
254
|
+
if (slideIndex < slides.length - 1) { slideIndex++; step = 0; loadSlide(); }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function prevSlide() {
|
|
258
|
+
if (slideIndex > 0) {
|
|
259
|
+
const t = transitions[slideIndex];
|
|
260
|
+
slideIndex--;
|
|
261
|
+
step = 0;
|
|
262
|
+
loadSlide(null, t);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function toggleFullscreen() {
|
|
267
|
+
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
|
|
268
|
+
else document.exitFullscreen();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function showCurtain(color) { curtain.style.background = color; curtain.classList.add('visible'); }
|
|
272
|
+
function hideCurtain() { curtain.classList.remove('visible'); }
|
|
273
|
+
function toggleCurtain(color) {
|
|
274
|
+
curtain.classList.contains('visible') ? hideCurtain() : showCurtain(color);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function toggleHelp() { help.classList.toggle('visible'); }
|
|
278
|
+
|
|
279
|
+
function gotoFirst() { slideIndex = 0; step = 0; loadSlide(); }
|
|
280
|
+
function gotoLast() { slideIndex = slides.length - 1; step = 0; loadSlide(); }
|
|
281
|
+
|
|
282
|
+
// ── Error display ──
|
|
283
|
+
function showError(msg) {
|
|
284
|
+
errorMsg.textContent = msg;
|
|
285
|
+
errorOverlay.classList.add('visible');
|
|
286
|
+
}
|
|
287
|
+
function hideError() { errorOverlay.classList.remove('visible'); }
|
|
288
|
+
|
|
289
|
+
// ── Go-to-slide ──
|
|
290
|
+
function enterGoto() { gotoMode = true; gotoBuffer = ''; updateStatus(); }
|
|
291
|
+
function exitGoto() { gotoMode = false; gotoBuffer = ''; updateStatus(); }
|
|
292
|
+
function commitGoto() {
|
|
293
|
+
const n = parseInt(gotoBuffer, 10);
|
|
294
|
+
exitGoto();
|
|
295
|
+
if (!isNaN(n) && n >= 1 && n <= slides.length) {
|
|
296
|
+
slideIndex = n - 1;
|
|
297
|
+
step = 0;
|
|
298
|
+
loadSlide();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function toggleTheme() {
|
|
303
|
+
const html = document.documentElement;
|
|
304
|
+
html.dataset.theme = html.dataset.theme === 'light' ? '' : 'light';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
curtain.addEventListener('click', hideCurtain);
|
|
308
|
+
help.addEventListener('click', e => { if (e.target === help) toggleHelp(); });
|
|
309
|
+
stage.addEventListener('click', advance);
|
|
310
|
+
|
|
311
|
+
// ── Keybindings ──
|
|
312
|
+
// To make keys configurable via deck.py in the future, merge a server-injected
|
|
313
|
+
// KEYBINDINGS_OVERRIDES object into this map before the listener runs.
|
|
314
|
+
const KEYBINDINGS = {
|
|
315
|
+
'ArrowRight': { action: advance, preventDefault: true },
|
|
316
|
+
' ': { action: advance, preventDefault: true },
|
|
317
|
+
'l': { action: advance, preventDefault: true },
|
|
318
|
+
'ArrowLeft': { action: retreat, preventDefault: true },
|
|
319
|
+
'Backspace': { action: retreat, preventDefault: true },
|
|
320
|
+
'h': { action: retreat, preventDefault: true },
|
|
321
|
+
'ArrowDown': { action: nextSlide, preventDefault: true },
|
|
322
|
+
'j': { action: nextSlide, preventDefault: true },
|
|
323
|
+
'ArrowUp': { action: prevSlide, preventDefault: true },
|
|
324
|
+
'k': { action: prevSlide, preventDefault: true },
|
|
325
|
+
'Home': { action: gotoFirst },
|
|
326
|
+
'^': { action: gotoFirst },
|
|
327
|
+
'End': { action: gotoLast },
|
|
328
|
+
'$': { action: gotoLast },
|
|
329
|
+
'g': { action: enterGoto },
|
|
330
|
+
'f': { action: toggleFullscreen },
|
|
331
|
+
'b': { action: () => toggleCurtain('black') },
|
|
332
|
+
'.': { action: () => toggleCurtain('black') },
|
|
333
|
+
'w': { action: () => toggleCurtain('white') },
|
|
334
|
+
'?': { action: toggleHelp },
|
|
335
|
+
't': { action: toggleTheme },
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
document.addEventListener('keydown', e => {
|
|
339
|
+
if (help.classList.contains('visible')) {
|
|
340
|
+
if (e.key === '?' || e.key === 'Escape') toggleHelp();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (curtain.classList.contains('visible')) { hideCurtain(); return; }
|
|
344
|
+
|
|
345
|
+
if (gotoMode) {
|
|
346
|
+
if (e.key >= '0' && e.key <= '9') { gotoBuffer += e.key; updateStatus(); }
|
|
347
|
+
else if (e.key === 'Enter') { e.preventDefault(); commitGoto(); }
|
|
348
|
+
else if (e.key === 'Backspace') { e.preventDefault(); gotoBuffer = gotoBuffer.slice(0, -1); updateStatus(); }
|
|
349
|
+
else if (e.key === 'Escape') { exitGoto(); }
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const binding = KEYBINDINGS[e.key];
|
|
354
|
+
if (binding) {
|
|
355
|
+
if (binding.preventDefault) e.preventDefault();
|
|
356
|
+
binding.action();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── WebSocket live reload ──
|
|
361
|
+
function connectWS() {
|
|
362
|
+
if (!WS_PORT) {
|
|
363
|
+
wsLabel.textContent = 'offline';
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const ws = new WebSocket(`ws://localhost:${WS_PORT}`);
|
|
367
|
+
|
|
368
|
+
ws.onopen = () => {
|
|
369
|
+
wsDot.className = 'connected';
|
|
370
|
+
wsLabel.textContent = 'live';
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
ws.onmessage = (ev) => {
|
|
374
|
+
const msg = JSON.parse(ev.data);
|
|
375
|
+
if (msg.type === 'update') {
|
|
376
|
+
slides = msg.slides;
|
|
377
|
+
transitions = msg.transitions ?? [];
|
|
378
|
+
hideError();
|
|
379
|
+
slideIndex = Math.min(slideIndex, Math.max(0, slides.length - 1));
|
|
380
|
+
step = 0;
|
|
381
|
+
loadSlide();
|
|
382
|
+
} else if (msg.type === 'error') {
|
|
383
|
+
showError(msg.message);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
ws.onclose = () => {
|
|
388
|
+
wsDot.className = '';
|
|
389
|
+
wsLabel.textContent = 'disconnected';
|
|
390
|
+
setTimeout(connectWS, 2000);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
ws.onerror = () => ws.close();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── Boot ──
|
|
397
|
+
readURL();
|
|
398
|
+
loadSlide(() => { if (step > 0) applyStep(); });
|
|
399
|
+
if (INITIAL_ERROR) showError(INITIAL_ERROR);
|
|
400
|
+
connectWS();
|