@sutrata/editor 0.1.2 → 0.3.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.
Files changed (125) hide show
  1. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  2. package/dist/ai/VoiceConfirmDialog.js +6 -6
  3. package/dist/ai/VoiceToolbar.d.ts +1 -0
  4. package/dist/ai/VoiceToolbar.js +22 -16
  5. package/dist/ai/clean-response.d.ts +7 -0
  6. package/dist/ai/clean-response.js +58 -0
  7. package/dist/ai/format-transcript.d.ts +3 -0
  8. package/dist/ai/format-transcript.js +7 -0
  9. package/dist/ai/prompts.d.ts +8 -1
  10. package/dist/ai/prompts.js +34 -7
  11. package/dist/autocomplete/suggestions.js +1 -1
  12. package/dist/context/DocumentContext.d.ts +34 -5
  13. package/dist/context/DocumentContext.js +152 -51
  14. package/dist/editor/EditorView.js +35 -5
  15. package/dist/editor/SelectionMenu.js +5 -3
  16. package/dist/editor/editor-bus.d.ts +3 -0
  17. package/dist/editor/editor-bus.js +13 -0
  18. package/dist/editor/external-change.d.ts +11 -0
  19. package/dist/editor/external-change.js +105 -0
  20. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  21. package/dist/editor/external-decorations-plugin.js +151 -0
  22. package/dist/editor/frontmatter-field-view.js +3 -0
  23. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  24. package/dist/editor/omitted-scene-plugin.js +44 -0
  25. package/dist/editor/plugins.js +4 -0
  26. package/dist/editor/private-metadata-plugin.d.ts +13 -0
  27. package/dist/editor/private-metadata-plugin.js +100 -0
  28. package/dist/editor/prosemirror-to-sutra.js +179 -74
  29. package/dist/editor/scene-heading-view.js +2 -0
  30. package/dist/editor/schema.js +74 -13
  31. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  32. package/dist/editor/sutra-to-prosemirror.js +32 -34
  33. package/dist/extensions/ai-provider.d.ts +44 -0
  34. package/dist/extensions/ai-provider.js +27 -0
  35. package/dist/extensions/collab.d.ts +21 -0
  36. package/dist/extensions/collab.js +6 -0
  37. package/dist/extensions/command-registry.d.ts +49 -0
  38. package/dist/extensions/command-registry.js +28 -0
  39. package/dist/extensions/decorations.d.ts +48 -0
  40. package/dist/extensions/decorations.js +6 -0
  41. package/dist/extensions/export-registry.d.ts +59 -0
  42. package/dist/extensions/export-registry.js +10 -0
  43. package/dist/extensions/panel-registry.d.ts +30 -0
  44. package/dist/extensions/panel-registry.js +16 -0
  45. package/dist/extensions/registries.d.ts +15 -0
  46. package/dist/extensions/registries.js +12 -0
  47. package/dist/extensions/registry-store.d.ts +26 -0
  48. package/dist/extensions/registry-store.js +35 -0
  49. package/dist/extensions/session.d.ts +32 -0
  50. package/dist/extensions/session.js +20 -0
  51. package/dist/extensions/speech-provider.d.ts +35 -0
  52. package/dist/extensions/speech-provider.js +17 -0
  53. package/dist/extensions/storage-adapter.d.ts +36 -1
  54. package/dist/file/builtin-exporters.d.ts +9 -0
  55. package/dist/file/builtin-exporters.js +72 -0
  56. package/dist/file/docx-exporter.js +7 -4
  57. package/dist/file/docx-importer.d.ts +9 -0
  58. package/dist/file/docx-importer.js +47 -19
  59. package/dist/file/scene-number.d.ts +5 -0
  60. package/dist/file/scene-number.js +8 -0
  61. package/dist/file/workflow-reports.js +13 -7
  62. package/dist/findreplace/FindReplace.js +5 -2
  63. package/dist/import/AIImportDialog.d.ts +11 -0
  64. package/dist/import/AIImportDialog.js +112 -0
  65. package/dist/import/ai-import.d.ts +51 -0
  66. package/dist/import/ai-import.js +200 -0
  67. package/dist/import/apply-import.d.ts +7 -0
  68. package/dist/import/apply-import.js +35 -0
  69. package/dist/import/extract.d.ts +17 -0
  70. package/dist/import/extract.js +18 -0
  71. package/dist/index.d.ts +18 -3
  72. package/dist/index.js +7 -0
  73. package/dist/locales/bn.json +8 -0
  74. package/dist/locales/en.json +8 -0
  75. package/dist/locales/gu.json +8 -0
  76. package/dist/locales/hi.json +8 -0
  77. package/dist/locales/kn.json +8 -0
  78. package/dist/locales/ml.json +8 -0
  79. package/dist/locales/mr.json +8 -0
  80. package/dist/locales/or.json +8 -0
  81. package/dist/locales/pa.json +8 -0
  82. package/dist/locales/si.json +8 -0
  83. package/dist/locales/ta.json +8 -0
  84. package/dist/locales/te.json +8 -0
  85. package/dist/navigator/SceneNavigator.js +125 -131
  86. package/dist/navigator/StatisticsDialog.js +5 -1
  87. package/dist/navigator/scene-edits.d.ts +18 -0
  88. package/dist/navigator/scene-edits.js +143 -0
  89. package/dist/navigator/scene-list.js +4 -4
  90. package/dist/navigator/scene-numbering.d.ts +37 -0
  91. package/dist/navigator/scene-numbering.js +88 -0
  92. package/dist/settings/SettingsDialog.js +8 -120
  93. package/dist/shell/AppBar.js +25 -4
  94. package/dist/shell/AppShell.js +18 -4
  95. package/dist/shell/ElementToolbar.d.ts +5 -0
  96. package/dist/shell/ElementToolbar.js +35 -25
  97. package/dist/shell/LanguageStatus.js +6 -0
  98. package/dist/shell/StatusBar.js +11 -6
  99. package/dist/shell/builtin-commands.d.ts +10 -0
  100. package/dist/shell/builtin-commands.js +43 -0
  101. package/dist/shell/icons.d.ts +6 -0
  102. package/dist/shell/icons.js +6 -0
  103. package/dist/shell/use-commands.d.ts +7 -0
  104. package/dist/shell/use-commands.js +24 -0
  105. package/dist/shell/use-panel-context.d.ts +3 -0
  106. package/dist/shell/use-panel-context.js +39 -0
  107. package/dist/source/SourceView.js +39 -3
  108. package/dist/styles/screenplay.css +114 -1
  109. package/dist/titlepage/ExportDialog.d.ts +5 -0
  110. package/dist/titlepage/ExportDialog.js +59 -96
  111. package/dist/titlepage/frontmatter-form.js +1 -1
  112. package/package.json +4 -4
  113. package/dist/ai/OnboardingDialog.d.ts +0 -6
  114. package/dist/ai/OnboardingDialog.js +0 -115
  115. package/dist/ai/ai-client.d.ts +0 -71
  116. package/dist/ai/ai-client.js +0 -494
  117. package/dist/ai/generated-providers.json +0 -3696
  118. package/dist/ai/voice-service.d.ts +0 -15
  119. package/dist/ai/voice-service.js +0 -81
  120. package/dist/editor/character-view.d.ts +0 -35
  121. package/dist/editor/character-view.js +0 -112
  122. package/dist/editor/transliterate-input.d.ts +0 -10
  123. package/dist/editor/transliterate-input.js +0 -88
  124. package/dist/extensions/types.d.ts +0 -73
  125. package/dist/extensions/types.js +0 -12
@@ -8,9 +8,12 @@ import { getEditorView, getSourceView } from '../editor/editor-bus';
8
8
  import { schema } from '../editor/schema';
9
9
  import { EditorView as CmView } from '@codemirror/view';
10
10
  import { StatisticsDialog } from './StatisticsDialog';
11
- import { EyeIcon, EyeOffIcon, LockIcon, StatsIcon, SparklesIcon, CloseIcon } from '../shell/icons';
12
- import { getAIConfig, callAIStructured, hasConfiguredApiKey } from '../ai/ai-client';
11
+ import { EyeIcon, EyeOffIcon, RenumberIcon, OmitIcon, RestoreIcon, StatsIcon, SparklesIcon, CloseIcon } from '../shell/icons';
12
+ import { sceneNumberIssues, renumberScenes } from './scene-numbering';
13
+ import { setSceneIdsInText, omitSceneInText, restoreSceneInText, applyTextEdit } from './scene-edits';
13
14
  import { SCENE_METADATA_PROMPT, SCENE_METADATA_SCHEMA } from '../ai/prompts';
15
+ import { useAI } from '../extensions/ai-provider';
16
+ import { useCanEdit } from '../extensions/session';
14
17
  import { prosemirrorToSutra } from '../editor/prosemirror-to-sutra';
15
18
  /** Update the scene synopsis in the raw Sutra text. */
16
19
  function updateSynopsisInText(text, sceneOffset, newSynopsis) {
@@ -165,39 +168,35 @@ function updateSceneMetadataInProseMirror(sceneIndex, key, val) {
165
168
  view.dispatch(tr);
166
169
  }
167
170
  }
168
- /** Lock scene numbers in raw Sutra text. */
169
- function lockNumbersInText(text) {
170
- const lines = text.split('\n');
171
- const result = [];
172
- let sceneCount = 0;
173
- for (let i = 0; i < lines.length; i++) {
174
- const line = lines[i] ?? '';
175
- const trimmed = line.trim();
176
- result.push(line);
177
- if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
178
- sceneCount++;
179
- let hasNumber = false;
180
- let j = i + 1;
181
- while (j < lines.length) {
182
- const nextLine = (lines[j] ?? '').trim();
183
- if (nextLine.startsWith('##') || nextLine.startsWith('#'))
184
- break;
185
- if (nextLine.startsWith('& number:')) {
186
- hasNumber = true;
187
- break;
188
- }
189
- j++;
190
- }
191
- if (!hasNumber) {
192
- result.push(`& number: ${sceneCount}`);
193
- }
194
- }
171
+ /** Ask the AI provider for a scene's synopsis + duration (one structured call). */
172
+ function generateSceneMetadata(ai, sceneText) {
173
+ return ai.completeStructured({
174
+ system: SCENE_METADATA_PROMPT, prompt: sceneText, schema: SCENE_METADATA_SCHEMA,
175
+ });
176
+ }
177
+ /**
178
+ * True when the provider can take calls; otherwise points the user at its
179
+ * setup (if it has one) and returns false.
180
+ */
181
+ async function ensureAIReady(ai, showToast) {
182
+ if (await ai.isConfigured())
183
+ return true;
184
+ if (ai.openSetup) {
185
+ showToast('AI must be set up to generate synopsis & duration. Opening Setup...', 'info');
186
+ ai.openSetup();
187
+ }
188
+ else {
189
+ showToast(`${ai.displayName} is not available right now.`, 'warn');
195
190
  }
196
- return result.join('\n');
191
+ return false;
197
192
  }
198
193
  export function SceneNavigator() {
199
- const { text, setText, setAIOnboardingVisible, showToast, setNavVisible } = useDocument();
194
+ const { text, setText, showToast, setNavVisible } = useDocument();
200
195
  const { t } = useTranslation();
196
+ // AI actions appear only with an injected, permitted AIProvider; structural
197
+ // actions (reorder, synopsis edits, number locking) only in edit sessions.
198
+ const ai = useAI();
199
+ const canEdit = useCanEdit();
201
200
  const [scenes, setScenes] = useState([]);
202
201
  const [dragIndex, setDragIndex] = useState(null);
203
202
  const [dropIndex, setDropIndex] = useState(null);
@@ -215,23 +214,23 @@ export function SceneNavigator() {
215
214
  useEffect(() => {
216
215
  setScenes(buildSceneList(text));
217
216
  }, [text]);
217
+ // Numbers that need renumbering (missing/duplicate/out of order; §7.4).
218
+ // Nothing is flagged until at least one scene has a number.
219
+ const numberIssues = React.useMemo(() => sceneNumberIssues(scenes.map(s => s.id)), [scenes]);
220
+ const issueLabel = (issue) => t(`navigator.number.${issue}`);
218
221
  const handleSceneMetadata = useCallback(async (scene) => {
219
222
  setIsProcessing(true);
220
223
  setProgressText('Generating synopsis & estimating duration...');
221
224
  try {
222
- const aiConfig = getAIConfig();
223
- if (!(await hasConfiguredApiKey(aiConfig))) {
224
- showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info');
225
- setAIOnboardingVisible(true);
225
+ if (!ai || !(await ensureAIReady(ai, showToast)))
226
226
  return;
227
- }
228
227
  const idx = scenes.findIndex(s => s.index === scene.index);
229
228
  if (idx === -1)
230
229
  return;
231
230
  const start = scene.textOffset;
232
231
  const end = idx + 1 < scenes.length ? scenes[idx + 1].textOffset : text.length;
233
232
  const sceneText = text.substring(start, end);
234
- const { synopsis, duration } = await callAIStructured(sceneText, SCENE_METADATA_PROMPT, aiConfig, SCENE_METADATA_SCHEMA);
233
+ const { synopsis, duration } = await generateSceneMetadata(ai, sceneText);
235
234
  const view = getEditorView();
236
235
  if (view) {
237
236
  updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim());
@@ -251,17 +250,13 @@ export function SceneNavigator() {
251
250
  setIsProcessing(false);
252
251
  setProgressText('');
253
252
  }
254
- }, [scenes, text, setText, setAIOnboardingVisible, showToast]);
253
+ }, [ai, scenes, text, setText, showToast]);
255
254
  const handleGenerateAllSceneMetadata = useCallback(async () => {
256
255
  setIsProcessing(true);
257
256
  setProgressText('Starting batch synopsis & duration generation...');
258
257
  try {
259
- const aiConfig = getAIConfig();
260
- if (!(await hasConfiguredApiKey(aiConfig))) {
261
- showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info');
262
- setAIOnboardingVisible(true);
258
+ if (!ai || !(await ensureAIReady(ai, showToast)))
263
259
  return;
264
- }
265
260
  const pmView = getEditorView();
266
261
  const cmView = getSourceView();
267
262
  let currentText = pmView ? prosemirrorToSutra(pmView.state.doc) : (cmView ? cmView.state.doc.toString() : textRef.current);
@@ -285,7 +280,7 @@ export function SceneNavigator() {
285
280
  const end = idx + 1 < currentScenes.length ? currentScenes[idx + 1].textOffset : currentText.length;
286
281
  const sceneText = currentText.substring(start, end);
287
282
  try {
288
- const { synopsis, duration } = await callAIStructured(sceneText, SCENE_METADATA_PROMPT, aiConfig, SCENE_METADATA_SCHEMA);
283
+ const { synopsis, duration } = await generateSceneMetadata(ai, sceneText);
289
284
  if (pmView) {
290
285
  if (!latestScene.synopsis)
291
286
  updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim());
@@ -321,7 +316,7 @@ export function SceneNavigator() {
321
316
  setIsProcessing(false);
322
317
  setProgressText('');
323
318
  }
324
- }, [setText, setAIOnboardingVisible, showToast]);
319
+ }, [ai, setText, showToast]);
325
320
  const handleFrontmatter = useCallback(() => {
326
321
  const view = getEditorView();
327
322
  if (!view) {
@@ -337,7 +332,7 @@ export function SceneNavigator() {
337
332
  }
338
333
  let tr = view.state.tr;
339
334
  const first = view.state.doc.firstChild;
340
- if (!first || first.type.name !== 'title_page') {
335
+ if (canEdit && (!first || first.type.name !== 'title_page')) {
341
336
  const field = schema.nodes['frontmatter_field'].create({ fmKey: 'title' });
342
337
  tr = tr.insert(0, schema.nodes['title_page'].create({}, [field]));
343
338
  }
@@ -348,7 +343,7 @@ export function SceneNavigator() {
348
343
  if (typeof window !== 'undefined' && window.innerWidth <= 640) {
349
344
  setNavVisible(false);
350
345
  }
351
- }, [setNavVisible]);
346
+ }, [canEdit, setNavVisible]);
352
347
  const handleJump = useCallback((scene) => {
353
348
  const view = getEditorView();
354
349
  if (!view) {
@@ -403,7 +398,7 @@ export function SceneNavigator() {
403
398
  }, []);
404
399
  const handleDrop = useCallback((e, targetIdx) => {
405
400
  e.preventDefault();
406
- if (dragIndex === null || dragIndex === targetIdx) {
401
+ if (!canEdit || dragIndex === null || dragIndex === targetIdx) {
407
402
  setDragIndex(null);
408
403
  setDropIndex(null);
409
404
  return;
@@ -412,7 +407,7 @@ export function SceneNavigator() {
412
407
  setText(newText);
413
408
  setDragIndex(null);
414
409
  setDropIndex(null);
415
- }, [dragIndex, text, scenes, setText]);
410
+ }, [canEdit, dragIndex, text, scenes, setText]);
416
411
  const commitSynopsis = useCallback((idx, scene) => {
417
412
  setEditingSceneIdx(null);
418
413
  const newSynopsis = editVal.trim();
@@ -472,50 +467,38 @@ export function SceneNavigator() {
472
467
  setText(nextText);
473
468
  }
474
469
  }, [editVal, text, setText]);
475
- const handleLockNumbers = useCallback(() => {
476
- const view = getEditorView();
477
- if (view) {
478
- let tr = view.state.tr;
479
- let headingCount = 0;
480
- let posMap = [];
481
- view.state.doc.forEach((node, offset) => {
482
- if (node.type.name === 'scene_heading') {
483
- headingCount++;
484
- let hasNumber = false;
485
- let index = view.state.doc.resolve(offset).index(0) + 1;
486
- while (index < view.state.doc.childCount) {
487
- const sibling = view.state.doc.child(index);
488
- if (sibling.type.name === 'scene_metadata') {
489
- if (sibling.attrs['metaKey'] === 'number') {
490
- hasNumber = true;
491
- break;
492
- }
493
- }
494
- else {
495
- break;
496
- }
497
- index++;
498
- }
499
- if (!hasNumber) {
500
- posMap.push({ headingPos: offset, currentNum: String(headingCount) });
501
- }
502
- }
503
- });
504
- for (let k = posMap.length - 1; k >= 0; k--) {
505
- const { headingPos, currentNum } = posMap[k];
506
- const headingNode = view.state.doc.nodeAt(headingPos);
507
- const insertPos = headingPos + headingNode.nodeSize;
508
- const metaNode = schema.nodes['scene_metadata'].create({ metaKey: 'number' }, schema.text(currentNum));
509
- tr = tr.insert(insertPos, metaNode);
510
- }
511
- view.dispatch(tr);
512
- }
513
- else {
514
- const nextText = lockNumbersInText(text);
515
- setText(nextText);
470
+ /**
471
+ * Current editor text: the navigator's `text` can lag the editor by a
472
+ * render, and these actions rewrite whole scenes.
473
+ */
474
+ const liveText = useCallback(() => {
475
+ const pmView = getEditorView();
476
+ const cmView = getSourceView();
477
+ return pmView ? prosemirrorToSutra(pmView.state.doc) : cmView ? cmView.state.doc.toString() : textRef.current;
478
+ }, []);
479
+ // Renumber scenes (format spec §7.4): only when the writer asks.
480
+ const handleRenumber = useCallback(() => {
481
+ const current = liveText();
482
+ const list = buildSceneList(current);
483
+ const next = renumberScenes(list.map(s => s.id));
484
+ const ids = next.map((n, i) => (n === list[i].id ? undefined : n));
485
+ const changed = ids.filter(n => n !== undefined).length;
486
+ if (changed === 0) {
487
+ showToast('Scene numbers are already in order.', 'info');
488
+ return;
516
489
  }
517
- }, [text, setText]);
518
- return (_jsxs("div", { className: "cs-navigator", role: "navigation", "aria-label": "Scene Navigator", children: [_jsxs("div", { className: "cs-nav-mobile-bar", children: [_jsx("span", { className: "cs-nav-mobile-title", children: t('navigator.title') }), _jsx("button", { type: "button", className: "cs-nav-mobile-close", "aria-label": "Close navigator", onClick: () => setNavVisible(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-nav-list", children: [_jsx("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: handleFrontmatter, title: t('navigator.frontmatter'), children: _jsx("span", { className: "cs-nav-title", children: t('navigator.frontmatter') }) }), _jsxs("div", { className: "cs-nav-section-header cs-nav-scenes-header", style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("span", { className: "cs-nav-title", children: t('navigator.title') }), _jsx("span", { className: "cs-nav-count", children: scenes.length })] }), _jsxs("div", { style: { display: 'flex', gap: '4px', alignItems: 'center' }, onClick: e => e.stopPropagation(), children: [_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: () => setShowNavSynopsis(!showNavSynopsis), title: showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', "aria-label": showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', children: showNavSynopsis ? _jsx(EyeIcon, { size: 14 }) : _jsx(EyeOffIcon, { size: 14 }) }), _jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleGenerateAllSceneMetadata, disabled: isProcessing, title: "Generate synopsis & estimate duration (all scenes)", "aria-label": "Generate synopsis & estimate duration (all scenes)", style: { opacity: isProcessing ? 0.5 : 1 }, children: _jsx(SparklesIcon, { size: 14 }) }), _jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleLockNumbers, title: "Lock Scene Numbers", "aria-label": "Lock Scene Numbers", children: _jsx(LockIcon, { size: 14 }) })] })] }), isProcessing && progressText && (_jsxs("div", { className: "cs-nav-progress", style: {
490
+ applyTextEdit(setSceneIdsInText(current, ids), setText);
491
+ showToast(`Renumbered ${changed} scene${changed === 1 ? '' : 's'}.`, 'success');
492
+ }, [liveText, setText, showToast]);
493
+ const handleOmitToggle = useCallback((scene) => {
494
+ const current = liveText();
495
+ const idx = buildSceneList(current).findIndex(s => s.index === scene.index);
496
+ if (idx === -1)
497
+ return;
498
+ const omitted = scene.status.toLowerCase() === 'omitted';
499
+ applyTextEdit(omitted ? restoreSceneInText(current, idx) : omitSceneInText(current, idx), setText);
500
+ }, [liveText, setText]);
501
+ return (_jsxs("div", { className: "cs-navigator", role: "navigation", "aria-label": "Scene Navigator", children: [_jsxs("div", { className: "cs-nav-mobile-bar", children: [_jsx("span", { className: "cs-nav-mobile-title", children: t('navigator.title') }), _jsx("button", { type: "button", className: "cs-nav-mobile-close", "aria-label": "Close navigator", onClick: () => setNavVisible(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-nav-list", children: [_jsx("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: handleFrontmatter, title: t('navigator.frontmatter'), children: _jsx("span", { className: "cs-nav-title", children: t('navigator.frontmatter') }) }), _jsxs("div", { className: "cs-nav-section-header cs-nav-scenes-header", style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("span", { className: "cs-nav-title", children: t('navigator.title') }), _jsx("span", { className: "cs-nav-count", children: scenes.length })] }), _jsxs("div", { style: { display: 'flex', gap: '4px', alignItems: 'center' }, onClick: e => e.stopPropagation(), children: [_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: () => setShowNavSynopsis(!showNavSynopsis), title: showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', "aria-label": showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', children: showNavSynopsis ? _jsx(EyeIcon, { size: 14 }) : _jsx(EyeOffIcon, { size: 14 }) }), ai && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleGenerateAllSceneMetadata, disabled: isProcessing, title: "Generate synopsis & estimate duration (all scenes)", "aria-label": "Generate synopsis & estimate duration (all scenes)", style: { opacity: isProcessing ? 0.5 : 1 }, children: _jsx(SparklesIcon, { size: 14 }) })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleRenumber, title: t('navigator.renumber'), "aria-label": t('navigator.renumber'), children: _jsx(RenumberIcon, { size: 14 }) }))] })] }), isProcessing && progressText && (_jsxs("div", { className: "cs-nav-progress", style: {
519
502
  display: 'flex',
520
503
  alignItems: 'center',
521
504
  gap: '8px',
@@ -525,48 +508,59 @@ export function SceneNavigator() {
525
508
  background: 'var(--cs-ui-bg-panel-subtle, #f4f2ea)',
526
509
  borderBottom: '1px solid var(--cs-ui-border-light, #e2e0d6)',
527
510
  fontWeight: 500,
528
- }, children: [_jsx("span", { className: "cs-spinner", style: { width: '10px', height: '10px', borderWidth: '1.5px' } }), _jsx("span", { children: progressText })] })), scenes.map((scene, idx) => (_jsxs("div", { role: "button", tabIndex: 0, "aria-label": `Jump to ${scene.heading}`, onKeyDown: (e) => {
529
- if (e.key === 'Enter' || e.key === ' ') {
530
- e.preventDefault();
531
- handleJump(scene);
532
- }
533
- }, className: [
534
- 'cs-nav-item',
535
- dragIndex === idx ? 'cs-nav-dragging' : '',
536
- dropIndex === idx ? 'cs-nav-drop-target' : '',
537
- ]
538
- .filter(Boolean)
539
- .join(' '), draggable: true, onClick: () => handleJump(scene), onDragStart: () => handleDragStart(idx), onDragOver: (e) => handleDragOver(e, idx), onDrop: (e) => handleDrop(e, idx), onDragEnd: () => {
540
- setDragIndex(null);
541
- setDropIndex(null);
542
- }, children: [_jsxs("div", { className: "cs-nav-scene-info", children: [_jsx("div", { className: "cs-nav-heading", children: scene.heading }), showNavSynopsis && (_jsxs("div", { className: "cs-nav-synopsis-row", style: { marginTop: '4px' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: editingSceneIdx === idx ? (_jsx("input", { type: "text", className: "cs-nav-synopsis-input", value: editVal, onChange: e => setEditVal(e.target.value), onBlur: () => commitSynopsis(idx, scene), onKeyDown: e => {
543
- if (e.key === 'Enter')
544
- commitSynopsis(idx, scene);
545
- if (e.key === 'Escape')
546
- setEditingSceneIdx(null);
547
- }, autoFocus: true, onClick: e => e.stopPropagation() })) : scene.synopsis ? (_jsx("div", { className: "cs-nav-scene-synopsis", onClick: e => {
548
- e.stopPropagation();
549
- setEditingSceneIdx(idx);
550
- setEditVal(scene.synopsis);
551
- }, title: "Click to edit synopsis", children: scene.synopsis })) : (_jsx("div", { className: "cs-nav-scene-synopsis cs-nav-synopsis-placeholder", onClick: e => {
552
- e.stopPropagation();
553
- setEditingSceneIdx(idx);
554
- setEditVal('');
555
- }, role: "button", tabIndex: 0, "aria-label": "Add synopsis", onKeyDown: e => {
556
- if (e.key === 'Enter' || e.key === ' ') {
511
+ }, children: [_jsx("span", { className: "cs-spinner", style: { width: '10px', height: '10px', borderWidth: '1.5px' } }), _jsx("span", { children: progressText })] })), scenes.map((scene, idx) => {
512
+ const issue = numberIssues[idx] ?? null;
513
+ const omitted = scene.status.toLowerCase() === 'omitted';
514
+ // The id is the scene number, so a missing id is one note, not two.
515
+ const note = issue ? issueLabel(issue) : scene.id ? null : t('navigator.missingId');
516
+ const notes = note ? [note] : [];
517
+ return (_jsxs("div", { role: "button", tabIndex: 0, "aria-label": `Jump to ${scene.heading}${notes.length ? ` (${notes.join('; ')})` : ''}`, onKeyDown: (e) => {
518
+ if (e.key === 'Enter' || e.key === ' ') {
519
+ e.preventDefault();
520
+ handleJump(scene);
521
+ }
522
+ }, className: [
523
+ 'cs-nav-item',
524
+ // No {#id}: comments, scene-level diff, and page locking can't anchor to it.
525
+ scene.id ? '' : 'cs-nav-scene-missing-id',
526
+ issue ? 'cs-nav-scene-number-issue' : '',
527
+ omitted ? 'cs-nav-scene-omitted' : '',
528
+ dragIndex === idx ? 'cs-nav-dragging' : '',
529
+ dropIndex === idx ? 'cs-nav-drop-target' : '',
530
+ ]
531
+ .filter(Boolean)
532
+ .join(' '), draggable: canEdit, onClick: () => handleJump(scene), onDragStart: () => handleDragStart(idx), onDragOver: (e) => handleDragOver(e, idx), onDrop: (e) => handleDrop(e, idx), onDragEnd: () => {
533
+ setDragIndex(null);
534
+ setDropIndex(null);
535
+ }, children: [_jsxs("div", { className: "cs-nav-scene-info", children: [_jsx("div", { className: "cs-nav-heading", children: scene.heading }), showNavSynopsis && (_jsxs("div", { className: "cs-nav-synopsis-row", style: { marginTop: '4px' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: !canEdit ? (scene.synopsis ? _jsx("div", { className: "cs-nav-scene-synopsis", children: scene.synopsis }) : null) : editingSceneIdx === idx ? (_jsx("input", { type: "text", className: "cs-nav-synopsis-input", value: editVal, onChange: e => setEditVal(e.target.value), onBlur: () => commitSynopsis(idx, scene), onKeyDown: e => {
536
+ if (e.key === 'Enter')
537
+ commitSynopsis(idx, scene);
538
+ if (e.key === 'Escape')
539
+ setEditingSceneIdx(null);
540
+ }, autoFocus: true, onClick: e => e.stopPropagation() })) : scene.synopsis ? (_jsx("div", { className: "cs-nav-scene-synopsis", onClick: e => {
541
+ e.stopPropagation();
542
+ setEditingSceneIdx(idx);
543
+ setEditVal(scene.synopsis);
544
+ }, title: "Click to edit synopsis", children: scene.synopsis })) : (_jsx("div", { className: "cs-nav-scene-synopsis cs-nav-synopsis-placeholder", onClick: e => {
557
545
  e.stopPropagation();
558
- e.preventDefault();
559
546
  setEditingSceneIdx(idx);
560
547
  setEditVal('');
561
- }
562
- }, children: "+ Synopsis" })) }), _jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); void handleSceneMetadata(scene); }, disabled: isProcessing, title: "Generate synopsis & estimate duration", "aria-label": "Generate synopsis & estimate duration", className: "cs-nav-action-btn cs-nav-synopsis-ai-btn", style: { color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }, children: _jsx(SparklesIcon, { size: 12 }) })] })), (scene.status || (showNavSynopsis && scene.estDuration)) && (_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }, children: [scene.status && (_jsx("span", { className: `cs-nav-status cs-status-${scene.status
563
- .toLowerCase()
564
- .replace(/\s+/g, '-')}`, children: scene.status })), showNavSynopsis && scene.estDuration && (_jsxs("span", { className: "cs-nav-status", title: "Estimated Onscreen Duration", style: {
565
- background: 'rgba(217, 119, 6, 0.1)',
566
- color: 'var(--cs-ui-accent, #c4760a)',
567
- border: '1px solid rgba(217, 119, 6, 0.25)',
568
- fontWeight: 600,
569
- }, children: ["\u23F1 ", scene.estDuration] }))] }))] }), scene.id && _jsx("div", { className: "cs-nav-scene-num", children: scene.id })] }, scene.id ?? `scene-${idx}`))), scenes.length === 0 && (_jsx("div", { className: "cs-nav-empty", children: t('navigator.empty') })), _jsx("div", { style: { marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }, children: _jsxs("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: () => setStatsOpen(true), style: { width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx(StatsIcon, { size: 14 }), " ", _jsx("span", { className: "cs-nav-title", children: "Statistics" })] }) })] }), statsOpen && _jsx(StatisticsDialog, { onClose: () => setStatsOpen(false) })] }));
548
+ }, role: "button", tabIndex: 0, "aria-label": "Add synopsis", onKeyDown: e => {
549
+ if (e.key === 'Enter' || e.key === ' ') {
550
+ e.stopPropagation();
551
+ e.preventDefault();
552
+ setEditingSceneIdx(idx);
553
+ setEditVal('');
554
+ }
555
+ }, children: "+ Synopsis" })) }), ai && _jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); void handleSceneMetadata(scene); }, disabled: isProcessing, title: "Generate synopsis & estimate duration", "aria-label": "Generate synopsis & estimate duration", className: "cs-nav-action-btn cs-nav-synopsis-ai-btn", style: { color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }, children: _jsx(SparklesIcon, { size: 12 }) })] })), (scene.status || (showNavSynopsis && scene.estDuration)) && (_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }, children: [scene.status && (_jsx("span", { className: `cs-nav-status cs-status-${scene.status
556
+ .toLowerCase()
557
+ .replace(/\s+/g, '-')}`, children: scene.status })), showNavSynopsis && scene.estDuration && (_jsxs("span", { className: "cs-nav-status", title: "Estimated Onscreen Duration", style: {
558
+ background: 'rgba(217, 119, 6, 0.1)',
559
+ color: 'var(--cs-ui-accent, #c4760a)',
560
+ border: '1px solid rgba(217, 119, 6, 0.25)',
561
+ fontWeight: 600,
562
+ }, children: ["\u23F1 ", scene.estDuration] }))] }))] }), _jsxs("div", { className: "cs-nav-scene-side", children: [scene.id ? (_jsx("div", { className: `cs-nav-scene-num${issue ? ' cs-nav-num-issue' : ''}`, title: issue ? issueLabel(issue) : undefined, children: scene.id })) : (_jsx("div", { className: "cs-nav-missing-id-icon", title: issue ? issueLabel(issue) : t('navigator.missingId'), "aria-hidden": "true", children: "\u26A0" })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn cs-nav-omit-btn", title: omitted ? t('navigator.restoreScene') : t('navigator.omitScene'), "aria-label": `${omitted ? t('navigator.restoreScene') : t('navigator.omitScene')}: ${scene.heading}`, onClick: e => { e.stopPropagation(); handleOmitToggle(scene); }, children: omitted ? _jsx(RestoreIcon, { size: 12 }) : _jsx(OmitIcon, { size: 12 }) }))] })] }, `${idx}:${scene.id ?? ''}`));
563
+ }), scenes.length === 0 && (_jsx("div", { className: "cs-nav-empty", children: t('navigator.empty') })), _jsx("div", { style: { marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }, children: _jsxs("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: () => setStatsOpen(true), style: { width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx(StatsIcon, { size: 14 }), " ", _jsx("span", { className: "cs-nav-title", children: "Statistics" })] }) })] }), statsOpen && _jsx(StatisticsDialog, { onClose: () => setStatsOpen(false) })] }));
570
564
  }
571
565
  /**
572
566
  * Reorder scene blocks in the source text.
@@ -3,10 +3,14 @@ import { useDocument } from '../context/DocumentContext';
3
3
  import { extractWorkflowData } from '../file/workflow-reports';
4
4
  import { getEditorView } from '../editor/editor-bus';
5
5
  import { schema } from '../editor/schema';
6
+ import { useCanEdit } from '../extensions/session';
6
7
  export function StatisticsDialog({ onClose }) {
7
8
  const { ast, text, setText } = useDocument();
9
+ const canEdit = useCanEdit();
8
10
  const { scenes, characters } = extractWorkflowData(ast);
9
11
  const handleUpdateActor = (charName, actorName) => {
12
+ if (!canEdit)
13
+ return;
10
14
  const cleanChar = charName.trim().toUpperCase();
11
15
  if (!cleanChar)
12
16
  return;
@@ -78,7 +82,7 @@ export function StatisticsDialog({ onClose }) {
78
82
  return (_jsx("div", { className: "cs-dialog-overlay", role: "presentation", onClick: e => {
79
83
  if (e.target === e.currentTarget)
80
84
  onClose();
81
- }, children: _jsxs("div", { className: "cs-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "cs-stats-dialog-title", style: { maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { className: "cs-panel-header", children: [_jsx("span", { id: "cs-stats-dialog-title", className: "cs-panel-title", children: "Script Statistics" }), _jsx("button", { className: "cs-panel-close", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsxs("div", { className: "cs-panel-body", style: { overflowY: 'auto', flex: 1, padding: '20px' }, children: [_jsxs("div", { className: "cs-stats-summary-grid", children: [_jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: scenes.length }), _jsx("div", { className: "cs-stats-card-lbl", children: "Scenes" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: wordCount.toLocaleString() }), _jsx("div", { className: "cs-stats-card-lbl", children: "Words" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: totalDurationLabel }), _jsx("div", { className: "cs-stats-card-lbl", children: "Est. Duration" })] })] }), _jsxs("div", { className: "cs-stats-section-hdr", children: ["Cast List & Occurrences (", characters.length, ")"] }), characters.length === 0 ? (_jsx("div", { style: { color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }, children: "No character cues detected in the screenplay yet." })) : (_jsxs("table", { className: "cs-stats-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { textAlign: 'left', width: '35%' }, children: "Character" }), _jsx("th", { style: { textAlign: 'left' }, children: "Actor" }), _jsx("th", { style: { textAlign: 'center', width: '15%' }, children: "Scenes" })] }) }), _jsx("tbody", { children: characters.map((c, i) => (_jsxs("tr", { children: [_jsx("td", { style: { fontWeight: '600' }, children: c.name }), _jsx("td", { children: _jsx("input", { type: "text", className: "cs-stats-actor-input", value: c.actor, onChange: (e) => handleUpdateActor(c.name, e.target.value), placeholder: "Assign actor...", "aria-label": `Actor for ${c.name}` }) }), _jsx("td", { style: { textAlign: 'center', fontWeight: '500' }, children: c.sceneCount })] }, i))) })] }))] }), _jsx("div", { style: {
85
+ }, children: _jsxs("div", { className: "cs-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "cs-stats-dialog-title", style: { maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { className: "cs-panel-header", children: [_jsx("span", { id: "cs-stats-dialog-title", className: "cs-panel-title", children: "Script Statistics" }), _jsx("button", { className: "cs-panel-close", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsxs("div", { className: "cs-panel-body", style: { overflowY: 'auto', flex: 1, padding: '20px' }, children: [_jsxs("div", { className: "cs-stats-summary-grid", children: [_jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: scenes.length }), _jsx("div", { className: "cs-stats-card-lbl", children: "Scenes" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: wordCount.toLocaleString() }), _jsx("div", { className: "cs-stats-card-lbl", children: "Words" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: totalDurationLabel }), _jsx("div", { className: "cs-stats-card-lbl", children: "Est. Duration" })] })] }), _jsxs("div", { className: "cs-stats-section-hdr", children: ["Cast List & Occurrences (", characters.length, ")"] }), characters.length === 0 ? (_jsx("div", { style: { color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }, children: "No character cues detected in the screenplay yet." })) : (_jsxs("table", { className: "cs-stats-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { textAlign: 'left', width: '35%' }, children: "Character" }), _jsx("th", { style: { textAlign: 'left' }, children: "Actor" }), _jsx("th", { style: { textAlign: 'center', width: '15%' }, children: "Scenes" })] }) }), _jsx("tbody", { children: characters.map((c, i) => (_jsxs("tr", { children: [_jsx("td", { style: { fontWeight: '600' }, children: c.name }), _jsx("td", { children: _jsx("input", { type: "text", className: "cs-stats-actor-input", value: c.actor, onChange: (e) => handleUpdateActor(c.name, e.target.value), readOnly: !canEdit, placeholder: "Assign actor...", "aria-label": `Actor for ${c.name}` }) }), _jsx("td", { style: { textAlign: 'center', fontWeight: '500' }, children: c.sceneCount })] }, i))) })] }))] }), _jsx("div", { style: {
82
86
  borderTop: '1px solid var(--cs-ui-border-light-subtle, #eeece4)',
83
87
  display: 'flex',
84
88
  justifyContent: 'flex-end',
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Set scene i's `{#id}` (its scene number, §7.4) to `ids[i]`, keeping the
3
+ * heading's other attributes; `undefined` leaves the scene as it is.
4
+ */
5
+ export declare function setSceneIdsInText(text: string, ids: readonly (string | undefined)[]): string;
6
+ /**
7
+ * Omit a scene (§7.4): keep its heading and number, set `& status: omitted`,
8
+ * and keep its body inside a comment so it can be restored.
9
+ */
10
+ export declare function omitSceneInText(text: string, index: number): string;
11
+ /** Undo omitSceneInText: drop `& status: omitted` and unwrap a body kept in a comment. */
12
+ export declare function restoreSceneInText(text: string, index: number): string;
13
+ /**
14
+ * Replace the document text with `next` as one undoable edit. In the
15
+ * formatted editor only the top-level blocks that changed are replaced, so
16
+ * the caret, scroll and history stay meaningful.
17
+ */
18
+ export declare function applyTextEdit(next: string, setText: (t: string) => void): void;
@@ -0,0 +1,143 @@
1
+ import { parse, splitAttributeBlock, formatAttributeBlock } from '@sutrata/parser';
2
+ import { buildSceneList } from './scene-list';
3
+ import { getEditorView, getSourceView } from '../editor/editor-bus';
4
+ import { sutraToProsemirror } from '../editor/sutra-to-prosemirror';
5
+ /**
6
+ * Text-level scene edits used by the navigator (renumber, omit, restore),
7
+ * and applyTextEdit, which puts the result into whichever editor is mounted
8
+ * as ONE undoable change.
9
+ */
10
+ const META = /^&\s+[A-Za-z0-9_-]+:/;
11
+ const LIST_ITEM = /^\s+-(\s|$)/;
12
+ function sceneSpans(text) {
13
+ const scenes = buildSceneList(text);
14
+ return scenes.map((s, i) => ({ start: s.textOffset, end: scenes[i + 1]?.textOffset ?? text.length }));
15
+ }
16
+ /**
17
+ * Split a scene's text into its heading block (heading + metadata, which may
18
+ * be separated by blank lines), its body, and anything from a following
19
+ * section heading on (sections end a scene's content).
20
+ */
21
+ function splitScene(segment) {
22
+ const lines = segment.split('\n');
23
+ let headEnd = 1;
24
+ for (let i = 1; i < lines.length; i++) {
25
+ const l = lines[i];
26
+ if (META.test(l.trim()) || LIST_ITEM.test(l))
27
+ headEnd = i + 1;
28
+ else if (l.trim() !== '')
29
+ break;
30
+ }
31
+ let bodyEnd = lines.length;
32
+ for (let i = headEnd; i < lines.length; i++) {
33
+ if (/^#(\s|$)/.test(lines[i])) {
34
+ bodyEnd = i;
35
+ break;
36
+ }
37
+ }
38
+ return {
39
+ head: lines.slice(0, headEnd).filter(l => l.trim() !== ''),
40
+ body: lines.slice(headEnd, bodyEnd).join('\n').trim(),
41
+ rest: lines.slice(bodyEnd).join('\n').trim(),
42
+ };
43
+ }
44
+ function joinScene(head, body, rest, isLast) {
45
+ return [head.join('\n'), body, rest].filter(Boolean).join('\n\n') + (isLast ? '\n' : '\n\n');
46
+ }
47
+ /**
48
+ * Set (replace or add) `& key: value` in a heading block; null removes it.
49
+ * A new line goes right under the heading (`first`) or after the existing
50
+ * metadata (`last`).
51
+ */
52
+ function setMeta(head, key, value, where = 'first') {
53
+ const re = new RegExp(`^&\\s+${key}:`);
54
+ const idx = head.findIndex(l => re.test(l.trim()));
55
+ const line = value === null ? null : `& ${key}: ${value}`;
56
+ if (idx >= 0)
57
+ return line === null ? head.filter((_, i) => i !== idx) : head.map((l, i) => (i === idx ? line : l));
58
+ if (line === null)
59
+ return head;
60
+ return where === 'first' ? [head[0], line, ...head.slice(1)] : [...head, line];
61
+ }
62
+ function editScenes(text, edit) {
63
+ const spans = sceneSpans(text);
64
+ let out = text;
65
+ for (let i = spans.length - 1; i >= 0; i--) {
66
+ const { start, end } = spans[i];
67
+ const parts = edit(i, splitScene(out.slice(start, end)));
68
+ if (!parts)
69
+ continue;
70
+ out = out.slice(0, start) + joinScene(parts.head, parts.body, parts.rest, end === text.length) + out.slice(end);
71
+ }
72
+ return out;
73
+ }
74
+ /**
75
+ * Set scene i's `{#id}` (its scene number, §7.4) to `ids[i]`, keeping the
76
+ * heading's other attributes; `undefined` leaves the scene as it is.
77
+ */
78
+ export function setSceneIdsInText(text, ids) {
79
+ return editScenes(text, (i, parts) => {
80
+ const id = ids[i];
81
+ if (id === undefined)
82
+ return null;
83
+ const m = /^(\s*##\s+)(.*)$/.exec(parts.head[0]);
84
+ const { body, attrs } = splitAttributeBlock(m[2].trimEnd());
85
+ const heading = `${m[1]}${body.trimEnd()}${formatAttributeBlock(id, attrs)}`;
86
+ return { ...parts, head: [heading, ...parts.head.slice(1)] };
87
+ });
88
+ }
89
+ /**
90
+ * Omit a scene (§7.4): keep its heading and number, set `& status: omitted`,
91
+ * and keep its body inside a comment so it can be restored.
92
+ */
93
+ export function omitSceneInText(text, index) {
94
+ return editScenes(text, (i, parts) => i !== index ? null : {
95
+ head: setMeta(parts.head, 'status', 'omitted', 'last'),
96
+ body: parts.body ? `<!-- ${parts.body.replace(/-->/g, '-- >')} -->` : '',
97
+ rest: parts.rest,
98
+ });
99
+ }
100
+ /** Undo omitSceneInText: drop `& status: omitted` and unwrap a body kept in a comment. */
101
+ export function restoreSceneInText(text, index) {
102
+ return editScenes(text, (i, parts) => {
103
+ if (i !== index)
104
+ return null;
105
+ const m = /^<!--\s?([\s\S]*?)\s?-->$/.exec(parts.body);
106
+ return { head: setMeta(parts.head, 'status', null), body: m ? m[1].trim() : parts.body, rest: parts.rest };
107
+ });
108
+ }
109
+ /**
110
+ * Replace the document text with `next` as one undoable edit. In the
111
+ * formatted editor only the top-level blocks that changed are replaced, so
112
+ * the caret, scroll and history stay meaningful.
113
+ */
114
+ export function applyTextEdit(next, setText) {
115
+ const view = getEditorView();
116
+ if (view) {
117
+ const target = sutraToProsemirror(parse(next));
118
+ const doc = view.state.doc;
119
+ const a = [];
120
+ const b = [];
121
+ doc.forEach(n => a.push(n));
122
+ target.forEach(n => b.push(n));
123
+ let head = 0;
124
+ while (head < a.length && head < b.length && a[head].eq(b[head]))
125
+ head++;
126
+ let tail = 0;
127
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].eq(b[b.length - 1 - tail]))
128
+ tail++;
129
+ if (head === a.length && head === b.length)
130
+ return;
131
+ const posOf = (nodes, index) => nodes.slice(0, index).reduce((p, n) => p + n.nodeSize, 0);
132
+ const from = posOf(a, head);
133
+ const to = posOf(a, a.length - tail);
134
+ view.dispatch(view.state.tr.replaceWith(from, to, b.slice(head, b.length - tail)));
135
+ return;
136
+ }
137
+ const source = getSourceView();
138
+ if (source) {
139
+ source.dispatch({ changes: { from: 0, to: source.state.doc.length, insert: next } });
140
+ return;
141
+ }
142
+ setText(next);
143
+ }
@@ -1,3 +1,4 @@
1
+ import { splitAttributeBlock } from '@sutrata/parser';
1
2
  /**
2
3
  * Derive a flat scene list from Sutra source text.
3
4
  * Scenes are identified by scene heading lines (## prefix).
@@ -14,10 +15,9 @@ export function buildSceneList(text) {
14
15
  if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
15
16
  const headingRaw = trimmed.slice(3).trim();
16
17
  const sceneStart = currentOffset;
17
- // Extract scene id from inline {#...} on the heading line, then strip it
18
- const idMatch = /\{#([^}]+)\}/.exec(headingRaw);
19
- const sceneId = idMatch?.[1] ?? null;
20
- const heading = headingRaw.replace(/\s*\{#[^}]+\}/, '').trim();
18
+ // Split the trailing attribute block ({#id lang=…}, §10) off the heading
19
+ const { body, id: sceneId } = splitAttributeBlock(headingRaw);
20
+ const heading = body.trim();
21
21
  // Collect metadata (& key: value) until next heading
22
22
  let synopsis = '';
23
23
  let status = '';