-
Notifications
You must be signed in to change notification settings - Fork 449
allow Vue nodes to be resized from all 4 corners #6187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e983786
cfe0676
78b3ef4
4e1168f
1a6171e
b3fcf8a
68255c9
fc4a43d
b54e919
27222a0
feda4d9
320ede5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| :class=" | ||
| cn( | ||
| 'bg-node-component-surface', | ||
| 'lg-node absolute rounded-2xl touch-none flex flex-col', | ||
| 'lg-node absolute rounded-2xl touch-none flex flex-col group', | ||
| 'border-1 border-solid border-node-component-border', | ||
| // hover (only when node should handle events) | ||
| shouldHandleNodePointerEvents && | ||
|
|
@@ -108,19 +108,25 @@ | |
| </div> | ||
| </template> | ||
|
|
||
| <!-- Resize handle --> | ||
| <div | ||
| v-if="!isCollapsed" | ||
| class="absolute right-0 bottom-0 h-3 w-3 cursor-se-resize opacity-0 transition-opacity duration-200 hover:bg-white hover:opacity-20" | ||
| @pointerdown.stop="startResize" | ||
| /> | ||
| <!-- Resize handles --> | ||
| <template v-if="!isCollapsed"> | ||
| <div | ||
| v-for="handle in cornerResizeHandles" | ||
| :key="handle.id" | ||
| role="button" | ||
| :aria-label="handle.ariaLabel" | ||
| :class="cn(baseResizeHandleClasses, handle.classes)" | ||
| @pointerdown.stop="handleResizePointerDown(handle.direction)($event)" | ||
| /> | ||
| </template> | ||
| </div> | ||
| </template> | ||
|
|
||
| <script setup lang="ts"> | ||
| import { whenever } from '@vueuse/core' | ||
| import { storeToRefs } from 'pinia' | ||
| import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue' | ||
| import { useI18n } from 'vue-i18n' | ||
|
|
||
| import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' | ||
| import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu' | ||
|
|
@@ -147,7 +153,8 @@ import { | |
| } from '@/utils/graphTraversalUtil' | ||
| import { cn } from '@/utils/tailwindUtil' | ||
|
|
||
| import { useNodeResize } from '../composables/useNodeResize' | ||
| 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' | ||
|
|
@@ -165,6 +172,8 @@ interface LGraphNodeProps { | |
|
|
||
| const { nodeData, error = null } = defineProps<LGraphNodeProps>() | ||
|
|
||
| const { t } = useI18n() | ||
|
|
||
| const { | ||
| handleNodeCollapse, | ||
| handleNodeTitleUpdate, | ||
|
|
@@ -243,8 +252,7 @@ onErrorCaptured((error) => { | |
| return false // Prevent error propagation | ||
| }) | ||
|
|
||
| // Use layout system for node position and dragging | ||
| const { position, size, zIndex } = useNodeLayout(() => nodeData.id) | ||
| const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id) | ||
| const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions( | ||
| () => nodeData, | ||
| handleNodeSelect | ||
|
|
@@ -282,19 +290,73 @@ onMounted(() => { | |
| } | ||
| }) | ||
|
|
||
| const baseResizeHandleClasses = | ||
| 'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [performance] low Priority Issue: Resize handles always rendered but invisible by default
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed (handles remain invisible with cursor-only indication) |
||
| const POSITION_EPSILON = 0.01 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [quality] low Priority Issue: Magic number POSITION_EPSILON without explanation
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skipping - unclear rationale for the threshold |
||
|
|
||
| type CornerResizeHandle = { | ||
| id: string | ||
| direction: ResizeHandleDirection | ||
| classes: string | ||
| ariaLabel: string | ||
| } | ||
|
|
||
| const cornerResizeHandles: CornerResizeHandle[] = [ | ||
| { | ||
| id: 'se', | ||
| direction: { horizontal: 'right', vertical: 'bottom' }, | ||
| classes: 'right-0 bottom-0 cursor-se-resize', | ||
| ariaLabel: t('g.resizeFromBottomRight') | ||
| }, | ||
| { | ||
| id: 'ne', | ||
| direction: { horizontal: 'right', vertical: 'top' }, | ||
| classes: 'right-0 top-0 cursor-ne-resize', | ||
| ariaLabel: t('g.resizeFromTopRight') | ||
| }, | ||
| { | ||
| id: 'sw', | ||
| direction: { horizontal: 'left', vertical: 'bottom' }, | ||
| classes: 'left-0 bottom-0 cursor-sw-resize', | ||
| ariaLabel: t('g.resizeFromBottomLeft') | ||
| }, | ||
| { | ||
| id: 'nw', | ||
| direction: { horizontal: 'left', vertical: 'top' }, | ||
| classes: 'left-0 top-0 cursor-nw-resize', | ||
| ariaLabel: t('g.resizeFromTopLeft') | ||
| } | ||
| ] | ||
|
|
||
| const { startResize } = useNodeResize( | ||
| (newSize, element) => { | ||
| // Apply size directly to DOM element - ResizeObserver will pick this up | ||
| (result, element) => { | ||
| if (isCollapsed.value) return | ||
|
|
||
| element.style.width = `${newSize.width}px` | ||
| element.style.height = `${newSize.height}px` | ||
| // Apply size directly to DOM element - ResizeObserver will pick this up | ||
| element.style.width = `${result.size.width}px` | ||
| element.style.height = `${result.size.height}px` | ||
|
|
||
| const currentPosition = position.value | ||
| const deltaX = Math.abs(result.position.x - currentPosition.x) | ||
| const deltaY = Math.abs(result.position.y - currentPosition.y) | ||
|
|
||
| if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) { | ||
| moveNodeTo(result.position) | ||
| } | ||
| }, | ||
| { | ||
| transformState | ||
| } | ||
| ) | ||
|
|
||
| const handleResizePointerDown = (direction: ResizeHandleDirection) => { | ||
| return (event: PointerEvent) => { | ||
| if (nodeData.flags?.pinned) return | ||
|
|
||
| startResize(event, direction, { ...position.value }) | ||
| } | ||
| } | ||
|
|
||
| whenever(isCollapsed, () => { | ||
| const element = nodeContainerRef.value | ||
| if (!element) return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import type { Point, Size } from '@/renderer/core/layout/types' | ||
|
|
||
| export type ResizeHandleDirection = { | ||
| horizontal: 'left' | 'right' | ||
| vertical: 'top' | 'bottom' | ||
| } | ||
|
|
||
| function applyHandleDelta( | ||
| startSize: Size, | ||
| delta: Point, | ||
| handle: ResizeHandleDirection | ||
| ): Size { | ||
| const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1 | ||
| const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1 | ||
|
|
||
| return { | ||
| width: startSize.width + delta.x * horizontalMultiplier, | ||
| height: startSize.height + delta.y * verticalMultiplier | ||
| } | ||
| } | ||
|
|
||
| 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, | ||
| nextSize: Size, | ||
| handle: ResizeHandleDirection | ||
| ): Point { | ||
| const widthDelta = startSize.width - nextSize.width | ||
| const heightDelta = startSize.height - nextSize.height | ||
|
|
||
| return { | ||
| x: | ||
| handle.horizontal === 'left' | ||
| ? startPosition.x + widthDelta | ||
| : startPosition.x, | ||
| y: | ||
| handle.vertical === 'top' | ||
| ? startPosition.y + heightDelta | ||
| : startPosition.y | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Computes the resulting size and position of a node given pointer movement | ||
| * and handle orientation. | ||
| */ | ||
| 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 position = computeAdjustedPosition( | ||
| startPosition, | ||
| startSize, | ||
| snapped, | ||
| handle | ||
| ) | ||
|
|
||
| return { | ||
| size: snapped, | ||
| position | ||
| } | ||
| } | ||
|
|
||
| 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 | ||
| }) | ||
| } | ||
|
|
||
| export function toCanvasDelta( | ||
| startPointer: Point, | ||
| currentPointer: Point, | ||
| scale: number | ||
| ): Point { | ||
| const safeScale = scale === 0 ? 1 : scale | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [quality] low Priority Issue: Redundant division by zero protection
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skipping - only used once, extraction wouldn't add value |
||
| return { | ||
| x: (currentPointer.x - startPointer.x) / safeScale, | ||
| y: (currentPointer.y - startPointer.y) / safeScale | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[quality] medium Priority
Issue: Resize handles missing accessibility attributes
Context: Resize handles lack aria-label or role attributes for screen reader users
Suggestion: Add aria-label describing the resize direction (e.g., "Resize from bottom-right corner")
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
role="button"and localizedaria-labelin b54e919There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hate that they still haven't added a role for handles like this :-(