@sutrata/editor 0.1.1 → 0.2.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 (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. package/vitest.setup.ts +0 -65
@@ -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 = '';