@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.
- package/package.json +63 -0
- package/src/CollapsePanel.tsx +35 -0
- package/src/DocumentDiffView.tsx +130 -0
- package/src/DocumentEditor.test.ts +118 -0
- package/src/DocumentEditor.tsx +2631 -0
- package/src/alignedLineDiff.test.ts +52 -0
- package/src/alignedLineDiff.ts +67 -0
- package/src/collectionAssetSrc.ts +2 -0
- package/src/commentUtils.test.ts +350 -0
- package/src/commentUtils.ts +546 -0
- package/src/constants/toolKeys.ts +14 -0
- package/src/contentFormat.test.ts +27 -0
- package/src/contentFormat.ts +18 -0
- package/src/editorExtensions.ts +155 -0
- package/src/extensions/appliedChangeMark.ts +115 -0
- package/src/extensions/autoReviewPrompts.test.ts +529 -0
- package/src/extensions/autoReviewPrompts.ts +1442 -0
- package/src/extensions/collectionImage.ts +26 -0
- package/src/extensions/collectionImagePaste.ts +215 -0
- package/src/extensions/commentMark.ts +223 -0
- package/src/extensions/documentAssetIdentity.ts +54 -0
- package/src/extensions/figure.ts +92 -0
- package/src/extensions/footnote.ts +186 -0
- package/src/extensions/indexMarker.ts +88 -0
- package/src/extensions/mathEditing.ts +239 -0
- package/src/extensions/mermaidBlock.tsx +297 -0
- package/src/extensions/slashCommands.test.ts +170 -0
- package/src/extensions/slashCommands.ts +746 -0
- package/src/extensions/smartTypography.test.ts +74 -0
- package/src/extensions/smartTypography.ts +120 -0
- package/src/index.ts +137 -0
- package/src/insertCollectionImage.test.ts +60 -0
- package/src/insertCollectionImage.ts +63 -0
- package/src/insertInlineAssetKind.test.ts +67 -0
- package/src/insertInlineAssetKind.ts +49 -0
- package/src/mermaidPreview.test.ts +54 -0
- package/src/mermaidPreview.ts +115 -0
- package/src/styled.ts +676 -0
- package/src/toolbar/ToolBtn.tsx +63 -0
- package/src/toolbar/Toolbar.test.tsx +325 -0
- package/src/toolbar/Toolbar.tsx +624 -0
- package/src/toolbar/groups/HeadingGroup.tsx +153 -0
- package/src/toolbar/overlayTypes.ts +28 -0
- package/src/useEditorExtensions.ts +22 -0
- 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
|
+
}
|