Operand

thee, sea, us.

gram: docs

> ./packages/core/src/prosemirror/commentOps.ts

/**
* Comment + tracked-change (proposeChange) PM transaction builders shared by
* the React and Vue adapters. They locate the target range, apply the mark(s),
* dispatch, and return a result. ID allocation lives in `commentIdAllocator.ts`
* and is injected. Adapter-specific state mutation, event emission, and sidebar
* UI stay in each adapter.
*/
import type { EditorView } from 'prosemirror-view';
import type { Comment } from '../types/content';
import type { CommentIdAllocator } from './commentIdAllocator';
import { findParaIdRange, findTextInPmParagraph } from './paraText';
/** Build a Comment object with a freshly-allocated ID. */
export function createComment(
allocator: CommentIdAllocator,
text: string,
authorName: string,
parentId?: number
): Comment {
return {
id: allocator.next(),
author: authorName,
date: new Date().toISOString(),
content: [
{
type: 'paragraph',
formatting: {},
content: [{ type: 'run', formatting: {}, content: [{ type: 'text', text }] }],
},
],
...(parentId !== undefined && { parentId }),
};
}
export interface AddCommentOptions {
paraId: string;
text: string;
author: string;
search?: string;
}
/**
* Locate the comment range (paragraph, narrowed by `search`), add the comment
* mark, dispatch, and return the created Comment. Returns null when the schema
* lacks a comment mark or the range can't be resolved. The caller owns adding
* the comment to its own state and showing the sidebar.
*/
export function addCommentToRange(
view: EditorView,
options: AddCommentOptions,
allocator: CommentIdAllocator
): Comment | null {
const { schema } = view.state;
if (!schema.marks.comment) return null;
const range = findParaIdRange(view.state.doc, options.paraId);
if (!range) return null;
let from = range.from + 1;
let to = range.to - 1;
if (options.search) {
const textRange = findTextInPmParagraph(view.state.doc, range.from, range.to, options.search);
if (!textRange) return null;
from = textRange.from;
to = textRange.to;
}
// Refuse an empty range (e.g. an empty paragraph with no search) so we never
// create an orphan comment — one serialized to comments.xml with no
// commentRangeStart/End anchor in document.xml.
if (from >= to) return null;
const comment = createComment(allocator, options.text, options.author);
view.dispatch(
view.state.tr.addMark(from, to, schema.marks.comment.create({ commentId: comment.id }))
);
return comment;
}
export interface ProposeChangeOptions {
paraId: string;
search: string;
replaceWith: string;
author: string;
}
/**
* Apply a tracked change (insertion / deletion / replace) to the located range.
* `search === ''` means a pure insertion at the paragraph end; `replaceWith ===
* ''` means a pure deletion. Refuses to layer onto an existing tracked change.
* Returns true when a change was dispatched, false otherwise.
*/
export function applyProposedChange(
view: EditorView,
options: ProposeChangeOptions,
allocator: CommentIdAllocator
): boolean {
const { schema } = view.state;
if (!schema.marks.deletion || !schema.marks.insertion) return false;
const range = findParaIdRange(view.state.doc, options.paraId);
if (!range) return false;
const isInsertion = options.search === '';
const isDeletion = options.replaceWith === '';
if (isInsertion && isDeletion) return false; // nothing to do
let textFrom: number;
let textTo: number;
if (isInsertion) {
// Insert at end of paragraph (just before closing token).
textFrom = range.to - 1;
textTo = range.to - 1;
} else {
const textRange = findTextInPmParagraph(view.state.doc, range.from, range.to, options.search);
if (!textRange) return false;
textFrom = textRange.from;
textTo = textRange.to;
}
// Refuse to layer onto an existing tracked change.
if (textFrom < textTo) {
let overlaps = false;
view.state.doc.nodesBetween(textFrom, textTo, (node) => {
for (const m of node.marks) {
if (m.type === schema.marks.insertion || m.type === schema.marks.deletion) {
overlaps = true;
return false;
}
}
return true;
});
if (overlaps) return false;
}
const revisionId = allocator.next();
const date = new Date().toISOString();
const deletionMark = schema.marks.deletion.create({ revisionId, author: options.author, date });
const insertionMark = schema.marks.insertion.create({ revisionId, author: options.author, date });
let tr = view.state.tr;
if (!isInsertion) tr = tr.addMark(textFrom, textTo, deletionMark);
if (!isDeletion) tr = tr.insert(textTo, schema.text(options.replaceWith, [insertionMark]));
view.dispatch(tr);
return true;
}