Fix node sizing in vue mode (#6289)
Before  After  Also add ~~content~~ [contain](https://developer.mozilla.org/en-US/docs/Web/CSS/contain) styling for improved render performance. Future: - Update size scaling for WidgetLayoutField widgets. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6289-Fix-node-sizing-in-vue-mode-2986d73d365081ac8fa0da35a635b226) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -8,8 +8,9 @@
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-component-surface',
|
||||
'lg-node absolute rounded-2xl touch-none flex flex-col',
|
||||
'bg-node-component-surface lg-node absolute',
|
||||
'min-h-min min-w-min contain-style contain-layout',
|
||||
'rounded-2xl touch-none flex flex-col',
|
||||
'border-1 border-solid border-node-component-border',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
@@ -83,7 +84,7 @@
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 pb-4"
|
||||
class="flex min-h-min min-w-min flex-1 flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
@@ -150,7 +151,6 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
|
||||
import { useNodeResize } from '../interactions/resize/useNodeResize'
|
||||
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
@@ -269,18 +269,9 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
|
||||
onMounted(() => {
|
||||
// Set initial DOM size from layout store, but respect intrinsic content minimum
|
||||
if (size.value && nodeContainerRef.value && transformState) {
|
||||
const intrinsicMin = calculateIntrinsicSize(
|
||||
nodeContainerRef.value,
|
||||
transformState.camera.z
|
||||
)
|
||||
|
||||
// Use the larger of stored size or intrinsic minimum
|
||||
const finalWidth = Math.max(size.value.width, intrinsicMin.width)
|
||||
const finalHeight = Math.max(size.value.height, intrinsicMin.height)
|
||||
|
||||
nodeContainerRef.value.style.width = `${finalWidth}px`
|
||||
nodeContainerRef.value.style.height = `${finalHeight}px`
|
||||
if (size.value && nodeContainerRef.value) {
|
||||
nodeContainerRef.value.style.width = `${size.value.width}px`
|
||||
nodeContainerRef.value.style.height = `${size.value.height}px`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header p-4 rounded-t-2xl w-full bg-node-component-header-surface text-node-component-header',
|
||||
'lg-node-header p-4 rounded-t-2xl w-full min-w-50',
|
||||
'bg-node-component-header-surface text-node-component-header',
|
||||
collapsed && 'rounded-2xl'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -19,26 +19,6 @@ function applyHandleDelta(
|
||||
}
|
||||
}
|
||||
|
||||
function clampToMinSize(size: Size, minSize: Size): Size {
|
||||
return {
|
||||
width: Math.max(size.width, minSize.width),
|
||||
height: Math.max(size.height, minSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
function snapSize(
|
||||
size: Size,
|
||||
minSize: Size,
|
||||
snapFn?: (size: Size) => Size
|
||||
): Size {
|
||||
if (!snapFn) return size
|
||||
const snapped = snapFn(size)
|
||||
return {
|
||||
width: Math.max(minSize.width, snapped.width),
|
||||
height: Math.max(minSize.height, snapped.height)
|
||||
}
|
||||
}
|
||||
|
||||
function computeAdjustedPosition(
|
||||
startPosition: Point,
|
||||
startSize: Size,
|
||||
@@ -68,20 +48,17 @@ export function computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta,
|
||||
minSize,
|
||||
handle,
|
||||
snapFn
|
||||
}: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
delta: Point
|
||||
minSize: Size
|
||||
handle: ResizeHandleDirection
|
||||
snapFn?: (size: Size) => Size
|
||||
}): { size: Size; position: Point } {
|
||||
const resized = applyHandleDelta(startSize, delta, handle)
|
||||
const clamped = clampToMinSize(resized, minSize)
|
||||
const snapped = snapSize(clamped, minSize, snapFn)
|
||||
const snapped = snapFn?.(resized) ?? resized
|
||||
const position = computeAdjustedPosition(
|
||||
startPosition,
|
||||
startSize,
|
||||
@@ -98,19 +75,16 @@ export function computeResizeOutcome({
|
||||
export function createResizeSession(config: {
|
||||
startSize: Size
|
||||
startPosition: Point
|
||||
minSize: Size
|
||||
handle: ResizeHandleDirection
|
||||
}) {
|
||||
const startSize = { ...config.startSize }
|
||||
const startPosition = { ...config.startPosition }
|
||||
const minSize = { ...config.minSize }
|
||||
const handle = config.handle
|
||||
|
||||
return (delta: Point, snapFn?: (size: Size) => Size) =>
|
||||
computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
minSize,
|
||||
handle,
|
||||
delta,
|
||||
snapFn
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { TransformState } from '@/renderer/core/layout/injectionKeys'
|
||||
import type { Point, Size } from '@/renderer/core/layout/types'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
|
||||
|
||||
import type { ResizeHandleDirection } from './resizeMath'
|
||||
import { createResizeSession, toCanvasDelta } from './resizeMath'
|
||||
@@ -76,8 +75,6 @@ export function useNodeResize(
|
||||
height: rect.height / scale
|
||||
}
|
||||
|
||||
const minSize = calculateIntrinsicSize(nodeElement, scale)
|
||||
|
||||
// Track shift key state and sync to canvas for snap preview
|
||||
const stopShiftSync = trackShiftKey(event)
|
||||
|
||||
@@ -89,7 +86,6 @@ export function useNodeResize(
|
||||
resizeSession.value = createResizeSession({
|
||||
startSize,
|
||||
startPosition: { ...startPosition },
|
||||
minSize,
|
||||
handle
|
||||
})
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { calculateIntrinsicSize } from './calculateIntrinsicSize'
|
||||
|
||||
describe('calculateIntrinsicSize', () => {
|
||||
let element: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a test element
|
||||
element = document.createElement('div')
|
||||
element.style.width = '200px'
|
||||
element.style.height = '100px'
|
||||
document.body.appendChild(element)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(element)
|
||||
})
|
||||
|
||||
it('should calculate intrinsic size and convert to canvas coordinates', () => {
|
||||
// Mock getBoundingClientRect to return specific dimensions
|
||||
const originalGetBoundingClientRect = element.getBoundingClientRect
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const scale = 2
|
||||
const result = calculateIntrinsicSize(element, scale)
|
||||
|
||||
// Should divide by scale to convert from screen to canvas coordinates
|
||||
expect(result).toEqual({
|
||||
width: 150, // 300 / 2
|
||||
height: 75 // 150 / 2
|
||||
})
|
||||
|
||||
element.getBoundingClientRect = originalGetBoundingClientRect
|
||||
})
|
||||
|
||||
it('should restore original size after measuring', () => {
|
||||
const originalWidth = element.style.width
|
||||
const originalHeight = element.style.height
|
||||
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
calculateIntrinsicSize(element, 1)
|
||||
|
||||
// Should restore original styles
|
||||
expect(element.style.width).toBe(originalWidth)
|
||||
expect(element.style.height).toBe(originalHeight)
|
||||
})
|
||||
|
||||
it('should handle scale of 1 correctly', () => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 400,
|
||||
height: 200,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 200,
|
||||
right: 400,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const result = calculateIntrinsicSize(element, 1)
|
||||
|
||||
expect(result).toEqual({
|
||||
width: 400,
|
||||
height: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle fractional scales', () => {
|
||||
element.getBoundingClientRect = () => ({
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
|
||||
const result = calculateIntrinsicSize(element, 0.5)
|
||||
|
||||
expect(result).toEqual({
|
||||
width: 600, // 300 / 0.5
|
||||
height: 300 // 150 / 0.5
|
||||
})
|
||||
})
|
||||
|
||||
it('should temporarily set width and height to auto during measurement', () => {
|
||||
let widthDuringMeasurement = ''
|
||||
let heightDuringMeasurement = ''
|
||||
|
||||
element.getBoundingClientRect = function (this: HTMLElement) {
|
||||
widthDuringMeasurement = this.style.width
|
||||
heightDuringMeasurement = this.style.height
|
||||
return {
|
||||
width: 300,
|
||||
height: 150,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 150,
|
||||
right: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}
|
||||
}
|
||||
|
||||
calculateIntrinsicSize(element, 1)
|
||||
|
||||
// During measurement, styles should be set to 'auto'
|
||||
expect(widthDuringMeasurement).toBe('auto')
|
||||
expect(heightDuringMeasurement).toBe('auto')
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Calculate the intrinsic (minimum content-based) size of a node element
|
||||
*
|
||||
* Temporarily sets the element to auto-size to measure its natural content dimensions,
|
||||
* then converts from screen coordinates to canvas coordinates using the camera scale.
|
||||
*
|
||||
* @param element - The node element to measure
|
||||
* @param scale - Camera zoom scale for coordinate conversion
|
||||
* @returns The intrinsic minimum size in canvas coordinates
|
||||
*/
|
||||
export function calculateIntrinsicSize(
|
||||
element: HTMLElement,
|
||||
scale: number
|
||||
): { width: number; height: number } {
|
||||
// Store original size to restore later
|
||||
const originalWidth = element.style.width
|
||||
const originalHeight = element.style.height
|
||||
|
||||
// Temporarily set to auto to measure natural content size
|
||||
element.style.width = 'auto'
|
||||
element.style.height = 'auto'
|
||||
|
||||
const intrinsicRect = element.getBoundingClientRect()
|
||||
|
||||
// Restore original size
|
||||
element.style.width = originalWidth
|
||||
element.style.height = originalHeight
|
||||
|
||||
// Convert from screen coordinates to canvas coordinates
|
||||
return {
|
||||
width: intrinsicRect.width / scale,
|
||||
height: intrinsicRect.height / scale
|
||||
}
|
||||
}
|
||||
@@ -12,20 +12,20 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[30px] items-center justify-between gap-2 overscroll-contain"
|
||||
class="flex h-[30px] min-w-105 items-center justify-between gap-2 overscroll-contain contain-size"
|
||||
>
|
||||
<div class="relative flex h-6 items-center">
|
||||
<div class="relative flex h-6 min-w-28 shrink-1 items-center">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle w-28 flex-1 truncate text-sm font-normal text-node-component-slot-text"
|
||||
class="lod-toggle flex-1 truncate text-sm font-normal text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="relative min-w-75 grow-1">
|
||||
<div
|
||||
class="lod-toggle w-75 cursor-default"
|
||||
class="lod-toggle cursor-default"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
describe('nodeResizeMath', () => {
|
||||
const startSize = { width: 200, height: 120 }
|
||||
const startPosition = { x: 80, y: 160 }
|
||||
const minSize = { width: 120, height: 80 }
|
||||
|
||||
it('computes resize from bottom-right corner without moving position', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 40, y: 20 },
|
||||
minSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
@@ -29,7 +27,6 @@ describe('nodeResizeMath', () => {
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: -30, y: -20 },
|
||||
minSize,
|
||||
handle: { horizontal: 'left', vertical: 'top' }
|
||||
})
|
||||
|
||||
@@ -37,27 +34,10 @@ describe('nodeResizeMath', () => {
|
||||
expect(outcome.position).toEqual({ x: 50, y: 140 })
|
||||
})
|
||||
|
||||
it('clamps to minimum size when shrinking below intrinsic size', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 500, y: 500 },
|
||||
minSize,
|
||||
handle: { horizontal: 'left', vertical: 'top' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual(minSize)
|
||||
expect(outcome.position).toEqual({
|
||||
x: startPosition.x + (startSize.width - minSize.width),
|
||||
y: startPosition.y + (startSize.height - minSize.height)
|
||||
})
|
||||
})
|
||||
|
||||
it('supports reusable resize sessions with snapping', () => {
|
||||
const session = createResizeSession({
|
||||
startSize,
|
||||
startPosition,
|
||||
minSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
@@ -91,7 +71,6 @@ describe('nodeResizeMath', () => {
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: -50, y: -30 },
|
||||
minSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
@@ -104,7 +83,6 @@ describe('nodeResizeMath', () => {
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 10000, y: 10000 },
|
||||
minSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
@@ -112,49 +90,5 @@ describe('nodeResizeMath', () => {
|
||||
expect(outcome.size.height).toBe(10120)
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
|
||||
it('respects minimum size even with extreme negative deltas', () => {
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: -1000, y: -1000 },
|
||||
minSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual(minSize)
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
|
||||
it('handles minSize larger than startSize', () => {
|
||||
const largeMinSize = { width: 300, height: 200 }
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 10, y: 10 },
|
||||
minSize: largeMinSize,
|
||||
handle: { horizontal: 'right', vertical: 'bottom' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual(largeMinSize)
|
||||
expect(outcome.position).toEqual(startPosition)
|
||||
})
|
||||
|
||||
it('adjusts position correctly when minSize prevents shrinking from top-left', () => {
|
||||
const largeMinSize = { width: 250, height: 150 }
|
||||
const outcome = computeResizeOutcome({
|
||||
startSize,
|
||||
startPosition,
|
||||
delta: { x: 100, y: 100 },
|
||||
minSize: largeMinSize,
|
||||
handle: { horizontal: 'left', vertical: 'top' }
|
||||
})
|
||||
|
||||
expect(outcome.size).toEqual(largeMinSize)
|
||||
expect(outcome.position).toEqual({
|
||||
x: startPosition.x + (startSize.width - largeMinSize.width),
|
||||
y: startPosition.y + (startSize.height - largeMinSize.height)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||