@puredesktop/platform-editor 1.0.0-beta.1

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 +63 -0
  2. package/src/CollapsePanel.tsx +35 -0
  3. package/src/DocumentDiffView.tsx +130 -0
  4. package/src/DocumentEditor.test.ts +118 -0
  5. package/src/DocumentEditor.tsx +2631 -0
  6. package/src/alignedLineDiff.test.ts +52 -0
  7. package/src/alignedLineDiff.ts +67 -0
  8. package/src/collectionAssetSrc.ts +2 -0
  9. package/src/commentUtils.test.ts +350 -0
  10. package/src/commentUtils.ts +546 -0
  11. package/src/constants/toolKeys.ts +14 -0
  12. package/src/contentFormat.test.ts +27 -0
  13. package/src/contentFormat.ts +18 -0
  14. package/src/editorExtensions.ts +155 -0
  15. package/src/extensions/appliedChangeMark.ts +115 -0
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -0
  17. package/src/extensions/autoReviewPrompts.ts +1442 -0
  18. package/src/extensions/collectionImage.ts +26 -0
  19. package/src/extensions/collectionImagePaste.ts +215 -0
  20. package/src/extensions/commentMark.ts +223 -0
  21. package/src/extensions/documentAssetIdentity.ts +54 -0
  22. package/src/extensions/figure.ts +92 -0
  23. package/src/extensions/footnote.ts +186 -0
  24. package/src/extensions/indexMarker.ts +88 -0
  25. package/src/extensions/mathEditing.ts +239 -0
  26. package/src/extensions/mermaidBlock.tsx +297 -0
  27. package/src/extensions/slashCommands.test.ts +170 -0
  28. package/src/extensions/slashCommands.ts +746 -0
  29. package/src/extensions/smartTypography.test.ts +74 -0
  30. package/src/extensions/smartTypography.ts +120 -0
  31. package/src/index.ts +137 -0
  32. package/src/insertCollectionImage.test.ts +60 -0
  33. package/src/insertCollectionImage.ts +63 -0
  34. package/src/insertInlineAssetKind.test.ts +67 -0
  35. package/src/insertInlineAssetKind.ts +49 -0
  36. package/src/mermaidPreview.test.ts +54 -0
  37. package/src/mermaidPreview.ts +115 -0
  38. package/src/styled.ts +676 -0
  39. package/src/toolbar/ToolBtn.tsx +63 -0
  40. package/src/toolbar/Toolbar.test.tsx +325 -0
  41. package/src/toolbar/Toolbar.tsx +624 -0
  42. package/src/toolbar/groups/HeadingGroup.tsx +153 -0
  43. package/src/toolbar/overlayTypes.ts +28 -0
  44. package/src/useEditorExtensions.ts +22 -0
  45. package/src/utils/markdownUtils.ts +331 -0
@@ -0,0 +1,2631 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type CSSProperties,
10
+ type ReactNode,
11
+ } from 'react'
12
+ import type { Editor } from '@tiptap/core'
13
+ import { migrateMathStrings } from '@tiptap/extension-mathematics'
14
+ import { EditorContent, EditorContext, useEditor } from '@tiptap/react'
15
+ import type { Extensions } from '@tiptap/react'
16
+ import { styled } from 'styled-components'
17
+ import { CloseOutlined, CommentOutlined } from '@ant-design/icons'
18
+ import {
19
+ applyCommentMarkToRange,
20
+ applyCommentReplacementById,
21
+ COMMENT_STATUS_LABELS,
22
+ COMMENT_TYPE_DEFINITIONS,
23
+ COMMENT_TYPE_LABELS,
24
+ defaultCommentReviewStatusForType,
25
+ defaultCommentSeverityForType,
26
+ normalizeCommentReviewStatus,
27
+ normalizeCommentSeverity,
28
+ normalizeCommentType,
29
+ parseCommentReplies,
30
+ removeAllCommentMarks,
31
+ removeCommentMarksById,
32
+ serializeCommentReplies,
33
+ stripCommentMarkupFromHtml,
34
+ updateCommentMarksById,
35
+ type CommentMarkAttrPatch,
36
+ type CommentReply,
37
+ type CommentReviewStatus,
38
+ type CommentSeverity,
39
+ type CommentType,
40
+ } from './commentUtils.js'
41
+ import {
42
+ resolveEditorContent,
43
+ type EditorInputFormat,
44
+ } from './contentFormat.js'
45
+ import {
46
+ createDefaultSlashCommands,
47
+ SlashCommands,
48
+ type SlashCommandItem,
49
+ } from './extensions/slashCommands.js'
50
+ import {
51
+ applyAutoReviewFindings,
52
+ collectScopedAutoReviewParagraphs,
53
+ runAutoReview as runEditorAutoReview,
54
+ type AutoReviewFindingInput,
55
+ type AutoReviewParagraph,
56
+ type RunAutoReviewOptions,
57
+ type RunAutoReviewResult,
58
+ } from './extensions/autoReviewPrompts.js'
59
+ import { toMd } from './utils/markdownUtils.js'
60
+ import {
61
+ DocumentEditorRoot,
62
+ DocumentEditorViewport,
63
+ DocumentEditorToolbarActions,
64
+ DocumentEditorToolbarWrap,
65
+ EditorWrapper,
66
+ } from './styled.js'
67
+ import { Toolbar } from './toolbar/Toolbar.js'
68
+ import type { EditorToolbarOverlays } from './toolbar/overlayTypes.js'
69
+
70
+ interface ActiveComment {
71
+ id: string
72
+ text: string
73
+ savedText: string
74
+ commentType: CommentType
75
+ savedCommentType: CommentType
76
+ subjectText: string
77
+ reviewStatus: CommentReviewStatus
78
+ savedReviewStatus: CommentReviewStatus
79
+ severity: CommentSeverity
80
+ author?: string
81
+ createdAt?: string
82
+ replies: CommentReply[]
83
+ resolvedAt?: string
84
+ resolvedBy?: string
85
+ x: number
86
+ y: number
87
+ }
88
+
89
+ interface SelectionBubbleState {
90
+ from: number
91
+ to: number
92
+ x: number
93
+ y: number
94
+ selectedText: string
95
+ }
96
+
97
+ interface InlineCommentDraft {
98
+ from: number
99
+ to: number
100
+ x: number
101
+ y: number
102
+ text: string
103
+ commentType: CommentType
104
+ selectedText: string
105
+ }
106
+
107
+ interface ReviewRailItem {
108
+ id: string
109
+ type: CommentType
110
+ status: CommentReviewStatus
111
+ resolved: boolean
112
+ top: number
113
+ left: number
114
+ }
115
+
116
+ interface ScrollSnapshot {
117
+ windowX: number
118
+ windowY: number
119
+ entries: Array<{
120
+ element: HTMLElement
121
+ left: number
122
+ top: number
123
+ }>
124
+ }
125
+
126
+ const MAX_VISIBLE_REVIEW_RAIL_ITEMS = 18
127
+ const REVIEW_RAIL_SCROLL_IDLE_MS = 160
128
+ const FRESH_COMMENT_HIGHLIGHT_MS = 2200
129
+ const REVIEW_RAIL_COMMENT_TYPES: CommentType[] = [
130
+ 'claim',
131
+ 'fact-check',
132
+ 'source',
133
+ 'thread',
134
+ 'logic',
135
+ 'copyedit',
136
+ 'voice',
137
+ ]
138
+ const REVIEW_RAIL_SELECTOR = REVIEW_RAIL_COMMENT_TYPES.map(
139
+ type => `span[data-comment-id][data-comment-type="${type}"]`,
140
+ ).join(', ')
141
+
142
+ export function isReviewRailCommentType(type: CommentType): boolean {
143
+ return REVIEW_RAIL_COMMENT_TYPES.includes(type)
144
+ }
145
+
146
+ export interface DocumentEditorHandle {
147
+ /** Insert an image or figure using a document `src` the app already chose. */
148
+ insertImage(options: { src: string; alt?: string; caption?: string }): void
149
+ /** Insert arbitrary HTML at the cursor. */
150
+ insertHtml(html: string): void
151
+ /** Open an existing annotation by id; only scroll when explicitly requested. */
152
+ openComment(
153
+ commentId: string,
154
+ options?: { scrollIntoView?: boolean },
155
+ ): boolean
156
+ /** Update an existing annotation mark without changing the selected manuscript text. */
157
+ updateComment(commentId: string, patch: CommentMarkAttrPatch): boolean
158
+ /** Accept a suggested edit by replacing its anchored text and leaving an applied-change trace. */
159
+ applyCommentReplacement(
160
+ commentId: string,
161
+ replacementText: string,
162
+ attrs: {
163
+ originalText: string
164
+ sourceType?: string
165
+ actor?: string
166
+ createdAt?: string
167
+ },
168
+ ): boolean
169
+ /** Remove an annotation mark without changing the selected manuscript text. */
170
+ removeComment(commentId: string): boolean
171
+ /** Remove every annotation mark from the current document. */
172
+ clearComments(): number
173
+ /** Run first-pass editorial review and optionally open annotations as they are added. */
174
+ runAutoReview(
175
+ options?: RunAutoReviewOptions & {
176
+ readAlong?: boolean
177
+ readAlongDelayMs?: number
178
+ },
179
+ ): Promise<RunAutoReviewResult>
180
+ getEditor(): Editor | null
181
+ }
182
+
183
+ export interface DocumentEditorProps {
184
+ value: string
185
+ extensions: Extensions
186
+ inputFormat?: EditorInputFormat
187
+ outputFormat?: 'html' | 'markdown'
188
+ onChange?: (content: string) => void
189
+ /** Enables toolbar comment marks (in-document only; no host integration). */
190
+ enableComments?: boolean
191
+ /**
192
+ * Show the floating comment bubble on selection. When false, commenting is
193
+ * still available through the slash menu — used by the quiet page view.
194
+ */
195
+ showSelectionComments?: boolean
196
+ showToolbar?: boolean
197
+ /**
198
+ * Chrome level. `'page'` is the quiet/printed view: the toolbar and the
199
+ * selection comment bubble are hidden and the surface renders as a centered
200
+ * page. Defaults to `'boxed'` (full chrome) — existing callers are unchanged.
201
+ */
202
+ viewMode?: 'boxed' | 'page'
203
+ toolbarActions?: ReactNode
204
+ className?: string
205
+ /** Link/comment prompt dialogs — supply from platform-ui in apps. */
206
+ toolbarOverlays?: EditorToolbarOverlays
207
+ /** Controls what appears behind the shared toolbar's three-dot overflow. */
208
+ toolbarAdvancedTools?: 'full' | 'formatting'
209
+ inEditorTopElement?: ReactNode
210
+ inEditorBottomElement?: ReactNode
211
+ /** App-specific slash commands appended to the shared editor commands. */
212
+ slashCommands?: SlashCommandItem[]
213
+ }
214
+
215
+ /**
216
+ * Environment-agnostic TipTap document surface.
217
+ * Apps own load/save, asset URLs, paste/drop persistence, and shell integration.
218
+ */
219
+ export const DocumentEditor = forwardRef<
220
+ DocumentEditorHandle,
221
+ DocumentEditorProps
222
+ >(function DocumentEditor(
223
+ {
224
+ value,
225
+ extensions,
226
+ inputFormat = 'html',
227
+ outputFormat = 'html',
228
+ onChange,
229
+ enableComments = false,
230
+ showSelectionComments = true,
231
+ showToolbar = true,
232
+ viewMode = 'boxed',
233
+ toolbarActions,
234
+ className,
235
+ toolbarOverlays,
236
+ toolbarAdvancedTools = 'full',
237
+ inEditorTopElement = null,
238
+ inEditorBottomElement = null,
239
+ slashCommands = [],
240
+ },
241
+ ref,
242
+ ): React.ReactElement {
243
+ const pageMode = viewMode === 'page'
244
+ // In the quiet page view the toolbar and selection bubble are hidden;
245
+ // commenting stays available through the slash menu.
246
+ const toolbarVisible = showToolbar && !pageMode
247
+ const selectionCommentsActive = showSelectionComments && !pageMode
248
+ const onChangeRef = useRef(onChange)
249
+ onChangeRef.current = onChange
250
+ const outputFormatRef = useRef(outputFormat)
251
+ outputFormatRef.current = outputFormat
252
+ const commentCardRef = useRef<HTMLDivElement>(null)
253
+ const editorViewportRef = useRef<HTMLDivElement>(null)
254
+ const annotationButtonRef = useRef<HTMLButtonElement>(null)
255
+ const inlineCommentDraftRef = useRef<HTMLFormElement>(null)
256
+ const inlineCommentTextRef = useRef<HTMLTextAreaElement>(null)
257
+ const scrollSnapshotRef = useRef<ScrollSnapshot | null>(null)
258
+ const freshCommentTimerRef = useRef<number | null>(null)
259
+ const [activeComment, setActiveComment] = useState<ActiveComment | null>(null)
260
+ const [replyText, setReplyText] = useState('')
261
+ const [selectionBubble, setSelectionBubble] =
262
+ useState<SelectionBubbleState | null>(null)
263
+ const [inlineCommentDraft, setInlineCommentDraft] =
264
+ useState<InlineCommentDraft | null>(null)
265
+ const [lastCommentType, setLastCommentType] = useState<CommentType>('general')
266
+ const [reviewRailItems, setReviewRailItems] = useState<ReviewRailItem[]>([])
267
+ const [commentNavigationIds, setCommentNavigationIds] = useState<string[]>([])
268
+ const [reviewRailScrolling, setReviewRailScrolling] = useState(false)
269
+ const [slashMenuOpen, setSlashMenuOpen] = useState(false)
270
+ const [freshCommentId, setFreshCommentId] = useState<string | null>(null)
271
+ const openCommentDraftRef = useRef<
272
+ (range: { from: number; to: number }, commentType: CommentType) => void
273
+ >(() => {})
274
+ const annotationRailOpen = Boolean(activeComment || inlineCommentDraft)
275
+ const [annotationRailTop, setAnnotationRailTop] = useState(0)
276
+
277
+ const resolveDisplayContent = useCallback(
278
+ (nextValue: string): string => resolveEditorContent(nextValue, inputFormat),
279
+ [inputFormat],
280
+ )
281
+ const lastValueRef = useRef(resolveDisplayContent(value))
282
+
283
+ // Comment operations live inside the slash menu too, so opening it on a
284
+ // selection gives one menu with both formatting and commenting.
285
+ const commentSlashItems = useMemo<SlashCommandItem[]>(() => {
286
+ if (!enableComments) return []
287
+ return [...PRIMARY_COMMENT_TYPES, ...SECONDARY_COMMENT_TYPES].map(type => ({
288
+ id: `comment-${type}`,
289
+ title: COMMENT_TYPE_LABELS[type],
290
+ description: 'Add a comment to the selection',
291
+ section: 'Comments',
292
+ run: ({ range }: { range: { from: number; to: number } }) =>
293
+ openCommentDraftRef.current(range, type),
294
+ }))
295
+ }, [enableComments])
296
+
297
+ const editorExtensions = useMemo(
298
+ () => [
299
+ ...extensions,
300
+ SlashCommands.configure({
301
+ items: [
302
+ ...commentSlashItems,
303
+ ...createDefaultSlashCommands(),
304
+ ...slashCommands,
305
+ ],
306
+ }),
307
+ ],
308
+ [commentSlashItems, extensions, slashCommands],
309
+ )
310
+
311
+ const editor = useEditor({
312
+ extensions: editorExtensions,
313
+ content: lastValueRef.current,
314
+ onUpdate: ({ editor: updatedEditor }) => {
315
+ const html = updatedEditor.getHTML()
316
+ lastValueRef.current = html
317
+ onChangeRef.current?.(
318
+ outputFormatRef.current === 'markdown' ? toMd(html) : html,
319
+ )
320
+ },
321
+ })
322
+
323
+ useEffect(() => {
324
+ if (!editor || editor.isDestroyed) return
325
+ const resolved = resolveDisplayContent(value)
326
+ if (resolved === lastValueRef.current) return
327
+ lastValueRef.current = resolved
328
+ editor.commands.setContent(resolved, { emitUpdate: false })
329
+ if (inputFormat === 'markdown') {
330
+ migrateMathStrings(editor)
331
+ }
332
+ }, [editor, inputFormat, resolveDisplayContent, value])
333
+
334
+ useEffect(() => {
335
+ if (!editor || editor.isDestroyed || inputFormat !== 'markdown') return
336
+ migrateMathStrings(editor)
337
+ }, [editor, inputFormat])
338
+
339
+ useEffect(() => {
340
+ const syncAnnotationRailTop = (): void => {
341
+ setAnnotationRailTop(
342
+ Math.max(
343
+ 0,
344
+ editorViewportRef.current?.getBoundingClientRect().top ?? 0,
345
+ ),
346
+ )
347
+ }
348
+ syncAnnotationRailTop()
349
+ window.addEventListener('resize', syncAnnotationRailTop)
350
+ return () => window.removeEventListener('resize', syncAnnotationRailTop)
351
+ }, [])
352
+
353
+ useEffect(() => {
354
+ if (!editor || editor.isDestroyed || !enableComments) return
355
+ if (!selectionCommentsActive) {
356
+ setSelectionBubble(null)
357
+ return
358
+ }
359
+ const clamp = (value: number, min: number, max: number): number =>
360
+ Math.max(min, Math.min(value, max))
361
+ const syncSelectionBubble = (): void => {
362
+ if (slashMenuOpen) {
363
+ setSelectionBubble(null)
364
+ return
365
+ }
366
+ if (inlineCommentDraft) {
367
+ const { from, to } = editor.state.selection
368
+ if (from !== inlineCommentDraft.from || to !== inlineCommentDraft.to) {
369
+ setInlineCommentDraft(null)
370
+ }
371
+ setSelectionBubble(null)
372
+ return
373
+ }
374
+ const { from, to, empty } = editor.state.selection
375
+ if (
376
+ empty ||
377
+ from === to ||
378
+ !editor.isEditable ||
379
+ !isSelectionAnnotatable(editor, from, to)
380
+ ) {
381
+ setSelectionBubble(null)
382
+ return
383
+ }
384
+ try {
385
+ const start = editor.view.coordsAtPos(from)
386
+ const end = editor.view.coordsAtPos(to)
387
+ const x = clamp(start.left - 34, 12, window.innerWidth - 44)
388
+ const y = clamp(
389
+ (start.top + end.bottom) / 2,
390
+ 52,
391
+ window.innerHeight - 52,
392
+ )
393
+ setSelectionBubble({
394
+ from,
395
+ to,
396
+ x,
397
+ y,
398
+ selectedText: editor.state.doc.textBetween(from, to, ' '),
399
+ })
400
+ } catch {
401
+ setSelectionBubble(null)
402
+ }
403
+ }
404
+ syncSelectionBubble()
405
+ editor.on('selectionUpdate', syncSelectionBubble)
406
+ editor.on('transaction', syncSelectionBubble)
407
+ window.addEventListener('scroll', syncSelectionBubble, true)
408
+ window.addEventListener('resize', syncSelectionBubble)
409
+ return () => {
410
+ editor.off('selectionUpdate', syncSelectionBubble)
411
+ editor.off('transaction', syncSelectionBubble)
412
+ window.removeEventListener('scroll', syncSelectionBubble, true)
413
+ window.removeEventListener('resize', syncSelectionBubble)
414
+ }
415
+ }, [editor, enableComments, inlineCommentDraft, selectionCommentsActive, slashMenuOpen])
416
+
417
+ // Track the slash menu's open state so the comment bubble can step aside —
418
+ // when you open the slash menu on a selection it becomes the single menu.
419
+ useEffect(() => {
420
+ if (!editor || editor.isDestroyed) return
421
+ const dom = editor.view.dom
422
+ const onToggle = (event: Event): void => {
423
+ const open = (event as CustomEvent<{ open: boolean }>).detail?.open
424
+ setSlashMenuOpen(Boolean(open))
425
+ }
426
+ dom.addEventListener('ps-slash-menu-toggle', onToggle)
427
+ return () => dom.removeEventListener('ps-slash-menu-toggle', onToggle)
428
+ }, [editor])
429
+
430
+ useEffect(() => {
431
+ if (!inlineCommentDraft) return
432
+ window.requestAnimationFrame(() => {
433
+ inlineCommentTextRef.current?.focus({ preventScroll: true })
434
+ restoreScrollSnapshot(scrollSnapshotRef.current)
435
+ })
436
+ }, [inlineCommentDraft])
437
+
438
+ const highlightFreshComment = useCallback((commentId: string): void => {
439
+ setFreshCommentId(commentId)
440
+ if (freshCommentTimerRef.current) {
441
+ window.clearTimeout(freshCommentTimerRef.current)
442
+ }
443
+ freshCommentTimerRef.current = window.setTimeout(() => {
444
+ setFreshCommentId(current => (current === commentId ? null : current))
445
+ freshCommentTimerRef.current = null
446
+ }, FRESH_COMMENT_HIGHLIGHT_MS)
447
+ }, [])
448
+
449
+ useEffect(
450
+ () => () => {
451
+ if (freshCommentTimerRef.current) {
452
+ window.clearTimeout(freshCommentTimerRef.current)
453
+ }
454
+ },
455
+ [],
456
+ )
457
+
458
+ useEffect(() => {
459
+ if (!enableComments) return
460
+ const onPointerDown = (event: PointerEvent): void => {
461
+ const target = event.target as Node
462
+ if (annotationButtonRef.current?.contains(target)) return
463
+ if (inlineCommentDraftRef.current?.contains(target)) return
464
+ setSelectionBubble(null)
465
+ setInlineCommentDraft(draft => {
466
+ if (!draft || draft.text.trim()) return draft
467
+ return null
468
+ })
469
+ }
470
+ const onKeyDown = (event: KeyboardEvent): void => {
471
+ if (event.key !== 'Escape') return
472
+ setSelectionBubble(null)
473
+ setInlineCommentDraft(null)
474
+ }
475
+ document.addEventListener('pointerdown', onPointerDown)
476
+ document.addEventListener('keydown', onKeyDown)
477
+ return () => {
478
+ document.removeEventListener('pointerdown', onPointerDown)
479
+ document.removeEventListener('keydown', onKeyDown)
480
+ }
481
+ }, [enableComments])
482
+
483
+ useEffect(() => {
484
+ if (!editor || editor.isDestroyed || !enableComments) return
485
+ const editorElement = editor.view.dom
486
+ const openComment = (target: HTMLElement): void => {
487
+ const commentElement = target.closest<HTMLElement>(
488
+ 'span[data-comment-id]',
489
+ )
490
+ if (!commentElement) return
491
+ const commentType = normalizeCommentType(
492
+ commentElement.dataset.commentType,
493
+ )
494
+ const subjectText =
495
+ commentElement.dataset.commentSubject?.trim() ||
496
+ commentElement.textContent?.trim() ||
497
+ ''
498
+ setActiveComment({
499
+ id: commentElement.dataset.commentId ?? '',
500
+ text: commentElement.dataset.commentText ?? '',
501
+ savedText: commentElement.dataset.commentText ?? '',
502
+ commentType,
503
+ savedCommentType: commentType,
504
+ subjectText,
505
+ reviewStatus: normalizeCommentReviewStatus(
506
+ commentElement.dataset.commentReviewStatus,
507
+ commentType,
508
+ ),
509
+ savedReviewStatus: normalizeCommentReviewStatus(
510
+ commentElement.dataset.commentReviewStatus,
511
+ commentType,
512
+ ),
513
+ severity: normalizeCommentSeverity(
514
+ commentElement.dataset.commentSeverity,
515
+ commentType,
516
+ ),
517
+ author: commentElement.dataset.commentAuthor ?? undefined,
518
+ createdAt: commentElement.dataset.commentCreatedAt ?? undefined,
519
+ replies: parseCommentReplies(commentElement.dataset.commentReplies),
520
+ resolvedAt: commentElement.dataset.commentResolvedAt ?? undefined,
521
+ resolvedBy: commentElement.dataset.commentResolvedBy ?? undefined,
522
+ x: panelX(),
523
+ y: panelY(),
524
+ })
525
+ setReplyText('')
526
+ }
527
+ const onEditorPointerDown = (event: PointerEvent): void => {
528
+ const target = event.target as HTMLElement | null
529
+ if (!target) return
530
+ if (!target.closest('span[data-comment-id]')) return
531
+ event.preventDefault()
532
+ openComment(target)
533
+ }
534
+ const onKeyDown = (event: KeyboardEvent): void => {
535
+ if (event.key === 'Escape') setActiveComment(null)
536
+ }
537
+ editorElement.addEventListener('pointerdown', onEditorPointerDown)
538
+ document.addEventListener('keydown', onKeyDown)
539
+ return () => {
540
+ editorElement.removeEventListener('pointerdown', onEditorPointerDown)
541
+ document.removeEventListener('keydown', onKeyDown)
542
+ }
543
+ }, [editor, enableComments])
544
+
545
+ useEffect(() => {
546
+ if (!editor || editor.isDestroyed || !enableComments) {
547
+ setReviewRailItems([])
548
+ setCommentNavigationIds([])
549
+ setReviewRailScrolling(false)
550
+ return
551
+ }
552
+ const editorElement = editor.view.dom
553
+ let frame = 0
554
+ let scrollIdleTimer = 0
555
+ let railHiddenForScroll = false
556
+ const syncReviewRail = (): void => {
557
+ frame = 0
558
+ const editorRect = editorElement.getBoundingClientRect()
559
+ const viewportRect =
560
+ editorViewportRef.current?.getBoundingClientRect() ?? null
561
+ const railLeft = annotationRailOpen
562
+ ? window.innerWidth - ANNOTATION_RAIL_WIDTH
563
+ : window.innerWidth
564
+ const viewportTop = Math.max(0, (viewportRect?.top ?? 0) + 8)
565
+ const viewportBottom = Math.min(
566
+ window.innerHeight,
567
+ viewportRect?.bottom ?? window.innerHeight,
568
+ )
569
+ const byId = new Map<string, ReviewRailItem>()
570
+ const navigationIds: string[] = []
571
+ const marks = Array.from(
572
+ editorElement.querySelectorAll<HTMLElement>(REVIEW_RAIL_SELECTOR),
573
+ )
574
+ for (const mark of marks) {
575
+ const id = mark.dataset.commentId
576
+ const type = normalizeCommentType(mark.dataset.commentType)
577
+ if (!id || !isReviewRailCommentType(type) || byId.has(id))
578
+ continue
579
+ const rect = mark.getBoundingClientRect()
580
+ const markCenter = rect.top + rect.height / 2
581
+ if (rect.width === 0 && rect.height === 0) continue
582
+ if (markCenter < viewportTop || markCenter > viewportBottom) continue
583
+ if (!navigationIds.includes(id)) navigationIds.push(id)
584
+ byId.set(id, {
585
+ id,
586
+ type,
587
+ status: normalizeCommentReviewStatus(
588
+ mark.dataset.commentReviewStatus,
589
+ type,
590
+ ),
591
+ resolved: Boolean(mark.dataset.commentResolvedAt),
592
+ top: markCenter,
593
+ left: Math.max(10, Math.min(editorRect.right + 10, railLeft - 118)),
594
+ })
595
+ if (byId.size >= MAX_VISIBLE_REVIEW_RAIL_ITEMS) break
596
+ }
597
+ setCommentNavigationIds(navigationIds)
598
+ setReviewRailItems(packReviewRailItems(Array.from(byId.values())))
599
+ }
600
+ const scheduleReviewRailSync = (): void => {
601
+ if (frame) return
602
+ frame = window.requestAnimationFrame(syncReviewRail)
603
+ }
604
+ const onScroll = (): void => {
605
+ if (!railHiddenForScroll) {
606
+ railHiddenForScroll = true
607
+ setReviewRailScrolling(true)
608
+ }
609
+ if (scrollIdleTimer) window.clearTimeout(scrollIdleTimer)
610
+ scrollIdleTimer = window.setTimeout(() => {
611
+ railHiddenForScroll = false
612
+ setReviewRailScrolling(false)
613
+ scheduleReviewRailSync()
614
+ }, REVIEW_RAIL_SCROLL_IDLE_MS)
615
+ }
616
+ scheduleReviewRailSync()
617
+ editor.on('transaction', scheduleReviewRailSync)
618
+ window.addEventListener('scroll', onScroll, true)
619
+ window.addEventListener('resize', scheduleReviewRailSync)
620
+ return () => {
621
+ if (frame) window.cancelAnimationFrame(frame)
622
+ if (scrollIdleTimer) window.clearTimeout(scrollIdleTimer)
623
+ editor.off('transaction', scheduleReviewRailSync)
624
+ window.removeEventListener('scroll', onScroll, true)
625
+ window.removeEventListener('resize', scheduleReviewRailSync)
626
+ }
627
+ }, [annotationRailOpen, editor, enableComments])
628
+
629
+ useEffect(() => {
630
+ if (!editor || editor.isDestroyed || !enableComments) return
631
+ const spotlightCommentId = freshCommentId ?? activeComment?.id ?? null
632
+ const marks = Array.from(
633
+ editor.view.dom.querySelectorAll<HTMLElement>('span[data-comment-id]'),
634
+ )
635
+ for (const mark of marks) {
636
+ const isSpotlight = Boolean(
637
+ spotlightCommentId && mark.dataset.commentId === spotlightCommentId,
638
+ )
639
+ const isSpotlightMuted = Boolean(spotlightCommentId && !isSpotlight)
640
+ mark.classList.toggle(
641
+ 'comment-mark--active',
642
+ Boolean(activeComment && mark.dataset.commentId === activeComment.id),
643
+ )
644
+ mark.classList.toggle('comment-mark--spotlight', isSpotlight)
645
+ mark.classList.toggle('comment-mark--spotlight-muted', isSpotlightMuted)
646
+ }
647
+ return () => {
648
+ for (const mark of marks) {
649
+ mark.classList.remove(
650
+ 'comment-mark--active',
651
+ 'comment-mark--spotlight',
652
+ 'comment-mark--spotlight-muted',
653
+ )
654
+ }
655
+ }
656
+ }, [activeComment, editor, enableComments, freshCommentId])
657
+
658
+ const updateActiveComment = useCallback(
659
+ (patch: Partial<ActiveComment>): void => {
660
+ setActiveComment(comment =>
661
+ comment ? { ...comment, ...patch } : comment,
662
+ )
663
+ },
664
+ [],
665
+ )
666
+
667
+ const restoreAnnotationScroll = useCallback((): void => {
668
+ const snapshot = scrollSnapshotRef.current
669
+ restoreScrollSnapshot(snapshot)
670
+ window.requestAnimationFrame(() => restoreScrollSnapshot(snapshot))
671
+ }, [])
672
+
673
+ const createCommentMark = useCallback(
674
+ ({
675
+ commentText,
676
+ commentType,
677
+ from,
678
+ to,
679
+ }: {
680
+ commentText: string
681
+ commentType: CommentType
682
+ from: number
683
+ to: number
684
+ }): void => {
685
+ if (!editor || editor.isDestroyed || from === to) return
686
+ const trimmed = commentText.trim()
687
+ if (!trimmed) return
688
+ applyCommentMarkToRange(editor, from, to, {
689
+ ...commentDefaultsForType(commentType),
690
+ commentId:
691
+ globalThis.crypto?.randomUUID?.() ??
692
+ `comment-${Math.random().toString(36).slice(2)}`,
693
+ commentText: trimmed,
694
+ commentType,
695
+ subjectText: editor.state.doc.textBetween(from, to, ' '),
696
+ author: 'Author',
697
+ createdAt: new Date().toISOString(),
698
+ replies: '[]',
699
+ })
700
+ restoreAnnotationScroll()
701
+ setLastCommentType(commentType)
702
+ setSelectionBubble(null)
703
+ },
704
+ [editor, restoreAnnotationScroll],
705
+ )
706
+
707
+ const openInlineCommentDraft = useCallback(
708
+ (commentType = lastCommentType): void => {
709
+ const selection = selectionBubble
710
+ if (!selection || !editor || editor.isDestroyed) return
711
+ scrollSnapshotRef.current = captureScrollSnapshot(editor.view.dom)
712
+ setLastCommentType(commentType)
713
+ setSelectionBubble(null)
714
+ setInlineCommentDraft({
715
+ ...selection,
716
+ x: panelX(),
717
+ y: panelY(),
718
+ text: '',
719
+ commentType,
720
+ })
721
+ },
722
+ [editor, lastCommentType, selectionBubble],
723
+ )
724
+
725
+ // Open a comment draft for an explicit range — used by the slash menu's
726
+ // comment commands, which carry the selection range rather than the bubble.
727
+ const openCommentDraftForRange = useCallback(
728
+ (range: { from: number; to: number }, commentType: CommentType): void => {
729
+ if (!editor || editor.isDestroyed) return
730
+ const { from, to } = range
731
+ if (from === to) return
732
+ const clamp = (value: number, min: number, max: number): number =>
733
+ Math.max(min, Math.min(value, max))
734
+ try {
735
+ const start = editor.view.coordsAtPos(from)
736
+ const end = editor.view.coordsAtPos(to)
737
+ const x = clamp(
738
+ (start.left + end.right) / 2,
739
+ 180,
740
+ window.innerWidth - 180,
741
+ )
742
+ const y = clamp(
743
+ Math.min(start.top, end.top) + 28,
744
+ 56,
745
+ window.innerHeight - 180,
746
+ )
747
+ const selectedText = editor.state.doc.textBetween(from, to, ' ').trim()
748
+ setLastCommentType(commentType)
749
+ setSelectionBubble(null)
750
+ setInlineCommentDraft({
751
+ from,
752
+ to,
753
+ x,
754
+ y,
755
+ text: '',
756
+ commentType,
757
+ selectedText,
758
+ })
759
+ } catch {
760
+ /* position unavailable — skip */
761
+ }
762
+ },
763
+ [editor],
764
+ )
765
+ openCommentDraftRef.current = openCommentDraftForRange
766
+
767
+ const confirmInlineCommentDraft = useCallback((): void => {
768
+ if (!inlineCommentDraft || !editor || editor.isDestroyed) return
769
+ scrollSnapshotRef.current =
770
+ scrollSnapshotRef.current ?? captureScrollSnapshot(editor.view.dom)
771
+ createCommentMark({
772
+ commentText: inlineCommentDraft.text,
773
+ commentType: inlineCommentDraft.commentType,
774
+ from: inlineCommentDraft.from,
775
+ to: inlineCommentDraft.to,
776
+ })
777
+ setInlineCommentDraft(null)
778
+ restoreAnnotationScroll()
779
+ }, [createCommentMark, editor, inlineCommentDraft])
780
+
781
+ const addReplyToActiveComment = useCallback((): void => {
782
+ if (!editor || editor.isDestroyed || !activeComment) return
783
+ const text = replyText.trim()
784
+ if (!text) return
785
+ const replies = [
786
+ ...activeComment.replies,
787
+ {
788
+ id:
789
+ globalThis.crypto?.randomUUID?.() ??
790
+ `reply-${Math.random().toString(36).slice(2)}`,
791
+ text,
792
+ author: 'Author',
793
+ createdAt: new Date().toISOString(),
794
+ },
795
+ ]
796
+ if (
797
+ updateCommentMarksById(editor, activeComment.id, {
798
+ replies: serializeCommentReplies(replies),
799
+ })
800
+ ) {
801
+ restoreAnnotationScroll()
802
+ updateActiveComment({ replies })
803
+ setReplyText('')
804
+ }
805
+ }, [activeComment, editor, replyText, updateActiveComment])
806
+
807
+ const setActiveCommentResolved = useCallback(
808
+ (resolved: boolean): void => {
809
+ if (!editor || editor.isDestroyed || !activeComment) return
810
+ const resolvedAt = resolved ? new Date().toISOString() : null
811
+ const patch = resolved
812
+ ? { resolvedAt: resolvedAt ?? undefined, resolvedBy: 'Author' }
813
+ : { resolvedAt: null, resolvedBy: null }
814
+ if (updateCommentMarksById(editor, activeComment.id, patch)) {
815
+ restoreAnnotationScroll()
816
+ updateActiveComment({
817
+ resolvedAt: resolvedAt ?? undefined,
818
+ resolvedBy: resolved ? 'Author' : undefined,
819
+ })
820
+ }
821
+ },
822
+ [activeComment, editor, restoreAnnotationScroll, updateActiveComment],
823
+ )
824
+
825
+ const setActiveCommentReviewOutcome = useCallback(
826
+ (reviewStatus: CommentReviewStatus, resolved: boolean): void => {
827
+ if (!editor || editor.isDestroyed || !activeComment) return
828
+ const resolvedAt = resolved ? new Date().toISOString() : null
829
+ if (
830
+ updateCommentMarksById(editor, activeComment.id, {
831
+ reviewStatus,
832
+ resolvedAt: resolvedAt ?? null,
833
+ resolvedBy: resolved ? 'Author' : null,
834
+ })
835
+ ) {
836
+ restoreAnnotationScroll()
837
+ updateActiveComment({
838
+ reviewStatus,
839
+ savedReviewStatus: reviewStatus,
840
+ resolvedAt: resolvedAt ?? undefined,
841
+ resolvedBy: resolved ? 'Author' : undefined,
842
+ })
843
+ }
844
+ },
845
+ [activeComment, editor, restoreAnnotationScroll, updateActiveComment],
846
+ )
847
+
848
+ const saveActiveCommentEdits = useCallback((): void => {
849
+ if (!editor || editor.isDestroyed || !activeComment) return
850
+ const text = activeComment.text.trim()
851
+ if (!text) return
852
+ if (
853
+ updateCommentMarksById(editor, activeComment.id, {
854
+ commentText: text,
855
+ commentType: activeComment.commentType,
856
+ reviewStatus: activeComment.reviewStatus,
857
+ severity: activeComment.severity,
858
+ })
859
+ ) {
860
+ restoreAnnotationScroll()
861
+ updateActiveComment({
862
+ text,
863
+ savedText: text,
864
+ savedCommentType: activeComment.commentType,
865
+ savedReviewStatus: activeComment.reviewStatus,
866
+ })
867
+ }
868
+ }, [activeComment, editor, updateActiveComment])
869
+
870
+ const deleteActiveComment = useCallback((): void => {
871
+ if (!editor || editor.isDestroyed || !activeComment) return
872
+ if (removeCommentMarksById(editor, activeComment.id)) {
873
+ restoreAnnotationScroll()
874
+ setActiveComment(null)
875
+ setFreshCommentId(null)
876
+ setReplyText('')
877
+ }
878
+ }, [activeComment, editor])
879
+
880
+ const clearAllComments = useCallback((): void => {
881
+ if (!editor || editor.isDestroyed) return
882
+ const confirmed = window.confirm(
883
+ 'Clear all annotations in this document? This removes comment, claim, fact-check, formatting, and question marks from the manuscript.',
884
+ )
885
+ if (!confirmed) return
886
+ const removed = removeAllCommentMarks(editor, {
887
+ scrollIntoView: false,
888
+ focusEditor: false,
889
+ })
890
+ if (removed) {
891
+ restoreAnnotationScroll()
892
+ setActiveComment(null)
893
+ setFreshCommentId(null)
894
+ setInlineCommentDraft(null)
895
+ setReplyText('')
896
+ setReviewRailItems([])
897
+ setCommentNavigationIds([])
898
+ }
899
+ }, [editor, restoreAnnotationScroll])
900
+
901
+ const cancelInlineCommentDraft = useCallback((): void => {
902
+ setInlineCommentDraft(null)
903
+ restoreAnnotationScroll()
904
+ }, [restoreAnnotationScroll])
905
+
906
+ const openReviewRailItem = useCallback(
907
+ (commentId: string, options: { scrollIntoView?: boolean } = {}): void => {
908
+ if (!editor || editor.isDestroyed) return
909
+ const mark = editor.view.dom.querySelector<HTMLElement>(
910
+ `span[data-comment-id="${cssEscape(commentId)}"]`,
911
+ )
912
+ if (!mark) return
913
+ if (options.scrollIntoView) {
914
+ mark.scrollIntoView({ block: 'center', behavior: 'smooth' })
915
+ }
916
+ const event =
917
+ typeof PointerEvent === 'function'
918
+ ? new PointerEvent('pointerdown', { bubbles: true, cancelable: true })
919
+ : new MouseEvent('pointerdown', { bubbles: true, cancelable: true })
920
+ mark.dispatchEvent(event)
921
+ },
922
+ [editor],
923
+ )
924
+
925
+ const openAdjacentComment = useCallback(
926
+ (direction: -1 | 1): void => {
927
+ if (!activeComment || commentNavigationIds.length < 2) return
928
+ const currentIndex = commentNavigationIds.indexOf(activeComment.id)
929
+ if (currentIndex === -1) return
930
+ const nextIndex =
931
+ (currentIndex + direction + commentNavigationIds.length) %
932
+ commentNavigationIds.length
933
+ openReviewRailItem(commentNavigationIds[nextIndex], {
934
+ scrollIntoView: true,
935
+ })
936
+ },
937
+ [activeComment, commentNavigationIds, openReviewRailItem],
938
+ )
939
+
940
+ const activeCommentChanged = Boolean(
941
+ activeComment &&
942
+ (activeComment.text.trim() !== activeComment.savedText ||
943
+ activeComment.commentType !== activeComment.savedCommentType ||
944
+ activeComment.reviewStatus !== activeComment.savedReviewStatus),
945
+ )
946
+
947
+ const providerValue = useMemo(() => ({ editor }), [editor])
948
+
949
+ const insertImage = useCallback(
950
+ ({
951
+ src,
952
+ alt = '',
953
+ caption = '',
954
+ }: {
955
+ src: string
956
+ alt?: string
957
+ caption?: string
958
+ }) => {
959
+ if (!editor || editor.isDestroyed) return
960
+ const stem = alt.trim() || 'image'
961
+ const commands = editor.chain().focus() as unknown as {
962
+ insertFigure?: (opts: {
963
+ src: string
964
+ alt?: string
965
+ caption?: string
966
+ }) => { run: () => void }
967
+ }
968
+ if (typeof commands.insertFigure === 'function') {
969
+ commands.insertFigure({ src, alt: stem, caption }).run()
970
+ return
971
+ }
972
+ editor.chain().focus().setImage({ src, alt: stem }).run()
973
+ },
974
+ [editor, restoreAnnotationScroll],
975
+ )
976
+
977
+ const insertHtml = useCallback(
978
+ (html: string) => {
979
+ if (!editor || editor.isDestroyed) return
980
+ editor.chain().focus().insertContent(html).run()
981
+ },
982
+ [editor],
983
+ )
984
+
985
+ useImperativeHandle(
986
+ ref,
987
+ () => ({
988
+ insertImage,
989
+ insertHtml,
990
+ openComment: (commentId, options) => {
991
+ const current = editor && !editor.isDestroyed ? editor : null
992
+ if (!current) return false
993
+ const mark = current.view.dom.querySelector<HTMLElement>(
994
+ `span[data-comment-id="${cssEscape(commentId)}"]`,
995
+ )
996
+ if (!mark) return false
997
+ openReviewRailItem(commentId, options)
998
+ return true
999
+ },
1000
+ updateComment: (commentId, patch) => {
1001
+ const current = editor && !editor.isDestroyed ? editor : null
1002
+ if (!current) return false
1003
+ return updateCommentMarksById(current, commentId, patch)
1004
+ },
1005
+ applyCommentReplacement: (commentId, replacementText, attrs) => {
1006
+ const current = editor && !editor.isDestroyed ? editor : null
1007
+ if (!current) return false
1008
+ return applyCommentReplacementById(
1009
+ current,
1010
+ commentId,
1011
+ replacementText,
1012
+ {
1013
+ ...attrs,
1014
+ sourceCommentId: commentId,
1015
+ },
1016
+ )
1017
+ },
1018
+ removeComment: commentId => {
1019
+ const current = editor && !editor.isDestroyed ? editor : null
1020
+ if (!current) return false
1021
+ return removeCommentMarksById(current, commentId)
1022
+ },
1023
+ clearComments: () => {
1024
+ const current = editor && !editor.isDestroyed ? editor : null
1025
+ if (!current) return 0
1026
+ let removed = removeAllCommentMarks(current)
1027
+ if (current.getHTML().includes('data-comment-id')) {
1028
+ const stripped = stripCommentMarkupFromHtml(current.getHTML())
1029
+ if (stripped.removed) {
1030
+ removed = Math.max(removed, stripped.removed)
1031
+ current.commands.setContent(stripped.html, { emitUpdate: true })
1032
+ }
1033
+ }
1034
+ if (removed) {
1035
+ setActiveComment(null)
1036
+ setFreshCommentId(null)
1037
+ setInlineCommentDraft(null)
1038
+ setReplyText('')
1039
+ setReviewRailItems([])
1040
+ setCommentNavigationIds([])
1041
+ }
1042
+ return removed
1043
+ },
1044
+ runAutoReview: async options => {
1045
+ const current = editor && !editor.isDestroyed ? editor : null
1046
+ if (!current) {
1047
+ return { paragraphCount: 0, findingCount: 0, applied: [], skipped: 0 }
1048
+ }
1049
+ if (!options?.readAlong) {
1050
+ return await runEditorAutoReview(current, options)
1051
+ }
1052
+ const paragraphs = collectScopedAutoReviewParagraphs(
1053
+ current,
1054
+ options.scope,
1055
+ )
1056
+ if (!options.findingsProvider) {
1057
+ return {
1058
+ paragraphCount: paragraphs.length,
1059
+ findingCount: 0,
1060
+ applied: [],
1061
+ skipped: 0,
1062
+ unavailableReason: 'Editorial review provider is not configured.',
1063
+ }
1064
+ }
1065
+ options.onProgress?.({
1066
+ paragraphCount: paragraphs.length,
1067
+ findingCount: 0,
1068
+ applied: [],
1069
+ skipped: 0,
1070
+ })
1071
+ const applied: RunAutoReviewResult['applied'] = []
1072
+ let skipped = 0
1073
+ let findingCount = 0
1074
+ let providerDone = false
1075
+ let providerError: unknown = null
1076
+ const queue: AutoReviewFindingInput[] = []
1077
+ let notifyQueue: (() => void) | null = null
1078
+ const wakeQueue = (): void => {
1079
+ notifyQueue?.()
1080
+ notifyQueue = null
1081
+ }
1082
+ const waitForQueue = (): Promise<void> =>
1083
+ new Promise(resolve => {
1084
+ notifyQueue = resolve
1085
+ })
1086
+
1087
+ void runProgressiveAutoReviewFindings(paragraphs, options, findings => {
1088
+ const remaining =
1089
+ typeof options.maxFindings === 'number'
1090
+ ? Math.max(0, options.maxFindings - findingCount)
1091
+ : findings.length
1092
+ if (!remaining) return
1093
+ const nextFindings = findings.slice(0, remaining)
1094
+ findingCount += nextFindings.length
1095
+ queue.push(...nextFindings)
1096
+ options.onProgress?.({
1097
+ paragraphCount: paragraphs.length,
1098
+ findingCount,
1099
+ applied: [...applied],
1100
+ skipped,
1101
+ })
1102
+ wakeQueue()
1103
+ })
1104
+ .catch(cause => {
1105
+ providerError = cause
1106
+ })
1107
+ .finally(() => {
1108
+ providerDone = true
1109
+ wakeQueue()
1110
+ })
1111
+
1112
+ while (!providerDone || queue.length) {
1113
+ if (options.signal?.aborted) break
1114
+ const finding = queue.shift()
1115
+ if (!finding) {
1116
+ await waitForQueue()
1117
+ continue
1118
+ }
1119
+ const result = applyAutoReviewFindings(
1120
+ current,
1121
+ paragraphs,
1122
+ [finding],
1123
+ {
1124
+ ...options,
1125
+ author: 'Auto review',
1126
+ idPrefix: 'auto-review',
1127
+ },
1128
+ )
1129
+ skipped += result.skipped
1130
+ const appliedFinding = result.applied[0]
1131
+ if (appliedFinding) {
1132
+ applied.push(appliedFinding)
1133
+ highlightFreshComment(appliedFinding.commentId)
1134
+ options.onProgress?.({
1135
+ paragraphCount: paragraphs.length,
1136
+ findingCount,
1137
+ applied: [...applied],
1138
+ skipped,
1139
+ })
1140
+ window.requestAnimationFrame(() =>
1141
+ window.requestAnimationFrame(() =>
1142
+ openReviewRailItem(appliedFinding.commentId, {
1143
+ scrollIntoView: true,
1144
+ }),
1145
+ ),
1146
+ )
1147
+ await waitForAutoReviewStep(
1148
+ options.signal,
1149
+ options.readAlongDelayMs,
1150
+ )
1151
+ }
1152
+ }
1153
+ if (providerError && !options.signal?.aborted) throw providerError
1154
+ return {
1155
+ paragraphCount: paragraphs.length,
1156
+ findingCount,
1157
+ applied,
1158
+ skipped,
1159
+ }
1160
+ },
1161
+ getEditor: () => (editor && !editor.isDestroyed ? editor : null),
1162
+ }),
1163
+ [editor, highlightFreshComment, insertHtml, insertImage, openReviewRailItem],
1164
+ )
1165
+
1166
+ const spotlightCommentId = freshCommentId ?? activeComment?.id ?? null
1167
+
1168
+ return (
1169
+ <DocumentEditorRoot className={className} $pageMode={pageMode}>
1170
+ <EditorContext.Provider value={providerValue}>
1171
+ {toolbarVisible && editor ? (
1172
+ <DocumentEditorToolbarWrap>
1173
+ <Toolbar
1174
+ overlays={toolbarOverlays}
1175
+ advancedTools={toolbarAdvancedTools}
1176
+ />
1177
+ {toolbarActions ? (
1178
+ <DocumentEditorToolbarActions>
1179
+ {toolbarActions}
1180
+ </DocumentEditorToolbarActions>
1181
+ ) : null}
1182
+ </DocumentEditorToolbarWrap>
1183
+ ) : null}
1184
+ <DocumentEditorViewport
1185
+ $pageMode={pageMode}
1186
+ ref={editorViewportRef}
1187
+ className={
1188
+ annotationRailOpen
1189
+ ? 'document-editor-viewport--annotation-rail-open'
1190
+ : undefined
1191
+ }
1192
+ style={
1193
+ {
1194
+ '--annotation-rail-top': `${annotationRailTop}px`,
1195
+ } as CSSProperties
1196
+ }
1197
+ >
1198
+ <EditorWrapper $pageMode={pageMode}>
1199
+ {inEditorTopElement}
1200
+
1201
+ <EditorContent editor={editor} />
1202
+ {selectionBubble ? (
1203
+ <MarginAnnotationButton
1204
+ ref={annotationButtonRef}
1205
+ $x={selectionBubble.x}
1206
+ $y={selectionBubble.y}
1207
+ type="button"
1208
+ title="Add annotation"
1209
+ aria-label="Add annotation to selection"
1210
+ onMouseDown={event => event.preventDefault()}
1211
+ onClick={() => openInlineCommentDraft('general')}
1212
+ >
1213
+ <CommentOutlined />
1214
+ </MarginAnnotationButton>
1215
+ ) : null}
1216
+ {reviewRailItems.length ? (
1217
+ <ReviewRailLayer
1218
+ $scrolling={reviewRailScrolling}
1219
+ aria-label="Manuscript review rail"
1220
+ >
1221
+ {reviewRailItems.map(item => (
1222
+ <ReviewRailButton
1223
+ key={item.id}
1224
+ type="button"
1225
+ $top={item.top}
1226
+ $left={item.left}
1227
+ $type={item.type}
1228
+ $active={activeComment?.id === item.id}
1229
+ $spotlight={spotlightCommentId === item.id}
1230
+ $spotlightMuted={Boolean(
1231
+ spotlightCommentId && spotlightCommentId !== item.id,
1232
+ )}
1233
+ $resolved={item.resolved}
1234
+ onClick={() => openReviewRailItem(item.id)}
1235
+ >
1236
+ <ReviewRailLabel>{COMMENT_TYPE_LABELS[item.type]}</ReviewRailLabel>
1237
+ <ReviewRailStatus>
1238
+ {item.resolved
1239
+ ? 'Resolved'
1240
+ : COMMENT_STATUS_LABELS[item.status]}
1241
+ </ReviewRailStatus>
1242
+ </ReviewRailButton>
1243
+ ))}
1244
+ </ReviewRailLayer>
1245
+ ) : null}
1246
+ {inlineCommentDraft ? (
1247
+ <AnnotationPanel
1248
+ ref={inlineCommentDraftRef}
1249
+ $x={inlineCommentDraft.x}
1250
+ $y={inlineCommentDraft.y}
1251
+ $reviewType={reviewPanelType(inlineCommentDraft.commentType)}
1252
+ data-inline-comment-composer
1253
+ role="dialog"
1254
+ aria-label="New annotation"
1255
+ onSubmit={event => {
1256
+ event.preventDefault()
1257
+ confirmInlineCommentDraft()
1258
+ }}
1259
+ >
1260
+ <AnnotationPanelHeader>
1261
+ <AnnotationTitleGroup>
1262
+ <AnnotationPanelKicker>
1263
+ {annotationKicker(inlineCommentDraft.commentType)}
1264
+ </AnnotationPanelKicker>
1265
+ <AnnotationPanelTitle>
1266
+ {newAnnotationTitle(inlineCommentDraft.commentType)}
1267
+ </AnnotationPanelTitle>
1268
+ </AnnotationTitleGroup>
1269
+ <AnnotationIconButton
1270
+ type="button"
1271
+ aria-label="Close annotation panel"
1272
+ onClick={cancelInlineCommentDraft}
1273
+ >
1274
+ <CloseOutlined />
1275
+ </AnnotationIconButton>
1276
+ </AnnotationPanelHeader>
1277
+ <AnnotationBody>
1278
+ <AnnotationQuote>
1279
+ <AnnotationSubjectLabel>
1280
+ {
1281
+ COMMENT_TYPE_DEFINITIONS[inlineCommentDraft.commentType]
1282
+ .subjectLabel
1283
+ }
1284
+ </AnnotationSubjectLabel>
1285
+ {inlineCommentDraft.selectedText}
1286
+ </AnnotationQuote>
1287
+ <AnnotationSectionLabel>
1288
+ Classification
1289
+ </AnnotationSectionLabel>
1290
+ <InlineCommentTypeList aria-label="Primary annotation type">
1291
+ {PRIMARY_COMMENT_TYPES.map(type => (
1292
+ <InlineCommentTypeButton
1293
+ key={type}
1294
+ type="button"
1295
+ $active={inlineCommentDraft.commentType === type}
1296
+ onClick={() => {
1297
+ setLastCommentType(type)
1298
+ setInlineCommentDraft(draft =>
1299
+ draft ? { ...draft, commentType: type } : draft,
1300
+ )
1301
+ }}
1302
+ >
1303
+ <AnnotationTypeButtonLabel>
1304
+ {COMMENT_TYPE_LABELS[type]}
1305
+ </AnnotationTypeButtonLabel>
1306
+ <AnnotationTypeButtonMeta>
1307
+ {
1308
+ COMMENT_STATUS_LABELS[
1309
+ defaultCommentReviewStatusForType(type)
1310
+ ]
1311
+ }
1312
+ </AnnotationTypeButtonMeta>
1313
+ </InlineCommentTypeButton>
1314
+ ))}
1315
+ </InlineCommentTypeList>
1316
+ <SecondaryTypeList aria-label="More annotation types">
1317
+ {SECONDARY_COMMENT_TYPES.map(type => (
1318
+ <SecondaryTypeButton
1319
+ key={type}
1320
+ type="button"
1321
+ $active={inlineCommentDraft.commentType === type}
1322
+ onClick={() => {
1323
+ setLastCommentType(type)
1324
+ setInlineCommentDraft(draft =>
1325
+ draft ? { ...draft, commentType: type } : draft,
1326
+ )
1327
+ }}
1328
+ >
1329
+ {COMMENT_TYPE_LABELS[type]}
1330
+ </SecondaryTypeButton>
1331
+ ))}
1332
+ </SecondaryTypeList>
1333
+ <AnnotationFieldHeader>
1334
+ <AnnotationFieldLabel>
1335
+ {
1336
+ COMMENT_TYPE_DEFINITIONS[inlineCommentDraft.commentType]
1337
+ .noteLabel
1338
+ }
1339
+ </AnnotationFieldLabel>
1340
+ <AnnotationFieldHint>
1341
+ {
1342
+ COMMENT_STATUS_LABELS[
1343
+ defaultCommentReviewStatusForType(
1344
+ inlineCommentDraft.commentType,
1345
+ )
1346
+ ]
1347
+ }
1348
+ </AnnotationFieldHint>
1349
+ </AnnotationFieldHeader>
1350
+ <InlineCommentTextArea
1351
+ ref={inlineCommentTextRef}
1352
+ value={inlineCommentDraft.text}
1353
+ rows={4}
1354
+ aria-label={
1355
+ COMMENT_TYPE_DEFINITIONS[inlineCommentDraft.commentType]
1356
+ .noteLabel
1357
+ }
1358
+ placeholder={
1359
+ COMMENT_TYPE_DEFINITIONS[inlineCommentDraft.commentType]
1360
+ .notePlaceholder
1361
+ }
1362
+ onChange={event =>
1363
+ setInlineCommentDraft(draft =>
1364
+ draft ? { ...draft, text: event.target.value } : draft,
1365
+ )
1366
+ }
1367
+ onKeyDown={event => {
1368
+ if (event.key === 'Escape') {
1369
+ event.preventDefault()
1370
+ cancelInlineCommentDraft()
1371
+ }
1372
+ if (
1373
+ (event.metaKey || event.ctrlKey) &&
1374
+ event.key === 'Enter'
1375
+ ) {
1376
+ event.preventDefault()
1377
+ confirmInlineCommentDraft()
1378
+ }
1379
+ }}
1380
+ />
1381
+ </AnnotationBody>
1382
+ <AnnotationFooter>
1383
+ <AnnotationSecondaryButton
1384
+ type="button"
1385
+ onClick={cancelInlineCommentDraft}
1386
+ >
1387
+ Cancel
1388
+ </AnnotationSecondaryButton>
1389
+ <AnnotationPrimaryButton
1390
+ type="submit"
1391
+ disabled={!inlineCommentDraft.text.trim()}
1392
+ >
1393
+ {saveAnnotationLabel(inlineCommentDraft.commentType)}
1394
+ </AnnotationPrimaryButton>
1395
+ </AnnotationFooter>
1396
+ </AnnotationPanel>
1397
+ ) : null}
1398
+ {activeComment ? (
1399
+ <CommentCard
1400
+ ref={commentCardRef}
1401
+ $x={activeComment.x}
1402
+ $y={activeComment.y}
1403
+ $reviewType={reviewPanelType(activeComment.commentType)}
1404
+ role="note"
1405
+ aria-label="Annotation"
1406
+ >
1407
+ <CommentCardHeader>
1408
+ <AnnotationTitleGroup>
1409
+ <AnnotationPanelKicker>
1410
+ {activeComment.resolvedAt
1411
+ ? 'Resolved'
1412
+ : annotationKicker(activeComment.commentType)}
1413
+ </AnnotationPanelKicker>
1414
+ <AnnotationPanelTitle>
1415
+ {annotationTitle(
1416
+ activeComment.commentType,
1417
+ activeComment.subjectText,
1418
+ )}
1419
+ </AnnotationPanelTitle>
1420
+ </AnnotationTitleGroup>
1421
+ <AnnotationHeaderActions>
1422
+ <AnnotationNavButton
1423
+ type="button"
1424
+ onClick={() => openAdjacentComment(-1)}
1425
+ disabled={commentNavigationIds.length < 2}
1426
+ >
1427
+ Previous
1428
+ </AnnotationNavButton>
1429
+ <AnnotationNavButton
1430
+ type="button"
1431
+ onClick={() => openAdjacentComment(1)}
1432
+ disabled={commentNavigationIds.length < 2}
1433
+ >
1434
+ Next
1435
+ </AnnotationNavButton>
1436
+ <AnnotationIconButton
1437
+ type="button"
1438
+ aria-label="Close annotation panel"
1439
+ onClick={() => setActiveComment(null)}
1440
+ >
1441
+ <CloseOutlined />
1442
+ </AnnotationIconButton>
1443
+ </AnnotationHeaderActions>
1444
+ </CommentCardHeader>
1445
+ <AnnotationBody>
1446
+ <CommentCardMeta>
1447
+ {activeComment.author ?? 'Author'}
1448
+ {activeComment.createdAt
1449
+ ? ` / ${formatCommentTimestamp(activeComment.createdAt)}`
1450
+ : ''}
1451
+ </CommentCardMeta>
1452
+ <AnnotationQuote>
1453
+ <AnnotationSubjectLabel>
1454
+ {
1455
+ COMMENT_TYPE_DEFINITIONS[activeComment.commentType]
1456
+ .subjectLabel
1457
+ }
1458
+ </AnnotationSubjectLabel>
1459
+ {activeComment.subjectText}
1460
+ </AnnotationQuote>
1461
+ <AnnotationSectionLabel>
1462
+ Classification
1463
+ </AnnotationSectionLabel>
1464
+ <InlineCommentTypeList aria-label="Primary annotation type">
1465
+ {PRIMARY_COMMENT_TYPES.map(type => (
1466
+ <InlineCommentTypeButton
1467
+ key={type}
1468
+ type="button"
1469
+ $active={activeComment.commentType === type}
1470
+ onClick={() =>
1471
+ updateActiveComment({
1472
+ commentType: type,
1473
+ reviewStatus:
1474
+ defaultCommentReviewStatusForType(type),
1475
+ severity: defaultCommentSeverityForType(type),
1476
+ })
1477
+ }
1478
+ >
1479
+ <AnnotationTypeButtonLabel>
1480
+ {COMMENT_TYPE_LABELS[type]}
1481
+ </AnnotationTypeButtonLabel>
1482
+ <AnnotationTypeButtonMeta>
1483
+ {
1484
+ COMMENT_STATUS_LABELS[
1485
+ defaultCommentReviewStatusForType(type)
1486
+ ]
1487
+ }
1488
+ </AnnotationTypeButtonMeta>
1489
+ </InlineCommentTypeButton>
1490
+ ))}
1491
+ </InlineCommentTypeList>
1492
+ <SecondaryTypeList aria-label="More annotation types">
1493
+ {SECONDARY_COMMENT_TYPES.map(type => (
1494
+ <SecondaryTypeButton
1495
+ key={type}
1496
+ type="button"
1497
+ $active={activeComment.commentType === type}
1498
+ onClick={() =>
1499
+ updateActiveComment({
1500
+ commentType: type,
1501
+ reviewStatus:
1502
+ defaultCommentReviewStatusForType(type),
1503
+ severity: defaultCommentSeverityForType(type),
1504
+ })
1505
+ }
1506
+ >
1507
+ {COMMENT_TYPE_LABELS[type]}
1508
+ </SecondaryTypeButton>
1509
+ ))}
1510
+ </SecondaryTypeList>
1511
+ <AnnotationSectionLabel>Status</AnnotationSectionLabel>
1512
+ <ReviewStatusList aria-label="Review status">
1513
+ {COMMENT_TYPE_DEFINITIONS[
1514
+ activeComment.commentType
1515
+ ].statuses.map(status => (
1516
+ <ReviewStatusButton
1517
+ key={status}
1518
+ type="button"
1519
+ $active={activeComment.reviewStatus === status}
1520
+ onClick={() =>
1521
+ updateActiveComment({ reviewStatus: status })
1522
+ }
1523
+ >
1524
+ {COMMENT_STATUS_LABELS[status]}
1525
+ </ReviewStatusButton>
1526
+ ))}
1527
+ </ReviewStatusList>
1528
+ <AnnotationFieldHeader>
1529
+ <AnnotationFieldLabel>
1530
+ {
1531
+ COMMENT_TYPE_DEFINITIONS[activeComment.commentType]
1532
+ .noteLabel
1533
+ }
1534
+ </AnnotationFieldLabel>
1535
+ <AnnotationFieldHint>
1536
+ {COMMENT_STATUS_LABELS[activeComment.reviewStatus]}
1537
+ </AnnotationFieldHint>
1538
+ </AnnotationFieldHeader>
1539
+ <CommentEditTextArea
1540
+ $spotlight={spotlightCommentId === activeComment.id}
1541
+ $type={activeComment.commentType}
1542
+ value={activeComment.text}
1543
+ rows={4}
1544
+ aria-label={
1545
+ COMMENT_TYPE_DEFINITIONS[activeComment.commentType]
1546
+ .noteLabel
1547
+ }
1548
+ placeholder={
1549
+ COMMENT_TYPE_DEFINITIONS[activeComment.commentType]
1550
+ .notePlaceholder
1551
+ }
1552
+ onChange={event =>
1553
+ updateActiveComment({ text: event.target.value })
1554
+ }
1555
+ onKeyDown={event => {
1556
+ if (event.key === 'Escape') {
1557
+ event.preventDefault()
1558
+ setActiveComment(null)
1559
+ }
1560
+ if (
1561
+ (event.metaKey || event.ctrlKey) &&
1562
+ event.key === 'Enter'
1563
+ ) {
1564
+ event.preventDefault()
1565
+ saveActiveCommentEdits()
1566
+ }
1567
+ }}
1568
+ />
1569
+ {activeComment.replies.length > 0 ? (
1570
+ <CommentReplies>
1571
+ {activeComment.replies.map(reply => (
1572
+ <CommentReplyItem key={reply.id}>
1573
+ <CommentReplyMeta>
1574
+ {reply.author ?? 'Reply'}
1575
+ {reply.createdAt
1576
+ ? ` / ${formatCommentTimestamp(reply.createdAt)}`
1577
+ : ''}
1578
+ </CommentReplyMeta>
1579
+ <div>{reply.text}</div>
1580
+ </CommentReplyItem>
1581
+ ))}
1582
+ </CommentReplies>
1583
+ ) : null}
1584
+ <CommentReplyForm
1585
+ onSubmit={event => {
1586
+ event.preventDefault()
1587
+ addReplyToActiveComment()
1588
+ }}
1589
+ >
1590
+ <CommentReplyInput
1591
+ value={replyText}
1592
+ rows={2}
1593
+ placeholder="Add reply..."
1594
+ onChange={event => setReplyText(event.target.value)}
1595
+ onKeyDown={event => {
1596
+ if (
1597
+ (event.metaKey || event.ctrlKey) &&
1598
+ event.key === 'Enter'
1599
+ ) {
1600
+ event.preventDefault()
1601
+ addReplyToActiveComment()
1602
+ }
1603
+ }}
1604
+ />
1605
+ <AnnotationSecondaryButton
1606
+ type="submit"
1607
+ disabled={!replyText.trim()}
1608
+ >
1609
+ Reply
1610
+ </AnnotationSecondaryButton>
1611
+ </CommentReplyForm>
1612
+ </AnnotationBody>
1613
+ <AnnotationFooter>
1614
+ <AnnotationDangerButton
1615
+ type="button"
1616
+ onClick={deleteActiveComment}
1617
+ >
1618
+ Delete
1619
+ </AnnotationDangerButton>
1620
+ <AnnotationDangerButton
1621
+ type="button"
1622
+ onClick={clearAllComments}
1623
+ >
1624
+ Clear all
1625
+ </AnnotationDangerButton>
1626
+ {activeComment.commentType === 'claim' ? (
1627
+ <>
1628
+ {activeComment.reviewStatus !== 'rejected' ? (
1629
+ <AnnotationSecondaryButton
1630
+ type="button"
1631
+ onClick={() =>
1632
+ setActiveCommentReviewOutcome('rejected', true)
1633
+ }
1634
+ >
1635
+ Reject claim
1636
+ </AnnotationSecondaryButton>
1637
+ ) : null}
1638
+ {activeComment.reviewStatus !== 'supported' ? (
1639
+ <AnnotationSecondaryButton
1640
+ type="button"
1641
+ onClick={() =>
1642
+ setActiveCommentReviewOutcome('supported', true)
1643
+ }
1644
+ >
1645
+ Mark supported
1646
+ </AnnotationSecondaryButton>
1647
+ ) : null}
1648
+ </>
1649
+ ) : activeComment.commentType === 'fact-check' ? (
1650
+ <>
1651
+ {activeComment.reviewStatus !== 'disputed' ? (
1652
+ <AnnotationSecondaryButton
1653
+ type="button"
1654
+ onClick={() =>
1655
+ setActiveCommentReviewOutcome('disputed', true)
1656
+ }
1657
+ >
1658
+ Mark disputed
1659
+ </AnnotationSecondaryButton>
1660
+ ) : null}
1661
+ {activeComment.reviewStatus !== 'verified' ? (
1662
+ <AnnotationSecondaryButton
1663
+ type="button"
1664
+ onClick={() =>
1665
+ setActiveCommentReviewOutcome('verified', true)
1666
+ }
1667
+ >
1668
+ Mark verified
1669
+ </AnnotationSecondaryButton>
1670
+ ) : null}
1671
+ </>
1672
+ ) : (
1673
+ <AnnotationSecondaryButton
1674
+ type="button"
1675
+ onClick={() =>
1676
+ setActiveCommentResolved(!activeComment.resolvedAt)
1677
+ }
1678
+ >
1679
+ {activeComment.resolvedAt ? 'Reopen' : 'Resolve'}
1680
+ </AnnotationSecondaryButton>
1681
+ )}
1682
+ <AnnotationPrimaryButton
1683
+ type="button"
1684
+ onClick={saveActiveCommentEdits}
1685
+ disabled={
1686
+ !activeComment.text.trim() || !activeCommentChanged
1687
+ }
1688
+ >
1689
+ {saveAnnotationLabel(activeComment.commentType)}
1690
+ </AnnotationPrimaryButton>
1691
+ </AnnotationFooter>
1692
+ </CommentCard>
1693
+ ) : null}
1694
+ {inEditorBottomElement}
1695
+ </EditorWrapper>
1696
+ </DocumentEditorViewport>
1697
+ </EditorContext.Provider>
1698
+ </DocumentEditorRoot>
1699
+ )
1700
+ })
1701
+
1702
+ const PRIMARY_COMMENT_TYPES: CommentType[] = ['general', 'claim', 'fact-check']
1703
+ const ANNOTATION_RAIL_WIDTH = 408
1704
+
1705
+ const SECONDARY_COMMENT_TYPES: CommentType[] = [
1706
+ 'copy-editing',
1707
+ 'formatting',
1708
+ 'question',
1709
+ ]
1710
+
1711
+ const MarginAnnotationButton = styled.button<{ $x: number; $y: number }>`
1712
+ position: fixed;
1713
+ left: ${({ $x }) => $x}px;
1714
+ top: ${({ $y }) => $y}px;
1715
+ z-index: 32;
1716
+ display: inline-flex;
1717
+ align-items: center;
1718
+ justify-content: center;
1719
+ width: 30px;
1720
+ height: 30px;
1721
+ transform: translateY(-50%);
1722
+ border: 1px solid rgb(from var(--platform-colors-border) r g b / 0.9);
1723
+ border-radius: 50%;
1724
+ background: var(--platform-colors-surface);
1725
+ color: var(--platform-colors-text-secondary);
1726
+ cursor: pointer;
1727
+ box-shadow: 0 8px 24px rgb(from var(--platform-colors-text) r g b / 0.12);
1728
+ padding: 0;
1729
+
1730
+ svg {
1731
+ font-size: 14px;
1732
+ }
1733
+
1734
+ &:hover,
1735
+ &:focus-visible {
1736
+ background: var(--platform-colors-surface-hover);
1737
+ color: var(--platform-colors-text);
1738
+ outline: none;
1739
+ }
1740
+ `
1741
+
1742
+ const ReviewRailLayer = styled.div<{ $scrolling: boolean }>`
1743
+ position: fixed;
1744
+ inset: 0;
1745
+ z-index: 20;
1746
+ opacity: ${({ $scrolling }) => ($scrolling ? 0 : 1)};
1747
+ pointer-events: none;
1748
+ transition: opacity 90ms ease;
1749
+ visibility: ${({ $scrolling }) => ($scrolling ? 'hidden' : 'visible')};
1750
+ `
1751
+
1752
+ const ReviewRailButton = styled.button<{
1753
+ $top: number
1754
+ $left: number
1755
+ $type: CommentType
1756
+ $active: boolean
1757
+ $spotlight: boolean
1758
+ $spotlightMuted: boolean
1759
+ $resolved: boolean
1760
+ }>`
1761
+ position: fixed;
1762
+ left: ${({ $left }) => $left}px;
1763
+ top: ${({ $top }) => $top}px;
1764
+ display: grid;
1765
+ width: 106px;
1766
+ min-height: 42px;
1767
+ transform: translateY(-50%);
1768
+ border: 1px solid
1769
+ ${({ $type, $active, $spotlight, $resolved }) =>
1770
+ $resolved
1771
+ ? 'var(--platform-colors-border)'
1772
+ : annotationTypeBorderColor($type, $active || $spotlight)};
1773
+ border-left-width: 4px;
1774
+ border-radius: 4px;
1775
+ background: ${({ $type, $active, $resolved }) =>
1776
+ $resolved
1777
+ ? 'rgb(255 255 255)'
1778
+ : $active
1779
+ ? annotationTypeBackgroundColor($type)
1780
+ : 'rgb(255 255 255)'};
1781
+ color: var(--platform-colors-text);
1782
+ cursor: pointer;
1783
+ font-family: var(--platform-typography-font-family, system-ui, sans-serif);
1784
+ line-height: 1.1;
1785
+ padding: 5px 7px 5px 6px;
1786
+ pointer-events: auto;
1787
+ text-align: left;
1788
+ box-shadow: ${({ $active, $spotlight, $type }) =>
1789
+ $spotlight
1790
+ ? `0 10px 30px rgb(15 23 42 / 0.18), 0 0 0 2px rgb(255 255 255), 0 0 0 2px ${annotationTypeBorderColor($type, false)}`
1791
+ : $active
1792
+ ? '0 10px 30px rgb(15 23 42 / 0.2), 0 0 0 2px rgb(255 255 255)'
1793
+ : '0 6px 18px rgb(15 23 42 / 0.14)'};
1794
+ opacity: ${({ $spotlightMuted, $resolved }) =>
1795
+ $spotlightMuted ? 0.26 : $resolved ? 0.58 : 1};
1796
+ transition:
1797
+ opacity 160ms ease,
1798
+ box-shadow 160ms ease,
1799
+ border-color 160ms ease,
1800
+ background-color 160ms ease;
1801
+
1802
+ &:hover,
1803
+ &:focus-visible {
1804
+ border-color: ${({ $type }) => annotationTypeBorderColor($type, true)};
1805
+ outline: none;
1806
+ }
1807
+ `
1808
+
1809
+ const ReviewRailLabel = styled.span`
1810
+ font-size: 10px;
1811
+ font-weight: 780;
1812
+ letter-spacing: 0.04em;
1813
+ text-transform: uppercase;
1814
+ white-space: nowrap;
1815
+ `
1816
+
1817
+ const ReviewRailStatus = styled.span`
1818
+ color: var(--platform-colors-text-secondary);
1819
+ font-size: 10px;
1820
+ font-weight: 650;
1821
+ overflow: hidden;
1822
+ text-overflow: ellipsis;
1823
+ white-space: nowrap;
1824
+ `
1825
+
1826
+ const AnnotationPanel = styled.form<{
1827
+ $x: number
1828
+ $y: number
1829
+ $reviewType?: CommentType
1830
+ }>`
1831
+ position: fixed;
1832
+ right: 0;
1833
+ top: var(--annotation-rail-top, 0px);
1834
+ bottom: 0;
1835
+ z-index: 30;
1836
+ display: grid;
1837
+ grid-template-rows: auto minmax(0, 1fr) auto;
1838
+ width: min(${ANNOTATION_RAIL_WIDTH}px, calc(100vw - 48px));
1839
+ max-height: none;
1840
+ border: 1px solid
1841
+ ${({ $reviewType }) =>
1842
+ $reviewType
1843
+ ? annotationTypeBorderColor($reviewType, false)
1844
+ : 'rgb(from var(--platform-colors-border) r g b / 0.92)'};
1845
+ border-top-width: ${({ $reviewType }) => ($reviewType ? '4px' : '1px')};
1846
+ border-right: 0;
1847
+ border-bottom: 0;
1848
+ border-radius: 0;
1849
+ background: var(--platform-colors-surface);
1850
+ box-shadow: -12px 0 32px rgb(from var(--platform-colors-text) r g b / 0.1);
1851
+ color: var(--platform-colors-text);
1852
+ font-family: var(--platform-typography-font-family, system-ui, sans-serif);
1853
+ font-size: 13px;
1854
+ line-height: 1.4;
1855
+ overflow: hidden;
1856
+ `
1857
+
1858
+ const AnnotationPanelHeader = styled.div`
1859
+ display: flex;
1860
+ align-items: center;
1861
+ justify-content: space-between;
1862
+ gap: 12px;
1863
+ min-height: 58px;
1864
+ border-bottom: 1px solid var(--platform-colors-border);
1865
+ padding: 12px 14px;
1866
+ `
1867
+
1868
+ const AnnotationTitleGroup = styled.div`
1869
+ min-width: 0;
1870
+ `
1871
+
1872
+ const AnnotationPanelKicker = styled.div`
1873
+ color: var(--platform-colors-text-secondary);
1874
+ font-size: 10px;
1875
+ font-weight: 760;
1876
+ letter-spacing: 0.04em;
1877
+ line-height: 1.2;
1878
+ text-transform: uppercase;
1879
+ `
1880
+
1881
+ const AnnotationPanelTitle = styled.div`
1882
+ color: var(--platform-colors-text);
1883
+ font-size: 14px;
1884
+ font-weight: 720;
1885
+ line-height: 1.25;
1886
+ `
1887
+
1888
+ const AnnotationIconButton = styled.button`
1889
+ display: inline-flex;
1890
+ align-items: center;
1891
+ justify-content: center;
1892
+ flex: 0 0 auto;
1893
+ width: 28px;
1894
+ height: 28px;
1895
+ border: 1px solid transparent;
1896
+ border-radius: 6px;
1897
+ background: transparent;
1898
+ color: var(--platform-colors-text-secondary);
1899
+ cursor: pointer;
1900
+ padding: 0;
1901
+
1902
+ svg {
1903
+ font-size: 13px;
1904
+ }
1905
+
1906
+ &:hover,
1907
+ &:focus-visible {
1908
+ border-color: var(--platform-colors-border);
1909
+ background: var(--platform-colors-surface-hover);
1910
+ color: var(--platform-colors-text);
1911
+ outline: none;
1912
+ }
1913
+ `
1914
+
1915
+ const AnnotationHeaderActions = styled.div`
1916
+ display: inline-flex;
1917
+ align-items: center;
1918
+ flex: 0 0 auto;
1919
+ gap: 6px;
1920
+ `
1921
+
1922
+ const AnnotationNavButton = styled.button`
1923
+ display: inline-flex;
1924
+ align-items: center;
1925
+ justify-content: center;
1926
+ height: 28px;
1927
+ border: 1px solid var(--platform-colors-border);
1928
+ border-radius: 5px;
1929
+ background: var(--platform-colors-surface);
1930
+ color: var(--platform-colors-text-secondary);
1931
+ cursor: pointer;
1932
+ font-family: var(--platform-typography-font-family, system-ui, sans-serif);
1933
+ font-size: 11px;
1934
+ font-weight: 650;
1935
+ line-height: 1;
1936
+ padding: 0 8px;
1937
+
1938
+ &:hover:not(:disabled),
1939
+ &:focus-visible:not(:disabled) {
1940
+ background: var(--platform-colors-surface-hover);
1941
+ color: var(--platform-colors-text);
1942
+ outline: none;
1943
+ }
1944
+
1945
+ &:disabled {
1946
+ cursor: default;
1947
+ opacity: 0.42;
1948
+ }
1949
+ `
1950
+
1951
+ const AnnotationBody = styled.div`
1952
+ display: grid;
1953
+ gap: 8px;
1954
+ align-content: start;
1955
+ min-height: 0;
1956
+ overflow: auto;
1957
+ padding: 14px;
1958
+ `
1959
+
1960
+ const AnnotationQuote = styled.blockquote`
1961
+ max-height: 74px;
1962
+ overflow: auto;
1963
+ margin: 0;
1964
+ border-left: 2px solid rgb(from var(--platform-colors-text) r g b / 0.22);
1965
+ color: var(--platform-colors-text-secondary);
1966
+ font-size: 12px;
1967
+ line-height: 1.4;
1968
+ padding: 2px 0 2px 9px;
1969
+ white-space: pre-wrap;
1970
+ `
1971
+
1972
+ const AnnotationSubjectLabel = styled.span`
1973
+ display: block;
1974
+ margin-bottom: 4px;
1975
+ color: var(--platform-colors-text-disabled);
1976
+ font-size: 10px;
1977
+ font-weight: 760;
1978
+ letter-spacing: 0.04em;
1979
+ text-transform: uppercase;
1980
+ `
1981
+
1982
+ const AnnotationSectionLabel = styled.div`
1983
+ margin-top: 4px;
1984
+ color: var(--platform-colors-text-disabled);
1985
+ font-size: 10px;
1986
+ font-weight: 760;
1987
+ letter-spacing: 0.05em;
1988
+ text-transform: uppercase;
1989
+ `
1990
+
1991
+ const InlineCommentTypeList = styled.div`
1992
+ display: grid;
1993
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1994
+ overflow: hidden;
1995
+ border: 1px solid var(--platform-colors-border);
1996
+ border-radius: 6px;
1997
+ background: var(--platform-colors-background);
1998
+ `
1999
+
2000
+ const ReviewStatusList = styled.div`
2001
+ display: flex;
2002
+ flex-wrap: wrap;
2003
+ gap: 4px;
2004
+ `
2005
+
2006
+ const ReviewStatusButton = styled.button<{ $active: boolean }>`
2007
+ min-height: 26px;
2008
+ border: 1px solid
2009
+ ${({ $active }) =>
2010
+ $active
2011
+ ? 'var(--platform-colors-border-strong, var(--platform-colors-text))'
2012
+ : 'var(--platform-colors-border)'};
2013
+ border-radius: 4px;
2014
+ background: ${({ $active }) =>
2015
+ $active
2016
+ ? 'var(--platform-colors-surface-hover)'
2017
+ : 'var(--platform-colors-surface)'};
2018
+ color: ${({ $active }) =>
2019
+ $active
2020
+ ? 'var(--platform-colors-text)'
2021
+ : 'var(--platform-colors-text-secondary)'};
2022
+ cursor: pointer;
2023
+ font: inherit;
2024
+ font-size: 11px;
2025
+ font-weight: 680;
2026
+ padding: 0 9px;
2027
+
2028
+ &:hover,
2029
+ &:focus-visible {
2030
+ border-color: var(--platform-colors-text);
2031
+ outline: none;
2032
+ }
2033
+ `
2034
+
2035
+ const AnnotationFieldHeader = styled.div`
2036
+ display: flex;
2037
+ align-items: baseline;
2038
+ justify-content: space-between;
2039
+ gap: 8px;
2040
+ margin-top: 6px;
2041
+ `
2042
+
2043
+ const AnnotationFieldLabel = styled.div`
2044
+ color: var(--platform-colors-text);
2045
+ font-size: 12px;
2046
+ font-weight: 720;
2047
+ `
2048
+
2049
+ const AnnotationFieldHint = styled.div`
2050
+ color: var(--platform-colors-text-secondary);
2051
+ font-size: 11px;
2052
+ font-weight: 650;
2053
+ `
2054
+
2055
+ const InlineCommentTypeButton = styled.button<{ $active: boolean }>`
2056
+ display: grid;
2057
+ align-content: center;
2058
+ gap: 2px;
2059
+ min-width: 0;
2060
+ min-height: 34px;
2061
+ border: 0;
2062
+ border-right: 1px solid var(--platform-colors-border);
2063
+ border-bottom: 1px solid var(--platform-colors-border);
2064
+ border-radius: 0;
2065
+ background: ${({ $active }) =>
2066
+ $active
2067
+ ? 'var(--platform-colors-surface-hover)'
2068
+ : 'var(--platform-colors-background)'};
2069
+ color: ${({ $active }) =>
2070
+ $active
2071
+ ? 'var(--platform-colors-text)'
2072
+ : 'var(--platform-colors-text-secondary)'};
2073
+ cursor: pointer;
2074
+ font: inherit;
2075
+ overflow: hidden;
2076
+ padding: 5px 7px;
2077
+ text-overflow: ellipsis;
2078
+
2079
+ &:nth-child(3n) {
2080
+ border-right: 0;
2081
+ }
2082
+
2083
+ &:nth-last-child(-n + 3) {
2084
+ border-bottom: 0;
2085
+ }
2086
+
2087
+ &:hover,
2088
+ &:focus-visible {
2089
+ background: var(--platform-colors-surface-hover);
2090
+ color: var(--platform-colors-text);
2091
+ outline: none;
2092
+ }
2093
+ `
2094
+
2095
+ const AnnotationTypeButtonLabel = styled.span`
2096
+ overflow: hidden;
2097
+ font-size: 11px;
2098
+ font-weight: 740;
2099
+ line-height: 1.15;
2100
+ text-overflow: ellipsis;
2101
+ white-space: nowrap;
2102
+ `
2103
+
2104
+ const AnnotationTypeButtonMeta = styled.span`
2105
+ overflow: hidden;
2106
+ color: var(--platform-colors-text-secondary);
2107
+ font-size: 9px;
2108
+ font-weight: 640;
2109
+ line-height: 1.15;
2110
+ text-overflow: ellipsis;
2111
+ text-transform: uppercase;
2112
+ white-space: nowrap;
2113
+ `
2114
+
2115
+ const SecondaryTypeList = styled.div`
2116
+ display: flex;
2117
+ flex-wrap: wrap;
2118
+ gap: 4px;
2119
+ `
2120
+
2121
+ const SecondaryTypeButton = styled.button<{ $active: boolean }>`
2122
+ min-height: 24px;
2123
+ border: 1px solid
2124
+ ${({ $active }) =>
2125
+ $active
2126
+ ? 'var(--platform-colors-text)'
2127
+ : 'var(--platform-colors-border)'};
2128
+ border-radius: 4px;
2129
+ background: ${({ $active }) =>
2130
+ $active
2131
+ ? 'var(--platform-colors-surface-hover)'
2132
+ : 'var(--platform-colors-surface)'};
2133
+ color: ${({ $active }) =>
2134
+ $active
2135
+ ? 'var(--platform-colors-text)'
2136
+ : 'var(--platform-colors-text-secondary)'};
2137
+ cursor: pointer;
2138
+ font: inherit;
2139
+ font-size: 10px;
2140
+ font-weight: 650;
2141
+ padding: 0 7px;
2142
+
2143
+ &:hover,
2144
+ &:focus-visible {
2145
+ border-color: var(--platform-colors-text);
2146
+ color: var(--platform-colors-text);
2147
+ outline: none;
2148
+ }
2149
+ `
2150
+
2151
+ const InlineCommentTextArea = styled.textarea`
2152
+ min-height: 132px;
2153
+ max-height: 220px;
2154
+ resize: vertical;
2155
+ border: 1px solid var(--platform-colors-border);
2156
+ border-radius: 6px;
2157
+ background: var(--platform-colors-background);
2158
+ color: var(--platform-colors-text);
2159
+ font: inherit;
2160
+ font-size: 13px;
2161
+ line-height: 1.45;
2162
+ padding: 9px 10px;
2163
+ outline: none;
2164
+
2165
+ &:focus {
2166
+ border-color: var(--platform-colors-accent);
2167
+ box-shadow: 0 0 0 2px rgb(from var(--platform-colors-accent) r g b / 0.14);
2168
+ }
2169
+ `
2170
+
2171
+ const AnnotationFooter = styled.div`
2172
+ display: flex;
2173
+ flex-wrap: wrap;
2174
+ justify-content: flex-end;
2175
+ align-items: center;
2176
+ gap: 7px;
2177
+ border-top: 1px solid var(--platform-colors-border);
2178
+ background: var(--platform-colors-surface);
2179
+ padding: 10px 12px;
2180
+ `
2181
+
2182
+ const AnnotationActionButton = styled.button`
2183
+ min-width: 0;
2184
+ min-height: 30px;
2185
+ border: 1px solid var(--platform-colors-border);
2186
+ border-radius: 5px;
2187
+ background: var(--platform-colors-surface);
2188
+ color: var(--platform-colors-text);
2189
+ cursor: pointer;
2190
+ font: inherit;
2191
+ font-size: 11px;
2192
+ font-weight: 680;
2193
+ overflow: hidden;
2194
+ padding: 0 9px;
2195
+ text-overflow: ellipsis;
2196
+ white-space: nowrap;
2197
+
2198
+ &:hover:not(:disabled),
2199
+ &:focus-visible {
2200
+ border-color: var(--platform-colors-border-strong);
2201
+ background: var(--platform-colors-surface-hover);
2202
+ outline: none;
2203
+ }
2204
+
2205
+ &:disabled {
2206
+ cursor: not-allowed;
2207
+ opacity: 0.42;
2208
+ }
2209
+ `
2210
+
2211
+ const AnnotationPrimaryButton = styled(AnnotationActionButton)`
2212
+ border-color: var(--platform-colors-text);
2213
+ background: var(--platform-colors-text);
2214
+ color: var(--platform-colors-surface);
2215
+ min-width: 92px;
2216
+ `
2217
+
2218
+ const AnnotationSecondaryButton = AnnotationActionButton
2219
+
2220
+ const AnnotationDangerButton = styled(AnnotationActionButton)`
2221
+ margin-right: auto;
2222
+ color: var(--platform-colors-danger, #b42318);
2223
+ min-width: 68px;
2224
+ `
2225
+
2226
+ function formatCommentTimestamp(value: string): string {
2227
+ const date = new Date(value)
2228
+ if (Number.isNaN(date.getTime())) return value
2229
+ return date.toLocaleString(undefined, {
2230
+ dateStyle: 'medium',
2231
+ timeStyle: 'short',
2232
+ })
2233
+ }
2234
+
2235
+ const CommentCard = styled.div<{
2236
+ $x: number
2237
+ $y: number
2238
+ $reviewType?: CommentType
2239
+ }>`
2240
+ position: fixed;
2241
+ right: 0;
2242
+ top: var(--annotation-rail-top, 0px);
2243
+ bottom: 0;
2244
+ z-index: 30;
2245
+ display: grid;
2246
+ grid-template-rows: auto minmax(0, 1fr) auto;
2247
+ width: min(${ANNOTATION_RAIL_WIDTH}px, calc(100vw - 48px));
2248
+ max-height: none;
2249
+ border: 1px solid
2250
+ ${({ $reviewType }) =>
2251
+ $reviewType
2252
+ ? annotationTypeBorderColor($reviewType, false)
2253
+ : 'rgb(from var(--platform-colors-border) r g b / 0.92)'};
2254
+ border-top-width: ${({ $reviewType }) => ($reviewType ? '4px' : '1px')};
2255
+ border-right: 0;
2256
+ border-bottom: 0;
2257
+ border-radius: 0;
2258
+ background: var(--platform-colors-surface);
2259
+ box-shadow: -12px 0 32px rgb(from var(--platform-colors-text) r g b / 0.1);
2260
+ color: var(--platform-colors-text);
2261
+ font-family: var(--platform-typography-font-family, system-ui, sans-serif);
2262
+ font-size: 13px;
2263
+ line-height: 1.4;
2264
+ overflow: hidden;
2265
+ `
2266
+
2267
+ const CommentCardHeader = styled.div`
2268
+ display: flex;
2269
+ align-items: center;
2270
+ justify-content: space-between;
2271
+ gap: 12px;
2272
+ min-height: 58px;
2273
+ border-bottom: 1px solid var(--platform-colors-border);
2274
+ padding: 12px 14px;
2275
+ `
2276
+
2277
+ const CommentEditTextArea = styled(InlineCommentTextArea)<{
2278
+ $spotlight: boolean
2279
+ $type: CommentType
2280
+ }>`
2281
+ min-height: 112px;
2282
+ border-color: var(--platform-colors-border);
2283
+ background: ${({ $spotlight, $type }) =>
2284
+ $spotlight
2285
+ ? annotationTypeBackgroundColor($type)
2286
+ : 'var(--platform-colors-background)'};
2287
+ box-shadow: ${({ $spotlight, $type }) =>
2288
+ $spotlight
2289
+ ? `inset 2px 0 0 ${annotationTypeBorderColor($type, false)}`
2290
+ : 'none'};
2291
+
2292
+ &:focus {
2293
+ border-color: var(--platform-colors-border);
2294
+ box-shadow: ${({ $spotlight, $type }) =>
2295
+ $spotlight
2296
+ ? `inset 2px 0 0 ${annotationTypeBorderColor($type, false)}, 0 0 0 2px rgb(from var(--platform-colors-accent) r g b / 0.08)`
2297
+ : '0 0 0 2px rgb(from var(--platform-colors-accent) r g b / 0.14)'};
2298
+ }
2299
+ `
2300
+
2301
+ const CommentCardMeta = styled.div`
2302
+ color: var(--platform-colors-text-disabled);
2303
+ font-size: 11px;
2304
+ `
2305
+
2306
+ const CommentReplies = styled.div`
2307
+ display: flex;
2308
+ flex-direction: column;
2309
+ gap: 8px;
2310
+ padding-top: 10px;
2311
+ border-top: 1px solid rgb(from var(--platform-colors-border) r g b / 0.85);
2312
+ `
2313
+
2314
+ const CommentReplyItem = styled.div`
2315
+ border-left: 2px solid rgb(from var(--platform-colors-text) r g b / 0.18);
2316
+ padding-left: 8px;
2317
+ white-space: pre-wrap;
2318
+ `
2319
+
2320
+ const CommentReplyMeta = styled.div`
2321
+ margin-bottom: 2px;
2322
+ color: var(--platform-colors-text-secondary);
2323
+ font-size: 11px;
2324
+ font-weight: 650;
2325
+ `
2326
+
2327
+ const CommentReplyForm = styled.form`
2328
+ display: grid;
2329
+ grid-template-columns: 1fr auto;
2330
+ gap: 8px;
2331
+ align-items: end;
2332
+ margin-top: 10px;
2333
+ `
2334
+
2335
+ const CommentReplyInput = styled.textarea`
2336
+ min-height: 42px;
2337
+ max-height: 110px;
2338
+ resize: vertical;
2339
+ border: 1px solid var(--platform-colors-border);
2340
+ border-radius: 6px;
2341
+ background: var(--platform-colors-background);
2342
+ color: var(--platform-colors-text);
2343
+ font: inherit;
2344
+ line-height: 1.35;
2345
+ padding: 7px 8px;
2346
+ outline: none;
2347
+
2348
+ &:focus {
2349
+ border-color: var(--platform-colors-accent);
2350
+ box-shadow: 0 0 0 2px rgb(from var(--platform-colors-accent) r g b / 0.14);
2351
+ }
2352
+ `
2353
+
2354
+ function panelX(): number {
2355
+ return Math.max(16, window.innerWidth - 392)
2356
+ }
2357
+
2358
+ function panelY(): number {
2359
+ return 96
2360
+ }
2361
+
2362
+ function commentDefaultsForType(type: CommentType): {
2363
+ reviewStatus: CommentReviewStatus
2364
+ severity: CommentSeverity
2365
+ } {
2366
+ return {
2367
+ reviewStatus: defaultCommentReviewStatusForType(type),
2368
+ severity: defaultCommentSeverityForType(type),
2369
+ }
2370
+ }
2371
+
2372
+ function newAnnotationTitle(type: CommentType): string {
2373
+ const definition = COMMENT_TYPE_DEFINITIONS[type]
2374
+ return type === 'general'
2375
+ ? 'New note'
2376
+ : `New ${definition.titlePrefix.toLowerCase()}`
2377
+ }
2378
+
2379
+ function annotationKicker(type: CommentType): string {
2380
+ if (type === 'claim') return 'Claim under review'
2381
+ if (type === 'fact-check') return 'Fact check'
2382
+ return type === 'general' ? 'Annotation' : 'Manuscript review item'
2383
+ }
2384
+
2385
+ function reviewPanelType(type: CommentType): CommentType | undefined {
2386
+ return type === 'general' ? undefined : type
2387
+ }
2388
+
2389
+ function annotationTypeBorderColor(type: CommentType, active: boolean): string {
2390
+ if (type === 'claim') return active ? 'rgb(180 83 9 / 0.95)' : 'rgb(217 119 6 / 0.72)'
2391
+ if (type === 'fact-check')
2392
+ return active ? 'rgb(185 28 28 / 0.95)' : 'rgb(220 38 38 / 0.72)'
2393
+ if (type === 'source')
2394
+ return active ? 'rgb(29 78 216 / 0.95)' : 'rgb(37 99 235 / 0.72)'
2395
+ if (type === 'thread')
2396
+ return active ? 'rgb(109 40 217 / 0.95)' : 'rgb(124 58 237 / 0.72)'
2397
+ if (type === 'logic')
2398
+ return active ? 'rgb(51 65 85 / 0.95)' : 'rgb(71 85 105 / 0.72)'
2399
+ if (type === 'conflict')
2400
+ return active ? 'rgb(15 118 110 / 0.95)' : 'rgb(13 148 136 / 0.72)'
2401
+ if (type === 'copyedit')
2402
+ return active ? 'rgb(21 128 61 / 0.95)' : 'rgb(22 163 74 / 0.72)'
2403
+ if (type === 'voice')
2404
+ return active ? 'rgb(190 24 93 / 0.95)' : 'rgb(219 39 119 / 0.72)'
2405
+ if (type === 'citation')
2406
+ return active ? 'rgb(29 78 216 / 0.92)' : 'rgb(37 99 235 / 0.58)'
2407
+ return active
2408
+ ? 'var(--platform-colors-text)'
2409
+ : 'rgb(from var(--platform-colors-border) r g b / 0.92)'
2410
+ }
2411
+
2412
+ function annotationTypeBackgroundColor(type: CommentType): string {
2413
+ if (type === 'claim') return 'rgb(255 247 237)'
2414
+ if (type === 'fact-check') return 'rgb(254 242 242)'
2415
+ if (type === 'source') return 'rgb(239 246 255)'
2416
+ if (type === 'thread') return 'rgb(245 243 255)'
2417
+ if (type === 'logic') return 'rgb(248 250 252)'
2418
+ if (type === 'conflict') return 'rgb(240 253 250)'
2419
+ if (type === 'copyedit') return 'rgb(240 253 244)'
2420
+ if (type === 'voice') return 'rgb(253 242 248)'
2421
+ return 'rgb(255 255 255)'
2422
+ }
2423
+
2424
+ function saveAnnotationLabel(type: CommentType): string {
2425
+ if (type === 'claim') return 'Save claim'
2426
+ if (type === 'fact-check') return 'Save fact check'
2427
+ if (type === 'copyedit') return 'Save copyedit'
2428
+ if (type === 'voice') return 'Save voice note'
2429
+ return 'Save'
2430
+ }
2431
+
2432
+ function annotationTitle(type: CommentType, subjectText: string): string {
2433
+ const prefix = COMMENT_TYPE_DEFINITIONS[type].titlePrefix
2434
+ const subject = subjectText.trim()
2435
+ if (!subject || type === 'general') return prefix
2436
+ return `${prefix}: "${shortAnnotationSubject(subject)}"`
2437
+ }
2438
+
2439
+ function shortAnnotationSubject(subject: string): string {
2440
+ return subject.length > 62 ? `${subject.slice(0, 59).trim()}...` : subject
2441
+ }
2442
+
2443
+ function cssEscape(value: string): string {
2444
+ return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
2445
+ ? CSS.escape(value)
2446
+ : value.replace(/["\\]/g, '\\$&')
2447
+ }
2448
+
2449
+ function packReviewRailItems(items: ReviewRailItem[]): ReviewRailItem[] {
2450
+ const minGap = 54
2451
+ return [...items]
2452
+ .sort((a, b) => a.top - b.top)
2453
+ .map((item, index, packed) => {
2454
+ if (!index) return item
2455
+ const previous = packed[index - 1]
2456
+ if (!previous || item.top - previous.top >= minGap) return item
2457
+ return { ...item, top: previous.top + minGap }
2458
+ })
2459
+ }
2460
+
2461
+ function waitForAutoReviewStep(
2462
+ signal?: AbortSignal,
2463
+ delayMs = 3000,
2464
+ ): Promise<void> {
2465
+ return new Promise(resolve => {
2466
+ if (signal?.aborted) {
2467
+ resolve()
2468
+ return
2469
+ }
2470
+ const timeout = window.setTimeout(resolve, delayMs)
2471
+ signal?.addEventListener(
2472
+ 'abort',
2473
+ () => {
2474
+ window.clearTimeout(timeout)
2475
+ resolve()
2476
+ },
2477
+ { once: true },
2478
+ )
2479
+ })
2480
+ }
2481
+
2482
+ async function runProgressiveAutoReviewFindings(
2483
+ paragraphs: AutoReviewParagraph[],
2484
+ options: RunAutoReviewOptions,
2485
+ onFindings: (findings: AutoReviewFindingInput[]) => void,
2486
+ ): Promise<void> {
2487
+ if (!options.findingsProvider || !paragraphs.length) return
2488
+ const batches = progressiveAutoReviewBatches(paragraphs)
2489
+ let nextBatchIndex = 0
2490
+ let reportedFindings = 0
2491
+ const concurrency = Math.min(4, batches.length)
2492
+
2493
+ async function runWorker(): Promise<void> {
2494
+ while (nextBatchIndex < batches.length && !options.signal?.aborted) {
2495
+ const batch = batches[nextBatchIndex]
2496
+ nextBatchIndex += 1
2497
+ if (!batch?.length) continue
2498
+ const findings = await options.findingsProvider?.({
2499
+ paragraphs: batch,
2500
+ options: {
2501
+ ...options,
2502
+ maxFindings:
2503
+ typeof options.maxFindings === 'number'
2504
+ ? Math.max(0, options.maxFindings - reportedFindings)
2505
+ : undefined,
2506
+ },
2507
+ })
2508
+ if (!findings?.length) continue
2509
+ const remaining =
2510
+ typeof options.maxFindings === 'number'
2511
+ ? Math.max(0, options.maxFindings - reportedFindings)
2512
+ : findings.length
2513
+ if (!remaining) return
2514
+ const nextFindings = findings.slice(0, remaining)
2515
+ reportedFindings += nextFindings.length
2516
+ onFindings(nextFindings)
2517
+ }
2518
+ }
2519
+
2520
+ await Promise.all(Array.from({ length: concurrency }, () => runWorker()))
2521
+ }
2522
+
2523
+ export function progressiveAutoReviewBatches(
2524
+ paragraphs: AutoReviewParagraph[],
2525
+ ): AutoReviewParagraph[][] {
2526
+ const firstBatchSize = 3
2527
+ const firstBatchCount = 4
2528
+ const laterBatchSize = 12
2529
+ const firstParagraphCount = Math.min(
2530
+ paragraphs.length,
2531
+ firstBatchSize * firstBatchCount,
2532
+ )
2533
+ const batches: AutoReviewParagraph[][] = []
2534
+
2535
+ for (let index = 0; index < firstParagraphCount; index += firstBatchSize) {
2536
+ batches.push(paragraphs.slice(index, index + firstBatchSize))
2537
+ }
2538
+ for (
2539
+ let index = firstParagraphCount;
2540
+ index < paragraphs.length;
2541
+ index += laterBatchSize
2542
+ ) {
2543
+ batches.push(paragraphs.slice(index, index + laterBatchSize))
2544
+ }
2545
+
2546
+ return batches
2547
+ }
2548
+
2549
+ function captureScrollSnapshot(anchor: HTMLElement): ScrollSnapshot {
2550
+ return {
2551
+ windowX: window.scrollX,
2552
+ windowY: window.scrollY,
2553
+ entries: scrollContainersFor(anchor).map(element => ({
2554
+ element,
2555
+ left: element.scrollLeft,
2556
+ top: element.scrollTop,
2557
+ })),
2558
+ }
2559
+ }
2560
+
2561
+ function restoreScrollSnapshot(snapshot: ScrollSnapshot | null): void {
2562
+ if (!snapshot) return
2563
+ window.scrollTo(snapshot.windowX, snapshot.windowY)
2564
+ for (const entry of snapshot.entries) {
2565
+ entry.element.scrollLeft = entry.left
2566
+ entry.element.scrollTop = entry.top
2567
+ }
2568
+ }
2569
+
2570
+ function scrollContainersFor(anchor: HTMLElement): HTMLElement[] {
2571
+ const containers: HTMLElement[] = []
2572
+ let element: HTMLElement | null = anchor
2573
+ while (element?.parentElement) {
2574
+ element = element.parentElement
2575
+ const style = window.getComputedStyle(element)
2576
+ const overflow = `${style.overflow} ${style.overflowY} ${style.overflowX}`
2577
+ if (
2578
+ /(auto|scroll|overlay)/.test(overflow) &&
2579
+ (element.scrollHeight > element.clientHeight ||
2580
+ element.scrollWidth > element.clientWidth)
2581
+ ) {
2582
+ containers.push(element)
2583
+ }
2584
+ }
2585
+ const scrollingElement = document.scrollingElement
2586
+ if (
2587
+ scrollingElement instanceof HTMLElement &&
2588
+ !containers.includes(scrollingElement)
2589
+ ) {
2590
+ containers.push(scrollingElement)
2591
+ }
2592
+ return containers
2593
+ }
2594
+
2595
+ export function isSelectionAnnotatable(
2596
+ editor: Editor,
2597
+ from: number,
2598
+ to: number,
2599
+ ): boolean {
2600
+ if (editor.isDestroyed || from === to || !editor.isEditable) return false
2601
+ const start = domElementAtPos(editor, Math.min(from, to))
2602
+ const end = domElementAtPos(editor, Math.max(from, to))
2603
+ if (!start || !end) return true
2604
+ const protectedStart = closestAnnotationProtectedElement(start)
2605
+ const protectedEnd = closestAnnotationProtectedElement(end)
2606
+ if (!protectedStart || !protectedEnd) return true
2607
+ return protectedStart !== protectedEnd && !protectedStart.contains(end)
2608
+ }
2609
+
2610
+ function domElementAtPos(editor: Editor, pos: number): HTMLElement | null {
2611
+ try {
2612
+ const dom = editor.view.domAtPos(pos).node
2613
+ return dom instanceof HTMLElement ? dom : dom.parentElement
2614
+ } catch {
2615
+ return null
2616
+ }
2617
+ }
2618
+
2619
+ function closestAnnotationProtectedElement(
2620
+ element: HTMLElement,
2621
+ ): HTMLElement | null {
2622
+ return element.closest(
2623
+ [
2624
+ '[contenteditable="false"]',
2625
+ 'cite.ps-citation',
2626
+ '[data-pm-reference-entry="true"]',
2627
+ '[data-pm-generated-references="true"]',
2628
+ '[data-pm-references-empty="true"]',
2629
+ ].join(','),
2630
+ )
2631
+ }