mirror of
https://github.com/docmost/docmost.git
synced 2026-05-21 01:04:39 +08:00
7981ef462e
* use local resizable * feat: aduio * support audio imports * feat: use confluence real file names * cleanup * error handling * hide notice * add audio * fix pulse * Fix import and export * unify pulse * hide in readonly mode * keywords * keyword * translations * better sort * feat: PDF embed * cleanup * remove audio menu * open active * hide focus on readonly mode * increase iframe default dimension
1099 lines
31 KiB
TypeScript
1099 lines
31 KiB
TypeScript
// https://github.com/ueberdosis/tiptap/blob/91c51be53c4655ef07e29ec489471524debfa0ca/packages/core/src/lib/ResizableNodeView.ts - MIT
|
|
import type { Node as PMNode } from '@tiptap/pm/model';
|
|
import type { Decoration, DecorationSource, NodeView } from '@tiptap/pm/view';
|
|
import type { Editor } from '@tiptap/core';
|
|
|
|
const isTouchEvent = (e: MouseEvent | TouchEvent): e is TouchEvent => {
|
|
return 'touches' in e;
|
|
};
|
|
|
|
/**
|
|
* Directions where resize handles can be placed
|
|
*
|
|
* @example
|
|
* - `'top'` - Top edge handle
|
|
* - `'bottom-right'` - Bottom-right corner handle
|
|
*/
|
|
export type ResizableNodeViewDirection =
|
|
| 'top'
|
|
| 'right'
|
|
| 'bottom'
|
|
| 'left'
|
|
| 'top-right'
|
|
| 'top-left'
|
|
| 'bottom-right'
|
|
| 'bottom-left';
|
|
|
|
/**
|
|
* Dimensions for the resizable node in pixels
|
|
*/
|
|
export type ResizableNodeDimensions = {
|
|
/** Width in pixels */
|
|
width: number;
|
|
/** Height in pixels */
|
|
height: number;
|
|
};
|
|
|
|
/**
|
|
* Configuration options for creating a ResizableNodeView
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* new ResizableNodeView({
|
|
* element: imgElement,
|
|
* node,
|
|
* getPos,
|
|
* onResize: (width, height) => {
|
|
* imgElement.style.width = `${width}px`
|
|
* imgElement.style.height = `${height}px`
|
|
* },
|
|
* onCommit: (width, height) => {
|
|
* editor.commands.updateAttributes('image', { width, height })
|
|
* },
|
|
* onUpdate: (node) => true,
|
|
* options: {
|
|
* directions: ['bottom-right', 'bottom-left'],
|
|
* min: { width: 100, height: 100 },
|
|
* preserveAspectRatio: true
|
|
* }
|
|
* })
|
|
* ```
|
|
*/
|
|
export type ResizableNodeViewOptions = {
|
|
/**
|
|
* The DOM element to make resizable (e.g., an img, video, or iframe element)
|
|
*/
|
|
element: HTMLElement;
|
|
|
|
/**
|
|
* The DOM element that will hold the editable content element
|
|
*/
|
|
contentElement?: HTMLElement;
|
|
|
|
/**
|
|
* The ProseMirror node instance
|
|
*/
|
|
node: PMNode;
|
|
|
|
/**
|
|
* The Tiptap editor instance
|
|
*/
|
|
editor: Editor;
|
|
|
|
/**
|
|
* Function that returns the current position of the node in the document
|
|
*/
|
|
getPos: () => number | undefined;
|
|
|
|
/**
|
|
* Callback fired continuously during resize with current dimensions.
|
|
* Use this to update the element's visual size in real-time.
|
|
*
|
|
* @param width - Current width in pixels
|
|
* @param height - Current height in pixels
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* onResize: (width, height) => {
|
|
* element.style.width = `${width}px`
|
|
* element.style.height = `${height}px`
|
|
* }
|
|
* ```
|
|
*/
|
|
onResize?: (width: number, height: number) => void;
|
|
|
|
/**
|
|
* Callback fired once when resize completes with final dimensions.
|
|
* Use this to persist the new size to the node's attributes.
|
|
*
|
|
* @param width - Final width in pixels
|
|
* @param height - Final height in pixels
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* onCommit: (width, height) => {
|
|
* const pos = getPos()
|
|
* if (pos !== undefined) {
|
|
* editor.commands.updateAttributes('image', { width, height })
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
onCommit: (width: number, height: number) => void;
|
|
|
|
/**
|
|
* Callback for handling node updates.
|
|
* Return `true` to accept the update, `false` to reject it.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* onUpdate: (node, decorations, innerDecorations) => {
|
|
* if (node.type !== this.node.type) return false
|
|
* return true
|
|
* }
|
|
* ```
|
|
*/
|
|
onUpdate: NodeView['update'];
|
|
|
|
/**
|
|
* Optional configuration for resize behavior and styling
|
|
*/
|
|
options?: {
|
|
/**
|
|
* Which resize handles to display.
|
|
* @default ['bottom-left', 'bottom-right', 'top-left', 'top-right']
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Only show corner handles
|
|
* directions: ['top-left', 'top-right', 'bottom-left', 'bottom-right']
|
|
*
|
|
* // Only show right edge handle
|
|
* directions: ['right']
|
|
* ```
|
|
*/
|
|
directions?: ResizableNodeViewDirection[];
|
|
|
|
/**
|
|
* Minimum dimensions in pixels
|
|
* @default { width: 8, height: 8 }
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* min: { width: 100, height: 50 }
|
|
* ```
|
|
*/
|
|
min?: Partial<ResizableNodeDimensions>;
|
|
|
|
/**
|
|
* Maximum dimensions in pixels
|
|
* @default undefined (no maximum)
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* max: { width: 1000, height: 800 }
|
|
* ```
|
|
*/
|
|
max?: Partial<ResizableNodeDimensions>;
|
|
|
|
/**
|
|
* Always preserve aspect ratio when resizing.
|
|
* When `false`, aspect ratio is preserved only when Shift key is pressed.
|
|
* @default false
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* preserveAspectRatio: true // Always lock aspect ratio
|
|
* ```
|
|
*/
|
|
preserveAspectRatio?: boolean;
|
|
|
|
/**
|
|
* Custom CSS class names for styling
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* className: {
|
|
* container: 'resize-container',
|
|
* wrapper: 'resize-wrapper',
|
|
* handle: 'resize-handle',
|
|
* resizing: 'is-resizing'
|
|
* }
|
|
* ```
|
|
*/
|
|
className?: {
|
|
/** Class for the outer container element */
|
|
container?: string;
|
|
/** Class for the wrapper element that contains the resizable element */
|
|
wrapper?: string;
|
|
/** Class applied to all resize handles */
|
|
handle?: string;
|
|
/** Class added to container while actively resizing */
|
|
resizing?: string;
|
|
};
|
|
|
|
/**
|
|
* Optional callback for creating custom resize handle elements.
|
|
*
|
|
* This function allows developers to define their own handle element
|
|
* (e.g., custom icons, classes, or styles) for a given resize direction.
|
|
* It is called internally for each handle direction.
|
|
*
|
|
* @param direction - The direction of the handle being created (e.g., 'top', 'bottom-right').
|
|
* @returns The custom handle HTMLElement.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* createCustomHandle: (direction) => {
|
|
* const handle = document.createElement('div')
|
|
* handle.dataset.resizeHandle = direction
|
|
* handle.style.position = 'absolute'
|
|
* handle.className = 'tiptap-custom-handle'
|
|
*
|
|
* const isTop = direction.includes('top')
|
|
* const isBottom = direction.includes('bottom')
|
|
* const isLeft = direction.includes('left')
|
|
* const isRight = direction.includes('right')
|
|
*
|
|
* if (isTop) handle.style.top = '0'
|
|
* if (isBottom) handle.style.bottom = '0'
|
|
* if (isLeft) handle.style.left = '0'
|
|
* if (isRight) handle.style.right = '0'
|
|
*
|
|
* // Edge handles span the full width or height
|
|
* if (direction === 'top' || direction === 'bottom') {
|
|
* handle.style.left = '0'
|
|
* handle.style.right = '0'
|
|
* }
|
|
*
|
|
* if (direction === 'left' || direction === 'right') {
|
|
* handle.style.top = '0'
|
|
* handle.style.bottom = '0'
|
|
* }
|
|
*
|
|
* return handle
|
|
* }
|
|
* ```
|
|
*/
|
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A NodeView implementation that adds resize handles to any DOM element.
|
|
*
|
|
* This class creates a resizable node view for Tiptap/ProseMirror editors.
|
|
* It wraps your element with resize handles and manages the resize interaction,
|
|
* including aspect ratio preservation, min/max constraints, and keyboard modifiers.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Basic usage in a Tiptap extension
|
|
* addNodeView() {
|
|
* return ({ node, getPos }) => {
|
|
* const img = document.createElement('img')
|
|
* img.src = node.attrs.src
|
|
*
|
|
* return new ResizableNodeView({
|
|
* element: img,
|
|
* node,
|
|
* getPos,
|
|
* onResize: (width, height) => {
|
|
* img.style.width = `${width}px`
|
|
* img.style.height = `${height}px`
|
|
* },
|
|
* onCommit: (width, height) => {
|
|
* this.editor.commands.updateAttributes('image', { width, height })
|
|
* },
|
|
* onUpdate: () => true,
|
|
* options: {
|
|
* min: { width: 100, height: 100 },
|
|
* preserveAspectRatio: true
|
|
* }
|
|
* })
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
export class ResizableNodeView {
|
|
/** The ProseMirror node instance */
|
|
node: PMNode;
|
|
|
|
/** The Tiptap editor instance */
|
|
editor: Editor;
|
|
|
|
/** The DOM element being made resizable */
|
|
element: HTMLElement;
|
|
|
|
/** The editable DOM element inside the DOM */
|
|
contentElement?: HTMLElement;
|
|
|
|
/** The outer container element (returned as NodeView.dom) */
|
|
container: HTMLElement;
|
|
|
|
/** The wrapper element that contains the element and handles */
|
|
wrapper: HTMLElement;
|
|
|
|
/** Function to get the current node position */
|
|
getPos: () => number | undefined;
|
|
|
|
/** Callback fired during resize */
|
|
onResize?: (width: number, height: number) => void;
|
|
|
|
/** Callback fired when resize completes */
|
|
onCommit: (width: number, height: number) => void;
|
|
|
|
/** Callback for node updates */
|
|
onUpdate?: NodeView['update'];
|
|
|
|
/** Active resize handle directions */
|
|
directions: ResizableNodeViewDirection[] = [
|
|
'bottom-left',
|
|
'bottom-right',
|
|
'top-left',
|
|
'top-right',
|
|
];
|
|
|
|
/** Minimum allowed dimensions */
|
|
minSize: ResizableNodeDimensions = {
|
|
height: 8,
|
|
width: 8,
|
|
};
|
|
|
|
/** Maximum allowed dimensions (optional) */
|
|
maxSize?: Partial<ResizableNodeDimensions>;
|
|
|
|
/** Whether to always preserve aspect ratio */
|
|
preserveAspectRatio: boolean = false;
|
|
|
|
/** CSS class names for elements */
|
|
classNames = {
|
|
container: '',
|
|
wrapper: '',
|
|
handle: '',
|
|
resizing: '',
|
|
};
|
|
|
|
/** Optional callback for creating custom resize handles */
|
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
|
|
|
/** Initial width of the element (for aspect ratio calculation) */
|
|
private initialWidth: number = 0;
|
|
|
|
/** Initial height of the element (for aspect ratio calculation) */
|
|
private initialHeight: number = 0;
|
|
|
|
/** Calculated aspect ratio (width / height) */
|
|
private aspectRatio: number = 1;
|
|
|
|
/** Whether a resize operation is currently active */
|
|
private isResizing: boolean = false;
|
|
|
|
/** The handle currently being dragged */
|
|
private activeHandle: ResizableNodeViewDirection | null = null;
|
|
|
|
/** Starting mouse X position when resize began */
|
|
private startX: number = 0;
|
|
|
|
/** Starting mouse Y position when resize began */
|
|
private startY: number = 0;
|
|
|
|
/** Element width when resize began */
|
|
private startWidth: number = 0;
|
|
|
|
/** Element height when resize began */
|
|
private startHeight: number = 0;
|
|
|
|
/** Whether Shift key is currently pressed (for temporary aspect ratio lock) */
|
|
private isShiftKeyPressed: boolean = false;
|
|
|
|
/** Last known editable state of the editor */
|
|
private lastEditableState: boolean | undefined = undefined;
|
|
|
|
/** Map of handle elements by direction */
|
|
private handleMap = new Map<ResizableNodeViewDirection, HTMLElement>();
|
|
|
|
/**
|
|
* Creates a new ResizableNodeView instance.
|
|
*
|
|
* The constructor sets up the resize handles, applies initial sizing from
|
|
* node attributes, and configures all resize behavior options.
|
|
*
|
|
* @param options - Configuration options for the resizable node view
|
|
*/
|
|
constructor(options: ResizableNodeViewOptions) {
|
|
this.node = options.node;
|
|
this.editor = options.editor;
|
|
this.element = options.element;
|
|
this.contentElement = options.contentElement;
|
|
|
|
this.getPos = options.getPos;
|
|
|
|
this.onResize = options.onResize;
|
|
this.onCommit = options.onCommit;
|
|
this.onUpdate = options.onUpdate;
|
|
|
|
if (options.options?.min) {
|
|
this.minSize = {
|
|
...this.minSize,
|
|
...options.options.min,
|
|
};
|
|
}
|
|
|
|
if (options.options?.max) {
|
|
this.maxSize = options.options.max;
|
|
}
|
|
|
|
if (options?.options?.directions) {
|
|
this.directions = options.options.directions;
|
|
}
|
|
|
|
if (options.options?.preserveAspectRatio) {
|
|
this.preserveAspectRatio = options.options.preserveAspectRatio;
|
|
}
|
|
|
|
if (options.options?.className) {
|
|
this.classNames = {
|
|
container: options.options.className.container || '',
|
|
wrapper: options.options.className.wrapper || '',
|
|
handle: options.options.className.handle || '',
|
|
resizing: options.options.className.resizing || '',
|
|
};
|
|
}
|
|
|
|
if (options.options?.createCustomHandle) {
|
|
this.createCustomHandle = options.options.createCustomHandle;
|
|
}
|
|
|
|
this.wrapper = this.createWrapper();
|
|
this.container = this.createContainer();
|
|
|
|
this.applyInitialSize();
|
|
|
|
if (this.editor.isEditable) {
|
|
this.attachHandles();
|
|
}
|
|
|
|
this.editor.on('update', this.handleEditorUpdate.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Returns the top-level DOM node that should be placed in the editor.
|
|
*
|
|
* This is required by the ProseMirror NodeView interface. The container
|
|
* includes the wrapper, handles, and the actual content element.
|
|
*
|
|
* @returns The container element to be inserted into the editor
|
|
*/
|
|
get dom() {
|
|
return this.container;
|
|
}
|
|
|
|
get contentDOM(): HTMLElement | null {
|
|
return this.contentElement ?? null;
|
|
}
|
|
|
|
private handleEditorUpdate() {
|
|
const isEditable = this.editor.isEditable;
|
|
|
|
// Only if state actually changed
|
|
if (isEditable === this.lastEditableState) {
|
|
return;
|
|
}
|
|
|
|
this.lastEditableState = isEditable;
|
|
|
|
if (!isEditable) {
|
|
this.removeHandles();
|
|
} else if (isEditable && this.handleMap.size === 0) {
|
|
this.attachHandles();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when the node's content or attributes change.
|
|
*
|
|
* Updates the internal node reference. If a custom `onUpdate` callback
|
|
* was provided, it will be called to handle additional update logic.
|
|
*
|
|
* @param node - The new/updated node
|
|
* @param decorations - Node decorations
|
|
* @param innerDecorations - Inner decorations
|
|
* @returns `false` if the node type has changed (requires full rebuild), otherwise the result of `onUpdate` or `true`
|
|
*/
|
|
update(
|
|
node: PMNode,
|
|
decorations: readonly Decoration[],
|
|
innerDecorations: DecorationSource,
|
|
): boolean {
|
|
if (node.type !== this.node.type) {
|
|
return false;
|
|
}
|
|
|
|
this.node = node;
|
|
|
|
if (this.onUpdate) {
|
|
return this.onUpdate(node, decorations, innerDecorations);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Cleanup method called when the node view is being removed.
|
|
*
|
|
* Removes all event listeners to prevent memory leaks. This is required
|
|
* by the ProseMirror NodeView interface. If a resize is active when
|
|
* destroy is called, it will be properly cancelled.
|
|
*/
|
|
destroy() {
|
|
if (this.isResizing) {
|
|
this.container.dataset.resizeState = 'false';
|
|
|
|
if (this.classNames.resizing) {
|
|
this.container.classList.remove(this.classNames.resizing);
|
|
}
|
|
|
|
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
document.removeEventListener('touchmove', this.handleTouchMove);
|
|
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
document.removeEventListener('touchend', this.handleMouseUp);
|
|
window.removeEventListener('blur', this.handleMouseUp);
|
|
document.removeEventListener('keydown', this.handleKeyDown);
|
|
document.removeEventListener('keyup', this.handleKeyUp);
|
|
this.isResizing = false;
|
|
this.activeHandle = null;
|
|
}
|
|
|
|
this.editor.off('update', this.handleEditorUpdate.bind(this));
|
|
|
|
this.container.remove();
|
|
}
|
|
|
|
/**
|
|
* Creates the outer container element.
|
|
*
|
|
* The container is the top-level element returned by the NodeView and
|
|
* wraps the entire resizable node. It's set up with flexbox to handle
|
|
* alignment and includes data attributes for styling and identification.
|
|
*
|
|
* @returns The container element
|
|
*/
|
|
createContainer() {
|
|
const element = document.createElement('div');
|
|
element.dataset.resizeContainer = '';
|
|
element.dataset.node = this.node.type.name;
|
|
element.style.display = 'flex';
|
|
|
|
if (this.classNames.container) {
|
|
element.className = this.classNames.container;
|
|
}
|
|
|
|
element.appendChild(this.wrapper);
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Creates the wrapper element that contains the content and handles.
|
|
*
|
|
* The wrapper uses relative positioning so that resize handles can be
|
|
* positioned absolutely within it. This is the direct parent of the
|
|
* content element being made resizable.
|
|
*
|
|
* @returns The wrapper element
|
|
*/
|
|
createWrapper() {
|
|
const element = document.createElement('div');
|
|
element.style.position = 'relative';
|
|
element.style.display = 'block';
|
|
element.dataset.resizeWrapper = '';
|
|
|
|
if (this.classNames.wrapper) {
|
|
element.className = this.classNames.wrapper;
|
|
}
|
|
|
|
element.appendChild(this.element);
|
|
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* Creates a resize handle element for a specific direction.
|
|
*
|
|
* Each handle is absolutely positioned and includes a data attribute
|
|
* identifying its direction for styling purposes.
|
|
*
|
|
* @param direction - The resize direction for this handle
|
|
* @returns The handle element
|
|
*/
|
|
private createHandle(direction: ResizableNodeViewDirection): HTMLElement {
|
|
const handle = document.createElement('div');
|
|
handle.dataset.resizeHandle = direction;
|
|
handle.style.position = 'absolute';
|
|
|
|
if (this.classNames.handle) {
|
|
handle.className = this.classNames.handle;
|
|
}
|
|
|
|
return handle;
|
|
}
|
|
|
|
/**
|
|
* Positions a handle element according to its direction.
|
|
*
|
|
* Corner handles (e.g., 'top-left') are positioned at the intersection
|
|
* of two edges. Edge handles (e.g., 'top') span the full width or height.
|
|
*
|
|
* @param handle - The handle element to position
|
|
* @param direction - The direction determining the position
|
|
*/
|
|
private positionHandle(
|
|
handle: HTMLElement,
|
|
direction: ResizableNodeViewDirection,
|
|
): void {
|
|
const isTop = direction.includes('top');
|
|
const isBottom = direction.includes('bottom');
|
|
const isLeft = direction.includes('left');
|
|
const isRight = direction.includes('right');
|
|
|
|
if (isTop) {
|
|
handle.style.top = '0';
|
|
}
|
|
|
|
if (isBottom) {
|
|
handle.style.bottom = '0';
|
|
}
|
|
|
|
if (isLeft) {
|
|
handle.style.left = '0';
|
|
}
|
|
|
|
if (isRight) {
|
|
handle.style.right = '0';
|
|
}
|
|
|
|
// Edge handles span the full width or height
|
|
if (direction === 'top' || direction === 'bottom') {
|
|
handle.style.left = '0';
|
|
handle.style.right = '0';
|
|
}
|
|
|
|
if (direction === 'left' || direction === 'right') {
|
|
handle.style.top = '0';
|
|
handle.style.bottom = '0';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates and attaches all resize handles to the wrapper.
|
|
*
|
|
* Iterates through the configured directions, creates a handle for each,
|
|
* positions it, attaches the mousedown listener, and appends it to the DOM.
|
|
*/
|
|
private attachHandles(): void {
|
|
this.directions.forEach((direction) => {
|
|
let handle: HTMLElement;
|
|
|
|
if (this.createCustomHandle) {
|
|
handle = this.createCustomHandle(direction);
|
|
} else {
|
|
handle = this.createHandle(direction);
|
|
}
|
|
|
|
if (!(handle instanceof HTMLElement)) {
|
|
console.warn(
|
|
`[ResizableNodeView] createCustomHandle("${direction}") did not return an HTMLElement. Falling back to default handle.`,
|
|
);
|
|
handle = this.createHandle(direction);
|
|
}
|
|
|
|
if (!this.createCustomHandle) {
|
|
this.positionHandle(handle, direction);
|
|
}
|
|
|
|
handle.addEventListener('mousedown', (event) =>
|
|
this.handleResizeStart(event, direction),
|
|
);
|
|
handle.addEventListener('touchstart', (event) =>
|
|
this.handleResizeStart(event as unknown as MouseEvent, direction),
|
|
);
|
|
|
|
this.handleMap.set(direction, handle);
|
|
|
|
this.wrapper.appendChild(handle);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes all resize handles from the wrapper.
|
|
*
|
|
* Cleans up the handle map and removes each handle element from the DOM.
|
|
*/
|
|
private removeHandles(): void {
|
|
this.handleMap.forEach((el) => el.remove());
|
|
this.handleMap.clear();
|
|
}
|
|
|
|
/**
|
|
* Applies initial sizing from node attributes to the element.
|
|
*
|
|
* If width/height attributes exist on the node, they're applied to the element.
|
|
* Otherwise, the element's natural/current dimensions are measured. The aspect
|
|
* ratio is calculated for later use in aspect-ratio-preserving resizes.
|
|
*/
|
|
private applyInitialSize(): void {
|
|
const width = this.node.attrs.width as number | undefined;
|
|
const height = this.node.attrs.height as number | undefined;
|
|
|
|
if (width) {
|
|
this.element.style.width = `${width}px`;
|
|
this.initialWidth = width;
|
|
} else {
|
|
this.initialWidth = this.element.offsetWidth;
|
|
}
|
|
|
|
if (height) {
|
|
this.element.style.height = `${height}px`;
|
|
this.initialHeight = height;
|
|
} else {
|
|
this.initialHeight = this.element.offsetHeight;
|
|
}
|
|
|
|
// Calculate aspect ratio for use during resizing
|
|
if (this.initialWidth > 0 && this.initialHeight > 0) {
|
|
this.aspectRatio = this.initialWidth / this.initialHeight;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiates a resize operation when a handle is clicked.
|
|
*
|
|
* Captures the starting mouse position and element dimensions, sets up
|
|
* the resize state, adds the resizing class and state attribute, and
|
|
* attaches document-level listeners for mouse movement and keyboard input.
|
|
*
|
|
* @param event - The mouse down event
|
|
* @param direction - The direction of the handle being dragged
|
|
*/
|
|
private handleResizeStart(
|
|
event: MouseEvent | TouchEvent,
|
|
direction: ResizableNodeViewDirection,
|
|
): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Capture initial state
|
|
this.isResizing = true;
|
|
this.activeHandle = direction;
|
|
|
|
if (isTouchEvent(event)) {
|
|
this.startX = event.touches[0].clientX;
|
|
this.startY = event.touches[0].clientY;
|
|
} else {
|
|
this.startX = event.clientX;
|
|
this.startY = event.clientY;
|
|
}
|
|
|
|
this.startWidth = this.element.offsetWidth;
|
|
this.startHeight = this.element.offsetHeight;
|
|
|
|
// Recalculate aspect ratio at resize start for accuracy
|
|
if (this.startWidth > 0 && this.startHeight > 0) {
|
|
this.aspectRatio = this.startWidth / this.startHeight;
|
|
}
|
|
|
|
const pos = this.getPos();
|
|
if (pos !== undefined) {
|
|
// TODO: Select the node in the editor
|
|
}
|
|
|
|
// Update UI state
|
|
this.container.dataset.resizeState = 'true';
|
|
|
|
if (this.classNames.resizing) {
|
|
this.container.classList.add(this.classNames.resizing);
|
|
}
|
|
|
|
// Attach document-level listeners for resize
|
|
document.addEventListener('mousemove', this.handleMouseMove);
|
|
document.addEventListener('touchmove', this.handleTouchMove);
|
|
document.addEventListener('mouseup', this.handleMouseUp);
|
|
document.addEventListener('touchend', this.handleMouseUp);
|
|
window.addEventListener('blur', this.handleMouseUp);
|
|
document.addEventListener('keydown', this.handleKeyDown);
|
|
document.addEventListener('keyup', this.handleKeyUp);
|
|
}
|
|
|
|
/**
|
|
* Handles mouse movement during an active resize.
|
|
*
|
|
* Calculates the delta from the starting position, computes new dimensions
|
|
* based on the active handle direction, applies constraints and aspect ratio,
|
|
* then updates the element's style and calls the onResize callback.
|
|
*
|
|
* @param event - The mouse move event
|
|
*/
|
|
private handleMouseMove = (event: MouseEvent): void => {
|
|
if (!this.isResizing || !this.activeHandle) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = event.clientX - this.startX;
|
|
const deltaY = event.clientY - this.startY;
|
|
|
|
this.handleResize(deltaX, deltaY);
|
|
};
|
|
|
|
private handleTouchMove = (event: TouchEvent): void => {
|
|
if (!this.isResizing || !this.activeHandle) {
|
|
return;
|
|
}
|
|
|
|
const touch = event.touches[0];
|
|
if (!touch) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = touch.clientX - this.startX;
|
|
const deltaY = touch.clientY - this.startY;
|
|
|
|
this.handleResize(deltaX, deltaY);
|
|
};
|
|
|
|
private handleResize(deltaX: number, deltaY: number) {
|
|
if (!this.activeHandle) {
|
|
return;
|
|
}
|
|
|
|
const shouldPreserveAspectRatio =
|
|
this.preserveAspectRatio || this.isShiftKeyPressed;
|
|
const { width, height } = this.calculateNewDimensions(
|
|
this.activeHandle,
|
|
deltaX,
|
|
deltaY,
|
|
);
|
|
const constrained = this.applyConstraints(
|
|
width,
|
|
height,
|
|
shouldPreserveAspectRatio,
|
|
);
|
|
|
|
this.element.style.width = `${constrained.width}px`;
|
|
this.element.style.height = `${constrained.height}px`;
|
|
|
|
if (this.onResize) {
|
|
this.onResize(constrained.width, constrained.height);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Completes the resize operation when the mouse button is released.
|
|
*
|
|
* Captures final dimensions, calls the onCommit callback to persist changes,
|
|
* removes the resizing state and class, and cleans up document-level listeners.
|
|
*/
|
|
private handleMouseUp = (): void => {
|
|
if (!this.isResizing) {
|
|
return;
|
|
}
|
|
|
|
const finalWidth = this.element.offsetWidth;
|
|
const finalHeight = this.element.offsetHeight;
|
|
|
|
this.onCommit(finalWidth, finalHeight);
|
|
|
|
this.isResizing = false;
|
|
this.activeHandle = null;
|
|
|
|
// Remove UI state
|
|
this.container.dataset.resizeState = 'false';
|
|
|
|
if (this.classNames.resizing) {
|
|
this.container.classList.remove(this.classNames.resizing);
|
|
}
|
|
|
|
// Clean up document-level listeners
|
|
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
document.removeEventListener('touchmove', this.handleTouchMove);
|
|
document.removeEventListener('mouseup', this.handleMouseUp);
|
|
document.removeEventListener('touchend', this.handleMouseUp);
|
|
window.removeEventListener('blur', this.handleMouseUp);
|
|
document.removeEventListener('keydown', this.handleKeyDown);
|
|
document.removeEventListener('keyup', this.handleKeyUp);
|
|
};
|
|
|
|
/**
|
|
* Tracks Shift key state to enable temporary aspect ratio locking.
|
|
*
|
|
* When Shift is pressed during resize, aspect ratio is preserved even if
|
|
* preserveAspectRatio is false.
|
|
*
|
|
* @param event - The keyboard event
|
|
*/
|
|
private handleKeyDown = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Shift') {
|
|
this.isShiftKeyPressed = true;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Tracks Shift key release to disable temporary aspect ratio locking.
|
|
*
|
|
* @param event - The keyboard event
|
|
*/
|
|
private handleKeyUp = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Shift') {
|
|
this.isShiftKeyPressed = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Calculates new dimensions based on mouse delta and resize direction.
|
|
*
|
|
* Takes the starting dimensions and applies the mouse movement delta
|
|
* according to the handle direction. For corner handles, both dimensions
|
|
* are affected. For edge handles, only one dimension changes. If aspect
|
|
* ratio should be preserved, delegates to applyAspectRatio.
|
|
*
|
|
* @param direction - The active resize handle direction
|
|
* @param deltaX - Horizontal mouse movement since resize start
|
|
* @param deltaY - Vertical mouse movement since resize start
|
|
* @returns The calculated width and height
|
|
*/
|
|
private calculateNewDimensions(
|
|
direction: ResizableNodeViewDirection,
|
|
deltaX: number,
|
|
deltaY: number,
|
|
): ResizableNodeDimensions {
|
|
let newWidth = this.startWidth;
|
|
let newHeight = this.startHeight;
|
|
|
|
const isRight = direction.includes('right');
|
|
const isLeft = direction.includes('left');
|
|
const isBottom = direction.includes('bottom');
|
|
const isTop = direction.includes('top');
|
|
|
|
// Apply horizontal delta
|
|
if (isRight) {
|
|
newWidth = this.startWidth + deltaX;
|
|
} else if (isLeft) {
|
|
newWidth = this.startWidth - deltaX;
|
|
}
|
|
|
|
// Apply vertical delta
|
|
if (isBottom) {
|
|
newHeight = this.startHeight + deltaY;
|
|
} else if (isTop) {
|
|
newHeight = this.startHeight - deltaY;
|
|
}
|
|
|
|
// For pure horizontal/vertical handles, only one dimension changes
|
|
if (direction === 'right' || direction === 'left') {
|
|
newWidth = this.startWidth + (isRight ? deltaX : -deltaX);
|
|
}
|
|
|
|
if (direction === 'top' || direction === 'bottom') {
|
|
newHeight = this.startHeight + (isBottom ? deltaY : -deltaY);
|
|
}
|
|
|
|
const shouldPreserveAspectRatio =
|
|
this.preserveAspectRatio || this.isShiftKeyPressed;
|
|
|
|
if (shouldPreserveAspectRatio) {
|
|
return this.applyAspectRatio(newWidth, newHeight, direction);
|
|
}
|
|
|
|
return { width: newWidth, height: newHeight };
|
|
}
|
|
|
|
/**
|
|
* Applies min/max constraints to dimensions.
|
|
*
|
|
* When aspect ratio is NOT preserved, constraints are applied independently
|
|
* to width and height. When aspect ratio IS preserved, constraints are
|
|
* applied while maintaining the aspect ratio—if one dimension hits a limit,
|
|
* the other is recalculated proportionally.
|
|
*
|
|
* This ensures that aspect ratio is never broken when constrained.
|
|
*
|
|
* @param width - The unconstrained width
|
|
* @param height - The unconstrained height
|
|
* @param preserveAspectRatio - Whether to maintain aspect ratio while constraining
|
|
* @returns The constrained dimensions
|
|
*/
|
|
private applyConstraints(
|
|
width: number,
|
|
height: number,
|
|
preserveAspectRatio: boolean,
|
|
): ResizableNodeDimensions {
|
|
if (!preserveAspectRatio) {
|
|
// Independent constraints for each dimension
|
|
let constrainedWidth = Math.max(this.minSize.width, width);
|
|
let constrainedHeight = Math.max(this.minSize.height, height);
|
|
|
|
if (this.maxSize?.width) {
|
|
constrainedWidth = Math.min(this.maxSize.width, constrainedWidth);
|
|
}
|
|
|
|
if (this.maxSize?.height) {
|
|
constrainedHeight = Math.min(this.maxSize.height, constrainedHeight);
|
|
}
|
|
|
|
return { width: constrainedWidth, height: constrainedHeight };
|
|
}
|
|
|
|
// Aspect-ratio-aware constraints: adjust both dimensions proportionally
|
|
let constrainedWidth = width;
|
|
let constrainedHeight = height;
|
|
|
|
// Check minimum constraints
|
|
if (constrainedWidth < this.minSize.width) {
|
|
constrainedWidth = this.minSize.width;
|
|
constrainedHeight = constrainedWidth / this.aspectRatio;
|
|
}
|
|
|
|
if (constrainedHeight < this.minSize.height) {
|
|
constrainedHeight = this.minSize.height;
|
|
constrainedWidth = constrainedHeight * this.aspectRatio;
|
|
}
|
|
|
|
// Check maximum constraints
|
|
if (this.maxSize?.width && constrainedWidth > this.maxSize.width) {
|
|
constrainedWidth = this.maxSize.width;
|
|
constrainedHeight = constrainedWidth / this.aspectRatio;
|
|
}
|
|
|
|
if (this.maxSize?.height && constrainedHeight > this.maxSize.height) {
|
|
constrainedHeight = this.maxSize.height;
|
|
constrainedWidth = constrainedHeight * this.aspectRatio;
|
|
}
|
|
|
|
return { width: constrainedWidth, height: constrainedHeight };
|
|
}
|
|
|
|
/**
|
|
* Adjusts dimensions to maintain the original aspect ratio.
|
|
*
|
|
* For horizontal handles (left/right), uses width as the primary dimension
|
|
* and calculates height from it. For vertical handles (top/bottom), uses
|
|
* height as primary and calculates width. For corner handles, uses width
|
|
* as the primary dimension.
|
|
*
|
|
* @param width - The new width
|
|
* @param height - The new height
|
|
* @param direction - The active resize direction
|
|
* @returns Dimensions adjusted to preserve aspect ratio
|
|
*/
|
|
private applyAspectRatio(
|
|
width: number,
|
|
height: number,
|
|
direction: ResizableNodeViewDirection,
|
|
): ResizableNodeDimensions {
|
|
const isHorizontal = direction === 'left' || direction === 'right';
|
|
const isVertical = direction === 'top' || direction === 'bottom';
|
|
|
|
if (isHorizontal) {
|
|
// For horizontal resize, width is primary
|
|
return {
|
|
width,
|
|
height: width / this.aspectRatio,
|
|
};
|
|
}
|
|
|
|
if (isVertical) {
|
|
// For vertical resize, height is primary
|
|
return {
|
|
width: height * this.aspectRatio,
|
|
height,
|
|
};
|
|
}
|
|
|
|
// For corner resize, width is primary
|
|
return {
|
|
width,
|
|
height: width / this.aspectRatio,
|
|
};
|
|
}
|
|
}
|