@sutrata/editor 0.1.1 → 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.
- package/README.md +87 -0
- 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 +28 -4
- package/dist/context/DocumentContext.js +67 -37
- package/dist/editor/EditorView.js +32 -4
- package/dist/editor/SelectionMenu.js +5 -3
- package/dist/editor/external-decorations-plugin.d.ts +23 -0
- package/dist/editor/external-decorations-plugin.js +151 -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 +10 -0
- package/dist/editor/private-metadata-plugin.js +60 -0
- package/dist/editor/prosemirror-to-sutra.js +179 -74
- package/dist/editor/scene-heading-view.js +2 -0
- package/dist/editor/schema.js +69 -12
- 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 +29 -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/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 +17 -2
- 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 +21 -2
- 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 +18 -1
- package/dist/styles/screenplay.css +113 -1
- package/dist/titlepage/ExportDialog.d.ts +5 -0
- package/dist/titlepage/ExportDialog.js +59 -96
- package/package.json +32 -5
- 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
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- package/vitest.setup.ts +0 -65
|
@@ -8,9 +8,12 @@ import { getEditorView, getSourceView } from '../editor/editor-bus';
|
|
|
8
8
|
import { schema } from '../editor/schema';
|
|
9
9
|
import { EditorView as CmView } from '@codemirror/view';
|
|
10
10
|
import { StatisticsDialog } from './StatisticsDialog';
|
|
11
|
-
import { EyeIcon, EyeOffIcon,
|
|
12
|
-
import {
|
|
11
|
+
import { EyeIcon, EyeOffIcon, RenumberIcon, OmitIcon, RestoreIcon, StatsIcon, SparklesIcon, CloseIcon } from '../shell/icons';
|
|
12
|
+
import { sceneNumberIssues, renumberScenes } from './scene-numbering';
|
|
13
|
+
import { setSceneIdsInText, omitSceneInText, restoreSceneInText, applyTextEdit } from './scene-edits';
|
|
13
14
|
import { SCENE_METADATA_PROMPT, SCENE_METADATA_SCHEMA } from '../ai/prompts';
|
|
15
|
+
import { useAI } from '../extensions/ai-provider';
|
|
16
|
+
import { useCanEdit } from '../extensions/session';
|
|
14
17
|
import { prosemirrorToSutra } from '../editor/prosemirror-to-sutra';
|
|
15
18
|
/** Update the scene synopsis in the raw Sutra text. */
|
|
16
19
|
function updateSynopsisInText(text, sceneOffset, newSynopsis) {
|
|
@@ -165,39 +168,35 @@ function updateSceneMetadataInProseMirror(sceneIndex, key, val) {
|
|
|
165
168
|
view.dispatch(tr);
|
|
166
169
|
}
|
|
167
170
|
}
|
|
168
|
-
/**
|
|
169
|
-
function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
j++;
|
|
190
|
-
}
|
|
191
|
-
if (!hasNumber) {
|
|
192
|
-
result.push(`& number: ${sceneCount}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
171
|
+
/** Ask the AI provider for a scene's synopsis + duration (one structured call). */
|
|
172
|
+
function generateSceneMetadata(ai, sceneText) {
|
|
173
|
+
return ai.completeStructured({
|
|
174
|
+
system: SCENE_METADATA_PROMPT, prompt: sceneText, schema: SCENE_METADATA_SCHEMA,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* True when the provider can take calls; otherwise points the user at its
|
|
179
|
+
* setup (if it has one) and returns false.
|
|
180
|
+
*/
|
|
181
|
+
async function ensureAIReady(ai, showToast) {
|
|
182
|
+
if (await ai.isConfigured())
|
|
183
|
+
return true;
|
|
184
|
+
if (ai.openSetup) {
|
|
185
|
+
showToast('AI must be set up to generate synopsis & duration. Opening Setup...', 'info');
|
|
186
|
+
ai.openSetup();
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
showToast(`${ai.displayName} is not available right now.`, 'warn');
|
|
195
190
|
}
|
|
196
|
-
return
|
|
191
|
+
return false;
|
|
197
192
|
}
|
|
198
193
|
export function SceneNavigator() {
|
|
199
|
-
const { text, setText,
|
|
194
|
+
const { text, setText, showToast, setNavVisible } = useDocument();
|
|
200
195
|
const { t } = useTranslation();
|
|
196
|
+
// AI actions appear only with an injected, permitted AIProvider; structural
|
|
197
|
+
// actions (reorder, synopsis edits, number locking) only in edit sessions.
|
|
198
|
+
const ai = useAI();
|
|
199
|
+
const canEdit = useCanEdit();
|
|
201
200
|
const [scenes, setScenes] = useState([]);
|
|
202
201
|
const [dragIndex, setDragIndex] = useState(null);
|
|
203
202
|
const [dropIndex, setDropIndex] = useState(null);
|
|
@@ -215,23 +214,23 @@ export function SceneNavigator() {
|
|
|
215
214
|
useEffect(() => {
|
|
216
215
|
setScenes(buildSceneList(text));
|
|
217
216
|
}, [text]);
|
|
217
|
+
// Numbers that need renumbering (missing/duplicate/out of order; §7.4).
|
|
218
|
+
// Nothing is flagged until at least one scene has a number.
|
|
219
|
+
const numberIssues = React.useMemo(() => sceneNumberIssues(scenes.map(s => s.id)), [scenes]);
|
|
220
|
+
const issueLabel = (issue) => t(`navigator.number.${issue}`);
|
|
218
221
|
const handleSceneMetadata = useCallback(async (scene) => {
|
|
219
222
|
setIsProcessing(true);
|
|
220
223
|
setProgressText('Generating synopsis & estimating duration...');
|
|
221
224
|
try {
|
|
222
|
-
|
|
223
|
-
if (!(await hasConfiguredApiKey(aiConfig))) {
|
|
224
|
-
showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info');
|
|
225
|
-
setAIOnboardingVisible(true);
|
|
225
|
+
if (!ai || !(await ensureAIReady(ai, showToast)))
|
|
226
226
|
return;
|
|
227
|
-
}
|
|
228
227
|
const idx = scenes.findIndex(s => s.index === scene.index);
|
|
229
228
|
if (idx === -1)
|
|
230
229
|
return;
|
|
231
230
|
const start = scene.textOffset;
|
|
232
231
|
const end = idx + 1 < scenes.length ? scenes[idx + 1].textOffset : text.length;
|
|
233
232
|
const sceneText = text.substring(start, end);
|
|
234
|
-
const { synopsis, duration } = await
|
|
233
|
+
const { synopsis, duration } = await generateSceneMetadata(ai, sceneText);
|
|
235
234
|
const view = getEditorView();
|
|
236
235
|
if (view) {
|
|
237
236
|
updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim());
|
|
@@ -251,17 +250,13 @@ export function SceneNavigator() {
|
|
|
251
250
|
setIsProcessing(false);
|
|
252
251
|
setProgressText('');
|
|
253
252
|
}
|
|
254
|
-
}, [scenes, text, setText,
|
|
253
|
+
}, [ai, scenes, text, setText, showToast]);
|
|
255
254
|
const handleGenerateAllSceneMetadata = useCallback(async () => {
|
|
256
255
|
setIsProcessing(true);
|
|
257
256
|
setProgressText('Starting batch synopsis & duration generation...');
|
|
258
257
|
try {
|
|
259
|
-
|
|
260
|
-
if (!(await hasConfiguredApiKey(aiConfig))) {
|
|
261
|
-
showToast('AI keys are required to generate synopsis & duration. Opening Setup...', 'info');
|
|
262
|
-
setAIOnboardingVisible(true);
|
|
258
|
+
if (!ai || !(await ensureAIReady(ai, showToast)))
|
|
263
259
|
return;
|
|
264
|
-
}
|
|
265
260
|
const pmView = getEditorView();
|
|
266
261
|
const cmView = getSourceView();
|
|
267
262
|
let currentText = pmView ? prosemirrorToSutra(pmView.state.doc) : (cmView ? cmView.state.doc.toString() : textRef.current);
|
|
@@ -285,7 +280,7 @@ export function SceneNavigator() {
|
|
|
285
280
|
const end = idx + 1 < currentScenes.length ? currentScenes[idx + 1].textOffset : currentText.length;
|
|
286
281
|
const sceneText = currentText.substring(start, end);
|
|
287
282
|
try {
|
|
288
|
-
const { synopsis, duration } = await
|
|
283
|
+
const { synopsis, duration } = await generateSceneMetadata(ai, sceneText);
|
|
289
284
|
if (pmView) {
|
|
290
285
|
if (!latestScene.synopsis)
|
|
291
286
|
updateSceneMetadataInProseMirror(scene.index, 'synopsis', synopsis.trim());
|
|
@@ -321,7 +316,7 @@ export function SceneNavigator() {
|
|
|
321
316
|
setIsProcessing(false);
|
|
322
317
|
setProgressText('');
|
|
323
318
|
}
|
|
324
|
-
}, [
|
|
319
|
+
}, [ai, setText, showToast]);
|
|
325
320
|
const handleFrontmatter = useCallback(() => {
|
|
326
321
|
const view = getEditorView();
|
|
327
322
|
if (!view) {
|
|
@@ -337,7 +332,7 @@ export function SceneNavigator() {
|
|
|
337
332
|
}
|
|
338
333
|
let tr = view.state.tr;
|
|
339
334
|
const first = view.state.doc.firstChild;
|
|
340
|
-
if (!first || first.type.name !== 'title_page') {
|
|
335
|
+
if (canEdit && (!first || first.type.name !== 'title_page')) {
|
|
341
336
|
const field = schema.nodes['frontmatter_field'].create({ fmKey: 'title' });
|
|
342
337
|
tr = tr.insert(0, schema.nodes['title_page'].create({}, [field]));
|
|
343
338
|
}
|
|
@@ -348,7 +343,7 @@ export function SceneNavigator() {
|
|
|
348
343
|
if (typeof window !== 'undefined' && window.innerWidth <= 640) {
|
|
349
344
|
setNavVisible(false);
|
|
350
345
|
}
|
|
351
|
-
}, [setNavVisible]);
|
|
346
|
+
}, [canEdit, setNavVisible]);
|
|
352
347
|
const handleJump = useCallback((scene) => {
|
|
353
348
|
const view = getEditorView();
|
|
354
349
|
if (!view) {
|
|
@@ -403,7 +398,7 @@ export function SceneNavigator() {
|
|
|
403
398
|
}, []);
|
|
404
399
|
const handleDrop = useCallback((e, targetIdx) => {
|
|
405
400
|
e.preventDefault();
|
|
406
|
-
if (dragIndex === null || dragIndex === targetIdx) {
|
|
401
|
+
if (!canEdit || dragIndex === null || dragIndex === targetIdx) {
|
|
407
402
|
setDragIndex(null);
|
|
408
403
|
setDropIndex(null);
|
|
409
404
|
return;
|
|
@@ -412,7 +407,7 @@ export function SceneNavigator() {
|
|
|
412
407
|
setText(newText);
|
|
413
408
|
setDragIndex(null);
|
|
414
409
|
setDropIndex(null);
|
|
415
|
-
}, [dragIndex, text, scenes, setText]);
|
|
410
|
+
}, [canEdit, dragIndex, text, scenes, setText]);
|
|
416
411
|
const commitSynopsis = useCallback((idx, scene) => {
|
|
417
412
|
setEditingSceneIdx(null);
|
|
418
413
|
const newSynopsis = editVal.trim();
|
|
@@ -472,50 +467,38 @@ export function SceneNavigator() {
|
|
|
472
467
|
setText(nextText);
|
|
473
468
|
}
|
|
474
469
|
}, [editVal, text, setText]);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
else {
|
|
495
|
-
break;
|
|
496
|
-
}
|
|
497
|
-
index++;
|
|
498
|
-
}
|
|
499
|
-
if (!hasNumber) {
|
|
500
|
-
posMap.push({ headingPos: offset, currentNum: String(headingCount) });
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
for (let k = posMap.length - 1; k >= 0; k--) {
|
|
505
|
-
const { headingPos, currentNum } = posMap[k];
|
|
506
|
-
const headingNode = view.state.doc.nodeAt(headingPos);
|
|
507
|
-
const insertPos = headingPos + headingNode.nodeSize;
|
|
508
|
-
const metaNode = schema.nodes['scene_metadata'].create({ metaKey: 'number' }, schema.text(currentNum));
|
|
509
|
-
tr = tr.insert(insertPos, metaNode);
|
|
510
|
-
}
|
|
511
|
-
view.dispatch(tr);
|
|
512
|
-
}
|
|
513
|
-
else {
|
|
514
|
-
const nextText = lockNumbersInText(text);
|
|
515
|
-
setText(nextText);
|
|
470
|
+
/**
|
|
471
|
+
* Current editor text: the navigator's `text` can lag the editor by a
|
|
472
|
+
* render, and these actions rewrite whole scenes.
|
|
473
|
+
*/
|
|
474
|
+
const liveText = useCallback(() => {
|
|
475
|
+
const pmView = getEditorView();
|
|
476
|
+
const cmView = getSourceView();
|
|
477
|
+
return pmView ? prosemirrorToSutra(pmView.state.doc) : cmView ? cmView.state.doc.toString() : textRef.current;
|
|
478
|
+
}, []);
|
|
479
|
+
// Renumber scenes (format spec §7.4): only when the writer asks.
|
|
480
|
+
const handleRenumber = useCallback(() => {
|
|
481
|
+
const current = liveText();
|
|
482
|
+
const list = buildSceneList(current);
|
|
483
|
+
const next = renumberScenes(list.map(s => s.id));
|
|
484
|
+
const ids = next.map((n, i) => (n === list[i].id ? undefined : n));
|
|
485
|
+
const changed = ids.filter(n => n !== undefined).length;
|
|
486
|
+
if (changed === 0) {
|
|
487
|
+
showToast('Scene numbers are already in order.', 'info');
|
|
488
|
+
return;
|
|
516
489
|
}
|
|
517
|
-
|
|
518
|
-
|
|
490
|
+
applyTextEdit(setSceneIdsInText(current, ids), setText);
|
|
491
|
+
showToast(`Renumbered ${changed} scene${changed === 1 ? '' : 's'}.`, 'success');
|
|
492
|
+
}, [liveText, setText, showToast]);
|
|
493
|
+
const handleOmitToggle = useCallback((scene) => {
|
|
494
|
+
const current = liveText();
|
|
495
|
+
const idx = buildSceneList(current).findIndex(s => s.index === scene.index);
|
|
496
|
+
if (idx === -1)
|
|
497
|
+
return;
|
|
498
|
+
const omitted = scene.status.toLowerCase() === 'omitted';
|
|
499
|
+
applyTextEdit(omitted ? restoreSceneInText(current, idx) : omitSceneInText(current, idx), setText);
|
|
500
|
+
}, [liveText, setText]);
|
|
501
|
+
return (_jsxs("div", { className: "cs-navigator", role: "navigation", "aria-label": "Scene Navigator", children: [_jsxs("div", { className: "cs-nav-mobile-bar", children: [_jsx("span", { className: "cs-nav-mobile-title", children: t('navigator.title') }), _jsx("button", { type: "button", className: "cs-nav-mobile-close", "aria-label": "Close navigator", onClick: () => setNavVisible(false), children: _jsx(CloseIcon, { size: 18 }) })] }), _jsxs("div", { className: "cs-nav-list", children: [_jsx("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: handleFrontmatter, title: t('navigator.frontmatter'), children: _jsx("span", { className: "cs-nav-title", children: t('navigator.frontmatter') }) }), _jsxs("div", { className: "cs-nav-section-header cs-nav-scenes-header", style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '8px' }, children: [_jsx("span", { className: "cs-nav-title", children: t('navigator.title') }), _jsx("span", { className: "cs-nav-count", children: scenes.length })] }), _jsxs("div", { style: { display: 'flex', gap: '4px', alignItems: 'center' }, onClick: e => e.stopPropagation(), children: [_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: () => setShowNavSynopsis(!showNavSynopsis), title: showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', "aria-label": showNavSynopsis ? 'Hide Synopsis & Duration' : 'Show Synopsis & Duration', children: showNavSynopsis ? _jsx(EyeIcon, { size: 14 }) : _jsx(EyeOffIcon, { size: 14 }) }), ai && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleGenerateAllSceneMetadata, disabled: isProcessing, title: "Generate synopsis & estimate duration (all scenes)", "aria-label": "Generate synopsis & estimate duration (all scenes)", style: { opacity: isProcessing ? 0.5 : 1 }, children: _jsx(SparklesIcon, { size: 14 }) })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn", onClick: handleRenumber, title: t('navigator.renumber'), "aria-label": t('navigator.renumber'), children: _jsx(RenumberIcon, { size: 14 }) }))] })] }), isProcessing && progressText && (_jsxs("div", { className: "cs-nav-progress", style: {
|
|
519
502
|
display: 'flex',
|
|
520
503
|
alignItems: 'center',
|
|
521
504
|
gap: '8px',
|
|
@@ -525,48 +508,59 @@ export function SceneNavigator() {
|
|
|
525
508
|
background: 'var(--cs-ui-bg-panel-subtle, #f4f2ea)',
|
|
526
509
|
borderBottom: '1px solid var(--cs-ui-border-light, #e2e0d6)',
|
|
527
510
|
fontWeight: 500,
|
|
528
|
-
}, children: [_jsx("span", { className: "cs-spinner", style: { width: '10px', height: '10px', borderWidth: '1.5px' } }), _jsx("span", { children: progressText })] })), scenes.map((scene, idx) =>
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
511
|
+
}, children: [_jsx("span", { className: "cs-spinner", style: { width: '10px', height: '10px', borderWidth: '1.5px' } }), _jsx("span", { children: progressText })] })), scenes.map((scene, idx) => {
|
|
512
|
+
const issue = numberIssues[idx] ?? null;
|
|
513
|
+
const omitted = scene.status.toLowerCase() === 'omitted';
|
|
514
|
+
// The id is the scene number, so a missing id is one note, not two.
|
|
515
|
+
const note = issue ? issueLabel(issue) : scene.id ? null : t('navigator.missingId');
|
|
516
|
+
const notes = note ? [note] : [];
|
|
517
|
+
return (_jsxs("div", { role: "button", tabIndex: 0, "aria-label": `Jump to ${scene.heading}${notes.length ? ` (${notes.join('; ')})` : ''}`, onKeyDown: (e) => {
|
|
518
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
519
|
+
e.preventDefault();
|
|
520
|
+
handleJump(scene);
|
|
521
|
+
}
|
|
522
|
+
}, className: [
|
|
523
|
+
'cs-nav-item',
|
|
524
|
+
// No {#id}: comments, scene-level diff, and page locking can't anchor to it.
|
|
525
|
+
scene.id ? '' : 'cs-nav-scene-missing-id',
|
|
526
|
+
issue ? 'cs-nav-scene-number-issue' : '',
|
|
527
|
+
omitted ? 'cs-nav-scene-omitted' : '',
|
|
528
|
+
dragIndex === idx ? 'cs-nav-dragging' : '',
|
|
529
|
+
dropIndex === idx ? 'cs-nav-drop-target' : '',
|
|
530
|
+
]
|
|
531
|
+
.filter(Boolean)
|
|
532
|
+
.join(' '), draggable: canEdit, onClick: () => handleJump(scene), onDragStart: () => handleDragStart(idx), onDragOver: (e) => handleDragOver(e, idx), onDrop: (e) => handleDrop(e, idx), onDragEnd: () => {
|
|
533
|
+
setDragIndex(null);
|
|
534
|
+
setDropIndex(null);
|
|
535
|
+
}, children: [_jsxs("div", { className: "cs-nav-scene-info", children: [_jsx("div", { className: "cs-nav-heading", children: scene.heading }), showNavSynopsis && (_jsxs("div", { className: "cs-nav-synopsis-row", style: { marginTop: '4px' }, children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: !canEdit ? (scene.synopsis ? _jsx("div", { className: "cs-nav-scene-synopsis", children: scene.synopsis }) : null) : editingSceneIdx === idx ? (_jsx("input", { type: "text", className: "cs-nav-synopsis-input", value: editVal, onChange: e => setEditVal(e.target.value), onBlur: () => commitSynopsis(idx, scene), onKeyDown: e => {
|
|
536
|
+
if (e.key === 'Enter')
|
|
537
|
+
commitSynopsis(idx, scene);
|
|
538
|
+
if (e.key === 'Escape')
|
|
539
|
+
setEditingSceneIdx(null);
|
|
540
|
+
}, autoFocus: true, onClick: e => e.stopPropagation() })) : scene.synopsis ? (_jsx("div", { className: "cs-nav-scene-synopsis", onClick: e => {
|
|
541
|
+
e.stopPropagation();
|
|
542
|
+
setEditingSceneIdx(idx);
|
|
543
|
+
setEditVal(scene.synopsis);
|
|
544
|
+
}, title: "Click to edit synopsis", children: scene.synopsis })) : (_jsx("div", { className: "cs-nav-scene-synopsis cs-nav-synopsis-placeholder", onClick: e => {
|
|
557
545
|
e.stopPropagation();
|
|
558
|
-
e.preventDefault();
|
|
559
546
|
setEditingSceneIdx(idx);
|
|
560
547
|
setEditVal('');
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
548
|
+
}, role: "button", tabIndex: 0, "aria-label": "Add synopsis", onKeyDown: e => {
|
|
549
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
550
|
+
e.stopPropagation();
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
setEditingSceneIdx(idx);
|
|
553
|
+
setEditVal('');
|
|
554
|
+
}
|
|
555
|
+
}, children: "+ Synopsis" })) }), ai && _jsx("button", { type: "button", onClick: (e) => { e.stopPropagation(); void handleSceneMetadata(scene); }, disabled: isProcessing, title: "Generate synopsis & estimate duration", "aria-label": "Generate synopsis & estimate duration", className: "cs-nav-action-btn cs-nav-synopsis-ai-btn", style: { color: 'var(--cs-ui-accent, #c4760a)', ...(isProcessing ? { opacity: 0.5 } : {}) }, children: _jsx(SparklesIcon, { size: 12 }) })] })), (scene.status || (showNavSynopsis && scene.estDuration)) && (_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center', marginTop: '6px' }, children: [scene.status && (_jsx("span", { className: `cs-nav-status cs-status-${scene.status
|
|
556
|
+
.toLowerCase()
|
|
557
|
+
.replace(/\s+/g, '-')}`, children: scene.status })), showNavSynopsis && scene.estDuration && (_jsxs("span", { className: "cs-nav-status", title: "Estimated Onscreen Duration", style: {
|
|
558
|
+
background: 'rgba(217, 119, 6, 0.1)',
|
|
559
|
+
color: 'var(--cs-ui-accent, #c4760a)',
|
|
560
|
+
border: '1px solid rgba(217, 119, 6, 0.25)',
|
|
561
|
+
fontWeight: 600,
|
|
562
|
+
}, children: ["\u23F1 ", scene.estDuration] }))] }))] }), _jsxs("div", { className: "cs-nav-scene-side", children: [scene.id ? (_jsx("div", { className: `cs-nav-scene-num${issue ? ' cs-nav-num-issue' : ''}`, title: issue ? issueLabel(issue) : undefined, children: scene.id })) : (_jsx("div", { className: "cs-nav-missing-id-icon", title: issue ? issueLabel(issue) : t('navigator.missingId'), "aria-hidden": "true", children: "\u26A0" })), canEdit && (_jsx("button", { type: "button", className: "cs-nav-action-btn cs-nav-omit-btn", title: omitted ? t('navigator.restoreScene') : t('navigator.omitScene'), "aria-label": `${omitted ? t('navigator.restoreScene') : t('navigator.omitScene')}: ${scene.heading}`, onClick: e => { e.stopPropagation(); handleOmitToggle(scene); }, children: omitted ? _jsx(RestoreIcon, { size: 12 }) : _jsx(OmitIcon, { size: 12 }) }))] })] }, `${idx}:${scene.id ?? ''}`));
|
|
563
|
+
}), scenes.length === 0 && (_jsx("div", { className: "cs-nav-empty", children: t('navigator.empty') })), _jsx("div", { style: { marginTop: '20px', padding: '0 8px 10px', borderTop: '1px solid #eee', paddingTop: '15px' }, children: _jsxs("button", { type: "button", className: "cs-nav-section-header cs-nav-frontmatter-btn", onClick: () => setStatsOpen(true), style: { width: '100%', textAlign: 'left', display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }, children: [_jsx(StatsIcon, { size: 14 }), " ", _jsx("span", { className: "cs-nav-title", children: "Statistics" })] }) })] }), statsOpen && _jsx(StatisticsDialog, { onClose: () => setStatsOpen(false) })] }));
|
|
570
564
|
}
|
|
571
565
|
/**
|
|
572
566
|
* Reorder scene blocks in the source text.
|
|
@@ -3,10 +3,14 @@ import { useDocument } from '../context/DocumentContext';
|
|
|
3
3
|
import { extractWorkflowData } from '../file/workflow-reports';
|
|
4
4
|
import { getEditorView } from '../editor/editor-bus';
|
|
5
5
|
import { schema } from '../editor/schema';
|
|
6
|
+
import { useCanEdit } from '../extensions/session';
|
|
6
7
|
export function StatisticsDialog({ onClose }) {
|
|
7
8
|
const { ast, text, setText } = useDocument();
|
|
9
|
+
const canEdit = useCanEdit();
|
|
8
10
|
const { scenes, characters } = extractWorkflowData(ast);
|
|
9
11
|
const handleUpdateActor = (charName, actorName) => {
|
|
12
|
+
if (!canEdit)
|
|
13
|
+
return;
|
|
10
14
|
const cleanChar = charName.trim().toUpperCase();
|
|
11
15
|
if (!cleanChar)
|
|
12
16
|
return;
|
|
@@ -78,7 +82,7 @@ export function StatisticsDialog({ onClose }) {
|
|
|
78
82
|
return (_jsx("div", { className: "cs-dialog-overlay", role: "presentation", onClick: e => {
|
|
79
83
|
if (e.target === e.currentTarget)
|
|
80
84
|
onClose();
|
|
81
|
-
}, children: _jsxs("div", { className: "cs-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "cs-stats-dialog-title", style: { maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { className: "cs-panel-header", children: [_jsx("span", { id: "cs-stats-dialog-title", className: "cs-panel-title", children: "Script Statistics" }), _jsx("button", { className: "cs-panel-close", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsxs("div", { className: "cs-panel-body", style: { overflowY: 'auto', flex: 1, padding: '20px' }, children: [_jsxs("div", { className: "cs-stats-summary-grid", children: [_jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: scenes.length }), _jsx("div", { className: "cs-stats-card-lbl", children: "Scenes" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: wordCount.toLocaleString() }), _jsx("div", { className: "cs-stats-card-lbl", children: "Words" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: totalDurationLabel }), _jsx("div", { className: "cs-stats-card-lbl", children: "Est. Duration" })] })] }), _jsxs("div", { className: "cs-stats-section-hdr", children: ["Cast List & Occurrences (", characters.length, ")"] }), characters.length === 0 ? (_jsx("div", { style: { color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }, children: "No character cues detected in the screenplay yet." })) : (_jsxs("table", { className: "cs-stats-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { textAlign: 'left', width: '35%' }, children: "Character" }), _jsx("th", { style: { textAlign: 'left' }, children: "Actor" }), _jsx("th", { style: { textAlign: 'center', width: '15%' }, children: "Scenes" })] }) }), _jsx("tbody", { children: characters.map((c, i) => (_jsxs("tr", { children: [_jsx("td", { style: { fontWeight: '600' }, children: c.name }), _jsx("td", { children: _jsx("input", { type: "text", className: "cs-stats-actor-input", value: c.actor, onChange: (e) => handleUpdateActor(c.name, e.target.value), placeholder: "Assign actor...", "aria-label": `Actor for ${c.name}` }) }), _jsx("td", { style: { textAlign: 'center', fontWeight: '500' }, children: c.sceneCount })] }, i))) })] }))] }), _jsx("div", { style: {
|
|
85
|
+
}, children: _jsxs("div", { className: "cs-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "cs-stats-dialog-title", style: { maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }, children: [_jsxs("div", { className: "cs-panel-header", children: [_jsx("span", { id: "cs-stats-dialog-title", className: "cs-panel-title", children: "Script Statistics" }), _jsx("button", { className: "cs-panel-close", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsxs("div", { className: "cs-panel-body", style: { overflowY: 'auto', flex: 1, padding: '20px' }, children: [_jsxs("div", { className: "cs-stats-summary-grid", children: [_jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: scenes.length }), _jsx("div", { className: "cs-stats-card-lbl", children: "Scenes" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: wordCount.toLocaleString() }), _jsx("div", { className: "cs-stats-card-lbl", children: "Words" })] }), _jsxs("div", { className: "cs-stats-card", children: [_jsx("div", { className: "cs-stats-card-val", children: totalDurationLabel }), _jsx("div", { className: "cs-stats-card-lbl", children: "Est. Duration" })] })] }), _jsxs("div", { className: "cs-stats-section-hdr", children: ["Cast List & Occurrences (", characters.length, ")"] }), characters.length === 0 ? (_jsx("div", { style: { color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }, children: "No character cues detected in the screenplay yet." })) : (_jsxs("table", { className: "cs-stats-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { textAlign: 'left', width: '35%' }, children: "Character" }), _jsx("th", { style: { textAlign: 'left' }, children: "Actor" }), _jsx("th", { style: { textAlign: 'center', width: '15%' }, children: "Scenes" })] }) }), _jsx("tbody", { children: characters.map((c, i) => (_jsxs("tr", { children: [_jsx("td", { style: { fontWeight: '600' }, children: c.name }), _jsx("td", { children: _jsx("input", { type: "text", className: "cs-stats-actor-input", value: c.actor, onChange: (e) => handleUpdateActor(c.name, e.target.value), readOnly: !canEdit, placeholder: "Assign actor...", "aria-label": `Actor for ${c.name}` }) }), _jsx("td", { style: { textAlign: 'center', fontWeight: '500' }, children: c.sceneCount })] }, i))) })] }))] }), _jsx("div", { style: {
|
|
82
86
|
borderTop: '1px solid var(--cs-ui-border-light-subtle, #eeece4)',
|
|
83
87
|
display: 'flex',
|
|
84
88
|
justifyContent: 'flex-end',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set scene i's `{#id}` (its scene number, §7.4) to `ids[i]`, keeping the
|
|
3
|
+
* heading's other attributes; `undefined` leaves the scene as it is.
|
|
4
|
+
*/
|
|
5
|
+
export declare function setSceneIdsInText(text: string, ids: readonly (string | undefined)[]): string;
|
|
6
|
+
/**
|
|
7
|
+
* Omit a scene (§7.4): keep its heading and number, set `& status: omitted`,
|
|
8
|
+
* and keep its body inside a comment so it can be restored.
|
|
9
|
+
*/
|
|
10
|
+
export declare function omitSceneInText(text: string, index: number): string;
|
|
11
|
+
/** Undo omitSceneInText: drop `& status: omitted` and unwrap a body kept in a comment. */
|
|
12
|
+
export declare function restoreSceneInText(text: string, index: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* Replace the document text with `next` as one undoable edit. In the
|
|
15
|
+
* formatted editor only the top-level blocks that changed are replaced, so
|
|
16
|
+
* the caret, scroll and history stay meaningful.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applyTextEdit(next: string, setText: (t: string) => void): void;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { parse, splitAttributeBlock, formatAttributeBlock } from '@sutrata/parser';
|
|
2
|
+
import { buildSceneList } from './scene-list';
|
|
3
|
+
import { getEditorView, getSourceView } from '../editor/editor-bus';
|
|
4
|
+
import { sutraToProsemirror } from '../editor/sutra-to-prosemirror';
|
|
5
|
+
/**
|
|
6
|
+
* Text-level scene edits used by the navigator (renumber, omit, restore),
|
|
7
|
+
* and applyTextEdit, which puts the result into whichever editor is mounted
|
|
8
|
+
* as ONE undoable change.
|
|
9
|
+
*/
|
|
10
|
+
const META = /^&\s+[A-Za-z0-9_-]+:/;
|
|
11
|
+
const LIST_ITEM = /^\s+-(\s|$)/;
|
|
12
|
+
function sceneSpans(text) {
|
|
13
|
+
const scenes = buildSceneList(text);
|
|
14
|
+
return scenes.map((s, i) => ({ start: s.textOffset, end: scenes[i + 1]?.textOffset ?? text.length }));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Split a scene's text into its heading block (heading + metadata, which may
|
|
18
|
+
* be separated by blank lines), its body, and anything from a following
|
|
19
|
+
* section heading on (sections end a scene's content).
|
|
20
|
+
*/
|
|
21
|
+
function splitScene(segment) {
|
|
22
|
+
const lines = segment.split('\n');
|
|
23
|
+
let headEnd = 1;
|
|
24
|
+
for (let i = 1; i < lines.length; i++) {
|
|
25
|
+
const l = lines[i];
|
|
26
|
+
if (META.test(l.trim()) || LIST_ITEM.test(l))
|
|
27
|
+
headEnd = i + 1;
|
|
28
|
+
else if (l.trim() !== '')
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
let bodyEnd = lines.length;
|
|
32
|
+
for (let i = headEnd; i < lines.length; i++) {
|
|
33
|
+
if (/^#(\s|$)/.test(lines[i])) {
|
|
34
|
+
bodyEnd = i;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
head: lines.slice(0, headEnd).filter(l => l.trim() !== ''),
|
|
40
|
+
body: lines.slice(headEnd, bodyEnd).join('\n').trim(),
|
|
41
|
+
rest: lines.slice(bodyEnd).join('\n').trim(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function joinScene(head, body, rest, isLast) {
|
|
45
|
+
return [head.join('\n'), body, rest].filter(Boolean).join('\n\n') + (isLast ? '\n' : '\n\n');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Set (replace or add) `& key: value` in a heading block; null removes it.
|
|
49
|
+
* A new line goes right under the heading (`first`) or after the existing
|
|
50
|
+
* metadata (`last`).
|
|
51
|
+
*/
|
|
52
|
+
function setMeta(head, key, value, where = 'first') {
|
|
53
|
+
const re = new RegExp(`^&\\s+${key}:`);
|
|
54
|
+
const idx = head.findIndex(l => re.test(l.trim()));
|
|
55
|
+
const line = value === null ? null : `& ${key}: ${value}`;
|
|
56
|
+
if (idx >= 0)
|
|
57
|
+
return line === null ? head.filter((_, i) => i !== idx) : head.map((l, i) => (i === idx ? line : l));
|
|
58
|
+
if (line === null)
|
|
59
|
+
return head;
|
|
60
|
+
return where === 'first' ? [head[0], line, ...head.slice(1)] : [...head, line];
|
|
61
|
+
}
|
|
62
|
+
function editScenes(text, edit) {
|
|
63
|
+
const spans = sceneSpans(text);
|
|
64
|
+
let out = text;
|
|
65
|
+
for (let i = spans.length - 1; i >= 0; i--) {
|
|
66
|
+
const { start, end } = spans[i];
|
|
67
|
+
const parts = edit(i, splitScene(out.slice(start, end)));
|
|
68
|
+
if (!parts)
|
|
69
|
+
continue;
|
|
70
|
+
out = out.slice(0, start) + joinScene(parts.head, parts.body, parts.rest, end === text.length) + out.slice(end);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Set scene i's `{#id}` (its scene number, §7.4) to `ids[i]`, keeping the
|
|
76
|
+
* heading's other attributes; `undefined` leaves the scene as it is.
|
|
77
|
+
*/
|
|
78
|
+
export function setSceneIdsInText(text, ids) {
|
|
79
|
+
return editScenes(text, (i, parts) => {
|
|
80
|
+
const id = ids[i];
|
|
81
|
+
if (id === undefined)
|
|
82
|
+
return null;
|
|
83
|
+
const m = /^(\s*##\s+)(.*)$/.exec(parts.head[0]);
|
|
84
|
+
const { body, attrs } = splitAttributeBlock(m[2].trimEnd());
|
|
85
|
+
const heading = `${m[1]}${body.trimEnd()}${formatAttributeBlock(id, attrs)}`;
|
|
86
|
+
return { ...parts, head: [heading, ...parts.head.slice(1)] };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Omit a scene (§7.4): keep its heading and number, set `& status: omitted`,
|
|
91
|
+
* and keep its body inside a comment so it can be restored.
|
|
92
|
+
*/
|
|
93
|
+
export function omitSceneInText(text, index) {
|
|
94
|
+
return editScenes(text, (i, parts) => i !== index ? null : {
|
|
95
|
+
head: setMeta(parts.head, 'status', 'omitted', 'last'),
|
|
96
|
+
body: parts.body ? `<!-- ${parts.body.replace(/-->/g, '-- >')} -->` : '',
|
|
97
|
+
rest: parts.rest,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** Undo omitSceneInText: drop `& status: omitted` and unwrap a body kept in a comment. */
|
|
101
|
+
export function restoreSceneInText(text, index) {
|
|
102
|
+
return editScenes(text, (i, parts) => {
|
|
103
|
+
if (i !== index)
|
|
104
|
+
return null;
|
|
105
|
+
const m = /^<!--\s?([\s\S]*?)\s?-->$/.exec(parts.body);
|
|
106
|
+
return { head: setMeta(parts.head, 'status', null), body: m ? m[1].trim() : parts.body, rest: parts.rest };
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Replace the document text with `next` as one undoable edit. In the
|
|
111
|
+
* formatted editor only the top-level blocks that changed are replaced, so
|
|
112
|
+
* the caret, scroll and history stay meaningful.
|
|
113
|
+
*/
|
|
114
|
+
export function applyTextEdit(next, setText) {
|
|
115
|
+
const view = getEditorView();
|
|
116
|
+
if (view) {
|
|
117
|
+
const target = sutraToProsemirror(parse(next));
|
|
118
|
+
const doc = view.state.doc;
|
|
119
|
+
const a = [];
|
|
120
|
+
const b = [];
|
|
121
|
+
doc.forEach(n => a.push(n));
|
|
122
|
+
target.forEach(n => b.push(n));
|
|
123
|
+
let head = 0;
|
|
124
|
+
while (head < a.length && head < b.length && a[head].eq(b[head]))
|
|
125
|
+
head++;
|
|
126
|
+
let tail = 0;
|
|
127
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail].eq(b[b.length - 1 - tail]))
|
|
128
|
+
tail++;
|
|
129
|
+
if (head === a.length && head === b.length)
|
|
130
|
+
return;
|
|
131
|
+
const posOf = (nodes, index) => nodes.slice(0, index).reduce((p, n) => p + n.nodeSize, 0);
|
|
132
|
+
const from = posOf(a, head);
|
|
133
|
+
const to = posOf(a, a.length - tail);
|
|
134
|
+
view.dispatch(view.state.tr.replaceWith(from, to, b.slice(head, b.length - tail)));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const source = getSourceView();
|
|
138
|
+
if (source) {
|
|
139
|
+
source.dispatch({ changes: { from: 0, to: source.state.doc.length, insert: next } });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
setText(next);
|
|
143
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitAttributeBlock } from '@sutrata/parser';
|
|
1
2
|
/**
|
|
2
3
|
* Derive a flat scene list from Sutra source text.
|
|
3
4
|
* Scenes are identified by scene heading lines (## prefix).
|
|
@@ -14,10 +15,9 @@ export function buildSceneList(text) {
|
|
|
14
15
|
if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
|
|
15
16
|
const headingRaw = trimmed.slice(3).trim();
|
|
16
17
|
const sceneStart = currentOffset;
|
|
17
|
-
//
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const heading = headingRaw.replace(/\s*\{#[^}]+\}/, '').trim();
|
|
18
|
+
// Split the trailing attribute block ({#id lang=…}, §10) off the heading
|
|
19
|
+
const { body, id: sceneId } = splitAttributeBlock(headingRaw);
|
|
20
|
+
const heading = body.trim();
|
|
21
21
|
// Collect metadata (& key: value) until next heading
|
|
22
22
|
let synopsis = '';
|
|
23
23
|
let status = '';
|