mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 01:31:10 +10:00
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
202 lines
4.5 KiB
TypeScript
202 lines
4.5 KiB
TypeScript
import {
|
|
findParentNode,
|
|
mergeAttributes,
|
|
Node,
|
|
wrappingInputRule,
|
|
} from "@tiptap/core";
|
|
import { TextSelection } from "@tiptap/pm/state";
|
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
import { CalloutType, getValidCalloutType } from "./utils";
|
|
|
|
export interface CalloutOptions {
|
|
HTMLAttributes: Record<string, any>;
|
|
view: any;
|
|
}
|
|
|
|
export interface CalloutAttributes {
|
|
/**
|
|
* The type of callout.
|
|
*/
|
|
type: CalloutType;
|
|
}
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
callout: {
|
|
setCallout: (attributes?: CalloutAttributes) => ReturnType;
|
|
unsetCallout: () => ReturnType;
|
|
toggleCallout: (attributes?: CalloutAttributes) => ReturnType;
|
|
updateCalloutType: (type: CalloutType) => ReturnType;
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* Matches a callout to a `:::` as input.
|
|
*/
|
|
export const inputRegex = /^:::([a-z]+)?[\s\n]$/;
|
|
|
|
export const Callout = Node.create<CalloutOptions>({
|
|
name: "callout",
|
|
|
|
addOptions() {
|
|
return {
|
|
HTMLAttributes: {},
|
|
view: null,
|
|
};
|
|
},
|
|
|
|
content: "block+",
|
|
group: "block",
|
|
defining: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
type: {
|
|
default: "info",
|
|
parseHTML: (element) => element.getAttribute("data-callout-type"),
|
|
renderHTML: (attributes) => ({
|
|
"data-callout-type": attributes.type,
|
|
}),
|
|
},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [
|
|
{
|
|
tag: `div[data-type="${this.name}"]`,
|
|
},
|
|
];
|
|
},
|
|
|
|
renderHTML({ HTMLAttributes }) {
|
|
return [
|
|
"div",
|
|
mergeAttributes(
|
|
{ "data-type": this.name },
|
|
this.options.HTMLAttributes,
|
|
HTMLAttributes,
|
|
),
|
|
0,
|
|
];
|
|
},
|
|
|
|
addCommands() {
|
|
return {
|
|
setCallout:
|
|
(attributes) =>
|
|
({ commands }) => {
|
|
return commands.setNode(this.name, attributes);
|
|
},
|
|
|
|
unsetCallout:
|
|
() =>
|
|
({ commands }) => {
|
|
return commands.lift(this.name);
|
|
},
|
|
|
|
toggleCallout:
|
|
(attributes) =>
|
|
({ commands }) => {
|
|
return commands.toggleWrap(this.name, attributes);
|
|
},
|
|
|
|
updateCalloutType:
|
|
(type: string) =>
|
|
({ commands }) =>
|
|
commands.updateAttributes("callout", {
|
|
type: getValidCalloutType(type),
|
|
}),
|
|
};
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(this.options.view);
|
|
},
|
|
|
|
addKeyboardShortcuts() {
|
|
return {
|
|
//"Mod-Shift-c": () => this.editor.commands.toggleCallout(),
|
|
|
|
/**
|
|
* Handle the backspace key when deleting content.
|
|
* Aims to stop merging callouts when deleting content in between.
|
|
*/
|
|
Backspace: ({ editor }) => {
|
|
const { state, view } = editor;
|
|
const { selection } = state;
|
|
|
|
// If the selection is not empty, return false
|
|
// and let other extension handle the deletion.
|
|
if (!selection.empty) {
|
|
return false;
|
|
}
|
|
|
|
const { $from } = selection;
|
|
|
|
// If not at the start of current node, no joining will happen
|
|
if ($from.parentOffset !== 0) {
|
|
return false;
|
|
}
|
|
|
|
const previousPosition = $from.before($from.depth) - 1;
|
|
|
|
// If nothing above to join with
|
|
if (previousPosition < 1) {
|
|
return false;
|
|
}
|
|
|
|
const previousPos = state.doc.resolve(previousPosition);
|
|
|
|
// If resolving previous position fails, bail out
|
|
if (!previousPos?.parent) {
|
|
return false;
|
|
}
|
|
|
|
const previousNode = previousPos.parent;
|
|
const parentNode = findParentNode(() => true)(selection);
|
|
|
|
if (!parentNode) {
|
|
return false;
|
|
}
|
|
|
|
const { node, pos, depth } = parentNode;
|
|
|
|
// If current node is nested
|
|
if (depth !== 1) {
|
|
return false;
|
|
}
|
|
|
|
// If previous node is a callout, cut current node's content into it
|
|
if (node.type !== this.type && previousNode.type === this.type) {
|
|
const { content, nodeSize } = node;
|
|
const { tr } = state;
|
|
|
|
tr.delete(pos, pos + nodeSize);
|
|
tr.setSelection(
|
|
TextSelection.near(tr.doc.resolve(previousPosition - 1)),
|
|
);
|
|
tr.insert(previousPosition - 1, content);
|
|
|
|
view.dispatch(tr);
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
};
|
|
},
|
|
|
|
addInputRules() {
|
|
return [
|
|
wrappingInputRule({
|
|
find: inputRegex,
|
|
type: this.type,
|
|
getAttributes: (match) => ({
|
|
type: getValidCalloutType(match[1]),
|
|
}),
|
|
}),
|
|
];
|
|
},
|
|
});
|