snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
# @tiptap/extension-image
[![Version](https://img.shields.io/npm/v/@tiptap/extension-image.svg?label=version)](https://www.npmjs.com/package/@tiptap/extension-image)
[![Downloads](https://img.shields.io/npm/dm/@tiptap/extension-image.svg)](https://npmcharts.com/compare/tiptap?minimal=true)
[![License](https://img.shields.io/npm/l/@tiptap/extension-image.svg)](https://www.npmjs.com/package/@tiptap/extension-image)
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub)](https://github.com/sponsors/ueberdosis)
## Introduction
Tiptap is a headless wrapper around [ProseMirror](https://ProseMirror.net) – a toolkit for building rich text WYSIWYG editors, which is already in use at many well-known companies such as _New York Times_, _The Guardian_ or _Atlassian_.
## Official Documentation
Documentation can be found on the [Tiptap website](https://tiptap.dev).
## License
Tiptap is open sourced software licensed under the [MIT license](https://github.com/ueberdosis/tiptap/blob/main/LICENSE.md).

View File

@@ -0,0 +1,50 @@
{
"name": "@tiptap/extension-image",
"version": "3.30.3",
"description": "image extension for tiptap",
"keywords": [
"tiptap",
"tiptap extension"
],
"homepage": "https://tiptap.dev",
"bugs": {
"url": "https://github.com/ueberdosis/tiptap/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ueberdosis/tiptap",
"directory": "packages/extension-image"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"files": [
"src",
"dist"
],
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": {
"import": "./dist/index.d.ts",
"require": "./dist/index.d.cts"
},
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "vp pack"
},
"devDependencies": {
"@tiptap/core": "workspace:^"
},
"peerDependencies": {
"@tiptap/core": "workspace:*"
}
}

View File

@@ -0,0 +1,556 @@
import { Editor, Extension } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Text from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import { Image } from './image.js'
describe('extension-image', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const destroyEditor = () => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
}
afterEach(() => {
destroyEditor()
})
const imgSrc = 'https://example.com/image.jpg'
it('should apply HTMLAttributes via renderHTML when resize is disabled', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: { src: imgSrc, alt: 'test-alt' },
},
],
},
})
expect(editor.getHTML()).toContain('class="my-classname"')
})
it('should apply HTMLAttributes in the editor DOM when resize is disabled', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: { src: imgSrc, alt: 'test-alt' },
},
],
},
})
const img = editor.view.dom.querySelector('img')
expect(img).not.toBeNull()
// ProseMirror may add additional classes like 'ProseMirror-selectednode'
expect(img?.getAttribute('class')).toContain('my-classname')
})
it('should apply HTMLAttributes via renderHTML when resize is enabled', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
},
resize: {
enabled: true,
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: { src: imgSrc, alt: 'test-alt' },
},
],
},
})
// getHTML() goes through renderHTML which already correctly merges HTMLAttributes
expect(editor.getHTML()).toContain('class="my-classname"')
})
it('should apply HTMLAttributes in the editor DOM when resize is enabled', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
'data-test': 'value',
},
resize: {
enabled: true,
},
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: { src: imgSrc, alt: 'test-alt' },
},
],
},
})
const img = editor.view.dom.querySelector('img')
expect(img).not.toBeNull()
expect(img?.getAttribute('class')).toBe('my-classname')
expect(img?.getAttribute('data-test')).toBe('value')
expect(img?.getAttribute('src')).toBe(imgSrc)
})
it('should apply HTMLAttributes in the editor DOM when resize is enabled and content is markdown', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Markdown,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
},
resize: {
enabled: true,
},
}),
],
content: '![Image](https://example.com/image.jpg)',
contentType: 'markdown',
})
const img = editor.view.dom.querySelector('img')
expect(img).not.toBeNull()
expect(img?.getAttribute('class')).toBe('my-classname')
expect(img?.getAttribute('src')).toBe('https://example.com/image.jpg')
})
it('should apply HTMLAttributes via renderHTML when resize is enabled and content is markdown', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Paragraph,
Text,
Markdown,
Image.configure({
HTMLAttributes: {
class: 'my-classname',
},
resize: {
enabled: true,
},
}),
],
content: '![Image](https://example.com/image.jpg)',
contentType: 'markdown',
})
// getHTML() goes through renderHTML which already correctly merges HTMLAttributes
expect(editor.getHTML()).toContain('class="my-classname"')
})
/**
* Minimal extension that adds a global `customId` attribute (rendered as `data-custom-id`)
* to specified node types. This simulates what UniqueID does but without the complex
* plugin machinery, making it reliable in test environments.
*/
const CustomIdAttribute = Extension.create({
name: 'customIdAttribute',
addGlobalAttributes() {
return [
{
types: ['image'],
attributes: {
customId: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-custom-id'),
renderHTML: (attributes: Record<string, any>) => {
if (!attributes.customId) {
return {}
}
return { 'data-custom-id': attributes.customId }
},
},
},
},
]
},
})
describe('resizable image attribute updates', () => {
it('should render global attributes on the resizable image element', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
CustomIdAttribute,
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
customId: 'my-id-123',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
expect(container).not.toBeNull()
const img = container?.querySelector('img')
expect(img).not.toBeNull()
expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png')
expect(img?.getAttribute('data-custom-id')).toBe('my-id-123')
})
it('should update global attributes on the img element when node attributes change', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
CustomIdAttribute,
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
customId: 'original-id',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('data-custom-id')).toBe('original-id')
// Update the customId so the img element reflects the new value.
editor.commands.updateAttributes('image', { customId: 'updated-id' })
expect(img?.getAttribute('data-custom-id')).toBe('updated-id')
})
it('should preserve global attributes when other attributes change', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
CustomIdAttribute,
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
customId: 'persistent-id',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('data-custom-id')).toBe('persistent-id')
// Update alt text while retaining data-custom-id.
editor.commands.updateAttributes('image', { alt: 'Updated alt text' })
expect(img?.getAttribute('alt')).toBe('Updated alt text')
expect(img?.getAttribute('data-custom-id')).toBe('persistent-id')
})
it('should update src on the resizable image when src attribute changes', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png')
editor.commands.updateAttributes('image', { src: 'https://tiptap.dev/new-image.png' })
expect(img?.getAttribute('src')).toBe('https://tiptap.dev/new-image.png')
})
it('should clear src on the resizable image when src is set to null', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png')
editor.commands.updateAttributes('image', { src: null })
expect(img?.hasAttribute('src')).toBe(false)
expect(img?.getAttribute('src')).toBeNull()
expect(img?.src).toBe('')
})
it('should clear src on the resizable image when src is set to an empty string', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('src')).toBe('https://tiptap.dev/placeholder.png')
editor.commands.updateAttributes('image', { src: '' })
expect(img?.hasAttribute('src')).toBe(false)
expect(img?.getAttribute('src')).toBeNull()
expect(img?.src).toBe('')
})
it('should remove attributes from the resizable image when set to null', () => {
editor = new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
Image.configure({
resize: { enabled: true },
}),
CustomIdAttribute,
],
content: {
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://tiptap.dev/placeholder.png',
alt: 'Some alt text',
title: 'Some title',
customId: 'to-be-removed',
},
},
],
},
})
const container = editor.view.dom.querySelector('[data-resize-container]')
const img = container?.querySelector('img')
expect(img?.getAttribute('alt')).toBe('Some alt text')
expect(img?.getAttribute('title')).toBe('Some title')
expect(img?.getAttribute('data-custom-id')).toBe('to-be-removed')
editor.commands.updateAttributes('image', { alt: null, title: null, customId: null })
expect(img?.hasAttribute('alt')).toBe(false)
expect(img?.hasAttribute('title')).toBe(false)
expect(img?.hasAttribute('data-custom-id')).toBe(false)
})
})
describe('resizable image visibility', () => {
const setImageLoaded = (loaded: boolean) => {
vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(loaded)
vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(loaded ? 100 : 0)
}
const mountResizableImage = () => {
editor = new Editor({
element: createEditorEl(),
extensions: [Document, Paragraph, Text, Image.configure({ resize: { enabled: true } })],
content: {
type: 'doc',
content: [{ type: 'image', attrs: { src: imgSrc } }],
},
})
return editor.view.dom.querySelector('[data-resize-container]') as HTMLElement
}
afterEach(() => {
vi.restoreAllMocks()
})
it('should show the node view when the image is already loaded on mount', () => {
// A cached image is already complete before the node view mounts, so
// `load` never fires again and the image would stay hidden forever.
setImageLoaded(true)
expect(mountResizableImage().style.visibility).toBe('')
})
it('should hide the node view until a not-yet-loaded image fires load', () => {
setImageLoaded(false)
const container = mountResizableImage()
expect(container.style.visibility).toBe('hidden')
container.querySelector('img')?.dispatchEvent(new Event('load'))
expect(container.style.visibility).toBe('')
})
it('should show the node view when the image fails to load', () => {
setImageLoaded(false)
const container = mountResizableImage()
container.querySelector('img')?.dispatchEvent(new Event('error'))
expect(container.style.visibility).toBe('')
})
})
})

View File

@@ -0,0 +1,330 @@
import type { ResizableNodeViewDirection, ResizableNodeViewOptions } from '@tiptap/core'
import {
getRenderedAttributes,
mergeAttributes,
Node,
nodeInputRule,
ResizableNodeView,
} from '@tiptap/core'
export interface ImageOptions {
/**
* Controls if the image node should be inline or not.
* @default false
* @example true
*/
inline: boolean
/**
* Controls if base64 images are allowed. Enable this if you want to allow
* base64 image urls in the `src` attribute.
* @default false
* @example true
*/
allowBase64: boolean
/**
* HTML attributes to add to the image element.
* @default {}
* @example { class: 'foo' }
*/
HTMLAttributes: Record<string, any>
/**
* Controls if the image should be resizable and how the resize is configured.
* @default false
* @example { directions: { top: true, right: true, bottom: true, left: true, topLeft: true, topRight: true, bottomLeft: true, bottomRight: true }, minWidth: 100, minHeight: 100 }
*/
resize:
| {
enabled: boolean
directions?: ResizableNodeViewDirection[]
minWidth?: number
minHeight?: number
alwaysPreserveAspectRatio?: boolean
}
| false
}
export interface SetImageOptions {
src: string
alt?: string
title?: string
width?: number
height?: number
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
/**
* Add an image
* @param options The image attributes
* @example
* editor
* .commands
* .setImage({ src: 'https://tiptap.dev/logo.png', alt: 'tiptap', title: 'tiptap logo' })
*/
setImage: (options: SetImageOptions) => ReturnType
}
}
}
/**
* Matches an image to a ![image](src "title") on input.
*/
export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
/**
* This extension allows you to insert images.
* @see https://www.tiptap.dev/api/nodes/image
*/
export const Image = Node.create<ImageOptions>({
name: 'image',
addOptions() {
return {
inline: false,
allowBase64: false,
HTMLAttributes: {},
resize: false,
}
},
inline() {
return this.options.inline
},
group() {
return this.options.inline ? 'inline' : 'block'
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: null,
},
height: {
default: null,
},
}
},
parseHTML() {
return [
{
tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])',
},
]
},
renderHTML({ HTMLAttributes }) {
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},
parseMarkdown: (token, helpers) => {
return helpers.createNode('image', {
src: token.href,
title: token.title,
alt: token.text,
})
},
renderMarkdown: node => {
const src = node.attrs?.src ?? ''
const alt = node.attrs?.alt ?? ''
const title = node.attrs?.title ?? ''
return title ? `![${alt}](${src} "${title}")` : `![${alt}](${src})`
},
addNodeView() {
if (!this.options.resize || !this.options.resize.enabled || typeof document === 'undefined') {
return null
}
const { directions, minWidth, minHeight, alwaysPreserveAspectRatio } = this.options.resize
const resizeManagedAttributes = new Set(['src', 'width', 'height'])
return ({ node, getPos, HTMLAttributes, editor }) => {
const el = document.createElement('img')
el.draggable = false
const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)
Object.entries(mergedAttributes).forEach(([key, value]) => {
if (value != null) {
switch (key) {
case 'src':
case 'width':
case 'height':
break
default:
el.setAttribute(key, value)
break
}
}
})
if (mergedAttributes.src !== null) {
el.src = mergedAttributes.src
}
let previousHTMLAttributes = { ...HTMLAttributes }
const syncImageSource = (src: unknown) => {
if (typeof src === 'string' && src !== '') {
if (el.getAttribute('src') !== src) {
el.src = src
}
return
}
if (el.hasAttribute('src')) {
el.removeAttribute('src')
}
if (el.src !== '') {
el.src = ''
}
}
syncImageSource(HTMLAttributes.src)
const onUpdate: ResizableNodeViewOptions['onUpdate'] = (
updatedNode: Parameters<typeof getRenderedAttributes>[0],
) => {
if (updatedNode.type !== node.type) {
return false
}
const extensionAttributes = editor.extensionManager.attributes.filter(
attribute => attribute.type === updatedNode.type.name,
)
const newHTMLAttributes = getRenderedAttributes(updatedNode, extensionAttributes)
// Remove attributes that were previously rendered but are no longer present
Object.keys(previousHTMLAttributes).forEach(key => {
if (!resizeManagedAttributes.has(key) && !(key in newHTMLAttributes)) {
el.removeAttribute(key)
}
})
Object.entries(newHTMLAttributes).forEach(([key, value]) => {
if (resizeManagedAttributes.has(key)) {
return
}
if (value != null) {
el.setAttribute(key, value)
} else {
el.removeAttribute(key)
}
})
syncImageSource(newHTMLAttributes.src)
previousHTMLAttributes = newHTMLAttributes
return true
}
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (width, height) => {
el.style.width = `${width}px`
el.style.height = `${height}px`
},
onCommit: (width, height) => {
const pos = getPos()
if (pos === undefined) {
return
}
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width,
height,
})
.run()
},
onUpdate,
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
},
})
const dom = nodeView.dom as HTMLElement
// when image is loaded, show the node view to get the correct dimensions
const showNodeView = () => {
dom.style.visibility = ''
dom.style.pointerEvents = ''
}
dom.style.visibility = 'hidden'
dom.style.pointerEvents = 'none'
if (el.complete && el.naturalWidth > 0) {
// A cached image is already complete before this node view mounts, so
// `load` never fires again and the image would stay hidden forever.
showNodeView()
} else {
el.onload = showNodeView
// Reveal on error too, so a broken src renders as an inspectable broken
// image instead of an invisible node.
el.onerror = showNodeView
}
return nodeView
}
},
addCommands() {
return {
setImage:
options =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
})
},
}
},
addInputRules() {
return [
nodeInputRule({
find: inputRegex,
type: this.type,
getAttributes: match => {
const [, , alt, src, title] = match
return { src, alt, title }
},
}),
]
},
})

View File

@@ -0,0 +1,5 @@
import { Image } from './image.js'
export * from './image.js'
export default Image

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite-plus'
import { basePackConfig } from '../../pack.config.mjs'
export default defineConfig({
pack: {
entry: ['src/index.ts'],
...basePackConfig(),
},
})