// 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; /** * Maximum dimensions in pixels * @default undefined (no maximum) * * @example * ```ts * max: { width: 1000, height: 800 } * ``` */ max?: Partial; /** * 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; /** 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(); /** * 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, }; } }