mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 19:32:37 +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 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]}`;
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -16,4 +16,4 @@ export const InlineFileExtensions = [
|
|||||||
'.mp4',
|
'.mp4',
|
||||||
'.mov',
|
'.mov',
|
||||||
];
|
];
|
||||||
export const MAX_FILE_SIZE = '20MB';
|
export const MAX_FILE_SIZE = '50MB';
|
||||||
|
|||||||
@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
119
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal file
119
packages/editor-ext/src/lib/attachment/attachment-upload.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
123
packages/editor-ext/src/lib/attachment/attachment.ts
Normal file
123
packages/editor-ext/src/lib/attachment/attachment.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
2
packages/editor-ext/src/lib/attachment/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Attachment } from "./attachment";
|
||||||
|
export * from "./attachment-upload";
|
||||||
Reference in New Issue
Block a user