Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
7.7 kB · 208 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209import { 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<ReplicacheMutators>; 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<string, string> = {}; for (let c of existingComments) { positionByEntityID[c.data.value] = c.data.position; } let anchorPosByEntityID: Record<string, number> = {}; 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 rangeexport function markEditorCommentIDs(mark: { attrs: { [key: string]: any };}): string[] { return ((mark.attrs.commentID as string) || "").split(" ").filter(Boolean);}