mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
add dom element resize observer registry for vue node components
This commit is contained in:
@@ -3,8 +3,10 @@ import type { Ref } from 'vue'
|
|||||||
|
|
||||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||||
import { createBounds } from '@/lib/litegraph/src/litegraph'
|
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
import { computeUnionBounds } from '@/utils/mathUtil'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the position of the selection toolbox independently.
|
* Manages the position of the selection toolbox independently.
|
||||||
@@ -34,17 +36,30 @@ export function useSelectionToolboxPosition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
visible.value = true
|
visible.value = true
|
||||||
const bounds = createBounds(selectableItems)
|
|
||||||
|
|
||||||
if (!bounds) {
|
// Get bounds from layout store for all selected items
|
||||||
return
|
const allBounds: ReadOnlyRect[] = []
|
||||||
|
for (const item of selectableItems) {
|
||||||
|
if (typeof item.id === 'string') {
|
||||||
|
const layout = layoutStore.getNodeLayoutRef(item.id).value
|
||||||
|
if (layout) {
|
||||||
|
allBounds.push([
|
||||||
|
layout.bounds.x,
|
||||||
|
layout.bounds.y,
|
||||||
|
layout.bounds.width,
|
||||||
|
layout.bounds.height
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [xBase, y, width] = bounds
|
// Compute union bounds
|
||||||
|
const unionBounds = computeUnionBounds(allBounds)
|
||||||
|
if (!unionBounds) return
|
||||||
|
|
||||||
worldPosition.value = {
|
worldPosition.value = {
|
||||||
x: xBase + width / 2,
|
x: unionBounds.x + unionBounds.width / 2,
|
||||||
y: y
|
y: unionBounds.y
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTransform()
|
updateTransform()
|
||||||
|
|||||||
@@ -1425,6 +1425,33 @@ class LayoutStoreImpl implements LayoutStore {
|
|||||||
getStateAsUpdate(): Uint8Array {
|
getStateAsUpdate(): Uint8Array {
|
||||||
return Y.encodeStateAsUpdate(this.ydoc)
|
return Y.encodeStateAsUpdate(this.ydoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch update node bounds using Yjs transaction for atomicity.
|
||||||
|
*/
|
||||||
|
batchUpdateNodeBounds(
|
||||||
|
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||||
|
): void {
|
||||||
|
if (updates.length === 0) return
|
||||||
|
|
||||||
|
// Set source to Vue for these DOM-driven updates
|
||||||
|
const originalSource = this.currentSource
|
||||||
|
this.currentSource = LayoutSource.Vue
|
||||||
|
|
||||||
|
this.ydoc.transact(() => {
|
||||||
|
for (const { nodeId, bounds } of updates) {
|
||||||
|
const ynode = this.ynodes.get(nodeId)
|
||||||
|
if (!ynode) continue
|
||||||
|
|
||||||
|
this.spatialIndex.update(nodeId, bounds)
|
||||||
|
ynode.set('bounds', bounds)
|
||||||
|
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||||
|
}
|
||||||
|
}, this.currentActor)
|
||||||
|
|
||||||
|
// Restore original source
|
||||||
|
this.currentSource = originalSource
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
|
|||||||
@@ -320,4 +320,9 @@ export interface LayoutStore {
|
|||||||
setActor(actor: string): void
|
setActor(actor: string): void
|
||||||
getCurrentSource(): LayoutSource
|
getCurrentSource(): LayoutSource
|
||||||
getCurrentActor(): string
|
getCurrentActor(): string
|
||||||
|
|
||||||
|
// Batch updates
|
||||||
|
batchUpdateNodeBounds(
|
||||||
|
updates: Array<{ nodeId: NodeId; bounds: Bounds }>
|
||||||
|
): void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayo
|
|||||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
import { useVueElementTracking } from '../composables/useVueNodeResizeTracking'
|
||||||
import NodeContent from './NodeContent.vue'
|
import NodeContent from './NodeContent.vue'
|
||||||
import NodeHeader from './NodeHeader.vue'
|
import NodeHeader from './NodeHeader.vue'
|
||||||
import NodeSlots from './NodeSlots.vue'
|
import NodeSlots from './NodeSlots.vue'
|
||||||
@@ -153,6 +154,8 @@ const emit = defineEmits<{
|
|||||||
'update:title': [nodeId: string, newTitle: string]
|
'update:title': [nodeId: string, newTitle: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
useVueElementTracking(props.nodeData.id, 'node')
|
||||||
|
|
||||||
// Inject selection state from parent
|
// Inject selection state from parent
|
||||||
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
const selectedNodeIds = inject(SelectedNodeIdsKey)
|
||||||
if (!selectedNodeIds) {
|
if (!selectedNodeIds) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Generic Vue Element Tracking System
|
||||||
|
*
|
||||||
|
* Automatically tracks DOM size and position changes for Vue-rendered elements
|
||||||
|
* and syncs them to the layout store. Uses a single shared ResizeObserver for
|
||||||
|
* performance, with elements identified by configurable data attributes.
|
||||||
|
*
|
||||||
|
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||||
|
* customizable data attributes and update handlers.
|
||||||
|
*/
|
||||||
|
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||||
|
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for different types of tracked elements
|
||||||
|
*/
|
||||||
|
interface ElementTrackingConfig {
|
||||||
|
/** Data attribute name (e.g., 'nodeId') */
|
||||||
|
dataAttribute: string
|
||||||
|
/** Handler for processing bounds updates */
|
||||||
|
updateHandler: (updates: Array<{ id: string; bounds: Bounds }>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of tracking configurations by element type
|
||||||
|
*/
|
||||||
|
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
|
||||||
|
[
|
||||||
|
'node',
|
||||||
|
{
|
||||||
|
dataAttribute: 'nodeId',
|
||||||
|
updateHandler: (updates) => {
|
||||||
|
const nodeUpdates = updates.map(({ id, bounds }) => ({
|
||||||
|
nodeId: id as NodeId,
|
||||||
|
bounds
|
||||||
|
}))
|
||||||
|
layoutStore.batchUpdateNodeBounds(nodeUpdates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
// Single ResizeObserver instance for all Vue elements
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
// Group updates by element type
|
||||||
|
const updatesByType = new Map<string, Array<{ id: string; bounds: Bounds }>>()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!(entry.target instanceof HTMLElement)) continue
|
||||||
|
const element = entry.target
|
||||||
|
|
||||||
|
// Find which type this element belongs to
|
||||||
|
let elementType: string | undefined
|
||||||
|
let elementId: string | undefined
|
||||||
|
|
||||||
|
for (const [type, config] of trackingConfigs) {
|
||||||
|
const id = element.dataset[config.dataAttribute]
|
||||||
|
if (id) {
|
||||||
|
elementType = type
|
||||||
|
elementId = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elementType || !elementId) continue
|
||||||
|
|
||||||
|
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
|
||||||
|
const bounds: Bounds = {
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatesByType.has(elementType)) {
|
||||||
|
updatesByType.set(elementType, [])
|
||||||
|
}
|
||||||
|
const updates = updatesByType.get(elementType)
|
||||||
|
if (updates) {
|
||||||
|
updates.push({ id: elementId, bounds })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process updates by type
|
||||||
|
for (const [type, updates] of updatesByType) {
|
||||||
|
const config = trackingConfigs.get(type)
|
||||||
|
if (config && updates.length > 0) {
|
||||||
|
config.updateHandler(updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks DOM element size/position changes for a Vue component and syncs to layout store
|
||||||
|
*
|
||||||
|
* Sets up automatic ResizeObserver tracking when the component mounts and cleans up
|
||||||
|
* when unmounted. The tracked element is identified by a data attribute set on the
|
||||||
|
* component's root DOM element.
|
||||||
|
*
|
||||||
|
* @param appIdentifier - Application-level identifier for this tracked element (not a DOM ID)
|
||||||
|
* Example: node ID like 'node-123', widget ID like 'widget-456'
|
||||||
|
* @param trackingType - Type of element being tracked, determines which tracking config to use
|
||||||
|
* Example: 'node' for Vue nodes, 'widget' for UI widgets
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Track a Vue node component with ID 'my-node-123'
|
||||||
|
* useVueElementTracking('my-node-123', 'node')
|
||||||
|
*
|
||||||
|
* // Would set data-node-id="my-node-123" on the component's root element
|
||||||
|
* // and sync size changes to layoutStore.batchUpdateNodeBounds()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useVueElementTracking(
|
||||||
|
appIdentifier: string,
|
||||||
|
trackingType: string
|
||||||
|
) {
|
||||||
|
onMounted(() => {
|
||||||
|
const element = getCurrentInstance()?.proxy?.$el
|
||||||
|
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||||
|
|
||||||
|
const config = trackingConfigs.get(trackingType)
|
||||||
|
if (config) {
|
||||||
|
// Set the appropriate data attribute
|
||||||
|
element.dataset[config.dataAttribute] = appIdentifier
|
||||||
|
resizeObserver.observe(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
const element = getCurrentInstance()?.proxy?.$el
|
||||||
|
if (!(element instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const config = trackingConfigs.get(trackingType)
|
||||||
|
if (config) {
|
||||||
|
// Remove the data attribute
|
||||||
|
delete element.dataset[config.dataAttribute]
|
||||||
|
resizeObserver.unobserve(element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import type { Bounds } from '@/renderer/core/layout/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the greatest common divisor (GCD) for two numbers.
|
* Finds the greatest common divisor (GCD) for two numbers.
|
||||||
*
|
*
|
||||||
@@ -5,12 +8,12 @@
|
|||||||
* @param b - The second number.
|
* @param b - The second number.
|
||||||
* @returns The GCD of the two numbers.
|
* @returns The GCD of the two numbers.
|
||||||
*/
|
*/
|
||||||
const gcd = (a: number, b: number): number => {
|
export const gcd = (a: number, b: number): number => {
|
||||||
return b === 0 ? a : gcd(b, a % b)
|
return b === 0 ? a : gcd(b, a % b)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the least common multiple (LCM) for two numbers.
|
* Finds the export least common multiple (LCM) for two numbers.
|
||||||
*
|
*
|
||||||
* @param a - The first number.
|
* @param a - The first number.
|
||||||
* @param b - The second number.
|
* @param b - The second number.
|
||||||
@@ -19,3 +22,48 @@ const gcd = (a: number, b: number): number => {
|
|||||||
export const lcm = (a: number, b: number): number => {
|
export const lcm = (a: number, b: number): number => {
|
||||||
return Math.abs(a * b) / gcd(a, b)
|
return Math.abs(a * b) / gcd(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the union (bounding box) of multiple rectangles using a single-pass algorithm.
|
||||||
|
*
|
||||||
|
* Finds the minimum and maximum x/y coordinates across all rectangles to create
|
||||||
|
* a single bounding rectangle that contains all input rectangles. Optimized for
|
||||||
|
* performance with V8-friendly tuple access patterns.
|
||||||
|
*
|
||||||
|
* @param rectangles - Array of rectangle tuples in [x, y, width, height] format
|
||||||
|
* @returns Bounds object with union rectangle, or null if no rectangles provided
|
||||||
|
*/
|
||||||
|
export function computeUnionBounds(
|
||||||
|
rectangles: readonly ReadOnlyRect[]
|
||||||
|
): Bounds | null {
|
||||||
|
const n = rectangles.length
|
||||||
|
if (n === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const r0 = rectangles[0]
|
||||||
|
let minX = r0[0]
|
||||||
|
let minY = r0[1]
|
||||||
|
let maxX = minX + r0[2]
|
||||||
|
let maxY = minY + r0[3]
|
||||||
|
|
||||||
|
for (let i = 1; i < n; i++) {
|
||||||
|
const r = rectangles[i]
|
||||||
|
const x1 = r[0]
|
||||||
|
const y1 = r[1]
|
||||||
|
const x2 = x1 + r[2]
|
||||||
|
const y2 = y1 + r[3]
|
||||||
|
|
||||||
|
if (x1 < minX) minX = x1
|
||||||
|
if (y1 < minY) minY = y1
|
||||||
|
if (x2 > maxX) maxX = x2
|
||||||
|
if (y2 > maxY) maxY = y2
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
|
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||||
|
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
|
||||||
|
() => ({
|
||||||
|
useVueElementTracking: vi.fn()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('@/composables/useErrorHandling', () => ({
|
||||||
|
useErrorHandling: () => ({
|
||||||
|
toastErrorHandler: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||||
|
useNodeLayout: () => ({
|
||||||
|
position: { x: 100, y: 50 },
|
||||||
|
startDrag: vi.fn(),
|
||||||
|
handleDrag: vi.fn(),
|
||||||
|
endDrag: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
|
||||||
|
useLOD: () => ({
|
||||||
|
lodLevel: { value: 0 },
|
||||||
|
shouldRenderWidgets: { value: true },
|
||||||
|
shouldRenderSlots: { value: true },
|
||||||
|
shouldRenderContent: { value: false },
|
||||||
|
lodCssClass: { value: '' }
|
||||||
|
}),
|
||||||
|
LODLevel: { MINIMAL: 0 }
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('LGraphNode', () => {
|
||||||
|
const mockNodeData: VueNodeData = {
|
||||||
|
id: 'test-node-123',
|
||||||
|
title: 'Test Node',
|
||||||
|
type: 'TestNode',
|
||||||
|
mode: 0,
|
||||||
|
flags: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
widgets: [],
|
||||||
|
selected: false,
|
||||||
|
executing: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountLGraphNode = (props: any, selectedNodeIds = new Set()) => {
|
||||||
|
return mount(LGraphNode, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[SelectedNodeIdsKey as symbol]: ref(selectedNodeIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call resize tracking composable with node ID', async () => {
|
||||||
|
const { useVueElementTracking } = vi.mocked(
|
||||||
|
await import(
|
||||||
|
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mountLGraphNode({ nodeData: mockNodeData })
|
||||||
|
|
||||||
|
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render with data-node-id attribute', () => {
|
||||||
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||||
|
|
||||||
|
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render node title', () => {
|
||||||
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Test Node')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply selected styling when selected prop is true', () => {
|
||||||
|
const wrapper = mountLGraphNode(
|
||||||
|
{ nodeData: mockNodeData, selected: true },
|
||||||
|
new Set(['test-node-123'])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('border-blue-500')
|
||||||
|
expect(wrapper.classes()).toContain('ring-2')
|
||||||
|
expect(wrapper.classes()).toContain('ring-blue-300')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply executing animation when executing prop is true', () => {
|
||||||
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('animate-pulse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit node-click event on pointer down', async () => {
|
||||||
|
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
|
||||||
|
|
||||||
|
await wrapper.trigger('pointerdown')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('node-click')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('node-click')?.[0]).toHaveLength(2)
|
||||||
|
expect(wrapper.emitted('node-click')?.[0][1]).toEqual(mockNodeData)
|
||||||
|
})
|
||||||
|
})
|
||||||
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
97
tests-ui/tests/utils/mathUtil.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||||
|
import { computeUnionBounds, gcd, lcm } from '@/utils/mathUtil'
|
||||||
|
|
||||||
|
describe('mathUtil', () => {
|
||||||
|
describe('gcd', () => {
|
||||||
|
it('should compute greatest common divisor correctly', () => {
|
||||||
|
expect(gcd(48, 18)).toBe(6)
|
||||||
|
expect(gcd(100, 25)).toBe(25)
|
||||||
|
expect(gcd(17, 13)).toBe(1)
|
||||||
|
expect(gcd(0, 5)).toBe(5)
|
||||||
|
expect(gcd(5, 0)).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lcm', () => {
|
||||||
|
it('should compute least common multiple correctly', () => {
|
||||||
|
expect(lcm(4, 6)).toBe(12)
|
||||||
|
expect(lcm(15, 20)).toBe(60)
|
||||||
|
expect(lcm(7, 11)).toBe(77)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeUnionBounds', () => {
|
||||||
|
it('should return null for empty input', () => {
|
||||||
|
expect(computeUnionBounds([])).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tests for tuple format (ReadOnlyRect)
|
||||||
|
it('should work with ReadOnlyRect tuple format', () => {
|
||||||
|
const tuples: ReadOnlyRect[] = [
|
||||||
|
[10, 20, 30, 40] as const, // bounds: 10,20 to 40,60
|
||||||
|
[50, 10, 20, 30] as const // bounds: 50,10 to 70,40
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = computeUnionBounds(tuples)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
x: 10, // min(10, 50)
|
||||||
|
y: 10, // min(20, 10)
|
||||||
|
width: 60, // max(40, 70) - min(10, 50) = 70 - 10
|
||||||
|
height: 50 // max(60, 40) - min(20, 10) = 60 - 10
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle single ReadOnlyRect tuple', () => {
|
||||||
|
const tuple: ReadOnlyRect = [10, 20, 30, 40] as const
|
||||||
|
const result = computeUnionBounds([tuple])
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle tuple format with negative dimensions', () => {
|
||||||
|
const tuples: ReadOnlyRect[] = [
|
||||||
|
[100, 50, -20, -10] as const, // x+width=80, y+height=40
|
||||||
|
[90, 45, 15, 20] as const // x+width=105, y+height=65
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = computeUnionBounds(tuples)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
x: 90, // min(100, 90)
|
||||||
|
y: 45, // min(50, 45)
|
||||||
|
width: 15, // max(80, 105) - min(100, 90) = 105 - 90
|
||||||
|
height: 20 // max(40, 65) - min(50, 45) = 65 - 45
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should maintain optimal performance with SoA tuples', () => {
|
||||||
|
// Test that array access is as expected for typical selection sizes
|
||||||
|
const tuples: ReadOnlyRect[] = Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, i) =>
|
||||||
|
[
|
||||||
|
i * 20, // x
|
||||||
|
i * 15, // y
|
||||||
|
100 + i * 5, // width
|
||||||
|
80 + i * 3 // height
|
||||||
|
] as const
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = computeUnionBounds(tuples)
|
||||||
|
|
||||||
|
expect(result).toBeTruthy()
|
||||||
|
expect(result!.x).toBe(0)
|
||||||
|
expect(result!.y).toBe(0)
|
||||||
|
expect(result!.width).toBe(325)
|
||||||
|
expect(result!.height).toBe(242)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user