@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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,624 +1,662 @@
1
- import { useEffect, useRef, useState } from 'react'
2
- import { useCurrentEditor } from '@tiptap/react'
3
- import { styled } from 'styled-components'
4
- import {
5
- BlockOutlined,
6
- BoldOutlined,
7
- CheckSquareOutlined,
8
- CodeOutlined,
9
- FileTextOutlined,
10
- ItalicOutlined,
11
- LinkOutlined,
12
- MoreOutlined,
13
- OrderedListOutlined,
14
- PartitionOutlined,
15
- RedoOutlined,
16
- TableOutlined,
17
- TagOutlined,
18
- UndoOutlined,
19
- UnorderedListOutlined,
20
- } from '@ant-design/icons'
21
- import { Divider, ToolbarRow, ToolBtn } from './ToolBtn.js'
22
- import type { EditorToolbarOverlays } from './overlayTypes.js'
23
- import { HeadingGroup } from './groups/HeadingGroup.js'
24
- import { CollapsePanel } from '../CollapsePanel.js'
25
- import { TOOL_KEYS } from '../constants/toolKeys.js'
26
- import {
27
- COMMENT_TYPES,
28
- COMMENT_TYPE_LABELS,
29
- type CommentType,
30
- } from '../commentUtils.js'
31
- import { copyAutoReviewPromptsToClipboard } from '../extensions/autoReviewPrompts.js'
32
-
33
- export function Toolbar({
34
- onAddComment,
35
- overlays,
36
- advancedTools = 'full',
37
- }: {
38
- onAddComment?: (payload: {
39
- commentText: string
40
- commentType: CommentType
41
- from: number
42
- to: number
43
- }) => void
44
- overlays?: EditorToolbarOverlays
45
- advancedTools?: 'full' | 'formatting'
46
- }): React.ReactElement | null {
47
- const { editor } = useCurrentEditor()
48
-
49
- const [showLinkInput, setShowLinkInput] = useState(false)
50
- const [linkUrl, setLinkUrl] = useState('')
51
- const [showCommentInput, setShowCommentInput] = useState(false)
52
- const [commentText, setCommentText] = useState('')
53
- const [commentType, setCommentType] = useState<CommentType>('general')
54
- const [commentSelection, setCommentSelection] = useState<{
55
- from: number
56
- to: number
57
- } | null>(null)
58
- const [advancedOpen, setAdvancedOpen] = useState(false)
59
- const [hasSelection, setHasSelection] = useState(false)
60
- const advancedRef = useRef<HTMLDivElement>(null)
61
- const lastSelectionRef = useRef<{ from: number; to: number } | null>(null)
62
-
63
- const isLink = !!editor?.isActive(TOOL_KEYS.LINK)
64
- const isBullet = !!editor?.isActive(TOOL_KEYS.BULLET_LIST)
65
- const isOrdered = !!editor?.isActive(TOOL_KEYS.ORDERED_LIST)
66
- const isCode = !!editor?.isActive(TOOL_KEYS.CODE)
67
- const isBold = !!editor?.isActive(TOOL_KEYS.BOLD)
68
- const isItalic = !!editor?.isActive(TOOL_KEYS.ITALIC)
69
- const isUnderline = !!editor?.isActive(TOOL_KEYS.UNDERLINE)
70
- const canUndo = !!editor?.can().undo()
71
- const canRedo = !!editor?.can().redo()
72
- const isTask = !!editor?.isActive(TOOL_KEYS.TASK_LIST)
73
- const isBlockquote = !!editor?.isActive(TOOL_KEYS.BLOCKQUOTE)
74
- const isCodeBlock = !!editor?.isActive(TOOL_KEYS.CODE_BLOCK)
75
- const isSubscript = !!editor?.isActive('subscript')
76
- const isSuperscript = !!editor?.isActive('superscript')
77
- const indexActive = !!editor?.isActive(TOOL_KEYS.INDEX_MARKER)
78
- const inTable = !!editor?.isActive(TOOL_KEYS.TABLE)
79
- const hasAutoReviewPrompts = Boolean(
80
- editor?.extensionManager.extensions.some(
81
- extension => extension.name === 'autoReviewPrompts',
82
- ),
83
- )
84
-
85
- useEffect(() => {
86
- if (!editor) return
87
- const syncSelection = (): void => {
88
- const { from, to } = editor.state.selection
89
- const nextHasSelection = from !== to
90
- lastSelectionRef.current = nextHasSelection ? { from, to } : null
91
- setHasSelection(nextHasSelection)
92
- }
93
- syncSelection()
94
- editor.on('selectionUpdate', syncSelection)
95
- editor.on('update', syncSelection)
96
- return () => {
97
- editor.off('selectionUpdate', syncSelection)
98
- editor.off('update', syncSelection)
99
- }
100
- }, [editor])
101
-
102
- useEffect(() => {
103
- if (!advancedOpen) return
104
- const onDown = (e: MouseEvent) => {
105
- if (!advancedRef.current?.contains(e.target as Node))
106
- setAdvancedOpen(false)
107
- }
108
- document.addEventListener('mousedown', onDown)
109
- return () => document.removeEventListener('mousedown', onDown)
110
- }, [advancedOpen])
111
-
112
- const handleLinkClick = () => {
113
- if (!editor) return
114
- if (isLink) {
115
- editor.chain().focus().unsetLink().run()
116
- return
117
- }
118
- const { href = '' } = editor.getAttributes('link')
119
- setLinkUrl(href)
120
- setShowLinkInput(true)
121
- }
122
-
123
- const confirmLink = () => {
124
- if (!editor) return
125
- const trimmed = linkUrl.trim()
126
- if (trimmed) editor.chain().focus().setLink({ href: trimmed }).run()
127
- else editor.chain().focus().unsetLink().run()
128
- setShowLinkInput(false)
129
- setLinkUrl('')
130
- }
131
-
132
- const cancelLink = () => {
133
- setShowLinkInput(false)
134
- setLinkUrl('')
135
- }
136
-
137
- const openCommentInput = () => {
138
- const selection = lastSelectionRef.current
139
- if (!selection) return
140
- setCommentSelection(selection)
141
- setShowCommentInput(true)
142
- }
143
-
144
- const cancelComment = () => {
145
- setShowCommentInput(false)
146
- setCommentText('')
147
- setCommentSelection(null)
148
- }
149
-
150
- const confirmComment = () => {
151
- const selection = commentSelection ?? lastSelectionRef.current
152
- const trimmed = commentText.trim()
153
- if (!selection || !trimmed || !onAddComment) return
154
- onAddComment({
155
- commentText: trimmed,
156
- commentType,
157
- from: selection.from,
158
- to: selection.to,
159
- })
160
- setShowCommentInput(false)
161
- setCommentText('')
162
- setCommentSelection(null)
163
- }
164
-
165
- const closeAdvanced = (fn: () => unknown) => {
166
- setAdvancedOpen(false)
167
- void fn()
168
- }
169
-
170
- const insertInlineMath = () => {
171
- if (!editor) return
172
- const latex = window.prompt('Inline LaTeX', 'E = mc^2')?.trim()
173
- if (!latex) return
174
- const commands = editor.chain().focus() as unknown as {
175
- insertInlineMath?: (options: { latex: string }) => { run: () => void }
176
- }
177
- commands.insertInlineMath?.({ latex })?.run()
178
- }
179
-
180
- const insertBlockMath = () => {
181
- if (!editor) return
182
- const latex = window.prompt('Block LaTeX', '\\\\frac{a}{b}')?.trim()
183
- if (!latex) return
184
- const commands = editor.chain().focus() as unknown as {
185
- insertBlockMath?: (options: { latex: string }) => { run: () => void }
186
- }
187
- commands.insertBlockMath?.({ latex })?.run()
188
- }
189
-
190
- const insertMermaidBlock = () => {
191
- if (!editor) return
192
- editor.chain().focus().insertMermaidBlock().run()
193
- }
194
-
195
- const addComment = () => {
196
- openCommentInput()
197
- }
198
-
199
- const prepareAutoReviewPrompts = async () => {
200
- if (!editor) return
201
- try {
202
- const result = await copyAutoReviewPromptsToClipboard(editor)
203
- if (!result.paragraphCount) {
204
- window.alert('No paragraph text found for editorial review.')
205
- return
206
- }
207
- window.alert(
208
- `Copied editorial review prompts for ${
209
- result.paragraphCount
210
- } paragraph${result.paragraphCount === 1 ? '' : 's'}.`,
211
- )
212
- } catch {
213
- window.alert('Could not copy auto-review prompts to the clipboard.')
214
- }
215
- }
216
-
217
- const indentList = () => {
218
- const commands = editor?.chain().focus() as unknown as {
219
- sinkListItem?: (type: string) => { run: () => void }
220
- }
221
- const action = commands.sinkListItem?.('listItem')
222
- action?.run()
223
- }
224
-
225
- if (!editor) return null
226
-
227
- const LinkPrompt = overlays?.LinkPrompt
228
- const CommentPrompt = overlays?.CommentPrompt
229
- const showFullAdvancedTools = advancedTools === 'full'
230
-
231
- return (
232
- <>
233
- <ToolbarRow>
234
- <ToolBtn
235
- disabled={!canUndo}
236
- onClick={() => editor.chain().focus().undo().run()}
237
- title="Undo"
238
- >
239
- <UndoOutlined />
240
- </ToolBtn>
241
- <ToolBtn
242
- disabled={!canRedo}
243
- onClick={() => editor.chain().focus().redo().run()}
244
- title="Redo"
245
- >
246
- <RedoOutlined />
247
- </ToolBtn>
248
- <Divider />
249
- <HeadingGroup />
250
- <Divider />
251
- <ToolBtn
252
- $active={!!isBlockquote}
253
- onClick={() => editor.chain().focus().toggleBlockquote().run()}
254
- title="Blockquote"
255
- >
256
- <span aria-hidden="true">“</span>
257
- </ToolBtn>
258
- <ToolBtn
259
- $active={!!isOrdered}
260
- onClick={() => editor.chain().focus().toggleOrderedList().run()}
261
- title="Ordered list"
262
- >
263
- <OrderedListOutlined />
264
- </ToolBtn>
265
- <ToolBtn
266
- $active={!!isBullet}
267
- onClick={() => editor.chain().focus().toggleBulletList().run()}
268
- title="Bullet list"
269
- >
270
- <UnorderedListOutlined />
271
- </ToolBtn>
272
- <ToolBtn
273
- onClick={indentList}
274
- title="Indent list"
275
- disabled={!isBullet && !isOrdered}
276
- >
277
- <span aria-hidden="true">⇥</span>
278
- </ToolBtn>
279
- <Divider />
280
- {onAddComment ? (
281
- <ToolBtn
282
- onClick={addComment}
283
- title="Add comment to selection"
284
- disabled={!hasSelection}
285
- >
286
- <TagOutlined />
287
- </ToolBtn>
288
- ) : null}
289
- <ToolBtn
290
- $active={!!isBold}
291
- onClick={() => editor.chain().focus().toggleBold().run()}
292
- title="Bold"
293
- >
294
- <BoldOutlined />
295
- </ToolBtn>
296
- <ToolBtn
297
- $active={!!isItalic}
298
- onClick={() => editor.chain().focus().toggleItalic().run()}
299
- title="Italic"
300
- >
301
- <ItalicOutlined />
302
- </ToolBtn>
303
- <ToolBtn
304
- $active={!!isCode}
305
- onClick={() => editor.chain().focus().toggleCode().run()}
306
- title="Inline code"
307
- >
308
- <CodeOutlined />
309
- </ToolBtn>
310
- <ToolBtn $active={!!isLink} onClick={handleLinkClick} title="Link">
311
- <LinkOutlined />
312
- </ToolBtn>
313
- <ToolBtn
314
- $active={!!isUnderline}
315
- onClick={() => editor.chain().focus().toggleUnderline().run()}
316
- title="Underline"
317
- >
318
- <UnderlineGlyph aria-hidden="true">U</UnderlineGlyph>
319
- </ToolBtn>
320
- <Divider />
321
- <AdvancedWrapper ref={advancedRef}>
322
- <ToolBtn
323
- $active={!!advancedOpen}
324
- onClick={() => setAdvancedOpen(open => !open)}
325
- title="More tools"
326
- >
327
- <MoreOutlined />
328
- </ToolBtn>
329
- <AdvancedPanel open={advancedOpen}>
330
- <AdvancedMenu>
331
- <AdvancedItem
332
- type="button"
333
- onClick={() =>
334
- closeAdvanced(() =>
335
- editor.chain().focus().toggleSubscript().run(),
336
- )
337
- }
338
- >
339
- <span aria-hidden="true">x₂</span>
340
- <span>{isSubscript ? 'Remove subscript' : 'Subscript'}</span>
341
- </AdvancedItem>
342
- <AdvancedItem
343
- type="button"
344
- onClick={() =>
345
- closeAdvanced(() =>
346
- editor.chain().focus().toggleSuperscript().run(),
347
- )
348
- }
349
- >
350
- <span aria-hidden="true">x²</span>
351
- <span>
352
- {isSuperscript ? 'Remove superscript' : 'Superscript'}
353
- </span>
354
- </AdvancedItem>
355
- {showFullAdvancedTools ? (
356
- <>
357
- <AdvancedItem
358
- type="button"
359
- onClick={() => closeAdvanced(insertInlineMath)}
360
- >
361
- <span aria-hidden="true">ƒx</span>
362
- <span>Insert inline equation</span>
363
- </AdvancedItem>
364
- <AdvancedItem
365
- type="button"
366
- onClick={() => closeAdvanced(insertBlockMath)}
367
- >
368
- <span aria-hidden="true">∑</span>
369
- <span>Insert display equation</span>
370
- </AdvancedItem>
371
- <AdvancedItem
372
- type="button"
373
- onClick={() => closeAdvanced(insertMermaidBlock)}
374
- >
375
- <PartitionOutlined />
376
- <span>Insert Mermaid diagram</span>
377
- </AdvancedItem>
378
- <AdvancedItem
379
- type="button"
380
- onClick={() =>
381
- closeAdvanced(() =>
382
- editor.chain().focus().toggleTaskList().run(),
383
- )
384
- }
385
- >
386
- <CheckSquareOutlined />
387
- <span>{isTask ? 'Remove task list' : 'Task list'}</span>
388
- </AdvancedItem>
389
- </>
390
- ) : null}
391
- {onAddComment ? (
392
- <AdvancedItem
393
- type="button"
394
- onClick={() => closeAdvanced(addComment)}
395
- disabled={!hasSelection}
396
- >
397
- <TagOutlined />
398
- <span>Add comment to selection</span>
399
- </AdvancedItem>
400
- ) : null}
401
- {showFullAdvancedTools && hasAutoReviewPrompts ? (
402
- <AdvancedItem
403
- type="button"
404
- onClick={() => closeAdvanced(prepareAutoReviewPrompts)}
405
- >
406
- <FileTextOutlined />
407
- <span>Copy editorial review prompts</span>
408
- </AdvancedItem>
409
- ) : null}
410
- {showFullAdvancedTools ? (
411
- <>
412
- <AdvancedItem
413
- type="button"
414
- onClick={() =>
415
- closeAdvanced(() =>
416
- editor.chain().focus().toggleBlockquote().run(),
417
- )
418
- }
419
- >
420
- <span>“</span>
421
- <span>
422
- {isBlockquote ? 'Remove blockquote' : 'Blockquote'}
423
- </span>
424
- </AdvancedItem>
425
- <AdvancedItem
426
- type="button"
427
- onClick={() =>
428
- closeAdvanced(() =>
429
- editor.chain().focus().toggleCodeBlock().run(),
430
- )
431
- }
432
- >
433
- <CodeOutlined />
434
- <span>
435
- {isCodeBlock ? 'Remove code block' : 'Code block'}
436
- </span>
437
- </AdvancedItem>
438
- <AdvancedItem
439
- type="button"
440
- onClick={() =>
441
- closeAdvanced(() =>
442
- editor
443
- .chain()
444
- .focus()
445
- .insertTable({
446
- rows: 3,
447
- cols: 3,
448
- withHeaderRow: true,
449
- })
450
- .run(),
451
- )
452
- }
453
- >
454
- <TableOutlined />
455
- <span>
456
- {inTable ? 'Insert another table' : 'Insert table'}
457
- </span>
458
- </AdvancedItem>
459
- <AdvancedItem
460
- type="button"
461
- onClick={() =>
462
- closeAdvanced(() =>
463
- editor.chain().focus().insertFootnote().run(),
464
- )
465
- }
466
- >
467
- <FileTextOutlined />
468
- <span>Insert footnote</span>
469
- </AdvancedItem>
470
- </>
471
- ) : null}
472
- <AdvancedItem
473
- type="button"
474
- disabled={!indexActive && !hasSelection}
475
- onClick={() =>
476
- closeAdvanced(() =>
477
- editor.chain().focus().toggleIndexMarker().run(),
478
- )
479
- }
480
- >
481
- <TagOutlined />
482
- <span>
483
- {indexActive ? 'Remove index marker' : 'Mark for index'}
484
- </span>
485
- </AdvancedItem>
486
- {inTable ? (
487
- <>
488
- <AdvancedItem
489
- type="button"
490
- onClick={() =>
491
- closeAdvanced(() =>
492
- editor.chain().focus().addRowAfter().run(),
493
- )
494
- }
495
- >
496
- <TableOutlined />
497
- <span>Add table row</span>
498
- </AdvancedItem>
499
- <AdvancedItem
500
- type="button"
501
- onClick={() =>
502
- closeAdvanced(() =>
503
- editor.chain().focus().addColumnAfter().run(),
504
- )
505
- }
506
- >
507
- <TableOutlined />
508
- <span>Add table column</span>
509
- </AdvancedItem>
510
- <AdvancedItem
511
- type="button"
512
- onClick={() =>
513
- closeAdvanced(() =>
514
- editor.chain().focus().deleteTable().run(),
515
- )
516
- }
517
- >
518
- <BlockOutlined />
519
- <span>Delete table</span>
520
- </AdvancedItem>
521
- </>
522
- ) : null}
523
- </AdvancedMenu>
524
- </AdvancedPanel>
525
- </AdvancedWrapper>
526
- </ToolbarRow>
527
-
528
- {LinkPrompt ? (
529
- <LinkPrompt
530
- open={showLinkInput}
531
- value={linkUrl}
532
- onChange={setLinkUrl}
533
- onConfirm={confirmLink}
534
- onCancel={cancelLink}
535
- />
536
- ) : null}
537
- {CommentPrompt ? (
538
- <CommentPrompt
539
- open={showCommentInput}
540
- value={commentText}
541
- commentType={commentType}
542
- commentTypeOptions={COMMENT_TYPES.map(value => ({
543
- value,
544
- label: COMMENT_TYPE_LABELS[value],
545
- }))}
546
- onChange={setCommentText}
547
- onCommentTypeChange={setCommentType}
548
- onConfirm={confirmComment}
549
- onCancel={cancelComment}
550
- confirmDisabled={!commentText.trim() || !commentSelection}
551
- />
552
- ) : null}
553
- </>
554
- )
555
- }
556
-
557
- const AdvancedWrapper = styled.div`
558
- position: relative;
559
- `
560
-
561
- const AdvancedPanel = styled(CollapsePanel)`
562
- position: absolute;
563
- top: calc(100% + 8px);
564
- right: 0;
565
- z-index: 20;
566
- min-width: 220px;
567
- background: var(--platform-colors-surface);
568
- border: 1px solid rgb(from var(--platform-colors-border) r g b / 0.92);
569
- box-shadow: 0 18px 36px rgb(from var(--platform-colors-text) r g b / 0.1);
570
-
571
- &[data-open='false'] {
572
- border-color: transparent;
573
- box-shadow: none;
574
- pointer-events: none;
575
- }
576
- `
577
-
578
- const AdvancedMenu = styled.div`
579
- display: flex;
580
- flex-direction: column;
581
- gap: 2px;
582
- padding: 8px;
583
- `
584
-
585
- const AdvancedItem = styled.button.attrs({
586
- onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => {
587
- event.preventDefault()
588
- },
589
- })`
590
- display: flex;
591
- align-items: center;
592
- gap: 10px;
593
- width: 100%;
594
- padding: 8px 10px;
595
- border: none;
596
- background: transparent;
597
- color: var(--platform-colors-text);
598
- text-align: left;
599
- font-size: 12px;
600
- transition: background 0.12s ease, color 0.12s ease;
601
-
602
- &:hover:not(:disabled) {
603
- background: rgb(from var(--platform-colors-surface-hover) r g b / 0.9);
604
- }
605
-
606
- &:disabled {
607
- opacity: 0.45;
608
- cursor: not-allowed;
609
- }
610
- `
611
-
612
- const UnderlineGlyph = styled.span`
613
- display: inline-flex;
614
- align-items: center;
615
- justify-content: center;
616
- width: 1em;
617
- color: currentColor;
618
- font-size: 16px;
619
- font-weight: 500;
620
- line-height: 1;
621
- text-decoration-line: underline;
622
- text-decoration-thickness: 1.5px;
623
- text-underline-offset: 3px;
624
- `
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { useCurrentEditor } from '@tiptap/react'
3
+ import { styled } from 'styled-components'
4
+ import {
5
+ BlockOutlined,
6
+ BoldOutlined,
7
+ CheckSquareOutlined,
8
+ CodeOutlined,
9
+ FileTextOutlined,
10
+ ItalicOutlined,
11
+ LinkOutlined,
12
+ MoreOutlined,
13
+ OrderedListOutlined,
14
+ PartitionOutlined,
15
+ RedoOutlined,
16
+ TableOutlined,
17
+ TagOutlined,
18
+ UndoOutlined,
19
+ UnorderedListOutlined,
20
+ } from '@ant-design/icons'
21
+ import { Divider, ToolbarRow, ToolBtn } from './ToolBtn.js'
22
+ import type { EditorToolbarOverlays } from './overlayTypes.js'
23
+ import { HeadingGroup } from './groups/HeadingGroup.js'
24
+ import { CollapsePanel } from '../CollapsePanel.js'
25
+ import { TOOL_KEYS } from '../constants/toolKeys.js'
26
+ import {
27
+ COMMENT_TYPES,
28
+ COMMENT_TYPE_LABELS,
29
+ type CommentType,
30
+ } from '../commentUtils.js'
31
+ import { copyAutoReviewPromptsToClipboard } from '../extensions/autoReviewPrompts.js'
32
+
33
+ export function Toolbar({
34
+ onAddComment,
35
+ overlays,
36
+ inlineTools,
37
+ advancedTools = 'full',
38
+ }: {
39
+ onAddComment?: (payload: {
40
+ commentText: string
41
+ commentType: CommentType
42
+ from: number
43
+ to: number
44
+ }) => void
45
+ overlays?: EditorToolbarOverlays
46
+ inlineTools?: React.ReactNode
47
+ advancedTools?: 'full' | 'formatting'
48
+ }): React.ReactElement | null {
49
+ const { editor } = useCurrentEditor()
50
+
51
+ const [showLinkInput, setShowLinkInput] = useState(false)
52
+ const [linkUrl, setLinkUrl] = useState('')
53
+ const [showCommentInput, setShowCommentInput] = useState(false)
54
+ const [commentText, setCommentText] = useState('')
55
+ const [commentType, setCommentType] = useState<CommentType>('general')
56
+ const [commentSelection, setCommentSelection] = useState<{
57
+ from: number
58
+ to: number
59
+ } | null>(null)
60
+ const [advancedOpen, setAdvancedOpen] = useState(false)
61
+ const [hasSelection, setHasSelection] = useState(false)
62
+ const advancedRef = useRef<HTMLDivElement>(null)
63
+ const lastSelectionRef = useRef<{ from: number; to: number } | null>(null)
64
+
65
+ const isLink = !!editor?.isActive(TOOL_KEYS.LINK)
66
+ const isBullet = !!editor?.isActive(TOOL_KEYS.BULLET_LIST)
67
+ const isOrdered = !!editor?.isActive(TOOL_KEYS.ORDERED_LIST)
68
+ const isCode = !!editor?.isActive(TOOL_KEYS.CODE)
69
+ const isBold = !!editor?.isActive(TOOL_KEYS.BOLD)
70
+ const isItalic = !!editor?.isActive(TOOL_KEYS.ITALIC)
71
+ const isUnderline = !!editor?.isActive(TOOL_KEYS.UNDERLINE)
72
+ const canUndo = !!editor?.can().undo()
73
+ const canRedo = !!editor?.can().redo()
74
+ const isTask = !!editor?.isActive(TOOL_KEYS.TASK_LIST)
75
+ const isBlockquote = !!editor?.isActive(TOOL_KEYS.BLOCKQUOTE)
76
+ const isCodeBlock = !!editor?.isActive(TOOL_KEYS.CODE_BLOCK)
77
+ const isSubscript = !!editor?.isActive('subscript')
78
+ const isSuperscript = !!editor?.isActive('superscript')
79
+ const indexActive = !!editor?.isActive(TOOL_KEYS.INDEX_MARKER)
80
+ const inTable = !!editor?.isActive(TOOL_KEYS.TABLE)
81
+ const hasAutoReviewPrompts = Boolean(
82
+ editor?.extensionManager.extensions.some(
83
+ extension => extension.name === 'autoReviewPrompts',
84
+ ),
85
+ )
86
+
87
+ useEffect(() => {
88
+ if (!editor) return
89
+ const syncSelection = (): void => {
90
+ const { from, to } = editor.state.selection
91
+ const nextHasSelection = from !== to
92
+ lastSelectionRef.current = nextHasSelection ? { from, to } : null
93
+ setHasSelection(nextHasSelection)
94
+ }
95
+ syncSelection()
96
+ editor.on('selectionUpdate', syncSelection)
97
+ editor.on('update', syncSelection)
98
+ return () => {
99
+ editor.off('selectionUpdate', syncSelection)
100
+ editor.off('update', syncSelection)
101
+ }
102
+ }, [editor])
103
+
104
+ useEffect(() => {
105
+ if (!advancedOpen) return
106
+ const onDown = (e: MouseEvent) => {
107
+ if (!advancedRef.current?.contains(e.target as Node))
108
+ setAdvancedOpen(false)
109
+ }
110
+ document.addEventListener('mousedown', onDown)
111
+ return () => document.removeEventListener('mousedown', onDown)
112
+ }, [advancedOpen])
113
+
114
+ const handleLinkClick = () => {
115
+ if (!editor) return
116
+ if (isLink) {
117
+ editor.chain().focus().unsetLink().run()
118
+ return
119
+ }
120
+ const { href = '' } = editor.getAttributes('link')
121
+ setLinkUrl(href)
122
+ setShowLinkInput(true)
123
+ }
124
+
125
+ const confirmLink = () => {
126
+ if (!editor) return
127
+ const trimmed = linkUrl.trim()
128
+ if (trimmed) editor.chain().focus().setLink({ href: trimmed }).run()
129
+ else editor.chain().focus().unsetLink().run()
130
+ setShowLinkInput(false)
131
+ setLinkUrl('')
132
+ }
133
+
134
+ const cancelLink = () => {
135
+ setShowLinkInput(false)
136
+ setLinkUrl('')
137
+ }
138
+
139
+ const openCommentInput = () => {
140
+ const selection = lastSelectionRef.current
141
+ if (!selection) return
142
+ setCommentSelection(selection)
143
+ setShowCommentInput(true)
144
+ }
145
+
146
+ const cancelComment = () => {
147
+ setShowCommentInput(false)
148
+ setCommentText('')
149
+ setCommentSelection(null)
150
+ }
151
+
152
+ const confirmComment = () => {
153
+ const selection = commentSelection ?? lastSelectionRef.current
154
+ const trimmed = commentText.trim()
155
+ if (!selection || !trimmed || !onAddComment) return
156
+ onAddComment({
157
+ commentText: trimmed,
158
+ commentType,
159
+ from: selection.from,
160
+ to: selection.to,
161
+ })
162
+ setShowCommentInput(false)
163
+ setCommentText('')
164
+ setCommentSelection(null)
165
+ }
166
+
167
+ const closeAdvanced = (fn: () => unknown) => {
168
+ setAdvancedOpen(false)
169
+ void fn()
170
+ }
171
+
172
+ const insertInlineMath = () => {
173
+ if (!editor) return
174
+ const latex = window.prompt('Inline LaTeX', 'E = mc^2')?.trim()
175
+ if (!latex) return
176
+ const commands = editor.chain().focus() as unknown as {
177
+ insertInlineMath?: (options: { latex: string }) => { run: () => void }
178
+ }
179
+ commands.insertInlineMath?.({ latex })?.run()
180
+ }
181
+
182
+ const insertBlockMath = () => {
183
+ if (!editor) return
184
+ const latex = window.prompt('Block LaTeX', '\\\\frac{a}{b}')?.trim()
185
+ if (!latex) return
186
+ const commands = editor.chain().focus() as unknown as {
187
+ insertBlockMath?: (options: { latex: string }) => { run: () => void }
188
+ }
189
+ commands.insertBlockMath?.({ latex })?.run()
190
+ }
191
+
192
+ const insertMermaidBlock = () => {
193
+ if (!editor) return
194
+ editor.chain().focus().insertMermaidBlock().run()
195
+ }
196
+
197
+ const addComment = () => {
198
+ openCommentInput()
199
+ }
200
+
201
+ const prepareAutoReviewPrompts = async () => {
202
+ if (!editor) return
203
+ try {
204
+ const result = await copyAutoReviewPromptsToClipboard(editor)
205
+ if (!result.paragraphCount) {
206
+ window.alert('No paragraph text found for editorial review.')
207
+ return
208
+ }
209
+ window.alert(
210
+ `Copied editorial review prompts for ${
211
+ result.paragraphCount
212
+ } paragraph${result.paragraphCount === 1 ? '' : 's'}.`,
213
+ )
214
+ } catch {
215
+ window.alert('Could not copy auto-review prompts to the clipboard.')
216
+ }
217
+ }
218
+
219
+ const indentList = () => {
220
+ const commands = editor?.chain().focus() as unknown as {
221
+ sinkListItem?: (type: string) => { run: () => void }
222
+ }
223
+ const action = commands.sinkListItem?.('listItem')
224
+ action?.run()
225
+ }
226
+
227
+ if (!editor) return null
228
+
229
+ const LinkPrompt = overlays?.LinkPrompt
230
+ const CommentPrompt = overlays?.CommentPrompt
231
+ const showFullAdvancedTools = advancedTools === 'full'
232
+
233
+ return (
234
+ <>
235
+ <ToolbarRow>
236
+ <ToolBtn
237
+ disabled={!canUndo}
238
+ onClick={() => editor.chain().focus().undo().run()}
239
+ title="Undo"
240
+ >
241
+ <UndoOutlined />
242
+ </ToolBtn>
243
+ <ToolBtn
244
+ disabled={!canRedo}
245
+ onClick={() => editor.chain().focus().redo().run()}
246
+ title="Redo"
247
+ >
248
+ <RedoOutlined />
249
+ </ToolBtn>
250
+ <Divider />
251
+ <HeadingGroup />
252
+ <Divider />
253
+ <ToolBtn
254
+ $active={!!isBlockquote}
255
+ onClick={() => editor.chain().focus().toggleBlockquote().run()}
256
+ title="Blockquote"
257
+ >
258
+ <span aria-hidden="true">“</span>
259
+ </ToolBtn>
260
+ <ToolBtn
261
+ $active={!!isOrdered}
262
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
263
+ title="Ordered list"
264
+ >
265
+ <OrderedListOutlined />
266
+ </ToolBtn>
267
+ <ToolBtn
268
+ $active={!!isBullet}
269
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
270
+ title="Bullet list"
271
+ >
272
+ <UnorderedListOutlined />
273
+ </ToolBtn>
274
+ <ToolBtn
275
+ onClick={indentList}
276
+ title="Indent list"
277
+ disabled={!isBullet && !isOrdered}
278
+ >
279
+ <span aria-hidden="true">⇥</span>
280
+ </ToolBtn>
281
+ <Divider />
282
+ {onAddComment ? (
283
+ <ToolBtn
284
+ onClick={addComment}
285
+ title="Add comment to selection"
286
+ disabled={!hasSelection}
287
+ >
288
+ <TagOutlined />
289
+ </ToolBtn>
290
+ ) : null}
291
+ <ToolBtn
292
+ $active={!!isBold}
293
+ onClick={() => editor.chain().focus().toggleBold().run()}
294
+ title="Bold"
295
+ >
296
+ <BoldOutlined />
297
+ </ToolBtn>
298
+ <ToolBtn
299
+ $active={!!isItalic}
300
+ onClick={() => editor.chain().focus().toggleItalic().run()}
301
+ title="Italic"
302
+ >
303
+ <ItalicOutlined />
304
+ </ToolBtn>
305
+ <ToolBtn
306
+ $active={!!isCode}
307
+ onClick={() => editor.chain().focus().toggleCode().run()}
308
+ title="Inline code"
309
+ >
310
+ <CodeOutlined />
311
+ </ToolBtn>
312
+ <ToolBtn $active={!!isLink} onClick={handleLinkClick} title="Link">
313
+ <LinkOutlined />
314
+ </ToolBtn>
315
+ <ToolBtn
316
+ $active={!!isUnderline}
317
+ onClick={() => editor.chain().focus().toggleUnderline().run()}
318
+ title="Underline"
319
+ >
320
+ <UnderlineGlyph aria-hidden="true">U</UnderlineGlyph>
321
+ </ToolBtn>
322
+ {inlineTools ? (
323
+ <>
324
+ <Divider />
325
+ {inlineTools}
326
+ </>
327
+ ) : null}
328
+ <Divider />
329
+ <AdvancedWrapper ref={advancedRef}>
330
+ <ToolBtn
331
+ $active={!!advancedOpen}
332
+ onClick={() => setAdvancedOpen(open => !open)}
333
+ title="More tools"
334
+ >
335
+ <MoreOutlined />
336
+ </ToolBtn>
337
+ <AdvancedPanel open={advancedOpen}>
338
+ <AdvancedMenu>
339
+ <AdvancedItem
340
+ type="button"
341
+ onClick={() =>
342
+ closeAdvanced(() =>
343
+ editor.chain().focus().toggleSubscript().run(),
344
+ )
345
+ }
346
+ >
347
+ <span aria-hidden="true">x₂</span>
348
+ <span>{isSubscript ? 'Remove subscript' : 'Subscript'}</span>
349
+ </AdvancedItem>
350
+ <AdvancedItem
351
+ type="button"
352
+ onClick={() =>
353
+ closeAdvanced(() =>
354
+ editor.chain().focus().toggleSuperscript().run(),
355
+ )
356
+ }
357
+ >
358
+ <span aria-hidden="true">x²</span>
359
+ <span>
360
+ {isSuperscript ? 'Remove superscript' : 'Superscript'}
361
+ </span>
362
+ </AdvancedItem>
363
+ {showFullAdvancedTools ? (
364
+ <>
365
+ <AdvancedItem
366
+ type="button"
367
+ onClick={() => closeAdvanced(insertInlineMath)}
368
+ >
369
+ <span aria-hidden="true">ƒx</span>
370
+ <span>Insert inline equation</span>
371
+ </AdvancedItem>
372
+ <AdvancedItem
373
+ type="button"
374
+ onClick={() => closeAdvanced(insertBlockMath)}
375
+ >
376
+ <span aria-hidden="true">∑</span>
377
+ <span>Insert display equation</span>
378
+ </AdvancedItem>
379
+ <AdvancedItem
380
+ type="button"
381
+ onClick={() => closeAdvanced(insertMermaidBlock)}
382
+ >
383
+ <PartitionOutlined />
384
+ <span>Insert Mermaid diagram</span>
385
+ </AdvancedItem>
386
+ <AdvancedItem
387
+ type="button"
388
+ onClick={() =>
389
+ closeAdvanced(() =>
390
+ editor.chain().focus().toggleTaskList().run(),
391
+ )
392
+ }
393
+ >
394
+ <CheckSquareOutlined />
395
+ <span>{isTask ? 'Remove task list' : 'Task list'}</span>
396
+ </AdvancedItem>
397
+ </>
398
+ ) : null}
399
+ {onAddComment ? (
400
+ <AdvancedItem
401
+ type="button"
402
+ onClick={() => closeAdvanced(addComment)}
403
+ disabled={!hasSelection}
404
+ >
405
+ <TagOutlined />
406
+ <span>Add comment to selection</span>
407
+ </AdvancedItem>
408
+ ) : null}
409
+ {showFullAdvancedTools && hasAutoReviewPrompts ? (
410
+ <AdvancedItem
411
+ type="button"
412
+ onClick={() => closeAdvanced(prepareAutoReviewPrompts)}
413
+ >
414
+ <FileTextOutlined />
415
+ <span>Copy editorial review prompts</span>
416
+ </AdvancedItem>
417
+ ) : null}
418
+ {showFullAdvancedTools ? (
419
+ <>
420
+ <AdvancedItem
421
+ type="button"
422
+ onClick={() =>
423
+ closeAdvanced(() =>
424
+ editor.chain().focus().toggleBlockquote().run(),
425
+ )
426
+ }
427
+ >
428
+ <span>“</span>
429
+ <span>
430
+ {isBlockquote ? 'Remove blockquote' : 'Blockquote'}
431
+ </span>
432
+ </AdvancedItem>
433
+ <AdvancedItem
434
+ type="button"
435
+ onClick={() =>
436
+ closeAdvanced(() =>
437
+ editor.chain().focus().toggleCodeBlock().run(),
438
+ )
439
+ }
440
+ >
441
+ <CodeOutlined />
442
+ <span>
443
+ {isCodeBlock ? 'Remove code block' : 'Code block'}
444
+ </span>
445
+ </AdvancedItem>
446
+ <AdvancedItem
447
+ type="button"
448
+ onClick={() =>
449
+ closeAdvanced(() =>
450
+ editor
451
+ .chain()
452
+ .focus()
453
+ .insertTable({
454
+ rows: 3,
455
+ cols: 3,
456
+ withHeaderRow: true,
457
+ })
458
+ .run(),
459
+ )
460
+ }
461
+ >
462
+ <TableOutlined />
463
+ <span>
464
+ {inTable ? 'Insert another table' : 'Insert table'}
465
+ </span>
466
+ </AdvancedItem>
467
+ <AdvancedItem
468
+ type="button"
469
+ onClick={() =>
470
+ closeAdvanced(() =>
471
+ editor.chain().focus().insertFootnote().run(),
472
+ )
473
+ }
474
+ >
475
+ <FileTextOutlined />
476
+ <span>Insert footnote</span>
477
+ </AdvancedItem>
478
+ </>
479
+ ) : null}
480
+ <AdvancedItem
481
+ type="button"
482
+ disabled={!indexActive && !hasSelection}
483
+ onClick={() =>
484
+ closeAdvanced(() =>
485
+ editor.chain().focus().toggleIndexMarker().run(),
486
+ )
487
+ }
488
+ >
489
+ <TagOutlined />
490
+ <span>
491
+ {indexActive ? 'Remove index marker' : 'Mark for index'}
492
+ </span>
493
+ </AdvancedItem>
494
+ {inTable ? (
495
+ <>
496
+ <AdvancedItem
497
+ type="button"
498
+ onClick={() =>
499
+ closeAdvanced(() =>
500
+ editor.chain().focus().addRowAfter().run(),
501
+ )
502
+ }
503
+ >
504
+ <TableOutlined />
505
+ <span>Add table row</span>
506
+ </AdvancedItem>
507
+ <AdvancedItem
508
+ type="button"
509
+ onClick={() =>
510
+ closeAdvanced(() =>
511
+ editor.chain().focus().addColumnAfter().run(),
512
+ )
513
+ }
514
+ >
515
+ <TableOutlined />
516
+ <span>Add table column</span>
517
+ </AdvancedItem>
518
+ <AdvancedItem
519
+ type="button"
520
+ onClick={() =>
521
+ closeAdvanced(() =>
522
+ editor.chain().focus().deleteTable().run(),
523
+ )
524
+ }
525
+ >
526
+ <BlockOutlined />
527
+ <span>Delete table</span>
528
+ </AdvancedItem>
529
+ </>
530
+ ) : null}
531
+ </AdvancedMenu>
532
+ </AdvancedPanel>
533
+ </AdvancedWrapper>
534
+ </ToolbarRow>
535
+
536
+ {LinkPrompt ? (
537
+ <LinkPrompt
538
+ open={showLinkInput}
539
+ value={linkUrl}
540
+ onChange={setLinkUrl}
541
+ onConfirm={confirmLink}
542
+ onCancel={cancelLink}
543
+ />
544
+ ) : null}
545
+ {CommentPrompt ? (
546
+ <CommentPrompt
547
+ open={showCommentInput}
548
+ value={commentText}
549
+ commentType={commentType}
550
+ commentTypeOptions={COMMENT_TYPES.map(value => ({
551
+ value,
552
+ label: COMMENT_TYPE_LABELS[value],
553
+ }))}
554
+ onChange={setCommentText}
555
+ onCommentTypeChange={setCommentType}
556
+ onConfirm={confirmComment}
557
+ onCancel={cancelComment}
558
+ confirmDisabled={!commentText.trim() || !commentSelection}
559
+ />
560
+ ) : null}
561
+ </>
562
+ )
563
+ }
564
+
565
+ const AdvancedWrapper = styled.div`
566
+ position: relative;
567
+ `
568
+
569
+ const AdvancedPanel = styled(CollapsePanel)`
570
+ position: absolute;
571
+ top: calc(100% + 8px);
572
+ right: 0;
573
+ z-index: 20;
574
+ min-width: 220px;
575
+ background: var(
576
+ --editor-toolbar-panel,
577
+ var(--editor-paper, var(--platform-colors-surface))
578
+ );
579
+ border: 1px solid
580
+ rgb(
581
+ from
582
+ var(
583
+ --editor-toolbar-border,
584
+ var(--editor-paper-border, var(--platform-colors-border))
585
+ )
586
+ r g b / 0.92
587
+ );
588
+ box-shadow: 0 18px 36px
589
+ rgb(
590
+ from
591
+ var(
592
+ --editor-toolbar-text,
593
+ var(--editor-paper-text, var(--platform-colors-text))
594
+ )
595
+ r g b / 0.1
596
+ );
597
+
598
+ &[data-open='false'] {
599
+ border-color: transparent;
600
+ display: none;
601
+ box-shadow: none;
602
+ pointer-events: none;
603
+ }
604
+ `
605
+
606
+ const AdvancedMenu = styled.div`
607
+ display: flex;
608
+ flex-direction: column;
609
+ gap: 2px;
610
+ padding: 8px;
611
+ `
612
+
613
+ const AdvancedItem = styled.button.attrs({
614
+ onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => {
615
+ event.preventDefault()
616
+ },
617
+ })`
618
+ display: flex;
619
+ align-items: center;
620
+ gap: 10px;
621
+ width: 100%;
622
+ padding: 8px 10px;
623
+ border: none;
624
+ background: transparent;
625
+ color: var(
626
+ --editor-toolbar-text,
627
+ var(--editor-paper-text, var(--platform-colors-text))
628
+ );
629
+ text-align: left;
630
+ font-size: 12px;
631
+ transition: background 0.12s ease, color 0.12s ease;
632
+
633
+ &:hover:not(:disabled) {
634
+ background: rgb(
635
+ from
636
+ var(
637
+ --editor-toolbar-subtle,
638
+ var(--editor-paper-subtle, var(--platform-colors-surface-hover))
639
+ )
640
+ r g b / 0.9
641
+ );
642
+ }
643
+
644
+ &:disabled {
645
+ opacity: 0.45;
646
+ cursor: not-allowed;
647
+ }
648
+ `
649
+
650
+ const UnderlineGlyph = styled.span`
651
+ display: inline-flex;
652
+ align-items: center;
653
+ justify-content: center;
654
+ width: 1em;
655
+ color: currentColor;
656
+ font-size: 16px;
657
+ font-weight: 500;
658
+ line-height: 1;
659
+ text-decoration-line: underline;
660
+ text-decoration-thickness: 1.5px;
661
+ text-underline-offset: 3px;
662
+ `