stagecraft 0.1.0
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.
- package/AGENT.md +792 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/bin/cli.js +51 -0
- package/bin/export.js +137 -0
- package/bin/init.js +52 -0
- package/bin/lib/edit-ops.js +405 -0
- package/bin/serve.js +278 -0
- package/dist/stagecraft.bundle.css +4443 -0
- package/dist/stagecraft.bundle.js +7621 -0
- package/dist/themes/brand.bundle.css +5262 -0
- package/dist/themes/neon.bundle.css +5289 -0
- package/dist/themes/paper.bundle.css +5276 -0
- package/dist/themes/phosphor.bundle.css +4443 -0
- package/dist/themes/shopware.bundle.css +5850 -0
- package/examples/closing-card.js +74 -0
- package/examples/orchestration-graph.js +156 -0
- package/examples/terminal-log.js +109 -0
- package/examples/token-stream.js +96 -0
- package/examples/whoami.js +90 -0
- package/package.json +41 -0
- package/src/components/activity-list.js +75 -0
- package/src/components/agenda.js +79 -0
- package/src/components/bar-chart.js +162 -0
- package/src/components/before-after.js +135 -0
- package/src/components/bento.js +73 -0
- package/src/components/big-number.js +87 -0
- package/src/components/callout.js +75 -0
- package/src/components/checklist.js +81 -0
- package/src/components/code-block.js +141 -0
- package/src/components/code-diff.js +98 -0
- package/src/components/compare.js +85 -0
- package/src/components/counter.js +80 -0
- package/src/components/cta.js +69 -0
- package/src/components/cycle.js +146 -0
- package/src/components/definition.js +96 -0
- package/src/components/donut-chart.js +179 -0
- package/src/components/full-image.js +82 -0
- package/src/components/funnel.js +111 -0
- package/src/components/gauge.js +147 -0
- package/src/components/heatmap.js +141 -0
- package/src/components/image-grid.js +80 -0
- package/src/components/image-text.js +96 -0
- package/src/components/kinetic-text.js +72 -0
- package/src/components/kpi.js +106 -0
- package/src/components/line-chart.js +215 -0
- package/src/components/manifesto.js +104 -0
- package/src/components/marquee.js +63 -0
- package/src/components/matrix2x2.js +151 -0
- package/src/components/pillars.js +80 -0
- package/src/components/pricing.js +90 -0
- package/src/components/process-flow.js +133 -0
- package/src/components/progress.js +136 -0
- package/src/components/punchline.js +82 -0
- package/src/components/pyramid.js +107 -0
- package/src/components/qanda.js +60 -0
- package/src/components/quote.js +70 -0
- package/src/components/roadmap.js +130 -0
- package/src/components/section-card.js +45 -0
- package/src/components/shift-arrow.js +41 -0
- package/src/components/spark-line.js +147 -0
- package/src/components/spotlight.js +85 -0
- package/src/components/statement.js +106 -0
- package/src/components/stats.js +91 -0
- package/src/components/steps.js +83 -0
- package/src/components/swot.js +110 -0
- package/src/components/team-grid.js +87 -0
- package/src/components/testimonial.js +99 -0
- package/src/components/timeline.js +91 -0
- package/src/components/tip.js +63 -0
- package/src/components/venn.js +198 -0
- package/src/edit-mode.js +1256 -0
- package/src/engine.js +823 -0
- package/src/helpers.js +169 -0
- package/src/transitions.js +101 -0
- package/starter/index.html +40 -0
- package/starter/slides/00-title.js +12 -0
- package/starter/stagecraft.config.js +8 -0
- package/themes/brand/base.css +4 -0
- package/themes/brand/components-business.css +173 -0
- package/themes/brand/components-chart.css +65 -0
- package/themes/brand/components-content.css +126 -0
- package/themes/brand/components-data.css +162 -0
- package/themes/brand/components-diagram.css +115 -0
- package/themes/brand/components-layout.css +112 -0
- package/themes/brand/components.css +46 -0
- package/themes/brand/manifest.json +20 -0
- package/themes/brand/tokens.css +20 -0
- package/themes/brand/transitions.css +4 -0
- package/themes/neon/base.css +10 -0
- package/themes/neon/components-business.css +189 -0
- package/themes/neon/components-chart.css +70 -0
- package/themes/neon/components-content.css +112 -0
- package/themes/neon/components-data.css +160 -0
- package/themes/neon/components-diagram.css +109 -0
- package/themes/neon/components-layout.css +87 -0
- package/themes/neon/components.css +87 -0
- package/themes/neon/manifest.json +21 -0
- package/themes/neon/tokens.css +17 -0
- package/themes/neon/transitions.css +13 -0
- package/themes/paper/base.css +9 -0
- package/themes/paper/components-business.css +196 -0
- package/themes/paper/components-chart.css +74 -0
- package/themes/paper/components-content.css +108 -0
- package/themes/paper/components-data.css +168 -0
- package/themes/paper/components-diagram.css +89 -0
- package/themes/paper/components-layout.css +105 -0
- package/themes/paper/components.css +60 -0
- package/themes/paper/manifest.json +10 -0
- package/themes/paper/tokens.css +21 -0
- package/themes/paper/transitions.css +11 -0
- package/themes/phosphor/base.css +511 -0
- package/themes/phosphor/components-business.css +818 -0
- package/themes/phosphor/components-chart.css +415 -0
- package/themes/phosphor/components-content.css +530 -0
- package/themes/phosphor/components-data.css +824 -0
- package/themes/phosphor/components-diagram.css +427 -0
- package/themes/phosphor/components-layout.css +450 -0
- package/themes/phosphor/components.css +223 -0
- package/themes/phosphor/manifest.json +11 -0
- package/themes/phosphor/tokens.css +17 -0
- package/themes/phosphor/transitions.css +213 -0
- package/themes/shopware/base.css +94 -0
- package/themes/shopware/components-business.css +344 -0
- package/themes/shopware/components-chart.css +121 -0
- package/themes/shopware/components-content.css +169 -0
- package/themes/shopware/components-data.css +219 -0
- package/themes/shopware/components-diagram.css +129 -0
- package/themes/shopware/components-layout.css +166 -0
- package/themes/shopware/components.css +83 -0
- package/themes/shopware/manifest.json +21 -0
- package/themes/shopware/tokens.css +68 -0
- package/themes/shopware/transitions.css +22 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stagecraft — Engine (Layer 0).
|
|
5
|
+
*
|
|
6
|
+
* The runtime: slide registry, navigation, step model, storyboard,
|
|
7
|
+
* transitions, deck loader, edit-mode WebSocket hook.
|
|
8
|
+
*
|
|
9
|
+
* Loads via plain <script> in index.html. Exposes `Stage.*` globals.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
(function (root) {
|
|
13
|
+
const Stage = root.Stage = root.Stage || {};
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Slide registry
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
Stage.slides = Stage.slides || [];
|
|
19
|
+
|
|
20
|
+
// Stage.register(slide [, meta])
|
|
21
|
+
// slide — { section, title, render, init?, replay?, steps?, onStep?, transition? }
|
|
22
|
+
// meta — optional: { notes, ... } merged onto the slide.
|
|
23
|
+
// This is the home for fields that belong to the *deck* rather than
|
|
24
|
+
// the *component* — most importantly `notes` (speaker notes shown
|
|
25
|
+
// in presenter view).
|
|
26
|
+
Stage.register = function (slide, meta) {
|
|
27
|
+
if (!slide || typeof slide.render !== 'function') {
|
|
28
|
+
throw new Error('Stage.register: slide must have a render(el) function');
|
|
29
|
+
}
|
|
30
|
+
if (meta && typeof meta === 'object') Object.assign(slide, meta);
|
|
31
|
+
Stage.slides.push(slide);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Transition registry
|
|
36
|
+
// Built-in transitions live in src/transitions.js. Themes may override or
|
|
37
|
+
// register new ones via Stage.registerTransition.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
Stage.transitions = Stage.transitions || {};
|
|
40
|
+
|
|
41
|
+
Stage.registerTransition = function (name, config) {
|
|
42
|
+
Stage.transitions[name] = config;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function applyTransition(el, name, phase /* 'enter' | 'exit' */) {
|
|
46
|
+
const t = Stage.transitions[name] || Stage.transitions.fade;
|
|
47
|
+
if (!t) return;
|
|
48
|
+
if (phase === 'enter') t.enter?.(el);
|
|
49
|
+
else t.exit?.(el);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Deck loader — Stage.deck({ theme, slides: [{src, transition?}, ...] })
|
|
54
|
+
// Sets the theme, fetches each slide script in order, starts the runtime.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
Stage.deck = function (config) {
|
|
57
|
+
Stage._config = config;
|
|
58
|
+
if (config.theme) document.documentElement.setAttribute('data-theme', config.theme);
|
|
59
|
+
// Slides list with transitions; engine reads .transition on enter.
|
|
60
|
+
Stage._manifestSlides = config.slides || [];
|
|
61
|
+
|
|
62
|
+
const sources = (config.slides || []).map(s => s.src);
|
|
63
|
+
loadScripts(sources).then(() => {
|
|
64
|
+
// After all slide scripts loaded, each has called Stage.register().
|
|
65
|
+
// Pair manifest transitions with registered slides by order.
|
|
66
|
+
Stage.slides.forEach((slide, i) => {
|
|
67
|
+
const m = Stage._manifestSlides[i];
|
|
68
|
+
if (m) slide.transition = m.transition || slide.transition || 'fade';
|
|
69
|
+
});
|
|
70
|
+
initRuntime();
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function loadScripts(srcs) {
|
|
75
|
+
return srcs.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadScript(src) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const s = document.createElement('script');
|
|
81
|
+
s.src = src;
|
|
82
|
+
s.async = false;
|
|
83
|
+
s.onload = () => resolve();
|
|
84
|
+
s.onerror = () => reject(new Error('Failed to load ' + src));
|
|
85
|
+
document.head.appendChild(s);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Runtime — navigation, step model, storyboard, edit-mode hook
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
let stage, welcome, uiTitle, curSec, dotsEl, hint;
|
|
93
|
+
let current = -1;
|
|
94
|
+
let currentStep = 0;
|
|
95
|
+
let activeCleanup = null;
|
|
96
|
+
let hintTimer = null;
|
|
97
|
+
let editMode = false; // user-toggleable: are edit affordances visible?
|
|
98
|
+
let serverAvailable = false; // is the dev server actually reachable?
|
|
99
|
+
let ws = null;
|
|
100
|
+
let presenterMode = false; // this window is rendering the presenter view
|
|
101
|
+
let bc = null; // BroadcastChannel for window sync
|
|
102
|
+
let suppressBroadcast = false; // re-entrancy guard for sync
|
|
103
|
+
let presenterEls = null; // { currentPane, nextPane, notesPane, timer, clock }
|
|
104
|
+
let talkStartTime = null; // for the elapsed timer
|
|
105
|
+
|
|
106
|
+
function initRuntime() {
|
|
107
|
+
if (Stage._inited) return;
|
|
108
|
+
Stage._inited = true;
|
|
109
|
+
|
|
110
|
+
// Are we the presenter window? Detected at init from ?mode=presenter.
|
|
111
|
+
const params = new URLSearchParams(location.search);
|
|
112
|
+
presenterMode = params.get('mode') === 'presenter';
|
|
113
|
+
if (presenterMode) {
|
|
114
|
+
document.body.classList.add('presenter-mode');
|
|
115
|
+
buildPresenterChrome();
|
|
116
|
+
} else {
|
|
117
|
+
buildChrome();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
bindKeyboard();
|
|
121
|
+
bindMouse();
|
|
122
|
+
bindHash();
|
|
123
|
+
bindResize();
|
|
124
|
+
tryConnectEditServer();
|
|
125
|
+
setupBroadcastChannel();
|
|
126
|
+
|
|
127
|
+
const initial = parseHash();
|
|
128
|
+
if (initial !== null) {
|
|
129
|
+
go(initial);
|
|
130
|
+
showHint();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If the storyboard was open before a reload (e.g. drag-drop triggered
|
|
134
|
+
// a manifest write → reload), restore it. We wait briefly so the
|
|
135
|
+
// current slide has rendered and the edit-mode WS has had a chance
|
|
136
|
+
// to attach (so connectors + drag handles are wired up).
|
|
137
|
+
try {
|
|
138
|
+
if (sessionStorage.getItem('stagecraft:overview') === '1') {
|
|
139
|
+
if (initial === null) go(0); // need a current slide so storyboard has context
|
|
140
|
+
const restore = () => {
|
|
141
|
+
if (overviewActive) return;
|
|
142
|
+
openOverview();
|
|
143
|
+
};
|
|
144
|
+
// Two-stage: try fast (edit mode already on), then again after WS
|
|
145
|
+
// has had time to upgrade.
|
|
146
|
+
setTimeout(restore, 50);
|
|
147
|
+
setTimeout(restore, 400);
|
|
148
|
+
}
|
|
149
|
+
} catch (e) {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildChrome() {
|
|
153
|
+
stage = document.getElementById('stage');
|
|
154
|
+
if (!stage) {
|
|
155
|
+
stage = document.createElement('main');
|
|
156
|
+
stage.id = 'stage';
|
|
157
|
+
document.body.appendChild(stage);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
welcome = document.getElementById('welcome');
|
|
161
|
+
uiTitle = document.getElementById('uiTitle');
|
|
162
|
+
curSec = document.getElementById('curSec');
|
|
163
|
+
dotsEl = document.getElementById('uiDots');
|
|
164
|
+
hint = document.getElementById('uiHint');
|
|
165
|
+
|
|
166
|
+
// build progress dots from unique section numbers
|
|
167
|
+
if (dotsEl) {
|
|
168
|
+
const sections = [...new Set(Stage.slides.map(s => s.section).filter(Boolean))].sort((a, b) => a - b);
|
|
169
|
+
const totalEl = document.querySelector('.ui-counter .total');
|
|
170
|
+
if (totalEl) totalEl.textContent = String(sections.length).padStart(2, '0');
|
|
171
|
+
sections.forEach(sec => {
|
|
172
|
+
const d = document.createElement('div');
|
|
173
|
+
d.className = 'dot';
|
|
174
|
+
d.dataset.sec = sec;
|
|
175
|
+
dotsEl.appendChild(d);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (welcome) {
|
|
180
|
+
welcome.addEventListener('click', () => start(), { once: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function start() {
|
|
185
|
+
if (current === -1) go(0);
|
|
186
|
+
showHint();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Presenter view chrome
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
function buildPresenterChrome() {
|
|
193
|
+
// Remove any normal-mode chrome the HTML happens to have inlined.
|
|
194
|
+
document.querySelectorAll('.welcome, .ui').forEach(n => n.remove());
|
|
195
|
+
const existingStage = document.getElementById('stage');
|
|
196
|
+
if (existingStage) existingStage.remove();
|
|
197
|
+
|
|
198
|
+
document.title = 'Stagecraft Presenter';
|
|
199
|
+
document.body.innerHTML = `
|
|
200
|
+
<div class="presenter-shell">
|
|
201
|
+
<div class="presenter-top">
|
|
202
|
+
<div class="presenter-pane presenter-current" id="presenterCurrent">
|
|
203
|
+
<div class="presenter-pane-label">NOW · slide <span id="presenterCurrentIdx">00</span></div>
|
|
204
|
+
<div class="presenter-pane-stage" id="presenterCurrentStage"></div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="presenter-pane presenter-next" id="presenterNext">
|
|
207
|
+
<div class="presenter-pane-label">NEXT</div>
|
|
208
|
+
<div class="presenter-pane-stage" id="presenterNextStage"></div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
<div class="presenter-meta">
|
|
212
|
+
<div class="presenter-timer" id="presenterTimer">00:00</div>
|
|
213
|
+
<div class="presenter-clock" id="presenterClock">--:--</div>
|
|
214
|
+
<button class="presenter-timer-reset" id="presenterTimerReset" title="Reset elapsed timer">↻ reset</button>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="presenter-notes" id="presenterNotes">
|
|
217
|
+
<div class="presenter-notes-label">SPEAKER NOTES</div>
|
|
218
|
+
<div class="presenter-notes-body" id="presenterNotesBody"></div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
presenterEls = {
|
|
224
|
+
currentStage: document.getElementById('presenterCurrentStage'),
|
|
225
|
+
currentIdx: document.getElementById('presenterCurrentIdx'),
|
|
226
|
+
nextStage: document.getElementById('presenterNextStage'),
|
|
227
|
+
notesBody: document.getElementById('presenterNotesBody'),
|
|
228
|
+
timer: document.getElementById('presenterTimer'),
|
|
229
|
+
clock: document.getElementById('presenterClock')
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Engine's "stage" reference now points to the current-slide container.
|
|
233
|
+
// The pane-stage is the inner scaler; we render slides into a wrapper
|
|
234
|
+
// inside the pane that lets us position child .slide elements normally.
|
|
235
|
+
stage = presenterEls.currentStage;
|
|
236
|
+
stage.classList.add('presenter-stage');
|
|
237
|
+
|
|
238
|
+
startTalkTimer();
|
|
239
|
+
document.getElementById('presenterTimerReset')?.addEventListener('click', () => {
|
|
240
|
+
talkStartTime = Date.now();
|
|
241
|
+
updatePresenterMeta();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
document.body.classList.add('armed');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function startTalkTimer() {
|
|
248
|
+
talkStartTime = Date.now();
|
|
249
|
+
updatePresenterMeta();
|
|
250
|
+
setInterval(updatePresenterMeta, 1000);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function updatePresenterMeta() {
|
|
254
|
+
if (!presenterEls) return;
|
|
255
|
+
const elapsed = Date.now() - (talkStartTime || Date.now());
|
|
256
|
+
const total = Math.floor(elapsed / 1000);
|
|
257
|
+
const h = Math.floor(total / 3600);
|
|
258
|
+
const m = Math.floor((total % 3600) / 60);
|
|
259
|
+
const s = total % 60;
|
|
260
|
+
const pad = n => String(n).padStart(2, '0');
|
|
261
|
+
presenterEls.timer.textContent = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
|
262
|
+
const now = new Date();
|
|
263
|
+
presenterEls.clock.textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderPresenterNext(idx) {
|
|
267
|
+
if (!presenterEls) return;
|
|
268
|
+
presenterEls.nextStage.innerHTML = '';
|
|
269
|
+
const nextIdx = idx + 1;
|
|
270
|
+
if (nextIdx >= Stage.slides.length) {
|
|
271
|
+
presenterEls.nextStage.innerHTML = '<div class="presenter-end">— end of deck —</div>';
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const next = Stage.slides[nextIdx];
|
|
275
|
+
const el = document.createElement('div');
|
|
276
|
+
el.className = 'slide current';
|
|
277
|
+
try { next.render(el); } catch (e) { console.warn('next render', e); }
|
|
278
|
+
presenterEls.nextStage.appendChild(el);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderPresenterNotes(slide) {
|
|
282
|
+
if (!presenterEls) return;
|
|
283
|
+
const notes = (slide && slide.notes) || '';
|
|
284
|
+
presenterEls.notesBody.textContent = notes || '(no notes for this slide)';
|
|
285
|
+
presenterEls.notesBody.classList.toggle('empty', !notes);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// BroadcastChannel — sync nav events across presenter + presentation windows
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
function setupBroadcastChannel() {
|
|
292
|
+
if (typeof BroadcastChannel === 'undefined') return;
|
|
293
|
+
bc = new BroadcastChannel('stagecraft:nav');
|
|
294
|
+
bc.addEventListener('message', (e) => {
|
|
295
|
+
const msg = e.data;
|
|
296
|
+
if (!msg || typeof msg !== 'object') return;
|
|
297
|
+
suppressBroadcast = true;
|
|
298
|
+
try {
|
|
299
|
+
switch (msg.type) {
|
|
300
|
+
case 'go': go(msg.idx); break;
|
|
301
|
+
case 'step': applyRemoteStep(msg.step); break;
|
|
302
|
+
case 'replay': replay(); break;
|
|
303
|
+
case 'overview': msg.open ? openOverview() : closeOverview(); break;
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
suppressBroadcast = false;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function broadcast(msg) {
|
|
312
|
+
if (!bc) return;
|
|
313
|
+
if (suppressBroadcast) return;
|
|
314
|
+
try { bc.postMessage(msg); } catch (e) { /* ignore */ }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function applyRemoteStep(step) {
|
|
318
|
+
const slide = Stage.slides[current];
|
|
319
|
+
if (!slide || !slide.steps) return;
|
|
320
|
+
currentStep = Math.max(0, Math.min(slide.steps - 1, step));
|
|
321
|
+
const el = stage.querySelector('.slide.current');
|
|
322
|
+
if (el && slide.onStep) {
|
|
323
|
+
try { slide.onStep(el, currentStep); } catch (e) { /* ignore */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Toggle edit-mode affordances on/off without disconnecting the server.
|
|
328
|
+
// Lets the user present cleanly even with the dev server running.
|
|
329
|
+
function toggleEditMode() {
|
|
330
|
+
if (!serverAvailable) {
|
|
331
|
+
// No server — nothing to toggle. Show a brief hint.
|
|
332
|
+
showHint();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
editMode = !editMode;
|
|
336
|
+
document.body.classList.toggle('edit-mode', editMode);
|
|
337
|
+
// Re-fire decoration on the current slide (so pin markers + hover affordances
|
|
338
|
+
// appear / disappear without a full reload).
|
|
339
|
+
const el = stage && stage.querySelector('.slide.current');
|
|
340
|
+
const slide = Stage.slides[current];
|
|
341
|
+
if (slide && el) {
|
|
342
|
+
if (editMode) {
|
|
343
|
+
if (Stage._editUI?.onSlideRendered) {
|
|
344
|
+
try { Stage._editUI.onSlideRendered(slide, current, el); } catch (e) {}
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
// Strip any present-mode pin markers + lingering hover outlines.
|
|
348
|
+
el.querySelectorAll('.pin-marker').forEach(n => n.remove());
|
|
349
|
+
document.querySelectorAll('.hover-outline, .note-overlay, .transition-picker, .add-slide-dialog').forEach(n => n.remove());
|
|
350
|
+
// Close storyboard too — it's an edit-mode-flavored view.
|
|
351
|
+
if (overviewActive) closeOverview();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log('[stagecraft] edit mode', editMode ? 'ON' : 'OFF');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function openPresenterWindow() {
|
|
358
|
+
const url = new URL(location.href);
|
|
359
|
+
url.searchParams.set('mode', 'presenter');
|
|
360
|
+
// Preserve hash so presenter opens on the current slide
|
|
361
|
+
window.open(url.toString(), 'stagecraft-presenter',
|
|
362
|
+
'width=1200,height=800,toolbar=no,menubar=no,location=no');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Slide navigation
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
function go(idx) {
|
|
369
|
+
if (idx < 0 || idx >= Stage.slides.length) return;
|
|
370
|
+
if (activeCleanup) { try { activeCleanup(); } catch (e) { console.warn(e); } activeCleanup = null; }
|
|
371
|
+
|
|
372
|
+
const old = stage.querySelector('.slide.current');
|
|
373
|
+
const oldSlide = Stage.slides[current];
|
|
374
|
+
if (old && oldSlide) {
|
|
375
|
+
old.classList.remove('current');
|
|
376
|
+
old.classList.add('exiting');
|
|
377
|
+
applyTransition(old, oldSlide.transition || 'fade', 'exit');
|
|
378
|
+
setTimeout(() => old.remove(), 700);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const slide = Stage.slides[idx];
|
|
382
|
+
const el = document.createElement('div');
|
|
383
|
+
el.className = 'slide';
|
|
384
|
+
el.dataset.idx = idx;
|
|
385
|
+
el.dataset.transition = slide.transition || 'fade';
|
|
386
|
+
try { slide.render(el); } catch (e) { console.error('render error', e); }
|
|
387
|
+
stage.appendChild(el);
|
|
388
|
+
|
|
389
|
+
// Assign data-stage-key after render for edit mode
|
|
390
|
+
if (Stage.assignStageKeys) Stage.assignStageKeys(el);
|
|
391
|
+
|
|
392
|
+
// Force reflow before adding .current so transitions run
|
|
393
|
+
void el.offsetHeight;
|
|
394
|
+
el.classList.add('current');
|
|
395
|
+
applyTransition(el, slide.transition || 'fade', 'enter');
|
|
396
|
+
|
|
397
|
+
current = idx;
|
|
398
|
+
currentStep = 0;
|
|
399
|
+
updateChrome(slide);
|
|
400
|
+
syncHash(idx);
|
|
401
|
+
|
|
402
|
+
// Presenter view extras
|
|
403
|
+
if (presenterMode && presenterEls) {
|
|
404
|
+
if (presenterEls.currentIdx) presenterEls.currentIdx.textContent = String(idx).padStart(2, '0');
|
|
405
|
+
renderPresenterNext(idx);
|
|
406
|
+
renderPresenterNotes(slide);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Broadcast nav to the other window (if any), unless this go() was
|
|
410
|
+
// itself triggered by a remote message.
|
|
411
|
+
broadcast({ type: 'go', idx });
|
|
412
|
+
|
|
413
|
+
setTimeout(() => {
|
|
414
|
+
if (current !== idx) return;
|
|
415
|
+
try {
|
|
416
|
+
activeCleanup = slide.init ? slide.init(el) : null;
|
|
417
|
+
if (slide.steps && slide.onStep) {
|
|
418
|
+
try { slide.onStep(el, 0); } catch (e) { console.error('onStep error', e); }
|
|
419
|
+
}
|
|
420
|
+
} catch (e) { console.error('init error', e); }
|
|
421
|
+
// Let edit-mode decorate the freshly rendered slide (pin markers, etc.)
|
|
422
|
+
if (editMode && Stage._editUI?.onSlideRendered) {
|
|
423
|
+
try { Stage._editUI.onSlideRendered(slide, idx, el); } catch (e) { /* ignore */ }
|
|
424
|
+
}
|
|
425
|
+
}, 80);
|
|
426
|
+
|
|
427
|
+
if (welcome && !welcome.classList.contains('hidden')) {
|
|
428
|
+
welcome.classList.add('hidden');
|
|
429
|
+
document.body.classList.add('armed');
|
|
430
|
+
setTimeout(() => welcome.remove(), 600);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function updateChrome(slide) {
|
|
435
|
+
if (uiTitle) uiTitle.textContent = slide.title || '';
|
|
436
|
+
const sec = slide.section;
|
|
437
|
+
if (curSec) curSec.textContent = String(sec).padStart(2, '0');
|
|
438
|
+
if (dotsEl) {
|
|
439
|
+
const dots = dotsEl.querySelectorAll('.dot');
|
|
440
|
+
dots.forEach(d => {
|
|
441
|
+
const s = Number(d.dataset.sec);
|
|
442
|
+
d.classList.remove('active', 'past');
|
|
443
|
+
if (s === sec) d.classList.add('active');
|
|
444
|
+
else if (s < sec) d.classList.add('past');
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function next() {
|
|
450
|
+
const slide = Stage.slides[current];
|
|
451
|
+
if (slide && slide.steps && currentStep < slide.steps - 1) {
|
|
452
|
+
currentStep++;
|
|
453
|
+
const el = stage.querySelector('.slide.current');
|
|
454
|
+
if (el && slide.onStep) {
|
|
455
|
+
try { slide.onStep(el, currentStep); } catch (e) { console.error('onStep error', e); }
|
|
456
|
+
}
|
|
457
|
+
broadcast({ type: 'step', step: currentStep });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (current < Stage.slides.length - 1) go(current + 1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function prev() {
|
|
464
|
+
const slide = Stage.slides[current];
|
|
465
|
+
if (slide && slide.steps && currentStep > 0) {
|
|
466
|
+
currentStep--;
|
|
467
|
+
const el = stage.querySelector('.slide.current');
|
|
468
|
+
if (el && slide.onStep) {
|
|
469
|
+
try { slide.onStep(el, currentStep); } catch (e) { console.error('onStep error', e); }
|
|
470
|
+
}
|
|
471
|
+
broadcast({ type: 'step', step: currentStep });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (current > 0) go(current - 1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function jumpToSection(secNum) {
|
|
478
|
+
const idx = Stage.slides.findIndex(s => s.section === secNum);
|
|
479
|
+
if (idx >= 0) go(idx);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function replay() {
|
|
483
|
+
const el = stage.querySelector('.slide.current');
|
|
484
|
+
const slide = Stage.slides[current];
|
|
485
|
+
if (!el || !slide) return;
|
|
486
|
+
if (activeCleanup) { try { activeCleanup(); } catch (e) {} activeCleanup = null; }
|
|
487
|
+
const fn = slide.replay || slide.init;
|
|
488
|
+
if (fn) {
|
|
489
|
+
try {
|
|
490
|
+
activeCleanup = fn(el) || null;
|
|
491
|
+
currentStep = 0;
|
|
492
|
+
if (slide.steps && slide.onStep) slide.onStep(el, 0);
|
|
493
|
+
} catch (e) { console.error('replay error', e); }
|
|
494
|
+
}
|
|
495
|
+
broadcast({ type: 'replay' });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function toggleFullscreen() {
|
|
499
|
+
if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
|
|
500
|
+
else document.exitFullscreen?.();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Storyboard
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
let overviewActive = false;
|
|
507
|
+
let overviewCleanups = [];
|
|
508
|
+
|
|
509
|
+
function openOverview() {
|
|
510
|
+
if (overviewActive) return;
|
|
511
|
+
overviewActive = true;
|
|
512
|
+
// Persist state across reloads (e.g. when a drag-drop triggers a manifest
|
|
513
|
+
// reload, we want the storyboard to come back open).
|
|
514
|
+
try { sessionStorage.setItem('stagecraft:overview', '1'); } catch (e) {}
|
|
515
|
+
|
|
516
|
+
const ov = document.createElement('div');
|
|
517
|
+
ov.id = 'overview';
|
|
518
|
+
ov.className = 'overview';
|
|
519
|
+
const sectionCount = new Set(Stage.slides.map(s => s.section).filter(Boolean)).size;
|
|
520
|
+
|
|
521
|
+
ov.innerHTML = `
|
|
522
|
+
<div class="overview-header">
|
|
523
|
+
<div class="left"><strong>Storyboard</strong> · ${Stage.slides.length} slides · ${sectionCount} sections${editMode ? ' · <span class="accent">EDIT MODE</span>' : ''}</div>
|
|
524
|
+
<div class="right"><span class="accent">click</span> to jump · <span class="accent">S</span> or <span class="accent">Esc</span> to close</div>
|
|
525
|
+
</div>
|
|
526
|
+
<div class="overview-grid" id="overviewGrid"></div>
|
|
527
|
+
`;
|
|
528
|
+
const grid = ov.querySelector('#overviewGrid');
|
|
529
|
+
|
|
530
|
+
Stage.slides.forEach((slide, i) => {
|
|
531
|
+
const tile = document.createElement('div');
|
|
532
|
+
tile.className = 'tile';
|
|
533
|
+
if (i === current) tile.classList.add('current');
|
|
534
|
+
tile.dataset.idx = i;
|
|
535
|
+
if (editMode) tile.setAttribute('draggable', 'true');
|
|
536
|
+
|
|
537
|
+
const scaler = document.createElement('div');
|
|
538
|
+
scaler.className = 'tile-scaler';
|
|
539
|
+
const slideEl = document.createElement('div');
|
|
540
|
+
slideEl.className = 'slide current';
|
|
541
|
+
try { slide.render(slideEl); } catch (e) { console.warn('tile render', e); }
|
|
542
|
+
scaler.appendChild(slideEl);
|
|
543
|
+
tile.appendChild(scaler);
|
|
544
|
+
|
|
545
|
+
const num = document.createElement('div');
|
|
546
|
+
num.className = 'tile-num';
|
|
547
|
+
num.textContent = String(i).padStart(2, '0');
|
|
548
|
+
tile.appendChild(num);
|
|
549
|
+
|
|
550
|
+
const label = document.createElement('div');
|
|
551
|
+
label.className = 'tile-label';
|
|
552
|
+
label.textContent = slide.title || '';
|
|
553
|
+
tile.appendChild(label);
|
|
554
|
+
|
|
555
|
+
// Edit-mode affordances per tile
|
|
556
|
+
if (editMode && Stage._editUI) {
|
|
557
|
+
Stage._editUI.decorateTile?.(tile, slide, i);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
tile.addEventListener('click', (e) => {
|
|
561
|
+
if (e.target.closest('.tile-edit-ui')) return; // ignore clicks on edit UI
|
|
562
|
+
// Suppress the synthetic click that fires after a drag-drop sequence.
|
|
563
|
+
if (Stage._editUI?.justFinishedDrag?.()) return;
|
|
564
|
+
closeOverview();
|
|
565
|
+
go(i);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
grid.appendChild(tile);
|
|
569
|
+
|
|
570
|
+
// run slide init for storyboard preview
|
|
571
|
+
if (slide.init) {
|
|
572
|
+
setTimeout(() => {
|
|
573
|
+
if (!overviewActive) return;
|
|
574
|
+
try {
|
|
575
|
+
const cleanup = slide.init(slideEl);
|
|
576
|
+
if (cleanup) overviewCleanups.push(cleanup);
|
|
577
|
+
} catch (e) { console.warn('tile init', e); }
|
|
578
|
+
}, 80);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
document.body.appendChild(ov);
|
|
583
|
+
|
|
584
|
+
scaleTiles();
|
|
585
|
+
// Edit-mode decorations (connectors, transition icons) need geometry, so
|
|
586
|
+
// they run AFTER scaleTiles. Re-run on every resize.
|
|
587
|
+
if (editMode && Stage._editUI) Stage._editUI.afterOverviewBuilt?.(ov);
|
|
588
|
+
|
|
589
|
+
requestAnimationFrame(() => ov.classList.add('in'));
|
|
590
|
+
requestAnimationFrame(() => {
|
|
591
|
+
const cur = ov.querySelector('.tile.current');
|
|
592
|
+
if (cur) cur.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function scaleTiles() {
|
|
597
|
+
const ov = document.getElementById('overview');
|
|
598
|
+
if (!ov) return;
|
|
599
|
+
const vw = window.innerWidth;
|
|
600
|
+
const vh = window.innerHeight;
|
|
601
|
+
const aspect = vh / vw;
|
|
602
|
+
ov.querySelectorAll('.tile').forEach(tile => {
|
|
603
|
+
const tw = tile.clientWidth;
|
|
604
|
+
const th = tw * aspect;
|
|
605
|
+
tile.style.height = th + 'px';
|
|
606
|
+
const scaler = tile.querySelector('.tile-scaler');
|
|
607
|
+
if (scaler) {
|
|
608
|
+
scaler.style.width = vw + 'px';
|
|
609
|
+
scaler.style.height = vh + 'px';
|
|
610
|
+
scaler.style.transform = `scale(${tw / vw})`;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function closeOverview() {
|
|
616
|
+
if (!overviewActive) return;
|
|
617
|
+
overviewActive = false;
|
|
618
|
+
try { sessionStorage.removeItem('stagecraft:overview'); } catch (e) {}
|
|
619
|
+
overviewCleanups.forEach(c => { try { c(); } catch (e) {} });
|
|
620
|
+
overviewCleanups = [];
|
|
621
|
+
const ov = document.getElementById('overview');
|
|
622
|
+
if (ov) {
|
|
623
|
+
ov.classList.remove('in');
|
|
624
|
+
setTimeout(() => ov.remove(), 280);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function toggleOverview() {
|
|
629
|
+
if (overviewActive) closeOverview();
|
|
630
|
+
else openOverview();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Keyboard / mouse / touch
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
function bindKeyboard() {
|
|
637
|
+
window.addEventListener('keydown', (e) => {
|
|
638
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
639
|
+
// Don't capture keys while user is editing text in edit mode
|
|
640
|
+
if (e.target && e.target.isContentEditable) return;
|
|
641
|
+
if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) return;
|
|
642
|
+
|
|
643
|
+
if (overviewActive) {
|
|
644
|
+
if (e.key === 's' || e.key === 'S' || e.key === 'Escape') {
|
|
645
|
+
e.preventDefault();
|
|
646
|
+
closeOverview();
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
switch (e.key) {
|
|
652
|
+
case 'ArrowRight':
|
|
653
|
+
case 'PageDown':
|
|
654
|
+
case ' ':
|
|
655
|
+
case 'Enter':
|
|
656
|
+
e.preventDefault(); next(); break;
|
|
657
|
+
case 'ArrowLeft':
|
|
658
|
+
case 'PageUp':
|
|
659
|
+
case 'Backspace':
|
|
660
|
+
e.preventDefault(); prev(); break;
|
|
661
|
+
case 'f': case 'F':
|
|
662
|
+
e.preventDefault(); toggleFullscreen(); break;
|
|
663
|
+
case 'r': case 'R':
|
|
664
|
+
e.preventDefault(); replay(); break;
|
|
665
|
+
case 's': case 'S':
|
|
666
|
+
e.preventDefault(); toggleOverview(); break;
|
|
667
|
+
case 'p': case 'P':
|
|
668
|
+
if (presenterMode) break;
|
|
669
|
+
e.preventDefault(); openPresenterWindow(); break;
|
|
670
|
+
case 'e': case 'E':
|
|
671
|
+
e.preventDefault(); toggleEditMode(); break;
|
|
672
|
+
case '?': case 'h': case 'H':
|
|
673
|
+
showHint(); break;
|
|
674
|
+
default:
|
|
675
|
+
if (/^[1-9]$/.test(e.key)) {
|
|
676
|
+
e.preventDefault();
|
|
677
|
+
jumpToSection(parseInt(e.key, 10));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function bindMouse() {
|
|
684
|
+
let touchStartX = null;
|
|
685
|
+
window.addEventListener('touchstart', (e) => {
|
|
686
|
+
touchStartX = e.touches[0].clientX;
|
|
687
|
+
}, { passive: true });
|
|
688
|
+
window.addEventListener('touchend', (e) => {
|
|
689
|
+
if (touchStartX == null) return;
|
|
690
|
+
const dx = e.changedTouches[0].clientX - touchStartX;
|
|
691
|
+
// Swipe always navigates. Tap only navigates outside edit mode.
|
|
692
|
+
if (Math.abs(dx) > 50) {
|
|
693
|
+
if (dx < 0) next(); else prev();
|
|
694
|
+
} else if (!editMode) {
|
|
695
|
+
next();
|
|
696
|
+
}
|
|
697
|
+
touchStartX = null;
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
window.addEventListener('click', (e) => {
|
|
701
|
+
if (overviewActive) return;
|
|
702
|
+
if (e.target.closest('#overview')) return;
|
|
703
|
+
if (e.target.closest('.qr-frame')) return;
|
|
704
|
+
// In edit mode, the slide surface is for editing — never advance
|
|
705
|
+
// on a free-space click. Navigation happens via keyboard (←/→/Space),
|
|
706
|
+
// swipe, or the storyboard. This is essential so single-click of a
|
|
707
|
+
// potential double-click target doesn't skip the slide.
|
|
708
|
+
if (editMode) return;
|
|
709
|
+
if (current === -1) { go(0); return; }
|
|
710
|
+
const w = window.innerWidth;
|
|
711
|
+
if (e.clientX < w / 3) prev(); else next();
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function bindHash() {
|
|
716
|
+
window.addEventListener('hashchange', () => {
|
|
717
|
+
const idx = parseHash();
|
|
718
|
+
if (idx !== null && idx !== current) go(idx);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function bindResize() {
|
|
723
|
+
window.addEventListener('resize', () => {
|
|
724
|
+
if (!overviewActive) return;
|
|
725
|
+
scaleTiles();
|
|
726
|
+
const ov = document.getElementById('overview');
|
|
727
|
+
if (editMode && Stage._editUI && ov) Stage._editUI.afterOverviewBuilt?.(ov);
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function parseHash() {
|
|
732
|
+
const m = location.hash.match(/^#(\d+)$/);
|
|
733
|
+
if (!m) return null;
|
|
734
|
+
const n = parseInt(m[1], 10);
|
|
735
|
+
return (n >= 0 && n < Stage.slides.length) ? n : null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function syncHash(idx) {
|
|
739
|
+
const target = '#' + idx;
|
|
740
|
+
if (location.hash !== target) {
|
|
741
|
+
try { history.replaceState(null, '', target); }
|
|
742
|
+
catch (e) { location.hash = target; }
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function showHint() {
|
|
747
|
+
if (!hint) return;
|
|
748
|
+
hint.classList.add('visible');
|
|
749
|
+
clearTimeout(hintTimer);
|
|
750
|
+
hintTimer = setTimeout(() => hint.classList.remove('visible'), 2500);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Edit-mode WebSocket hook
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
function tryConnectEditServer() {
|
|
757
|
+
try {
|
|
758
|
+
ws = new WebSocket(`ws://${location.hostname || 'localhost'}:${location.port || 3000}/stagecraft`);
|
|
759
|
+
ws.addEventListener('open', () => {
|
|
760
|
+
serverAvailable = true;
|
|
761
|
+
// Default to edit-mode-on when the server is reachable. The user can
|
|
762
|
+
// toggle this off with `E` to present cleanly while the server stays
|
|
763
|
+
// running.
|
|
764
|
+
editMode = true;
|
|
765
|
+
document.body.classList.add('edit-mode');
|
|
766
|
+
console.log('[stagecraft] edit mode ON — connected to dev server');
|
|
767
|
+
if (Stage._editUI) Stage._editUI.activate(ws);
|
|
768
|
+
});
|
|
769
|
+
ws.addEventListener('message', handleServerMessage);
|
|
770
|
+
ws.addEventListener('error', () => { /* silent */ });
|
|
771
|
+
ws.addEventListener('close', () => {
|
|
772
|
+
if (serverAvailable) {
|
|
773
|
+
serverAvailable = false;
|
|
774
|
+
editMode = false;
|
|
775
|
+
document.body.classList.remove('edit-mode');
|
|
776
|
+
console.log('[stagecraft] edit mode OFF — server disconnected');
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
} catch (e) {
|
|
780
|
+
// No server — silent
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function handleServerMessage(ev) {
|
|
785
|
+
let msg;
|
|
786
|
+
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
|
787
|
+
if (msg.type === 'reload') {
|
|
788
|
+
handleReload(msg);
|
|
789
|
+
} else if (msg.type === 'reload-all') {
|
|
790
|
+
location.reload();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function handleReload(msg) {
|
|
795
|
+
const target = msg.target;
|
|
796
|
+
if (target === 'theme-css') {
|
|
797
|
+
// Reload stylesheets in place
|
|
798
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
|
|
799
|
+
const u = new URL(link.href);
|
|
800
|
+
u.searchParams.set('_r', Date.now());
|
|
801
|
+
link.href = u.toString();
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (target === 'manifest' || target === 'slide' || target === 'theme-js') {
|
|
806
|
+
// Reload the whole page; preserve current slide via hash.
|
|
807
|
+
location.reload();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Expose internal API for edit-mode UI module
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
Stage._engine = {
|
|
815
|
+
go, next, prev, replay, toggleOverview, openOverview, closeOverview,
|
|
816
|
+
currentIndex: () => current,
|
|
817
|
+
currentStep: () => currentStep,
|
|
818
|
+
isEditMode: () => editMode,
|
|
819
|
+
getWs: () => ws,
|
|
820
|
+
getStageEl: () => stage,
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|