feat: editor file attachments (#194)

* fix current slider value

* WIP

* changes to extension attributes

* update command title
This commit is contained in:
Philip Okugbe
2024-08-26 12:38:47 +01:00
committed by GitHub
parent 7e80797e3f
commit 7dc37b933f
19 changed files with 450 additions and 16 deletions

View File

@ -0,0 +1,48 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
export default function AttachmentView(props: NodeViewProps) {
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { hovered, ref } = useHover();
return (
<NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle>
<Group
justify="space-between"
gap="xl"
style={{ cursor: "pointer" }}
wrap="nowrap"
h={25}
>
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Text component="span" size="md" truncate="end">
{name}
</Text>
<Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)}
</Text>
</Group>
{selected || hovered ? (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,31 @@
import { handleAttachmentUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
export const uploadAttachmentAction = handleAttachmentUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (file.type.includes("image/") || file.type.includes("video/")) {
return false;
}
if (file.size / 1024 / 1024 > 50) {
notifications.show({
color: "red",
message: `File exceeds the 50 MB attachment limit`,
});
return false;
}
return true;
},
});

View File

@ -1,6 +1,7 @@
import type { EditorView } from "@tiptap/pm/view"; import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
export const handleFilePaste = ( export const handleFilePaste = (
view: EditorView, view: EditorView,
@ -15,6 +16,7 @@ export const handleFilePaste = (
if (file) { if (file) {
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
} }
return true; return true;
} }
@ -38,6 +40,7 @@ export const handleFileDrop = (
if (file) { if (file) {
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }

View File

@ -1,4 +1,4 @@
import React, { memo, useCallback, useState } from "react"; import React, { memo, useCallback, useEffect, useState } from "react";
import { Slider } from "@mantine/core"; import { Slider } from "@mantine/core";
export type ImageWidthProps = { export type ImageWidthProps = {
@ -9,6 +9,10 @@ export type ImageWidthProps = {
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => { export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
const [currentValue, setCurrentValue] = useState(value); const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = useCallback( const handleChange = useCallback(
(newValue: number) => { (newValue: number) => {
onChange(newValue); onChange(newValue);

View File

@ -1,12 +1,16 @@
import { handleImageUpload } from "@docmost/editor-ext"; import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
export const uploadImageAction = handleImageUpload({ export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
try { try {
return await uploadFile(file, pageId); return await uploadFile(file, pageId);
} catch (err) { } catch (err) {
console.error("failed to upload image", err); notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err; throw err;
} }
}, },
@ -14,8 +18,11 @@ export const uploadImageAction = handleImageUpload({
if (!file.type.includes("image/")) { if (!file.type.includes("image/")) {
return false; return false;
} }
if (file.size / 1024 / 1024 > 20) { if (file.size / 1024 / 1024 > 50) {
//error("File size too big (max 20MB)."); notifications.show({
color: "red",
message: `File exceeds the 50 MB attachment limit`,
});
return false; return false;
} }
return true; return true;

View File

@ -13,6 +13,7 @@ import {
IconMath, IconMath,
IconMathFunction, IconMathFunction,
IconMovie, IconMovie,
IconPaperclip,
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
@ -23,6 +24,7 @@ import {
} from "@/features/editor/components/slash-menu/types"; } from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
const CommandGroups: SlashMenuGroupedItemsType = { const CommandGroups: SlashMenuGroupedItemsType = {
basic: [ basic: [
@ -127,7 +129,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
{ {
title: "Image", title: "Image",
description: "Upload an image from your computer.", description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"], searchTerms: ["photo", "picture", "media"],
icon: IconPhoto, icon: IconPhoto,
command: ({ editor, range }) => { command: ({ editor, range }) => {
@ -140,11 +142,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId); uploadImageAction(file, editor.view, pos, pageId);
}
} }
}; };
input.click(); input.click();
@ -152,7 +156,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
{ {
title: "Video", title: "Video",
description: "Upload an video from your computer.", description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"], searchTerms: ["video", "mp4", "media"],
icon: IconMovie, icon: IconMovie,
command: ({ editor, range }) => { command: ({ editor, range }) => {
@ -175,6 +179,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click(); input.click();
}, },
}, },
{
title: "File attachment",
description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload file
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
if (file.type.includes("image/*")) {
uploadImageAction(file, editor.view, pos, pageId);
} else if (file.type.includes("video/*")) {
uploadVideoAction(file, editor.view, pos, pageId);
} else {
uploadAttachmentAction(file, editor.view, pos, pageId);
}
}
};
input.click();
},
},
{ {
title: "Table", title: "Table",
description: "Insert a table.", description: "Insert a table.",

View File

@ -1,12 +1,16 @@
import { handleVideoUpload } from "@docmost/editor-ext"; import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
export const uploadVideoAction = handleVideoUpload({ export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => { onUpload: async (file: File, pageId: string): Promise<any> => {
try { try {
return await uploadFile(file, pageId); return await uploadFile(file, pageId);
} catch (err) { } catch (err) {
console.error("failed to upload image", err); notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err; throw err;
} }
}, },
@ -15,7 +19,11 @@ export const uploadVideoAction = handleVideoUpload({
return false; return false;
} }
if (file.size / 1024 / 1024 > 20) { if (file.size / 1024 / 1024 > 50) {
notifications.show({
color: "red",
message: `File exceeds the 50 MB attachment limit`,
});
return false; return false;
} }
return true; return true;

View File

@ -31,6 +31,7 @@ import {
TiptapVideo, TiptapVideo,
LinkExtension, LinkExtension,
Selection, Selection,
Attachment,
CustomCodeBlock, CustomCodeBlock,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
@ -46,6 +47,7 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight"; import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
@ -146,6 +148,9 @@ export const mainExtensions = [
}, },
}), }),
Selection, Selection,
Attachment.configure({
view: AttachmentView,
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -9,5 +9,29 @@
outline: none; outline: none;
} }
} }
.attachment-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--mantine-color-body);
border-radius: var(--mantine-radius-default);
cursor: pointer;
padding: 15px;
height: 25px;
@mixin light {
border: 1px solid var(--mantine-color-gray-3);
}
@mixin dark {
border: 1px solid var(--mantine-color-dark-4);
}
}
.uploading-text {
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
}
} }

View File

@ -30,7 +30,7 @@ export function getAvatarUrl(avatarUrl: string) {
return null; return null;
} }
if (avatarUrl.startsWith("http")) { if (avatarUrl?.startsWith("http")) {
return avatarUrl; return avatarUrl;
} }
@ -42,5 +42,5 @@ export function getSpaceUrl(spaceSlug: string) {
} }
export function getFileUrl(src: string) { export function getFileUrl(src: string) {
return src.startsWith("/files/") ? getBackendUrl() + src : src; return src?.startsWith("/files/") ? getBackendUrl() + src : src;
} }

View File

@ -25,3 +25,22 @@ export const computeSpaceSlug = (name: string) => {
return alphanumericName.toLowerCase(); return alphanumericName.toLowerCase();
} }
}; };
export const formatBytes = (
bytes: number,
decimalPlaces: number = 2,
): string => {
if (bytes === 0) return "0.0 KB";
const unitSize = 1024;
const precision = decimalPlaces < 0 ? 0 : decimalPlaces;
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const kilobytes = bytes / unitSize;
const unitIndex = Math.floor(Math.log(kilobytes) / Math.log(unitSize));
const adjustedUnitIndex = Math.max(unitIndex, 0);
const adjustedSize = kilobytes / Math.pow(unitSize, adjustedUnitIndex);
return `${adjustedSize.toFixed(precision)} ${units[adjustedUnitIndex]}`;
};

View File

@ -27,6 +27,7 @@ import {
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
TrailingNode, TrailingNode,
Attachment,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, JSONContent } from '@tiptap/core'; import { generateText, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html'; import { generateHTML } from '../common/helpers/prosemirror/html';
@ -65,6 +66,7 @@ export const tiptapExtensions = [
TiptapImage, TiptapImage,
TiptapVideo, TiptapVideo,
Callout, Callout,
Attachment,
CustomCodeBlock CustomCodeBlock
] as any; ] as any;

View File

@ -16,4 +16,4 @@ export const InlineFileExtensions = [
'.mp4', '.mp4',
'.mov', '.mov',
]; ];
export const MAX_FILE_SIZE = '20MB'; export const MAX_FILE_SIZE = '50MB';

View File

@ -123,6 +123,11 @@ export class AttachmentController {
return res.send(fileResponse); return res.send(fileResponse);
} catch (err: any) { } catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${MAX_FILE_SIZE} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err); this.logger.error(err);
throw new BadRequestException('Error processing file upload.'); throw new BadRequestException('Error processing file upload.');
} }

View File

@ -38,7 +38,6 @@ export async function prepareFile(
mimeType: file.mimetype, mimeType: file.mimetype,
}; };
} catch (error) { } catch (error) {
console.error('Error in file preparation:', error);
throw error; throw error;
} }
} }

View File

@ -10,5 +10,5 @@ export * from "./lib/callout";
export * from "./lib/media-utils"; export * from "./lib/media-utils";
export * from "./lib/link"; export * from "./lib/link";
export * from "./lib/selection"; export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/custom-code-block" export * from "./lib/custom-code-block"

View File

@ -0,0 +1,119 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
const uploadKey = new PluginKey("attachment-upload");
export const AttachmentUploadPlugin = ({
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, fileName } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", placeholderClass);
const uploadingText = document.createElement("span");
uploadingText.setAttribute("class", "uploading-text");
uploadingText.textContent = `Uploading ${fileName}`;
placeholder.appendChild(uploadingText);
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 handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => {
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();
tr.setMeta(uploadKey, {
add: {
id,
pos,
fileName: file.name,
},
});
view.dispatch(tr);
await onUpload(file, pageId).then(
(attachment: IAttachment) => {
const { schema } = view.state;
const pos = findPlaceholder(view.state, id);
if (pos == null) return;
if (!attachment) return;
const node = schema.nodes.attachment?.create({
url: `/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
mime: attachment.mimeType,
size: attachment.fileSize,
attachmentId: attachment.id,
});
if (!node) return;
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
() => {
// Deletes the placeholder on error
const transaction = view.state.tr
.delete(pos, pos)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
},
);
};

View File

@ -0,0 +1,123 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { AttachmentUploadPlugin } from "./attachment-upload";
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface AttachmentAttributes {
url?: string;
name?: string;
mime?: string; // mime type e.g. application/zip
size?: number;
attachmentId?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
attachment: {
setAttachment: (attributes: AttachmentAttributes) => ReturnType;
};
}
}
export const Attachment = Node.create<AttachmentOptions>({
name: "attachment",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
name: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-name"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-name": attributes.name,
}),
},
extension: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-mime"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-mime": attributes.mime,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-attachment-size"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-size": attributes.size,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addCommands() {
return {
setAttachment:
(attrs: AttachmentAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "attachment",
attrs: attrs,
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
addProseMirrorPlugins() {
return [
AttachmentUploadPlugin({
placeholderClass: "attachment-placeholder",
}),
];
},
});

View File

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