editor improvements

* add callout, youtube embed, image, video, table, detail, math
* fix attachments module
* other fixes
This commit is contained in:
Philipinho
2024-06-20 14:57:00 +01:00
parent c7925739cb
commit 1f4bd129a8
74 changed files with 5205 additions and 381 deletions
@@ -0,0 +1,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
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",
}),
];
},
});
@@ -0,0 +1,2 @@
export { TiptapImage } from "./image";
export * from "./image-upload";