Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 158 additions & 29 deletions src/ui/LinearLayoutMatrixEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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 | undefined>): 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()
Expand All @@ -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
Expand Down Expand Up @@ -841,51 +955,66 @@ 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()
}

private scheduleCellSizeUpdate(): void {
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
Expand Down