diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index d608f75fb8..3285758cff 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1031,5 +1031,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "cdb0b02e84f6eb7f4db962c177d082e0f89ec48517abc775736a3d17e4da9ba8" + "sourceHash": "fa717df8cc013f9703b118596afe4cbbf655fe73f2e4e856588844110998ee2e" } diff --git a/apps/docs/document-api/reference/comments/patch.mdx b/apps/docs/document-api/reference/comments/patch.mdx index 252b0010f6..60204130ca 100644 --- a/apps/docs/document-api/reference/comments/patch.mdx +++ b/apps/docs/document-api/reference/comments/patch.mdx @@ -28,7 +28,7 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields | --- | --- | --- | --- | | `commentId` | string | yes | | | `isInternal` | boolean | no | | -| `status` | enum | no | `"resolved"` | +| `status` | enum | no | `"resolved"`, `"active"` | | `target` | TextAddress | no | TextAddress | | `target.blockId` | string | no | | | `target.kind` | `"text"` | no | Constant: `"text"` | @@ -124,9 +124,10 @@ Returns a Receipt confirming the comment was updated; reports NO_OP if no fields "type": "boolean" }, "status": { - "description": "Set comment status. Use 'resolved' to mark as resolved.", + "description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).", "enum": [ - "resolved" + "resolved", + "active" ] }, "target": { diff --git a/packages/document-api/src/comments/comments.test.ts b/packages/document-api/src/comments/comments.test.ts index 24063f41b7..ca6d3d7793 100644 --- a/packages/document-api/src/comments/comments.test.ts +++ b/packages/document-api/src/comments/comments.test.ts @@ -14,6 +14,7 @@ const stubAdapter = () => reply: mock(() => ({ success: true })), move: mock(() => ({ success: true })), resolve: mock(() => ({ success: true })), + reopen: mock(() => ({ success: true })), remove: mock(() => ({ success: true })), setInternal: mock(() => ({ success: true })), setActive: mock(() => ({ success: true })), @@ -60,7 +61,7 @@ describe('executeCommentsPatch validation', () => { it('rejects invalid status', () => { expect(() => executeCommentsPatch(stubAdapter(), { commentId: 'c1', status: 'open' } as any)).toThrow( - /must be "resolved"/, + /must be "resolved" or "active"/, ); }); @@ -75,6 +76,20 @@ describe('executeCommentsPatch validation', () => { executeCommentsPatch(adapter, { commentId: 'c1', isInternal: true }); expect(adapter.setInternal).toHaveBeenCalled(); }); + + it('routes status:"resolved" to adapter.resolve', () => { + const adapter = stubAdapter(); + executeCommentsPatch(adapter, { commentId: 'c1', status: 'resolved' }); + expect(adapter.resolve).toHaveBeenCalledWith({ commentId: 'c1' }, undefined); + expect(adapter.reopen).not.toHaveBeenCalled(); + }); + + it('routes status:"active" to adapter.reopen (lifecycle inverse of resolve)', () => { + const adapter = stubAdapter(); + executeCommentsPatch(adapter, { commentId: 'c1', status: 'active' }); + expect(adapter.reopen).toHaveBeenCalledWith({ commentId: 'c1' }, undefined); + expect(adapter.resolve).not.toHaveBeenCalled(); + }); }); describe('executeCommentsDelete validation', () => { diff --git a/packages/document-api/src/comments/comments.ts b/packages/document-api/src/comments/comments.ts index ec4bbe8445..09f08e7bee 100644 --- a/packages/document-api/src/comments/comments.ts +++ b/packages/document-api/src/comments/comments.ts @@ -44,6 +44,14 @@ export interface ResolveCommentInput { commentId: string; } +/** + * Input for reopening a previously-resolved comment. Accepted as the + * `status: 'active'` branch of `comments.patch`. + */ +export interface ReopenCommentInput { + commentId: string; +} + export interface RemoveCommentInput { commentId: string; } @@ -104,8 +112,12 @@ export interface CommentsPatchInput { text?: string; /** New anchor range (routes to move). */ target?: TextAddress; - /** Set status to 'resolved' (routes to resolve). */ - status?: 'resolved'; + /** + * Lifecycle transition. `'resolved'` routes to resolve, `'active'` + * routes to reopen — symmetric inverse that removes the resolve + * anchors and restores the live comment mark. + */ + status?: 'resolved' | 'active'; /** Set the internal/private flag (routes to setInternal). */ isInternal?: boolean; } @@ -132,6 +144,14 @@ export interface CommentsAdapter { move(input: MoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Resolve an open comment. */ resolve(input: ResolveCommentInput, options?: RevisionGuardOptions): Receipt; + /** + * Reopen a previously-resolved comment. Symmetric inverse of + * {@link CommentsAdapter.resolve}: removes the + * `commentRangeStart` / `commentRangeEnd` anchor nodes inserted at + * resolve time and restores the live `comment` mark across the + * original range so subsequent operations see the comment as active. + */ + reopen(input: ReopenCommentInput, options?: RevisionGuardOptions): Receipt; /** Remove a comment from the document. */ remove(input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt; /** Set the internal/private flag on a comment. */ @@ -268,11 +288,15 @@ function validatePatchCommentInput(input: unknown): asserts input is CommentsPat }); } - if (status !== undefined && status !== 'resolved') { - throw new DocumentApiValidationError('INVALID_INPUT', `status must be "resolved", got "${String(status)}".`, { - field: 'status', - value: status, - }); + if (status !== undefined && status !== 'resolved' && status !== 'active') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `status must be "resolved" or "active", got "${String(status)}".`, + { + field: 'status', + value: status, + }, + ); } if (isInternal !== undefined && typeof isInternal !== 'boolean') { @@ -341,6 +365,9 @@ export function executeCommentsPatch( if (input.status === 'resolved') { return adapter.resolve({ commentId: input.commentId }, options); } + if (input.status === 'active') { + return adapter.reopen({ commentId: input.commentId }, options); + } if (input.isInternal !== undefined) { return adapter.setInternal({ commentId: input.commentId, isInternal: input.isInternal }, options); } diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index b8e05565af..d77ea0fdd1 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -4812,7 +4812,11 @@ const operationSchemas: Record = { commentId: { type: 'string' }, text: { type: 'string', description: 'Updated comment text.' }, target: textAddressSchema, - status: { enum: ['resolved'], description: "Set comment status. Use 'resolved' to mark as resolved." }, + status: { + enum: ['resolved', 'active'], + description: + "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse).", + }, isInternal: { type: 'boolean', description: 'When true, marks the comment as internal (hidden from external collaborators).', diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index a30988b4a3..47e880c4c7 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -1416,6 +1416,7 @@ export type { ReplyToCommentInput, MoveCommentInput, ResolveCommentInput, + ReopenCommentInput, RemoveCommentInput, SetCommentInternalInput, GoToCommentInput, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts index ecb7ce0ec6..1fd01d7201 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.test.ts @@ -15,6 +15,7 @@ function makeEditor(overrides: Partial = {}): Editor { addCommentReply: vi.fn(() => true), moveComment: vi.fn(() => true), resolveComment: vi.fn(() => true), + reopenComment: vi.fn(() => true), removeComment: vi.fn(() => true), setCommentInternal: vi.fn(() => true), setActiveComment: vi.fn(() => true), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts index 584c403ac6..d8df91c1b9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/capabilities-adapter.ts @@ -59,7 +59,7 @@ const REQUIRED_COMMANDS: Partial 0; + const isResolvedInStore = existing ? isCommentResolved(existing) : false; + const isResolvedInDoc = isAnchored && identity.anchors.every((a) => a.status === 'resolved'); + if (!isResolvedInStore && !isResolvedInDoc) { + return { + success: false, + failure: { code: 'NO_OP', message: 'Comment is already active.' }, + }; + } + + // Recover the original `internal` flag from the entity store when + // present; the engine helper falls back to the value stamped on + // `commentRangeStart` when this is undefined, so a runtime-resolved + // comment with no entity record still round-trips correctly. + const storedInternal = (existing as { isInternal?: unknown } | undefined)?.isInternal; + const internalOverride = typeof storedInternal === 'boolean' ? storedInternal : undefined; + + const receipt = executeDomainCommand( + editor, + () => { + const didReopen = reopenComment({ + commentId: identity.commentId, + importedId: identity.importedId, + internal: internalOverride, + }); + if (didReopen) { + // Clear the resolved markers in the entity store so subsequent + // `comments.list()` reflects the reopen. `resolvedTime` is + // dropped explicitly because `upsertCommentEntity` merges + // partials and would otherwise leave the prior timestamp in + // place. + upsertCommentEntity(store, identity.commentId, { + importedId: identity.importedId, + isDone: false, + resolvedTime: null, + }); + } + return Boolean(didReopen); + }, + { expectedRevision: options?.expectedRevision }, + ); + + if (receipt.steps[0]?.effect !== 'changed') { + return { + success: false, + failure: { code: 'NO_OP', message: 'Comment reopen produced no change.' }, + }; + } + + return { success: true, updated: [toCommentAddress(identity.commentId)] }; +} + function removeCommentHandler(editor: Editor, input: RemoveCommentInput, options?: RevisionGuardOptions): Receipt { const removeComment = requireEditorCommand(editor.commands?.removeComment, 'comments.remove (removeComment)'); @@ -986,6 +1049,7 @@ export function createCommentsWrapper(editor: Editor): CommentsAdapter { move: (input: MoveCommentInput, options?: RevisionGuardOptions) => moveCommentHandler(editor, input, options), resolve: (input: ResolveCommentInput, options?: RevisionGuardOptions) => resolveCommentHandler(editor, input, options), + reopen: (input: ReopenCommentInput, options?: RevisionGuardOptions) => reopenCommentHandler(editor, input, options), remove: (input: RemoveCommentInput, options?: RevisionGuardOptions) => removeCommentHandler(editor, input, options), setInternal: (input: SetCommentInternalInput, options?: RevisionGuardOptions) => setCommentInternalHandler(editor, input, options), diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-helpers.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-helpers.js index 2f0bc0ecea..dc49c0e2ea 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-helpers.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-helpers.js @@ -190,6 +190,136 @@ export const resolveCommentById = ({ commentId, importedId, state, tr, dispatch return true; }; +/** + * Collect all `commentRangeStart` / `commentRangeEnd` anchor nodes for a + * given comment id and pair them up into ranges in document order. + * + * Handles split / multi-segment anchors the same way `resolveCommentById` + * inserts them: starts and ends are matched by document order so a + * comment that originally spanned multiple disjoint inline ranges + * round-trips as a sequence of `(start, end)` pairs. Mismatched counts + * (extra start with no matching end, or vice versa) are dropped to + * avoid leaving the doc in a partially-anchored state — the caller + * receives the well-formed pairs only. + * + * @param {string} commentId The canonical comment ID (matches `w:id` attr) + * @param {string} [importedId] Optional imported alias to also match + * @param {import('prosemirror-model').Node} doc The ProseMirror document + * @returns {{ pairs: Array<{ from: number; to: number; internal: boolean }>, anchorNodePositions: number[] }} + */ +const getCommentRangeAnchorsById = (commentId, doc, importedId) => { + /** @type {Array<{ pos: number; type: 'start' | 'end'; internal: boolean }>} */ + const anchors = []; + + doc.descendants((node, pos) => { + const typeName = node.type?.name; + if (typeName !== 'commentRangeStart' && typeName !== 'commentRangeEnd') return; + const wid = node.attrs?.['w:id']; + if (wid !== commentId && (!importedId || wid !== importedId)) return; + anchors.push({ + pos, + type: typeName === 'commentRangeStart' ? 'start' : 'end', + internal: !!node.attrs?.internal, + }); + }); + + /** @type {Array<{ from: number; to: number; internal: boolean }>} */ + const pairs = []; + /** @type {Array<{ pos: number; internal: boolean }>} */ + const stack = []; + for (const anchor of anchors) { + if (anchor.type === 'start') { + stack.push({ pos: anchor.pos, internal: anchor.internal }); + continue; + } + const opener = stack.shift(); + if (!opener) continue; + pairs.push({ from: opener.pos, to: anchor.pos, internal: opener.internal }); + } + + return { + pairs, + anchorNodePositions: anchors.map((a) => a.pos), + }; +}; + +/** + * Reopen a previously-resolved comment by removing its + * `commentRangeStart` / `commentRangeEnd` anchor nodes and re-inserting + * a live `comment` mark across the same range(s). Symmetric inverse of + * {@link resolveCommentById}. + * + * The mark is re-inserted with the original `(commentId, importedId, + * internal)` attrs so subsequent export, search, and entity-store + * lookups see the same shape as a never-resolved comment. The caller + * supplies `importedId` and `internal` because they aren't fully + * recoverable from the doc alone (`commentRangeStart` keeps `internal` + * but `importedId` lives in the entity store, and the public `comments.patch` + * input doesn't take it). + * + * Idempotent on the no-op path: if no matching anchor nodes exist, + * returns `false` without dispatching. + * + * @param {Object} param0 + * @param {string} param0.commentId The canonical comment ID + * @param {string} [param0.importedId] The imported alias (matched against `w:id` for legacy docs) + * @param {boolean} [param0.internal] Override for the restored mark's `internal` flag — falls back to the value stamped on `commentRangeStart` so import-resolved comments keep their flag + * @param {import('prosemirror-state').EditorState} param0.state Current editor state + * @param {import('prosemirror-state').Transaction} param0.tr Current transaction + * @param {Function} param0.dispatch The dispatch function + * @returns {boolean} True when the anchor nodes existed and the mark was restored + */ +export const reopenCommentById = ({ commentId, importedId, internal, state, tr, dispatch }) => { + const { schema } = state; + const markType = schema.marks?.[CommentMarkName]; + if (!markType) return false; + + const { pairs, anchorNodePositions } = getCommentRangeAnchorsById(commentId, state.doc, importedId); + if (!pairs.length) return false; + + // Re-add the comment mark first, working in *original* document + // coordinates. Because subsequent deletes will shift positions, we + // map the inserts forward through `tr.mapping` after each step. The + // pairs array is already in document order; restoring the mark from + // first to last keeps mappings monotonic. + pairs.forEach(({ from, to, internal: anchorInternal }) => { + const mappedFrom = tr.mapping.map(from); + const mappedTo = tr.mapping.map(to); + if (mappedTo <= mappedFrom) return; + const attrs = { + commentId, + importedId, + internal: typeof internal === 'boolean' ? internal : anchorInternal, + }; + // The mark must cover the inline content *between* the anchor + // nodes, not the anchor nodes themselves. `commentRangeStart` sits + // at `from` (one node-size wide) and `commentRangeEnd` sits at + // `to`. Adding the mark from `from + 1` to `to` covers exactly the + // text that was originally marked before resolve. + tr.addMark(mappedFrom + 1, mappedTo, markType.create(attrs)); + }); + + // Delete the anchor nodes in descending order so earlier deletes + // don't shift later positions. `getCommentRangeAnchorsById` returns + // raw doc-positions; map each through `tr.mapping` so previous + // mark insertions are accounted for, then sort the *mapped* + // positions descending. + const mappedAnchorPositions = anchorNodePositions.map((pos) => tr.mapping.map(pos)).sort((a, b) => b - a); + mappedAnchorPositions.forEach((pos) => { + // Each anchor node is one node-size wide. Recompute the node size + // defensively in case mapping collapsed the range to zero width + // (e.g. concurrent delete elsewhere). + const node = tr.doc.nodeAt(pos); + if (!node) return; + const typeName = node.type?.name; + if (typeName !== 'commentRangeStart' && typeName !== 'commentRangeEnd') return; + tr.delete(pos, pos + node.nodeSize); + }); + + dispatch(tr); + return true; +}; + /** * Prepare comments for export by converting the marks back to commentRange nodes * This function handles both Word format (via commentsExtended.xml) and Google Docs format diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js index cdb7b6dfdf..f7fe6ed59e 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js @@ -5,6 +5,7 @@ import { CommentMarkName } from './comments-constants.js'; import { getHighlightColor, removeCommentsById, + reopenCommentById, resolveCommentById, translateFormatChangesToEnglish, } from './comments-helpers.js'; @@ -299,6 +300,12 @@ export const CommentsPlugin = Extension.create({ tr.setMeta(CommentsPluginKey, { event: 'update' }); return resolveCommentById({ commentId, importedId, state, tr, dispatch }); }, + reopenComment: + ({ commentId, importedId, internal }) => + ({ tr, dispatch, state }) => { + tr.setMeta(CommentsPluginKey, { event: 'update' }); + return reopenCommentById({ commentId, importedId, internal, state, tr, dispatch }); + }, editComment: ({ commentId, importedId, content, text }) => ({ editor }) => { diff --git a/packages/super-editor/src/editors/v1/extensions/comment/comments.test.js b/packages/super-editor/src/editors/v1/extensions/comment/comments.test.js index 70fb61c1af..abc14c14c7 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/comments.test.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/comments.test.js @@ -582,6 +582,126 @@ describe('comments plugin commands', () => { ]); }); + it('reopens a resolved comment by removing range nodes and restoring the mark', () => { + const { commands, state, schema } = setup(); + + // First resolve the comment so the doc has commentRangeStart / + // commentRangeEnd anchor nodes and no live mark — the shape we + // expect when reopening hits. + const resolveTr = state.tr; + commands.resolveComment({ commentId: 'comment-1' })({ + tr: resolveTr, + dispatch: vi.fn(), + state, + }); + const resolvedState = state.apply(resolveTr); + + const reopenTr = resolvedState.tr; + const dispatch = vi.fn(); + const result = commands.reopenComment({ commentId: 'comment-1' })({ + tr: reopenTr, + dispatch, + state: resolvedState, + }); + + expect(result).toBe(true); + expect(dispatch).toHaveBeenCalledWith(reopenTr); + + const applied = resolvedState.apply(reopenTr); + const restoredMarkIds = []; + const remainingAnchors = []; + + applied.doc.descendants((node) => { + node.marks.forEach((mark) => { + if (mark.type === schema.marks[CommentMarkName]) { + restoredMarkIds.push(mark.attrs.commentId); + } + }); + if (node.type.name === 'commentRangeStart' || node.type.name === 'commentRangeEnd') { + remainingAnchors.push({ type: node.type.name, id: node.attrs['w:id'] }); + } + }); + + // Mark restored across the original range. Each text run carries + // the mark on its inline content, so length matches the inline + // node count of the original "Hello" text (a single text node). + expect(restoredMarkIds.length).toBeGreaterThan(0); + expect(restoredMarkIds.every((id) => id === 'comment-1')).toBe(true); + // Anchor nodes are gone — reopen is the symmetric inverse of resolve. + expect(remainingAnchors).toEqual([]); + }); + + it('reopen returns false when the comment is not resolved (no anchors found)', () => { + const { commands, state } = setup(); + // Doc starts with the live mark, never resolved — no anchors to + // reopen against. Helper must report no-op. + const tr = state.tr; + const dispatch = vi.fn(); + const result = commands.reopenComment({ commentId: 'comment-1' })({ tr, dispatch, state }); + + expect(result).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('reopen restores the original `internal` flag stamped on commentRangeStart', () => { + const { commands, state, schema } = setup(); + // Resolve first to plant anchor nodes (internal: true from setup). + const resolveTr = state.tr; + commands.resolveComment({ commentId: 'comment-1' })({ + tr: resolveTr, + dispatch: vi.fn(), + state, + }); + const resolvedState = state.apply(resolveTr); + + // Reopen with no `internal` override — helper should fall back to + // the value stamped on the anchor node (true in this fixture). + const reopenTr = resolvedState.tr; + commands.reopenComment({ commentId: 'comment-1' })({ + tr: reopenTr, + dispatch: vi.fn(), + state: resolvedState, + }); + const applied = resolvedState.apply(reopenTr); + + let restoredInternal; + applied.doc.descendants((node) => { + const mark = node.marks.find((m) => m.type === schema.marks[CommentMarkName]); + if (mark && restoredInternal === undefined) { + restoredInternal = mark.attrs.internal; + } + }); + expect(restoredInternal).toBe(true); + }); + + it('reopen honors an explicit `internal` override (entity-store value)', () => { + const { commands, state, schema } = setup(); + const resolveTr = state.tr; + commands.resolveComment({ commentId: 'comment-1' })({ + tr: resolveTr, + dispatch: vi.fn(), + state, + }); + const resolvedState = state.apply(resolveTr); + + const reopenTr = resolvedState.tr; + commands.reopenComment({ commentId: 'comment-1', internal: false })({ + tr: reopenTr, + dispatch: vi.fn(), + state: resolvedState, + }); + const applied = resolvedState.apply(reopenTr); + + let restoredInternal; + applied.doc.descendants((node) => { + const mark = node.marks.find((m) => m.type === schema.marks[CommentMarkName]); + if (mark && restoredInternal === undefined) { + restoredInternal = mark.attrs.internal; + } + }); + expect(restoredInternal).toBe(false); + }); + it('sets active comment meta', () => { const { commands } = setup(); const tr = { setMeta: vi.fn() }; diff --git a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts index 578e68afca..b642b5fc66 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/comment-commands.ts @@ -84,6 +84,25 @@ export type ResolveCommentOptions = { importedId?: string; }; +/** + * Options for the `reopenComment` command — symmetric inverse of + * `resolveComment`. Restores the live `comment` mark across the + * range previously anchored by `commentRangeStart`/`commentRangeEnd`. + */ +export type ReopenCommentOptions = { + /** The comment ID to reopen */ + commentId: string; + /** The imported comment ID — matched against `w:id` for legacy DOCX */ + importedId?: string; + /** + * Override for the restored mark's `internal` flag. When omitted, + * the helper falls back to the value stamped on the resolve-time + * `commentRangeStart` anchor so import-resolved comments keep their + * flag without needing the entity store. + */ + internal?: boolean; +}; + /** Options for editComment command */ export type EditCommentOptions = { /** The comment ID to edit */ @@ -188,6 +207,23 @@ export interface CommentCommands { */ resolveComment: (options: ResolveCommentOptions) => boolean; + /** + * Reopen a previously-resolved comment — the symmetric inverse of + * `resolveComment`. Removes the `commentRangeStart` / + * `commentRangeEnd` anchor nodes inserted at resolve time and + * restores the live `comment` mark across the same range so the + * comment surfaces again on the editing surface and in + * `comments.list()` / `selection.current().activeCommentIds`. + * + * Surfaced on the public Document API as + * `editor.doc.comments.patch({ commentId, status: 'active' })`. + * + * @param options - Object containing commentId and optional importedId / internal override + * @example + * editor.commands.reopenComment({ commentId: 'comment-123' }) + */ + reopenComment: (options: ReopenCommentOptions) => boolean; + /** * Edit an existing comment payload. * @param options - Object containing comment id and updated content