@tiptap/core 3.9.1 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +540 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +458 -3
- package/dist/index.d.ts +458 -3
- package/dist/index.js +539 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/ExtensionManager.ts +7 -1
- package/src/Node.ts +1 -1
- package/src/index.ts +1 -0
- package/src/lib/ResizableNodeview.ts +936 -0
- package/src/lib/index.ts +1 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import type { Node as PMNode } from '@tiptap/pm/model'
|
|
2
|
+
import type { Decoration, DecorationSource, NodeView } from '@tiptap/pm/view'
|
|
3
|
+
|
|
4
|
+
const isTouchEvent = (e: MouseEvent | TouchEvent): e is TouchEvent => {
|
|
5
|
+
return 'touches' in e
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directions where resize handles can be placed
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* - `'top'` - Top edge handle
|
|
13
|
+
* - `'bottom-right'` - Bottom-right corner handle
|
|
14
|
+
*/
|
|
15
|
+
export type ResizableNodeViewDirection =
|
|
16
|
+
| 'top'
|
|
17
|
+
| 'right'
|
|
18
|
+
| 'bottom'
|
|
19
|
+
| 'left'
|
|
20
|
+
| 'top-right'
|
|
21
|
+
| 'top-left'
|
|
22
|
+
| 'bottom-right'
|
|
23
|
+
| 'bottom-left'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Dimensions for the resizable node in pixels
|
|
27
|
+
*/
|
|
28
|
+
export type ResizableNodeDimensions = {
|
|
29
|
+
/** Width in pixels */
|
|
30
|
+
width: number
|
|
31
|
+
/** Height in pixels */
|
|
32
|
+
height: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configuration options for creating a ResizableNodeview
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* new ResizableNodeview({
|
|
41
|
+
* element: imgElement,
|
|
42
|
+
* node,
|
|
43
|
+
* getPos,
|
|
44
|
+
* onResize: (width, height) => {
|
|
45
|
+
* imgElement.style.width = `${width}px`
|
|
46
|
+
* imgElement.style.height = `${height}px`
|
|
47
|
+
* },
|
|
48
|
+
* onCommit: (width, height) => {
|
|
49
|
+
* editor.commands.updateAttributes('image', { width, height })
|
|
50
|
+
* },
|
|
51
|
+
* onUpdate: (node) => true,
|
|
52
|
+
* options: {
|
|
53
|
+
* directions: ['bottom-right', 'bottom-left'],
|
|
54
|
+
* min: { width: 100, height: 100 },
|
|
55
|
+
* preserveAspectRatio: true
|
|
56
|
+
* }
|
|
57
|
+
* })
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export type ResizableNodeViewOptions = {
|
|
61
|
+
/**
|
|
62
|
+
* The DOM element to make resizable (e.g., an img, video, or iframe element)
|
|
63
|
+
*/
|
|
64
|
+
element: HTMLElement
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The DOM element that will hold the editable content element
|
|
68
|
+
*/
|
|
69
|
+
contentElement?: HTMLElement
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The ProseMirror node instance
|
|
73
|
+
*/
|
|
74
|
+
node: PMNode
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Function that returns the current position of the node in the document
|
|
78
|
+
*/
|
|
79
|
+
getPos: () => number | undefined
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Callback fired continuously during resize with current dimensions.
|
|
83
|
+
* Use this to update the element's visual size in real-time.
|
|
84
|
+
*
|
|
85
|
+
* @param width - Current width in pixels
|
|
86
|
+
* @param height - Current height in pixels
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* onResize: (width, height) => {
|
|
91
|
+
* element.style.width = `${width}px`
|
|
92
|
+
* element.style.height = `${height}px`
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
onResize?: (width: number, height: number) => void
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Callback fired once when resize completes with final dimensions.
|
|
100
|
+
* Use this to persist the new size to the node's attributes.
|
|
101
|
+
*
|
|
102
|
+
* @param width - Final width in pixels
|
|
103
|
+
* @param height - Final height in pixels
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* onCommit: (width, height) => {
|
|
108
|
+
* const pos = getPos()
|
|
109
|
+
* if (pos !== undefined) {
|
|
110
|
+
* editor.commands.updateAttributes('image', { width, height })
|
|
111
|
+
* }
|
|
112
|
+
* }
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
onCommit: (width: number, height: number) => void
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Callback for handling node updates.
|
|
119
|
+
* Return `true` to accept the update, `false` to reject it.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* onUpdate: (node, decorations, innerDecorations) => {
|
|
124
|
+
* if (node.type !== this.node.type) return false
|
|
125
|
+
* return true
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
onUpdate: NodeView['update']
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Optional configuration for resize behavior and styling
|
|
133
|
+
*/
|
|
134
|
+
options?: {
|
|
135
|
+
/**
|
|
136
|
+
* Which resize handles to display.
|
|
137
|
+
* @default ['bottom-left', 'bottom-right', 'top-left', 'top-right']
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* // Only show corner handles
|
|
142
|
+
* directions: ['top-left', 'top-right', 'bottom-left', 'bottom-right']
|
|
143
|
+
*
|
|
144
|
+
* // Only show right edge handle
|
|
145
|
+
* directions: ['right']
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
directions?: ResizableNodeViewDirection[]
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Minimum dimensions in pixels
|
|
152
|
+
* @default { width: 8, height: 8 }
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* min: { width: 100, height: 50 }
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
min?: Partial<ResizableNodeDimensions>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Maximum dimensions in pixels
|
|
163
|
+
* @default undefined (no maximum)
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* max: { width: 1000, height: 800 }
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
max?: Partial<ResizableNodeDimensions>
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Always preserve aspect ratio when resizing.
|
|
174
|
+
* When `false`, aspect ratio is preserved only when Shift key is pressed.
|
|
175
|
+
* @default false
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* preserveAspectRatio: true // Always lock aspect ratio
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
preserveAspectRatio?: boolean
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Custom CSS class names for styling
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```ts
|
|
189
|
+
* className: {
|
|
190
|
+
* container: 'resize-container',
|
|
191
|
+
* wrapper: 'resize-wrapper',
|
|
192
|
+
* handle: 'resize-handle',
|
|
193
|
+
* resizing: 'is-resizing'
|
|
194
|
+
* }
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
className?: {
|
|
198
|
+
/** Class for the outer container element */
|
|
199
|
+
container?: string
|
|
200
|
+
/** Class for the wrapper element that contains the resizable element */
|
|
201
|
+
wrapper?: string
|
|
202
|
+
/** Class applied to all resize handles */
|
|
203
|
+
handle?: string
|
|
204
|
+
/** Class added to container while actively resizing */
|
|
205
|
+
resizing?: string
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A NodeView implementation that adds resize handles to any DOM element.
|
|
212
|
+
*
|
|
213
|
+
* This class creates a resizable node view for Tiptap/ProseMirror editors.
|
|
214
|
+
* It wraps your element with resize handles and manages the resize interaction,
|
|
215
|
+
* including aspect ratio preservation, min/max constraints, and keyboard modifiers.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* // Basic usage in a Tiptap extension
|
|
220
|
+
* addNodeView() {
|
|
221
|
+
* return ({ node, getPos }) => {
|
|
222
|
+
* const img = document.createElement('img')
|
|
223
|
+
* img.src = node.attrs.src
|
|
224
|
+
*
|
|
225
|
+
* return new ResizableNodeview({
|
|
226
|
+
* element: img,
|
|
227
|
+
* node,
|
|
228
|
+
* getPos,
|
|
229
|
+
* onResize: (width, height) => {
|
|
230
|
+
* img.style.width = `${width}px`
|
|
231
|
+
* img.style.height = `${height}px`
|
|
232
|
+
* },
|
|
233
|
+
* onCommit: (width, height) => {
|
|
234
|
+
* this.editor.commands.updateAttributes('image', { width, height })
|
|
235
|
+
* },
|
|
236
|
+
* onUpdate: () => true,
|
|
237
|
+
* options: {
|
|
238
|
+
* min: { width: 100, height: 100 },
|
|
239
|
+
* preserveAspectRatio: true
|
|
240
|
+
* }
|
|
241
|
+
* })
|
|
242
|
+
* }
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export class ResizableNodeview {
|
|
247
|
+
/** The ProseMirror node instance */
|
|
248
|
+
node: PMNode
|
|
249
|
+
|
|
250
|
+
/** The DOM element being made resizable */
|
|
251
|
+
element: HTMLElement
|
|
252
|
+
|
|
253
|
+
/** The editable DOM element inside the DOM */
|
|
254
|
+
contentElement?: HTMLElement
|
|
255
|
+
|
|
256
|
+
/** The outer container element (returned as NodeView.dom) */
|
|
257
|
+
container: HTMLElement
|
|
258
|
+
|
|
259
|
+
/** The wrapper element that contains the element and handles */
|
|
260
|
+
wrapper: HTMLElement
|
|
261
|
+
|
|
262
|
+
/** Function to get the current node position */
|
|
263
|
+
getPos: () => number | undefined
|
|
264
|
+
|
|
265
|
+
/** Callback fired during resize */
|
|
266
|
+
onResize?: (width: number, height: number) => void
|
|
267
|
+
|
|
268
|
+
/** Callback fired when resize completes */
|
|
269
|
+
onCommit: (width: number, height: number) => void
|
|
270
|
+
|
|
271
|
+
/** Callback for node updates */
|
|
272
|
+
onUpdate?: NodeView['update']
|
|
273
|
+
|
|
274
|
+
/** Active resize handle directions */
|
|
275
|
+
directions: ResizableNodeViewDirection[] = ['bottom-left', 'bottom-right', 'top-left', 'top-right']
|
|
276
|
+
|
|
277
|
+
/** Minimum allowed dimensions */
|
|
278
|
+
minSize: ResizableNodeDimensions = {
|
|
279
|
+
height: 8,
|
|
280
|
+
width: 8,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Maximum allowed dimensions (optional) */
|
|
284
|
+
maxSize?: Partial<ResizableNodeDimensions>
|
|
285
|
+
|
|
286
|
+
/** Whether to always preserve aspect ratio */
|
|
287
|
+
preserveAspectRatio: boolean = false
|
|
288
|
+
|
|
289
|
+
/** CSS class names for elements */
|
|
290
|
+
classNames = {
|
|
291
|
+
container: '',
|
|
292
|
+
wrapper: '',
|
|
293
|
+
handle: '',
|
|
294
|
+
resizing: '',
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Initial width of the element (for aspect ratio calculation) */
|
|
298
|
+
private initialWidth: number = 0
|
|
299
|
+
|
|
300
|
+
/** Initial height of the element (for aspect ratio calculation) */
|
|
301
|
+
private initialHeight: number = 0
|
|
302
|
+
|
|
303
|
+
/** Calculated aspect ratio (width / height) */
|
|
304
|
+
private aspectRatio: number = 1
|
|
305
|
+
|
|
306
|
+
/** Whether a resize operation is currently active */
|
|
307
|
+
private isResizing: boolean = false
|
|
308
|
+
|
|
309
|
+
/** The handle currently being dragged */
|
|
310
|
+
private activeHandle: ResizableNodeViewDirection | null = null
|
|
311
|
+
|
|
312
|
+
/** Starting mouse X position when resize began */
|
|
313
|
+
private startX: number = 0
|
|
314
|
+
|
|
315
|
+
/** Starting mouse Y position when resize began */
|
|
316
|
+
private startY: number = 0
|
|
317
|
+
|
|
318
|
+
/** Element width when resize began */
|
|
319
|
+
private startWidth: number = 0
|
|
320
|
+
|
|
321
|
+
/** Element height when resize began */
|
|
322
|
+
private startHeight: number = 0
|
|
323
|
+
|
|
324
|
+
/** Whether Shift key is currently pressed (for temporary aspect ratio lock) */
|
|
325
|
+
private isShiftKeyPressed: boolean = false
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Creates a new ResizableNodeview instance.
|
|
329
|
+
*
|
|
330
|
+
* The constructor sets up the resize handles, applies initial sizing from
|
|
331
|
+
* node attributes, and configures all resize behavior options.
|
|
332
|
+
*
|
|
333
|
+
* @param options - Configuration options for the resizable node view
|
|
334
|
+
*/
|
|
335
|
+
constructor(options: ResizableNodeViewOptions) {
|
|
336
|
+
this.node = options.node
|
|
337
|
+
this.element = options.element
|
|
338
|
+
this.contentElement = options.contentElement
|
|
339
|
+
|
|
340
|
+
this.getPos = options.getPos
|
|
341
|
+
|
|
342
|
+
this.onResize = options.onResize
|
|
343
|
+
this.onCommit = options.onCommit
|
|
344
|
+
this.onUpdate = options.onUpdate
|
|
345
|
+
|
|
346
|
+
if (options.options?.min) {
|
|
347
|
+
this.minSize = {
|
|
348
|
+
...this.minSize,
|
|
349
|
+
...options.options.min,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (options.options?.max) {
|
|
354
|
+
this.maxSize = options.options.max
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (options?.options?.directions) {
|
|
358
|
+
this.directions = options.options.directions
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (options.options?.preserveAspectRatio) {
|
|
362
|
+
this.preserveAspectRatio = options.options.preserveAspectRatio
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (options.options?.className) {
|
|
366
|
+
this.classNames = {
|
|
367
|
+
container: options.options.className.container || '',
|
|
368
|
+
wrapper: options.options.className.wrapper || '',
|
|
369
|
+
handle: options.options.className.handle || '',
|
|
370
|
+
resizing: options.options.className.resizing || '',
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
this.wrapper = this.createWrapper()
|
|
375
|
+
this.container = this.createContainer()
|
|
376
|
+
|
|
377
|
+
this.applyInitialSize()
|
|
378
|
+
this.attachHandles()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Returns the top-level DOM node that should be placed in the editor.
|
|
383
|
+
*
|
|
384
|
+
* This is required by the ProseMirror NodeView interface. The container
|
|
385
|
+
* includes the wrapper, handles, and the actual content element.
|
|
386
|
+
*
|
|
387
|
+
* @returns The container element to be inserted into the editor
|
|
388
|
+
*/
|
|
389
|
+
get dom() {
|
|
390
|
+
return this.container
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
get contentDOM() {
|
|
394
|
+
return this.contentElement
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Called when the node's content or attributes change.
|
|
399
|
+
*
|
|
400
|
+
* Updates the internal node reference. If a custom `onUpdate` callback
|
|
401
|
+
* was provided, it will be called to handle additional update logic.
|
|
402
|
+
*
|
|
403
|
+
* @param node - The new/updated node
|
|
404
|
+
* @param decorations - Node decorations
|
|
405
|
+
* @param innerDecorations - Inner decorations
|
|
406
|
+
* @returns `false` if the node type has changed (requires full rebuild), otherwise the result of `onUpdate` or `true`
|
|
407
|
+
*/
|
|
408
|
+
update(node: PMNode, decorations: readonly Decoration[], innerDecorations: DecorationSource): boolean {
|
|
409
|
+
if (node.type !== this.node.type) {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
this.node = node
|
|
414
|
+
|
|
415
|
+
if (this.onUpdate) {
|
|
416
|
+
return this.onUpdate(node, decorations, innerDecorations)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Cleanup method called when the node view is being removed.
|
|
424
|
+
*
|
|
425
|
+
* Removes all event listeners to prevent memory leaks. This is required
|
|
426
|
+
* by the ProseMirror NodeView interface. If a resize is active when
|
|
427
|
+
* destroy is called, it will be properly cancelled.
|
|
428
|
+
*/
|
|
429
|
+
destroy() {
|
|
430
|
+
if (this.isResizing) {
|
|
431
|
+
this.container.dataset.resizeState = 'false'
|
|
432
|
+
|
|
433
|
+
if (this.classNames.resizing) {
|
|
434
|
+
this.container.classList.remove(this.classNames.resizing)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
document.removeEventListener('mousemove', this.handleMouseMove)
|
|
438
|
+
document.removeEventListener('mouseup', this.handleMouseUp)
|
|
439
|
+
document.removeEventListener('keydown', this.handleKeyDown)
|
|
440
|
+
document.removeEventListener('keyup', this.handleKeyUp)
|
|
441
|
+
this.isResizing = false
|
|
442
|
+
this.activeHandle = null
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.container.remove()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Creates the outer container element.
|
|
450
|
+
*
|
|
451
|
+
* The container is the top-level element returned by the NodeView and
|
|
452
|
+
* wraps the entire resizable node. It's set up with flexbox to handle
|
|
453
|
+
* alignment and includes data attributes for styling and identification.
|
|
454
|
+
*
|
|
455
|
+
* @returns The container element
|
|
456
|
+
*/
|
|
457
|
+
createContainer() {
|
|
458
|
+
const element = document.createElement('div')
|
|
459
|
+
element.dataset.resizeContainer = ''
|
|
460
|
+
element.dataset.node = this.node.type.name
|
|
461
|
+
element.style.display = 'flex'
|
|
462
|
+
element.style.justifyContent = 'flex-start'
|
|
463
|
+
element.style.alignItems = 'flex-start'
|
|
464
|
+
|
|
465
|
+
if (this.classNames.container) {
|
|
466
|
+
element.className = this.classNames.container
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
element.appendChild(this.wrapper)
|
|
470
|
+
|
|
471
|
+
return element
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Creates the wrapper element that contains the content and handles.
|
|
476
|
+
*
|
|
477
|
+
* The wrapper uses relative positioning so that resize handles can be
|
|
478
|
+
* positioned absolutely within it. This is the direct parent of the
|
|
479
|
+
* content element being made resizable.
|
|
480
|
+
*
|
|
481
|
+
* @returns The wrapper element
|
|
482
|
+
*/
|
|
483
|
+
createWrapper() {
|
|
484
|
+
const element = document.createElement('div')
|
|
485
|
+
element.style.position = 'relative'
|
|
486
|
+
element.style.display = 'block'
|
|
487
|
+
element.dataset.resizeWrapper = ''
|
|
488
|
+
|
|
489
|
+
if (this.classNames.wrapper) {
|
|
490
|
+
element.className = this.classNames.wrapper
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
element.appendChild(this.element)
|
|
494
|
+
|
|
495
|
+
return element
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Creates a resize handle element for a specific direction.
|
|
500
|
+
*
|
|
501
|
+
* Each handle is absolutely positioned and includes a data attribute
|
|
502
|
+
* identifying its direction for styling purposes.
|
|
503
|
+
*
|
|
504
|
+
* @param direction - The resize direction for this handle
|
|
505
|
+
* @returns The handle element
|
|
506
|
+
*/
|
|
507
|
+
private createHandle(direction: ResizableNodeViewDirection): HTMLElement {
|
|
508
|
+
const handle = document.createElement('div')
|
|
509
|
+
handle.dataset.resizeHandle = direction
|
|
510
|
+
handle.style.position = 'absolute'
|
|
511
|
+
|
|
512
|
+
if (this.classNames.handle) {
|
|
513
|
+
handle.className = this.classNames.handle
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return handle
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Positions a handle element according to its direction.
|
|
521
|
+
*
|
|
522
|
+
* Corner handles (e.g., 'top-left') are positioned at the intersection
|
|
523
|
+
* of two edges. Edge handles (e.g., 'top') span the full width or height.
|
|
524
|
+
*
|
|
525
|
+
* @param handle - The handle element to position
|
|
526
|
+
* @param direction - The direction determining the position
|
|
527
|
+
*/
|
|
528
|
+
private positionHandle(handle: HTMLElement, direction: ResizableNodeViewDirection): void {
|
|
529
|
+
const isTop = direction.includes('top')
|
|
530
|
+
const isBottom = direction.includes('bottom')
|
|
531
|
+
const isLeft = direction.includes('left')
|
|
532
|
+
const isRight = direction.includes('right')
|
|
533
|
+
|
|
534
|
+
if (isTop) {
|
|
535
|
+
handle.style.top = '0'
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (isBottom) {
|
|
539
|
+
handle.style.bottom = '0'
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (isLeft) {
|
|
543
|
+
handle.style.left = '0'
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (isRight) {
|
|
547
|
+
handle.style.right = '0'
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Edge handles span the full width or height
|
|
551
|
+
if (direction === 'top' || direction === 'bottom') {
|
|
552
|
+
handle.style.left = '0'
|
|
553
|
+
handle.style.right = '0'
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (direction === 'left' || direction === 'right') {
|
|
557
|
+
handle.style.top = '0'
|
|
558
|
+
handle.style.bottom = '0'
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Creates and attaches all resize handles to the wrapper.
|
|
564
|
+
*
|
|
565
|
+
* Iterates through the configured directions, creates a handle for each,
|
|
566
|
+
* positions it, attaches the mousedown listener, and appends it to the DOM.
|
|
567
|
+
*/
|
|
568
|
+
private attachHandles(): void {
|
|
569
|
+
this.directions.forEach(direction => {
|
|
570
|
+
const handle = this.createHandle(direction)
|
|
571
|
+
this.positionHandle(handle, direction)
|
|
572
|
+
handle.addEventListener('mousedown', event => this.handleResizeStart(event, direction))
|
|
573
|
+
handle.addEventListener('touchstart', event => this.handleResizeStart(event as unknown as MouseEvent, direction))
|
|
574
|
+
this.wrapper.appendChild(handle)
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Applies initial sizing from node attributes to the element.
|
|
580
|
+
*
|
|
581
|
+
* If width/height attributes exist on the node, they're applied to the element.
|
|
582
|
+
* Otherwise, the element's natural/current dimensions are measured. The aspect
|
|
583
|
+
* ratio is calculated for later use in aspect-ratio-preserving resizes.
|
|
584
|
+
*/
|
|
585
|
+
private applyInitialSize(): void {
|
|
586
|
+
const width = this.node.attrs.width as number | undefined
|
|
587
|
+
const height = this.node.attrs.height as number | undefined
|
|
588
|
+
|
|
589
|
+
if (width) {
|
|
590
|
+
this.element.style.width = `${width}px`
|
|
591
|
+
this.initialWidth = width
|
|
592
|
+
} else {
|
|
593
|
+
this.initialWidth = this.element.offsetWidth
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (height) {
|
|
597
|
+
this.element.style.height = `${height}px`
|
|
598
|
+
this.initialHeight = height
|
|
599
|
+
} else {
|
|
600
|
+
this.initialHeight = this.element.offsetHeight
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Calculate aspect ratio for use during resizing
|
|
604
|
+
if (this.initialWidth > 0 && this.initialHeight > 0) {
|
|
605
|
+
this.aspectRatio = this.initialWidth / this.initialHeight
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Initiates a resize operation when a handle is clicked.
|
|
611
|
+
*
|
|
612
|
+
* Captures the starting mouse position and element dimensions, sets up
|
|
613
|
+
* the resize state, adds the resizing class and state attribute, and
|
|
614
|
+
* attaches document-level listeners for mouse movement and keyboard input.
|
|
615
|
+
*
|
|
616
|
+
* @param event - The mouse down event
|
|
617
|
+
* @param direction - The direction of the handle being dragged
|
|
618
|
+
*/
|
|
619
|
+
private handleResizeStart(event: MouseEvent | TouchEvent, direction: ResizableNodeViewDirection): void {
|
|
620
|
+
event.preventDefault()
|
|
621
|
+
event.stopPropagation()
|
|
622
|
+
|
|
623
|
+
// Capture initial state
|
|
624
|
+
this.isResizing = true
|
|
625
|
+
this.activeHandle = direction
|
|
626
|
+
|
|
627
|
+
if (isTouchEvent(event)) {
|
|
628
|
+
this.startX = event.touches[0].clientX
|
|
629
|
+
this.startY = event.touches[0].clientY
|
|
630
|
+
} else {
|
|
631
|
+
this.startX = event.clientX
|
|
632
|
+
this.startY = event.clientY
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.startWidth = this.element.offsetWidth
|
|
636
|
+
this.startHeight = this.element.offsetHeight
|
|
637
|
+
|
|
638
|
+
// Recalculate aspect ratio at resize start for accuracy
|
|
639
|
+
if (this.startWidth > 0 && this.startHeight > 0) {
|
|
640
|
+
this.aspectRatio = this.startWidth / this.startHeight
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const pos = this.getPos()
|
|
644
|
+
if (pos !== undefined) {
|
|
645
|
+
// TODO: Select the node in the editor
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Update UI state
|
|
649
|
+
this.container.dataset.resizeState = 'true'
|
|
650
|
+
|
|
651
|
+
if (this.classNames.resizing) {
|
|
652
|
+
this.container.classList.add(this.classNames.resizing)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Attach document-level listeners for resize
|
|
656
|
+
document.addEventListener('mousemove', this.handleMouseMove)
|
|
657
|
+
document.addEventListener('touchmove', this.handleTouchMove)
|
|
658
|
+
document.addEventListener('mouseup', this.handleMouseUp)
|
|
659
|
+
document.addEventListener('keydown', this.handleKeyDown)
|
|
660
|
+
document.addEventListener('keyup', this.handleKeyUp)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Handles mouse movement during an active resize.
|
|
665
|
+
*
|
|
666
|
+
* Calculates the delta from the starting position, computes new dimensions
|
|
667
|
+
* based on the active handle direction, applies constraints and aspect ratio,
|
|
668
|
+
* then updates the element's style and calls the onResize callback.
|
|
669
|
+
*
|
|
670
|
+
* @param event - The mouse move event
|
|
671
|
+
*/
|
|
672
|
+
private handleMouseMove = (event: MouseEvent): void => {
|
|
673
|
+
if (!this.isResizing || !this.activeHandle) {
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const deltaX = event.clientX - this.startX
|
|
678
|
+
const deltaY = event.clientY - this.startY
|
|
679
|
+
|
|
680
|
+
this.handleResize(deltaX, deltaY)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private handleTouchMove = (event: TouchEvent): void => {
|
|
684
|
+
if (!this.isResizing || !this.activeHandle) {
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const touch = event.touches[0]
|
|
689
|
+
if (!touch) {
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const deltaX = touch.clientX - this.startX
|
|
694
|
+
const deltaY = touch.clientY - this.startY
|
|
695
|
+
|
|
696
|
+
this.handleResize(deltaX, deltaY)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private handleResize(deltaX: number, deltaY: number) {
|
|
700
|
+
if (!this.activeHandle) {
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const shouldPreserveAspectRatio = this.preserveAspectRatio || this.isShiftKeyPressed
|
|
705
|
+
const { width, height } = this.calculateNewDimensions(this.activeHandle, deltaX, deltaY)
|
|
706
|
+
const constrained = this.applyConstraints(width, height, shouldPreserveAspectRatio)
|
|
707
|
+
|
|
708
|
+
this.element.style.width = `${constrained.width}px`
|
|
709
|
+
this.element.style.height = `${constrained.height}px`
|
|
710
|
+
|
|
711
|
+
if (this.onResize) {
|
|
712
|
+
this.onResize(constrained.width, constrained.height)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Completes the resize operation when the mouse button is released.
|
|
718
|
+
*
|
|
719
|
+
* Captures final dimensions, calls the onCommit callback to persist changes,
|
|
720
|
+
* removes the resizing state and class, and cleans up document-level listeners.
|
|
721
|
+
*/
|
|
722
|
+
private handleMouseUp = (): void => {
|
|
723
|
+
if (!this.isResizing) {
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const finalWidth = this.element.offsetWidth
|
|
728
|
+
const finalHeight = this.element.offsetHeight
|
|
729
|
+
|
|
730
|
+
this.onCommit(finalWidth, finalHeight)
|
|
731
|
+
|
|
732
|
+
this.isResizing = false
|
|
733
|
+
this.activeHandle = null
|
|
734
|
+
|
|
735
|
+
// Remove UI state
|
|
736
|
+
this.container.dataset.resizeState = 'false'
|
|
737
|
+
|
|
738
|
+
if (this.classNames.resizing) {
|
|
739
|
+
this.container.classList.remove(this.classNames.resizing)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Clean up document-level listeners
|
|
743
|
+
document.removeEventListener('mousemove', this.handleMouseMove)
|
|
744
|
+
document.removeEventListener('mouseup', this.handleMouseUp)
|
|
745
|
+
document.removeEventListener('keydown', this.handleKeyDown)
|
|
746
|
+
document.removeEventListener('keyup', this.handleKeyUp)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Tracks Shift key state to enable temporary aspect ratio locking.
|
|
751
|
+
*
|
|
752
|
+
* When Shift is pressed during resize, aspect ratio is preserved even if
|
|
753
|
+
* preserveAspectRatio is false.
|
|
754
|
+
*
|
|
755
|
+
* @param event - The keyboard event
|
|
756
|
+
*/
|
|
757
|
+
private handleKeyDown = (event: KeyboardEvent): void => {
|
|
758
|
+
if (event.key === 'Shift') {
|
|
759
|
+
this.isShiftKeyPressed = true
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Tracks Shift key release to disable temporary aspect ratio locking.
|
|
765
|
+
*
|
|
766
|
+
* @param event - The keyboard event
|
|
767
|
+
*/
|
|
768
|
+
private handleKeyUp = (event: KeyboardEvent): void => {
|
|
769
|
+
if (event.key === 'Shift') {
|
|
770
|
+
this.isShiftKeyPressed = false
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Calculates new dimensions based on mouse delta and resize direction.
|
|
776
|
+
*
|
|
777
|
+
* Takes the starting dimensions and applies the mouse movement delta
|
|
778
|
+
* according to the handle direction. For corner handles, both dimensions
|
|
779
|
+
* are affected. For edge handles, only one dimension changes. If aspect
|
|
780
|
+
* ratio should be preserved, delegates to applyAspectRatio.
|
|
781
|
+
*
|
|
782
|
+
* @param direction - The active resize handle direction
|
|
783
|
+
* @param deltaX - Horizontal mouse movement since resize start
|
|
784
|
+
* @param deltaY - Vertical mouse movement since resize start
|
|
785
|
+
* @returns The calculated width and height
|
|
786
|
+
*/
|
|
787
|
+
private calculateNewDimensions(
|
|
788
|
+
direction: ResizableNodeViewDirection,
|
|
789
|
+
deltaX: number,
|
|
790
|
+
deltaY: number,
|
|
791
|
+
): ResizableNodeDimensions {
|
|
792
|
+
let newWidth = this.startWidth
|
|
793
|
+
let newHeight = this.startHeight
|
|
794
|
+
|
|
795
|
+
const isRight = direction.includes('right')
|
|
796
|
+
const isLeft = direction.includes('left')
|
|
797
|
+
const isBottom = direction.includes('bottom')
|
|
798
|
+
const isTop = direction.includes('top')
|
|
799
|
+
|
|
800
|
+
// Apply horizontal delta
|
|
801
|
+
if (isRight) {
|
|
802
|
+
newWidth = this.startWidth + deltaX
|
|
803
|
+
} else if (isLeft) {
|
|
804
|
+
newWidth = this.startWidth - deltaX
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Apply vertical delta
|
|
808
|
+
if (isBottom) {
|
|
809
|
+
newHeight = this.startHeight + deltaY
|
|
810
|
+
} else if (isTop) {
|
|
811
|
+
newHeight = this.startHeight - deltaY
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// For pure horizontal/vertical handles, only one dimension changes
|
|
815
|
+
if (direction === 'right' || direction === 'left') {
|
|
816
|
+
newWidth = this.startWidth + (isRight ? deltaX : -deltaX)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (direction === 'top' || direction === 'bottom') {
|
|
820
|
+
newHeight = this.startHeight + (isBottom ? deltaY : -deltaY)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const shouldPreserveAspectRatio = this.preserveAspectRatio || this.isShiftKeyPressed
|
|
824
|
+
|
|
825
|
+
if (shouldPreserveAspectRatio) {
|
|
826
|
+
return this.applyAspectRatio(newWidth, newHeight, direction)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return { width: newWidth, height: newHeight }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Applies min/max constraints to dimensions.
|
|
834
|
+
*
|
|
835
|
+
* When aspect ratio is NOT preserved, constraints are applied independently
|
|
836
|
+
* to width and height. When aspect ratio IS preserved, constraints are
|
|
837
|
+
* applied while maintaining the aspect ratio—if one dimension hits a limit,
|
|
838
|
+
* the other is recalculated proportionally.
|
|
839
|
+
*
|
|
840
|
+
* This ensures that aspect ratio is never broken when constrained.
|
|
841
|
+
*
|
|
842
|
+
* @param width - The unconstrained width
|
|
843
|
+
* @param height - The unconstrained height
|
|
844
|
+
* @param preserveAspectRatio - Whether to maintain aspect ratio while constraining
|
|
845
|
+
* @returns The constrained dimensions
|
|
846
|
+
*/
|
|
847
|
+
private applyConstraints(width: number, height: number, preserveAspectRatio: boolean): ResizableNodeDimensions {
|
|
848
|
+
if (!preserveAspectRatio) {
|
|
849
|
+
// Independent constraints for each dimension
|
|
850
|
+
let constrainedWidth = Math.max(this.minSize.width, width)
|
|
851
|
+
let constrainedHeight = Math.max(this.minSize.height, height)
|
|
852
|
+
|
|
853
|
+
if (this.maxSize?.width) {
|
|
854
|
+
constrainedWidth = Math.min(this.maxSize.width, constrainedWidth)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (this.maxSize?.height) {
|
|
858
|
+
constrainedHeight = Math.min(this.maxSize.height, constrainedHeight)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return { width: constrainedWidth, height: constrainedHeight }
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Aspect-ratio-aware constraints: adjust both dimensions proportionally
|
|
865
|
+
let constrainedWidth = width
|
|
866
|
+
let constrainedHeight = height
|
|
867
|
+
|
|
868
|
+
// Check minimum constraints
|
|
869
|
+
if (constrainedWidth < this.minSize.width) {
|
|
870
|
+
constrainedWidth = this.minSize.width
|
|
871
|
+
constrainedHeight = constrainedWidth / this.aspectRatio
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (constrainedHeight < this.minSize.height) {
|
|
875
|
+
constrainedHeight = this.minSize.height
|
|
876
|
+
constrainedWidth = constrainedHeight * this.aspectRatio
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Check maximum constraints
|
|
880
|
+
if (this.maxSize?.width && constrainedWidth > this.maxSize.width) {
|
|
881
|
+
constrainedWidth = this.maxSize.width
|
|
882
|
+
constrainedHeight = constrainedWidth / this.aspectRatio
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (this.maxSize?.height && constrainedHeight > this.maxSize.height) {
|
|
886
|
+
constrainedHeight = this.maxSize.height
|
|
887
|
+
constrainedWidth = constrainedHeight * this.aspectRatio
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return { width: constrainedWidth, height: constrainedHeight }
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Adjusts dimensions to maintain the original aspect ratio.
|
|
895
|
+
*
|
|
896
|
+
* For horizontal handles (left/right), uses width as the primary dimension
|
|
897
|
+
* and calculates height from it. For vertical handles (top/bottom), uses
|
|
898
|
+
* height as primary and calculates width. For corner handles, uses width
|
|
899
|
+
* as the primary dimension.
|
|
900
|
+
*
|
|
901
|
+
* @param width - The new width
|
|
902
|
+
* @param height - The new height
|
|
903
|
+
* @param direction - The active resize direction
|
|
904
|
+
* @returns Dimensions adjusted to preserve aspect ratio
|
|
905
|
+
*/
|
|
906
|
+
private applyAspectRatio(
|
|
907
|
+
width: number,
|
|
908
|
+
height: number,
|
|
909
|
+
direction: ResizableNodeViewDirection,
|
|
910
|
+
): ResizableNodeDimensions {
|
|
911
|
+
const isHorizontal = direction === 'left' || direction === 'right'
|
|
912
|
+
const isVertical = direction === 'top' || direction === 'bottom'
|
|
913
|
+
|
|
914
|
+
if (isHorizontal) {
|
|
915
|
+
// For horizontal resize, width is primary
|
|
916
|
+
return {
|
|
917
|
+
width,
|
|
918
|
+
height: width / this.aspectRatio,
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (isVertical) {
|
|
923
|
+
// For vertical resize, height is primary
|
|
924
|
+
return {
|
|
925
|
+
width: height * this.aspectRatio,
|
|
926
|
+
height,
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// For corner resize, width is primary
|
|
931
|
+
return {
|
|
932
|
+
width,
|
|
933
|
+
height: width / this.aspectRatio,
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|