voxflow 1.15.1 → 1.15.3
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/README.md +6 -4
- package/dist/index.js +1 -1
- package/lib/commands/slice-stage.js +63 -0
- package/lib/commands/slice.js +8 -5
- package/lib/stage-core/local-render.js +268 -0
- package/lib/stage-core/server.js +171 -0
- package/lib/stage-ui/slice/template.js +489 -7
- package/package.json +1 -1
- package/lib/commands/slice-preview.js +0 -266
|
@@ -231,6 +231,121 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
231
231
|
}
|
|
232
232
|
.render-btn.completed:hover { filter: none; text-decoration: underline; }
|
|
233
233
|
|
|
234
|
+
/* Secondary (de-emphasised) render variant for "the other path". When the
|
|
235
|
+
user has no cached login, local becomes primary and cloud takes this
|
|
236
|
+
style; when they're logged in, local takes this style. The pair is
|
|
237
|
+
always visible — the emphasis just flips. */
|
|
238
|
+
.render-btn.secondary {
|
|
239
|
+
background: transparent; color: var(--text);
|
|
240
|
+
border-color: var(--border); font-weight: 500;
|
|
241
|
+
}
|
|
242
|
+
.render-btn.secondary:hover:not(:disabled) {
|
|
243
|
+
border-color: var(--accent); filter: none;
|
|
244
|
+
}
|
|
245
|
+
.render-btn.secondary.completed { color: var(--accent); }
|
|
246
|
+
|
|
247
|
+
/* Local-render mini-progress bar shown next to the local button while a
|
|
248
|
+
job is rendering. Sits inline with the button so the header doesn't
|
|
249
|
+
reflow. */
|
|
250
|
+
.render-local-progress {
|
|
251
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
252
|
+
font-size: 11px; color: var(--muted);
|
|
253
|
+
font-variant-numeric: tabular-nums;
|
|
254
|
+
}
|
|
255
|
+
.render-local-progress.hidden { display: none; }
|
|
256
|
+
.render-local-progress .bar {
|
|
257
|
+
width: 90px; height: 4px; border-radius: 2px;
|
|
258
|
+
background: var(--panel-2); overflow: hidden;
|
|
259
|
+
border: 1px solid var(--border);
|
|
260
|
+
}
|
|
261
|
+
.render-local-progress .bar > span {
|
|
262
|
+
display: block; height: 100%;
|
|
263
|
+
background: var(--accent);
|
|
264
|
+
transition: width 0.15s ease;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Toast that confirms the local mp4 was written. Persistent — user
|
|
268
|
+
dismisses with the close button. The "Open in Finder" link is a
|
|
269
|
+
file:// href; browser security may block click-to-open without a
|
|
270
|
+
prior user gesture, so we render it as a plain link. */
|
|
271
|
+
.local-render-toast {
|
|
272
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 60;
|
|
273
|
+
max-width: 360px;
|
|
274
|
+
background: var(--panel); color: var(--text);
|
|
275
|
+
border: 1px solid var(--good); border-radius: 8px;
|
|
276
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
|
277
|
+
padding: 10px 12px;
|
|
278
|
+
font-size: 12px;
|
|
279
|
+
display: none; flex-direction: column; gap: 6px;
|
|
280
|
+
}
|
|
281
|
+
.local-render-toast.open { display: flex; }
|
|
282
|
+
.local-render-toast .toast-head {
|
|
283
|
+
display: flex; align-items: center; gap: 6px;
|
|
284
|
+
font-weight: 600; color: var(--good);
|
|
285
|
+
}
|
|
286
|
+
.local-render-toast .toast-head .grow { flex: 1; }
|
|
287
|
+
.local-render-toast .toast-head .close {
|
|
288
|
+
appearance: none; border: 0; background: transparent;
|
|
289
|
+
color: var(--muted); font-size: 16px; line-height: 1;
|
|
290
|
+
padding: 0 4px; cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
.local-render-toast .path {
|
|
293
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
294
|
+
font-size: 11px; color: var(--muted); word-break: break-all;
|
|
295
|
+
}
|
|
296
|
+
.local-render-toast .actions { display: flex; gap: 8px; }
|
|
297
|
+
.local-render-toast .actions a, .local-render-toast .actions button {
|
|
298
|
+
appearance: none; cursor: pointer; font: inherit;
|
|
299
|
+
font-size: 11px; padding: 4px 8px; border-radius: 4px;
|
|
300
|
+
border: 1px solid var(--border); background: var(--panel-2);
|
|
301
|
+
color: var(--text); text-decoration: none;
|
|
302
|
+
}
|
|
303
|
+
.local-render-toast .actions a:hover,
|
|
304
|
+
.local-render-toast .actions button:hover { border-color: var(--accent); }
|
|
305
|
+
|
|
306
|
+
/* Inline-edit affordance — textarea replaces the caption / narration / title
|
|
307
|
+
text in place. Mirrors the original element's metrics so the card layout
|
|
308
|
+
doesn't jump when the user clicks to edit. */
|
|
309
|
+
.stage-card .editable {
|
|
310
|
+
cursor: text;
|
|
311
|
+
border-radius: 3px;
|
|
312
|
+
padding: 1px 3px; margin: -1px -3px;
|
|
313
|
+
transition: background 0.12s ease, outline 0.12s ease;
|
|
314
|
+
}
|
|
315
|
+
.stage-card .editable:hover {
|
|
316
|
+
background: rgba(0,0,0,0.04);
|
|
317
|
+
outline: 1px dashed rgba(0,0,0,0.2);
|
|
318
|
+
}
|
|
319
|
+
.stage-card[data-card-bg="dark"] .editable:hover {
|
|
320
|
+
background: rgba(255,255,255,0.06);
|
|
321
|
+
outline: 1px dashed rgba(255,255,255,0.25);
|
|
322
|
+
}
|
|
323
|
+
.stage-card textarea.inline-edit {
|
|
324
|
+
width: 100%; min-height: 1.6em;
|
|
325
|
+
font: inherit; color: inherit;
|
|
326
|
+
background: rgba(0,0,0,0.05);
|
|
327
|
+
border: 1px solid currentColor; border-radius: 3px;
|
|
328
|
+
padding: 2px 4px; margin: -2px -4px;
|
|
329
|
+
resize: none; outline: none;
|
|
330
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 25%, transparent);
|
|
331
|
+
}
|
|
332
|
+
.stage-card[data-card-bg="dark"] textarea.inline-edit {
|
|
333
|
+
background: rgba(255,255,255,0.08);
|
|
334
|
+
}
|
|
335
|
+
.stage-card .inline-edit-err {
|
|
336
|
+
position: absolute; left: 10px; right: 10px; bottom: 10px;
|
|
337
|
+
background: var(--bad); color: white;
|
|
338
|
+
font-size: 10px; line-height: 1.3;
|
|
339
|
+
padding: 4px 6px; border-radius: 4px;
|
|
340
|
+
z-index: 5;
|
|
341
|
+
}
|
|
342
|
+
.stage-card .inline-edit-hint {
|
|
343
|
+
position: absolute; left: 10px; right: 10px; bottom: 4px;
|
|
344
|
+
font-size: 9px; color: var(--muted); letter-spacing: 0.04em;
|
|
345
|
+
text-align: center; pointer-events: none; opacity: 0.7;
|
|
346
|
+
z-index: 4;
|
|
347
|
+
}
|
|
348
|
+
|
|
234
349
|
.render-modal-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 0 6px; }
|
|
235
350
|
.render-modal-body .quota-line {
|
|
236
351
|
display: flex; justify-content: space-between; align-items: baseline;
|
|
@@ -600,9 +715,17 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
600
715
|
<span class="dot" id="dot"></span>
|
|
601
716
|
<span id="status-label">connecting…</span>
|
|
602
717
|
</div>
|
|
603
|
-
<
|
|
604
|
-
<span class="
|
|
605
|
-
<span id="render-
|
|
718
|
+
<span class="render-local-progress hidden" id="render-local-progress" aria-live="polite">
|
|
719
|
+
<span class="bar"><span id="render-local-progress-fill" style="width:0%"></span></span>
|
|
720
|
+
<span id="render-local-progress-label">0%</span>
|
|
721
|
+
</span>
|
|
722
|
+
<button class="render-btn" id="render-local-btn" type="button" aria-label="Render this deck to mp4 locally (no cloud, no quota)" title="Render locally with @remotion/renderer — no network, no quota">
|
|
723
|
+
<span class="icon">⬇</span>
|
|
724
|
+
<span id="render-local-btn-label">Render mp4 (local)</span>
|
|
725
|
+
</button>
|
|
726
|
+
<button class="render-btn secondary" id="render-btn" type="button" aria-label="Render this deck to mp4 (cloud)" title="Submit to cloud renderer — costs 200 quota">
|
|
727
|
+
<span class="icon">☁</span>
|
|
728
|
+
<span id="render-btn-label">Render mp4 (cloud)</span>
|
|
606
729
|
</button>
|
|
607
730
|
<button class="versions-btn" id="versions-btn" type="button" aria-label="Show version history" aria-expanded="false">
|
|
608
731
|
<span class="icon">📚</span>
|
|
@@ -662,6 +785,21 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
662
785
|
<span>✏</span><span>Edit selection with AI</span>
|
|
663
786
|
</button>
|
|
664
787
|
|
|
788
|
+
<div class="local-render-toast" id="local-render-toast" role="status" aria-live="polite">
|
|
789
|
+
<div class="toast-head">
|
|
790
|
+
<span>✓</span>
|
|
791
|
+
<span>Local render complete</span>
|
|
792
|
+
<span class="grow"></span>
|
|
793
|
+
<button class="close" id="local-render-toast-close" type="button" aria-label="Close">×</button>
|
|
794
|
+
</div>
|
|
795
|
+
<div class="path" id="local-render-toast-path"></div>
|
|
796
|
+
<div class="actions">
|
|
797
|
+
<a id="local-render-toast-open" href="#" target="_blank" rel="noopener">Open mp4</a>
|
|
798
|
+
<a id="local-render-toast-reveal" href="#">Open folder</a>
|
|
799
|
+
<button id="local-render-toast-copy" type="button">Copy path</button>
|
|
800
|
+
</div>
|
|
801
|
+
</div>
|
|
802
|
+
|
|
665
803
|
<div class="modal-backdrop" id="render-modal" role="dialog" aria-modal="true" aria-labelledby="render-modal-title" hidden>
|
|
666
804
|
<div class="modal">
|
|
667
805
|
<div class="modal-header">
|
|
@@ -924,16 +1062,34 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
924
1062
|
if (Array.isArray(card.title)) titleArr = card.title;
|
|
925
1063
|
else if (typeof card.title === 'string') titleArr = [card.title];
|
|
926
1064
|
else titleArr = ['(card ' + (i + 1) + ')'];
|
|
1065
|
+
// Each title line is independently editable — saving rewrites the
|
|
1066
|
+
// entire title[] array, so a click on line 0 mutates only that
|
|
1067
|
+
// index and leaves the rest as-is.
|
|
927
1068
|
titleHtml = '<div class="title-text">'
|
|
928
|
-
+ titleArr.map(function (line) {
|
|
929
|
-
return '<span class="line"
|
|
1069
|
+
+ titleArr.map(function (line, lineIdx) {
|
|
1070
|
+
return '<span class="line editable" data-edit-field="title" data-edit-index="'
|
|
1071
|
+
+ lineIdx + '" title="Click to edit, Esc to cancel, Cmd/Ctrl+Enter to save">'
|
|
1072
|
+
+ escapeHtml(line) + '</span>';
|
|
930
1073
|
}).join('')
|
|
931
1074
|
+ '</div>';
|
|
932
1075
|
} else {
|
|
933
1076
|
var caption = typeof card.caption === 'string' ? card.caption : ('Card ' + (i + 1));
|
|
934
1077
|
var narration = typeof card.narration === 'string' ? card.narration : '';
|
|
935
|
-
|
|
936
|
-
|
|
1078
|
+
// body cards may have caption + narration; only caption is editable
|
|
1079
|
+
// here when caption exists (some cards lack caption — quote/data/list).
|
|
1080
|
+
// Narration is editable on every non-title card.
|
|
1081
|
+
var captionHtml = (typeof card.caption === 'string')
|
|
1082
|
+
? '<div class="caption editable" data-edit-field="caption" title="Click to edit">'
|
|
1083
|
+
+ escapeHtml(caption) + '</div>'
|
|
1084
|
+
: '<div class="caption">' + escapeHtml(caption) + '</div>';
|
|
1085
|
+
var narrationHtml = narration
|
|
1086
|
+
? '<div class="narration editable" data-edit-field="narration" title="Click to edit narration">'
|
|
1087
|
+
+ escapeHtml(narration) + '</div>'
|
|
1088
|
+
: (kind === 'body'
|
|
1089
|
+
? '<div class="narration editable" data-edit-field="narration" data-empty="1"'
|
|
1090
|
+
+ ' title="Click to add narration" style="opacity:0.4">+ narration</div>'
|
|
1091
|
+
: '');
|
|
1092
|
+
titleHtml = captionHtml + narrationHtml;
|
|
937
1093
|
}
|
|
938
1094
|
|
|
939
1095
|
var figureChip = card.figureKeyword
|
|
@@ -1099,6 +1255,40 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
1099
1255
|
es.addEventListener('open', function () {
|
|
1100
1256
|
setStatus('good', 'live');
|
|
1101
1257
|
});
|
|
1258
|
+
|
|
1259
|
+
// ─── Local-render progress fan-in (same SSE channel as deck) ──────
|
|
1260
|
+
es.addEventListener('render-local-progress', function (e) {
|
|
1261
|
+
try {
|
|
1262
|
+
var msg = JSON.parse(e.data);
|
|
1263
|
+
if (!msg || msg.jobId !== renderLocalJobId) return;
|
|
1264
|
+
var pct = (typeof msg.progress === 'number' ? msg.progress : 0) * 100;
|
|
1265
|
+
showLocalProgress(pct);
|
|
1266
|
+
if (renderLocalState !== 'rendering') {
|
|
1267
|
+
renderLocalState = 'rendering';
|
|
1268
|
+
refreshRenderLocalBtn();
|
|
1269
|
+
}
|
|
1270
|
+
} catch (_) { /* malformed — ignore */ }
|
|
1271
|
+
});
|
|
1272
|
+
es.addEventListener('render-local-done', function (e) {
|
|
1273
|
+
try {
|
|
1274
|
+
var msg = JSON.parse(e.data);
|
|
1275
|
+
if (!msg || msg.jobId !== renderLocalJobId) return;
|
|
1276
|
+
renderLocalState = 'completed';
|
|
1277
|
+
refreshRenderLocalBtn();
|
|
1278
|
+
hideLocalProgress();
|
|
1279
|
+
showToast(msg.outputPath);
|
|
1280
|
+
} catch (_) { /* ignore */ }
|
|
1281
|
+
});
|
|
1282
|
+
es.addEventListener('render-local-error', function (e) {
|
|
1283
|
+
try {
|
|
1284
|
+
var msg = JSON.parse(e.data);
|
|
1285
|
+
if (!msg || msg.jobId !== renderLocalJobId) return;
|
|
1286
|
+
renderLocalState = 'failed';
|
|
1287
|
+
refreshRenderLocalBtn();
|
|
1288
|
+
hideLocalProgress();
|
|
1289
|
+
alert('Local render failed: ' + (msg.message || 'unknown error'));
|
|
1290
|
+
} catch (_) { /* ignore */ }
|
|
1291
|
+
});
|
|
1102
1292
|
}
|
|
1103
1293
|
connect();
|
|
1104
1294
|
|
|
@@ -1461,6 +1651,298 @@ function renderSliceStageHtml({ sourcePath, port }) {
|
|
|
1461
1651
|
|
|
1462
1652
|
refreshRenderBtn();
|
|
1463
1653
|
|
|
1654
|
+
// ─── Render to MP4 (local, @remotion/renderer) ──────────────────────
|
|
1655
|
+
// Mirrors the cloud renderer state machine but skips the JWT / quota
|
|
1656
|
+
// dance. Progress arrives on the deck SSE channel (server fans out
|
|
1657
|
+
// render-local-progress / render-local-done / render-local-error) so
|
|
1658
|
+
// we add listeners inside connect()/onmessage below — see the SSE
|
|
1659
|
+
// wiring further down for that fan-in.
|
|
1660
|
+
var renderLocalBtn = document.getElementById('render-local-btn');
|
|
1661
|
+
var renderLocalBtnLabel = document.getElementById('render-local-btn-label');
|
|
1662
|
+
var renderLocalProgress = document.getElementById('render-local-progress');
|
|
1663
|
+
var renderLocalProgressFill = document.getElementById('render-local-progress-fill');
|
|
1664
|
+
var renderLocalProgressLabel = document.getElementById('render-local-progress-label');
|
|
1665
|
+
var toast = document.getElementById('local-render-toast');
|
|
1666
|
+
var toastPath = document.getElementById('local-render-toast-path');
|
|
1667
|
+
var toastOpen = document.getElementById('local-render-toast-open');
|
|
1668
|
+
var toastReveal = document.getElementById('local-render-toast-reveal');
|
|
1669
|
+
var toastCopy = document.getElementById('local-render-toast-copy');
|
|
1670
|
+
var toastClose = document.getElementById('local-render-toast-close');
|
|
1671
|
+
|
|
1672
|
+
var renderLocalState = 'idle'; // idle | submitting | rendering | completed | failed
|
|
1673
|
+
var renderLocalJobId = null;
|
|
1674
|
+
var renderLocalOutput = null;
|
|
1675
|
+
|
|
1676
|
+
function refreshRenderLocalBtn() {
|
|
1677
|
+
renderLocalBtn.classList.remove('completed');
|
|
1678
|
+
if (renderLocalState === 'idle' || renderLocalState === 'failed') {
|
|
1679
|
+
renderLocalBtn.disabled = false;
|
|
1680
|
+
renderLocalBtnLabel.textContent = 'Render mp4 (local)';
|
|
1681
|
+
} else if (renderLocalState === 'submitting') {
|
|
1682
|
+
renderLocalBtn.disabled = true;
|
|
1683
|
+
renderLocalBtnLabel.textContent = 'Starting…';
|
|
1684
|
+
} else if (renderLocalState === 'rendering') {
|
|
1685
|
+
renderLocalBtn.disabled = true;
|
|
1686
|
+
renderLocalBtnLabel.textContent = 'Rendering…';
|
|
1687
|
+
} else if (renderLocalState === 'completed') {
|
|
1688
|
+
renderLocalBtn.disabled = false;
|
|
1689
|
+
renderLocalBtn.classList.add('completed');
|
|
1690
|
+
renderLocalBtnLabel.textContent = 'Re-render local';
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function showLocalProgress(pct) {
|
|
1694
|
+
renderLocalProgress.classList.remove('hidden');
|
|
1695
|
+
var clamped = Math.max(0, Math.min(100, pct));
|
|
1696
|
+
renderLocalProgressFill.style.width = clamped.toFixed(1) + '%';
|
|
1697
|
+
renderLocalProgressLabel.textContent = clamped.toFixed(0) + '%';
|
|
1698
|
+
}
|
|
1699
|
+
function hideLocalProgress() {
|
|
1700
|
+
renderLocalProgress.classList.add('hidden');
|
|
1701
|
+
renderLocalProgressFill.style.width = '0%';
|
|
1702
|
+
renderLocalProgressLabel.textContent = '0%';
|
|
1703
|
+
}
|
|
1704
|
+
function showToast(outputPath) {
|
|
1705
|
+
renderLocalOutput = outputPath;
|
|
1706
|
+
toastPath.textContent = outputPath;
|
|
1707
|
+
// file:// link only renders if the user copy-pastes — Chrome blocks
|
|
1708
|
+
// file:// navigation from http://. We still expose the URL via Copy
|
|
1709
|
+
// path, and the anchor itself is a no-op fallback.
|
|
1710
|
+
var fileUrl = 'file://' + outputPath;
|
|
1711
|
+
toastOpen.setAttribute('href', fileUrl);
|
|
1712
|
+
toastReveal.setAttribute('href', 'file://' + outputPath.replace(/[^/]+$/, ''));
|
|
1713
|
+
toast.classList.add('open');
|
|
1714
|
+
}
|
|
1715
|
+
function hideToast() { toast.classList.remove('open'); }
|
|
1716
|
+
|
|
1717
|
+
toastClose.addEventListener('click', hideToast);
|
|
1718
|
+
toastCopy.addEventListener('click', function () {
|
|
1719
|
+
if (!renderLocalOutput) return;
|
|
1720
|
+
copyTextToClipboard(renderLocalOutput, toastCopy);
|
|
1721
|
+
});
|
|
1722
|
+
|
|
1723
|
+
renderLocalBtn.addEventListener('click', function () {
|
|
1724
|
+
if (renderLocalState === 'rendering' || renderLocalState === 'submitting') return;
|
|
1725
|
+
if (!currentDeck || !Array.isArray(currentDeck.cards)) {
|
|
1726
|
+
alert('No deck loaded — fix the JSON parse error first.');
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
renderLocalState = 'submitting';
|
|
1730
|
+
refreshRenderLocalBtn();
|
|
1731
|
+
showLocalProgress(0);
|
|
1732
|
+
hideToast();
|
|
1733
|
+
fetch('/api/render-local', {
|
|
1734
|
+
method: 'POST',
|
|
1735
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1736
|
+
credentials: 'omit',
|
|
1737
|
+
body: JSON.stringify({}), // server reads the live snapshot
|
|
1738
|
+
})
|
|
1739
|
+
.then(function (r) { return r.json().then(function (j) { return { status: r.status, json: j }; }); })
|
|
1740
|
+
.then(function (resp) {
|
|
1741
|
+
if (resp.status !== 200 || resp.json.code !== 'success') {
|
|
1742
|
+
renderLocalState = 'failed';
|
|
1743
|
+
refreshRenderLocalBtn();
|
|
1744
|
+
hideLocalProgress();
|
|
1745
|
+
alert('Local render failed: ' + (resp.json.message || ('HTTP ' + resp.status)));
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
renderLocalJobId = resp.json.jobId;
|
|
1749
|
+
renderLocalOutput = resp.json.outputPath;
|
|
1750
|
+
renderLocalState = 'rendering';
|
|
1751
|
+
refreshRenderLocalBtn();
|
|
1752
|
+
})
|
|
1753
|
+
.catch(function (err) {
|
|
1754
|
+
renderLocalState = 'failed';
|
|
1755
|
+
refreshRenderLocalBtn();
|
|
1756
|
+
hideLocalProgress();
|
|
1757
|
+
alert('Network error: ' + err.message);
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
// Auth-state probe — flips the visual emphasis between local and cloud
|
|
1762
|
+
// render. When the user is logged out (no cached JWT), the local
|
|
1763
|
+
// button stays primary and the cloud button gets the secondary style
|
|
1764
|
+
// (already the default in the HTML). Logged in: emphasis flips.
|
|
1765
|
+
fetch('/api/auth-state', { credentials: 'omit' })
|
|
1766
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
1767
|
+
.then(function (j) {
|
|
1768
|
+
if (!j) return;
|
|
1769
|
+
if (j.tokenAvailable) {
|
|
1770
|
+
// Cloud → primary, local → secondary
|
|
1771
|
+
renderBtn.classList.remove('secondary');
|
|
1772
|
+
renderLocalBtn.classList.add('secondary');
|
|
1773
|
+
}
|
|
1774
|
+
})
|
|
1775
|
+
.catch(function () { /* leave defaults; logged-out is the safer assumption */ });
|
|
1776
|
+
|
|
1777
|
+
refreshRenderLocalBtn();
|
|
1778
|
+
|
|
1779
|
+
// ─── Inline edit (caption / narration / title rows) ─────────────────
|
|
1780
|
+
// Conservative scope: only "safe" text fields are click-to-edit. theme,
|
|
1781
|
+
// figureKeyword, kind, and card add/delete still require external
|
|
1782
|
+
// editor (or AI-prompt flow above). Save trigger: blur or Cmd/Ctrl+
|
|
1783
|
+
// Enter. Cancel: Escape.
|
|
1784
|
+
var activeInlineEdit = null; // { node, originalText, cardIdx, field, lineIdx }
|
|
1785
|
+
|
|
1786
|
+
function cancelInlineEdit() {
|
|
1787
|
+
if (!activeInlineEdit) return;
|
|
1788
|
+
var ed = activeInlineEdit;
|
|
1789
|
+
activeInlineEdit = null;
|
|
1790
|
+
var ta = ed.node.querySelector && ed.node.querySelector('textarea.inline-edit');
|
|
1791
|
+
// Restore original text — we re-render the card label from
|
|
1792
|
+
// currentDeck so theme styles re-apply cleanly.
|
|
1793
|
+
ed.node.textContent = ed.originalText;
|
|
1794
|
+
if (ed.errNode && ed.errNode.parentNode) ed.errNode.parentNode.removeChild(ed.errNode);
|
|
1795
|
+
if (ed.hintNode && ed.hintNode.parentNode) ed.hintNode.parentNode.removeChild(ed.hintNode);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function applyEditToDeck(deck, cardIdx, field, lineIdx, newValue) {
|
|
1799
|
+
// Returns a shallow-copied deck with the single field replaced.
|
|
1800
|
+
// Never mutates the original; the save endpoint validates the result.
|
|
1801
|
+
var copy = JSON.parse(JSON.stringify(deck));
|
|
1802
|
+
var card = copy.cards && copy.cards[cardIdx];
|
|
1803
|
+
if (!card) return null;
|
|
1804
|
+
if (field === 'caption') card.caption = newValue;
|
|
1805
|
+
else if (field === 'narration') {
|
|
1806
|
+
if (newValue === '') {
|
|
1807
|
+
delete card.narration;
|
|
1808
|
+
} else {
|
|
1809
|
+
card.narration = newValue;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
else if (field === 'title') {
|
|
1813
|
+
if (!Array.isArray(card.title)) card.title = [];
|
|
1814
|
+
card.title[lineIdx] = newValue;
|
|
1815
|
+
}
|
|
1816
|
+
return copy;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function commitInlineEdit(newValue) {
|
|
1820
|
+
if (!activeInlineEdit) return;
|
|
1821
|
+
var ed = activeInlineEdit;
|
|
1822
|
+
if (newValue === ed.originalText) {
|
|
1823
|
+
cancelInlineEdit();
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
var updatedDeck = applyEditToDeck(currentDeck, ed.cardIdx, ed.field, ed.lineIdx, newValue);
|
|
1827
|
+
if (!updatedDeck) { cancelInlineEdit(); return; }
|
|
1828
|
+
|
|
1829
|
+
// Optimistically swap in the new text so the user sees it instantly;
|
|
1830
|
+
// server validation may still reject (rare — schema caps). If it
|
|
1831
|
+
// does, we restore originalText and surface the error inline.
|
|
1832
|
+
var node = ed.node;
|
|
1833
|
+
node.textContent = newValue;
|
|
1834
|
+
var ta = node.querySelector && node.querySelector('textarea.inline-edit');
|
|
1835
|
+
if (ta) ta.remove();
|
|
1836
|
+
if (ed.hintNode && ed.hintNode.parentNode) ed.hintNode.parentNode.removeChild(ed.hintNode);
|
|
1837
|
+
if (ed.errNode && ed.errNode.parentNode) ed.errNode.parentNode.removeChild(ed.errNode);
|
|
1838
|
+
node.style.opacity = '0.6';
|
|
1839
|
+
|
|
1840
|
+
var snapshot = ed;
|
|
1841
|
+
activeInlineEdit = null;
|
|
1842
|
+
|
|
1843
|
+
fetch('/api/deck', {
|
|
1844
|
+
method: 'POST',
|
|
1845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1846
|
+
credentials: 'omit',
|
|
1847
|
+
body: JSON.stringify({ deck: updatedDeck }),
|
|
1848
|
+
})
|
|
1849
|
+
.then(function (r) { return r.json().then(function (j) { return { status: r.status, json: j }; }); })
|
|
1850
|
+
.then(function (resp) {
|
|
1851
|
+
node.style.opacity = '';
|
|
1852
|
+
if (resp.status !== 200 || resp.json.code !== 'success') {
|
|
1853
|
+
// Restore + surface the validation error inline. The file
|
|
1854
|
+
// watcher will not have fired (we didn't write), so the page
|
|
1855
|
+
// text needs explicit revert.
|
|
1856
|
+
node.textContent = snapshot.originalText;
|
|
1857
|
+
var card = node.closest && node.closest('.stage-card');
|
|
1858
|
+
if (card) {
|
|
1859
|
+
var err = document.createElement('div');
|
|
1860
|
+
err.className = 'inline-edit-err';
|
|
1861
|
+
err.textContent = resp.json.message || 'Save rejected';
|
|
1862
|
+
card.appendChild(err);
|
|
1863
|
+
setTimeout(function () { if (err.parentNode) err.parentNode.removeChild(err); }, 3500);
|
|
1864
|
+
}
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
// Success — watcher will broadcast deck SSE, which re-renders
|
|
1868
|
+
// the card with the new value + diff highlight ring. Nothing
|
|
1869
|
+
// more to do here.
|
|
1870
|
+
})
|
|
1871
|
+
.catch(function (err) {
|
|
1872
|
+
node.style.opacity = '';
|
|
1873
|
+
node.textContent = snapshot.originalText;
|
|
1874
|
+
alert('Save failed: ' + err.message);
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function startInlineEdit(node) {
|
|
1879
|
+
if (activeInlineEdit) return;
|
|
1880
|
+
var card = node.closest && node.closest('.stage-card');
|
|
1881
|
+
if (!card) return;
|
|
1882
|
+
var cardIdx = parseInt(card.getAttribute('data-card-idx'), 10);
|
|
1883
|
+
if (!Number.isFinite(cardIdx)) return;
|
|
1884
|
+
var field = node.getAttribute('data-edit-field');
|
|
1885
|
+
var lineIdx = parseInt(node.getAttribute('data-edit-index') || '0', 10);
|
|
1886
|
+
if (!Number.isFinite(lineIdx)) lineIdx = 0;
|
|
1887
|
+
var isEmpty = node.getAttribute('data-empty') === '1';
|
|
1888
|
+
var original = isEmpty ? '' : node.textContent;
|
|
1889
|
+
var ta = document.createElement('textarea');
|
|
1890
|
+
ta.className = 'inline-edit';
|
|
1891
|
+
ta.value = original;
|
|
1892
|
+
// Approximate visual metrics so the card doesn't reflow noticeably.
|
|
1893
|
+
var rect = node.getBoundingClientRect();
|
|
1894
|
+
ta.style.minHeight = Math.max(20, rect.height) + 'px';
|
|
1895
|
+
ta.rows = field === 'narration' ? 4 : 2;
|
|
1896
|
+
node.textContent = '';
|
|
1897
|
+
node.appendChild(ta);
|
|
1898
|
+
var hint = document.createElement('div');
|
|
1899
|
+
hint.className = 'inline-edit-hint';
|
|
1900
|
+
hint.textContent = 'Cmd/Ctrl+Enter: save · Esc: cancel · blur: save';
|
|
1901
|
+
card.appendChild(hint);
|
|
1902
|
+
|
|
1903
|
+
activeInlineEdit = {
|
|
1904
|
+
node: node,
|
|
1905
|
+
originalText: original,
|
|
1906
|
+
cardIdx: cardIdx,
|
|
1907
|
+
field: field,
|
|
1908
|
+
lineIdx: lineIdx,
|
|
1909
|
+
hintNode: hint,
|
|
1910
|
+
errNode: null,
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
ta.focus();
|
|
1914
|
+
ta.setSelectionRange(0, ta.value.length);
|
|
1915
|
+
|
|
1916
|
+
ta.addEventListener('keydown', function (e) {
|
|
1917
|
+
if (e.key === 'Escape') {
|
|
1918
|
+
e.preventDefault();
|
|
1919
|
+
cancelInlineEdit();
|
|
1920
|
+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
1921
|
+
e.preventDefault();
|
|
1922
|
+
commitInlineEdit(ta.value);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
ta.addEventListener('blur', function () {
|
|
1926
|
+
// Use rAF so escape-keydown (which calls cancelInlineEdit) wins the
|
|
1927
|
+
// race against blur. By the time rAF fires, activeInlineEdit will
|
|
1928
|
+
// be null if the user pressed Escape.
|
|
1929
|
+
requestAnimationFrame(function () {
|
|
1930
|
+
if (!activeInlineEdit || activeInlineEdit.node !== node) return;
|
|
1931
|
+
commitInlineEdit(ta.value);
|
|
1932
|
+
});
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
cardsPane.addEventListener('click', function (ev) {
|
|
1937
|
+
var target = ev.target.closest && ev.target.closest('.editable');
|
|
1938
|
+
if (!target) return;
|
|
1939
|
+
// Don't intercept clicks while an edit is already running on this
|
|
1940
|
+
// same node — the textarea inside catches its own events.
|
|
1941
|
+
if (target.querySelector && target.querySelector('textarea.inline-edit')) return;
|
|
1942
|
+
ev.stopPropagation();
|
|
1943
|
+
startInlineEdit(target);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1464
1946
|
// ─── Edit-with-AI: prompt builder (mirrors stage-core/edit-prompt.js) ─
|
|
1465
1947
|
// Kept as a small in-browser duplicate because the template is an
|
|
1466
1948
|
// inline string — pulling the Node module across the boundary would
|
package/package.json
CHANGED