diff --git a/apps/client/src/features/editor/components/attachment/attachment-view.tsx b/apps/client/src/features/editor/components/attachment/attachment-view.tsx new file mode 100644 index 00000000..d3858520 --- /dev/null +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -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 ( + + + + + + + + {name} + + + + {formatBytes(size)} + + + + {selected || hovered ? ( + + + + + + ) : ( + "" + )} + + + + ); +} diff --git a/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx b/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx new file mode 100644 index 00000000..921d30fb --- /dev/null +++ b/apps/client/src/features/editor/components/attachment/upload-attachment-action.tsx @@ -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 => { + 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; + }, +}); diff --git a/apps/client/src/features/editor/components/common/file-upload-handler.tsx b/apps/client/src/features/editor/components/common/file-upload-handler.tsx index 87c84bc2..0486286a 100644 --- a/apps/client/src/features/editor/components/common/file-upload-handler.tsx +++ b/apps/client/src/features/editor/components/common/file-upload-handler.tsx @@ -1,6 +1,7 @@ import type { EditorView } from "@tiptap/pm/view"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; +import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; export const handleFilePaste = ( view: EditorView, @@ -15,6 +16,7 @@ export const handleFilePaste = ( if (file) { uploadImageAction(file, view, pos, pageId); uploadVideoAction(file, view, pos, pageId); + uploadAttachmentAction(file, view, pos, pageId); } return true; } @@ -38,6 +40,7 @@ export const handleFileDrop = ( if (file) { uploadImageAction(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; } diff --git a/apps/client/src/features/editor/components/common/node-width-resize.tsx b/apps/client/src/features/editor/components/common/node-width-resize.tsx index ca4e8dcc..ef1ea1f1 100644 --- a/apps/client/src/features/editor/components/common/node-width-resize.tsx +++ b/apps/client/src/features/editor/components/common/node-width-resize.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from "react"; +import React, { memo, useCallback, useEffect, useState } from "react"; import { Slider } from "@mantine/core"; export type ImageWidthProps = { @@ -9,6 +9,10 @@ export type ImageWidthProps = { export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => { const [currentValue, setCurrentValue] = useState(value); + useEffect(() => { + setCurrentValue(value); + }, [value]); + const handleChange = useCallback( (newValue: number) => { onChange(newValue); diff --git a/apps/client/src/features/editor/components/image/upload-image-action.tsx b/apps/client/src/features/editor/components/image/upload-image-action.tsx index a23f2c17..ea7f6213 100644 --- a/apps/client/src/features/editor/components/image/upload-image-action.tsx +++ b/apps/client/src/features/editor/components/image/upload-image-action.tsx @@ -1,12 +1,16 @@ import { handleImageUpload } from "@docmost/editor-ext"; import { uploadFile } from "@/features/page/services/page-service.ts"; +import { notifications } from "@mantine/notifications"; export const uploadImageAction = handleImageUpload({ onUpload: async (file: File, pageId: string): Promise => { try { return await uploadFile(file, pageId); } catch (err) { - console.error("failed to upload image", err); + notifications.show({ + color: "red", + message: err?.response.data.message, + }); throw err; } }, @@ -14,8 +18,11 @@ export const uploadImageAction = handleImageUpload({ if (!file.type.includes("image/")) { return false; } - if (file.size / 1024 / 1024 > 20) { - //error("File size too big (max 20MB)."); + if (file.size / 1024 / 1024 > 50) { + notifications.show({ + color: "red", + message: `File exceeds the 50 MB attachment limit`, + }); return false; } return true; diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 8fb69cde..b77688ee 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -13,6 +13,7 @@ import { IconMath, IconMathFunction, IconMovie, + IconPaperclip, IconPhoto, IconTable, IconTypography, @@ -23,6 +24,7 @@ import { } from "@/features/editor/components/slash-menu/types"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-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 = { basic: [ @@ -127,7 +129,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { }, { title: "Image", - description: "Upload an image from your computer.", + description: "Upload any image from your device.", searchTerms: ["photo", "picture", "media"], icon: IconPhoto, command: ({ editor, range }) => { @@ -140,11 +142,13 @@ const CommandGroups: SlashMenuGroupedItemsType = { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; + input.multiple = true; input.onchange = async () => { if (input.files?.length) { - const file = input.files[0]; - const pos = editor.view.state.selection.from; - uploadImageAction(file, editor.view, pos, pageId); + for (const file of input.files) { + const pos = editor.view.state.selection.from; + uploadImageAction(file, editor.view, pos, pageId); + } } }; input.click(); @@ -152,7 +156,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { }, { title: "Video", - description: "Upload an video from your computer.", + description: "Upload any video from your device.", searchTerms: ["video", "mp4", "media"], icon: IconMovie, command: ({ editor, range }) => { @@ -175,6 +179,37 @@ const CommandGroups: SlashMenuGroupedItemsType = { 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", description: "Insert a table.", diff --git a/apps/client/src/features/editor/components/video/upload-video-action.tsx b/apps/client/src/features/editor/components/video/upload-video-action.tsx index 212b72a5..3a59ad16 100644 --- a/apps/client/src/features/editor/components/video/upload-video-action.tsx +++ b/apps/client/src/features/editor/components/video/upload-video-action.tsx @@ -1,12 +1,16 @@ import { handleVideoUpload } from "@docmost/editor-ext"; import { uploadFile } from "@/features/page/services/page-service.ts"; +import { notifications } from "@mantine/notifications"; export const uploadVideoAction = handleVideoUpload({ onUpload: async (file: File, pageId: string): Promise => { try { return await uploadFile(file, pageId); } catch (err) { - console.error("failed to upload image", err); + notifications.show({ + color: "red", + message: err?.response.data.message, + }); throw err; } }, @@ -15,7 +19,11 @@ export const uploadVideoAction = handleVideoUpload({ 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 true; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 674a85a4..ee76b8f6 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -31,6 +31,7 @@ import { TiptapVideo, LinkExtension, Selection, + Attachment, CustomCodeBlock, } from "@docmost/editor-ext"; 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 { common, createLowlight } from "lowlight"; 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 plaintext from "highlight.js/lib/languages/plaintext"; @@ -146,6 +148,9 @@ export const mainExtensions = [ }, }), Selection, + Attachment.configure({ + view: AttachmentView, + }), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/styles/media.css b/apps/client/src/features/editor/styles/media.css index ef616d74..0d425665 100644 --- a/apps/client/src/features/editor/styles/media.css +++ b/apps/client/src/features/editor/styles/media.css @@ -9,5 +9,29 @@ 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); + } } diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index 33323b0b..d58faf79 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -30,7 +30,7 @@ export function getAvatarUrl(avatarUrl: string) { return null; } - if (avatarUrl.startsWith("http")) { + if (avatarUrl?.startsWith("http")) { return avatarUrl; } @@ -42,5 +42,5 @@ export function getSpaceUrl(spaceSlug: string) { } export function getFileUrl(src: string) { - return src.startsWith("/files/") ? getBackendUrl() + src : src; + return src?.startsWith("/files/") ? getBackendUrl() + src : src; } diff --git a/apps/client/src/lib/utils.ts b/apps/client/src/lib/utils.ts index fb04aab2..d97be6bf 100644 --- a/apps/client/src/lib/utils.ts +++ b/apps/client/src/lib/utils.ts @@ -25,3 +25,22 @@ export const computeSpaceSlug = (name: string) => { 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]}`; +}; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 7a9718c7..8e2d13ad 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -27,6 +27,7 @@ import { TiptapImage, TiptapVideo, TrailingNode, + Attachment, } from '@docmost/editor-ext'; import { generateText, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -65,6 +66,7 @@ export const tiptapExtensions = [ TiptapImage, TiptapVideo, Callout, + Attachment, CustomCodeBlock ] as any; diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts index 02010c37..e6e75fe7 100644 --- a/apps/server/src/core/attachment/attachment.constants.ts +++ b/apps/server/src/core/attachment/attachment.constants.ts @@ -16,4 +16,4 @@ export const InlineFileExtensions = [ '.mp4', '.mov', ]; -export const MAX_FILE_SIZE = '20MB'; +export const MAX_FILE_SIZE = '50MB'; diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index b69e4bed..fbc819c9 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -123,6 +123,11 @@ export class AttachmentController { return res.send(fileResponse); } 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); throw new BadRequestException('Error processing file upload.'); } diff --git a/apps/server/src/core/attachment/attachment.utils.ts b/apps/server/src/core/attachment/attachment.utils.ts index e2b34b13..59b94dd0 100644 --- a/apps/server/src/core/attachment/attachment.utils.ts +++ b/apps/server/src/core/attachment/attachment.utils.ts @@ -38,7 +38,6 @@ export async function prepareFile( mimeType: file.mimetype, }; } catch (error) { - console.error('Error in file preparation:', error); throw error; } } diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 0c1c5a26..ce36ebac 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -10,5 +10,5 @@ export * from "./lib/callout"; export * from "./lib/media-utils"; export * from "./lib/link"; export * from "./lib/selection"; +export * from "./lib/attachment"; export * from "./lib/custom-code-block" - diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts new file mode 100644 index 00000000..b1f10858 --- /dev/null +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -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); + }, + ); + }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts new file mode 100644 index 00000000..a9ff4f07 --- /dev/null +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -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; + 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 { + attachment: { + setAttachment: (attributes: AttachmentAttributes) => ReturnType; + }; + } +} + +export const Attachment = Node.create({ + 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", + }), + ]; + }, +}); diff --git a/packages/editor-ext/src/lib/attachment/index.ts b/packages/editor-ext/src/lib/attachment/index.ts new file mode 100644 index 00000000..e553c417 --- /dev/null +++ b/packages/editor-ext/src/lib/attachment/index.ts @@ -0,0 +1,2 @@ +export { Attachment } from "./attachment"; +export * from "./attachment-upload";