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

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,350 +1,350 @@
1
- /**
2
- * @vitest-environment happy-dom
3
- */
4
- import { Editor } from '@tiptap/core'
5
- import StarterKit from '@tiptap/starter-kit'
6
- import { describe, expect, it, vi } from 'vitest'
7
- import {
8
- COMMENT_STATUS_LABELS,
9
- COMMENT_TYPE_DEFINITIONS,
10
- applyCommentMarkToRange,
11
- applyCommentReplacementById,
12
- normalizeCommentReviewStatus,
13
- removeAllCommentMarks,
14
- removeCommentMarksById,
15
- serializeCommentReplies,
16
- stripCommentMarkupFromHtml,
17
- updateCommentMarksById,
18
- } from './commentUtils.js'
19
- import { AppliedChangeMark } from './extensions/appliedChangeMark.js'
20
- import { CommentMark } from './extensions/commentMark.js'
21
-
22
- describe('comment marks', () => {
23
- it('does not scroll or focus the editor by default when creating marks', () => {
24
- const editor = new Editor({
25
- extensions: [StarterKit, CommentMark],
26
- content: '<p>Review this sentence.</p>',
27
- })
28
- const dispatchSpy = vi.spyOn(editor.view, 'dispatch')
29
- const focusSpy = vi.spyOn(editor.view, 'focus')
30
-
31
- expect(
32
- applyCommentMarkToRange(editor, 1, 7, {
33
- commentId: 'comment-quiet',
34
- commentText: 'No viewport jump.',
35
- }),
36
- ).toBe(true)
37
-
38
- expect(dispatchSpy).toHaveBeenCalledOnce()
39
- expect(dispatchSpy.mock.calls[0]?.[0].scrolledIntoView).toBe(false)
40
- expect(focusSpy).not.toHaveBeenCalled()
41
- editor.destroy()
42
- })
43
-
44
- it('can opt into scroll and focus behavior for compatibility', () => {
45
- const mark = { id: 'mark' }
46
- const transaction = {
47
- scrolledIntoView: false,
48
- addMark: vi.fn(() => transaction),
49
- scrollIntoView: vi.fn(() => {
50
- transaction.scrolledIntoView = true
51
- return transaction
52
- }),
53
- }
54
- const focusSpy = vi.fn()
55
- const dispatchSpy = vi.fn()
56
- const editor = {
57
- isDestroyed: false,
58
- schema: {
59
- marks: {
60
- commentMark: {
61
- create: vi.fn(() => mark),
62
- },
63
- },
64
- },
65
- state: {
66
- doc: { content: { size: 24 } },
67
- tr: transaction,
68
- },
69
- view: { dispatch: dispatchSpy },
70
- commands: { focus: focusSpy },
71
- } as unknown as Editor
72
-
73
- expect(
74
- applyCommentMarkToRange(
75
- editor,
76
- 1,
77
- 7,
78
- {
79
- commentId: 'comment-scroll',
80
- commentText: 'Scroll on purpose.',
81
- },
82
- { scrollIntoView: true, focusEditor: true },
83
- ),
84
- ).toBe(true)
85
-
86
- expect(transaction.scrollIntoView).toHaveBeenCalledOnce()
87
- expect(dispatchSpy).toHaveBeenCalledWith(transaction)
88
- expect(focusSpy).toHaveBeenCalledWith(7)
89
- })
90
-
91
- it('does not scroll or focus the editor by default when updating or deleting marks', () => {
92
- const editor = new Editor({
93
- extensions: [StarterKit, CommentMark],
94
- content: '<p>Review this sentence.</p>',
95
- })
96
- expect(
97
- applyCommentMarkToRange(editor, 1, 7, {
98
- commentId: 'comment-static',
99
- commentText: 'Static viewport.',
100
- }),
101
- ).toBe(true)
102
- const dispatchSpy = vi.spyOn(editor.view, 'dispatch')
103
- const focusSpy = vi.spyOn(editor.view, 'focus')
104
-
105
- expect(
106
- updateCommentMarksById(editor, 'comment-static', {
107
- commentText: 'Still static.',
108
- }),
109
- ).toBe(true)
110
- expect(removeCommentMarksById(editor, 'comment-static')).toBe(true)
111
-
112
- expect(dispatchSpy).toHaveBeenCalledTimes(2)
113
- expect(dispatchSpy.mock.calls[0]?.[0].scrolledIntoView).toBe(false)
114
- expect(dispatchSpy.mock.calls[1]?.[0].scrolledIntoView).toBe(false)
115
- expect(focusSpy).not.toHaveBeenCalled()
116
- editor.destroy()
117
- })
118
-
119
- it('clears every comment mark without deleting manuscript text', () => {
120
- const editor = new Editor({
121
- extensions: [StarterKit, CommentMark],
122
- content: '<p>Review this sentence and verify that statement.</p>',
123
- })
124
-
125
- expect(
126
- applyCommentMarkToRange(editor, 1, 7, {
127
- commentId: 'comment-clear-1',
128
- commentText: 'Support this.',
129
- commentType: 'claim',
130
- }),
131
- ).toBe(true)
132
- expect(
133
- applyCommentMarkToRange(editor, 26, 32, {
134
- commentId: 'comment-clear-2',
135
- commentText: 'Verify this.',
136
- commentType: 'fact-check',
137
- }),
138
- ).toBe(true)
139
-
140
- expect(removeAllCommentMarks(editor)).toBe(2)
141
- expect(editor.getHTML()).not.toContain('data-comment-id=')
142
- expect(editor.getText()).toContain(
143
- 'Review this sentence and verify that statement.',
144
- )
145
- editor.destroy()
146
- })
147
-
148
- it('serializes comment text into persisted editor HTML', () => {
149
- const editor = new Editor({
150
- extensions: [StarterKit, CommentMark],
151
- content: '<p>Review this sentence.</p>',
152
- })
153
-
154
- const start = editor.state.doc
155
- .textBetween(0, editor.state.doc.content.size)
156
- .indexOf('Review')
157
- expect(start).toBeGreaterThanOrEqual(0)
158
-
159
- const from = start + 1
160
- const to = from + 'Review'.length
161
- const applied = applyCommentMarkToRange(editor, from, to, {
162
- commentId: 'comment-1',
163
- commentText: 'Clarify the claim.',
164
- commentType: 'claim',
165
- subjectText: 'Review',
166
- author: 'Reviewer',
167
- createdAt: '2026-06-11T12:00:00.000Z',
168
- })
169
-
170
- expect(applied).toBe(true)
171
- expect(editor.getHTML()).toContain('data-comment-id="comment-1"')
172
- expect(editor.getHTML()).toContain('data-comment-text="Clarify the claim."')
173
- expect(editor.getHTML()).toContain('data-comment-type="claim"')
174
- expect(editor.getHTML()).toContain('data-comment-subject="Review"')
175
- expect(editor.getHTML()).toContain(
176
- 'data-comment-review-status="needs-support"',
177
- )
178
- expect(editor.getHTML()).toContain('data-comment-severity="advisory"')
179
- expect(editor.getHTML()).toContain('comment-mark--claim')
180
- expect(editor.getHTML()).toContain('data-comment-author="Reviewer"')
181
- editor.destroy()
182
- })
183
-
184
- it('serializes comment replies and resolution state', () => {
185
- const editor = new Editor({
186
- extensions: [StarterKit, CommentMark],
187
- content: '<p>Review this sentence.</p>',
188
- })
189
-
190
- expect(
191
- applyCommentMarkToRange(editor, 1, 7, {
192
- commentId: 'comment-2',
193
- commentText: 'Check this.',
194
- commentType: 'fact-check',
195
- subjectText: 'Review',
196
- author: 'Reviewer',
197
- createdAt: '2026-06-11T12:00:00.000Z',
198
- }),
199
- ).toBe(true)
200
-
201
- expect(
202
- updateCommentMarksById(editor, 'comment-2', {
203
- replies: serializeCommentReplies([
204
- {
205
- id: 'reply-1',
206
- text: 'Updated.',
207
- author: 'Author',
208
- createdAt: '2026-06-11T12:05:00.000Z',
209
- },
210
- ]),
211
- resolvedAt: '2026-06-11T12:10:00.000Z',
212
- resolvedBy: 'Author',
213
- }),
214
- ).toBe(true)
215
-
216
- expect(editor.getHTML()).toContain('data-comment-replies=')
217
- expect(editor.getHTML()).toContain('Updated.')
218
- expect(editor.getHTML()).toContain(
219
- 'data-comment-resolved-at="2026-06-11T12:10:00.000Z"',
220
- )
221
- expect(editor.getHTML()).toContain('comment-mark--resolved')
222
- expect(editor.getHTML()).toContain('data-comment-review-status="unverified"')
223
-
224
- expect(
225
- updateCommentMarksById(editor, 'comment-2', {
226
- resolvedAt: null,
227
- resolvedBy: null,
228
- }),
229
- ).toBe(true)
230
-
231
- expect(editor.getHTML()).not.toContain('data-comment-resolved-at')
232
- editor.destroy()
233
- })
234
-
235
- it('updates review status without changing manuscript text', () => {
236
- const editor = new Editor({
237
- extensions: [StarterKit, CommentMark],
238
- content: '<p>Review this sentence.</p>',
239
- })
240
-
241
- expect(
242
- applyCommentMarkToRange(editor, 1, 7, {
243
- commentId: 'comment-status',
244
- commentText: 'Needs support.',
245
- commentType: 'claim',
246
- subjectText: 'Review',
247
- }),
248
- ).toBe(true)
249
-
250
- expect(
251
- updateCommentMarksById(editor, 'comment-status', {
252
- reviewStatus: 'supported',
253
- }),
254
- ).toBe(true)
255
-
256
- expect(editor.getHTML()).toContain('data-comment-review-status="supported"')
257
- expect(editor.getText()).toContain('Review this sentence.')
258
- editor.destroy()
259
- })
260
-
261
- it('supports question annotations as a built-in comment type', () => {
262
- const editor = new Editor({
263
- extensions: [StarterKit, CommentMark],
264
- content: '<p>Can this claim be clearer?</p>',
265
- })
266
-
267
- expect(
268
- applyCommentMarkToRange(editor, 1, 4, {
269
- commentId: 'comment-question',
270
- commentText: 'Ask the author to clarify.',
271
- commentType: 'question',
272
- }),
273
- ).toBe(true)
274
-
275
- expect(editor.getHTML()).toContain('data-comment-type="question"')
276
- expect(editor.getHTML()).toContain('comment-mark--question')
277
- editor.destroy()
278
- })
279
-
280
- it('strips stale comment spans while preserving manuscript text', () => {
281
- const result = stripCommentMarkupFromHtml(
282
- '<p>Keep <span data-comment-id="c1" data-comment-type="claim" class="comment-mark comment-mark--claim">this text</span> clean.</p>',
283
- )
284
-
285
- expect(result.removed).toBe(1)
286
- expect(result.html).toBe('<p>Keep this text clean.</p>')
287
- expect(result.html).not.toContain('data-comment-id')
288
- expect(result.html).not.toContain('comment-mark')
289
- })
290
-
291
- it('supports stale copyedit annotations after standards change', () => {
292
- expect(COMMENT_STATUS_LABELS.stale).toBe('Stale')
293
- expect(COMMENT_TYPE_DEFINITIONS.copyedit.statuses).toContain('stale')
294
- expect(normalizeCommentReviewStatus('stale', 'copyedit')).toBe('stale')
295
- })
296
-
297
- it('removes all marks for a deleted comment', () => {
298
- const editor = new Editor({
299
- extensions: [StarterKit, CommentMark],
300
- content: '<p>Delete this annotation.</p>',
301
- })
302
-
303
- expect(
304
- applyCommentMarkToRange(editor, 1, 7, {
305
- commentId: 'comment-delete',
306
- commentText: 'Remove me.',
307
- commentType: 'general',
308
- }),
309
- ).toBe(true)
310
-
311
- expect(removeCommentMarksById(editor, 'comment-delete')).toBe(true)
312
- expect(editor.getHTML()).not.toContain('data-comment-id="comment-delete"')
313
- expect(editor.getHTML()).toContain('Delete this annotation.')
314
- editor.destroy()
315
- })
316
-
317
- it('accepts a copy edit as an applied manuscript change', () => {
318
- const editor = new Editor({
319
- extensions: [StarterKit, CommentMark, AppliedChangeMark],
320
- content: '<p>Review this sentence.</p>',
321
- })
322
-
323
- expect(
324
- applyCommentMarkToRange(editor, 1, 7, {
325
- commentId: 'comment-edit',
326
- commentText: 'Revise',
327
- commentType: 'copy-editing',
328
- subjectText: 'Review',
329
- }),
330
- ).toBe(true)
331
-
332
- expect(
333
- applyCommentReplacementById(editor, 'comment-edit', 'Revise', {
334
- originalText: 'Review',
335
- sourceType: 'copy-editing',
336
- actor: 'Author',
337
- createdAt: '2026-06-18T12:00:00.000Z',
338
- }),
339
- ).toBe(true)
340
-
341
- const html = editor.getHTML()
342
- expect(editor.getText()).toContain('Revise this sentence.')
343
- expect(html).toContain('data-pm-applied-change="true"')
344
- expect(html).toContain('data-pm-applied-original="Review"')
345
- expect(html).toContain('data-pm-applied-replacement="Revise"')
346
- expect(html).toContain('data-pm-applied-source-comment-id="comment-edit"')
347
- expect(html).not.toContain('data-comment-id="comment-edit"')
348
- editor.destroy()
349
- })
350
- })
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { Editor } from '@tiptap/core'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import {
8
+ COMMENT_STATUS_LABELS,
9
+ COMMENT_TYPE_DEFINITIONS,
10
+ applyCommentMarkToRange,
11
+ applyCommentReplacementById,
12
+ normalizeCommentReviewStatus,
13
+ removeAllCommentMarks,
14
+ removeCommentMarksById,
15
+ serializeCommentReplies,
16
+ stripCommentMarkupFromHtml,
17
+ updateCommentMarksById,
18
+ } from './commentUtils.js'
19
+ import { AppliedChangeMark } from './extensions/appliedChangeMark.js'
20
+ import { CommentMark } from './extensions/commentMark.js'
21
+
22
+ describe('comment marks', () => {
23
+ it('does not scroll or focus the editor by default when creating marks', () => {
24
+ const editor = new Editor({
25
+ extensions: [StarterKit, CommentMark],
26
+ content: '<p>Review this sentence.</p>',
27
+ })
28
+ const dispatchSpy = vi.spyOn(editor.view, 'dispatch')
29
+ const focusSpy = vi.spyOn(editor.view, 'focus')
30
+
31
+ expect(
32
+ applyCommentMarkToRange(editor, 1, 7, {
33
+ commentId: 'comment-quiet',
34
+ commentText: 'No viewport jump.',
35
+ }),
36
+ ).toBe(true)
37
+
38
+ expect(dispatchSpy).toHaveBeenCalledOnce()
39
+ expect(dispatchSpy.mock.calls[0]?.[0].scrolledIntoView).toBe(false)
40
+ expect(focusSpy).not.toHaveBeenCalled()
41
+ editor.destroy()
42
+ })
43
+
44
+ it('can opt into scroll and focus behavior for compatibility', () => {
45
+ const mark = { id: 'mark' }
46
+ const transaction = {
47
+ scrolledIntoView: false,
48
+ addMark: vi.fn(() => transaction),
49
+ scrollIntoView: vi.fn(() => {
50
+ transaction.scrolledIntoView = true
51
+ return transaction
52
+ }),
53
+ }
54
+ const focusSpy = vi.fn()
55
+ const dispatchSpy = vi.fn()
56
+ const editor = {
57
+ isDestroyed: false,
58
+ schema: {
59
+ marks: {
60
+ commentMark: {
61
+ create: vi.fn(() => mark),
62
+ },
63
+ },
64
+ },
65
+ state: {
66
+ doc: { content: { size: 24 } },
67
+ tr: transaction,
68
+ },
69
+ view: { dispatch: dispatchSpy },
70
+ commands: { focus: focusSpy },
71
+ } as unknown as Editor
72
+
73
+ expect(
74
+ applyCommentMarkToRange(
75
+ editor,
76
+ 1,
77
+ 7,
78
+ {
79
+ commentId: 'comment-scroll',
80
+ commentText: 'Scroll on purpose.',
81
+ },
82
+ { scrollIntoView: true, focusEditor: true },
83
+ ),
84
+ ).toBe(true)
85
+
86
+ expect(transaction.scrollIntoView).toHaveBeenCalledOnce()
87
+ expect(dispatchSpy).toHaveBeenCalledWith(transaction)
88
+ expect(focusSpy).toHaveBeenCalledWith(7)
89
+ })
90
+
91
+ it('does not scroll or focus the editor by default when updating or deleting marks', () => {
92
+ const editor = new Editor({
93
+ extensions: [StarterKit, CommentMark],
94
+ content: '<p>Review this sentence.</p>',
95
+ })
96
+ expect(
97
+ applyCommentMarkToRange(editor, 1, 7, {
98
+ commentId: 'comment-static',
99
+ commentText: 'Static viewport.',
100
+ }),
101
+ ).toBe(true)
102
+ const dispatchSpy = vi.spyOn(editor.view, 'dispatch')
103
+ const focusSpy = vi.spyOn(editor.view, 'focus')
104
+
105
+ expect(
106
+ updateCommentMarksById(editor, 'comment-static', {
107
+ commentText: 'Still static.',
108
+ }),
109
+ ).toBe(true)
110
+ expect(removeCommentMarksById(editor, 'comment-static')).toBe(true)
111
+
112
+ expect(dispatchSpy).toHaveBeenCalledTimes(2)
113
+ expect(dispatchSpy.mock.calls[0]?.[0].scrolledIntoView).toBe(false)
114
+ expect(dispatchSpy.mock.calls[1]?.[0].scrolledIntoView).toBe(false)
115
+ expect(focusSpy).not.toHaveBeenCalled()
116
+ editor.destroy()
117
+ })
118
+
119
+ it('clears every comment mark without deleting manuscript text', () => {
120
+ const editor = new Editor({
121
+ extensions: [StarterKit, CommentMark],
122
+ content: '<p>Review this sentence and verify that statement.</p>',
123
+ })
124
+
125
+ expect(
126
+ applyCommentMarkToRange(editor, 1, 7, {
127
+ commentId: 'comment-clear-1',
128
+ commentText: 'Support this.',
129
+ commentType: 'claim',
130
+ }),
131
+ ).toBe(true)
132
+ expect(
133
+ applyCommentMarkToRange(editor, 26, 32, {
134
+ commentId: 'comment-clear-2',
135
+ commentText: 'Verify this.',
136
+ commentType: 'fact-check',
137
+ }),
138
+ ).toBe(true)
139
+
140
+ expect(removeAllCommentMarks(editor)).toBe(2)
141
+ expect(editor.getHTML()).not.toContain('data-comment-id=')
142
+ expect(editor.getText()).toContain(
143
+ 'Review this sentence and verify that statement.',
144
+ )
145
+ editor.destroy()
146
+ })
147
+
148
+ it('serializes comment text into persisted editor HTML', () => {
149
+ const editor = new Editor({
150
+ extensions: [StarterKit, CommentMark],
151
+ content: '<p>Review this sentence.</p>',
152
+ })
153
+
154
+ const start = editor.state.doc
155
+ .textBetween(0, editor.state.doc.content.size)
156
+ .indexOf('Review')
157
+ expect(start).toBeGreaterThanOrEqual(0)
158
+
159
+ const from = start + 1
160
+ const to = from + 'Review'.length
161
+ const applied = applyCommentMarkToRange(editor, from, to, {
162
+ commentId: 'comment-1',
163
+ commentText: 'Clarify the claim.',
164
+ commentType: 'claim',
165
+ subjectText: 'Review',
166
+ author: 'Reviewer',
167
+ createdAt: '2026-06-11T12:00:00.000Z',
168
+ })
169
+
170
+ expect(applied).toBe(true)
171
+ expect(editor.getHTML()).toContain('data-comment-id="comment-1"')
172
+ expect(editor.getHTML()).toContain('data-comment-text="Clarify the claim."')
173
+ expect(editor.getHTML()).toContain('data-comment-type="claim"')
174
+ expect(editor.getHTML()).toContain('data-comment-subject="Review"')
175
+ expect(editor.getHTML()).toContain(
176
+ 'data-comment-review-status="needs-support"',
177
+ )
178
+ expect(editor.getHTML()).toContain('data-comment-severity="advisory"')
179
+ expect(editor.getHTML()).toContain('comment-mark--claim')
180
+ expect(editor.getHTML()).toContain('data-comment-author="Reviewer"')
181
+ editor.destroy()
182
+ })
183
+
184
+ it('serializes comment replies and resolution state', () => {
185
+ const editor = new Editor({
186
+ extensions: [StarterKit, CommentMark],
187
+ content: '<p>Review this sentence.</p>',
188
+ })
189
+
190
+ expect(
191
+ applyCommentMarkToRange(editor, 1, 7, {
192
+ commentId: 'comment-2',
193
+ commentText: 'Check this.',
194
+ commentType: 'fact-check',
195
+ subjectText: 'Review',
196
+ author: 'Reviewer',
197
+ createdAt: '2026-06-11T12:00:00.000Z',
198
+ }),
199
+ ).toBe(true)
200
+
201
+ expect(
202
+ updateCommentMarksById(editor, 'comment-2', {
203
+ replies: serializeCommentReplies([
204
+ {
205
+ id: 'reply-1',
206
+ text: 'Updated.',
207
+ author: 'Author',
208
+ createdAt: '2026-06-11T12:05:00.000Z',
209
+ },
210
+ ]),
211
+ resolvedAt: '2026-06-11T12:10:00.000Z',
212
+ resolvedBy: 'Author',
213
+ }),
214
+ ).toBe(true)
215
+
216
+ expect(editor.getHTML()).toContain('data-comment-replies=')
217
+ expect(editor.getHTML()).toContain('Updated.')
218
+ expect(editor.getHTML()).toContain(
219
+ 'data-comment-resolved-at="2026-06-11T12:10:00.000Z"',
220
+ )
221
+ expect(editor.getHTML()).toContain('comment-mark--resolved')
222
+ expect(editor.getHTML()).toContain('data-comment-review-status="unverified"')
223
+
224
+ expect(
225
+ updateCommentMarksById(editor, 'comment-2', {
226
+ resolvedAt: null,
227
+ resolvedBy: null,
228
+ }),
229
+ ).toBe(true)
230
+
231
+ expect(editor.getHTML()).not.toContain('data-comment-resolved-at')
232
+ editor.destroy()
233
+ })
234
+
235
+ it('updates review status without changing manuscript text', () => {
236
+ const editor = new Editor({
237
+ extensions: [StarterKit, CommentMark],
238
+ content: '<p>Review this sentence.</p>',
239
+ })
240
+
241
+ expect(
242
+ applyCommentMarkToRange(editor, 1, 7, {
243
+ commentId: 'comment-status',
244
+ commentText: 'Needs support.',
245
+ commentType: 'claim',
246
+ subjectText: 'Review',
247
+ }),
248
+ ).toBe(true)
249
+
250
+ expect(
251
+ updateCommentMarksById(editor, 'comment-status', {
252
+ reviewStatus: 'supported',
253
+ }),
254
+ ).toBe(true)
255
+
256
+ expect(editor.getHTML()).toContain('data-comment-review-status="supported"')
257
+ expect(editor.getText()).toContain('Review this sentence.')
258
+ editor.destroy()
259
+ })
260
+
261
+ it('supports question annotations as a built-in comment type', () => {
262
+ const editor = new Editor({
263
+ extensions: [StarterKit, CommentMark],
264
+ content: '<p>Can this claim be clearer?</p>',
265
+ })
266
+
267
+ expect(
268
+ applyCommentMarkToRange(editor, 1, 4, {
269
+ commentId: 'comment-question',
270
+ commentText: 'Ask the author to clarify.',
271
+ commentType: 'question',
272
+ }),
273
+ ).toBe(true)
274
+
275
+ expect(editor.getHTML()).toContain('data-comment-type="question"')
276
+ expect(editor.getHTML()).toContain('comment-mark--question')
277
+ editor.destroy()
278
+ })
279
+
280
+ it('strips stale comment spans while preserving manuscript text', () => {
281
+ const result = stripCommentMarkupFromHtml(
282
+ '<p>Keep <span data-comment-id="c1" data-comment-type="claim" class="comment-mark comment-mark--claim">this text</span> clean.</p>',
283
+ )
284
+
285
+ expect(result.removed).toBe(1)
286
+ expect(result.html).toBe('<p>Keep this text clean.</p>')
287
+ expect(result.html).not.toContain('data-comment-id')
288
+ expect(result.html).not.toContain('comment-mark')
289
+ })
290
+
291
+ it('supports stale copyedit annotations after standards change', () => {
292
+ expect(COMMENT_STATUS_LABELS.stale).toBe('Stale')
293
+ expect(COMMENT_TYPE_DEFINITIONS.copyedit.statuses).toContain('stale')
294
+ expect(normalizeCommentReviewStatus('stale', 'copyedit')).toBe('stale')
295
+ })
296
+
297
+ it('removes all marks for a deleted comment', () => {
298
+ const editor = new Editor({
299
+ extensions: [StarterKit, CommentMark],
300
+ content: '<p>Delete this annotation.</p>',
301
+ })
302
+
303
+ expect(
304
+ applyCommentMarkToRange(editor, 1, 7, {
305
+ commentId: 'comment-delete',
306
+ commentText: 'Remove me.',
307
+ commentType: 'general',
308
+ }),
309
+ ).toBe(true)
310
+
311
+ expect(removeCommentMarksById(editor, 'comment-delete')).toBe(true)
312
+ expect(editor.getHTML()).not.toContain('data-comment-id="comment-delete"')
313
+ expect(editor.getHTML()).toContain('Delete this annotation.')
314
+ editor.destroy()
315
+ })
316
+
317
+ it('accepts a copy edit as an applied manuscript change', () => {
318
+ const editor = new Editor({
319
+ extensions: [StarterKit, CommentMark, AppliedChangeMark],
320
+ content: '<p>Review this sentence.</p>',
321
+ })
322
+
323
+ expect(
324
+ applyCommentMarkToRange(editor, 1, 7, {
325
+ commentId: 'comment-edit',
326
+ commentText: 'Revise',
327
+ commentType: 'copy-editing',
328
+ subjectText: 'Review',
329
+ }),
330
+ ).toBe(true)
331
+
332
+ expect(
333
+ applyCommentReplacementById(editor, 'comment-edit', 'Revise', {
334
+ originalText: 'Review',
335
+ sourceType: 'copy-editing',
336
+ actor: 'Author',
337
+ createdAt: '2026-06-18T12:00:00.000Z',
338
+ }),
339
+ ).toBe(true)
340
+
341
+ const html = editor.getHTML()
342
+ expect(editor.getText()).toContain('Revise this sentence.')
343
+ expect(html).toContain('data-pm-applied-change="true"')
344
+ expect(html).toContain('data-pm-applied-original="Review"')
345
+ expect(html).toContain('data-pm-applied-replacement="Revise"')
346
+ expect(html).toContain('data-pm-applied-source-comment-id="comment-edit"')
347
+ expect(html).not.toContain('data-comment-id="comment-edit"')
348
+ editor.destroy()
349
+ })
350
+ })