@sutrata/editor 0.3.0 → 0.4.1

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 (39) hide show
  1. package/dist/ai/VoiceConfirmDialog.js +3 -1
  2. package/dist/ai/VoiceToolbar.js +8 -8
  3. package/dist/context/DocumentContext.js +30 -24
  4. package/dist/editor/SelectionMenu.js +3 -1
  5. package/dist/file/builtin-exporters.js +20 -24
  6. package/dist/findreplace/FindReplace.js +31 -22
  7. package/dist/findreplace/scroll-to-match.d.ts +1 -1
  8. package/dist/findreplace/scroll-to-match.js +12 -2
  9. package/dist/i18n/useTranslation.js +1 -1
  10. package/dist/import/AIImportDialog.js +20 -18
  11. package/dist/locales/bn.json +195 -13
  12. package/dist/locales/en.json +194 -12
  13. package/dist/locales/gu.json +195 -13
  14. package/dist/locales/hi.json +195 -13
  15. package/dist/locales/kn.json +195 -13
  16. package/dist/locales/ml.json +195 -13
  17. package/dist/locales/mr.json +195 -13
  18. package/dist/locales/or.json +195 -13
  19. package/dist/locales/pa.json +194 -12
  20. package/dist/locales/si.json +195 -13
  21. package/dist/locales/ta.json +195 -13
  22. package/dist/locales/te.json +195 -13
  23. package/dist/navigator/SceneNavigator.js +118 -62
  24. package/dist/navigator/StatisticsDialog.js +4 -2
  25. package/dist/navigator/scene-list.d.ts +7 -0
  26. package/dist/navigator/scene-list.js +14 -0
  27. package/dist/shell/AppBar.js +2 -2
  28. package/dist/shell/AppShell.js +1 -1
  29. package/dist/shell/ConfirmDialog.js +4 -2
  30. package/dist/shell/ElementToolbar.js +4 -2
  31. package/dist/shell/LanguageStatus.js +3 -1
  32. package/dist/shell/StatusBar.js +1 -1
  33. package/dist/shell/VersionHistory.js +4 -2
  34. package/dist/source/find-highlight-source.d.ts +1 -1
  35. package/dist/source/find-highlight-source.js +3 -2
  36. package/dist/styles/screenplay.css +12 -0
  37. package/dist/titlepage/ExportDialog.js +6 -6
  38. package/dist/titlepage/TitlePageDialog.js +2 -2
  39. package/package.json +1 -1
@@ -3,9 +3,11 @@ import { useState, useEffect } from 'react';
3
3
  import { useAI } from '../extensions/ai-provider';
4
4
  import { formatTranscript as formatWithAI } from './format-transcript';
5
5
  import { SparklesIcon } from '../shell/icons';
6
+ import { useTranslation } from '../i18n/useTranslation';
6
7
  /** Rendered only where useAI() is non-null (VoiceToolbar, SelectionMenu's Format). */
7
8
  export function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCancel }) {
8
9
  const ai = useAI();
10
+ const { t } = useTranslation();
9
11
  const [formattedText, setFormattedText] = useState('');
10
12
  const [isLoading, setIsLoading] = useState(true);
11
13
  const [error, setError] = useState('');
@@ -33,5 +35,5 @@ export function VoiceConfirmDialog({ rawTranscript, langCode, onConfirm, onCance
33
35
  localStorage.setItem('sutrata_always_auto_insert', alwaysAutoInsert ? 'true' : 'false');
34
36
  onConfirm(formattedText);
35
37
  };
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" })] })] })) })] }) }));
38
+ 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": t('voice.title'), 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: t('voice.title') })] }), _jsx("button", { type: "button", className: "cs-fr-close", onClick: onCancel, "aria-label": t('common.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: t('voice.formatting') })] })) : (_jsxs(_Fragment, { children: [error && (_jsx("div", { className: "cs-voice-error", role: "alert", children: t('voice.notice').replace('{error}', error) })), _jsxs("div", { className: "cs-voice-grid", children: [_jsxs("div", { className: "cs-voice-column", children: [_jsx("span", { className: "cs-voice-col-title", children: t('voice.raw') }), _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: t('voice.formatted') }), _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: t('voice.autoInsert') })] }) }), _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: t('voice.discard') }), _jsx("button", { type: "button", className: "cs-voice-btn-primary", onClick: handleConfirm, children: t('voice.insert') })] })] })) })] }) }));
37
39
  }
@@ -26,7 +26,7 @@ export function VoiceToolbar() {
26
26
  const ai = useAI();
27
27
  const speech = useSpeech();
28
28
  const sessionRef = useRef(null);
29
- const { locale } = useTranslation();
29
+ const { locale, t } = useTranslation();
30
30
  const defaultLang = VOICE_LANGUAGES.find(l => l.base === locale)?.code || 'en-US';
31
31
  const [langCode, setLangCode] = useState(defaultLang);
32
32
  const [isListening, setIsListening] = useState(false);
@@ -84,10 +84,10 @@ export function VoiceToolbar() {
84
84
  const result = await formatTranscript(ai, fullTranscript, langCode);
85
85
  insertSutra(result.trim(), mode);
86
86
  setVoiceActive(false);
87
- showToast('Voice dictation inserted.', 'success');
87
+ showToast(t('voice.toast.inserted'), 'success');
88
88
  }
89
89
  catch (e) {
90
- showToast(`Auto-insert failed: ${e.message || String(e)}. Opening confirmation dialog.`, 'warn');
90
+ showToast(t('voice.toast.autoFailed').replace('{error}', e.message || String(e)), 'warn');
91
91
  setShowConfirm(true);
92
92
  }
93
93
  finally {
@@ -101,11 +101,11 @@ export function VoiceToolbar() {
101
101
  const handleStart = async () => {
102
102
  if (!(await ai.isConfigured())) {
103
103
  if (ai.openSetup) {
104
- showToast('AI must be set up to format dictated text. Opening Setup...', 'info');
104
+ showToast(t('voice.toast.setup'), 'info');
105
105
  ai.openSetup();
106
106
  }
107
107
  else {
108
- showToast(`${ai.displayName} is not available right now.`, 'warn');
108
+ showToast(t('ai.unavailable').replace('{name}', ai.displayName), 'warn');
109
109
  }
110
110
  return;
111
111
  }
@@ -161,7 +161,7 @@ export function VoiceToolbar() {
161
161
  insertSutra(formattedText, mode);
162
162
  setShowConfirm(false);
163
163
  setVoiceActive(false);
164
- showToast('Screenplay formatted and inserted.', 'success');
164
+ showToast(t('voice.toast.formatted'), 'success');
165
165
  };
166
166
  const activeBaseLang = VOICE_LANGUAGES.find(l => l.code === langCode)?.base || 'en';
167
167
  const cheatsheet = LOCALIZED_COMMANDS[activeBaseLang] || LOCALIZED_COMMANDS.en;
@@ -172,10 +172,10 @@ export function VoiceToolbar() {
172
172
  setVoiceActive(false);
173
173
  } }));
174
174
  }
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: () => {
175
+ return (_jsxs("div", { className: "cs-voice-toolbar", role: "region", "aria-label": t('voice.toolbar.aria'), children: [_jsxs("div", { className: "cs-voice-toolbar-row", children: [_jsxs("div", { className: "cs-voice-toolbar-badge", children: [_jsx(MicIcon, { size: 14 }), _jsx("span", { children: t('appbar.voice') })] }), _jsx("select", { className: "cs-voice-select", value: langCode, onChange: e => setLangCode(e.target.value), disabled: isListening, "aria-label": t('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: t('voice.stopFormat') }), _jsx("canvas", { ref: canvasRef, width: 110, height: 26, className: "cs-voice-waveform", role: "img", "aria-label": t('voice.wave') })] })) : (_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 ? t('voice.formattingBtn') : t('voice.start') })] }), transcript && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "cs-voice-btn-secondary", onClick: handleFormatCaptured, disabled: isFormatting, children: t('voice.reformat') }), _jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: () => {
176
176
  setTranscript('');
177
177
  setError('');
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 => {
178
+ }, children: t('voice.clear') })] }))] })), _jsx("button", { type: "button", className: "cs-voice-btn-ghost", onClick: handleCancel, children: t('common.close') }), !isSupported && (_jsx("span", { className: "cs-voice-warning", children: t('voice.notSupported').replace('{name}', speech.displayName) }))] }), (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: t('voice.speakNow') })] })) : (_jsx("textarea", { className: "cs-voice-textarea", value: transcript, onChange: (e) => setTranscript(e.target.value), placeholder: t('voice.editCaptured'), 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: t('voice.commands') }), cheatsheet.map(c => {
179
179
  if (c.type === 'literal')
180
180
  return null;
181
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));
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useTranslation } from '../i18n/useTranslation';
2
3
  import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react';
3
4
  import { parse, importFountain, fountainDocToSutra } from '@sutrata/parser';
4
5
  import { StorageAdapterProvider } from '../extensions/storage-adapter';
@@ -66,6 +67,11 @@ function useRegistryProp(value, create) {
66
67
  return useMemo(() => (Array.isArray(value) || !value ? create(value ?? []) : value), [value]); // eslint-disable-line react-hooks/exhaustive-deps
67
68
  }
68
69
  export function DocumentProvider({ children, storageAdapter, aiProvider, speechProvider, session, panels, commands, exporters, decorationProviders, collabBinding, }) {
70
+ const { t } = useTranslation();
71
+ // Callbacks and mount effects read the latest translator through a ref so a
72
+ // language switch never re-runs them.
73
+ const tRef = useRef(t);
74
+ tRef.current = t;
69
75
  const panelRegistry = useRegistryProp(panels, createPanelRegistry);
70
76
  const commandRegistry = useRegistryProp(commands, createCommandRegistry);
71
77
  const exportRegistry = useRegistryProp(exporters, createExportRegistry);
@@ -170,7 +176,7 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
170
176
  if (!cancelled && handle)
171
177
  fileHandleRef.current = handle;
172
178
  }
173
- showToast('Recovered unsaved changes from your last session.', 'info');
179
+ showToast(tRef.current('doc.recovered'), 'info');
174
180
  }
175
181
  catch {
176
182
  // Best-effort recovery; fall back to the empty template on failure.
@@ -258,7 +264,7 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
258
264
  catch {
259
265
  if (!autosaveFailedRef.current) {
260
266
  autosaveFailedRef.current = true;
261
- showToast('Autosave failed — your browser storage may be full or unavailable.', 'warn');
267
+ showToast(tRef.current('doc.autosaveFailed'), 'warn');
262
268
  }
263
269
  }
264
270
  }, [storageAdapter, showToast, applyExternalText]);
@@ -301,9 +307,9 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
301
307
  return;
302
308
  }
303
309
  showConfirm({
304
- title: 'Discard Unsaved Changes?',
305
- message: 'Starting a new screenplay will discard all unsaved edits to your current document.',
306
- confirmLabel: 'Discard & Create New',
310
+ title: tRef.current('doc.discardTitle'),
311
+ message: tRef.current('doc.newMsg'),
312
+ confirmLabel: tRef.current('doc.newConfirm'),
307
313
  destructive: true,
308
314
  onConfirm: doNew,
309
315
  });
@@ -355,9 +361,9 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
355
361
  }
356
362
  await new Promise((resolve) => {
357
363
  showConfirm({
358
- title: 'Discard Unsaved Changes?',
359
- message: 'Opening a different screenplay will discard any unsaved edits to your current document.',
360
- confirmLabel: 'Discard & Open',
364
+ title: tRef.current('doc.discardTitle'),
365
+ message: tRef.current('doc.openMsg'),
366
+ confirmLabel: tRef.current('doc.openConfirm'),
361
367
  destructive: true,
362
368
  onConfirm: () => { void doOpen().then(resolve); },
363
369
  onCancel: () => resolve(),
@@ -389,7 +395,7 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
389
395
  if (result.handle)
390
396
  void storageAdapter.persistFileHandle?.(result.savedName, result.handle);
391
397
  void refreshVersions(result.savedName);
392
- showToast(`Saved ${result.savedName}`, 'success');
398
+ showToast(tRef.current('doc.saved').replace('{name}', result.savedName), 'success');
393
399
  }
394
400
  }, [filePath, text, showToast, storageAdapter, refreshVersions]);
395
401
  const saveFileAs = useCallback(async () => {
@@ -406,7 +412,7 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
406
412
  if (result.handle)
407
413
  void storageAdapter.persistFileHandle?.(result.savedName, result.handle);
408
414
  void refreshVersions(result.savedName);
409
- showToast(`Saved as ${result.savedName}`, 'success');
415
+ showToast(tRef.current('doc.savedAs').replace('{name}', result.savedName), 'success');
410
416
  }
411
417
  }, [filePath, text, showToast, storageAdapter, refreshVersions]);
412
418
  // Reimports a .docx previously exported via exportToDocx (optionally corrected/proofread
@@ -422,15 +428,15 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
422
428
  return warnings;
423
429
  return new Promise((resolve) => {
424
430
  showConfirm({
425
- title: 'Import Word Document',
426
- message: 'Replace the current screenplay content with the imported Word document? This cannot be undone.',
427
- confirmLabel: 'Replace Screenplay',
431
+ title: tRef.current('doc.importTitle'),
432
+ message: tRef.current('doc.importMsg'),
433
+ confirmLabel: tRef.current('doc.importConfirm'),
428
434
  destructive: true,
429
435
  onConfirm: () => {
430
436
  const frontmatterRaw = ast.frontmatter?.raw;
431
437
  const newText = frontmatterRaw ? `${frontmatterRaw}\n${bodyText}\n` : `${bodyText}\n`;
432
438
  setText(newText);
433
- showToast('Word document imported successfully', 'success');
439
+ showToast(tRef.current('doc.imported'), 'success');
434
440
  resolve(warnings);
435
441
  },
436
442
  onCancel: () => resolve(null),
@@ -450,25 +456,25 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
450
456
  const restoreVersion = useCallback((entry) => {
451
457
  setText(entry.content); // setText already marks the document dirty
452
458
  setVersionHistoryVisible(false);
453
- showToast('Version restored — remember to save.', 'info');
459
+ showToast(tRef.current('doc.versionRestored'), 'info');
454
460
  }, [setText, showToast]);
455
461
  const clearVersionHistory = useCallback(async () => {
456
462
  if (!filePath)
457
463
  return;
458
464
  return new Promise((resolve) => {
459
465
  showConfirm({
460
- title: 'Delete Version History?',
461
- message: `Delete all ${versions.length} saved versions of ${filePath}? This cannot be undone.`,
462
- confirmLabel: 'Delete Version History',
466
+ title: tRef.current('doc.clearVersionsTitle'),
467
+ message: tRef.current('doc.clearVersionsMsg').replace('{count}', String(versions.length)).replace('{file}', filePath),
468
+ confirmLabel: tRef.current('version.delete'),
463
469
  destructive: true,
464
470
  onConfirm: async () => {
465
471
  try {
466
472
  await storageAdapter.deleteAllVersions?.(filePath);
467
473
  setVersions([]);
468
- showToast('Version history cleared', 'success');
474
+ showToast(tRef.current('doc.versionsCleared'), 'success');
469
475
  }
470
476
  catch {
471
- showToast('Failed to clear version history', 'error');
477
+ showToast(tRef.current('doc.versionsClearFailed'), 'error');
472
478
  }
473
479
  resolve();
474
480
  },
@@ -669,10 +675,10 @@ export function DocumentProvider({ children, storageAdapter, aiProvider, speechP
669
675
  return;
670
676
  }
671
677
  showConfirm({
672
- title: 'Unsaved Changes',
673
- message: 'You have unsaved changes. Save them before closing?',
674
- confirmLabel: 'Save & Close',
675
- cancelLabel: 'Cancel',
678
+ title: tRef.current('doc.unsavedTitle'),
679
+ message: tRef.current('doc.unsavedMsg'),
680
+ confirmLabel: tRef.current('doc.saveClose'),
681
+ cancelLabel: tRef.current('common.cancel'),
676
682
  onConfirm: () => {
677
683
  void saveFile().then(() => {
678
684
  // saveFile only clears isDirty on success — if the user cancelled a
@@ -6,6 +6,7 @@ import { insertSutra } from './insert-helper';
6
6
  import { VoiceConfirmDialog } from '../ai/VoiceConfirmDialog';
7
7
  import { SparklesIcon } from '../shell/icons';
8
8
  import { useAI } from '../extensions/ai-provider';
9
+ import { useTranslation } from '../i18n/useTranslation';
9
10
  const VOICE_LANG_MAP = {
10
11
  en: 'en-US',
11
12
  hi: 'hi-IN',
@@ -35,6 +36,7 @@ function isSelectionInEditor(sel) {
35
36
  export function SelectionMenu() {
36
37
  const { currentLanguage } = useLanguage();
37
38
  const { mode } = useDocument();
39
+ const { t } = useTranslation();
38
40
  const ai = useAI();
39
41
  const [menuPosition, setMenuPosition] = useState(null);
40
42
  const [selectedText, setSelectedText] = useState('');
@@ -100,5 +102,5 @@ export function SelectionMenu() {
100
102
  return (_jsxs("div", { className: "cs-selection-menu", style: {
101
103
  left: `${menuPosition.x}px`,
102
104
  top: `${menuPosition.y}px`,
103
- }, role: "toolbar", "aria-label": "Text Selection Actions", children: [ai && (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "cs-selection-btn cs-selection-btn-format", onClick: handleFormat, title: "Format selected text using AI", children: [_jsx(SparklesIcon, { size: 12 }), _jsx("span", { children: "Format" })] }), _jsx("div", { className: "cs-selection-sep" })] })), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCut, children: "Cut" }), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCopy, children: "Copy" })] }));
105
+ }, role: "toolbar", "aria-label": t('selection.label'), children: [ai && (_jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "cs-selection-btn cs-selection-btn-format", onClick: handleFormat, title: t('selection.formatTitle'), children: [_jsx(SparklesIcon, { size: 12 }), _jsx("span", { children: t('selection.format') })] }), _jsx("div", { className: "cs-selection-sep" })] })), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCut, children: t('selection.cut') }), _jsx("button", { type: "button", className: "cs-selection-btn", onClick: handleCopy, children: t('selection.copy') })] }));
104
106
  }
@@ -1,26 +1,22 @@
1
1
  import { exportFountain } from '@sutrata/parser';
2
2
  import { exportToDocx } from './docx-exporter';
3
3
  import { extractWorkflowData, exportOneLinerCsv, exportShotListCsv, exportCastReportCsv, exportOneLinerDocx, exportShotListDocx, exportCastReportDocx, openPrintPreview, openScreenplayPrintPreview, } from './workflow-reports';
4
- /** Short descriptions of the built-in report families, shown under each report's title. */
5
- const REPORT_DESCRIPTIONS = {
6
- 'One-Liner Schedule': 'A scene-by-scene production stripboard breakdown with locations, cast, and timings.',
7
- 'Shot List': 'Camera shot specifications extracted from scene headings and production notes.',
8
- 'Cast Breakdown': 'Character occurrences, dialogue counts, and assigned actors list.',
9
- };
10
4
  const suffix = (ctx) => (ctx.options.romanized ? '_romanized' : '');
11
5
  const titleSuffix = (ctx) => (ctx.options.romanized ? ' (ROMANIZED)' : '');
12
6
  /** CSV with a BOM so Excel reads UTF-8 (Indic text) correctly. */
13
7
  function csv(content) {
14
8
  return new Blob(['\uFEFF', content], { type: 'text/csv;charset=utf-8' });
15
9
  }
16
- function report(id, family, formatLabel, ext, stem, run, successMessage) {
10
+ function report(t, id, familyKey, formatKey, ext, stem, run, successKey) {
11
+ const family = t(`export.family.${familyKey}`);
12
+ const formatLabel = t(`export.format.${formatKey}`);
17
13
  return {
18
14
  id, label: `${family} — ${formatLabel}`, group: 'report', family, formatLabel,
19
- description: REPORT_DESCRIPTIONS[family],
15
+ description: t(`export.family.${familyKey}.desc`),
20
16
  supports: { romanize: true },
21
17
  run,
22
18
  fileName: ctx => `${ctx.baseName}_${stem}${suffix(ctx)}.${ext ?? 'html'}`,
23
- successMessage,
19
+ successMessage: t(successKey),
24
20
  };
25
21
  }
26
22
  /**
@@ -31,42 +27,42 @@ function report(id, family, formatLabel, ext, stem, run, successMessage) {
31
27
  export function builtinExporters(t) {
32
28
  return [
33
29
  {
34
- id: 'pdf', label: 'PDF / Print Preview', group: 'screenplay',
35
- description: 'Print-ready industry screenplay layout styled according to your active visual template.',
30
+ id: 'pdf', label: t('export.pdfLabel'), group: 'screenplay',
31
+ description: t('export.pdfDesc'),
36
32
  supports: { style: true, skipNotes: true, romanize: true },
37
33
  run: async (ctx) => {
38
34
  const title = `${ctx.baseName.toUpperCase()} - SCREENPLAY${titleSuffix(ctx)}`;
39
35
  await openScreenplayPrintPreview(ctx.ast, title, ctx.options.styleId, ctx.customStyles, ctx.options.skipNotes);
40
36
  },
41
37
  fileName: ctx => `${ctx.baseName}${suffix(ctx)}.pdf`,
42
- successMessage: 'Opened Screenplay Print Preview',
38
+ successMessage: t('export.done.pdf'),
43
39
  },
44
40
  {
45
- id: 'docx', label: 'Microsoft Word (.docx)', group: 'screenplay',
41
+ id: 'docx', label: t('export.docxLabel'), group: 'screenplay',
46
42
  description: t('export.docxDesc'),
47
43
  supports: { style: true, skipNotes: true, romanize: true },
48
44
  run: ctx => exportToDocx(ctx.ast, ctx.options.styleId, ctx.customStyles, ctx.options.skipNotes),
49
45
  fileName: ctx => `${ctx.baseName}${suffix(ctx)}.docx`,
50
- successMessage: 'Exported Word document (.docx)',
46
+ successMessage: t('export.done.docx'),
51
47
  },
52
48
  {
53
- id: 'fountain', label: 'Fountain (.fountain)', group: 'screenplay',
49
+ id: 'fountain', label: t('export.fountainLabel'), group: 'screenplay',
54
50
  description: t('export.fountainDesc'),
55
51
  run: async (ctx) => {
56
52
  const result = exportFountain(ctx.ast);
57
53
  return { blob: new Blob([result.text], { type: 'text/plain;charset=utf-8' }), warnings: result.warnings };
58
54
  },
59
55
  fileName: ctx => `${ctx.baseName}.fountain`,
60
- successMessage: 'Exported Fountain screenplay',
56
+ successMessage: t('export.done.fountain'),
61
57
  },
62
- report('oneliner-print', 'One-Liner Schedule', 'Print View', null, 'one_liner', async (ctx) => {
58
+ report(t, 'oneliner-print', 'oneliner', 'print', null, 'one_liner', async (ctx) => {
63
59
  await openPrintPreview(extractWorkflowData(ctx.ast).scenes, `${ctx.baseName.toUpperCase()} - ONE-LINER SCHEDULE${titleSuffix(ctx)}`);
64
- }, 'Opened One-Liner Schedule'),
65
- report('oneliner-docx', 'One-Liner Schedule', 'Word (.docx)', 'docx', 'one_liner', ctx => exportOneLinerDocx(extractWorkflowData(ctx.ast).scenes), 'Exported One-Liner DOCX'),
66
- report('oneliner-csv', 'One-Liner Schedule', 'Spreadsheet (.csv)', 'csv', 'one_liner', async (ctx) => csv(exportOneLinerCsv(extractWorkflowData(ctx.ast).scenes)), 'Exported One-Liner CSV'),
67
- report('shotlist-docx', 'Shot List', 'Word (.docx)', 'docx', 'shot_list', ctx => exportShotListDocx(extractWorkflowData(ctx.ast).scenes), 'Exported Shot List DOCX'),
68
- report('shotlist-csv', 'Shot List', 'Spreadsheet (.csv)', 'csv', 'shot_list', async (ctx) => csv(exportShotListCsv(extractWorkflowData(ctx.ast).scenes)), 'Exported Shot List CSV'),
69
- report('cast-docx', 'Cast Breakdown', 'Word (.docx)', 'docx', 'cast_report', ctx => exportCastReportDocx(extractWorkflowData(ctx.ast).characters), 'Exported Cast Report DOCX'),
70
- report('cast-csv', 'Cast Breakdown', 'Spreadsheet (.csv)', 'csv', 'cast_report', async (ctx) => csv(exportCastReportCsv(extractWorkflowData(ctx.ast).characters)), 'Exported Cast Report CSV'),
60
+ }, 'export.done.oneliner.print'),
61
+ report(t, 'oneliner-docx', 'oneliner', 'docx', 'docx', 'one_liner', ctx => exportOneLinerDocx(extractWorkflowData(ctx.ast).scenes), 'export.done.oneliner.docx'),
62
+ report(t, 'oneliner-csv', 'oneliner', 'csv', 'csv', 'one_liner', async (ctx) => csv(exportOneLinerCsv(extractWorkflowData(ctx.ast).scenes)), 'export.done.oneliner.csv'),
63
+ report(t, 'shotlist-docx', 'shotlist', 'docx', 'docx', 'shot_list', ctx => exportShotListDocx(extractWorkflowData(ctx.ast).scenes), 'export.done.shotlist.docx'),
64
+ report(t, 'shotlist-csv', 'shotlist', 'csv', 'csv', 'shot_list', async (ctx) => csv(exportShotListCsv(extractWorkflowData(ctx.ast).scenes)), 'export.done.shotlist.csv'),
65
+ report(t, 'cast-docx', 'cast', 'docx', 'docx', 'cast_report', ctx => exportCastReportDocx(extractWorkflowData(ctx.ast).characters), 'export.done.cast.docx'),
66
+ report(t, 'cast-csv', 'cast', 'csv', 'csv', 'cast_report', async (ctx) => csv(exportCastReportCsv(extractWorkflowData(ctx.ast).characters)), 'export.done.cast.csv'),
71
67
  ];
72
68
  }
@@ -19,16 +19,24 @@ export function FindReplace() {
19
19
  const [currentIndex, setCurrentIndex] = useState(0);
20
20
  const queryRef = useRef(null);
21
21
  const nextIndexAfterReplace = useRef(null);
22
- // Recompute matches whenever query/text/caseSensitive/mode changes.
22
+ // What the next highlight pass should scroll: 'none' (text edits), 'soft'
23
+ // (query typing: only if the match is off-screen), 'center' (navigation).
24
+ const pendingScroll = useRef('none');
25
+ const lastSearch = useRef({ query: '', caseSensitive: false, mode: mode });
26
+ // Recompute matches whenever query/text/caseSensitive/mode changes — but only
27
+ // while the panel is open, so a leftover query never affects the editor.
23
28
  // In formatted mode we search the PM doc's display text (not raw Sutra) so
24
29
  // offsets are correct; in source mode the CodeMirror doc *is* the raw Sutra
25
30
  // text, so raw-offset matches from find-engine map directly onto it.
26
31
  useEffect(() => {
32
+ if (!findReplaceVisible)
33
+ return;
27
34
  if (!query) {
28
35
  setMatches([]);
29
36
  setCurrentIndex(0);
30
37
  clearFindHighlights();
31
38
  clearSourceFindHighlights();
39
+ lastSearch.current = { query, caseSensitive, mode };
32
40
  return;
33
41
  }
34
42
  let found;
@@ -41,44 +49,43 @@ export function FindReplace() {
41
49
  const view = getEditorView();
42
50
  found = view ? findInPmDoc(view.state.doc, query, { caseSensitive }) : [];
43
51
  }
52
+ const prev = lastSearch.current;
53
+ const searchChanged = prev.query !== query || prev.caseSensitive !== caseSensitive || prev.mode !== mode;
54
+ lastSearch.current = { query, caseSensitive, mode };
44
55
  setMatches(found);
45
- const highlight = mode === 'source' ? setSourceFindHighlights : setFindHighlights;
46
- const scrollTo = mode === 'source' ? scrollToSourceMatch : scrollToPmMatch;
47
56
  if (nextIndexAfterReplace.current !== null) {
48
57
  const idx = Math.min(nextIndexAfterReplace.current, Math.max(0, found.length - 1));
49
58
  setCurrentIndex(idx);
50
59
  nextIndexAfterReplace.current = null;
51
- highlight(found, idx);
52
- if (found.length > 0)
53
- scrollTo(found[idx]);
60
+ pendingScroll.current = 'center';
54
61
  }
55
62
  else {
56
63
  setCurrentIndex(0);
57
- highlight(found, 0);
58
- if (found.length > 0)
59
- scrollTo(found[0]);
64
+ // Plain document edits must not move the viewport.
65
+ pendingScroll.current = searchChanged ? 'soft' : 'none';
60
66
  }
61
67
  // text in deps so we re-search after replace; view not in deps (stable ref)
62
68
  // eslint-disable-next-line react-hooks/exhaustive-deps
63
- }, [query, text, caseSensitive, mode]);
64
- // Scroll to current match and update active highlight whenever index changes.
65
- const didMount = useRef(false);
69
+ }, [query, text, caseSensitive, mode, findReplaceVisible]);
70
+ // Update the active highlight (and scroll if requested) whenever matches/index change.
66
71
  useEffect(() => {
67
- if (!didMount.current) {
68
- didMount.current = true;
72
+ if (!findReplaceVisible || !query)
69
73
  return;
70
- }
74
+ const scroll = pendingScroll.current;
75
+ pendingScroll.current = 'none';
76
+ const cur = matches[currentIndex];
71
77
  if (mode === 'source') {
72
78
  setSourceFindHighlights(matches, currentIndex);
73
- if (matches.length > 0)
74
- scrollToSourceMatch(matches[currentIndex]);
79
+ if (cur && scroll !== 'none')
80
+ scrollToSourceMatch(cur, scroll === 'center');
75
81
  }
76
82
  else {
77
83
  setFindHighlights(matches, currentIndex);
78
- if (matches.length > 0)
79
- scrollToPmMatch(matches[currentIndex]);
84
+ if (cur && scroll !== 'none')
85
+ scrollToPmMatch(cur, scroll === 'center');
80
86
  }
81
- }, [currentIndex, matches, mode]);
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, [currentIndex, matches, mode, findReplaceVisible]);
82
89
  // Clear highlights when the panel closes.
83
90
  useEffect(() => {
84
91
  if (!findReplaceVisible) {
@@ -94,11 +101,13 @@ export function FindReplace() {
94
101
  const goNext = useCallback(() => {
95
102
  if (matches.length === 0)
96
103
  return;
104
+ pendingScroll.current = 'center';
97
105
  setCurrentIndex(i => (i + 1) % matches.length);
98
106
  }, [matches.length]);
99
107
  const goPrev = useCallback(() => {
100
108
  if (matches.length === 0)
101
109
  return;
110
+ pendingScroll.current = 'center';
102
111
  setCurrentIndex(i => (i - 1 + matches.length) % matches.length);
103
112
  }, [matches.length]);
104
113
  const handleReplaceOne = useCallback(() => {
@@ -162,6 +171,6 @@ export function FindReplace() {
162
171
  // Read-only sessions can search but not replace.
163
172
  const isReplace = findMode === 'replace' && canEdit;
164
173
  const hasMatches = matches.length > 0;
165
- return (_jsxs("div", { className: "cs-find-replace", role: "dialog", "aria-label": isReplace ? 'Find and Replace' : 'Find', children: [_jsxs("div", { className: "cs-fr-header", children: [_jsxs("div", { className: "cs-fr-mode-tabs", children: [_jsx("button", { className: `cs-fr-tab${!isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('find'), children: t('findreplace.findOnly') }), canEdit && _jsx("button", { className: `cs-fr-tab${isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('replace'), children: t('findreplace.title') })] }), _jsx("button", { className: "cs-fr-close", "aria-label": "Close", onClick: () => setFindReplaceVisible(false), children: "\u00D7" })] }), _jsxs("div", { className: "cs-fr-row", children: [_jsx("input", { ref: queryRef, className: "cs-fr-input", type: "text", placeholder: t('findreplace.find'), value: query, onChange: e => setQuery(e.target.value), onKeyDown: handleQueryKeyDown, "aria-label": "Find" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Previous match", title: "Previous match (Shift+Enter)", onClick: goPrev, disabled: !hasMatches, children: "\u25B2" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": "Next match", title: "Next match (Enter)", onClick: goNext, disabled: !hasMatches, children: "\u25BC" }), _jsx("button", { type: "button", className: `cs-fr-toggle${caseSensitive ? ' cs-fr-active' : ''}`, onClick: () => setCaseSensitive(v => !v), title: t('findreplace.caseSensitive'), "aria-pressed": caseSensitive, children: "Aa" })] }), _jsxs("div", { className: "cs-fr-status", children: [query && !hasMatches && (_jsx("span", { className: "cs-fr-nomatch", children: t('findreplace.noMatches') })), hasMatches && (_jsxs("span", { className: "cs-fr-nav-count", children: [currentIndex + 1, " of ", matches.length] }))] }), isReplace && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-fr-row", children: _jsx("input", { className: "cs-fr-input", type: "text", placeholder: t('findreplace.replace'), value: replacement, onChange: e => setReplacement(e.target.value), onKeyDown: e => { if (e.key === 'Escape')
166
- setFindReplaceVisible(false); }, "aria-label": "Replace with" }) }), _jsxs("div", { className: "cs-fr-actions", children: [_jsx("button", { className: "cs-fr-btn", onClick: handleReplaceOne, disabled: !hasMatches, children: t('findreplace.replaceOne') }), _jsx("button", { className: "cs-fr-btn", onClick: handleReplaceAll, disabled: !query || !hasMatches, children: t('findreplace.replaceAll') })] })] }))] }));
174
+ return (_jsxs("div", { className: "cs-find-replace", role: "dialog", "aria-label": isReplace ? 'Find and Replace' : 'Find', children: [_jsxs("div", { className: "cs-fr-header", children: [_jsxs("div", { className: "cs-fr-mode-tabs", children: [_jsx("button", { className: `cs-fr-tab${!isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('find'), children: t('findreplace.findOnly') }), canEdit && _jsx("button", { className: `cs-fr-tab${isReplace ? ' cs-fr-tab-active' : ''}`, onClick: () => setFindMode('replace'), children: t('findreplace.title') })] }), _jsx("button", { className: "cs-fr-close", "aria-label": t('common.close'), onClick: () => setFindReplaceVisible(false), children: "\u00D7" })] }), _jsxs("div", { className: "cs-fr-row", children: [_jsx("input", { ref: queryRef, className: "cs-fr-input", type: "text", placeholder: t('findreplace.find'), value: query, onChange: e => setQuery(e.target.value), onKeyDown: handleQueryKeyDown, "aria-label": t('findreplace.find') }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": t('findreplace.prev'), title: t('findreplace.prevTitle'), onClick: goPrev, disabled: !hasMatches, children: "\u25B2" }), _jsx("button", { type: "button", className: "cs-fr-nav-btn", "aria-label": t('findreplace.next'), title: t('findreplace.nextTitle'), onClick: goNext, disabled: !hasMatches, children: "\u25BC" }), _jsx("button", { type: "button", className: `cs-fr-toggle${caseSensitive ? ' cs-fr-active' : ''}`, onClick: () => setCaseSensitive(v => !v), title: t('findreplace.caseSensitive'), "aria-pressed": caseSensitive, children: "Aa" })] }), _jsxs("div", { className: "cs-fr-status", children: [query && !hasMatches && (_jsx("span", { className: "cs-fr-nomatch", children: t('findreplace.noMatches') })), hasMatches && (_jsx("span", { className: "cs-fr-nav-count", children: t('findreplace.countOf').replace('{current}', String(currentIndex + 1)).replace('{total}', String(matches.length)) }))] }), isReplace && (_jsxs(_Fragment, { children: [_jsx("div", { className: "cs-fr-row", children: _jsx("input", { className: "cs-fr-input", type: "text", placeholder: t('findreplace.replace'), value: replacement, onChange: e => setReplacement(e.target.value), onKeyDown: e => { if (e.key === 'Escape')
175
+ setFindReplaceVisible(false); }, "aria-label": t('findreplace.replace') }) }), _jsxs("div", { className: "cs-fr-actions", children: [_jsx("button", { className: "cs-fr-btn", onClick: handleReplaceOne, disabled: !hasMatches, children: t('findreplace.replaceOne') }), _jsx("button", { className: "cs-fr-btn", onClick: handleReplaceAll, disabled: !query || !hasMatches, children: t('findreplace.replaceAll') })] })] }))] }));
167
176
  }
@@ -24,6 +24,6 @@ export declare function findInPmDoc(doc: Node, query: string, options?: {
24
24
  * Highlighting is handled by the find-highlight plugin (decorations) — no selection
25
25
  * dispatch here so focus stays in the find input.
26
26
  */
27
- export declare function scrollToPmMatch(match: PmMatch): void;
27
+ export declare function scrollToPmMatch(match: PmMatch, center?: boolean): void;
28
28
  /** Legacy: maps a flat text-space offset to PM position. Used by tests only. */
29
29
  export declare function textOffsetToPmPos(doc: Node, textOffset: number): number | null;
@@ -52,14 +52,24 @@ export function findInPmDoc(doc, query, options = {}) {
52
52
  * Highlighting is handled by the find-highlight plugin (decorations) — no selection
53
53
  * dispatch here so focus stays in the find input.
54
54
  */
55
- export function scrollToPmMatch(match) {
55
+ export function scrollToPmMatch(match, center = true) {
56
56
  const view = getEditorView();
57
57
  if (!view)
58
58
  return;
59
59
  try {
60
60
  const domNode = view.nodeDOM(match.pmFrom);
61
61
  const el = domNode instanceof Element ? domNode : domNode?.parentElement;
62
- el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
62
+ if (!el)
63
+ return;
64
+ if (!center) {
65
+ // While typing: only scroll when the match is off-screen, and don't animate.
66
+ const r = el.getBoundingClientRect();
67
+ if (r.top >= 0 && r.bottom <= window.innerHeight)
68
+ return;
69
+ el.scrollIntoView({ block: 'center' });
70
+ return;
71
+ }
72
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
63
73
  }
64
74
  catch {
65
75
  // At doc boundary — ignore
@@ -17,7 +17,7 @@ const LOCALES = { en, hi, ta, te, ml, kn, bn, gu, mr, pa, or, si };
17
17
  const TranslationContext = createContext({
18
18
  locale: 'en',
19
19
  setLocale: () => { },
20
- t: (key) => key,
20
+ t: (key) => en[key] ?? key,
21
21
  });
22
22
  const STORAGE_KEY = 'sutrata-ui-locale';
23
23
  export function TranslationProvider({ children }) {