From fcc3a30635fc410c0accd63eafa761627e26a2fc Mon Sep 17 00:00:00 2001 From: leeliu103 Date: Tue, 25 Nov 2025 15:12:54 +0000 Subject: [PATCH] Improve matrix editor resize: viewport awareness, performance, robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 5 critical issues in modal resize functionality: 1. Viewport-aware bounds management - Add window resize listener to recompute effectiveMinSize - Dialog stays within viewport when browser window resizes - Prevents modal from getting stuck off-screen 2. Proper max-size clamping - Respect true maxWidth/maxHeight with 16px margin - Shift or shrink dialog when near viewport edges - No longer forces dialog outside viewport 3. Robust resize state management - Add mouseleave and blur listeners to stop resize - Check event.buttons === 0 in handleResize - Resize reliably stops even if mouseup is lost 4. Smooth performance with RAF batching - Batch cell-size and scrollbar updates into single RAF callback - Eliminate multiple synchronous layout passes - No more jank during fast resize 5. Remove duplicate side-effects - Gate ResizeObserver callback while resizeState is active - Prevent double RAF scheduling - Cleaner control flow All 109 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ui/LinearLayoutMatrixEditor.ts | 187 ++++++++++++++++++++++++----- 1 file changed, 158 insertions(+), 29 deletions(-) diff --git a/src/ui/LinearLayoutMatrixEditor.ts b/src/ui/LinearLayoutMatrixEditor.ts index b29c966..802e6e4 100644 --- a/src/ui/LinearLayoutMatrixEditor.ts +++ b/src/ui/LinearLayoutMatrixEditor.ts @@ -53,6 +53,7 @@ export class LinearLayoutMatrixEditor { private readonly minSize: DialogSize = { width: 500, height: 400 } private readonly defaultSize: DialogSize = { width: 800, height: 600 } private effectiveMinSize: DialogSize = { width: 500, height: 400 } + private readonly viewportMargin = 16 private readonly maxCellSize = 48 private readonly minCellSize = 32 private pendingAutoFitCellSize: number | null = null @@ -71,7 +72,10 @@ export class LinearLayoutMatrixEditor { private resizeObserver: ResizeObserver | null = null private pendingAnimation: number | null = null + private pendingCellMeasurement = false + private pendingScrollbarSync = false private readonly keydownHandler: (event: KeyboardEvent) => void + private readonly viewportResizeHandler: () => void private readonly visibilityListeners = new Set<(isOpen: boolean) => void>() private readonly autoFitClass = 'matrix-auto-fit' private forwardedHoverTarget: HTMLCanvasElement | null = null @@ -220,8 +224,17 @@ export class LinearLayoutMatrixEditor { }) document.addEventListener('mouseup', () => { - this.dragOffset = null - this.resizeState = null + this.endPointerInteractions() + }) + + document.addEventListener('mouseleave', (event) => { + if (!event.relatedTarget) { + this.endPointerInteractions() + } + }) + + window.addEventListener('blur', () => { + this.endPointerInteractions() }) this.keydownHandler = (event: KeyboardEvent) => { @@ -234,8 +247,22 @@ export class LinearLayoutMatrixEditor { } } + this.viewportResizeHandler = () => { + if (!this.isOpen) { + return + } + this.updateEffectiveMinSize() + this.clampDialogWithinViewport() + this.scheduleCellSizeUpdate() + } + + window.addEventListener('resize', this.viewportResizeHandler) + if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver(() => { + if (this.resizeState) { + return + } this.scheduleCellSizeUpdate() }) this.resizeObserver.observe(this.dialog) @@ -774,32 +801,114 @@ export class LinearLayoutMatrixEditor { private applyDialogSize(size: DialogSize): void { this.dialog.style.width = `${size.width}px` this.dialog.style.height = `${size.height}px` + this.updateEffectiveMinSize(size) + } + + private updateEffectiveMinSize(baseSize?: DialogSize): void { + const previous = this.effectiveMinSize + const viewportLimits = this.getViewportLimits() + const selectMin = (values: Array): number => { + const numericValues = values.filter( + (value): value is number => typeof value === 'number' && Number.isFinite(value) + ) + if (numericValues.length === 0) { + return 0 + } + return Math.min(...numericValues) + } + this.effectiveMinSize = { - width: Math.min(size.width, this.minSize.width), - height: Math.min(size.height, this.minSize.height), + width: selectMin([ + this.minSize.width, + viewportLimits.width, + baseSize?.width, + previous.width, + ]), + height: selectMin([ + this.minSize.height, + viewportLimits.height, + baseSize?.height, + previous.height, + ]), + } + } + + private getViewportLimits(): DialogSize { + if (typeof window === 'undefined') { + return { ...this.minSize } + } + const padding = this.viewportMargin * 2 + return { + width: Math.max(window.innerWidth - padding, 0), + height: Math.max(window.innerHeight - padding, 0), } } private positionDialogAtCenter(size: DialogSize): void { - const left = Math.max((window.innerWidth - size.width) / 2, 16) - const top = Math.max((window.innerHeight - size.height) / 2, 16) + if (typeof window === 'undefined') { + this.dialog.style.left = '0px' + this.dialog.style.top = '0px' + this.hasPosition = true + return + } + const left = Math.max((window.innerWidth - size.width) / 2, this.viewportMargin) + const top = Math.max((window.innerHeight - size.height) / 2, this.viewportMargin) this.dialog.style.left = `${left}px` this.dialog.style.top = `${top}px` this.hasPosition = true } private clampDialogWithinViewport(): void { - if (!this.hasPosition) { + if (!this.hasPosition || typeof window === 'undefined') { return } - const maxLeft = Math.max(window.innerWidth - this.dialog.offsetWidth, 0) - const maxTop = Math.max(window.innerHeight - this.dialog.offsetHeight, 0) - const clampedLeft = Math.min(Math.max(this.getDialogLeft(), 0), maxLeft) - const clampedTop = Math.min(Math.max(this.getDialogTop(), 0), maxTop) + + const viewportLimits = this.getViewportLimits() + const currentWidth = this.dialog.offsetWidth + const currentHeight = this.dialog.offsetHeight + let width = Math.max(currentWidth, this.effectiveMinSize.width) + let height = Math.max(currentHeight, this.effectiveMinSize.height) + + if (viewportLimits.width > 0) { + width = Math.min(width, viewportLimits.width) + } + if (viewportLimits.height > 0) { + height = Math.min(height, viewportLimits.height) + } + + if (width !== currentWidth) { + this.dialog.style.width = `${width}px` + } + if (height !== currentHeight) { + this.dialog.style.height = `${height}px` + } + + const clampedLeft = this.clampAxisPosition(this.getDialogLeft(), window.innerWidth, width) + const clampedTop = this.clampAxisPosition(this.getDialogTop(), window.innerHeight, height) this.dialog.style.left = `${clampedLeft}px` this.dialog.style.top = `${clampedTop}px` } + private clampAxisPosition(position: number, viewportSize: number, elementSize: number): number { + const margin = this.viewportMargin + let min = margin + let max = viewportSize - margin - elementSize + + if (max < min) { + const centered = Math.max((viewportSize - elementSize) / 2, 0) + min = centered + max = centered + } + + if (position < min) { + return min + } + if (position > max) { + return max + } + return position + } + private startDrag(event: MouseEvent): void { event.preventDefault() const rect = this.dialog.getBoundingClientRect() @@ -809,6 +918,11 @@ export class LinearLayoutMatrixEditor { } } + private endPointerInteractions(): void { + this.dragOffset = null + this.resizeState = null + } + private handleDrag(event: MouseEvent): void { if (!this.dragOffset) { return @@ -841,26 +955,23 @@ export class LinearLayoutMatrixEditor { if (!this.resizeState) { return } + if (typeof event.buttons === 'number' && event.buttons === 0) { + this.endPointerInteractions() + return + } const deltaX = event.clientX - this.resizeState.startX const deltaY = event.clientY - this.resizeState.startY let width = this.resizeState.width + deltaX let height = this.resizeState.height + deltaY - const currentLeft = this.getDialogLeft() - const currentTop = this.getDialogTop() - - const maxWidth = window.innerWidth - currentLeft - 16 - const maxHeight = window.innerHeight - currentTop - 16 - const minWidth = this.effectiveMinSize.width const minHeight = this.effectiveMinSize.height - const clampedMaxWidth = Math.max(maxWidth, minWidth) - const clampedMaxHeight = Math.max(maxHeight, minHeight) - width = Math.min(Math.max(width, minWidth), clampedMaxWidth) - height = Math.min(Math.max(height, minHeight), clampedMaxHeight) + width = Math.max(width, minWidth) + height = Math.max(height, minHeight) this.dialog.style.width = `${width}px` this.dialog.style.height = `${height}px` + this.clampDialogWithinViewport() this.scheduleCellSizeUpdate() } @@ -868,24 +979,42 @@ export class LinearLayoutMatrixEditor { if (!this.isOpen) { return } + this.pendingCellMeasurement = true + this.pendingScrollbarSync = true const raf = window.requestAnimationFrame?.bind(window) if (!raf) { - this.updateCellSize() + this.flushLayoutUpdates() return } - if (this.pendingAnimation) { - window.cancelAnimationFrame?.(this.pendingAnimation) + if (this.pendingAnimation !== null) { + return } this.pendingAnimation = raf(() => { this.pendingAnimation = null - this.updateCellSize() - // Force a second layout pass to ensure scrollbars appear correctly - raf(() => { - this.forceScrollbarUpdate() - }) + this.flushLayoutUpdates() }) } + private flushLayoutUpdates(): void { + if (!this.isOpen) { + this.pendingCellMeasurement = false + this.pendingScrollbarSync = false + return + } + + const shouldMeasureCells = this.pendingCellMeasurement + const shouldSyncScrollbars = this.pendingScrollbarSync + this.pendingCellMeasurement = false + this.pendingScrollbarSync = false + + if (shouldMeasureCells) { + this.updateCellSize() + } + if (shouldSyncScrollbars) { + this.forceScrollbarUpdate() + } + } + private updateCellSize(): void { if (!this.isOpen || this.rowBits.length === 0 || this.columnBits.length === 0) { return