import { EditorView } from "prosemirror-view"; import { v7 } from "uuid"; import * as Y from "yjs"; import * as base64 from "base64-js"; import { generateKeyBetween } from "fractional-indexing"; import { Replicache } from "replicache"; import type { ReplicacheMutators } from "src/replicache"; import type { UndoManager } from "src/undoManager"; import { schema } from "components/Blocks/TextBlock/schema"; import { commentDraftKey, getCommentDraftRange, } from "components/Blocks/TextBlock/commentDraftPlugin"; import { useEditorStates } from "src/state/useEditorState"; import { scanIndex } from "src/replicache/utils"; import { useEditorCommentDraftStore, useEditorCommentSheetStore } from "./editorCommentStores"; export function startEditorCommentDraft( view: EditorView, blockID: string, pageID: string, ) { let { from, to } = view.state.selection; if (from === to) { // With a collapsed selection, anchor the comment to the word at the cursor let $from = view.state.doc.resolve(from); let parentStart = $from.start(); let text = $from.parent.textBetween( 0, $from.parent.content.size, undefined, "", ); let offset = from - parentStart; let start = offset; let end = offset; while (start > 0 && /\S/.test(text[start - 1])) start--; while (end < text.length && /\S/.test(text[end])) end++; if (start === end) return; from = parentStart + start; to = parentStart + end; } let existing = useEditorCommentDraftStore.getState().draft; if (existing && existing.blockID !== blockID) clearDraftDecoration(existing.blockID); view.dispatch(view.state.tr.setMeta(commentDraftKey, { from, to })); useEditorCommentDraftStore.setState({ draft: { blockID, pageID } }); } function clearDraftDecoration(blockID: string) { let editorState = useEditorStates.getState().editorStates[blockID]; if (editorState?.view) { editorState.view.dispatch( editorState.view.state.tr.setMeta(commentDraftKey, null), ); } } export function cancelEditorCommentDraft() { let draft = useEditorCommentDraftStore.getState().draft; if (!draft) return; clearDraftDecoration(draft.blockID); useEditorCommentDraftStore.setState({ draft: null }); } export async function submitEditorCommentDraft({ rep, undoManager, permissionSet, authorDid, ydoc, }: { rep: Replicache; undoManager: UndoManager; permissionSet: string; authorDid: string; ydoc: Y.Doc; }) { let draft = useEditorCommentDraftStore.getState().draft; if (!draft) return; let view = useEditorStates.getState().editorStates[draft.blockID]?.view; if (!view) return; let range = getCommentDraftRange(view.state); if (!range) return; let commentEntityID = v7(); // Order the new comment among the block's existing comments by where its // anchor falls in the text, mirroring insertFootnote. let existingComments = await rep.query(async (tx) => { let scan = scanIndex(tx); return scan.eav(draft.blockID, "block/comment"); }); let positionByEntityID: Record = {}; for (let c of existingComments) { positionByEntityID[c.data.value] = c.data.position; } let anchorPosByEntityID: Record = {}; view.state.doc.descendants((node, pos) => { for (let mark of node.marks) { if (mark.type === schema.marks.comment) { for (let id of markEditorCommentIDs(mark)) { if (anchorPosByEntityID[id] === undefined) anchorPosByEntityID[id] = pos; } } } }); let beforePosition: string | null = null; let afterPosition: string | null = null; for (let [entityID, anchorPos] of Object.entries(anchorPosByEntityID)) { let p = positionByEntityID[entityID]; if (p === undefined) continue; if (anchorPos < range.from) { if (beforePosition === null || p > beforePosition) beforePosition = p; } else { if (afterPosition === null || p < afterPosition) afterPosition = p; } } let position = generateKeyBetween(beforePosition, afterPosition); // The comment's facts and its anchor mark undo as one step. The mark // transaction is dispatched as a bulkOp so trackUndoRedo adds it to this // group instead of starting its own. undoManager.startGroup(); try { await rep.mutate.createEditorComment({ commentEntityID, blockID: draft.blockID, permission_set: permissionSet, position, authorDid, createdAt: new Date().toISOString(), anchorStart: range.from, anchorEnd: range.to, content: base64.fromByteArray(Y.encodeStateAsUpdate(ydoc)), }); // Same-type marks exclude each other, so a plain addMark over a range // that already carries a comment mark would replace it and corrupt that // comment's anchor. Instead each anchor mark holds a space-separated list // of comment IDs, and overlaps merge the new ID into the existing list // segment by segment. let markType = schema.marks.comment; let tr = view.state.tr; view.state.doc.nodesBetween(range.from, range.to, (node, pos) => { if (!node.isText) return; let segFrom = Math.max(pos, range.from); let segTo = Math.min(pos + node.nodeSize, range.to); if (segFrom >= segTo) return; let existing = node.marks.find((m) => m.type === markType); let ids = existing ? markEditorCommentIDs(existing) : []; if (!ids.includes(commentEntityID)) ids.push(commentEntityID); tr.addMark(segFrom, segTo, markType.create({ commentID: ids.join(" ") })); }); tr.setMeta(commentDraftKey, null); tr.setMeta("bulkOp", true); view.dispatch(tr); } finally { undoManager.endGroup(); } useEditorCommentDraftStore.setState({ draft: null }); return commentEntityID; } // Strip a comment's ID from its block's anchor marks: the mark is removed on // segments where it was the only comment, and rewritten to the remaining IDs // where comments overlap — the inverse of the merge in submitEditorCommentDraft. // Used when a thread is resolved (deleted) to make the text read as plain // everywhere (no mark to render, highlight, or click). Dispatched as a bulkOp // so trackUndoRedo joins the open undo group, letting the mark edit and the // comment delete undo together. The edit syncs to peers over yjs; only the // resolver (whose block editor is mounted) needs to run it. export function removeEditorCommentMark(blockID: string, commentEntityID: string) { let view = useEditorStates.getState().editorStates[blockID]?.view; if (!view) return; let markType = schema.marks.comment; let tr = view.state.tr; // RemoveMark/AddMark steps don't shift positions, so positions read from the // original doc stay valid as the transaction accumulates. view.state.doc.descendants((node, pos) => { if (!node.isText) return; let existing = node.marks.find((m) => m.type === markType); if (!existing) return; let ids = markEditorCommentIDs(existing); if (!ids.includes(commentEntityID)) return; let from = pos; let to = pos + node.nodeSize; tr.removeMark(from, to, markType); let remaining = ids.filter((id) => id !== commentEntityID); if (remaining.length > 0) tr.addMark(from, to, markType.create({ commentID: remaining.join(" ") })); }); if (tr.steps.length === 0) return; tr.setMeta("bulkOp", true); view.dispatch(tr); } // Anchor marks carry one or more comment IDs, space-separated, so // overlapping comments can share a range export function markEditorCommentIDs(mark: { attrs: { [key: string]: any }; }): string[] { return ((mark.attrs.commentID as string) || "").split(" ").filter(Boolean); }