shadimage
Shadcn Tiptap
blockimagesource: ok · refreshed

Image

npx shadcn add "https://tiptap.niazmorshed.dev/r/image.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
rich-text@tiptap/extension-image

Files3

extensions/image-placeholder.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
	Popover,
	PopoverContent,
	PopoverTrigger,
} from "@/components/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
	NODE_HANDLES_SELECTED_STYLE_CLASSNAME,
	cn,
	isValidUrl,
} from "@/lib/utils";
import {
	type CommandProps,
	type Editor,
	Node,
	type NodeViewProps,
	NodeViewWrapper,
	ReactNodeViewRenderer,
	mergeAttributes,
} from "@tiptap/react";
import { Image, Link, Upload } from "lucide-react";
import { type FormEvent, useState } from "react";

export interface ImagePlaceholderOptions {
	HTMLAttributes: Record<string, any>;
	onDrop: (files: File[], editor: Editor) => void;
	onDropRejected?: (files: File[], editor: Editor) => void;
	onEmbed: (url: string, editor: Editor) => void;
	allowedMimeTypes?: Record<string, string[]>;
	maxFiles?: number;
	maxSize?: number;
}

declare module "@tiptap/core" {
	interface Commands<ReturnType> {
		imagePlaceholder: {
			/**
			 * Inserts an image placeholder
			 */
			insertImagePlaceholder: () => ReturnType;
		};
	}
}

export const ImagePlaceholder = Node.create<ImagePlaceholderOptions>({
	name: "image-placeholder",

	addOptions() {
		return {
			HTMLAttributes: {},
			onDrop: () => {},
			onDropRejected: () => {},
			onEmbed: () => {},
		};
	},

	group: "block",

	parseHTML() {
		return [{ tag: `div[data-type="${this.name}"]` }];
	},

	renderHTML({ HTMLAttributes }) {
		return ["div", mergeAttributes(HTMLAttributes)];
	},

	addNodeView() {
		return ReactNodeViewRenderer(ImagePlaceholderComponent, {
			className: NODE_HANDLES_SELECTED_STYLE_CLASSNAME,
		});
	},

	addCommands() {
		return {
			insertImagePlaceholder: () => (props: CommandProps) => {
				return props.commands.insertContent({
					type: "image-placeholder",
				});
			},
		};
	},
});

function ImagePlaceholderComponent(props: NodeViewProps) {
	const { editor, extension, selected } = props;

	const [open, setOpen] = useState(false);
	const [url, setUrl] = useState("");
	const [urlError, setUrlError] = useState(false);
	const [isDragActive, setIsDragActive] = useState(false);
	const [isDragReject, setIsDragReject] = useState(false);

	const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
		e.preventDefault();
		e.stopPropagation();
		setIsDragActive(true);
	};

	const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
		e.preventDefault();
		e.stopPropagation();
		setIsDragActive(false);
		setIsDragReject(false);
	};

	const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
		e.preventDefault();
		e.stopPropagation();
	};

	const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
		e.preventDefault();
		e.stopPropagation();
		setIsDragActive(false);
		setIsDragReject(false);

		const { files } = e.dataTransfer;
		const acceptedFiles: File[] = [];
		const rejectedFiles: File[] = [];

		Array.from(files).map((file) => {
			if (
				extension.options.allowedMimeTypes &&
				!Object.keys(extension.options.allowedMimeTypes).some((type) =>
					file.type.match(type),
				)
			) {
				rejectedFiles.push(file);
			} else if (
				extension.options.maxSize &&
				file.size > extension.options.maxSize
			) {
				rejectedFiles.push(file);
			} else {
				acceptedFiles.push(file);
			}
		});

		if (rejectedFiles.length > 0) {
			setIsDragReject(true);
			extension.options.onDropRejected?.(rejectedFiles, editor);
		}

		if (acceptedFiles.length > 0) {
			handleAcceptedFiles(acceptedFiles);
		}
	};

	const handleAcceptedFiles = (acceptedFiles: File[]) => {
		acceptedFiles.map((file) => {
			const reader = new FileReader();

			reader.onload = () => {
				const src = reader.result as string;
				editor.chain().focus().setImage({ src }).run();
			};

			reader.readAsDataURL(file);
		});

		if (extension.options.onDrop) {
			extension.options.onDrop(acceptedFiles, editor);
		}
	};

	const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		const files = Array.from(e.target.files || []);
		handleAcceptedFiles(files);
	};

	const handleInsertEmbed = (e: FormEvent) => {
		e.preventDefault();
		const valid = isValidUrl(url);
		if (!valid) {
			setUrlError(true);
			return;
		}
		if (url !== "") {
			editor.chain().focus().setImage({ src: url }).run();
			extension.options.onEmbed(url, editor);
		}
	};

	return (
		<NodeViewWrapper className="w-full">
			<Popover modal open={open}>
				<PopoverTrigger
					onClick={() => {
						setOpen(true);
					}}
					asChild
					className="w-full"
				>
					<div
						className={cn(
							"flex cursor-pointer items-center gap-3 rounded-md bg-accent p-2 py-3 text-sm text-accent-foreground transition-colors hover:bg-secondary",
							selected && "bg-primary/10 hover:bg-primary/20",
						)}
					>
						<Image className="h-6 w-6" />
						Add an image
					</div>
				</PopoverTrigger>
				<PopoverContent
					className="w-[450px] px-0 py-2"
					onPointerDownOutside={() => {
						setOpen(false);
					}}
					onEscapeKeyDown={() => {
						setOpen(false);
					}}
				>
					<Tabs defaultValue="upload" className="px-3">
						<TabsList>
							<TabsTrigger className="px-2 py-1 text-sm" value="upload">
								<Upload className="mr-2 h-4 w-4" />
								Upload
							</TabsTrigger>
							<TabsTrigger className="px-2 py-1 text-sm" value="url">
								<Link className="mr-2 h-4 w-4" />
								Embed link
							</TabsTrigger>
						</TabsList>

						<TabsContent value="upload">
							<div
								onDragEnter={handleDragEnter}
								onDragLeave={handleDragLeave}
								onDragOver={handleDragOver}
								onDrop={handleDrop}
								className={cn(
									"my-2 rounded-md border border-dashed text-sm transition-colors",
									isDragActive && "border-primary bg-secondary",
									isDragReject && "border-destructive bg-destructive/10",
									"hover:bg-secondary",
								)}
							>
								<input
									type="file"
									accept={Object.keys(
										extension.options.allowedMimeTypes || {},
									).join(",")}
									multiple={extension.options.maxFiles !== 1}
									onChange={handleFileInputChange}
									className="hidden"
									id="file-input"
								/>
								<label
									htmlFor="file-input"
									className="flex h-28 w-full cursor-pointer flex-col items-center justify-center text-center"
								>
									<Upload className="mx-auto mb-2 h-6 w-6" />
									Drag & drop or click to upload
								</label>
							</div>
						</TabsContent>
						<TabsContent value="url">
							<form onSubmit={handleInsertEmbed}>
								<Input
									value={url}
									onChange={(e) => {
										setUrl(e.target.value);
										if (urlError) {
											setUrlError(false);
										}
									}}
									placeholder="Paste the image link..."
								/>
								{urlError && (
									<p className="py-1.5 text-xs text-danger-11">
										Please enter a valid URL
									</p>
								)}
								<Button
									onClick={handleInsertEmbed}
									type="button"
									size="sm"
									className="my-2 h-8 w-full p-2 text-xs"
								>
									Embed Image
								</Button>
								<p className="text-center text-xs text-gray-11">
									Works with any image from the web
								</p>
							</form>
						</TabsContent>
					</Tabs>
				</PopoverContent>
			</Popover>
		</NodeViewWrapper>
	);
}
extensions/image.tsx
"use client";

import Image from "@tiptap/extension-image";
import {
	NodeViewContent,
	type NodeViewProps,
	NodeViewWrapper,
	ReactNodeViewRenderer,
} from "@tiptap/react";
import {
	AlignCenter,
	AlignLeft,
	AlignRight,
	Copy,
	Maximize,
	MoreVertical,
	Trash,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";

import { Button } from "@/components/ui/button";
import {
	DropdownMenu,
	DropdownMenuContent,
	DropdownMenuItem,
	DropdownMenuSeparator,
	DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { cn, duplicateContent } from "@/lib/utils";

export const ImageExtension = Image.extend({
	addAttributes() {
		return {
			src: {
				default: null,
			},
			alt: {
				default: null,
			},
			title: {
				default: null,
			},
			width: {
				default: "100%",
			},
			height: {
				default: null,
			},
			align: {
				default: "center",
			},
		};
	},

	addNodeView: () => {
		return ReactNodeViewRenderer(TiptapImage);
	},
});

function TiptapImage(props: NodeViewProps) {
	const { node, editor, selected, deleteNode, updateAttributes } = props;
	const imageRef = useRef<HTMLImageElement | null>(null);
	const nodeRef = useRef<HTMLDivElement | null>(null);
	const [resizing, setResizing] = useState(false);
	const [resizingPosition, setResizingPosition] = useState<"left" | "right">(
		"left",
	);
	const [resizeInitialWidth, setResizeInitialWidth] = useState(0);
	const [resizeInitialMouseX, setResizeInitialMouseX] = useState(0);

	const [openedMore, setOpenedMore] = useState(false);

	function handleResizingPosition({
		e,
		position,
	}: {
		e: React.MouseEvent<HTMLDivElement, MouseEvent>;
		position: "left" | "right";
	}) {
		startResize(e);
		setResizingPosition(position);
	}

	function startResize(event: React.MouseEvent<HTMLDivElement>) {
		event.preventDefault();

		setResizing(true);

		setResizeInitialMouseX(event.clientX);
		if (imageRef.current) {
			setResizeInitialWidth(imageRef.current.offsetWidth);
		}
	}

	function resize(event: MouseEvent) {
		if (!resizing) {
			return;
		}

		let dx = event.clientX - resizeInitialMouseX;
		if (resizingPosition === "left") {
			dx = resizeInitialMouseX - event.clientX;
		}

		const newWidth = Math.max(resizeInitialWidth + dx, 150); // Minimum width: 150
		const parentWidth = nodeRef.current?.parentElement?.offsetWidth || 0; // Get the parent element's width

		if (newWidth < parentWidth) {
			updateAttributes({
				width: newWidth,
			});
		}
	}

	function endResize() {
		setResizing(false);
		setResizeInitialMouseX(0);
		setResizeInitialWidth(0);
	}

	function handleTouchStart(
		event: React.TouchEvent,
		position: "left" | "right",
	) {
		event.preventDefault();

		setResizing(true);
		setResizingPosition(position);

		setResizeInitialMouseX(event.touches[0].clientX);
		if (imageRef.current) {
			setResizeInitialWidth(imageRef.current.offsetWidth);
		}
	}

	function handleTouchMove(event: TouchEvent) {
		if (!resizing) {
			return;
		}

		let dx = event.touches[0].clientX - resizeInitialMouseX;
		if (resizingPosition === "left") {
			dx = resizeInitialMouseX - event.touches[0].clientX;
		}

		const newWidth = Math.max(resizeInitialWidth + dx, 150);
		const parentWidth = nodeRef.current?.parentElement?.offsetWidth || 0;

		if (newWidth < parentWidth) {
			updateAttributes({
				width: newWidth,
			});
		}
	}

	function handleTouchEnd() {
		setResizing(false);
		setResizeInitialMouseX(0);
		setResizeInitialWidth(0);
	}

	// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
	useEffect(() => {
		// Mouse events
		window.addEventListener("mousemove", resize);
		window.addEventListener("mouseup", endResize);
		// Touch events
		window.addEventListener("touchmove", handleTouchMove);
		window.addEventListener("touchend", handleTouchEnd);
		return () => {
			window.removeEventListener("mousemove", resize);
			window.removeEventListener("mouseup", endResize);
			window.removeEventListener("touchmove", handleTouchMove);
			window.removeEventListener("touchend", handleTouchEnd);
		};
	}, [resizing, resizeInitialMouseX, resizeInitialWidth]);

	return (
		<NodeViewWrapper
			ref={nodeRef}
			className={cn(
				"relative flex flex-col rounded-md border-2 border-transparent",
				selected ? "border-blue-300" : "",
				node.attrs.align === "left" && "left-0 -translate-x-0",
				node.attrs.align === "center" && "left-1/2 -translate-x-1/2",
				node.attrs.align === "right" && "left-full -translate-x-full",
			)}
			style={{ width: node.attrs.width }}
		>
			<div
				className={cn(
					"group relative flex flex-col rounded-md",
					resizing && "",
				)}
			>
				<img
					ref={imageRef}
					src={node.attrs.src}
					alt={node.attrs.alt}
					title={node.attrs.title}
				/>
				<NodeViewContent as="figcaption" className="text-center">
					{node.attrs.title}
				</NodeViewContent>

				{editor?.isEditable && (
					<>
						<div
							className="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2"
							style={{ left: 0 }}
							onMouseDown={(event) => {
								handleResizingPosition({ e: event, position: "left" });
							}}
							onTouchStart={(event) => handleTouchStart(event, "left")}
						>
							<div className="z-20 h-[70px] w-1 rounded-xl border bg-[rgba(0,0,0,0.65)] opacity-0 transition-all group-hover:opacity-100" />
						</div>
						<div
							className="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2"
							style={{ right: 0 }}
							onMouseDown={(event) => {
								handleResizingPosition({ e: event, position: "right" });
							}}
							onTouchStart={(event) => handleTouchStart(event, "right")}
						>
							<div className="z-20 h-[70px] w-1 rounded-xl border bg-[rgba(0,0,0,0.65)] opacity-0 transition-all group-hover:opacity-100" />
						</div>
						<div
							className={cn(
								"absolute right-4 top-4 flex items-center gap-1 rounded-md border bg-background p-1 opacity-0 transition-opacity",
								!resizing && "group-hover:opacity-100",
								openedMore && "opacity-100",
							)}
						>
							<Button
								size="icon"
								className={cn(
									"size-7",
									node.attrs.align === "left" && "bg-accent",
								)}
								variant="ghost"
								onClick={() => {
									updateAttributes({
										align: "left",
									});
								}}
							>
								<AlignLeft className="size-4" />
							</Button>
							<Button
								size="icon"
								className={cn(
									"size-7",
									node.attrs.align === "center" && "bg-accent",
								)}
								variant="ghost"
								onClick={() => {
									updateAttributes({
										align: "center",
									});
								}}
							>
								<AlignCenter className="size-4" />
							</Button>
							<Button
								size="icon"
								className={cn(
									"size-7",
									node.attrs.align === "right" && "bg-accent",
								)}
								variant="ghost"
								onClick={() => {
									updateAttributes({
										align: "right",
									});
								}}
							>
								<AlignRight className="size-4" />
							</Button>
							<Separator orientation="vertical" className="h-[20px]" />
							<DropdownMenu
								open={openedMore}
								onOpenChange={(val) => {
									setOpenedMore(val);
								}}
							>
								<DropdownMenuTrigger asChild>
									<Button size="icon" className="size-7" variant="ghost">
										<MoreVertical className="size-4" />
									</Button>
								</DropdownMenuTrigger>
								<DropdownMenuContent
									align="start"
									alignOffset={-90}
									className="mt-1 text-sm"
								>
									<DropdownMenuItem
										onClick={() => {
											duplicateContent(editor);
										}}
									>
										<Copy className="mr-2 size-4" /> Duplicate
									</DropdownMenuItem>
									<DropdownMenuItem
										onClick={() => {
											updateAttributes({
												width: "fit-content",
											});
										}}
									>
										<Maximize className="mr-2 size-4" /> Full Screen
									</DropdownMenuItem>
									<DropdownMenuSeparator />
									<DropdownMenuItem
										className="text-destructive focus:text-destructive"
										onClick={() => {
											deleteNode();
										}}
									>
										<Trash className="mr-2 size-4" /> Delete Image
									</DropdownMenuItem>
								</DropdownMenuContent>
							</DropdownMenu>
						</div>
					</>
				)}
			</div>
		</NodeViewWrapper>
	);
}
toolbars/image-placeholder-toolbar.tsx
"use client";

import { Image } from "lucide-react";
import React from "react";

import { Button, type ButtonProps } from "@/components/ui/button";
import {
	Tooltip,
	TooltipContent,
	TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useToolbar } from "@/components/toolbars/toolbar-provider";

const ImagePlaceholderToolbar = React.forwardRef<
	HTMLButtonElement,
	ButtonProps
>(({ className, onClick, children, ...props }, ref) => {
	const { editor } = useToolbar();
	return (
		<Tooltip>
			<TooltipTrigger asChild>
				<Button
					variant="ghost"
					size="icon"
					className={cn(
						"h-8 w-8",
						editor?.isActive("image-placeholder") && "bg-accent",
						className,
					)}
					onClick={(e) => {
						editor?.chain().focus().insertImagePlaceholder().run();
						onClick?.(e);
					}}
					ref={ref}
					{...props}
				>
					{children || <Image className="h-4 w-4" />}
				</Button>
			</TooltipTrigger>
			<TooltipContent>
				<span>Image</span>
			</TooltipContent>
		</Tooltip>
	);
});

ImagePlaceholderToolbar.displayName = "ImagePlaceholderToolbar";

export { ImagePlaceholderToolbar };

More from Shadcn Tiptap

Browse all components in this library →