mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 02:25:01 +08:00
editor improvements
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user