mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-29 16:57:30 +00:00
Compare commits
8 Commits
refactor/a
...
fix/collap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c415d101 | ||
|
|
81e6282599 | ||
|
|
b8480f889e | ||
|
|
b49ea9fabd | ||
|
|
8da4640a76 | ||
|
|
3a0c718a17 | ||
|
|
5dfcc59f13 | ||
|
|
6823097bc1 |
2
.github/actions/setup-frontend/action.yaml
vendored
2
.github/actions/setup-frontend/action.yaml
vendored
@@ -13,8 +13,6 @@ runs:
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
@@ -76,8 +74,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -85,8 +85,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -203,8 +201,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
4
.github/workflows/release-draft-create.yaml
vendored
4
.github/workflows/release-draft-create.yaml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
2
.github/workflows/release-npm-types.yaml
vendored
@@ -76,8 +76,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
4
.github/workflows/release-pypi-dev.yaml
vendored
4
.github/workflows/release-pypi-dev.yaml
vendored
@@ -16,10 +16,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
2
.github/workflows/release-version-bump.yaml
vendored
@@ -144,8 +144,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -52,8 +52,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -30,8 +30,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
116
browser_tests/assets/selection/subgraph-with-regular-node.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -31,10 +31,14 @@ export class VueNodeHelpers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locator for a Vue node by the node's title (displayed name in the header)
|
||||
* Get locator for Vue nodes by the node's title (displayed name in the header).
|
||||
* Matches against the actual title element, not the full node body.
|
||||
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
|
||||
*/
|
||||
getNodeByTitle(title: string): Locator {
|
||||
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
|
||||
return this.page.locator('[data-node-id]').filter({
|
||||
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
108
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
108
browser_tests/tests/collapsedNodeLinks.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
const SLOT_BOUNDS_MARGIN = 20
|
||||
const SUBGRAPH_ID = '2'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
async function assertSlotsWithinNodeBounds(page: Page, nodeId: string) {
|
||||
await page
|
||||
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
|
||||
.first()
|
||||
.waitFor()
|
||||
|
||||
const result = await page.evaluate(
|
||||
({ nodeId, margin }) => {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
) as HTMLElement | null
|
||||
if (!nodeEl) return { ok: false, violations: ['node element not found'] }
|
||||
|
||||
const nodeRect = nodeEl.getBoundingClientRect()
|
||||
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
|
||||
const violations: string[] = []
|
||||
|
||||
for (const slotEl of slotEls) {
|
||||
const slotRect = slotEl.getBoundingClientRect()
|
||||
const cx = slotRect.left + slotRect.width / 2 - nodeRect.left
|
||||
const cy = slotRect.top + slotRect.height / 2 - nodeRect.top
|
||||
|
||||
if (cx < -margin || cx > nodeRect.width + margin)
|
||||
violations.push(`slot X=${cx} outside width=${nodeRect.width}`)
|
||||
if (cy < -margin || cy > nodeRect.height + margin)
|
||||
violations.push(`slot Y=${cy} outside height=${nodeRect.height}`)
|
||||
}
|
||||
|
||||
return { ok: violations.length === 0, violations }
|
||||
},
|
||||
{ nodeId, margin: SLOT_BOUNDS_MARGIN }
|
||||
)
|
||||
|
||||
expect(
|
||||
result.ok,
|
||||
`Slot positions out of bounds: ${result.violations?.join(', ')}`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Collapsed node link positions',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('link endpoints stay within collapsed node bounds', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
|
||||
})
|
||||
|
||||
test('links follow collapsed node after drag', async ({ comfyPage }) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box!.x + box!.width / 2 + 200,
|
||||
box!.y + box!.height / 2 + 100,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
|
||||
})
|
||||
|
||||
test('links recover correct positions after expand', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
await node.toggleCollapse()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
@@ -345,4 +346,71 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'template cards display overlay tags correctly',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'tagged-template',
|
||||
title: 'Tagged Template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'A template with tags.',
|
||||
tags: ['Relight', 'Image Edit']
|
||||
},
|
||||
{
|
||||
name: 'no-tags',
|
||||
title: 'No Tags',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'A template without tags.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const taggedCard = comfyPage.page.getByTestId(
|
||||
TestIds.templates.workflowCard('tagged-template')
|
||||
)
|
||||
await expect(taggedCard).toBeVisible({ timeout: 5000 })
|
||||
await expect(taggedCard.getByText('Relight')).toBeVisible()
|
||||
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
|
||||
|
||||
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-cards-with-overlay-tags.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -75,6 +75,30 @@ await page.evaluate(() => {
|
||||
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
|
||||
```
|
||||
|
||||
## Assertion Best Practices
|
||||
|
||||
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
|
||||
|
||||
```typescript
|
||||
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
|
||||
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
|
||||
4
|
||||
)
|
||||
await node.move(100, 200)
|
||||
|
||||
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
|
||||
expect.soft(menuItem1).toBeVisible()
|
||||
expect.soft(menuItem2).toBeVisible()
|
||||
expect.soft(menuItem3).toBeVisible()
|
||||
|
||||
// ❌ Bare expect on a precondition — no context when it fails
|
||||
expect(node.widgets).toHaveLength(4)
|
||||
```
|
||||
|
||||
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
|
||||
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
|
||||
- Prefer Playwright's built-in message parameter over custom error classes
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tags are respected by config:
|
||||
@@ -86,6 +110,7 @@ Tags are respected by config:
|
||||
|
||||
- Check `browser_tests/assets/` for test data and fixtures
|
||||
- Use realistic ComfyUI workflows for E2E tests
|
||||
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
|
||||
|
||||
## Running Tests
|
||||
|
||||
|
||||
15
package.json
15
package.json
@@ -36,7 +36,6 @@
|
||||
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
|
||||
"locale": "lobe-i18n locale",
|
||||
"oxlint": "oxlint src --type-aware",
|
||||
"preinstall": "pnpm dlx only-allow pnpm",
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook",
|
||||
@@ -200,11 +199,21 @@
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
"node": "24.x",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@firebase/util",
|
||||
"core-js",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SquareChip from '../chip/SquareChip.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import CardBottom from './CardBottom.vue'
|
||||
import CardContainer from './CardContainer.vue'
|
||||
import CardDescription from './CardDescription.vue'
|
||||
@@ -174,7 +174,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
Button,
|
||||
SquareChip
|
||||
Tag
|
||||
},
|
||||
setup() {
|
||||
const favorited = ref(false)
|
||||
@@ -218,7 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template v-if="args.showTopLeft" #top-left>
|
||||
<SquareChip label="Featured" />
|
||||
<Tag label="Featured" />
|
||||
</template>
|
||||
|
||||
<template v-if="args.showTopRight" #top-right>
|
||||
@@ -238,17 +238,17 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template v-if="args.showBottomLeft" #bottom-left>
|
||||
<SquareChip label="New" />
|
||||
<Tag label="New" />
|
||||
</template>
|
||||
|
||||
<template v-if="args.showBottomRight" #bottom-right>
|
||||
<SquareChip v-if="args.showFileType" :label="args.fileType" />
|
||||
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
|
||||
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
|
||||
<Tag v-if="args.showFileType" :label="args.fileType" />
|
||||
<Tag v-if="args.showFileSize" :label="args.fileSize" />
|
||||
<Tag v-for="tag in args.tags" :key="tag" :label="tag">
|
||||
<template v-if="tag === 'LoRA'" #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</Tag>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import SquareChip from './SquareChip.vue'
|
||||
|
||||
const meta: Meta<typeof SquareChip> = {
|
||||
title: 'Components/SquareChip',
|
||||
component: SquareChip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: {
|
||||
control: 'text',
|
||||
defaultValue: 'Tag'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const TagList: Story = {
|
||||
render: () => ({
|
||||
components: { SquareChip },
|
||||
template: `
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<SquareChip label="JavaScript" />
|
||||
<SquareChip label="TypeScript" />
|
||||
<SquareChip label="Vue.js" />
|
||||
<SquareChip label="React" />
|
||||
<SquareChip label="Node.js" />
|
||||
<SquareChip label="Python" />
|
||||
<SquareChip label="Docker" />
|
||||
<SquareChip label="Kubernetes" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<div :class="chipClasses">
|
||||
<slot name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light' | 'gray'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: cn('bg-base-background/50 text-base-foreground backdrop-blur-[2px]'),
|
||||
gray: cn(
|
||||
'bg-modal-card-tag-background text-base-foreground backdrop-blur-[2px]'
|
||||
)
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
return cn(baseClasses, variantStyles[variant])
|
||||
})
|
||||
</script>
|
||||
104
src/components/chip/Tag.stories.ts
Normal file
104
src/components/chip/Tag.stories.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Tag from './Tag.vue'
|
||||
|
||||
const meta: Meta<typeof Tag> = {
|
||||
title: 'Components/Tag',
|
||||
component: Tag,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
shape: {
|
||||
control: 'select',
|
||||
options: ['square', 'rounded', 'overlay']
|
||||
},
|
||||
state: {
|
||||
control: 'select',
|
||||
options: ['default', 'unselected', 'selected']
|
||||
},
|
||||
removable: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
label: 'Tag',
|
||||
shape: 'square',
|
||||
state: 'default',
|
||||
removable: false
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Rounded: Story = {
|
||||
args: {
|
||||
label: 'Tag',
|
||||
shape: 'rounded'
|
||||
}
|
||||
}
|
||||
|
||||
export const Unselected: Story = {
|
||||
args: {
|
||||
label: 'Tag',
|
||||
state: 'unselected'
|
||||
}
|
||||
}
|
||||
|
||||
export const Removable: Story = {
|
||||
args: {
|
||||
label: 'Tag',
|
||||
removable: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { Tag },
|
||||
template: `
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Square</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag label="Default" />
|
||||
<Tag label="Unselected" state="unselected" />
|
||||
<Tag label="Removable" removable />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-xs text-muted-foreground">Rounded</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag label="Default" shape="rounded" />
|
||||
<Tag label="Unselected" shape="rounded" state="unselected" />
|
||||
<Tag label="Removable" shape="rounded" removable />
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-zinc-800 p-2 rounded">
|
||||
<p class="mb-2 text-xs text-muted-foreground">Overlay (on images)</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag label="png" shape="overlay" />
|
||||
<Tag label="1.2 MB" shape="overlay" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const TagList: Story = {
|
||||
render: () => ({
|
||||
components: { Tag },
|
||||
template: `
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Tag label="JavaScript" />
|
||||
<Tag label="TypeScript" />
|
||||
<Tag label="Vue.js" />
|
||||
<Tag label="React" />
|
||||
<Tag label="Node.js" />
|
||||
<Tag label="Python" />
|
||||
<Tag label="Docker" />
|
||||
<Tag label="Kubernetes" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
66
src/components/chip/Tag.test.ts
Normal file
66
src/components/chip/Tag.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Tag from './Tag.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { remove: 'Remove' } } }
|
||||
})
|
||||
|
||||
function renderTag(
|
||||
props: {
|
||||
label: string
|
||||
shape?: 'square' | 'rounded'
|
||||
removable?: boolean
|
||||
onRemove?: (...args: unknown[]) => void
|
||||
},
|
||||
options?: { slots?: Record<string, string> }
|
||||
) {
|
||||
return render(Tag, {
|
||||
props,
|
||||
global: { plugins: [i18n] },
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
describe('Tag', () => {
|
||||
it('renders label text', () => {
|
||||
renderTag({ label: 'JavaScript' })
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show remove button by default', () => {
|
||||
renderTag({ label: 'Test' })
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows remove button when removable', () => {
|
||||
renderTag({ label: 'Test', removable: true })
|
||||
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits remove event when remove button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRemove = vi.fn()
|
||||
renderTag({ label: 'Test', removable: true, onRemove })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders icon slot content', () => {
|
||||
renderTag(
|
||||
{ label: 'LoRA' },
|
||||
{
|
||||
slots: {
|
||||
icon: '<i data-testid="tag-icon" class="icon-[lucide--folder]" />'
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(screen.getByTestId('tag-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
46
src/components/chip/Tag.vue
Normal file
46
src/components/chip/Tag.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { tagVariants } from './tag.variants'
|
||||
import type { TagVariants } from './tag.variants'
|
||||
|
||||
const {
|
||||
label,
|
||||
shape = 'square',
|
||||
state = 'default',
|
||||
removable = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
label: string
|
||||
shape?: TagVariants['shape']
|
||||
state?: TagVariants['state']
|
||||
removable?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [event: Event]
|
||||
}>()
|
||||
|
||||
const tagClass = computed(() =>
|
||||
cn(tagVariants({ shape, state, removable }), className)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="tagClass">
|
||||
<slot name="icon" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
:aria-label="$t('g.remove')"
|
||||
class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-white/10"
|
||||
@click.stop="emit('remove', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
29
src/components/chip/tag.variants.ts
Normal file
29
src/components/chip/tag.variants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const tagVariants = cva({
|
||||
base: 'inline-flex h-6 shrink-0 items-center justify-center gap-1 text-xs',
|
||||
variants: {
|
||||
shape: {
|
||||
square: 'rounded-sm bg-modal-card-tag-background',
|
||||
rounded: 'rounded-full bg-secondary-background',
|
||||
overlay: 'rounded-sm bg-zinc-500/40 text-white/90'
|
||||
},
|
||||
state: {
|
||||
default: 'text-modal-card-tag-foreground',
|
||||
unselected: 'text-muted-foreground opacity-70',
|
||||
selected: 'text-modal-card-tag-foreground'
|
||||
},
|
||||
removable: {
|
||||
true: 'py-1 pr-1 pl-2',
|
||||
false: 'px-2 py-1'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
shape: 'square',
|
||||
state: 'default',
|
||||
removable: false
|
||||
}
|
||||
})
|
||||
|
||||
export type TagVariants = VariantProps<typeof tagVariants>
|
||||
@@ -265,10 +265,11 @@
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<template v-if="template.tags && template.tags.length > 0">
|
||||
<SquareChip
|
||||
<Tag
|
||||
v-for="tag in template.tags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
shape="overlay"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -402,7 +403,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
|
||||
@@ -99,13 +99,13 @@
|
||||
</Button>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip label="png" />
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<Tag label="png" shape="overlay" />
|
||||
<Tag label="1.2 MB" shape="overlay" />
|
||||
<Tag label="LoRA" shape="overlay">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--folder]" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</Tag>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
@@ -129,7 +129,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
|
||||
@@ -6,7 +6,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import SquareChip from '@/components/chip/SquareChip.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
@@ -76,7 +76,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
CardContainer,
|
||||
CardTop,
|
||||
CardBottom,
|
||||
SquareChip
|
||||
Tag
|
||||
},
|
||||
setup() {
|
||||
const t = (k: string) => k
|
||||
@@ -276,13 +276,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
</Button>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip label="png" />
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<Tag label="png" />
|
||||
<Tag label="1.2 MB" />
|
||||
<Tag label="LoRA">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</Tag>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
@@ -392,13 +392,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
|
||||
</Button>
|
||||
</template>
|
||||
<template #bottom-right>
|
||||
<SquareChip label="png" />
|
||||
<SquareChip label="1.2 MB" />
|
||||
<SquareChip label="LoRA">
|
||||
<Tag label="png" />
|
||||
<Tag label="1.2 MB" />
|
||||
<Tag label="LoRA">
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--folder] size-3" />
|
||||
</template>
|
||||
</SquareChip>
|
||||
</Tag>
|
||||
</template>
|
||||
</CardTop>
|
||||
</template>
|
||||
|
||||
@@ -19,11 +19,27 @@ import {
|
||||
} from './useSlotElementTracking'
|
||||
|
||||
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
|
||||
const mockCanvasState = vi.hoisted(() => ({
|
||||
canvas: {} as object | null
|
||||
}))
|
||||
const mockClientPosToCanvasPos = vi.hoisted(() =>
|
||||
vi.fn(([x, y]: [number, number]) => [x * 0.5, y * 0.5] as [number, number])
|
||||
)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: mockGraph, setDirty: vi.fn() } }
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasState
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: mockClientPosToCanvasPos
|
||||
})
|
||||
}))
|
||||
|
||||
const NODE_ID = 'test-node'
|
||||
const SLOT_INDEX = 0
|
||||
|
||||
@@ -45,9 +61,10 @@ function createWrapperComponent(type: 'input' | 'output') {
|
||||
})
|
||||
}
|
||||
|
||||
function createSlotElement(): HTMLElement {
|
||||
function createSlotElement(collapsed = false): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
container.dataset.nodeId = NODE_ID
|
||||
if (collapsed) container.dataset.collapsed = ''
|
||||
container.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -113,6 +130,8 @@ describe('useSlotElementTracking', () => {
|
||||
actor: 'test'
|
||||
})
|
||||
mockGraph._nodes = [{ id: 1 }]
|
||||
mockCanvasState.canvas = {}
|
||||
mockClientPosToCanvasPos.mockClear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -251,4 +270,57 @@ describe('useSlotElementTracking', () => {
|
||||
|
||||
expect(batchUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('collapsed node slot sync', () => {
|
||||
function registerCollapsedSlot() {
|
||||
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
|
||||
const slotEl = createSlotElement(true)
|
||||
|
||||
const registryStore = useNodeSlotRegistryStore()
|
||||
const node = registryStore.ensureNode(NODE_ID)
|
||||
node.slots.set(slotKey, {
|
||||
el: slotEl,
|
||||
index: SLOT_INDEX,
|
||||
type: 'input',
|
||||
cachedOffset: { x: 50, y: 60 }
|
||||
})
|
||||
|
||||
return { slotKey, node }
|
||||
}
|
||||
|
||||
it('uses clientPosToCanvasPos for collapsed nodes', () => {
|
||||
const { slotKey } = registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
// Slot element center: (10 + 10/2, 30 + 10/2) = (15, 35)
|
||||
const screenCenter: [number, number] = [15, 35]
|
||||
expect(mockClientPosToCanvasPos).toHaveBeenCalledWith(screenCenter)
|
||||
|
||||
// Mock returns x*0.5, y*0.5
|
||||
const layout = layoutStore.getSlotLayout(slotKey)
|
||||
expect(layout).not.toBeNull()
|
||||
expect(layout!.position.x).toBe(screenCenter[0] * 0.5)
|
||||
expect(layout!.position.y).toBe(screenCenter[1] * 0.5)
|
||||
})
|
||||
|
||||
it('clears cachedOffset for collapsed nodes', () => {
|
||||
const { slotKey, node } = registerCollapsedSlot()
|
||||
const entry = node.slots.get(slotKey)!
|
||||
expect(entry.cachedOffset).toBeDefined()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(entry.cachedOffset).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defers sync when canvas is not initialized', () => {
|
||||
mockCanvasState.canvas = null
|
||||
registerCollapsedSlot()
|
||||
|
||||
syncNodeSlotLayoutsFromDOM(NODE_ID)
|
||||
|
||||
expect(mockClientPosToCanvasPos).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -134,11 +136,26 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
.value?.el.closest('[data-node-id]')
|
||||
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
|
||||
const nodeRect = nodeEl?.getBoundingClientRect()
|
||||
|
||||
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
|
||||
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
|
||||
const isCollapsed = nodeEl?.dataset.collapsed != null
|
||||
const effectiveScale =
|
||||
nodeRect && nodeLayout.size.width > 0
|
||||
!isCollapsed && nodeRect && nodeLayout.size.width > 0
|
||||
? nodeRect.width / nodeLayout.size.width
|
||||
: 0
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const conv =
|
||||
isCollapsed && canvasStore.canvas
|
||||
? useSharedCanvasPositionConversion()
|
||||
: null
|
||||
|
||||
if (isCollapsed && !conv) {
|
||||
scheduleSlotLayoutSync(nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
const batch: Array<{ key: string; layout: SlotLayout }> = []
|
||||
|
||||
for (const [slotKey, entry] of node.slots) {
|
||||
@@ -155,22 +172,30 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
|
||||
rect.top + rect.height / 2
|
||||
]
|
||||
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
let centerCanvas: { x: number; y: number }
|
||||
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
if (conv) {
|
||||
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
|
||||
centerCanvas = { x: cx, y: cy }
|
||||
entry.cachedOffset = undefined
|
||||
} else {
|
||||
if (!nodeRect || effectiveScale <= 0) continue
|
||||
|
||||
const centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
// DOM-relative measurement: compute offset from the node element's
|
||||
// top-left corner in canvas units. The node element is rendered at
|
||||
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
|
||||
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
|
||||
entry.cachedOffset = {
|
||||
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
|
||||
y:
|
||||
(screenCenter[1] - nodeRect.top) / effectiveScale -
|
||||
LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
centerCanvas = {
|
||||
x: nodeLayout.position.x + entry.cachedOffset.x,
|
||||
y: nodeLayout.position.y + entry.cachedOffset.y
|
||||
}
|
||||
}
|
||||
|
||||
const nextLayout = createSlotLayout({
|
||||
|
||||
@@ -15,8 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user