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/image-upload.ts b/apps/web/components/tailwind/image-upload.ts
index 2424d7f45..24145ff4e 100644
--- a/apps/web/components/tailwind/image-upload.ts
+++ b/apps/web/components/tailwind/image-upload.ts
@@ -10,6 +10,7 @@ const onUpload = (file: File) => {
},
body: file,
});
+
return new Promise((resolve) => {
toast.promise(
promise.then(async (res) => {
@@ -47,10 +48,11 @@ export const uploadFn = createImageUpload({
validateFn: (file) => {
if (!file.type.includes("image/")) {
toast.error("File type not supported.");
- return;
+ return false;
} else if (file.size / 1024 / 1024 > 20) {
toast.error("File size too big (max 20MB).");
- return;
+ return false;
}
+ return true;
},
});
diff --git a/packages/headless/src/components/editor.tsx b/packages/headless/src/components/editor.tsx
index 95c7f6a48..d38faec22 100644
--- a/packages/headless/src/components/editor.tsx
+++ b/packages/headless/src/components/editor.tsx
@@ -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;
};
diff --git a/packages/headless/src/plugins/upload-images.tsx b/packages/headless/src/plugins/upload-images.tsx
index f2a6e1fb4..d7ee9df8f 100644
--- a/packages/headless/src/plugins/upload-images.tsx
+++ b/packages/headless/src/plugins/upload-images.tsx
@@ -62,7 +62,8 @@ export const createImageUpload =
({ validateFn, onUpload }: ImageUploadOptions): UploadFn =>
(file, view, pos) => {
// check if the file is an image
- validateFn?.(file);
+ const validated = validateFn?.(file);
+ if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};