@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,648 +0,0 @@
1
- import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
- import { parse, importFountain, fountainDocToSutra } from '@sutrata/parser'
3
- import type { DocumentNode } from '@sutrata/parser'
4
- import type { EditorMode, VersionEntry } from '../types'
5
- import { StorageAdapterProvider } from '../extensions/storage-adapter'
6
- import type { StorageAdapter } from '../extensions/storage-adapter'
7
- import { importDocx as importDocxToSutra } from '../file/docx-importer'
8
- import { runCommand, getEditorView } from '../editor/editor-bus'
9
- import { setFlatFrontmatterField } from '../editor/frontmatter-field'
10
- import { makeSetBlock, makeSetOrToggleNote } from '../shell/toolbar-actions'
11
- import { resolveStyle } from '../styles/registry'
12
- import type { ScreenplayStyleDefinition } from '../styles/types'
13
-
14
- import type { ConfirmOptions } from '../shell/ConfirmDialog'
15
- import type { ToastItem } from '../shell/Toast'
16
-
17
- export type FindMode = 'find' | 'replace'
18
-
19
- interface DocumentContextValue {
20
- text: string
21
- ast: DocumentNode
22
- mode: EditorMode
23
- filePath: string | null
24
- isDirty: boolean
25
- lastSaveTarget: 'file' | 'local' | null
26
- ribbonVisible: boolean
27
- navVisible: boolean
28
- exportVisible: boolean
29
- settingsVisible: boolean
30
- aiOnboardingVisible: boolean
31
- voiceActive: boolean
32
- metadataVisible: boolean
33
- findReplaceVisible: boolean
34
- findMode: FindMode
35
- styleVisible: boolean
36
- customStyles: ScreenplayStyleDefinition[]
37
- resolvedStyle: ScreenplayStyleDefinition
38
- watermarkText: string | null
39
- confirmModal: ConfirmOptions | null
40
- toasts: ToastItem[]
41
- versions: VersionEntry[]
42
- versionHistoryVisible: boolean
43
- setText: (next: string) => void
44
- setMode: (mode: EditorMode) => void
45
- setFilePath: (path: string | null) => void
46
- setRibbonVisible: (v: boolean) => void
47
- setNavVisible: (v: boolean) => void
48
- setExportVisible: (v: boolean) => void
49
- setSettingsVisible: (v: boolean) => void
50
- setAIOnboardingVisible: (v: boolean) => void
51
- setVoiceActive: (v: boolean) => void
52
- setMetadataVisible: (v: boolean) => void
53
- setFindReplaceVisible: (visible: boolean) => void
54
- setFindMode: (mode: FindMode) => void
55
- setStyleVisible: (v: boolean) => void
56
- setVersionHistoryVisible: (v: boolean) => void
57
- setDocumentStyle: (id: string) => void
58
- reloadCustomStyles: () => Promise<void>
59
- markClean: () => void
60
- save: () => Promise<void>
61
- newDocument: (initialContent?: string) => void
62
- openFile: () => Promise<void>
63
- saveFile: () => Promise<void>
64
- saveFileAs: () => Promise<void>
65
- importDocx: () => Promise<string[] | null>
66
- restoreVersion: (entry: VersionEntry) => void
67
- clearVersionHistory: () => Promise<void>
68
- showConfirm: (options: ConfirmOptions) => void
69
- closeConfirm: () => void
70
- showToast: (message: string, type?: 'info' | 'success' | 'warn' | 'error') => void
71
- dismissToast: (id: string) => void
72
- }
73
-
74
- const DocumentContext = createContext<DocumentContextValue | null>(null)
75
-
76
- const EMPTY_DOC = '## Scene 1\n\nAction.\n'
77
-
78
- // Points at whichever autosave key (a real filePath, or '__autosave__' for an
79
- // untitled doc) currently holds unsaved-but-not-yet-file-saved content, so a
80
- // reload can find it. Cleared once the content is genuinely saved to a file.
81
- const RECOVERY_POINTER_KEY = 'sutrata:recovery-pointer'
82
- // A save fires shortly after the user pauses...
83
- const AUTOSAVE_PAUSE_MS = 3_000
84
- // ...and unconditionally at this interval even if they never pause (previously
85
- // this was a pure 30s debounce that never fired while the user kept typing).
86
- const AUTOSAVE_MAX_WAIT_MS = 30_000
87
-
88
- interface RecoveryPointer { path: string; updatedAt: number }
89
-
90
- function readRecoveryPointer(): RecoveryPointer | null {
91
- try {
92
- const raw = localStorage.getItem(RECOVERY_POINTER_KEY)
93
- if (!raw) return null
94
- const parsed = JSON.parse(raw) as Partial<RecoveryPointer>
95
- if (typeof parsed.path !== 'string' || typeof parsed.updatedAt !== 'number') return null
96
- return parsed as RecoveryPointer
97
- } catch {
98
- return null
99
- }
100
- }
101
-
102
- function writeRecoveryPointer(path: string): void {
103
- try {
104
- localStorage.setItem(RECOVERY_POINTER_KEY, JSON.stringify({ path, updatedAt: Date.now() }))
105
- } catch {
106
- // localStorage unavailable (private browsing, quota) — recovery just won't work this session.
107
- }
108
- }
109
-
110
- function clearRecoveryPointer(): void {
111
- try {
112
- localStorage.removeItem(RECOVERY_POINTER_KEY)
113
- } catch {
114
- // ignore
115
- }
116
- }
117
-
118
- export function DocumentProvider({ children, storageAdapter }: { children: React.ReactNode; storageAdapter: StorageAdapter }) {
119
- const [text, setTextState] = useState(EMPTY_DOC)
120
- const [ast, setAst] = useState<DocumentNode>(() => parse(EMPTY_DOC))
121
- const [mode, setMode] = useState<EditorMode>('formatted')
122
- const [filePath, setFilePath] = useState<string | null>(null)
123
- const [isDirty, setIsDirty] = useState(false)
124
- // Where the most recent save attempt went. isDirty tracks "does this differ
125
- // from the real file on disk" (VS Code-style) — a local autosave backs the
126
- // content up but does NOT clear isDirty, only saveFile()/saveFileAs() does.
127
- // lastSaveTarget just tells the UI which kind of save last happened, so it
128
- // can show e.g. "backed up locally, not yet saved" while still dirty.
129
- const [lastSaveTarget, setLastSaveTarget] = useState<'file' | 'local' | null>(null)
130
- const [ribbonVisible, setRibbonVisible] = useState(true)
131
- const [navVisible, setNavVisible] = useState(() => window.innerWidth > 640)
132
- const [exportVisible, setExportVisible] = useState(false)
133
- const [settingsVisible, setSettingsVisible] = useState(false)
134
- const [aiOnboardingVisible, setAIOnboardingVisible] = useState(false)
135
- const [voiceActive, setVoiceActive] = useState(false)
136
- const [metadataVisible, setMetadataVisible] = useState(true)
137
- const [findReplaceVisible, setFindReplaceVisible] = useState(false)
138
- const [findMode, setFindMode] = useState<FindMode>('find')
139
- const [styleVisible, setStyleVisible] = useState(false)
140
- const [customStyles, setCustomStyles] = useState<ScreenplayStyleDefinition[]>([])
141
- const [confirmModal, setConfirmModal] = useState<ConfirmOptions | null>(null)
142
- const [toasts, setToasts] = useState<ToastItem[]>([])
143
- const [versions, setVersions] = useState<VersionEntry[]>([])
144
- const [versionHistoryVisible, setVersionHistoryVisible] = useState(false)
145
-
146
- const showConfirm = useCallback((options: ConfirmOptions) => {
147
- setConfirmModal(options)
148
- }, [])
149
-
150
- const closeConfirm = useCallback(() => {
151
- setConfirmModal(null)
152
- }, [])
153
-
154
- const showToast = useCallback((message: string, type: 'info' | 'success' | 'warn' | 'error' = 'info') => {
155
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
156
- setToasts((prev) => [...prev, { id, message, type }])
157
- }, [])
158
-
159
- const dismissToast = useCallback((id: string) => {
160
- setToasts((prev) => prev.filter((t) => t.id !== id))
161
- }, [])
162
-
163
- // Opaque handle from the StorageAdapter's File System Access-style open/save
164
- // (unknown on purpose — see extensions/storage-adapter.ts) letting saveFile()
165
- // write back in place instead of prompting Save As every time.
166
- const fileHandleRef = useRef<unknown>(null)
167
-
168
- // isDirtyRef is updated synchronously by setDirty() below, everywhere isDirty
169
- // changes — not via a useEffect syncing off the `isDirty` state, which would lag
170
- // a render+commit behind and race anything (like the close-guard) that checks
171
- // "did that save actually succeed?" right after calling setDirty in the same tick.
172
- const isDirtyRef = useRef(false)
173
- const setDirty = useCallback((value: boolean) => {
174
- isDirtyRef.current = value
175
- setIsDirty(value)
176
- }, [])
177
-
178
- const reloadCustomStyles = useCallback(async () => {
179
- try {
180
- setCustomStyles(await storageAdapter.loadAllStyles())
181
- } catch {
182
- // IndexedDB unavailable (private browsing, test environment, etc.) — fall
183
- // back to built-in styles only rather than crashing the app.
184
- setCustomStyles([])
185
- }
186
- }, [storageAdapter])
187
-
188
- useEffect(() => { void reloadCustomStyles() }, [reloadCustomStyles])
189
-
190
- // Recover autosaved-but-unsaved content on boot (Ctrl-R, a crash, or the tab closing
191
- // without an explicit save) instead of always starting from a blank document.
192
- useEffect(() => {
193
- const pointer = readRecoveryPointer()
194
- if (!pointer) return
195
- let cancelled = false
196
- void (async () => {
197
- try {
198
- const content = await storageAdapter.loadDocument(pointer.path)
199
- if (cancelled || !content) return
200
- setTextState(content)
201
- setAst(parse(content))
202
- setFilePath(pointer.path === '__autosave__' ? null : pointer.path)
203
- setDirty(true)
204
- // Still marked dirty above regardless — a restored draft isn't confirmed
205
- // written back until the user actually saves it again.
206
- if (pointer.path !== '__autosave__') {
207
- const handle = await storageAdapter.restoreFileHandle?.(pointer.path)
208
- if (!cancelled && handle) fileHandleRef.current = handle
209
- }
210
- showToast('Recovered unsaved changes from your last session.', 'info')
211
- } catch {
212
- // Best-effort recovery; fall back to the empty template on failure.
213
- }
214
- })()
215
- return () => { cancelled = true }
216
- }, [storageAdapter, showToast])
217
-
218
- const setText = useCallback((next: string) => {
219
- setTextState(next)
220
- setAst(parse(next))
221
- setDirty(true)
222
- }, [])
223
-
224
- const frontmatterData = ast.frontmatter?.data as Record<string, unknown> | undefined
225
- const documentStyleId = frontmatterData?.['style'] as string | undefined
226
- const resolvedStyle = useMemo(
227
- () => resolveStyle(documentStyleId, customStyles),
228
- [documentStyleId, customStyles],
229
- )
230
- const watermarkRaw = frontmatterData?.['watermark']
231
- const watermarkText = typeof watermarkRaw === 'string' && watermarkRaw.trim() ? watermarkRaw.trim() : null
232
-
233
- const setDocumentStyle = useCallback((id: string) => {
234
- setFlatFrontmatterField(getEditorView(), text, setText, 'style', id)
235
- }, [text, setText])
236
-
237
- const markClean = useCallback(() => setDirty(false), [])
238
-
239
- // Refs so the autosave path always writes the latest text/filePath without
240
- // needing them in a dependency array that would tear down the timers below
241
- // on every keystroke.
242
- const textRef = useRef(text)
243
- useEffect(() => { textRef.current = text }, [text])
244
- const filePathRef = useRef(filePath)
245
- useEffect(() => { filePathRef.current = filePath }, [filePath])
246
- const autosaveFailedRef = useRef(false)
247
-
248
- const save = useCallback(async () => {
249
- const path = filePathRef.current ?? '__autosave__'
250
- try {
251
- await storageAdapter.saveDocument(path, textRef.current)
252
- writeRecoveryPointer(path)
253
- setLastSaveTarget('local')
254
- autosaveFailedRef.current = false
255
- // Deliberately NOT setDirty(false) here — this is a local backup, not a
256
- // save to the real file, so the document must keep showing as unsaved
257
- // (VS Code-style) until saveFile()/saveFileAs() succeeds. Still reset the
258
- // ceiling/ref below so a marathon typing session keeps getting backed up
259
- // roughly every AUTOSAVE_MAX_WAIT_MS instead of only once.
260
- if (maxWaitTimerRef.current) { clearTimeout(maxWaitTimerRef.current); maxWaitTimerRef.current = null }
261
- dirtySinceRef.current = null
262
- } catch {
263
- if (!autosaveFailedRef.current) {
264
- autosaveFailedRef.current = true
265
- showToast('Autosave failed — your browser storage may be full or unavailable.', 'warn')
266
- }
267
- }
268
- }, [storageAdapter, showToast])
269
-
270
- const newDocument = useCallback((initialContent?: string) => {
271
- const doNew = () => {
272
- if (pauseTimerRef.current) { clearTimeout(pauseTimerRef.current); pauseTimerRef.current = null }
273
- if (maxWaitTimerRef.current) { clearTimeout(maxWaitTimerRef.current); maxWaitTimerRef.current = null }
274
- dirtySinceRef.current = null
275
- fileHandleRef.current = null
276
- clearRecoveryPointer()
277
- const content = initialContent ?? EMPTY_DOC
278
- setTextState(content)
279
- setAst(parse(content))
280
- setFilePath(null)
281
- setDirty(false)
282
- setLastSaveTarget(null)
283
- setVersions([])
284
- }
285
-
286
- if (!isDirty) {
287
- doNew()
288
- return
289
- }
290
-
291
- showConfirm({
292
- title: 'Discard Unsaved Changes?',
293
- message: 'Starting a new screenplay will discard all unsaved edits to your current document.',
294
- confirmLabel: 'Discard & Create New',
295
- destructive: true,
296
- onConfirm: doNew,
297
- })
298
- }, [isDirty, showConfirm])
299
-
300
- const openFile = useCallback(async () => {
301
- const doOpen = async () => {
302
- const result = await storageAdapter.openFile()
303
- if (!result) return
304
- if (pauseTimerRef.current) { clearTimeout(pauseTimerRef.current); pauseTimerRef.current = null }
305
- if (maxWaitTimerRef.current) { clearTimeout(maxWaitTimerRef.current); maxWaitTimerRef.current = null }
306
- dirtySinceRef.current = null
307
- clearRecoveryPointer()
308
- let content = result.content
309
- const isFountain = result.name.endsWith('.fountain')
310
- if (isFountain) {
311
- const { document } = importFountain(content)
312
- content = fountainDocToSutra(document)
313
- }
314
- const parsed = parse(content)
315
- if (!parsed.frontmatter) {
316
- const baseName = result.name.replace(/\.[^.]+$/, '')
317
- content = `---\ntitle: ${baseName}\n---\n\n${content}`
318
- }
319
- // Fountain files get no handle (we save as .sutra); .sutra files keep their handle
320
- fileHandleRef.current = isFountain ? null : (result.handle ?? null)
321
- const newPath = result.name.replace(/\.fountain$/i, '.sutra')
322
- setTextState(content)
323
- setAst(parse(content))
324
- setFilePath(newPath)
325
- // Opening a file is a clean state; fountain conversion counts as dirty since format changed
326
- setDirty(isFountain)
327
- setLastSaveTarget(isFountain ? null : 'file')
328
- if (!isFountain && fileHandleRef.current) {
329
- void storageAdapter.persistFileHandle?.(newPath, fileHandleRef.current)
330
- }
331
- }
332
-
333
- if (!isDirty) {
334
- await doOpen()
335
- return
336
- }
337
-
338
- await new Promise<void>((resolve) => {
339
- showConfirm({
340
- title: 'Discard Unsaved Changes?',
341
- message: 'Opening a different screenplay will discard any unsaved edits to your current document.',
342
- confirmLabel: 'Discard & Open',
343
- destructive: true,
344
- onConfirm: () => { void doOpen().then(resolve) },
345
- onCancel: () => resolve(),
346
- })
347
- })
348
- }, [storageAdapter, isDirty, showConfirm])
349
-
350
- // Power-user feature: versions are captured only on explicit saveFile/saveFileAs
351
- // (never on autosave — see save() above). Called both by the filePath-change
352
- // effect below and directly after a save that reuses the same filePath (which
353
- // wouldn't otherwise re-trigger that effect).
354
- const refreshVersions = useCallback(async (path: string) => {
355
- try {
356
- const loaded = await storageAdapter.listVersions(path)
357
- setVersions(loaded)
358
- } catch {
359
- // silently fail if storage unavailable
360
- }
361
- }, [storageAdapter])
362
-
363
- const saveFile = useCallback(async () => {
364
- const name = filePath ?? 'untitled.sutra'
365
- const result = await storageAdapter.saveFile(name, text, fileHandleRef.current)
366
- if (result) {
367
- fileHandleRef.current = result.handle
368
- setFilePath(result.savedName)
369
- setDirty(false)
370
- setLastSaveTarget('file')
371
- clearRecoveryPointer()
372
- if (result.handle) void storageAdapter.persistFileHandle?.(result.savedName, result.handle)
373
- void refreshVersions(result.savedName)
374
- showToast(`Saved ${result.savedName}`, 'success')
375
- }
376
- }, [filePath, text, showToast, storageAdapter, refreshVersions])
377
-
378
- const saveFileAs = useCallback(async () => {
379
- const name = filePath ?? 'untitled.sutra'
380
- const result = await storageAdapter.saveFile(name, text, null)
381
- if (result) {
382
- fileHandleRef.current = result.handle
383
- setFilePath(result.savedName)
384
- setDirty(false)
385
- setLastSaveTarget('file')
386
- clearRecoveryPointer()
387
- if (result.handle) void storageAdapter.persistFileHandle?.(result.savedName, result.handle)
388
- void refreshVersions(result.savedName)
389
- showToast(`Saved as ${result.savedName}`, 'success')
390
- }
391
- }, [filePath, text, showToast, storageAdapter, refreshVersions])
392
-
393
- // Reimports a .docx previously exported via exportToDocx (optionally corrected/proofread
394
- // in Word — the exporter's CineXxx paragraph styles are what makes the round trip possible;
395
- // font-only changes don't affect them). Returns null if the user cancelled the file picker
396
- // or declined the replace-content confirmation; otherwise the import warnings (possibly empty).
397
- const importDocx = useCallback(async (): Promise<string[] | null> => {
398
- const picked = await storageAdapter.openDocx()
399
- if (!picked) return null
400
- const { bodyText, warnings } = await importDocxToSutra(picked.buffer)
401
- if (bodyText === null) return warnings
402
- return new Promise((resolve) => {
403
- showConfirm({
404
- title: 'Import Word Document',
405
- message: 'Replace the current screenplay content with the imported Word document? This cannot be undone.',
406
- confirmLabel: 'Replace Screenplay',
407
- destructive: true,
408
- onConfirm: () => {
409
- const frontmatterRaw = ast.frontmatter?.raw
410
- const newText = frontmatterRaw ? `${frontmatterRaw}\n${bodyText}\n` : `${bodyText}\n`
411
- setText(newText)
412
- showToast('Word document imported successfully', 'success')
413
- resolve(warnings)
414
- },
415
- onCancel: () => resolve(null),
416
- })
417
- })
418
- }, [ast, setText, showConfirm, showToast])
419
-
420
- // Power-user feature: versions are captured only on explicit saveFile/saveFileAs
421
- // (never on autosave — see save() above), so this needs an explicit refresh
422
- // after each of those succeed, not just when filePath changes.
423
- useEffect(() => {
424
- if (!filePath) {
425
- setVersions([])
426
- return
427
- }
428
- void refreshVersions(filePath)
429
- }, [filePath, refreshVersions])
430
-
431
- const restoreVersion = useCallback((entry: VersionEntry) => {
432
- setText(entry.content) // setText already marks the document dirty
433
- setVersionHistoryVisible(false)
434
- showToast('Version restored — remember to save.', 'info')
435
- }, [setText, showToast])
436
-
437
- const clearVersionHistory = useCallback(async () => {
438
- if (!filePath) return
439
- return new Promise<void>((resolve) => {
440
- showConfirm({
441
- title: 'Delete Version History?',
442
- message: `Delete all ${versions.length} saved versions of ${filePath}? This cannot be undone.`,
443
- confirmLabel: 'Delete Version History',
444
- destructive: true,
445
- onConfirm: async () => {
446
- try {
447
- await storageAdapter.deleteAllVersions?.(filePath)
448
- setVersions([])
449
- showToast('Version history cleared', 'success')
450
- } catch {
451
- showToast('Failed to clear version history', 'error')
452
- }
453
- resolve()
454
- },
455
- onCancel: () => resolve(),
456
- })
457
- })
458
- }, [filePath, versions.length, showConfirm, showToast, storageAdapter])
459
-
460
- // Autosave: a short pause after the last keystroke triggers a save, and a hard
461
- // ceiling (AUTOSAVE_MAX_WAIT_MS) forces one regardless, so a long uninterrupted
462
- // typing session is never more than ~30s away from a recoverable backup.
463
- const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
464
- const maxWaitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
465
- const dirtySinceRef = useRef<number | null>(null)
466
-
467
- useEffect(() => {
468
- if (!isDirty) {
469
- if (pauseTimerRef.current) { clearTimeout(pauseTimerRef.current); pauseTimerRef.current = null }
470
- if (maxWaitTimerRef.current) { clearTimeout(maxWaitTimerRef.current); maxWaitTimerRef.current = null }
471
- dirtySinceRef.current = null
472
- return
473
- }
474
-
475
- if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
476
- pauseTimerRef.current = setTimeout(() => { void save() }, AUTOSAVE_PAUSE_MS)
477
-
478
- // Only armed once per dirty streak — NOT reset on every keystroke — so it
479
- // actually acts as a ceiling instead of an ever-postponed debounce.
480
- if (dirtySinceRef.current === null) {
481
- dirtySinceRef.current = Date.now()
482
- maxWaitTimerRef.current = setTimeout(() => { void save() }, AUTOSAVE_MAX_WAIT_MS)
483
- }
484
-
485
- return () => {
486
- if (pauseTimerRef.current) { clearTimeout(pauseTimerRef.current); pauseTimerRef.current = null }
487
- }
488
- }, [text, isDirty, save])
489
-
490
- useEffect(() => {
491
- return () => {
492
- if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
493
- if (maxWaitTimerRef.current) clearTimeout(maxWaitTimerRef.current)
494
- }
495
- }, [])
496
-
497
- // beforeunload guard — fires on page reload, window close, Electron reload
498
- useEffect(() => {
499
- const onBeforeUnload = (e: BeforeUnloadEvent) => {
500
- if (!isDirty) return
501
- e.preventDefault()
502
- e.returnValue = 'You have unsaved changes.'
503
- }
504
- window.addEventListener('beforeunload', onBeforeUnload)
505
- return () => window.removeEventListener('beforeunload', onBeforeUnload)
506
- }, [isDirty])
507
-
508
- useEffect(() => {
509
- const onKeyDown = (e: KeyboardEvent) => {
510
- const mod = e.ctrlKey || e.metaKey
511
- const cs = mod && e.shiftKey
512
-
513
- // File operations — use e.code for layout-independence
514
- if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyS') {
515
- e.preventDefault(); void saveFile(); return
516
- }
517
- if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyO') {
518
- e.preventDefault(); void openFile(); return
519
- }
520
- if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyF') {
521
- e.preventDefault()
522
- setFindMode('find')
523
- setFindReplaceVisible(v => !v)
524
- return
525
- }
526
- if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyH') {
527
- e.preventDefault()
528
- setFindMode('replace')
529
- setFindReplaceVisible(true)
530
- return
531
- }
532
-
533
- // Block type shortcuts Alt+Shift+letter
534
- if (e.altKey && e.shiftKey && !mod) {
535
- if (e.code === 'KeyN') {
536
- e.preventDefault()
537
- runCommand(makeSetOrToggleNote())
538
- return
539
- }
540
- const blockMap: Record<string, Parameters<typeof makeSetBlock>> = {
541
- 'KeyH': ['scene_heading', { id: null }],
542
- 'KeyA': ['action'],
543
- 'KeyC': ['character', { extension: null, isDual: false }],
544
- 'KeyD': ['dialogue'],
545
- 'KeyP': ['parenthetical'],
546
- 'KeyX': ['transition'],
547
- }
548
- if (e.code in blockMap) {
549
- e.preventDefault()
550
- const [type, attrs] = blockMap[e.code]!
551
- runCommand(makeSetBlock(type, attrs ?? {}))
552
- return
553
- }
554
- }
555
-
556
- // UI toggles Ctrl+Shift+letter
557
- if (cs && !e.altKey) {
558
- if (e.code === 'KeyE') { e.preventDefault(); setMode(m => m === 'formatted' ? 'source' : 'formatted'); return }
559
- if (e.code === 'KeyH') { e.preventDefault(); setRibbonVisible(v => !v); return } // was KeyR
560
- if (e.code === 'KeyB') { e.preventDefault(); setNavVisible(v => !v); return }
561
- }
562
- }
563
- window.addEventListener('keydown', onKeyDown)
564
- return () => window.removeEventListener('keydown', onKeyDown)
565
- }, [saveFile, openFile])
566
-
567
- // Native menu IPC (Desktop wrapper only) — receives commands from the main process menu
568
- useEffect(() => {
569
- const api = window.desktopAPI
570
- if (!api?.onMenuCommand) return
571
- const off = api.onMenuCommand((cmd) => {
572
- switch (cmd) {
573
- case 'file:new':
574
- newDocument(EMPTY_DOC)
575
- break
576
- case 'file:open': void openFile(); break
577
- case 'file:save': void saveFile(); break
578
- case 'file:saveAs': void saveFileAs(); break
579
- case 'edit:find':
580
- setFindMode('find'); setFindReplaceVisible(true); break
581
- case 'edit:replace':
582
- setFindMode('replace'); setFindReplaceVisible(true); break
583
- case 'edit:toggleSource':
584
- setMode(m => m === 'formatted' ? 'source' : 'formatted'); break
585
- case 'edit:toggleRibbon':
586
- setRibbonVisible(v => !v); break
587
- case 'edit:toggleNav':
588
- setNavVisible(v => !v); break
589
- case 'edit:toggleMetadata':
590
- setMetadataVisible(!metadataVisible); break
591
- }
592
- })
593
- return off
594
- }, [isDirty, newDocument, openFile, saveFile, saveFileAs, setText, setMode, setRibbonVisible, setNavVisible, metadataVisible, showConfirm])
595
-
596
- // Native window-close guard (Desktop wrapper only). beforeunload isn't reliable for
597
- // an OS-level close across Tauri's backing webviews, so the Rust side intercepts it
598
- // and asks here instead; nothing to close until we say so via closeWindow().
599
- useEffect(() => {
600
- const api = window.desktopAPI
601
- if (!api?.onCloseRequested) return
602
- const off = api.onCloseRequested(() => {
603
- if (!isDirtyRef.current) {
604
- void api.closeWindow?.()
605
- return
606
- }
607
- showConfirm({
608
- title: 'Unsaved Changes',
609
- message: 'You have unsaved changes. Save them before closing?',
610
- confirmLabel: 'Save & Close',
611
- cancelLabel: 'Cancel',
612
- onConfirm: () => {
613
- void saveFile().then(() => {
614
- // saveFile only clears isDirty on success — if the user cancelled a
615
- // Save As picker or it failed, stay open instead of closing anyway.
616
- if (!isDirtyRef.current) void api.closeWindow?.()
617
- })
618
- },
619
- // onCancel omitted — dismissing just leaves the window open.
620
- })
621
- })
622
- return off
623
- }, [saveFile, showConfirm])
624
-
625
- return (
626
- <StorageAdapterProvider value={storageAdapter}>
627
- <DocumentContext.Provider value={{
628
- text, ast, mode, filePath, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
629
- aiOnboardingVisible, voiceActive, metadataVisible,
630
- findReplaceVisible, findMode, styleVisible, customStyles, resolvedStyle, watermarkText,
631
- confirmModal, toasts, versions, versionHistoryVisible,
632
- setText, setMode, setFilePath, setRibbonVisible, setNavVisible, setExportVisible, setSettingsVisible,
633
- setAIOnboardingVisible, setVoiceActive, setMetadataVisible,
634
- setFindReplaceVisible, setFindMode, setStyleVisible, setVersionHistoryVisible, setDocumentStyle, reloadCustomStyles,
635
- markClean, save, newDocument, openFile, saveFile, saveFileAs, importDocx, restoreVersion, clearVersionHistory,
636
- showConfirm, closeConfirm, showToast, dismissToast,
637
- }}>
638
- {children}
639
- </DocumentContext.Provider>
640
- </StorageAdapterProvider>
641
- )
642
- }
643
-
644
- export function useDocument() {
645
- const ctx = useContext(DocumentContext)
646
- if (!ctx) throw new Error('useDocument must be used inside DocumentProvider')
647
- return ctx
648
- }
@@ -1,40 +0,0 @@
1
- // Types for window.desktopAPI exposed by desktop container shells — keep in sync.
2
- interface DesktopAPI {
3
- readFile: (path: string) => Promise<string>
4
- writeFile: (path: string, content: string) => Promise<void>
5
- showOpenDialog: () => Promise<string | null>
6
- showSaveDialog: (defaultName: string) => Promise<string | null>
7
- // Keyring & AI API bridge for Tauri desktop
8
- getApiKey?: (provider: string) => Promise<string | null>
9
- setApiKey?: (provider: string, key: string) => Promise<void>
10
- deleteApiKey?: (provider: string) => Promise<void>
11
- callProviderApi?: (
12
- url: string,
13
- method: string,
14
- headers: Record<string, string>,
15
- body: any
16
- ) => Promise<{ status: number; data: any }>
17
- onMenuCommand: (callback: (command: string) => void) => () => void
18
- // Native PDF export (Windows only — WebView2 PrintToPdf). Absent on other
19
- // platforms; callers must feature-detect before use.
20
- showSavePdfDialog?: (defaultName: string) => Promise<string | null>
21
- exportScreenplayToPdf?: (html: string, path: string) => Promise<void>
22
- // Disk-backed autosave (format spec §3.2: IndexedDB on web, disk on
23
- // desktop). local-storage-adapter.ts prefers these over IndexedDB when present.
24
- autosaveWrite?: (key: string, content: string) => Promise<void>
25
- autosaveRead?: (key: string) => Promise<string | null>
26
- // Native window-close guard: beforeunload isn't reliably fired by a native
27
- // close (vs. an in-page reload) across WRY's backing webviews, so the Rust
28
- // side intercepts CloseRequested and asks the frontend via this event
29
- // instead of closing immediately.
30
- onCloseRequested?: (callback: () => void) => () => void
31
- closeWindow?: () => Promise<void>
32
- }
33
-
34
- declare global {
35
- interface Window {
36
- desktopAPI?: DesktopAPI
37
- }
38
- }
39
-
40
- export {}