Files
docmost/packages/editor-ext/src/lib/callout/callout.ts
Philipinho 1f4bd129a8 editor improvements
* add callout, youtube embed, image, video, table, detail, math
* fix attachments module
* other fixes
2024-06-20 14:57:00 +01:00

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]),
}),
}),
];
},
});