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
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:
1695
packages/extension-image/CHANGELOG.md
Normal file
1695
packages/extension-image/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/extension-image/README.md
Normal file
18
packages/extension-image/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @tiptap/extension-image
|
||||
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-image)
|
||||
[](https://npmcharts.com/compare/tiptap?minimal=true)
|
||||
[](https://www.npmjs.com/package/@tiptap/extension-image)
|
||||
[](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).
|
||||
50
packages/extension-image/package.json
Normal file
50
packages/extension-image/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
556
packages/extension-image/src/image.test.ts
Normal file
556
packages/extension-image/src/image.test.ts
Normal 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: '',
|
||||
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: '',
|
||||
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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
330
packages/extension-image/src/image.ts
Normal file
330
packages/extension-image/src/image.ts
Normal 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  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 ? `` : ``
|
||||
},
|
||||
|
||||
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 }
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
5
packages/extension-image/src/index.ts
Normal file
5
packages/extension-image/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Image } from './image.js'
|
||||
|
||||
export * from './image.js'
|
||||
|
||||
export default Image
|
||||
9
packages/extension-image/vite.config.ts
Normal file
9
packages/extension-image/vite.config.ts
Normal 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(),
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user