@sutrata/editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/LICENSE +674 -0
  2. package/dist/ai/OnboardingDialog.d.ts +6 -0
  3. package/dist/ai/OnboardingDialog.js +115 -0
  4. package/dist/ai/VoiceConfirmDialog.d.ts +9 -0
  5. package/dist/ai/VoiceConfirmDialog.js +37 -0
  6. package/dist/ai/VoiceToolbar.d.ts +2 -0
  7. package/dist/ai/VoiceToolbar.js +177 -0
  8. package/dist/ai/ai-client.d.ts +71 -0
  9. package/dist/ai/ai-client.js +494 -0
  10. package/dist/ai/commands.d.ts +12 -0
  11. package/dist/ai/commands.js +140 -0
  12. package/dist/ai/generated-providers.json +3696 -0
  13. package/dist/ai/prompts.d.ts +25 -0
  14. package/dist/ai/prompts.js +59 -0
  15. package/dist/ai/voice-service.d.ts +15 -0
  16. package/dist/ai/voice-service.js +81 -0
  17. package/dist/autocomplete/AutocompletePlugin.d.ts +2 -0
  18. package/dist/autocomplete/AutocompletePlugin.js +19 -0
  19. package/dist/autocomplete/suggestions.d.ts +25 -0
  20. package/dist/autocomplete/suggestions.js +126 -0
  21. package/dist/context/DocumentContext.d.ts +68 -0
  22. package/dist/context/DocumentContext.js +605 -0
  23. package/dist/desktop-api.d.ts +40 -0
  24. package/dist/editor/EditorView.d.ts +2 -0
  25. package/dist/editor/EditorView.js +78 -0
  26. package/dist/editor/SelectionMenu.d.ts +2 -0
  27. package/dist/editor/SelectionMenu.js +102 -0
  28. package/dist/editor/attr-pencil-plugin.d.ts +17 -0
  29. package/dist/editor/attr-pencil-plugin.js +158 -0
  30. package/dist/editor/character-decoration-plugin.d.ts +26 -0
  31. package/dist/editor/character-decoration-plugin.js +77 -0
  32. package/dist/editor/character-view.d.ts +35 -0
  33. package/dist/editor/character-view.js +112 -0
  34. package/dist/editor/editor-bus.d.ts +14 -0
  35. package/dist/editor/editor-bus.js +39 -0
  36. package/dist/editor/find-highlight-plugin.d.ts +7 -0
  37. package/dist/editor/find-highlight-plugin.js +57 -0
  38. package/dist/editor/frontmatter-field-view.d.ts +10 -0
  39. package/dist/editor/frontmatter-field-view.js +48 -0
  40. package/dist/editor/frontmatter-field.d.ts +21 -0
  41. package/dist/editor/frontmatter-field.js +116 -0
  42. package/dist/editor/grapheme-cursor.d.ts +10 -0
  43. package/dist/editor/grapheme-cursor.js +104 -0
  44. package/dist/editor/ime-plugin.d.ts +18 -0
  45. package/dist/editor/ime-plugin.js +71 -0
  46. package/dist/editor/insert-helper.d.ts +1 -0
  47. package/dist/editor/insert-helper.js +42 -0
  48. package/dist/editor/keymap.d.ts +3 -0
  49. package/dist/editor/keymap.js +131 -0
  50. package/dist/editor/language-class-plugin.d.ts +7 -0
  51. package/dist/editor/language-class-plugin.js +75 -0
  52. package/dist/editor/plugins.d.ts +1 -0
  53. package/dist/editor/plugins.js +25 -0
  54. package/dist/editor/prosemirror-to-sutra.d.ts +2 -0
  55. package/dist/editor/prosemirror-to-sutra.js +208 -0
  56. package/dist/editor/scene-heading-view.d.ts +27 -0
  57. package/dist/editor/scene-heading-view.js +136 -0
  58. package/dist/editor/schema.d.ts +3 -0
  59. package/dist/editor/schema.js +135 -0
  60. package/dist/editor/sutra-to-prosemirror.d.ts +3 -0
  61. package/dist/editor/sutra-to-prosemirror.js +154 -0
  62. package/dist/editor/transliterate-input.d.ts +10 -0
  63. package/dist/editor/transliterate-input.js +88 -0
  64. package/dist/extensions/storage-adapter.d.ts +59 -0
  65. package/dist/extensions/storage-adapter.js +9 -0
  66. package/dist/extensions/types.d.ts +73 -0
  67. package/dist/extensions/types.js +12 -0
  68. package/dist/file/docx-exporter.d.ts +8 -0
  69. package/dist/file/docx-exporter.js +494 -0
  70. package/dist/file/docx-importer.d.ts +10 -0
  71. package/dist/file/docx-importer.js +283 -0
  72. package/dist/file/print-paginator.d.ts +29 -0
  73. package/dist/file/print-paginator.js +289 -0
  74. package/dist/file/templates.d.ts +2 -0
  75. package/dist/file/templates.js +66 -0
  76. package/dist/file/workflow-reports.d.ts +34 -0
  77. package/dist/file/workflow-reports.js +719 -0
  78. package/dist/findreplace/FindReplace.d.ts +2 -0
  79. package/dist/findreplace/FindReplace.js +164 -0
  80. package/dist/findreplace/find-engine.d.ts +29 -0
  81. package/dist/findreplace/find-engine.js +69 -0
  82. package/dist/findreplace/scroll-to-match.d.ts +29 -0
  83. package/dist/findreplace/scroll-to-match.js +74 -0
  84. package/dist/fonts/tofu-detect.d.ts +1 -0
  85. package/dist/fonts/tofu-detect.js +48 -0
  86. package/dist/i18n/LanguageContext.d.ts +10 -0
  87. package/dist/i18n/LanguageContext.js +174 -0
  88. package/dist/i18n/language-detect.d.ts +1 -0
  89. package/dist/i18n/language-detect.js +58 -0
  90. package/dist/i18n/useTranslation.d.ts +11 -0
  91. package/dist/i18n/useTranslation.js +60 -0
  92. package/dist/index.d.ts +11 -0
  93. package/dist/index.js +5 -0
  94. package/dist/locales/bn.json +143 -0
  95. package/dist/locales/en.json +143 -0
  96. package/dist/locales/gu.json +143 -0
  97. package/dist/locales/hi.json +143 -0
  98. package/dist/locales/kn.json +143 -0
  99. package/dist/locales/ml.json +143 -0
  100. package/dist/locales/mr.json +143 -0
  101. package/dist/locales/or.json +143 -0
  102. package/dist/locales/pa.json +143 -0
  103. package/dist/locales/si.json +143 -0
  104. package/dist/locales/ta.json +143 -0
  105. package/dist/locales/te.json +143 -0
  106. package/dist/navigator/SceneNavigator.d.ts +2 -0
  107. package/dist/navigator/SceneNavigator.js +612 -0
  108. package/dist/navigator/StatisticsDialog.d.ts +6 -0
  109. package/dist/navigator/StatisticsDialog.js +146 -0
  110. package/dist/navigator/scene-list.d.ts +16 -0
  111. package/dist/navigator/scene-list.js +70 -0
  112. package/dist/settings/SettingsDialog.d.ts +6 -0
  113. package/dist/settings/SettingsDialog.js +147 -0
  114. package/dist/shell/AppBar.d.ts +2 -0
  115. package/dist/shell/AppBar.js +34 -0
  116. package/dist/shell/AppShell.d.ts +2 -0
  117. package/dist/shell/AppShell.js +30 -0
  118. package/dist/shell/ConfirmDialog.d.ts +16 -0
  119. package/dist/shell/ConfirmDialog.js +23 -0
  120. package/dist/shell/ElementToolbar.d.ts +2 -0
  121. package/dist/shell/ElementToolbar.js +30 -0
  122. package/dist/shell/LanguageStatus.d.ts +2 -0
  123. package/dist/shell/LanguageStatus.js +65 -0
  124. package/dist/shell/StatusBar.d.ts +2 -0
  125. package/dist/shell/StatusBar.js +54 -0
  126. package/dist/shell/SutrataLogo.d.ts +8 -0
  127. package/dist/shell/SutrataLogo.js +7 -0
  128. package/dist/shell/Toast.d.ts +12 -0
  129. package/dist/shell/Toast.js +21 -0
  130. package/dist/shell/ToolbarButton.d.ts +11 -0
  131. package/dist/shell/ToolbarButton.js +4 -0
  132. package/dist/shell/VersionHistory.d.ts +12 -0
  133. package/dist/shell/VersionHistory.js +89 -0
  134. package/dist/shell/Watermark.d.ts +3 -0
  135. package/dist/shell/Watermark.js +10 -0
  136. package/dist/shell/fonts/fonts.css +1133 -0
  137. package/dist/shell/icons.d.ts +45 -0
  138. package/dist/shell/icons.js +51 -0
  139. package/dist/shell/toolbar-actions.d.ts +6 -0
  140. package/dist/shell/toolbar-actions.js +58 -0
  141. package/dist/source/SourceView.d.ts +1 -0
  142. package/dist/source/SourceView.js +106 -0
  143. package/dist/source/find-highlight-source.d.ts +14 -0
  144. package/dist/source/find-highlight-source.js +47 -0
  145. package/dist/source/sutra-lang.d.ts +2 -0
  146. package/dist/source/sutra-lang.js +97 -0
  147. package/dist/spellcheck/dictionaries.d.ts +9 -0
  148. package/dist/spellcheck/dictionaries.js +16 -0
  149. package/dist/spellcheck/hunspell.d.ts +23 -0
  150. package/dist/spellcheck/hunspell.js +94 -0
  151. package/dist/spellcheck/spellcheck-plugin.d.ts +18 -0
  152. package/dist/spellcheck/spellcheck-plugin.js +75 -0
  153. package/dist/styles/StyleDialog.d.ts +6 -0
  154. package/dist/styles/StyleDialog.js +63 -0
  155. package/dist/styles/builtin-styles.d.ts +43 -0
  156. package/dist/styles/builtin-styles.js +131 -0
  157. package/dist/styles/css-adapter.d.ts +32 -0
  158. package/dist/styles/css-adapter.js +71 -0
  159. package/dist/styles/docx-adapter.d.ts +7 -0
  160. package/dist/styles/docx-adapter.js +84 -0
  161. package/dist/styles/embed-fonts.d.ts +20 -0
  162. package/dist/styles/embed-fonts.js +81 -0
  163. package/dist/styles/pagination.d.ts +19 -0
  164. package/dist/styles/pagination.js +22 -0
  165. package/dist/styles/print-adapter.d.ts +6 -0
  166. package/dist/styles/print-adapter.js +288 -0
  167. package/dist/styles/registry.d.ts +6 -0
  168. package/dist/styles/registry.js +14 -0
  169. package/dist/styles/screenplay.css +3006 -0
  170. package/dist/styles/script-ranges.d.ts +6 -0
  171. package/dist/styles/script-ranges.js +17 -0
  172. package/dist/styles/types.d.ts +93 -0
  173. package/dist/styles/types.js +18 -0
  174. package/dist/styles/validate.d.ts +12 -0
  175. package/dist/styles/validate.js +105 -0
  176. package/dist/templates/templates.d.ts +10 -0
  177. package/dist/templates/templates.js +181 -0
  178. package/dist/titlepage/ExportDialog.d.ts +1 -0
  179. package/dist/titlepage/ExportDialog.js +152 -0
  180. package/dist/titlepage/TitlePageDialog.d.ts +6 -0
  181. package/dist/titlepage/TitlePageDialog.js +96 -0
  182. package/dist/titlepage/frontmatter-form.d.ts +56 -0
  183. package/dist/titlepage/frontmatter-form.js +245 -0
  184. package/dist/titlepage/languages.d.ts +10 -0
  185. package/dist/titlepage/languages.js +31 -0
  186. package/dist/types.d.ts +6 -0
  187. package/dist/types.js +1 -0
  188. package/package.json +47 -0
  189. package/scripts/copy-assets.mjs +16 -0
  190. package/scripts/generate-providers.mjs +127 -0
  191. package/src/ai/OnboardingDialog.tsx +390 -0
  192. package/src/ai/VoiceConfirmDialog.tsx +137 -0
  193. package/src/ai/VoiceToolbar.tsx +321 -0
  194. package/src/ai/ai-client.ts +596 -0
  195. package/src/ai/commands.ts +164 -0
  196. package/src/ai/generated-providers.json +3696 -0
  197. package/src/ai/prompts.ts +62 -0
  198. package/src/ai/voice-service.ts +96 -0
  199. package/src/autocomplete/AutocompletePlugin.ts +21 -0
  200. package/src/autocomplete/suggestions.ts +147 -0
  201. package/src/context/DocumentContext.tsx +648 -0
  202. package/src/desktop-api.d.ts +40 -0
  203. package/src/editor/EditorView.tsx +82 -0
  204. package/src/editor/SelectionMenu.tsx +152 -0
  205. package/src/editor/attr-pencil-plugin.ts +172 -0
  206. package/src/editor/character-decoration-plugin.ts +104 -0
  207. package/src/editor/editor-bus.ts +51 -0
  208. package/src/editor/find-highlight-plugin.ts +68 -0
  209. package/src/editor/frontmatter-field-view.ts +52 -0
  210. package/src/editor/frontmatter-field.ts +115 -0
  211. package/src/editor/grapheme-cursor.ts +99 -0
  212. package/src/editor/ime-plugin.ts +85 -0
  213. package/src/editor/insert-helper.ts +43 -0
  214. package/src/editor/keymap.ts +124 -0
  215. package/src/editor/language-class-plugin.ts +91 -0
  216. package/src/editor/plugins.ts +26 -0
  217. package/src/editor/prosemirror-to-sutra.ts +192 -0
  218. package/src/editor/scene-heading-view.ts +147 -0
  219. package/src/editor/schema.ts +156 -0
  220. package/src/editor/sutra-to-prosemirror.ts +156 -0
  221. package/src/extensions/storage-adapter.ts +65 -0
  222. package/src/extensions/types.ts +60 -0
  223. package/src/file/docx-exporter.ts +585 -0
  224. package/src/file/docx-importer.ts +313 -0
  225. package/src/file/print-paginator.ts +301 -0
  226. package/src/file/templates.ts +73 -0
  227. package/src/file/workflow-reports.ts +820 -0
  228. package/src/findreplace/FindReplace.tsx +242 -0
  229. package/src/findreplace/find-engine.ts +97 -0
  230. package/src/findreplace/scroll-to-match.ts +89 -0
  231. package/src/fonts/tofu-detect.ts +53 -0
  232. package/src/i18n/LanguageContext.tsx +207 -0
  233. package/src/i18n/language-detect.ts +73 -0
  234. package/src/i18n/useTranslation.tsx +74 -0
  235. package/src/index.ts +27 -0
  236. package/src/locales/bn.json +143 -0
  237. package/src/locales/en.json +143 -0
  238. package/src/locales/gu.json +143 -0
  239. package/src/locales/hi.json +143 -0
  240. package/src/locales/kn.json +143 -0
  241. package/src/locales/ml.json +143 -0
  242. package/src/locales/mr.json +143 -0
  243. package/src/locales/or.json +143 -0
  244. package/src/locales/pa.json +143 -0
  245. package/src/locales/si.json +143 -0
  246. package/src/locales/ta.json +143 -0
  247. package/src/locales/te.json +143 -0
  248. package/src/navigator/SceneNavigator.tsx +844 -0
  249. package/src/navigator/StatisticsDialog.tsx +249 -0
  250. package/src/navigator/scene-list.ts +84 -0
  251. package/src/settings/SettingsDialog.tsx +303 -0
  252. package/src/shell/AppBar.tsx +354 -0
  253. package/src/shell/AppShell.tsx +71 -0
  254. package/src/shell/ConfirmDialog.tsx +71 -0
  255. package/src/shell/ElementToolbar.tsx +175 -0
  256. package/src/shell/LanguageStatus.tsx +101 -0
  257. package/src/shell/StatusBar.tsx +135 -0
  258. package/src/shell/SutrataLogo.tsx +89 -0
  259. package/src/shell/Toast.tsx +46 -0
  260. package/src/shell/ToolbarButton.tsx +30 -0
  261. package/src/shell/VersionHistory.tsx +212 -0
  262. package/src/shell/Watermark.tsx +9 -0
  263. package/src/shell/fonts/fonts.css +1133 -0
  264. package/src/shell/icons.tsx +335 -0
  265. package/src/shell/toolbar-actions.ts +59 -0
  266. package/src/source/SourceView.tsx +111 -0
  267. package/src/source/find-highlight-source.ts +57 -0
  268. package/src/source/sutra-lang.ts +121 -0
  269. package/src/spellcheck/dictionaries.ts +21 -0
  270. package/src/spellcheck/hunspell.ts +110 -0
  271. package/src/spellcheck/spellcheck-plugin.ts +83 -0
  272. package/src/styles/StyleDialog.tsx +148 -0
  273. package/src/styles/builtin-styles.ts +139 -0
  274. package/src/styles/css-adapter.ts +76 -0
  275. package/src/styles/docx-adapter.ts +109 -0
  276. package/src/styles/embed-fonts.ts +81 -0
  277. package/src/styles/pagination.ts +27 -0
  278. package/src/styles/print-adapter.ts +288 -0
  279. package/src/styles/registry.ts +20 -0
  280. package/src/styles/screenplay.css +3006 -0
  281. package/src/styles/script-ranges.ts +17 -0
  282. package/src/styles/types.ts +104 -0
  283. package/src/styles/validate.ts +114 -0
  284. package/src/templates/templates.ts +194 -0
  285. package/src/titlepage/ExportDialog.tsx +494 -0
  286. package/src/titlepage/TitlePageDialog.tsx +312 -0
  287. package/src/titlepage/frontmatter-form.ts +280 -0
  288. package/src/titlepage/languages.ts +37 -0
  289. package/src/types.ts +7 -0
  290. package/tests/AppBar.test.tsx +35 -0
  291. package/tests/AppShell.test.tsx +22 -0
  292. package/tests/ElementToolbar.test.tsx +36 -0
  293. package/tests/LanguageStatus.test.tsx +129 -0
  294. package/tests/SelectionMenu.test.tsx +71 -0
  295. package/tests/ToolbarButton.test.tsx +24 -0
  296. package/tests/ai-onboarding.test.ts +102 -0
  297. package/tests/ai-status.test.tsx +72 -0
  298. package/tests/ai-structured.test.ts +94 -0
  299. package/tests/autocomplete/AutocompletePlugin.test.ts +20 -0
  300. package/tests/autocomplete/suggestions.test.ts +106 -0
  301. package/tests/document-context.test.tsx +565 -0
  302. package/tests/docx-importer.test.ts +160 -0
  303. package/tests/editor/character-decoration-plugin.test.ts +101 -0
  304. package/tests/editor/character-extension.test.ts +44 -0
  305. package/tests/editor/grapheme-cursor.test.ts +118 -0
  306. package/tests/editor/ime-plugin.test.ts +72 -0
  307. package/tests/editor/inline-note-bridge.test.ts +32 -0
  308. package/tests/editor/language-class-plugin.test.ts +265 -0
  309. package/tests/editor/title-page.test.ts +124 -0
  310. package/tests/embed-fonts.test.ts +17 -0
  311. package/tests/exporters.test.ts +365 -0
  312. package/tests/findreplace/FindReplace.test.tsx +391 -0
  313. package/tests/findreplace/find-engine.test.ts +86 -0
  314. package/tests/findreplace/scroll-to-match.test.ts +50 -0
  315. package/tests/fonts/tofu-detect.test.ts +122 -0
  316. package/tests/i18n/LanguageContext.test.tsx +146 -0
  317. package/tests/i18n/language-detect.test.ts +54 -0
  318. package/tests/i18n/locale-parity.test.ts +25 -0
  319. package/tests/i18n/useTranslation.test.tsx +45 -0
  320. package/tests/keymap.test.ts +18 -0
  321. package/tests/navigator/SceneNavigator.test.tsx +177 -0
  322. package/tests/navigator/StatisticsDialog.test.tsx +63 -0
  323. package/tests/navigator/scene-list.test.ts +71 -0
  324. package/tests/print-paginator.test.ts +82 -0
  325. package/tests/shell/VersionHistory.test.tsx +129 -0
  326. package/tests/spellcheck/hunspell.test.ts +82 -0
  327. package/tests/spellcheck/spellcheck-plugin.test.ts +56 -0
  328. package/tests/styles.test.ts +315 -0
  329. package/tests/templates/templates.test.ts +83 -0
  330. package/tests/titlepage/TitlePageDialog.test.tsx +90 -0
  331. package/tests/titlepage/apply-title-page.test.ts +61 -0
  332. package/tests/titlepage/frontmatter-form.test.ts +115 -0
  333. package/tests/toolbar-actions.test.ts +93 -0
  334. package/tests/voice-commands.test.ts +58 -0
  335. package/tsconfig.json +17 -0
  336. package/vitest.config.ts +11 -0
  337. package/vitest.setup.ts +65 -0
@@ -0,0 +1,844 @@
1
+ import React, { useEffect, useState, useCallback } from 'react'
2
+ import { TextSelection } from 'prosemirror-state'
3
+ import { useDocument } from '../context/DocumentContext'
4
+ import { useTranslation } from '../i18n/useTranslation'
5
+ import { buildSceneList, SceneEntry } from './scene-list'
6
+ import { getEditorView, getSourceView } from '../editor/editor-bus'
7
+ import { schema } from '../editor/schema'
8
+ import { EditorView as CmView } from '@codemirror/view'
9
+ import { StatisticsDialog } from './StatisticsDialog'
10
+ import { EyeIcon, EyeOffIcon, LockIcon, StatsIcon, SparklesIcon, CloseIcon } from '../shell/icons'
11
+ import { getAIConfig, callAIStructured, hasConfiguredApiKey } from '../ai/ai-client'
12
+ import { SCENE_METADATA_PROMPT, SCENE_METADATA_SCHEMA } from '../ai/prompts'
13
+ import { prosemirrorToSutra } from '../editor/prosemirror-to-sutra'
14
+
15
+ /** Update the scene synopsis in the raw Sutra text. */
16
+ function updateSynopsisInText(text: string, sceneOffset: number, newSynopsis: string): string {
17
+ const lines = text.split('\n')
18
+ let currentOffset = 0
19
+ let targetLineIdx = -1
20
+
21
+ for (let i = 0; i < lines.length; i++) {
22
+ if (currentOffset === sceneOffset) {
23
+ targetLineIdx = i
24
+ break
25
+ }
26
+ currentOffset += (lines[i]?.length ?? 0) + 1
27
+ }
28
+
29
+ if (targetLineIdx === -1) return text
30
+
31
+ let synopsisLineIdx = -1
32
+ let insertIdx = targetLineIdx + 1
33
+
34
+ for (let j = targetLineIdx + 1; j < lines.length; j++) {
35
+ const line = (lines[j] ?? '').trim()
36
+ if (line.startsWith('##') || line.startsWith('#')) break
37
+ if (line.startsWith('& synopsis:')) {
38
+ synopsisLineIdx = j
39
+ break
40
+ }
41
+ if (line.startsWith('&')) {
42
+ insertIdx = j + 1
43
+ } else if (line !== '') {
44
+ break
45
+ }
46
+ }
47
+
48
+ const newLines = [...lines]
49
+ if (synopsisLineIdx !== -1) {
50
+ if (newSynopsis.trim() === '') {
51
+ newLines.splice(synopsisLineIdx, 1)
52
+ } else {
53
+ newLines[synopsisLineIdx] = `& synopsis: ${newSynopsis}`
54
+ }
55
+ } else if (newSynopsis.trim() !== '') {
56
+ newLines.splice(insertIdx, 0, `& synopsis: ${newSynopsis}`)
57
+ }
58
+
59
+ return newLines.join('\n')
60
+ }
61
+
62
+ /** Update a scene metadata key/value in the raw Sutra text. */
63
+ function updateSceneMetadataInText(text: string, sceneOffset: number, key: string, val: string): string {
64
+ const lines = text.split('\n')
65
+ let currentOffset = 0
66
+ let targetLineIdx = -1
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ if (currentOffset === sceneOffset) {
70
+ targetLineIdx = i
71
+ break
72
+ }
73
+ currentOffset += (lines[i]?.length ?? 0) + 1
74
+ }
75
+
76
+ if (targetLineIdx === -1) return text
77
+
78
+ let metadataLineIdx = -1
79
+ let insertIdx = targetLineIdx + 1
80
+
81
+ for (let j = targetLineIdx + 1; j < lines.length; j++) {
82
+ const line = (lines[j] ?? '').trim()
83
+ if (line.startsWith('##') || line.startsWith('#')) break
84
+ if (line.startsWith(`& ${key}:`)) {
85
+ metadataLineIdx = j
86
+ break
87
+ }
88
+ if (line.startsWith('&')) {
89
+ insertIdx = j + 1
90
+ } else if (line !== '') {
91
+ break
92
+ }
93
+ }
94
+
95
+ const newLines = [...lines]
96
+ if (metadataLineIdx !== -1) {
97
+ if (val.trim() === '') {
98
+ newLines.splice(metadataLineIdx, 1)
99
+ } else {
100
+ newLines[metadataLineIdx] = `& ${key}: ${val}`
101
+ }
102
+ } else if (val.trim() !== '') {
103
+ newLines.splice(insertIdx, 0, `& ${key}: ${val}`)
104
+ }
105
+
106
+ return newLines.join('\n')
107
+ }
108
+
109
+ /** Update a scene metadata key/value in the ProseMirror editor document. */
110
+ function updateSceneMetadataInProseMirror(sceneIndex: number, key: string, val: string) {
111
+ const view = getEditorView()
112
+ if (!view) return
113
+
114
+ let headingPmPos: number | null = null
115
+ let headingCount = 0
116
+ view.state.doc.forEach((node, offset) => {
117
+ if (headingPmPos !== null) return
118
+ if (node.type.name === 'scene_heading') {
119
+ headingCount++
120
+ if (headingCount === sceneIndex) headingPmPos = offset
121
+ }
122
+ })
123
+
124
+ if (headingPmPos !== null) {
125
+ let tr = view.state.tr
126
+ let existingPos: number | null = null
127
+ let index = view.state.doc.resolve(headingPmPos).index(0) + 1
128
+ let pos = headingPmPos + view.state.doc.nodeAt(headingPmPos)!.nodeSize
129
+
130
+ while (index < view.state.doc.childCount) {
131
+ const sibling = view.state.doc.child(index)
132
+ if (sibling.type.name === 'scene_metadata') {
133
+ if (sibling.attrs['metaKey'] === key) {
134
+ existingPos = pos
135
+ break
136
+ }
137
+ } else {
138
+ break
139
+ }
140
+ pos += sibling.nodeSize
141
+ index++
142
+ }
143
+
144
+ if (existingPos !== null) {
145
+ const node = view.state.doc.nodeAt(existingPos)!
146
+ if (val === '') {
147
+ tr = tr.delete(existingPos, existingPos + node.nodeSize)
148
+ } else {
149
+ tr = tr.replaceWith(
150
+ existingPos + 1,
151
+ existingPos + node.nodeSize - 1,
152
+ schema.text(val)
153
+ )
154
+ }
155
+ } else if (val !== '') {
156
+ let insertPos = headingPmPos + view.state.doc.nodeAt(headingPmPos)!.nodeSize
157
+ let siblingIdx = view.state.doc.resolve(headingPmPos).index(0) + 1
158
+ while (siblingIdx < view.state.doc.childCount) {
159
+ const sibling = view.state.doc.child(siblingIdx)
160
+ if (sibling.type.name === 'scene_metadata') {
161
+ insertPos += sibling.nodeSize
162
+ siblingIdx++
163
+ } else {
164
+ break
165
+ }
166
+ }
167
+
168
+ const metaNode = schema.nodes['scene_metadata']!.create(
169
+ { metaKey: key },
170
+ schema.text(val)
171
+ )
172
+ tr = tr.insert(insertPos, metaNode)
173
+ }
174
+ view.dispatch(tr)
175
+ }
176
+ }
177
+
178
+ /** Lock scene numbers in raw Sutra text. */
179
+ function lockNumbersInText(text: string): string {
180
+ const lines = text.split('\n')
181
+ const result: string[] = []
182
+ let sceneCount = 0
183
+
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const line = lines[i] ?? ''
186
+ const trimmed = line.trim()
187
+ result.push(line)
188
+
189
+ if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
190
+ sceneCount++
191
+ let hasNumber = false
192
+ let j = i + 1
193
+ while (j < lines.length) {
194
+ const nextLine = (lines[j] ?? '').trim()
195
+ if (nextLine.startsWith('##') || nextLine.startsWith('#')) break
196
+ if (nextLine.startsWith('& number:')) {
197
+ hasNumber = true
198
+ break
199
+ }
200
+ j++
201
+ }
202
+
203
+ if (!hasNumber) {
204
+ result.push(`& number: ${sceneCount}`)
205
+ }
206
+ }
207
+ }
208
+ return result.join('\n')
209
+ }
210
+
211
+ export function SceneNavigator() {
212
+ const { text, setText, setAIOnboardingVisible, showToast, setNavVisible } = useDocument()
213
+ const { t } = useTranslation()
214
+ const [scenes, setScenes] = useState<SceneEntry[]>([])
215
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
216
+ const [dropIndex, setDropIndex] = useState<number | null>(null)
217
+
218
+ // New States
219
+ const [showNavSynopsis, setShowNavSynopsis] = useState(true)
220
+ const [statsOpen, setStatsOpen] = useState(false)
221
+ const [editingSceneIdx, setEditingSceneIdx] = useState<number | null>(null)
222
+ const [editVal, setEditVal] = useState('')
223
+
224
+ // AI Generation States
225
+ const [isProcessing, setIsProcessing] = useState(false)
226
+ const [progressText, setProgressText] = useState('')
227
+ const textRef = React.useRef(text)
228
+ textRef.current = text
229
+
230
+ // Rebuild scene list whenever text changes
231
+ useEffect(() => {
232
+ setScenes(buildSceneList(text))
233
+ }, [text])
234
+
235
+ const handleSceneMetadata = useCallback(async (scene: SceneEntry) => {
236
+ setIsProcessing(true)
237
+ setProgressText('Generating synopsis & estimating duration...')
238
+ try {
239
+ const aiConfig = getAIConfig()
240
+ if (!(await hasConfiguredApiKey(aiConfig))) {
241
+ showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info')
242
+ setAIOnboardingVisible(true)
243
+ return
244
+ }
245
+
246
+ const idx = scenes.findIndex(s => s.index === scene.index)
247
+ if (idx === -1) return
248
+ const start = scene.textOffset
249
+ const end = idx + 1 < scenes.length ? scenes[idx + 1]!.textOffset : text.length
250
+ const sceneText = text.substring(start, end)
251
+
252
+ const { synopsis, duration } = await callAIStructured<{ synopsis: string; duration: string }>(
253
+ sceneText, SCENE_METADATA_PROMPT, aiConfig, SCENE_METADATA_SCHEMA
254
+ )
255
+
256
+ const view = getEditorView()
257
+ if (view) {
258
+ updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim())
259
+ updateSceneMetadataInProseMirror(scene.index, 'est-duration', duration.trim())
260
+ } else {
261
+ let nextText = updateSceneMetadataInText(text, scene.textOffset, 'synopsis', synopsis.trim())
262
+ nextText = updateSceneMetadataInText(nextText, scene.textOffset, 'est-duration', duration.trim())
263
+ setText(nextText)
264
+ }
265
+ showToast(`Generated synopsis & duration for scene ${scene.id || scene.index}.`, 'success')
266
+ } catch (e: any) {
267
+ showToast(`Scene metadata generation failed: ${e.message || String(e)}`, 'error')
268
+ } finally {
269
+ setIsProcessing(false)
270
+ setProgressText('')
271
+ }
272
+ }, [scenes, text, setText, setAIOnboardingVisible, showToast])
273
+
274
+ const handleGenerateAllSceneMetadata = useCallback(async () => {
275
+ setIsProcessing(true)
276
+ setProgressText('Starting batch synopsis & duration generation...')
277
+ try {
278
+ const aiConfig = getAIConfig()
279
+ if (!(await hasConfiguredApiKey(aiConfig))) {
280
+ showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info')
281
+ setAIOnboardingVisible(true)
282
+ return
283
+ }
284
+
285
+ const pmView = getEditorView()
286
+ const cmView = getSourceView()
287
+ let currentText = pmView ? prosemirrorToSutra(pmView.state.doc) : (cmView ? cmView.state.doc.toString() : textRef.current)
288
+
289
+ let currentScenes = buildSceneList(currentText)
290
+ const missing = currentScenes.filter(s => !s.synopsis || !s.estDuration)
291
+ if (missing.length === 0) {
292
+ showToast('All scenes already have synopsis and duration.', 'info')
293
+ return
294
+ }
295
+
296
+ let current = 0
297
+ for (const scene of missing) {
298
+ current++
299
+ setProgressText(`Generating synopsis & duration (${current}/${missing.length})...`)
300
+
301
+ currentText = pmView ? prosemirrorToSutra(pmView.state.doc) : (cmView ? cmView.state.doc.toString() : textRef.current)
302
+ currentScenes = buildSceneList(currentText)
303
+
304
+ const latestScene = currentScenes.find(s => s.index === scene.index)
305
+ if (!latestScene) continue
306
+
307
+ const start = latestScene.textOffset
308
+ const idx = currentScenes.findIndex(s => s.index === scene.index)
309
+ const end = idx + 1 < currentScenes.length ? currentScenes[idx + 1]!.textOffset : currentText.length
310
+ const sceneText = currentText.substring(start, end)
311
+
312
+ try {
313
+ const { synopsis, duration } = await callAIStructured<{ synopsis: string; duration: string }>(
314
+ sceneText, SCENE_METADATA_PROMPT, aiConfig, SCENE_METADATA_SCHEMA
315
+ )
316
+
317
+ if (pmView) {
318
+ if (!latestScene.synopsis) updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim())
319
+ if (!latestScene.estDuration) updateSceneMetadataInProseMirror(scene.index, 'est-duration', duration.trim())
320
+ } else {
321
+ let nextText = currentText
322
+ if (!latestScene.synopsis) nextText = updateSceneMetadataInText(nextText, latestScene.textOffset, 'synopsis', synopsis.trim())
323
+ if (!latestScene.estDuration) nextText = updateSceneMetadataInText(nextText, latestScene.textOffset, 'est-duration', duration.trim())
324
+ if (cmView) {
325
+ cmView.dispatch({
326
+ changes: { from: 0, to: cmView.state.doc.length, insert: nextText }
327
+ })
328
+ } else {
329
+ setText(nextText)
330
+ }
331
+ }
332
+ } catch (err) {
333
+ console.warn(`Failed scene metadata for scene ${scene.index}:`, err)
334
+ }
335
+ }
336
+
337
+ showToast(`Generated synopsis & duration for ${missing.length} scenes.`, 'success')
338
+ } catch (e: any) {
339
+ showToast(`Batch generation failed: ${e.message || String(e)}`, 'error')
340
+ } finally {
341
+ setIsProcessing(false)
342
+ setProgressText('')
343
+ }
344
+ }, [setText, setAIOnboardingVisible, showToast])
345
+
346
+ const handleFrontmatter = useCallback(() => {
347
+ const view = getEditorView()
348
+ if (!view) {
349
+ const cmView = getSourceView()
350
+ if (cmView) {
351
+ cmView.dispatch({
352
+ selection: { anchor: 0 },
353
+ effects: CmView.scrollIntoView(0, { y: 'start' }),
354
+ })
355
+ cmView.focus()
356
+ }
357
+ return
358
+ }
359
+ let tr = view.state.tr
360
+ const first = view.state.doc.firstChild
361
+ if (!first || first.type.name !== 'title_page') {
362
+ const field = schema.nodes['frontmatter_field']!.create({ fmKey: 'title' })
363
+ tr = tr.insert(0, schema.nodes['title_page']!.create({}, [field]))
364
+ }
365
+ tr = tr.setSelection(TextSelection.near(tr.doc.resolve(2)))
366
+ view.dispatch(tr)
367
+ view.focus()
368
+ view.dom.closest('.cs-main')?.scrollTo({ top: 0, behavior: 'smooth' })
369
+ if (typeof window !== 'undefined' && window.innerWidth <= 640) {
370
+ setNavVisible(false)
371
+ }
372
+ }, [setNavVisible])
373
+
374
+ const handleJump = useCallback((scene: SceneEntry) => {
375
+ const view = getEditorView()
376
+ if (!view) {
377
+ const cmView = getSourceView()
378
+ if (cmView) {
379
+ cmView.dispatch({
380
+ selection: { anchor: scene.textOffset },
381
+ effects: CmView.scrollIntoView(scene.textOffset, { y: 'start' }),
382
+ })
383
+ cmView.focus()
384
+ if (typeof window !== 'undefined' && window.innerWidth <= 640) {
385
+ setNavVisible(false)
386
+ }
387
+ }
388
+ return
389
+ }
390
+ try {
391
+ let headingPmPos: number | null = null
392
+ let headingCount = 0
393
+ view.state.doc.forEach((node, offset) => {
394
+ if (headingPmPos !== null) return
395
+ if (node.type.name === 'scene_heading') {
396
+ headingCount++
397
+ if (headingCount === scene.index) headingPmPos = offset
398
+ }
399
+ })
400
+ if (headingPmPos === null) return
401
+
402
+ const resolved = view.state.doc.resolve(headingPmPos + 1)
403
+ const sel = TextSelection.near(resolved)
404
+ view.dispatch(view.state.tr.setSelection(sel))
405
+ view.focus()
406
+
407
+ const domNode = view.nodeDOM(headingPmPos)
408
+ const el = domNode instanceof Element ? domNode : (domNode as Node | null)?.parentElement
409
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
410
+ if (typeof window !== 'undefined' && window.innerWidth <= 640) {
411
+ setNavVisible(false)
412
+ }
413
+ } catch {
414
+ /* at doc boundary — ignore */
415
+ }
416
+ }, [setNavVisible])
417
+
418
+ const handleDragStart = useCallback((idx: number) => {
419
+ setDragIndex(idx)
420
+ }, [])
421
+
422
+ const handleDragOver = useCallback((e: React.DragEvent, idx: number) => {
423
+ e.preventDefault()
424
+ setDropIndex(idx)
425
+ }, [])
426
+
427
+ const handleDrop = useCallback(
428
+ (e: React.DragEvent, targetIdx: number) => {
429
+ e.preventDefault()
430
+ if (dragIndex === null || dragIndex === targetIdx) {
431
+ setDragIndex(null)
432
+ setDropIndex(null)
433
+ return
434
+ }
435
+
436
+ const newText = reorderScenes(text, scenes, dragIndex, targetIdx)
437
+ setText(newText)
438
+ setDragIndex(null)
439
+ setDropIndex(null)
440
+ },
441
+ [dragIndex, text, scenes, setText]
442
+ )
443
+
444
+ const commitSynopsis = useCallback((idx: number, scene: SceneEntry) => {
445
+ setEditingSceneIdx(null)
446
+ const newSynopsis = editVal.trim()
447
+ if (newSynopsis === scene.synopsis) return
448
+
449
+ const view = getEditorView()
450
+ if (view) {
451
+ let headingPmPos: number | null = null
452
+ let headingCount = 0
453
+ view.state.doc.forEach((node, offset) => {
454
+ if (headingPmPos !== null) return
455
+ if (node.type.name === 'scene_heading') {
456
+ headingCount++
457
+ if (headingCount === scene.index) headingPmPos = offset
458
+ }
459
+ })
460
+
461
+ if (headingPmPos !== null) {
462
+ let tr = view.state.tr
463
+ let existingPos: number | null = null
464
+ let index = view.state.doc.resolve(headingPmPos).index(0) + 1
465
+ let pos = headingPmPos + view.state.doc.nodeAt(headingPmPos)!.nodeSize
466
+
467
+ while (index < view.state.doc.childCount) {
468
+ const sibling = view.state.doc.child(index)
469
+ if (sibling.type.name === 'scene_metadata') {
470
+ if (sibling.attrs['metaKey'] === 'synopsis') {
471
+ existingPos = pos
472
+ break
473
+ }
474
+ } else {
475
+ break
476
+ }
477
+ pos += sibling.nodeSize
478
+ index++
479
+ }
480
+
481
+ if (existingPos !== null) {
482
+ const node = view.state.doc.nodeAt(existingPos)!
483
+ if (newSynopsis === '') {
484
+ tr = tr.delete(existingPos, existingPos + node.nodeSize)
485
+ } else {
486
+ tr = tr.replaceWith(
487
+ existingPos + 1,
488
+ existingPos + node.nodeSize - 1,
489
+ schema.text(newSynopsis)
490
+ )
491
+ }
492
+ } else if (newSynopsis !== '') {
493
+ const insertPos = headingPmPos + view.state.doc.nodeAt(headingPmPos)!.nodeSize
494
+ const metaNode = schema.nodes['scene_metadata']!.create(
495
+ { metaKey: 'synopsis' },
496
+ schema.text(newSynopsis)
497
+ )
498
+ tr = tr.insert(insertPos, metaNode)
499
+ }
500
+ view.dispatch(tr)
501
+ }
502
+ } else {
503
+ const nextText = updateSynopsisInText(text, scene.textOffset, newSynopsis)
504
+ setText(nextText)
505
+ }
506
+ }, [editVal, text, setText])
507
+
508
+ const handleLockNumbers = useCallback(() => {
509
+ const view = getEditorView()
510
+ if (view) {
511
+ let tr = view.state.tr
512
+ let headingCount = 0
513
+ let posMap: { headingPos: number; currentNum: string | null }[] = []
514
+
515
+ view.state.doc.forEach((node, offset) => {
516
+ if (node.type.name === 'scene_heading') {
517
+ headingCount++
518
+ let hasNumber = false
519
+ let index = view.state.doc.resolve(offset).index(0) + 1
520
+ while (index < view.state.doc.childCount) {
521
+ const sibling = view.state.doc.child(index)
522
+ if (sibling.type.name === 'scene_metadata') {
523
+ if (sibling.attrs['metaKey'] === 'number') {
524
+ hasNumber = true
525
+ break
526
+ }
527
+ } else {
528
+ break
529
+ }
530
+ index++
531
+ }
532
+ if (!hasNumber) {
533
+ posMap.push({ headingPos: offset, currentNum: String(headingCount) })
534
+ }
535
+ }
536
+ })
537
+
538
+ for (let k = posMap.length - 1; k >= 0; k--) {
539
+ const { headingPos, currentNum } = posMap[k]!
540
+ const headingNode = view.state.doc.nodeAt(headingPos)!
541
+ const insertPos = headingPos + headingNode.nodeSize
542
+ const metaNode = schema.nodes['scene_metadata']!.create(
543
+ { metaKey: 'number' },
544
+ schema.text(currentNum!)
545
+ )
546
+ tr = tr.insert(insertPos, metaNode)
547
+ }
548
+ view.dispatch(tr)
549
+ } else {
550
+ const nextText = lockNumbersInText(text)
551
+ setText(nextText)
552
+ }
553
+ }, [text, setText])
554
+
555
+ return (
556
+ <div className="cs-navigator" role="navigation" aria-label="Scene Navigator">
557
+ <div className="cs-nav-mobile-bar">
558
+ <span className="cs-nav-mobile-title">{t('navigator.title')}</span>
559
+ <button
560
+ type="button"
561
+ className="cs-nav-mobile-close"
562
+ aria-label="Close navigator"
563
+ onClick={() => setNavVisible(false)}
564
+ >
565
+ <CloseIcon size={18} />
566
+ </button>
567
+ </div>
568
+ <div className="cs-nav-list">
569
+ <button
570
+ type="button"
571
+ className="cs-nav-section-header cs-nav-frontmatter-btn"
572
+ onClick={handleFrontmatter}
573
+ title={t('navigator.frontmatter')}
574
+ >
575
+ <span className="cs-nav-title">{t('navigator.frontmatter')}</span>
576
+ </button>
577
+
578
+ <div className="cs-nav-section-header cs-nav-scenes-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
579
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
580
+ <span className="cs-nav-title">{t('navigator.title')}</span>
581
+ <span className="cs-nav-count">{scenes.length}</span>
582
+ </div>
583
+ <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }} onClick={e => e.stopPropagation()}>
584
+ <button
585
+ type="button"
586
+ className="cs-nav-action-btn"
587
+ onClick={() => setShowNavSynopsis(!showNavSynopsis)}
588
+ title={showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration'}
589
+ aria-label={showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration'}
590
+ >
591
+ {showNavSynopsis ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
592
+ </button>
593
+ <button
594
+ type="button"
595
+ className="cs-nav-action-btn"
596
+ onClick={handleGenerateAllSceneMetadata}
597
+ disabled={isProcessing}
598
+ title="Generate synopsis & estimate duration (all scenes)"
599
+ aria-label="Generate synopsis & estimate duration (all scenes)"
600
+ style={{ opacity: isProcessing ? 0.5 : 1 }}
601
+ >
602
+ <SparklesIcon size={14} />
603
+ </button>
604
+ <button
605
+ type="button"
606
+ className="cs-nav-action-btn"
607
+ onClick={handleLockNumbers}
608
+ title="Lock Scene Numbers"
609
+ aria-label="Lock Scene Numbers"
610
+ >
611
+ <LockIcon size={14} />
612
+ </button>
613
+ </div>
614
+ </div>
615
+
616
+ {isProcessing && progressText && (
617
+ <div
618
+ className="cs-nav-progress"
619
+ style={{
620
+ display: 'flex',
621
+ alignItems: 'center',
622
+ gap: '8px',
623
+ padding: '6px 12px',
624
+ fontSize: '11px',
625
+ color: 'var(--cs-ui-accent, #c4760a)',
626
+ background: 'var(--cs-ui-bg-panel-subtle, #f4f2ea)',
627
+ borderBottom: '1px solid var(--cs-ui-border-light, #e2e0d6)',
628
+ fontWeight: 500,
629
+ }}
630
+ >
631
+ <span className="cs-spinner" style={{ width: '10px', height: '10px', borderWidth: '1.5px' }} />
632
+ <span>{progressText}</span>
633
+ </div>
634
+ )}
635
+
636
+ {scenes.map((scene, idx) => (
637
+ <div
638
+ key={scene.id ?? `scene-${idx}`}
639
+ role="button"
640
+ tabIndex={0}
641
+ aria-label={`Jump to ${scene.heading}`}
642
+ onKeyDown={(e) => {
643
+ if (e.key === 'Enter' || e.key === ' ') {
644
+ e.preventDefault()
645
+ handleJump(scene)
646
+ }
647
+ }}
648
+ className={[
649
+ 'cs-nav-item',
650
+ dragIndex === idx ? 'cs-nav-dragging' : '',
651
+ dropIndex === idx ? 'cs-nav-drop-target' : '',
652
+ ]
653
+ .filter(Boolean)
654
+ .join(' ')}
655
+ draggable
656
+ onClick={() => handleJump(scene)}
657
+ onDragStart={() => handleDragStart(idx)}
658
+ onDragOver={(e) => handleDragOver(e, idx)}
659
+ onDrop={(e) => handleDrop(e, idx)}
660
+ onDragEnd={() => {
661
+ setDragIndex(null)
662
+ setDropIndex(null)
663
+ }}
664
+ >
665
+ <div className="cs-nav-scene-info">
666
+ <div className="cs-nav-heading">{scene.heading}</div>
667
+
668
+ {showNavSynopsis && (
669
+ <div className="cs-nav-synopsis-row" style={{ marginTop: '4px' }}>
670
+ <div style={{ flex: 1, minWidth: 0 }}>
671
+ {editingSceneIdx === idx ? (
672
+ <input
673
+ type="text"
674
+ className="cs-nav-synopsis-input"
675
+ value={editVal}
676
+ onChange={e => setEditVal(e.target.value)}
677
+ onBlur={() => commitSynopsis(idx, scene)}
678
+ onKeyDown={e => {
679
+ if (e.key === 'Enter') commitSynopsis(idx, scene)
680
+ if (e.key === 'Escape') setEditingSceneIdx(null)
681
+ }}
682
+ autoFocus
683
+ onClick={e => e.stopPropagation()}
684
+ />
685
+ ) : scene.synopsis ? (
686
+ <div
687
+ className="cs-nav-scene-synopsis"
688
+ onClick={e => {
689
+ e.stopPropagation()
690
+ setEditingSceneIdx(idx)
691
+ setEditVal(scene.synopsis)
692
+ }}
693
+ title="Click to edit synopsis"
694
+ >
695
+ {scene.synopsis}
696
+ </div>
697
+ ) : (
698
+ <div
699
+ className="cs-nav-scene-synopsis cs-nav-synopsis-placeholder"
700
+ onClick={e => {
701
+ e.stopPropagation()
702
+ setEditingSceneIdx(idx)
703
+ setEditVal('')
704
+ }}
705
+ role="button"
706
+ tabIndex={0}
707
+ aria-label="Add synopsis"
708
+ onKeyDown={e => {
709
+ if (e.key === 'Enter' || e.key === ' ') {
710
+ e.stopPropagation()
711
+ e.preventDefault()
712
+ setEditingSceneIdx(idx)
713
+ setEditVal('')
714
+ }
715
+ }}
716
+ >
717
+ + Synopsis
718
+ </div>
719
+ )}
720
+ </div>
721
+
722
+ <button
723
+ type="button"
724
+ onClick={(e) => { e.stopPropagation(); void handleSceneMetadata(scene) }}
725
+ disabled={isProcessing}
726
+ title="Generate synopsis & estimate duration"
727
+ aria-label="Generate synopsis & estimate duration"
728
+ className="cs-nav-action-btn cs-nav-synopsis-ai-btn"
729
+ style={{ color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }}
730
+ >
731
+ <SparklesIcon size={12} />
732
+ </button>
733
+ </div>
734
+ )}
735
+
736
+ {(scene.status || (showNavSynopsis && scene.estDuration)) && (
737
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }}>
738
+ {scene.status && (
739
+ <span
740
+ className={`cs-nav-status cs-status-${scene.status
741
+ .toLowerCase()
742
+ .replace(/\s+/g, '-')}`}
743
+ >
744
+ {scene.status}
745
+ </span>
746
+ )}
747
+ {showNavSynopsis && scene.estDuration && (
748
+ <span
749
+ className="cs-nav-status"
750
+ title="Estimated Onscreen Duration"
751
+ style={{
752
+ background: 'rgba(217, 119, 6, 0.1)',
753
+ color: 'var(--cs-ui-accent, #c4760a)',
754
+ border: '1px solid rgba(217, 119, 6, 0.25)',
755
+ fontWeight: 600,
756
+ }}
757
+ >
758
+ ⏱ {scene.estDuration}
759
+ </span>
760
+ )}
761
+ </div>
762
+ )}
763
+ </div>
764
+ {scene.id && <div className="cs-nav-scene-num">{scene.id}</div>}
765
+ </div>
766
+ ))}
767
+
768
+ {scenes.length === 0 && (
769
+ <div className="cs-nav-empty">
770
+ {t('navigator.empty')}
771
+ </div>
772
+ )}
773
+
774
+ <div style={{ marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
775
+ <button
776
+ type="button"
777
+ className="cs-nav-section-header cs-nav-frontmatter-btn"
778
+ onClick={() => setStatsOpen(true)}
779
+ style={{ width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}
780
+ >
781
+ <StatsIcon size={14} /> <span className="cs-nav-title">Statistics</span>
782
+ </button>
783
+ </div>
784
+ </div>
785
+
786
+ {statsOpen && <StatisticsDialog onClose={() => setStatsOpen(false)} />}
787
+ </div>
788
+ )
789
+ }
790
+
791
+ /**
792
+ * Reorder scene blocks in the source text.
793
+ * Moves the entire block (heading through next heading) without mutating IDs.
794
+ */
795
+ function reorderScenes(
796
+ text: string,
797
+ scenes: SceneEntry[],
798
+ fromIdx: number,
799
+ toIdx: number
800
+ ): string {
801
+ const lines = text.split('\n')
802
+
803
+ const sceneLineRanges = scenes.map((scene, i) => {
804
+ const nextScene = scenes[i + 1]
805
+ const startLine = findLineAt(lines, scene.textOffset)
806
+ const endLine = nextScene
807
+ ? findLineAt(lines, nextScene.textOffset) - 1
808
+ : lines.length - 1
809
+ return { startLine, endLine }
810
+ })
811
+
812
+ // Extract prefix before first scene
813
+ const firstSceneStart = sceneLineRanges[0]?.startLine ?? 0
814
+ const prefixLines = lines.slice(0, firstSceneStart)
815
+
816
+ // Extract line blocks for each scene
817
+ const blocks = sceneLineRanges.map(r => lines.slice(r.startLine, r.endLine + 1))
818
+
819
+ const blockToMove = blocks[fromIdx]
820
+ if (!blockToMove) return text
821
+
822
+ // Remove the block from the list
823
+ const remainingBlocks = blocks.filter((_, i) => i !== fromIdx)
824
+
825
+ // Insert the block at the target index
826
+ remainingBlocks.splice(toIdx, 0, blockToMove)
827
+
828
+ // Rebuild the text
829
+ const finalLines = [...prefixLines]
830
+ for (const b of remainingBlocks) {
831
+ finalLines.push(...b)
832
+ }
833
+
834
+ return finalLines.join('\n')
835
+ }
836
+
837
+ function findLineAt(lines: string[], offset: number): number {
838
+ let pos = 0
839
+ for (let i = 0; i < lines.length; i++) {
840
+ if (pos >= offset) return i
841
+ pos += (lines[i]?.length ?? 0) + 1
842
+ }
843
+ return lines.length - 1
844
+ }