@sutrata/editor 0.1.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/LICENSE +674 -0
- package/dist/ai/OnboardingDialog.d.ts +6 -0
- package/dist/ai/OnboardingDialog.js +115 -0
- package/dist/ai/VoiceConfirmDialog.d.ts +9 -0
- package/dist/ai/VoiceConfirmDialog.js +37 -0
- package/dist/ai/VoiceToolbar.d.ts +2 -0
- package/dist/ai/VoiceToolbar.js +177 -0
- package/dist/ai/ai-client.d.ts +71 -0
- package/dist/ai/ai-client.js +494 -0
- package/dist/ai/commands.d.ts +12 -0
- package/dist/ai/commands.js +140 -0
- package/dist/ai/generated-providers.json +3696 -0
- package/dist/ai/prompts.d.ts +25 -0
- package/dist/ai/prompts.js +59 -0
- package/dist/ai/voice-service.d.ts +15 -0
- package/dist/ai/voice-service.js +81 -0
- package/dist/autocomplete/AutocompletePlugin.d.ts +2 -0
- package/dist/autocomplete/AutocompletePlugin.js +19 -0
- package/dist/autocomplete/suggestions.d.ts +25 -0
- package/dist/autocomplete/suggestions.js +126 -0
- package/dist/context/DocumentContext.d.ts +68 -0
- package/dist/context/DocumentContext.js +605 -0
- package/dist/desktop-api.d.ts +40 -0
- package/dist/editor/EditorView.d.ts +2 -0
- package/dist/editor/EditorView.js +78 -0
- package/dist/editor/SelectionMenu.d.ts +2 -0
- package/dist/editor/SelectionMenu.js +102 -0
- package/dist/editor/attr-pencil-plugin.d.ts +17 -0
- package/dist/editor/attr-pencil-plugin.js +158 -0
- package/dist/editor/character-decoration-plugin.d.ts +26 -0
- package/dist/editor/character-decoration-plugin.js +77 -0
- package/dist/editor/character-view.d.ts +35 -0
- package/dist/editor/character-view.js +112 -0
- package/dist/editor/editor-bus.d.ts +14 -0
- package/dist/editor/editor-bus.js +39 -0
- package/dist/editor/find-highlight-plugin.d.ts +7 -0
- package/dist/editor/find-highlight-plugin.js +57 -0
- package/dist/editor/frontmatter-field-view.d.ts +10 -0
- package/dist/editor/frontmatter-field-view.js +48 -0
- package/dist/editor/frontmatter-field.d.ts +21 -0
- package/dist/editor/frontmatter-field.js +116 -0
- package/dist/editor/grapheme-cursor.d.ts +10 -0
- package/dist/editor/grapheme-cursor.js +104 -0
- package/dist/editor/ime-plugin.d.ts +18 -0
- package/dist/editor/ime-plugin.js +71 -0
- package/dist/editor/insert-helper.d.ts +1 -0
- package/dist/editor/insert-helper.js +42 -0
- package/dist/editor/keymap.d.ts +3 -0
- package/dist/editor/keymap.js +131 -0
- package/dist/editor/language-class-plugin.d.ts +7 -0
- package/dist/editor/language-class-plugin.js +75 -0
- package/dist/editor/plugins.d.ts +1 -0
- package/dist/editor/plugins.js +25 -0
- package/dist/editor/prosemirror-to-sutra.d.ts +2 -0
- package/dist/editor/prosemirror-to-sutra.js +208 -0
- package/dist/editor/scene-heading-view.d.ts +27 -0
- package/dist/editor/scene-heading-view.js +136 -0
- package/dist/editor/schema.d.ts +3 -0
- package/dist/editor/schema.js +135 -0
- package/dist/editor/sutra-to-prosemirror.d.ts +3 -0
- package/dist/editor/sutra-to-prosemirror.js +154 -0
- package/dist/editor/transliterate-input.d.ts +10 -0
- package/dist/editor/transliterate-input.js +88 -0
- package/dist/extensions/storage-adapter.d.ts +59 -0
- package/dist/extensions/storage-adapter.js +9 -0
- package/dist/extensions/types.d.ts +73 -0
- package/dist/extensions/types.js +12 -0
- package/dist/file/docx-exporter.d.ts +8 -0
- package/dist/file/docx-exporter.js +494 -0
- package/dist/file/docx-importer.d.ts +10 -0
- package/dist/file/docx-importer.js +283 -0
- package/dist/file/print-paginator.d.ts +29 -0
- package/dist/file/print-paginator.js +289 -0
- package/dist/file/templates.d.ts +2 -0
- package/dist/file/templates.js +66 -0
- package/dist/file/workflow-reports.d.ts +34 -0
- package/dist/file/workflow-reports.js +719 -0
- package/dist/findreplace/FindReplace.d.ts +2 -0
- package/dist/findreplace/FindReplace.js +164 -0
- package/dist/findreplace/find-engine.d.ts +29 -0
- package/dist/findreplace/find-engine.js +69 -0
- package/dist/findreplace/scroll-to-match.d.ts +29 -0
- package/dist/findreplace/scroll-to-match.js +74 -0
- package/dist/fonts/tofu-detect.d.ts +1 -0
- package/dist/fonts/tofu-detect.js +48 -0
- package/dist/i18n/LanguageContext.d.ts +10 -0
- package/dist/i18n/LanguageContext.js +174 -0
- package/dist/i18n/language-detect.d.ts +1 -0
- package/dist/i18n/language-detect.js +58 -0
- package/dist/i18n/useTranslation.d.ts +11 -0
- package/dist/i18n/useTranslation.js +60 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/locales/bn.json +143 -0
- package/dist/locales/en.json +143 -0
- package/dist/locales/gu.json +143 -0
- package/dist/locales/hi.json +143 -0
- package/dist/locales/kn.json +143 -0
- package/dist/locales/ml.json +143 -0
- package/dist/locales/mr.json +143 -0
- package/dist/locales/or.json +143 -0
- package/dist/locales/pa.json +143 -0
- package/dist/locales/si.json +143 -0
- package/dist/locales/ta.json +143 -0
- package/dist/locales/te.json +143 -0
- package/dist/navigator/SceneNavigator.d.ts +2 -0
- package/dist/navigator/SceneNavigator.js +612 -0
- package/dist/navigator/StatisticsDialog.d.ts +6 -0
- package/dist/navigator/StatisticsDialog.js +146 -0
- package/dist/navigator/scene-list.d.ts +16 -0
- package/dist/navigator/scene-list.js +70 -0
- package/dist/settings/SettingsDialog.d.ts +6 -0
- package/dist/settings/SettingsDialog.js +147 -0
- package/dist/shell/AppBar.d.ts +2 -0
- package/dist/shell/AppBar.js +34 -0
- package/dist/shell/AppShell.d.ts +2 -0
- package/dist/shell/AppShell.js +30 -0
- package/dist/shell/ConfirmDialog.d.ts +16 -0
- package/dist/shell/ConfirmDialog.js +23 -0
- package/dist/shell/ElementToolbar.d.ts +2 -0
- package/dist/shell/ElementToolbar.js +30 -0
- package/dist/shell/LanguageStatus.d.ts +2 -0
- package/dist/shell/LanguageStatus.js +65 -0
- package/dist/shell/StatusBar.d.ts +2 -0
- package/dist/shell/StatusBar.js +54 -0
- package/dist/shell/SutrataLogo.d.ts +8 -0
- package/dist/shell/SutrataLogo.js +7 -0
- package/dist/shell/Toast.d.ts +12 -0
- package/dist/shell/Toast.js +21 -0
- package/dist/shell/ToolbarButton.d.ts +11 -0
- package/dist/shell/ToolbarButton.js +4 -0
- package/dist/shell/VersionHistory.d.ts +12 -0
- package/dist/shell/VersionHistory.js +89 -0
- package/dist/shell/Watermark.d.ts +3 -0
- package/dist/shell/Watermark.js +10 -0
- package/dist/shell/fonts/fonts.css +1133 -0
- package/dist/shell/icons.d.ts +45 -0
- package/dist/shell/icons.js +51 -0
- package/dist/shell/toolbar-actions.d.ts +6 -0
- package/dist/shell/toolbar-actions.js +58 -0
- package/dist/source/SourceView.d.ts +1 -0
- package/dist/source/SourceView.js +106 -0
- package/dist/source/find-highlight-source.d.ts +14 -0
- package/dist/source/find-highlight-source.js +47 -0
- package/dist/source/sutra-lang.d.ts +2 -0
- package/dist/source/sutra-lang.js +97 -0
- package/dist/spellcheck/dictionaries.d.ts +9 -0
- package/dist/spellcheck/dictionaries.js +16 -0
- package/dist/spellcheck/hunspell.d.ts +23 -0
- package/dist/spellcheck/hunspell.js +94 -0
- package/dist/spellcheck/spellcheck-plugin.d.ts +18 -0
- package/dist/spellcheck/spellcheck-plugin.js +75 -0
- package/dist/styles/StyleDialog.d.ts +6 -0
- package/dist/styles/StyleDialog.js +63 -0
- package/dist/styles/builtin-styles.d.ts +43 -0
- package/dist/styles/builtin-styles.js +131 -0
- package/dist/styles/css-adapter.d.ts +32 -0
- package/dist/styles/css-adapter.js +71 -0
- package/dist/styles/docx-adapter.d.ts +7 -0
- package/dist/styles/docx-adapter.js +84 -0
- package/dist/styles/embed-fonts.d.ts +20 -0
- package/dist/styles/embed-fonts.js +81 -0
- package/dist/styles/pagination.d.ts +19 -0
- package/dist/styles/pagination.js +22 -0
- package/dist/styles/print-adapter.d.ts +6 -0
- package/dist/styles/print-adapter.js +288 -0
- package/dist/styles/registry.d.ts +6 -0
- package/dist/styles/registry.js +14 -0
- package/dist/styles/screenplay.css +3006 -0
- package/dist/styles/script-ranges.d.ts +6 -0
- package/dist/styles/script-ranges.js +17 -0
- package/dist/styles/types.d.ts +93 -0
- package/dist/styles/types.js +18 -0
- package/dist/styles/validate.d.ts +12 -0
- package/dist/styles/validate.js +105 -0
- package/dist/templates/templates.d.ts +10 -0
- package/dist/templates/templates.js +181 -0
- package/dist/titlepage/ExportDialog.d.ts +1 -0
- package/dist/titlepage/ExportDialog.js +152 -0
- package/dist/titlepage/TitlePageDialog.d.ts +6 -0
- package/dist/titlepage/TitlePageDialog.js +96 -0
- package/dist/titlepage/frontmatter-form.d.ts +56 -0
- package/dist/titlepage/frontmatter-form.js +245 -0
- package/dist/titlepage/languages.d.ts +10 -0
- package/dist/titlepage/languages.js +31 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +1 -0
- package/package.json +47 -0
- package/scripts/copy-assets.mjs +16 -0
- package/scripts/generate-providers.mjs +127 -0
- package/src/ai/OnboardingDialog.tsx +390 -0
- package/src/ai/VoiceConfirmDialog.tsx +137 -0
- package/src/ai/VoiceToolbar.tsx +321 -0
- package/src/ai/ai-client.ts +596 -0
- package/src/ai/commands.ts +164 -0
- package/src/ai/generated-providers.json +3696 -0
- package/src/ai/prompts.ts +62 -0
- package/src/ai/voice-service.ts +96 -0
- package/src/autocomplete/AutocompletePlugin.ts +21 -0
- package/src/autocomplete/suggestions.ts +147 -0
- package/src/context/DocumentContext.tsx +648 -0
- package/src/desktop-api.d.ts +40 -0
- package/src/editor/EditorView.tsx +82 -0
- package/src/editor/SelectionMenu.tsx +152 -0
- package/src/editor/attr-pencil-plugin.ts +172 -0
- package/src/editor/character-decoration-plugin.ts +104 -0
- package/src/editor/editor-bus.ts +51 -0
- package/src/editor/find-highlight-plugin.ts +68 -0
- package/src/editor/frontmatter-field-view.ts +52 -0
- package/src/editor/frontmatter-field.ts +115 -0
- package/src/editor/grapheme-cursor.ts +99 -0
- package/src/editor/ime-plugin.ts +85 -0
- package/src/editor/insert-helper.ts +43 -0
- package/src/editor/keymap.ts +124 -0
- package/src/editor/language-class-plugin.ts +91 -0
- package/src/editor/plugins.ts +26 -0
- package/src/editor/prosemirror-to-sutra.ts +192 -0
- package/src/editor/scene-heading-view.ts +147 -0
- package/src/editor/schema.ts +156 -0
- package/src/editor/sutra-to-prosemirror.ts +156 -0
- package/src/extensions/storage-adapter.ts +65 -0
- package/src/extensions/types.ts +60 -0
- package/src/file/docx-exporter.ts +585 -0
- package/src/file/docx-importer.ts +313 -0
- package/src/file/print-paginator.ts +301 -0
- package/src/file/templates.ts +73 -0
- package/src/file/workflow-reports.ts +820 -0
- package/src/findreplace/FindReplace.tsx +242 -0
- package/src/findreplace/find-engine.ts +97 -0
- package/src/findreplace/scroll-to-match.ts +89 -0
- package/src/fonts/tofu-detect.ts +53 -0
- package/src/i18n/LanguageContext.tsx +207 -0
- package/src/i18n/language-detect.ts +73 -0
- package/src/i18n/useTranslation.tsx +74 -0
- package/src/index.ts +27 -0
- package/src/locales/bn.json +143 -0
- package/src/locales/en.json +143 -0
- package/src/locales/gu.json +143 -0
- package/src/locales/hi.json +143 -0
- package/src/locales/kn.json +143 -0
- package/src/locales/ml.json +143 -0
- package/src/locales/mr.json +143 -0
- package/src/locales/or.json +143 -0
- package/src/locales/pa.json +143 -0
- package/src/locales/si.json +143 -0
- package/src/locales/ta.json +143 -0
- package/src/locales/te.json +143 -0
- package/src/navigator/SceneNavigator.tsx +844 -0
- package/src/navigator/StatisticsDialog.tsx +249 -0
- package/src/navigator/scene-list.ts +84 -0
- package/src/settings/SettingsDialog.tsx +303 -0
- package/src/shell/AppBar.tsx +354 -0
- package/src/shell/AppShell.tsx +71 -0
- package/src/shell/ConfirmDialog.tsx +71 -0
- package/src/shell/ElementToolbar.tsx +175 -0
- package/src/shell/LanguageStatus.tsx +101 -0
- package/src/shell/StatusBar.tsx +135 -0
- package/src/shell/SutrataLogo.tsx +89 -0
- package/src/shell/Toast.tsx +46 -0
- package/src/shell/ToolbarButton.tsx +30 -0
- package/src/shell/VersionHistory.tsx +212 -0
- package/src/shell/Watermark.tsx +9 -0
- package/src/shell/fonts/fonts.css +1133 -0
- package/src/shell/icons.tsx +335 -0
- package/src/shell/toolbar-actions.ts +59 -0
- package/src/source/SourceView.tsx +111 -0
- package/src/source/find-highlight-source.ts +57 -0
- package/src/source/sutra-lang.ts +121 -0
- package/src/spellcheck/dictionaries.ts +21 -0
- package/src/spellcheck/hunspell.ts +110 -0
- package/src/spellcheck/spellcheck-plugin.ts +83 -0
- package/src/styles/StyleDialog.tsx +148 -0
- package/src/styles/builtin-styles.ts +139 -0
- package/src/styles/css-adapter.ts +76 -0
- package/src/styles/docx-adapter.ts +109 -0
- package/src/styles/embed-fonts.ts +81 -0
- package/src/styles/pagination.ts +27 -0
- package/src/styles/print-adapter.ts +288 -0
- package/src/styles/registry.ts +20 -0
- package/src/styles/screenplay.css +3006 -0
- package/src/styles/script-ranges.ts +17 -0
- package/src/styles/types.ts +104 -0
- package/src/styles/validate.ts +114 -0
- package/src/templates/templates.ts +194 -0
- package/src/titlepage/ExportDialog.tsx +494 -0
- package/src/titlepage/TitlePageDialog.tsx +312 -0
- package/src/titlepage/frontmatter-form.ts +280 -0
- package/src/titlepage/languages.ts +37 -0
- package/src/types.ts +7 -0
- package/tests/AppBar.test.tsx +35 -0
- package/tests/AppShell.test.tsx +22 -0
- package/tests/ElementToolbar.test.tsx +36 -0
- package/tests/LanguageStatus.test.tsx +129 -0
- package/tests/SelectionMenu.test.tsx +71 -0
- package/tests/ToolbarButton.test.tsx +24 -0
- package/tests/ai-onboarding.test.ts +102 -0
- package/tests/ai-status.test.tsx +72 -0
- package/tests/ai-structured.test.ts +94 -0
- package/tests/autocomplete/AutocompletePlugin.test.ts +20 -0
- package/tests/autocomplete/suggestions.test.ts +106 -0
- package/tests/document-context.test.tsx +565 -0
- package/tests/docx-importer.test.ts +160 -0
- package/tests/editor/character-decoration-plugin.test.ts +101 -0
- package/tests/editor/character-extension.test.ts +44 -0
- package/tests/editor/grapheme-cursor.test.ts +118 -0
- package/tests/editor/ime-plugin.test.ts +72 -0
- package/tests/editor/inline-note-bridge.test.ts +32 -0
- package/tests/editor/language-class-plugin.test.ts +265 -0
- package/tests/editor/title-page.test.ts +124 -0
- package/tests/embed-fonts.test.ts +17 -0
- package/tests/exporters.test.ts +365 -0
- package/tests/findreplace/FindReplace.test.tsx +391 -0
- package/tests/findreplace/find-engine.test.ts +86 -0
- package/tests/findreplace/scroll-to-match.test.ts +50 -0
- package/tests/fonts/tofu-detect.test.ts +122 -0
- package/tests/i18n/LanguageContext.test.tsx +146 -0
- package/tests/i18n/language-detect.test.ts +54 -0
- package/tests/i18n/locale-parity.test.ts +25 -0
- package/tests/i18n/useTranslation.test.tsx +45 -0
- package/tests/keymap.test.ts +18 -0
- package/tests/navigator/SceneNavigator.test.tsx +177 -0
- package/tests/navigator/StatisticsDialog.test.tsx +63 -0
- package/tests/navigator/scene-list.test.ts +71 -0
- package/tests/print-paginator.test.ts +82 -0
- package/tests/shell/VersionHistory.test.tsx +129 -0
- package/tests/spellcheck/hunspell.test.ts +82 -0
- package/tests/spellcheck/spellcheck-plugin.test.ts +56 -0
- package/tests/styles.test.ts +315 -0
- package/tests/templates/templates.test.ts +83 -0
- package/tests/titlepage/TitlePageDialog.test.tsx +90 -0
- package/tests/titlepage/apply-title-page.test.ts +61 -0
- package/tests/titlepage/frontmatter-form.test.ts +115 -0
- package/tests/toolbar-actions.test.ts +93 -0
- package/tests/voice-commands.test.ts +58 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +11 -0
- package/vitest.setup.ts +65 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { useDocument } from '../context/DocumentContext';
|
|
4
|
+
import { useTranslation } from '../i18n/useTranslation';
|
|
5
|
+
import { findAll, replaceAll } from './find-engine';
|
|
6
|
+
import { findInPmDoc, scrollToPmMatch } from './scroll-to-match';
|
|
7
|
+
import { getEditorView } from '../editor/editor-bus';
|
|
8
|
+
import { setFindHighlights, clearFindHighlights } from '../editor/find-highlight-plugin';
|
|
9
|
+
import { setSourceFindHighlights, clearSourceFindHighlights, scrollToSourceMatch } from '../source/find-highlight-source';
|
|
10
|
+
export function FindReplace() {
|
|
11
|
+
const { text, setText, mode, findReplaceVisible, setFindReplaceVisible, findMode, setFindMode } = useDocument();
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const [query, setQuery] = useState('');
|
|
14
|
+
const [replacement, setReplacement] = useState('');
|
|
15
|
+
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
16
|
+
const [matches, setMatches] = useState([]);
|
|
17
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
18
|
+
const queryRef = useRef(null);
|
|
19
|
+
const nextIndexAfterReplace = useRef(null);
|
|
20
|
+
// Recompute matches whenever query/text/caseSensitive/mode changes.
|
|
21
|
+
// In formatted mode we search the PM doc's display text (not raw Sutra) so
|
|
22
|
+
// offsets are correct; in source mode the CodeMirror doc *is* the raw Sutra
|
|
23
|
+
// text, so raw-offset matches from find-engine map directly onto it.
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!query) {
|
|
26
|
+
setMatches([]);
|
|
27
|
+
setCurrentIndex(0);
|
|
28
|
+
clearFindHighlights();
|
|
29
|
+
clearSourceFindHighlights();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
let found;
|
|
33
|
+
if (mode === 'source') {
|
|
34
|
+
found = findAll(text, query, { caseSensitive }).map(m => ({
|
|
35
|
+
start: m.start, end: m.end, pmFrom: m.start, pmTo: m.end, text: m.text,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const view = getEditorView();
|
|
40
|
+
found = view ? findInPmDoc(view.state.doc, query, { caseSensitive }) : [];
|
|
41
|
+
}
|
|
42
|
+
setMatches(found);
|
|
43
|
+
const highlight = mode === 'source' ? setSourceFindHighlights : setFindHighlights;
|
|
44
|
+
const scrollTo = mode === 'source' ? scrollToSourceMatch : scrollToPmMatch;
|
|
45
|
+
if (nextIndexAfterReplace.current !== null) {
|
|
46
|
+
const idx = Math.min(nextIndexAfterReplace.current, Math.max(0, found.length - 1));
|
|
47
|
+
setCurrentIndex(idx);
|
|
48
|
+
nextIndexAfterReplace.current = null;
|
|
49
|
+
highlight(found, idx);
|
|
50
|
+
if (found.length > 0)
|
|
51
|
+
scrollTo(found[idx]);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
setCurrentIndex(0);
|
|
55
|
+
highlight(found, 0);
|
|
56
|
+
if (found.length > 0)
|
|
57
|
+
scrollTo(found[0]);
|
|
58
|
+
}
|
|
59
|
+
// text in deps so we re-search after replace; view not in deps (stable ref)
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
+
}, [query, text, caseSensitive, mode]);
|
|
62
|
+
// Scroll to current match and update active highlight whenever index changes.
|
|
63
|
+
const didMount = useRef(false);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!didMount.current) {
|
|
66
|
+
didMount.current = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (mode === 'source') {
|
|
70
|
+
setSourceFindHighlights(matches, currentIndex);
|
|
71
|
+
if (matches.length > 0)
|
|
72
|
+
scrollToSourceMatch(matches[currentIndex]);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
setFindHighlights(matches, currentIndex);
|
|
76
|
+
if (matches.length > 0)
|
|
77
|
+
scrollToPmMatch(matches[currentIndex]);
|
|
78
|
+
}
|
|
79
|
+
}, [currentIndex, matches, mode]);
|
|
80
|
+
// Clear highlights when the panel closes.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (!findReplaceVisible) {
|
|
83
|
+
clearFindHighlights();
|
|
84
|
+
clearSourceFindHighlights();
|
|
85
|
+
}
|
|
86
|
+
}, [findReplaceVisible]);
|
|
87
|
+
// Focus the input when the panel opens.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (findReplaceVisible)
|
|
90
|
+
queryRef.current?.focus();
|
|
91
|
+
}, [findReplaceVisible]);
|
|
92
|
+
const goNext = useCallback(() => {
|
|
93
|
+
if (matches.length === 0)
|
|
94
|
+
return;
|
|
95
|
+
setCurrentIndex(i => (i + 1) % matches.length);
|
|
96
|
+
}, [matches.length]);
|
|
97
|
+
const goPrev = useCallback(() => {
|
|
98
|
+
if (matches.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
setCurrentIndex(i => (i - 1 + matches.length) % matches.length);
|
|
101
|
+
}, [matches.length]);
|
|
102
|
+
const handleReplaceOne = useCallback(() => {
|
|
103
|
+
if (matches.length === 0)
|
|
104
|
+
return;
|
|
105
|
+
const match = matches[currentIndex];
|
|
106
|
+
let newText;
|
|
107
|
+
if (mode === 'source') {
|
|
108
|
+
// In source mode the match's start/end are already raw Sutra offsets.
|
|
109
|
+
newText = text.slice(0, match.start) + replacement + text.slice(match.end);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const needle = match.text;
|
|
113
|
+
// Find the Nth (currentIndex-th) occurrence of the matched display text in the
|
|
114
|
+
// raw Sutra source. Display text and raw source share the same word characters;
|
|
115
|
+
// sigils (##, **, blank lines) don't contain prose words, so occurrence index is stable.
|
|
116
|
+
let rawIdx = -1;
|
|
117
|
+
let count = 0;
|
|
118
|
+
let searchFrom = 0;
|
|
119
|
+
while (searchFrom < text.length) {
|
|
120
|
+
const found = text.indexOf(needle, searchFrom);
|
|
121
|
+
if (found === -1)
|
|
122
|
+
break;
|
|
123
|
+
if (count === currentIndex) {
|
|
124
|
+
rawIdx = found;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
count++;
|
|
128
|
+
searchFrom = found + 1;
|
|
129
|
+
}
|
|
130
|
+
if (rawIdx === -1)
|
|
131
|
+
return;
|
|
132
|
+
newText = text.slice(0, rawIdx) + replacement + text.slice(rawIdx + needle.length);
|
|
133
|
+
}
|
|
134
|
+
nextIndexAfterReplace.current = Math.min(currentIndex, matches.length - 2);
|
|
135
|
+
setText(newText);
|
|
136
|
+
}, [matches, currentIndex, text, replacement, setText, mode]);
|
|
137
|
+
const handleReplaceAll = useCallback(() => {
|
|
138
|
+
if (!query || matches.length === 0)
|
|
139
|
+
return;
|
|
140
|
+
setText(replaceAll(text, query, replacement, { caseSensitive }));
|
|
141
|
+
}, [text, query, replacement, caseSensitive, matches.length, setText]);
|
|
142
|
+
const handleQueryKeyDown = useCallback((e) => {
|
|
143
|
+
if (e.key === 'Escape') {
|
|
144
|
+
setFindReplaceVisible(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
goPrev();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (e.key === 'Enter') {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
goNext();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}, [setFindReplaceVisible, goNext, goPrev]);
|
|
158
|
+
if (!findReplaceVisible)
|
|
159
|
+
return null;
|
|
160
|
+
const isReplace = findMode === 'replace';
|
|
161
|
+
const hasMatches = matches.length > 0;
|
|
162
|
+
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') }), _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')
|
|
163
|
+
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') })] })] }))] }));
|
|
164
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find & Replace engine for Sutrata.
|
|
3
|
+
* NFC-normalized, grapheme-boundary-aware search/replace.
|
|
4
|
+
*/
|
|
5
|
+
export interface FindMatch {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FindOptions {
|
|
11
|
+
caseSensitive?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Find all occurrences of `query` in `text`.
|
|
15
|
+
* Both are NFC-normalized before comparison.
|
|
16
|
+
* Case-insensitivity applies only to scripts with case (Latin, Greek, Cyrillic);
|
|
17
|
+
* it's a silent no-op for caseless scripts (Devanagari, Tamil, etc.).
|
|
18
|
+
*/
|
|
19
|
+
export declare function findAll(text: string, query: string, options?: FindOptions): FindMatch[];
|
|
20
|
+
/**
|
|
21
|
+
* Replace all occurrences of `query` in `text` with `replacement`.
|
|
22
|
+
* Preserves the original text's casing/encoding for replaced segments.
|
|
23
|
+
*/
|
|
24
|
+
export declare function replaceAll(text: string, query: string, replacement: string, options?: FindOptions): string;
|
|
25
|
+
/**
|
|
26
|
+
* Replace all occurrences within a specific range [rangeStart, rangeEnd).
|
|
27
|
+
* Text outside the range is preserved unchanged.
|
|
28
|
+
*/
|
|
29
|
+
export declare function replaceInRange(text: string, query: string, replacement: string, rangeStart: number, rangeEnd: number, options?: FindOptions): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find & Replace engine for Sutrata.
|
|
3
|
+
* NFC-normalized, grapheme-boundary-aware search/replace.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Normalize text for comparison: NFC + optional case-fold.
|
|
7
|
+
*/
|
|
8
|
+
function normalize(text, caseSensitive) {
|
|
9
|
+
const nfc = text.normalize('NFC');
|
|
10
|
+
return caseSensitive ? nfc : nfc.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Find all occurrences of `query` in `text`.
|
|
14
|
+
* Both are NFC-normalized before comparison.
|
|
15
|
+
* Case-insensitivity applies only to scripts with case (Latin, Greek, Cyrillic);
|
|
16
|
+
* it's a silent no-op for caseless scripts (Devanagari, Tamil, etc.).
|
|
17
|
+
*/
|
|
18
|
+
export function findAll(text, query, options = {}) {
|
|
19
|
+
if (!query || !text)
|
|
20
|
+
return [];
|
|
21
|
+
const { caseSensitive = true } = options;
|
|
22
|
+
const normalizedText = normalize(text, caseSensitive);
|
|
23
|
+
const normalizedQuery = normalize(query, caseSensitive);
|
|
24
|
+
if (!normalizedQuery)
|
|
25
|
+
return [];
|
|
26
|
+
const matches = [];
|
|
27
|
+
let searchFrom = 0;
|
|
28
|
+
while (searchFrom < normalizedText.length) {
|
|
29
|
+
const idx = normalizedText.indexOf(normalizedQuery, searchFrom);
|
|
30
|
+
if (idx === -1)
|
|
31
|
+
break;
|
|
32
|
+
matches.push({
|
|
33
|
+
start: idx,
|
|
34
|
+
end: idx + normalizedQuery.length,
|
|
35
|
+
text: text.slice(idx, idx + normalizedQuery.length),
|
|
36
|
+
});
|
|
37
|
+
searchFrom = idx + 1; // Advance by 1 to allow overlapping but not infinite loop
|
|
38
|
+
}
|
|
39
|
+
return matches;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Replace all occurrences of `query` in `text` with `replacement`.
|
|
43
|
+
* Preserves the original text's casing/encoding for replaced segments.
|
|
44
|
+
*/
|
|
45
|
+
export function replaceAll(text, query, replacement, options = {}) {
|
|
46
|
+
const matches = findAll(text, query, options);
|
|
47
|
+
if (matches.length === 0)
|
|
48
|
+
return text;
|
|
49
|
+
let result = '';
|
|
50
|
+
let lastEnd = 0;
|
|
51
|
+
for (const match of matches) {
|
|
52
|
+
result += text.slice(lastEnd, match.start);
|
|
53
|
+
result += replacement;
|
|
54
|
+
lastEnd = match.end;
|
|
55
|
+
}
|
|
56
|
+
result += text.slice(lastEnd);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Replace all occurrences within a specific range [rangeStart, rangeEnd).
|
|
61
|
+
* Text outside the range is preserved unchanged.
|
|
62
|
+
*/
|
|
63
|
+
export function replaceInRange(text, query, replacement, rangeStart, rangeEnd, options = {}) {
|
|
64
|
+
const before = text.slice(0, rangeStart);
|
|
65
|
+
const within = text.slice(rangeStart, rangeEnd);
|
|
66
|
+
const after = text.slice(rangeEnd);
|
|
67
|
+
const replacedWithin = replaceAll(within, query, replacement, options);
|
|
68
|
+
return before + replacedWithin + after;
|
|
69
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Node } from 'prosemirror-model';
|
|
2
|
+
/**
|
|
3
|
+
* Walk the PM doc once and return:
|
|
4
|
+
* - `text`: flat concatenation of all text node content (no sigils, no blank lines)
|
|
5
|
+
* - `pmPositions`: parallel array where pmPositions[i] is the PM doc position
|
|
6
|
+
* corresponding to character i in `text`
|
|
7
|
+
*/
|
|
8
|
+
export declare function buildPmTextMap(doc: Node): {
|
|
9
|
+
text: string;
|
|
10
|
+
pmPositions: number[];
|
|
11
|
+
};
|
|
12
|
+
export interface PmMatch {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
pmFrom: number;
|
|
16
|
+
pmTo: number;
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function findInPmDoc(doc: Node, query: string, options?: {
|
|
20
|
+
caseSensitive?: boolean;
|
|
21
|
+
}): PmMatch[];
|
|
22
|
+
/**
|
|
23
|
+
* Scroll the editor so the given match is visible.
|
|
24
|
+
* Highlighting is handled by the find-highlight plugin (decorations) — no selection
|
|
25
|
+
* dispatch here so focus stays in the find input.
|
|
26
|
+
*/
|
|
27
|
+
export declare function scrollToPmMatch(match: PmMatch): void;
|
|
28
|
+
/** Legacy: maps a flat text-space offset to PM position. Used by tests only. */
|
|
29
|
+
export declare function textOffsetToPmPos(doc: Node, textOffset: number): number | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getEditorView } from '../editor/editor-bus';
|
|
2
|
+
/**
|
|
3
|
+
* Walk the PM doc once and return:
|
|
4
|
+
* - `text`: flat concatenation of all text node content (no sigils, no blank lines)
|
|
5
|
+
* - `pmPositions`: parallel array where pmPositions[i] is the PM doc position
|
|
6
|
+
* corresponding to character i in `text`
|
|
7
|
+
*/
|
|
8
|
+
export function buildPmTextMap(doc) {
|
|
9
|
+
const chars = [];
|
|
10
|
+
const pmPositions = [];
|
|
11
|
+
doc.nodesBetween(0, doc.content.size, (node, pmPos) => {
|
|
12
|
+
if (node.isText) {
|
|
13
|
+
const t = node.text;
|
|
14
|
+
for (let i = 0; i < t.length; i++) {
|
|
15
|
+
chars.push(t[i]);
|
|
16
|
+
pmPositions.push(pmPos + i);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
});
|
|
21
|
+
return { text: chars.join(''), pmPositions };
|
|
22
|
+
}
|
|
23
|
+
export function findInPmDoc(doc, query, options = {}) {
|
|
24
|
+
if (!query)
|
|
25
|
+
return [];
|
|
26
|
+
const { text, pmPositions } = buildPmTextMap(doc);
|
|
27
|
+
if (!text)
|
|
28
|
+
return [];
|
|
29
|
+
const { caseSensitive = false } = options;
|
|
30
|
+
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
31
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
32
|
+
const matches = [];
|
|
33
|
+
let searchFrom = 0;
|
|
34
|
+
while (searchFrom < haystack.length) {
|
|
35
|
+
const idx = haystack.indexOf(needle, searchFrom);
|
|
36
|
+
if (idx === -1)
|
|
37
|
+
break;
|
|
38
|
+
const end = idx + needle.length;
|
|
39
|
+
matches.push({
|
|
40
|
+
start: idx,
|
|
41
|
+
end,
|
|
42
|
+
pmFrom: pmPositions[idx],
|
|
43
|
+
pmTo: pmPositions[end - 1] + 1,
|
|
44
|
+
text: text.slice(idx, end),
|
|
45
|
+
});
|
|
46
|
+
searchFrom = idx + 1;
|
|
47
|
+
}
|
|
48
|
+
return matches;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Scroll the editor so the given match is visible.
|
|
52
|
+
* Highlighting is handled by the find-highlight plugin (decorations) — no selection
|
|
53
|
+
* dispatch here so focus stays in the find input.
|
|
54
|
+
*/
|
|
55
|
+
export function scrollToPmMatch(match) {
|
|
56
|
+
const view = getEditorView();
|
|
57
|
+
if (!view)
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const domNode = view.nodeDOM(match.pmFrom);
|
|
61
|
+
const el = domNode instanceof Element ? domNode : domNode?.parentElement;
|
|
62
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// At doc boundary — ignore
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Legacy: maps a flat text-space offset to PM position. Used by tests only. */
|
|
69
|
+
export function textOffsetToPmPos(doc, textOffset) {
|
|
70
|
+
const { pmPositions } = buildPmTextMap(doc);
|
|
71
|
+
if (textOffset >= pmPositions.length)
|
|
72
|
+
return null;
|
|
73
|
+
return pmPositions[textOffset] ?? null;
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function detectTofu(text: string, fontFamily?: string): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Detect if a string contains unmapped glyphs (tofu / .notdef boxes)
|
|
2
|
+
// by rendering to an invisible canvas and comparing dimensions
|
|
3
|
+
export function detectTofu(text, fontFamily = 'monospace') {
|
|
4
|
+
if (!text || !text.length)
|
|
5
|
+
return false;
|
|
6
|
+
if (typeof document === 'undefined')
|
|
7
|
+
return false; // SSR/Node env
|
|
8
|
+
// Create invisible canvas
|
|
9
|
+
const canvas = document.createElement('canvas');
|
|
10
|
+
const ctx = canvas.getContext('2d');
|
|
11
|
+
if (!ctx)
|
|
12
|
+
return false;
|
|
13
|
+
canvas.width = 1000;
|
|
14
|
+
canvas.height = 100;
|
|
15
|
+
// Render reference .notdef character (U+FFFD, replacement char)
|
|
16
|
+
ctx.font = `12px ${fontFamily}`;
|
|
17
|
+
ctx.fillStyle = 'black';
|
|
18
|
+
ctx.fillText('�', 10, 50);
|
|
19
|
+
try {
|
|
20
|
+
const refData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
21
|
+
const refPixels = countNonZeroPixels(refData);
|
|
22
|
+
// Render each character from input text
|
|
23
|
+
for (const char of text) {
|
|
24
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
25
|
+
ctx.fillText(char, 10, 50);
|
|
26
|
+
const charData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
27
|
+
const charPixels = countNonZeroPixels(charData);
|
|
28
|
+
// If pixel count matches .notdef (within 10% tolerance), it's likely tofu
|
|
29
|
+
if (Math.abs(charPixels - refPixels) < refPixels * 0.1) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
function countNonZeroPixels(imageData) {
|
|
40
|
+
let count = 0;
|
|
41
|
+
// Every 4th value is alpha; count pixels where any channel is non-zero
|
|
42
|
+
for (let i = 0; i < imageData.length; i += 4) {
|
|
43
|
+
if ((imageData[i] ?? 0) > 0 || (imageData[i + 1] ?? 0) > 0 || (imageData[i + 2] ?? 0) > 0) {
|
|
44
|
+
count++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface LanguageContextType {
|
|
3
|
+
currentLanguage: string;
|
|
4
|
+
resolveLanguageAtCaret: (text: string, caretPos: number, docFrontmatter?: Record<string, any>) => string;
|
|
5
|
+
}
|
|
6
|
+
export declare function LanguageProvider({ children }: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}): React.JSX.Element;
|
|
9
|
+
export declare function useLanguage(): LanguageContextType;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useState, useCallback } from 'react';
|
|
3
|
+
import { parse } from '@sutrata/parser';
|
|
4
|
+
import { detectScript } from './language-detect';
|
|
5
|
+
const LanguageContext = createContext(undefined);
|
|
6
|
+
export function LanguageProvider({ children }) {
|
|
7
|
+
const [currentLanguage, setCurrentLanguage] = useState('en');
|
|
8
|
+
const resolveLanguageAtCaret = useCallback((text, caretPos, docFrontmatter = {}) => {
|
|
9
|
+
try {
|
|
10
|
+
const ast = parse(text);
|
|
11
|
+
let effectiveLanguage = 'en';
|
|
12
|
+
// 1. Default to document frontmatter lang if present (lowest priority)
|
|
13
|
+
if (docFrontmatter.lang) {
|
|
14
|
+
effectiveLanguage = docFrontmatter.lang;
|
|
15
|
+
}
|
|
16
|
+
// 2. Find the block containing the caret
|
|
17
|
+
const blockInfo = findBlockAtPosition(ast, caretPos);
|
|
18
|
+
if (!blockInfo) {
|
|
19
|
+
return effectiveLanguage;
|
|
20
|
+
}
|
|
21
|
+
const block = blockInfo.node;
|
|
22
|
+
// 3. Check for & lang: scene metadata (beats frontmatter)
|
|
23
|
+
if (isSceneHeading(block) && block.metadata && block.metadata.length > 0) {
|
|
24
|
+
const langMeta = block.metadata.find(m => m.key === 'lang');
|
|
25
|
+
if (langMeta) {
|
|
26
|
+
effectiveLanguage = langMeta.value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 4. Check for inline {lang=…} tag (beats all)
|
|
30
|
+
const inlineTag = findInlineLangTag(block, caretPos);
|
|
31
|
+
if (inlineTag) {
|
|
32
|
+
effectiveLanguage = inlineTag;
|
|
33
|
+
}
|
|
34
|
+
// 5. Fallback: detect from text at caret if no higher-priority language was found
|
|
35
|
+
// This applies when: no inline tag, no scene metadata, and no frontmatter lang
|
|
36
|
+
if (!inlineTag && !hasLangMetadata(block) && !docFrontmatter.lang) {
|
|
37
|
+
const blockText = getBlockText(block);
|
|
38
|
+
if (blockText) {
|
|
39
|
+
effectiveLanguage = detectScript(blockText);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return effectiveLanguage;
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error('Error resolving language at caret:', err);
|
|
46
|
+
return 'en';
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
return (_jsx(LanguageContext.Provider, { value: { currentLanguage, resolveLanguageAtCaret }, children: children }));
|
|
50
|
+
}
|
|
51
|
+
export function useLanguage() {
|
|
52
|
+
const context = useContext(LanguageContext);
|
|
53
|
+
if (!context) {
|
|
54
|
+
throw new Error('useLanguage must be used within LanguageProvider');
|
|
55
|
+
}
|
|
56
|
+
return context;
|
|
57
|
+
}
|
|
58
|
+
// Helper: Check if a node is a SceneHeadingNode
|
|
59
|
+
function isSceneHeading(node) {
|
|
60
|
+
return node.type === 'scene-heading';
|
|
61
|
+
}
|
|
62
|
+
// Helper: Get text content from a block
|
|
63
|
+
function getBlockText(block) {
|
|
64
|
+
const blockAny = block;
|
|
65
|
+
// For SceneHeadingNode, extract text from children (action, dialogue, etc.)
|
|
66
|
+
if (blockAny.type === 'scene-heading' && blockAny.children && blockAny.children.length > 0) {
|
|
67
|
+
const childTexts = blockAny.children
|
|
68
|
+
.map((child) => {
|
|
69
|
+
if (child.type === 'action' && child.spans) {
|
|
70
|
+
return extractTextFromSpans(child.spans);
|
|
71
|
+
}
|
|
72
|
+
if (child.type === 'dialogue' && child.spans) {
|
|
73
|
+
return extractTextFromSpans(child.spans);
|
|
74
|
+
}
|
|
75
|
+
if (child.text)
|
|
76
|
+
return child.text;
|
|
77
|
+
return '';
|
|
78
|
+
})
|
|
79
|
+
.filter((t) => t.length > 0)
|
|
80
|
+
.join(' ');
|
|
81
|
+
if (childTexts)
|
|
82
|
+
return childTexts;
|
|
83
|
+
}
|
|
84
|
+
// Direct text property (for action, transition, note, etc.)
|
|
85
|
+
if (blockAny.text)
|
|
86
|
+
return blockAny.text;
|
|
87
|
+
// Inline spans (for action, dialogue, lyrics)
|
|
88
|
+
if (blockAny.spans) {
|
|
89
|
+
return blockAny.spans
|
|
90
|
+
.map((span) => {
|
|
91
|
+
if (span.type === 'text')
|
|
92
|
+
return span.text;
|
|
93
|
+
if (span.type === 'note')
|
|
94
|
+
return span.text;
|
|
95
|
+
if (span.spans)
|
|
96
|
+
return extractTextFromSpans(span.spans);
|
|
97
|
+
return '';
|
|
98
|
+
})
|
|
99
|
+
.join('');
|
|
100
|
+
}
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
// Helper: Extract text from nested spans
|
|
104
|
+
function extractTextFromSpans(spans) {
|
|
105
|
+
return spans
|
|
106
|
+
.map(span => {
|
|
107
|
+
if (span.type === 'text')
|
|
108
|
+
return span.text;
|
|
109
|
+
if (span.type === 'note')
|
|
110
|
+
return span.text;
|
|
111
|
+
if (span.spans)
|
|
112
|
+
return extractTextFromSpans(span.spans);
|
|
113
|
+
return '';
|
|
114
|
+
})
|
|
115
|
+
.join('');
|
|
116
|
+
}
|
|
117
|
+
// Helper: Check if block has lang metadata
|
|
118
|
+
function hasLangMetadata(block) {
|
|
119
|
+
if (!isSceneHeading(block))
|
|
120
|
+
return false;
|
|
121
|
+
return block.metadata && block.metadata.length > 0 && block.metadata.some(m => m.key === 'lang');
|
|
122
|
+
}
|
|
123
|
+
// Helper: Find the block containing the caret position
|
|
124
|
+
// Returns the block and its start position
|
|
125
|
+
function findBlockAtPosition(ast, pos) {
|
|
126
|
+
if (!ast.children)
|
|
127
|
+
return null;
|
|
128
|
+
// Approximate position tracking - simplified for now
|
|
129
|
+
// In a real implementation, this would use actual byte offsets
|
|
130
|
+
let currentPos = 0;
|
|
131
|
+
for (const child of ast.children) {
|
|
132
|
+
const blockAny = child;
|
|
133
|
+
// Use raw text for more accurate length calculation
|
|
134
|
+
const blockLength = blockAny.raw ? blockAny.raw.length + 1 : 50; // +1 for newline, fallback to ~50 chars
|
|
135
|
+
if (pos >= currentPos && pos < currentPos + blockLength) {
|
|
136
|
+
return { node: child, startPos: currentPos };
|
|
137
|
+
}
|
|
138
|
+
currentPos += blockLength;
|
|
139
|
+
}
|
|
140
|
+
// If position is beyond all blocks, return the last block as fallback
|
|
141
|
+
if (ast.children.length > 0) {
|
|
142
|
+
let totalPos = 0;
|
|
143
|
+
const lastChild = ast.children[ast.children.length - 1];
|
|
144
|
+
return { node: lastChild, startPos: totalPos };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Helper: Get the length of a block in characters
|
|
149
|
+
function getBlockLength(block) {
|
|
150
|
+
const blockAny = block;
|
|
151
|
+
if (blockAny.raw) {
|
|
152
|
+
return blockAny.raw.length + 1; // +1 for newline
|
|
153
|
+
}
|
|
154
|
+
return (blockAny.text?.length ?? 0) + 1; // +1 for newline
|
|
155
|
+
}
|
|
156
|
+
// Helper: Find inline {lang=XX} tag in the block
|
|
157
|
+
function findInlineLangTag(block, caretPos) {
|
|
158
|
+
const blockText = getBlockText(block);
|
|
159
|
+
if (!blockText)
|
|
160
|
+
return null;
|
|
161
|
+
// Regex to find {lang=XX} tags - match 2-letter language codes
|
|
162
|
+
const tagRegex = /\{lang=([a-z]{2})\}/g;
|
|
163
|
+
let match;
|
|
164
|
+
let closestTag = null;
|
|
165
|
+
let closestDistance = Infinity;
|
|
166
|
+
while ((match = tagRegex.exec(blockText)) !== null) {
|
|
167
|
+
const distance = Math.abs(caretPos - match.index);
|
|
168
|
+
if (distance < closestDistance) {
|
|
169
|
+
closestDistance = distance;
|
|
170
|
+
closestTag = match[1] ?? null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return closestTag;
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function detectScript(text: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Detect the dominant script/language from a text run using Unicode blocks
|
|
2
|
+
// Returns BCP-47 language code: 'hi', 'ta', 'te', 'kn', 'ml', 'bn', 'gu', 'pa', 'or', 'si', 'en'
|
|
3
|
+
// Unicode block ranges for each script (start–end inclusive codepoint)
|
|
4
|
+
const SCRIPT_RANGES = [
|
|
5
|
+
// Devanagari: U+0900–U+097F
|
|
6
|
+
{ name: 'Devanagari', lang: 'hi', start: 0x0900, end: 0x097f },
|
|
7
|
+
// Bengali: U+0980–U+09FF
|
|
8
|
+
{ name: 'Bengali', lang: 'bn', start: 0x0980, end: 0x09ff },
|
|
9
|
+
// Gurmukhi: U+0A00–U+0A7F
|
|
10
|
+
{ name: 'Gurmukhi', lang: 'pa', start: 0x0a00, end: 0x0a7f },
|
|
11
|
+
// Gujarati: U+0A80–U+0AFF
|
|
12
|
+
{ name: 'Gujarati', lang: 'gu', start: 0x0a80, end: 0x0aff },
|
|
13
|
+
// Odia: U+0B00–U+0B7F
|
|
14
|
+
{ name: 'Odia', lang: 'or', start: 0x0b00, end: 0x0b7f },
|
|
15
|
+
// Tamil: U+0B80–U+0BFF
|
|
16
|
+
{ name: 'Tamil', lang: 'ta', start: 0x0b80, end: 0x0bff },
|
|
17
|
+
// Telugu: U+0C00–U+0C7F
|
|
18
|
+
{ name: 'Telugu', lang: 'te', start: 0x0c00, end: 0x0c7f },
|
|
19
|
+
// Kannada: U+0C80–U+0CFF
|
|
20
|
+
{ name: 'Kannada', lang: 'kn', start: 0x0c80, end: 0x0cff },
|
|
21
|
+
// Malayalam: U+0D00–U+0D7F
|
|
22
|
+
{ name: 'Malayalam', lang: 'ml', start: 0x0d00, end: 0x0d7f },
|
|
23
|
+
// Sinhala: U+0D80–U+0DFF
|
|
24
|
+
{ name: 'Sinhala', lang: 'si', start: 0x0d80, end: 0x0dff },
|
|
25
|
+
// Latin: U+0000–U+007F, U+0100–U+017F (Latin Extended-A, etc.)
|
|
26
|
+
{ name: 'Latin', lang: 'en', start: 0x0000, end: 0x007f },
|
|
27
|
+
{ name: 'Latin Extended', lang: 'en', start: 0x0100, end: 0x017f },
|
|
28
|
+
];
|
|
29
|
+
export function detectScript(text) {
|
|
30
|
+
if (!text || text.length === 0)
|
|
31
|
+
return 'en';
|
|
32
|
+
// Count codepoints in each script
|
|
33
|
+
const scriptCounts = {};
|
|
34
|
+
for (const char of text) {
|
|
35
|
+
const code = char.charCodeAt(0);
|
|
36
|
+
// Skip common symbols, whitespace, punctuation (treat as neutral)
|
|
37
|
+
if (code <= 0x007f && (code < 0x0041 || (code > 0x005a && code < 0x0061) || code > 0x007a)) {
|
|
38
|
+
continue; // punctuation, spaces, numbers
|
|
39
|
+
}
|
|
40
|
+
// Find matching script range
|
|
41
|
+
for (const range of SCRIPT_RANGES) {
|
|
42
|
+
if (code >= range.start && code <= range.end) {
|
|
43
|
+
scriptCounts[range.lang] = (scriptCounts[range.lang] ?? 0) + 1;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Find the dominant script
|
|
49
|
+
let dominantLang = 'en';
|
|
50
|
+
let maxCount = 0;
|
|
51
|
+
for (const [lang, count] of Object.entries(scriptCounts)) {
|
|
52
|
+
if (count > maxCount) {
|
|
53
|
+
dominantLang = lang;
|
|
54
|
+
maxCount = count;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return dominantLang;
|
|
58
|
+
}
|