@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,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
|
-
}
|
package/src/desktop-api.d.ts
DELETED
|
@@ -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 {}
|