@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.
Files changed (125) hide show
  1. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  2. package/dist/ai/VoiceConfirmDialog.js +6 -6
  3. package/dist/ai/VoiceToolbar.d.ts +1 -0
  4. package/dist/ai/VoiceToolbar.js +22 -16
  5. package/dist/ai/clean-response.d.ts +7 -0
  6. package/dist/ai/clean-response.js +58 -0
  7. package/dist/ai/format-transcript.d.ts +3 -0
  8. package/dist/ai/format-transcript.js +7 -0
  9. package/dist/ai/prompts.d.ts +8 -1
  10. package/dist/ai/prompts.js +34 -7
  11. package/dist/autocomplete/suggestions.js +1 -1
  12. package/dist/context/DocumentContext.d.ts +34 -5
  13. package/dist/context/DocumentContext.js +152 -51
  14. package/dist/editor/EditorView.js +35 -5
  15. package/dist/editor/SelectionMenu.js +5 -3
  16. package/dist/editor/editor-bus.d.ts +3 -0
  17. package/dist/editor/editor-bus.js +13 -0
  18. package/dist/editor/external-change.d.ts +11 -0
  19. package/dist/editor/external-change.js +105 -0
  20. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  21. package/dist/editor/external-decorations-plugin.js +151 -0
  22. package/dist/editor/frontmatter-field-view.js +3 -0
  23. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  24. package/dist/editor/omitted-scene-plugin.js +44 -0
  25. package/dist/editor/plugins.js +4 -0
  26. package/dist/editor/private-metadata-plugin.d.ts +13 -0
  27. package/dist/editor/private-metadata-plugin.js +100 -0
  28. package/dist/editor/prosemirror-to-sutra.js +179 -74
  29. package/dist/editor/scene-heading-view.js +2 -0
  30. package/dist/editor/schema.js +74 -13
  31. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  32. package/dist/editor/sutra-to-prosemirror.js +32 -34
  33. package/dist/extensions/ai-provider.d.ts +44 -0
  34. package/dist/extensions/ai-provider.js +27 -0
  35. package/dist/extensions/collab.d.ts +21 -0
  36. package/dist/extensions/collab.js +6 -0
  37. package/dist/extensions/command-registry.d.ts +49 -0
  38. package/dist/extensions/command-registry.js +28 -0
  39. package/dist/extensions/decorations.d.ts +48 -0
  40. package/dist/extensions/decorations.js +6 -0
  41. package/dist/extensions/export-registry.d.ts +59 -0
  42. package/dist/extensions/export-registry.js +10 -0
  43. package/dist/extensions/panel-registry.d.ts +30 -0
  44. package/dist/extensions/panel-registry.js +16 -0
  45. package/dist/extensions/registries.d.ts +15 -0
  46. package/dist/extensions/registries.js +12 -0
  47. package/dist/extensions/registry-store.d.ts +26 -0
  48. package/dist/extensions/registry-store.js +35 -0
  49. package/dist/extensions/session.d.ts +32 -0
  50. package/dist/extensions/session.js +20 -0
  51. package/dist/extensions/speech-provider.d.ts +35 -0
  52. package/dist/extensions/speech-provider.js +17 -0
  53. package/dist/extensions/storage-adapter.d.ts +36 -1
  54. package/dist/file/builtin-exporters.d.ts +9 -0
  55. package/dist/file/builtin-exporters.js +72 -0
  56. package/dist/file/docx-exporter.js +7 -4
  57. package/dist/file/docx-importer.d.ts +9 -0
  58. package/dist/file/docx-importer.js +47 -19
  59. package/dist/file/scene-number.d.ts +5 -0
  60. package/dist/file/scene-number.js +8 -0
  61. package/dist/file/workflow-reports.js +13 -7
  62. package/dist/findreplace/FindReplace.js +5 -2
  63. package/dist/import/AIImportDialog.d.ts +11 -0
  64. package/dist/import/AIImportDialog.js +112 -0
  65. package/dist/import/ai-import.d.ts +51 -0
  66. package/dist/import/ai-import.js +200 -0
  67. package/dist/import/apply-import.d.ts +7 -0
  68. package/dist/import/apply-import.js +35 -0
  69. package/dist/import/extract.d.ts +17 -0
  70. package/dist/import/extract.js +18 -0
  71. package/dist/index.d.ts +18 -3
  72. package/dist/index.js +7 -0
  73. package/dist/locales/bn.json +8 -0
  74. package/dist/locales/en.json +8 -0
  75. package/dist/locales/gu.json +8 -0
  76. package/dist/locales/hi.json +8 -0
  77. package/dist/locales/kn.json +8 -0
  78. package/dist/locales/ml.json +8 -0
  79. package/dist/locales/mr.json +8 -0
  80. package/dist/locales/or.json +8 -0
  81. package/dist/locales/pa.json +8 -0
  82. package/dist/locales/si.json +8 -0
  83. package/dist/locales/ta.json +8 -0
  84. package/dist/locales/te.json +8 -0
  85. package/dist/navigator/SceneNavigator.js +125 -131
  86. package/dist/navigator/StatisticsDialog.js +5 -1
  87. package/dist/navigator/scene-edits.d.ts +18 -0
  88. package/dist/navigator/scene-edits.js +143 -0
  89. package/dist/navigator/scene-list.js +4 -4
  90. package/dist/navigator/scene-numbering.d.ts +37 -0
  91. package/dist/navigator/scene-numbering.js +88 -0
  92. package/dist/settings/SettingsDialog.js +8 -120
  93. package/dist/shell/AppBar.js +25 -4
  94. package/dist/shell/AppShell.js +18 -4
  95. package/dist/shell/ElementToolbar.d.ts +5 -0
  96. package/dist/shell/ElementToolbar.js +35 -25
  97. package/dist/shell/LanguageStatus.js +6 -0
  98. package/dist/shell/StatusBar.js +11 -6
  99. package/dist/shell/builtin-commands.d.ts +10 -0
  100. package/dist/shell/builtin-commands.js +43 -0
  101. package/dist/shell/icons.d.ts +6 -0
  102. package/dist/shell/icons.js +6 -0
  103. package/dist/shell/use-commands.d.ts +7 -0
  104. package/dist/shell/use-commands.js +24 -0
  105. package/dist/shell/use-panel-context.d.ts +3 -0
  106. package/dist/shell/use-panel-context.js +39 -0
  107. package/dist/source/SourceView.js +39 -3
  108. package/dist/styles/screenplay.css +114 -1
  109. package/dist/titlepage/ExportDialog.d.ts +5 -0
  110. package/dist/titlepage/ExportDialog.js +59 -96
  111. package/dist/titlepage/frontmatter-form.js +1 -1
  112. package/package.json +4 -4
  113. package/dist/ai/OnboardingDialog.d.ts +0 -6
  114. package/dist/ai/OnboardingDialog.js +0 -115
  115. package/dist/ai/ai-client.d.ts +0 -71
  116. package/dist/ai/ai-client.js +0 -494
  117. package/dist/ai/generated-providers.json +0 -3696
  118. package/dist/ai/voice-service.d.ts +0 -15
  119. package/dist/ai/voice-service.js +0 -81
  120. package/dist/editor/character-view.d.ts +0 -35
  121. package/dist/editor/character-view.js +0 -112
  122. package/dist/editor/transliterate-input.d.ts +0 -10
  123. package/dist/editor/transliterate-input.js +0 -88
  124. package/dist/extensions/types.d.ts +0 -73
  125. 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 { runCommand, getEditorView } from '../editor/editor-bus';
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 { makeSetBlock, makeSetOrToggleNote } from '../shell/toolbar-actions';
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
- export function DocumentProvider({ children, storageAdapter }) {
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, setFilePath] = useState(null);
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
- const setText = useCallback((next) => {
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
- setDirty(true);
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
- // Refs so the autosave path always writes the latest text/filePath without
161
- // needing them in a dependency array that would tear down the timers below
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, textRef.current);
172
- writeRecoveryPointer(path);
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
- // Block type shortcuts Alt+Shift+letter
472
- if (e.altKey && e.shiftKey && !mod) {
473
- if (e.code === 'KeyN') {
474
- e.preventDefault();
475
- runCommand(makeSetOrToggleNote());
476
- return;
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
- text, ast, mode, filePath, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
590
- aiOnboardingVisible, voiceActive, metadataVisible,
591
- findReplaceVisible, findMode, styleVisible, customStyles, resolvedStyle, watermarkText,
592
- confirmModal, toasts, versions, versionHistoryVisible,
593
- setText, setMode, setFilePath, setRibbonVisible, setNavVisible, setExportVisible, setSettingsVisible,
594
- setAIOnboardingVisible, setVoiceActive, setMetadataVisible,
595
- setFindReplaceVisible, setFindMode, setStyleVisible, setVersionHistoryVisible, setDocumentStyle, reloadCustomStyles,
596
- markClean, save, newDocument, openFile, saveFile, saveFileAs, importDocx, restoreVersion, clearVersionHistory,
597
- showConfirm, closeConfirm, showToast, dismissToast,
598
- }, children: children }) }));
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: buildPlugins() });
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
- setText(newText);
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
- return () => { setEditorView(null); view.destroy(); viewRef.current = null; };
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: buildPlugins() });
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 {};