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

View File

@ -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";

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

View File

@ -0,0 +1,2 @@
export { Callout } from "./callout";
export * from "./utils";

View 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";
}
}

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

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

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

View File

@ -0,0 +1,3 @@
export { Details } from "./details";
export { DetailsSummary } from "./details-summary";
export { DetailsContent } from "./details-content";

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

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

View File

@ -0,0 +1,2 @@
export { TiptapImage } from "./image";
export * from "./image-upload";

View File

@ -0,0 +1,2 @@
export { MathInline } from "./math-inline";
export { MathBlock } from "./math-block";

View 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("$", ""),
}),
}),
];
},
});

View 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("$", ""),
}),
}),
];
},
});

View 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>;
}

View File

@ -0,0 +1,6 @@
import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content: "paragraph+",
});

View File

@ -0,0 +1,3 @@
import TiptapTableHeader from "@tiptap/extension-table-header";
export const TableHeader = TiptapTableHeader.configure();

View File

@ -0,0 +1,4 @@
export * from "./table-extension";
export * from "./header";
export * from "./row";
export * from "./cell";

View File

@ -0,0 +1,6 @@
import TiptapTableRow from "@tiptap/extension-table-row";
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: "(tableCell | tableHeader)*",
});

View File

@ -0,0 +1,7 @@
import TiptapTable from "@tiptap/extension-table";
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: false,
allowTableNodeSelection: true,
});

View 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>`;
}

View File

@ -0,0 +1,2 @@
export { TiptapVideo } from "./video";
export * from "./video-upload";

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

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