@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,546 +1,546 @@
1
- import type { Editor } from '@tiptap/core'
2
-
3
- export const COMMENT_TYPES = [
4
- 'general',
5
- 'agent-review',
6
- 'formatting',
7
- 'copy-editing',
8
- 'claim',
9
- 'fact-check',
10
- 'source',
11
- 'thread',
12
- 'conflict',
13
- 'logic',
14
- 'copyedit',
15
- 'voice',
16
- 'question',
17
- 'citation',
18
- ] as const
19
-
20
- export type CommentType = (typeof COMMENT_TYPES)[number]
21
-
22
- export const COMMENT_REVIEW_STATUSES = [
23
- 'open',
24
- 'needs-support',
25
- 'supported',
26
- 'rejected',
27
- 'unverified',
28
- 'verified',
29
- 'disputed',
30
- 'needs-source',
31
- 'suggested',
32
- 'needs-review',
33
- 'stale',
34
- ] as const
35
-
36
- export type CommentReviewStatus = (typeof COMMENT_REVIEW_STATUSES)[number]
37
-
38
- export const COMMENT_SEVERITIES = ['note', 'advisory', 'blocking'] as const
39
-
40
- export type CommentSeverity = (typeof COMMENT_SEVERITIES)[number]
41
-
42
- export const COMMENT_TYPE_LABELS: Record<CommentType, string> = {
43
- general: 'Comment',
44
- 'agent-review': 'Agent review',
45
- formatting: 'Formatting',
46
- 'copy-editing': 'Edit suggestion',
47
- claim: 'Claim',
48
- 'fact-check': 'Fact check',
49
- source: 'Source',
50
- thread: 'Thread',
51
- conflict: 'Conflict',
52
- logic: 'Logic',
53
- copyedit: 'Copyedit',
54
- voice: 'Voice',
55
- question: 'Question',
56
- citation: 'Citation',
57
- }
58
-
59
- export interface CommentTypeDefinition {
60
- label: string
61
- groupLabel: string
62
- titlePrefix: string
63
- subjectLabel: string
64
- noteLabel: string
65
- notePlaceholder: string
66
- defaultStatus: CommentReviewStatus
67
- defaultSeverity: CommentSeverity
68
- statuses: CommentReviewStatus[]
69
- }
70
-
71
- export const COMMENT_STATUS_LABELS: Record<CommentReviewStatus, string> = {
72
- open: 'Open',
73
- 'needs-support': 'Needs support',
74
- supported: 'Supported',
75
- rejected: 'Rejected',
76
- unverified: 'Unverified',
77
- verified: 'Verified',
78
- disputed: 'Disputed',
79
- 'needs-source': 'Needs source',
80
- suggested: 'Suggested',
81
- 'needs-review': 'Needs review',
82
- stale: 'Stale',
83
- }
84
-
85
- export const COMMENT_TYPE_DEFINITIONS: Record<
86
- CommentType,
87
- CommentTypeDefinition
88
- > = {
89
- general: {
90
- label: 'Comment',
91
- groupLabel: 'Other notes',
92
- titlePrefix: 'Comment',
93
- subjectLabel: 'Selected text',
94
- noteLabel: 'Note',
95
- notePlaceholder: 'Add an editorial note...',
96
- defaultStatus: 'open',
97
- defaultSeverity: 'note',
98
- statuses: ['open'],
99
- },
100
- 'agent-review': {
101
- label: 'Agent review',
102
- groupLabel: 'Other notes',
103
- titlePrefix: 'Agent review',
104
- subjectLabel: 'Review target',
105
- noteLabel: 'Review note',
106
- notePlaceholder: 'Summarize the review finding...',
107
- defaultStatus: 'open',
108
- defaultSeverity: 'advisory',
109
- statuses: ['open'],
110
- },
111
- formatting: {
112
- label: 'Formatting',
113
- groupLabel: 'Formatting',
114
- titlePrefix: 'Formatting issue',
115
- subjectLabel: 'Affected text',
116
- noteLabel: 'Formatting note',
117
- notePlaceholder: 'Describe the layout, style, or export issue...',
118
- defaultStatus: 'open',
119
- defaultSeverity: 'advisory',
120
- statuses: ['open'],
121
- },
122
- 'copy-editing': {
123
- label: 'Edit suggestion',
124
- groupLabel: 'Edit suggestions',
125
- titlePrefix: 'Edit suggestion',
126
- subjectLabel: 'Suggested edit target',
127
- noteLabel: 'Suggestion',
128
- notePlaceholder: 'Describe the proposed wording change...',
129
- defaultStatus: 'open',
130
- defaultSeverity: 'note',
131
- statuses: ['open'],
132
- },
133
- claim: {
134
- label: 'Claim',
135
- groupLabel: 'Claims',
136
- titlePrefix: 'Claim',
137
- subjectLabel: 'Claim under review',
138
- noteLabel: 'Support needed',
139
- notePlaceholder: 'What support, citation, or rewrite is needed?',
140
- defaultStatus: 'needs-support',
141
- defaultSeverity: 'advisory',
142
- statuses: ['needs-support', 'supported', 'rejected'],
143
- },
144
- 'fact-check': {
145
- label: 'Fact check',
146
- groupLabel: 'Fact checks',
147
- titlePrefix: 'Fact check',
148
- subjectLabel: 'Statement to verify',
149
- noteLabel: 'Verification note',
150
- notePlaceholder: 'What should be verified, and against what source?',
151
- defaultStatus: 'unverified',
152
- defaultSeverity: 'advisory',
153
- statuses: ['unverified', 'verified', 'disputed'],
154
- },
155
- source: {
156
- label: 'Source',
157
- groupLabel: 'Source checks',
158
- titlePrefix: 'Source check',
159
- subjectLabel: 'Attributed claim',
160
- noteLabel: 'Source question',
161
- notePlaceholder: 'What should be confirmed against the cited source?',
162
- defaultStatus: 'unverified',
163
- defaultSeverity: 'advisory',
164
- statuses: ['unverified', 'verified', 'disputed'],
165
- },
166
- thread: {
167
- label: 'Thread',
168
- groupLabel: 'Thread checks',
169
- titlePrefix: 'Thread check',
170
- subjectLabel: 'Promise to reader',
171
- noteLabel: 'Thread question',
172
- notePlaceholder: 'What promise or payoff should be checked?',
173
- defaultStatus: 'open',
174
- defaultSeverity: 'advisory',
175
- statuses: ['open', 'supported', 'rejected'],
176
- },
177
- conflict: {
178
- label: 'Conflict',
179
- groupLabel: 'Conflict checks',
180
- titlePrefix: 'Conflict check',
181
- subjectLabel: 'Conflicting statement',
182
- noteLabel: 'Conflict question',
183
- notePlaceholder: 'What statements need to be reconciled?',
184
- defaultStatus: 'needs-support',
185
- defaultSeverity: 'advisory',
186
- statuses: ['needs-support', 'supported', 'rejected'],
187
- },
188
- logic: {
189
- label: 'Logic',
190
- groupLabel: 'Logic checks',
191
- titlePrefix: 'Logic check',
192
- subjectLabel: 'Reasoning gap',
193
- noteLabel: 'Logic question',
194
- notePlaceholder: 'What missing step should be checked?',
195
- defaultStatus: 'needs-support',
196
- defaultSeverity: 'advisory',
197
- statuses: ['needs-support', 'supported', 'rejected'],
198
- },
199
- copyedit: {
200
- label: 'Copyedit',
201
- groupLabel: 'Copyedit',
202
- titlePrefix: 'Copyedit',
203
- subjectLabel: 'Text to correct',
204
- noteLabel: 'Suggestion',
205
- notePlaceholder: 'Name the rule, correction, or ambiguity...',
206
- defaultStatus: 'suggested',
207
- defaultSeverity: 'note',
208
- statuses: ['suggested', 'stale', 'supported', 'rejected'],
209
- },
210
- voice: {
211
- label: 'Voice',
212
- groupLabel: 'Voice',
213
- titlePrefix: 'Voice check',
214
- subjectLabel: 'Voice drift',
215
- noteLabel: 'Voice note',
216
- notePlaceholder: 'Point at the drift from the author profile...',
217
- defaultStatus: 'needs-review',
218
- defaultSeverity: 'advisory',
219
- statuses: ['needs-review', 'supported', 'rejected'],
220
- },
221
- question: {
222
- label: 'Question',
223
- groupLabel: 'Other notes',
224
- titlePrefix: 'Question',
225
- subjectLabel: 'Question target',
226
- noteLabel: 'Question',
227
- notePlaceholder: 'Ask the author or editor a question...',
228
- defaultStatus: 'open',
229
- defaultSeverity: 'note',
230
- statuses: ['open'],
231
- },
232
- citation: {
233
- label: 'Citation issue',
234
- groupLabel: 'Citation issues',
235
- titlePrefix: 'Citation issue',
236
- subjectLabel: 'Citation target',
237
- noteLabel: 'Citation note',
238
- notePlaceholder: 'Describe the missing, weak, or mismatched source...',
239
- defaultStatus: 'needs-source',
240
- defaultSeverity: 'advisory',
241
- statuses: ['needs-source', 'supported', 'rejected'],
242
- },
243
- }
244
-
245
- export function normalizeCommentType(
246
- value: string | null | undefined,
247
- ): CommentType {
248
- return COMMENT_TYPES.includes(value as CommentType)
249
- ? (value as CommentType)
250
- : 'general'
251
- }
252
-
253
- export function normalizeCommentReviewStatus(
254
- value: string | null | undefined,
255
- type: CommentType = 'general',
256
- ): CommentReviewStatus {
257
- return COMMENT_REVIEW_STATUSES.includes(value as CommentReviewStatus)
258
- ? (value as CommentReviewStatus)
259
- : COMMENT_TYPE_DEFINITIONS[type].defaultStatus
260
- }
261
-
262
- export function normalizeCommentSeverity(
263
- value: string | null | undefined,
264
- type: CommentType = 'general',
265
- ): CommentSeverity {
266
- return COMMENT_SEVERITIES.includes(value as CommentSeverity)
267
- ? (value as CommentSeverity)
268
- : COMMENT_TYPE_DEFINITIONS[type].defaultSeverity
269
- }
270
-
271
- export function defaultCommentReviewStatusForType(
272
- type: CommentType,
273
- ): CommentReviewStatus {
274
- return COMMENT_TYPE_DEFINITIONS[type].defaultStatus
275
- }
276
-
277
- export function defaultCommentSeverityForType(
278
- type: CommentType,
279
- ): CommentSeverity {
280
- return COMMENT_TYPE_DEFINITIONS[type].defaultSeverity
281
- }
282
-
283
- export interface CommentReply {
284
- id: string
285
- text: string
286
- author?: string
287
- createdAt?: string
288
- }
289
-
290
- export interface CommentMarkAttrs {
291
- commentId: string
292
- commentText: string
293
- author?: string
294
- createdAt?: string
295
- commentType?: CommentType
296
- subjectText?: string
297
- reviewStatus?: CommentReviewStatus
298
- severity?: CommentSeverity
299
- replies?: string
300
- resolvedAt?: string
301
- resolvedBy?: string
302
- }
303
-
304
- export type CommentMarkAttrPatch = {
305
- [Key in keyof CommentMarkAttrs]?: CommentMarkAttrs[Key] | null
306
- }
307
-
308
- export interface CommentMarkMutationOptions {
309
- scrollIntoView?: boolean
310
- focusEditor?: boolean
311
- }
312
-
313
- export interface AppliedChangeMarkAttrs {
314
- originalText: string
315
- replacementText: string
316
- sourceCommentId?: string
317
- sourceType?: string
318
- actor?: string
319
- createdAt?: string
320
- }
321
-
322
- export function applyCommentMarkToRange(
323
- editor: Editor,
324
- from: number,
325
- to: number,
326
- attrs: CommentMarkAttrs,
327
- options: CommentMarkMutationOptions = {},
328
- ): boolean {
329
- if (editor.isDestroyed) return false
330
- const markType = editor.schema.marks.commentMark
331
- if (!markType) return false
332
- const start = Math.max(0, Math.min(from, to, editor.state.doc.content.size))
333
- const end = Math.max(
334
- 0,
335
- Math.min(Math.max(from, to), editor.state.doc.content.size),
336
- )
337
- if (start === end) return false
338
- const tr = editor.state.tr.addMark(start, end, markType.create(attrs))
339
- editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
340
- if (options.focusEditor) editor.commands.focus(end)
341
- return true
342
- }
343
-
344
- export function updateCommentMarksById(
345
- editor: Editor,
346
- commentId: string,
347
- patch: CommentMarkAttrPatch,
348
- options: CommentMarkMutationOptions = {},
349
- ): boolean {
350
- if (editor.isDestroyed || !commentId) return false
351
- const markType = editor.schema.marks.commentMark
352
- if (!markType) return false
353
-
354
- let changed = false
355
- const tr = editor.state.tr
356
- editor.state.doc.descendants((node, pos) => {
357
- if (!node.isText) return true
358
- for (const mark of node.marks) {
359
- if (mark.type !== markType || mark.attrs.commentId !== commentId) continue
360
- const from = pos
361
- const to = pos + node.nodeSize
362
- const nextAttrs = { ...mark.attrs, ...patch }
363
- tr.removeMark(from, to, markType)
364
- tr.addMark(from, to, markType.create(nextAttrs))
365
- changed = true
366
- }
367
- return true
368
- })
369
-
370
- if (!changed) return false
371
- editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
372
- if (options.focusEditor) editor.commands.focus()
373
- return true
374
- }
375
-
376
- export function removeCommentMarksById(
377
- editor: Editor,
378
- commentId: string,
379
- options: CommentMarkMutationOptions = {},
380
- ): boolean {
381
- if (editor.isDestroyed || !commentId) return false
382
- const markType = editor.schema.marks.commentMark
383
- if (!markType) return false
384
-
385
- let changed = false
386
- const tr = editor.state.tr
387
- editor.state.doc.descendants((node, pos) => {
388
- if (!node.isText) return true
389
- for (const mark of node.marks) {
390
- if (mark.type !== markType || mark.attrs.commentId !== commentId) continue
391
- tr.removeMark(pos, pos + node.nodeSize, markType)
392
- changed = true
393
- }
394
- return true
395
- })
396
-
397
- if (!changed) return false
398
- editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
399
- if (options.focusEditor) editor.commands.focus()
400
- return true
401
- }
402
-
403
- export function removeAllCommentMarks(
404
- editor: Editor,
405
- options: CommentMarkMutationOptions = {},
406
- ): number {
407
- if (editor.isDestroyed) return 0
408
- const markType = editor.schema.marks.commentMark
409
- if (!markType) return 0
410
-
411
- const removedIds = new Set<string>()
412
- let changed = false
413
- editor.state.doc.descendants(node => {
414
- if (!node.isText) return true
415
- for (const mark of node.marks) {
416
- if (mark.type !== markType) continue
417
- const commentId = String(mark.attrs.commentId ?? '')
418
- if (commentId) removedIds.add(commentId)
419
- changed = true
420
- }
421
- return true
422
- })
423
-
424
- if (!changed) return 0
425
- const tr = editor.state.tr.removeMark(
426
- 0,
427
- editor.state.doc.content.size,
428
- markType,
429
- )
430
- editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
431
- if (options.focusEditor) editor.commands.focus()
432
- return removedIds.size || 1
433
- }
434
-
435
- export function stripCommentMarkupFromHtml(html: string): {
436
- html: string
437
- removed: number
438
- } {
439
- if (!html.includes('data-comment-id')) return { html, removed: 0 }
440
- const parser = new DOMParser()
441
- const doc = parser.parseFromString(html, 'text/html')
442
- let removed = 0
443
- const commentElements = Array.from(
444
- doc.body.querySelectorAll<HTMLElement>('[data-comment-id]'),
445
- )
446
- for (const element of commentElements) {
447
- removed += 1
448
- element.replaceWith(...Array.from(element.childNodes))
449
- }
450
- for (const element of Array.from(
451
- doc.body.querySelectorAll<HTMLElement>('*'),
452
- )) {
453
- for (const attribute of Array.from(element.attributes)) {
454
- if (
455
- attribute.name.startsWith('data-comment-') ||
456
- attribute.name === 'data-applied-change'
457
- ) {
458
- element.removeAttribute(attribute.name)
459
- }
460
- }
461
- element.classList.remove('comment-mark', 'comment-mark--active')
462
- if (!element.getAttribute('class')) element.removeAttribute('class')
463
- }
464
- return { html: doc.body.innerHTML, removed }
465
- }
466
-
467
- export function applyCommentReplacementById(
468
- editor: Editor,
469
- commentId: string,
470
- replacementText: string,
471
- attrs: Omit<AppliedChangeMarkAttrs, 'replacementText'>,
472
- options: CommentMarkMutationOptions = {},
473
- ): boolean {
474
- if (editor.isDestroyed || !commentId) return false
475
- const replacement = replacementText.trim()
476
- if (!replacement) return false
477
- const commentMarkType = editor.schema.marks.commentMark
478
- const appliedMarkType = editor.schema.marks.appliedChangeMark
479
- if (!commentMarkType || !appliedMarkType) return false
480
-
481
- let from: number | null = null
482
- let to: number | null = null
483
- editor.state.doc.descendants((node, pos) => {
484
- if (!node.isText) return true
485
- const hasComment = node.marks.some(
486
- mark =>
487
- mark.type === commentMarkType && mark.attrs.commentId === commentId,
488
- )
489
- if (!hasComment) return true
490
- from = from === null ? pos : Math.min(from, pos)
491
- to = to === null ? pos + node.nodeSize : Math.max(to, pos + node.nodeSize)
492
- return true
493
- })
494
-
495
- if (from === null || to === null || from >= to) return false
496
- const originalText =
497
- attrs.originalText || editor.state.doc.textBetween(from, to, ' ')
498
- const appliedMark = appliedMarkType.create({
499
- ...attrs,
500
- originalText,
501
- replacementText: replacement,
502
- sourceCommentId: attrs.sourceCommentId ?? commentId,
503
- })
504
- const replacementNode = editor.schema.text(replacement, [appliedMark])
505
- const tr = editor.state.tr.replaceWith(from, to, replacementNode)
506
- editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
507
- if (options.focusEditor) editor.commands.focus(from + replacement.length)
508
- return true
509
- }
510
-
511
- export function parseCommentReplies(
512
- value: string | null | undefined,
513
- ): CommentReply[] {
514
- if (!value) return []
515
- try {
516
- const parsed = JSON.parse(value) as unknown
517
- if (!Array.isArray(parsed)) return []
518
- return parsed
519
- .map((reply): CommentReply | null => {
520
- if (!reply || typeof reply !== 'object') return null
521
- const candidate = reply as Partial<CommentReply>
522
- if (typeof candidate.text !== 'string' || !candidate.text.trim())
523
- return null
524
- return {
525
- id:
526
- typeof candidate.id === 'string' && candidate.id
527
- ? candidate.id
528
- : `reply-${Math.random().toString(36).slice(2)}`,
529
- text: candidate.text,
530
- author:
531
- typeof candidate.author === 'string' ? candidate.author : undefined,
532
- createdAt:
533
- typeof candidate.createdAt === 'string'
534
- ? candidate.createdAt
535
- : undefined,
536
- }
537
- })
538
- .filter((reply): reply is CommentReply => reply !== null)
539
- } catch {
540
- return []
541
- }
542
- }
543
-
544
- export function serializeCommentReplies(replies: CommentReply[]): string {
545
- return JSON.stringify(replies)
546
- }
1
+ import type { Editor } from '@tiptap/core'
2
+
3
+ export const COMMENT_TYPES = [
4
+ 'general',
5
+ 'agent-review',
6
+ 'formatting',
7
+ 'copy-editing',
8
+ 'claim',
9
+ 'fact-check',
10
+ 'source',
11
+ 'thread',
12
+ 'conflict',
13
+ 'logic',
14
+ 'copyedit',
15
+ 'voice',
16
+ 'question',
17
+ 'citation',
18
+ ] as const
19
+
20
+ export type CommentType = (typeof COMMENT_TYPES)[number]
21
+
22
+ export const COMMENT_REVIEW_STATUSES = [
23
+ 'open',
24
+ 'needs-support',
25
+ 'supported',
26
+ 'rejected',
27
+ 'unverified',
28
+ 'verified',
29
+ 'disputed',
30
+ 'needs-source',
31
+ 'suggested',
32
+ 'needs-review',
33
+ 'stale',
34
+ ] as const
35
+
36
+ export type CommentReviewStatus = (typeof COMMENT_REVIEW_STATUSES)[number]
37
+
38
+ export const COMMENT_SEVERITIES = ['note', 'advisory', 'blocking'] as const
39
+
40
+ export type CommentSeverity = (typeof COMMENT_SEVERITIES)[number]
41
+
42
+ export const COMMENT_TYPE_LABELS: Record<CommentType, string> = {
43
+ general: 'Comment',
44
+ 'agent-review': 'Agent review',
45
+ formatting: 'Formatting',
46
+ 'copy-editing': 'Edit suggestion',
47
+ claim: 'Claim',
48
+ 'fact-check': 'Fact check',
49
+ source: 'Source',
50
+ thread: 'Thread',
51
+ conflict: 'Conflict',
52
+ logic: 'Logic',
53
+ copyedit: 'Copyedit',
54
+ voice: 'Voice',
55
+ question: 'Question',
56
+ citation: 'Citation',
57
+ }
58
+
59
+ export interface CommentTypeDefinition {
60
+ label: string
61
+ groupLabel: string
62
+ titlePrefix: string
63
+ subjectLabel: string
64
+ noteLabel: string
65
+ notePlaceholder: string
66
+ defaultStatus: CommentReviewStatus
67
+ defaultSeverity: CommentSeverity
68
+ statuses: CommentReviewStatus[]
69
+ }
70
+
71
+ export const COMMENT_STATUS_LABELS: Record<CommentReviewStatus, string> = {
72
+ open: 'Open',
73
+ 'needs-support': 'Needs support',
74
+ supported: 'Supported',
75
+ rejected: 'Rejected',
76
+ unverified: 'Unverified',
77
+ verified: 'Verified',
78
+ disputed: 'Disputed',
79
+ 'needs-source': 'Needs source',
80
+ suggested: 'Suggested',
81
+ 'needs-review': 'Needs review',
82
+ stale: 'Stale',
83
+ }
84
+
85
+ export const COMMENT_TYPE_DEFINITIONS: Record<
86
+ CommentType,
87
+ CommentTypeDefinition
88
+ > = {
89
+ general: {
90
+ label: 'Comment',
91
+ groupLabel: 'Other notes',
92
+ titlePrefix: 'Comment',
93
+ subjectLabel: 'Selected text',
94
+ noteLabel: 'Note',
95
+ notePlaceholder: 'Add an editorial note...',
96
+ defaultStatus: 'open',
97
+ defaultSeverity: 'note',
98
+ statuses: ['open'],
99
+ },
100
+ 'agent-review': {
101
+ label: 'Agent review',
102
+ groupLabel: 'Other notes',
103
+ titlePrefix: 'Agent review',
104
+ subjectLabel: 'Review target',
105
+ noteLabel: 'Review note',
106
+ notePlaceholder: 'Summarize the review finding...',
107
+ defaultStatus: 'open',
108
+ defaultSeverity: 'advisory',
109
+ statuses: ['open'],
110
+ },
111
+ formatting: {
112
+ label: 'Formatting',
113
+ groupLabel: 'Formatting',
114
+ titlePrefix: 'Formatting issue',
115
+ subjectLabel: 'Affected text',
116
+ noteLabel: 'Formatting note',
117
+ notePlaceholder: 'Describe the layout, style, or export issue...',
118
+ defaultStatus: 'open',
119
+ defaultSeverity: 'advisory',
120
+ statuses: ['open'],
121
+ },
122
+ 'copy-editing': {
123
+ label: 'Edit suggestion',
124
+ groupLabel: 'Edit suggestions',
125
+ titlePrefix: 'Edit suggestion',
126
+ subjectLabel: 'Suggested edit target',
127
+ noteLabel: 'Suggestion',
128
+ notePlaceholder: 'Describe the proposed wording change...',
129
+ defaultStatus: 'open',
130
+ defaultSeverity: 'note',
131
+ statuses: ['open'],
132
+ },
133
+ claim: {
134
+ label: 'Claim',
135
+ groupLabel: 'Claims',
136
+ titlePrefix: 'Claim',
137
+ subjectLabel: 'Claim under review',
138
+ noteLabel: 'Support needed',
139
+ notePlaceholder: 'What support, citation, or rewrite is needed?',
140
+ defaultStatus: 'needs-support',
141
+ defaultSeverity: 'advisory',
142
+ statuses: ['needs-support', 'supported', 'rejected'],
143
+ },
144
+ 'fact-check': {
145
+ label: 'Fact check',
146
+ groupLabel: 'Fact checks',
147
+ titlePrefix: 'Fact check',
148
+ subjectLabel: 'Statement to verify',
149
+ noteLabel: 'Verification note',
150
+ notePlaceholder: 'What should be verified, and against what source?',
151
+ defaultStatus: 'unverified',
152
+ defaultSeverity: 'advisory',
153
+ statuses: ['unverified', 'verified', 'disputed'],
154
+ },
155
+ source: {
156
+ label: 'Source',
157
+ groupLabel: 'Source checks',
158
+ titlePrefix: 'Source check',
159
+ subjectLabel: 'Attributed claim',
160
+ noteLabel: 'Source question',
161
+ notePlaceholder: 'What should be confirmed against the cited source?',
162
+ defaultStatus: 'unverified',
163
+ defaultSeverity: 'advisory',
164
+ statuses: ['unverified', 'verified', 'disputed'],
165
+ },
166
+ thread: {
167
+ label: 'Thread',
168
+ groupLabel: 'Thread checks',
169
+ titlePrefix: 'Thread check',
170
+ subjectLabel: 'Promise to reader',
171
+ noteLabel: 'Thread question',
172
+ notePlaceholder: 'What promise or payoff should be checked?',
173
+ defaultStatus: 'open',
174
+ defaultSeverity: 'advisory',
175
+ statuses: ['open', 'supported', 'rejected'],
176
+ },
177
+ conflict: {
178
+ label: 'Conflict',
179
+ groupLabel: 'Conflict checks',
180
+ titlePrefix: 'Conflict check',
181
+ subjectLabel: 'Conflicting statement',
182
+ noteLabel: 'Conflict question',
183
+ notePlaceholder: 'What statements need to be reconciled?',
184
+ defaultStatus: 'needs-support',
185
+ defaultSeverity: 'advisory',
186
+ statuses: ['needs-support', 'supported', 'rejected'],
187
+ },
188
+ logic: {
189
+ label: 'Logic',
190
+ groupLabel: 'Logic checks',
191
+ titlePrefix: 'Logic check',
192
+ subjectLabel: 'Reasoning gap',
193
+ noteLabel: 'Logic question',
194
+ notePlaceholder: 'What missing step should be checked?',
195
+ defaultStatus: 'needs-support',
196
+ defaultSeverity: 'advisory',
197
+ statuses: ['needs-support', 'supported', 'rejected'],
198
+ },
199
+ copyedit: {
200
+ label: 'Copyedit',
201
+ groupLabel: 'Copyedit',
202
+ titlePrefix: 'Copyedit',
203
+ subjectLabel: 'Text to correct',
204
+ noteLabel: 'Suggestion',
205
+ notePlaceholder: 'Name the rule, correction, or ambiguity...',
206
+ defaultStatus: 'suggested',
207
+ defaultSeverity: 'note',
208
+ statuses: ['suggested', 'stale', 'supported', 'rejected'],
209
+ },
210
+ voice: {
211
+ label: 'Voice',
212
+ groupLabel: 'Voice',
213
+ titlePrefix: 'Voice check',
214
+ subjectLabel: 'Voice drift',
215
+ noteLabel: 'Voice note',
216
+ notePlaceholder: 'Point at the drift from the author profile...',
217
+ defaultStatus: 'needs-review',
218
+ defaultSeverity: 'advisory',
219
+ statuses: ['needs-review', 'supported', 'rejected'],
220
+ },
221
+ question: {
222
+ label: 'Question',
223
+ groupLabel: 'Other notes',
224
+ titlePrefix: 'Question',
225
+ subjectLabel: 'Question target',
226
+ noteLabel: 'Question',
227
+ notePlaceholder: 'Ask the author or editor a question...',
228
+ defaultStatus: 'open',
229
+ defaultSeverity: 'note',
230
+ statuses: ['open'],
231
+ },
232
+ citation: {
233
+ label: 'Citation issue',
234
+ groupLabel: 'Citation issues',
235
+ titlePrefix: 'Citation issue',
236
+ subjectLabel: 'Citation target',
237
+ noteLabel: 'Citation note',
238
+ notePlaceholder: 'Describe the missing, weak, or mismatched source...',
239
+ defaultStatus: 'needs-source',
240
+ defaultSeverity: 'advisory',
241
+ statuses: ['needs-source', 'supported', 'rejected'],
242
+ },
243
+ }
244
+
245
+ export function normalizeCommentType(
246
+ value: string | null | undefined,
247
+ ): CommentType {
248
+ return COMMENT_TYPES.includes(value as CommentType)
249
+ ? (value as CommentType)
250
+ : 'general'
251
+ }
252
+
253
+ export function normalizeCommentReviewStatus(
254
+ value: string | null | undefined,
255
+ type: CommentType = 'general',
256
+ ): CommentReviewStatus {
257
+ return COMMENT_REVIEW_STATUSES.includes(value as CommentReviewStatus)
258
+ ? (value as CommentReviewStatus)
259
+ : COMMENT_TYPE_DEFINITIONS[type].defaultStatus
260
+ }
261
+
262
+ export function normalizeCommentSeverity(
263
+ value: string | null | undefined,
264
+ type: CommentType = 'general',
265
+ ): CommentSeverity {
266
+ return COMMENT_SEVERITIES.includes(value as CommentSeverity)
267
+ ? (value as CommentSeverity)
268
+ : COMMENT_TYPE_DEFINITIONS[type].defaultSeverity
269
+ }
270
+
271
+ export function defaultCommentReviewStatusForType(
272
+ type: CommentType,
273
+ ): CommentReviewStatus {
274
+ return COMMENT_TYPE_DEFINITIONS[type].defaultStatus
275
+ }
276
+
277
+ export function defaultCommentSeverityForType(
278
+ type: CommentType,
279
+ ): CommentSeverity {
280
+ return COMMENT_TYPE_DEFINITIONS[type].defaultSeverity
281
+ }
282
+
283
+ export interface CommentReply {
284
+ id: string
285
+ text: string
286
+ author?: string
287
+ createdAt?: string
288
+ }
289
+
290
+ export interface CommentMarkAttrs {
291
+ commentId: string
292
+ commentText: string
293
+ author?: string
294
+ createdAt?: string
295
+ commentType?: CommentType
296
+ subjectText?: string
297
+ reviewStatus?: CommentReviewStatus
298
+ severity?: CommentSeverity
299
+ replies?: string
300
+ resolvedAt?: string
301
+ resolvedBy?: string
302
+ }
303
+
304
+ export type CommentMarkAttrPatch = {
305
+ [Key in keyof CommentMarkAttrs]?: CommentMarkAttrs[Key] | null
306
+ }
307
+
308
+ export interface CommentMarkMutationOptions {
309
+ scrollIntoView?: boolean
310
+ focusEditor?: boolean
311
+ }
312
+
313
+ export interface AppliedChangeMarkAttrs {
314
+ originalText: string
315
+ replacementText: string
316
+ sourceCommentId?: string
317
+ sourceType?: string
318
+ actor?: string
319
+ createdAt?: string
320
+ }
321
+
322
+ export function applyCommentMarkToRange(
323
+ editor: Editor,
324
+ from: number,
325
+ to: number,
326
+ attrs: CommentMarkAttrs,
327
+ options: CommentMarkMutationOptions = {},
328
+ ): boolean {
329
+ if (editor.isDestroyed) return false
330
+ const markType = editor.schema.marks.commentMark
331
+ if (!markType) return false
332
+ const start = Math.max(0, Math.min(from, to, editor.state.doc.content.size))
333
+ const end = Math.max(
334
+ 0,
335
+ Math.min(Math.max(from, to), editor.state.doc.content.size),
336
+ )
337
+ if (start === end) return false
338
+ const tr = editor.state.tr.addMark(start, end, markType.create(attrs))
339
+ editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
340
+ if (options.focusEditor) editor.commands.focus(end)
341
+ return true
342
+ }
343
+
344
+ export function updateCommentMarksById(
345
+ editor: Editor,
346
+ commentId: string,
347
+ patch: CommentMarkAttrPatch,
348
+ options: CommentMarkMutationOptions = {},
349
+ ): boolean {
350
+ if (editor.isDestroyed || !commentId) return false
351
+ const markType = editor.schema.marks.commentMark
352
+ if (!markType) return false
353
+
354
+ let changed = false
355
+ const tr = editor.state.tr
356
+ editor.state.doc.descendants((node, pos) => {
357
+ if (!node.isText) return true
358
+ for (const mark of node.marks) {
359
+ if (mark.type !== markType || mark.attrs.commentId !== commentId) continue
360
+ const from = pos
361
+ const to = pos + node.nodeSize
362
+ const nextAttrs = { ...mark.attrs, ...patch }
363
+ tr.removeMark(from, to, markType)
364
+ tr.addMark(from, to, markType.create(nextAttrs))
365
+ changed = true
366
+ }
367
+ return true
368
+ })
369
+
370
+ if (!changed) return false
371
+ editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
372
+ if (options.focusEditor) editor.commands.focus()
373
+ return true
374
+ }
375
+
376
+ export function removeCommentMarksById(
377
+ editor: Editor,
378
+ commentId: string,
379
+ options: CommentMarkMutationOptions = {},
380
+ ): boolean {
381
+ if (editor.isDestroyed || !commentId) return false
382
+ const markType = editor.schema.marks.commentMark
383
+ if (!markType) return false
384
+
385
+ let changed = false
386
+ const tr = editor.state.tr
387
+ editor.state.doc.descendants((node, pos) => {
388
+ if (!node.isText) return true
389
+ for (const mark of node.marks) {
390
+ if (mark.type !== markType || mark.attrs.commentId !== commentId) continue
391
+ tr.removeMark(pos, pos + node.nodeSize, markType)
392
+ changed = true
393
+ }
394
+ return true
395
+ })
396
+
397
+ if (!changed) return false
398
+ editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
399
+ if (options.focusEditor) editor.commands.focus()
400
+ return true
401
+ }
402
+
403
+ export function removeAllCommentMarks(
404
+ editor: Editor,
405
+ options: CommentMarkMutationOptions = {},
406
+ ): number {
407
+ if (editor.isDestroyed) return 0
408
+ const markType = editor.schema.marks.commentMark
409
+ if (!markType) return 0
410
+
411
+ const removedIds = new Set<string>()
412
+ let changed = false
413
+ editor.state.doc.descendants(node => {
414
+ if (!node.isText) return true
415
+ for (const mark of node.marks) {
416
+ if (mark.type !== markType) continue
417
+ const commentId = String(mark.attrs.commentId ?? '')
418
+ if (commentId) removedIds.add(commentId)
419
+ changed = true
420
+ }
421
+ return true
422
+ })
423
+
424
+ if (!changed) return 0
425
+ const tr = editor.state.tr.removeMark(
426
+ 0,
427
+ editor.state.doc.content.size,
428
+ markType,
429
+ )
430
+ editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
431
+ if (options.focusEditor) editor.commands.focus()
432
+ return removedIds.size || 1
433
+ }
434
+
435
+ export function stripCommentMarkupFromHtml(html: string): {
436
+ html: string
437
+ removed: number
438
+ } {
439
+ if (!html.includes('data-comment-id')) return { html, removed: 0 }
440
+ const parser = new DOMParser()
441
+ const doc = parser.parseFromString(html, 'text/html')
442
+ let removed = 0
443
+ const commentElements = Array.from(
444
+ doc.body.querySelectorAll<HTMLElement>('[data-comment-id]'),
445
+ )
446
+ for (const element of commentElements) {
447
+ removed += 1
448
+ element.replaceWith(...Array.from(element.childNodes))
449
+ }
450
+ for (const element of Array.from(
451
+ doc.body.querySelectorAll<HTMLElement>('*'),
452
+ )) {
453
+ for (const attribute of Array.from(element.attributes)) {
454
+ if (
455
+ attribute.name.startsWith('data-comment-') ||
456
+ attribute.name === 'data-applied-change'
457
+ ) {
458
+ element.removeAttribute(attribute.name)
459
+ }
460
+ }
461
+ element.classList.remove('comment-mark', 'comment-mark--active')
462
+ if (!element.getAttribute('class')) element.removeAttribute('class')
463
+ }
464
+ return { html: doc.body.innerHTML, removed }
465
+ }
466
+
467
+ export function applyCommentReplacementById(
468
+ editor: Editor,
469
+ commentId: string,
470
+ replacementText: string,
471
+ attrs: Omit<AppliedChangeMarkAttrs, 'replacementText'>,
472
+ options: CommentMarkMutationOptions = {},
473
+ ): boolean {
474
+ if (editor.isDestroyed || !commentId) return false
475
+ const replacement = replacementText.trim()
476
+ if (!replacement) return false
477
+ const commentMarkType = editor.schema.marks.commentMark
478
+ const appliedMarkType = editor.schema.marks.appliedChangeMark
479
+ if (!commentMarkType || !appliedMarkType) return false
480
+
481
+ let from: number | null = null
482
+ let to: number | null = null
483
+ editor.state.doc.descendants((node, pos) => {
484
+ if (!node.isText) return true
485
+ const hasComment = node.marks.some(
486
+ mark =>
487
+ mark.type === commentMarkType && mark.attrs.commentId === commentId,
488
+ )
489
+ if (!hasComment) return true
490
+ from = from === null ? pos : Math.min(from, pos)
491
+ to = to === null ? pos + node.nodeSize : Math.max(to, pos + node.nodeSize)
492
+ return true
493
+ })
494
+
495
+ if (from === null || to === null || from >= to) return false
496
+ const originalText =
497
+ attrs.originalText || editor.state.doc.textBetween(from, to, ' ')
498
+ const appliedMark = appliedMarkType.create({
499
+ ...attrs,
500
+ originalText,
501
+ replacementText: replacement,
502
+ sourceCommentId: attrs.sourceCommentId ?? commentId,
503
+ })
504
+ const replacementNode = editor.schema.text(replacement, [appliedMark])
505
+ const tr = editor.state.tr.replaceWith(from, to, replacementNode)
506
+ editor.view.dispatch(options.scrollIntoView ? tr.scrollIntoView() : tr)
507
+ if (options.focusEditor) editor.commands.focus(from + replacement.length)
508
+ return true
509
+ }
510
+
511
+ export function parseCommentReplies(
512
+ value: string | null | undefined,
513
+ ): CommentReply[] {
514
+ if (!value) return []
515
+ try {
516
+ const parsed = JSON.parse(value) as unknown
517
+ if (!Array.isArray(parsed)) return []
518
+ return parsed
519
+ .map((reply): CommentReply | null => {
520
+ if (!reply || typeof reply !== 'object') return null
521
+ const candidate = reply as Partial<CommentReply>
522
+ if (typeof candidate.text !== 'string' || !candidate.text.trim())
523
+ return null
524
+ return {
525
+ id:
526
+ typeof candidate.id === 'string' && candidate.id
527
+ ? candidate.id
528
+ : `reply-${Math.random().toString(36).slice(2)}`,
529
+ text: candidate.text,
530
+ author:
531
+ typeof candidate.author === 'string' ? candidate.author : undefined,
532
+ createdAt:
533
+ typeof candidate.createdAt === 'string'
534
+ ? candidate.createdAt
535
+ : undefined,
536
+ }
537
+ })
538
+ .filter((reply): reply is CommentReply => reply !== null)
539
+ } catch {
540
+ return []
541
+ }
542
+ }
543
+
544
+ export function serializeCommentReplies(replies: CommentReply[]): string {
545
+ return JSON.stringify(replies)
546
+ }