mirror of
https://github.com/ueberdosis/tiptap.git
synced 2025-06-14 05:52:47 +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 () => {
|
export default () => {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [Document, Paragraph, Text, Image, Dropcursor],
|
extensions: [Document, Paragraph, Text, Image.configure({ resize: { minWidth: 100, minHeight: 100 } }), Dropcursor],
|
||||||
content: `
|
content: `
|
||||||
<p>This is a basic example of implementing images. Drag to re-order.</p>
|
<p>This is a basic example of implementing images. Drag to re-order.</p>
|
||||||
<img src="https://placehold.co/800x400" />
|
<img src="https://unsplash.it/seed/tiptap/800/400" />
|
||||||
<img src="https://placehold.co/800x400/6A00F5/white" />
|
<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')
|
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 {
|
img {
|
||||||
display: block;
|
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;
|
margin: 1.5rem 0;
|
||||||
max-width: 100%;
|
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%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,4 +21,41 @@ context('/src/Nodes/Image/Vue/', () => {
|
|||||||
cy.get('.tiptap').find('img').should('have.attr', 'src', 'foobar.png')
|
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() {
|
mounted() {
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
extensions: [Document, Paragraph, Text, Image, Dropcursor],
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Image.configure({ resize: { minWidth: 100, minHeight: 100 } }),
|
||||||
|
Dropcursor,
|
||||||
|
],
|
||||||
content: `
|
content: `
|
||||||
<p>This is a basic example of implementing images. Drag to re-order.</p>
|
<p>This is a basic example of implementing images. Drag to re-order.</p>
|
||||||
<img src="https://placehold.co/800x400" />
|
<img src="https://unsplash.it/seed/tiptap/800/400" />
|
||||||
<img src="https://placehold.co/800x400/6A00F5/white" />
|
<img src="https://unsplash.it/seed/tiptap-2/800/400" />
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -64,13 +70,62 @@ export default {
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
display: block;
|
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;
|
margin: 1.5rem 0;
|
||||||
max-width: 100%;
|
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>
|
</style>
|
||||||
|
@ -206,10 +206,16 @@ export class ExtensionManager {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nodeViewResult = addNodeView()
|
||||||
|
|
||||||
|
if (!nodeViewResult) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const nodeview: NodeViewConstructor = (node, view, getPos, decorations, innerDecorations) => {
|
const nodeview: NodeViewConstructor = (node, view, getPos, decorations, innerDecorations) => {
|
||||||
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
|
const HTMLAttributes = getRenderedAttributes(node, extensionAttributes)
|
||||||
|
|
||||||
return addNodeView()({
|
return nodeViewResult({
|
||||||
// pass-through
|
// pass-through
|
||||||
node,
|
node,
|
||||||
view,
|
view,
|
||||||
|
@ -18,7 +18,7 @@ export interface NodeConfig<Options = any, Storage = any>
|
|||||||
editor: Editor
|
editor: Editor
|
||||||
type: NodeType
|
type: NodeType
|
||||||
parent: ParentConfig<NodeConfig<Options, Storage>>['addNodeView']
|
parent: ParentConfig<NodeConfig<Options, Storage>>['addNodeView']
|
||||||
}) => NodeViewRenderer)
|
}) => NodeViewRenderer | null)
|
||||||
| 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 './createChainableState.js'
|
||||||
export * from './createDocument.js'
|
export * from './createDocument.js'
|
||||||
export * from './createNodeFromContent.js'
|
export * from './createNodeFromContent.js'
|
||||||
|
export * from './createResizableNodeView.js'
|
||||||
export * from './defaultBlockAt.js'
|
export * from './defaultBlockAt.js'
|
||||||
export * from './findChildren.js'
|
export * from './findChildren.js'
|
||||||
export * from './findChildrenInRange.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 {
|
export interface ImageOptions {
|
||||||
/**
|
/**
|
||||||
@ -22,6 +28,19 @@ export interface ImageOptions {
|
|||||||
* @example { class: 'foo' }
|
* @example { class: 'foo' }
|
||||||
*/
|
*/
|
||||||
HTMLAttributes: Record<string, any>
|
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 {
|
export interface SetImageOptions {
|
||||||
@ -65,6 +84,7 @@ export const Image = Node.create<ImageOptions>({
|
|||||||
inline: false,
|
inline: false,
|
||||||
allowBase64: false,
|
allowBase64: false,
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
|
resize: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -110,6 +130,50 @@ export const Image = Node.create<ImageOptions>({
|
|||||||
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
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() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setImage:
|
setImage:
|
||||||
|
Loading…
Reference in New Issue
Block a user