@sutrata/editor 0.1.1 → 0.1.2

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 (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +30 -3
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. package/vitest.setup.ts +0 -65
@@ -1,242 +0,0 @@
1
- import React, { useState, useCallback, useEffect, useRef } from 'react'
2
- import { useDocument } from '../context/DocumentContext'
3
- import { useTranslation } from '../i18n/useTranslation'
4
- import { findAll, replaceAll } from './find-engine'
5
- import { findInPmDoc, scrollToPmMatch } from './scroll-to-match'
6
- import type { PmMatch } 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
-
11
- export function FindReplace() {
12
- const { text, setText, mode, findReplaceVisible, setFindReplaceVisible, findMode, setFindMode } = useDocument()
13
- const { t } = useTranslation()
14
- const [query, setQuery] = useState('')
15
- const [replacement, setReplacement] = useState('')
16
- const [caseSensitive, setCaseSensitive] = useState(false)
17
- const [matches, setMatches] = useState<PmMatch[]>([])
18
- const [currentIndex, setCurrentIndex] = useState(0)
19
- const queryRef = useRef<HTMLInputElement>(null)
20
- const nextIndexAfterReplace = useRef<number | null>(null)
21
-
22
- // Recompute matches whenever query/text/caseSensitive/mode changes.
23
- // In formatted mode we search the PM doc's display text (not raw Sutra) so
24
- // offsets are correct; in source mode the CodeMirror doc *is* the raw Sutra
25
- // text, so raw-offset matches from find-engine map directly onto it.
26
- useEffect(() => {
27
- if (!query) {
28
- setMatches([])
29
- setCurrentIndex(0)
30
- clearFindHighlights()
31
- clearSourceFindHighlights()
32
- return
33
- }
34
- let found: PmMatch[]
35
- if (mode === 'source') {
36
- found = findAll(text, query, { caseSensitive }).map(m => ({
37
- start: m.start, end: m.end, pmFrom: m.start, pmTo: m.end, text: m.text,
38
- }))
39
- } else {
40
- const view = getEditorView()
41
- found = view ? findInPmDoc(view.state.doc, query, { caseSensitive }) : []
42
- }
43
- setMatches(found)
44
- const highlight = mode === 'source' ? setSourceFindHighlights : setFindHighlights
45
- const scrollTo = mode === 'source' ? scrollToSourceMatch : scrollToPmMatch
46
- if (nextIndexAfterReplace.current !== null) {
47
- const idx = Math.min(nextIndexAfterReplace.current, Math.max(0, found.length - 1))
48
- setCurrentIndex(idx)
49
- nextIndexAfterReplace.current = null
50
- highlight(found, idx)
51
- if (found.length > 0) scrollTo(found[idx]!)
52
- } else {
53
- setCurrentIndex(0)
54
- highlight(found, 0)
55
- if (found.length > 0) scrollTo(found[0]!)
56
- }
57
- // text in deps so we re-search after replace; view not in deps (stable ref)
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [query, text, caseSensitive, mode])
60
-
61
- // Scroll to current match and update active highlight whenever index changes.
62
- const didMount = useRef(false)
63
- useEffect(() => {
64
- if (!didMount.current) { didMount.current = true; return }
65
- if (mode === 'source') {
66
- setSourceFindHighlights(matches, currentIndex)
67
- if (matches.length > 0) scrollToSourceMatch(matches[currentIndex]!)
68
- } else {
69
- setFindHighlights(matches, currentIndex)
70
- if (matches.length > 0) scrollToPmMatch(matches[currentIndex]!)
71
- }
72
- }, [currentIndex, matches, mode])
73
-
74
- // Clear highlights when the panel closes.
75
- useEffect(() => {
76
- if (!findReplaceVisible) {
77
- clearFindHighlights()
78
- clearSourceFindHighlights()
79
- }
80
- }, [findReplaceVisible])
81
-
82
- // Focus the input when the panel opens.
83
- useEffect(() => {
84
- if (findReplaceVisible) queryRef.current?.focus()
85
- }, [findReplaceVisible])
86
-
87
- const goNext = useCallback(() => {
88
- if (matches.length === 0) return
89
- setCurrentIndex(i => (i + 1) % matches.length)
90
- }, [matches.length])
91
-
92
- const goPrev = useCallback(() => {
93
- if (matches.length === 0) return
94
- setCurrentIndex(i => (i - 1 + matches.length) % matches.length)
95
- }, [matches.length])
96
-
97
- const handleReplaceOne = useCallback(() => {
98
- if (matches.length === 0) return
99
- const match = matches[currentIndex]!
100
- let newText: string
101
- if (mode === 'source') {
102
- // In source mode the match's start/end are already raw Sutra offsets.
103
- newText = text.slice(0, match.start) + replacement + text.slice(match.end)
104
- } else {
105
- const needle = match.text
106
- // Find the Nth (currentIndex-th) occurrence of the matched display text in the
107
- // raw Sutra source. Display text and raw source share the same word characters;
108
- // sigils (##, **, blank lines) don't contain prose words, so occurrence index is stable.
109
- let rawIdx = -1
110
- let count = 0
111
- let searchFrom = 0
112
- while (searchFrom < text.length) {
113
- const found = text.indexOf(needle, searchFrom)
114
- if (found === -1) break
115
- if (count === currentIndex) { rawIdx = found; break }
116
- count++
117
- searchFrom = found + 1
118
- }
119
- if (rawIdx === -1) return
120
- newText = text.slice(0, rawIdx) + replacement + text.slice(rawIdx + needle.length)
121
- }
122
- nextIndexAfterReplace.current = Math.min(currentIndex, matches.length - 2)
123
- setText(newText)
124
- }, [matches, currentIndex, text, replacement, setText, mode])
125
-
126
- const handleReplaceAll = useCallback(() => {
127
- if (!query || matches.length === 0) return
128
- setText(replaceAll(text, query, replacement, { caseSensitive }))
129
- }, [text, query, replacement, caseSensitive, matches.length, setText])
130
-
131
- const handleQueryKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
132
- if (e.key === 'Escape') { setFindReplaceVisible(false); return }
133
- if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); goPrev(); return }
134
- if (e.key === 'Enter') { e.preventDefault(); goNext(); return }
135
- }, [setFindReplaceVisible, goNext, goPrev])
136
-
137
- if (!findReplaceVisible) return null
138
-
139
- const isReplace = findMode === 'replace'
140
- const hasMatches = matches.length > 0
141
-
142
- return (
143
- <div className="cs-find-replace" role="dialog" aria-label={isReplace ? 'Find and Replace' : 'Find'}>
144
- <div className="cs-fr-header">
145
- <div className="cs-fr-mode-tabs">
146
- <button
147
- className={`cs-fr-tab${!isReplace ? ' cs-fr-tab-active' : ''}`}
148
- onClick={() => setFindMode('find')}
149
- >{t('findreplace.findOnly')}</button>
150
- <button
151
- className={`cs-fr-tab${isReplace ? ' cs-fr-tab-active' : ''}`}
152
- onClick={() => setFindMode('replace')}
153
- >{t('findreplace.title')}</button>
154
- </div>
155
- <button
156
- className="cs-fr-close"
157
- aria-label="Close"
158
- onClick={() => setFindReplaceVisible(false)}
159
- >×</button>
160
- </div>
161
-
162
- {/* Search row: input + nav buttons inline + Aa toggle */}
163
- <div className="cs-fr-row">
164
- <input
165
- ref={queryRef}
166
- className="cs-fr-input"
167
- type="text"
168
- placeholder={t('findreplace.find')}
169
- value={query}
170
- onChange={e => setQuery(e.target.value)}
171
- onKeyDown={handleQueryKeyDown}
172
- aria-label="Find"
173
- />
174
- <button
175
- type="button"
176
- className="cs-fr-nav-btn"
177
- aria-label="Previous match"
178
- title="Previous match (Shift+Enter)"
179
- onClick={goPrev}
180
- disabled={!hasMatches}
181
- >▲</button>
182
- <button
183
- type="button"
184
- className="cs-fr-nav-btn"
185
- aria-label="Next match"
186
- title="Next match (Enter)"
187
- onClick={goNext}
188
- disabled={!hasMatches}
189
- >▼</button>
190
- <button
191
- type="button"
192
- className={`cs-fr-toggle${caseSensitive ? ' cs-fr-active' : ''}`}
193
- onClick={() => setCaseSensitive(v => !v)}
194
- title={t('findreplace.caseSensitive')}
195
- aria-pressed={caseSensitive}
196
- >Aa</button>
197
- </div>
198
-
199
- {/* Status: count or no-match, always same height to avoid layout shift */}
200
- <div className="cs-fr-status">
201
- {query && !hasMatches && (
202
- <span className="cs-fr-nomatch">{t('findreplace.noMatches')}</span>
203
- )}
204
- {hasMatches && (
205
- <span className="cs-fr-nav-count">{currentIndex + 1} of {matches.length}</span>
206
- )}
207
- </div>
208
-
209
- {isReplace && (
210
- <>
211
- <div className="cs-fr-row">
212
- <input
213
- className="cs-fr-input"
214
- type="text"
215
- placeholder={t('findreplace.replace')}
216
- value={replacement}
217
- onChange={e => setReplacement(e.target.value)}
218
- onKeyDown={e => { if (e.key === 'Escape') setFindReplaceVisible(false) }}
219
- aria-label="Replace with"
220
- />
221
- </div>
222
- <div className="cs-fr-actions">
223
- <button
224
- className="cs-fr-btn"
225
- onClick={handleReplaceOne}
226
- disabled={!hasMatches}
227
- >
228
- {t('findreplace.replaceOne')}
229
- </button>
230
- <button
231
- className="cs-fr-btn"
232
- onClick={handleReplaceAll}
233
- disabled={!query || !hasMatches}
234
- >
235
- {t('findreplace.replaceAll')}
236
- </button>
237
- </div>
238
- </>
239
- )}
240
- </div>
241
- )
242
- }
@@ -1,97 +0,0 @@
1
- /**
2
- * Find & Replace engine for Sutrata.
3
- * NFC-normalized, grapheme-boundary-aware search/replace.
4
- */
5
-
6
- export interface FindMatch {
7
- start: number // character offset in text
8
- end: number // exclusive end
9
- text: string // matched text
10
- }
11
-
12
- export interface FindOptions {
13
- caseSensitive?: boolean
14
- }
15
-
16
- /**
17
- * Normalize text for comparison: NFC + optional case-fold.
18
- */
19
- function normalize(text: string, caseSensitive: boolean): string {
20
- const nfc = text.normalize('NFC')
21
- return caseSensitive ? nfc : nfc.toLowerCase()
22
- }
23
-
24
- /**
25
- * Find all occurrences of `query` in `text`.
26
- * Both are NFC-normalized before comparison.
27
- * Case-insensitivity applies only to scripts with case (Latin, Greek, Cyrillic);
28
- * it's a silent no-op for caseless scripts (Devanagari, Tamil, etc.).
29
- */
30
- export function findAll(text: string, query: string, options: FindOptions = {}): FindMatch[] {
31
- if (!query || !text) return []
32
-
33
- const { caseSensitive = true } = options
34
- const normalizedText = normalize(text, caseSensitive)
35
- const normalizedQuery = normalize(query, caseSensitive)
36
-
37
- if (!normalizedQuery) return []
38
-
39
- const matches: FindMatch[] = []
40
- let searchFrom = 0
41
-
42
- while (searchFrom < normalizedText.length) {
43
- const idx = normalizedText.indexOf(normalizedQuery, searchFrom)
44
- if (idx === -1) break
45
-
46
- matches.push({
47
- start: idx,
48
- end: idx + normalizedQuery.length,
49
- text: text.slice(idx, idx + normalizedQuery.length),
50
- })
51
-
52
- searchFrom = idx + 1 // Advance by 1 to allow overlapping but not infinite loop
53
- }
54
-
55
- return matches
56
- }
57
-
58
- /**
59
- * Replace all occurrences of `query` in `text` with `replacement`.
60
- * Preserves the original text's casing/encoding for replaced segments.
61
- */
62
- export function replaceAll(text: string, query: string, replacement: string, options: FindOptions = {}): string {
63
- const matches = findAll(text, query, options)
64
- if (matches.length === 0) return text
65
-
66
- let result = ''
67
- let lastEnd = 0
68
-
69
- for (const match of matches) {
70
- result += text.slice(lastEnd, match.start)
71
- result += replacement
72
- lastEnd = match.end
73
- }
74
- result += text.slice(lastEnd)
75
-
76
- return result
77
- }
78
-
79
- /**
80
- * Replace all occurrences within a specific range [rangeStart, rangeEnd).
81
- * Text outside the range is preserved unchanged.
82
- */
83
- export function replaceInRange(
84
- text: string,
85
- query: string,
86
- replacement: string,
87
- rangeStart: number,
88
- rangeEnd: number,
89
- options: FindOptions = {}
90
- ): string {
91
- const before = text.slice(0, rangeStart)
92
- const within = text.slice(rangeStart, rangeEnd)
93
- const after = text.slice(rangeEnd)
94
-
95
- const replacedWithin = replaceAll(within, query, replacement, options)
96
- return before + replacedWithin + after
97
- }
@@ -1,89 +0,0 @@
1
- import type { Node } from 'prosemirror-model'
2
- import { getEditorView } from '../editor/editor-bus'
3
-
4
- /**
5
- * Walk the PM doc once and return:
6
- * - `text`: flat concatenation of all text node content (no sigils, no blank lines)
7
- * - `pmPositions`: parallel array where pmPositions[i] is the PM doc position
8
- * corresponding to character i in `text`
9
- */
10
- export function buildPmTextMap(doc: Node): { text: string; pmPositions: number[] } {
11
- const chars: string[] = []
12
- const pmPositions: number[] = []
13
-
14
- doc.nodesBetween(0, doc.content.size, (node, pmPos) => {
15
- if (node.isText) {
16
- const t = node.text!
17
- for (let i = 0; i < t.length; i++) {
18
- chars.push(t[i]!)
19
- pmPositions.push(pmPos + i)
20
- }
21
- }
22
- return true
23
- })
24
-
25
- return { text: chars.join(''), pmPositions }
26
- }
27
-
28
- export interface PmMatch {
29
- start: number // index into PM text map
30
- end: number
31
- pmFrom: number // PM doc position
32
- pmTo: number
33
- text: string
34
- }
35
-
36
- export function findInPmDoc(
37
- doc: Node,
38
- query: string,
39
- options: { caseSensitive?: boolean } = {},
40
- ): PmMatch[] {
41
- if (!query) return []
42
- const { text, pmPositions } = buildPmTextMap(doc)
43
- if (!text) return []
44
-
45
- const { caseSensitive = false } = options
46
- const haystack = caseSensitive ? text : text.toLowerCase()
47
- const needle = caseSensitive ? query : query.toLowerCase()
48
-
49
- const matches: PmMatch[] = []
50
- let searchFrom = 0
51
- while (searchFrom < haystack.length) {
52
- const idx = haystack.indexOf(needle, searchFrom)
53
- if (idx === -1) break
54
- const end = idx + needle.length
55
- matches.push({
56
- start: idx,
57
- end,
58
- pmFrom: pmPositions[idx]!,
59
- pmTo: pmPositions[end - 1]! + 1,
60
- text: text.slice(idx, end),
61
- })
62
- searchFrom = idx + 1
63
- }
64
- return matches
65
- }
66
-
67
- /**
68
- * Scroll the editor so the given match is visible.
69
- * Highlighting is handled by the find-highlight plugin (decorations) — no selection
70
- * dispatch here so focus stays in the find input.
71
- */
72
- export function scrollToPmMatch(match: PmMatch): void {
73
- const view = getEditorView()
74
- if (!view) return
75
- try {
76
- const domNode = view.nodeDOM(match.pmFrom)
77
- const el = domNode instanceof Element ? domNode : (domNode as Element | null)?.parentElement
78
- el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
79
- } catch {
80
- // At doc boundary — ignore
81
- }
82
- }
83
-
84
- /** Legacy: maps a flat text-space offset to PM position. Used by tests only. */
85
- export function textOffsetToPmPos(doc: Node, textOffset: number): number | null {
86
- const { pmPositions } = buildPmTextMap(doc)
87
- if (textOffset >= pmPositions.length) return null
88
- return pmPositions[textOffset] ?? null
89
- }
@@ -1,53 +0,0 @@
1
- // Detect if a string contains unmapped glyphs (tofu / .notdef boxes)
2
- // by rendering to an invisible canvas and comparing dimensions
3
-
4
- export function detectTofu(text: string, fontFamily: string = 'monospace'): boolean {
5
- if (!text || !text.length) return false
6
- if (typeof document === 'undefined') return false // SSR/Node env
7
-
8
- // Create invisible canvas
9
- const canvas = document.createElement('canvas')
10
- const ctx = canvas.getContext('2d')
11
- if (!ctx) return false
12
-
13
- canvas.width = 1000
14
- canvas.height = 100
15
-
16
- // Render reference .notdef character (U+FFFD, replacement char)
17
- ctx.font = `12px ${fontFamily}`
18
- ctx.fillStyle = 'black'
19
- ctx.fillText('�', 10, 50)
20
-
21
- try {
22
- const refData = ctx.getImageData(0, 0, canvas.width, canvas.height).data
23
- const refPixels = countNonZeroPixels(refData)
24
-
25
- // Render each character from input text
26
- for (const char of text) {
27
- ctx.clearRect(0, 0, canvas.width, canvas.height)
28
- ctx.fillText(char, 10, 50)
29
- const charData = ctx.getImageData(0, 0, canvas.width, canvas.height).data
30
- const charPixels = countNonZeroPixels(charData)
31
-
32
- // If pixel count matches .notdef (within 10% tolerance), it's likely tofu
33
- if (Math.abs(charPixels - refPixels) < refPixels * 0.1) {
34
- return true
35
- }
36
- }
37
- } catch {
38
- return false
39
- }
40
-
41
- return false
42
- }
43
-
44
- function countNonZeroPixels(imageData: Uint8ClampedArray): number {
45
- let count = 0
46
- // Every 4th value is alpha; count pixels where any channel is non-zero
47
- for (let i = 0; i < imageData.length; i += 4) {
48
- if ((imageData[i] ?? 0) > 0 || (imageData[i + 1] ?? 0) > 0 || (imageData[i + 2] ?? 0) > 0) {
49
- count++
50
- }
51
- }
52
- return count
53
- }
@@ -1,207 +0,0 @@
1
- import React, { createContext, useContext, useState, useCallback } from 'react'
2
- import { parse, DocumentNode, SceneHeadingNode, ContentNode } from '@sutrata/parser'
3
- import { detectScript } from './language-detect'
4
-
5
- interface LanguageContextType {
6
- currentLanguage: string
7
- resolveLanguageAtCaret: (text: string, caretPos: number, docFrontmatter?: Record<string, any>) => string
8
- }
9
-
10
- const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
11
-
12
- export function LanguageProvider({ children }: { children: React.ReactNode }) {
13
- const [currentLanguage, setCurrentLanguage] = useState<string>('en')
14
-
15
- const resolveLanguageAtCaret = useCallback(
16
- (text: string, caretPos: number, docFrontmatter: Record<string, any> = {}): string => {
17
- try {
18
- const ast = parse(text)
19
- let effectiveLanguage = 'en'
20
-
21
- // 1. Default to document frontmatter lang if present (lowest priority)
22
- if (docFrontmatter.lang) {
23
- effectiveLanguage = docFrontmatter.lang
24
- }
25
-
26
- // 2. Find the block containing the caret
27
- const blockInfo = findBlockAtPosition(ast, caretPos)
28
- if (!blockInfo) {
29
- return effectiveLanguage
30
- }
31
-
32
- const block = blockInfo.node
33
-
34
- // 3. Check for & lang: scene metadata (beats frontmatter)
35
- if (isSceneHeading(block) && block.metadata && block.metadata.length > 0) {
36
- const langMeta = block.metadata.find(m => m.key === 'lang')
37
- if (langMeta) {
38
- effectiveLanguage = langMeta.value
39
- }
40
- }
41
-
42
- // 4. Check for inline {lang=…} tag (beats all)
43
- const inlineTag = findInlineLangTag(block, caretPos)
44
- if (inlineTag) {
45
- effectiveLanguage = inlineTag
46
- }
47
-
48
- // 5. Fallback: detect from text at caret if no higher-priority language was found
49
- // This applies when: no inline tag, no scene metadata, and no frontmatter lang
50
- if (!inlineTag && !hasLangMetadata(block) && !docFrontmatter.lang) {
51
- const blockText = getBlockText(block)
52
- if (blockText) {
53
- effectiveLanguage = detectScript(blockText)
54
- }
55
- }
56
-
57
- return effectiveLanguage
58
- } catch (err) {
59
- console.error('Error resolving language at caret:', err)
60
- return 'en'
61
- }
62
- },
63
- []
64
- )
65
-
66
- return (
67
- <LanguageContext.Provider value={{ currentLanguage, resolveLanguageAtCaret }}>
68
- {children}
69
- </LanguageContext.Provider>
70
- )
71
- }
72
-
73
- export function useLanguage() {
74
- const context = useContext(LanguageContext)
75
- if (!context) {
76
- throw new Error('useLanguage must be used within LanguageProvider')
77
- }
78
- return context
79
- }
80
-
81
- // Helper: Check if a node is a SceneHeadingNode
82
- function isSceneHeading(node: ContentNode): node is SceneHeadingNode {
83
- return (node as any).type === 'scene-heading'
84
- }
85
-
86
- // Helper: Get text content from a block
87
- function getBlockText(block: ContentNode): string {
88
- const blockAny = block as any
89
-
90
- // For SceneHeadingNode, extract text from children (action, dialogue, etc.)
91
- if (blockAny.type === 'scene-heading' && blockAny.children && blockAny.children.length > 0) {
92
- const childTexts = blockAny.children
93
- .map((child: any) => {
94
- if (child.type === 'action' && child.spans) {
95
- return extractTextFromSpans(child.spans)
96
- }
97
- if (child.type === 'dialogue' && child.spans) {
98
- return extractTextFromSpans(child.spans)
99
- }
100
- if (child.text) return child.text
101
- return ''
102
- })
103
- .filter((t: string) => t.length > 0)
104
- .join(' ')
105
-
106
- if (childTexts) return childTexts
107
- }
108
-
109
- // Direct text property (for action, transition, note, etc.)
110
- if (blockAny.text) return blockAny.text
111
-
112
- // Inline spans (for action, dialogue, lyrics)
113
- if (blockAny.spans) {
114
- return blockAny.spans
115
- .map((span: any) => {
116
- if (span.type === 'text') return span.text
117
- if (span.type === 'note') return span.text
118
- if (span.spans) return extractTextFromSpans(span.spans)
119
- return ''
120
- })
121
- .join('')
122
- }
123
-
124
- return ''
125
- }
126
-
127
- // Helper: Extract text from nested spans
128
- function extractTextFromSpans(spans: any[]): string {
129
- return spans
130
- .map(span => {
131
- if (span.type === 'text') return span.text
132
- if (span.type === 'note') return span.text
133
- if (span.spans) return extractTextFromSpans(span.spans)
134
- return ''
135
- })
136
- .join('')
137
- }
138
-
139
- // Helper: Check if block has lang metadata
140
- function hasLangMetadata(block: ContentNode): boolean {
141
- if (!isSceneHeading(block)) return false
142
- return block.metadata && block.metadata.length > 0 && block.metadata.some(m => m.key === 'lang')
143
- }
144
-
145
- // Helper: Find the block containing the caret position
146
- // Returns the block and its start position
147
- function findBlockAtPosition(
148
- ast: DocumentNode,
149
- pos: number
150
- ): { node: ContentNode; startPos: number } | null {
151
- if (!ast.children) return null
152
-
153
- // Approximate position tracking - simplified for now
154
- // In a real implementation, this would use actual byte offsets
155
- let currentPos = 0
156
-
157
- for (const child of ast.children) {
158
- const blockAny = child as any
159
- // Use raw text for more accurate length calculation
160
- const blockLength = blockAny.raw ? blockAny.raw.length + 1 : 50 // +1 for newline, fallback to ~50 chars
161
-
162
- if (pos >= currentPos && pos < currentPos + blockLength) {
163
- return { node: child, startPos: currentPos }
164
- }
165
- currentPos += blockLength
166
- }
167
-
168
- // If position is beyond all blocks, return the last block as fallback
169
- if (ast.children.length > 0) {
170
- let totalPos = 0
171
- const lastChild = ast.children[ast.children.length - 1]!
172
- return { node: lastChild, startPos: totalPos }
173
- }
174
-
175
- return null
176
- }
177
-
178
- // Helper: Get the length of a block in characters
179
- function getBlockLength(block: ContentNode): number {
180
- const blockAny = block as any
181
- if (blockAny.raw) {
182
- return blockAny.raw.length + 1 // +1 for newline
183
- }
184
- return (blockAny.text?.length ?? 0) + 1 // +1 for newline
185
- }
186
-
187
- // Helper: Find inline {lang=XX} tag in the block
188
- function findInlineLangTag(block: ContentNode, caretPos: number): string | null {
189
- const blockText = getBlockText(block)
190
- if (!blockText) return null
191
-
192
- // Regex to find {lang=XX} tags - match 2-letter language codes
193
- const tagRegex = /\{lang=([a-z]{2})\}/g
194
- let match
195
- let closestTag: string | null = null
196
- let closestDistance = Infinity
197
-
198
- while ((match = tagRegex.exec(blockText)) !== null) {
199
- const distance = Math.abs(caretPos - match.index)
200
- if (distance < closestDistance) {
201
- closestDistance = distance
202
- closestTag = match[1] ?? null
203
- }
204
- }
205
-
206
- return closestTag
207
- }