mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-11 20:23:36 +08:00
Merge b7a27b1e84
into 0c1e100359
This commit is contained in:
commit
5acf70ac35
5
.changeset/fresh-stingrays-learn.md
Normal file
5
.changeset/fresh-stingrays-learn.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'@tiptap/core': minor
|
||||
---
|
||||
|
||||
Added new `createResizableNodeView` helper function that creates resizable node view elements`
|
7
.changeset/orange-coins-smoke.md
Normal file
7
.changeset/orange-coins-smoke.md
Normal 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.
|
5
.changeset/perfect-mails-drive.md
Normal file
5
.changeset/perfect-mails-drive.md
Normal 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
|
@ -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" />
|
||||
`,
|
||||
})
|
||||
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
/**
|
||||
|
42
packages/core/src/helpers/createResizableNodeView.ts
Normal file
42
packages/core/src/helpers/createResizableNodeView.ts
Normal 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)
|
||||
}
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
103
packages/core/src/helpers/resizable/create-resize-handles.ts
Normal file
103
packages/core/src/helpers/resizable/create-resize-handles.ts
Normal 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,
|
||||
})
|
||||
}
|
97
packages/core/src/helpers/resizable/dom-utils.ts
Normal file
97
packages/core/src/helpers/resizable/dom-utils.ts
Normal 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,
|
||||
}
|
5
packages/core/src/helpers/resizable/index.ts
Normal file
5
packages/core/src/helpers/resizable/index.ts
Normal 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'
|
197
packages/core/src/helpers/resizable/setup-resize-handlers.ts
Normal file
197
packages/core/src/helpers/resizable/setup-resize-handlers.ts
Normal 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)
|
||||
}
|
52
packages/core/src/helpers/resizable/types.ts
Normal file
52
packages/core/src/helpers/resizable/types.ts
Normal 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
|
||||
}
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user