@tiptap/core 3.9.1 → 3.10.1

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.
@@ -0,0 +1,942 @@
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
+ }
937
+
938
+ /**
939
+ * Alias for ResizableNodeView to maintain consistent naming.
940
+ * @deprecated Use ResizableNodeView instead - will be removed in future versions.
941
+ */
942
+ export const ResizableNodeview = ResizableNodeView