Shadcn Tiptap
blockimage-placeholdersource: ok · refreshed
Image Placeholder
npx shadcn add "https://tiptap.niazmorshed.dev/r/image-placeholder.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumrich-text@tiptap/core@tiptap/react@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 };