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.
@@ -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
- <button class="render-btn" id="render-btn" type="button" aria-label="Render this deck to mp4 (cloud)">
604
- <span class="icon">▶</span>
605
- <span id="render-btn-label">Render mp4</span>
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">' + escapeHtml(line) + '</span>';
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
- titleHtml = '<div class="caption">' + escapeHtml(caption) + '</div>'
936
- + (narration ? '<div class="narration">' + escapeHtml(narration) + '</div>' : '');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voxflow",
3
- "version": "1.15.1",
3
+ "version": "1.15.3",
4
4
  "description": "AI audio content creation CLI — stories, podcasts, narration, dubbing, transcription, translation, and video translation with TTS",
5
5
  "bin": {
6
6
  "voxflow": "./dist/index.js"