220 lines
7.5 KiB
TypeScript
220 lines
7.5 KiB
TypeScript
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<string>("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<Decoration>[] = [];
|
|
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);
|
|
});
|
|
}
|