This commit is contained in:
bdbch 2025-06-04 22:01:46 +03:00 committed by GitHub
commit 5acf70ac35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 837 additions and 16 deletions

View File

@ -0,0 +1,5 @@
---
'@tiptap/core': minor
---
Added new `createResizableNodeView` helper function that creates resizable node view elements`

View File

@ -0,0 +1,7 @@
---
'@tiptap/core': major
---
the addNodeView function can now return `null` to dynamically disable rendering of a node view
While this should not directly cause any issues, it's noteworthy as it still could affect some behavior in some edge cases.

View File

@ -0,0 +1,5 @@
---
'@tiptap/extension-image': minor
---
Added a new `resize` option that allows images to be resized. The option adds resize handlers to images allowing users to manually resize images via drag and drop or touch

View File

@ -10,11 +10,11 @@ import React, { useCallback } from 'react'
export default () => {
const editor = useEditor({
extensions: [Document, Paragraph, Text, Image, Dropcursor],
extensions: [Document, Paragraph, Text, Image.configure({ resize: { minWidth: 100, minHeight: 100 } }), Dropcursor],
content: `
<p>This is a basic example of implementing images. Drag to re-order.</p>
<img src="https://placehold.co/800x400" />
<img src="https://placehold.co/800x400/6A00F5/white" />
<img src="https://unsplash.it/seed/tiptap/800/400" />
<img src="https://unsplash.it/seed/tiptap-2/800/400" />
`,
})

View File

@ -21,4 +21,41 @@ context('/src/Nodes/Image/React/', () => {
cy.get('.tiptap').find('img').should('have.attr', 'src', 'foobar.png')
})
})
it('should verify resize handlers on the image node', () => {
// Insert a pre-defined image content to ensure consistent testing
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<img src="test-image.jpg" alt="Test image" />')
// Wait for the image to be properly rendered with its nodeview
cy.wait(500)
// Find the image container with data-node="image" attribute
cy.get('.tiptap [data-node="image"]')
.should('exist')
.and('have.length', 1)
.then($imageNode => {
// Check for all resize handles
const resizeHandles = [
'left',
'right',
'top',
'bottom',
'top-left',
'top-right',
'bottom-left',
'bottom-right',
]
// Verify each resize handle exists with the correct position attribute
resizeHandles.forEach(position => {
cy.wrap($imageNode)
.find(`.resize-handle-${position}`)
.should('exist')
.should('have.attr', 'data-position', position)
.should('be.visible')
})
})
})
})
})

View File

@ -6,12 +6,61 @@
img {
display: block;
height: auto;
margin: 1.5rem 0;
max-width: 100%;
}
[data-resize-container] {
&.ProseMirror-selectednode {
outline: 3px solid var(--purple);
background-color: var(--purple-light);
}
}
[data-resize-container] img {
max-width: 100%;
}
[data-resize-container][data-node='image'] {
margin: 1.5rem 0;
max-width: 100%;
}
[data-resize-handle][data-edge] {
background-color: var(--purple);
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 1;
}
&[data-orientation='horizontal'] {
width: 4px;
}
&[data-orientation='vertical'] {
height: 4px;
}
}
[data-resize-handle][data-corner] {
background-color: var(--white);
width: 10px;
height: 10px;
border: 2px solid var(--purple);
}
[data-corner='top-left'] {
transform: translate(-50%, -50%);
}
[data-corner='top-right'] {
transform: translate(50%, -50%);
}
[data-corner='bottom-left'] {
transform: translate(-50%, 50%);
}
[data-corner='bottom-right'] {
transform: translate(50%, 50%);
}
}

View File

@ -21,4 +21,41 @@ context('/src/Nodes/Image/Vue/', () => {
cy.get('.tiptap').find('img').should('have.attr', 'src', 'foobar.png')
})
})
it('should verify resize handlers on the image node', () => {
// Insert a pre-defined image content to ensure consistent testing
cy.get('.tiptap').then(([{ editor }]) => {
editor.commands.setContent('<img src="test-image.jpg" alt="Test image" />')
// Wait for the image to be properly rendered with its nodeview
cy.wait(500)
// Find the image container with data-node="image" attribute
cy.get('.tiptap [data-node="image"]')
.should('exist')
.and('have.length', 1)
.then($imageNode => {
// Check for all resize handles
const resizeHandles = [
'left',
'right',
'top',
'bottom',
'top-left',
'top-right',
'bottom-left',
'bottom-right',
]
// Verify each resize handle exists with the correct position attribute
resizeHandles.forEach(position => {
cy.wrap($imageNode)
.find(`.resize-handle-${position}`)
.should('exist')
.should('have.attr', 'data-position', position)
.should('be.visible')
})
})
})
})
})

View File

@ -40,11 +40,17 @@ export default {
mounted() {
this.editor = new Editor({
extensions: [Document, Paragraph, Text, Image, Dropcursor],
extensions: [
Document,
Paragraph,
Text,
Image.configure({ resize: { minWidth: 100, minHeight: 100 } }),
Dropcursor,
],
content: `
<p>This is a basic example of implementing images. Drag to re-order.</p>
<img src="https://placehold.co/800x400" />
<img src="https://placehold.co/800x400/6A00F5/white" />
<img src="https://unsplash.it/seed/tiptap/800/400" />
<img src="https://unsplash.it/seed/tiptap-2/800/400" />
`,
})
},
@ -64,13 +70,62 @@ export default {
img {
display: block;
height: auto;
}
[data-resize-container] {
&.ProseMirror-selectednode {
background-color: var(--purple-light);
}
}
[data-resize-container] img {
max-width: 100%;
}
[data-resize-container][data-node='image'] {
margin: 1.5rem 0;
max-width: 100%;
}
&.ProseMirror-selectednode {
outline: 3px solid var(--purple);
[data-resize-handle][data-edge] {
background-color: var(--purple);
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 1;
}
&[data-orientation='horizontal'] {
width: 4px;
}
&[data-orientation='vertical'] {
height: 4px;
}
}
[data-resize-handle][data-corner] {
background-color: var(--white);
width: 10px;
height: 10px;
border: 2px solid var(--purple);
}
[data-corner='top-left'] {
transform: translate(-50%, -50%);
}
[data-corner='top-right'] {
transform: translate(50%, -50%);
}
[data-corner='bottom-left'] {
transform: translate(-50%, 50%);
}
[data-corner='bottom-right'] {
transform: translate(50%, 50%);
}
}
</style>

View File

@ -206,10 +206,16 @@ export class ExtensionManager {
return []
}
const nodeViewResult = addNodeView()
if (!nodeViewResult) {
return []
}
const nodeview: NodeViewConstructor = (node, view, getPos, decorations, innerDecorations) => {
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
return addNodeView()({
return nodeViewResult({
// pass-through
node,
view,

View File

@ -18,7 +18,7 @@ export interface NodeConfig<Options = any, Storage = any>
editor: Editor
type: NodeType
parent: ParentConfig<NodeConfig<Options, Storage>>['addNodeView']
}) => NodeViewRenderer)
}) => NodeViewRenderer | null)
| null
/**

View File

@ -0,0 +1,42 @@
import type {
ResizableNodeViewDiagonalDirection,
ResizableNodeViewDirection,
ResizableNodeViewDirections,
ResizableNodeViewOptions,
} from './resizable/index.js'
import { createResizableNodeView as createResizableNodeViewModular } from './resizable/index.js'
export type {
ResizableNodeViewDiagonalDirection,
ResizableNodeViewDirection,
ResizableNodeViewDirections,
ResizableNodeViewOptions,
}
/**
* Creates a resizable node view for Tiptap
*
* @example
* ```js
* addNodeView() {
* return ({ node, getPos, editor }) => {
* const img = document.createElement('img')
* img.src = node.attrs.src
*
* const resizable = createResizableNodeView({
* dom: img,
* editor,
* getPos,
* node,
* minWidth: 100,
* minHeight: 50,
* })
*
* return { dom: resizable }
* }
* }
* ```
*/
export function createResizableNodeView(options: ResizableNodeViewOptions): HTMLElement {
return createResizableNodeViewModular(options)
}

View File

@ -2,6 +2,7 @@ export * from './combineTransactionSteps.js'
export * from './createChainableState.js'
export * from './createDocument.js'
export * from './createNodeFromContent.js'
export * from './createResizableNodeView.js'
export * from './defaultBlockAt.js'
export * from './findChildren.js'
export * from './findChildrenInRange.js'

View File

@ -0,0 +1,59 @@
import { createResizeHandle } from './create-resize-handles.js'
import { createDOMElement, createWrapper, defaultDirections } from './dom-utils.js'
import { setupResizeHandlers } from './setup-resize-handlers.js'
import type {
ResizableNodeViewDiagonalDirection,
ResizableNodeViewDirection,
ResizableNodeViewOptions,
} from './types.js'
/**
* Creates a resizable node view with size constraints
*/
export function createResizableNodeView({
directions = defaultDirections,
dom,
editor,
getPos,
node,
minWidth = 20,
minHeight = 20,
}: ResizableNodeViewOptions): HTMLElement {
// Setup the node DOM element
dom.style.display = 'block'
dom.style.position = 'relative'
dom.style.zIndex = '0'
// Create wrapper structure
const wrapper = createWrapper()
wrapper.appendChild(dom)
// Add resize handles for each enabled direction (including corners)
Object.entries(directions).forEach(([direction, isEnabled]) => {
if (isEnabled) {
const directionKey = direction as ResizableNodeViewDirection | ResizableNodeViewDiagonalDirection
const handle = createResizeHandle(directionKey)
wrapper.appendChild(handle)
setupResizeHandlers(handle, directionKey, dom, editor, getPos, node, minWidth, minHeight)
}
})
// Create the main container
const container = createDOMElement('div', {
classes: ['resize-container'],
dataset: {
resizeContainer: '',
node: node.type.name,
},
styles: {
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'flex-start',
},
})
// Add the wrapper (which contains the original DOM) to the container
container.appendChild(wrapper)
return container
}

View File

@ -0,0 +1,103 @@
import { createDOMElement } from './dom-utils.js'
import type { ResizableNodeViewDiagonalDirection, ResizableNodeViewDirection } from './types.js'
/**
* Creates a resize handle for the specified direction or diagonal direction
*/
export function createResizeHandle(
direction: ResizableNodeViewDirection | ResizableNodeViewDiagonalDirection,
): HTMLElement {
// Determine if this is a regular direction or a diagonal direction
const isDiagonal = direction.includes('-')
const styles: Record<string, string> = {
position: 'absolute',
zIndex: '1',
}
// Prepare classes array - all handles get 'resize-handle' class
const classes = ['resize-handle']
// Prepare dataset object
const dataset: Record<string, string> = {
resizeHandle: direction,
}
if (isDiagonal) {
// Add diagonal-specific class
classes.push('resize-handle-corner')
// Handle diagonal directions (corners)
styles.width = '12px'
styles.height = '12px'
// Add corner-specific class and dataset
switch (direction) {
case 'top-left':
classes.push('resize-handle-top-left')
dataset.corner = 'top-left'
styles.top = '0'
styles.left = '0'
styles.cursor = 'nw-resize'
break
case 'top-right':
classes.push('resize-handle-top-right')
dataset.corner = 'top-right'
styles.top = '0'
styles.right = '0'
styles.cursor = 'ne-resize'
break
case 'bottom-left':
classes.push('resize-handle-bottom-left')
dataset.corner = 'bottom-left'
styles.bottom = '0'
styles.left = '0'
styles.cursor = 'sw-resize'
break
case 'bottom-right':
classes.push('resize-handle-bottom-right')
dataset.corner = 'bottom-right'
styles.bottom = '0'
styles.right = '0'
styles.cursor = 'se-resize'
break
default:
console.warn(`Unknown diagonal direction: ${direction}`)
break
}
dataset.orientation = 'diagonal'
} else {
// Handle regular directions (edges)
const isHorizontal = direction === 'left' || direction === 'right'
// Add edge-specific class
classes.push(isHorizontal ? 'resize-handle-horizontal' : 'resize-handle-vertical')
classes.push(`resize-handle-${direction}`)
styles.cursor = isHorizontal ? 'ew-resize' : 'ns-resize'
if (isHorizontal) {
styles.top = '0'
styles.height = '100%'
} else {
styles.left = '0'
styles.width = '100%'
}
// Position the handle on the correct side
styles[direction] = '0'
dataset.orientation = isHorizontal ? 'horizontal' : 'vertical'
dataset.edge = direction
}
return createDOMElement('div', {
classes,
styles,
attributes: {
'aria-label': 'Resize',
},
dataset,
})
}

View File

@ -0,0 +1,97 @@
import type { ResizableNodeViewDirections } from './types.js'
/**
* Options for creating a DOM element
*/
interface CreateDOMElementOptions {
/**
* CSS classes to add to the element
*/
classes?: string[]
/**
* CSS styles to apply to the element
*/
styles?: Record<string, string>
/**
* HTML attributes to set on the element
*/
attributes?: Record<string, string>
/**
* Dataset attributes to set on the element
*/
dataset?: Record<string, string>
/**
* Child elements to append
*/
children?: HTMLElement[]
}
/**
* Creates a DOM element with specified properties and styles
*/
export function createDOMElement(
tag: string,
{ classes = [], styles = {}, attributes = {}, dataset = {}, children = [] }: CreateDOMElementOptions = {},
): HTMLElement {
const element = document.createElement(tag)
// Add CSS classes
if (classes.length) {
element.classList.add(...classes)
}
// Add styles
Object.entries(styles).forEach(([key, value]) => {
element.style[key as any] = value
})
// Add attributes
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value)
})
// Add dataset properties
Object.entries(dataset).forEach(([key, value]) => {
element.dataset[key] = value
})
// Append child elements
children.forEach(child => {
element.appendChild(child)
})
return element
}
/**
* Creates the wrapper element that contains the node and resize handles
*/
export function createWrapper(): HTMLElement {
return createDOMElement('div', {
styles: {
position: 'relative',
display: 'block',
},
dataset: {
resizeWrapper: '',
},
})
}
/**
* Default resize directions configuration
*/
export const defaultDirections: ResizableNodeViewDirections = {
bottom: true,
left: true,
right: true,
top: true,
'top-left': true,
'top-right': true,
'bottom-left': true,
'bottom-right': true,
}

View File

@ -0,0 +1,5 @@
// Export the main helper function
export { createResizableNodeView } from './create-resizable-node-view.js'
// Export types for use in other components
export type * from './types.js'

View File

@ -0,0 +1,197 @@
import type { Node } from '@tiptap/pm/model'
import type { Editor } from '../../Editor.js'
import type { ResizableNodeViewDiagonalDirection, ResizableNodeViewDirection } from './types.js'
// Default minimum dimensions (pixels)
const DEFAULT_MIN_WIDTH = 20
const DEFAULT_MIN_HEIGHT = 20
/**
* Sets up resize event handlers for a handle with size clamping
*/
export function setupResizeHandlers(
element: HTMLElement,
direction: ResizableNodeViewDirection | ResizableNodeViewDiagonalDirection,
dom: HTMLElement,
editor: Editor,
getPos: () => number | undefined,
node: Node,
minWidth = DEFAULT_MIN_WIDTH,
minHeight = DEFAULT_MIN_HEIGHT,
): void {
let isResizing = false
let currentPosition: { x: number; y: number } | null = null
let initialDimensions = {
width: dom.clientWidth,
height: dom.clientHeight,
}
// Determine which dimensions to update based on direction
const updateWidth =
direction === 'left' ||
direction === 'right' ||
direction === 'top-left' ||
direction === 'top-right' ||
direction === 'bottom-left' ||
direction === 'bottom-right'
const updateHeight =
direction === 'top' ||
direction === 'bottom' ||
direction === 'top-left' ||
direction === 'top-right' ||
direction === 'bottom-left' ||
direction === 'bottom-right'
// Determine which side of the element we're affecting
const affectsLeft = direction === 'left' || direction === 'top-left' || direction === 'bottom-left'
const affectsTop = direction === 'top' || direction === 'top-left' || direction === 'top-right'
function updateNodeSize() {
const pos = getPos()
if (pos === undefined) {
return
}
const hasUpdatedWidth = dom.clientWidth !== initialDimensions.width
const hasUpdatedHeight = dom.clientHeight !== initialDimensions.height
editor.commands.command(({ tr }) => {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
width: hasUpdatedWidth ? dom.clientWidth : node.attrs.width,
height: hasUpdatedHeight ? dom.clientHeight : node.attrs.height,
})
return true
})
}
const handleMove = (e: MouseEvent | TouchEvent): void => {
if (!isResizing || !currentPosition) {
return
}
const isAspectLocked = e.shiftKey
e.preventDefault()
e.stopPropagation()
const newPosition = {
x: e instanceof MouseEvent ? e.clientX : e.touches[0].clientX,
y: e instanceof MouseEvent ? e.clientY : e.touches[0].clientY,
}
const deltaX = newPosition.x - currentPosition.x
const deltaY = newPosition.y - currentPosition.y
// Calculate new dimensions based on direction and delta
let newWidth = updateWidth ? dom.clientWidth + (affectsLeft ? -deltaX : deltaX) : dom.clientWidth
let newHeight = updateHeight ? dom.clientHeight + (affectsTop ? -deltaY : deltaY) : dom.clientHeight
// Apply size clamping
newWidth = Math.max(minWidth, newWidth)
newHeight = Math.max(minHeight, newHeight)
// If aspect ratio is locked, adjust the other dimension accordingly
if (isAspectLocked) {
const aspectRatio = initialDimensions.width / initialDimensions.height
if (updateWidth) {
newHeight = Math.round(newWidth / aspectRatio)
} else if (updateHeight) {
newWidth = Math.round(newHeight * aspectRatio)
}
}
const hasUpdatedWidth = newWidth !== dom.clientWidth
const hasUpdatedHeight = newHeight !== dom.clientHeight
// Only update width if it's being modified and is above minimum size
if (hasUpdatedWidth) {
dom.style.width = `${newWidth}px`
}
// Only update height if it's being modified and is above minimum size
if (hasUpdatedHeight) {
dom.style.height = `${newHeight}px`
}
currentPosition = newPosition
}
const handleEnd = (e: MouseEvent | TouchEvent): void => {
if (!isResizing) {
return
}
e.preventDefault()
e.stopPropagation()
updateNodeSize()
isResizing = false
currentPosition = null
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('touchmove', handleMove)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('touchend', handleEnd)
}
const handleKeydown = (e: KeyboardEvent): void => {
// if we press escape, we stop resizing and stop all event handlers
// and also reset the initial dimensions
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
isResizing = false
currentPosition = null
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('touchmove', handleMove)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('touchend', handleEnd)
document.removeEventListener('keydown', handleKeydown)
dom.style.width = `${initialDimensions.width}px`
dom.style.height = `${initialDimensions.height}px`
updateNodeSize()
initialDimensions = {
width: dom.clientWidth,
height: dom.clientHeight,
}
document.removeEventListener('keydown', handleKeydown)
}
}
const handleStart = (e: MouseEvent | TouchEvent): void => {
e.preventDefault()
e.stopPropagation()
initialDimensions = {
width: dom.clientWidth,
height: dom.clientHeight,
}
isResizing = true
currentPosition = {
x: e instanceof MouseEvent ? e.clientX : e.touches[0].clientX,
y: e instanceof MouseEvent ? e.clientY : e.touches[0].clientY,
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('touchmove', handleMove)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('touchend', handleEnd)
document.addEventListener('keydown', handleKeydown)
}
element.addEventListener('mousedown', handleStart)
element.addEventListener('touchstart', handleStart)
}

View File

@ -0,0 +1,52 @@
import type { Node } from '@tiptap/pm/model'
import type { Editor } from '../../Editor.js'
export type ResizableNodeViewDirection = 'top' | 'right' | 'bottom' | 'left'
export type ResizableNodeViewDiagonalDirection = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
export type ResizableNodeViewDirections = {
[key in ResizableNodeViewDirection | ResizableNodeViewDiagonalDirection]: boolean
}
/**
* Options for creating a resizable node view
*/
export interface ResizableNodeViewOptions {
/**
* Which resize handles should be enabled
* @default All directions and corners enabled
*/
directions?: ResizableNodeViewDirections
/**
* The DOM element to make resizable
*/
dom: HTMLElement
/**
* The editor instance
*/
editor: Editor
/**
* Function to get the position of the node
*/
getPos: () => number | undefined
/**
* The node instance
*/
node: Node
/**
* Minimum width in pixels that the node can be resized to
* @default 20
*/
minWidth?: number
/**
* Minimum height in pixels that the node can be resized to
* @default 20
*/
minHeight?: number
}

View File

@ -1,4 +1,10 @@
import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'
import {
type ResizableNodeViewDirections,
createResizableNodeView,
mergeAttributes,
Node,
nodeInputRule,
} from '@tiptap/core'
export interface ImageOptions {
/**
@ -22,6 +28,19 @@ export interface ImageOptions {
* @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:
| {
directions?: ResizableNodeViewDirections
minWidth?: number
minHeight?: number
}
| false
}
export interface SetImageOptions {
@ -65,6 +84,7 @@ export const Image = Node.create<ImageOptions>({
inline: false,
allowBase64: false,
HTMLAttributes: {},
resize: false,
}
},
@ -110,6 +130,50 @@ export const Image = Node.create<ImageOptions>({
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
},
addNodeView() {
if (!this.options.resize) {
return null
}
const { directions, minWidth, minHeight } = this.options.resize
return ({ node, getPos, HTMLAttributes }) => {
const el = document.createElement('img')
Object.entries(HTMLAttributes).forEach(([key, value]) => {
if (value) {
switch (key) {
case 'width':
el.style.width = `${value}px`
break
case 'height':
el.style.height = `${value}px`
break
default:
el.setAttribute(key, value)
break
}
}
})
el.src = HTMLAttributes.src
const resizable = createResizableNodeView({
directions,
minWidth,
minHeight,
dom: el,
editor: this.editor,
getPos,
node,
})
return {
dom: resizable,
}
}
},
addCommands() {
return {
setImage: