@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.
Files changed (337) hide show
  1. package/LICENSE +674 -0
  2. package/dist/ai/OnboardingDialog.d.ts +6 -0
  3. package/dist/ai/OnboardingDialog.js +115 -0
  4. package/dist/ai/VoiceConfirmDialog.d.ts +9 -0
  5. package/dist/ai/VoiceConfirmDialog.js +37 -0
  6. package/dist/ai/VoiceToolbar.d.ts +2 -0
  7. package/dist/ai/VoiceToolbar.js +177 -0
  8. package/dist/ai/ai-client.d.ts +71 -0
  9. package/dist/ai/ai-client.js +494 -0
  10. package/dist/ai/commands.d.ts +12 -0
  11. package/dist/ai/commands.js +140 -0
  12. package/dist/ai/generated-providers.json +3696 -0
  13. package/dist/ai/prompts.d.ts +25 -0
  14. package/dist/ai/prompts.js +59 -0
  15. package/dist/ai/voice-service.d.ts +15 -0
  16. package/dist/ai/voice-service.js +81 -0
  17. package/dist/autocomplete/AutocompletePlugin.d.ts +2 -0
  18. package/dist/autocomplete/AutocompletePlugin.js +19 -0
  19. package/dist/autocomplete/suggestions.d.ts +25 -0
  20. package/dist/autocomplete/suggestions.js +126 -0
  21. package/dist/context/DocumentContext.d.ts +68 -0
  22. package/dist/context/DocumentContext.js +605 -0
  23. package/dist/desktop-api.d.ts +40 -0
  24. package/dist/editor/EditorView.d.ts +2 -0
  25. package/dist/editor/EditorView.js +78 -0
  26. package/dist/editor/SelectionMenu.d.ts +2 -0
  27. package/dist/editor/SelectionMenu.js +102 -0
  28. package/dist/editor/attr-pencil-plugin.d.ts +17 -0
  29. package/dist/editor/attr-pencil-plugin.js +158 -0
  30. package/dist/editor/character-decoration-plugin.d.ts +26 -0
  31. package/dist/editor/character-decoration-plugin.js +77 -0
  32. package/dist/editor/character-view.d.ts +35 -0
  33. package/dist/editor/character-view.js +112 -0
  34. package/dist/editor/editor-bus.d.ts +14 -0
  35. package/dist/editor/editor-bus.js +39 -0
  36. package/dist/editor/find-highlight-plugin.d.ts +7 -0
  37. package/dist/editor/find-highlight-plugin.js +57 -0
  38. package/dist/editor/frontmatter-field-view.d.ts +10 -0
  39. package/dist/editor/frontmatter-field-view.js +48 -0
  40. package/dist/editor/frontmatter-field.d.ts +21 -0
  41. package/dist/editor/frontmatter-field.js +116 -0
  42. package/dist/editor/grapheme-cursor.d.ts +10 -0
  43. package/dist/editor/grapheme-cursor.js +104 -0
  44. package/dist/editor/ime-plugin.d.ts +18 -0
  45. package/dist/editor/ime-plugin.js +71 -0
  46. package/dist/editor/insert-helper.d.ts +1 -0
  47. package/dist/editor/insert-helper.js +42 -0
  48. package/dist/editor/keymap.d.ts +3 -0
  49. package/dist/editor/keymap.js +131 -0
  50. package/dist/editor/language-class-plugin.d.ts +7 -0
  51. package/dist/editor/language-class-plugin.js +75 -0
  52. package/dist/editor/plugins.d.ts +1 -0
  53. package/dist/editor/plugins.js +25 -0
  54. package/dist/editor/prosemirror-to-sutra.d.ts +2 -0
  55. package/dist/editor/prosemirror-to-sutra.js +208 -0
  56. package/dist/editor/scene-heading-view.d.ts +27 -0
  57. package/dist/editor/scene-heading-view.js +136 -0
  58. package/dist/editor/schema.d.ts +3 -0
  59. package/dist/editor/schema.js +135 -0
  60. package/dist/editor/sutra-to-prosemirror.d.ts +3 -0
  61. package/dist/editor/sutra-to-prosemirror.js +154 -0
  62. package/dist/editor/transliterate-input.d.ts +10 -0
  63. package/dist/editor/transliterate-input.js +88 -0
  64. package/dist/extensions/storage-adapter.d.ts +59 -0
  65. package/dist/extensions/storage-adapter.js +9 -0
  66. package/dist/extensions/types.d.ts +73 -0
  67. package/dist/extensions/types.js +12 -0
  68. package/dist/file/docx-exporter.d.ts +8 -0
  69. package/dist/file/docx-exporter.js +494 -0
  70. package/dist/file/docx-importer.d.ts +10 -0
  71. package/dist/file/docx-importer.js +283 -0
  72. package/dist/file/print-paginator.d.ts +29 -0
  73. package/dist/file/print-paginator.js +289 -0
  74. package/dist/file/templates.d.ts +2 -0
  75. package/dist/file/templates.js +66 -0
  76. package/dist/file/workflow-reports.d.ts +34 -0
  77. package/dist/file/workflow-reports.js +719 -0
  78. package/dist/findreplace/FindReplace.d.ts +2 -0
  79. package/dist/findreplace/FindReplace.js +164 -0
  80. package/dist/findreplace/find-engine.d.ts +29 -0
  81. package/dist/findreplace/find-engine.js +69 -0
  82. package/dist/findreplace/scroll-to-match.d.ts +29 -0
  83. package/dist/findreplace/scroll-to-match.js +74 -0
  84. package/dist/fonts/tofu-detect.d.ts +1 -0
  85. package/dist/fonts/tofu-detect.js +48 -0
  86. package/dist/i18n/LanguageContext.d.ts +10 -0
  87. package/dist/i18n/LanguageContext.js +174 -0
  88. package/dist/i18n/language-detect.d.ts +1 -0
  89. package/dist/i18n/language-detect.js +58 -0
  90. package/dist/i18n/useTranslation.d.ts +11 -0
  91. package/dist/i18n/useTranslation.js +60 -0
  92. package/dist/index.d.ts +11 -0
  93. package/dist/index.js +5 -0
  94. package/dist/locales/bn.json +143 -0
  95. package/dist/locales/en.json +143 -0
  96. package/dist/locales/gu.json +143 -0
  97. package/dist/locales/hi.json +143 -0
  98. package/dist/locales/kn.json +143 -0
  99. package/dist/locales/ml.json +143 -0
  100. package/dist/locales/mr.json +143 -0
  101. package/dist/locales/or.json +143 -0
  102. package/dist/locales/pa.json +143 -0
  103. package/dist/locales/si.json +143 -0
  104. package/dist/locales/ta.json +143 -0
  105. package/dist/locales/te.json +143 -0
  106. package/dist/navigator/SceneNavigator.d.ts +2 -0
  107. package/dist/navigator/SceneNavigator.js +612 -0
  108. package/dist/navigator/StatisticsDialog.d.ts +6 -0
  109. package/dist/navigator/StatisticsDialog.js +146 -0
  110. package/dist/navigator/scene-list.d.ts +16 -0
  111. package/dist/navigator/scene-list.js +70 -0
  112. package/dist/settings/SettingsDialog.d.ts +6 -0
  113. package/dist/settings/SettingsDialog.js +147 -0
  114. package/dist/shell/AppBar.d.ts +2 -0
  115. package/dist/shell/AppBar.js +34 -0
  116. package/dist/shell/AppShell.d.ts +2 -0
  117. package/dist/shell/AppShell.js +30 -0
  118. package/dist/shell/ConfirmDialog.d.ts +16 -0
  119. package/dist/shell/ConfirmDialog.js +23 -0
  120. package/dist/shell/ElementToolbar.d.ts +2 -0
  121. package/dist/shell/ElementToolbar.js +30 -0
  122. package/dist/shell/LanguageStatus.d.ts +2 -0
  123. package/dist/shell/LanguageStatus.js +65 -0
  124. package/dist/shell/StatusBar.d.ts +2 -0
  125. package/dist/shell/StatusBar.js +54 -0
  126. package/dist/shell/SutrataLogo.d.ts +8 -0
  127. package/dist/shell/SutrataLogo.js +7 -0
  128. package/dist/shell/Toast.d.ts +12 -0
  129. package/dist/shell/Toast.js +21 -0
  130. package/dist/shell/ToolbarButton.d.ts +11 -0
  131. package/dist/shell/ToolbarButton.js +4 -0
  132. package/dist/shell/VersionHistory.d.ts +12 -0
  133. package/dist/shell/VersionHistory.js +89 -0
  134. package/dist/shell/Watermark.d.ts +3 -0
  135. package/dist/shell/Watermark.js +10 -0
  136. package/dist/shell/fonts/fonts.css +1133 -0
  137. package/dist/shell/icons.d.ts +45 -0
  138. package/dist/shell/icons.js +51 -0
  139. package/dist/shell/toolbar-actions.d.ts +6 -0
  140. package/dist/shell/toolbar-actions.js +58 -0
  141. package/dist/source/SourceView.d.ts +1 -0
  142. package/dist/source/SourceView.js +106 -0
  143. package/dist/source/find-highlight-source.d.ts +14 -0
  144. package/dist/source/find-highlight-source.js +47 -0
  145. package/dist/source/sutra-lang.d.ts +2 -0
  146. package/dist/source/sutra-lang.js +97 -0
  147. package/dist/spellcheck/dictionaries.d.ts +9 -0
  148. package/dist/spellcheck/dictionaries.js +16 -0
  149. package/dist/spellcheck/hunspell.d.ts +23 -0
  150. package/dist/spellcheck/hunspell.js +94 -0
  151. package/dist/spellcheck/spellcheck-plugin.d.ts +18 -0
  152. package/dist/spellcheck/spellcheck-plugin.js +75 -0
  153. package/dist/styles/StyleDialog.d.ts +6 -0
  154. package/dist/styles/StyleDialog.js +63 -0
  155. package/dist/styles/builtin-styles.d.ts +43 -0
  156. package/dist/styles/builtin-styles.js +131 -0
  157. package/dist/styles/css-adapter.d.ts +32 -0
  158. package/dist/styles/css-adapter.js +71 -0
  159. package/dist/styles/docx-adapter.d.ts +7 -0
  160. package/dist/styles/docx-adapter.js +84 -0
  161. package/dist/styles/embed-fonts.d.ts +20 -0
  162. package/dist/styles/embed-fonts.js +81 -0
  163. package/dist/styles/pagination.d.ts +19 -0
  164. package/dist/styles/pagination.js +22 -0
  165. package/dist/styles/print-adapter.d.ts +6 -0
  166. package/dist/styles/print-adapter.js +288 -0
  167. package/dist/styles/registry.d.ts +6 -0
  168. package/dist/styles/registry.js +14 -0
  169. package/dist/styles/screenplay.css +3006 -0
  170. package/dist/styles/script-ranges.d.ts +6 -0
  171. package/dist/styles/script-ranges.js +17 -0
  172. package/dist/styles/types.d.ts +93 -0
  173. package/dist/styles/types.js +18 -0
  174. package/dist/styles/validate.d.ts +12 -0
  175. package/dist/styles/validate.js +105 -0
  176. package/dist/templates/templates.d.ts +10 -0
  177. package/dist/templates/templates.js +181 -0
  178. package/dist/titlepage/ExportDialog.d.ts +1 -0
  179. package/dist/titlepage/ExportDialog.js +152 -0
  180. package/dist/titlepage/TitlePageDialog.d.ts +6 -0
  181. package/dist/titlepage/TitlePageDialog.js +96 -0
  182. package/dist/titlepage/frontmatter-form.d.ts +56 -0
  183. package/dist/titlepage/frontmatter-form.js +245 -0
  184. package/dist/titlepage/languages.d.ts +10 -0
  185. package/dist/titlepage/languages.js +31 -0
  186. package/dist/types.d.ts +6 -0
  187. package/dist/types.js +1 -0
  188. package/package.json +47 -0
  189. package/scripts/copy-assets.mjs +16 -0
  190. package/scripts/generate-providers.mjs +127 -0
  191. package/src/ai/OnboardingDialog.tsx +390 -0
  192. package/src/ai/VoiceConfirmDialog.tsx +137 -0
  193. package/src/ai/VoiceToolbar.tsx +321 -0
  194. package/src/ai/ai-client.ts +596 -0
  195. package/src/ai/commands.ts +164 -0
  196. package/src/ai/generated-providers.json +3696 -0
  197. package/src/ai/prompts.ts +62 -0
  198. package/src/ai/voice-service.ts +96 -0
  199. package/src/autocomplete/AutocompletePlugin.ts +21 -0
  200. package/src/autocomplete/suggestions.ts +147 -0
  201. package/src/context/DocumentContext.tsx +648 -0
  202. package/src/desktop-api.d.ts +40 -0
  203. package/src/editor/EditorView.tsx +82 -0
  204. package/src/editor/SelectionMenu.tsx +152 -0
  205. package/src/editor/attr-pencil-plugin.ts +172 -0
  206. package/src/editor/character-decoration-plugin.ts +104 -0
  207. package/src/editor/editor-bus.ts +51 -0
  208. package/src/editor/find-highlight-plugin.ts +68 -0
  209. package/src/editor/frontmatter-field-view.ts +52 -0
  210. package/src/editor/frontmatter-field.ts +115 -0
  211. package/src/editor/grapheme-cursor.ts +99 -0
  212. package/src/editor/ime-plugin.ts +85 -0
  213. package/src/editor/insert-helper.ts +43 -0
  214. package/src/editor/keymap.ts +124 -0
  215. package/src/editor/language-class-plugin.ts +91 -0
  216. package/src/editor/plugins.ts +26 -0
  217. package/src/editor/prosemirror-to-sutra.ts +192 -0
  218. package/src/editor/scene-heading-view.ts +147 -0
  219. package/src/editor/schema.ts +156 -0
  220. package/src/editor/sutra-to-prosemirror.ts +156 -0
  221. package/src/extensions/storage-adapter.ts +65 -0
  222. package/src/extensions/types.ts +60 -0
  223. package/src/file/docx-exporter.ts +585 -0
  224. package/src/file/docx-importer.ts +313 -0
  225. package/src/file/print-paginator.ts +301 -0
  226. package/src/file/templates.ts +73 -0
  227. package/src/file/workflow-reports.ts +820 -0
  228. package/src/findreplace/FindReplace.tsx +242 -0
  229. package/src/findreplace/find-engine.ts +97 -0
  230. package/src/findreplace/scroll-to-match.ts +89 -0
  231. package/src/fonts/tofu-detect.ts +53 -0
  232. package/src/i18n/LanguageContext.tsx +207 -0
  233. package/src/i18n/language-detect.ts +73 -0
  234. package/src/i18n/useTranslation.tsx +74 -0
  235. package/src/index.ts +27 -0
  236. package/src/locales/bn.json +143 -0
  237. package/src/locales/en.json +143 -0
  238. package/src/locales/gu.json +143 -0
  239. package/src/locales/hi.json +143 -0
  240. package/src/locales/kn.json +143 -0
  241. package/src/locales/ml.json +143 -0
  242. package/src/locales/mr.json +143 -0
  243. package/src/locales/or.json +143 -0
  244. package/src/locales/pa.json +143 -0
  245. package/src/locales/si.json +143 -0
  246. package/src/locales/ta.json +143 -0
  247. package/src/locales/te.json +143 -0
  248. package/src/navigator/SceneNavigator.tsx +844 -0
  249. package/src/navigator/StatisticsDialog.tsx +249 -0
  250. package/src/navigator/scene-list.ts +84 -0
  251. package/src/settings/SettingsDialog.tsx +303 -0
  252. package/src/shell/AppBar.tsx +354 -0
  253. package/src/shell/AppShell.tsx +71 -0
  254. package/src/shell/ConfirmDialog.tsx +71 -0
  255. package/src/shell/ElementToolbar.tsx +175 -0
  256. package/src/shell/LanguageStatus.tsx +101 -0
  257. package/src/shell/StatusBar.tsx +135 -0
  258. package/src/shell/SutrataLogo.tsx +89 -0
  259. package/src/shell/Toast.tsx +46 -0
  260. package/src/shell/ToolbarButton.tsx +30 -0
  261. package/src/shell/VersionHistory.tsx +212 -0
  262. package/src/shell/Watermark.tsx +9 -0
  263. package/src/shell/fonts/fonts.css +1133 -0
  264. package/src/shell/icons.tsx +335 -0
  265. package/src/shell/toolbar-actions.ts +59 -0
  266. package/src/source/SourceView.tsx +111 -0
  267. package/src/source/find-highlight-source.ts +57 -0
  268. package/src/source/sutra-lang.ts +121 -0
  269. package/src/spellcheck/dictionaries.ts +21 -0
  270. package/src/spellcheck/hunspell.ts +110 -0
  271. package/src/spellcheck/spellcheck-plugin.ts +83 -0
  272. package/src/styles/StyleDialog.tsx +148 -0
  273. package/src/styles/builtin-styles.ts +139 -0
  274. package/src/styles/css-adapter.ts +76 -0
  275. package/src/styles/docx-adapter.ts +109 -0
  276. package/src/styles/embed-fonts.ts +81 -0
  277. package/src/styles/pagination.ts +27 -0
  278. package/src/styles/print-adapter.ts +288 -0
  279. package/src/styles/registry.ts +20 -0
  280. package/src/styles/screenplay.css +3006 -0
  281. package/src/styles/script-ranges.ts +17 -0
  282. package/src/styles/types.ts +104 -0
  283. package/src/styles/validate.ts +114 -0
  284. package/src/templates/templates.ts +194 -0
  285. package/src/titlepage/ExportDialog.tsx +494 -0
  286. package/src/titlepage/TitlePageDialog.tsx +312 -0
  287. package/src/titlepage/frontmatter-form.ts +280 -0
  288. package/src/titlepage/languages.ts +37 -0
  289. package/src/types.ts +7 -0
  290. package/tests/AppBar.test.tsx +35 -0
  291. package/tests/AppShell.test.tsx +22 -0
  292. package/tests/ElementToolbar.test.tsx +36 -0
  293. package/tests/LanguageStatus.test.tsx +129 -0
  294. package/tests/SelectionMenu.test.tsx +71 -0
  295. package/tests/ToolbarButton.test.tsx +24 -0
  296. package/tests/ai-onboarding.test.ts +102 -0
  297. package/tests/ai-status.test.tsx +72 -0
  298. package/tests/ai-structured.test.ts +94 -0
  299. package/tests/autocomplete/AutocompletePlugin.test.ts +20 -0
  300. package/tests/autocomplete/suggestions.test.ts +106 -0
  301. package/tests/document-context.test.tsx +565 -0
  302. package/tests/docx-importer.test.ts +160 -0
  303. package/tests/editor/character-decoration-plugin.test.ts +101 -0
  304. package/tests/editor/character-extension.test.ts +44 -0
  305. package/tests/editor/grapheme-cursor.test.ts +118 -0
  306. package/tests/editor/ime-plugin.test.ts +72 -0
  307. package/tests/editor/inline-note-bridge.test.ts +32 -0
  308. package/tests/editor/language-class-plugin.test.ts +265 -0
  309. package/tests/editor/title-page.test.ts +124 -0
  310. package/tests/embed-fonts.test.ts +17 -0
  311. package/tests/exporters.test.ts +365 -0
  312. package/tests/findreplace/FindReplace.test.tsx +391 -0
  313. package/tests/findreplace/find-engine.test.ts +86 -0
  314. package/tests/findreplace/scroll-to-match.test.ts +50 -0
  315. package/tests/fonts/tofu-detect.test.ts +122 -0
  316. package/tests/i18n/LanguageContext.test.tsx +146 -0
  317. package/tests/i18n/language-detect.test.ts +54 -0
  318. package/tests/i18n/locale-parity.test.ts +25 -0
  319. package/tests/i18n/useTranslation.test.tsx +45 -0
  320. package/tests/keymap.test.ts +18 -0
  321. package/tests/navigator/SceneNavigator.test.tsx +177 -0
  322. package/tests/navigator/StatisticsDialog.test.tsx +63 -0
  323. package/tests/navigator/scene-list.test.ts +71 -0
  324. package/tests/print-paginator.test.ts +82 -0
  325. package/tests/shell/VersionHistory.test.tsx +129 -0
  326. package/tests/spellcheck/hunspell.test.ts +82 -0
  327. package/tests/spellcheck/spellcheck-plugin.test.ts +56 -0
  328. package/tests/styles.test.ts +315 -0
  329. package/tests/templates/templates.test.ts +83 -0
  330. package/tests/titlepage/TitlePageDialog.test.tsx +90 -0
  331. package/tests/titlepage/apply-title-page.test.ts +61 -0
  332. package/tests/titlepage/frontmatter-form.test.ts +115 -0
  333. package/tests/toolbar-actions.test.ts +93 -0
  334. package/tests/voice-commands.test.ts +58 -0
  335. package/tsconfig.json +17 -0
  336. package/vitest.config.ts +11 -0
  337. package/vitest.setup.ts +65 -0
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function FindReplace(): React.JSX.Element | null;
@@ -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
+ }