editor improvements

* add callout, youtube embed, image, video, table, detail, math
* fix attachments module
* other fixes
This commit is contained in:
Philipinho
2024-06-20 14:57:00 +01:00
parent c7925739cb
commit 1f4bd129a8
74 changed files with 5205 additions and 381 deletions
@@ -0,0 +1,111 @@
import {
Node,
defaultBlockAt,
findParentNode,
mergeAttributes,
} from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
export interface DetailsContentOptions {
HTMLAttributes: Record<string, any>;
}
export const DetailsContent = Node.create<DetailsContentOptions>({
name: "detailsContent",
group: "block",
content: "block*",
defining: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
const view = editor.view;
const state = editor.state;
const selection = state.selection;
const findNode = findParentNode((node) => node.type.name === this.name)(
selection,
);
if (!selection.empty || !findNode || !findNode.node.childCount) {
return false;
}
const childCount = findNode.node.childCount;
if (!(childCount === selection.$from.index(findNode.depth) + 1)) {
return false;
}
const fillNode =
findNode.node.type.contentMatch.defaultType?.createAndFill();
if (!fillNode) {
return false;
}
const lastNode = findNode.node.child(childCount - 1);
if (!lastNode.eq(fillNode)) {
return false;
}
const rootNode = selection.$from.node(-3);
if (!rootNode) {
return false;
}
const indexAfter = selection.$from.indexAfter(-3);
const nodeType = defaultBlockAt(rootNode.contentMatchAt(indexAfter));
if (
!nodeType ||
!rootNode.canReplaceWith(indexAfter, indexAfter, nodeType)
) {
return false;
}
const defaultNode = nodeType.createAndFill();
if (!defaultNode) {
return false;
}
const tr = state.tr;
const after = selection.$from.after(-2);
tr.replaceWith(after, after, defaultNode);
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
const from = state.doc
.resolve(findNode.pos + 1)
.posAtIndex(childCount - 1, findNode.depth);
const to = from + lastNode.nodeSize;
tr.delete(from, to);
tr.scrollIntoView();
view.dispatch(tr);
return true;
},
};
},
});
@@ -0,0 +1,96 @@
import { Node, defaultBlockAt, mergeAttributes } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
export interface DetailsSummaryOptions {
HTMLAttributes: Record<string, any>;
}
export const DetailsSummary = Node.create<DetailsSummaryOptions>({
name: "detailsSummary",
group: "block",
content: "inline*",
defining: true,
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: "summary",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"summary",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const state = editor.state;
const selection = state.selection;
if (selection.$anchor.parent.type.name !== this.name) {
return false;
}
if (selection.$anchor.parentOffset !== 0) {
return false;
}
return editor.chain().unsetDetails().focus().run();
},
Enter: ({ editor }) => {
const view = editor.view;
const state = editor.state;
const head = state.selection.$head;
if (head.parent.type.name !== this.name) {
return false;
}
const hasOffset =
// @ts-ignore
view.domAtPos(head.after() + 1).node.offsetParent !== null;
const findNode = hasOffset
? state.doc.nodeAt(head.after())
: head.node(-2);
if (!findNode) {
return false;
}
const indexAfter = hasOffset ? 0 : head.indexAfter(-1);
const nodeType = defaultBlockAt(findNode.contentMatchAt(indexAfter));
if (
!nodeType ||
!findNode.canReplaceWith(indexAfter, indexAfter, nodeType)
) {
return false;
}
const defaultNode = nodeType.createAndFill();
if (!defaultNode) {
return false;
}
const tr = state.tr;
const after = hasOffset ? head.after() + 1 : head.after(-1);
tr.replaceWith(after, after, defaultNode);
tr.setSelection(Selection.near(tr.doc.resolve(after), 1));
tr.scrollIntoView();
view.dispatch(tr);
return true;
},
};
},
});
@@ -0,0 +1,236 @@
import {
Node,
findChildren,
findParentNode,
mergeAttributes,
wrappingInputRule,
} from "@tiptap/core";
import { icon, setAttributes } from "../utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
details: {
setDetails: () => ReturnType;
unsetDetails: () => ReturnType;
toggleDetails: () => ReturnType;
};
}
}
export interface DetailsOptions {
HTMLAttributes: Record<string, any>;
}
export const Details = Node.create<DetailsOptions>({
name: "details",
group: "block",
content: "detailsSummary detailsContent",
defining: true,
isolating: true,
allowGapCursor: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
open: {
default: false,
parseHTML: (e) => e.getAttribute("open"),
renderHTML: (a) => (a.open ? { open: "" } : {}),
},
};
},
parseHTML() {
return [
{
tag: "details",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"details",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement("div");
const btn = document.createElement("button");
const ico = document.createElement("div");
const div = document.createElement("div");
for (const [key, value] of Object.entries(
mergeAttributes(this.options.HTMLAttributes),
)) {
if (value !== undefined && value !== null) {
dom.setAttribute(key, value);
}
}
dom.setAttribute("data-type", this.name);
btn.setAttribute("data-type", `${this.name}Button`);
div.setAttribute("data-type", `${this.name}Container`);
if (node.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
ico.innerHTML = icon("right-line");
btn.addEventListener("click", () => {
const open = !dom.hasAttribute("open");
if (!editor.isEditable) {
// In readonly mode, toggle the 'open' attribute without updating the document state.
if (open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
return;
}
setAttributes(editor, getPos, { ...node.attrs, open });
});
btn.append(ico);
dom.append(btn);
dom.append(div);
return {
dom,
contentDOM: div,
update: (updatedNode) => {
if (updatedNode.type !== this.type) {
return false;
}
if (updatedNode.attrs.open) {
dom.setAttribute("open", "true");
} else {
dom.removeAttribute("open");
}
return true;
},
};
};
},
addCommands() {
return {
setDetails: () => {
return ({ state, chain }) => {
const range = state.selection.$from.blockRange(state.selection.$to);
if (!range) {
return false;
}
const slice = state.doc.slice(range.start, range.end);
if (
!state.schema.nodes.detailsContent.contentMatch.matchFragment(
slice.content,
)
) {
return false;
}
return chain()
.insertContentAt(
{
from: range.start,
to: range.end,
},
{
type: this.name,
attrs: {
open: true,
},
content: [
{
type: "detailsSummary",
},
{
type: "detailsContent",
content: slice.toJSON()?.content ?? [],
},
],
},
)
.setTextSelection(range.start + 2)
.run();
};
},
unsetDetails: () => {
return ({ state, chain }) => {
const parent = findParentNode((node) => node.type === this.type)(
state.selection,
);
if (!parent) {
return false;
}
const summary = findChildren(
parent.node,
(node) => node.type.name === "detailsSummary",
);
const content = findChildren(
parent.node,
(node) => node.type.name === "detailsContent",
);
if (!summary.length || !content.length) {
return false;
}
const range = {
from: parent.pos,
to: parent.pos + parent.node.nodeSize,
};
const defaultType = state.doc.resolve(range.from).parent.type
.contentMatch.defaultType;
return chain()
.insertContentAt(range, [
defaultType?.create(null, summary[0].node.content).toJSON(),
...(content[0].node.content.toJSON() ?? []),
])
.setTextSelection(range.from + 1)
.run();
};
},
toggleDetails: () => {
return ({ state, chain }) => {
const node = findParentNode((node) => node.type === this.type)(
state.selection,
);
if (node) {
return chain().unsetDetails().run();
} else {
return chain().setDetails().run();
}
};
},
};
},
addInputRules() {
return [
wrappingInputRule({
find: /^:::details\s$/,
type: this.type,
}),
];
},
addKeyboardShortcuts() {
return {
"Mod-Alt-d": () => this.editor.commands.toggleDetails(),
};
},
});
@@ -0,0 +1,3 @@
export { Details } from "./details";
export { DetailsSummary } from "./details-summary";
export { DetailsContent } from "./details-content";