@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.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/package.json +1 -1
- package/src/CollapsePanel.tsx +35 -35
- package/src/DocumentDiffView.tsx +130 -130
- package/src/DocumentEditor.test.ts +118 -118
- package/src/DocumentEditor.tsx +2651 -2631
- package/src/alignedLineDiff.test.ts +52 -52
- package/src/alignedLineDiff.ts +67 -67
- package/src/collectionAssetSrc.ts +2 -2
- package/src/commentUtils.test.ts +350 -350
- package/src/commentUtils.ts +546 -546
- package/src/constants/toolKeys.ts +14 -14
- package/src/contentFormat.test.ts +27 -27
- package/src/contentFormat.ts +18 -18
- package/src/editorExtensions.ts +155 -155
- package/src/extensions/appliedChangeMark.ts +115 -115
- package/src/extensions/autoReviewPrompts.test.ts +529 -529
- package/src/extensions/autoReviewPrompts.ts +1442 -1442
- package/src/extensions/collectionImage.ts +26 -26
- package/src/extensions/collectionImagePaste.ts +215 -215
- package/src/extensions/commentMark.ts +223 -223
- package/src/extensions/documentAssetIdentity.ts +54 -54
- package/src/extensions/figure.ts +92 -92
- package/src/extensions/footnote.ts +186 -186
- package/src/extensions/indexMarker.ts +88 -88
- package/src/extensions/mathEditing.ts +239 -239
- package/src/extensions/mermaidBlock.tsx +297 -297
- package/src/extensions/slashCommands.test.ts +170 -170
- package/src/extensions/slashCommands.ts +746 -746
- package/src/extensions/smartTypography.test.ts +74 -74
- package/src/extensions/smartTypography.ts +120 -120
- package/src/index.ts +138 -137
- package/src/insertCollectionImage.test.ts +60 -60
- package/src/insertCollectionImage.ts +63 -63
- package/src/insertInlineAssetKind.test.ts +67 -67
- package/src/insertInlineAssetKind.ts +49 -49
- package/src/mermaidPreview.test.ts +54 -54
- package/src/mermaidPreview.ts +115 -115
- package/src/styled.ts +697 -676
- package/src/toolbar/ToolBtn.tsx +77 -63
- package/src/toolbar/Toolbar.test.tsx +325 -325
- package/src/toolbar/Toolbar.tsx +662 -624
- package/src/toolbar/groups/HeadingGroup.tsx +175 -153
- package/src/toolbar/overlayTypes.ts +28 -28
- package/src/useEditorExtensions.ts +22 -22
- package/src/utils/markdownUtils.ts +331 -331
|
@@ -1,325 +1,325 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
import { act } from 'react'
|
|
3
|
-
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
-
import { EditorContext } from '@tiptap/react'
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
-
import { Toolbar } from './Toolbar.js'
|
|
7
|
-
;(
|
|
8
|
-
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
9
|
-
).IS_REACT_ACT_ENVIRONMENT = true
|
|
10
|
-
|
|
11
|
-
interface MockEditorOptions {
|
|
12
|
-
selection?: { from: number; to: number }
|
|
13
|
-
active?: Record<string, boolean>
|
|
14
|
-
autoReviewPrompts?: boolean
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function createChain() {
|
|
18
|
-
const chain = {
|
|
19
|
-
focus: vi.fn(() => chain),
|
|
20
|
-
undo: vi.fn(() => chain),
|
|
21
|
-
redo: vi.fn(() => chain),
|
|
22
|
-
toggleBlockquote: vi.fn(() => chain),
|
|
23
|
-
toggleOrderedList: vi.fn(() => chain),
|
|
24
|
-
toggleBulletList: vi.fn(() => chain),
|
|
25
|
-
toggleBold: vi.fn(() => chain),
|
|
26
|
-
toggleItalic: vi.fn(() => chain),
|
|
27
|
-
toggleCode: vi.fn(() => chain),
|
|
28
|
-
toggleUnderline: vi.fn(() => chain),
|
|
29
|
-
setLink: vi.fn(() => chain),
|
|
30
|
-
unsetLink: vi.fn(() => chain),
|
|
31
|
-
sinkListItem: vi.fn(() => chain),
|
|
32
|
-
toggleSubscript: vi.fn(() => chain),
|
|
33
|
-
toggleSuperscript: vi.fn(() => chain),
|
|
34
|
-
insertInlineMath: vi.fn(() => chain),
|
|
35
|
-
insertBlockMath: vi.fn(() => chain),
|
|
36
|
-
insertMermaidBlock: vi.fn(() => chain),
|
|
37
|
-
toggleTaskList: vi.fn(() => chain),
|
|
38
|
-
toggleCodeBlock: vi.fn(() => chain),
|
|
39
|
-
insertTable: vi.fn(() => chain),
|
|
40
|
-
insertFootnote: vi.fn(() => chain),
|
|
41
|
-
toggleIndexMarker: vi.fn(() => chain),
|
|
42
|
-
addRowAfter: vi.fn(() => chain),
|
|
43
|
-
addColumnAfter: vi.fn(() => chain),
|
|
44
|
-
deleteTable: vi.fn(() => chain),
|
|
45
|
-
run: vi.fn(() => true),
|
|
46
|
-
}
|
|
47
|
-
return chain
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function createEditor(options: MockEditorOptions = {}) {
|
|
51
|
-
const chain = createChain()
|
|
52
|
-
const handlers = new Map<string, Set<() => void>>()
|
|
53
|
-
const active = options.active ?? {}
|
|
54
|
-
const selection = options.selection ?? { from: 1, to: 1 }
|
|
55
|
-
const doc = {
|
|
56
|
-
descendants: vi.fn((visitor: (node: unknown, pos: number) => boolean) => {
|
|
57
|
-
visitor(
|
|
58
|
-
{
|
|
59
|
-
type: { name: 'paragraph' },
|
|
60
|
-
textContent: 'Small layout failures are easy to spot.',
|
|
61
|
-
nodeSize: 42,
|
|
62
|
-
},
|
|
63
|
-
0,
|
|
64
|
-
)
|
|
65
|
-
}),
|
|
66
|
-
}
|
|
67
|
-
const editor = {
|
|
68
|
-
state: { selection, doc },
|
|
69
|
-
extensionManager: {
|
|
70
|
-
extensions: options.autoReviewPrompts
|
|
71
|
-
? [{ name: 'autoReviewPrompts' }]
|
|
72
|
-
: [],
|
|
73
|
-
},
|
|
74
|
-
chain: vi.fn(() => chain),
|
|
75
|
-
can: vi.fn(() => ({
|
|
76
|
-
undo: vi.fn(() => true),
|
|
77
|
-
redo: vi.fn(() => false),
|
|
78
|
-
})),
|
|
79
|
-
isActive: vi.fn((name: string) => Boolean(active[name])),
|
|
80
|
-
getAttributes: vi.fn(() => ({ href: '' })),
|
|
81
|
-
on: vi.fn((event: string, handler: () => void) => {
|
|
82
|
-
if (!handlers.has(event)) handlers.set(event, new Set())
|
|
83
|
-
handlers.get(event)?.add(handler)
|
|
84
|
-
}),
|
|
85
|
-
off: vi.fn((event: string, handler: () => void) => {
|
|
86
|
-
handlers.get(event)?.delete(handler)
|
|
87
|
-
}),
|
|
88
|
-
emit(event: string) {
|
|
89
|
-
handlers.get(event)?.forEach(handler => handler())
|
|
90
|
-
},
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return { editor, chain }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function renderToolbar({
|
|
97
|
-
editor,
|
|
98
|
-
onAddComment,
|
|
99
|
-
advancedTools,
|
|
100
|
-
}: {
|
|
101
|
-
editor: ReturnType<typeof createEditor>['editor']
|
|
102
|
-
onAddComment?: Parameters<typeof Toolbar>[0]['onAddComment']
|
|
103
|
-
advancedTools?: Parameters<typeof Toolbar>[0]['advancedTools']
|
|
104
|
-
}): { container: HTMLDivElement; root: Root } {
|
|
105
|
-
const container = document.createElement('div')
|
|
106
|
-
document.body.appendChild(container)
|
|
107
|
-
const root = createRoot(container)
|
|
108
|
-
act(() => {
|
|
109
|
-
root.render(
|
|
110
|
-
<EditorContext.Provider value={{ editor: editor as never }}>
|
|
111
|
-
<Toolbar onAddComment={onAddComment} advancedTools={advancedTools} />
|
|
112
|
-
</EditorContext.Provider>,
|
|
113
|
-
)
|
|
114
|
-
})
|
|
115
|
-
return { container, root }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function click(element: Element | null): void {
|
|
119
|
-
expect(element).not.toBeNull()
|
|
120
|
-
act(() => {
|
|
121
|
-
element?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
122
|
-
})
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
afterEach(() => {
|
|
126
|
-
vi.restoreAllMocks()
|
|
127
|
-
document.body.innerHTML = ''
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
describe('Toolbar', () => {
|
|
131
|
-
it('toggles core writing controls through the editor command chain', () => {
|
|
132
|
-
const { editor, chain } = createEditor()
|
|
133
|
-
const { container } = renderToolbar({ editor })
|
|
134
|
-
|
|
135
|
-
click(container.querySelector('[title="Bold"]'))
|
|
136
|
-
click(container.querySelector('[title="Italic"]'))
|
|
137
|
-
click(container.querySelector('[title="Underline"]'))
|
|
138
|
-
click(container.querySelector('[title="Inline code"]'))
|
|
139
|
-
click(container.querySelector('[title="Blockquote"]'))
|
|
140
|
-
click(container.querySelector('[title="Ordered list"]'))
|
|
141
|
-
click(container.querySelector('[title="Bullet list"]'))
|
|
142
|
-
|
|
143
|
-
expect(chain.toggleBold).toHaveBeenCalledOnce()
|
|
144
|
-
expect(chain.toggleItalic).toHaveBeenCalledOnce()
|
|
145
|
-
expect(chain.toggleUnderline).toHaveBeenCalledOnce()
|
|
146
|
-
expect(chain.toggleCode).toHaveBeenCalledOnce()
|
|
147
|
-
expect(chain.toggleBlockquote).toHaveBeenCalledOnce()
|
|
148
|
-
expect(chain.toggleOrderedList).toHaveBeenCalledOnce()
|
|
149
|
-
expect(chain.toggleBulletList).toHaveBeenCalledOnce()
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('opens the advanced content dropdown and closes it on outside click', () => {
|
|
153
|
-
const { editor } = createEditor()
|
|
154
|
-
const { container } = renderToolbar({ editor })
|
|
155
|
-
|
|
156
|
-
click(container.querySelector('[title="More tools"]'))
|
|
157
|
-
|
|
158
|
-
expect(container.textContent).toContain('Insert inline equation')
|
|
159
|
-
expect(container.textContent).toContain('Insert table')
|
|
160
|
-
expect(container.querySelector('[data-open="true"]')).not.toBeNull()
|
|
161
|
-
|
|
162
|
-
act(() => {
|
|
163
|
-
document.body.dispatchEvent(
|
|
164
|
-
new MouseEvent('mousedown', { bubbles: true }),
|
|
165
|
-
)
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
expect(container.querySelector('[data-open="false"]')).not.toBeNull()
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('runs advanced dropdown content commands', () => {
|
|
172
|
-
const promptSpy = vi
|
|
173
|
-
.spyOn(window, 'prompt')
|
|
174
|
-
.mockReturnValueOnce('E = mc^2')
|
|
175
|
-
.mockReturnValueOnce('a / b')
|
|
176
|
-
const { editor, chain } = createEditor()
|
|
177
|
-
const { container } = renderToolbar({ editor })
|
|
178
|
-
|
|
179
|
-
click(container.querySelector('[title="More tools"]'))
|
|
180
|
-
click(
|
|
181
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
182
|
-
button.textContent?.includes('Insert inline equation'),
|
|
183
|
-
) ?? null,
|
|
184
|
-
)
|
|
185
|
-
click(container.querySelector('[title="More tools"]'))
|
|
186
|
-
click(
|
|
187
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
188
|
-
button.textContent?.includes('Insert display equation'),
|
|
189
|
-
) ?? null,
|
|
190
|
-
)
|
|
191
|
-
click(container.querySelector('[title="More tools"]'))
|
|
192
|
-
click(
|
|
193
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
194
|
-
button.textContent?.includes('Insert Mermaid diagram'),
|
|
195
|
-
) ?? null,
|
|
196
|
-
)
|
|
197
|
-
click(container.querySelector('[title="More tools"]'))
|
|
198
|
-
click(
|
|
199
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
200
|
-
button.textContent?.includes('Insert table'),
|
|
201
|
-
) ?? null,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
expect(promptSpy).toHaveBeenCalledTimes(2)
|
|
205
|
-
expect(chain.insertInlineMath).toHaveBeenCalledWith({ latex: 'E = mc^2' })
|
|
206
|
-
expect(chain.insertBlockMath).toHaveBeenCalledWith({ latex: 'a / b' })
|
|
207
|
-
expect(chain.insertMermaidBlock).toHaveBeenCalledOnce()
|
|
208
|
-
expect(chain.insertTable).toHaveBeenCalledWith({
|
|
209
|
-
rows: 3,
|
|
210
|
-
cols: 3,
|
|
211
|
-
withHeaderRow: true,
|
|
212
|
-
})
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('can limit the advanced dropdown to formatting-only tools', () => {
|
|
216
|
-
const { editor } = createEditor()
|
|
217
|
-
const { container } = renderToolbar({
|
|
218
|
-
editor,
|
|
219
|
-
advancedTools: 'formatting',
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
click(container.querySelector('[title="More tools"]'))
|
|
223
|
-
|
|
224
|
-
expect(container.textContent).toContain('Subscript')
|
|
225
|
-
expect(container.textContent).toContain('Superscript')
|
|
226
|
-
expect(container.textContent).toContain('Mark for index')
|
|
227
|
-
expect(container.textContent).not.toContain('Insert inline equation')
|
|
228
|
-
expect(container.textContent).not.toContain('Insert table')
|
|
229
|
-
expect(container.textContent).not.toContain('Copy editorial review prompts')
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
it('enables comment actions only when text is selected', () => {
|
|
233
|
-
const onAddComment = vi.fn()
|
|
234
|
-
const withoutSelection = createEditor()
|
|
235
|
-
const { container: disabledContainer } = renderToolbar({
|
|
236
|
-
editor: withoutSelection.editor,
|
|
237
|
-
onAddComment,
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
expect(
|
|
241
|
-
disabledContainer
|
|
242
|
-
.querySelector('[title="Add comment to selection"]')
|
|
243
|
-
?.hasAttribute('disabled'),
|
|
244
|
-
).toBe(true)
|
|
245
|
-
|
|
246
|
-
document.body.innerHTML = ''
|
|
247
|
-
const withSelection = createEditor({ selection: { from: 1, to: 8 } })
|
|
248
|
-
const { container } = renderToolbar({
|
|
249
|
-
editor: withSelection.editor,
|
|
250
|
-
onAddComment,
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
expect(
|
|
254
|
-
container
|
|
255
|
-
.querySelector('[title="Add comment to selection"]')
|
|
256
|
-
?.hasAttribute('disabled'),
|
|
257
|
-
).toBe(false)
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
it('shows table-specific commands when the cursor is in a table', () => {
|
|
261
|
-
const { editor, chain } = createEditor({ active: { table: true } })
|
|
262
|
-
const { container } = renderToolbar({ editor })
|
|
263
|
-
|
|
264
|
-
click(container.querySelector('[title="More tools"]'))
|
|
265
|
-
|
|
266
|
-
click(
|
|
267
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
268
|
-
button.textContent?.includes('Add table row'),
|
|
269
|
-
) ?? null,
|
|
270
|
-
)
|
|
271
|
-
click(container.querySelector('[title="More tools"]'))
|
|
272
|
-
click(
|
|
273
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
274
|
-
button.textContent?.includes('Add table column'),
|
|
275
|
-
) ?? null,
|
|
276
|
-
)
|
|
277
|
-
click(container.querySelector('[title="More tools"]'))
|
|
278
|
-
click(
|
|
279
|
-
Array.from(container.querySelectorAll('button')).find(button =>
|
|
280
|
-
button.textContent?.includes('Delete table'),
|
|
281
|
-
) ?? null,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
expect(chain.addRowAfter).toHaveBeenCalledOnce()
|
|
285
|
-
expect(chain.addColumnAfter).toHaveBeenCalledOnce()
|
|
286
|
-
expect(chain.deleteTable).toHaveBeenCalledOnce()
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('copies editorial review prompts when the auto-review extension is present', async () => {
|
|
290
|
-
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
|
|
291
|
-
Object.defineProperty(navigator, 'clipboard', {
|
|
292
|
-
configurable: true,
|
|
293
|
-
value: clipboard,
|
|
294
|
-
})
|
|
295
|
-
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
296
|
-
const { editor } = createEditor({ autoReviewPrompts: true })
|
|
297
|
-
const { container } = renderToolbar({ editor })
|
|
298
|
-
|
|
299
|
-
click(container.querySelector('[title="More tools"]'))
|
|
300
|
-
await act(async () => {
|
|
301
|
-
Array.from(container.querySelectorAll('button'))
|
|
302
|
-
.find(button =>
|
|
303
|
-
button.textContent?.includes('Copy editorial review prompts'),
|
|
304
|
-
)
|
|
305
|
-
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
expect(clipboard.writeText).toHaveBeenCalledOnce()
|
|
309
|
-
const copied = clipboard.writeText.mock.calls[0]?.[0] as string
|
|
310
|
-
expect(copied).toContain('# Claim detection prompt')
|
|
311
|
-
expect(copied).toContain('one thing only: CLAIMS')
|
|
312
|
-
expect(copied).toContain('# Fact-check detection prompt')
|
|
313
|
-
expect(copied).toContain('one thing only: FACT-CHECK NEEDS')
|
|
314
|
-
expect(copied).toContain('# Source detection prompt')
|
|
315
|
-
expect(copied).toContain('one thing only: SOURCE checks')
|
|
316
|
-
expect(copied).toContain('# Thread detection prompt')
|
|
317
|
-
expect(copied).toContain('You are detecting THREADS')
|
|
318
|
-
expect(copied).toContain('# Logic detection prompt')
|
|
319
|
-
expect(copied).toContain('You are detecting LOGIC gaps')
|
|
320
|
-
expect(copied).toContain('Small layout failures are easy to spot.')
|
|
321
|
-
expect(alertSpy).toHaveBeenCalledWith(
|
|
322
|
-
'Copied editorial review prompts for 1 paragraph.',
|
|
323
|
-
)
|
|
324
|
-
})
|
|
325
|
-
})
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { EditorContext } from '@tiptap/react'
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { Toolbar } from './Toolbar.js'
|
|
7
|
+
;(
|
|
8
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
9
|
+
).IS_REACT_ACT_ENVIRONMENT = true
|
|
10
|
+
|
|
11
|
+
interface MockEditorOptions {
|
|
12
|
+
selection?: { from: number; to: number }
|
|
13
|
+
active?: Record<string, boolean>
|
|
14
|
+
autoReviewPrompts?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createChain() {
|
|
18
|
+
const chain = {
|
|
19
|
+
focus: vi.fn(() => chain),
|
|
20
|
+
undo: vi.fn(() => chain),
|
|
21
|
+
redo: vi.fn(() => chain),
|
|
22
|
+
toggleBlockquote: vi.fn(() => chain),
|
|
23
|
+
toggleOrderedList: vi.fn(() => chain),
|
|
24
|
+
toggleBulletList: vi.fn(() => chain),
|
|
25
|
+
toggleBold: vi.fn(() => chain),
|
|
26
|
+
toggleItalic: vi.fn(() => chain),
|
|
27
|
+
toggleCode: vi.fn(() => chain),
|
|
28
|
+
toggleUnderline: vi.fn(() => chain),
|
|
29
|
+
setLink: vi.fn(() => chain),
|
|
30
|
+
unsetLink: vi.fn(() => chain),
|
|
31
|
+
sinkListItem: vi.fn(() => chain),
|
|
32
|
+
toggleSubscript: vi.fn(() => chain),
|
|
33
|
+
toggleSuperscript: vi.fn(() => chain),
|
|
34
|
+
insertInlineMath: vi.fn(() => chain),
|
|
35
|
+
insertBlockMath: vi.fn(() => chain),
|
|
36
|
+
insertMermaidBlock: vi.fn(() => chain),
|
|
37
|
+
toggleTaskList: vi.fn(() => chain),
|
|
38
|
+
toggleCodeBlock: vi.fn(() => chain),
|
|
39
|
+
insertTable: vi.fn(() => chain),
|
|
40
|
+
insertFootnote: vi.fn(() => chain),
|
|
41
|
+
toggleIndexMarker: vi.fn(() => chain),
|
|
42
|
+
addRowAfter: vi.fn(() => chain),
|
|
43
|
+
addColumnAfter: vi.fn(() => chain),
|
|
44
|
+
deleteTable: vi.fn(() => chain),
|
|
45
|
+
run: vi.fn(() => true),
|
|
46
|
+
}
|
|
47
|
+
return chain
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createEditor(options: MockEditorOptions = {}) {
|
|
51
|
+
const chain = createChain()
|
|
52
|
+
const handlers = new Map<string, Set<() => void>>()
|
|
53
|
+
const active = options.active ?? {}
|
|
54
|
+
const selection = options.selection ?? { from: 1, to: 1 }
|
|
55
|
+
const doc = {
|
|
56
|
+
descendants: vi.fn((visitor: (node: unknown, pos: number) => boolean) => {
|
|
57
|
+
visitor(
|
|
58
|
+
{
|
|
59
|
+
type: { name: 'paragraph' },
|
|
60
|
+
textContent: 'Small layout failures are easy to spot.',
|
|
61
|
+
nodeSize: 42,
|
|
62
|
+
},
|
|
63
|
+
0,
|
|
64
|
+
)
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
const editor = {
|
|
68
|
+
state: { selection, doc },
|
|
69
|
+
extensionManager: {
|
|
70
|
+
extensions: options.autoReviewPrompts
|
|
71
|
+
? [{ name: 'autoReviewPrompts' }]
|
|
72
|
+
: [],
|
|
73
|
+
},
|
|
74
|
+
chain: vi.fn(() => chain),
|
|
75
|
+
can: vi.fn(() => ({
|
|
76
|
+
undo: vi.fn(() => true),
|
|
77
|
+
redo: vi.fn(() => false),
|
|
78
|
+
})),
|
|
79
|
+
isActive: vi.fn((name: string) => Boolean(active[name])),
|
|
80
|
+
getAttributes: vi.fn(() => ({ href: '' })),
|
|
81
|
+
on: vi.fn((event: string, handler: () => void) => {
|
|
82
|
+
if (!handlers.has(event)) handlers.set(event, new Set())
|
|
83
|
+
handlers.get(event)?.add(handler)
|
|
84
|
+
}),
|
|
85
|
+
off: vi.fn((event: string, handler: () => void) => {
|
|
86
|
+
handlers.get(event)?.delete(handler)
|
|
87
|
+
}),
|
|
88
|
+
emit(event: string) {
|
|
89
|
+
handlers.get(event)?.forEach(handler => handler())
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { editor, chain }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderToolbar({
|
|
97
|
+
editor,
|
|
98
|
+
onAddComment,
|
|
99
|
+
advancedTools,
|
|
100
|
+
}: {
|
|
101
|
+
editor: ReturnType<typeof createEditor>['editor']
|
|
102
|
+
onAddComment?: Parameters<typeof Toolbar>[0]['onAddComment']
|
|
103
|
+
advancedTools?: Parameters<typeof Toolbar>[0]['advancedTools']
|
|
104
|
+
}): { container: HTMLDivElement; root: Root } {
|
|
105
|
+
const container = document.createElement('div')
|
|
106
|
+
document.body.appendChild(container)
|
|
107
|
+
const root = createRoot(container)
|
|
108
|
+
act(() => {
|
|
109
|
+
root.render(
|
|
110
|
+
<EditorContext.Provider value={{ editor: editor as never }}>
|
|
111
|
+
<Toolbar onAddComment={onAddComment} advancedTools={advancedTools} />
|
|
112
|
+
</EditorContext.Provider>,
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
return { container, root }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function click(element: Element | null): void {
|
|
119
|
+
expect(element).not.toBeNull()
|
|
120
|
+
act(() => {
|
|
121
|
+
element?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
vi.restoreAllMocks()
|
|
127
|
+
document.body.innerHTML = ''
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('Toolbar', () => {
|
|
131
|
+
it('toggles core writing controls through the editor command chain', () => {
|
|
132
|
+
const { editor, chain } = createEditor()
|
|
133
|
+
const { container } = renderToolbar({ editor })
|
|
134
|
+
|
|
135
|
+
click(container.querySelector('[title="Bold"]'))
|
|
136
|
+
click(container.querySelector('[title="Italic"]'))
|
|
137
|
+
click(container.querySelector('[title="Underline"]'))
|
|
138
|
+
click(container.querySelector('[title="Inline code"]'))
|
|
139
|
+
click(container.querySelector('[title="Blockquote"]'))
|
|
140
|
+
click(container.querySelector('[title="Ordered list"]'))
|
|
141
|
+
click(container.querySelector('[title="Bullet list"]'))
|
|
142
|
+
|
|
143
|
+
expect(chain.toggleBold).toHaveBeenCalledOnce()
|
|
144
|
+
expect(chain.toggleItalic).toHaveBeenCalledOnce()
|
|
145
|
+
expect(chain.toggleUnderline).toHaveBeenCalledOnce()
|
|
146
|
+
expect(chain.toggleCode).toHaveBeenCalledOnce()
|
|
147
|
+
expect(chain.toggleBlockquote).toHaveBeenCalledOnce()
|
|
148
|
+
expect(chain.toggleOrderedList).toHaveBeenCalledOnce()
|
|
149
|
+
expect(chain.toggleBulletList).toHaveBeenCalledOnce()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('opens the advanced content dropdown and closes it on outside click', () => {
|
|
153
|
+
const { editor } = createEditor()
|
|
154
|
+
const { container } = renderToolbar({ editor })
|
|
155
|
+
|
|
156
|
+
click(container.querySelector('[title="More tools"]'))
|
|
157
|
+
|
|
158
|
+
expect(container.textContent).toContain('Insert inline equation')
|
|
159
|
+
expect(container.textContent).toContain('Insert table')
|
|
160
|
+
expect(container.querySelector('[data-open="true"]')).not.toBeNull()
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
document.body.dispatchEvent(
|
|
164
|
+
new MouseEvent('mousedown', { bubbles: true }),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(container.querySelector('[data-open="false"]')).not.toBeNull()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('runs advanced dropdown content commands', () => {
|
|
172
|
+
const promptSpy = vi
|
|
173
|
+
.spyOn(window, 'prompt')
|
|
174
|
+
.mockReturnValueOnce('E = mc^2')
|
|
175
|
+
.mockReturnValueOnce('a / b')
|
|
176
|
+
const { editor, chain } = createEditor()
|
|
177
|
+
const { container } = renderToolbar({ editor })
|
|
178
|
+
|
|
179
|
+
click(container.querySelector('[title="More tools"]'))
|
|
180
|
+
click(
|
|
181
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
182
|
+
button.textContent?.includes('Insert inline equation'),
|
|
183
|
+
) ?? null,
|
|
184
|
+
)
|
|
185
|
+
click(container.querySelector('[title="More tools"]'))
|
|
186
|
+
click(
|
|
187
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
188
|
+
button.textContent?.includes('Insert display equation'),
|
|
189
|
+
) ?? null,
|
|
190
|
+
)
|
|
191
|
+
click(container.querySelector('[title="More tools"]'))
|
|
192
|
+
click(
|
|
193
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
194
|
+
button.textContent?.includes('Insert Mermaid diagram'),
|
|
195
|
+
) ?? null,
|
|
196
|
+
)
|
|
197
|
+
click(container.querySelector('[title="More tools"]'))
|
|
198
|
+
click(
|
|
199
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
200
|
+
button.textContent?.includes('Insert table'),
|
|
201
|
+
) ?? null,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
expect(promptSpy).toHaveBeenCalledTimes(2)
|
|
205
|
+
expect(chain.insertInlineMath).toHaveBeenCalledWith({ latex: 'E = mc^2' })
|
|
206
|
+
expect(chain.insertBlockMath).toHaveBeenCalledWith({ latex: 'a / b' })
|
|
207
|
+
expect(chain.insertMermaidBlock).toHaveBeenCalledOnce()
|
|
208
|
+
expect(chain.insertTable).toHaveBeenCalledWith({
|
|
209
|
+
rows: 3,
|
|
210
|
+
cols: 3,
|
|
211
|
+
withHeaderRow: true,
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('can limit the advanced dropdown to formatting-only tools', () => {
|
|
216
|
+
const { editor } = createEditor()
|
|
217
|
+
const { container } = renderToolbar({
|
|
218
|
+
editor,
|
|
219
|
+
advancedTools: 'formatting',
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
click(container.querySelector('[title="More tools"]'))
|
|
223
|
+
|
|
224
|
+
expect(container.textContent).toContain('Subscript')
|
|
225
|
+
expect(container.textContent).toContain('Superscript')
|
|
226
|
+
expect(container.textContent).toContain('Mark for index')
|
|
227
|
+
expect(container.textContent).not.toContain('Insert inline equation')
|
|
228
|
+
expect(container.textContent).not.toContain('Insert table')
|
|
229
|
+
expect(container.textContent).not.toContain('Copy editorial review prompts')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('enables comment actions only when text is selected', () => {
|
|
233
|
+
const onAddComment = vi.fn()
|
|
234
|
+
const withoutSelection = createEditor()
|
|
235
|
+
const { container: disabledContainer } = renderToolbar({
|
|
236
|
+
editor: withoutSelection.editor,
|
|
237
|
+
onAddComment,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
expect(
|
|
241
|
+
disabledContainer
|
|
242
|
+
.querySelector('[title="Add comment to selection"]')
|
|
243
|
+
?.hasAttribute('disabled'),
|
|
244
|
+
).toBe(true)
|
|
245
|
+
|
|
246
|
+
document.body.innerHTML = ''
|
|
247
|
+
const withSelection = createEditor({ selection: { from: 1, to: 8 } })
|
|
248
|
+
const { container } = renderToolbar({
|
|
249
|
+
editor: withSelection.editor,
|
|
250
|
+
onAddComment,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(
|
|
254
|
+
container
|
|
255
|
+
.querySelector('[title="Add comment to selection"]')
|
|
256
|
+
?.hasAttribute('disabled'),
|
|
257
|
+
).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('shows table-specific commands when the cursor is in a table', () => {
|
|
261
|
+
const { editor, chain } = createEditor({ active: { table: true } })
|
|
262
|
+
const { container } = renderToolbar({ editor })
|
|
263
|
+
|
|
264
|
+
click(container.querySelector('[title="More tools"]'))
|
|
265
|
+
|
|
266
|
+
click(
|
|
267
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
268
|
+
button.textContent?.includes('Add table row'),
|
|
269
|
+
) ?? null,
|
|
270
|
+
)
|
|
271
|
+
click(container.querySelector('[title="More tools"]'))
|
|
272
|
+
click(
|
|
273
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
274
|
+
button.textContent?.includes('Add table column'),
|
|
275
|
+
) ?? null,
|
|
276
|
+
)
|
|
277
|
+
click(container.querySelector('[title="More tools"]'))
|
|
278
|
+
click(
|
|
279
|
+
Array.from(container.querySelectorAll('button')).find(button =>
|
|
280
|
+
button.textContent?.includes('Delete table'),
|
|
281
|
+
) ?? null,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
expect(chain.addRowAfter).toHaveBeenCalledOnce()
|
|
285
|
+
expect(chain.addColumnAfter).toHaveBeenCalledOnce()
|
|
286
|
+
expect(chain.deleteTable).toHaveBeenCalledOnce()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('copies editorial review prompts when the auto-review extension is present', async () => {
|
|
290
|
+
const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
|
|
291
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
292
|
+
configurable: true,
|
|
293
|
+
value: clipboard,
|
|
294
|
+
})
|
|
295
|
+
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
296
|
+
const { editor } = createEditor({ autoReviewPrompts: true })
|
|
297
|
+
const { container } = renderToolbar({ editor })
|
|
298
|
+
|
|
299
|
+
click(container.querySelector('[title="More tools"]'))
|
|
300
|
+
await act(async () => {
|
|
301
|
+
Array.from(container.querySelectorAll('button'))
|
|
302
|
+
.find(button =>
|
|
303
|
+
button.textContent?.includes('Copy editorial review prompts'),
|
|
304
|
+
)
|
|
305
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
expect(clipboard.writeText).toHaveBeenCalledOnce()
|
|
309
|
+
const copied = clipboard.writeText.mock.calls[0]?.[0] as string
|
|
310
|
+
expect(copied).toContain('# Claim detection prompt')
|
|
311
|
+
expect(copied).toContain('one thing only: CLAIMS')
|
|
312
|
+
expect(copied).toContain('# Fact-check detection prompt')
|
|
313
|
+
expect(copied).toContain('one thing only: FACT-CHECK NEEDS')
|
|
314
|
+
expect(copied).toContain('# Source detection prompt')
|
|
315
|
+
expect(copied).toContain('one thing only: SOURCE checks')
|
|
316
|
+
expect(copied).toContain('# Thread detection prompt')
|
|
317
|
+
expect(copied).toContain('You are detecting THREADS')
|
|
318
|
+
expect(copied).toContain('# Logic detection prompt')
|
|
319
|
+
expect(copied).toContain('You are detecting LOGIC gaps')
|
|
320
|
+
expect(copied).toContain('Small layout failures are easy to spot.')
|
|
321
|
+
expect(alertSpy).toHaveBeenCalledWith(
|
|
322
|
+
'Copied editorial review prompts for 1 paragraph.',
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
})
|