mirror of
https://github.com/docmost/docmost.git
synced 2025-11-17 23:01:09 +10:00
editor improvements
* add callout, youtube embed, image, video, table, detail, math * fix attachments module * other fixes
This commit is contained in:
@ -1,2 +1,10 @@
|
||||
export * from './lib/trailing-node';
|
||||
export * from './lib/comment/comment'
|
||||
export * from "./lib/trailing-node";
|
||||
export * from "./lib/comment/comment";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/math";
|
||||
export * from "./lib/details";
|
||||
export * from "./lib/table";
|
||||
export * from "./lib/image";
|
||||
export * from "./lib/video";
|
||||
export * from "./lib/callout";
|
||||
export * from "./lib/media-utils";
|
||||
|
||||
201
packages/editor-ext/src/lib/callout/callout.ts
Normal file
201
packages/editor-ext/src/lib/callout/callout.ts
Normal file
@ -0,0 +1,201 @@
|
||||
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]),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
2
packages/editor-ext/src/lib/callout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Callout } from "./callout";
|
||||
export * from "./utils";
|
||||
8
packages/editor-ext/src/lib/callout/utils.ts
Normal file
8
packages/editor-ext/src/lib/callout/utils.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export type CalloutType = "default" | "info" | "success" | "warning" | "danger";
|
||||
const validCalloutTypes = ["default", "info", "success", "warning", "danger"];
|
||||
|
||||
export function getValidCalloutType(value: string): string {
|
||||
if (value) {
|
||||
return validCalloutTypes.includes(value) ? value : "info";
|
||||
}
|
||||
}
|
||||
111
packages/editor-ext/src/lib/details/details-content.ts
Normal file
111
packages/editor-ext/src/lib/details/details-content.ts
Normal file
@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal file
96
packages/editor-ext/src/lib/details/details-summary.ts
Normal file
@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
236
packages/editor-ext/src/lib/details/details.ts
Normal file
236
packages/editor-ext/src/lib/details/details.ts
Normal file
@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
3
packages/editor-ext/src/lib/details/index.ts
Normal file
3
packages/editor-ext/src/lib/details/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Details } from "./details";
|
||||
export { DetailsSummary } from "./details-summary";
|
||||
export { DetailsContent } from "./details-content";
|
||||
125
packages/editor-ext/src/lib/image/image-upload.ts
Normal file
125
packages/editor-ext/src/lib/image/image-upload.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { IAttachment } from "client/src/lib/types";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
|
||||
const uploadKey = new PluginKey("image-upload");
|
||||
|
||||
export const ImageUploadPlugin = ({
|
||||
placeHolderClass,
|
||||
}: {
|
||||
placeHolderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "img-placeholder");
|
||||
const image = document.createElement("img");
|
||||
image.setAttribute("class", placeHolderClass);
|
||||
image.src = src;
|
||||
placeholder.appendChild(image);
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleImageUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return;
|
||||
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.image?.create({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the image placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
};
|
||||
148
packages/editor-ext/src/lib/image/image.ts
Normal file
148
packages/editor-ext/src/lib/image/image.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { ImageUploadPlugin } from "./image-upload";
|
||||
import { mergeAttributes, Range } from "@tiptap/core";
|
||||
|
||||
export interface ImageOptions extends DefaultImageOptions {
|
||||
view: any;
|
||||
}
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageBlock: {
|
||||
setImage: (attributes: ImageAttributes) => ReturnType;
|
||||
setImageAt: (
|
||||
attributes: ImageAttributes & { pos: number | Range },
|
||||
) => ReturnType;
|
||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setImageWidth: (width: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TiptapImage = Image.extend<ImageOptions>({
|
||||
name: "image",
|
||||
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("src"),
|
||||
renderHTML: (attributes) => ({
|
||||
src: attributes.src,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
alt: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("alt"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
alt: attributes.alt,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-attachment-id": attributes.align,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"img",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImage:
|
||||
(attrs: ImageAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "image",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setImageAt:
|
||||
(attrs) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContentAt(attrs.pos, {
|
||||
type: "image",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setImageAlign:
|
||||
(align) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("image", { align }),
|
||||
|
||||
setImageWidth:
|
||||
(width) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("image", {
|
||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
ImageUploadPlugin({
|
||||
placeHolderClass: "image-upload",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
2
packages/editor-ext/src/lib/image/index.ts
Normal file
2
packages/editor-ext/src/lib/image/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TiptapImage } from "./image";
|
||||
export * from "./image-upload";
|
||||
2
packages/editor-ext/src/lib/math/index.ts
Normal file
2
packages/editor-ext/src/lib/math/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { MathInline } from "./math-inline";
|
||||
export { MathBlock } from "./math-block";
|
||||
95
packages/editor-ext/src/lib/math/math-block.ts
Normal file
95
packages/editor-ext/src/lib/math/math-block.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { Node, nodeInputRule } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
mathBlock: {
|
||||
setMathBlock: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathBlockOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface MathBlockAttributes {
|
||||
katex: string;
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:\$\$\$)((?:[^$]+))(?:\$\$\$))$/;
|
||||
|
||||
export const MathBlock = Node.create({
|
||||
name: "mathBlock",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
katex: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.innerHTML.split("$")[1],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "div",
|
||||
getAttrs: (node: HTMLElement) => {
|
||||
return node.hasAttribute("data-katex") ? {} : false;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
{},
|
||||
["div", { "data-katex": true }, `$${HTMLAttributes.katex}$`],
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return node.attrs.katex;
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMathBlock:
|
||||
(attributes?: Record<string, any>) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
katex: match[1].replaceAll("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
92
packages/editor-ext/src/lib/math/math-inline.ts
Normal file
92
packages/editor-ext/src/lib/math/math-inline.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { Node, nodeInputRule, wrappingInputRule } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
mathInline: {
|
||||
setMathInline: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface MathInlineOption {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export const inputRegex = /(?:^|\s)((?:\$\$)((?:[^$]+))(?:\$\$))$/;
|
||||
|
||||
export const MathInline = Node.create<MathInlineOption>({
|
||||
name: "mathInline",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
katex: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.innerHTML.split("$")[1],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "span",
|
||||
getAttrs: (node: HTMLElement) => {
|
||||
return node.hasAttribute("data-katex") ? {} : false;
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
{},
|
||||
["span", { "data-katex": true }, `$${HTMLAttributes.katex}$`],
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return node.attrs.katex;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMathInline:
|
||||
(attributes?: Record<string, any>) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
getAttributes: (match) => ({
|
||||
katex: match[1].replaceAll("$", ""),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
51
packages/editor-ext/src/lib/media-utils.ts
Normal file
51
packages/editor-ext/src/lib/media-utils.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export type UploadFn = (
|
||||
file: File,
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
pageId: string,
|
||||
) => void;
|
||||
|
||||
export const handleMediaPaste = (
|
||||
view: EditorView,
|
||||
event: ClipboardEvent,
|
||||
uploadFn: UploadFn,
|
||||
pageId: string,
|
||||
) => {
|
||||
if (event.clipboardData?.files.length) {
|
||||
event.preventDefault();
|
||||
const [file] = Array.from(event.clipboardData.files);
|
||||
const pos = view.state.selection.from;
|
||||
|
||||
if (file) uploadFn(file, view, pos, pageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const handleMediaDrop = (
|
||||
view: EditorView,
|
||||
event: DragEvent,
|
||||
moved: boolean,
|
||||
uploadFn: UploadFn,
|
||||
pageId: string,
|
||||
) => {
|
||||
if (!moved && event.dataTransfer?.files.length) {
|
||||
event.preventDefault();
|
||||
const [file] = Array.from(event.dataTransfer.files);
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
// here we deduct 1 from the pos or else the image will create an extra node
|
||||
if (file) uploadFn(file, view, coordinates?.pos ?? 0 - 1, pageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export interface MediaUploadOptions {
|
||||
validateFn?: (file: File) => void;
|
||||
onUpload: (file: File, pageId: string) => Promise<any>;
|
||||
}
|
||||
6
packages/editor-ext/src/lib/table/cell.ts
Normal file
6
packages/editor-ext/src/lib/table/cell.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
|
||||
|
||||
export const TableCell = TiptapTableCell.extend({
|
||||
name: "tableCell",
|
||||
content: "paragraph+",
|
||||
});
|
||||
3
packages/editor-ext/src/lib/table/header.ts
Normal file
3
packages/editor-ext/src/lib/table/header.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import TiptapTableHeader from "@tiptap/extension-table-header";
|
||||
|
||||
export const TableHeader = TiptapTableHeader.configure();
|
||||
4
packages/editor-ext/src/lib/table/index.ts
Normal file
4
packages/editor-ext/src/lib/table/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./table-extension";
|
||||
export * from "./header";
|
||||
export * from "./row";
|
||||
export * from "./cell";
|
||||
6
packages/editor-ext/src/lib/table/row.ts
Normal file
6
packages/editor-ext/src/lib/table/row.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import TiptapTableRow from "@tiptap/extension-table-row";
|
||||
|
||||
export const TableRow = TiptapTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
content: "(tableCell | tableHeader)*",
|
||||
});
|
||||
7
packages/editor-ext/src/lib/table/table-extension.ts
Normal file
7
packages/editor-ext/src/lib/table/table-extension.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import TiptapTable from "@tiptap/extension-table";
|
||||
|
||||
export const Table = TiptapTable.configure({
|
||||
resizable: true,
|
||||
lastColumnResizable: false,
|
||||
allowTableNodeSelection: true,
|
||||
});
|
||||
373
packages/editor-ext/src/lib/utils.ts
Normal file
373
packages/editor-ext/src/lib/utils.ts
Normal file
@ -0,0 +1,373 @@
|
||||
// @ts-nocheck
|
||||
import { Editor, findParentNode } from "@tiptap/core";
|
||||
import { Selection, Transaction } from "@tiptap/pm/state";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import { Node, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { Table } from "./table/table-extension";
|
||||
|
||||
export const isRectSelected = (rect: any) => (selection: CellSelection) => {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const start = selection.$anchorCell.start(-1);
|
||||
const cells = map.cellsInRect(rect);
|
||||
const selectedCells = map.cellsInRect(
|
||||
map.rectBetween(
|
||||
selection.$anchorCell.pos - start,
|
||||
selection.$headCell.pos - start,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const findTable = (selection: Selection) =>
|
||||
findParentNode(
|
||||
(node) => node.type.spec.tableRole && node.type.spec.tableRole === "table",
|
||||
)(selection);
|
||||
|
||||
export const isCellSelection = (selection: any) =>
|
||||
selection instanceof CellSelection;
|
||||
|
||||
export const isColumnSelected = (columnIndex: number) => (selection: any) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: columnIndex,
|
||||
right: columnIndex + 1,
|
||||
top: 0,
|
||||
bottom: map.height,
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isRowSelected = (rowIndex: number) => (selection: any) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: rowIndex,
|
||||
bottom: rowIndex + 1,
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isTableSelected = (selection: any) => {
|
||||
if (isCellSelection(selection)) {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
|
||||
return isRectSelected({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height,
|
||||
})(selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getCellsInColumn =
|
||||
(columnIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(columnIndex)
|
||||
? columnIndex
|
||||
: Array.from([columnIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.width - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: index,
|
||||
right: index + 1,
|
||||
top: 0,
|
||||
bottom: map.height,
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInRow =
|
||||
(rowIndex: number | number[]) => (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const indexes = Array.isArray(rowIndex)
|
||||
? rowIndex
|
||||
: Array.from([rowIndex]);
|
||||
|
||||
return indexes.reduce(
|
||||
(acc, index) => {
|
||||
if (index >= 0 && index <= map.height - 1) {
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: index,
|
||||
bottom: index + 1,
|
||||
});
|
||||
|
||||
return acc.concat(
|
||||
cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
return { pos, start: pos + 1, node };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { pos: number; start: number; node: Node | null | undefined }[],
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCellsInTable = (selection: Selection) => {
|
||||
const table = findTable(selection);
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
const cells = map.cellsInRect({
|
||||
left: 0,
|
||||
right: map.width,
|
||||
top: 0,
|
||||
bottom: map.height,
|
||||
});
|
||||
|
||||
return cells.map((nodePos) => {
|
||||
const node = table.node.nodeAt(nodePos);
|
||||
const pos = nodePos + table.start;
|
||||
|
||||
return { pos, start: pos + 1, node };
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findParentNodeClosestToPos = (
|
||||
$pos: ResolvedPos,
|
||||
predicate: (node: Node) => boolean,
|
||||
) => {
|
||||
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||
const node = $pos.node(i);
|
||||
|
||||
if (predicate(node)) {
|
||||
return {
|
||||
pos: i > 0 ? $pos.before(i) : 0,
|
||||
start: $pos.start(i),
|
||||
depth: i,
|
||||
node,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||
const predicate = (node: Node) =>
|
||||
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
|
||||
|
||||
return findParentNodeClosestToPos($pos, predicate);
|
||||
};
|
||||
|
||||
const select =
|
||||
(type: "row" | "column") => (index: number) => (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
const isRowSelection = type === "row";
|
||||
|
||||
if (table) {
|
||||
const map = TableMap.get(table.node);
|
||||
|
||||
// Check if the index is valid
|
||||
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||
const left = isRowSelection ? 0 : index;
|
||||
const top = isRowSelection ? index : 0;
|
||||
const right = isRowSelection ? map.width : index + 1;
|
||||
const bottom = isRowSelection ? index + 1 : map.height;
|
||||
|
||||
const cellsInFirstRow = map.cellsInRect({
|
||||
left,
|
||||
top,
|
||||
right: isRowSelection ? right : left + 1,
|
||||
bottom: isRowSelection ? top + 1 : bottom,
|
||||
});
|
||||
|
||||
const cellsInLastRow =
|
||||
bottom - top === 1
|
||||
? cellsInFirstRow
|
||||
: map.cellsInRect({
|
||||
left: isRowSelection ? left : right - 1,
|
||||
top: isRowSelection ? bottom - 1 : top,
|
||||
right,
|
||||
bottom,
|
||||
});
|
||||
|
||||
const head = table.start + cellsInFirstRow[0];
|
||||
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
// @ts-ignore
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const selectColumn = select("column");
|
||||
|
||||
export const selectRow = select("row");
|
||||
|
||||
export const selectTable = (tr: Transaction) => {
|
||||
const table = findTable(tr.selection);
|
||||
|
||||
if (table) {
|
||||
const { map } = TableMap.get(table.node);
|
||||
|
||||
if (map && map.length) {
|
||||
const head = table.start + map[0];
|
||||
const anchor = table.start + map[map.length - 1];
|
||||
const $head = tr.doc.resolve(head);
|
||||
const $anchor = tr.doc.resolve(anchor);
|
||||
|
||||
// @ts-ignore
|
||||
return tr.setSelection(new CellSelection($anchor, $head));
|
||||
}
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
export const isColumnGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from,
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (
|
||||
!editor.isActive(Table.name) ||
|
||||
!node ||
|
||||
isTableSelected(state.selection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !["TD", "TH"].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripColumn =
|
||||
container &&
|
||||
container.querySelector &&
|
||||
container.querySelector("a.grip-column.selected");
|
||||
|
||||
return !!gripColumn;
|
||||
};
|
||||
|
||||
export const isRowGripSelected = ({
|
||||
editor,
|
||||
view,
|
||||
state,
|
||||
from,
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
}) => {
|
||||
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (
|
||||
!editor.isActive(Table.name) ||
|
||||
!node ||
|
||||
isTableSelected(state.selection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let container = node;
|
||||
|
||||
while (container && !["TD", "TH"].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
|
||||
const gripRow =
|
||||
container &&
|
||||
container.querySelector &&
|
||||
container.querySelector("a.grip-row.selected");
|
||||
|
||||
return !!gripRow;
|
||||
};
|
||||
|
||||
export function parseAttributes(value: string) {
|
||||
const regex = /([^=\s]+)="?([^"]+)"?/g;
|
||||
const attrs: Record<string, string> = {};
|
||||
let match: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = regex.exec(value))) {
|
||||
attrs[match[1]] = match[2];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
|
||||
export function setAttributes(
|
||||
editor: Editor,
|
||||
getPos: (() => number) | boolean,
|
||||
attrs: Record<string, any>,
|
||||
) {
|
||||
if (editor.isEditable && typeof getPos === "function") {
|
||||
editor.view.dispatch(
|
||||
editor.view.state.tr.setNodeMarkup(getPos(), undefined, attrs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function icon(name: string) {
|
||||
return `<span class="ProseMirror-icon ProseMirror-icon-${name}"></span>`;
|
||||
}
|
||||
2
packages/editor-ext/src/lib/video/index.ts
Normal file
2
packages/editor-ext/src/lib/video/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TiptapVideo } from "./video";
|
||||
export * from "./video-upload";
|
||||
125
packages/editor-ext/src/lib/video/video-upload.ts
Normal file
125
packages/editor-ext/src/lib/video/video-upload.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { IAttachment } from "client/src/lib/types";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
|
||||
const uploadKey = new PluginKey("video-upload");
|
||||
|
||||
export const VideoUploadPlugin = ({
|
||||
placeHolderClass,
|
||||
}: {
|
||||
placeHolderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "video-placeholder");
|
||||
const video = document.createElement("video");
|
||||
video.setAttribute("class", placeHolderClass);
|
||||
video.src = src;
|
||||
placeholder.appendChild(video);
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleVideoUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return;
|
||||
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.video?.create({
|
||||
src: `/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the image placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
};
|
||||
146
packages/editor-ext/src/lib/video/video.ts
Normal file
146
packages/editor-ext/src/lib/video/video.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { VideoUploadPlugin } from "./video-upload";
|
||||
import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core";
|
||||
|
||||
export interface VideoOptions {
|
||||
view: any;
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
export interface VideoAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
videoBlock: {
|
||||
setVideo: (attributes: VideoAttributes) => ReturnType;
|
||||
setVideoAt: (
|
||||
attributes: VideoAttributes & { pos: number | Range },
|
||||
) => ReturnType;
|
||||
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setVideoWidth: (width: number) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||
|
||||
export const TiptapVideo = Node.create<VideoOptions>({
|
||||
name: "video",
|
||||
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
view: null,
|
||||
HTMLAttributes: {},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("src"),
|
||||
renderHTML: (attributes) => ({
|
||||
src: attributes.src,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: VideoAttributes) => ({
|
||||
"data-attachment-id": attributes.align,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: VideoAttributes) => ({
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: VideoAttributes) => ({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: VideoAttributes) => ({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"video",
|
||||
{ controls: "true", ...HTMLAttributes },
|
||||
["source", HTMLAttributes],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo:
|
||||
(attrs: VideoAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: "video",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
|
||||
setVideoAlign:
|
||||
(align) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("video", { align }),
|
||||
|
||||
setVideoWidth:
|
||||
(width) =>
|
||||
({ commands }) =>
|
||||
commands.updateAttributes("video", {
|
||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
nodeInputRule({
|
||||
find: VIDEO_INPUT_REGEX,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const [, , src] = match;
|
||||
return { src };
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
VideoUploadPlugin({
|
||||
placeHolderClass: "video-upload",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user