@sutrata/editor 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
- package/dist/ai/VoiceConfirmDialog.js +6 -6
- package/dist/ai/VoiceToolbar.d.ts +1 -0
- package/dist/ai/VoiceToolbar.js +22 -16
- package/dist/ai/clean-response.d.ts +7 -0
- package/dist/ai/clean-response.js +58 -0
- package/dist/ai/format-transcript.d.ts +3 -0
- package/dist/ai/format-transcript.js +7 -0
- package/dist/ai/prompts.d.ts +8 -1
- package/dist/ai/prompts.js +34 -7
- package/dist/autocomplete/suggestions.js +1 -1
- package/dist/context/DocumentContext.d.ts +34 -5
- package/dist/context/DocumentContext.js +152 -51
- package/dist/editor/EditorView.js +35 -5
- package/dist/editor/SelectionMenu.js +5 -3
- package/dist/editor/editor-bus.d.ts +3 -0
- package/dist/editor/editor-bus.js +13 -0
- package/dist/editor/external-change.d.ts +11 -0
- package/dist/editor/external-change.js +105 -0
- package/dist/editor/external-decorations-plugin.d.ts +23 -0
- package/dist/editor/external-decorations-plugin.js +151 -0
- package/dist/editor/frontmatter-field-view.js +3 -0
- package/dist/editor/omitted-scene-plugin.d.ts +9 -0
- package/dist/editor/omitted-scene-plugin.js +44 -0
- package/dist/editor/plugins.js +4 -0
- package/dist/editor/private-metadata-plugin.d.ts +13 -0
- package/dist/editor/private-metadata-plugin.js +100 -0
- package/dist/editor/prosemirror-to-sutra.js +179 -74
- package/dist/editor/scene-heading-view.js +2 -0
- package/dist/editor/schema.js +74 -13
- package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
- package/dist/editor/sutra-to-prosemirror.js +32 -34
- package/dist/extensions/ai-provider.d.ts +44 -0
- package/dist/extensions/ai-provider.js +27 -0
- package/dist/extensions/collab.d.ts +21 -0
- package/dist/extensions/collab.js +6 -0
- package/dist/extensions/command-registry.d.ts +49 -0
- package/dist/extensions/command-registry.js +28 -0
- package/dist/extensions/decorations.d.ts +48 -0
- package/dist/extensions/decorations.js +6 -0
- package/dist/extensions/export-registry.d.ts +59 -0
- package/dist/extensions/export-registry.js +10 -0
- package/dist/extensions/panel-registry.d.ts +30 -0
- package/dist/extensions/panel-registry.js +16 -0
- package/dist/extensions/registries.d.ts +15 -0
- package/dist/extensions/registries.js +12 -0
- package/dist/extensions/registry-store.d.ts +26 -0
- package/dist/extensions/registry-store.js +35 -0
- package/dist/extensions/session.d.ts +32 -0
- package/dist/extensions/session.js +20 -0
- package/dist/extensions/speech-provider.d.ts +35 -0
- package/dist/extensions/speech-provider.js +17 -0
- package/dist/extensions/storage-adapter.d.ts +36 -1
- package/dist/file/builtin-exporters.d.ts +9 -0
- package/dist/file/builtin-exporters.js +72 -0
- package/dist/file/docx-exporter.js +7 -4
- package/dist/file/docx-importer.d.ts +9 -0
- package/dist/file/docx-importer.js +47 -19
- package/dist/file/scene-number.d.ts +5 -0
- package/dist/file/scene-number.js +8 -0
- package/dist/file/workflow-reports.js +13 -7
- package/dist/findreplace/FindReplace.js +5 -2
- package/dist/import/AIImportDialog.d.ts +11 -0
- package/dist/import/AIImportDialog.js +112 -0
- package/dist/import/ai-import.d.ts +51 -0
- package/dist/import/ai-import.js +200 -0
- package/dist/import/apply-import.d.ts +7 -0
- package/dist/import/apply-import.js +35 -0
- package/dist/import/extract.d.ts +17 -0
- package/dist/import/extract.js +18 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +7 -0
- package/dist/locales/bn.json +8 -0
- package/dist/locales/en.json +8 -0
- package/dist/locales/gu.json +8 -0
- package/dist/locales/hi.json +8 -0
- package/dist/locales/kn.json +8 -0
- package/dist/locales/ml.json +8 -0
- package/dist/locales/mr.json +8 -0
- package/dist/locales/or.json +8 -0
- package/dist/locales/pa.json +8 -0
- package/dist/locales/si.json +8 -0
- package/dist/locales/ta.json +8 -0
- package/dist/locales/te.json +8 -0
- package/dist/navigator/SceneNavigator.js +125 -131
- package/dist/navigator/StatisticsDialog.js +5 -1
- package/dist/navigator/scene-edits.d.ts +18 -0
- package/dist/navigator/scene-edits.js +143 -0
- package/dist/navigator/scene-list.js +4 -4
- package/dist/navigator/scene-numbering.d.ts +37 -0
- package/dist/navigator/scene-numbering.js +88 -0
- package/dist/settings/SettingsDialog.js +8 -120
- package/dist/shell/AppBar.js +25 -4
- package/dist/shell/AppShell.js +18 -4
- package/dist/shell/ElementToolbar.d.ts +5 -0
- package/dist/shell/ElementToolbar.js +35 -25
- package/dist/shell/LanguageStatus.js +6 -0
- package/dist/shell/StatusBar.js +11 -6
- package/dist/shell/builtin-commands.d.ts +10 -0
- package/dist/shell/builtin-commands.js +43 -0
- package/dist/shell/icons.d.ts +6 -0
- package/dist/shell/icons.js +6 -0
- package/dist/shell/use-commands.d.ts +7 -0
- package/dist/shell/use-commands.js +24 -0
- package/dist/shell/use-panel-context.d.ts +3 -0
- package/dist/shell/use-panel-context.js +39 -0
- package/dist/source/SourceView.js +39 -3
- package/dist/styles/screenplay.css +114 -1
- package/dist/titlepage/ExportDialog.d.ts +5 -0
- package/dist/titlepage/ExportDialog.js +59 -96
- package/dist/titlepage/frontmatter-form.js +1 -1
- package/package.json +4 -4
- package/dist/ai/OnboardingDialog.d.ts +0 -6
- package/dist/ai/OnboardingDialog.js +0 -115
- package/dist/ai/ai-client.d.ts +0 -71
- package/dist/ai/ai-client.js +0 -494
- package/dist/ai/generated-providers.json +0 -3696
- package/dist/ai/voice-service.d.ts +0 -15
- package/dist/ai/voice-service.js +0 -81
- package/dist/editor/character-view.d.ts +0 -35
- package/dist/editor/character-view.js +0 -112
- package/dist/editor/transliterate-input.d.ts +0 -10
- package/dist/editor/transliterate-input.js +0 -88
- package/dist/extensions/types.d.ts +0 -73
- package/dist/extensions/types.js +0 -12
|
@@ -2,10 +2,22 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { parse, importFountain, fountainDocToSutra } from '@sutrata/parser';
|
|
4
4
|
import { StorageAdapterProvider } from '../extensions/storage-adapter';
|
|
5
|
+
import { AIProviderProvider } from '../extensions/ai-provider';
|
|
6
|
+
import { SpeechProviderProvider } from '../extensions/speech-provider';
|
|
7
|
+
import { SessionProvider, DEFAULT_SESSION, isFeatureEnabled } from '../extensions/session';
|
|
8
|
+
import { PanelRegistryProvider, createPanelRegistry } from '../extensions/panel-registry';
|
|
9
|
+
import { CommandRegistryProvider, createCommandRegistry, matchesShortcut } from '../extensions/command-registry';
|
|
10
|
+
import { ExportRegistryProvider, createExportRegistry } from '../extensions/export-registry';
|
|
11
|
+
import { withBuiltins } from '../extensions/registry-store';
|
|
12
|
+
import { DecorationProvidersProvider } from '../extensions/decorations';
|
|
13
|
+
import { CollabBindingProvider } from '../extensions/collab';
|
|
5
14
|
import { importDocx as importDocxToSutra } from '../file/docx-importer';
|
|
6
|
-
import {
|
|
15
|
+
import { getEditorView, getSourceView, markExternalText } from '../editor/editor-bus';
|
|
16
|
+
import { externalChange } from '../editor/external-change';
|
|
17
|
+
import { sutraToProsemirror } from '../editor/sutra-to-prosemirror';
|
|
18
|
+
import { prosemirrorToSutra } from '../editor/prosemirror-to-sutra';
|
|
7
19
|
import { setFlatFrontmatterField } from '../editor/frontmatter-field';
|
|
8
|
-
import {
|
|
20
|
+
import { builtinCommands } from '../shell/builtin-commands';
|
|
9
21
|
import { resolveStyle } from '../styles/registry';
|
|
10
22
|
const DocumentContext = createContext(null);
|
|
11
23
|
const EMPTY_DOC = '## Scene 1\n\nAction.\n';
|
|
@@ -48,11 +60,30 @@ function clearRecoveryPointer() {
|
|
|
48
60
|
// ignore
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
|
-
|
|
63
|
+
const NO_DECORATIONS = [];
|
|
64
|
+
/** A registry prop as a registry: a fixed list becomes a registry of its own. */
|
|
65
|
+
function useRegistryProp(value, create) {
|
|
66
|
+
return useMemo(() => (Array.isArray(value) || !value ? create(value ?? []) : value), [value]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
67
|
+
}
|
|
68
|
+
export function DocumentProvider({ children, storageAdapter, aiProvider, speechProvider, session, panels, commands, exporters, decorationProviders, collabBinding, }) {
|
|
69
|
+
const panelRegistry = useRegistryProp(panels, createPanelRegistry);
|
|
70
|
+
const commandRegistry = useRegistryProp(commands, createCommandRegistry);
|
|
71
|
+
const exportRegistry = useRegistryProp(exporters, createExportRegistry);
|
|
52
72
|
const [text, setTextState] = useState(EMPTY_DOC);
|
|
53
73
|
const [ast, setAst] = useState(() => parse(EMPTY_DOC));
|
|
54
74
|
const [mode, setMode] = useState('formatted');
|
|
55
|
-
const [filePath,
|
|
75
|
+
const [filePath, setFilePathState] = useState(null);
|
|
76
|
+
// Updated at once (like textRef), so a save right after setFilePath, even
|
|
77
|
+
// from a child's effect in the same commit, goes to the new path.
|
|
78
|
+
const filePathRef = useRef(filePath);
|
|
79
|
+
// Mirrored in render, not in an effect: a parent's effect runs after its
|
|
80
|
+
// children's, and would overwrite a newer path set by a child's effect.
|
|
81
|
+
filePathRef.current = filePath;
|
|
82
|
+
const setFilePath = useCallback((path) => {
|
|
83
|
+
filePathRef.current = path;
|
|
84
|
+
setFilePathState(path);
|
|
85
|
+
}, []);
|
|
86
|
+
const fileDisplayName = filePath ? storageAdapter.displayName?.(filePath) ?? filePath : null;
|
|
56
87
|
const [isDirty, setIsDirty] = useState(false);
|
|
57
88
|
// Where the most recent save attempt went. isDirty tracks "does this differ
|
|
58
89
|
// from the real file on disk" (VS Code-style) — a local autosave backs the
|
|
@@ -64,7 +95,6 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
64
95
|
const [navVisible, setNavVisible] = useState(() => window.innerWidth > 640);
|
|
65
96
|
const [exportVisible, setExportVisible] = useState(false);
|
|
66
97
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
|
67
|
-
const [aiOnboardingVisible, setAIOnboardingVisible] = useState(false);
|
|
68
98
|
const [voiceActive, setVoiceActive] = useState(false);
|
|
69
99
|
const [metadataVisible, setMetadataVisible] = useState(true);
|
|
70
100
|
const [findReplaceVisible, setFindReplaceVisible] = useState(false);
|
|
@@ -112,10 +142,15 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
112
142
|
}
|
|
113
143
|
}, [storageAdapter]);
|
|
114
144
|
useEffect(() => { void reloadCustomStyles(); }, [reloadCustomStyles]);
|
|
145
|
+
// `localFiles: false` (SessionContext): the embedder's documents are not local
|
|
146
|
+
// files, so there is no file to open or save as, and no local-session recovery
|
|
147
|
+
// (the embedder opens its documents and keeps its own offline copy).
|
|
148
|
+
const localFilesRef = useRef(true);
|
|
149
|
+
localFilesRef.current = isFeatureEnabled(session ?? DEFAULT_SESSION, 'localFiles');
|
|
115
150
|
// Recover autosaved-but-unsaved content on boot (Ctrl-R, a crash, or the tab closing
|
|
116
151
|
// without an explicit save) instead of always starting from a blank document.
|
|
117
152
|
useEffect(() => {
|
|
118
|
-
const pointer = readRecoveryPointer();
|
|
153
|
+
const pointer = localFilesRef.current ? readRecoveryPointer() : null;
|
|
119
154
|
if (!pointer)
|
|
120
155
|
return;
|
|
121
156
|
let cancelled = false;
|
|
@@ -143,10 +178,18 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
143
178
|
})();
|
|
144
179
|
return () => { cancelled = true; };
|
|
145
180
|
}, [storageAdapter, showToast]);
|
|
146
|
-
|
|
181
|
+
// Refs so the autosave path always writes the latest text/filePath without
|
|
182
|
+
// needing them in a dependency array that would tear down the timers below
|
|
183
|
+
// on every keystroke. setText also updates textRef at once, so a save that
|
|
184
|
+
// is waiting on storage can tell whether the user typed in the meantime.
|
|
185
|
+
const textRef = useRef(text);
|
|
186
|
+
textRef.current = text; // in render, for the same reason as filePathRef
|
|
187
|
+
const setText = useCallback((next, options) => {
|
|
188
|
+
textRef.current = next;
|
|
147
189
|
setTextState(next);
|
|
148
190
|
setAst(parse(next));
|
|
149
|
-
|
|
191
|
+
if (options?.markDirty !== false)
|
|
192
|
+
setDirty(true);
|
|
150
193
|
}, []);
|
|
151
194
|
const frontmatterData = ast.frontmatter?.data;
|
|
152
195
|
const documentStyleId = frontmatterData?.['style'];
|
|
@@ -157,19 +200,48 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
157
200
|
setFlatFrontmatterField(getEditorView(), text, setText, 'style', id);
|
|
158
201
|
}, [text, setText]);
|
|
159
202
|
const markClean = useCallback(() => setDirty(false), []);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// on every keystroke.
|
|
163
|
-
const textRef = useRef(text);
|
|
164
|
-
useEffect(() => { textRef.current = text; }, [text]);
|
|
165
|
-
const filePathRef = useRef(filePath);
|
|
166
|
-
useEffect(() => { filePathRef.current = filePath; }, [filePath]);
|
|
203
|
+
const modeRef = useRef(mode);
|
|
204
|
+
modeRef.current = mode;
|
|
167
205
|
const autosaveFailedRef = useRef(false);
|
|
206
|
+
// The text last written to or received from storage, for filePath.
|
|
207
|
+
const syncedRef = useRef(null);
|
|
208
|
+
/**
|
|
209
|
+
* Applies a document from storage (a SaveResult or a remote update, OSS spec
|
|
210
|
+
* §11.5): one change, kept out of undo history, not a user edit. In the
|
|
211
|
+
* formatted editor only the blocks that differ are replaced.
|
|
212
|
+
*/
|
|
213
|
+
const applyExternalText = useCallback((path, next) => {
|
|
214
|
+
const view = getEditorView();
|
|
215
|
+
if (modeRef.current === 'formatted' && view) {
|
|
216
|
+
const tr = externalChange(view.state, sutraToProsemirror(parse(next)));
|
|
217
|
+
if (tr)
|
|
218
|
+
view.dispatch(tr);
|
|
219
|
+
// The editor writes its own serialization of `next`; that is what is synced.
|
|
220
|
+
syncedRef.current = { path, text: prosemirrorToSutra(view.state.doc) };
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
markExternalText(next);
|
|
224
|
+
setText(next, { markDirty: false });
|
|
225
|
+
syncedRef.current = { path, text: next };
|
|
226
|
+
}
|
|
227
|
+
}, [setText]);
|
|
168
228
|
const save = useCallback(async () => {
|
|
169
229
|
const path = filePathRef.current ?? '__autosave__';
|
|
230
|
+
const sent = textRef.current;
|
|
231
|
+
if (syncedRef.current?.path === path && syncedRef.current.text === sent)
|
|
232
|
+
return;
|
|
170
233
|
try {
|
|
171
|
-
await storageAdapter.saveDocument(path,
|
|
172
|
-
|
|
234
|
+
const result = await storageAdapter.saveDocument(path, sent);
|
|
235
|
+
syncedRef.current = { path, text: sent };
|
|
236
|
+
const content = result ? result.content : undefined;
|
|
237
|
+
// Only if nothing was typed while the save was in flight; otherwise the
|
|
238
|
+
// next save carries those edits and the adapter merges again.
|
|
239
|
+
const samePath = (filePathRef.current ?? '__autosave__') === path;
|
|
240
|
+
if (content !== undefined && content !== sent && textRef.current === sent && samePath) {
|
|
241
|
+
applyExternalText(path, content);
|
|
242
|
+
}
|
|
243
|
+
if (localFilesRef.current)
|
|
244
|
+
writeRecoveryPointer(path);
|
|
173
245
|
setLastSaveTarget('local');
|
|
174
246
|
autosaveFailedRef.current = false;
|
|
175
247
|
// Deliberately NOT setDirty(false) here — this is a local backup, not a
|
|
@@ -189,7 +261,20 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
189
261
|
showToast('Autosave failed — your browser storage may be full or unavailable.', 'warn');
|
|
190
262
|
}
|
|
191
263
|
}
|
|
192
|
-
}, [storageAdapter, showToast]);
|
|
264
|
+
}, [storageAdapter, showToast, applyExternalText]);
|
|
265
|
+
// Changes saved elsewhere (OSS spec §11.5), applied only while there are no
|
|
266
|
+
// unsaved local edits; otherwise the next save merges them.
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!filePath || !storageAdapter.subscribe)
|
|
269
|
+
return;
|
|
270
|
+
return storageAdapter.subscribe(filePath, content => {
|
|
271
|
+
const synced = syncedRef.current;
|
|
272
|
+
if (filePathRef.current !== filePath || synced?.path !== filePath || textRef.current !== synced.text)
|
|
273
|
+
return;
|
|
274
|
+
if (content !== textRef.current)
|
|
275
|
+
applyExternalText(filePath, content);
|
|
276
|
+
});
|
|
277
|
+
}, [filePath, storageAdapter, applyExternalText]);
|
|
193
278
|
const newDocument = useCallback((initialContent) => {
|
|
194
279
|
const doNew = () => {
|
|
195
280
|
if (pauseTimerRef.current) {
|
|
@@ -224,6 +309,8 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
224
309
|
});
|
|
225
310
|
}, [isDirty, showConfirm]);
|
|
226
311
|
const openFile = useCallback(async () => {
|
|
312
|
+
if (!localFilesRef.current)
|
|
313
|
+
return;
|
|
227
314
|
const doOpen = async () => {
|
|
228
315
|
const result = await storageAdapter.openFile();
|
|
229
316
|
if (!result)
|
|
@@ -306,6 +393,8 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
306
393
|
}
|
|
307
394
|
}, [filePath, text, showToast, storageAdapter, refreshVersions]);
|
|
308
395
|
const saveFileAs = useCallback(async () => {
|
|
396
|
+
if (!localFilesRef.current)
|
|
397
|
+
return;
|
|
309
398
|
const name = filePath ?? 'untitled.sutra';
|
|
310
399
|
const result = await storageAdapter.saveFile(name, text, null);
|
|
311
400
|
if (result) {
|
|
@@ -441,10 +530,36 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
441
530
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
442
531
|
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
443
532
|
}, [isDirty]);
|
|
533
|
+
const canEditRef = useRef(true);
|
|
534
|
+
canEditRef.current = (session ?? DEFAULT_SESSION).permission === 'edit';
|
|
444
535
|
useEffect(() => {
|
|
445
536
|
const onKeyDown = (e) => {
|
|
446
537
|
const mod = e.ctrlKey || e.metaKey;
|
|
447
538
|
const cs = mod && e.shiftKey;
|
|
539
|
+
// Read-only sessions: only find, mode and panel toggles.
|
|
540
|
+
if (!canEditRef.current) {
|
|
541
|
+
if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyF') {
|
|
542
|
+
e.preventDefault();
|
|
543
|
+
setFindMode('find');
|
|
544
|
+
setFindReplaceVisible(v => !v);
|
|
545
|
+
}
|
|
546
|
+
else if (cs && !e.altKey && e.code === 'KeyE') {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
setMode(m => m === 'formatted' ? 'source' : 'formatted');
|
|
549
|
+
}
|
|
550
|
+
else if (cs && !e.altKey && e.code === 'KeyB') {
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
setNavVisible(v => !v);
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
const cmd = commandRegistry.list().find(c => c.readOnly && c.shortcut && matchesShortcut(c.shortcut, e));
|
|
556
|
+
if (cmd) {
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
cmd.run({ view: getEditorView(), sourceView: getSourceView(), mode: modeRef.current, text: textRef.current, setText });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
448
563
|
// File operations — use e.code for layout-independence
|
|
449
564
|
if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyS') {
|
|
450
565
|
e.preventDefault();
|
|
@@ -468,27 +583,13 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
468
583
|
setFindReplaceVisible(true);
|
|
469
584
|
return;
|
|
470
585
|
}
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const blockMap = {
|
|
479
|
-
'KeyH': ['scene_heading', { id: null }],
|
|
480
|
-
'KeyA': ['action'],
|
|
481
|
-
'KeyC': ['character', { extension: null, isDual: false }],
|
|
482
|
-
'KeyD': ['dialogue'],
|
|
483
|
-
'KeyP': ['parenthetical'],
|
|
484
|
-
'KeyX': ['transition'],
|
|
485
|
-
};
|
|
486
|
-
if (e.code in blockMap) {
|
|
487
|
-
e.preventDefault();
|
|
488
|
-
const [type, attrs] = blockMap[e.code];
|
|
489
|
-
runCommand(makeSetBlock(type, attrs ?? {}));
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
586
|
+
// Command shortcuts (built-in element types, embedder commands)
|
|
587
|
+
const cmd = withBuiltins(builtinCommands(k => k), commandRegistry.list())
|
|
588
|
+
.find(c => c.shortcut && matchesShortcut(c.shortcut, e));
|
|
589
|
+
if (cmd) {
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
cmd.run({ view: getEditorView(), sourceView: getSourceView(), mode: modeRef.current, text: textRef.current, setText });
|
|
592
|
+
return;
|
|
492
593
|
}
|
|
493
594
|
// UI toggles Ctrl+Shift+letter
|
|
494
595
|
if (cs && !e.altKey) {
|
|
@@ -511,7 +612,7 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
511
612
|
};
|
|
512
613
|
window.addEventListener('keydown', onKeyDown);
|
|
513
614
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
514
|
-
}, [saveFile, openFile]);
|
|
615
|
+
}, [saveFile, openFile, commandRegistry, setText]);
|
|
515
616
|
// Native menu IPC (Desktop wrapper only) — receives commands from the main process menu
|
|
516
617
|
useEffect(() => {
|
|
517
618
|
const api = window.desktopAPI;
|
|
@@ -585,17 +686,17 @@ export function DocumentProvider({ children, storageAdapter }) {
|
|
|
585
686
|
});
|
|
586
687
|
return off;
|
|
587
688
|
}, [saveFile, showConfirm]);
|
|
588
|
-
return (_jsx(StorageAdapterProvider, { value: storageAdapter, children: _jsx(DocumentContext.Provider, { value: {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
689
|
+
return (_jsx(StorageAdapterProvider, { value: storageAdapter, children: _jsx(SessionProvider, { value: session ?? DEFAULT_SESSION, children: _jsx(AIProviderProvider, { value: aiProvider ?? null, children: _jsx(SpeechProviderProvider, { value: speechProvider ?? null, children: _jsx(PanelRegistryProvider, { value: panelRegistry, children: _jsx(CommandRegistryProvider, { value: commandRegistry, children: _jsx(ExportRegistryProvider, { value: exportRegistry, children: _jsx(DecorationProvidersProvider, { value: decorationProviders ?? NO_DECORATIONS, children: _jsx(CollabBindingProvider, { value: collabBinding ?? null, children: _jsx(DocumentContext.Provider, { value: {
|
|
690
|
+
text, ast, mode, filePath, fileDisplayName, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
|
|
691
|
+
voiceActive, metadataVisible,
|
|
692
|
+
findReplaceVisible, findMode, styleVisible, customStyles, resolvedStyle, watermarkText,
|
|
693
|
+
confirmModal, toasts, versions, versionHistoryVisible,
|
|
694
|
+
setText, setMode, setFilePath, setRibbonVisible, setNavVisible, setExportVisible, setSettingsVisible,
|
|
695
|
+
setVoiceActive, setMetadataVisible,
|
|
696
|
+
setFindReplaceVisible, setFindMode, setStyleVisible, setVersionHistoryVisible, setDocumentStyle, reloadCustomStyles,
|
|
697
|
+
markClean, save, newDocument, openFile, saveFile, saveFileAs, importDocx, restoreVersion, clearVersionHistory,
|
|
698
|
+
showConfirm, closeConfirm, showToast, dismissToast,
|
|
699
|
+
}, children: children }) }) }) }) }) }) }) }) }) }));
|
|
599
700
|
}
|
|
600
701
|
export function useDocument() {
|
|
601
702
|
const ctx = useContext(DocumentContext);
|
|
@@ -12,8 +12,29 @@ import { useDocument } from '../context/DocumentContext';
|
|
|
12
12
|
import { SceneHeadingView } from './scene-heading-view';
|
|
13
13
|
import { FrontmatterFieldView } from './frontmatter-field-view';
|
|
14
14
|
import { applyStyleVars } from '../styles/css-adapter';
|
|
15
|
+
import { useCanEdit } from '../extensions/session';
|
|
16
|
+
import { useDecorationProviders } from '../extensions/decorations';
|
|
17
|
+
import { useCollabBinding } from '../extensions/collab';
|
|
18
|
+
import { createExternalDecorationsPlugin } from './external-decorations-plugin';
|
|
19
|
+
import { EXTERNAL_CHANGE } from './external-change';
|
|
15
20
|
export function EditorView() {
|
|
16
|
-
const { text, setText, resolvedStyle } = useDocument();
|
|
21
|
+
const { text, ast, setText, resolvedStyle } = useDocument();
|
|
22
|
+
const astRef = useRef(ast);
|
|
23
|
+
astRef.current = ast;
|
|
24
|
+
// Embedder decorations and collab plugins join the built-in ones every time
|
|
25
|
+
// the state is (re)created. Both are read once per state: pass stable values.
|
|
26
|
+
const decorationProviders = useDecorationProviders();
|
|
27
|
+
const collab = useCollabBinding();
|
|
28
|
+
const plugins = () => [
|
|
29
|
+
...buildPlugins(),
|
|
30
|
+
...(decorationProviders.length > 0 ? [createExternalDecorationsPlugin(decorationProviders, () => astRef.current)] : []),
|
|
31
|
+
...(collab ? collab.plugins(schema) : []),
|
|
32
|
+
];
|
|
33
|
+
// Read-only sessions: not editable, and every doc-changing transaction —
|
|
34
|
+
// typed, or dispatched by a toolbar/navigator/node view — is dropped here.
|
|
35
|
+
const canEdit = useCanEdit();
|
|
36
|
+
const canEditRef = useRef(canEdit);
|
|
37
|
+
canEditRef.current = canEdit;
|
|
17
38
|
const mountRef = useRef(null);
|
|
18
39
|
const viewRef = useRef(null);
|
|
19
40
|
const textRef = useRef(text);
|
|
@@ -26,21 +47,25 @@ export function EditorView() {
|
|
|
26
47
|
if (!mountRef.current)
|
|
27
48
|
return;
|
|
28
49
|
const doc = sutraToProsemirror(parse(textRef.current));
|
|
29
|
-
const state = EditorState.create({ doc, schema, plugins:
|
|
50
|
+
const state = EditorState.create({ doc, schema, plugins: plugins() });
|
|
30
51
|
const view = new PmEditorView(mountRef.current, {
|
|
31
52
|
state,
|
|
32
53
|
nodeViews: {
|
|
33
54
|
scene_heading: (node, view, getPos) => new SceneHeadingView(node, view, getPos),
|
|
34
55
|
frontmatter_field: (node, view, getPos) => new FrontmatterFieldView(node, view, getPos),
|
|
35
56
|
},
|
|
57
|
+
editable: () => canEditRef.current,
|
|
36
58
|
dispatchTransaction(tr) {
|
|
59
|
+
if (tr.docChanged && !canEditRef.current && !tr.getMeta(EXTERNAL_CHANGE))
|
|
60
|
+
return;
|
|
37
61
|
const newState = view.state.apply(tr);
|
|
38
62
|
view.updateState(newState);
|
|
39
63
|
if (tr.docChanged) {
|
|
40
64
|
const newText = prosemirrorToSutra(newState.doc);
|
|
41
65
|
if (newText !== textRef.current) {
|
|
42
66
|
selfChangeRef.current = true;
|
|
43
|
-
|
|
67
|
+
// A change applied from storage is not a user edit.
|
|
68
|
+
setText(newText, tr.getMeta(EXTERNAL_CHANGE) ? { markDirty: false } : undefined);
|
|
44
69
|
}
|
|
45
70
|
}
|
|
46
71
|
emit();
|
|
@@ -48,7 +73,8 @@ export function EditorView() {
|
|
|
48
73
|
});
|
|
49
74
|
viewRef.current = view;
|
|
50
75
|
setEditorView(view);
|
|
51
|
-
|
|
76
|
+
const detachCollab = collab?.attach(view);
|
|
77
|
+
return () => { detachCollab?.(); setEditorView(null); view.destroy(); viewRef.current = null; };
|
|
52
78
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
53
79
|
// Sync external text changes into ProseMirror (e.g. switching from source mode).
|
|
54
80
|
// Skip when the editor itself produced the change — it already has the right state.
|
|
@@ -63,10 +89,14 @@ export function EditorView() {
|
|
|
63
89
|
const currentText = prosemirrorToSutra(view.state.doc);
|
|
64
90
|
if (currentText !== text) {
|
|
65
91
|
const doc = sutraToProsemirror(parse(text));
|
|
66
|
-
const state = EditorState.create({ doc, schema, plugins:
|
|
92
|
+
const state = EditorState.create({ doc, schema, plugins: plugins() });
|
|
67
93
|
view.updateState(state);
|
|
68
94
|
}
|
|
69
95
|
}, [text]);
|
|
96
|
+
// Re-evaluate `editable` when the session's permission changes.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
viewRef.current?.setProps({});
|
|
99
|
+
}, [canEdit]);
|
|
70
100
|
// Apply the document's resolved style as CSS custom properties on the mount element.
|
|
71
101
|
// Works identically for built-in and user-imported styles — both are plain
|
|
72
102
|
// ScreenplayStyleDefinition objects by the time they reach here.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { useLanguage } from '../i18n/LanguageContext';
|
|
4
4
|
import { useDocument } from '../context/DocumentContext';
|
|
5
5
|
import { insertSutra } from './insert-helper';
|
|
6
6
|
import { VoiceConfirmDialog } from '../ai/VoiceConfirmDialog';
|
|
7
7
|
import { SparklesIcon } from '../shell/icons';
|
|
8
|
+
import { useAI } from '../extensions/ai-provider';
|
|
8
9
|
const VOICE_LANG_MAP = {
|
|
9
10
|
en: 'en-US',
|
|
10
11
|
hi: 'hi-IN',
|
|
@@ -34,6 +35,7 @@ function isSelectionInEditor(sel) {
|
|
|
34
35
|
export function SelectionMenu() {
|
|
35
36
|
const { currentLanguage } = useLanguage();
|
|
36
37
|
const { mode } = useDocument();
|
|
38
|
+
const ai = useAI();
|
|
37
39
|
const [menuPosition, setMenuPosition] = useState(null);
|
|
38
40
|
const [selectedText, setSelectedText] = useState('');
|
|
39
41
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
@@ -84,7 +86,7 @@ export function SelectionMenu() {
|
|
|
84
86
|
setShowConfirm(true);
|
|
85
87
|
setMenuPosition(null);
|
|
86
88
|
};
|
|
87
|
-
if (showConfirm) {
|
|
89
|
+
if (showConfirm && ai) {
|
|
88
90
|
const langCode = VOICE_LANG_MAP[currentLanguage] || 'en-US';
|
|
89
91
|
return (_jsx(VoiceConfirmDialog, { rawTranscript: selectedText, langCode: langCode, onConfirm: (formattedText) => {
|
|
90
92
|
insertSutra(formattedText, mode);
|
|
@@ -98,5 +100,5 @@ export function SelectionMenu() {
|
|
|
98
100
|
return (_jsxs("div", { className: "cs-selection-menu", style: {
|
|
99
101
|
left: `${menuPosition.x}px`,
|
|
100
102
|
top: `${menuPosition.y}px`,
|
|
101
|
-
}, role: "toolbar", "aria-label": "Text Selection Actions", children: [_jsxs("button", { type: "button", className: "cs-selection-btn cs-selection-btn-format", onClick: handleFormat, title: "Format selected text using AI", children: [_jsx(SparklesIcon, { size: 12 }), _jsx("span", { children: "Format" })] }), _jsx("div", { className: "cs-selection-sep" }), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCut, children: "Cut" }), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCopy, children: "Copy" })] }));
|
|
103
|
+
}, role: "toolbar", "aria-label": "Text Selection Actions", children: [ai && (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "cs-selection-btn cs-selection-btn-format", onClick: handleFormat, title: "Format selected text using AI", children: [_jsx(SparklesIcon, { size: 12 }), _jsx("span", { children: "Format" })] }), _jsx("div", { className: "cs-selection-sep" })] })), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCut, children: "Cut" }), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCopy, children: "Copy" })] }));
|
|
102
104
|
}
|
|
@@ -12,3 +12,6 @@ export declare function emit(): void;
|
|
|
12
12
|
/** Window event that opens the title page form (TitlePageDialog, mounted by AppShell). */
|
|
13
13
|
export declare const OPEN_TITLE_PAGE_EVENT = "cs:open-title-page";
|
|
14
14
|
export declare function openTitlePageForm(): void;
|
|
15
|
+
export declare function markExternalText(text: string): void;
|
|
16
|
+
/** True, once, if `text` is the one marked by markExternalText. */
|
|
17
|
+
export declare function takeExternalText(text: string): boolean;
|
|
@@ -37,3 +37,16 @@ export const OPEN_TITLE_PAGE_EVENT = 'cs:open-title-page';
|
|
|
37
37
|
export function openTitlePageForm() {
|
|
38
38
|
window.dispatchEvent(new CustomEvent(OPEN_TITLE_PAGE_EVENT));
|
|
39
39
|
}
|
|
40
|
+
// Text the document context is applying from storage (a SaveResult or a
|
|
41
|
+
// remote update). The source view applies it outside its undo history.
|
|
42
|
+
let externalText = null;
|
|
43
|
+
export function markExternalText(text) {
|
|
44
|
+
externalText = text;
|
|
45
|
+
}
|
|
46
|
+
/** True, once, if `text` is the one marked by markExternalText. */
|
|
47
|
+
export function takeExternalText(text) {
|
|
48
|
+
const hit = externalText === text;
|
|
49
|
+
if (hit)
|
|
50
|
+
externalText = null;
|
|
51
|
+
return hit;
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Node } from 'prosemirror-model';
|
|
2
|
+
import type { EditorState, Transaction } from 'prosemirror-state';
|
|
3
|
+
/** Transaction meta marking a change that came from storage, not the user. */
|
|
4
|
+
export declare const EXTERNAL_CHANGE = "sutrataExternalChange";
|
|
5
|
+
/**
|
|
6
|
+
* A transaction turning `state.doc` into `next` that replaces only the
|
|
7
|
+
* top-level blocks that differ (a block whose only change is its attributes
|
|
8
|
+
* keeps its content), so the selection in untouched blocks stays where it is.
|
|
9
|
+
* Kept out of undo history and marked EXTERNAL_CHANGE. Null if they are equal.
|
|
10
|
+
*/
|
|
11
|
+
export declare function externalChange(state: EditorState, next: Node): Transaction | null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Transaction meta marking a change that came from storage, not the user. */
|
|
2
|
+
export const EXTERNAL_CHANGE = 'sutrataExternalChange';
|
|
3
|
+
/** Above this many block pairs the diff gives up and replaces the whole changed middle. */
|
|
4
|
+
const MAX_DIFF_CELLS = 4_000_000;
|
|
5
|
+
/**
|
|
6
|
+
* A transaction turning `state.doc` into `next` that replaces only the
|
|
7
|
+
* top-level blocks that differ (a block whose only change is its attributes
|
|
8
|
+
* keeps its content), so the selection in untouched blocks stays where it is.
|
|
9
|
+
* Kept out of undo history and marked EXTERNAL_CHANGE. Null if they are equal.
|
|
10
|
+
*/
|
|
11
|
+
export function externalChange(state, next) {
|
|
12
|
+
if (state.doc.eq(next))
|
|
13
|
+
return null;
|
|
14
|
+
const a = children(state.doc);
|
|
15
|
+
const b = children(next);
|
|
16
|
+
let start = 0;
|
|
17
|
+
while (start < a.length && start < b.length && a[start].eq(b[start]))
|
|
18
|
+
start++;
|
|
19
|
+
let endA = a.length;
|
|
20
|
+
let endB = b.length;
|
|
21
|
+
while (endA > start && endB > start && a[endA - 1].eq(b[endB - 1])) {
|
|
22
|
+
endA--;
|
|
23
|
+
endB--;
|
|
24
|
+
}
|
|
25
|
+
const runs = diffRuns(a, b, start, endA, start, endB);
|
|
26
|
+
const posA = offsets(a);
|
|
27
|
+
const posB = offsets(b);
|
|
28
|
+
const tr = state.tr;
|
|
29
|
+
// Last run first, so earlier positions stay valid.
|
|
30
|
+
for (const r of runs.reverse()) {
|
|
31
|
+
const one = r.aTo - r.aFrom === 1 && r.bTo - r.bFrom === 1;
|
|
32
|
+
const x = a[r.aFrom];
|
|
33
|
+
const y = b[r.bFrom];
|
|
34
|
+
if (one && x && y && x.type === y.type && x.content.eq(y.content) && x.sameMarkup(y) === false &&
|
|
35
|
+
y.type.validContent(x.content)) {
|
|
36
|
+
tr.setNodeMarkup(posA[r.aFrom], undefined, y.attrs, y.marks);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
tr.replace(posA[r.aFrom], posA[r.aTo], next.slice(posB[r.bFrom], posB[r.bTo]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return tr.setMeta('addToHistory', false).setMeta(EXTERNAL_CHANGE, true);
|
|
43
|
+
}
|
|
44
|
+
function children(doc) {
|
|
45
|
+
const list = [];
|
|
46
|
+
doc.forEach(n => { list.push(n); });
|
|
47
|
+
return list;
|
|
48
|
+
}
|
|
49
|
+
/** Start position of each top-level child, plus the end of the content. */
|
|
50
|
+
function offsets(nodes) {
|
|
51
|
+
const pos = [0];
|
|
52
|
+
for (const n of nodes)
|
|
53
|
+
pos.push(pos.at(-1) + n.nodeSize);
|
|
54
|
+
return pos;
|
|
55
|
+
}
|
|
56
|
+
/** Runs of unmatched blocks between a[aStart..aEnd) and b[bStart..bEnd), by longest common subsequence. */
|
|
57
|
+
function diffRuns(a, b, aStart, aEnd, bStart, bEnd) {
|
|
58
|
+
const n = aEnd - aStart;
|
|
59
|
+
const m = bEnd - bStart;
|
|
60
|
+
if (n === 0 && m === 0)
|
|
61
|
+
return [];
|
|
62
|
+
if (n === 0 || m === 0 || n * m > MAX_DIFF_CELLS)
|
|
63
|
+
return [{ aFrom: aStart, aTo: aEnd, bFrom: bStart, bTo: bEnd }];
|
|
64
|
+
// lcs[i][j]: LCS length of a[aStart+i..aEnd) and b[bStart+j..bEnd).
|
|
65
|
+
const w = m + 1;
|
|
66
|
+
const lcs = new Uint32Array((n + 1) * w);
|
|
67
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
68
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
69
|
+
lcs[i * w + j] = a[aStart + i].eq(b[bStart + j])
|
|
70
|
+
? lcs[(i + 1) * w + j + 1] + 1
|
|
71
|
+
: Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const runs = [];
|
|
75
|
+
let run = null;
|
|
76
|
+
let i = 0;
|
|
77
|
+
let j = 0;
|
|
78
|
+
const extend = (di, dj) => {
|
|
79
|
+
if (!run)
|
|
80
|
+
run = { aFrom: aStart + i, aTo: aStart + i, bFrom: bStart + j, bTo: bStart + j };
|
|
81
|
+
run.aTo += di;
|
|
82
|
+
run.bTo += dj;
|
|
83
|
+
};
|
|
84
|
+
while (i < n || j < m) {
|
|
85
|
+
if (i < n && j < m && a[aStart + i].eq(b[bStart + j])) {
|
|
86
|
+
if (run) {
|
|
87
|
+
runs.push(run);
|
|
88
|
+
run = null;
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
else if (j < m && (i === n || lcs[i * w + j + 1] >= lcs[(i + 1) * w + j])) {
|
|
94
|
+
extend(0, 1);
|
|
95
|
+
j++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
extend(1, 0);
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (run)
|
|
103
|
+
runs.push(run);
|
|
104
|
+
return runs;
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
2
|
+
import { DecorationSet } from 'prosemirror-view';
|
|
3
|
+
import type { Node as PmNode } from 'prosemirror-model';
|
|
4
|
+
import type { DocumentNode } from '@sutrata/parser';
|
|
5
|
+
import type { DecorationProvider } from '../extensions/decorations';
|
|
6
|
+
/**
|
|
7
|
+
* Renders DecorationProvider output (extensions/decorations.ts) in the
|
|
8
|
+
* formatted editor. Anchors are { sceneId, from?, to? } with offsets into the
|
|
9
|
+
* scene's text; this plugin maps them to document positions, recomputes on
|
|
10
|
+
* every document change and whenever a provider signals a change, and routes
|
|
11
|
+
* clicks on decorated elements to the spec's onClick (in any session mode).
|
|
12
|
+
*/
|
|
13
|
+
export declare const externalDecorationsKey: PluginKey<DecorationSet>;
|
|
14
|
+
interface SceneRange {
|
|
15
|
+
from: number;
|
|
16
|
+
to: number;
|
|
17
|
+
}
|
|
18
|
+
/** Top-level position range of every scene with an id, heading through the block before the next heading. */
|
|
19
|
+
export declare function sceneRanges(doc: PmNode): Map<string, SceneRange>;
|
|
20
|
+
/** Scene text as anchors see it: blocks joined with '\n', line breaks as '\n'. */
|
|
21
|
+
export declare function textOf(doc: PmNode, range: SceneRange, to?: number): string;
|
|
22
|
+
export declare function createExternalDecorationsPlugin(providers: readonly DecorationProvider[], getAst: () => DocumentNode): Plugin<DecorationSet>;
|
|
23
|
+
export {};
|