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