@sutrata/editor 0.1.1 → 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.
- package/README.md +87 -0
- package/dist/ai/generated-providers.json +1 -1
- package/package.json +30 -3
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- 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
|
-
}
|