@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/CollapsePanel.tsx +35 -35
- package/src/DocumentDiffView.tsx +130 -130
- package/src/DocumentEditor.test.ts +118 -118
- package/src/DocumentEditor.tsx +2651 -2631
- package/src/alignedLineDiff.test.ts +52 -52
- package/src/alignedLineDiff.ts +67 -67
- package/src/collectionAssetSrc.ts +2 -2
- package/src/commentUtils.test.ts +350 -350
- package/src/commentUtils.ts +546 -546
- package/src/constants/toolKeys.ts +14 -14
- package/src/contentFormat.test.ts +27 -27
- package/src/contentFormat.ts +18 -18
- package/src/editorExtensions.ts +155 -155
- package/src/extensions/appliedChangeMark.ts +115 -115
- package/src/extensions/autoReviewPrompts.test.ts +529 -529
- package/src/extensions/autoReviewPrompts.ts +1442 -1442
- package/src/extensions/collectionImage.ts +26 -26
- package/src/extensions/collectionImagePaste.ts +215 -215
- package/src/extensions/commentMark.ts +223 -223
- package/src/extensions/documentAssetIdentity.ts +54 -54
- package/src/extensions/figure.ts +92 -92
- package/src/extensions/footnote.ts +186 -186
- package/src/extensions/indexMarker.ts +88 -88
- package/src/extensions/mathEditing.ts +239 -239
- package/src/extensions/mermaidBlock.tsx +297 -297
- package/src/extensions/slashCommands.test.ts +170 -170
- package/src/extensions/slashCommands.ts +746 -746
- package/src/extensions/smartTypography.test.ts +74 -74
- package/src/extensions/smartTypography.ts +120 -120
- package/src/index.ts +138 -137
- package/src/insertCollectionImage.test.ts +60 -60
- package/src/insertCollectionImage.ts +63 -63
- package/src/insertInlineAssetKind.test.ts +67 -67
- package/src/insertInlineAssetKind.ts +49 -49
- package/src/mermaidPreview.test.ts +54 -54
- package/src/mermaidPreview.ts +115 -115
- package/src/styled.ts +697 -676
- package/src/toolbar/ToolBtn.tsx +77 -63
- package/src/toolbar/Toolbar.test.tsx +325 -325
- package/src/toolbar/Toolbar.tsx +662 -624
- package/src/toolbar/groups/HeadingGroup.tsx +175 -153
- package/src/toolbar/overlayTypes.ts +28 -28
- package/src/useEditorExtensions.ts +22 -22
- package/src/utils/markdownUtils.ts +331 -331
package/src/commentUtils.ts
CHANGED
|
@@ -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
|
+
}
|