@sutrata/editor 0.1.2 → 0.2.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 (118) 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 +28 -4
  13. package/dist/context/DocumentContext.js +67 -37
  14. package/dist/editor/EditorView.js +32 -4
  15. package/dist/editor/SelectionMenu.js +5 -3
  16. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  17. package/dist/editor/external-decorations-plugin.js +151 -0
  18. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  19. package/dist/editor/omitted-scene-plugin.js +44 -0
  20. package/dist/editor/plugins.js +4 -0
  21. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  22. package/dist/editor/private-metadata-plugin.js +60 -0
  23. package/dist/editor/prosemirror-to-sutra.js +179 -74
  24. package/dist/editor/scene-heading-view.js +2 -0
  25. package/dist/editor/schema.js +69 -12
  26. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  27. package/dist/editor/sutra-to-prosemirror.js +32 -34
  28. package/dist/extensions/ai-provider.d.ts +44 -0
  29. package/dist/extensions/ai-provider.js +27 -0
  30. package/dist/extensions/collab.d.ts +21 -0
  31. package/dist/extensions/collab.js +6 -0
  32. package/dist/extensions/command-registry.d.ts +49 -0
  33. package/dist/extensions/command-registry.js +28 -0
  34. package/dist/extensions/decorations.d.ts +48 -0
  35. package/dist/extensions/decorations.js +6 -0
  36. package/dist/extensions/export-registry.d.ts +59 -0
  37. package/dist/extensions/export-registry.js +10 -0
  38. package/dist/extensions/panel-registry.d.ts +30 -0
  39. package/dist/extensions/panel-registry.js +16 -0
  40. package/dist/extensions/registries.d.ts +15 -0
  41. package/dist/extensions/registries.js +12 -0
  42. package/dist/extensions/registry-store.d.ts +26 -0
  43. package/dist/extensions/registry-store.js +35 -0
  44. package/dist/extensions/session.d.ts +29 -0
  45. package/dist/extensions/session.js +20 -0
  46. package/dist/extensions/speech-provider.d.ts +35 -0
  47. package/dist/extensions/speech-provider.js +17 -0
  48. package/dist/file/builtin-exporters.d.ts +9 -0
  49. package/dist/file/builtin-exporters.js +72 -0
  50. package/dist/file/docx-exporter.js +7 -4
  51. package/dist/file/docx-importer.d.ts +9 -0
  52. package/dist/file/docx-importer.js +47 -19
  53. package/dist/file/scene-number.d.ts +5 -0
  54. package/dist/file/scene-number.js +8 -0
  55. package/dist/file/workflow-reports.js +13 -7
  56. package/dist/findreplace/FindReplace.js +5 -2
  57. package/dist/import/AIImportDialog.d.ts +11 -0
  58. package/dist/import/AIImportDialog.js +112 -0
  59. package/dist/import/ai-import.d.ts +51 -0
  60. package/dist/import/ai-import.js +200 -0
  61. package/dist/import/apply-import.d.ts +7 -0
  62. package/dist/import/apply-import.js +35 -0
  63. package/dist/import/extract.d.ts +17 -0
  64. package/dist/import/extract.js +18 -0
  65. package/dist/index.d.ts +17 -2
  66. package/dist/index.js +7 -0
  67. package/dist/locales/bn.json +8 -0
  68. package/dist/locales/en.json +8 -0
  69. package/dist/locales/gu.json +8 -0
  70. package/dist/locales/hi.json +8 -0
  71. package/dist/locales/kn.json +8 -0
  72. package/dist/locales/ml.json +8 -0
  73. package/dist/locales/mr.json +8 -0
  74. package/dist/locales/or.json +8 -0
  75. package/dist/locales/pa.json +8 -0
  76. package/dist/locales/si.json +8 -0
  77. package/dist/locales/ta.json +8 -0
  78. package/dist/locales/te.json +8 -0
  79. package/dist/navigator/SceneNavigator.js +125 -131
  80. package/dist/navigator/StatisticsDialog.js +5 -1
  81. package/dist/navigator/scene-edits.d.ts +18 -0
  82. package/dist/navigator/scene-edits.js +143 -0
  83. package/dist/navigator/scene-list.js +4 -4
  84. package/dist/navigator/scene-numbering.d.ts +37 -0
  85. package/dist/navigator/scene-numbering.js +88 -0
  86. package/dist/settings/SettingsDialog.js +8 -120
  87. package/dist/shell/AppBar.js +21 -2
  88. package/dist/shell/AppShell.js +18 -4
  89. package/dist/shell/ElementToolbar.d.ts +5 -0
  90. package/dist/shell/ElementToolbar.js +35 -25
  91. package/dist/shell/LanguageStatus.js +6 -0
  92. package/dist/shell/StatusBar.js +11 -6
  93. package/dist/shell/builtin-commands.d.ts +10 -0
  94. package/dist/shell/builtin-commands.js +43 -0
  95. package/dist/shell/icons.d.ts +6 -0
  96. package/dist/shell/icons.js +6 -0
  97. package/dist/shell/use-commands.d.ts +7 -0
  98. package/dist/shell/use-commands.js +24 -0
  99. package/dist/shell/use-panel-context.d.ts +3 -0
  100. package/dist/shell/use-panel-context.js +39 -0
  101. package/dist/source/SourceView.js +18 -1
  102. package/dist/styles/screenplay.css +113 -1
  103. package/dist/titlepage/ExportDialog.d.ts +5 -0
  104. package/dist/titlepage/ExportDialog.js +59 -96
  105. package/package.json +4 -4
  106. package/dist/ai/OnboardingDialog.d.ts +0 -6
  107. package/dist/ai/OnboardingDialog.js +0 -115
  108. package/dist/ai/ai-client.d.ts +0 -71
  109. package/dist/ai/ai-client.js +0 -494
  110. package/dist/ai/generated-providers.json +0 -3696
  111. package/dist/ai/voice-service.d.ts +0 -15
  112. package/dist/ai/voice-service.js +0 -81
  113. package/dist/editor/character-view.d.ts +0 -35
  114. package/dist/editor/character-view.js +0 -112
  115. package/dist/editor/transliterate-input.d.ts +0 -10
  116. package/dist/editor/transliterate-input.js +0 -88
  117. package/dist/extensions/types.d.ts +0 -73
  118. package/dist/extensions/types.js +0 -12
@@ -5,5 +5,6 @@ interface Props {
5
5
  onConfirm: (formattedText: string) => void;
6
6
  onCancel: () => void;
7
7
  }
8
+ /** Rendered only where useAI() is non-null (VoiceToolbar, SelectionMenu's Format). */
8
9
  export declare function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCancel }: Props): React.JSX.Element;
9
10
  export {};
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import { callAI, getAIConfig } from './ai-client';
4
- import { VOICE_FORMAT_SYSTEM_PROMPT } from './prompts';
3
+ import { useAI } from '../extensions/ai-provider';
4
+ import { formatTranscript as formatWithAI } from './format-transcript';
5
5
  import { SparklesIcon } from '../shell/icons';
6
+ /** Rendered only where useAI() is non-null (VoiceToolbar, SelectionMenu's Format). */
6
7
  export function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCancel }) {
8
+ const ai = useAI();
7
9
  const [formattedText, setFormattedText] = useState('');
8
10
  const [isLoading, setIsLoading] = useState(true);
9
11
  const [error, setError] = useState('');
@@ -15,9 +17,7 @@ export function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCance
15
17
  setIsLoading(true);
16
18
  setError('');
17
19
  try {
18
- const config = getAIConfig();
19
- const prompt = `Raw Voice Transcript (${langCode}):\n"${rawTranscript}"`;
20
- const result = await callAI(prompt, VOICE_FORMAT_SYSTEM_PROMPT, config, { clean: 'voice' });
20
+ const result = await formatWithAI(ai, rawTranscript, langCode);
21
21
  setFormattedText(result.trim());
22
22
  }
23
23
  catch (e) {
@@ -33,5 +33,5 @@ export function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCance
33
33
  localStorage.setItem('sutrata_always_auto_insert', alwaysAutoInsert ? 'true' : 'false');
34
34
  onConfirm(formattedText);
35
35
  };
36
- return (_jsx("div", { className: "cs-dialog-overlay", onClick: onCancel, style: { zIndex: 310 }, children: _jsxs("div", { className: "cs-dialog cs-voice-confirm-dialog", role: "dialog", "aria-modal": "true", "aria-label": "Confirm Voice Dictation Formatting", onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: "cs-dialog-header", children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx(SparklesIcon, { size: 16 }), _jsx("span", { className: "cs-dialog-title", children: "Review Voice Formatting" })] }), _jsx("button", { type: "button", className: "cs-fr-close", onClick: onCancel, "aria-label": "Close", children: "\u00D7" })] }), _jsx("div", { className: "cs-voice-confirm-body", children: isLoading ? (_jsxs("div", { className: "cs-voice-loading-state", children: [_jsx("div", { className: "cs-spinner", style: { width: '28px', height: '28px', borderWidth: '2.5px' } }), _jsx("span", { style: { fontSize: '13px', color: 'var(--cs-ui-text-dark-secondary, #a8a69d)' }, children: "Formatting speech into Sutra screenplay format..." })] })) : (_jsxs(_Fragment, { children: [error && (_jsxs("div", { className: "cs-voice-error", role: "alert", children: ["AI formatting notice: ", error, " (showing raw input below)"] })), _jsxs("div", { className: "cs-voice-grid", children: [_jsxs("div", { className: "cs-voice-column", children: [_jsx("span", { className: "cs-voice-col-title", children: "Raw Dictation" }), _jsx("div", { className: "cs-voice-box-raw", children: rawTranscript })] }), _jsxs("div", { className: "cs-voice-column", children: [_jsx("span", { className: "cs-voice-col-title", children: "Formatted Sutra" }), _jsx("textarea", { className: "cs-voice-box-formatted", value: formattedText, onChange: e => setFormattedText(e.target.value), rows: 8 })] })] }), _jsx("div", { className: "cs-voice-options", children: _jsxs("label", { className: "cs-voice-checkbox-label", children: [_jsx("input", { type: "checkbox", checked: alwaysAutoInsert, onChange: e => setAlwaysAutoInsert(e.target.checked) }), _jsx("span", { children: "Always auto-insert directly into screenplay (skip review)" })] }) }), _jsxs("div", { className: "cs-dialog-actions", style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '12px' }, children: [_jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: onCancel, children: "Discard" }), _jsx("button", { type: "button", className: "cs-voice-btn-primary", onClick: handleConfirm, children: "Insert Into Screenplay" })] })] })) })] }) }));
36
+ return (_jsx("div", { className: "cs-dialog-overlay", onClick: onCancel, style: { zIndex: 310 }, children: _jsxs("div", { className: "cs-dialog cs-voice-confirm-dialog", role: "dialog", "aria-modal": "true", "aria-label": "Review Formatting", onClick: e => e.stopPropagation(), children: [_jsxs("div", { className: "cs-dialog-header", children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx(SparklesIcon, { size: 16 }), _jsx("span", { className: "cs-dialog-title", children: "Review Formatting" })] }), _jsx("button", { type: "button", className: "cs-fr-close", onClick: onCancel, "aria-label": "Close", children: "\u00D7" })] }), _jsx("div", { className: "cs-voice-confirm-body", children: isLoading ? (_jsxs("div", { className: "cs-voice-loading-state", children: [_jsx("div", { className: "cs-spinner", style: { width: '28px', height: '28px', borderWidth: '2.5px' } }), _jsx("span", { style: { fontSize: '13px', color: 'var(--cs-ui-text-dark-secondary, #a8a69d)' }, children: "Formatting into Sutra screenplay format..." })] })) : (_jsxs(_Fragment, { children: [error && (_jsxs("div", { className: "cs-voice-error", role: "alert", children: ["AI formatting notice: ", error, " (showing raw input below)"] })), _jsxs("div", { className: "cs-voice-grid", children: [_jsxs("div", { className: "cs-voice-column", children: [_jsx("span", { className: "cs-voice-col-title", children: "Raw Content" }), _jsx("div", { className: "cs-voice-box-raw", tabIndex: 0, children: rawTranscript })] }), _jsxs("div", { className: "cs-voice-column", children: [_jsx("span", { className: "cs-voice-col-title", children: "Formatted Sutra" }), _jsx("textarea", { className: "cs-voice-box-formatted", value: formattedText, onChange: e => setFormattedText(e.target.value), rows: 8 })] })] }), _jsx("div", { className: "cs-voice-options", children: _jsxs("label", { className: "cs-voice-checkbox-label", children: [_jsx("input", { type: "checkbox", checked: alwaysAutoInsert, onChange: e => setAlwaysAutoInsert(e.target.checked) }), _jsx("span", { children: "Always auto-insert directly into screenplay (skip review)" })] }) }), _jsxs("div", { className: "cs-dialog-actions", style: { display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '12px' }, children: [_jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: onCancel, children: "Discard" }), _jsx("button", { type: "button", className: "cs-voice-btn-primary", onClick: handleConfirm, children: "Insert Into Screenplay" })] })] })) })] }) }));
37
37
  }
@@ -1,2 +1,3 @@
1
1
  import React from 'react';
2
+ /** Rendered by AppShell only when useSpeech() (which implies useAI()) is non-null. */
2
3
  export declare function VoiceToolbar(): React.JSX.Element;
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useRef } from 'react';
3
3
  import { useDocument } from '../context/DocumentContext';
4
- import { voiceServiceInstance } from './voice-service';
5
- import { getAIConfig, callAI, hasConfiguredApiKey } from './ai-client';
6
- import { VOICE_FORMAT_SYSTEM_PROMPT } from './prompts';
4
+ import { formatTranscript } from './format-transcript';
5
+ import { useAI } from '../extensions/ai-provider';
6
+ import { useSpeech } from '../extensions/speech-provider';
7
7
  import { insertSutra } from '../editor/insert-helper';
8
8
  import { VoiceConfirmDialog } from './VoiceConfirmDialog';
9
9
  import { LOCALIZED_COMMANDS } from './commands';
@@ -20,8 +20,12 @@ const VOICE_LANGUAGES = [
20
20
  { code: 'gu-IN', label: 'ગુજરાતી (Gujarati)', base: 'gu' },
21
21
  { code: 'mr-IN', label: 'मराठी (Marathi)', base: 'mr' },
22
22
  ];
23
+ /** Rendered by AppShell only when useSpeech() (which implies useAI()) is non-null. */
23
24
  export function VoiceToolbar() {
24
- const { mode, setVoiceActive, setAIOnboardingVisible, showToast } = useDocument();
25
+ const { mode, setVoiceActive, showToast } = useDocument();
26
+ const ai = useAI();
27
+ const speech = useSpeech();
28
+ const sessionRef = useRef(null);
25
29
  const { locale } = useTranslation();
26
30
  const defaultLang = VOICE_LANGUAGES.find(l => l.base === locale)?.code || 'en-US';
27
31
  const [langCode, setLangCode] = useState(defaultLang);
@@ -36,7 +40,7 @@ export function VoiceToolbar() {
36
40
  setLangCode(matched);
37
41
  }, [locale]);
38
42
  const canvasRef = useRef(null);
39
- const isSupported = voiceServiceInstance.isSupported();
43
+ const isSupported = speech.isSupported();
40
44
  // Audio waveform animation
41
45
  useEffect(() => {
42
46
  if (!isListening)
@@ -77,9 +81,7 @@ export function VoiceToolbar() {
77
81
  if (alwaysAuto) {
78
82
  setIsFormatting(true);
79
83
  try {
80
- const config = getAIConfig();
81
- const prompt = `Raw Voice Transcript (${langCode}):\n"${fullTranscript}"`;
82
- const result = await callAI(prompt, VOICE_FORMAT_SYSTEM_PROMPT, config, { clean: 'voice' });
84
+ const result = await formatTranscript(ai, fullTranscript, langCode);
83
85
  insertSutra(result.trim(), mode);
84
86
  setVoiceActive(false);
85
87
  showToast('Voice dictation inserted.', 'success');
@@ -97,16 +99,20 @@ export function VoiceToolbar() {
97
99
  }
98
100
  };
99
101
  const handleStart = async () => {
100
- const hasKey = await hasConfiguredApiKey(getAIConfig());
101
- if (!hasKey) {
102
- showToast('AI keys are required to format dictated text. Opening Setup...', 'info');
103
- setAIOnboardingVisible(true);
102
+ if (!(await ai.isConfigured())) {
103
+ if (ai.openSetup) {
104
+ showToast('AI must be set up to format dictated text. Opening Setup...', 'info');
105
+ ai.openSetup();
106
+ }
107
+ else {
108
+ showToast(`${ai.displayName} is not available right now.`, 'warn');
109
+ }
104
110
  return;
105
111
  }
106
112
  setError('');
107
113
  setInterimResult('');
108
114
  setIsListening(true);
109
- voiceServiceInstance.start(langCode, {
115
+ sessionRef.current = speech.listen(langCode, {
110
116
  onResult: (text, isFinal) => {
111
117
  if (isFinal) {
112
118
  setTranscript(prev => (prev ? prev + ' ' + text : text));
@@ -131,7 +137,7 @@ export function VoiceToolbar() {
131
137
  });
132
138
  };
133
139
  const handleStopListening = async () => {
134
- voiceServiceInstance.stop();
140
+ sessionRef.current?.stop();
135
141
  setIsListening(false);
136
142
  const fullTranscript = (transcript + ' ' + interimResult).trim();
137
143
  if (fullTranscript) {
@@ -147,7 +153,7 @@ export function VoiceToolbar() {
147
153
  await performFormatting(fullTranscript);
148
154
  };
149
155
  const handleCancel = () => {
150
- voiceServiceInstance.abort();
156
+ sessionRef.current?.abort();
151
157
  setIsListening(false);
152
158
  setVoiceActive(false);
153
159
  };
@@ -169,7 +175,7 @@ export function VoiceToolbar() {
169
175
  return (_jsxs("div", { className: "cs-voice-toolbar", role: "region", "aria-label": "Voice Dictation Toolbar", children: [_jsxs("div", { className: "cs-voice-toolbar-row", children: [_jsxs("div", { className: "cs-voice-toolbar-badge", children: [_jsx(MicIcon, { size: 14 }), _jsx("span", { children: "Voice Dictation" })] }), _jsx("select", { className: "cs-voice-select", value: langCode, onChange: e => setLangCode(e.target.value), disabled: isListening, "aria-label": "Voice Language", children: VOICE_LANGUAGES.map(l => (_jsx("option", { value: l.code, children: l.label }, l.code))) }), isListening ? (_jsxs("div", { className: "cs-voice-listening-group", children: [_jsx("button", { type: "button", className: "cs-voice-btn-danger", onClick: handleStopListening, children: "Stop & Format" }), _jsx("canvas", { ref: canvasRef, width: 110, height: 26, className: "cs-voice-waveform", role: "img", "aria-label": "Audio waveform visualization" })] })) : (_jsxs("div", { className: "cs-voice-actions-group", children: [_jsxs("button", { type: "button", className: "cs-voice-btn-primary", onClick: handleStart, disabled: !isSupported || isFormatting, children: [_jsx(MicIcon, { size: 14 }), _jsx("span", { children: isFormatting ? 'Formatting...' : 'Start Dictation' })] }), transcript && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "cs-voice-btn-secondary", onClick: handleFormatCaptured, disabled: isFormatting, children: "Re-format" }), _jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: () => {
170
176
  setTranscript('');
171
177
  setError('');
172
- }, children: "Clear" })] }))] })), _jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: handleCancel, children: "Close" }), !isSupported && (_jsx("span", { className: "cs-voice-warning", children: "Web Speech API is not supported in this browser." }))] }), (isListening || transcript || interimResult) && (isListening ? (_jsxs("div", { className: "cs-voice-transcript-box", children: [transcript, interimResult && _jsxs("span", { className: "cs-voice-interim", children: [" ", interimResult] }), !transcript && !interimResult && _jsx("span", { className: "cs-voice-placeholder", children: "Speak now to dictate..." })] })) : (_jsx("textarea", { className: "cs-voice-textarea", value: transcript, onChange: (e) => setTranscript(e.target.value), placeholder: "Edit captured text before formatting...", rows: 2 }))), error && (_jsx("div", { className: "cs-voice-error", role: "alert", children: error })), _jsxs("div", { className: "cs-voice-cheatsheet", children: [_jsx("span", { className: "cs-voice-cheatsheet-title", children: "Commands:" }), cheatsheet.map(c => {
178
+ }, children: "Clear" })] }))] })), _jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: handleCancel, children: "Close" }), !isSupported && (_jsxs("span", { className: "cs-voice-warning", children: [speech.displayName, " is not supported in this browser."] }))] }), (isListening || transcript || interimResult) && (isListening ? (_jsxs("div", { className: "cs-voice-transcript-box", children: [transcript, interimResult && _jsxs("span", { className: "cs-voice-interim", children: [" ", interimResult] }), !transcript && !interimResult && _jsx("span", { className: "cs-voice-placeholder", children: "Speak now to dictate..." })] })) : (_jsx("textarea", { className: "cs-voice-textarea", value: transcript, onChange: (e) => setTranscript(e.target.value), placeholder: "Edit captured text before formatting...", rows: 2 }))), error && (_jsx("div", { className: "cs-voice-error", role: "alert", children: error })), _jsxs("div", { className: "cs-voice-cheatsheet", children: [_jsx("span", { className: "cs-voice-cheatsheet-title", children: "Commands:" }), cheatsheet.map(c => {
173
179
  if (c.type === 'literal')
174
180
  return null;
175
181
  return (_jsxs("span", { className: "cs-voice-tag", children: [_jsx("span", { className: "cs-voice-tag-type", children: c.type }), ": \u201C", c.keywords[0], "\u201D"] }, c.type));
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Strip the chatter models wrap around Sutra output (code fences, list
3
+ * bullets, restated rules, "->" reasoning lines), keeping the blank lines
4
+ * that separate Sutra blocks. Applied to every AI formatting result before
5
+ * it is inserted, whichever AIProvider produced it.
6
+ */
7
+ export declare function cleanVoiceResponse(text: string): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Strip the chatter models wrap around Sutra output (code fences, list
3
+ * bullets, restated rules, "->" reasoning lines), keeping the blank lines
4
+ * that separate Sutra blocks. Applied to every AI formatting result before
5
+ * it is inserted, whichever AIProvider produced it.
6
+ */
7
+ export function cleanVoiceResponse(text) {
8
+ let cleaned = text.trim();
9
+ // Strip markdown code block wrapper if present
10
+ cleaned = cleaned.replace(/^```[a-zA-Z0-9]*\n/, '').replace(/\n```$/, '');
11
+ const lines = cleaned.split('\n');
12
+ const filteredLines = lines.map(line => {
13
+ let l = line.trim();
14
+ // Strip leading list bullet (e.g. *, -, +, •) and spaces
15
+ l = l.replace(/^[*•\-+]\s*/, '').trim();
16
+ // Strip leading/trailing backticks if any
17
+ l = l.replace(/^`+/, '').replace(/`+$/, '').trim();
18
+ // Strip common explanatory trailing comments in parens
19
+ l = l.replace(/\s*\(Standard screenplay shorthand[^)]*\)/gi, '');
20
+ l = l.replace(/\s*\(Standard shorthand[^)]*\)/gi, '');
21
+ return l;
22
+ }).filter(line => {
23
+ const trimmed = line.toLowerCase();
24
+ // Keep blank lines: they separate Sutra blocks (a blank line ends dialogue).
25
+ if (!trimmed)
26
+ return true;
27
+ // Discard reasoning/mapping/explanation patterns
28
+ if (trimmed.includes('->') ||
29
+ trimmed.includes('=>') ||
30
+ trimmed.startsWith('input:') ||
31
+ trimmed.startsWith('task:') ||
32
+ trimmed.startsWith('rules:') ||
33
+ trimmed.startsWith('instructions:') ||
34
+ trimmed.startsWith('sutra rules:') ||
35
+ trimmed.startsWith('scene headings:') ||
36
+ trimmed.startsWith('scene id:') ||
37
+ trimmed.startsWith('characters:') ||
38
+ trimmed.startsWith('parentheticals:') ||
39
+ trimmed.startsWith('dialogue:') ||
40
+ trimmed.startsWith('transitions:') ||
41
+ trimmed.startsWith('lyrics:') ||
42
+ trimmed.startsWith('action:') ||
43
+ trimmed.startsWith('format:') ||
44
+ trimmed.startsWith('components:') ||
45
+ trimmed.startsWith('output only') ||
46
+ trimmed.startsWith('no markdown') ||
47
+ trimmed.includes('convert it into valid sutra') ||
48
+ trimmed.includes('sutra syntax rules') ||
49
+ trimmed.includes('clearly a scene heading') ||
50
+ trimmed.includes('screenplay shorthand') ||
51
+ trimmed.includes('append as')) {
52
+ return false;
53
+ }
54
+ return true;
55
+ });
56
+ // Collapse runs of blank lines left behind by discarded explanation lines.
57
+ return filteredLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
58
+ }
@@ -0,0 +1,3 @@
1
+ import type { AIProvider } from '../extensions/ai-provider';
2
+ /** Format a dictated (or selected) transcript as Sutra with the injected AI provider. */
3
+ export declare function formatTranscript(ai: AIProvider, transcript: string, langCode: string): Promise<string>;
@@ -0,0 +1,7 @@
1
+ import { VOICE_FORMAT_SYSTEM_PROMPT } from './prompts';
2
+ import { cleanVoiceResponse } from './clean-response';
3
+ /** Format a dictated (or selected) transcript as Sutra with the injected AI provider. */
4
+ export async function formatTranscript(ai, transcript, langCode) {
5
+ const prompt = `Raw Voice Transcript (${langCode}):\n"${transcript}"`;
6
+ return cleanVoiceResponse(await ai.complete({ system: VOICE_FORMAT_SYSTEM_PROMPT, prompt }));
7
+ }
@@ -1,4 +1,4 @@
1
- export declare const VOICE_FORMAT_SYSTEM_PROMPT = "You are Sutrata's AI screenplay formatter.\nYour task is to take a raw voice dictation transcript (which may contain explicit formatting keywords like \"scene\", \"character\", \"dialogue\", \"parenthetical\", \"transition\") and convert it into valid Sutra screenplay format.\n\nSutra syntax rules:\n1. Scene Headings: Start with \"##\" (e.g., \"## INT. COFFEE SHOP - DAY\"). If a scene number/ID is specified (e.g., \"scene #2\", \"scene 2\", or \"scene 3A\"), append it as a Sutra attribute ID at the end of the scene heading, like \"{#2}\" or \"{#3A}\" (e.g., \"## EXT. SWIMMING POOL - DAY {#2}\").\n2. Characters: All-caps, alone on a line (e.g., \"RAJ\").\n3. Parentheticals: In parentheses, on a line by itself under character (e.g., \"(whispering)\").\n4. Dialogue: Indented text directly following a character or parenthetical.\n5. Transitions: In uppercase, prefixed with \">\" or ending with \"TO:\" (e.g., \"> CUT TO:\").\n6. Lyrics: Prefixed with \"~\" (e.g., \"~ Sing a song\").\n7. Action: Standard descriptive paragraph.\n\nCRITICAL INSTRUCTIONS:\n- Preserve Indic/Unicode characters and mixed-language code-switching exactly as transcribed. Do NOT translate non-English text to English.\n- Output ONLY the formatted Sutra text.\n- Do NOT include any explanations, steps, reasoning, bullet points, meta-commentary, or introductory/concluding text.\n- Do NOT output headings, drafts, prefixes, or suffixes.\n- Do NOT wrap in markdown code blocks like ```cine.\n- The output must contain nothing but the final formatted Sutra screenplay lines.\n";
1
+ export declare const VOICE_FORMAT_SYSTEM_PROMPT = "You are Sutrata's AI screenplay formatter.\nYour task is to take a raw voice dictation transcript (which may contain explicit formatting keywords like \"scene\", \"character\", \"dialogue\", \"parenthetical\", \"transition\") and convert it into valid Sutra screenplay format.\n\nSutra syntax rules (one element per block; separate blocks with a blank line):\n1. Scene Headings: Start with \"##\" (e.g., \"## INT. COFFEE SHOP - DAY\"). If a scene number/ID is specified (e.g., \"scene #2\", \"scene 2\", or \"scene 3A\"), append it as a Sutra attribute ID at the end of the scene heading, like \"{#2}\" or \"{#3A}\" (e.g., \"## EXT. SWIMMING POOL - DAY {#2}\").\n2. Scene Metadata: Lines directly under a scene heading starting with \"&\", as \"& key: value\" (e.g., \"& time: DAY\", \"& synopsis: Raj waits for Meera.\"). Only add metadata the speaker actually gave.\n3. Character Cues: Start with \"@\", alone on a line (e.g., \"@RAJ\", \"@\u092E\u0940\u0930\u093E\"). Keep the speaker's casing and script; never rely on ALL CAPS.\n4. Parentheticals: In parentheses, on a line by itself under the character cue (e.g., \"(whispering)\").\n5. Dialogue: The lines directly after the character cue or parenthetical, with no blank line in between. A blank line ends the dialogue block.\n6. Transitions: Start with \">>\" (e.g., \">> CUT TO:\").\n7. Lyrics: Prefixed with \"~ \" (e.g., \"~ Sing a song\").\n8. Action: Standard descriptive paragraph.\n\nCRITICAL INSTRUCTIONS:\n- Preserve Indic/Unicode characters and mixed-language code-switching exactly as transcribed. Do NOT translate non-English text to English.\n- Output ONLY the formatted Sutra text.\n- Do NOT include any explanations, steps, reasoning, bullet points, meta-commentary, or introductory/concluding text.\n- Do NOT output headings, drafts, prefixes, or suffixes.\n- Do NOT wrap in markdown code blocks like ```cine.\n- The output must contain nothing but the final formatted Sutra screenplay lines.\n";
2
2
  /**
3
3
  * Combined synopsis+duration prompt. Merged into one call (and one JSON
4
4
  * response) instead of two separate requests to halve per-scene LLM cost.
@@ -23,3 +23,10 @@ export declare const SCENE_METADATA_SCHEMA: {
23
23
  readonly required: readonly ["synopsis", "duration"];
24
24
  readonly additionalProperties: false;
25
25
  };
26
+ /**
27
+ * AI-assisted import (import/ai-import.ts): turns a chunk of a screenplay
28
+ * written elsewhere (Word, plain text, Markdown) into Sutra. The content
29
+ * check after each call compares words in and out, so the rules below about
30
+ * never adding, dropping or translating text are enforced, not just asked.
31
+ */
32
+ export declare const IMPORT_FORMAT_SYSTEM_PROMPT = "You convert screenplay text into Sutra, a plain-text screenplay format. You receive one chunk of a longer script; format only that chunk.\n\nSutra syntax (separate blocks with one blank line):\n1. Scene heading: \"## \" + the heading text as written (e.g. \"## INT. COFFEE SHOP - DAY\", \"## \u0905\u0902\u0926\u0930. \u0930\u0947\u0932\u0935\u0947 \u0938\u094D\u091F\u0947\u0936\u0928 - \u0930\u093E\u0924\"). If the source numbers the scene, append the number as \"{#12}\" at the end of the heading line and drop it from the text.\n2. Action: a plain paragraph with no sigil.\n3. Character cue: \"@\" + the name exactly as written, alone on a line, optionally followed by an extension like \"(V.O.)\". Never change the name's case or script.\n4. Dialogue: the lines directly under the cue, no blank line in between.\n5. Parenthetical: \"(text)\" on its own line inside a dialogue block.\n6. Transition: \">> \" + text (e.g. \">> CUT TO:\").\n7. Centered text: \">> text <<\".\n8. Lyrics: \"~ \" at the start of every lyric line.\n9. Section/act heading: \"# \" + text.\n10. Emphasis already marked with *italic*, **bold** or _underline_ must be kept.\n\nRules:\n- Keep every word of the source, in its original language and script. Never translate, transliterate, summarise, correct, reorder or add text.\n- Do not invent scene headings, character names, metadata (\"& key: value\") or notes.\n- Page numbers, \"CONTINUED\", \"(MORE)\" and \"(CONT'D)\" page furniture may be dropped.\n- If a line's role is unclear, keep it as action.\n- Output only the Sutra text: no explanations, no code fences, no frontmatter.";
@@ -1,14 +1,15 @@
1
1
  export const VOICE_FORMAT_SYSTEM_PROMPT = `You are Sutrata's AI screenplay formatter.
2
2
  Your task is to take a raw voice dictation transcript (which may contain explicit formatting keywords like "scene", "character", "dialogue", "parenthetical", "transition") and convert it into valid Sutra screenplay format.
3
3
 
4
- Sutra syntax rules:
4
+ Sutra syntax rules (one element per block; separate blocks with a blank line):
5
5
  1. Scene Headings: Start with "##" (e.g., "## INT. COFFEE SHOP - DAY"). If a scene number/ID is specified (e.g., "scene #2", "scene 2", or "scene 3A"), append it as a Sutra attribute ID at the end of the scene heading, like "{#2}" or "{#3A}" (e.g., "## EXT. SWIMMING POOL - DAY {#2}").
6
- 2. Characters: All-caps, alone on a line (e.g., "RAJ").
7
- 3. Parentheticals: In parentheses, on a line by itself under character (e.g., "(whispering)").
8
- 4. Dialogue: Indented text directly following a character or parenthetical.
9
- 5. Transitions: In uppercase, prefixed with ">" or ending with "TO:" (e.g., "> CUT TO:").
10
- 6. Lyrics: Prefixed with "~" (e.g., "~ Sing a song").
11
- 7. Action: Standard descriptive paragraph.
6
+ 2. Scene Metadata: Lines directly under a scene heading starting with "&", as "& key: value" (e.g., "& time: DAY", "& synopsis: Raj waits for Meera."). Only add metadata the speaker actually gave.
7
+ 3. Character Cues: Start with "@", alone on a line (e.g., "@RAJ", "@मीरा"). Keep the speaker's casing and script; never rely on ALL CAPS.
8
+ 4. Parentheticals: In parentheses, on a line by itself under the character cue (e.g., "(whispering)").
9
+ 5. Dialogue: The lines directly after the character cue or parenthetical, with no blank line in between. A blank line ends the dialogue block.
10
+ 6. Transitions: Start with ">>" (e.g., ">> CUT TO:").
11
+ 7. Lyrics: Prefixed with "~ " (e.g., "~ Sing a song").
12
+ 8. Action: Standard descriptive paragraph.
12
13
 
13
14
  CRITICAL INSTRUCTIONS:
14
15
  - Preserve Indic/Unicode characters and mixed-language code-switching exactly as transcribed. Do NOT translate non-English text to English.
@@ -57,3 +58,29 @@ export const SCENE_METADATA_SCHEMA = {
57
58
  required: ['synopsis', 'duration'],
58
59
  additionalProperties: false,
59
60
  };
61
+ /**
62
+ * AI-assisted import (import/ai-import.ts): turns a chunk of a screenplay
63
+ * written elsewhere (Word, plain text, Markdown) into Sutra. The content
64
+ * check after each call compares words in and out, so the rules below about
65
+ * never adding, dropping or translating text are enforced, not just asked.
66
+ */
67
+ export const IMPORT_FORMAT_SYSTEM_PROMPT = `You convert screenplay text into Sutra, a plain-text screenplay format. You receive one chunk of a longer script; format only that chunk.
68
+
69
+ Sutra syntax (separate blocks with one blank line):
70
+ 1. Scene heading: "## " + the heading text as written (e.g. "## INT. COFFEE SHOP - DAY", "## अंदर. रेलवे स्टेशन - रात"). If the source numbers the scene, append the number as "{#12}" at the end of the heading line and drop it from the text.
71
+ 2. Action: a plain paragraph with no sigil.
72
+ 3. Character cue: "@" + the name exactly as written, alone on a line, optionally followed by an extension like "(V.O.)". Never change the name's case or script.
73
+ 4. Dialogue: the lines directly under the cue, no blank line in between.
74
+ 5. Parenthetical: "(text)" on its own line inside a dialogue block.
75
+ 6. Transition: ">> " + text (e.g. ">> CUT TO:").
76
+ 7. Centered text: ">> text <<".
77
+ 8. Lyrics: "~ " at the start of every lyric line.
78
+ 9. Section/act heading: "# " + text.
79
+ 10. Emphasis already marked with *italic*, **bold** or _underline_ must be kept.
80
+
81
+ Rules:
82
+ - Keep every word of the source, in its original language and script. Never translate, transliterate, summarise, correct, reorder or add text.
83
+ - Do not invent scene headings, character names, metadata ("& key: value") or notes.
84
+ - Page numbers, "CONTINUED", "(MORE)" and "(CONT'D)" page furniture may be dropped.
85
+ - If a line's role is unclear, keep it as action.
86
+ - Output only the Sutra text: no explanations, no code fences, no frontmatter.`;
@@ -3,7 +3,7 @@ const PREDEFINED_TRANSITIONS = [
3
3
  'MATCH CUT TO:', 'WIPE TO:', 'IRIS IN:', 'IRIS OUT:',
4
4
  ];
5
5
  const RESERVED_METADATA_KEYS = [
6
- 'status', 'tags', 'location', 'time', 'number', 'lang', 'mood',
6
+ 'status', 'tags', 'location', 'time', 'lang', 'mood',
7
7
  'arc', 'note', 'locked',
8
8
  ];
9
9
  /**
@@ -2,6 +2,14 @@ import React from 'react';
2
2
  import type { DocumentNode } from '@sutrata/parser';
3
3
  import type { EditorMode, VersionEntry } from '../types';
4
4
  import type { StorageAdapter } from '../extensions/storage-adapter';
5
+ import type { AIProvider } from '../extensions/ai-provider';
6
+ import type { SpeechProvider } from '../extensions/speech-provider';
7
+ import type { SessionContext } from '../extensions/session';
8
+ import type { PanelRegistry, PanelContribution } from '../extensions/panel-registry';
9
+ import type { CommandRegistry, CommandContribution } from '../extensions/command-registry';
10
+ import type { ExportRegistry, ExportContribution } from '../extensions/export-registry';
11
+ import type { DecorationProvider } from '../extensions/decorations';
12
+ import type { CollabBinding } from '../extensions/collab';
5
13
  import type { ScreenplayStyleDefinition } from '../styles/types';
6
14
  import type { ConfirmOptions } from '../shell/ConfirmDialog';
7
15
  import type { ToastItem } from '../shell/Toast';
@@ -17,7 +25,6 @@ interface DocumentContextValue {
17
25
  navVisible: boolean;
18
26
  exportVisible: boolean;
19
27
  settingsVisible: boolean;
20
- aiOnboardingVisible: boolean;
21
28
  voiceActive: boolean;
22
29
  metadataVisible: boolean;
23
30
  findReplaceVisible: boolean;
@@ -37,7 +44,6 @@ interface DocumentContextValue {
37
44
  setNavVisible: (v: boolean) => void;
38
45
  setExportVisible: (v: boolean) => void;
39
46
  setSettingsVisible: (v: boolean) => void;
40
- setAIOnboardingVisible: (v: boolean) => void;
41
47
  setVoiceActive: (v: boolean) => void;
42
48
  setMetadataVisible: (v: boolean) => void;
43
49
  setFindReplaceVisible: (visible: boolean) => void;
@@ -60,9 +66,27 @@ interface DocumentContextValue {
60
66
  showToast: (message: string, type?: 'info' | 'success' | 'warn' | 'error') => void;
61
67
  dismissToast: (id: string) => void;
62
68
  }
63
- export declare function DocumentProvider({ children, storageAdapter }: {
69
+ export interface DocumentProviderProps {
64
70
  children: React.ReactNode;
71
+ /** Required: where documents, versions and styles are stored. */
65
72
  storageAdapter: StorageAdapter;
66
- }): React.JSX.Element;
73
+ /** Optional LLM access. Without it, AI features are hidden. */
74
+ aiProvider?: AIProvider;
75
+ /** Optional speech-to-text. Voice dictation needs it and aiProvider. */
76
+ speechProvider?: SpeechProvider;
77
+ /** Optional user/permission/feature-flag context. Default: local user, `edit`. */
78
+ session?: SessionContext;
79
+ /** Optional embedder panels: a live registry, or a fixed list. */
80
+ panels?: PanelRegistry | PanelContribution[];
81
+ /** Optional embedder commands (toolbar/app-bar entries, shortcuts): a live registry, or a fixed list. */
82
+ commands?: CommandRegistry | CommandContribution[];
83
+ /** Optional embedder export formats and reports: a live registry, or a fixed list. */
84
+ exporters?: ExportRegistry | ExportContribution[];
85
+ /** Optional annotation sources (comments, breakdown, revisions). Pass a stable array. */
86
+ decorationProviders?: DecorationProvider[];
87
+ /** Optional real-time collaboration binding for the formatted editor. */
88
+ collabBinding?: CollabBinding;
89
+ }
90
+ export declare function DocumentProvider({ children, storageAdapter, aiProvider, speechProvider, session, panels, commands, exporters, decorationProviders, collabBinding, }: DocumentProviderProps): React.JSX.Element;
67
91
  export declare function useDocument(): DocumentContextValue;
68
92
  export {};
@@ -2,10 +2,19 @@ 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 } 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 } from '../editor/editor-bus';
7
16
  import { setFlatFrontmatterField } from '../editor/frontmatter-field';
8
- import { makeSetBlock, makeSetOrToggleNote } from '../shell/toolbar-actions';
17
+ import { builtinCommands } from '../shell/builtin-commands';
9
18
  import { resolveStyle } from '../styles/registry';
10
19
  const DocumentContext = createContext(null);
11
20
  const EMPTY_DOC = '## Scene 1\n\nAction.\n';
@@ -48,7 +57,15 @@ function clearRecoveryPointer() {
48
57
  // ignore
49
58
  }
50
59
  }
51
- export function DocumentProvider({ children, storageAdapter }) {
60
+ const NO_DECORATIONS = [];
61
+ /** A registry prop as a registry: a fixed list becomes a registry of its own. */
62
+ function useRegistryProp(value, create) {
63
+ return useMemo(() => (Array.isArray(value) || !value ? create(value ?? []) : value), [value]); // eslint-disable-line react-hooks/exhaustive-deps
64
+ }
65
+ export function DocumentProvider({ children, storageAdapter, aiProvider, speechProvider, session, panels, commands, exporters, decorationProviders, collabBinding, }) {
66
+ const panelRegistry = useRegistryProp(panels, createPanelRegistry);
67
+ const commandRegistry = useRegistryProp(commands, createCommandRegistry);
68
+ const exportRegistry = useRegistryProp(exporters, createExportRegistry);
52
69
  const [text, setTextState] = useState(EMPTY_DOC);
53
70
  const [ast, setAst] = useState(() => parse(EMPTY_DOC));
54
71
  const [mode, setMode] = useState('formatted');
@@ -64,7 +81,6 @@ export function DocumentProvider({ children, storageAdapter }) {
64
81
  const [navVisible, setNavVisible] = useState(() => window.innerWidth > 640);
65
82
  const [exportVisible, setExportVisible] = useState(false);
66
83
  const [settingsVisible, setSettingsVisible] = useState(false);
67
- const [aiOnboardingVisible, setAIOnboardingVisible] = useState(false);
68
84
  const [voiceActive, setVoiceActive] = useState(false);
69
85
  const [metadataVisible, setMetadataVisible] = useState(true);
70
86
  const [findReplaceVisible, setFindReplaceVisible] = useState(false);
@@ -162,6 +178,8 @@ export function DocumentProvider({ children, storageAdapter }) {
162
178
  // on every keystroke.
163
179
  const textRef = useRef(text);
164
180
  useEffect(() => { textRef.current = text; }, [text]);
181
+ const modeRef = useRef(mode);
182
+ modeRef.current = mode;
165
183
  const filePathRef = useRef(filePath);
166
184
  useEffect(() => { filePathRef.current = filePath; }, [filePath]);
167
185
  const autosaveFailedRef = useRef(false);
@@ -441,10 +459,36 @@ export function DocumentProvider({ children, storageAdapter }) {
441
459
  window.addEventListener('beforeunload', onBeforeUnload);
442
460
  return () => window.removeEventListener('beforeunload', onBeforeUnload);
443
461
  }, [isDirty]);
462
+ const canEditRef = useRef(true);
463
+ canEditRef.current = (session ?? DEFAULT_SESSION).permission === 'edit';
444
464
  useEffect(() => {
445
465
  const onKeyDown = (e) => {
446
466
  const mod = e.ctrlKey || e.metaKey;
447
467
  const cs = mod && e.shiftKey;
468
+ // Read-only sessions: only find, mode and panel toggles.
469
+ if (!canEditRef.current) {
470
+ if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyF') {
471
+ e.preventDefault();
472
+ setFindMode('find');
473
+ setFindReplaceVisible(v => !v);
474
+ }
475
+ else if (cs && !e.altKey && e.code === 'KeyE') {
476
+ e.preventDefault();
477
+ setMode(m => m === 'formatted' ? 'source' : 'formatted');
478
+ }
479
+ else if (cs && !e.altKey && e.code === 'KeyB') {
480
+ e.preventDefault();
481
+ setNavVisible(v => !v);
482
+ }
483
+ else {
484
+ const cmd = commandRegistry.list().find(c => c.readOnly && c.shortcut && matchesShortcut(c.shortcut, e));
485
+ if (cmd) {
486
+ e.preventDefault();
487
+ cmd.run({ view: getEditorView(), sourceView: getSourceView(), mode: modeRef.current, text: textRef.current, setText });
488
+ }
489
+ }
490
+ return;
491
+ }
448
492
  // File operations — use e.code for layout-independence
449
493
  if (mod && !e.shiftKey && !e.altKey && e.code === 'KeyS') {
450
494
  e.preventDefault();
@@ -468,27 +512,13 @@ export function DocumentProvider({ children, storageAdapter }) {
468
512
  setFindReplaceVisible(true);
469
513
  return;
470
514
  }
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
- }
515
+ // Command shortcuts (built-in element types, embedder commands)
516
+ const cmd = withBuiltins(builtinCommands(k => k), commandRegistry.list())
517
+ .find(c => c.shortcut && matchesShortcut(c.shortcut, e));
518
+ if (cmd) {
519
+ e.preventDefault();
520
+ cmd.run({ view: getEditorView(), sourceView: getSourceView(), mode: modeRef.current, text: textRef.current, setText });
521
+ return;
492
522
  }
493
523
  // UI toggles Ctrl+Shift+letter
494
524
  if (cs && !e.altKey) {
@@ -511,7 +541,7 @@ export function DocumentProvider({ children, storageAdapter }) {
511
541
  };
512
542
  window.addEventListener('keydown', onKeyDown);
513
543
  return () => window.removeEventListener('keydown', onKeyDown);
514
- }, [saveFile, openFile]);
544
+ }, [saveFile, openFile, commandRegistry, setText]);
515
545
  // Native menu IPC (Desktop wrapper only) — receives commands from the main process menu
516
546
  useEffect(() => {
517
547
  const api = window.desktopAPI;
@@ -585,17 +615,17 @@ export function DocumentProvider({ children, storageAdapter }) {
585
615
  });
586
616
  return off;
587
617
  }, [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 }) }));
618
+ 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: {
619
+ text, ast, mode, filePath, isDirty, lastSaveTarget, ribbonVisible, navVisible, exportVisible, settingsVisible,
620
+ voiceActive, metadataVisible,
621
+ findReplaceVisible, findMode, styleVisible, customStyles, resolvedStyle, watermarkText,
622
+ confirmModal, toasts, versions, versionHistoryVisible,
623
+ setText, setMode, setFilePath, setRibbonVisible, setNavVisible, setExportVisible, setSettingsVisible,
624
+ setVoiceActive, setMetadataVisible,
625
+ setFindReplaceVisible, setFindMode, setStyleVisible, setVersionHistoryVisible, setDocumentStyle, reloadCustomStyles,
626
+ markClean, save, newDocument, openFile, saveFile, saveFileAs, importDocx, restoreVersion, clearVersionHistory,
627
+ showConfirm, closeConfirm, showToast, dismissToast,
628
+ }, children: children }) }) }) }) }) }) }) }) }) }));
599
629
  }
600
630
  export function useDocument() {
601
631
  const ctx = useContext(DocumentContext);