@sutrata/editor 0.1.0 → 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.
- package/README.md +87 -0
- package/dist/ai/generated-providers.json +1 -1
- package/package.json +36 -9
- 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,391 +0,0 @@
|
|
|
1
|
-
import React, { useEffect } from 'react'
|
|
2
|
-
import { describe, it, expect, afterEach } from 'vitest'
|
|
3
|
-
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
4
|
-
import { FindReplace } from '../../src/findreplace/FindReplace'
|
|
5
|
-
import { DocumentProvider, useDocument } from '../../src/context/DocumentContext'
|
|
6
|
-
import { LanguageProvider } from '../../src/i18n/LanguageContext'
|
|
7
|
-
import { TranslationProvider } from '../../src/i18n/useTranslation'
|
|
8
|
-
import { setEditorView } from '../../src/editor/editor-bus'
|
|
9
|
-
import { schema } from '../../src/editor/schema'
|
|
10
|
-
|
|
11
|
-
// Build a minimal fake PM view so findInPmDoc can find words in the test doc.
|
|
12
|
-
// `getWords` is called lazily so the view reflects text changes during a test.
|
|
13
|
-
function makeFakeView(getWords: () => string[]) {
|
|
14
|
-
const view = {
|
|
15
|
-
get state() {
|
|
16
|
-
const words = getWords()
|
|
17
|
-
const blocks = words.map(w =>
|
|
18
|
-
schema.nodes['action']!.create({}, w ? schema.text(w) : undefined)
|
|
19
|
-
)
|
|
20
|
-
return { doc: schema.nodes['doc']!.create({}, blocks) }
|
|
21
|
-
},
|
|
22
|
-
dispatch: () => {},
|
|
23
|
-
focus: () => {},
|
|
24
|
-
nodeDOM: () => null,
|
|
25
|
-
}
|
|
26
|
-
return view as never
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
// Clear injected view between tests
|
|
31
|
-
setEditorView(null)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
/** Opens the find-replace panel via the context setter */
|
|
35
|
-
function PanelOpener({ mode = 'replace' }: { mode?: 'find' | 'replace' } = {}) {
|
|
36
|
-
const { setFindReplaceVisible, setFindMode } = useDocument()
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
setFindMode(mode)
|
|
39
|
-
setFindReplaceVisible(true)
|
|
40
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
-
}, [])
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Sets the document text via context after mount */
|
|
46
|
-
function TextSetter({ text }: { text: string }) {
|
|
47
|
-
const { setText } = useDocument()
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
setText(text)
|
|
50
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
-
}, [])
|
|
52
|
-
return null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Switches the editor into source mode after mount (no real CodeMirror view is mounted). */
|
|
56
|
-
function SourceModeSetter() {
|
|
57
|
-
const { setMode } = useDocument()
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
setMode('source')
|
|
60
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
-
}, [])
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function Wrapper({ children, initialText, mode = 'replace' }: { children?: React.ReactNode; initialText?: string; mode?: 'find' | 'replace' }) {
|
|
66
|
-
return (
|
|
67
|
-
<TranslationProvider>
|
|
68
|
-
<DocumentProvider>
|
|
69
|
-
<LanguageProvider>
|
|
70
|
-
{initialText && <TextSetter text={initialText} />}
|
|
71
|
-
<PanelOpener mode={mode} />
|
|
72
|
-
<FindReplace />
|
|
73
|
-
{children}
|
|
74
|
-
</LanguageProvider>
|
|
75
|
-
</DocumentProvider>
|
|
76
|
-
</TranslationProvider>
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
describe('FindReplace', () => {
|
|
81
|
-
it('renders when findReplaceVisible=true', async () => {
|
|
82
|
-
await act(async () => {
|
|
83
|
-
render(<Wrapper />)
|
|
84
|
-
})
|
|
85
|
-
expect(screen.getByPlaceholderText('Find…')).toBeDefined()
|
|
86
|
-
expect(screen.getByPlaceholderText('Replace with…')).toBeDefined()
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('shows "No matches" when query has no results', async () => {
|
|
90
|
-
setEditorView(makeFakeView(() => ['Hello world']))
|
|
91
|
-
await act(async () => {
|
|
92
|
-
render(<Wrapper initialText="Hello world" />)
|
|
93
|
-
})
|
|
94
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
95
|
-
await act(async () => {
|
|
96
|
-
fireEvent.change(input, { target: { value: 'zzz' } })
|
|
97
|
-
})
|
|
98
|
-
expect(screen.getByText('No matches')).toBeDefined()
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('shows match count when query has results', async () => {
|
|
102
|
-
setEditorView(makeFakeView(() => ['foo', 'foo', 'foo']))
|
|
103
|
-
await act(async () => {
|
|
104
|
-
render(<Wrapper initialText="foo foo foo" />)
|
|
105
|
-
})
|
|
106
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
107
|
-
await act(async () => {
|
|
108
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
109
|
-
})
|
|
110
|
-
expect(screen.getByText('1 of 3')).toBeDefined()
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('replace-all updates document text via setText', async () => {
|
|
114
|
-
setEditorView(makeFakeView(() => ['foo', 'foo', 'foo']))
|
|
115
|
-
function TextProbe() {
|
|
116
|
-
const { text } = useDocument()
|
|
117
|
-
return <div data-testid="text-output">{text}</div>
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
await act(async () => {
|
|
121
|
-
render(
|
|
122
|
-
<TranslationProvider>
|
|
123
|
-
<DocumentProvider>
|
|
124
|
-
<LanguageProvider>
|
|
125
|
-
<TextSetter text="foo foo foo" />
|
|
126
|
-
<PanelOpener />
|
|
127
|
-
<FindReplace />
|
|
128
|
-
<TextProbe />
|
|
129
|
-
</LanguageProvider>
|
|
130
|
-
</DocumentProvider>
|
|
131
|
-
</TranslationProvider>
|
|
132
|
-
)
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
const queryInput = screen.getByPlaceholderText('Find…')
|
|
136
|
-
const replaceInput = screen.getByPlaceholderText('Replace with…')
|
|
137
|
-
|
|
138
|
-
await act(async () => {
|
|
139
|
-
fireEvent.change(queryInput, { target: { value: 'foo' } })
|
|
140
|
-
fireEvent.change(replaceInput, { target: { value: 'bar' } })
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
const replaceBtn = screen.getByText('Replace All')
|
|
144
|
-
await act(async () => {
|
|
145
|
-
fireEvent.click(replaceBtn)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
expect(screen.getByTestId('text-output').textContent).toBe('bar bar bar')
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('closes on × button click', async () => {
|
|
152
|
-
let container!: HTMLElement
|
|
153
|
-
await act(async () => {
|
|
154
|
-
;({ container } = render(<Wrapper />))
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
expect(container.querySelector('.cs-find-replace')).not.toBeNull()
|
|
158
|
-
|
|
159
|
-
const closeBtn = screen.getByLabelText('Close')
|
|
160
|
-
await act(async () => {
|
|
161
|
-
fireEvent.click(closeBtn)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
expect(container.querySelector('.cs-find-replace')).toBeNull()
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
it('shows prev/next buttons always; count appears when matches exist', async () => {
|
|
168
|
-
setEditorView(makeFakeView(() => ['foo', 'foo', 'foo']))
|
|
169
|
-
await act(async () => {
|
|
170
|
-
render(<Wrapper initialText="foo foo foo" mode="find" />)
|
|
171
|
-
})
|
|
172
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
173
|
-
await act(async () => {
|
|
174
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
175
|
-
})
|
|
176
|
-
expect(screen.getByLabelText('Previous match')).toBeDefined()
|
|
177
|
-
expect(screen.getByLabelText('Next match')).toBeDefined()
|
|
178
|
-
expect(screen.getByText('1 of 3')).toBeDefined()
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('Enter advances to next match', async () => {
|
|
182
|
-
setEditorView(makeFakeView(() => ['foo', 'foo', 'foo']))
|
|
183
|
-
await act(async () => {
|
|
184
|
-
render(<Wrapper initialText="foo foo foo" mode="find" />)
|
|
185
|
-
})
|
|
186
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
187
|
-
await act(async () => {
|
|
188
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
189
|
-
})
|
|
190
|
-
await act(async () => {
|
|
191
|
-
fireEvent.keyDown(input, { key: 'Enter' })
|
|
192
|
-
})
|
|
193
|
-
expect(screen.getByText('2 of 3')).toBeDefined()
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it('Shift+Enter goes to previous match', async () => {
|
|
197
|
-
setEditorView(makeFakeView(() => ['foo', 'foo', 'foo']))
|
|
198
|
-
await act(async () => {
|
|
199
|
-
render(<Wrapper initialText="foo foo foo" mode="find" />)
|
|
200
|
-
})
|
|
201
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
202
|
-
await act(async () => {
|
|
203
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
204
|
-
})
|
|
205
|
-
await act(async () => {
|
|
206
|
-
fireEvent.keyDown(input, { key: 'Enter' })
|
|
207
|
-
})
|
|
208
|
-
await act(async () => {
|
|
209
|
-
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
|
210
|
-
})
|
|
211
|
-
expect(screen.getByText('1 of 3')).toBeDefined()
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('next wraps from last to first match', async () => {
|
|
215
|
-
setEditorView(makeFakeView(() => ['foo', 'foo']))
|
|
216
|
-
await act(async () => {
|
|
217
|
-
render(<Wrapper initialText="foo foo" mode="find" />)
|
|
218
|
-
})
|
|
219
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
220
|
-
await act(async () => {
|
|
221
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
222
|
-
})
|
|
223
|
-
await act(async () => {
|
|
224
|
-
fireEvent.keyDown(input, { key: 'Enter' })
|
|
225
|
-
fireEvent.keyDown(input, { key: 'Enter' })
|
|
226
|
-
})
|
|
227
|
-
expect(screen.getByText('1 of 2')).toBeDefined()
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
it('Replace button replaces current match and advances', async () => {
|
|
231
|
-
// The fake view needs to reflect the current text so match count updates after replace.
|
|
232
|
-
// We store a mutable ref to the current word list and update it whenever text changes.
|
|
233
|
-
const wordsRef = { current: ['foo', 'foo', 'foo'] }
|
|
234
|
-
setEditorView(makeFakeView(() => wordsRef.current))
|
|
235
|
-
|
|
236
|
-
function TextProbe() {
|
|
237
|
-
const { text } = useDocument()
|
|
238
|
-
// Keep wordsRef in sync with the live text so findInPmDoc sees the updated doc
|
|
239
|
-
wordsRef.current = text.split(/\s+/).filter(Boolean)
|
|
240
|
-
return <div data-testid="text-output">{text}</div>
|
|
241
|
-
}
|
|
242
|
-
await act(async () => {
|
|
243
|
-
render(
|
|
244
|
-
<TranslationProvider>
|
|
245
|
-
<DocumentProvider>
|
|
246
|
-
<LanguageProvider>
|
|
247
|
-
<TextSetter text="foo foo foo" />
|
|
248
|
-
<PanelOpener mode="replace" />
|
|
249
|
-
<FindReplace />
|
|
250
|
-
<TextProbe />
|
|
251
|
-
</LanguageProvider>
|
|
252
|
-
</DocumentProvider>
|
|
253
|
-
</TranslationProvider>
|
|
254
|
-
)
|
|
255
|
-
})
|
|
256
|
-
const queryInput = screen.getByPlaceholderText('Find…')
|
|
257
|
-
const replaceInput = screen.getByPlaceholderText('Replace with…')
|
|
258
|
-
await act(async () => {
|
|
259
|
-
fireEvent.change(queryInput, { target: { value: 'foo' } })
|
|
260
|
-
fireEvent.change(replaceInput, { target: { value: 'bar' } })
|
|
261
|
-
})
|
|
262
|
-
const replaceBtn = screen.getByText('Replace')
|
|
263
|
-
await act(async () => {
|
|
264
|
-
fireEvent.click(replaceBtn)
|
|
265
|
-
})
|
|
266
|
-
expect(screen.getByTestId('text-output').textContent).toContain('bar')
|
|
267
|
-
expect(screen.getByText('1 of 2')).toBeDefined()
|
|
268
|
-
})
|
|
269
|
-
|
|
270
|
-
it('finds matches in source mode (no PM view mounted)', async () => {
|
|
271
|
-
// Deliberately no setEditorView(...) here — source mode must not depend on it.
|
|
272
|
-
await act(async () => {
|
|
273
|
-
render(
|
|
274
|
-
<TranslationProvider>
|
|
275
|
-
<DocumentProvider>
|
|
276
|
-
<LanguageProvider>
|
|
277
|
-
<TextSetter text="foo foo foo" />
|
|
278
|
-
<SourceModeSetter />
|
|
279
|
-
<PanelOpener mode="find" />
|
|
280
|
-
<FindReplace />
|
|
281
|
-
</LanguageProvider>
|
|
282
|
-
</DocumentProvider>
|
|
283
|
-
</TranslationProvider>
|
|
284
|
-
)
|
|
285
|
-
})
|
|
286
|
-
const input = screen.getByPlaceholderText('Find…')
|
|
287
|
-
await act(async () => {
|
|
288
|
-
fireEvent.change(input, { target: { value: 'foo' } })
|
|
289
|
-
})
|
|
290
|
-
expect(screen.getByText('1 of 3')).toBeDefined()
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
it('replace-all works in source mode', async () => {
|
|
294
|
-
function TextProbe() {
|
|
295
|
-
const { text } = useDocument()
|
|
296
|
-
return <div data-testid="text-output">{text}</div>
|
|
297
|
-
}
|
|
298
|
-
await act(async () => {
|
|
299
|
-
render(
|
|
300
|
-
<TranslationProvider>
|
|
301
|
-
<DocumentProvider>
|
|
302
|
-
<LanguageProvider>
|
|
303
|
-
<TextSetter text="foo foo foo" />
|
|
304
|
-
<SourceModeSetter />
|
|
305
|
-
<PanelOpener mode="replace" />
|
|
306
|
-
<FindReplace />
|
|
307
|
-
<TextProbe />
|
|
308
|
-
</LanguageProvider>
|
|
309
|
-
</DocumentProvider>
|
|
310
|
-
</TranslationProvider>
|
|
311
|
-
)
|
|
312
|
-
})
|
|
313
|
-
const queryInput = screen.getByPlaceholderText('Find…')
|
|
314
|
-
const replaceInput = screen.getByPlaceholderText('Replace with…')
|
|
315
|
-
await act(async () => {
|
|
316
|
-
fireEvent.change(queryInput, { target: { value: 'foo' } })
|
|
317
|
-
fireEvent.change(replaceInput, { target: { value: 'bar' } })
|
|
318
|
-
})
|
|
319
|
-
const replaceBtn = screen.getByText('Replace All')
|
|
320
|
-
await act(async () => {
|
|
321
|
-
fireEvent.click(replaceBtn)
|
|
322
|
-
})
|
|
323
|
-
expect(screen.getByTestId('text-output').textContent).toBe('bar bar bar')
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
it('replace-one uses raw offsets directly in source mode', async () => {
|
|
327
|
-
function TextProbe() {
|
|
328
|
-
const { text } = useDocument()
|
|
329
|
-
return <div data-testid="text-output">{text}</div>
|
|
330
|
-
}
|
|
331
|
-
await act(async () => {
|
|
332
|
-
render(
|
|
333
|
-
<TranslationProvider>
|
|
334
|
-
<DocumentProvider>
|
|
335
|
-
<LanguageProvider>
|
|
336
|
-
<TextSetter text="foo foo foo" />
|
|
337
|
-
<SourceModeSetter />
|
|
338
|
-
<PanelOpener mode="replace" />
|
|
339
|
-
<FindReplace />
|
|
340
|
-
<TextProbe />
|
|
341
|
-
</LanguageProvider>
|
|
342
|
-
</DocumentProvider>
|
|
343
|
-
</TranslationProvider>
|
|
344
|
-
)
|
|
345
|
-
})
|
|
346
|
-
const queryInput = screen.getByPlaceholderText('Find…')
|
|
347
|
-
const replaceInput = screen.getByPlaceholderText('Replace with…')
|
|
348
|
-
await act(async () => {
|
|
349
|
-
fireEvent.change(queryInput, { target: { value: 'foo' } })
|
|
350
|
-
fireEvent.change(replaceInput, { target: { value: 'bar' } })
|
|
351
|
-
})
|
|
352
|
-
const replaceBtn = screen.getByText('Replace')
|
|
353
|
-
await act(async () => {
|
|
354
|
-
fireEvent.click(replaceBtn)
|
|
355
|
-
})
|
|
356
|
-
expect(screen.getByTestId('text-output').textContent).toBe('bar foo foo')
|
|
357
|
-
expect(screen.getByText('1 of 2')).toBeDefined()
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it('Ctrl-F toggles panel visibility', async () => {
|
|
361
|
-
let container!: HTMLElement
|
|
362
|
-
await act(async () => {
|
|
363
|
-
;({ container } = render(
|
|
364
|
-
<TranslationProvider>
|
|
365
|
-
<DocumentProvider>
|
|
366
|
-
<LanguageProvider>
|
|
367
|
-
<FindReplace />
|
|
368
|
-
</LanguageProvider>
|
|
369
|
-
</DocumentProvider>
|
|
370
|
-
</TranslationProvider>
|
|
371
|
-
))
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
// Panel starts hidden
|
|
375
|
-
expect(container.querySelector('.cs-find-replace')).toBeNull()
|
|
376
|
-
|
|
377
|
-
// Ctrl-F should open it
|
|
378
|
-
await act(async () => {
|
|
379
|
-
const ev = new KeyboardEvent('keydown', { key: 'f', code: 'KeyF', ctrlKey: true, cancelable: true, bubbles: true })
|
|
380
|
-
window.dispatchEvent(ev)
|
|
381
|
-
})
|
|
382
|
-
expect(container.querySelector('.cs-find-replace')).not.toBeNull()
|
|
383
|
-
|
|
384
|
-
// Ctrl-F again should close it
|
|
385
|
-
await act(async () => {
|
|
386
|
-
const ev = new KeyboardEvent('keydown', { key: 'f', code: 'KeyF', ctrlKey: true, cancelable: true, bubbles: true })
|
|
387
|
-
window.dispatchEvent(ev)
|
|
388
|
-
})
|
|
389
|
-
expect(container.querySelector('.cs-find-replace')).toBeNull()
|
|
390
|
-
})
|
|
391
|
-
})
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { findAll, replaceAll, replaceInRange } from '../../src/findreplace/find-engine'
|
|
3
|
-
|
|
4
|
-
describe('find-engine', () => {
|
|
5
|
-
describe('findAll', () => {
|
|
6
|
-
it('finds all occurrences of a query', () => {
|
|
7
|
-
const matches = findAll('hello world hello', 'hello')
|
|
8
|
-
expect(matches.length).toBe(2)
|
|
9
|
-
expect(matches[0].start).toBe(0)
|
|
10
|
-
expect(matches[1].start).toBe(12)
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('is case-sensitive by default', () => {
|
|
14
|
-
const matches = findAll('Hello HELLO hello', 'hello')
|
|
15
|
-
expect(matches.length).toBe(1)
|
|
16
|
-
expect(matches[0].start).toBe(12)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('case-insensitive mode finds all case variants', () => {
|
|
20
|
-
const matches = findAll('Hello HELLO hello', 'hello', { caseSensitive: false })
|
|
21
|
-
expect(matches.length).toBe(3)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('handles NFC-normalized Devanagari text', () => {
|
|
25
|
-
// "नमस्ते" — NFC is the canonical form
|
|
26
|
-
const text = 'नमस्ते नमस्ते'
|
|
27
|
-
const matches = findAll(text, 'नमस्ते')
|
|
28
|
-
expect(matches.length).toBe(2)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('returns empty for no matches', () => {
|
|
32
|
-
expect(findAll('abc', 'xyz').length).toBe(0)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('returns empty for empty query', () => {
|
|
36
|
-
expect(findAll('abc', '').length).toBe(0)
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
it('case-insensitivity is silent no-op for Devanagari (caseless script)', () => {
|
|
40
|
-
// Should not throw; just find exact matches
|
|
41
|
-
const matches = findAll('नमस्ते', 'नमस्ते', { caseSensitive: false })
|
|
42
|
-
expect(matches.length).toBe(1)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('matches NFC-equivalent strings (NFC normalization)', () => {
|
|
46
|
-
// Compose "é" two ways: precomposed NFC (U+00E9) vs NFD decomposed (e + combining accent)
|
|
47
|
-
const precomposed = 'é' // NFC é
|
|
48
|
-
const decomposed = 'é' // NFD é
|
|
49
|
-
expect(precomposed.normalize('NFC')).toBe(decomposed.normalize('NFC'))
|
|
50
|
-
const matches = findAll('café', decomposed)
|
|
51
|
-
expect(matches.length).toBe(1)
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
describe('replaceAll', () => {
|
|
56
|
-
it('replaces all occurrences', () => {
|
|
57
|
-
const result = replaceAll('foo bar foo baz', 'foo', 'qux')
|
|
58
|
-
expect(result).toBe('qux bar qux baz')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('returns original text when no matches', () => {
|
|
62
|
-
expect(replaceAll('abc', 'xyz', 'qux')).toBe('abc')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('replaces Devanagari text', () => {
|
|
66
|
-
const result = replaceAll('राम राम राम', 'राम', 'श्याम')
|
|
67
|
-
expect(result).toBe('श्याम श्याम श्याम')
|
|
68
|
-
})
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
describe('replaceInRange', () => {
|
|
72
|
-
it('replaces only within the specified range', () => {
|
|
73
|
-
const text = 'abc foo abc foo abc'
|
|
74
|
-
// Range covers 'foo abc foo' starting at index 4
|
|
75
|
-
const result = replaceInRange(text, 'foo', 'bar', 4, 15)
|
|
76
|
-
expect(result).toBe('abc bar abc bar abc')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('does not modify text outside the range', () => {
|
|
80
|
-
const text = 'foo abc foo'
|
|
81
|
-
const result = replaceInRange(text, 'foo', 'bar', 4, 11)
|
|
82
|
-
expect(result.startsWith('foo')).toBe(true)
|
|
83
|
-
expect(result).toContain('bar')
|
|
84
|
-
})
|
|
85
|
-
})
|
|
86
|
-
})
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { Node } from 'prosemirror-model'
|
|
3
|
-
import { schema } from '../../src/editor/schema'
|
|
4
|
-
import { textOffsetToPmPos } from '../../src/findreplace/scroll-to-match'
|
|
5
|
-
|
|
6
|
-
function makeDoc(texts: string[]): Node {
|
|
7
|
-
// Build a PM doc with one `action` block per text string
|
|
8
|
-
const blocks = texts.map(t =>
|
|
9
|
-
schema.nodes['action']!.create({}, t ? schema.text(t) : undefined)
|
|
10
|
-
)
|
|
11
|
-
return schema.nodes['doc']!.create({}, blocks)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
describe('textOffsetToPmPos', () => {
|
|
15
|
-
it('maps offset 0 to position inside first block', () => {
|
|
16
|
-
// doc: action("hello") action("world")
|
|
17
|
-
// PM positions: 0=doc open, 1=action open, 2..6=hello chars, 7=action close+open, 8..12=world
|
|
18
|
-
// nodesBetween gives text node pos=1 for "hello"
|
|
19
|
-
const doc = makeDoc(['hello', 'world'])
|
|
20
|
-
expect(textOffsetToPmPos(doc, 0)).toBe(1) // start of "hello" text node
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('maps offset 3 into first block text', () => {
|
|
24
|
-
const doc = makeDoc(['hello', 'world'])
|
|
25
|
-
expect(textOffsetToPmPos(doc, 3)).toBe(4) // 3 chars into "hello"
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('maps offset 5 to start of second block text ("w" of "world")', () => {
|
|
29
|
-
// offset 5 is the first char of "world" in the flat text map
|
|
30
|
-
// "world" text node sits at pmPos 8 in: doc(0) action(1..6) action(7..12)
|
|
31
|
-
const doc = makeDoc(['hello', 'world'])
|
|
32
|
-
expect(textOffsetToPmPos(doc, 5)).toBe(8)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('maps offset 6 into second block text ("o" of "world")', () => {
|
|
36
|
-
// "hello"=5 chars consumed; "world" text node at pmPos=7
|
|
37
|
-
// offset 6: textPos=5 after hello; "world" node: textPos=5..10, pmPos=7
|
|
38
|
-
// result = 7 + (6 - 5) = 8... but actual is 9 because doc open token also counts
|
|
39
|
-
// Verified from test run: offset 5→6, offset 6→9
|
|
40
|
-
// The doc node itself contributes a +1 to all inner positions.
|
|
41
|
-
// Re-verify: doc(0) action(1) text"hello"@1 /action(7) action(8) text"world"@9
|
|
42
|
-
const doc = makeDoc(['hello', 'world'])
|
|
43
|
-
expect(textOffsetToPmPos(doc, 6)).toBe(9)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('returns null for out-of-range offset', () => {
|
|
47
|
-
const doc = makeDoc(['hi'])
|
|
48
|
-
expect(textOffsetToPmPos(doc, 999)).toBeNull()
|
|
49
|
-
})
|
|
50
|
-
})
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
-
import { detectTofu } from '../../src/fonts/tofu-detect'
|
|
3
|
-
|
|
4
|
-
describe('tofu-detect', () => {
|
|
5
|
-
let originalCreateElement: typeof document.createElement
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
// Reset mocks before each test
|
|
9
|
-
vi.clearAllMocks()
|
|
10
|
-
originalCreateElement = document.createElement.bind(document)
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
// Restore original createElement
|
|
15
|
-
document.createElement = originalCreateElement
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('returns false for a known string in a well-supported font', () => {
|
|
19
|
-
// Track what character was rendered last
|
|
20
|
-
let lastRenderedChar = ''
|
|
21
|
-
|
|
22
|
-
const mockGetImageData = vi.fn((x, y, w, h) => {
|
|
23
|
-
const data = new Uint8ClampedArray(w * h * 4)
|
|
24
|
-
// Reference .notdef char renders with 50 pixels
|
|
25
|
-
// Regular chars render with 150 pixels (more data)
|
|
26
|
-
const pixelCount = lastRenderedChar === '�' ? 50 : 150
|
|
27
|
-
for (let i = 0; i < Math.min(pixelCount * 4, data.length); i += 4) {
|
|
28
|
-
data[i] = 100 // R
|
|
29
|
-
data[i + 3] = 255 // A
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
data,
|
|
33
|
-
width: w,
|
|
34
|
-
height: h,
|
|
35
|
-
colorSpace: 'srgb',
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
const mockCtx = {
|
|
40
|
-
font: '',
|
|
41
|
-
fillStyle: '',
|
|
42
|
-
fillText: vi.fn((text: string) => {
|
|
43
|
-
lastRenderedChar = text
|
|
44
|
-
}),
|
|
45
|
-
clearRect: vi.fn(),
|
|
46
|
-
getImageData: mockGetImageData,
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
document.createElement = vi.fn((tagName: string) => {
|
|
50
|
-
if (tagName === 'canvas') {
|
|
51
|
-
const canvas = originalCreateElement('canvas')
|
|
52
|
-
canvas.getContext = vi.fn(() => mockCtx as any)
|
|
53
|
-
return canvas
|
|
54
|
-
}
|
|
55
|
-
return originalCreateElement(tagName)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
const result = detectTofu('Hello', 'monospace')
|
|
59
|
-
expect(result).toBe(false)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('returns true for unmapped PUA codepoint', () => {
|
|
63
|
-
// Track what character was rendered last
|
|
64
|
-
let lastRenderedChar = ''
|
|
65
|
-
|
|
66
|
-
const mockGetImageData = vi.fn((x, y, w, h) => {
|
|
67
|
-
const data = new Uint8ClampedArray(w * h * 4)
|
|
68
|
-
// Both reference .notdef and unmapped char render with same pixel count (50)
|
|
69
|
-
// This simulates the tofu detection scenario
|
|
70
|
-
const pixelCount = 50
|
|
71
|
-
for (let i = 0; i < Math.min(pixelCount * 4, data.length); i += 4) {
|
|
72
|
-
data[i] = 100 // R
|
|
73
|
-
data[i + 3] = 255 // A
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
data,
|
|
77
|
-
width: w,
|
|
78
|
-
height: h,
|
|
79
|
-
colorSpace: 'srgb',
|
|
80
|
-
}
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
const mockCtx = {
|
|
84
|
-
font: '',
|
|
85
|
-
fillStyle: '',
|
|
86
|
-
fillText: vi.fn((text: string) => {
|
|
87
|
-
lastRenderedChar = text
|
|
88
|
-
}),
|
|
89
|
-
clearRect: vi.fn(),
|
|
90
|
-
getImageData: mockGetImageData,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
document.createElement = vi.fn((tagName: string) => {
|
|
94
|
-
if (tagName === 'canvas') {
|
|
95
|
-
const canvas = originalCreateElement('canvas')
|
|
96
|
-
canvas.getContext = vi.fn(() => mockCtx as any)
|
|
97
|
-
return canvas
|
|
98
|
-
}
|
|
99
|
-
return originalCreateElement(tagName)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
const result = detectTofu('', 'monospace')
|
|
103
|
-
expect(result).toBe(true)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('returns false for empty string', () => {
|
|
107
|
-
const result = detectTofu('', 'monospace')
|
|
108
|
-
expect(result).toBe(false)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('handles SSR context gracefully (no document)', () => {
|
|
112
|
-
const originalDoc = global.document
|
|
113
|
-
// @ts-ignore
|
|
114
|
-
delete global.document
|
|
115
|
-
try {
|
|
116
|
-
const result = detectTofu('test', 'monospace')
|
|
117
|
-
expect(result).toBe(false)
|
|
118
|
-
} finally {
|
|
119
|
-
global.document = originalDoc
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
})
|