@sutrata/editor 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
- package/dist/ai/VoiceConfirmDialog.js +6 -6
- package/dist/ai/VoiceToolbar.d.ts +1 -0
- package/dist/ai/VoiceToolbar.js +22 -16
- package/dist/ai/clean-response.d.ts +7 -0
- package/dist/ai/clean-response.js +58 -0
- package/dist/ai/format-transcript.d.ts +3 -0
- package/dist/ai/format-transcript.js +7 -0
- package/dist/ai/prompts.d.ts +8 -1
- package/dist/ai/prompts.js +34 -7
- package/dist/autocomplete/suggestions.js +1 -1
- package/dist/context/DocumentContext.d.ts +28 -4
- package/dist/context/DocumentContext.js +67 -37
- package/dist/editor/EditorView.js +32 -4
- package/dist/editor/SelectionMenu.js +5 -3
- package/dist/editor/external-decorations-plugin.d.ts +23 -0
- package/dist/editor/external-decorations-plugin.js +151 -0
- package/dist/editor/omitted-scene-plugin.d.ts +9 -0
- package/dist/editor/omitted-scene-plugin.js +44 -0
- package/dist/editor/plugins.js +4 -0
- package/dist/editor/private-metadata-plugin.d.ts +10 -0
- package/dist/editor/private-metadata-plugin.js +60 -0
- package/dist/editor/prosemirror-to-sutra.js +179 -74
- package/dist/editor/scene-heading-view.js +2 -0
- package/dist/editor/schema.js +69 -12
- package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
- package/dist/editor/sutra-to-prosemirror.js +32 -34
- package/dist/extensions/ai-provider.d.ts +44 -0
- package/dist/extensions/ai-provider.js +27 -0
- package/dist/extensions/collab.d.ts +21 -0
- package/dist/extensions/collab.js +6 -0
- package/dist/extensions/command-registry.d.ts +49 -0
- package/dist/extensions/command-registry.js +28 -0
- package/dist/extensions/decorations.d.ts +48 -0
- package/dist/extensions/decorations.js +6 -0
- package/dist/extensions/export-registry.d.ts +59 -0
- package/dist/extensions/export-registry.js +10 -0
- package/dist/extensions/panel-registry.d.ts +30 -0
- package/dist/extensions/panel-registry.js +16 -0
- package/dist/extensions/registries.d.ts +15 -0
- package/dist/extensions/registries.js +12 -0
- package/dist/extensions/registry-store.d.ts +26 -0
- package/dist/extensions/registry-store.js +35 -0
- package/dist/extensions/session.d.ts +29 -0
- package/dist/extensions/session.js +20 -0
- package/dist/extensions/speech-provider.d.ts +35 -0
- package/dist/extensions/speech-provider.js +17 -0
- package/dist/file/builtin-exporters.d.ts +9 -0
- package/dist/file/builtin-exporters.js +72 -0
- package/dist/file/docx-exporter.js +7 -4
- package/dist/file/docx-importer.d.ts +9 -0
- package/dist/file/docx-importer.js +47 -19
- package/dist/file/scene-number.d.ts +5 -0
- package/dist/file/scene-number.js +8 -0
- package/dist/file/workflow-reports.js +13 -7
- package/dist/findreplace/FindReplace.js +5 -2
- package/dist/import/AIImportDialog.d.ts +11 -0
- package/dist/import/AIImportDialog.js +112 -0
- package/dist/import/ai-import.d.ts +51 -0
- package/dist/import/ai-import.js +200 -0
- package/dist/import/apply-import.d.ts +7 -0
- package/dist/import/apply-import.js +35 -0
- package/dist/import/extract.d.ts +17 -0
- package/dist/import/extract.js +18 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +7 -0
- package/dist/locales/bn.json +8 -0
- package/dist/locales/en.json +8 -0
- package/dist/locales/gu.json +8 -0
- package/dist/locales/hi.json +8 -0
- package/dist/locales/kn.json +8 -0
- package/dist/locales/ml.json +8 -0
- package/dist/locales/mr.json +8 -0
- package/dist/locales/or.json +8 -0
- package/dist/locales/pa.json +8 -0
- package/dist/locales/si.json +8 -0
- package/dist/locales/ta.json +8 -0
- package/dist/locales/te.json +8 -0
- package/dist/navigator/SceneNavigator.js +125 -131
- package/dist/navigator/StatisticsDialog.js +5 -1
- package/dist/navigator/scene-edits.d.ts +18 -0
- package/dist/navigator/scene-edits.js +143 -0
- package/dist/navigator/scene-list.js +4 -4
- package/dist/navigator/scene-numbering.d.ts +37 -0
- package/dist/navigator/scene-numbering.js +88 -0
- package/dist/settings/SettingsDialog.js +8 -120
- package/dist/shell/AppBar.js +21 -2
- package/dist/shell/AppShell.js +18 -4
- package/dist/shell/ElementToolbar.d.ts +5 -0
- package/dist/shell/ElementToolbar.js +35 -25
- package/dist/shell/LanguageStatus.js +6 -0
- package/dist/shell/StatusBar.js +11 -6
- package/dist/shell/builtin-commands.d.ts +10 -0
- package/dist/shell/builtin-commands.js +43 -0
- package/dist/shell/icons.d.ts +6 -0
- package/dist/shell/icons.js +6 -0
- package/dist/shell/use-commands.d.ts +7 -0
- package/dist/shell/use-commands.js +24 -0
- package/dist/shell/use-panel-context.d.ts +3 -0
- package/dist/shell/use-panel-context.js +39 -0
- package/dist/source/SourceView.js +18 -1
- package/dist/styles/screenplay.css +113 -1
- package/dist/titlepage/ExportDialog.d.ts +5 -0
- package/dist/titlepage/ExportDialog.js +59 -96
- package/package.json +32 -5
- package/dist/ai/OnboardingDialog.d.ts +0 -6
- package/dist/ai/OnboardingDialog.js +0 -115
- package/dist/ai/ai-client.d.ts +0 -71
- package/dist/ai/ai-client.js +0 -494
- package/dist/ai/generated-providers.json +0 -3696
- package/dist/ai/voice-service.d.ts +0 -15
- package/dist/ai/voice-service.js +0 -81
- package/dist/editor/character-view.d.ts +0 -35
- package/dist/editor/character-view.js +0 -112
- package/dist/editor/transliterate-input.d.ts +0 -10
- package/dist/editor/transliterate-input.js +0 -88
- package/dist/extensions/types.d.ts +0 -73
- package/dist/extensions/types.js +0 -12
- package/scripts/copy-assets.mjs +0 -16
- package/scripts/generate-providers.mjs +0 -127
- package/src/ai/OnboardingDialog.tsx +0 -390
- package/src/ai/VoiceConfirmDialog.tsx +0 -137
- package/src/ai/VoiceToolbar.tsx +0 -321
- package/src/ai/ai-client.ts +0 -596
- package/src/ai/commands.ts +0 -164
- package/src/ai/generated-providers.json +0 -3696
- package/src/ai/prompts.ts +0 -62
- package/src/ai/voice-service.ts +0 -96
- package/src/autocomplete/AutocompletePlugin.ts +0 -21
- package/src/autocomplete/suggestions.ts +0 -147
- package/src/context/DocumentContext.tsx +0 -648
- package/src/desktop-api.d.ts +0 -40
- package/src/editor/EditorView.tsx +0 -82
- package/src/editor/SelectionMenu.tsx +0 -152
- package/src/editor/attr-pencil-plugin.ts +0 -172
- package/src/editor/character-decoration-plugin.ts +0 -104
- package/src/editor/editor-bus.ts +0 -51
- package/src/editor/find-highlight-plugin.ts +0 -68
- package/src/editor/frontmatter-field-view.ts +0 -52
- package/src/editor/frontmatter-field.ts +0 -115
- package/src/editor/grapheme-cursor.ts +0 -99
- package/src/editor/ime-plugin.ts +0 -85
- package/src/editor/insert-helper.ts +0 -43
- package/src/editor/keymap.ts +0 -124
- package/src/editor/language-class-plugin.ts +0 -91
- package/src/editor/plugins.ts +0 -26
- package/src/editor/prosemirror-to-sutra.ts +0 -192
- package/src/editor/scene-heading-view.ts +0 -147
- package/src/editor/schema.ts +0 -156
- package/src/editor/sutra-to-prosemirror.ts +0 -156
- package/src/extensions/storage-adapter.ts +0 -65
- package/src/extensions/types.ts +0 -60
- package/src/file/docx-exporter.ts +0 -585
- package/src/file/docx-importer.ts +0 -313
- package/src/file/print-paginator.ts +0 -301
- package/src/file/templates.ts +0 -73
- package/src/file/workflow-reports.ts +0 -820
- package/src/findreplace/FindReplace.tsx +0 -242
- package/src/findreplace/find-engine.ts +0 -97
- package/src/findreplace/scroll-to-match.ts +0 -89
- package/src/fonts/tofu-detect.ts +0 -53
- package/src/i18n/LanguageContext.tsx +0 -207
- package/src/i18n/language-detect.ts +0 -73
- package/src/i18n/useTranslation.tsx +0 -74
- package/src/index.ts +0 -27
- package/src/locales/bn.json +0 -143
- package/src/locales/en.json +0 -143
- package/src/locales/gu.json +0 -143
- package/src/locales/hi.json +0 -143
- package/src/locales/kn.json +0 -143
- package/src/locales/ml.json +0 -143
- package/src/locales/mr.json +0 -143
- package/src/locales/or.json +0 -143
- package/src/locales/pa.json +0 -143
- package/src/locales/si.json +0 -143
- package/src/locales/ta.json +0 -143
- package/src/locales/te.json +0 -143
- package/src/navigator/SceneNavigator.tsx +0 -844
- package/src/navigator/StatisticsDialog.tsx +0 -249
- package/src/navigator/scene-list.ts +0 -84
- package/src/settings/SettingsDialog.tsx +0 -303
- package/src/shell/AppBar.tsx +0 -354
- package/src/shell/AppShell.tsx +0 -71
- package/src/shell/ConfirmDialog.tsx +0 -71
- package/src/shell/ElementToolbar.tsx +0 -175
- package/src/shell/LanguageStatus.tsx +0 -101
- package/src/shell/StatusBar.tsx +0 -135
- package/src/shell/SutrataLogo.tsx +0 -89
- package/src/shell/Toast.tsx +0 -46
- package/src/shell/ToolbarButton.tsx +0 -30
- package/src/shell/VersionHistory.tsx +0 -212
- package/src/shell/Watermark.tsx +0 -9
- package/src/shell/fonts/fonts.css +0 -1133
- package/src/shell/icons.tsx +0 -335
- package/src/shell/toolbar-actions.ts +0 -59
- package/src/source/SourceView.tsx +0 -111
- package/src/source/find-highlight-source.ts +0 -57
- package/src/source/sutra-lang.ts +0 -121
- package/src/spellcheck/dictionaries.ts +0 -21
- package/src/spellcheck/hunspell.ts +0 -110
- package/src/spellcheck/spellcheck-plugin.ts +0 -83
- package/src/styles/StyleDialog.tsx +0 -148
- package/src/styles/builtin-styles.ts +0 -139
- package/src/styles/css-adapter.ts +0 -76
- package/src/styles/docx-adapter.ts +0 -109
- package/src/styles/embed-fonts.ts +0 -81
- package/src/styles/pagination.ts +0 -27
- package/src/styles/print-adapter.ts +0 -288
- package/src/styles/registry.ts +0 -20
- package/src/styles/screenplay.css +0 -3006
- package/src/styles/script-ranges.ts +0 -17
- package/src/styles/types.ts +0 -104
- package/src/styles/validate.ts +0 -114
- package/src/templates/templates.ts +0 -194
- package/src/titlepage/ExportDialog.tsx +0 -494
- package/src/titlepage/TitlePageDialog.tsx +0 -312
- package/src/titlepage/frontmatter-form.ts +0 -280
- package/src/titlepage/languages.ts +0 -37
- package/src/types.ts +0 -7
- package/tests/AppBar.test.tsx +0 -35
- package/tests/AppShell.test.tsx +0 -22
- package/tests/ElementToolbar.test.tsx +0 -36
- package/tests/LanguageStatus.test.tsx +0 -129
- package/tests/SelectionMenu.test.tsx +0 -71
- package/tests/ToolbarButton.test.tsx +0 -24
- package/tests/ai-onboarding.test.ts +0 -102
- package/tests/ai-status.test.tsx +0 -72
- package/tests/ai-structured.test.ts +0 -94
- package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
- package/tests/autocomplete/suggestions.test.ts +0 -106
- package/tests/document-context.test.tsx +0 -565
- package/tests/docx-importer.test.ts +0 -160
- package/tests/editor/character-decoration-plugin.test.ts +0 -101
- package/tests/editor/character-extension.test.ts +0 -44
- package/tests/editor/grapheme-cursor.test.ts +0 -118
- package/tests/editor/ime-plugin.test.ts +0 -72
- package/tests/editor/inline-note-bridge.test.ts +0 -32
- package/tests/editor/language-class-plugin.test.ts +0 -265
- package/tests/editor/title-page.test.ts +0 -124
- package/tests/embed-fonts.test.ts +0 -17
- package/tests/exporters.test.ts +0 -365
- package/tests/findreplace/FindReplace.test.tsx +0 -391
- package/tests/findreplace/find-engine.test.ts +0 -86
- package/tests/findreplace/scroll-to-match.test.ts +0 -50
- package/tests/fonts/tofu-detect.test.ts +0 -122
- package/tests/i18n/LanguageContext.test.tsx +0 -146
- package/tests/i18n/language-detect.test.ts +0 -54
- package/tests/i18n/locale-parity.test.ts +0 -25
- package/tests/i18n/useTranslation.test.tsx +0 -45
- package/tests/keymap.test.ts +0 -18
- package/tests/navigator/SceneNavigator.test.tsx +0 -177
- package/tests/navigator/StatisticsDialog.test.tsx +0 -63
- package/tests/navigator/scene-list.test.ts +0 -71
- package/tests/print-paginator.test.ts +0 -82
- package/tests/shell/VersionHistory.test.tsx +0 -129
- package/tests/spellcheck/hunspell.test.ts +0 -82
- package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
- package/tests/styles.test.ts +0 -315
- package/tests/templates/templates.test.ts +0 -83
- package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
- package/tests/titlepage/apply-title-page.test.ts +0 -61
- package/tests/titlepage/frontmatter-form.test.ts +0 -115
- package/tests/toolbar-actions.test.ts +0 -93
- package/tests/voice-commands.test.ts +0 -58
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -11
- package/vitest.setup.ts +0 -65
|
@@ -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
|
-
}
|
package/src/fonts/tofu-detect.ts
DELETED
|
@@ -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
|
-
}
|