diff --git a/apps/docs/guides/image-upload.mdx b/apps/docs/guides/image-upload.mdx
new file mode 100644
index 000000000..60e9098e4
--- /dev/null
+++ b/apps/docs/guides/image-upload.mdx
@@ -0,0 +1,137 @@
+---
+title: "Image Upload (New)"
+description: "Uploading images in the editor"
+---
+
+
+
+
+ Configure image extension with your styling. The `imageClass` is used for styling the placeholder image.
+
+ ```tsx
+ //extensions.ts
+ import { UploadImagesPlugin } from "novel/plugins";
+
+ const tiptapImage = TiptapImage.extend({
+ addProseMirrorPlugins() {
+ return [
+ UploadImagesPlugin({
+ imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
+ }),
+ ];
+ },
+ }).configure({
+ allowBase64: true,
+ HTMLAttributes: {
+ class: cx("rounded-lg border border-muted"),
+ },
+ });
+
+ export const defaultExtensions = [
+ tiptapImage,
+ //other extensions
+ ];
+
+ //editor.tsx
+ const Editor = () => {
+ return
+ }
+
+ ```
+
+
+
+
+ `onUpload` should return a `Promise`
+ `validateFn` is triggered before an image is uploaded. It should return a `boolean` value.
+
+ ```tsx image-upload.ts
+ import { createImageUpload } from "novel/plugins";
+ import { toast } from "sonner";
+
+ const onUpload = async (file: File) => {
+ const promise = fetch("/api/upload", {
+ method: "POST",
+ headers: {
+ "content-type": file?.type || "application/octet-stream",
+ "x-vercel-filename": file?.name || "image.png",
+ },
+ body: file,
+ });
+
+ //This should return a src of the uploaded image
+ return promise;
+ };
+
+ export const uploadFn = createImageUpload({
+ onUpload,
+ validateFn: (file) => {
+ if (!file.type.includes("image/")) {
+ toast.error("File type not supported.");
+ return false;
+ } else if (file.size / 1024 / 1024 > 20) {
+ toast.error("File size too big (max 20MB).");
+ return false;
+ }
+ return true;
+ },
+ });
+
+ ```
+
+
+
+ This is required to handle image paste and drop events in the editor.
+ ```tsx editor.tsx
+ import { handleImageDrop, handleImagePaste } from "novel/plugins";
+ import { uploadFn } from "./image-upload";
+
+ ...
+ handleImagePaste(view, event, uploadFn),
+ handleDrop: (view, event, _slice, moved) => handleImageDrop(view, event, moved, uploadFn),
+ ...
+ }}
+ />
+ ...
+ ```
+
+
+
+
+
+ ```tsx
+ import { ImageIcon } from "lucide-react";
+ import { createSuggestionItems } from "novel/extensions";
+ import { uploadFn } from "./image-upload";
+
+ export const suggestionItems = createSuggestionItems([
+ ...,
+ {
+ title: "Image",
+ description: "Upload an image from your computer.",
+ searchTerms: ["photo", "picture", "media"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).run();
+ // upload image
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "image/*";
+ input.onchange = async () => {
+ if (input.files?.length) {
+ const file = input.files[0];
+ const pos = editor.view.state.selection.from;
+ uploadFn(file, editor.view, pos);
+ }
+ };
+ input.click();
+ },
+ }
+ ])
+ ```
+
+
+
+
diff --git a/apps/docs/guides/tailwind/extensions.mdx b/apps/docs/guides/tailwind/extensions.mdx
index 4303ea3b8..720bc8030 100644
--- a/apps/docs/guides/tailwind/extensions.mdx
+++ b/apps/docs/guides/tailwind/extensions.mdx
@@ -18,7 +18,6 @@ import {
StarterKit,
Placeholder,
} from "novel/extensions";
-import { UploadImagesPlugin } from "novel/plugins";
import { cx } from "class-variance-authority";
import { slashCommand } from "./slash-command";
@@ -30,28 +29,11 @@ const placeholder = Placeholder;
const tiptapLink = TiptapLink.configure({
HTMLAttributes: {
class: cx(
- "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer"
+ "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
),
},
});
-const tiptapImage = TiptapImage.extend({
- addProseMirrorPlugins() {
- return [UploadImagesPlugin()];
- },
-}).configure({
- allowBase64: true,
- HTMLAttributes: {
- class: cx("rounded-lg border border-muted"),
- },
-});
-
-const updatedImage = UpdatedImage.configure({
- HTMLAttributes: {
- class: cx("rounded-lg border border-muted"),
- },
-});
-
const taskList = TaskList.configure({
HTMLAttributes: {
class: cx("not-prose pl-2"),
diff --git a/apps/docs/guides/tailwind/setup.mdx b/apps/docs/guides/tailwind/setup.mdx
index 7f6aa5fb4..3f0a79714 100644
--- a/apps/docs/guides/tailwind/setup.mdx
+++ b/apps/docs/guides/tailwind/setup.mdx
@@ -4,13 +4,17 @@ description: "Follow this guide to set up Novel with Tailwindcss"
---
- This example demonstrates the use of Shadcn-ui for ui, but alternative libraries and components
- can also be employed.
+ This example demonstrates the use of Shadcn-ui for ui, but alternative
+ libraries and components can also be employed.
-
- You can find more info about installing shadcn-ui here. You will need to add the following
- components: Button, Separator, Popover, Command, Dialog,
+
+ You can find more info about installing shadcn-ui here. You will need to add
+ the following components: Button, Separator, Popover, Command, Dialog,
This example will use the same stucture from here: [Anatomy](/quickstart#anatomy)\
@@ -76,70 +80,40 @@ You can find the full example here: [Tailwind Example](https://github.com/steven
## Create Menus
-
+
Slash commands are a way to quickly insert content into the editor.
-
+
The bubble menu is a context menu that appears when you select text.
## Add Editor Props
-`defaultEditorProps` are required to fix the slash command keyboard navigation. For any custom use case you can write your own or extend the default props.
-
-```tsx novel/src/editor.tsx
-export const defaultEditorProps: EditorProviderProps["editorProps"] = {
- handleDOMEvents: {
- keydown: (_view, event) => {
- // prevent default event listeners from firing when slash command is active
- if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
- const slashCommand = document.querySelector("#slash-command");
- if (slashCommand) {
- return true;
- }
- }
- },
- },
- handlePaste: (view, event) => {
- if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
- event.preventDefault();
- const file = event.clipboardData.files[0];
- const pos = view.state.selection.from;
-
- startImageUpload(file, view, pos);
- return true;
- }
- return false;
- },
- handleDrop: (view, event, _slice, moved) => {
- if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
- event.preventDefault();
- const file = event.dataTransfer.files[0];
- const coordinates = view.posAtCoords({
- left: event.clientX,
- top: event.clientY,
- });
- // here we deduct 1 from the pos or else the image will create an extra node
- startImageUpload(file, view, coordinates?.pos || 0 - 1);
- return true;
- }
- return false;
- },
-};
-```
+`handleCommandNavigation` is required for fixing the arrow navigation in the / command;
```tsx
+import { handleCommandNavigation } from "novel/extensions";
import { defaultEditorProps, EditorContent } from "novel";
handleCommandNavigation(event),
+ },
+ attributes: {
+ class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
+ }
+ }}
/>
```
@@ -223,24 +197,24 @@ import { defaultEditorProps, EditorContent } from "novel";
ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
- background-color: var(--novel-white);
+ background-color: hsl(var(--background));
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
- border: 2px solid var(--novel-stone-900);
+ border: 2px solid hsl(var(--border));
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
- background-color: var(--novel-stone-50);
+ background-color: hsl(var(--accent));
}
&:active {
- background-color: var(--novel-stone-200);
+ background-color: hsl(var(--accent));
}
&::before {
@@ -260,7 +234,7 @@ import { defaultEditorProps, EditorContent } from "novel";
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
- color: var(--novel-stone-400);
+ color: var(--muted-foreground);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
diff --git a/apps/docs/guides/tailwind/slash-command.mdx b/apps/docs/guides/tailwind/slash-command.mdx
index 7b44aac9f..142f41620 100644
--- a/apps/docs/guides/tailwind/slash-command.mdx
+++ b/apps/docs/guides/tailwind/slash-command.mdx
@@ -14,7 +14,6 @@ import {
Heading1,
Heading2,
Heading3,
- ImageIcon,
List,
ListOrdered,
MessageSquarePlus,
@@ -41,7 +40,12 @@ export const suggestionItems = createSuggestionItems([
searchTerms: ["p", "paragraph"],
icon: ,
command: ({ editor, range }) => {
- editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .run();
},
},
{
@@ -59,7 +63,12 @@ export const suggestionItems = createSuggestionItems([
searchTerms: ["title", "big", "large"],
icon: ,
command: ({ editor, range }) => {
- editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 1 })
+ .run();
},
},
{
@@ -68,7 +77,12 @@ export const suggestionItems = createSuggestionItems([
searchTerms: ["subtitle", "medium"],
icon: ,
command: ({ editor, range }) => {
- editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 2 })
+ .run();
},
},
{
@@ -77,7 +91,12 @@ export const suggestionItems = createSuggestionItems([
searchTerms: ["subtitle", "small"],
icon: ,
command: ({ editor, range }) => {
- editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run();
},
},
{
@@ -120,27 +139,6 @@ export const suggestionItems = createSuggestionItems([
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
- {
- title: "Image",
- description: "Upload an image from your computer.",
- searchTerms: ["photo", "picture", "media"],
- icon: ,
- command: ({ editor, range }) => {
- editor.chain().focus().deleteRange(range).run();
- // upload image
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "image/*";
- input.onchange = async () => {
- if (input.files?.length) {
- const file = input.files[0];
- const pos = editor.view.state.selection.from;
- startImageUpload(file, editor.view, pos);
- }
- };
- input.click();
- },
- },
]);
export const slashCommand = Command.configure({
diff --git a/apps/docs/mint.json b/apps/docs/mint.json
index 4f1ee9c4a..35d9ae858 100644
--- a/apps/docs/mint.json
+++ b/apps/docs/mint.json
@@ -50,7 +50,8 @@
"guides/tailwind/bubble-menu"
]
},
- "guides/ai-command"
+ "guides/ai-command",
+ "guides/image-upload"
]
},
{
diff --git a/apps/web/components/tailwind/editor.tsx b/apps/web/components/tailwind/editor.tsx
index 6b7a7462c..7ecfcf587 100644
--- a/apps/web/components/tailwind/editor.tsx
+++ b/apps/web/components/tailwind/editor.tsx
@@ -3,7 +3,6 @@ import { defaultEditorContent } from "@/lib/content";
import React, { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import {
- defaultEditorProps,
EditorInstance,
EditorRoot,
EditorBubble,
@@ -13,7 +12,7 @@ import {
EditorContent,
type JSONContent,
} from "novel";
-import { ImageResizer } from "novel/extensions";
+import { ImageResizer, handleCommandNavigation } from "novel/extensions";
import { defaultExtensions } from "./extensions";
import { Separator } from "./ui/separator";
import { NodeSelector } from "./selectors/node-selector";
@@ -22,6 +21,8 @@ import { ColorSelector } from "./selectors/color-selector";
import { TextButtons } from "./selectors/text-buttons";
import { slashCommand, suggestionItems } from "./slash-command";
+import { handleImageDrop, handleImagePaste } from "novel/plugins";
+import { uploadFn } from "./image-upload";
const extensions = [...defaultExtensions, slashCommand];
@@ -64,7 +65,13 @@ const TailwindEditor = () => {
extensions={extensions}
className="relative min-h-[500px] w-full max-w-screen-lg border-muted bg-background sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"
editorProps={{
- ...defaultEditorProps,
+ handleDOMEvents: {
+ keydown: (_view, event) => handleCommandNavigation(event),
+ },
+ handlePaste: (view, event) =>
+ handleImagePaste(view, event, uploadFn),
+ handleDrop: (view, event, _slice, moved) =>
+ handleImageDrop(view, event, moved, uploadFn),
attributes: {
class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`,
},
diff --git a/apps/web/components/tailwind/extensions.ts b/apps/web/components/tailwind/extensions.ts
index 464f2be69..dd59b3c11 100644
--- a/apps/web/components/tailwind/extensions.ts
+++ b/apps/web/components/tailwind/extensions.ts
@@ -26,7 +26,11 @@ const tiptapLink = TiptapLink.configure({
const tiptapImage = TiptapImage.extend({
addProseMirrorPlugins() {
- return [UploadImagesPlugin()];
+ return [
+ UploadImagesPlugin({
+ imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
+ }),
+ ];
},
}).configure({
allowBase64: true,
diff --git a/apps/web/components/tailwind/image-upload.ts b/apps/web/components/tailwind/image-upload.ts
new file mode 100644
index 000000000..24145ff4e
--- /dev/null
+++ b/apps/web/components/tailwind/image-upload.ts
@@ -0,0 +1,58 @@
+import { createImageUpload } from "novel/plugins";
+import { toast } from "sonner";
+
+const onUpload = (file: File) => {
+ const promise = fetch("/api/upload", {
+ method: "POST",
+ headers: {
+ "content-type": file?.type || "application/octet-stream",
+ "x-vercel-filename": file?.name || "image.png",
+ },
+ body: file,
+ });
+
+ return new Promise((resolve) => {
+ toast.promise(
+ promise.then(async (res) => {
+ // Successfully uploaded image
+ if (res.status === 200) {
+ const { url } = (await res.json()) as any;
+ // preload the image
+ let image = new Image();
+ image.src = url;
+ image.onload = () => {
+ resolve(url);
+ };
+ // No blob store configured
+ } else if (res.status === 401) {
+ resolve(file);
+ throw new Error(
+ "`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead.",
+ );
+ // Unknown error
+ } else {
+ throw new Error(`Error uploading image. Please try again.`);
+ }
+ }),
+ {
+ loading: "Uploading image...",
+ success: "Image uploaded successfully.",
+ error: (e) => e.message,
+ },
+ );
+ });
+};
+
+export const uploadFn = createImageUpload({
+ onUpload,
+ validateFn: (file) => {
+ if (!file.type.includes("image/")) {
+ toast.error("File type not supported.");
+ return false;
+ } else if (file.size / 1024 / 1024 > 20) {
+ toast.error("File size too big (max 20MB).");
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/apps/web/components/tailwind/slash-command.tsx b/apps/web/components/tailwind/slash-command.tsx
index a701e9815..43f1162e4 100644
--- a/apps/web/components/tailwind/slash-command.tsx
+++ b/apps/web/components/tailwind/slash-command.tsx
@@ -12,8 +12,8 @@ import {
TextQuote,
} from "lucide-react";
import { createSuggestionItems } from "novel/extensions";
-import { startImageUpload } from "novel/plugins";
import { Command, renderItems } from "novel/extensions";
+import { uploadFn } from "./image-upload";
export const suggestionItems = createSuggestionItems([
{
@@ -145,7 +145,7 @@ export const suggestionItems = createSuggestionItems([
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
- startImageUpload(file, editor.view, pos);
+ uploadFn(file, editor.view, pos);
}
};
input.click();
diff --git a/packages/headless/src/components/editor.tsx b/packages/headless/src/components/editor.tsx
index bbf992e5e..d38faec22 100644
--- a/packages/headless/src/components/editor.tsx
+++ b/packages/headless/src/components/editor.tsx
@@ -3,11 +3,11 @@ import { EditorProvider } from "@tiptap/react";
import { Provider } from "jotai";
import tunnel from "tunnel-rat";
import { simpleExtensions } from "../extensions";
-import { startImageUpload } from "../plugins/upload-images";
import { novelStore } from "../utils/store";
import { EditorCommandTunnelContext } from "./editor-command";
import type { FC, ReactNode } from "react";
import type { EditorProviderProps, JSONContent } from "@tiptap/react";
+import type { EditorView } from "@tiptap/pm/view";
export interface EditorProps {
readonly children: ReactNode;
@@ -31,7 +31,7 @@ export const EditorRoot: FC = ({ children }) => {
};
export type EditorContentProps = Omit & {
- readonly children: ReactNode;
+ readonly children?: ReactNode;
readonly className?: string;
readonly initialContent?: JSONContent;
};
@@ -57,42 +57,3 @@ export const EditorContent = forwardRef(
);
EditorContent.displayName = "EditorContent";
-
-export const defaultEditorProps: EditorProviderProps["editorProps"] = {
- handleDOMEvents: {
- keydown: (_view, event) => {
- // prevent default event listeners from firing when slash command is active
- if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
- const slashCommand = document.querySelector("#slash-command");
- if (slashCommand) {
- return true;
- }
- }
- },
- },
- handlePaste: (view, event) => {
- if (event.clipboardData?.files.length) {
- event.preventDefault();
- const [file] = Array.from(event.clipboardData.files);
- const pos = view.state.selection.from;
-
- if (file) startImageUpload(file, view, pos);
- return true;
- }
- return false;
- },
- handleDrop: (view, event, _slice, moved) => {
- if (!moved && event.dataTransfer?.files.length) {
- event.preventDefault();
- const [file] = Array.from(event.dataTransfer.files);
- const coordinates = view.posAtCoords({
- left: event.clientX,
- top: event.clientY,
- });
- // here we deduct 1 from the pos or else the image will create an extra node
- if (file) startImageUpload(file, view, coordinates?.pos ?? 0 - 1);
- return true;
- }
- return false;
- },
-};
diff --git a/packages/headless/src/components/index.ts b/packages/headless/src/components/index.ts
index ec742ed66..9771b553b 100644
--- a/packages/headless/src/components/index.ts
+++ b/packages/headless/src/components/index.ts
@@ -2,12 +2,7 @@ export { useCurrentEditor as useEditor } from "@tiptap/react";
export { type Editor as EditorInstance } from "@tiptap/core";
export type { JSONContent } from "@tiptap/react";
-export {
- EditorRoot,
- EditorContent,
- type EditorContentProps,
- defaultEditorProps,
-} from "./editor";
+export { EditorRoot, EditorContent, type EditorContentProps } from "./editor";
export { EditorBubble } from "./editor-bubble";
export { EditorBubbleItem } from "./editor-bubble-item";
export { EditorCommand } from "./editor-command";
diff --git a/packages/headless/src/extensions/slash-command.tsx b/packages/headless/src/extensions/slash-command.tsx
index 9ba3e0c28..f4bdc74ab 100644
--- a/packages/headless/src/extensions/slash-command.tsx
+++ b/packages/headless/src/extensions/slash-command.tsx
@@ -103,4 +103,13 @@ export interface SuggestionItem {
export const createSuggestionItems = (items: SuggestionItem[]) => items;
+export const handleCommandNavigation = (event: KeyboardEvent) => {
+ if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
+ const slashCommand = document.querySelector("#slash-command");
+ if (slashCommand) {
+ return true;
+ }
+ }
+};
+
export { Command, renderItems };
diff --git a/packages/headless/src/plugins/index.ts b/packages/headless/src/plugins/index.ts
index e1af0678c..8c20baf86 100644
--- a/packages/headless/src/plugins/index.ts
+++ b/packages/headless/src/plugins/index.ts
@@ -1,5 +1,8 @@
export {
UploadImagesPlugin,
- startImageUpload,
- handleImageUpload,
+ type UploadFn,
+ type ImageUploadOptions,
+ createImageUpload,
+ handleImageDrop,
+ handleImagePaste,
} from "./upload-images";
diff --git a/packages/headless/src/plugins/upload-images.tsx b/packages/headless/src/plugins/upload-images.tsx
index 5b67c518d..d7ee9df8f 100644
--- a/packages/headless/src/plugins/upload-images.tsx
+++ b/packages/headless/src/plugins/upload-images.tsx
@@ -1,11 +1,9 @@
-//@ts-nocheck
-//TODO: remove ts-nocheck from here some day
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
const uploadKey = new PluginKey("upload-image");
-export const UploadImagesPlugin = () =>
+export const UploadImagesPlugin = ({ imageClass }: { imageClass: string }) =>
new Plugin({
key: uploadKey,
state: {
@@ -15,6 +13,7 @@ export const UploadImagesPlugin = () =>
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 && action.add) {
const { id, pos, src } = action.add;
@@ -22,10 +21,7 @@ export const UploadImagesPlugin = () =>
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
- image.setAttribute(
- "class",
- "opacity-40 rounded-lg border border-stone-200"
- );
+ image.setAttribute("class", imageClass);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
@@ -34,7 +30,11 @@ export const UploadImagesPlugin = () =>
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(
- set.find(null, null, (spec) => spec.id == action.remove.id)
+ set.find(
+ undefined,
+ undefined,
+ (spec) => spec.id == action.remove.id,
+ ),
);
}
return set;
@@ -48,104 +48,102 @@ export const UploadImagesPlugin = () =>
});
function findPlaceholder(state: EditorState, id: {}) {
- const decos = uploadKey.getState(state);
- const found = decos.find(null, null, (spec) => spec.id == id);
- return found.length ? found[0].from : null;
+ 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 function startImageUpload(file: File, view: EditorView, pos: number) {
- // check if the file is an image
- if (!file.type.includes("image/")) {
- //TODO add toast back
- // toast.error("File type not supported.");
- return;
-
- // check if the file size is less than 20MB
- } else if (file.size / 1024 / 1024 > 20) {
- // toast.error("File size too big (max 20MB).");
- return;
- }
+export interface ImageUploadOptions {
+ validateFn?: (file: File) => void;
+ onUpload: (file: File) => Promise;
+}
- // 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();
-
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => {
- tr.setMeta(uploadKey, {
- add: {
- id,
- pos,
- src: reader.result,
- },
- });
- view.dispatch(tr);
- };
+export const createImageUpload =
+ ({ validateFn, onUpload }: ImageUploadOptions): UploadFn =>
+ (file, view, pos) => {
+ // check if the file is an image
+ const validated = validateFn?.(file);
+ if (!validated) return;
+ // A fresh object to act as the ID for this upload
+ const id = {};
- handleImageUpload(file).then((src) => {
- const { schema } = view.state;
+ // Replace the selection with a placeholder
+ const tr = view.state.tr;
+ if (!tr.selection.empty) tr.deleteSelection();
- let pos = findPlaceholder(view.state, id);
- // If the content around the placeholder has been deleted, drop
- // the image
- if (pos == null) return;
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ reader.onload = () => {
+ tr.setMeta(uploadKey, {
+ add: {
+ id,
+ pos,
+ src: reader.result,
+ },
+ });
+ view.dispatch(tr);
+ };
- // Otherwise, insert it at the placeholder's position, and remove
- // the placeholder
+ onUpload(file).then((src) => {
+ const { schema } = view.state;
- // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read
- // the image locally
- const imageSrc = typeof src === "object" ? reader.result : src;
+ let pos = findPlaceholder(view.state, id);
- const node = schema.nodes.image.create({ src: imageSrc });
- const transaction = view.state.tr
- .replaceWith(pos, pos, node)
- .setMeta(uploadKey, { remove: { id } });
- view.dispatch(transaction);
- });
-}
+ // If the content around the placeholder has been deleted, drop
+ // the image
+ if (pos == null) return;
+
+ // Otherwise, insert it at the placeholder's position, and remove
+ // the placeholder
+
+ // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read
+ // the image locally
+ const imageSrc = typeof src === "object" ? reader.result : src;
+
+ const node = schema.nodes.image?.create({ src: imageSrc });
+ if (!node) return;
-export const handleImageUpload = (file: File) => {
- // upload to Vercel Blob, TODO: fix toat
- // return new Promise((resolve) => {
- // toast.promise(
- // fetch("/api/upload", {
- // method: "POST",
- // headers: {
- // "content-type": file?.type || "application/octet-stream",
- // "x-vercel-filename": file?.name || "image.png",
- // },
- // body: file,
- // }).then(async (res) => {
- // // Successfully uploaded image
- // if (res.status === 200) {
- // const { url } = (await res.json()) as any;
- // // preload the image
- // let image = new Image();
- // image.src = url;
- // image.onload = () => {
- // resolve(url);
- // };
- // // No blob store configured
- // } else if (res.status === 401) {
- // resolve(file);
- // throw new Error(
- // "`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead."
- // );
- // // Unknown error
- // } else {
- // throw new Error(`Error uploading image. Please try again.`);
- // }
- // }),
- // {
- // loading: "Uploading image...",
- // success: "Image uploaded successfully.",
- // error: (e) => e.message,
- // }
- // );
- // });
+ const transaction = view.state.tr
+ .replaceWith(pos, pos, node)
+ .setMeta(uploadKey, { remove: { id } });
+ view.dispatch(transaction);
+ });
+ };
+
+export type UploadFn = (file: File, view: EditorView, pos: number) => void;
+
+export const handleImagePaste = (
+ view: EditorView,
+ event: ClipboardEvent,
+ uploadFn: UploadFn,
+) => {
+ if (event.clipboardData?.files.length) {
+ event.preventDefault();
+ const [file] = Array.from(event.clipboardData.files);
+ const pos = view.state.selection.from;
+
+ if (file) uploadFn(file, view, pos);
+ return true;
+ }
+ return false;
+};
+
+export const handleImageDrop = (
+ view: EditorView,
+ event: DragEvent,
+ moved: boolean,
+ uploadFn: UploadFn,
+) => {
+ if (!moved && event.dataTransfer?.files.length) {
+ event.preventDefault();
+ const [file] = Array.from(event.dataTransfer.files);
+ const coordinates = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+ // here we deduct 1 from the pos or else the image will create an extra node
+ if (file) uploadFn(file, view, coordinates?.pos ?? 0 - 1);
+ return true;
+ }
+ return false;
};