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

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

View File

@ -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<any> => {
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;

View File

@ -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.",

View File

@ -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<any> => {
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;

View File

@ -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[];

View File

@ -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);
}
}

View File

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

View File

@ -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]}`;
};

View File

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

View File

@ -16,4 +16,4 @@ export const InlineFileExtensions = [
'.mp4',
'.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);
} 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.');
}

View File

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