mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 10:22:36 +10:00
feat: internal page links and mentions (#604)
* Work on mentions * fix: properly parse page slug * fix editor suggestion bugs * mentions must start with whitespace * add icon to page mention render * feat: backlinks - WIP * UI - WIP * permissions check * use FTS for page suggestion * cleanup * WIP * page title fallback * feat: handle internal link paste * link styling * WIP * Switch back to LIKE operator for search suggestion * WIP * scope to workspaceId * still create link for pages not found * select necessary columns * cleanups
This commit is contained in:
@ -11,8 +11,9 @@ export * from "./lib/media-utils";
|
||||
export * from "./lib/link";
|
||||
export * from "./lib/selection";
|
||||
export * from "./lib/attachment";
|
||||
export * from "./lib/custom-code-block"
|
||||
export * from "./lib/custom-code-block";
|
||||
export * from "./lib/drawio";
|
||||
export * from "./lib/excalidraw";
|
||||
export * from "./lib/embed";
|
||||
export * from "./lib/mention";
|
||||
export * from "./lib/markdown";
|
||||
|
||||
334
packages/editor-ext/src/lib/mention.ts
Normal file
334
packages/editor-ext/src/lib/mention.ts
Normal file
@ -0,0 +1,334 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
|
||||
export interface MentionNodeAttrs {
|
||||
/**
|
||||
* unique mention node id (uuidv7)
|
||||
*/
|
||||
id: string | null;
|
||||
/**
|
||||
* The label to be rendered by the editor as the displayed text for this mentioned
|
||||
* item, if provided.
|
||||
*/
|
||||
label?: string | null;
|
||||
|
||||
/**
|
||||
* the entity type - user or page
|
||||
*/
|
||||
entityType: "user" | "page";
|
||||
|
||||
/**
|
||||
* the entity id - userId or pageId
|
||||
*/
|
||||
entityId?: string | null;
|
||||
|
||||
/**
|
||||
* page slugId
|
||||
*/
|
||||
slugId?: string | null;
|
||||
|
||||
/**
|
||||
* the id of the user who initiated the mention
|
||||
*/
|
||||
creatorId?: string;
|
||||
}
|
||||
|
||||
export type MentionOptions<
|
||||
SuggestionItem = any,
|
||||
Attrs extends Record<string, any> = MentionNodeAttrs,
|
||||
> = {
|
||||
/**
|
||||
* The HTML attributes for a mention node.
|
||||
* @default {}
|
||||
* @example { class: 'foo' }
|
||||
*/
|
||||
HTMLAttributes: Record<string, any>;
|
||||
|
||||
/**
|
||||
* A function to render the text of a mention.
|
||||
* @param props The render props
|
||||
* @returns The text
|
||||
* @example ({ options, node }) => `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
|
||||
*/
|
||||
renderText: (props: {
|
||||
options: MentionOptions<SuggestionItem, Attrs>;
|
||||
node: ProseMirrorNode;
|
||||
}) => string;
|
||||
|
||||
/**
|
||||
* A function to render the HTML of a mention.
|
||||
* @param props The render props
|
||||
* @returns The HTML as a ProseMirror DOM Output Spec
|
||||
* @example ({ options, node }) => ['span', { 'data-type': 'mention' }, `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`]
|
||||
*/
|
||||
renderHTML: (props: {
|
||||
options: MentionOptions<SuggestionItem, Attrs>;
|
||||
node: ProseMirrorNode;
|
||||
}) => DOMOutputSpec;
|
||||
|
||||
/**
|
||||
* Whether to delete the trigger character with backspace.
|
||||
* @default false
|
||||
*/
|
||||
deleteTriggerWithBackspace: boolean;
|
||||
|
||||
/**
|
||||
* The suggestion options.
|
||||
* @default {}
|
||||
* @example { char: '@', pluginKey: MentionPluginKey, command: ({ editor, range, props }) => { ... } }
|
||||
*/
|
||||
suggestion: Omit<SuggestionOptions<SuggestionItem, Attrs>, "editor">;
|
||||
};
|
||||
|
||||
/**
|
||||
* The plugin key for the mention plugin.
|
||||
* @default 'mention'
|
||||
*/
|
||||
export const MentionPluginKey = new PluginKey("mention");
|
||||
|
||||
/**
|
||||
* This extension allows you to insert mentions into the editor.
|
||||
* @see https://www.tiptap.dev/api/extensions/mention
|
||||
*/
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: "mention",
|
||||
|
||||
priority: 101,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
deleteTriggerWithBackspace: false,
|
||||
renderHTML({ options, node }) {
|
||||
const isUserMention = node.attrs.entityType === "user";
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
|
||||
`${isUserMention ? options.suggestion.char : ""}${node.attrs.label ?? node.attrs.entityId}`,
|
||||
];
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
pluginKey: MentionPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(" ");
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " ",
|
||||
},
|
||||
])
|
||||
.run();
|
||||
|
||||
// get reference to `window` object from editor element, to support cross-frame JS usage
|
||||
editor.view.dom.ownerDocument.defaultView
|
||||
?.getSelection()
|
||||
?.collapseToEnd();
|
||||
},
|
||||
allow: ({ state, range }) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: "inline",
|
||||
inline: true,
|
||||
selectable: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-id": attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-label"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.label) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-label": attributes.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
entityType: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-entity-type"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.entityType) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-entity-type": attributes.entityType,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
entityId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-entity-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.entityId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-entity-id": attributes.entityId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
slugId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-slug-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.slugId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-slug-id": attributes.slugId,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
creatorId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-creator-id"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.creatorId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"data-creator-id": attributes.creatorId,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const mergedOptions = { ...this.options };
|
||||
|
||||
mergedOptions.HTMLAttributes = mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
);
|
||||
const html = this.options.renderHTML({
|
||||
options: mergedOptions,
|
||||
node,
|
||||
});
|
||||
|
||||
if (typeof html === "string") {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
html,
|
||||
];
|
||||
}
|
||||
return html;
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return this.options.renderText({
|
||||
options: this.options,
|
||||
node,
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({ tr, state }) => {
|
||||
let isMention = false;
|
||||
const { selection } = state;
|
||||
const { empty, anchor } = selection;
|
||||
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isMention = true;
|
||||
tr.insertText(
|
||||
this.options.deleteTriggerWithBackspace
|
||||
? ""
|
||||
: this.options.suggestion.char || "",
|
||||
pos,
|
||||
pos + node.nodeSize,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return isMention;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user