mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-15 01:01:09 +10:00
feat: editor file attachments (#194)
* fix current slider value * WIP * changes to extension attributes * update command title
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]}`;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -16,4 +16,4 @@ export const InlineFileExtensions = [
|
||||
'.mp4',
|
||||
'.mov',
|
||||
];
|
||||
export const MAX_FILE_SIZE = '20MB';
|
||||
export const MAX_FILE_SIZE = '50MB';
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
@ -38,7 +38,6 @@ export async function prepareFile(
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in file preparation:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user