Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c02dfca6 | ||
|
|
e6534f17e6 |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 66 KiB |
@@ -31,6 +31,7 @@
|
||||
"logs": "Logs",
|
||||
"videoFailedToLoad": "Video failed to load",
|
||||
"audioFailedToLoad": "Audio failed to load",
|
||||
"liveSamplingPreview": "Live sampling preview",
|
||||
"extensionName": "Extension Name",
|
||||
"reloadToApplyChanges": "Reload to apply changes",
|
||||
"insert": "Insert",
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as Y from 'yjs'
|
||||
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type {
|
||||
BatchUpdateBoundsOperation,
|
||||
Bounds,
|
||||
CreateLinkOperation,
|
||||
CreateNodeOperation,
|
||||
@@ -871,6 +872,12 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
case 'deleteNode':
|
||||
this.handleDeleteNode(operation as DeleteNodeOperation, change)
|
||||
break
|
||||
case 'batchUpdateBounds':
|
||||
this.handleBatchUpdateBounds(
|
||||
operation as BatchUpdateBoundsOperation,
|
||||
change
|
||||
)
|
||||
break
|
||||
case 'createLink':
|
||||
this.handleCreateLink(operation as CreateLinkOperation, change)
|
||||
break
|
||||
@@ -1099,6 +1106,38 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
change.nodeIds.push(operation.nodeId)
|
||||
}
|
||||
|
||||
private handleBatchUpdateBounds(
|
||||
operation: BatchUpdateBoundsOperation,
|
||||
change: LayoutChange
|
||||
): void {
|
||||
const spatialUpdates: Array<{ nodeId: NodeId; bounds: Bounds }> = []
|
||||
|
||||
for (const nodeId of operation.nodeIds) {
|
||||
const data = operation.bounds[nodeId]
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode || !data) continue
|
||||
|
||||
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
|
||||
ynode.set('size', {
|
||||
width: data.bounds.width,
|
||||
height: data.bounds.height
|
||||
})
|
||||
ynode.set('bounds', data.bounds)
|
||||
|
||||
spatialUpdates.push({ nodeId, bounds: data.bounds })
|
||||
change.nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
// Batch update spatial index for better performance
|
||||
if (spatialUpdates.length > 0) {
|
||||
this.spatialIndex.batchUpdate(spatialUpdates)
|
||||
}
|
||||
|
||||
if (change.nodeIds.length) {
|
||||
change.type = 'update'
|
||||
}
|
||||
}
|
||||
|
||||
private handleCreateLink(
|
||||
operation: CreateLinkOperation,
|
||||
change: LayoutChange
|
||||
@@ -1379,19 +1418,38 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
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
|
||||
const nodeIds: NodeId[] = []
|
||||
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('position', { x: bounds.x, y: bounds.y })
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) continue
|
||||
const currentLayout = yNodeToLayout(ynode)
|
||||
|
||||
boundsRecord[nodeId] = {
|
||||
bounds,
|
||||
previousBounds: currentLayout.bounds
|
||||
}
|
||||
}, this.currentActor)
|
||||
nodeIds.push(nodeId)
|
||||
}
|
||||
|
||||
if (!nodeIds.length) {
|
||||
this.currentSource = originalSource
|
||||
return
|
||||
}
|
||||
|
||||
const operation: BatchUpdateBoundsOperation = {
|
||||
type: 'batchUpdateBounds',
|
||||
entity: 'node',
|
||||
nodeIds,
|
||||
bounds: boundsRecord,
|
||||
timestamp: Date.now(),
|
||||
source: this.currentSource,
|
||||
actor: this.currentActor
|
||||
}
|
||||
|
||||
this.applyOperation(operation)
|
||||
|
||||
// Restore original source
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +267,11 @@ export function useLinkLayoutSync() {
|
||||
case 'resizeNode':
|
||||
recomputeLinksForNode(parseInt(change.operation.nodeId))
|
||||
break
|
||||
case 'batchUpdateBounds':
|
||||
for (const nodeId of change.operation.nodeIds) {
|
||||
recomputeLinksForNode(parseInt(nodeId))
|
||||
}
|
||||
break
|
||||
case 'createLink':
|
||||
recomputeLinkById(change.operation.linkId)
|
||||
break
|
||||
|
||||
@@ -122,7 +122,7 @@ type OperationType =
|
||||
| 'createNode'
|
||||
| 'deleteNode'
|
||||
| 'setNodeVisibility'
|
||||
| 'batchUpdate'
|
||||
| 'batchUpdateBounds'
|
||||
| 'createLink'
|
||||
| 'deleteLink'
|
||||
| 'createReroute'
|
||||
@@ -184,10 +184,11 @@ interface SetNodeVisibilityOperation extends NodeOpBase {
|
||||
/**
|
||||
* Batch update operation for atomic multi-property changes
|
||||
*/
|
||||
interface BatchUpdateOperation extends NodeOpBase {
|
||||
type: 'batchUpdate'
|
||||
updates: Partial<NodeLayout>
|
||||
previousValues: Partial<NodeLayout>
|
||||
export interface BatchUpdateBoundsOperation extends OperationMeta {
|
||||
entity: 'node'
|
||||
type: 'batchUpdateBounds'
|
||||
nodeIds: NodeId[]
|
||||
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,7 +245,7 @@ export type LayoutOperation =
|
||||
| CreateNodeOperation
|
||||
| DeleteNodeOperation
|
||||
| SetNodeVisibilityOperation
|
||||
| BatchUpdateOperation
|
||||
| BatchUpdateBoundsOperation
|
||||
| CreateLinkOperation
|
||||
| DeleteLinkOperation
|
||||
| CreateRerouteOperation
|
||||
|
||||
@@ -55,6 +55,17 @@ export class SpatialIndexManager {
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple nodes' bounds in the spatial index
|
||||
* More efficient than calling update() multiple times as it only invalidates cache once
|
||||
*/
|
||||
batchUpdate(updates: Array<{ nodeId: NodeId; bounds: Bounds }>): void {
|
||||
for (const { nodeId, bounds } of updates) {
|
||||
this.quadTree.update(nodeId, bounds)
|
||||
}
|
||||
this.invalidateCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the spatial index
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="video-preview group relative flex flex-col items-center"
|
||||
class="video-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@@ -12,12 +12,12 @@
|
||||
>
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="videoError"
|
||||
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.videoFailedToLoad') }}</p>
|
||||
@@ -27,17 +27,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="h-[352px] w-full"
|
||||
border-radius="5px"
|
||||
/>
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
|
||||
<!-- Main Video -->
|
||||
<video
|
||||
v-else
|
||||
:src="currentVideoUrl"
|
||||
class="block h-[352px] w-full object-contain"
|
||||
class="block size-full object-contain"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
class="image-preview group relative flex flex-col items-center"
|
||||
class="image-preview group relative flex size-full min-h-16 min-w-16 flex-col"
|
||||
data-capture-node="true"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
@@ -12,12 +12,12 @@
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="relative w-full max-w-[352px] overflow-hidden rounded-[5px] bg-[#262729]"
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="flex h-[352px] w-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-gray-800/50 text-center text-white"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image-off] h-12 w-12 text-gray-400" />
|
||||
<p class="text-sm text-gray-300">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
@@ -27,18 +27,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Skeleton
|
||||
v-else-if="isLoading"
|
||||
class="h-[352px] w-full"
|
||||
border-radius="5px"
|
||||
/>
|
||||
<Skeleton v-else-if="isLoading" class="size-full" border-radius="5px" />
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="currentImageUrl"
|
||||
:alt="imageAltText"
|
||||
class="block h-[352px] w-full object-contain"
|
||||
class="block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
:class="
|
||||
cn(
|
||||
'bg-node-component-surface',
|
||||
'lg-node absolute rounded-2xl touch-none',
|
||||
'lg-node absolute rounded-2xl touch-none flex flex-col',
|
||||
'border-1 border-solid border-node-component-border',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
class="flex min-h-0 flex-1 flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
@@ -98,18 +98,12 @@
|
||||
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:media="nodeMedia"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
<div v-if="shouldShowPreviewImg" class="px-4">
|
||||
<img
|
||||
:src="latestPreviewUrl"
|
||||
alt="preview"
|
||||
class="max-h-64 w-full object-contain"
|
||||
/>
|
||||
<div v-if="hasCustomContent" class="min-h-0 flex-1">
|
||||
<NodeContent :node-data="nodeData" :media="nodeMedia" />
|
||||
</div>
|
||||
<!-- Live mid-execution preview images -->
|
||||
<div v-if="shouldShowPreviewImg" class="min-h-0 flex-1 px-4">
|
||||
<LivePreview :image-url="latestPreviewUrl || null" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -154,6 +148,8 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useNodeResize } from '../composables/useNodeResize'
|
||||
import { calculateIntrinsicSize } from '../utils/calculateIntrinsicSize'
|
||||
import LivePreview from './LivePreview.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
@@ -247,7 +243,7 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
|
||||
const { pointerHandlers, isDragging, dragStyle } = useNodePointerInteractions(
|
||||
() => nodeData,
|
||||
handleNodeSelect
|
||||
@@ -269,13 +265,19 @@ const handleContextMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (size.value && transformState?.camera) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.value.width * scale,
|
||||
height: size.value.height * scale
|
||||
}
|
||||
resize(screenSize)
|
||||
// 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`
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
73
src/renderer/extensions/vueNodes/components/LivePreview.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div v-if="imageUrl" class="flex h-full min-h-16 w-full min-w-16 flex-col">
|
||||
<!-- Image Container -->
|
||||
<div
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="flex h-full w-full flex-col items-center justify-center text-center text-pure-white"
|
||||
>
|
||||
<i-lucide:image-off class="mb-1 size-8 text-gray-500" />
|
||||
<p class="text-xs text-gray-400">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Image -->
|
||||
<img
|
||||
v-else
|
||||
:src="imageUrl"
|
||||
:alt="$t('g.liveSamplingPreview')"
|
||||
class="pointer-events-none h-full w-full object-contain object-center"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
||||
{{
|
||||
imageError
|
||||
? $t('g.errorLoadingImage')
|
||||
: actualDimensions || $t('g.calculatingDimensions')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface LivePreviewProps {
|
||||
/** Image URL to display */
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<LivePreviewProps>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
() => {
|
||||
// Reset states when URL changes
|
||||
actualDimensions.value = null
|
||||
imageError.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const handleImageLoad = (event: Event) => {
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
const img = event.target
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ $t('Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<div v-else class="lg-node-content flex h-full flex-col">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<VideoPreview
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref } from 'vue'
|
||||
import type { TransformState } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
|
||||
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
|
||||
import { calculateIntrinsicSize } from '@/renderer/extensions/vueNodes/utils/calculateIntrinsicSize'
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
@@ -64,29 +65,16 @@ export function useNodeResize(
|
||||
if (!(nodeElement instanceof HTMLElement)) return
|
||||
|
||||
const rect = nodeElement.getBoundingClientRect()
|
||||
|
||||
// Calculate intrinsic content size once at start
|
||||
const originalWidth = nodeElement.style.width
|
||||
const originalHeight = nodeElement.style.height
|
||||
nodeElement.style.width = 'auto'
|
||||
nodeElement.style.height = 'auto'
|
||||
|
||||
const intrinsicRect = nodeElement.getBoundingClientRect()
|
||||
|
||||
// Restore original size
|
||||
nodeElement.style.width = originalWidth
|
||||
nodeElement.style.height = originalHeight
|
||||
|
||||
// Convert to canvas coordinates using transform state
|
||||
const scale = transformState.camera.z
|
||||
|
||||
// Calculate current size in canvas coordinates
|
||||
resizeStartSize.value = {
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale
|
||||
}
|
||||
intrinsicMinSize.value = {
|
||||
width: intrinsicRect.width / scale,
|
||||
height: intrinsicRect.height / scale
|
||||
}
|
||||
|
||||
// Calculate intrinsic content size (minimum based on content)
|
||||
intrinsicMinSize.value = calculateIntrinsicSize(nodeElement, scale)
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
if (
|
||||
|
||||
@@ -258,14 +258,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
mutations.moveNode(nodeId, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node size
|
||||
*/
|
||||
function resize(newSize: { width: number; height: number }) {
|
||||
mutations.setSource(LayoutSource.Vue)
|
||||
mutations.resizeNode(nodeId, newSize)
|
||||
}
|
||||
|
||||
return {
|
||||
// Reactive state (via customRef)
|
||||
layoutRef,
|
||||
@@ -278,7 +270,6 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
|
||||
// Mutations
|
||||
moveTo,
|
||||
resize,
|
||||
|
||||
// Drag handlers
|
||||
startDrag,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import {
|
||||
@@ -165,10 +165,11 @@ describe('layoutStore CRDT operations', () => {
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
|
||||
// Wait for async notification
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
expect(changes.length).toBeGreaterThanOrEqual(1)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.source).toBe('vue')
|
||||
expect(lastChange.operation.actor).toBe('user-123')
|
||||
@@ -176,6 +177,48 @@ describe('layoutStore CRDT operations', () => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should emit change when batch updating node bounds', async () => {
|
||||
const nodeId = 'test-node-6'
|
||||
const layout = createTestNode(nodeId)
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.External,
|
||||
actor: 'test'
|
||||
})
|
||||
|
||||
const changes: LayoutChange[] = []
|
||||
const unsubscribe = layoutStore.onChange((change) => {
|
||||
changes.push(change)
|
||||
})
|
||||
|
||||
const newBounds = { x: 40, y: 60, width: 220, height: 120 }
|
||||
layoutStore.batchUpdateNodeBounds([{ nodeId, bounds: newBounds }])
|
||||
|
||||
// Wait for onChange callback to be called (uses setTimeout internally)
|
||||
await vi.waitFor(() => {
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
const lastChange = changes[changes.length - 1]
|
||||
expect(lastChange.operation.type).toBe('batchUpdateBounds')
|
||||
})
|
||||
|
||||
const lastChange = changes[changes.length - 1]
|
||||
if (lastChange.operation.type === 'batchUpdateBounds') {
|
||||
expect(lastChange.nodeIds).toContain(nodeId)
|
||||
expect(lastChange.operation.bounds[nodeId]?.bounds).toEqual(newBounds)
|
||||
}
|
||||
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
expect(nodeRef.value?.position).toEqual({ x: 40, y: 60 })
|
||||
expect(nodeRef.value?.size).toEqual({ width: 220, height: 120 })
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should query nodes by spatial bounds', () => {
|
||||
const nodes = [
|
||||
{ id: 'node-a', position: { x: 0, y: 0 } },
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
liveSamplingPreview: 'Live sampling preview',
|
||||
imageFailedToLoad: 'Image failed to load',
|
||||
errorLoadingImage: 'Error loading image',
|
||||
calculatingDimensions: 'Calculating dimensions'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('LivePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrl: '/api/view?filename=test_sample.png&type=temp'
|
||||
}
|
||||
|
||||
const mountLivePreview = (props = {}) => {
|
||||
return mount(LivePreview, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
'i-lucide:image-off': true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders preview when imageUrl provided', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('img').attributes('src')).toBe(defaultProps.imageUrl)
|
||||
})
|
||||
|
||||
it('does not render when no imageUrl provided', () => {
|
||||
const wrapper = mountLivePreview({ imageUrl: null })
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('')
|
||||
})
|
||||
|
||||
it('displays calculating dimensions text initially', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
const wrapper = mountLivePreview()
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('alt')).toBe('Live sampling preview')
|
||||
})
|
||||
|
||||
it('handles image load event', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Mock the naturalWidth and naturalHeight properties on the img element
|
||||
Object.defineProperty(img.element, 'naturalWidth', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
Object.defineProperty(img.element, 'naturalHeight', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
|
||||
// Trigger the load event
|
||||
await img.trigger('load')
|
||||
|
||||
expect(wrapper.text()).toContain('512 x 512')
|
||||
})
|
||||
|
||||
it('handles image error state', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Trigger the error event
|
||||
await img.trigger('error')
|
||||
|
||||
// Check that the image is hidden and error content is shown
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Image failed to load')
|
||||
})
|
||||
|
||||
it('resets state when imageUrl changes', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Set error state via event
|
||||
await img.trigger('error')
|
||||
expect(wrapper.text()).toContain('Error loading image')
|
||||
|
||||
// Change imageUrl prop
|
||||
await wrapper.setProps({ imageUrl: '/new-image.png' })
|
||||
await nextTick()
|
||||
|
||||
// State should be reset - dimensions text should show calculating
|
||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||
expect(wrapper.text()).not.toContain('Error loading image')
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const wrapper = mountLivePreview()
|
||||
const img = wrapper.find('img')
|
||||
|
||||
// Trigger error event
|
||||
await img.trigger('error')
|
||||
|
||||
// Should show error state instead of image
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Image failed to load')
|
||||
expect(wrapper.text()).toContain('Error loading image')
|
||||
})
|
||||
})
|
||||