mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-22 15:54:09 +00:00
Merge branch 'main' into vue-nodes/feat/skeleton-missing-nodes
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
v-for="nodeData in nodesToRender"
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
@@ -183,12 +183,12 @@ const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const nodesToRender = viewportCulling.nodesToRender
|
||||
const allNodes = viewportCulling.allNodes
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate(
|
||||
vueNodeLifecycle.detectChangesInRAF.value
|
||||
)
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
}
|
||||
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/**
|
||||
* Viewport Culling Composable
|
||||
* Vue Nodes Viewport Culling
|
||||
*
|
||||
* Handles viewport culling optimization for Vue nodes including:
|
||||
* - Transform state synchronization
|
||||
* - Visible node calculation with screen space transforms
|
||||
* - Adaptive margin computation based on zoom level
|
||||
* - Performance optimizations for large graphs
|
||||
* Principles:
|
||||
* 1. Query DOM directly using data attributes (no cache to maintain)
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { type Ref, computed, readonly, ref } from 'vue'
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useTransformState } from '@/renderer/core/layout/useTransformState'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
@@ -25,188 +23,84 @@ export function useViewportCulling(
|
||||
nodeManager: Ref<NodeManager | null>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
// Transform tracking for performance optimization
|
||||
const lastScale = ref(1)
|
||||
const lastOffsetX = ref(0)
|
||||
const lastOffsetY = ref(0)
|
||||
|
||||
// Current transform state
|
||||
const currentTransformState = computed(() => ({
|
||||
scale: lastScale.value,
|
||||
offsetX: lastOffsetX.value,
|
||||
offsetY: lastOffsetY.value
|
||||
}))
|
||||
|
||||
/**
|
||||
* Computed property that returns nodes visible in the current viewport
|
||||
* Implements sophisticated culling algorithm with adaptive margins
|
||||
*/
|
||||
const nodesToRender = computed(() => {
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = Array.from(vueNodeData.value.values())
|
||||
|
||||
// Apply viewport culling - check if node bounds intersect with viewport
|
||||
// TODO: use quadtree
|
||||
if (nodeManager.value && canvasStore.canvas && comfyApp.canvas) {
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager.value
|
||||
|
||||
// Ensure transform is synced before checking visibility
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
|
||||
const ds = canvas.ds
|
||||
|
||||
// Work in screen space - viewport is simply the canvas element size
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
|
||||
// Add margin that represents a constant distance in canvas space
|
||||
// Convert canvas units to screen pixels by multiplying by scale
|
||||
const canvasMarginDistance = 200 // Fixed margin in canvas units
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const filtered = allNodes.filter((nodeData) => {
|
||||
const node = manager.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
// Transform node position to screen space (same as DOM widgets)
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
// Check if node bounds intersect with expanded viewport (in screen space)
|
||||
const isVisible = !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
|
||||
return isVisible
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return allNodes
|
||||
const allNodes = computed(() => {
|
||||
if (!isVueNodesEnabled.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle transform updates with performance optimization
|
||||
* Only syncs when transform actually changes to avoid unnecessary reflows
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
const handleTransformUpdate = (detectChangesInRAF: () => void) => {
|
||||
// Skip all work if Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync transform state only when it changes (avoids reflows)
|
||||
if (comfyApp.canvas?.ds) {
|
||||
const currentScale = comfyApp.canvas.ds.scale
|
||||
const currentOffsetX = comfyApp.canvas.ds.offset[0]
|
||||
const currentOffsetY = comfyApp.canvas.ds.offset[1]
|
||||
|
||||
if (
|
||||
currentScale !== lastScale.value ||
|
||||
currentOffsetX !== lastOffsetX.value ||
|
||||
currentOffsetY !== lastOffsetY.value
|
||||
) {
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
lastScale.value = currentScale
|
||||
lastOffsetX.value = currentOffsetX
|
||||
lastOffsetY.value = currentOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Detect node changes during transform updates
|
||||
detectChangesInRAF()
|
||||
|
||||
// Trigger reactivity for nodesToRender
|
||||
void nodesToRender.value.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate if a specific node is visible in viewport
|
||||
* Useful for individual node visibility checks
|
||||
*/
|
||||
const isNodeVisible = (nodeData: VueNodeData): boolean => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) {
|
||||
return true // Default to visible if culling not available
|
||||
}
|
||||
const updateVisibility = () => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const node = nodeManager.value.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
const manager = nodeManager.value
|
||||
const ds = canvas.ds
|
||||
|
||||
// Viewport bounds
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
const canvasMarginDistance = 200
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
const margin = 500 * ds.scale
|
||||
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
// Get all node elements at once
|
||||
const nodeElements = document.querySelectorAll('[data-node-id]')
|
||||
|
||||
return !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
// Update each element's visibility
|
||||
for (const element of nodeElements) {
|
||||
const nodeId = element.getAttribute('data-node-id')
|
||||
if (!nodeId) continue
|
||||
|
||||
const node = manager.getNode(nodeId)
|
||||
if (!node) continue
|
||||
|
||||
// Calculate if node is outside viewport
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
const isNodeOutsideViewport =
|
||||
screen_x + screen_width < -margin ||
|
||||
screen_x > viewport_width + margin ||
|
||||
screen_y + screen_height < -margin ||
|
||||
screen_y > viewport_height + margin
|
||||
|
||||
// Setting display none directly avoid potential cascade resolution
|
||||
if (element instanceof HTMLElement) {
|
||||
element.style.display = isNodeOutsideViewport ? 'none' : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
let rafId: number | null = null
|
||||
|
||||
/**
|
||||
* Get viewport bounds information for debugging
|
||||
* Handle transform update - called by TransformPane event
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
*/
|
||||
const getViewportInfo = () => {
|
||||
if (!canvasStore.canvas || !comfyApp.canvas) {
|
||||
return null
|
||||
const handleTransformUpdate = () => {
|
||||
if (!isVueNodesEnabled.value) return
|
||||
|
||||
// Cancel previous RAF if still pending
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
return {
|
||||
viewport_width: canvas.canvas.width,
|
||||
viewport_height: canvas.canvas.height,
|
||||
scale: ds.scale,
|
||||
offset: [ds.offset[0], ds.offset[1]],
|
||||
margin_distance: 200,
|
||||
margin_x: 200 * ds.scale,
|
||||
margin_y: 200 * ds.scale
|
||||
}
|
||||
// Schedule update in next animation frame
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updateVisibility()
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
nodesToRender,
|
||||
allNodes,
|
||||
handleTransformUpdate,
|
||||
isNodeVisible,
|
||||
getViewportInfo,
|
||||
|
||||
// Transform state
|
||||
currentTransformState: readonly(currentTransformState),
|
||||
lastScale: readonly(lastScale),
|
||||
lastOffsetX: readonly(lastOffsetX),
|
||||
lastOffsetY: readonly(lastOffsetY)
|
||||
updateVisibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,19 @@ import {
|
||||
type Point,
|
||||
type RerouteId,
|
||||
type RerouteLayout,
|
||||
type Size,
|
||||
type SlotLayout
|
||||
} from '@/renderer/core/layout/types'
|
||||
import {
|
||||
REROUTE_RADIUS,
|
||||
boundsIntersect,
|
||||
pointInBounds
|
||||
} from '@/renderer/core/layout/utils/layoutMath'
|
||||
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
|
||||
import {
|
||||
type NodeLayoutMap,
|
||||
layoutToYNode,
|
||||
yNodeToLayout
|
||||
} from '@/renderer/core/layout/utils/mappers'
|
||||
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
|
||||
|
||||
type YEventChange = {
|
||||
@@ -48,9 +58,6 @@ type YEventChange = {
|
||||
|
||||
const logger = log.getLogger('LayoutStore')
|
||||
|
||||
// Constants
|
||||
const REROUTE_RADIUS = 8
|
||||
|
||||
// Utility functions
|
||||
function asRerouteId(id: string | number): RerouteId {
|
||||
return Number(id)
|
||||
@@ -60,15 +67,6 @@ function asLinkId(id: string | number): LinkId {
|
||||
return Number(id)
|
||||
}
|
||||
|
||||
interface NodeLayoutData {
|
||||
id: NodeId
|
||||
position: Point
|
||||
size: Size
|
||||
zIndex: number
|
||||
visible: boolean
|
||||
bounds: Bounds
|
||||
}
|
||||
|
||||
interface LinkData {
|
||||
id: LinkId
|
||||
sourceNodeId: NodeId
|
||||
@@ -91,15 +89,6 @@ interface TypedYMap<T> {
|
||||
}
|
||||
|
||||
class LayoutStoreImpl implements LayoutStore {
|
||||
private static readonly NODE_DEFAULTS: NodeLayoutData = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
private static readonly REROUTE_DEFAULTS: RerouteData = {
|
||||
id: 0,
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -109,7 +98,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
// Yjs document and shared data structures
|
||||
private ydoc = new Y.Doc()
|
||||
private ynodes: Y.Map<Y.Map<unknown>> // Maps nodeId -> Y.Map containing NodeLayout data
|
||||
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
|
||||
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
|
||||
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
|
||||
private yoperations: Y.Array<LayoutOperation> // Operation log
|
||||
@@ -155,7 +144,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
this.rerouteSpatialIndex = new SpatialIndexManager()
|
||||
|
||||
// Listen for Yjs changes and trigger Vue reactivity
|
||||
this.ynodes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
|
||||
this.ynodes.observe((event: Y.YMapEvent<NodeLayoutMap>) => {
|
||||
this.version++
|
||||
|
||||
// Trigger all affected node refs
|
||||
@@ -184,16 +173,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private getNodeField<K extends keyof NodeLayoutData>(
|
||||
ynode: Y.Map<unknown>,
|
||||
field: K,
|
||||
defaultValue: NodeLayoutData[K] = LayoutStoreImpl.NODE_DEFAULTS[field]
|
||||
): NodeLayoutData[K] {
|
||||
const typedNode = ynode as TypedYMap<NodeLayoutData>
|
||||
const value = typedNode.get(field)
|
||||
return value ?? defaultValue
|
||||
}
|
||||
|
||||
private getLinkField<K extends keyof LinkData>(
|
||||
ylink: Y.Map<unknown>,
|
||||
field: K
|
||||
@@ -227,7 +206,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
get: () => {
|
||||
track()
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
const layout = ynode ? this.yNodeToLayout(ynode) : null
|
||||
const layout = ynode ? yNodeToLayout(ynode) : null
|
||||
return layout
|
||||
},
|
||||
set: (newLayout: NodeLayout | null) => {
|
||||
@@ -242,7 +221,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
timestamp: Date.now(),
|
||||
source: this.currentSource,
|
||||
actor: this.currentActor,
|
||||
previousLayout: this.yNodeToLayout(existing)
|
||||
previousLayout: yNodeToLayout(existing)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -260,7 +239,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
actor: this.currentActor
|
||||
})
|
||||
} else {
|
||||
const existingLayout = this.yNodeToLayout(existing)
|
||||
const existingLayout = yNodeToLayout(existing)
|
||||
|
||||
// Check what properties changed
|
||||
if (
|
||||
@@ -330,8 +309,8 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
if (layout && this.boundsIntersect(layout.bounds, bounds)) {
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout && boundsIntersect(layout.bounds, bounds)) {
|
||||
result.push(nodeId)
|
||||
}
|
||||
}
|
||||
@@ -352,7 +331,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
result.set(nodeId, layout)
|
||||
}
|
||||
@@ -378,7 +357,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
for (const [nodeId] of this.ynodes) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode) {
|
||||
const layout = this.yNodeToLayout(ynode)
|
||||
const layout = yNodeToLayout(ynode)
|
||||
if (layout) {
|
||||
nodes.push([nodeId, layout])
|
||||
}
|
||||
@@ -389,7 +368,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
|
||||
|
||||
for (const [nodeId, layout] of nodes) {
|
||||
if (this.pointInBounds(point, layout.bounds)) {
|
||||
if (pointInBounds(point, layout.bounds)) {
|
||||
return nodeId
|
||||
}
|
||||
}
|
||||
@@ -561,16 +540,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return this.rerouteLayouts.get(rerouteId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create internal key for link segment
|
||||
*/
|
||||
private makeLinkSegmentKey(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null
|
||||
): string {
|
||||
return `${linkId}:${rerouteId ?? 'final'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Update link segment layout data
|
||||
*/
|
||||
@@ -579,7 +548,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
rerouteId: RerouteId | null,
|
||||
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
|
||||
): void {
|
||||
const key = this.makeLinkSegmentKey(linkId, rerouteId)
|
||||
const key = makeLinkSegmentKey(linkId, rerouteId)
|
||||
const existing = this.linkSegmentLayouts.get(key)
|
||||
|
||||
// Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
|
||||
@@ -629,7 +598,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Delete link segment layout data
|
||||
*/
|
||||
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void {
|
||||
const key = this.makeLinkSegmentKey(linkId, rerouteId)
|
||||
const key = makeLinkSegmentKey(linkId, rerouteId)
|
||||
const deleted = this.linkSegmentLayouts.delete(key)
|
||||
if (deleted) {
|
||||
// Remove from spatial index
|
||||
@@ -693,7 +662,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
rerouteId: segmentLayout.rerouteId
|
||||
}
|
||||
}
|
||||
} else if (this.pointInBounds(point, segmentLayout.bounds)) {
|
||||
} else if (pointInBounds(point, segmentLayout.bounds)) {
|
||||
// Fallback to bounding box test
|
||||
return {
|
||||
linkId: segmentLayout.linkId,
|
||||
@@ -733,7 +702,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
// Check precise bounds for candidates
|
||||
for (const key of candidateSlotKeys) {
|
||||
const slotLayout = this.slotLayouts.get(key)
|
||||
if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) {
|
||||
if (slotLayout && pointInBounds(point, slotLayout.bounds)) {
|
||||
return slotLayout
|
||||
}
|
||||
}
|
||||
@@ -969,7 +938,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
}
|
||||
|
||||
this.ynodes.set(layout.id, this.layoutToYNode(layout))
|
||||
this.ynodes.set(layout.id, layoutToYNode(layout))
|
||||
|
||||
// Add to spatial index
|
||||
this.spatialIndex.insert(layout.id, layout.bounds)
|
||||
@@ -987,7 +956,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
return
|
||||
}
|
||||
|
||||
const size = this.getNodeField(ynode, 'size')
|
||||
const size = yNodeToLayout(ynode).size
|
||||
const newBounds = {
|
||||
x: operation.position.x,
|
||||
y: operation.position.y,
|
||||
@@ -1016,7 +985,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
const ynode = this.ynodes.get(operation.nodeId)
|
||||
if (!ynode) return
|
||||
|
||||
const position = this.getNodeField(ynode, 'position')
|
||||
const position = yNodeToLayout(ynode).position
|
||||
const newBounds = {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
@@ -1053,7 +1022,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
operation: CreateNodeOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const ynode = this.layoutToYNode(operation.layout)
|
||||
const ynode = layoutToYNode(operation.layout)
|
||||
this.ynodes.set(operation.nodeId, ynode)
|
||||
|
||||
// Add to spatial index
|
||||
@@ -1187,7 +1156,7 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
* Update node bounds helper
|
||||
*/
|
||||
private updateNodeBounds(
|
||||
ynode: Y.Map<unknown>,
|
||||
ynode: NodeLayoutMap,
|
||||
position: Point,
|
||||
size: { width: number; height: number }
|
||||
): void {
|
||||
@@ -1335,27 +1304,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private layoutToYNode(layout: NodeLayout): Y.Map<unknown> {
|
||||
const ynode = new Y.Map<unknown>()
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
return ynode
|
||||
}
|
||||
|
||||
private yNodeToLayout(ynode: Y.Map<unknown>): NodeLayout {
|
||||
return {
|
||||
id: this.getNodeField(ynode, 'id'),
|
||||
position: this.getNodeField(ynode, 'position'),
|
||||
size: this.getNodeField(ynode, 'size'),
|
||||
zIndex: this.getNodeField(ynode, 'zIndex'),
|
||||
visible: this.getNodeField(ynode, 'visible'),
|
||||
bounds: this.getNodeField(ynode, 'bounds')
|
||||
}
|
||||
}
|
||||
|
||||
private notifyChange(change: LayoutChange): void {
|
||||
this.changeListeners.forEach((listener) => {
|
||||
@@ -1367,24 +1315,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
})
|
||||
}
|
||||
|
||||
private pointInBounds(point: Point, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
private boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
|
||||
// CRDT-specific methods
|
||||
getOperationsSince(timestamp: number): LayoutOperation[] {
|
||||
const operations: LayoutOperation[] = []
|
||||
|
||||
21
src/renderer/core/layout/utils/layoutMath.ts
Normal file
21
src/renderer/core/layout/utils/layoutMath.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Bounds, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
export const REROUTE_RADIUS = 8
|
||||
|
||||
export function pointInBounds(point: Point, bounds: Bounds): boolean {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x <= bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y <= bounds.y + bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
export function boundsIntersect(a: Bounds, b: Bounds): boolean {
|
||||
return !(
|
||||
a.x + a.width < b.x ||
|
||||
b.x + b.width < a.x ||
|
||||
a.y + a.height < b.y ||
|
||||
b.y + b.height < a.y
|
||||
)
|
||||
}
|
||||
11
src/renderer/core/layout/utils/layoutUtils.ts
Normal file
11
src/renderer/core/layout/utils/layoutUtils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { LinkId, RerouteId } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Creates a unique key for identifying link segments in spatial indexes
|
||||
*/
|
||||
export function makeLinkSegmentKey(
|
||||
linkId: LinkId,
|
||||
rerouteId: RerouteId | null
|
||||
): string {
|
||||
return `${linkId}:${rerouteId ?? 'final'}`
|
||||
}
|
||||
45
src/renderer/core/layout/utils/mappers.ts
Normal file
45
src/renderer/core/layout/utils/mappers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
export type NodeLayoutMap = Y.Map<NodeLayout[keyof NodeLayout]>
|
||||
|
||||
export const NODE_LAYOUT_DEFAULTS: NodeLayout = {
|
||||
id: 'unknown-node',
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 50 },
|
||||
zIndex: 0,
|
||||
visible: true,
|
||||
bounds: { x: 0, y: 0, width: 100, height: 50 }
|
||||
}
|
||||
|
||||
export function layoutToYNode(layout: NodeLayout): NodeLayoutMap {
|
||||
const ynode = new Y.Map<NodeLayout[keyof NodeLayout]>() as NodeLayoutMap
|
||||
ynode.set('id', layout.id)
|
||||
ynode.set('position', layout.position)
|
||||
ynode.set('size', layout.size)
|
||||
ynode.set('zIndex', layout.zIndex)
|
||||
ynode.set('visible', layout.visible)
|
||||
ynode.set('bounds', layout.bounds)
|
||||
return ynode
|
||||
}
|
||||
|
||||
function getOr<K extends keyof NodeLayout>(
|
||||
map: NodeLayoutMap,
|
||||
key: K,
|
||||
fallback: NodeLayout[K]
|
||||
): NodeLayout[K] {
|
||||
const v = map.get(key)
|
||||
return (v ?? fallback) as NodeLayout[K]
|
||||
}
|
||||
|
||||
export function yNodeToLayout(ynode: NodeLayoutMap): NodeLayout {
|
||||
return {
|
||||
id: getOr(ynode, 'id', NODE_LAYOUT_DEFAULTS.id),
|
||||
position: getOr(ynode, 'position', NODE_LAYOUT_DEFAULTS.position),
|
||||
size: getOr(ynode, 'size', NODE_LAYOUT_DEFAULTS.size),
|
||||
zIndex: getOr(ynode, 'zIndex', NODE_LAYOUT_DEFAULTS.zIndex),
|
||||
visible: getOr(ynode, 'visible', NODE_LAYOUT_DEFAULTS.visible),
|
||||
bounds: getOr(ynode, 'bounds', NODE_LAYOUT_DEFAULTS.bounds)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
// border
|
||||
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
|
||||
!!executing && 'border-blue-500 dark-theme:border-blue-500',
|
||||
!!(error || nodeData.hasErrors) &&
|
||||
'border-error dark-theme:border-error',
|
||||
!!(error || nodeData.hasErrors) && 'border-error',
|
||||
// hover
|
||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||
// Selected
|
||||
@@ -22,8 +21,7 @@
|
||||
!!isSelected && 'outline-black dark-theme:outline-white',
|
||||
!!(isSelected && executing) &&
|
||||
'outline-blue-500 dark-theme:outline-blue-500',
|
||||
!!(isSelected && (error || nodeData.hasErrors)) &&
|
||||
'outline-error dark-theme:outline-error',
|
||||
!!(isSelected && (error || nodeData.hasErrors)) && 'outline-error',
|
||||
{
|
||||
'animate-pulse': executing,
|
||||
'opacity-50': nodeData.mode === 4,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-[1px] text-[#888682] dark-theme:text-[#5B5E7D]"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import type { ButtonProps } from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
describe('WidgetButton Interactions', () => {
|
||||
const createMockWidget = (
|
||||
options: Partial<ButtonProps> = {},
|
||||
callback?: () => void,
|
||||
name: string = 'test_button'
|
||||
): SimplifiedWidget<void> => ({
|
||||
name,
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
|
||||
return mount(WidgetButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Button }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clickButton = async (wrapper: ReturnType<typeof mount>) => {
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
await button.trigger('click')
|
||||
return button
|
||||
}
|
||||
|
||||
describe('Click Handling', () => {
|
||||
it('calls callback when button is clicked', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
await clickButton(wrapper)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call callback when button is readonly', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
await clickButton(wrapper)
|
||||
|
||||
expect(mockCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget({}, undefined)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Should not throw error when clicking without callback
|
||||
await expect(clickButton(wrapper)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('calls callback multiple times when clicked multiple times', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const numClicks = 8
|
||||
|
||||
await clickButton(wrapper)
|
||||
for (let i = 0; i < numClicks; i++) {
|
||||
await clickButton(wrapper)
|
||||
}
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(numClicks)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders button component', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders widget label when name is provided', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const label = wrapper.find('label')
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.text()).toBe('test_button')
|
||||
})
|
||||
|
||||
it('does not render label when widget name is empty', () => {
|
||||
const widget = createMockWidget({}, undefined, '')
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const label = wrapper.find('label')
|
||||
expect(label.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sets button size to small', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('size')).toBe('small')
|
||||
})
|
||||
|
||||
it('passes widget options to button component', () => {
|
||||
const buttonOptions = {
|
||||
label: 'Custom Label',
|
||||
icon: 'pi pi-check',
|
||||
severity: 'success' as const
|
||||
}
|
||||
const widget = createMockWidget(buttonOptions)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Custom Label')
|
||||
expect(button.props('icon')).toBe('pi pi-check')
|
||||
expect(button.props('severity')).toBe('success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Readonly Mode', () => {
|
||||
it('disables button when readonly', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
// Test the actual DOM button element instead of the Vue component props
|
||||
const buttonElement = wrapper.find('button')
|
||||
expect(buttonElement.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables button when not readonly', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
// Test the actual DOM button element instead of the Vue component props
|
||||
const buttonElement = wrapper.find('button')
|
||||
expect(buttonElement.element.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Options', () => {
|
||||
it('handles button with text only', () => {
|
||||
const widget = createMockWidget({ label: 'Click Me' })
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Click Me')
|
||||
expect(button.props('icon')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles button with icon only', () => {
|
||||
const widget = createMockWidget({ icon: 'pi pi-star' })
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('icon')).toBe('pi pi-star')
|
||||
})
|
||||
|
||||
it('handles button with both text and icon', () => {
|
||||
const widget = createMockWidget({
|
||||
label: 'Save',
|
||||
icon: 'pi pi-save'
|
||||
})
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('label')).toBe('Save')
|
||||
expect(button.props('icon')).toBe('pi pi-save')
|
||||
})
|
||||
|
||||
it.for([
|
||||
'secondary',
|
||||
'success',
|
||||
'info',
|
||||
'warning',
|
||||
'danger',
|
||||
'help',
|
||||
'contrast'
|
||||
] as const)('handles button severity: %s', (severity) => {
|
||||
const widget = createMockWidget({ severity })
|
||||
const wrapper = mountComponent(widget)
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('severity')).toBe(severity)
|
||||
})
|
||||
|
||||
it.for(['outlined', 'text'] as const)(
|
||||
'handles button variant: %s',
|
||||
(variant) => {
|
||||
const widget = createMockWidget({ variant })
|
||||
const wrapper = mountComponent(widget)
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.props('variant')).toBe(variant)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createMockWidget()
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
const button = wrapper.findComponent({ name: 'Button' })
|
||||
expect(button.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles callback that throws error', async () => {
|
||||
const mockCallback = vi.fn(() => {
|
||||
throw new Error('Callback error')
|
||||
})
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Should not break the component when callback throws
|
||||
await expect(clickButton(wrapper)).rejects.toThrow('Callback error')
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handles rapid consecutive clicks', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Simulate rapid clicks
|
||||
const clickPromises = Array.from({ length: 16 }, () =>
|
||||
clickButton(wrapper)
|
||||
)
|
||||
await Promise.all(clickPromises)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
describe('WidgetColorPicker Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetColorPicker, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: {
|
||||
ColorPicker,
|
||||
WidgetLayoutField
|
||||
}
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setColorPickerValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: unknown
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createMockWidget('#ffffff')
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createMockWidget('ff0000')
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000')
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders color picker component', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes display to a single leading #', () => {
|
||||
// Case 1: model value already includes '#'
|
||||
let widget = createMockWidget('#ff0000')
|
||||
let wrapper = mountComponent(widget, '#ff0000')
|
||||
let colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
|
||||
// Case 2: model value missing '#'
|
||||
widget = createMockWidget('ff0000')
|
||||
wrapper = mountComponent(widget, 'ff0000')
|
||||
colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('renders layout field wrapper', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current color value as text', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates color text when value changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
// Need to check the local state update
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
// Be specific about the displayed value including the leading '#'
|
||||
expect.soft(colorText.text()).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('uses default color when no value provided', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
// Should use the default value from the composable
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Readonly Mode', () => {
|
||||
it('disables color picker when readonly', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000', true)
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('enables color picker when not readonly', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000', false)
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('disabled')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Color Formats', () => {
|
||||
it('handles valid hex colors', async () => {
|
||||
const validHexColors = [
|
||||
'#000000',
|
||||
'#ffffff',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#123abc'
|
||||
]
|
||||
|
||||
for (const color of validHexColors) {
|
||||
const widget = createMockWidget(color)
|
||||
const wrapper = mountComponent(widget, color)
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe(color)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles short hex colors', () => {
|
||||
const widget = createMockWidget('#fff')
|
||||
const wrapper = mountComponent(widget, '#fff')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#fff')
|
||||
})
|
||||
|
||||
it('passes widget options to color picker', () => {
|
||||
const colorOptions = {
|
||||
format: 'hex' as const,
|
||||
inline: true
|
||||
}
|
||||
const widget = createMockWidget('#ff0000', colorOptions)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('format')).toBe('hex')
|
||||
expect(colorPicker.props('inline')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Layout Integration', () => {
|
||||
it('passes widget to layout field', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('maintains proper component structure', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
// Should have layout field containing label with color picker and text
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const label = wrapper.find('label')
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorText = wrapper.find('span')
|
||||
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
expect(colorText.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty color value', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createMockWidget('invalid-color')
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,19 +14,26 @@
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span class="text-xs">#{{ localValue }}</span>
|
||||
<span class="text-xs" data-testid="widget-color-text">{{
|
||||
toHexFromFormat(localValue, format)
|
||||
}}</span>
|
||||
</label>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
type ColorFormat,
|
||||
type HSB,
|
||||
isColorFormat,
|
||||
toHexFromFormat
|
||||
} from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
@@ -36,8 +43,10 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
@@ -46,14 +55,33 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: '#000000',
|
||||
emit
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
}
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
>
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="text-sm text-[#888682] dark-theme:text-[#9FA2BD] font-normal flex-1 truncate w-20"
|
||||
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import {
|
||||
@@ -14,6 +15,19 @@ export const useKeybindingService = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// Helper function to determine if an event should be forwarded to canvas
|
||||
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
|
||||
// Don't forward if modifier keys are pressed (except shift)
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Keys that LiteGraph handles but aren't in core keybindings
|
||||
const canvasKeys = ['Delete', 'Backspace']
|
||||
|
||||
return canvasKeys.includes(event.key)
|
||||
}
|
||||
|
||||
const keybindHandler = async function (event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
if (keyCombo.isModifier) {
|
||||
@@ -26,6 +40,7 @@ export const useKeybindingService = () => {
|
||||
keyCombo.isReservedByTextInput &&
|
||||
(target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
target.contentEditable === 'true' ||
|
||||
(target.tagName === 'SPAN' &&
|
||||
target.classList.contains('property_value')))
|
||||
) {
|
||||
@@ -53,6 +68,20 @@ export const useKeybindingService = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward unhandled canvas-targeted events to LiteGraph
|
||||
if (!keybinding && shouldForwardToCanvas(event)) {
|
||||
const canvas = app.canvas
|
||||
if (
|
||||
canvas &&
|
||||
canvas.processKey &&
|
||||
typeof canvas.processKey === 'function'
|
||||
) {
|
||||
// Let LiteGraph handle the event
|
||||
canvas.processKey(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear dialogs if not using modifiers
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
type RGB = { r: number; g: number; b: number }
|
||||
export interface HSB {
|
||||
h: number
|
||||
s: number
|
||||
b: number
|
||||
}
|
||||
type HSL = { h: number; s: number; l: number }
|
||||
type HSLA = { h: number; s: number; l: number; a: number }
|
||||
type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
|
||||
type ColorFormatInternal = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
|
||||
export type ColorFormat = 'hex' | 'rgb' | 'hsb'
|
||||
interface HSV {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
}
|
||||
|
||||
export interface ColorAdjustOptions {
|
||||
lightness?: number
|
||||
@@ -59,6 +70,65 @@ export function hexToRgb(hex: string): RGB {
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
export function rgbToHex({ r, g, b }: RGB): string {
|
||||
const toHex = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(n)))
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
export function hsbToRgb({ h, s, b }: HSB): RGB {
|
||||
// Normalize
|
||||
const hh = ((h % 360) + 360) % 360
|
||||
const ss = Math.max(0, Math.min(100, s)) / 100
|
||||
const vv = Math.max(0, Math.min(100, b)) / 100
|
||||
|
||||
const c = vv * ss
|
||||
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
|
||||
const m = vv - c
|
||||
|
||||
let rp = 0,
|
||||
gp = 0,
|
||||
bp = 0
|
||||
|
||||
if (hh < 60) {
|
||||
rp = c
|
||||
gp = x
|
||||
bp = 0
|
||||
} else if (hh < 120) {
|
||||
rp = x
|
||||
gp = c
|
||||
bp = 0
|
||||
} else if (hh < 180) {
|
||||
rp = 0
|
||||
gp = c
|
||||
bp = x
|
||||
} else if (hh < 240) {
|
||||
rp = 0
|
||||
gp = x
|
||||
bp = c
|
||||
} else if (hh < 300) {
|
||||
rp = x
|
||||
gp = 0
|
||||
bp = c
|
||||
} else {
|
||||
rp = c
|
||||
gp = 0
|
||||
bp = x
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.floor((rp + m) * 255),
|
||||
g: Math.floor((gp + m) * 255),
|
||||
b: Math.floor((bp + m) * 255)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize various color inputs (hex, rgb/rgba, hsl/hsla, hsb string/object)
|
||||
* into lowercase #rrggbb. Falls back to #000000 on invalid inputs.
|
||||
*/
|
||||
export function parseToRgb(color: string): RGB {
|
||||
const format = identifyColorFormat(color)
|
||||
if (!format) return { r: 0, g: 0, b: 0 }
|
||||
@@ -112,7 +182,7 @@ export function parseToRgb(color: string): RGB {
|
||||
}
|
||||
}
|
||||
|
||||
const identifyColorFormat = (color: string): ColorFormat | null => {
|
||||
const identifyColorFormat = (color: string): ColorFormatInternal | null => {
|
||||
if (!color) return null
|
||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||
return 'hex'
|
||||
@@ -133,7 +203,73 @@ const isHSLA = (color: unknown): color is HSLA => {
|
||||
)
|
||||
}
|
||||
|
||||
function parseToHSLA(color: string, format: ColorFormat): HSLA | null {
|
||||
export function isColorFormat(v: unknown): v is ColorFormat {
|
||||
return v === 'hex' || v === 'rgb' || v === 'hsb'
|
||||
}
|
||||
|
||||
function isHSBObject(v: unknown): v is HSB {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const rec = v as Record<string, unknown>
|
||||
return (
|
||||
typeof rec.h === 'number' &&
|
||||
Number.isFinite(rec.h) &&
|
||||
typeof rec.s === 'number' &&
|
||||
Number.isFinite(rec.s) &&
|
||||
typeof (rec as Record<string, unknown>).b === 'number' &&
|
||||
Number.isFinite((rec as Record<string, number>).b!)
|
||||
)
|
||||
}
|
||||
|
||||
function isHSVObject(v: unknown): v is HSV {
|
||||
if (!v || typeof v !== 'object') return false
|
||||
const rec = v as Record<string, unknown>
|
||||
return (
|
||||
typeof rec.h === 'number' &&
|
||||
Number.isFinite(rec.h) &&
|
||||
typeof rec.s === 'number' &&
|
||||
Number.isFinite(rec.s) &&
|
||||
typeof (rec as Record<string, unknown>).v === 'number' &&
|
||||
Number.isFinite((rec as Record<string, number>).v!)
|
||||
)
|
||||
}
|
||||
|
||||
export function toHexFromFormat(val: unknown, format: ColorFormat): string {
|
||||
if (format === 'hex' && typeof val === 'string') {
|
||||
const raw = val.trim().toLowerCase()
|
||||
if (!raw) return '#000000'
|
||||
if (/^[0-9a-f]{3}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{3}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{6}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{6}$/.test(raw)) return raw
|
||||
return '#000000'
|
||||
}
|
||||
|
||||
if (format === 'rgb' && typeof val === 'string') {
|
||||
const rgb = parseToRgb(val)
|
||||
return rgbToHex(rgb).toLowerCase()
|
||||
}
|
||||
|
||||
if (format === 'hsb') {
|
||||
if (isHSBObject(val)) {
|
||||
return rgbToHex(hsbToRgb(val)).toLowerCase()
|
||||
}
|
||||
if (isHSVObject(val)) {
|
||||
const { h, s, v } = val
|
||||
return rgbToHex(hsbToRgb({ h, s, b: v })).toLowerCase()
|
||||
}
|
||||
if (typeof val === 'string') {
|
||||
const nums = val.match(/\d+(?:\.\d+)?/g)?.map(Number) || []
|
||||
if (nums.length >= 3) {
|
||||
return rgbToHex(
|
||||
hsbToRgb({ h: nums[0], s: nums[1], b: nums[2] })
|
||||
).toLowerCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
return '#000000'
|
||||
}
|
||||
|
||||
function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
let match: RegExpMatchArray | null
|
||||
|
||||
switch (format) {
|
||||
|
||||
Reference in New Issue
Block a user