import { isMatch } from "es-toolkit/compat"; import { sanitizeUrl } from "../../utils/urls"; import type Token from "markdown-it/lib/token.mjs"; import type { NodeSpec, Node as ProsemirrorNode, NodeType, Schema, } from "prosemirror-model"; import type { Command } from "prosemirror-state"; import { NodeSelection, Plugin, TextSelection } from "prosemirror-state"; import type { Primitive } from "utility-types"; import { v4 as uuidv4 } from "uuid"; import env from "../../env"; import type { UnfurlResponse } from "../../types"; import { MentionType, UnfurlResourceType } from "../../types"; import { MentionCollection, MentionDocument, MentionGroup, MentionIssue, MentionProject, MentionPullRequest, MentionURL, MentionUser, } from "../components/Mentions"; import type { MarkdownSerializerState } from "../lib/markdown/serializer"; import { transformListToMentions } from "../lib/mention"; import { findParentNodeClosestToPos } from "../queries/findParentNode"; import { isInList } from "../queries/isInList"; import { isList } from "../queries/isList"; import mentionRule from "../rules/mention"; import type { ComponentProps } from "../types"; import Node from "./Node"; export default class Mention extends Node { get name() { return "mention"; } get schema(): NodeSpec { const toPlainText = (node: ProsemirrorNode) => node.attrs.type === MentionType.User ? `@${node.attrs.label}` : node.attrs.label; return { attrs: { type: { default: MentionType.User, }, label: {}, modelId: {}, actorId: { default: undefined, }, id: { default: undefined, }, href: { default: undefined, }, unfurl: { default: undefined, }, }, inline: true, marks: "", group: "inline", atom: true, parseDOM: [ { tag: `.${this.name}`, preserveWhitespace: "full", priority: 100, getAttrs: (dom: HTMLElement) => { const type = dom.dataset.type; const modelId = dom.dataset.id; if (!type || !modelId) { return false; } return { type, modelId, actorId: dom.dataset.actorid, label: dom.innerText, id: dom.id, href: dom.getAttribute("href"), unfurl: dom.dataset.unfurl ? JSON.parse(dom.dataset.unfurl) : undefined, }; }, }, ], toDOM: (node) => [ node.attrs.type === MentionType.User ? "span" : "a", { class: `${node.type.name} use-hover-preview`, id: node.attrs.id, href: node.attrs.type === MentionType.User ? undefined : node.attrs.type === MentionType.Document ? `${env.URL}/doc/${node.attrs.modelId}` : node.attrs.type === MentionType.Collection ? `${env.URL}/collection/${node.attrs.modelId}` : sanitizeUrl(node.attrs.href), "data-type": node.attrs.type, "data-id": node.attrs.modelId, "data-actorid": node.attrs.actorId, "data-url": node.attrs.type === MentionType.PullRequest || node.attrs.type === MentionType.Issue || node.attrs.type === MentionType.Project ? sanitizeUrl(node.attrs.href) : `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`, "data-unfurl": JSON.stringify(node.attrs.unfurl), }, toPlainText(node), ], leafText: toPlainText, }; } component = (props: ComponentProps) => { switch (props.node.attrs.type) { case MentionType.User: return ; case MentionType.Group: return ; case MentionType.Document: return ; case MentionType.Collection: return ; case MentionType.Issue: return ( ); case MentionType.PullRequest: return ( ); case MentionType.Project: return ( ); case MentionType.URL: return ( ); default: return null; } }; get rulePlugins() { return [mentionRule]; } get plugins() { return [ // Ensure mentions have unique IDs new Plugin({ appendTransaction: (_transactions, _oldState, newState) => { const tr = newState.tr; const existingIds = new Set(); let modified = false; tr.doc.descendants((node, pos) => { let nodeId = node.attrs.id; if ( node.type.name === this.name && (!nodeId || existingIds.has(nodeId)) ) { nodeId = uuidv4(); modified = true; tr.setNodeAttribute(pos, "id", nodeId); } existingIds.add(nodeId); }); if (modified) { return tr; } return null; }, }), ]; } keys(): Record { const NavigableMention = [ MentionType.Collection, MentionType.Document, MentionType.Issue, MentionType.PullRequest, MentionType.Project, ]; return { Enter: (state) => { const { selection } = state; if ( selection instanceof NodeSelection && selection.node.type.name === this.name && NavigableMention.includes(selection.node.attrs.type) ) { const mentionType = selection.node.attrs.type; let link: string; if ( mentionType === MentionType.Issue || mentionType === MentionType.PullRequest || mentionType === MentionType.Project ) { link = selection.node.attrs.href; } else { const { modelId } = selection.node.attrs; const linkType = selection.node.attrs.type === MentionType.Document ? "doc" : "collection"; link = `/${linkType}/${modelId}`; } this.editor.props.onClickLink?.(link); return true; } return false; }, }; } commands({ type }: { type: NodeType; schema: Schema }) { return { mention: (attrs: Record): Command => (state, dispatch) => { const { selection } = state; const position = selection instanceof TextSelection ? selection.$cursor?.pos : selection.$to.pos; if (position === undefined) { return false; } const node = type.create(attrs); const transaction = state.tr.insert(position, node); dispatch?.(transaction); return true; }, mention_list: (attrs: Record): Command => (state, dispatch) => { const { selection } = state; const position = selection instanceof TextSelection ? selection.$cursor?.pos : selection.$to.pos; if (position === undefined || !isInList(state)) { return false; } const resolvedPos = state.tr.doc.resolve(position); const nodeWithPos = findParentNodeClosestToPos(resolvedPos, (node) => isList(node, this.editor.schema) ); if (!nodeWithPos) { return false; } const listNode = nodeWithPos.node, from = nodeWithPos.pos, to = from + listNode.nodeSize; const listNodeWithMentions = transformListToMentions( listNode, this.editor.schema, attrs ); const tr = state.tr.deleteRange(from, to); dispatch?.( tr .setSelection(TextSelection.near(tr.doc.resolve(from))) .replaceSelectionWith(listNodeWithMentions) ); return true; }, }; } toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { const mType = node.attrs.type; const mId = node.attrs.modelId; const label = node.attrs.label; const id = node.attrs.id; // Use regular links for document and collection mentions if (mType === MentionType.Document) { state.write(`[${label}](/doc/${mId})`); } else if (mType === MentionType.Collection) { state.write(`[${label}](/collection/${mId})`); } else { // Keep the existing mention:// format for other types (user, group, issue, pull_request, url) state.write(`@[${label}](mention://${id}/${mType}/${mId})`); } } parseMarkdown() { return { node: "mention", getAttrs: (tok: Token) => ({ id: tok.attrGet("id"), type: tok.attrGet("type"), modelId: tok.attrGet("modelId"), label: tok.content, }), }; } handleChangeUnfurl = ({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) => (unfurl: UnfurlResponse[keyof UnfurlResponse]) => { const { view } = this.editor; const { tr } = view.state; const label = unfurl.type === UnfurlResourceType.Issue || unfurl.type === UnfurlResourceType.PR || unfurl.type === UnfurlResourceType.URL ? unfurl.title : unfurl.type === UnfurlResourceType.Project ? unfurl.name : undefined; const overrides: Record = label ? { label } : {}; overrides.unfurl = unfurl; const pos = getPos(); if (!isMatch(node.attrs, overrides)) { const transaction = tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...overrides, }); view.dispatch(transaction); } }; }