["editor"]>;
+
+export type SelectorItem = {
+ name: string;
+ icon: LucideIcon;
+ command: (editor: NovelEditor) => void;
+ isActive: (editor: NovelEditor) => boolean;
+};
+
+const items: SelectorItem[] = [
+ {
+ name: "Text",
+ icon: TextIcon,
+ command: (editor) => editor.chain().focus().clearNodes().run(),
+ // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
+ isActive: (editor) =>
+ editor.isActive("paragraph") &&
+ !editor.isActive("bulletList") &&
+ !editor.isActive("orderedList"),
+ },
+ {
+ name: "Heading 1",
+ icon: Heading1,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 1 }),
+ },
+ {
+ name: "Heading 2",
+ icon: Heading2,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 2 }),
+ },
+ {
+ name: "Heading 3",
+ icon: Heading3,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
+ isActive: (editor) => editor.isActive("heading", { level: 3 }),
+ },
+ {
+ name: "To-do List",
+ icon: CheckSquare,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleTaskList().run(),
+ isActive: (editor) => editor.isActive("taskItem"),
+ },
+ {
+ name: "Bullet List",
+ icon: ListOrdered,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleBulletList().run(),
+ isActive: (editor) => editor.isActive("bulletList"),
+ },
+ {
+ name: "Numbered List",
+ icon: ListOrdered,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleOrderedList().run(),
+ isActive: (editor) => editor.isActive("orderedList"),
+ },
+ {
+ name: "Quote",
+ icon: TextQuote,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleBlockquote().run(),
+ isActive: (editor) => editor.isActive("blockquote"),
+ },
+ {
+ name: "Code",
+ icon: Code,
+ command: (editor) =>
+ editor.chain().focus().clearNodes().toggleCodeBlock().run(),
+ isActive: (editor) => editor.isActive("codeBlock"),
+ },
+];
+interface NodeSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
+ name: "Multiple",
+ };
+
+ return (
+
+
+
+
+
+ {items.map((item, index) => (
+ {
+ item.command(editor);
+ onOpenChange(false);
+ }}
+ className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm"
+ >
+
+ {activeItem.name === item.name && }
+
+ ))}
+
+
+ );
+};
diff --git a/examples/with-novel/editor/slash-command.tsx b/examples/with-novel/editor/slash-command.tsx
new file mode 100644
index 0000000000..1be463de30
--- /dev/null
+++ b/examples/with-novel/editor/slash-command.tsx
@@ -0,0 +1,160 @@
+import { uploadFn } from "@/uploadthing/novel-plugin";
+import {
+ CheckSquare,
+ Code,
+ Heading1,
+ Heading2,
+ Heading3,
+ ImageIcon,
+ List,
+ ListOrdered,
+ MessageSquarePlus,
+ Text,
+ TextQuote,
+} from "lucide-react";
+import { Command, createSuggestionItems, renderItems } from "novel/extensions";
+
+export const suggestionItems = createSuggestionItems([
+ {
+ title: "Send Feedback",
+ description: "Let us know how we can improve.",
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).run();
+ window.open("/feedback", "_blank");
+ },
+ },
+ {
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .run();
+ },
+ },
+ {
+ title: "To-do List",
+ description: "Track tasks with a to-do list.",
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
+ },
+ },
+ {
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 1 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 2 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run();
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .toggleBlockquote()
+ .run(),
+ },
+ {
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon: ,
+ 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;
+ uploadFn(file!, editor.view, pos);
+ }
+ };
+ input.click();
+ },
+ },
+]);
+
+export const slashCommand = Command.configure({
+ suggestion: {
+ items: () => suggestionItems,
+ render: renderItems,
+ },
+});
diff --git a/examples/with-novel/editor/text-buttons.tsx b/examples/with-novel/editor/text-buttons.tsx
new file mode 100644
index 0000000000..baf1223af9
--- /dev/null
+++ b/examples/with-novel/editor/text-buttons.tsx
@@ -0,0 +1,70 @@
+import { Button } from "@/ui/button";
+import {
+ BoldIcon,
+ CodeIcon,
+ ItalicIcon,
+ StrikethroughIcon,
+ UnderlineIcon,
+} from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+import { twMerge } from "tailwind-merge";
+
+import type { SelectorItem } from "./node-selector";
+
+export const TextButtons = () => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const items: SelectorItem[] = [
+ {
+ name: "bold",
+ isActive: (editor) => editor.isActive("bold"),
+ command: (editor) => editor.chain().focus().toggleBold().run(),
+ icon: BoldIcon,
+ },
+ {
+ name: "italic",
+ isActive: (editor) => editor.isActive("italic"),
+ command: (editor) => editor.chain().focus().toggleItalic().run(),
+ icon: ItalicIcon,
+ },
+ {
+ name: "underline",
+ isActive: (editor) => editor.isActive("underline"),
+ command: (editor) => editor.chain().focus().toggleUnderline().run(),
+ icon: UnderlineIcon,
+ },
+ {
+ name: "strike",
+ isActive: (editor) => editor.isActive("strike"),
+ command: (editor) => editor.chain().focus().toggleStrike().run(),
+ icon: StrikethroughIcon,
+ },
+ {
+ name: "code",
+ isActive: (editor) => editor.isActive("code"),
+ command: (editor) => editor.chain().focus().toggleCode().run(),
+ icon: CodeIcon,
+ },
+ ];
+ return (
+
+ {items.map((item, index) => (
+ {
+ item.command(editor);
+ }}
+ >
+
+
+ ))}
+
+ );
+};
diff --git a/examples/with-novel/next-env.d.ts b/examples/with-novel/next-env.d.ts
new file mode 100644
index 0000000000..4f11a03dc6
--- /dev/null
+++ b/examples/with-novel/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/examples/with-novel/next.config.js b/examples/with-novel/next.config.js
new file mode 100644
index 0000000000..c4a407b7ce
--- /dev/null
+++ b/examples/with-novel/next.config.js
@@ -0,0 +1,2 @@
+/** @type {import('next').NextConfig} */
+export default {};
diff --git a/examples/with-novel/package.json b/examples/with-novel/package.json
new file mode 100644
index 0000000000..dbe54b2a1f
--- /dev/null
+++ b/examples/with-novel/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "with-novel",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.0.5",
+ "@radix-ui/react-popover": "^1.0.6",
+ "@radix-ui/react-slot": "^1.0.2",
+ "@tailwindcss/typography": "^0.5.10",
+ "@uploadthing/react": "^6.5.3",
+ "class-variance-authority": "^0.7.0",
+ "cmdk": "^0.2.1",
+ "lucide-react": "^0.368.0",
+ "next": "14.2.1",
+ "next-themes": "^0.3.0",
+ "novel": "^0.3.1",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "sonner": "^1.4.41",
+ "tailwind-merge": "^2.2.1",
+ "uploadthing": "6.10.3",
+ "use-debounce": "^9.0.3"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.21",
+ "@types/react": "18.2.78",
+ "@types/react-dom": "18.2.25",
+ "tailwindcss": "^3.4.1",
+ "tailwindcss-animate": "^1.0.7",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/examples/with-novel/postcss.config.cjs b/examples/with-novel/postcss.config.cjs
new file mode 100644
index 0000000000..ee5f90b309
--- /dev/null
+++ b/examples/with-novel/postcss.config.cjs
@@ -0,0 +1,5 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ },
+};
diff --git a/examples/with-novel/tailwind.config.ts b/examples/with-novel/tailwind.config.ts
new file mode 100644
index 0000000000..c309c004b7
--- /dev/null
+++ b/examples/with-novel/tailwind.config.ts
@@ -0,0 +1,79 @@
+import typography from "@tailwindcss/typography";
+import type { Config } from "tailwindcss";
+import animate from "tailwindcss-animate";
+
+export default {
+ darkMode: ["class"],
+ content: [
+ "./app/**/*.{ts,tsx}",
+ "./editor/**/*.{ts,tsx}",
+ "./ui/**/*.{ts,tsx}",
+ ],
+ prefix: "",
+ theme: {
+ container: {
+ center: true,
+ padding: "2rem",
+ screens: {
+ "2xl": "1400px",
+ },
+ },
+ extend: {
+ colors: {
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
+ },
+ plugins: [animate, typography],
+} satisfies Config;
diff --git a/examples/with-novel/tsconfig.json b/examples/with-novel/tsconfig.json
new file mode 100644
index 0000000000..cd9852d0ad
--- /dev/null
+++ b/examples/with-novel/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ /* Base Options: */
+ "skipLibCheck": true,
+ "target": "es2022",
+ "allowJs": true,
+ "moduleDetection": "force",
+ "isolatedModules": true,
+
+ /* Strictness */
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "checkJs": true,
+
+ /* Bundled projects */
+ "lib": ["dom", "dom.iterable", "ES2022"],
+ "noEmit": true,
+ "module": "Preserve",
+ "jsx": "preserve",
+ "plugins": [{ "name": "next" }],
+ "incremental": true,
+
+ /* Path Aliases */
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "*.js",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/with-novel/ui/button.tsx b/examples/with-novel/ui/button.tsx
new file mode 100644
index 0000000000..55bec375ed
--- /dev/null
+++ b/examples/with-novel/ui/button.tsx
@@ -0,0 +1,55 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { twMerge } from "tailwind-merge";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/examples/with-novel/ui/command.tsx b/examples/with-novel/ui/command.tsx
new file mode 100644
index 0000000000..d25bfcae0d
--- /dev/null
+++ b/examples/with-novel/ui/command.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import * as React from "react";
+import { Dialog, DialogContent } from "@/ui/dialog";
+import { type DialogProps } from "@radix-ui/react-dialog";
+import { Command as CommandPrimitive } from "cmdk";
+import { twMerge } from "tailwind-merge";
+
+const Magic = ({ className }: { className: string }) => (
+
+);
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/examples/with-novel/ui/dialog.tsx b/examples/with-novel/ui/dialog.tsx
new file mode 100644
index 0000000000..5e52628d86
--- /dev/null
+++ b/examples/with-novel/ui/dialog.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { twMerge } from "tailwind-merge";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/examples/with-novel/ui/popover.tsx b/examples/with-novel/ui/popover.tsx
new file mode 100644
index 0000000000..a38d9036d4
--- /dev/null
+++ b/examples/with-novel/ui/popover.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import { twMerge } from "tailwind-merge";
+
+const Popover = PopoverPrimitive.Root;
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/examples/with-novel/uploadthing/client.ts b/examples/with-novel/uploadthing/client.ts
new file mode 100644
index 0000000000..2a8e4243f2
--- /dev/null
+++ b/examples/with-novel/uploadthing/client.ts
@@ -0,0 +1,6 @@
+import { generateReactHelpers } from "@uploadthing/react";
+
+import type { UploadRouter } from "./server";
+
+export const { uploadFiles, getRouteConfig } =
+ generateReactHelpers();
diff --git a/examples/with-novel/uploadthing/novel-plugin.ts b/examples/with-novel/uploadthing/novel-plugin.ts
new file mode 100644
index 0000000000..3175fb5193
--- /dev/null
+++ b/examples/with-novel/uploadthing/novel-plugin.ts
@@ -0,0 +1,62 @@
+import { createImageUpload } from "novel/plugins";
+import { toast } from "sonner";
+
+import { isValidFileSize, isValidFileType } from "uploadthing/client";
+
+import { getRouteConfig, uploadFiles } from "./client";
+
+const preloadImage = (url: string, tries = 0, maxTries = 10) =>
+ new Promise((resolve, reject) => {
+ const image = new Image();
+ image.src = url;
+ image.onload = () => resolve(url);
+ image.onerror = (e) => {
+ if (tries < maxTries) {
+ preloadImage(url, ++tries).then(resolve);
+ } else {
+ toast.error(
+ "Image was uploaded, but failed to preload. Please refresh your browser, and try again.",
+ );
+ reject(e);
+ }
+ };
+ });
+
+export const uploadFn = createImageUpload({
+ onUpload: (file) => {
+ /**
+ * Upload the file to the server and preload the image in the browser
+ */
+ const uploadPromise = uploadFiles("imageUploader", {
+ files: [file],
+ skipPolling: true,
+ });
+
+ return new Promise((resolve) => {
+ toast.promise(
+ uploadPromise.then(async (res) => {
+ const [uploadedFileData] = res;
+ const imageUrl = await preloadImage(uploadedFileData!.url);
+ resolve(imageUrl);
+ }),
+ {
+ loading: "Uploading image...",
+ success: "Image uploaded successfully.",
+ error: (e) => e.message,
+ },
+ );
+ });
+ },
+ validateFn: (file) => {
+ const config = getRouteConfig("imageUploader");
+ if (!isValidFileType(file, config)) {
+ toast.error("File type not supported.");
+ return false;
+ }
+ if (!isValidFileSize(file, config)) {
+ toast.error("File size too big.");
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/examples/with-novel/uploadthing/server.ts b/examples/with-novel/uploadthing/server.ts
new file mode 100644
index 0000000000..5151253a22
--- /dev/null
+++ b/examples/with-novel/uploadthing/server.ts
@@ -0,0 +1,26 @@
+import { createUploadthing, FileRouter } from "uploadthing/next";
+
+const f = createUploadthing({
+ /**
+ * Log out more information about the error, but don't return it to the client
+ * @see https://docs.uploadthing.com/errors#error-formatting
+ */
+ errorFormatter: (err) => {
+ console.log("Error uploading file", err.message);
+ console.log(" - Above error caused by:", err.cause);
+
+ return { message: err.message };
+ },
+});
+
+export const uploadRouter = {
+ imageUploader: f({ image: { maxFileSize: "1MB", maxFileCount: 4 } })
+ .middleware(() => {
+ return {};
+ })
+ .onUploadComplete(({ file }) => {
+ console.log("upload completed", file);
+ }),
+} satisfies FileRouter;
+
+export type UploadRouter = typeof uploadRouter;
diff --git a/packages/react/src/components/button.tsx b/packages/react/src/components/button.tsx
index 151875beb5..6bb2227934 100644
--- a/packages/react/src/components/button.tsx
+++ b/packages/react/src/components/button.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import {
@@ -22,6 +22,7 @@ import type { FileRouter } from "uploadthing/types";
import type { UploadthingComponentProps } from "../types";
import { INTERNAL_uploadthingHookGen } from "../useUploadThing";
+import { usePaste } from "../utils/usePaste";
import { progressWidths, Spinner } from "./shared";
type ButtonStyleFieldCallbackArgs = {
@@ -64,9 +65,17 @@ export type UploadButtonProps<
content?: ButtonContent;
};
+/** These are some internal stuff we use to test the component and for forcing a state in docs */
+type UploadThingInternalProps = {
+ __internal_state?: "readying" | "ready" | "uploading";
+ __internal_upload_progress?: number;
+ __internal_button_disabled?: boolean;
+};
+
/**
+ * @remarks It is not recommended using this directly as it requires manually binding generics. Instead, use `createUploadButton`.
* @example
- *
+ *
* endpoint="someEndpoint"
* onUploadComplete={(res) => console.log(res)}
* onUploadError={(err) => console.log(err)}
@@ -87,17 +96,8 @@ export function UploadButton<
TRouter,
TEndpoint,
TSkipPolling
- > & {
- // props not exposed on public type
- // Allow to set internal state for testing
- __internal_state?: "readying" | "ready" | "uploading";
- // Allow to set upload progress for testing
- __internal_upload_progress?: number;
- // Allow to set ready explicitly and independently of internal state
- __internal_ready?: boolean;
- // Allow to disable the button
- __internal_button_disabled?: boolean;
- };
+ > &
+ UploadThingInternalProps;
const { mode = "auto", appendOnPaste = false } = $props.config ?? {};
@@ -107,16 +107,12 @@ export function UploadButton<
const fileInputRef = useRef(null);
const labelRef = useRef(null);
- const [uploadProgressState, setUploadProgress] = useState(
+ const [uploadProgress, setUploadProgress] = useState(
$props.__internal_upload_progress ?? 0,
);
const [files, setFiles] = useState([]);
- const [isManualTriggerDisplayed, setIsManualTriggerDisplayed] =
- useState(false);
- const uploadProgress =
- $props.__internal_upload_progress ?? uploadProgressState;
- const { startUpload, isUploading, permittedFileInfo } = useUploadThing(
+ const { startUpload, isUploading, routeConfig } = useUploadThing(
$props.endpoint,
{
headers: $props.headers,
@@ -125,7 +121,6 @@ export function UploadButton<
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
- setIsManualTriggerDisplayed(false);
setFiles([]);
$props.onClientUploadComplete?.(res);
setUploadProgress(0);
@@ -140,95 +135,96 @@ export function UploadButton<
},
);
- const { fileTypes, multiple } = generatePermittedFileTypes(
- permittedFileInfo?.config,
- );
+ const { fileTypes, multiple } = generatePermittedFileTypes(routeConfig);
- const ready =
- $props.__internal_ready ??
- ($props.__internal_state === "ready" || fileTypes.length > 0);
+ const fileRouteInput = "input" in $props ? $props.input : undefined;
+ const inputProps = useMemo(
+ () => ({
+ type: "file",
+ ref: fileInputRef,
+ multiple,
+ accept: generateMimeTypes(fileTypes).join(", "),
+ onChange: (e: React.ChangeEvent) => {
+ if (!e.target.files) return;
+ const selectedFiles = Array.from(e.target.files);
- useEffect(() => {
- const handlePaste = (event: ClipboardEvent) => {
- if (!appendOnPaste) return;
- if (document.activeElement !== labelRef.current) return;
+ if (mode === "manual") {
+ setFiles(selectedFiles);
+ return;
+ }
- const pastedFiles = getFilesFromClipboardEvent(event);
- if (!pastedFiles) return;
+ void startUpload(selectedFiles, fileRouteInput);
+ },
+ disabled: fileTypes.length === 0,
+ tabIndex: fileTypes.length === 0 ? -1 : 0,
+ }),
+ [fileRouteInput, fileTypes, mode, multiple, startUpload],
+ );
- setFiles((prev) => [...prev, ...pastedFiles]);
+ if ($props.__internal_button_disabled) inputProps.disabled = true;
- if (mode === "auto") {
- const input = "input" in $props ? $props.input : undefined;
- void startUpload(files, input);
- }
- };
-
- window.addEventListener("paste", handlePaste);
- return () => {
- window.removeEventListener("paste", handlePaste);
- };
- }, [startUpload, appendOnPaste, $props, files, mode, fileTypes]);
-
- const getUploadButtonText = (fileTypes: string[]) => {
- if (isManualTriggerDisplayed)
- return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
- if (fileTypes.length === 0) return "Loading...";
- return `Choose File${multiple ? `(s)` : ``}`;
- };
+ const state = (() => {
+ if ($props.__internal_state) return $props.__internal_state;
+ if (inputProps.disabled) return "readying";
+ if (!inputProps.disabled && !isUploading) return "ready";
+ return "uploading";
+ })();
- const getUploadButtonContents = (fileTypes: string[]) => {
- if (state !== "uploading") {
- return getUploadButtonText(fileTypes);
- }
- if (uploadProgress === 100) {
- return ;
- }
- return {uploadProgress}%;
- };
+ usePaste((event) => {
+ if (!appendOnPaste) return;
+ if (document.activeElement !== fileInputRef.current) return;
- const getInputProps = () => ({
- type: "file",
- ref: fileInputRef,
- multiple,
- accept: generateMimeTypes(fileTypes).join(", "),
- onChange: (e: React.ChangeEvent) => {
- if (!e.target.files) return;
- const selectedFiles = Array.from(e.target.files);
-
- if (mode === "manual") {
- setFiles(selectedFiles);
- setIsManualTriggerDisplayed(true);
- return;
- }
+ const pastedFiles = getFilesFromClipboardEvent(event);
+ if (!pastedFiles) return;
+
+ let filesToUpload = pastedFiles;
+ setFiles((prev) => {
+ filesToUpload = [...prev, ...pastedFiles];
+ return filesToUpload;
+ });
+ if (mode === "auto") {
const input = "input" in $props ? $props.input : undefined;
- void startUpload(selectedFiles, input);
- },
- disabled: $props.__internal_button_disabled ?? !ready,
- ...(!($props.__internal_button_disabled ?? !ready) ? { tabIndex: 0 } : {}),
+ void startUpload(files, input);
+ }
});
const styleFieldArg = {
- ready: ready,
- isUploading: $props.__internal_state === "uploading" || isUploading,
+ ready: state !== "readying",
+ isUploading: state === "uploading",
uploadProgress,
fileTypes,
} as ButtonStyleFieldCallbackArgs;
- const state = (() => {
- if ($props.__internal_state) return $props.__internal_state;
- if (!ready) return "readying";
- if (ready && !isUploading) return "ready";
+ const renderButton = () => {
+ const customContent = contentFieldToContent(
+ $props.content?.button,
+ styleFieldArg,
+ );
+ if (customContent) return customContent;
- return "uploading";
- })();
+ if (state === "readying") {
+ return "Loading...";
+ }
+
+ if (state !== "uploading") {
+ if (mode === "manual" && files.length > 0) {
+ return `Upload ${files.length} file${files.length === 1 ? "" : "s"}`;
+ }
+ return `Choose File${inputProps.multiple ? `(s)` : ``}`;
+ }
+
+ if (uploadProgress === 100) {
+ return ;
+ }
+
+ return {uploadProgress}%;
+ };
const renderClearButton = () => (