@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
@@ -1,844 +0,0 @@
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
- }