@explita/tiptap-extension-image
v0.1.0
Published
A framework-agnostic TipTap v3 image extension with upload placeholders, drag-and-drop uploads, resizing, alignment controls, and external image sources.
Maintainers
Readme
@explita/tiptap-extension-image
A rich TipTap image extension with upload placeholders, drag-and-drop, resizable handles, alignment toolbar, and external image support.
Features
- Two visual modes: Upload placeholder (dashed drop zone) and rendered
<img>element - Click to upload: Opens a native file picker filtered to PNG, JPEG, GIF, and WebP
- Drag-and-drop: Drag an image file directly onto the placeholder; visual feedback on hover
- Clipboard paste support: Paste images directly from the clipboard and upload through the configured
onUploadcallback. - External image support: External images bypass upload processing and are stored directly as their source URL.
- Alignment toolbar: Floating bar with Inline / Left / Center / Right buttons on hover
- Resize handles: Optional drag-to-resize via
ResizableNodeViewwith configurable directions, min dimensions, and aspect-ratio lock - Error states: Inline error messages for invalid file types, failed uploads, or empty results
- Multi-image upload: Upload callback can return multiple images — all are inserted into the document
- Framework-agnostic: No React, Vue, or Tailwind dependencies — plain DOM node view
- No external CSS required: All styles are inline; customize via
HTMLAttributesor[data-image-node]CSS selectors
Installation
npm install @explita/tiptap-extension-image
# or
pnpm add @explita/tiptap-extension-imageThis package has a peer dependency on @tiptap/core and @tiptap/pm (v3).
This extension is designed as a replacement for
@tiptap/extension-image. Do not install both extensions in the same editor instance.
Usage
Basic setup
import { Editor } from "@tiptap/core";
import { Image } from "@explita/tiptap-extension-image";
const editor = new Editor({
extensions: [
Image.configure({
onUpload: async (files) => {
// Upload the files to your server/CDN and return image metadata
const formData = new FormData();
for (const f of files) formData.append("files", f);
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await res.json();
return data.map((item) => ({
src: item.url,
alt: item.alt,
title: item.title,
}));
},
}),
],
});Upload errors
Throw an error from onUpload to display the error state:
onUpload: async (files) => {
const result = await upload(files[0]);
if (!result.success) {
throw new Error("Upload failed");
}
return [{ src: result.url }];
};Commands
// Insert an upload placeholder
editor.chain().setImageUpload().run();
// Insert an external image from a URL
editor
.chain()
.setExternalImage({
src: "https://example.com/image.jpg",
alt: "Alt text",
title: "Title",
})
.run();
// Change alignment of the selected image
editor.chain().setImageAlignment("left").run();
editor.chain().setImageAlignment("center").run();
editor.chain().setImageAlignment("right").run();
editor.chain().setImageAlignment(null).run(); // inline / resetWith external picker (vault, gallery, etc.)
Image.configure({
onPick: async () => {
// Open your custom picker UI
const images = await myPicker.open();
return images; // Array<{ src, alt?, title? }> or null if cancelled
},
onUpload: async (files) => {
// Fallback upload
const url = await uploadFile(files[0]);
return [{ src: url }];
},
});With resize handles
Image.configure({
resize: {
enabled: true,
directions: ["se", "e"], // "se" (corner) | "e" (right edge) | "ne" | "nw" | "sw" | "w" | "n" | "s"
minWidth: 50,
minHeight: 50,
preserveAspectRatio: true,
},
});Configuration
| Option | Type | Default | Description |
| ---------------- | ------------------------------------------------------ | ------- | ----------------------------------------------------- |
| inline | boolean | true | Render inline (<span>) or as block (<div>) |
| onUpload | ((files: File[]) => Promise\<ImageAsset[]\>) \| null | null | Callback to upload files; returns image assets |
| onPick | (() => Promise\<ImageAsset[] \| null\>) \| null | null | Callback to pick from an external source |
| HTMLAttributes | Record\<string, any\> | {} | Extra HTML attributes merged onto the <img> element |
| resize | ResizeConfig \| false | false | Configures drag-resize handles (see below) |
| pasteImages | boolean | false | Enables clipboard image uploads |
ImageAsset
interface ImageAsset {
id?: string;
src: string;
alt?: string;
title?: string;
width?: number;
height?: number;
mimeType?: string;
size?: number;
}ResizeConfig
interface ResizeConfig {
enabled: boolean;
directions?: ResizableNodeViewDirection[]; // e.g. ["se", "e"]
minWidth?: number;
minHeight?: number;
preserveAspectRatio?: boolean;
}Commands API
The extension extends TipTap's Commands interface with three commands:
| Command | Signature | Description |
| ------------------- | ----------------------------------------------------------------------- | ------------------------------------- |
| setImageUpload | () => ReturnType | Inserts a new upload placeholder node |
| setExternalImage | (params: { src: string; alt?: string; title?: string }) => ReturnType | Inserts an image from a URL |
| setImageAlignment | (align: "left" \| "center" \| "right" \| null) => ReturnType | Sets alignment on the selected image |
HTML Parsing / Rendering
- Parses from:
<span[data-image-node]>,<div[data-image-node]>,<img[data-image-node]> - Renders as:
<img>withdata-image-node(image mode), or<span>/<div>withdata-image-node(placeholder mode)
Styling
All visual styling is applied via inline CSS. You can override it by targeting the [data-image-node] attribute:
[data-image-node] {
border-radius: 8px;
}
/* Placeholder-specific */
[data-image-node] {
/* The placeholder is a <span> or <div> with this attribute */
}
/*
* Dark mode support.
*
* The extension already references CSS variables — with light-mode
* fallbacks — in all its inline styles. To switch colours for dark
* mode you only need to define the variables under whatever class or
* attribute your app uses (e.g. `.dark`, `[data-theme="dark"]`,
* `.theme-dark`, `body.dark`). The new values cascade automatically;
* no `!important` overrides are needed.
*
* Full list of available variables with their default (light) values:
*/
.dark {
/* ── Placeholder ── */
--image-ph-bg: #1f2937;
--image-ph-border: #4b5563;
--image-ph-icon: #9ca3af;
--image-ph-label: #d1d5db;
--image-ph-hint: #6b7280;
/* ── Placeholder hover ── */
--image-ph-hover-bg: #374151;
--image-ph-hover-border: #6b7280;
--image-ph-hover-icon: #e5e7eb;
/* ── Drag-over / active ── */
--image-ph-active-bg: #1e3a5f;
--image-ph-active-border: #60a5fa;
--image-ph-active-label: #60a5fa;
/* ── Upload in progress ── */
--image-ph-progress-bg: #1e3a5f;
--image-ph-progress-border: #60a5fa;
--image-ph-progress-icon: #60a5fa;
--image-ph-progress-label: #60a5fa;
/* ── Error state ── */
--image-ph-error-bg: #3b1a1a;
--image-ph-error-border: #f87171;
--image-ph-error-text: #fca5a5;
/* ── Close button ── */
--image-close-bg: #4b5563;
--image-close-color: #d1d5db;
--image-close-hover-bg: #6b7280;
--image-close-hover-color: #f9fafb;
/* ── Alignment toolbar ── */
--image-toolbar-bg: rgba(45, 45, 45, 0.92);
--image-toolbar-btn: rgba(255, 255, 255, 0.6);
--image-toolbar-btn-hover-bg: rgba(255, 255, 255, 0.15);
}Or use the HTMLAttributes option to pass additional attributes:
Image.configure({
HTMLAttributes: {
class: "my-custom-image",
style: "border-radius: 8px;",
},
});License
MIT © Explita
