mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 22:59:14 +00:00
reset branch
This commit is contained in:
3
.github/workflows/i18n.yaml
vendored
3
.github/workflows/i18n.yaml
vendored
@@ -7,11 +7,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
update-locales:
|
||||
# Branch detection: Only run for manual dispatch or version-bump-* branches from main repo
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.head_ref, 'version-bump-'))
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.head.repo.full_name == github.repository && (startsWith(github.head_ref, 'version-bump-') || startsWith(github.head_ref, 'sno-fix-playwright-')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
|
||||
32
babel-plugin-stub-vue-imports.cjs
Normal file
32
babel-plugin-stub-vue-imports.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
module.exports = function(babel) {
|
||||
const { types: t } = babel;
|
||||
|
||||
return {
|
||||
visitor: {
|
||||
ImportDeclaration(path) {
|
||||
const source = path.node.source.value;
|
||||
|
||||
// Handle Vue files
|
||||
if (source.endsWith('.vue')) {
|
||||
const specifiers = path.node.specifiers;
|
||||
if (specifiers.length > 0 && specifiers[0].type === 'ImportDefaultSpecifier') {
|
||||
const name = specifiers[0].local.name;
|
||||
// Replace with a variable declaration
|
||||
path.replaceWith(
|
||||
t.variableDeclaration('const', [
|
||||
t.variableDeclarator(
|
||||
t.identifier(name),
|
||||
t.objectExpression([])
|
||||
)
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
// Handle CSS files - just remove the import
|
||||
else if (source.endsWith('.css') || source.endsWith('.scss') || source.endsWith('.less')) {
|
||||
path.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
41
package.json
41
package.json
@@ -14,9 +14,9 @@
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"zipdist": "node scripts/zipdist.js",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
||||
"test:browser": "npx nx e2e",
|
||||
"test:unit": "nx run test tests-ui/tests",
|
||||
@@ -34,16 +34,21 @@
|
||||
"knip": "knip --cache",
|
||||
"knip:no-cache": "knip",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "npx playwright test --config=playwright.i18n.config.ts",
|
||||
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
|
||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@iconify-json/lucide": "^1.2.66",
|
||||
"@iconify/tailwind": "^1.2.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@lobehub/i18n-cli": "^1.25.1",
|
||||
"@nx/eslint": "21.4.1",
|
||||
"@nx/playwright": "21.4.1",
|
||||
@@ -64,13 +69,21 @@
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"@vue/babel-plugin-jsx": "^1.5.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"babel-helper-vue-jsx-merge-props": "^2.0.3",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||
"babel-plugin-transform-import-ignore": "^1.1.0",
|
||||
"babel-plugin-transform-vue-jsx": "^3.7.0",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-typescript-vue3": "^2.1.1",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-storybook": "^9.1.6",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-storybook": "^9.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "^15.9.0",
|
||||
"happy-dom": "^15.11.0",
|
||||
@@ -81,18 +94,18 @@
|
||||
"lint-staged": "^15.2.7",
|
||||
"nx": "21.4.1",
|
||||
"prettier": "^3.3.2",
|
||||
"storybook": "^9.1.6",
|
||||
"storybook": "^9.1.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tsx": "^4.15.6",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"unplugin-icons": "^0.22.0",
|
||||
"unplugin-vue-components": "^0.28.0",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^3.2.4",
|
||||
@@ -105,7 +118,7 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.72",
|
||||
"@iconify/json": "^2.2.380",
|
||||
"@primeuix/forms": "0.0.2",
|
||||
"@primeuix/styled": "0.3.2",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
export default defineConfig({
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const config: any = defineConfig({
|
||||
testDir: './scripts',
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
@@ -8,5 +12,41 @@ export default defineConfig({
|
||||
},
|
||||
reporter: 'list',
|
||||
timeout: 60000,
|
||||
testMatch: /collect-i18n-.*\.ts/
|
||||
workers: 1, // Run tests serially to avoid duplicate user creation
|
||||
testMatch: /collect-i18n-.*\.ts/,
|
||||
// Start dev server before running tests
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000
|
||||
}
|
||||
})
|
||||
|
||||
// Configure babel plugins for TypeScript with declare fields and module resolution
|
||||
config['@playwright/test'] = {
|
||||
babelPlugins: [
|
||||
// Stub Vue and CSS imports first to prevent parsing errors
|
||||
[path.join(__dirname, 'babel-plugin-stub-vue-imports.cjs')],
|
||||
// Module resolver to handle @ alias
|
||||
[
|
||||
'babel-plugin-module-resolver',
|
||||
{
|
||||
root: ['./'],
|
||||
alias: {
|
||||
'@': './src'
|
||||
}
|
||||
}
|
||||
],
|
||||
// Then TypeScript transformation with declare field support
|
||||
[
|
||||
'@babel/plugin-transform-typescript',
|
||||
{
|
||||
allowDeclareFields: true,
|
||||
onlyRemoveTypeImports: true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
2633
pnpm-lock.yaml
generated
2633
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,10 @@ import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { CORE_MENU_COMMANDS } from '../src/constants/coreMenuCommands'
|
||||
import { DESKTOP_DIALOGS } from '../src/constants/desktopDialogs'
|
||||
import { SERVER_CONFIG_ITEMS } from '../src/constants/serverConfig'
|
||||
import type { FormItem, SettingParams } from '../src/platform/settings/types'
|
||||
import type { ComfyCommandImpl } from '../src/stores/commandStore'
|
||||
import type { FormItem, SettingParams } from '../src/types/settingTypes'
|
||||
import { formatCamelCase, normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
import './setup-browser-globals.js'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const commandsPath = './src/locales/en/commands.json'
|
||||
@@ -48,15 +49,28 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
Array.from(allLabels).map((label) => [normalizeI18nKey(label), label])
|
||||
)
|
||||
|
||||
const allCommandsLocale = Object.fromEntries(
|
||||
commands.map((command) => [
|
||||
// Load existing commands to preserve Desktop commands
|
||||
const existingCommands = JSON.parse(fs.readFileSync(commandsPath, 'utf-8'))
|
||||
|
||||
// Filter out Desktop commands from existing commands
|
||||
const desktopCommands = Object.fromEntries(
|
||||
Object.entries(existingCommands).filter(([key]) =>
|
||||
key.startsWith('Comfy-Desktop')
|
||||
)
|
||||
)
|
||||
|
||||
const allCommandsLocale = Object.fromEntries([
|
||||
// Keep Desktop commands that aren't in the current collection
|
||||
...Object.entries(desktopCommands),
|
||||
// Add/update commands from current collection
|
||||
...commands.map((command) => [
|
||||
normalizeI18nKey(command.id),
|
||||
{
|
||||
label: command.label,
|
||||
tooltip: command.tooltip
|
||||
}
|
||||
])
|
||||
)
|
||||
])
|
||||
|
||||
// Settings
|
||||
const settings = await comfyPage.page.evaluate(() => {
|
||||
@@ -79,8 +93,21 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
}))
|
||||
})
|
||||
|
||||
const allSettingsLocale = Object.fromEntries(
|
||||
settings.map((setting) => [
|
||||
// Load existing settings to preserve Desktop settings
|
||||
const existingSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
|
||||
|
||||
// Filter out Desktop settings from existing settings
|
||||
const desktopSettings = Object.fromEntries(
|
||||
Object.entries(existingSettings).filter(([key]) =>
|
||||
key.startsWith('Comfy-Desktop')
|
||||
)
|
||||
)
|
||||
|
||||
const allSettingsLocale = Object.fromEntries([
|
||||
// Keep Desktop settings that aren't in the current collection
|
||||
...Object.entries(desktopSettings),
|
||||
// Add/update settings from current collection
|
||||
...settings.map((setting) => [
|
||||
normalizeI18nKey(setting.id),
|
||||
{
|
||||
name: setting.name,
|
||||
@@ -99,7 +126,7 @@ test('collect-i18n-general', async ({ comfyPage }) => {
|
||||
: undefined
|
||||
}
|
||||
])
|
||||
)
|
||||
])
|
||||
|
||||
const allSettingCategoriesLocale = Object.fromEntries(
|
||||
settings
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Setup browser globals before any other imports that might use them
|
||||
import * as fs from 'fs'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
@@ -5,6 +6,7 @@ import type { ComfyNodeDef } from '../src/schemas/nodeDefSchema'
|
||||
import type { ComfyApi } from '../src/scripts/api'
|
||||
import { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
import './setup-browser-globals.js'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -26,6 +28,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
})
|
||||
})
|
||||
|
||||
// Note: Don't mock the object_info API endpoint - let it hit the actual backend
|
||||
|
||||
const nodeDefs: ComfyNodeDefImpl[] = (
|
||||
Object.values(
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
@@ -41,6 +45,27 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
// If no node definitions were collected (e.g., running without backend),
|
||||
// create empty locale files to avoid build failures
|
||||
if (nodeDefs.length === 0) {
|
||||
console.warn('No node definitions found - creating empty locale files')
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
localePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes: {},
|
||||
nodeCategories: {}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify({}, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
|
||||
91
src/renderer/core/layout/TransformPane.vue
Normal file
91
src/renderer/core/layout/TransformPane.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useTransformState } from '@/renderer/core/layout/useTransformState'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
provide('transformState', {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
|
||||
if (nodeElement) {
|
||||
// TODO: Emit event for node interaction
|
||||
// Node interaction with nodeId will be handled in future implementation
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
rafStatusChange: [active: boolean]
|
||||
transformUpdate: [time: number]
|
||||
}>()
|
||||
|
||||
useCanvasTransformSync(props.canvas, syncWithCanvas, {
|
||||
onStart: () => emit('rafStatusChange', true),
|
||||
onUpdate: (duration) => emit('transformUpdate', duration),
|
||||
onStop: () => emit('rafStatusChange', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Allow pointer events on nodes */
|
||||
.transform-pane :deep([data-node-id]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
229
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
229
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* DOM-based slot registration with performance optimization
|
||||
*
|
||||
* Measures the actual DOM position of a Vue slot connector and registers it
|
||||
* into the LayoutStore so hit-testing and link rendering use the true position.
|
||||
*
|
||||
* Performance strategy:
|
||||
* - Cache slot offset relative to node (avoids DOM reads during drag)
|
||||
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
|
||||
* - Batch DOM reads via requestAnimationFrame
|
||||
* - Only remeasure on structural changes (resize, collapse, LOD)
|
||||
*/
|
||||
import {
|
||||
type Ref,
|
||||
type WatchStopHandle,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch
|
||||
} from 'vue'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
|
||||
|
||||
import { getSlotKey } from './slotIdentifier'
|
||||
|
||||
export type TransformState = {
|
||||
screenToCanvas: (p: LayoutPoint) => LayoutPoint
|
||||
}
|
||||
|
||||
// Shared RAF queue for batching measurements
|
||||
const measureQueue = new Set<() => void>()
|
||||
let rafId: number | null = null
|
||||
// Track mounted components to prevent execution on unmounted ones
|
||||
const mountedComponents = new WeakSet<object>()
|
||||
|
||||
function scheduleMeasurement(fn: () => void) {
|
||||
measureQueue.add(fn)
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
const batch = Array.from(measureQueue)
|
||||
measureQueue.clear()
|
||||
batch.forEach((measure) => measure())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupFunctions = new WeakMap<
|
||||
Ref<HTMLElement | null>,
|
||||
{
|
||||
stopWatcher?: WatchStopHandle
|
||||
handleResize?: () => void
|
||||
}
|
||||
>()
|
||||
|
||||
interface SlotRegistrationOptions {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
isInput: boolean
|
||||
element: Ref<HTMLElement | null>
|
||||
transform?: TransformState
|
||||
}
|
||||
|
||||
export function useDomSlotRegistration(options: SlotRegistrationOptions) {
|
||||
const { nodeId, slotIndex, isInput, element: elRef, transform } = options
|
||||
|
||||
// Early return if no nodeId
|
||||
if (!nodeId || nodeId === '') {
|
||||
return {
|
||||
remeasure: () => {}
|
||||
}
|
||||
}
|
||||
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
|
||||
// Track if this component is mounted
|
||||
const componentToken = {}
|
||||
|
||||
// Cached offset from node position (avoids DOM reads during drag)
|
||||
const cachedOffset = ref<LayoutPoint | null>(null)
|
||||
const lastMeasuredBounds = ref<DOMRect | null>(null)
|
||||
|
||||
// Measure DOM and cache offset (expensive, minimize calls)
|
||||
const measureAndCacheOffset = () => {
|
||||
// Skip if component was unmounted
|
||||
if (!mountedComponents.has(componentToken)) return
|
||||
|
||||
const el = elRef.value
|
||||
if (!el || !transform?.screenToCanvas) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
// Skip if bounds haven't changed significantly (within 0.5px)
|
||||
if (lastMeasuredBounds.value) {
|
||||
const prev = lastMeasuredBounds.value
|
||||
if (
|
||||
Math.abs(rect.left - prev.left) < 0.5 &&
|
||||
Math.abs(rect.top - prev.top) < 0.5 &&
|
||||
Math.abs(rect.width - prev.width) < 0.5 &&
|
||||
Math.abs(rect.height - prev.height) < 0.5
|
||||
) {
|
||||
return // No significant change - skip update
|
||||
}
|
||||
}
|
||||
|
||||
lastMeasuredBounds.value = rect
|
||||
|
||||
// Center of the visual connector (dot) in screen coords
|
||||
const centerScreen = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2
|
||||
}
|
||||
const centerCanvas = transform.screenToCanvas(centerScreen)
|
||||
|
||||
// Cache offset from node position for fast updates during drag
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (nodeLayout) {
|
||||
cachedOffset.value = {
|
||||
x: centerCanvas.x - nodeLayout.position.x,
|
||||
y: centerCanvas.y - nodeLayout.position.y
|
||||
}
|
||||
}
|
||||
|
||||
updateSlotPosition(centerCanvas)
|
||||
}
|
||||
|
||||
// Fast update using cached offset (no DOM read)
|
||||
const updateFromCachedOffset = () => {
|
||||
if (!cachedOffset.value) {
|
||||
// No cached offset yet, need to measure
|
||||
scheduleMeasurement(measureAndCacheOffset)
|
||||
return
|
||||
}
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!nodeLayout) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate absolute position from node position + cached offset
|
||||
const centerCanvas = {
|
||||
x: nodeLayout.position.x + cachedOffset.value.x,
|
||||
y: nodeLayout.position.y + cachedOffset.value.y
|
||||
}
|
||||
|
||||
updateSlotPosition(centerCanvas)
|
||||
}
|
||||
|
||||
// Update slot position in layout store
|
||||
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
|
||||
const size = LiteGraph.NODE_SLOT_HEIGHT
|
||||
const half = size / 2
|
||||
|
||||
layoutStore.updateSlotLayout(slotKey, {
|
||||
nodeId,
|
||||
index: slotIndex,
|
||||
type: isInput ? 'input' : 'output',
|
||||
position: { x: centerCanvas.x, y: centerCanvas.y },
|
||||
bounds: {
|
||||
x: centerCanvas.x - half,
|
||||
y: centerCanvas.y - half,
|
||||
width: size,
|
||||
height: size
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Mark component as mounted
|
||||
mountedComponents.add(componentToken)
|
||||
|
||||
// Initial measure after mount
|
||||
await nextTick()
|
||||
measureAndCacheOffset()
|
||||
|
||||
// Subscribe to node position changes for fast cached updates
|
||||
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
|
||||
|
||||
const stopWatcher = watch(
|
||||
nodeRef,
|
||||
(newLayout) => {
|
||||
if (newLayout) {
|
||||
// Node moved/resized - update using cached offset
|
||||
updateFromCachedOffset()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Store cleanup functions without type assertions
|
||||
const cleanup = cleanupFunctions.get(elRef) || {}
|
||||
cleanup.stopWatcher = stopWatcher
|
||||
|
||||
// Window resize - remeasure as viewport changed
|
||||
const handleResize = () => {
|
||||
scheduleMeasurement(measureAndCacheOffset)
|
||||
}
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
cleanup.handleResize = handleResize
|
||||
cleanupFunctions.set(elRef, cleanup)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Mark component as unmounted
|
||||
mountedComponents.delete(componentToken)
|
||||
|
||||
// Clean up watchers and listeners
|
||||
const cleanup = cleanupFunctions.get(elRef)
|
||||
if (cleanup) {
|
||||
if (cleanup.stopWatcher) cleanup.stopWatcher()
|
||||
if (cleanup.handleResize) {
|
||||
window.removeEventListener('resize', cleanup.handleResize)
|
||||
}
|
||||
cleanupFunctions.delete(elRef)
|
||||
}
|
||||
|
||||
// Remove from layout store
|
||||
layoutStore.deleteSlotLayout(slotKey)
|
||||
|
||||
// Remove from measurement queue if pending
|
||||
measureQueue.delete(measureAndCacheOffset)
|
||||
})
|
||||
|
||||
return {
|
||||
// Expose for forced remeasure on structural changes
|
||||
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
|
||||
}
|
||||
}
|
||||
246
src/renderer/core/layout/useTransformState.ts
Normal file
246
src/renderer/core/layout/useTransformState.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Composable for managing transform state synchronized with LiteGraph canvas
|
||||
*
|
||||
* This composable is a critical part of the hybrid rendering architecture that
|
||||
* allows Vue components to render in perfect alignment with LiteGraph's canvas.
|
||||
*
|
||||
* ## Core Concept
|
||||
*
|
||||
* LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
|
||||
* Vue components need to render nodes on top of this canvas. The challenge is
|
||||
* synchronizing the coordinate systems:
|
||||
*
|
||||
* - LiteGraph: Uses canvas coordinates with its own transform matrix
|
||||
* - Vue/DOM: Uses screen coordinates with CSS transforms
|
||||
*
|
||||
* ## Solution: Transform Container Pattern
|
||||
*
|
||||
* Instead of transforming individual nodes (O(n) complexity), we:
|
||||
* 1. Mirror LiteGraph's transform matrix to a single CSS container
|
||||
* 2. Place all Vue nodes as children with simple absolute positioning
|
||||
* 3. Achieve O(1) transform updates regardless of node count
|
||||
*
|
||||
* ## Coordinate Systems
|
||||
*
|
||||
* - **Canvas coordinates**: LiteGraph's internal coordinate system
|
||||
* - **Screen coordinates**: Browser's viewport coordinate system
|
||||
* - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - GPU acceleration via CSS transforms
|
||||
* - No layout thrashing (only transform changes)
|
||||
* - Efficient viewport culling calculations
|
||||
* - Scales to 1000+ nodes while maintaining 60 FPS
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { camera, transformStyle, canvasToScreen } = useTransformState()
|
||||
*
|
||||
* // In template
|
||||
* <div :style="transformStyle">
|
||||
* <NodeComponent
|
||||
* v-for="node in nodes"
|
||||
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
* />
|
||||
* </div>
|
||||
*
|
||||
* // Convert coordinates
|
||||
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
|
||||
* ```
|
||||
*/
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Camera {
|
||||
x: number
|
||||
y: number
|
||||
z: number // scale/zoom
|
||||
}
|
||||
|
||||
export const useTransformState = () => {
|
||||
// Reactive state mirroring LiteGraph's canvas transform
|
||||
const camera = reactive<Camera>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1
|
||||
})
|
||||
|
||||
// Computed transform string for CSS
|
||||
const transformStyle = computed(() => ({
|
||||
// Match LiteGraph DragAndScale.toCanvasContext():
|
||||
// ctx.scale(scale); ctx.translate(offset)
|
||||
// CSS applies right-to-left, so "scale() translate()" -> translate first, then scale
|
||||
// Effective mapping: screen = (canvas + offset) * scale
|
||||
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
/**
|
||||
* Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
|
||||
*
|
||||
* Called every frame via RAF to ensure Vue components stay aligned with canvas.
|
||||
* This is the heart of the hybrid rendering system - it bridges the gap between
|
||||
* LiteGraph's canvas transforms and Vue's reactive system.
|
||||
*
|
||||
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
|
||||
*/
|
||||
const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
if (!canvas || !canvas.ds) return
|
||||
|
||||
// Mirror LiteGraph's transform state to Vue's reactive state
|
||||
// ds.offset = pan offset, ds.scale = zoom level
|
||||
camera.x = canvas.ds.offset[0]
|
||||
camera.y = canvas.ds.offset[1]
|
||||
camera.z = canvas.ds.scale || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts canvas coordinates to screen coordinates
|
||||
*
|
||||
* Applies the same transform that LiteGraph uses for rendering.
|
||||
* Essential for positioning Vue components to align with canvas elements.
|
||||
*
|
||||
* Formula: screen = (canvas + offset) * scale
|
||||
*
|
||||
* @param point - Point in canvas coordinate system
|
||||
* @returns Point in screen coordinate system
|
||||
*/
|
||||
const canvasToScreen = (point: Point): Point => {
|
||||
return {
|
||||
x: (point.x + camera.x) * camera.z,
|
||||
y: (point.y + camera.y) * camera.z
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts screen coordinates to canvas coordinates
|
||||
*
|
||||
* Inverse of canvasToScreen. Useful for hit testing and converting
|
||||
* mouse events back to canvas space.
|
||||
*
|
||||
* Formula: canvas = screen / scale - offset
|
||||
*
|
||||
* @param point - Point in screen coordinate system
|
||||
* @returns Point in canvas coordinate system
|
||||
*/
|
||||
const screenToCanvas = (point: Point): Point => {
|
||||
return {
|
||||
x: point.x / camera.z - camera.x,
|
||||
y: point.y / camera.z - camera.y
|
||||
}
|
||||
}
|
||||
|
||||
// Get node's screen bounds for culling
|
||||
const getNodeScreenBounds = (
|
||||
pos: ArrayLike<number>,
|
||||
size: ArrayLike<number>
|
||||
): DOMRect => {
|
||||
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
|
||||
const width = size[0] * camera.z
|
||||
const height = size[1] * camera.z
|
||||
|
||||
return new DOMRect(topLeft.x, topLeft.y, width, height)
|
||||
}
|
||||
|
||||
// Helper: Calculate zoom-adjusted margin for viewport culling
|
||||
const calculateAdjustedMargin = (baseMargin: number): number => {
|
||||
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
|
||||
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
|
||||
return baseMargin
|
||||
}
|
||||
|
||||
// Helper: Check if node is too small to be visible at current zoom
|
||||
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
|
||||
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
|
||||
return nodeScreenSize < 4
|
||||
}
|
||||
|
||||
// Helper: Calculate expanded viewport bounds with margin
|
||||
const getExpandedViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
return {
|
||||
left: -marginX,
|
||||
right: viewport.width + marginX,
|
||||
top: -marginY,
|
||||
bottom: viewport.height + marginY
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Test if node intersects with viewport bounds
|
||||
const testViewportIntersection = (
|
||||
screenPos: { x: number; y: number },
|
||||
nodeSize: ArrayLike<number>,
|
||||
bounds: { left: number; right: number; top: number; bottom: number }
|
||||
): boolean => {
|
||||
const nodeRight = screenPos.x + nodeSize[0] * camera.z
|
||||
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
|
||||
|
||||
return !(
|
||||
nodeRight < bounds.left ||
|
||||
screenPos.x > bounds.right ||
|
||||
nodeBottom < bounds.top ||
|
||||
screenPos.y > bounds.bottom
|
||||
)
|
||||
}
|
||||
|
||||
// Check if node is within viewport with frustum and size-based culling
|
||||
const isNodeInViewport = (
|
||||
nodePos: ArrayLike<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
): boolean => {
|
||||
// Early exit for tiny nodes
|
||||
if (isNodeTooSmall(nodeSize)) return false
|
||||
|
||||
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
|
||||
const adjustedMargin = calculateAdjustedMargin(margin)
|
||||
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
|
||||
|
||||
return testViewportIntersection(screenPos, nodeSize, bounds)
|
||||
}
|
||||
|
||||
// Get viewport bounds in canvas coordinates (for spatial index queries)
|
||||
const getViewportBounds = (
|
||||
viewport: { width: number; height: number },
|
||||
margin: number = 0.2
|
||||
) => {
|
||||
const marginX = viewport.width * margin
|
||||
const marginY = viewport.height * margin
|
||||
|
||||
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
|
||||
const bottomRight = screenToCanvas({
|
||||
x: viewport.width + marginX,
|
||||
y: viewport.height + marginY
|
||||
})
|
||||
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
camera: readonly(camera),
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
getNodeScreenBounds,
|
||||
isNodeInViewport,
|
||||
getViewportBounds
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user