@sutrata/editor 0.1.0 → 0.1.2

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 (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +36 -9
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. 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
- }