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