import { syntaxTree } from "@codemirror/language"; import { Decoration } from "@codemirror/view"; import { WidgetType } from "@codemirror/view"; import type { Range } from "@codemirror/state"; import type { Client } from "../client.ts"; import { decoratorStateField, isCursorInRange } from "./util.ts"; import { type ParsedComment, parseCommentBlock, } from "../../plug-api/lib/comments.ts"; export function buildReplyInsertion( raw: string, blockFrom: number, parsed: ParsedComment, author: string | undefined, date: string, ): { insertAt: number; text: string; cursorPos: number } { const last = parsed.thread[parsed.thread.length - 1]; const prior = parsed.thread[parsed.thread.length - 2]; const replyTo = last.author ?? prior?.addressee ?? last.addressee; // A reply always carries a signature, even without a configured author -- // an unsigned reply is otherwise indistinguishable from a continuation of // the message before it (see the "a signature closes a message" rule in // plug-api/lib/comments.ts). const sig = author ? ` — ${author}, ${date}` : ` — ${date}`; // When nobody is addressed (no addressee to reply to and no author to // reply back to), the reply stays an unaddressed message line, same as // any other note-to-self. Generated lines are left-aligned (no indentation). const linePrefix = replyTo !== undefined ? `@${replyTo}: ` : ""; const line = `${linePrefix}${sig}`; // A reply always makes the block multi-line, so the closing `-->` must // land on its own line. Insert right before the literal `-->`: if it's // already on its own line (e.g. a prior reply already relocated it), // just add the new line ahead of it; otherwise break it onto a fresh // line first. Either way, no leading indentation is introduced. const arrowIdx = raw.lastIndexOf("-->"); const closerOnOwnLine = arrowIdx > 0 && raw[arrowIdx - 1] === "\n"; const leadingNewline = closerOnOwnLine ? "" : "\n"; const text = `${leadingNewline}${line}\n`; const insertAt = blockFrom + arrowIdx; return { insertAt, text, cursorPos: insertAt + leadingNewline.length + linePrefix.length, }; } export function resolveRange( docText: string, range: [number, number], ): [number, number] { const from = range[0] > 0 && docText[range[0] - 1] === "\n" ? range[0] - 1 : range[0]; return [from, range[1]]; } class CommentCardWidget extends WidgetType { constructor( readonly raw: string, readonly parsed: ParsedComment, readonly range: [number, number], readonly client: Client, readonly readOnly: boolean, ) { super(); } toDOM() { // The widget's root element must not have vertical margins: CodeMirror // measures block heights with getBoundingClientRect(), which excludes // them, so any margin here desyncs the height map from the real layout // and corrupts coordinate-based cursor motion and selection drawing. // The gap around the card lives as padding on this wrapper instead. const wrapper = document.createElement("div"); wrapper.className = "sb-comment-widget"; const card = document.createElement("div"); card.className = "sb-comment-card"; wrapper.appendChild(card); if (this.parsed.quote) { const q = document.createElement("div"); q.className = "sb-comment-quote"; q.textContent = this.parsed.quote; card.appendChild(q); } for (const msg of this.parsed.thread) { const row = document.createElement("div"); row.className = "sb-comment-message"; if (msg.addressee) { const chip = document.createElement("span"); chip.className = "sb-comment-addressee"; chip.textContent = `@${msg.addressee}`; row.appendChild(chip); } const body = document.createElement("span"); body.className = "sb-comment-text"; body.textContent = msg.addressee ? ` ${msg.text}` : msg.text; row.appendChild(body); if (msg.author) { const sig = document.createElement("span"); sig.className = "sb-comment-sig"; sig.textContent = ` — ${msg.author}${msg.date ? `, ${msg.date}` : ""}`; row.appendChild(sig); } card.appendChild(row); } if (!this.readOnly) { const actions = document.createElement("div"); actions.className = "sb-comment-actions"; // Reply only makes sense on a thread that's addressed to somebody -- // an unaddressed note has no one to reply to. buildReplyInsertion's // unaddressed fallback stays in place regardless, as a safeguard for // mixed threads (addressed earlier, unaddressed last message). if (this.parsed.addressees.length > 0) { actions.appendChild(this.button("Reply", () => this.reply())); } actions.appendChild(this.button("Resolve", () => this.resolve())); card.appendChild(actions); } wrapper.addEventListener("mousedown", (e) => { // Leave the Reply/Resolve buttons' own mousedown/click behavior alone. if ((e.target as HTMLElement).closest("button")) { return; } if (e.altKey) { // Move the document cursor into the block so it re-renders as raw // text, same convention as other Live Preview widgets (see // attachWidgetEventHandlers in widget_util.ts). this.client.editorView.dispatch({ selection: { anchor: this.range[0] }, }); this.client.focus(); e.preventDefault(); } // CodeMirror overrides mousedown on parent elements to implement its // own selection highlighting; don't let that hijack clicks on the card. e.stopPropagation(); }); return wrapper; } private button(label: string, onClick: () => void): HTMLButtonElement { const b = document.createElement("button"); b.textContent = label; b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); }); return b; } private reply() { const view = this.client.editorView; const author = this.client.config.get("comments.author", ""); const date = new Date().toISOString().slice(0, 10); const r = buildReplyInsertion( this.raw, this.range[0], this.parsed, author || undefined, date, ); view.dispatch({ changes: { from: r.insertAt, insert: r.text }, selection: { anchor: r.cursorPos }, }); view.focus(); } private resolve() { const view = this.client.editorView; const [from, to] = resolveRange( view.state.doc.toString(), this.range, ); view.dispatch({ changes: { from, to, insert: "" } }); } override eq(other: CommentCardWidget) { return other.raw === this.raw && other.range[0] === this.range[0]; } } export function commentPlugin(client: Client) { return decoratorStateField((state) => { const widgets: Range[] = []; syntaxTree(state).iterate({ enter: (node) => { if (node.name !== "CommentBlock") { return; } const range: [number, number] = [node.from, node.to]; if (isCursorInRange(state, range)) { return; } const raw = state.sliceDoc(node.from, node.to); const parsed = parseCommentBlock(raw); if (!parsed) { return; } widgets.push( Decoration.replace({ widget: new CommentCardWidget( raw, parsed, range, client, state.readOnly, ), block: true, }).range(range[0], range[1]), ); }, }); return Decoration.set(widgets, true); }); }