mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 01:27:23 +00:00
Compare commits
2 Commits
DynamicGro
...
drjkl/arch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b75e575f | ||
|
|
df40b2c8fa |
@@ -33,15 +33,15 @@ Flag:
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
### Dedicated Stores and Data/Behavior Separation
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
|
||||
63
.github/workflows/cla.yml
vendored
63
.github/workflows/cla.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read # 'read' is enough because signatures live in a REMOTE repo
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT required to write to the centralized signatures repo.
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
# Where the CLA document lives (shown to contributors)
|
||||
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
|
||||
|
||||
# Centralized signature storage
|
||||
remote-organization-name: comfy-org
|
||||
remote-repository-name: comfy-cla
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,coderabbitai[bot],comfy-pr-bot,dependabot[bot],github-actions[bot],copilot-swe-agent[bot],devin-ai-integration[bot],*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
🎉 Thank you for your contribution, we really appreciate it! 🎉
|
||||
|
||||
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
|
||||
|
||||
- Confirm that you own your contribution.
|
||||
- Keep the right to reuse your own code.
|
||||
- Grant us a copyright license to include and share it within our projects.
|
||||
|
||||
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
|
||||
|
||||
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
|
||||
|
||||
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.
|
||||
@@ -246,7 +246,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
@@ -56,16 +56,12 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,10 +112,6 @@ export const TestIds = {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
card: 'asset-card'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
|
||||
@@ -223,23 +223,4 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should focus keybindings search when opening manage shortcuts', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||
).toBeFocused()
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Settings...')
|
||||
).not.toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
|
||||
|
||||
interface WidgetSnapshot {
|
||||
type: string
|
||||
value: string
|
||||
hasLayout: boolean
|
||||
}
|
||||
|
||||
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ nodeId, widgetName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
|
||||
|
||||
return {
|
||||
type: widget?.type ?? '',
|
||||
value: String(widget?.value ?? ''),
|
||||
hasLayout: widget?.last_y != null
|
||||
}
|
||||
},
|
||||
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Promoted subgraph asset widgets',
|
||||
{ tag: ['@cloud', '@canvas', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('legacy asset browser selection updates the promoted host widget value', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getHostWidgetSnapshot(comfyPage.page))
|
||||
.toMatchObject({
|
||||
type: 'asset',
|
||||
hasLayout: true
|
||||
})
|
||||
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
|
||||
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
|
||||
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
|
||||
await hostNode.centerOnNode()
|
||||
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
|
||||
await promotedWidget.click()
|
||||
|
||||
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const assetCard = modal
|
||||
.getByTestId(TestIds.assets.card)
|
||||
.filter({ hasText: SELECTED_MODEL })
|
||||
.first()
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
|
||||
)
|
||||
.toBe(SELECTED_MODEL)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
@@ -98,225 +95,4 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
|
||||
// widening the race window so a guard regression deterministically surfaces.
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
graph.onNodeRemoved = originalNodeRemoved
|
||||
canvas.onSelectionChange = originalSelectionChange
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
|
||||
|
||||
// Defers only the legacy selection-change callback, so the detached host
|
||||
// node lingers in the reactive selection while onNodeRemoved still runs
|
||||
// normally and clears it from the canvas. This isolates the panel render
|
||||
// path: a panel mounted during this window reads the stale selection.
|
||||
async function deferSelectionChange(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<DeferredHandlers> {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const original = canvas.onSelectionChange
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => original?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
canvas.onSelectionChange = original
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || text.endsWith('has no graph')
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function reopenRightSidePanel(comfyPage: ComfyPage) {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Unpacks the subgraph behind deferred teardown, runs an optional
|
||||
// interaction while the node is detached but not yet cleaned up, then
|
||||
// drains the deferred handlers and reports any NullGraphErrors seen.
|
||||
async function unpackAndCaptureNullGraphErrors(
|
||||
comfyPage: ComfyPage,
|
||||
options: {
|
||||
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
|
||||
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
const deferred = await options.defer(comfyPage)
|
||||
try {
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await options.duringWindow?.(comfyPage)
|
||||
await deferred.evaluate((handlers) => handlers.drain())
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
await deferred.evaluate((handlers) => handlers.restore())
|
||||
await deferred.dispose()
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,30 +335,6 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test('pointerCancel stops autopan', async ({ comfyPage }) => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
|
||||
const getOffset = () => comfyPage.canvasOps.getOffset()
|
||||
const initialOffset = await getOffset()
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
|
||||
|
||||
await test.step('move outside pan range and cancel drag', async () => {
|
||||
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
|
||||
await ksampler.header.evaluate((node) =>
|
||||
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
)
|
||||
})
|
||||
|
||||
const secondaryOffset = await getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -6,6 +6,21 @@ Date: 2026-03-23
|
||||
|
||||
Proposed
|
||||
|
||||
### Amendment (2026-06-19, PR 12617)
|
||||
|
||||
The single central registry this ADR calls the "World" was superseded during
|
||||
implementation. Runtime entity data is held in dedicated Pinia stores keyed by
|
||||
string IDs — `widgetValueStore`, `domWidgetStore`, `layoutStore`,
|
||||
`nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`.
|
||||
Widget values are keyed by `WidgetId` (`graphId:nodeId:name`, see
|
||||
`src/types/widgetId.ts`); the `world/*` layer (`widgetValueIO`, `entityIds`,
|
||||
`brand`, `WidgetEntityId`) was deleted. The ECS principles below still hold —
|
||||
plain-data components, separation of data from behavior, command-driven
|
||||
mutation, and no god-object growth — realized across those stores. Where the
|
||||
text below says "the World," read "the set of dedicated stores"; where it shows
|
||||
`world.getComponent(id, Component)`, read the matching store getter (for
|
||||
example `widgetValueStore.getWidget(widgetId)`).
|
||||
|
||||
## Context
|
||||
|
||||
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
|
||||
@@ -40,7 +55,7 @@ Six entity kinds, each with a branded ID type:
|
||||
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
|
||||
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
|
||||
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
|
||||
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
|
||||
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetId` |
|
||||
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
|
||||
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
|
||||
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
|
||||
@@ -54,7 +69,6 @@ Each entity kind gets a nominal/branded type wrapping its underlying primitive.
|
||||
```ts
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
@@ -63,7 +77,12 @@ type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' }
|
||||
```
|
||||
|
||||
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
|
||||
> **Amended (PR 12617):** Widgets are keyed by a branded composite **string**,
|
||||
> `WidgetId = graphId:nodeId:name` (`src/types/widgetId.ts`), rather than a
|
||||
> synthetic numeric `WidgetEntityId`. The composite stays self-documenting and
|
||||
> survives renames at the store layer. The numeric per-kind brands above for
|
||||
> Node/Link/Reroute/Group remain aspirational and unshipped; treat them as
|
||||
> design intent. Slots have no independent ID yet.
|
||||
|
||||
### Component Decomposition
|
||||
|
||||
@@ -139,18 +158,24 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
|
||||
| `GroupVisual` | `color` |
|
||||
| `GroupChildren` | child entity refs (nodes, reroutes) |
|
||||
|
||||
### World
|
||||
### Dedicated stores
|
||||
|
||||
A central registry (the "World") maps entity IDs to their component sets. One
|
||||
World exists per workflow instance, containing all entities across all nesting
|
||||
levels. Each entity carries a `graphScope` identifier linking it to its
|
||||
containing graph. The World also maintains a scope registry mapping each
|
||||
`graphId` to its parent (or null for the root graph).
|
||||
Component data lives in a set of dedicated Pinia stores, each owning one
|
||||
concern and keyed by a string ID that embeds its graph scope (for example
|
||||
`widgetValueStore` keyed by `WidgetId = graphId:nodeId:name`, `layoutStore`
|
||||
keyed by `nodeId`/`linkId`/`rerouteId`, `nodeOutputStore` keyed by
|
||||
`subgraphId:nodeId`). Each store provides a clear-by-graph lifecycle hook
|
||||
(`clearGraph(graphId)`) and query helpers. A scope registry maps each `graphId`
|
||||
to its parent (or null for the root graph).
|
||||
|
||||
> The original design centralized this in one "World" registry per workflow
|
||||
> instance; PR 12617 replaced that with the dedicated stores above. The
|
||||
> remainder of this section describes scoping, which applies per store.
|
||||
|
||||
The "single source of truth" claim in this ADR is scoped to one workflow
|
||||
instance. In a future linked-subgraph model, shared definitions can be loaded
|
||||
into multiple workflow instances, but mutable runtime components
|
||||
(`WidgetValue`, execution state, selection, transient layout caches) remain
|
||||
instance, per concern. In a future linked-subgraph model, shared definitions
|
||||
can be loaded into multiple workflow instances, but mutable runtime state
|
||||
(widget values, execution state, selection, transient layout caches) remains
|
||||
instance-scoped unless explicitly declared shareable.
|
||||
|
||||
### Subgraph recursion model
|
||||
@@ -166,7 +191,7 @@ queries by `graphScope`.
|
||||
|
||||
### Systems (future work)
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
|
||||
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Initial candidates:
|
||||
|
||||
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
|
||||
- **SerializationSystem** — queries all components to produce/consume workflow JSON
|
||||
@@ -178,25 +203,23 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. **Define types** — branded IDs, component interfaces, World type in a new `src/ecs/` directory
|
||||
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
|
||||
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
|
||||
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
|
||||
1. **Define types** — string-key ID types (for example `WidgetId`) and plain-data component interfaces, owned by the store for each concern
|
||||
2. **Bridge layer** — adapter functions that read component data from existing class instances (zero-copy where possible)
|
||||
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on store-backed components rather than class properties
|
||||
4. **Incremental extraction** — migrate one component at a time from classes into its dedicated store, using the bridge layer for backward compatibility
|
||||
5. **Deprecate class properties** — once all consumers read from the store, mark class properties as deprecated
|
||||
|
||||
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
|
||||
|
||||
### Relationship to ADR 0003 (Command Pattern / CRDT)
|
||||
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
|
||||
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the dedicated stores that hold it. They are complementary architectural layers:
|
||||
|
||||
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
|
||||
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
|
||||
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
|
||||
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the relevant stores.
|
||||
- **The dedicated stores** (ADR 0008) hold component data and expose mutation APIs (for example `useLayoutMutations()`, `widgetValueStore.setValue`); each owns its own transaction boundary.
|
||||
|
||||
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
|
||||
|
||||
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
|
||||
A store's imperative mutators are internal implementation. External callers submit commands; each mutating store wraps its writes in a transaction (the Y.js-backed `layoutStore` already does this). This follows Redux: internal mutation is imperative, while the public API is action-based.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
@@ -210,26 +233,26 @@ For the full design showing how each lifecycle scenario maps to a command, see [
|
||||
|
||||
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
|
||||
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
|
||||
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
|
||||
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
|
||||
- Branded IDs (including the composite `WidgetId` string) prevent a class of bugs where IDs are accidentally used across entity kinds
|
||||
- Each dedicated store provides a single source of truth for its concern inside a workflow instance, simplifying debugging and state inspection
|
||||
- Aligns with the CRDT layout system direction from ADR 0003
|
||||
|
||||
### Negative
|
||||
|
||||
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
|
||||
- Additional indirection: reading a node's position requires a store lookup instead of `node.pos`
|
||||
- Learning curve for contributors unfamiliar with ECS patterns
|
||||
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
|
||||
- Widgets and Slots need synthetic IDs, adding ID management complexity
|
||||
|
||||
### Render-Loop Performance Implications and Mitigations
|
||||
|
||||
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
||||
Replacing direct property reads (`node.pos`) with store lookups (for example `layoutStore` position reads) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
|
||||
|
||||
Planned mitigations for the ECS render path:
|
||||
|
||||
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
|
||||
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
|
||||
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
||||
3. Allow a hot-path storage upgrade behind a store's API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
|
||||
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
|
||||
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
|
||||
|
||||
@@ -247,7 +270,6 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
|
||||
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
|
||||
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
|
||||
@@ -258,5 +280,5 @@ Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
|
||||
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
|
||||
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
|
||||
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|
||||
- Widgets are addressed by the composite `WidgetId` string, so they need no synthetic counter. The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters cover the kinds that have numeric IDs.
|
||||
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the store-backed component data and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._
|
||||
|
||||
> **Post-pivot status (PR 12617).** This analysis was written against the
|
||||
> single-World ECS target. The project has since chosen dedicated Pinia stores
|
||||
> over one unified World, which acts on several of the concerns raised below.
|
||||
> Resolution notes are inlined where the pivot answers a critique; the still-open
|
||||
> gaps (extension-callback continuity, atomicity/undo, Y.js ↔ ECS coexistence)
|
||||
> remain live. Verification snapshots predate PR 12617.
|
||||
|
||||
---
|
||||
|
||||
## I. On the Accuracy of Self-Diagnosis
|
||||
@@ -15,7 +22,7 @@ The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines —
|
||||
|
||||
Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated.
|
||||
|
||||
The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs.
|
||||
The stores — six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects, keyed by `WidgetId`. The `LayoutStore` does wrap Y.js CRDTs. (The `PromotionStore` named in the original snapshot was removed by PR 12617; promoted value state now lives in `WidgetValueStore`, and `PreviewExposureStore` holds host-scoped preview exposures.)
|
||||
|
||||
This level of factual accuracy — 28 out of 30 sampled citation checks
|
||||
(93.3%) — is, one might say, the work of a consciousness that has genuinely
|
||||
@@ -47,6 +54,12 @@ This is the individuation dream: the fragmented psyche imagines itself unified,
|
||||
|
||||
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
|
||||
|
||||
> **Resolved (PR 12617).** The single World was set aside. The project keeps the
|
||||
> fragments deliberately apart — dedicated stores, each holding one concern and
|
||||
> keyed by its own string identity. The integration the dream sought lives in the
|
||||
> shared discipline (plain-data components, command-driven mutation), with the
|
||||
> stores standing on their own.
|
||||
|
||||
### The Line-Count Comparisons
|
||||
|
||||
The lifecycle scenarios compare current implementations against projected ECS equivalents:
|
||||
@@ -93,6 +106,12 @@ But one must be careful not to mistake diversity for disorder. Some of these com
|
||||
|
||||
The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain.
|
||||
|
||||
> **Resolved (PR 12617).** The pivot honored this concern. `WidgetValueStore`
|
||||
> keeps the self-documenting composite as its key, branded as a string
|
||||
> (`WidgetId = graphId:nodeId:name`, `src/types/widgetId.ts`), gaining cross-kind
|
||||
> safety while preserving the structural meaning the synthetic integer would have
|
||||
> shed.
|
||||
|
||||
## V. On the Subgraph: The Child Who Contains the Parent
|
||||
|
||||
The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system.
|
||||
@@ -115,13 +134,13 @@ This is sound. The documents would benefit from being equally realistic about th
|
||||
|
||||
### Factual Corrections Required
|
||||
|
||||
| Document | Error | Correction |
|
||||
| --------------------- | ---------------------------------- | ---------------------------------- |
|
||||
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
|
||||
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
|
||||
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
|
||||
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
|
||||
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` |
|
||||
| Document | Error | Correction |
|
||||
| --------------------- | ---------------------------------- | ----------------------------------------------------- |
|
||||
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
|
||||
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
|
||||
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
|
||||
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
|
||||
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | moot — `PromotedWidgetViewManager` removed (PR 12617) |
|
||||
|
||||
### Analytical Gaps
|
||||
|
||||
@@ -129,7 +148,7 @@ This is sound. The documents would benefit from being equally realistic about th
|
||||
2. **Atomicity guarantees** are claimed but not mechanically specified.
|
||||
3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage.
|
||||
4. **ECS line-count projections** are aspirational and should be marked as estimates.
|
||||
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything."
|
||||
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything." _(Resolved by PR 12617: `WidgetId` keeps the composite as a branded string.)_
|
||||
|
||||
### What the Documents Do Well
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
|
||||
analysis that shipped slice 1 of
|
||||
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
|
||||
which structural patterns our `src/world/` substrate adopts, which it
|
||||
deliberately departs from, and where the trade-offs are load-bearing rather
|
||||
than incidental. Thyseus is called out specifically because it is the most
|
||||
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
|
||||
closest external analog to the command layer ADR 0003 / ADR 0008 are
|
||||
converging on, so it gets dedicated treatment in §2.5 and §3.5._
|
||||
> **Superseded (PR 12617).** The single `src/world/` substrate this appendix
|
||||
> analyzes was removed; the project adopted dedicated Pinia stores
|
||||
> (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`,
|
||||
> `subgraphNavigationStore`, `previewExposureStore`) keyed by string IDs. §1
|
||||
> (the external library survey) remains valid reference material and supports
|
||||
> the dedicated-store direction — its first unanimous finding, that components
|
||||
> live with the code that owns them, is exactly what per-domain stores do. §2–§4
|
||||
> describe the deleted `src/world/` substrate (`world.ts`, `entityIds.ts`,
|
||||
> `widgetComponents.ts`, `WidgetEntityId`) and are retained for historical
|
||||
> rationale only; read their references to "the World" as "the relevant
|
||||
> dedicated store."
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, Thyseus, and Bevy. This appendix records which structural patterns
|
||||
the surveyed libraries share, which the project departs from, and where the
|
||||
trade-offs carry weight. Thyseus is called out specifically because it is the
|
||||
most Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is
|
||||
the closest external analog to the command layer ADR 0003 / ADR 0008 converge
|
||||
on, so it gets dedicated treatment in §2.5 and §3.5._
|
||||
|
||||
---
|
||||
|
||||
@@ -49,9 +53,9 @@ Two structural patterns are unanimous across the surveyed libraries:
|
||||
because it commits to a full system-execution runtime, not just
|
||||
storage.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
The dedicated-store end state — each store a small, focused module keyed by a
|
||||
string ID — sits squarely in this band: a small surface per store, with
|
||||
component shapes defined next to the store that owns them.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,12 +145,11 @@ export function spawnEntities(commands: Commands) {
|
||||
```
|
||||
|
||||
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
|
||||
deferred mutations against a command buffer; the World applies them at
|
||||
deferred mutations against a command buffer; the substrate applies them at
|
||||
defined sync points in the schedule. This is the same shape Bevy uses
|
||||
and is the closest direct external analog to the mutation layer
|
||||
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
|
||||
[World API and Command Layer](./ecs-world-command-api.md) describe for
|
||||
this codebase.
|
||||
and is the closest direct external analog to the per-store mutation layer
|
||||
[ADR 0003](../adr/0003-crdt-based-layout-system.md) describes for this
|
||||
codebase (realized as store mutation APIs such as `useLayoutMutations()`).
|
||||
|
||||
We deliberately match the **shape** of this pattern: external callers
|
||||
submit commands; only the executor calls the World's imperative
|
||||
@@ -172,8 +175,8 @@ yet:
|
||||
|
||||
The point of calling Thyseus out separately is that when ADR 0008 lands
|
||||
its command executor slice, "what does this look like in Thyseus?" is a
|
||||
load-bearing comparison point — not a curiosity. Diverging from the
|
||||
Bevy/Thyseus shape there should require an explicit justification, not
|
||||
comparison point worth taking seriously. Diverging from the
|
||||
Bevy/Thyseus shape there should require an explicit justification rather than
|
||||
silent drift.
|
||||
|
||||
---
|
||||
@@ -181,7 +184,7 @@ silent drift.
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
on structural grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
@@ -215,8 +218,8 @@ SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
`widgetValueStore` facade rely on. This constraint carries real weight
|
||||
beyond a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
@@ -261,7 +264,7 @@ The contract is pinned in the doc-comment at the top of
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` carries real weight:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (what it looks like with the World), and a **Key Differences** table.
|
||||
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
|
||||
|
||||
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
|
||||
|
||||
## 1. Node Removal
|
||||
|
||||
@@ -63,47 +65,43 @@ Problems: the graph method manually disconnects every slot, cleans up reroutes,
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant NOS as nodeOutputStore
|
||||
participant DWS as domWidgetStore
|
||||
|
||||
Caller->>CS: removeNode(world, nodeId)
|
||||
Caller->>CS: removeNode(nodeId)
|
||||
|
||||
CS->>W: getComponent(nodeId, Connectivity)
|
||||
W-->>CS: { inputSlotIds, outputSlotIds }
|
||||
CS->>LS: read node links (incoming + outgoing)
|
||||
LS-->>CS: linkIds
|
||||
|
||||
loop each slotId
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
W-->>CS: { linkIds }
|
||||
loop each linkId
|
||||
CS->>CS: removeLink(world, linkId)
|
||||
Note over CS,W: removes Link entity + updates remote slots
|
||||
end
|
||||
CS->>W: deleteEntity(slotId)
|
||||
loop each linkId
|
||||
CS->>LM: deleteLink(linkId)
|
||||
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
|
||||
end
|
||||
|
||||
CS->>W: getComponent(nodeId, WidgetContainer)
|
||||
W-->>CS: { widgetIds }
|
||||
loop each widgetId
|
||||
CS->>W: deleteEntity(widgetId)
|
||||
loop each widget on node
|
||||
CS->>WVS: deleteWidget(widgetId)
|
||||
CS->>DWS: unregisterWidget(widgetId)
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(nodeId)
|
||||
Note over W: removes Position, NodeVisual, NodeType,<br/>Connectivity, Execution, Properties,<br/>WidgetContainer — all at once
|
||||
|
||||
CS->>VS: markChanged()
|
||||
CS->>NOS: removeNodeOutputs(nodeId)
|
||||
CS->>LM: deleteNode(nodeId)
|
||||
Note over CS,LS: coordinated cleanup across stores —<br/>each store drops its entry for the node
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem knows Connectivity + SlotConnection |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteEntity()` removes all components atomically |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | RenderSystem sees missing entity on next frame |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | System snapshots affected components before deletion |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only World + ConnectivitySystem |
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| Lines of code | ~107 in one method | ~30 in system function |
|
||||
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
|
||||
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
|
||||
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
|
||||
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
|
||||
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
|
||||
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
|
||||
|
||||
## 2. Serialization
|
||||
|
||||
@@ -165,41 +163,37 @@ Problems: serialization logic lives in 6 different `serialize()` methods across
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant CLS as node class state
|
||||
|
||||
Caller->>SS: serialize(world)
|
||||
Caller->>SS: serialize(graphId)
|
||||
|
||||
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
|
||||
W-->>SS: all node entities with their components
|
||||
SS->>LS: read node layouts (position, size, z-index)
|
||||
LS-->>SS: layout entries for graphId
|
||||
|
||||
SS->>W: queryAll(LinkEndpoints)
|
||||
W-->>SS: all link entities
|
||||
SS->>LS: read links + reroutes for graphId
|
||||
LS-->>SS: link / reroute entries
|
||||
|
||||
SS->>W: queryAll(SlotIdentity, SlotConnection)
|
||||
W-->>SS: all slot entities
|
||||
SS->>WVS: getWidget(widgetId) per node widget
|
||||
WVS-->>SS: WidgetState values
|
||||
|
||||
SS->>W: queryAll(RerouteLinks, Position)
|
||||
W-->>SS: all reroute entities
|
||||
SS->>CLS: read type / properties / flags
|
||||
CLS-->>SS: per-node class data
|
||||
|
||||
SS->>W: queryAll(GroupMeta, GroupChildren, Position)
|
||||
W-->>SS: all group entities
|
||||
|
||||
SS->>W: queryAll(SubgraphStructure, SubgraphMeta)
|
||||
W-->>SS: all subgraph entities
|
||||
|
||||
SS->>SS: assemble JSON from component data
|
||||
SS->>SS: assemble JSON from store entries + class state
|
||||
SS-->>Caller: SerializedGraph
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------------- | ----------------------------------------------- | ---------------------------------------------- |
|
||||
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem |
|
||||
| Widget values | Collected inline during `node.serialize()` | WidgetValue component queried directly |
|
||||
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat query — SubgraphStructure has entity refs |
|
||||
| Adding a new component | Modify the entity's `serialize()` method | Add component to query in SerializationSystem |
|
||||
| Testing | Need full object graph to test serialization | Mock World with test components |
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------------- | ----------------------------------------------- | --------------------------------------------------------- |
|
||||
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem reading the stores |
|
||||
| Widget values | Collected inline during `node.serialize()` | `widgetValueStore.getWidget(widgetId)` read directly |
|
||||
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat read — layout entries carry scope tags, no recursion |
|
||||
| Adding a new component | Modify the entity's `serialize()` method | Read one more store in SerializationSystem |
|
||||
| Testing | Need full object graph to test serialization | Seed the stores with test entries |
|
||||
|
||||
## 3. Deserialization
|
||||
|
||||
@@ -274,64 +268,48 @@ Problems: two-phase creation is necessary because nodes need to reference each o
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant SS as SerializationSystem
|
||||
participant W as World
|
||||
participant LS as LayoutSystem
|
||||
participant LM as useLayoutMutations()
|
||||
participant WVS as widgetValueStore
|
||||
participant ES as ExecutionSystem
|
||||
|
||||
Caller->>SS: deserialize(world, data)
|
||||
Caller->>SS: deserialize(graphId, data)
|
||||
|
||||
SS->>W: clear() [remove all entities]
|
||||
SS->>WVS: clearGraph(graphId)
|
||||
Note over SS,WVS: drop stale widget entries for this graph
|
||||
|
||||
Note over SS,W: All entities created in one pass — no two-phase needed
|
||||
Note over SS,LM: All entries created in one pass — no two-phase needed
|
||||
|
||||
loop each node in data
|
||||
SS->>W: createEntity(NodeEntityId)
|
||||
SS->>W: setComponent(id, Position, {...})
|
||||
SS->>W: setComponent(id, NodeType, {...})
|
||||
SS->>W: setComponent(id, NodeVisual, {...})
|
||||
SS->>W: setComponent(id, Properties, {...})
|
||||
SS->>W: setComponent(id, Execution, {...})
|
||||
SS->>LM: createNode(nodeId, { position, size, ... })
|
||||
end
|
||||
|
||||
loop each slot in data
|
||||
SS->>W: createEntity(SlotEntityId)
|
||||
SS->>W: setComponent(id, SlotIdentity, {...})
|
||||
SS->>W: setComponent(id, SlotConnection, {...})
|
||||
end
|
||||
|
||||
Note over SS,W: Slots reference links by ID — no resolution needed yet
|
||||
|
||||
loop each link in data
|
||||
SS->>W: createEntity(LinkEntityId)
|
||||
SS->>W: setComponent(id, LinkEndpoints, {...})
|
||||
SS->>LM: createLink(linkId, source, target)
|
||||
end
|
||||
|
||||
Note over SS,W: Connectivity assembled from slot/link components
|
||||
Note over SS,LM: links reference node + slot IDs directly,<br/>no instance resolution needed
|
||||
|
||||
loop each widget in data
|
||||
SS->>W: createEntity(WidgetEntityId)
|
||||
SS->>W: setComponent(id, WidgetIdentity, {...})
|
||||
SS->>W: setComponent(id, WidgetValue, {...})
|
||||
SS->>WVS: registerWidget(widgetId, { value, ... })
|
||||
end
|
||||
|
||||
SS->>SS: create reroutes, groups, subgraphs similarly
|
||||
SS->>SS: create reroutes, groups via layout mutations;<br/>subgraph scopes tagged on entries
|
||||
|
||||
Note over SS,W: Systems react to populated World
|
||||
Note over SS,ES: Systems read the populated stores
|
||||
|
||||
SS->>LS: runLayout(world)
|
||||
SS->>ES: computeExecutionOrder(world)
|
||||
SS->>ES: computeExecutionOrder(graphId)
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances |
|
||||
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: WidgetValue component written directly |
|
||||
| Store population | Side effect of `widget.setNodeId()` | World IS the store — writing component IS population |
|
||||
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems query World after deserialization |
|
||||
| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs |
|
||||
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entity → skip; components that loaded are still valid |
|
||||
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — links reference string IDs, not instances |
|
||||
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: `widgetValueStore.registerWidget(widgetId, state)` |
|
||||
| Store population | Side effect of `widget.setNodeId()` | Direct: writing the store entry is the population |
|
||||
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems read the stores after deserialization |
|
||||
| Subgraph ordering | Topological sort required | Flat write — scope tags on entries, no instance ordering |
|
||||
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entry → skip; entries that loaded are still valid |
|
||||
|
||||
## 4. Pack Subgraph
|
||||
|
||||
@@ -394,50 +372,50 @@ Problems: 200+ lines in one method. Manual boundary link analysis. Clone-seriali
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant SNS as subgraphNavigationStore
|
||||
|
||||
Caller->>CS: packSubgraph(world, selectedEntityIds)
|
||||
Caller->>CS: packSubgraph(selectedNodeIds)
|
||||
|
||||
CS->>W: query Connectivity + SlotConnection for selected nodes
|
||||
CS->>LS: read links for selected nodes
|
||||
CS->>CS: classify links as internal vs boundary
|
||||
|
||||
CS->>W: create new GraphId scope in scopes registry
|
||||
CS->>SNS: register new subgraph graphId
|
||||
|
||||
Note over CS,W: Create SubgraphNode entity in parent scope
|
||||
Note over CS,LM: Create SubgraphNode layout entry in parent graph
|
||||
|
||||
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
|
||||
CS->>W: setComponent(nodeId, Position, { center of selection })
|
||||
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
|
||||
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
|
||||
CS->>LM: createNode(subgraphNodeId, { position: center of selection })
|
||||
CS->>CS: record SubgraphNode interface (boundary slots)
|
||||
|
||||
Note over CS,W: Re-parent selected entities into new graph scope
|
||||
Note over CS,LS: Re-tag selected entries into new graph scope
|
||||
|
||||
loop each selected entity
|
||||
CS->>W: update graphScope to new graphId
|
||||
loop each selected node + link
|
||||
CS->>LS: set graphId scope tag to new subgraph graphId
|
||||
end
|
||||
|
||||
Note over CS,W: Create boundary slots on SubgraphNode
|
||||
Note over CS,LM: Reconnect boundary links to SubgraphNode slots
|
||||
|
||||
loop each boundary input link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to target new slot
|
||||
CS->>LM: deleteLink(oldLinkId)
|
||||
CS->>LM: createLink(newLinkId, source, subgraphNode input slot)
|
||||
end
|
||||
|
||||
loop each boundary output link
|
||||
CS->>W: create SlotEntity on SubgraphNode
|
||||
CS->>W: update LinkEndpoints to source from new slot
|
||||
CS->>LM: deleteLink(oldLinkId)
|
||||
CS->>LM: createLink(newLinkId, subgraphNode output slot, target)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
|
||||
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
|
||||
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
|
||||
| Code size | 200+ lines | ~50 lines in system |
|
||||
| Undo | `beforeChange()`/`afterChange()` wraps everything | Snapshot affected components before mutation |
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Entity movement | Clone → serialize → configure → remove originals | Re-tag entries: change graphId scope tag on store entries |
|
||||
| Boundary links | Disconnect → remove → recreate → reconnect | `deleteLink`/`createLink` against the new SubgraphNode slots |
|
||||
| Intermediate inconsistency | Graph is partially disconnected during operation | Mutations batch together as one command sequence |
|
||||
| Code size | 200+ lines | ~50 lines in system |
|
||||
| Undo | `beforeChange()`/`afterChange()` wraps everything | Layout mutation commands replay and undo as a batch |
|
||||
|
||||
## 5. Unpack Subgraph
|
||||
|
||||
@@ -496,48 +474,49 @@ Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant SNS as subgraphNavigationStore
|
||||
|
||||
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
|
||||
Caller->>CS: unpackSubgraph(subgraphNodeId)
|
||||
|
||||
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
|
||||
W-->>CS: { graphId, interface }
|
||||
CS->>CS: read SubgraphNode interface (boundary slots)
|
||||
|
||||
CS->>W: query entities where graphScope = graphId
|
||||
W-->>CS: all child entities (nodes, links, reroutes, etc.)
|
||||
CS->>LS: query entries where graphId scope = subgraph graphId
|
||||
LS-->>CS: child entries (nodes, links, reroutes)
|
||||
|
||||
Note over CS,W: Re-parent entities to containing graph scope
|
||||
Note over CS,LS: Re-tag entries to containing graph scope
|
||||
|
||||
loop each child entity
|
||||
CS->>W: update graphScope to parent scope
|
||||
loop each child entry
|
||||
CS->>LS: set graphId scope tag to parent scope
|
||||
end
|
||||
|
||||
Note over CS,W: Reconnect boundary links
|
||||
Note over CS,LM: Reconnect boundary links
|
||||
|
||||
loop each boundary slot in interface
|
||||
CS->>W: getComponent(slotId, SlotConnection)
|
||||
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
|
||||
CS->>LM: deleteLink(boundaryLinkId)
|
||||
CS->>LM: createLink(newLinkId, external slot → internal node slot)
|
||||
end
|
||||
|
||||
CS->>W: deleteEntity(subgraphNodeId)
|
||||
CS->>W: remove graphId from scopes registry
|
||||
CS->>LM: deleteNode(subgraphNodeId)
|
||||
CS->>SNS: drop subgraph graphId
|
||||
|
||||
Note over CS,W: Offset positions
|
||||
Note over CS,LM: Offset positions
|
||||
|
||||
loop each moved entity
|
||||
CS->>W: update Position component
|
||||
loop each moved node
|
||||
CS->>LM: moveNode(nodeId, position + offset)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ----------------- | --------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entities keep their IDs, only graphScope changes |
|
||||
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
|
||||
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
|
||||
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |
|
||||
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
|
||||
| Aspect | Current | ECS |
|
||||
| ----------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entries keep their IDs, only the scope tag changes |
|
||||
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as SubgraphNode interface slots |
|
||||
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Re-tag store entries between scopes |
|
||||
| Link reconnection | Remap origin_id/target_id, create new LLink objects | `deleteLink`/`createLink` against the resolved endpoints |
|
||||
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
|
||||
|
||||
## 6. Connect Slots
|
||||
|
||||
@@ -591,33 +570,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant CS as ConnectivitySystem
|
||||
participant W as World
|
||||
participant VS as VersionSystem
|
||||
participant LS as layoutStore
|
||||
participant LM as useLayoutMutations()
|
||||
|
||||
Caller->>CS: connect(world, outputSlotId, inputSlotId)
|
||||
Caller->>CS: connect(outputSlot, inputSlot)
|
||||
|
||||
CS->>W: getComponent(inputSlotId, SlotConnection)
|
||||
CS->>LS: read input slot link
|
||||
opt already connected
|
||||
CS->>CS: removeLink(world, existingLinkId)
|
||||
CS->>LM: deleteLink(existingLinkId)
|
||||
end
|
||||
|
||||
CS->>W: createEntity(LinkEntityId)
|
||||
CS->>W: setComponent(linkId, LinkEndpoints, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
|
||||
CS->>W: update SlotConnection on outputSlotId (add linkId)
|
||||
CS->>W: update SlotConnection on inputSlotId (set linkId)
|
||||
|
||||
CS->>VS: markChanged()
|
||||
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
|
||||
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (queries World) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `markChanged()` — one call |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Component update: `setComponent(slotId, SlotConnection, ...)` |
|
||||
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
|
||||
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
|
||||
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
|
||||
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
|
||||
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
|
||||
|
||||
## 7. Copy / Paste
|
||||
@@ -688,57 +662,61 @@ parent IDs all remapped independently. ~300 lines across multiple methods.
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant CS as ClipboardSystem
|
||||
participant W as World
|
||||
participant LS as layoutStore
|
||||
participant WVS as widgetValueStore
|
||||
participant LM as useLayoutMutations()
|
||||
participant CB as Clipboard
|
||||
|
||||
rect rgb(40, 40, 60)
|
||||
Note over User,CB: Copy
|
||||
User->>CS: copy(world, selectedEntityIds)
|
||||
CS->>W: snapshot all components for selected entities
|
||||
CS->>W: snapshot components for child entities (slots, widgets)
|
||||
CS->>W: snapshot connected links (LinkEndpoints)
|
||||
CS->>CB: store component snapshot
|
||||
User->>CS: copy(selectedNodeIds)
|
||||
CS->>LS: snapshot layout entries (nodes, links, reroutes)
|
||||
CS->>WVS: snapshot WidgetState for each widgetId
|
||||
CS->>CB: store cross-store snapshot
|
||||
end
|
||||
|
||||
rect rgb(40, 60, 40)
|
||||
Note over User,CB: Paste
|
||||
User->>CS: paste(world, position)
|
||||
User->>CS: paste(position)
|
||||
CS->>CB: retrieve snapshot
|
||||
|
||||
CS->>CS: generate ID remap table (old → new branded IDs)
|
||||
CS->>CS: build ID remap table (old → new nodeId / WidgetId)
|
||||
|
||||
loop each entity in snapshot
|
||||
CS->>W: createEntity(newId)
|
||||
loop each component
|
||||
CS->>W: setComponent(newId, type, remappedData)
|
||||
Note over CS,W: entity ID refs in component data<br/>are remapped via table
|
||||
end
|
||||
loop each node in snapshot
|
||||
CS->>LM: createNode(newNodeId, remapped layout)
|
||||
end
|
||||
loop each link in snapshot
|
||||
CS->>LM: createLink(newLinkId, remapped endpoints)
|
||||
Note over CS,LM: node + slot refs remapped via table
|
||||
end
|
||||
loop each widget in snapshot
|
||||
CS->>WVS: registerWidget(newWidgetId, WidgetState)
|
||||
end
|
||||
|
||||
CS->>CS: offset all Position components to cursor
|
||||
CS->>LM: batchMoveNodes(offset all to cursor)
|
||||
end
|
||||
```
|
||||
|
||||
### Key Differences
|
||||
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) |
|
||||
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components |
|
||||
| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createEntity()` → `setComponent()` per entity (flat) |
|
||||
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs |
|
||||
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
|
||||
| Aspect | Current | ECS |
|
||||
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- |
|
||||
| Copy format | Clone → serialize → JSON (format depends on class) | Store-entry snapshot (uniform shape across stores) |
|
||||
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | One remap table applied to string keys (`nodeId`, `WidgetId`) |
|
||||
| Paste reconstruction | `createNode()` → `add()` → `configure()` → `connect()` per node | `createNode`/`createLink`/`registerWidget` per entry (flat) |
|
||||
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot carries scope tags; remap rewrites graphId keys |
|
||||
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
|
||||
|
||||
## Summary: Cross-Cutting Benefits
|
||||
|
||||
| Benefit | Scenarios Where It Applies |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| **Atomic operations** | Node Removal, Pack/Unpack — no intermediate inconsistent state |
|
||||
| **No scattered `_version++`** | All scenarios — VersionSystem handles change tracking |
|
||||
| **No callback cascades** | Deserialization, Connect — systems query World instead of firing callbacks |
|
||||
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table instead of per-type logic |
|
||||
| **Entity deletion = cleanup** | Node Removal — `deleteEntity()` removes all components |
|
||||
| **No two-phase creation** | Deserialization — components reference IDs, not instances |
|
||||
| **Move instead of clone** | Pack/Unpack — entities keep their IDs, just change scope |
|
||||
| **Testable in isolation** | All scenarios — mock World, test one system |
|
||||
| **Undo/redo for free** | All scenarios — snapshot components before mutation, restore on undo |
|
||||
| Benefit | Scenarios Where It Applies |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Batched operations** | Node Removal, Pack/Unpack — mutations apply together as one command sequence |
|
||||
| **No scattered `_version++`** | All scenarios — layout mutation commands carry change tracking |
|
||||
| **No callback cascades** | Deserialization, Connect — systems read the stores instead of firing callbacks |
|
||||
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table over string keys instead of per-type logic |
|
||||
| **Coordinated cleanup** | Node Removal — `deleteWidget` + `deleteLink`/`deleteNode` + `removeNodeOutputs` + `unregisterWidget` |
|
||||
| **No two-phase creation** | Deserialization — store entries reference string IDs, not instances |
|
||||
| **Move instead of clone** | Pack/Unpack — entries keep their IDs, only the scope tag changes |
|
||||
| **Testable in isolation** | All scenarios — seed the relevant stores, test one system |
|
||||
| **Undo/redo for free** | All scenarios — layout mutation commands replay and undo |
|
||||
|
||||
@@ -10,6 +10,14 @@ target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
|
||||
For verified accuracy of these documents, see
|
||||
[Appendix: Critical Analysis](appendix-critical-analysis.md).
|
||||
|
||||
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
|
||||
> string IDs, one store per concern (widget values, DOM widgets, layout, node
|
||||
> outputs, subgraph navigation, preview exposure). The earlier "single unified
|
||||
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
|
||||
> was rejected. PR 12617 shipped the first stores against composite
|
||||
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
|
||||
> around dedicated stores; shipped work is marked ✅.
|
||||
|
||||
## Planning assumptions
|
||||
|
||||
- The bridge period is expected to span 2-3 release cycles.
|
||||
@@ -23,36 +31,16 @@ For verified accuracy of these documents, see
|
||||
Zero behavioral risk. Prepares the codebase for extraction without changing
|
||||
runtime semantics. All items are independently shippable.
|
||||
|
||||
### 0a. Centralize version counter
|
||||
### 0a. Centralize version counter ✅ Shipped
|
||||
|
||||
`graph._version++` appears in 19 locations across 7 files. The counter is only
|
||||
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
|
||||
is not used for dirty-checking, caching, or reactivity.
|
||||
`LGraph.incrementVersion()` exists and is used everywhere. The counter is only
|
||||
read for debug display in `LGraphCanvas.renderInfo()`; it is not used for
|
||||
dirty-checking, caching, or reactivity.
|
||||
|
||||
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
|
||||
increments.
|
||||
**Remaining cleanup:** One stray direct `_version++` at `LGraph.ts:831` should
|
||||
be replaced with `incrementVersion()`.
|
||||
|
||||
```
|
||||
incrementVersion(): void {
|
||||
this._version++
|
||||
}
|
||||
```
|
||||
|
||||
| File | Sites |
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
|
||||
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
|
||||
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
|
||||
| `BaseWidget.ts` | 1 (line 439) |
|
||||
| `SubgraphInput.ts` | 1 (line 137) |
|
||||
| `SubgraphInputNode.ts` | 1 (line 190) |
|
||||
| `SubgraphOutput.ts` | 1 (line 102) |
|
||||
|
||||
**Why first:** Creates the seam where a VersionSystem can later intercept,
|
||||
batch, or replace the mechanism. Mechanical find-and-replace with zero
|
||||
behavioral change.
|
||||
|
||||
**Risk:** None. Existing null guards at call sites are preserved.
|
||||
**Risk:** None. Mechanical one-line change; existing null guards preserved.
|
||||
|
||||
### 0b. Add missing ID type aliases
|
||||
|
||||
@@ -79,246 +67,198 @@ Five factual errors verified during code review (see
|
||||
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
|
||||
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
|
||||
lines not ~180
|
||||
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
|
||||
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Types and World Shell
|
||||
## Phase 1: Types and Dedicated Stores
|
||||
|
||||
Introduces the ECS type vocabulary and an empty World. No migration of existing
|
||||
code — new types coexist with old ones.
|
||||
Introduces the ID type vocabulary and the dedicated stores. Phase 1 end-state is
|
||||
N dedicated Pinia stores, each keyed by a composite string ID, coexisting with
|
||||
legacy class instances.
|
||||
|
||||
### 1a. Branded entity ID types
|
||||
### 1a. Branded string ID types ✅ Shipped (PR 12617)
|
||||
|
||||
Define branded types in a new `src/ecs/entityId.ts`:
|
||||
|
||||
```
|
||||
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
||||
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
||||
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
||||
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
||||
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
||||
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
||||
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
|
||||
```
|
||||
|
||||
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
|
||||
system boundaries (deserialization, legacy bridge).
|
||||
|
||||
**Does NOT change existing code.** The branded types are new exports consumed
|
||||
only by new ECS code.
|
||||
|
||||
**Risk:** Low. New files, no modifications to existing code.
|
||||
|
||||
**Consideration:** `NodeId = number | string` is the current type. The branded
|
||||
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
|
||||
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
|
||||
|
||||
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
|
||||
- Or define `NodeEntityId = number | string` with branding (less safe)
|
||||
|
||||
Recommend the former: the bridge layer coerces string IDs to a numeric
|
||||
mapping, and only branded numeric IDs enter the World.
|
||||
|
||||
### 1b. Component interfaces
|
||||
|
||||
Define component interfaces in `src/ecs/components/`:
|
||||
|
||||
```
|
||||
src/ecs/
|
||||
entityId.ts # Branded ID types
|
||||
components/
|
||||
position.ts # Position (shared by Node, Reroute, Group)
|
||||
nodeType.ts # NodeType
|
||||
nodeVisual.ts # NodeVisual
|
||||
connectivity.ts # Connectivity
|
||||
execution.ts # Execution
|
||||
properties.ts # Properties
|
||||
widgetContainer.ts # WidgetContainer
|
||||
linkEndpoints.ts # LinkEndpoints
|
||||
...
|
||||
world.ts # World type and factory
|
||||
```
|
||||
|
||||
Components are TypeScript interfaces only — no runtime code. They mirror
|
||||
the decomposition in ADR 0008 Section "Component Decomposition."
|
||||
|
||||
**Risk:** None. Interface-only files.
|
||||
|
||||
### 1c. World type
|
||||
|
||||
Define the World as a typed container:
|
||||
`src/types/widgetId.ts` ships the branded string `WidgetId`:
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
nodes: Map<NodeEntityId, NodeComponents>
|
||||
links: Map<LinkEntityId, LinkComponents>
|
||||
widgets: Map<WidgetEntityId, WidgetComponents>
|
||||
slots: Map<SlotEntityId, SlotComponents>
|
||||
reroutes: Map<RerouteEntityId, RerouteComponents>
|
||||
groups: Map<GroupEntityId, GroupComponents>
|
||||
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
|
||||
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
|
||||
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
|
||||
}
|
||||
type WidgetId = string & { readonly __brand: 'WidgetId' }
|
||||
```
|
||||
|
||||
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
|
||||
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
|
||||
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
|
||||
model.
|
||||
Format: `graphId:nodeId:name`. A `parseWidgetId()` helper splits a `WidgetId`
|
||||
back into its `{ graphId, nodeId, name }` parts at store boundaries.
|
||||
|
||||
World scope is per workflow instance. Linked subgraph definitions can be reused
|
||||
The composite string key carries the structural relationship (graph -> node ->
|
||||
widget) directly in the key. There is no synthetic opaque number and no reverse
|
||||
lookup index.
|
||||
|
||||
**Consideration:** `NodeId = number | string`. The `string` branch exists for
|
||||
subgraph-related nodes (GroupNode hack). The `WidgetId` format stringifies the
|
||||
`nodeId` segment, so both numeric and string node IDs flow through unchanged.
|
||||
|
||||
### 1b. Plain-data store state shapes
|
||||
|
||||
Each dedicated store holds plain-data records for its concern — no methods on the
|
||||
records, behavior lives in store actions and composables. State shapes mirror the
|
||||
decomposition in ADR 0008 Section "Component Decomposition" (position, node type,
|
||||
node visual, connectivity, execution, properties, widget container, link
|
||||
endpoints).
|
||||
|
||||
**Risk:** None. Type-only definitions.
|
||||
|
||||
### 1c. Dedicated stores
|
||||
|
||||
Phase 1 end-state is a set of dedicated Pinia stores, one per concern, each
|
||||
keyed by its own composite string ID. Each store owns its data and exposes a
|
||||
narrow accessor surface. There is no single container that fronts all entities.
|
||||
|
||||
Shipped stores:
|
||||
|
||||
| Store | File |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
|
||||
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
|
||||
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
|
||||
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
|
||||
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
|
||||
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
|
||||
|
||||
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
|
||||
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
|
||||
is no shared `lastWidgetId` counter; identity comes from the composite key.
|
||||
|
||||
Store scope is per workflow instance. Linked subgraph definitions can be reused
|
||||
across instances, but mutable runtime state (widget values, execution state,
|
||||
selection/transient view state) remains instance-scoped through `graphId`.
|
||||
selection/transient view state) stays instance-scoped through `graphId` embedded
|
||||
in each composite key.
|
||||
|
||||
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
|
||||
persistence. The World exists but nothing populates it yet.
|
||||
Subgraphs are not a separate store. Subgraph nesting is tracked in
|
||||
`subgraphNavigationStore`. See
|
||||
[Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full model.
|
||||
|
||||
**Risk:** Low. New code, no integration points.
|
||||
**Risk:** Low. Stores are additive; integration happens in Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Bridge Layer
|
||||
## Phase 2: Store Integration
|
||||
|
||||
Connects the legacy class instances to the World. Both old and new code can
|
||||
read entity state; writes still go through legacy classes.
|
||||
Connects the legacy class instances to the dedicated stores. Both old and new
|
||||
code can read entity state; writes for not-yet-migrated concerns still go through
|
||||
legacy classes.
|
||||
|
||||
### 2a. Read-only bridge for Position
|
||||
### 2a. Position reads through layoutStore
|
||||
|
||||
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
|
||||
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
|
||||
bridge reads from LayoutStore and populates the World's `Position` component.
|
||||
`layoutStore` (`src/renderer/core/layout/store/layoutStore.ts`) already extracts
|
||||
position data for nodes, links, and reroutes into Y.js CRDTs and is the source of
|
||||
truth for layout.
|
||||
|
||||
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
|
||||
them into the World. New code reads `world.getComponent(nodeId, Position)`;
|
||||
legacy code continues to read `node.pos` / LayoutStore directly.
|
||||
**Approach:** New code reads position via `layoutStore` queries (and
|
||||
`useLayoutMutations()` for writes); legacy code continues to read `node.pos`
|
||||
directly during the transition. No second copy of position data is introduced —
|
||||
`layoutStore` stays authoritative.
|
||||
|
||||
**Open question:** Should the World wrap the Y.js maps or maintain its own
|
||||
plain-data copy? Options:
|
||||
**Risk:** Medium. The legacy `node.pos` read path must stay consistent with
|
||||
`layoutStore` during the transition. Watch for stale reads during render.
|
||||
|
||||
| Approach | Pros | Cons |
|
||||
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
|
||||
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
|
||||
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
|
||||
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
|
||||
### 2b. Consolidate widget callers onto widgetValueStore ✅ Largely shipped (PR 12617)
|
||||
|
||||
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
|
||||
copy is cheap (position is small data). Revisit if sync overhead becomes
|
||||
measurable.
|
||||
`widgetValueStore` (`src/stores/widgetValueStore.ts`) holds widget state in
|
||||
plain records keyed by `WidgetId` (`graphId:nodeId:name`) and is the source of
|
||||
truth for widget values. PR 12617 reverted the earlier synthetic-numeric-ID
|
||||
bridge approach.
|
||||
|
||||
**Risk:** Medium. Introduces a sync point between two state systems. Must
|
||||
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
|
||||
position during render).
|
||||
**Remaining work:** Consolidate the remaining widget callers onto
|
||||
`widgetValueStore`. Reads use `getWidget(widgetId)` / `getNodeWidgets(graphId,
|
||||
nodeId)`; writes use `setValue(widgetId, value)`; `parseWidgetId()` recovers the
|
||||
`{ graphId, nodeId, name }` parts at boundaries.
|
||||
|
||||
### 2b. Read-only bridge for WidgetValue
|
||||
|
||||
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
|
||||
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
|
||||
the closest proto-ECS store.
|
||||
|
||||
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
|
||||
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
|
||||
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
||||
|
||||
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
|
||||
|
||||
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
|
||||
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
||||
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
||||
**Risk:** Low. The store is well-structured and `WidgetId`-native; identity comes
|
||||
from the composite key with no separate lookup index.
|
||||
|
||||
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
|
||||
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
|
||||
source node/widget identity is preserved only as migration and diagnostic
|
||||
metadata.
|
||||
|
||||
### 2c. Read-only bridge for Node metadata
|
||||
### 2c. Node metadata stores
|
||||
|
||||
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
||||
reading from `LGraphNode` instances. These are simple property copies.
|
||||
Populate node-metadata records (node type, visual, properties, execution) by
|
||||
reading from `LGraphNode` instances. These are simple property copies into the
|
||||
relevant store.
|
||||
|
||||
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
|
||||
creates the corresponding entity in the World and populates its components.
|
||||
When a node is removed, the bridge deletes the entity.
|
||||
|
||||
The `incrementVersion()` method from Phase 0a becomes the hook point — when
|
||||
version increments, the bridge can re-sync changed components. (This is why
|
||||
centralizing version first matters.)
|
||||
**Approach:** When a node is added to the graph (`LGraph.add()`), the store
|
||||
records its metadata. When a node is removed, the store drops it. The
|
||||
`incrementVersion()` seam from Phase 0a is a candidate hook point for re-sync
|
||||
when changed.
|
||||
|
||||
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
|
||||
without breaking existing behavior. The bridge is read-only (World mirrors
|
||||
classes, not the reverse), which limits blast radius.
|
||||
without breaking existing behavior. Stores mirror the classes during the
|
||||
transition, which limits blast radius.
|
||||
|
||||
### Bridge sunset criteria (applies to every Phase 2 bridge)
|
||||
### Store sunset criteria (applies to every Phase 2 concern)
|
||||
|
||||
A bridge can move from "transitional" to "removal candidate" only when:
|
||||
A legacy path can move from "transitional" to "removal candidate" only when:
|
||||
|
||||
- All production reads for that concern flow through World component queries.
|
||||
- All production writes for that concern flow through system APIs.
|
||||
- Serialization parity tests show no diff between legacy and World paths.
|
||||
- Extension compatibility tests pass without bridge-only fallback paths.
|
||||
- All production reads for that concern flow through store accessors.
|
||||
- All production writes for that concern flow through store actions.
|
||||
- Serialization parity tests show no diff between legacy and store-driven paths.
|
||||
- Extension compatibility tests pass without legacy-only fallback paths.
|
||||
|
||||
These criteria prevent the bridge from becoming permanent by default.
|
||||
These criteria prevent the dual path from becoming permanent by default.
|
||||
|
||||
### Bridge duration and maintenance controls
|
||||
### Dual-path duration and maintenance controls
|
||||
|
||||
To contain dual-path maintenance cost during Phases 2-4:
|
||||
|
||||
- Every bridge concern has a named owner and target sunset release.
|
||||
- Every PR touching bridge-covered data paths must include parity tests for both
|
||||
legacy and World-driven execution.
|
||||
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
|
||||
milestone; upward trends block new bridge expansion.
|
||||
- Any bridge that misses its target sunset release requires an explicit risk
|
||||
- Every concern has a named owner and target sunset release.
|
||||
- Every PR touching store-covered data paths must include parity tests for both
|
||||
legacy and store-driven execution.
|
||||
- Legacy fallback usage is instrumented in integration/e2e and reviewed every
|
||||
milestone; upward trends block new dual-path expansion.
|
||||
- Any concern that misses its target sunset release requires an explicit risk
|
||||
review and revised removal plan.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Systems
|
||||
|
||||
Introduce system functions that operate on World data. Systems coexist with
|
||||
Introduce system functions that operate on store data. Systems coexist with
|
||||
legacy methods — they don't replace them yet.
|
||||
|
||||
### 3a. SerializationSystem (read-only)
|
||||
|
||||
A function `serializeFromWorld(world: World): SerializedGraph` that produces
|
||||
workflow JSON by querying World components. Run alongside the existing
|
||||
`LGraph.serialize()` in tests to verify equivalence.
|
||||
A function `serializeFromStores(): SerializedGraph` that produces workflow JSON
|
||||
by querying the dedicated stores. Run alongside the existing `LGraph.serialize()`
|
||||
in tests to verify equivalence.
|
||||
|
||||
**Why first:** Serialization is read-only and has a clear correctness check
|
||||
(output must match existing serialization). It exercises every component type
|
||||
and proves the World contains sufficient data.
|
||||
(output must match existing serialization). It exercises every store and proves
|
||||
the stores contain sufficient data.
|
||||
|
||||
**Risk:** Low. Runs in parallel with existing code; does not replace it.
|
||||
|
||||
### 3b. VersionSystem
|
||||
|
||||
Replace the `incrementVersion()` method with a system that owns all change
|
||||
tracking. The system observes component mutations on the World and
|
||||
auto-increments the version counter.
|
||||
Move change tracking behind a system that observes store mutations and
|
||||
auto-increments the version counter, replacing scattered explicit increment
|
||||
calls.
|
||||
|
||||
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
|
||||
doesn't see changes).
|
||||
**Dependency:** Requires Phase 2 store integration (otherwise the system doesn't
|
||||
see changes).
|
||||
|
||||
**Risk:** Medium. Must not miss any change that the scattered `_version++`
|
||||
currently catches. The 19-site inventory from Phase 0a serves as the test
|
||||
matrix.
|
||||
historically caught.
|
||||
|
||||
### 3c. ConnectivitySystem (queries only)
|
||||
|
||||
A system that can answer connectivity queries by reading `Connectivity`,
|
||||
`SlotConnection`, and `LinkEndpoints` components from the World:
|
||||
A system that answers connectivity queries by reading connectivity, slot, and
|
||||
link-endpoint records from the relevant stores:
|
||||
|
||||
- "What nodes are connected to this node's inputs?"
|
||||
- "What links pass through this reroute?"
|
||||
- "What is the execution order?"
|
||||
|
||||
Does not perform mutations yet — just queries. Validates that the World's
|
||||
connectivity data is complete and consistent with the class-based graph.
|
||||
Does not perform mutations yet — just queries. Validates that store connectivity
|
||||
data is complete and consistent with the class-based graph.
|
||||
|
||||
**Risk:** Low. Read-only system with equivalence tests.
|
||||
|
||||
@@ -326,27 +266,27 @@ connectivity data is complete and consistent with the class-based graph.
|
||||
|
||||
## Phase 4: Write Path Migration
|
||||
|
||||
Systems begin owning mutations. Legacy class methods delegate to systems.
|
||||
This is the highest-risk phase.
|
||||
Systems begin owning mutations. Legacy class methods delegate to stores and
|
||||
systems. This is the highest-risk phase.
|
||||
|
||||
### 4a. Position writes through World
|
||||
### 4a. Position writes through layoutStore
|
||||
|
||||
New code writes position via `world.setComponent(nodeId, Position, ...)`.
|
||||
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
|
||||
New code writes position via `useLayoutMutations()` against `layoutStore`. A
|
||||
compatibility shim propagates changes back to `LGraphNode.pos` for legacy
|
||||
readers.
|
||||
|
||||
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
|
||||
Phase 4 has World -> legacy (write bridge). Both paths must work during the
|
||||
transition.
|
||||
**This inverts the data flow:** Phase 2 had legacy -> store (read path). Phase 4
|
||||
has store -> legacy (write path). Both must work during the transition.
|
||||
|
||||
**Risk:** High. Two-way sync between World and legacy state. Must handle
|
||||
re-entrant updates (World write triggers bridge, which writes to legacy,
|
||||
which must NOT trigger another World write).
|
||||
**Risk:** High. Two-way sync between `layoutStore` and legacy state. Must handle
|
||||
re-entrant updates (store write triggers the shim, which writes to legacy, which
|
||||
must NOT trigger another store write).
|
||||
|
||||
### 4b. ConnectivitySystem mutations
|
||||
|
||||
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
|
||||
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
|
||||
system.
|
||||
functions over the connectivity stores. Legacy `LGraphNode.connect()` etc.
|
||||
delegate to the system.
|
||||
|
||||
**Extension API concern:** The current system fires callbacks at each step:
|
||||
|
||||
@@ -363,8 +303,8 @@ the system knowing about the callback API.
|
||||
|
||||
**Phase 4 callback contract (locked):**
|
||||
|
||||
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
|
||||
- If either callback rejects, abort with no component writes, no version bump,
|
||||
- `onConnectOutput()` and `onConnectInput()` run before any store mutation.
|
||||
- If either callback rejects, abort with no store writes, no version bump,
|
||||
and no lifecycle events.
|
||||
- `onConnectionsChange()` fires synchronously after commit, preserving current
|
||||
source-then-target ordering.
|
||||
@@ -374,14 +314,14 @@ the system knowing about the callback API.
|
||||
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
||||
validated against real-world extensions.
|
||||
|
||||
### 4c. Widget write path
|
||||
### 4c. Widget write path ✅ Largely shipped (PR 12617)
|
||||
|
||||
Widget value changes go through the World instead of directly through
|
||||
WidgetValueStore. The World's `WidgetValue` component becomes the single
|
||||
source of truth; WidgetValueStore becomes a read-through cache or is removed.
|
||||
`widgetValueStore.setValue()` is already the widget write path and the source of
|
||||
truth for widget values. Remaining work routes the last legacy widget writers
|
||||
through `setValue()` rather than mutating widget instances directly.
|
||||
|
||||
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
|
||||
change is routing writes through the World instead of the store.
|
||||
**Risk:** Medium. The store is well-abstracted and `WidgetId`-native. The main
|
||||
change is migrating the remaining direct-mutation call sites onto `setValue()`.
|
||||
|
||||
### 4d. Layout write path and render decoupling
|
||||
|
||||
@@ -407,25 +347,25 @@ Before enabling ECS render reads as default for any migrated family:
|
||||
- Compare legacy vs ECS p95 frame time and mean draw cost.
|
||||
- Block rollout on statistically significant regression beyond agreed budget
|
||||
(default budget: 5% p95 frame-time regression ceiling).
|
||||
- Capture profiler traces proving the dominant cost is not repeated
|
||||
`world.getComponent()` lookups.
|
||||
- Capture profiler traces proving the dominant cost is not repeated store
|
||||
accessor lookups.
|
||||
|
||||
### Phase 3 -> 4 gate (required)
|
||||
|
||||
Phase 4 starts only when all of the following are true:
|
||||
|
||||
- A transaction wrapper API exists on the World and is used by connectivity and
|
||||
widget write paths in integration tests.
|
||||
- A store/command-executor transaction wrapper exists and is used by connectivity
|
||||
and widget write paths in integration tests.
|
||||
- Undo batching parity is proven: one logical user action yields one undo
|
||||
checkpoint in both legacy and ECS paths.
|
||||
checkpoint in both legacy and store-driven paths.
|
||||
- Callback timing and rejection semantics from Phase 4b are covered by
|
||||
integration tests.
|
||||
- A representative extension suite passes, including `rgthree-comfy`.
|
||||
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
|
||||
- Write-path re-entrancy tests prove there is no store <-> legacy feedback
|
||||
loop.
|
||||
- Layout migration for any enabled node family passes read-only render checks
|
||||
(no `arrange()` writes during draw).
|
||||
- Render hot-path benchmark gate passes for every family moving to ECS-first
|
||||
- Render hot-path benchmark gate passes for every family moving to store-first
|
||||
reads.
|
||||
|
||||
---
|
||||
@@ -435,10 +375,11 @@ Phase 4 starts only when all of the following are true:
|
||||
Remove bridge layers and deprecated class properties. This phase happens
|
||||
per-component, not all at once.
|
||||
|
||||
### 5a. Remove Position bridge
|
||||
### 5a. Remove Position compatibility shim
|
||||
|
||||
Once all position reads and writes go through the World, remove the bridge
|
||||
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
|
||||
Once all position reads and writes go through `layoutStore`, remove the
|
||||
compatibility shim and the `pos`/`size` properties from `LGraphNode`, `Reroute`,
|
||||
`LGraphGroup`.
|
||||
|
||||
### 5b. Remove widget class hierarchy
|
||||
|
||||
@@ -448,9 +389,9 @@ replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
|
||||
|
||||
### 5c. Dissolve god objects
|
||||
|
||||
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
|
||||
holding the entity ID and delegating to the World. Eventually, they can be
|
||||
removed entirely, replaced by entity ID + component queries.
|
||||
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is holding
|
||||
the composite ID and delegating to the stores. Eventually, they can be removed
|
||||
entirely, replaced by composite IDs + store queries.
|
||||
|
||||
**Risk:** Very High. This is the irreversible step. Must be done only after
|
||||
thorough validation that all consumers (including extensions) work with the
|
||||
@@ -460,8 +401,8 @@ ECS path.
|
||||
|
||||
Legacy removal starts only when all of the following are true:
|
||||
|
||||
- The component being removed has no remaining direct reads or writes outside
|
||||
World/system APIs.
|
||||
- The concern being removed has no remaining direct reads or writes outside
|
||||
store/system APIs.
|
||||
- Serialization equivalence tests pass continuously for one release cycle.
|
||||
- A representative extension compatibility matrix is green, including
|
||||
`rgthree-comfy`.
|
||||
@@ -489,20 +430,20 @@ The team prepares a single go/no-go packet containing:
|
||||
|
||||
### CRDT / ECS coexistence
|
||||
|
||||
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
|
||||
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
|
||||
uses plain `Map`s. These must coexist.
|
||||
`layoutStore` uses Y.js CRDTs for collaboration-ready position data
|
||||
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The other dedicated
|
||||
stores hold plain reactive data. These must coexist.
|
||||
|
||||
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
|
||||
defers the hard question. Eventually, the World may need to be CRDT-native —
|
||||
but this requires a separate ADR.
|
||||
`layoutStore` stays authoritative for layout (Phase 2a), so position data has a
|
||||
single CRDT-backed home. Whether other stores need CRDT backing is open and
|
||||
requires a separate ADR.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- Should non-position components also be CRDT-backed for collaboration?
|
||||
- Does the World need an operation log for undo/redo, or can that remain
|
||||
external (Y.js undo manager)?
|
||||
- How does conflict resolution work when two users modify the same component?
|
||||
- Should non-position stores also be CRDT-backed for collaboration?
|
||||
- Do the stores need an operation log for undo/redo, or can that remain external
|
||||
(Y.js undo manager)?
|
||||
- How does conflict resolution work when two users modify the same record?
|
||||
|
||||
### Extension API preservation
|
||||
|
||||
@@ -529,7 +470,7 @@ event listeners instead of callbacks.
|
||||
|
||||
**Phase 4 decisions:**
|
||||
|
||||
- Rejection callbacks act as pre-commit guards (reject before World mutation).
|
||||
- Rejection callbacks act as pre-commit guards (reject before store mutation).
|
||||
- Callback dispatch remains synchronous during the bridge period.
|
||||
- Callback order remains: output validation -> input validation -> commit ->
|
||||
output change notification -> input change notification.
|
||||
@@ -546,16 +487,12 @@ incrementally to ECS-native patterns.
|
||||
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
|
||||
seedWidget?.setValue(42)
|
||||
|
||||
// ECS pattern (using the bridge/world widget lookup index)
|
||||
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
|
||||
// Store pattern (composite WidgetId, no reverse-lookup index needed)
|
||||
const seedWidgetId = widgetValueStore
|
||||
.getNodeWidgets(graphId, nodeId)
|
||||
.find((id) => parseWidgetId(id).name === 'seed')
|
||||
if (seedWidgetId) {
|
||||
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
|
||||
if (widgetValue) {
|
||||
world.setComponent(seedWidgetId, WidgetValue, {
|
||||
...widgetValue,
|
||||
value: 42
|
||||
})
|
||||
}
|
||||
widgetValueStore.setValue(seedWidgetId, 42)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -606,17 +543,15 @@ lifecycleEvents.on('entity.removed', (event) => {
|
||||
// Legacy pattern (do not add new usages)
|
||||
graph._version++
|
||||
|
||||
// Bridge-safe transitional pattern (Phase 0a)
|
||||
// Transitional pattern (Phase 0a)
|
||||
graph.incrementVersion()
|
||||
|
||||
// ECS-native pattern: mutate through command/system API.
|
||||
// Store-native pattern: mutate through the command/system API.
|
||||
// VersionSystem bumps once at transaction commit.
|
||||
executor.run({
|
||||
type: 'SetWidgetValue',
|
||||
execute(world) {
|
||||
const value = world.getComponent(widgetId, WidgetValue)
|
||||
if (!value) return
|
||||
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
|
||||
execute() {
|
||||
widgetValueStore.setValue(widgetId, 42)
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -628,9 +563,11 @@ executor.run({
|
||||
|
||||
### Atomicity and transactions
|
||||
|
||||
The ECS lifecycle scenarios claim operations are "atomic." This requires
|
||||
the World to support transactions — the ability to batch multiple component
|
||||
writes and commit or rollback as a unit.
|
||||
The lifecycle scenarios claim operations are "atomic." This requires a
|
||||
store/command-executor transaction — the ability to batch multiple store writes
|
||||
and commit or rollback as a unit. `layoutStore` already wraps its mutations in
|
||||
Y.js transactions; the command executor extends the same discipline across
|
||||
stores.
|
||||
|
||||
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
|
||||
checkpoints but not true transactions. The graph can be in an inconsistent
|
||||
@@ -638,10 +575,10 @@ state between these calls.
|
||||
|
||||
**Phase 4 baseline semantics:**
|
||||
|
||||
- Mutating systems run inside `world.transaction(label, fn)`.
|
||||
- The bridge maps one World transaction to one `beforeChange()` /
|
||||
- Mutating systems run inside a single command-executor transaction.
|
||||
- The bridge maps one executor transaction to one `beforeChange()` /
|
||||
`afterChange()` bracket.
|
||||
- Operations with multiple component writes (for example `connect()` touching
|
||||
- Operations with multiple store writes (for example `connect()` touching
|
||||
slots, links, and node metadata) still commit as one transaction and therefore
|
||||
one undo entry.
|
||||
- Failed transactions do not publish partial writes, lifecycle events, or
|
||||
@@ -649,65 +586,64 @@ state between these calls.
|
||||
|
||||
**Questions to resolve:**
|
||||
|
||||
- How should `world.transaction()` interact with Y.js transactions when a
|
||||
component is CRDT-backed?
|
||||
- How should the command-executor transaction interact with the Y.js
|
||||
transactions that `layoutStore` already runs?
|
||||
- Is eventual consistency acceptable for derived data updates between
|
||||
transactions, or must post-transaction state always be immediately
|
||||
consistent?
|
||||
|
||||
### Keying strategy unification
|
||||
|
||||
The 6 proto-ECS stores use 6 different keying strategies:
|
||||
The dedicated stores use per-concern keying strategies:
|
||||
|
||||
| Store | Key Format |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
|
||||
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
|
||||
| DomWidgetStore | Widget UUID |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
||||
| SubgraphNavigationStore | subgraphId or `'root'` |
|
||||
| Store | Key Format |
|
||||
| ------------------------- | ---------------------------------- |
|
||||
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
|
||||
| `domWidgetStore` | Widget UUID |
|
||||
| `layoutStore` | Raw nodeId/linkId/rerouteId |
|
||||
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
|
||||
| `subgraphNavigationStore` | subgraphId or `'root'` |
|
||||
|
||||
ADR 0009 refines the promoted-widget target: promoted value widgets should use
|
||||
host boundary identity (`host node locator + SubgraphInput.name`), not interior
|
||||
source node/widget identity.
|
||||
|
||||
The World unifies these under branded entity IDs. But stores that use
|
||||
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
||||
reality — a widget is identified by its relationship to a node. Synthetic
|
||||
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
|
||||
lookup index.
|
||||
Composite string keys won over synthetic numeric IDs. A widget is identified by
|
||||
its relationship to a graph and node, and the `graphId:nodeId:name` key carries
|
||||
that relationship directly. PR 12617 kept the composite string instead of an
|
||||
opaque number, so no reverse lookup index is required — `parseWidgetId()`
|
||||
recovers the parts on demand.
|
||||
|
||||
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
|
||||
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
|
||||
for the transition period.
|
||||
**Resolution:** Self-documenting composite keys, parsed at boundaries. Each store
|
||||
keeps the key format that matches its concern; there is no forced unification
|
||||
under a single ID space.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 0a (incrementVersion) ──┐
|
||||
Phase 0b (ID type aliases) ───┤
|
||||
Phase 0a (incrementVersion) ──── ✅ shipped (one stray cleanup remaining)
|
||||
Phase 0b (ID type aliases) ───┐
|
||||
Phase 0c (doc fixes) ─────────┤── no dependencies between these
|
||||
│
|
||||
Phase 1a (branded IDs) ────────┤
|
||||
Phase 1b (component interfaces) ┤── 1b depends on 1a
|
||||
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
|
||||
|
||||
Phase 2a (Position bridge) ────┐── depends on 1c
|
||||
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
|
||||
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
|
||||
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
|
||||
Phase 1b (store state shapes) ─┐── depends on 1a
|
||||
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
|
||||
|
||||
Phase 2a (Position via layoutStore) ─┐── depends on 1c
|
||||
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
|
||||
Phase 2c (Node metadata stores) ────┘── depends on 1c
|
||||
|
||||
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
|
||||
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
|
||||
Phase 3b (VersionSystem) ──────── depends on 2c (store-level change tracking)
|
||||
Phase 3c (ConnectivitySystem) ──── depends on 2c
|
||||
|
||||
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
|
||||
|
||||
Phase 4a (Position writes) ────── depends on 2a, 3b
|
||||
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
|
||||
Phase 4c (Widget writes) ─────── depends on 2b
|
||||
Phase 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
|
||||
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
|
||||
|
||||
Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
||||
@@ -715,16 +651,19 @@ Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
||||
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
|
||||
```
|
||||
|
||||
The dedicated stores (1c) are the hub: Phase 2 routes legacy data into them,
|
||||
Phase 3 systems read from them, Phase 4 routes writes through them.
|
||||
|
||||
## Risk Summary
|
||||
|
||||
| Phase | Risk | Reversibility | Extension Impact |
|
||||
| ------------------ | ---------- | ----------------------- | --------------------------- |
|
||||
| 0 (Foundation) | None | Fully reversible | None |
|
||||
| 1 (Types/World) | Low | New files, deletable | None |
|
||||
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
|
||||
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
||||
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
||||
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
||||
| Phase | Risk | Reversibility | Extension Impact |
|
||||
| --------------------- | ---------- | ----------------------- | --------------------------- |
|
||||
| 0 (Foundation) | None | Fully reversible | None |
|
||||
| 1 (Types/Stores) | Low | New files, deletable | None |
|
||||
| 2 (Store integration) | Low-Medium | Additive store reads | None |
|
||||
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
||||
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
||||
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
||||
|
||||
The plan is designed so that Phases 0-3 can ship without any risk to
|
||||
extensions or existing behavior. Phase 4 is where the real migration begins,
|
||||
|
||||
@@ -2,30 +2,29 @@
|
||||
|
||||
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## 1. World Overview
|
||||
## 1. Store Overview
|
||||
|
||||
The World is the single source of truth for runtime entity state in one
|
||||
workflow instance. Entities are just branded IDs. Components are plain data
|
||||
objects. Systems are functions that query the World.
|
||||
The source of truth for runtime entity state in one workflow instance is the set
|
||||
of dedicated Pinia stores. Each store is keyed by per-store string IDs.
|
||||
Components are plain data objects. Systems are functions that query the relevant
|
||||
store(s).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph World["World (Central Registry)"]
|
||||
subgraph Stores["Dedicated Stores (source of truth)"]
|
||||
direction TB
|
||||
NodeStore["Nodes
|
||||
Map<NodeEntityId, NodeComponents>"]
|
||||
LinkStore["Links
|
||||
Map<LinkEntityId, LinkComponents>"]
|
||||
ScopeRegistry["Graph Scopes
|
||||
Map<GraphId, ParentGraphId | null>"]
|
||||
WidgetStore["Widgets
|
||||
Map<WidgetEntityId, WidgetComponents>"]
|
||||
SlotStore["Slots
|
||||
Map<SlotEntityId, SlotComponents>"]
|
||||
RerouteStore["Reroutes
|
||||
Map<RerouteEntityId, RerouteComponents>"]
|
||||
GroupStore["Groups
|
||||
Map<GroupEntityId, GroupComponents>"]
|
||||
WidgetValueStore["widgetValueStore
|
||||
Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
active subgraph path"]
|
||||
PreviewExposureStore["previewExposureStore
|
||||
preview exposure state"]
|
||||
end
|
||||
|
||||
subgraph Systems["Systems (Behavior)"]
|
||||
@@ -38,53 +37,50 @@ Map<GroupEntityId, GroupComponents>"]
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
RS -->|reads| World
|
||||
SS -->|reads/writes| World
|
||||
CS -->|reads/writes| World
|
||||
LS -->|reads/writes| World
|
||||
ES -->|reads| World
|
||||
VS -->|reads/writes| World
|
||||
RS -->|reads| Stores
|
||||
SS -->|reads/writes| Stores
|
||||
CS -->|reads/writes| LayoutStore
|
||||
LS -->|reads/writes| LayoutStore
|
||||
ES -->|reads| NodeOutputStore
|
||||
VS -->|reads/writes| LayoutStore
|
||||
|
||||
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
|
||||
style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
|
||||
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
|
||||
```
|
||||
|
||||
### Entity IDs
|
||||
### Entity Keys
|
||||
|
||||
Each store addresses entities by its own string-key convention.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "Branded IDs (compile-time distinct)"
|
||||
NID["NodeEntityId
|
||||
number & { __brand: 'NodeEntityId' }"]
|
||||
LID["LinkEntityId
|
||||
number & { __brand: 'LinkEntityId' }"]
|
||||
WID["WidgetEntityId
|
||||
number & { __brand: 'WidgetEntityId' }"]
|
||||
SLID["SlotEntityId
|
||||
number & { __brand: 'SlotEntityId' }"]
|
||||
RID["RerouteEntityId
|
||||
number & { __brand: 'RerouteEntityId' }"]
|
||||
GID["GroupEntityId
|
||||
number & { __brand: 'GroupEntityId' }"]
|
||||
subgraph "Per-store string keys"
|
||||
WID["WidgetId
|
||||
graphId:nodeId:name
|
||||
(branded string, src/types/widgetId.ts)"]
|
||||
NLID["nodeLocatorId
|
||||
subgraphId:nodeId"]
|
||||
NID["nodeId (raw)"]
|
||||
LID["linkId (raw)"]
|
||||
RID["rerouteId (raw)"]
|
||||
end
|
||||
|
||||
GRID["GraphId
|
||||
string & { __brand: 'GraphId' }"]:::scopeId
|
||||
|
||||
NID -.-x LID
|
||||
LID -.-x WID
|
||||
WID -.-x SLID
|
||||
|
||||
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
|
||||
|
||||
linkStyle 0 stroke:red,stroke-dasharray:5
|
||||
linkStyle 1 stroke:red,stroke-dasharray:5
|
||||
linkStyle 2 stroke:red,stroke-dasharray:5
|
||||
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
|
||||
NLID -->|nodeOutputStore| W
|
||||
NID -->|layoutStore| W
|
||||
LID -->|layoutStore| W
|
||||
RID -->|layoutStore| W
|
||||
```
|
||||
|
||||
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
|
||||
`WidgetId = graphId:nodeId:name` is itself a branded string (see
|
||||
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
|
||||
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
|
||||
`rerouteId`. Each store enforces its own key shape; there is no single shared
|
||||
entity-ID type across stores.
|
||||
|
||||
Note: `GraphId` is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
Note: `graphId` is a scope identifier. It identifies which graph an entity
|
||||
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
|
||||
`SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
|
||||
|
||||
### Linked subgraphs and instance-varying state
|
||||
|
||||
@@ -94,9 +90,10 @@ instance-scoped.
|
||||
- Shared definition-level data (interface shape, default metadata) can be reused
|
||||
across instances.
|
||||
- Runtime state (`WidgetValue`, execution/transient state, selection) is scoped
|
||||
to the containing `graphId` chain inside one World instance.
|
||||
- "Single source of truth" therefore means one source per workflow instance,
|
||||
not one global source across all linked instances.
|
||||
to the containing `graphId` chain inside one workflow instance.
|
||||
- "Single source of truth" therefore means one source per workflow instance
|
||||
across the dedicated stores, with no global source shared across all linked
|
||||
instances.
|
||||
|
||||
### Recursive subgraphs without inheritance
|
||||
|
||||
@@ -130,7 +127,7 @@ graph LR
|
||||
B12["connect(), disconnect()"]
|
||||
end
|
||||
|
||||
subgraph After["NodeEntityId + Components"]
|
||||
subgraph After["nodeId-keyed components (across stores)"]
|
||||
direction TB
|
||||
A1["Position
|
||||
{ pos, size, bounding }"]
|
||||
@@ -180,7 +177,7 @@ target_id, target_slot, type"]
|
||||
B5["resolve()"]
|
||||
end
|
||||
|
||||
subgraph After["LinkEntityId + Components"]
|
||||
subgraph After["linkId-keyed components (layoutStore)"]
|
||||
direction TB
|
||||
A1["LinkEndpoints
|
||||
{ originId, originSlot,
|
||||
@@ -214,7 +211,7 @@ graph LR
|
||||
B5["useWidgetValueStore()"]
|
||||
end
|
||||
|
||||
subgraph After["WidgetEntityId + Components"]
|
||||
subgraph After["WidgetId + components"]
|
||||
direction TB
|
||||
A1["WidgetIdentity
|
||||
{ name, widgetType, parentNodeId }"]
|
||||
@@ -228,8 +225,7 @@ graph LR
|
||||
B2 -.-> A2
|
||||
B3 -.-> A3
|
||||
B4 -.->|"moves to"| SYS1["RenderSystem"]
|
||||
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
|
||||
B6 -.->|"moves to"| SYS3["PromotionSystem"]
|
||||
B5 -.->|"absorbed by"| SYS2["widgetValueStore"]
|
||||
|
||||
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
@@ -237,7 +233,7 @@ graph LR
|
||||
|
||||
## 3. System Architecture
|
||||
|
||||
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
|
||||
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
@@ -389,30 +385,25 @@ graph TD
|
||||
VS["VersionSystem"]
|
||||
end
|
||||
|
||||
World["World
|
||||
(instance-scoped source of truth)"]
|
||||
|
||||
subgraph Components["Component Stores"]
|
||||
Pos["Position"]
|
||||
Vis["*Visual"]
|
||||
Con["Connectivity"]
|
||||
Val["*Value"]
|
||||
subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
|
||||
LayoutStore["layoutStore"]
|
||||
WidgetValueStore["widgetValueStore"]
|
||||
DomWidgetStore["domWidgetStore"]
|
||||
NodeOutputStore["nodeOutputStore"]
|
||||
end
|
||||
|
||||
Systems -->|"query/mutate"| World
|
||||
World -->|"contains"| Components
|
||||
Systems -->|"query/mutate"| Stores
|
||||
|
||||
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
|
||||
style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
style Stores fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
|
||||
```
|
||||
|
||||
Key differences:
|
||||
|
||||
- **No circular dependencies**: entities are IDs, not class instances
|
||||
- **No Demeter violations**: systems query the World directly, never reach through entities
|
||||
- **No scattered store access**: the World _is_ the store; systems are the only writers
|
||||
- **Unidirectional**: Input → Systems → World → Render (no back-edges)
|
||||
- **No Demeter violations**: systems query stores directly, never reach through entities
|
||||
- **Data lives in dedicated stores**: systems are the only writers
|
||||
- **Unidirectional**: Input → Systems → Stores → Render (no back-edges)
|
||||
- **Instance safety**: linked definitions can be reused without forcing shared
|
||||
mutable widget/execution state across instances
|
||||
|
||||
@@ -447,9 +438,10 @@ No inheritance hierarchy.
|
||||
Subgraph = node + component."]
|
||||
S3["One system per concern.
|
||||
Systems don't overlap."]
|
||||
S4["Branded per-kind IDs.
|
||||
Compile-time type errors."]
|
||||
S5["Systems query World.
|
||||
S4["Consistent per-store
|
||||
string-key conventions
|
||||
(WidgetId, nodeLocatorId, raw ids)."]
|
||||
S5["Systems query stores.
|
||||
No entity→entity refs."]
|
||||
S6["VersionSystem owns
|
||||
all change tracking."]
|
||||
@@ -479,30 +471,30 @@ sequenceDiagram
|
||||
participant Legacy as Legacy Code
|
||||
participant Class as LGraphNode (class)
|
||||
participant Bridge as Bridge Adapter
|
||||
participant World as World (ECS)
|
||||
participant Store as layoutStore (ECS)
|
||||
participant New as New Code / Systems
|
||||
|
||||
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
|
||||
Note over Legacy,New: Phase 1: Bridge reads from class, writes to store
|
||||
|
||||
Legacy->>Class: node.pos = [100, 200]
|
||||
Class->>Bridge: pos setter intercepted
|
||||
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
|
||||
Bridge->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [100, 200], size: [...] }
|
||||
New->>Store: layoutStore read for nodeId
|
||||
Store-->>New: { pos: [100, 200], size: [...] }
|
||||
|
||||
Note over Legacy,New: Phase 2: New features build on ECS directly
|
||||
|
||||
New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
|
||||
World->>Bridge: change detected
|
||||
New->>Store: useLayoutMutations().moveNode(nodeId, { pos: [150, 250] })
|
||||
Store->>Bridge: change detected
|
||||
Bridge->>Class: node._pos = [150, 250]
|
||||
Legacy->>Class: node.pos
|
||||
Class-->>Legacy: [150, 250]
|
||||
|
||||
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
|
||||
|
||||
New->>World: world.getComponent(nodeId, Position)
|
||||
World-->>New: { pos: [150, 250] }
|
||||
New->>Store: layoutStore read for nodeId
|
||||
Store-->>New: { pos: [150, 250] }
|
||||
```
|
||||
|
||||
### Incremental layout/render separation
|
||||
@@ -524,16 +516,17 @@ incremental rollout safety.
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Phase1["Phase 1: Types Only"]
|
||||
T1["Define branded IDs"]
|
||||
T1["Define string-key types
|
||||
(WidgetId, nodeLocatorId)"]
|
||||
T2["Define component interfaces"]
|
||||
T3["Define World type"]
|
||||
T3["Define store shapes"]
|
||||
end
|
||||
|
||||
subgraph Phase2["Phase 2: Bridge"]
|
||||
B1["Bridge adapters
|
||||
class ↔ World sync"]
|
||||
class ↔ store sync"]
|
||||
B2["New features use
|
||||
World as source"]
|
||||
stores as source"]
|
||||
B3["Old code unchanged"]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# World API and Command Layer
|
||||
|
||||
How the ECS World's imperative API relates to ADR 0003's command pattern
|
||||
requirement, and why the two are complementary rather than conflicting.
|
||||
|
||||
This document responds to the concern that `world.setComponent()` and
|
||||
`ConnectivitySystem.connect()` are "imperative mutators" incompatible with
|
||||
serializable, idempotent commands. The short answer: they are the
|
||||
**implementation** of commands, not a replacement for them.
|
||||
|
||||
## Architectural Layering
|
||||
|
||||
```
|
||||
Caller → Command → System (handler) → World (store) → Y.js (sync)
|
||||
↓
|
||||
Command Log (undo, replay, sync)
|
||||
```
|
||||
|
||||
- **Commands** describe intent. They are serializable, deterministic, and
|
||||
idempotent.
|
||||
- **Systems** are command handlers. They validate, execute, and emit lifecycle
|
||||
events.
|
||||
- **The World** is the store. It holds component data. It does not know about
|
||||
commands.
|
||||
|
||||
This is the same relationship Redux has between actions, reducers, and the
|
||||
store. The store's `dispatch()` is imperative. That does not make Redux
|
||||
incompatible with serializable actions.
|
||||
|
||||
## Proposed World Mutation API
|
||||
|
||||
The World exposes a thin imperative surface. Every mutation goes through a
|
||||
system, and every system call is invoked by a command.
|
||||
|
||||
### World Core API
|
||||
|
||||
```ts
|
||||
interface World {
|
||||
// Reads (no command needed)
|
||||
getComponent<C>(id: EntityId, key: ComponentKey<C>): C | undefined
|
||||
hasComponent(id: EntityId, key: ComponentKey<C>): boolean
|
||||
queryAll<C extends ComponentKey[]>(...keys: C): QueryResult<C>[]
|
||||
|
||||
// Mutations (called only by systems, inside transactions)
|
||||
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
||||
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
||||
setComponent<C>(id: EntityId, key: ComponentKey<C>, data: C): void
|
||||
removeComponent(id: EntityId, key: ComponentKey<C>): void
|
||||
|
||||
// Transaction boundary
|
||||
transaction<T>(label: string, fn: () => T): T
|
||||
}
|
||||
```
|
||||
|
||||
These methods are **internal**. External callers never call
|
||||
`world.setComponent()` directly — they submit commands.
|
||||
|
||||
### Command Interface
|
||||
|
||||
```ts
|
||||
interface Command<T = void> {
|
||||
readonly type: string
|
||||
execute(world: World): T
|
||||
}
|
||||
```
|
||||
|
||||
A command is a plain object with a `type` discriminator and an `execute`
|
||||
method that receives the World. The command executor wraps every
|
||||
`execute()` call in a World transaction.
|
||||
|
||||
### Command Executor
|
||||
|
||||
```ts
|
||||
interface CommandExecutor {
|
||||
run<T>(command: Command<T>): T
|
||||
batch(label: string, commands: Command[]): void
|
||||
}
|
||||
|
||||
function createCommandExecutor(world: World): CommandExecutor {
|
||||
return {
|
||||
run(command) {
|
||||
return world.transaction(command.type, () => command.execute(world))
|
||||
},
|
||||
batch(label, commands) {
|
||||
world.transaction(label, () => {
|
||||
for (const cmd of commands) cmd.execute(world)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every command execution:
|
||||
|
||||
1. Opens a World transaction (maps to one `beforeChange`/`afterChange`
|
||||
bracket for undo).
|
||||
2. Calls the command's `execute()`, which invokes system functions.
|
||||
3. Commits the transaction. On failure, rolls back — no partial writes, no
|
||||
lifecycle events, no version bump.
|
||||
|
||||
## From Imperative Calls to Commands
|
||||
|
||||
The lifecycle scenarios in
|
||||
[ecs-lifecycle-scenarios.md](ecs-lifecycle-scenarios.md) show system calls
|
||||
like `ConnectivitySystem.connect(world, outputSlotId, inputSlotId)`. These
|
||||
are the **internals** of a command. Here is how each scenario maps:
|
||||
|
||||
### Connect Slots
|
||||
|
||||
The lifecycle scenario shows:
|
||||
|
||||
```ts
|
||||
// Inside ConnectivitySystem — this is the handler, not the public API
|
||||
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
|
||||
```
|
||||
|
||||
The public API is a command:
|
||||
|
||||
```ts
|
||||
const connectSlots: Command = {
|
||||
type: 'ConnectSlots',
|
||||
outputSlotId,
|
||||
inputSlotId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId)
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(connectSlots)
|
||||
```
|
||||
|
||||
The command object is serializable (`{ type, outputSlotId, inputSlotId }`).
|
||||
It can be sent over a wire, stored in a log, or replayed.
|
||||
|
||||
### Move Node
|
||||
|
||||
```ts
|
||||
const moveNode: Command = {
|
||||
type: 'MoveNode',
|
||||
nodeId,
|
||||
pos: [150, 250],
|
||||
|
||||
execute(world) {
|
||||
LayoutSystem.moveNode(world, this.nodeId, this.pos)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Node
|
||||
|
||||
```ts
|
||||
const removeNode: Command = {
|
||||
type: 'RemoveNode',
|
||||
nodeId,
|
||||
|
||||
execute(world) {
|
||||
ConnectivitySystem.removeNode(world, this.nodeId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Set Widget Value
|
||||
|
||||
```ts
|
||||
const setWidgetValue: Command = {
|
||||
type: 'SetWidgetValue',
|
||||
widgetId,
|
||||
value,
|
||||
|
||||
execute(world) {
|
||||
world.setComponent(this.widgetId, WidgetValue, {
|
||||
...world.getComponent(this.widgetId, WidgetValue)!,
|
||||
value: this.value
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batch: Paste
|
||||
|
||||
Paste is a compound operation — many entities created in one undo step:
|
||||
|
||||
```ts
|
||||
const paste: Command = {
|
||||
type: 'Paste',
|
||||
snapshot,
|
||||
offset,
|
||||
|
||||
execute(world) {
|
||||
const remap = new Map<EntityId, EntityId>()
|
||||
|
||||
for (const entity of this.snapshot.entities) {
|
||||
const newId = world.createEntity(entity.kind)
|
||||
remap.set(entity.id, newId)
|
||||
|
||||
for (const [key, data] of entity.components) {
|
||||
world.setComponent(newId, key, remapEntityRefs(data, remap))
|
||||
}
|
||||
}
|
||||
|
||||
// Offset positions
|
||||
for (const [, newId] of remap) {
|
||||
const pos = world.getComponent(newId, Position)
|
||||
if (pos) {
|
||||
world.setComponent(newId, Position, {
|
||||
...pos,
|
||||
pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executor.run(paste) // one transaction, one undo step
|
||||
```
|
||||
|
||||
## Addressing the Six Concerns
|
||||
|
||||
The PR review raised six "critical conflicts." Here is how the command layer
|
||||
resolves each:
|
||||
|
||||
### 1. "The World API is imperative, not command-based"
|
||||
|
||||
Correct — by design. The World is the store. Commands are the public
|
||||
mutation API above it. `world.setComponent()` is to commands what
|
||||
`state[key] = value` is to Redux reducers.
|
||||
|
||||
### 2. "Systems are orchestrators, not command producers"
|
||||
|
||||
Systems are command **handlers**. A command's `execute()` calls system
|
||||
functions. Systems do not spontaneously mutate the World — they are invoked
|
||||
by commands.
|
||||
|
||||
### 3. "Auto-incrementing IDs are non-stable in concurrent environments"
|
||||
|
||||
For local-only operations, auto-increment is fine. For CRDT sync, entity
|
||||
creation goes through a CRDT-aware ID generator (Y.js provides this via
|
||||
`doc.clientID` + logical clock). The command layer can select the ID
|
||||
strategy:
|
||||
|
||||
```ts
|
||||
// Local-only command
|
||||
world.createEntity(kind) // auto-increment
|
||||
|
||||
// CRDT-aware command (future)
|
||||
world.createEntityWithId(kind, crdtGeneratedId)
|
||||
```
|
||||
|
||||
This is an ID generation concern, not an ECS architecture concern.
|
||||
|
||||
### 4. "No transaction primitive exists"
|
||||
|
||||
`world.transaction(label, fn)` is the primitive. It maps to one
|
||||
`beforeChange`/`afterChange` bracket. The command executor wraps every
|
||||
`execute()` call in a transaction. See the [migration plan's Phase 3→4
|
||||
gate](ecs-migration-plan.md#phase-3---4-gate-required) for the acceptance
|
||||
criteria.
|
||||
|
||||
### 5. "No idempotency guarantees"
|
||||
|
||||
Idempotency is a property of the command, not the store. Two strategies:
|
||||
|
||||
- **Content-addressed IDs**: The command specifies the entity ID rather than
|
||||
auto-generating. Replaying the command with the same ID is a no-op if the
|
||||
entity already exists.
|
||||
- **Command deduplication**: The command log tracks applied command IDs.
|
||||
Replaying an already-applied command is skipped.
|
||||
|
||||
Both are standard CRDT patterns and belong in the command executor, not the
|
||||
World.
|
||||
|
||||
### 6. "No error semantics"
|
||||
|
||||
Commands return results. The executor can wrap execution:
|
||||
|
||||
```ts
|
||||
type CommandResult<T> =
|
||||
| { status: 'applied'; value: T }
|
||||
| { status: 'rejected'; reason: string }
|
||||
| { status: 'no-op' }
|
||||
|
||||
function run<T>(command: Command<T>): CommandResult<T> {
|
||||
try {
|
||||
const value = world.transaction(command.type, () => command.execute(world))
|
||||
return { status: 'applied', value }
|
||||
} catch (e) {
|
||||
if (e instanceof RejectionError) {
|
||||
return { status: 'rejected', reason: e.message }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rejection semantics (e.g., `onConnectInput` returning false) throw a
|
||||
`RejectionError` inside the system, which the transaction rolls back.
|
||||
|
||||
## Why Two ADRs
|
||||
|
||||
ADR 0003 defines the command pattern and CRDT sync layer.
|
||||
ADR 0008 defines the entity data model.
|
||||
|
||||
They are **complementary architectural layers**, not competing proposals:
|
||||
|
||||
| Concern | Owns It |
|
||||
| ------------------------- | -------- |
|
||||
| Entity taxonomy and IDs | ADR 0008 |
|
||||
| Component decomposition | ADR 0008 |
|
||||
| World (store) | ADR 0008 |
|
||||
| Command interface | ADR 0003 |
|
||||
| Undo/redo via command log | ADR 0003 |
|
||||
| CRDT sync | ADR 0003 |
|
||||
| Serialization format | ADR 0008 |
|
||||
| Replay and idempotency | ADR 0003 |
|
||||
|
||||
Merging them into a single mega-ADR would conflate the data model with the
|
||||
mutation strategy. Keeping them separate allows each to evolve independently
|
||||
— the World can change its internal representation without affecting the
|
||||
command API, and the command layer can adopt new sync strategies without
|
||||
restructuring the entity model.
|
||||
|
||||
## Relationship to Lifecycle Scenarios
|
||||
|
||||
The [lifecycle scenarios](ecs-lifecycle-scenarios.md) show system-level
|
||||
calls (`ConnectivitySystem.connect()`, `ClipboardSystem.paste()`, etc.).
|
||||
These are the **inside** of a command — what the command handler does when
|
||||
the command is executed.
|
||||
|
||||
The scenarios deliberately omit the command layer to focus on how systems
|
||||
interact with the World. Adding command wrappers is mechanical: every
|
||||
system call shown in the scenarios becomes the body of a command's
|
||||
`execute()` method.
|
||||
|
||||
## When This Gets Built
|
||||
|
||||
The command layer is not part of the initial ECS migration phases (0–3).
|
||||
During Phases 0–3, the bridge layer provides mutation entry points that
|
||||
will later become command handlers. The command layer is introduced in
|
||||
Phase 4 when write paths migrate from legacy to ECS:
|
||||
|
||||
- **Phase 4a**: Position write commands replace direct `node.pos =` assignment
|
||||
- **Phase 4b**: Connectivity commands replace `node.connect()` /
|
||||
`node.disconnect()`
|
||||
- **Phase 4c**: Widget value commands replace direct store writes
|
||||
|
||||
Each Phase 4 step introduces commands for one concern, with the system
|
||||
function as the handler and the World transaction as the atomicity
|
||||
boundary.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Entity Interactions (Current System)
|
||||
|
||||
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md).
|
||||
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md), whose target is realized as a set of dedicated Pinia stores (see [Proto-ECS Stores](proto-ecs-stores.md)).
|
||||
|
||||
## Entities
|
||||
|
||||
@@ -361,7 +361,7 @@ graph TD
|
||||
subgraph Stores
|
||||
WVS["WidgetValueStore
|
||||
(Pinia)"]
|
||||
PS["PromotionStore
|
||||
PES["PreviewExposureStore
|
||||
(Pinia)"]
|
||||
LM["LayoutMutations
|
||||
(composable)"]
|
||||
@@ -379,9 +379,9 @@ lastRerouteId, lastGroupId)"]
|
||||
Widget <-->|"value, label, disabled"| WVS
|
||||
WVS -.->|"keyed by graphId:nodeId:name"| Widget
|
||||
|
||||
%% PromotionStore
|
||||
SGNode -->|"tracks promoted widgets"| PS
|
||||
Widget -.->|"isPromotedByAny() query"| PS
|
||||
%% PreviewExposureStore
|
||||
SGNode -->|"host-scoped preview exposures"| PES
|
||||
PES -.->|"keyed by host node locator"| SGNode
|
||||
|
||||
%% LayoutMutations
|
||||
Node -->|"pos/size setter"| LM
|
||||
|
||||
@@ -115,7 +115,7 @@ If slots are reordered (e.g., by an extension adding a slot), all links referenc
|
||||
|
||||
### No Cross-Kind ID Safety
|
||||
|
||||
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008.
|
||||
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. The dedicated-store direction addresses this with branded string keys where cross-kind safety pays off (for example `WidgetId` in `widgetValueStore`, `src/types/widgetId.ts`).
|
||||
|
||||
## 5. Law of Demeter Violations
|
||||
|
||||
@@ -201,12 +201,12 @@ This means:
|
||||
|
||||
## How ECS Addresses These Problems
|
||||
|
||||
| Problem | ECS Solution |
|
||||
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||
| God objects | Data split into small, focused components; behavior lives in systems |
|
||||
| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy |
|
||||
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
|
||||
| Inconsistent IDs | Branded per-kind IDs with compile-time safety |
|
||||
| Demeter violations | Systems query the World directly; no entity-to-entity references |
|
||||
| Scattered side effects | Version tracking becomes a system responsibility; stores become systems |
|
||||
| Render-time mutations | Render system reads components without writing; layout system runs separately |
|
||||
| Problem | ECS Solution |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| God objects | Data split into small, focused components in dedicated stores; behavior lives in systems |
|
||||
| Circular dependencies | Entities are addressed by string keys; components have no inheritance hierarchy |
|
||||
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
|
||||
| Inconsistent IDs | Branded string keys per store (for example `WidgetId`) for cross-kind safety |
|
||||
| Demeter violations | Systems query the relevant store directly; no entity-to-entity references |
|
||||
| Scattered side effects | Version tracking becomes a system responsibility; mutations flow through store command APIs |
|
||||
| Render-time mutations | Render system reads components without writing; layout system runs separately |
|
||||
|
||||
@@ -6,17 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
|
||||
|
||||
## 1. What's Already Extracted
|
||||
|
||||
Five stores extract entity state out of class instances into centralized,
|
||||
queryable registries. Promoted value-widget topology is no longer a store; ADR
|
||||
0009 represents it as ordinary linked `SubgraphInput` state.
|
||||
Six dedicated stores extract entity state out of class instances into focused,
|
||||
queryable registries, each owning one concern. Promoted value-widget topology is
|
||||
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
|
||||
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
|
||||
`WidgetId`.
|
||||
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| Store | Extracts From | Scoping | Key Format | Data Shape |
|
||||
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
|
||||
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
|
||||
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
|
||||
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
|
||||
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
|
||||
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
|
||||
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
|
||||
|
||||
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
|
||||
the host boundary (`host node locator + SubgraphInput.name`), while interior
|
||||
@@ -31,9 +34,9 @@ The closest thing to a true ECS component store in the codebase today.
|
||||
### State Shape
|
||||
|
||||
```
|
||||
Map<UUID, Map<WidgetKey, WidgetState>>
|
||||
Map<UUID, Map<WidgetId, WidgetState>>
|
||||
│ │ │
|
||||
graphId "nodeId:name" pure data object
|
||||
graphId "graphId:nodeId:name" pure data object
|
||||
```
|
||||
|
||||
`WidgetState` is a plain data object with no methods:
|
||||
@@ -56,7 +59,7 @@ Map<UUID, Map<WidgetKey, WidgetState>>
|
||||
**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object:
|
||||
|
||||
```
|
||||
widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId })
|
||||
widget._state = useWidgetValueStore().registerWidget(widgetId, { ...this._state, nodeId })
|
||||
```
|
||||
|
||||
After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference.
|
||||
@@ -119,20 +122,22 @@ Legacy `properties.proxyWidgets` is load-time migration input only.
|
||||
╰────────────────╯ ╰──────────────────────╯
|
||||
```
|
||||
|
||||
`PromotedWidgetViewManager`
|
||||
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
|
||||
synthetic widget views derived from linked subgraph inputs. It does not sit on
|
||||
top of a promotion registry.
|
||||
A promoted host widget is ordinary `WidgetState` in `WidgetValueStore`, keyed by
|
||||
the `WidgetId` carried on the `SubgraphInput` (`input.widgetId`). `SubgraphNode.widgets`
|
||||
is a read-only projection over the node's inputs that resolves each value via
|
||||
`useWidgetValueStore().getWidget(input.widgetId)`. There is no synthetic widget
|
||||
view object and no view cache to reconcile (PR 12617 deleted `PromotedWidgetView`
|
||||
and `PromotedWidgetViewManager`).
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ----------------------------- | --------- | ------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
|
||||
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | -------- | -------------------------------------------------------------- |
|
||||
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
|
||||
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
|
||||
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
|
||||
| Promoted value is plain data | Yes | Host widget is `WidgetState` in the store, keyed by `WidgetId` |
|
||||
| Projection over data | Yes | `SubgraphNode.widgets` derives from inputs; no view cache |
|
||||
|
||||
## 4. LayoutStore (CRDT)
|
||||
|
||||
@@ -171,14 +176,14 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
|
||||
### ECS Alignment
|
||||
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | --------- | --------------------------------------------------- |
|
||||
| Position data extracted | Yes | Closest to the ECS `Position` component |
|
||||
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
|
||||
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
|
||||
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
|
||||
| Module-scope access | **No** | Domain objects import store at module level |
|
||||
| No entity ID branding | **No** | Plain numbers, no type safety across kinds |
|
||||
| Aspect | ECS-like | Why |
|
||||
| ---------------------------- | --------- | ------------------------------------------------------- |
|
||||
| Position data extracted | Yes | Closest to the ECS `Position` component |
|
||||
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
|
||||
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
|
||||
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
|
||||
| Module-scope access | **No** | Domain objects import store at module level |
|
||||
| Per-store keying | Yes | Owns `nodeId`/`linkId`/`rerouteId` keys for its concern |
|
||||
|
||||
## 5. Pattern Analysis
|
||||
|
||||
@@ -190,30 +195,32 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
|
||||
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
|
||||
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
|
||||
|
||||
### What's Missing vs Full ECS
|
||||
### Target Design and Remaining Gaps
|
||||
|
||||
Dedicated per-domain stores with their own string keys are the target, not a way
|
||||
station toward one unified registry. The remaining gaps are about behavior and
|
||||
data flow, not about collapsing the stores together.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Have["What We Have"]
|
||||
subgraph Have["What We Have (and Want)"]
|
||||
style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
|
||||
H1["Centralized data stores"]
|
||||
H1["Dedicated per-domain stores"]
|
||||
H2["Plain data components
|
||||
(WidgetState, LayoutMap)"]
|
||||
H3["Query APIs
|
||||
(getWidget, preview exposures)"]
|
||||
H4["Graph-scoped lifecycle"]
|
||||
H5["Partial position extraction
|
||||
H5["Per-store string keys
|
||||
(WidgetId, nodeLocatorId)"]
|
||||
H6["Position extraction
|
||||
(LayoutStore)"]
|
||||
end
|
||||
|
||||
subgraph Missing["What's Missing"]
|
||||
style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
|
||||
M1["Unified World
|
||||
(6 stores, 6 keying strategies)"]
|
||||
M2["Branded entity IDs
|
||||
(keys are string concatenations)"]
|
||||
M3["System layer
|
||||
(mutations from anywhere)"]
|
||||
M3["System / command layer
|
||||
(sanctioned mutation path)"]
|
||||
M4["Complete extraction
|
||||
(behavior still on classes)"]
|
||||
M5["No entity-to-entity refs
|
||||
@@ -225,19 +232,21 @@ graph TD
|
||||
|
||||
### Keying Strategy Comparison
|
||||
|
||||
Each store invents its own identity scheme:
|
||||
Each store owns the identity scheme that fits its concern:
|
||||
|
||||
| Store | Key Format | Entity ID Used | Type-Safe? |
|
||||
| ---------------- | --------------------------- | ----------------------- | ---------- |
|
||||
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
| Store | Key Format | Key Type | Type-Safe? |
|
||||
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
|
||||
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
|
||||
| DomWidgetStore | Widget UUID | UUID (string) | No |
|
||||
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
|
||||
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
|
||||
|
||||
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
|
||||
For promoted value widgets, ADR 0009 narrows the target key to host boundary
|
||||
identity (`host node locator + SubgraphInput.name`) instead of interior source
|
||||
identity.
|
||||
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
|
||||
which carries its scope and survives renames at the store layer. The remaining
|
||||
stores can adopt their own branded string keys where cross-kind safety pays off,
|
||||
without a shared entity-ID space. For promoted value widgets, ADR 0009 keys on
|
||||
the host boundary: the input's `WidgetId` (host node locator + `SubgraphInput.name`),
|
||||
not interior source identity.
|
||||
|
||||
## 6. Extraction Map
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ graph TD
|
||||
|
||||
subgraph Unified["Unified: Composition"]
|
||||
direction TB
|
||||
W["World (flat)"]
|
||||
W["Dedicated stores (flat)"]
|
||||
N1["Node A
|
||||
graphScope: root"]
|
||||
N2["Node B (subgraph carrier)
|
||||
@@ -95,30 +95,29 @@ graph TD
|
||||
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
|
||||
```
|
||||
|
||||
In the ECS World:
|
||||
Across the dedicated stores:
|
||||
|
||||
- **Every graph is a graph.** The "root" graph is simply the one whose
|
||||
`graphScope` has no parent.
|
||||
- **Nesting is a component, not a type.** A node can carry a
|
||||
`SubgraphStructure` component, which references another graph scope. That
|
||||
scope contains its own entities — nodes, links, widgets, reroutes, groups —
|
||||
all living in the same flat World.
|
||||
- **One World per workflow.** All entities across all nesting levels coexist in
|
||||
a single World, each tagged with a `graphScope` identifier. There are no
|
||||
sub-worlds, no recursive containers. The fractal structure is encoded in the
|
||||
data, not in the container hierarchy.
|
||||
- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds
|
||||
including `SubgraphEntityId`. Under unification, "subgraph" is not an entity
|
||||
kind — it is a node with a component. The taxonomy becomes: Node, Link,
|
||||
Widget, Slot, Reroute, Group.
|
||||
- **ID counters remain global.** All entity IDs are allocated from a single
|
||||
counter space, shared across all nesting levels. This preserves the current
|
||||
`rootGraph.state` behavior and guarantees ID uniqueness across the entire
|
||||
World.
|
||||
- **Graph scope parentage is tracked.** The World maintains a scope registry:
|
||||
each `graphId` maps to its parent `graphId` (or null for the root). This
|
||||
enables the ancestor walk required by the acyclicity invariant and supports
|
||||
queries like "all entities transitively contained by this graph."
|
||||
- **Nesting is a component.** A node can carry a `SubgraphStructure` component,
|
||||
which references another graph scope. That scope contains its own entities —
|
||||
nodes, links, widgets, reroutes, groups — and each store entry tags the scope
|
||||
it belongs to.
|
||||
- **Scope tagging over container nesting.** Entries across all nesting levels
|
||||
live in the same stores, each tagged with a `graphScope` identifier. The
|
||||
stores stay flat; the fractal structure is encoded in the keys and scope tags,
|
||||
with no recursive containers.
|
||||
- **Entity taxonomy: six kinds.** A subgraph is a node carrying a
|
||||
`SubgraphStructure` component. The taxonomy is: Node, Link, Widget, Slot,
|
||||
Reroute, Group. `SubgraphEntityId` is replaced by a `GraphId` scope identifier.
|
||||
- **Numeric ID counters stay shared.** Node/link/reroute IDs are allocated from
|
||||
a single counter space across nesting levels, preserving the current
|
||||
`rootGraph.state` behavior and guaranteeing uniqueness. Widget keys embed
|
||||
their scope directly (`WidgetId = graphId:nodeId:name`).
|
||||
- **Graph scope parentage is tracked.** A scope registry maps each `graphId` to
|
||||
its parent `graphId` (or null for the root). This enables the ancestor walk
|
||||
required by the acyclicity invariant and supports queries like "all entities
|
||||
transitively contained by this graph."
|
||||
|
||||
### The acyclicity invariant
|
||||
|
||||
@@ -367,15 +366,17 @@ sequenceDiagram
|
||||
Exec->>IW: reads input value (42)
|
||||
```
|
||||
|
||||
### Candidate B: Simplified component promotion
|
||||
### Candidate B: Simplified component promotion (rejected)
|
||||
|
||||
ADR 0009 chose Candidate A. Candidate B is retained here as the rejected
|
||||
alternative; it relied on a source-widget lookup model that no longer exists.
|
||||
|
||||
Promotion remains a first-class concept, simplified from three layers to one:
|
||||
|
||||
- A `WidgetPromotion` component on a widget entity:
|
||||
`{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }`
|
||||
- The SubgraphNode's widget list includes promoted widget entity IDs directly
|
||||
- Value reads/writes delegate to the source widget's `WidgetValue` component via
|
||||
World lookup
|
||||
- A `WidgetPromotion` component on a widget entity referencing the host node and
|
||||
source widget
|
||||
- The SubgraphNode's widget list includes promoted widget references directly
|
||||
- Value reads/writes delegate to the source widget's value via a store lookup
|
||||
- Serialized as `properties.proxyWidgets` (unchanged)
|
||||
|
||||
This removes the ViewManager and proxy widget reconciliation but preserves the
|
||||
@@ -399,8 +400,9 @@ concept of promotion as distinct from connection.
|
||||
|
||||
Whichever candidate is chosen:
|
||||
|
||||
- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node
|
||||
reference. This is settled (see Section 4).
|
||||
- **Internal identity is the `WidgetId` string.** Serialization uses widget name
|
||||
- parent node reference, while runtime state keys on `WidgetId`
|
||||
(`graphId:nodeId:name`). This is settled (see Section 4).
|
||||
- **The type → widget mapping is authoritative.** The widget registry
|
||||
(`widgetStore.widgets`) is the single source of truth for which types produce
|
||||
widgets. No parallel mechanism should duplicate this.
|
||||
@@ -473,10 +475,10 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
|
||||
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
|
||||
see no change.
|
||||
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------ |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
|
||||
| Direction | Format | Notes |
|
||||
| --------------- | ------------------------------- | -------------------------------------------------- |
|
||||
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
|
||||
| **Load/import** | Nested (current) or future flat | Migration: normalize to store-backed state on load |
|
||||
|
||||
The migration pattern: load any supported format and normalize to the internal
|
||||
model. The system accepts old formats indefinitely but produces the current
|
||||
@@ -486,7 +488,7 @@ format on save.
|
||||
|
||||
| Context | Identity | Example |
|
||||
| -------------------- | ---------------------------------------------------------- | ---------------------------------- |
|
||||
| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` |
|
||||
| **Internal (store)** | `WidgetId` composite string | `'graphId:42:seed' as WidgetId` |
|
||||
| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget |
|
||||
|
||||
On save: the `SerializationSystem` queries `WidgetIdentity.name` and
|
||||
@@ -494,7 +496,7 @@ On save: the `SerializationSystem` queries `WidgetIdentity.name` and
|
||||
order.
|
||||
|
||||
On load: widget values are matched by name against the node definition's input
|
||||
specs, then assigned `WidgetEntityId`s from the global counter.
|
||||
specs, then registered in `WidgetValueStore` under their `WidgetId`.
|
||||
|
||||
This is the existing contract, preserved exactly.
|
||||
|
||||
@@ -553,7 +555,7 @@ This document proposes or surfaces the following changes to
|
||||
| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component |
|
||||
| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier |
|
||||
| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities |
|
||||
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
|
||||
| Storage structure | Implied per-graph containment | Dedicated stores with `graphScope`-tagged entries; no single registry |
|
||||
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
|
||||
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
|
||||
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
|
||||
|
||||
@@ -344,15 +344,6 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,224 +0,0 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBoxes: boundingBoxes.boundingBoxes,
|
||||
palette: { swatchTitle: 'Edit', addColor: 'Add' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
const fakeCtx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
canvas.getContext = (() =>
|
||||
fakeCtx) as unknown as HTMLCanvasElement['getContext']
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
canvas.setPointerCapture = () => {}
|
||||
canvas.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
prepCanvas(canvas)
|
||||
return { ...result, canvas }
|
||||
}
|
||||
|
||||
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0] as BoundingBox[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('WidgetBoundingBoxes', () => {
|
||||
it('renders the canvas and editor shell', () => {
|
||||
renderWidget([])
|
||||
expect(
|
||||
screen.getByTestId('bounding-boxes').querySelector('canvas')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the region editor panel when a region is active', () => {
|
||||
renderWidget([box()])
|
||||
expect(screen.getByText('obj')).toBeTruthy()
|
||||
expect(screen.getByText('text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals the text field after switching the region to text', async () => {
|
||||
renderWidget([box()])
|
||||
expect(
|
||||
screen.queryByPlaceholderText('text to render (verbatim)')
|
||||
).toBeNull()
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
expect(
|
||||
screen.getByPlaceholderText('text to render (verbatim)')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears all regions via the clear button', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('Clear all'))
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a region through canvas pointer events', async () => {
|
||||
const { canvas, emitted } = renderWidget([])
|
||||
await fireEvent.pointerDown(canvas, {
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerMove(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerUp(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(lastBoxes(emitted)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks focus and blur on the canvas', async () => {
|
||||
const { canvas } = renderWidget([box()])
|
||||
await fireEvent.focus(canvas)
|
||||
await fireEvent.blur(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens an inline editor on double click', async () => {
|
||||
const { canvas, container } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
expect(container.querySelector('textarea')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('syncs description edits back to the model', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('description of this region'),
|
||||
'a caption'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
|
||||
})
|
||||
|
||||
it('edits the text field once the region is a text region', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('text to render (verbatim)'),
|
||||
'hello'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('deletes the active region with the Delete key', async () => {
|
||||
const { canvas, emitted } = renderWidget([box()])
|
||||
await fireEvent.keyDown(canvas, { key: 'Delete' })
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears hover state on pointer leave', async () => {
|
||||
const { canvas } = renderWidget([
|
||||
box({ x: 10, y: 10, width: 256, height: 256 })
|
||||
])
|
||||
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
|
||||
await fireEvent.pointerLeave(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the inline editor on blur', async () => {
|
||||
const { canvas, container, emitted } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
const editor = container.querySelector('textarea')!
|
||||
await fireEvent.update(editor, 'committed')
|
||||
await fireEvent.blur(editor)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex size-full flex-col gap-1 select-none"
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeRegion"
|
||||
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'obj'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('obj')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeObj') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'text'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('text')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeText') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRegion.type === 'text'"
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.textLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.text"
|
||||
:placeholder="$t('boundingBoxes.textPlaceholder')"
|
||||
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.descLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.desc"
|
||||
:placeholder="$t('boundingBoxes.descPlaceholder')"
|
||||
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0 truncate text-sm text-muted-foreground">
|
||||
{{ $t('boundingBoxes.colors') }}
|
||||
</span>
|
||||
<PaletteSwatchRow
|
||||
v-model="activeRegion.palette"
|
||||
:max="maxColors"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
|
||||
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
|
||||
|
||||
const {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
</script>
|
||||
@@ -427,6 +427,7 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
@@ -452,14 +453,16 @@ onMounted(() => {
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
if (isCloud) {
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
}
|
||||
|
||||
originalOnClose()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
autofocus
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdrViewerContent from './HdrViewerContent.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
|
||||
|
||||
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
|
||||
vi.mock('@/composables/useHdrViewer', () => ({
|
||||
useHdrViewer: () => holder.viewer,
|
||||
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading', downloadImage: 'Download' },
|
||||
hdrViewer: {
|
||||
failedToLoad: 'Failed',
|
||||
exposure: 'Exposure',
|
||||
normalizeExposure: 'Auto exposure',
|
||||
channel: 'Channel',
|
||||
channels: {
|
||||
rgb: 'RGB',
|
||||
r: 'R',
|
||||
g: 'G',
|
||||
b: 'B',
|
||||
a: 'Alpha',
|
||||
luminance: 'Luminance'
|
||||
},
|
||||
sourceGamut: 'Source gamut',
|
||||
dither: 'Dither',
|
||||
clipWarnings: 'Clip warnings',
|
||||
fitView: 'Fit',
|
||||
histogram: 'Histogram',
|
||||
resolution: 'Resolution',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
stdDev: 'Std dev',
|
||||
nan: 'NaN',
|
||||
inf: 'Inf'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
exposureStops: ref(0),
|
||||
dither: ref(true),
|
||||
clipWarnings: ref(false),
|
||||
gamut: ref('sRGB'),
|
||||
channel: ref('r'),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
dimensions: ref('512 x 512'),
|
||||
stats: ref({
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 0.5,
|
||||
stdDev: 0.2,
|
||||
nanCount: 2,
|
||||
infCount: 1
|
||||
}),
|
||||
histogram: ref(new Uint32Array([1, 2, 3, 4])),
|
||||
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
|
||||
mount: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
normalizeExposure: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return render(HdrViewerContent, {
|
||||
props: { imageUrl: '/api/view?filename=out.exr' },
|
||||
global: { plugins: [i18n], stubs: { Button: true } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('HdrViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
holder.viewer = makeViewer()
|
||||
})
|
||||
|
||||
it('renders the full statistics set including NaN/Inf', () => {
|
||||
renderViewer()
|
||||
for (const label of [
|
||||
'Resolution',
|
||||
'Min',
|
||||
'Max',
|
||||
'Mean',
|
||||
'Std dev',
|
||||
'NaN',
|
||||
'Inf'
|
||||
]) {
|
||||
screen.getByText(label)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the pixel readout when a pixel is hovered', () => {
|
||||
renderViewer()
|
||||
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors the histogram according to the selected channel', () => {
|
||||
holder.viewer = makeViewer({ channel: ref('g') })
|
||||
const { container } = renderViewer()
|
||||
const path = container.querySelector('svg path')
|
||||
expect(path?.getAttribute('class')).toContain('text-green-500')
|
||||
})
|
||||
|
||||
it('renders an option for each channel mode', () => {
|
||||
renderViewer()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Luminance' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,258 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderRow(modelValue: string[], max = 5) {
|
||||
return render(PaletteSwatchRow, {
|
||||
props: { modelValue, max },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
describe('PaletteSwatchRow', () => {
|
||||
it('renders one swatch per color', () => {
|
||||
const { container } = renderRow(['#ff0000', '#00ff00'])
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color on right click', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
|
||||
expect(lastEmit(emitted)).toEqual(['#00ff00'])
|
||||
})
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
|
||||
button: 0,
|
||||
clientX: 5,
|
||||
clientY: 5
|
||||
})
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
|
||||
:title="t('palette.addColor')"
|
||||
@click="addColor"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
@@ -1,54 +0,0 @@
|
||||
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetColors from './WidgetColors.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderWidget(modelValue: string[], widget?: { name: string }) {
|
||||
return render(WidgetColors, {
|
||||
props: { modelValue, widget },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.()
|
||||
})
|
||||
|
||||
describe('WidgetColors', () => {
|
||||
it('renders the palette swatch row for each color', () => {
|
||||
renderWidget(['#ff0000', '#00ff00'])
|
||||
const root = screen.getByTestId('colors')
|
||||
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the widget name as an inline label', () => {
|
||||
renderWidget(['#ff0000'], { name: 'color_palette' })
|
||||
expect(screen.getByText('color_palette')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits an updated palette when a color is added', async () => {
|
||||
const { emitted } = renderWidget([])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
const calls = emitted()['update:modelValue'] as unknown[][]
|
||||
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
|
||||
})
|
||||
|
||||
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
|
||||
const { container } = renderWidget(['#ff0000'])
|
||||
const onDocMove = vi.fn()
|
||||
document.addEventListener('pointermove', onDocMove)
|
||||
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
|
||||
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
|
||||
expect(onDocMove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center gap-2"
|
||||
data-testid="colors"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span
|
||||
v-if="widget?.name"
|
||||
class="shrink-0 truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</span>
|
||||
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const MAX_COLORS = 16
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string[]>({ default: () => [] })
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -166,9 +165,7 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
@@ -178,9 +175,7 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -24,7 +23,7 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -38,7 +37,7 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeId: '24',
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -56,7 +55,7 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeId: '45',
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
@@ -76,7 +75,7 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([3, 15]),
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
|
||||
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -157,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -250,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -388,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeId: '10',
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
export interface ErrorItem extends ResolvedErrorMessage {
|
||||
/** Raw source/API-compatible message. */
|
||||
@@ -13,7 +12,7 @@ export interface ErrorItem extends ResolvedErrorMessage {
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: NodeExecutionId
|
||||
nodeId?: string
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
|
||||
@@ -671,30 +671,6 @@ describe('useErrorGroups', () => {
|
||||
expect(nodeIds).toEqual(['1', '2', '10'])
|
||||
})
|
||||
|
||||
it('marks only nested execution paths as subgraph node cards', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
},
|
||||
'1:20': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [{ type: 'err', message: 'Error', details: '' }]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroup?.cards).toMatchObject([
|
||||
{ nodeId: '1', isSubgraphNode: false },
|
||||
{ nodeId: '1:20', isSubgraphNode: true }
|
||||
])
|
||||
})
|
||||
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
resolveRunErrorMessage
|
||||
} from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import {
|
||||
compareExecutionId,
|
||||
tryNormalizeNodeExecutionId
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
return {
|
||||
@@ -119,7 +119,7 @@ function getOrCreateGroup(
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: NodeExecutionId,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
@@ -130,7 +130,7 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: nodeId.includes(':'),
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -305,7 +305,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: NodeExecutionId,
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
@@ -371,11 +371,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
@@ -406,12 +404,9 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
||||
if (!nodeId) return
|
||||
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
@@ -422,7 +417,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
@@ -673,7 +669,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -695,17 +691,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
||||
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
@@ -716,7 +707,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -104,7 +103,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: '42',
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -182,7 +181,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeId: '42',
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -3,12 +3,17 @@ import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
|
||||
type SidebarIconProps = {
|
||||
icon: string
|
||||
selected: boolean
|
||||
tooltip?: string
|
||||
class?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -79,20 +84,4 @@ describe('SidebarIcon', () => {
|
||||
tooltipText
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to label for tooltip when no tooltip is provided', async () => {
|
||||
const labelText = 'WASNodeSuitePreprocessors'
|
||||
const { user } = renderSidebarIcon({ label: labelText })
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
|
||||
|
||||
await user.hover(screen.getByRole('button'))
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,11 +40,9 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<!-- w-max sizes the label to the rail instead of the padding-inset
|
||||
button content box, which is too narrow for one-line labels -->
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
|
||||
class="side-bar-button-label text-center text-2xs"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -85,14 +83,7 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
/**
|
||||
* Falls back to the label when no tooltip is provided, so labels clamped
|
||||
* to two lines can always be recovered in full on hover.
|
||||
*/
|
||||
const computedTooltip = computed(() => {
|
||||
const text = tooltip || label
|
||||
return st(text, text) + tooltipSuffix
|
||||
})
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
import type { HitMode, Region } from './boundingBoxesUtil'
|
||||
import {
|
||||
applyDrag,
|
||||
boxesAt,
|
||||
fromBoundingBoxes,
|
||||
tagRects,
|
||||
toBoundingBoxes
|
||||
} from './boundingBoxesUtil'
|
||||
|
||||
const region = (over: Partial<Region> = {}): Region => ({
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
w: 0.2,
|
||||
h: 0.2,
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: [],
|
||||
...over
|
||||
})
|
||||
|
||||
describe('applyDrag', () => {
|
||||
it('moves without resizing and keeps width/height', () => {
|
||||
const out = applyDrag('move', region({ x: 0.2, y: 0.2 }), 0.1, 0.1)
|
||||
expect(out.x).toBeCloseTo(0.3)
|
||||
expect(out.y).toBeCloseTo(0.3)
|
||||
expect(out.w).toBeCloseTo(0.2)
|
||||
expect(out.h).toBeCloseTo(0.2)
|
||||
})
|
||||
|
||||
it('clamps a move so the box stays inside the unit square', () => {
|
||||
const out = applyDrag(
|
||||
'move',
|
||||
region({ x: 0.9, y: 0.9, w: 0.2, h: 0.2 }),
|
||||
0.5,
|
||||
0.5
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.8)
|
||||
expect(out.y).toBeCloseTo(0.8)
|
||||
})
|
||||
|
||||
it('grows from the bottom-right for draw and resize-br', () => {
|
||||
for (const mode of ['draw', 'resize-br'] as HitMode[]) {
|
||||
const out = applyDrag(
|
||||
mode,
|
||||
region({ x: 0.2, y: 0.2, w: 0.1, h: 0.1 }),
|
||||
0.1,
|
||||
0.2
|
||||
)
|
||||
expect(out).toMatchObject({ x: 0.2, y: 0.2 })
|
||||
expect(out.w).toBeCloseTo(0.2)
|
||||
expect(out.h).toBeCloseTo(0.3)
|
||||
}
|
||||
})
|
||||
|
||||
it('moves the top-left corner on resize-tl', () => {
|
||||
const out = applyDrag(
|
||||
'resize-tl',
|
||||
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
|
||||
0.1,
|
||||
0.1
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.6)
|
||||
expect(out.y).toBeCloseTo(0.6)
|
||||
expect(out.w).toBeCloseTo(0.1)
|
||||
expect(out.h).toBeCloseTo(0.1)
|
||||
})
|
||||
|
||||
it('normalizes a corner drag that inverts the box', () => {
|
||||
const out = applyDrag(
|
||||
'resize-tl',
|
||||
region({ x: 0.5, y: 0.5, w: 0.2, h: 0.2 }),
|
||||
0.3,
|
||||
0
|
||||
)
|
||||
expect(out.x).toBeCloseTo(0.7)
|
||||
expect(out.w).toBeCloseTo(0.1)
|
||||
expect(out.y).toBeCloseTo(0.5)
|
||||
expect(out.h).toBeCloseTo(0.2)
|
||||
})
|
||||
|
||||
it('resizes single edges', () => {
|
||||
expect(applyDrag('resize-r', region({ w: 0.2 }), 0.1, 0).w).toBeCloseTo(0.3)
|
||||
expect(applyDrag('resize-b', region({ h: 0.2 }), 0, 0.1).h).toBeCloseTo(0.3)
|
||||
const top = applyDrag('resize-t', region({ y: 0.4, h: 0.2 }), 0, 0.1)
|
||||
expect(top.y).toBeCloseTo(0.5)
|
||||
expect(top.h).toBeCloseTo(0.1)
|
||||
const left = applyDrag('resize-l', region({ x: 0.4, w: 0.2 }), 0.1, 0)
|
||||
expect(left.x).toBeCloseTo(0.5)
|
||||
expect(left.w).toBeCloseTo(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boxesAt', () => {
|
||||
const regions: Region[] = [region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 })]
|
||||
|
||||
it('detects a corner handle', () => {
|
||||
const hits = boxesAt(regions, 0.2, 0.2, 6, 100, 100, -1)
|
||||
expect(hits[0]).toEqual({ index: 0, mode: 'resize-tl' })
|
||||
})
|
||||
|
||||
it('detects an interior move', () => {
|
||||
const hits = boxesAt(regions, 0.3, 0.3, 6, 100, 100, -1)
|
||||
expect(hits[0]).toEqual({ index: 0, mode: 'move' })
|
||||
})
|
||||
|
||||
it('returns nothing when the pointer misses every box', () => {
|
||||
expect(boxesAt(regions, 0.9, 0.9, 6, 100, 100, -1)).toEqual([])
|
||||
})
|
||||
|
||||
it('brings the active box to the front of overlapping candidates', () => {
|
||||
const overlapping: Region[] = [
|
||||
region({ x: 0.2, y: 0.2, w: 0.2, h: 0.2 }),
|
||||
region({ x: 0.25, y: 0.25, w: 0.2, h: 0.2 })
|
||||
]
|
||||
const hits = boxesAt(overlapping, 0.3, 0.3, 6, 100, 100, 1)
|
||||
expect(hits).toHaveLength(2)
|
||||
expect(hits[0].index).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagRects', () => {
|
||||
const measure = (s: string) => s.length * 7
|
||||
|
||||
it('places the first tag at the top-left corner', () => {
|
||||
const rects = tagRects(
|
||||
[region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })],
|
||||
100,
|
||||
100,
|
||||
measure
|
||||
)
|
||||
expect(rects[0]).toMatchObject({ x: 10, y: 10, tag: '01' })
|
||||
expect(rects[0].w).toBe(measure('01') + 8)
|
||||
})
|
||||
|
||||
it('moves a colliding tag to a different corner', () => {
|
||||
const boxes = [
|
||||
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 }),
|
||||
region({ x: 0.1, y: 0.1, w: 0.3, h: 0.3 })
|
||||
]
|
||||
const rects = tagRects(boxes, 100, 100, measure)
|
||||
const sameSpot = rects[1].x === rects[0].x && rects[1].y === rects[0].y
|
||||
expect(sameSpot).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromBoundingBoxes', () => {
|
||||
it('converts pixel boxes to normalized regions with metadata', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
w: 0.3,
|
||||
h: 0.4,
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#fff']
|
||||
})
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: []
|
||||
})
|
||||
})
|
||||
|
||||
it('drops entries that are not bounding boxes', () => {
|
||||
const boxes = [null, { x: 1 }, undefined] as unknown as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)).toEqual([])
|
||||
})
|
||||
|
||||
it('guards against zero dimensions', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 5,
|
||||
height: 5,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: [] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 0, 0)[0]).toMatchObject({
|
||||
x: 5,
|
||||
y: 5,
|
||||
w: 5,
|
||||
h: 5
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toBoundingBoxes', () => {
|
||||
it('rounds normalized regions back to pixels and copies the palette', () => {
|
||||
const palette = ['#abc']
|
||||
const regions: Region[] = [
|
||||
region({ x: 0.1, y: 0.2, w: 0.3, h: 0.4, palette })
|
||||
]
|
||||
const [box] = toBoundingBoxes(regions, 1000, 1000)
|
||||
expect(box).toMatchObject({ x: 100, y: 200, width: 300, height: 400 })
|
||||
expect(box.metadata.palette).toEqual(['#abc'])
|
||||
expect(box.metadata.palette).not.toBe(palette)
|
||||
})
|
||||
|
||||
it('round-trips from pixels to regions and back', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: [] }
|
||||
}
|
||||
]
|
||||
const restored = toBoundingBoxes(
|
||||
fromBoundingBoxes(boxes, 1000, 1000),
|
||||
1000,
|
||||
1000
|
||||
)
|
||||
expect(restored).toEqual(boxes)
|
||||
})
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
import type { BoundingBox, BoundingBoxMetadata } from '@/types/boundingBoxes'
|
||||
|
||||
export type HitMode =
|
||||
| 'move'
|
||||
| 'draw'
|
||||
| 'resize-tl'
|
||||
| 'resize-tr'
|
||||
| 'resize-bl'
|
||||
| 'resize-br'
|
||||
| 'resize-t'
|
||||
| 'resize-b'
|
||||
| 'resize-l'
|
||||
| 'resize-r'
|
||||
|
||||
export interface Region extends BoundingBoxMetadata {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface BoxCandidate {
|
||||
index: number
|
||||
mode: HitMode
|
||||
}
|
||||
|
||||
interface TagRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
tag: string
|
||||
}
|
||||
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
||||
|
||||
function normalizeBox(b: Region): Region {
|
||||
let { x, y, w, h } = b
|
||||
if (w < 0) {
|
||||
x += w
|
||||
w = -w
|
||||
}
|
||||
if (h < 0) {
|
||||
y += h
|
||||
h = -h
|
||||
}
|
||||
x = clamp01(x)
|
||||
y = clamp01(y)
|
||||
w = Math.min(w, 1 - x)
|
||||
h = Math.min(h, 1 - y)
|
||||
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }
|
||||
}
|
||||
|
||||
function rectHitTest(
|
||||
mx: number,
|
||||
my: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
rx: number,
|
||||
ry: number
|
||||
): HitMode | null {
|
||||
const h = (cx: number, cy: number) =>
|
||||
Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry
|
||||
if (h(x1, y1)) return 'resize-tl'
|
||||
if (h(x2, y1)) return 'resize-tr'
|
||||
if (h(x1, y2)) return 'resize-bl'
|
||||
if (h(x2, y2)) return 'resize-br'
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return 'resize-t'
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return 'resize-b'
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return 'resize-l'
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return 'resize-r'
|
||||
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return 'move'
|
||||
return null
|
||||
}
|
||||
|
||||
export function applyDrag(
|
||||
mode: HitMode,
|
||||
start: Region,
|
||||
dx: number,
|
||||
dy: number
|
||||
): Region {
|
||||
let { x, y, w, h } = start
|
||||
switch (mode) {
|
||||
case 'move':
|
||||
x += dx
|
||||
y += dy
|
||||
x = clamp01(Math.min(x, 1 - w))
|
||||
y = clamp01(Math.min(y, 1 - h))
|
||||
break
|
||||
case 'draw':
|
||||
case 'resize-br':
|
||||
w += dx
|
||||
h += dy
|
||||
break
|
||||
case 'resize-tl':
|
||||
x += dx
|
||||
y += dy
|
||||
w -= dx
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-tr':
|
||||
y += dy
|
||||
w += dx
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-bl':
|
||||
x += dx
|
||||
w -= dx
|
||||
h += dy
|
||||
break
|
||||
case 'resize-t':
|
||||
y += dy
|
||||
h -= dy
|
||||
break
|
||||
case 'resize-b':
|
||||
h += dy
|
||||
break
|
||||
case 'resize-l':
|
||||
x += dx
|
||||
w -= dx
|
||||
break
|
||||
case 'resize-r':
|
||||
w += dx
|
||||
break
|
||||
}
|
||||
return mode === 'move'
|
||||
? { ...start, x, y }
|
||||
: normalizeBox({ ...start, x, y, w, h })
|
||||
}
|
||||
|
||||
export function boxesAt(
|
||||
regions: readonly Region[],
|
||||
mxN: number,
|
||||
myN: number,
|
||||
handlePx: number,
|
||||
logW: number,
|
||||
logH: number,
|
||||
activeIdx: number
|
||||
): BoxCandidate[] {
|
||||
const rx = handlePx / Math.max(1, logW)
|
||||
const ry = handlePx / Math.max(1, logH)
|
||||
const res: BoxCandidate[] = []
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const b = regions[i]
|
||||
const mode = rectHitTest(mxN, myN, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry)
|
||||
if (mode) res.push({ index: i, mode })
|
||||
}
|
||||
const ai = res.findIndex((c) => c.index === activeIdx)
|
||||
if (ai > 0) res.unshift(res.splice(ai, 1)[0])
|
||||
return res
|
||||
}
|
||||
|
||||
export function tagRects(
|
||||
regions: readonly Region[],
|
||||
logW: number,
|
||||
logH: number,
|
||||
measureWidth: (s: string) => number,
|
||||
height = 14
|
||||
): TagRect[] {
|
||||
const placed: TagRect[] = []
|
||||
const rects: TagRect[] = []
|
||||
const hits = (a: TagRect, b: TagRect) =>
|
||||
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const b = regions[i]
|
||||
const x1 = b.x * logW
|
||||
const y1 = b.y * logH
|
||||
const x2 = (b.x + b.w) * logW
|
||||
const y2 = (b.y + b.h) * logH
|
||||
const tag = String(i + 1).padStart(2, '0')
|
||||
const w = measureWidth(tag) + 8
|
||||
let pick: [number, number] = [x1, y1]
|
||||
for (const [cx, cy] of [
|
||||
[x1, y1],
|
||||
[x2 - w, y1],
|
||||
[x2 - w, y2 - height],
|
||||
[x1, y2 - height]
|
||||
] as const) {
|
||||
const candidate: TagRect = { x: cx, y: cy, w, h: height, tag }
|
||||
if (!placed.some((p) => hits(candidate, p))) {
|
||||
pick = [cx, cy]
|
||||
break
|
||||
}
|
||||
}
|
||||
const r: TagRect = { x: pick[0], y: pick[1], w, h: height, tag }
|
||||
placed.push(r)
|
||||
rects[i] = r
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
if (!b || typeof b !== 'object') return false
|
||||
const box = b as Record<string, unknown>
|
||||
return (
|
||||
typeof box.x === 'number' &&
|
||||
typeof box.y === 'number' &&
|
||||
typeof box.width === 'number' &&
|
||||
typeof box.height === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
height: number
|
||||
): Region[] {
|
||||
const w = width || 1
|
||||
const h = height || 1
|
||||
return boxes.filter(isBoundingBox).map((box) => {
|
||||
const meta = (box.metadata ?? {}) as Partial<BoundingBoxMetadata>
|
||||
return {
|
||||
x: box.x / w,
|
||||
y: box.y / h,
|
||||
w: box.width / w,
|
||||
h: box.height / h,
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function toBoundingBoxes(
|
||||
regions: readonly Region[],
|
||||
width: number,
|
||||
height: number
|
||||
): BoundingBox[] {
|
||||
return regions.map((r) => ({
|
||||
x: Math.round(r.x * width),
|
||||
y: Math.round(r.y * height),
|
||||
width: Math.round(r.w * width),
|
||||
height: Math.round(r.h * height),
|
||||
metadata: {
|
||||
type: r.type,
|
||||
text: r.text,
|
||||
desc: r.desc,
|
||||
palette: r.palette.slice()
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
const el = document.createElement('canvas')
|
||||
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
|
||||
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
el.focus = () => {}
|
||||
el.setPointerCapture = () => {}
|
||||
el.releasePointerCapture = () => {}
|
||||
return el
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
over: Partial<PointerEvent> = {}
|
||||
) =>
|
||||
({
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
altKey: false,
|
||||
pointerId: 1,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
...over
|
||||
}) as unknown as PointerEvent
|
||||
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
type Api = ReturnType<typeof useBoundingBoxes>
|
||||
interface Captured extends Api {
|
||||
canvasEl: ShallowRef<HTMLCanvasElement | null>
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial)
|
||||
const api = useBoundingBoxes('1', {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
captured = { canvasEl, modelValue, ...api }
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
render(Harness)
|
||||
captured!.canvasEl.value = makeCanvas()
|
||||
return captured!
|
||||
}
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes initialization', () => {
|
||||
it('derives regions from the initial model value', () => {
|
||||
const c = setup([box()])
|
||||
expect(c.hasRegions.value).toBe(true)
|
||||
expect(c.activeRegion.value).toMatchObject({ type: 'obj' })
|
||||
})
|
||||
|
||||
it('exposes an aspect-ratio canvas style from the node width/height', () => {
|
||||
const c = setup()
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '512 / 512' })
|
||||
})
|
||||
|
||||
it('starts with no active region when empty', () => {
|
||||
const c = setup()
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.activeRegion.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes drawing', () => {
|
||||
it('draws a new region and syncs it to the model value', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
it('changes the active region type', async () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions', async () => {
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
it('opens on double click and commits the description', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the inline editor on Escape', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
})
|
||||
@@ -1,614 +0,0 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
applyDrag,
|
||||
boxesAt,
|
||||
fromBoundingBoxes,
|
||||
tagRects,
|
||||
toBoundingBoxes
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import type {
|
||||
HitMode,
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { readableTextColor, textOnColor } from '@/utils/colorUtil'
|
||||
|
||||
const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
style: Record<string, string>
|
||||
index: number
|
||||
}
|
||||
|
||||
interface UseBoundingBoxesOptions {
|
||||
canvasEl: Readonly<ShallowRef<HTMLCanvasElement | null>>
|
||||
canvasContainer: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
inlineEditorEl: Readonly<ShallowRef<HTMLTextAreaElement | null>>
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
export function useBoundingBoxes(
|
||||
nodeId: string,
|
||||
{
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
}: UseBoundingBoxesOptions
|
||||
) {
|
||||
const focused = ref(false)
|
||||
const drawing = ref(false)
|
||||
const dragMode = ref<HitMode | null>(null)
|
||||
const dragStartNorm = ref<{ x: number; y: number } | null>(null)
|
||||
const boxAtStart = ref<Region | null>(null)
|
||||
const hoverIndex = ref<number | null>(null)
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
const litegraphNode = computed(() =>
|
||||
nodeId && app.canvas?.graph ? app.canvas.graph.getNodeById(nodeId) : null
|
||||
)
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isNodeSelected = computed(() =>
|
||||
selectedNodeIds.value.has(String(nodeId))
|
||||
)
|
||||
|
||||
function dimWidget(name: 'width' | 'height'): number | undefined {
|
||||
const v = litegraphNode.value?.widgets?.find((w) => w.name === name)?.value
|
||||
return typeof v === 'number' && v > 0 ? v : undefined
|
||||
}
|
||||
const widthValue = computed(() => dimWidget('width') ?? 1024)
|
||||
const heightValue = computed(() => dimWidget('height') ?? 1024)
|
||||
|
||||
const state = ref({
|
||||
regions: fromBoundingBoxes(
|
||||
modelValue.value ?? [],
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
})
|
||||
const activeIndex = ref(state.value.regions.length ? 0 : -1)
|
||||
|
||||
const aspectRatio = computed(
|
||||
() => `${widthValue.value} / ${heightValue.value}`
|
||||
)
|
||||
const canvasStyle = computed(() => ({ aspectRatio: aspectRatio.value }))
|
||||
|
||||
const activeRegion = computed(() =>
|
||||
activeIndex.value >= 0 ? state.value.regions[activeIndex.value] : null
|
||||
)
|
||||
const hasRegions = computed(() => state.value.regions.length > 0)
|
||||
|
||||
function clampToCanvas(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
}
|
||||
|
||||
function pointerNorm(e: PointerEvent) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return { x: 0, y: 0 }
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: clampToCanvas((e.clientX - r.left) / r.width),
|
||||
y: clampToCanvas((e.clientY - r.top) / r.height)
|
||||
}
|
||||
}
|
||||
|
||||
let rafHandle = 0
|
||||
function requestDraw() {
|
||||
if (rafHandle) return
|
||||
rafHandle = requestAnimationFrame(() => {
|
||||
rafHandle = 0
|
||||
drawCanvas()
|
||||
})
|
||||
}
|
||||
|
||||
function measureWidth(ctx: CanvasRenderingContext2D, s: string) {
|
||||
return ctx.measureText(s).width
|
||||
}
|
||||
|
||||
function drawCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const bw = Math.max(1, Math.round(W * dpr))
|
||||
const bh = Math.max(1, Math.round(H * dpr))
|
||||
if (el.width !== bw || el.height !== bh) {
|
||||
el.width = bw
|
||||
el.height = bh
|
||||
}
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
if (bgImage.value) {
|
||||
ctx.drawImage(bgImage.value, 0, 0, W, H)
|
||||
ctx.fillStyle = `rgba(0,0,0,${BG_DIM})`
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
.map((_, i) => i)
|
||||
.filter((i) => i !== aIdx)
|
||||
.reverse()
|
||||
if (aIdx >= 0 && aIdx < state.value.regions.length) order.push(aIdx)
|
||||
|
||||
ctx.font = 'bold 11px monospace'
|
||||
const tag_rects = tagRects(state.value.regions, W, H, (s) =>
|
||||
measureWidth(ctx, s)
|
||||
)
|
||||
|
||||
for (const i of order) {
|
||||
const b = state.value.regions[i]
|
||||
const active = i === aIdx
|
||||
const pal = (b.palette || []).filter(Boolean)
|
||||
const col = pal.length ? pal[0] : '#8c8c8c'
|
||||
const x1 = b.x * W
|
||||
const y1 = b.y * H
|
||||
const x2 = (b.x + b.w) * W
|
||||
const y2 = (b.y + b.h) * H
|
||||
const w = x2 - x1
|
||||
const h = y2 - y1
|
||||
const hovered = i === hoverIndex.value || active
|
||||
|
||||
if (active) {
|
||||
ctx.fillStyle = 'rgba(26,26,26,0.88)'
|
||||
ctx.fillRect(x1, y1, w, h)
|
||||
}
|
||||
ctx.fillStyle = col + (hovered ? '3a' : '22')
|
||||
ctx.fillRect(x1, y1, w, h)
|
||||
|
||||
const lw = active ? 2 : hovered ? 1.5 : 1
|
||||
ctx.strokeStyle = col
|
||||
ctx.lineWidth = lw
|
||||
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw)
|
||||
|
||||
if (pal.length) {
|
||||
const sw = w / pal.length
|
||||
const sh = 7
|
||||
for (let p = 0; p < pal.length; p++) {
|
||||
const sx = x1 + Math.round(p * sw)
|
||||
ctx.fillStyle = pal[p]
|
||||
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(x1, y1, w, h)
|
||||
ctx.clip()
|
||||
|
||||
let body = b.desc || ''
|
||||
if (b.type === 'text' && b.text)
|
||||
body = `"${b.text}"` + (body ? ` — ${body}` : '')
|
||||
if (body) {
|
||||
ctx.font = '12px monospace'
|
||||
ctx.fillStyle = readableTextColor(col)
|
||||
const pad = 4
|
||||
const lh = 14
|
||||
let ty = y1 + 15 + 12
|
||||
for (const line of wrapLines(ctx, body, w - pad * 2)) {
|
||||
if (ty > y1 + h) break
|
||||
ctx.fillText(line, x1 + pad, ty)
|
||||
ty += lh
|
||||
}
|
||||
}
|
||||
|
||||
const tr = tag_rects[i]
|
||||
ctx.font = 'bold 11px monospace'
|
||||
ctx.fillStyle = col
|
||||
ctx.fillRect(tr.x, tr.y, tr.w, 14)
|
||||
if (i === hoverTagIndex.value) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.25)'
|
||||
ctx.fillRect(tr.x, tr.y, tr.w, 14)
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13)
|
||||
}
|
||||
ctx.fillStyle = textOnColor(col)
|
||||
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function wrapLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxW: number
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
for (const para of text.split('\n')) {
|
||||
let line = ''
|
||||
for (const word of para.split(/\s+/)) {
|
||||
if (!word) continue
|
||||
const test = line ? `${line} ${word}` : word
|
||||
if (line && ctx.measureText(test).width > maxW) {
|
||||
out.push(line)
|
||||
line = word
|
||||
} else {
|
||||
line = test
|
||||
}
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const hitTestPoint = (mN: { x: number; y: number }) => {
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
if (!cands.length) return null
|
||||
return (
|
||||
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
|
||||
cands[0]
|
||||
)
|
||||
}
|
||||
|
||||
const titleAt = (mN: { x: number; y: number }) => {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const rects = tagRects(state.value.regions, W, H, (s) =>
|
||||
measureWidth(ctx, s)
|
||||
)
|
||||
const px = mN.x * W
|
||||
const py = mN.y * H
|
||||
for (let i = state.value.regions.length - 1; i >= 0; i--) {
|
||||
const r = rects[i]
|
||||
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h)
|
||||
return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickForSelection(mN: { x: number; y: number }, cycle: boolean) {
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
if (!cands.length) return null
|
||||
const activeResize = cands.find(
|
||||
(c) => c.index === activeIndex.value && c.mode !== 'move'
|
||||
)
|
||||
if (activeResize && !cycle) return activeResize
|
||||
const ti = titleAt(mN)
|
||||
if (ti !== null && !cycle) return { index: ti, mode: 'move' as HitMode }
|
||||
if (cycle && cands.length > 1) {
|
||||
const pos = cands.findIndex((c) => c.index === activeIndex.value)
|
||||
return cands[(pos + 1) % cands.length]
|
||||
}
|
||||
return (
|
||||
cands.find((c) => c.index === activeIndex.value && c.mode !== 'move') ||
|
||||
cands[0]
|
||||
)
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
canvasEl.value?.focus()
|
||||
hoverTagIndex.value = null
|
||||
hoverIndex.value = null
|
||||
const mN = pointerNorm(e)
|
||||
const hit = pickForSelection(mN, e.altKey)
|
||||
if (hit) {
|
||||
activeIndex.value = hit.index
|
||||
dragMode.value = hit.mode
|
||||
boxAtStart.value = { ...state.value.regions[hit.index] }
|
||||
} else {
|
||||
dragMode.value = 'draw'
|
||||
const nb: Region = {
|
||||
x: mN.x,
|
||||
y: mN.y,
|
||||
w: 0,
|
||||
h: 0,
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: []
|
||||
}
|
||||
state.value.regions.push(nb)
|
||||
activeIndex.value = state.value.regions.length - 1
|
||||
boxAtStart.value = { ...nb }
|
||||
}
|
||||
drawing.value = true
|
||||
dragStartNorm.value = mN
|
||||
canvasEl.value?.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function onDocPointerMove(e: PointerEvent) {
|
||||
if (
|
||||
!drawing.value ||
|
||||
!boxAtStart.value ||
|
||||
!dragStartNorm.value ||
|
||||
!dragMode.value
|
||||
)
|
||||
return
|
||||
const mN = pointerNorm(e)
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function onDocPointerUp(e: PointerEvent) {
|
||||
if (!drawing.value) return
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
}
|
||||
|
||||
function onCanvasPointerMove(e: PointerEvent) {
|
||||
if (drawing.value) onDocPointerMove(e)
|
||||
else onPointerMove(e)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (drawing.value) return
|
||||
const mN = pointerNorm(e)
|
||||
const ti = titleAt(mN)
|
||||
const hit = hitTestPoint(mN)
|
||||
const hb = ti !== null ? ti : hit ? hit.index : null
|
||||
if (ti !== hoverTagIndex.value || hb !== hoverIndex.value) {
|
||||
hoverTagIndex.value = ti
|
||||
hoverIndex.value = hb
|
||||
requestDraw()
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
if (hoverTagIndex.value !== null || hoverIndex.value !== null) {
|
||||
hoverTagIndex.value = null
|
||||
hoverIndex.value = null
|
||||
requestDraw()
|
||||
}
|
||||
}
|
||||
|
||||
const canvasCursor = computed(() =>
|
||||
hoverTagIndex.value !== null ? 'pointer' : 'crosshair'
|
||||
)
|
||||
|
||||
function onDoubleClick(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
const mN = pointerNormFromMouse(e)
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const cands = boxesAt(
|
||||
state.value.regions,
|
||||
mN.x,
|
||||
mN.y,
|
||||
HANDLE_PX,
|
||||
W,
|
||||
H,
|
||||
activeIndex.value
|
||||
)
|
||||
const target = cands.find((c) => c.index === activeIndex.value) || cands[0]
|
||||
if (!target) return
|
||||
openInlineEditor(target.index)
|
||||
}
|
||||
|
||||
function pointerNormFromMouse(e: MouseEvent) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return { x: 0, y: 0 }
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
x: clampToCanvas((e.clientX - r.left) / r.width),
|
||||
y: clampToCanvas((e.clientY - r.top) / r.height)
|
||||
}
|
||||
}
|
||||
|
||||
function openInlineEditor(index: number) {
|
||||
const b = state.value.regions[index]
|
||||
if (!b) return
|
||||
activeIndex.value = index
|
||||
const { w: W, h: H } = logicalSize()
|
||||
const w = Math.min(W, Math.max(70, b.w * W))
|
||||
const h = Math.min(H, Math.max(42, b.h * H))
|
||||
const left = Math.max(0, Math.min(b.x * W, W - w))
|
||||
const top = Math.max(0, Math.min(b.y * H, H - h))
|
||||
inlineEditor.value = {
|
||||
value: b.desc || '',
|
||||
index,
|
||||
style: {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
borderColor: (b.palette || []).find(Boolean) || '#46b4e6'
|
||||
}
|
||||
}
|
||||
void nextTick(() => {
|
||||
inlineEditorEl.value?.focus()
|
||||
inlineEditorEl.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function onInlineKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
inlineEditor.value = null
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
commitInlineEditor()
|
||||
}
|
||||
}
|
||||
|
||||
function commitInlineEditor() {
|
||||
const ed = inlineEditor.value
|
||||
if (!ed) return
|
||||
const b = state.value.regions[ed.index]
|
||||
if (b) b.desc = ed.value
|
||||
inlineEditor.value = null
|
||||
syncState()
|
||||
}
|
||||
|
||||
function onCanvasKeyDown(e: KeyboardEvent) {
|
||||
if (drawing.value) return
|
||||
const idx = activeIndex.value
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && idx >= 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
removeRegion(idx)
|
||||
syncState()
|
||||
}
|
||||
}
|
||||
|
||||
function removeRegion(i: number) {
|
||||
state.value.regions.splice(i, 1)
|
||||
if (!state.value.regions.length) activeIndex.value = -1
|
||||
else if (i <= activeIndex.value)
|
||||
activeIndex.value = Math.max(0, activeIndex.value - 1)
|
||||
}
|
||||
|
||||
function setActiveType(t: 'obj' | 'text') {
|
||||
if (activeRegion.value) {
|
||||
activeRegion.value.type = t
|
||||
syncState()
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
syncState()
|
||||
}
|
||||
|
||||
function syncState() {
|
||||
modelValue.value = toBoundingBoxes(
|
||||
state.value.regions,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
watch(containerWidth, () => requestDraw())
|
||||
watch(
|
||||
() => state.value.regions.length,
|
||||
() => requestDraw()
|
||||
)
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const snap = (v: number) =>
|
||||
Math.max(DIMENSION_STEP, Math.round(v / DIMENSION_STEP) * DIMENSION_STEP)
|
||||
const targetW = snap(naturalWidth)
|
||||
const targetH = snap(naturalHeight)
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && widthWidget.value !== targetW) {
|
||||
widthWidget.value = targetW
|
||||
widthWidget.callback?.(targetW)
|
||||
}
|
||||
if (heightWidget && heightWidget.value !== targetH) {
|
||||
heightWidget.value = targetH
|
||||
heightWidget.callback?.(targetH)
|
||||
}
|
||||
}
|
||||
|
||||
let lastBgUrl = ''
|
||||
function updateBgImage() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('background')
|
||||
const inputNode = slot >= 0 ? node.getInputNode(slot) : null
|
||||
const url = inputNode
|
||||
? nodeOutputStore.getNodeImageUrls(inputNode)?.[0]
|
||||
: undefined
|
||||
if (!url) {
|
||||
if (bgImage.value) {
|
||||
bgImage.value = null
|
||||
lastBgUrl = ''
|
||||
requestDraw()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (url === lastBgUrl) return
|
||||
lastBgUrl = url
|
||||
const currentUrl = url
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
if (currentUrl !== lastBgUrl) return
|
||||
bgImage.value = img
|
||||
applyImageDimensions(img.naturalWidth, img.naturalHeight)
|
||||
requestDraw()
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafHandle) cancelAnimationFrame(rafHandle)
|
||||
})
|
||||
|
||||
return {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors: MAX_ELEMENT_COLORS,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
@@ -51,11 +50,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -67,11 +62,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
@@ -90,11 +81,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'clip')
|
||||
|
||||
node.onConnectionsChange!(
|
||||
NodeSlotType.OUTPUT,
|
||||
@@ -116,11 +103,7 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'model'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'model')
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
@@ -246,11 +229,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'image'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(node.id), 'image')
|
||||
mediaStore.setMissingMedia([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
@@ -300,11 +279,7 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
// Verify the hooks actually work
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([lateNode.id]),
|
||||
'value'
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, String(lateNode.id), 'value')
|
||||
|
||||
lateNode.onConnectionsChange!(
|
||||
NodeSlotType.INPUT,
|
||||
|
||||
@@ -34,7 +34,6 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { appendNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import {
|
||||
collectAllNodes,
|
||||
@@ -84,7 +83,7 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
|
||||
const promotedSource = widgetPromotedSource(node, widget)
|
||||
const executionId = promotedSource
|
||||
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
|
||||
? `${hostExecId}:${promotedSource.nodeId}`
|
||||
: hostExecId
|
||||
const widgetName = promotedSource?.widgetName ?? widget.name
|
||||
|
||||
|
||||
@@ -703,55 +703,3 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
||||
expect(subgraphNode.has_errors).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pre-remove vueNodeData drain', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('drops vueNodeData entry before node.onRemoved fires', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.has(String(node.id))).toBe(true)
|
||||
|
||||
let dataPresentInOnRemoved: boolean | undefined
|
||||
node.onRemoved = () => {
|
||||
dataPresentInOnRemoved = vueNodeData.has(String(node.id))
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(
|
||||
dataPresentInOnRemoved,
|
||||
'vueNodeData entry must be cleared before node.onRemoved fires so reactive consumers cannot observe the detached node'
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears vueNodeData when LGraph.clear() dispatches node:before-removed for each node', () => {
|
||||
const graph = new LGraph()
|
||||
const nodeA = new LGraphNode('a')
|
||||
const nodeB = new LGraphNode('b')
|
||||
graph.add(nodeA)
|
||||
graph.add(nodeB)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
expect(vueNodeData.size).toBe(2)
|
||||
|
||||
const beforeRemovedSpy = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', beforeRemovedSpy)
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
beforeRemovedSpy,
|
||||
'clear() must dispatch node:before-removed so reactive consumers can drop refs before nodes detach'
|
||||
).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vueNodeData.size,
|
||||
'node:before-removed listener must drain vueNodeData when clear() removes every node'
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,8 +30,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
|
||||
import type { NodeId as WorkflowNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
@@ -84,7 +82,6 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -97,7 +94,7 @@ export interface SafeWidgetData {
|
||||
* host subgraph node. Used for missing-model lookups that key by
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceExecutionId?: string
|
||||
/**
|
||||
* Interior source widget name. Only set for promoted widgets, where `name`
|
||||
* is the host input slot name; missing-model lookups key by the interior
|
||||
@@ -140,7 +137,7 @@ export interface GraphNodeManager {
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: WorkflowNodeId): LGraphNode | undefined
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
cleanup(): void
|
||||
@@ -214,8 +211,7 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +225,7 @@ function isDOMBackedWidget(widget: IBaseWidget): boolean {
|
||||
interface PromotedWidgetMetadata {
|
||||
controlWidget?: SafeControlWidget
|
||||
isDOMWidget: boolean
|
||||
sourceExecutionId?: NodeExecutionId
|
||||
sourceExecutionId?: string
|
||||
sourceWidgetName?: string
|
||||
}
|
||||
|
||||
@@ -520,8 +516,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
|
||||
// Get access to original LiteGraph node (non-reactive)
|
||||
const getNode = (id: WorkflowNodeId): LGraphNode | undefined => {
|
||||
return nodeRefs.get(String(id))
|
||||
const getNode = (id: string): LGraphNode | undefined => {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
@@ -612,20 +608,27 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
}
|
||||
}
|
||||
|
||||
const dropNodeReferences = (node: LGraphNode) => {
|
||||
const id = String(node.id)
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node removal from the graph - cleans up all references
|
||||
*/
|
||||
const handleNodeRemoved = (
|
||||
node: LGraphNode,
|
||||
originalCallback?: (node: LGraphNode) => void
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
originalCallback?.(node)
|
||||
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
originalCallback(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -634,8 +637,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined,
|
||||
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
|
||||
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
|
||||
) => {
|
||||
return () => {
|
||||
// Restore original callbacks
|
||||
@@ -643,17 +645,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
graph.events.removeEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners - now simplified with extracted handlers
|
||||
*/
|
||||
const setupEventListeners = (): (() => void) => {
|
||||
// Store original callbacks
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
@@ -669,16 +669,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
handleNodeRemoved(node, originalOnNodeRemoved)
|
||||
}
|
||||
|
||||
const beforeNodeRemovedListener = (
|
||||
e: CustomEvent<{ node: LGraphNode }>
|
||||
) => {
|
||||
dropNodeReferences(e.detail.node)
|
||||
}
|
||||
graph.events.addEventListener(
|
||||
'node:before-removed',
|
||||
beforeNodeRemovedListener
|
||||
)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
@@ -827,11 +817,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
// Return cleanup function
|
||||
return createCleanupFunction(
|
||||
originalOnNodeAdded || undefined,
|
||||
originalOnNodeRemoved || undefined,
|
||||
originalOnTrigger || undefined,
|
||||
beforeNodeRemovedListener
|
||||
originalOnTrigger || undefined
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -137,18 +137,6 @@ describe(usePromotedPreviews, () => {
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array (does not throw) when SubgraphNode is detached', () => {
|
||||
const setup = createSetup()
|
||||
const parentGraph = setup.subgraphNode.graph!
|
||||
parentGraph.add(setup.subgraphNode)
|
||||
parentGraph.remove(setup.subgraphNode)
|
||||
|
||||
expect(setup.subgraphNode.graph).toBeNull()
|
||||
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
||||
expect(() => promotedPreviews.value).not.toThrow()
|
||||
expect(promotedPreviews.value).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no $$ promotions exist', () => {
|
||||
const setup = createSetup()
|
||||
addInteriorNode(setup, { id: 10 })
|
||||
|
||||
@@ -6,11 +6,7 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import {
|
||||
appendNodeExecutionId,
|
||||
createNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
interface PromotedPreview {
|
||||
sourceNodeId: string
|
||||
@@ -42,7 +38,7 @@ export function usePromotedPreviews(
|
||||
function readReactivePreviewUrls(
|
||||
leafHost: SubgraphNode,
|
||||
leafSourceNodeId: string,
|
||||
leafExecutionId: NodeExecutionId,
|
||||
leafExecutionId: string,
|
||||
interiorNode: LGraphNode
|
||||
): string[] | undefined {
|
||||
const locatorId = createNodeLocatorId(
|
||||
@@ -72,7 +68,6 @@ export function usePromotedPreviews(
|
||||
const promotedPreviews = computed((): PromotedPreview[] => {
|
||||
const node = toValue(lgraphNode)
|
||||
if (!(node instanceof SubgraphNode)) return []
|
||||
if (node.isDetached) return []
|
||||
|
||||
const rootGraphId = node.rootGraph.id
|
||||
const hostLocator = String(node.id)
|
||||
@@ -126,7 +121,7 @@ export function usePromotedPreviews(
|
||||
const urls = readReactivePreviewUrls(
|
||||
leafHost,
|
||||
leaf.sourceNodeId,
|
||||
appendNodeExecutionId(leafHostLocator, leaf.sourceNodeId),
|
||||
`${leafHostLocator}:${leaf.sourceNodeId}`,
|
||||
interiorNode
|
||||
)
|
||||
if (!urls?.length) return []
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
import { usePaletteSwatchRow } from './usePaletteSwatchRow'
|
||||
|
||||
const scopes: EffectScope[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (scopes.length) scopes.pop()?.stop()
|
||||
})
|
||||
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
addColor()
|
||||
expect(modelValue.value).toEqual(['#000000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color by index', () => {
|
||||
const { modelValue, remove } = setup(['#a', '#b', '#c'])
|
||||
remove(1)
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
for (const i of [0, 1]) {
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', String(i))
|
||||
container.value!.appendChild(swatch)
|
||||
}
|
||||
const second = container.value!.children[1] as HTMLDivElement
|
||||
second.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#b', '#a'])
|
||||
})
|
||||
|
||||
it('cancels a stale drag when the primary button is no longer pressed', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
for (const i of [0, 1]) {
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', String(i))
|
||||
container.value!.appendChild(swatch)
|
||||
}
|
||||
const second = container.value!.children[1] as HTMLDivElement
|
||||
second.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 0 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores non-left-button pointer downs', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
onPointerDown(0, { button: 2, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10 })
|
||||
)
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container,
|
||||
picker
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
const next = modelValue.value.slice()
|
||||
next[pickerIndex.value] = v
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
function remove(i: number) {
|
||||
const next = modelValue.value.slice()
|
||||
next.splice(i, 1)
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
function addColor() {
|
||||
modelValue.value = [...modelValue.value, '#ffffff']
|
||||
}
|
||||
|
||||
const drag = ref<{
|
||||
index: number
|
||||
startX: number
|
||||
startY: number
|
||||
active: boolean
|
||||
} | null>(null)
|
||||
|
||||
function onPointerDown(i: number, e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
drag.value = {
|
||||
index: i,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
active: false
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
const d = drag.value
|
||||
if (!d) return
|
||||
if ((e.buttons & 1) === 0) {
|
||||
drag.value = null
|
||||
return
|
||||
}
|
||||
if (!d.active) {
|
||||
if (Math.abs(e.clientX - d.startX) + Math.abs(e.clientY - d.startY) < 4)
|
||||
return
|
||||
d.active = true
|
||||
}
|
||||
const rows =
|
||||
container.value?.querySelectorAll<HTMLDivElement>('[data-index]')
|
||||
if (!rows) return
|
||||
for (const other of rows) {
|
||||
if (parseInt(other.dataset.index || '-1', 10) === d.index) continue
|
||||
const r = other.getBoundingClientRect()
|
||||
if (
|
||||
e.clientX >= r.left &&
|
||||
e.clientX <= r.right &&
|
||||
e.clientY >= r.top - 6 &&
|
||||
e.clientY <= r.bottom + 6
|
||||
) {
|
||||
const oi = parseInt(other.dataset.index || '-1', 10)
|
||||
if (oi < 0) continue
|
||||
const next = modelValue.value.slice()
|
||||
const [moved] = next.splice(d.index, 1)
|
||||
const insertAt = e.clientX > r.left + r.width / 2 ? oi + 1 : oi
|
||||
next.splice(insertAt > d.index ? insertAt - 1 : insertAt, 0, moved)
|
||||
modelValue.value = next
|
||||
drag.value = null
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(document, 'pointerup', () => {
|
||||
drag.value = null
|
||||
})
|
||||
|
||||
useEventListener(document, 'pointercancel', () => {
|
||||
drag.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
}
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import type { ChromaticityCoords, GamutName } from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix
|
||||
} from '@/renderer/hdr/colorGamut'
|
||||
import {
|
||||
HDR_VIEWER_FRAGMENT_SHADER,
|
||||
HDR_VIEWER_VERTEX_SHADER
|
||||
} from '@/renderer/hdr/hdrViewerShader'
|
||||
import type { ChannelHistograms, ImageStats } from '@/renderer/hdr/hdrStats'
|
||||
import {
|
||||
computeChannelHistograms,
|
||||
computeImageStats
|
||||
} from '@/renderer/hdr/hdrStats'
|
||||
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
|
||||
import { getImageFilenameFromUrl } from '@/utils/hdrFormatUtil'
|
||||
|
||||
const MIN_ZOOM = 0.05
|
||||
const MAX_ZOOM = 64
|
||||
|
||||
export type ChannelMode = 'rgb' | 'r' | 'g' | 'b' | 'a' | 'luminance'
|
||||
|
||||
export const CHANNEL_MODES: ChannelMode[] = [
|
||||
'rgb',
|
||||
'r',
|
||||
'g',
|
||||
'b',
|
||||
'a',
|
||||
'luminance'
|
||||
]
|
||||
|
||||
const CHANNEL_INDEX: Record<ChannelMode, number> = {
|
||||
rgb: 0,
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 4,
|
||||
luminance: 5
|
||||
}
|
||||
|
||||
export interface PixelReadout {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number | null
|
||||
}
|
||||
|
||||
interface ExrTexData {
|
||||
header?: { chromaticities?: ChromaticityCoords }
|
||||
}
|
||||
|
||||
function createLoader(url: string) {
|
||||
const filename = getImageFilenameFromUrl(url)
|
||||
if (filename?.toLowerCase().endsWith('.hdr')) return new RGBELoader()
|
||||
const loader = new EXRLoader()
|
||||
loader.setDataType(THREE.FloatType)
|
||||
return loader
|
||||
}
|
||||
|
||||
function makeReader(
|
||||
data: ArrayLike<number>,
|
||||
type: THREE.TextureDataType
|
||||
): (index: number) => number {
|
||||
if (type === THREE.HalfFloatType) {
|
||||
return (index) => THREE.DataUtils.fromHalfFloat(data[index])
|
||||
}
|
||||
return (index) => data[index]
|
||||
}
|
||||
|
||||
function loadHdrTexture(
|
||||
url: string
|
||||
): Promise<{ texture: THREE.DataTexture; gamut: GamutName }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
createLoader(url).load(
|
||||
url,
|
||||
(texture, texData) => {
|
||||
const chromaticities = (texData as ExrTexData)?.header?.chromaticities
|
||||
resolve({
|
||||
texture,
|
||||
gamut: detectGamutFromChromaticities(chromaticities)
|
||||
})
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useHdrViewer() {
|
||||
const exposureStops = ref(0)
|
||||
const dither = ref(true)
|
||||
const clipWarnings = ref(false)
|
||||
const gamut = ref<GamutName>('sRGB')
|
||||
const channel = ref<ChannelMode>('rgb')
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const dimensions = ref<string | null>(null)
|
||||
const stats = ref<ImageStats | null>(null)
|
||||
const histograms = shallowRef<ChannelHistograms | null>(null)
|
||||
const pixel = ref<PixelReadout | null>(null)
|
||||
|
||||
const histogram = computed<Uint32Array | null>(() => {
|
||||
const channelHistograms = histograms.value
|
||||
if (!channelHistograms) return null
|
||||
switch (channel.value) {
|
||||
case 'r':
|
||||
return channelHistograms.r
|
||||
case 'g':
|
||||
return channelHistograms.g
|
||||
case 'b':
|
||||
return channelHistograms.b
|
||||
case 'a':
|
||||
return channelHistograms.a
|
||||
default:
|
||||
return channelHistograms.luminance
|
||||
}
|
||||
})
|
||||
|
||||
const containerRef = shallowRef<HTMLElement | null>(null)
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let viewport: WebGLViewport | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.OrthographicCamera | null = null
|
||||
let material: THREE.ShaderMaterial | null = null
|
||||
let mesh: THREE.Mesh | null = null
|
||||
let texture: THREE.Texture | null = null
|
||||
let imageAspect = 1
|
||||
let frameRequested = false
|
||||
|
||||
let readSample: ((index: number) => number) | null = null
|
||||
let imageWidth = 0
|
||||
let imageHeight = 0
|
||||
let imageChannels = 4
|
||||
|
||||
const raycaster = new THREE.Raycaster()
|
||||
const pointerNdc = new THREE.Vector2()
|
||||
|
||||
function requestRender() {
|
||||
if (!renderer || frameRequested) return
|
||||
frameRequested = true
|
||||
requestAnimationFrame(() => {
|
||||
frameRequested = false
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
})
|
||||
}
|
||||
|
||||
function containerSize() {
|
||||
const el = containerRef.value
|
||||
return {
|
||||
width: el?.clientWidth || 1,
|
||||
height: el?.clientHeight || 1
|
||||
}
|
||||
}
|
||||
|
||||
function updateProjection() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const halfH = 0.5
|
||||
const halfW = (0.5 * width) / height
|
||||
camera.left = -halfW
|
||||
camera.right = halfW
|
||||
camera.top = halfH
|
||||
camera.bottom = -halfH
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
if (!camera) return
|
||||
const { width, height } = containerSize()
|
||||
const containerAspect = width / height
|
||||
camera.zoom = Math.min(1, containerAspect / imageAspect)
|
||||
camera.position.set(0, 0, 1)
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function applyUniforms() {
|
||||
if (!material) return
|
||||
material.uniforms.uGain.value = Math.pow(2, exposureStops.value)
|
||||
material.uniforms.uDither.value = dither.value
|
||||
material.uniforms.uClipWarnings.value = clipWarnings.value
|
||||
material.uniforms.uChannel.value = CHANNEL_INDEX[channel.value]
|
||||
const m = gamutToSrgbMatrix(gamut.value)
|
||||
;(material.uniforms.uGamutToSRGB.value as THREE.Matrix3).set(
|
||||
m[0],
|
||||
m[1],
|
||||
m[2],
|
||||
m[3],
|
||||
m[4],
|
||||
m[5],
|
||||
m[6],
|
||||
m[7],
|
||||
m[8]
|
||||
)
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function buildScene() {
|
||||
renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false })
|
||||
viewport = new WebGLViewport(renderer)
|
||||
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
renderer.setPixelRatio(window.devicePixelRatio)
|
||||
renderer.setClearColor(0x0a0a0a, 1)
|
||||
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)
|
||||
camera.position.set(0, 0, 1)
|
||||
|
||||
material = new THREE.ShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: HDR_VIEWER_VERTEX_SHADER,
|
||||
fragmentShader: HDR_VIEWER_FRAGMENT_SHADER,
|
||||
uniforms: {
|
||||
uImage: { value: null },
|
||||
uGamutToSRGB: { value: new THREE.Matrix3() },
|
||||
uGain: { value: 1 },
|
||||
uChannel: { value: 0 },
|
||||
uDither: { value: true },
|
||||
uClipWarnings: { value: false },
|
||||
uClipRange: { value: new THREE.Vector2(0, 1) }
|
||||
}
|
||||
})
|
||||
|
||||
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
|
||||
scene.add(mesh)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!renderer) return
|
||||
const { width, height } = containerSize()
|
||||
renderer.setSize(width, height, false)
|
||||
updateProjection()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function setTexture(loaded: THREE.DataTexture) {
|
||||
if (!material || !mesh) return
|
||||
loaded.colorSpace = THREE.LinearSRGBColorSpace
|
||||
loaded.minFilter = THREE.LinearFilter
|
||||
loaded.magFilter = THREE.LinearFilter
|
||||
loaded.needsUpdate = true
|
||||
|
||||
const { width, height, data } = loaded.image
|
||||
texture = loaded
|
||||
imageAspect = width / height
|
||||
mesh.scale.set(imageAspect, 1, 1)
|
||||
material.uniforms.uImage.value = loaded
|
||||
dimensions.value = `${width} x ${height}`
|
||||
|
||||
if (!data) return
|
||||
imageWidth = width
|
||||
imageHeight = height
|
||||
imageChannels = data.length / (width * height)
|
||||
readSample = makeReader(data, loaded.type)
|
||||
stats.value = computeImageStats(readSample, data.length, imageChannels)
|
||||
histograms.value = computeChannelHistograms(
|
||||
readSample,
|
||||
data.length,
|
||||
imageChannels
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(container: HTMLElement, url: string) {
|
||||
containerRef.value = container
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
buildScene()
|
||||
container.appendChild(renderer!.domElement)
|
||||
renderer!.domElement.classList.add('block', 'size-full')
|
||||
resize()
|
||||
applyUniforms()
|
||||
attachInteractions(renderer!.domElement)
|
||||
viewport!.observeResize(container, resize)
|
||||
|
||||
const { texture: loaded, gamut: detectedGamut } =
|
||||
await loadHdrTexture(url)
|
||||
if (!material || !mesh) {
|
||||
loaded.dispose()
|
||||
return
|
||||
}
|
||||
gamut.value = detectedGamut
|
||||
setTexture(loaded)
|
||||
applyUniforms()
|
||||
fitView()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
dispose()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExposure() {
|
||||
const max = stats.value?.max ?? 0
|
||||
exposureStops.value = max > 0 ? -Math.log2(max) : 0
|
||||
}
|
||||
|
||||
function attachInteractions(canvas: HTMLCanvasElement) {
|
||||
canvas.addEventListener('wheel', onWheel, { passive: false })
|
||||
canvas.addEventListener('pointerdown', onPointerDown)
|
||||
canvas.addEventListener('pointermove', onHoverMove)
|
||||
canvas.addEventListener('pointerleave', onHoverLeave)
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (!camera) return
|
||||
event.preventDefault()
|
||||
const factor = Math.exp(-event.deltaY * 0.001)
|
||||
const nextZoom = THREE.MathUtils.clamp(
|
||||
camera.zoom * factor,
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM
|
||||
)
|
||||
camera.zoom = nextZoom
|
||||
camera.updateProjectionMatrix()
|
||||
requestRender()
|
||||
}
|
||||
|
||||
let dragStart: { x: number; y: number; camX: number; camY: number } | null =
|
||||
null
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!camera) return
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
camX: camera.position.x,
|
||||
camY: camera.position.y
|
||||
}
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!camera || !dragStart) return
|
||||
const { height } = containerSize()
|
||||
const worldPerPixel = 1 / (height * camera.zoom)
|
||||
camera.position.x =
|
||||
dragStart.camX - (event.clientX - dragStart.x) * worldPerPixel
|
||||
camera.position.y =
|
||||
dragStart.camY + (event.clientY - dragStart.y) * worldPerPixel
|
||||
requestRender()
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragStart = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onHoverMove(event: PointerEvent) {
|
||||
if (!camera || !mesh || !renderer || dragStart || !readSample) return
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
pointerNdc.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
pointerNdc.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
raycaster.setFromCamera(pointerNdc, camera)
|
||||
const hit = raycaster.intersectObject(mesh)[0]
|
||||
if (!hit?.uv) {
|
||||
pixel.value = null
|
||||
return
|
||||
}
|
||||
const col = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.x * imageWidth),
|
||||
0,
|
||||
imageWidth - 1
|
||||
)
|
||||
const row = THREE.MathUtils.clamp(
|
||||
Math.floor(hit.uv.y * imageHeight),
|
||||
0,
|
||||
imageHeight - 1
|
||||
)
|
||||
const base = (row * imageWidth + col) * imageChannels
|
||||
pixel.value = {
|
||||
x: col,
|
||||
y: imageHeight - 1 - row,
|
||||
r: readSample(base),
|
||||
g: readSample(base + 1),
|
||||
b: readSample(base + 2),
|
||||
a: imageChannels === 4 ? readSample(base + 3) : null
|
||||
}
|
||||
}
|
||||
|
||||
function onHoverLeave() {
|
||||
pixel.value = null
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
|
||||
if (renderer) {
|
||||
renderer.domElement.removeEventListener('wheel', onWheel)
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown)
|
||||
renderer.domElement.removeEventListener('pointermove', onHoverMove)
|
||||
renderer.domElement.removeEventListener('pointerleave', onHoverLeave)
|
||||
}
|
||||
viewport?.disposeRenderer()
|
||||
texture?.dispose()
|
||||
material?.dispose()
|
||||
mesh?.geometry.dispose()
|
||||
|
||||
renderer = null
|
||||
viewport = null
|
||||
scene = null
|
||||
camera = null
|
||||
material = null
|
||||
mesh = null
|
||||
texture = null
|
||||
readSample = null
|
||||
}
|
||||
|
||||
watch([exposureStops, dither, clipWarnings, gamut, channel], applyUniforms)
|
||||
|
||||
onUnmounted(dispose)
|
||||
|
||||
return {
|
||||
exposureStops,
|
||||
dither,
|
||||
clipWarnings,
|
||||
gamut,
|
||||
channel,
|
||||
loading,
|
||||
error,
|
||||
dimensions,
|
||||
stats,
|
||||
histogram,
|
||||
pixel,
|
||||
mount,
|
||||
dispose,
|
||||
fitView,
|
||||
normalizeExposure
|
||||
}
|
||||
}
|
||||
@@ -522,22 +522,6 @@ describe('hasUnpromotedWidgets', () => {
|
||||
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false (does not throw) when SubgraphNode is detached', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
const interiorNode = new LGraphNode('InnerNode')
|
||||
subgraph.add(interiorNode)
|
||||
interiorNode.addWidget('text', 'seed', '123', () => {})
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => hasUnpromotedWidgets(subgraphNode)).not.toThrow()
|
||||
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
|
||||
@@ -633,7 +633,6 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
|
||||
}
|
||||
|
||||
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
|
||||
if (subgraphNode.isDetached) return false
|
||||
const { subgraph } = subgraphNode
|
||||
|
||||
return subgraph.nodes.some((interiorNode) =>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -25,21 +20,6 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -49,22 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -305,101 +287,3 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,12 +2,10 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -15,14 +13,11 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -33,15 +28,6 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
}
|
||||
type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -224,321 +210,7 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const ROW_MARKER = '__row__'
|
||||
const rowHeaderName = (group: string, row: number) =>
|
||||
`${group}.${ROW_MARKER}${row}`
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Extract the row index from a header widget name, or `undefined`. */
|
||||
function headerRowIndex(group: string, name: string): number | undefined {
|
||||
const prefix = `${group}.${ROW_MARKER}`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const row = Number(name.slice(prefix.length))
|
||||
return Number.isInteger(row) ? row : undefined
|
||||
}
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
|
||||
|
||||
const CANVAS_MARGIN = 15
|
||||
|
||||
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
|
||||
function drawGroupButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
disabled: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
if (disabled) ctx.globalAlpha *= 0.5
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
|
||||
height * 0.5
|
||||
])
|
||||
ctx.fill()
|
||||
if (!disabled) ctx.stroke()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, width * 0.5, y + height * 0.7)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Horizontal centre of a row header's remove (✕) hit target. */
|
||||
const removeButtonCenterX = (width: number) =>
|
||||
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
|
||||
function drawGroupRowHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
removable: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
|
||||
if (removable) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const countGroupRows = (group: string, node: LGraphNode): number =>
|
||||
(node.widgets ?? []).reduce(
|
||||
(count, w) =>
|
||||
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
|
||||
0
|
||||
)
|
||||
|
||||
/** Build a row's header + field widgets, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
const header = node.addCustomWidget({
|
||||
name: rowHeaderName(group, row),
|
||||
type: 'dynamic_group_row',
|
||||
value: row,
|
||||
y: 0,
|
||||
serialize: false,
|
||||
callback: undefined as IBaseWidget['callback'],
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
const idx = headerRowIndex(group, this.name) ?? 0
|
||||
const label = t('dynamicGroup.row', { index: idx + 1 })
|
||||
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
|
||||
if (event.type !== 'pointerup' || !this.options?.removable) return false
|
||||
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
|
||||
return false
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, removable: row >= state.min }
|
||||
})
|
||||
header.callback = function (this: IBaseWidget) {
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
}
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || w.name.startsWith(`${group}.`)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const headerRow = headerRowIndex(group, w.name)
|
||||
if (headerRow !== undefined && headerRow > row) {
|
||||
w.name = rowHeaderName(group, headerRow - 1)
|
||||
w.options ??= {}
|
||||
w.options.removable = headerRow - 1 >= state.min
|
||||
continue
|
||||
}
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => name.startsWith(`${group}.`)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => isRowMember(inp.name))
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = { min, max, inputSpecs }
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group_add',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
drawGroupButton(
|
||||
ctx,
|
||||
width,
|
||||
y,
|
||||
t('dynamicGroup.addRow'),
|
||||
!!this.options?.disabled
|
||||
)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
|
||||
if (event.type !== 'pointerup' || this.options?.disabled) return false
|
||||
addRow(inputName, typedNode)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, disabled: false }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
rebuildRows(inputName, count, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { state } = vi.hoisted(() => ({
|
||||
state: {
|
||||
extension: null as { nodeCreated: (node: unknown) => void } | null,
|
||||
widgetState: undefined as { options: Record<string, unknown> } | undefined
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({
|
||||
registerExtension: (ext: { nodeCreated: (node: unknown) => void }) => {
|
||||
state.extension = ext
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: () => state.widgetState })
|
||||
}))
|
||||
|
||||
await import('./createBoundingBoxes')
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
hidden: boolean
|
||||
options: Record<string, unknown>
|
||||
widgetId?: string
|
||||
}
|
||||
|
||||
function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
size: [100, 100] as [number, number],
|
||||
setSize: vi.fn(),
|
||||
findInputSlot: () => 0,
|
||||
isInputConnected: () => connected,
|
||||
widgets,
|
||||
onConnectionsChange: undefined as unknown
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.widgetState = undefined
|
||||
})
|
||||
|
||||
describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
it('ignores nodes of other classes', () => {
|
||||
const node = makeNode(true, 'SomethingElse')
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.setSize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enlarges the node and hides width/height when a background is connected', () => {
|
||||
const node = makeNode(true)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.setSize).toHaveBeenCalledWith([420, 560])
|
||||
expect(node.widgets[0].hidden).toBe(true)
|
||||
expect(node.widgets[1].hidden).toBe(true)
|
||||
expect(node.widgets[0].options.hidden).toBe(true)
|
||||
expect(node.widgets[2].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('shows width/height when no background is connected', () => {
|
||||
const node = makeNode(false)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[0].hidden).toBe(false)
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
node.widgets[0].widgetId = 'w-0'
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(state.widgetState.options.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('chains a connections-change handler that re-syncs visibility', () => {
|
||||
const node = makeNode(false)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(typeof node.onConnectionsChange).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'CreateBoundingBoxes') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 420), Math.max(oldHeight, 560)])
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
syncDimensionVisibility
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './createBoundingBoxes'
|
||||
import './customWidgets'
|
||||
import './dynamicPrompts'
|
||||
import './editAttention'
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
import type { EventManager } from './EventManager'
|
||||
@@ -29,7 +27,8 @@ export type Viewport3dDeps = {
|
||||
viewHelperManager: ViewHelperManager
|
||||
}
|
||||
|
||||
export class Viewport3d extends WebGLViewport {
|
||||
export class Viewport3d {
|
||||
renderer: THREE.WebGLRenderer
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
@@ -53,6 +52,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
isViewerMode: boolean = false
|
||||
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private externalActiveCamera: THREE.Camera | null = null
|
||||
private overlay: SceneOverlay | null = null
|
||||
@@ -63,7 +63,6 @@ export class Viewport3d extends WebGLViewport {
|
||||
deps: Viewport3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
super(deps.renderer)
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
@@ -74,6 +73,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
this.applyTargetSize(options.width, options.height)
|
||||
}
|
||||
|
||||
this.renderer = deps.renderer
|
||||
this.eventManager = deps.eventManager
|
||||
this.sceneManager = deps.sceneManager
|
||||
this.cameraManager = deps.cameraManager
|
||||
@@ -94,7 +94,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.observeResize(container, () => this.handleResize())
|
||||
this.initResizeObserver(container)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -118,6 +118,16 @@ export class Viewport3d extends WebGLViewport {
|
||||
this.targetAspectRatio = width / height
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.handleResize()
|
||||
})
|
||||
this.resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
private initContextMenu(): void {
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
@@ -390,15 +400,29 @@ export class Viewport3d extends WebGLViewport {
|
||||
this.initialRenderTimer = null
|
||||
}
|
||||
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
this.resizeObserver = null
|
||||
}
|
||||
|
||||
this.disposeContextMenuGuard?.()
|
||||
this.disposeContextMenuGuard = null
|
||||
|
||||
this.renderer.forceContextLoss()
|
||||
const canvas = this.renderer.domElement
|
||||
const event = new Event('webglcontextlost', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
this.renderLoop?.stop()
|
||||
this.renderLoop = null
|
||||
|
||||
this.disposeManagers()
|
||||
|
||||
this.disposeRenderer()
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
}
|
||||
|
||||
protected disposeManagers(): void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
@@ -272,7 +273,17 @@ export class PrimitiveNode extends LGraphNode {
|
||||
widgetName: 'value',
|
||||
nodeTypeForBrowser: targetNode.comfyClass ?? '',
|
||||
inputNameForBrowser: targetInputName,
|
||||
defaultValue
|
||||
defaultValue,
|
||||
onValueChange: (widget, newValue, oldValue) => {
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
this,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
this.onWidgetChanged?.(widget.name, newValue, oldValue, widget)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphGroup,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
@@ -324,96 +323,6 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node:before-removed event', () => {
|
||||
it('fires node:before-removed for a successful node removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const events: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
graph.events.addEventListener('node:before-removed', (e) => {
|
||||
events.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].node).toBe(node)
|
||||
expect(events[0].graphAtDispatch).toBe(graph)
|
||||
expect(node.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed for a node not in the graph', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when removing an LGraphGroup', () => {
|
||||
const graph = new LGraph()
|
||||
const group = new LGraphGroup('test-group')
|
||||
graph.add(group)
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(group)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire node:before-removed when ignore_remove is set', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
node.ignore_remove = true
|
||||
|
||||
const fired = vi.fn()
|
||||
graph.events.addEventListener('node:before-removed', fired)
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(fired).not.toHaveBeenCalled()
|
||||
expect(graph.nodes).toContain(node)
|
||||
})
|
||||
|
||||
it('fires node:before-removed before node.onRemoved and detach', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const order: string[] = []
|
||||
graph.events.addEventListener('node:before-removed', () => {
|
||||
order.push(
|
||||
`before-removed(graph=${node.graph === graph ? 'set' : 'null'})`
|
||||
)
|
||||
})
|
||||
node.onRemoved = () => {
|
||||
order.push(`onRemoved(graph=${node.graph === graph ? 'set' : 'null'})`)
|
||||
}
|
||||
graph.onNodeRemoved = (n) => {
|
||||
order.push(`onNodeRemoved(graph=${n.graph === null ? 'null' : 'set'})`)
|
||||
}
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(order).toEqual([
|
||||
'before-removed(graph=set)',
|
||||
'onRemoved(graph=set)',
|
||||
'onNodeRemoved(graph=null)'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -466,53 +375,6 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
expect(graphRemovedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed on the inner subgraph for each inner node', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
|
||||
|
||||
const dispatched: { node: LGraphNode; graphAtDispatch: unknown }[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', (e) => {
|
||||
dispatched.push({
|
||||
node: e.detail.node,
|
||||
graphAtDispatch: e.detail.node.graph
|
||||
})
|
||||
})
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(dispatched.map((e) => e.node)).toEqual(innerNodes)
|
||||
for (const entry of dispatched) {
|
||||
expect(entry.graphAtDispatch).toBe(subgraph)
|
||||
}
|
||||
})
|
||||
|
||||
it('subgraph-definition GC dispatches node:before-removed before each inner node onRemoved', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 1)
|
||||
const innerNode = innerNodes[0]
|
||||
|
||||
const order: string[] = []
|
||||
subgraph.events.addEventListener('node:before-removed', () => {
|
||||
order.push('before-removed')
|
||||
})
|
||||
innerNode.onRemoved = () => {
|
||||
order.push('onRemoved')
|
||||
}
|
||||
subgraph.onNodeRemoved = () => {
|
||||
order.push('onNodeRemoved')
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(order).toEqual(['before-removed', 'onRemoved', 'onNodeRemoved'])
|
||||
})
|
||||
|
||||
it('subgraph definition is removed when SubgraphNode is removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
|
||||
|
||||
@@ -155,13 +155,6 @@ export interface BaseLGraph {
|
||||
readonly rootGraph: LGraph
|
||||
}
|
||||
|
||||
function fireNodeRemovalLifecycle(node: LGraphNode): void {
|
||||
const graph: LGraph | null = node.graph
|
||||
graph?.events.dispatch('node:before-removed', { node })
|
||||
node.onRemoved?.()
|
||||
graph?.onNodeRemoved?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
|
||||
* supported callbacks:
|
||||
@@ -393,7 +386,8 @@ export class LGraph
|
||||
// safe clear
|
||||
if (this._nodes) {
|
||||
for (const _node of this._nodes) {
|
||||
fireNodeRemovalLifecycle(_node)
|
||||
_node.onRemoved?.()
|
||||
this.onNodeRemoved?.(_node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,8 +1046,6 @@ export class LGraph
|
||||
// sure? - almost sure is wrong
|
||||
this.beforeChange()
|
||||
|
||||
this.events.dispatch('node:before-removed', { node })
|
||||
|
||||
const { inputs, outputs } = node
|
||||
|
||||
// disconnect inputs
|
||||
@@ -1089,7 +1081,10 @@ export class LGraph
|
||||
)
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
|
||||
forEachNode(node.subgraph, (innerNode) => {
|
||||
innerNode.onRemoved?.()
|
||||
innerNode.graph?.onNodeRemoved?.(innerNode)
|
||||
})
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,7 +829,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (this._lowQualityZoomThreshold > 0) {
|
||||
this._isLowQuality = scale < this._lowQualityZoomThreshold
|
||||
}
|
||||
this.setDirty(true, true)
|
||||
}
|
||||
|
||||
// Initialize link renderer if graph is available
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
@@ -51,13 +51,6 @@ export interface LGraphEventMap {
|
||||
closingGraph: LGraph | Subgraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires on the owning graph before per-node teardown begins
|
||||
*/
|
||||
'node:before-removed': {
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
'node:property:changed': {
|
||||
nodeId: NodeId
|
||||
property: string
|
||||
|
||||
@@ -85,19 +85,6 @@ describe('SubgraphNode Construction', () => {
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
})
|
||||
|
||||
it('should return empty widgets array (not throw) after removal', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const parentGraph = subgraphNode.graph!
|
||||
parentGraph.add(subgraphNode)
|
||||
|
||||
parentGraph.remove(subgraphNode)
|
||||
|
||||
expect(subgraphNode.graph).toBeNull()
|
||||
expect(() => subgraphNode.widgets).not.toThrow()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should synchronize slots with subgraph definition',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -68,10 +68,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return this.graph.rootGraph
|
||||
}
|
||||
|
||||
get isDetached(): boolean {
|
||||
return !this.graph
|
||||
}
|
||||
|
||||
override get displayType(): string {
|
||||
return 'Subgraph node'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import type {
|
||||
@@ -71,7 +70,6 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
@@ -143,8 +141,6 @@ export type IWidget =
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
| IRangeWidget
|
||||
| IBoundingBoxesWidget
|
||||
| IColorsWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -347,19 +343,6 @@ export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface IBoundingBoxesWidget extends IBaseWidget<
|
||||
BoundingBox[],
|
||||
'boundingboxes'
|
||||
> {
|
||||
type: 'boundingboxes'
|
||||
value: BoundingBox[]
|
||||
}
|
||||
|
||||
export interface IColorsWidget extends IBaseWidget<string[], 'colors'> {
|
||||
type: 'colors'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
export interface RangeValue {
|
||||
min: number
|
||||
max: number
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
|
||||
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
|
||||
|
||||
function fakeCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: ''
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('BoundingBoxesWidget', () => {
|
||||
it('has the boundingboxes type and draws the Vue-only placeholder', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const widget = new BoundingBoxesWidget(
|
||||
{
|
||||
type: 'boundingboxes',
|
||||
name: 'editor_state',
|
||||
value: [],
|
||||
options: {},
|
||||
y: 0
|
||||
},
|
||||
node
|
||||
)
|
||||
expect(widget.type).toBe('boundingboxes')
|
||||
const ctx = fakeCtx()
|
||||
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
expect(() => widget.onClick({} as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { IBoundingBoxesWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class BoundingBoxesWidget
|
||||
extends BaseWidget<IBoundingBoxesWidget>
|
||||
implements IBoundingBoxesWidget
|
||||
{
|
||||
override type = 'boundingboxes' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Bounding Boxes')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { DrawWidgetOptions } from '@/lib/litegraph/src/widgets/BaseWidget'
|
||||
|
||||
import { ColorsWidget } from './ColorsWidget'
|
||||
|
||||
function fakeCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: ''
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
describe('ColorsWidget', () => {
|
||||
it('has the colors type and draws the Vue-only placeholder', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const widget = new ColorsWidget(
|
||||
{ type: 'colors', name: 'palette', value: [], options: {}, y: 0 },
|
||||
node
|
||||
)
|
||||
expect(widget.type).toBe('colors')
|
||||
const ctx = fakeCtx()
|
||||
widget.drawWidget(ctx, { width: 200 } as DrawWidgetOptions)
|
||||
expect(ctx.fillText).toHaveBeenCalled()
|
||||
expect(() => widget.onClick({} as never)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { IColorsWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
export class ColorsWidget
|
||||
extends BaseWidget<IColorsWidget>
|
||||
implements IColorsWidget
|
||||
{
|
||||
override type = 'colors' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Colors')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {}
|
||||
}
|
||||
@@ -21,8 +21,6 @@ import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { BoundingBoxesWidget } from './BoundingBoxesWidget'
|
||||
import { ColorsWidget } from './ColorsWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { RangeWidget } from './RangeWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
@@ -64,8 +62,6 @@ export type WidgetTypeMap = {
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
range: RangeWidget
|
||||
boundingboxes: BoundingBoxesWidget
|
||||
colors: ColorsWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -148,10 +144,6 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
case 'range':
|
||||
return toClass(RangeWidget, narrowedWidget, node)
|
||||
case 'boundingboxes':
|
||||
return toClass(BoundingBoxesWidget, narrowedWidget, node)
|
||||
case 'colors':
|
||||
return toClass(ColorsWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -387,35 +387,6 @@
|
||||
"collapseAll": "Collapse all",
|
||||
"expandAll": "Expand all"
|
||||
},
|
||||
"hdrViewer": {
|
||||
"title": "HDR Viewer",
|
||||
"openInHdrViewer": "Open in HDR Viewer",
|
||||
"hdrImage": "HDR image",
|
||||
"failedToLoad": "Failed to load HDR image",
|
||||
"exposure": "Exposure",
|
||||
"normalizeExposure": "Auto exposure",
|
||||
"channel": "Channel",
|
||||
"channels": {
|
||||
"rgb": "RGB",
|
||||
"r": "R",
|
||||
"g": "G",
|
||||
"b": "B",
|
||||
"a": "Alpha",
|
||||
"luminance": "Luminance"
|
||||
},
|
||||
"sourceGamut": "Source gamut",
|
||||
"dither": "Dither",
|
||||
"clipWarnings": "Clip warnings",
|
||||
"fitView": "Fit",
|
||||
"histogram": "Histogram",
|
||||
"resolution": "Resolution",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"mean": "Mean",
|
||||
"stdDev": "Std dev",
|
||||
"nan": "NaN",
|
||||
"inf": "Inf"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Nodes Manager",
|
||||
"nodePackInfo": "Node Pack Info",
|
||||
@@ -895,8 +866,8 @@
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"assets": "Assets",
|
||||
"workflows": "Work\u00adflows",
|
||||
"templates": "Tem\u00adplates",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"imported": "Imported",
|
||||
@@ -2142,21 +2113,6 @@
|
||||
"monotone_cubic": "Smooth",
|
||||
"linear": "Linear"
|
||||
},
|
||||
"boundingBoxes": {
|
||||
"clearAll": "Clear all",
|
||||
"clickRegionToEdit": "Click a region to edit it.",
|
||||
"typeObj": "obj",
|
||||
"typeText": "text",
|
||||
"textLabel": "Text",
|
||||
"descLabel": "description",
|
||||
"textPlaceholder": "text to render (verbatim)",
|
||||
"descPlaceholder": "description of this region",
|
||||
"colors": "color_palette"
|
||||
},
|
||||
"palette": {
|
||||
"addColor": "Add a color",
|
||||
"swatchTitle": "Click edit · drag reorder · right-click remove"
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||
@@ -2233,11 +2189,6 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"row": "Row {index}"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
@@ -2995,7 +2946,7 @@
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Short\u00adcuts",
|
||||
"shortcuts": "Shortcuts",
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<BaseModalLayout
|
||||
v-model:right-panel-open="isRightPanelOpen"
|
||||
data-testid="asset-browser-modal"
|
||||
data-component-id="AssetBrowserModal"
|
||||
class="size-full max-h-full max-w-full min-w-0"
|
||||
:content-title="displayTitle"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="asset-card"
|
||||
data-component-id="AssetCard"
|
||||
:data-asset-id="asset.id"
|
||||
:aria-labelledby="titleId"
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type * as outputAssetUtilModule from '../utils/outputAssetUtil'
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
@@ -146,17 +145,6 @@ vi.mock('../schemas/assetMetadataSchema', () => ({
|
||||
getOutputAssetMetadata: mockGetOutputAssetMetadata
|
||||
}))
|
||||
|
||||
const mockResolveOutputAssetItems = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue([])
|
||||
)
|
||||
vi.mock('../utils/outputAssetUtil', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof outputAssetUtilModule>()
|
||||
return {
|
||||
...actual,
|
||||
resolveOutputAssetItems: mockResolveOutputAssetItems
|
||||
}
|
||||
})
|
||||
|
||||
const mockDeleteAsset = vi.hoisted(() => vi.fn())
|
||||
const mockCreateAssetExport = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
|
||||
@@ -295,8 +283,6 @@ describe('useMediaAssetActions', () => {
|
||||
mockGetOutputAssetMetadata.mockReset()
|
||||
mockGetOutputAssetMetadata.mockReturnValue(null)
|
||||
mockGetAssetType.mockReset()
|
||||
mockResolveOutputAssetItems.mockReset()
|
||||
mockResolveOutputAssetItems.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('addWorkflow', () => {
|
||||
@@ -596,236 +582,6 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - OSS multi-output expansion', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = false
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockImplementation(
|
||||
(meta: Record<string, unknown> | undefined) =>
|
||||
meta && 'jobId' in meta ? meta : null
|
||||
)
|
||||
})
|
||||
|
||||
function createOutputAsset(
|
||||
id: string,
|
||||
name: string,
|
||||
jobId: string,
|
||||
outputCount?: number,
|
||||
previewUrl?: string
|
||||
): AssetItem {
|
||||
return createMockAsset({
|
||||
id,
|
||||
name,
|
||||
tags: ['output'],
|
||||
preview_url: previewUrl ?? `https://example.com/${name}`,
|
||||
user_metadata: { jobId, nodeId: '1', subfolder: '', outputCount }
|
||||
})
|
||||
}
|
||||
|
||||
it('expands a grouped asset into individual downloads', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-out1', 'out1.png', 'job1'),
|
||||
createOutputAsset('g1-out2', 'out2.png', 'job1'),
|
||||
createOutputAsset('g1-out3', 'out3.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jobId: 'job1', outputCount: 3 }),
|
||||
expect.objectContaining({ createdAt: expect.any(String) })
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://example.com/out1.png',
|
||||
'out1.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://example.com/out2.png',
|
||||
'out2.png'
|
||||
)
|
||||
expect(mockDownloadFile).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'https://example.com/out3.png',
|
||||
'out3.png'
|
||||
)
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mixes grouped and single-output assets in one selection', async () => {
|
||||
const grouped = createOutputAsset('g1', 'cover.png', 'job1', 2)
|
||||
const single = createOutputAsset('s1', 'solo.png', 'job2')
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createOutputAsset('g1-a', 'a.png', 'job1'),
|
||||
createOutputAsset('g1-b', 'b.png', 'job1')
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, single])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
expect(mockResolveOutputAssetItems).toHaveBeenCalledTimes(1)
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['a.png', 'b.png', 'solo.png'])
|
||||
})
|
||||
|
||||
it('falls back to the original asset when resolveOutputAssetItems returns empty', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not call resolveOutputAssetItems when no grouped assets are selected', () => {
|
||||
const single1 = createOutputAsset(
|
||||
's1',
|
||||
'a.png',
|
||||
'job1',
|
||||
undefined,
|
||||
'https://example.com/a.png'
|
||||
)
|
||||
const single2 = createOutputAsset(
|
||||
's2',
|
||||
'b.png',
|
||||
'job2',
|
||||
1,
|
||||
'https://example.com/b.png'
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([single1, single2])
|
||||
|
||||
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('deduplicates downloads when an expanded child is also selected alongside its parent', async () => {
|
||||
const grouped = createOutputAsset('job1-cover', 'cover.png', 'job1', 3)
|
||||
const child = createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
|
||||
mockResolveOutputAssetItems.mockResolvedValueOnce([
|
||||
createMockAsset({
|
||||
id: 'job1-child-a',
|
||||
name: 'out1.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out1.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
}),
|
||||
createMockAsset({
|
||||
id: 'job1-child-b',
|
||||
name: 'out2.png',
|
||||
tags: ['output'],
|
||||
preview_url: 'https://example.com/out2.png',
|
||||
user_metadata: { jobId: 'job1', nodeId: '1', subfolder: '' }
|
||||
})
|
||||
])
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped, child])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['out1.png', 'out2.png'])
|
||||
})
|
||||
|
||||
it('falls back to the preview download when resolveOutputAssetItems rejects', async () => {
|
||||
const grouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover.png'
|
||||
)
|
||||
mockResolveOutputAssetItems.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([grouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/cover.png',
|
||||
'cover.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('still downloads resolvable assets when one grouped asset fails to expand', async () => {
|
||||
const failingGrouped = createOutputAsset(
|
||||
'g1',
|
||||
'cover1.png',
|
||||
'job1',
|
||||
3,
|
||||
'https://example.com/cover1.png'
|
||||
)
|
||||
const okGrouped = createOutputAsset('g2', 'cover2.png', 'job2', 2)
|
||||
|
||||
mockResolveOutputAssetItems.mockImplementation(
|
||||
(metadata: { jobId: string }) => {
|
||||
if (metadata.jobId === 'job1') {
|
||||
return Promise.reject(new Error('job1 lookup failed'))
|
||||
}
|
||||
return Promise.resolve([
|
||||
createOutputAsset('g2-a', 'out2a.png', 'job2'),
|
||||
createOutputAsset('g2-b', 'out2b.png', 'job2')
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([failingGrouped, okGrouped])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
const filenames = mockDownloadFile.mock.calls.map((call) => call[1])
|
||||
expect(filenames).toEqual(['cover1.png', 'out2a.png', 'out2b.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - cloud zip filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
|
||||
@@ -27,10 +27,7 @@ import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
|
||||
import { clearNodePreviewCacheForValues } from '../utils/clearNodePreviewCacheForValues'
|
||||
import { markDeletedAssetsAsMissingMedia } from '../utils/markDeletedAssetsAsMissingMedia'
|
||||
import {
|
||||
getAssetOutputCount,
|
||||
resolveOutputAssetItems
|
||||
} from '../utils/outputAssetUtil'
|
||||
import { getAssetOutputCount } from '../utils/outputAssetUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { isResultItemType } from '@/utils/typeGuardUtil'
|
||||
@@ -112,9 +109,8 @@ export function useMediaAssetActions() {
|
||||
* Download one or more assets.
|
||||
* In cloud mode, creates a ZIP export via the backend when called with
|
||||
* 2+ assets or with any asset whose job has `outputCount > 1`.
|
||||
* In OSS mode, downloads each file directly, expanding grouped assets
|
||||
* (`outputCount > 1`) into their individual outputs.
|
||||
* With no argument, uses the asset from `MediaAssetKey` context.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*/
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const targetAssets =
|
||||
@@ -131,13 +127,13 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMultiOutputJobs) {
|
||||
void downloadAssetsIndividually(targetAssets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
targetAssets.forEach((asset) => downloadSingleAsset(asset))
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
@@ -154,66 +150,6 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSingleAsset(asset: AssetItem) {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
}
|
||||
|
||||
async function expandAssetForDownload(
|
||||
asset: AssetItem
|
||||
): Promise<AssetItem[]> {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (
|
||||
!metadata ||
|
||||
typeof metadata.outputCount !== 'number' ||
|
||||
metadata.outputCount <= 1
|
||||
) {
|
||||
return [asset]
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
return resolved.length > 0 ? resolved : [asset]
|
||||
} catch (error) {
|
||||
console.error('Failed to expand grouped asset for download:', error)
|
||||
return [asset]
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsIndividually(assets: AssetItem[]) {
|
||||
try {
|
||||
const expanded = await Promise.all(assets.map(expandAssetForDownload))
|
||||
const seenAssetIds = new Set<string>()
|
||||
const filesToDownload = expanded.flat().filter((asset) => {
|
||||
if (seenAssetIds.has(asset.id)) return false
|
||||
seenAssetIds.add(asset.id)
|
||||
return true
|
||||
})
|
||||
|
||||
filesToDownload.forEach((asset) => downloadSingleAsset(asset))
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t(
|
||||
'mediaAsset.selection.downloadsStarted',
|
||||
filesToDownload.length
|
||||
),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to download assets:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssetsAsZip(assets: AssetItem[]) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetAssetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import { createAssetWidget } from './createAssetWidget'
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
|
||||
const show = vi.fn()
|
||||
const browse = vi.fn()
|
||||
return {
|
||||
useAssetBrowserDialog: () => ({ show, browse })
|
||||
}
|
||||
})
|
||||
|
||||
interface HostAssetWidget extends IBaseWidget<
|
||||
string,
|
||||
'asset',
|
||||
IWidgetAssetOptions
|
||||
> {
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
type OnWidgetChanged = NonNullable<LGraphNode['onWidgetChanged']>
|
||||
|
||||
function checkpointAsset(name: string): AssetItem {
|
||||
return {
|
||||
id: `asset-${name}`,
|
||||
name,
|
||||
hash: 'checkpoint-hash',
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
|
||||
function createAssetWidgetNode() {
|
||||
const node = new LGraphNode('TestNode')
|
||||
const onWidgetChanged = vi.fn<OnWidgetChanged>()
|
||||
node.onWidgetChanged = onWidgetChanged
|
||||
|
||||
return { node, onWidgetChanged }
|
||||
}
|
||||
|
||||
function assertAssetOptions(
|
||||
options: unknown
|
||||
): asserts options is IWidgetAssetOptions {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new Error('Expected asset widget options')
|
||||
}
|
||||
if (!('openModal' in options) || typeof options.openModal !== 'function') {
|
||||
throw new Error('Expected asset widget options')
|
||||
}
|
||||
}
|
||||
|
||||
function firstShowOptions() {
|
||||
const showOptions = vi.mocked(useAssetBrowserDialog().show).mock.calls[0]?.[0]
|
||||
if (!showOptions) {
|
||||
throw new Error('Expected the asset browser dialog to open')
|
||||
}
|
||||
return showOptions
|
||||
}
|
||||
|
||||
describe('createAssetWidget', () => {
|
||||
let captureCanvasState: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
captureCanvasState = vi.fn()
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
workflow: {
|
||||
activeWorkflow: {
|
||||
changeTracker: { captureCanvasState }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves regular asset widget change handling for the owning widget', async () => {
|
||||
const { node, onWidgetChanged } = createAssetWidgetNode()
|
||||
const widget = createAssetWidget({
|
||||
node,
|
||||
widgetName: 'ckpt_name',
|
||||
nodeTypeForBrowser: 'CheckpointLoaderSimple',
|
||||
inputNameForBrowser: 'ckpt_name',
|
||||
defaultValue: 'fake_model.safetensors'
|
||||
})
|
||||
|
||||
assertAssetOptions(widget.options)
|
||||
await widget.options.openModal(widget)
|
||||
const showOptions = firstShowOptions()
|
||||
|
||||
expect(showOptions).toMatchObject({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: 'fake_model.safetensors'
|
||||
})
|
||||
|
||||
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
|
||||
|
||||
expect(widget.value).toBe('real_model.safetensors')
|
||||
expect(onWidgetChanged).toHaveBeenCalledWith(
|
||||
'ckpt_name',
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
widget
|
||||
)
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('commits cloned asset modal selections through the promoted host widget', async () => {
|
||||
const { node, onWidgetChanged: sourceOnWidgetChanged } =
|
||||
createAssetWidgetNode()
|
||||
const sourceWidget = createAssetWidget({
|
||||
node,
|
||||
widgetName: 'ckpt_name',
|
||||
nodeTypeForBrowser: 'CheckpointLoaderSimple',
|
||||
inputNameForBrowser: 'ckpt_name',
|
||||
defaultValue: 'fake_model.safetensors'
|
||||
})
|
||||
assertAssetOptions(sourceWidget.options)
|
||||
const hostCallback = vi.fn<NonNullable<IBaseWidget['callback']>>()
|
||||
const hostNode = new LGraphNode('PromotedHostNode')
|
||||
const hostOnWidgetChanged = vi.fn<OnWidgetChanged>()
|
||||
hostNode.onWidgetChanged = hostOnWidgetChanged
|
||||
const hostWidget: HostAssetWidget = {
|
||||
type: 'asset',
|
||||
name: 'host_ckpt_name',
|
||||
value: 'fake_model.safetensors',
|
||||
callback: hostCallback,
|
||||
options: sourceWidget.options,
|
||||
node: hostNode,
|
||||
y: 0
|
||||
}
|
||||
|
||||
await sourceWidget.options.openModal(hostWidget)
|
||||
const showOptions = firstShowOptions()
|
||||
|
||||
expect(showOptions).toMatchObject({
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
inputName: 'ckpt_name',
|
||||
currentValue: 'fake_model.safetensors'
|
||||
})
|
||||
|
||||
showOptions.onAssetSelected?.(checkpointAsset('real_model.safetensors'))
|
||||
|
||||
expect(sourceOnWidgetChanged).not.toHaveBeenCalled()
|
||||
expect(hostWidget.value).toBe('real_model.safetensors')
|
||||
expect(hostCallback).toHaveBeenCalledWith('real_model.safetensors')
|
||||
expect(hostOnWidgetChanged).toHaveBeenCalledWith(
|
||||
'host_ckpt_name',
|
||||
'real_model.safetensors',
|
||||
'fake_model.safetensors',
|
||||
hostWidget
|
||||
)
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -12,11 +12,8 @@ import {
|
||||
assetItemSchema
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
type WidgetWithNode = IBaseWidget & { node: LGraphNode }
|
||||
|
||||
interface CreateAssetWidgetParams {
|
||||
/** The node to add the widget to */
|
||||
node: LGraphNode
|
||||
@@ -28,26 +25,34 @@ interface CreateAssetWidgetParams {
|
||||
inputNameForBrowser?: string
|
||||
/** Default value for the widget */
|
||||
defaultValue?: string
|
||||
/** Callback when widget value changes */
|
||||
onValueChange?: (
|
||||
widget: IBaseWidget,
|
||||
newValue: string,
|
||||
oldValue: unknown
|
||||
) => void
|
||||
}
|
||||
|
||||
interface CreateAssetWidgetOptionsParams {
|
||||
widgetName: string
|
||||
nodeTypeForBrowser: string
|
||||
inputNameForBrowser?: string
|
||||
}
|
||||
/**
|
||||
* Creates an asset widget that opens the Asset Browser dialog for model selection.
|
||||
* Used by both regular nodes (via useComboWidget) and PrimitiveNode.
|
||||
*
|
||||
* @param params - Configuration for the asset widget
|
||||
* @returns The created asset widget
|
||||
*/
|
||||
export function createAssetWidget(
|
||||
params: CreateAssetWidgetParams
|
||||
): IBaseWidget {
|
||||
const {
|
||||
node,
|
||||
widgetName,
|
||||
nodeTypeForBrowser,
|
||||
inputNameForBrowser,
|
||||
defaultValue,
|
||||
onValueChange
|
||||
} = params
|
||||
|
||||
function hasOwnerNode(widget: IBaseWidget): widget is WidgetWithNode {
|
||||
return (
|
||||
'node' in widget && typeof widget.node === 'object' && widget.node !== null
|
||||
)
|
||||
}
|
||||
|
||||
function createAssetWidgetOptions({
|
||||
widgetName,
|
||||
nodeTypeForBrowser,
|
||||
inputNameForBrowser
|
||||
}: CreateAssetWidgetOptionsParams): IWidgetAssetOptions {
|
||||
const inputName = inputNameForBrowser ?? widgetName
|
||||
const displayLabel = defaultValue ?? t('widgets.selectModel')
|
||||
const assetBrowserDialog = useAssetBrowserDialog()
|
||||
|
||||
async function openModal(widget: IBaseWidget) {
|
||||
@@ -55,8 +60,8 @@ function createAssetWidgetOptions({
|
||||
|
||||
await assetBrowserDialog.show({
|
||||
nodeType: nodeTypeForBrowser,
|
||||
inputName,
|
||||
currentValue: String(widget.value ?? ''),
|
||||
inputName: inputNameForBrowser ?? widgetName,
|
||||
currentValue: widget.value as string,
|
||||
onAssetSelected: (asset) => {
|
||||
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||
|
||||
@@ -93,44 +98,15 @@ function createAssetWidgetOptions({
|
||||
|
||||
const oldValue = widget.value
|
||||
widget.value = validatedFilename.data
|
||||
widget.callback?.(widget.value)
|
||||
if (hasOwnerNode(widget)) {
|
||||
widget.node.onWidgetChanged?.(
|
||||
widget.name,
|
||||
validatedFilename.data,
|
||||
oldValue,
|
||||
widget
|
||||
)
|
||||
}
|
||||
if (oldValue !== validatedFilename.data) {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
onValueChange?.(widget, validatedFilename.data, oldValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
const options: IWidgetAssetOptions = {
|
||||
openModal,
|
||||
nodeType: nodeTypeForBrowser
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetWidget(
|
||||
params: CreateAssetWidgetParams
|
||||
): IBaseWidget {
|
||||
const {
|
||||
node,
|
||||
widgetName,
|
||||
nodeTypeForBrowser,
|
||||
inputNameForBrowser,
|
||||
defaultValue
|
||||
} = params
|
||||
|
||||
const displayLabel = defaultValue ?? t('widgets.selectModel')
|
||||
const options = createAssetWidgetOptions({
|
||||
widgetName,
|
||||
nodeTypeForBrowser,
|
||||
inputNameForBrowser
|
||||
})
|
||||
return node.addWidget('asset', widgetName, displayLabel, () => {}, options)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
@@ -187,7 +188,9 @@ const signUpWithEmail = async (values: SignUpData) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
telemetry?.trackSignupOpened()
|
||||
if (isCloud) {
|
||||
telemetry?.trackSignupOpened()
|
||||
}
|
||||
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getSurveyCompletedStatus,
|
||||
submitSurvey
|
||||
} from '@/platform/cloud/onboarding/auth'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
|
||||
@@ -45,7 +46,9 @@ onMounted(async () => {
|
||||
await router.replace({ name: 'cloud-user-check' })
|
||||
return
|
||||
}
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('opened')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check survey status:', error)
|
||||
}
|
||||
@@ -59,7 +62,9 @@ const onSubmitSurvey = async (payload: Record<string, unknown>) => {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await submitSurvey(payload)
|
||||
useTelemetry()?.trackSurvey('submitted', payload)
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSurvey('submitted', payload)
|
||||
}
|
||||
await router.push({ name: 'cloud-user-check' })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
|
||||
@@ -53,9 +53,11 @@ watch(
|
||||
)
|
||||
|
||||
const handleSubscribe = () => {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
})
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('subscribe_clicked', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase()
|
||||
})
|
||||
}
|
||||
isAwaitingStripeSubscription.value = true
|
||||
showSubscriptionDialog()
|
||||
}
|
||||
|
||||
@@ -237,10 +237,12 @@ function useSubscriptionInternal() {
|
||||
const showSubscriptionDialog = (options?: {
|
||||
reason?: SubscriptionDialogReason
|
||||
}) => {
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackSubscription('modal_opened', {
|
||||
current_tier: subscriptionTier.value?.toLowerCase(),
|
||||
reason: options?.reason
|
||||
})
|
||||
}
|
||||
|
||||
void showSubscriptionRequiredDialog(options)
|
||||
}
|
||||
|
||||
@@ -38,19 +38,6 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockTrackHelpResourceClicked: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () =>
|
||||
mockIsCloud.value
|
||||
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
|
||||
: null
|
||||
}))
|
||||
|
||||
// Mock window.open
|
||||
const mockOpen = vi.fn()
|
||||
Object.defineProperty(window, 'open', {
|
||||
@@ -61,7 +48,6 @@ Object.defineProperty(window, 'open', {
|
||||
describe('useSubscriptionActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
describe('handleAddApiCredits', () => {
|
||||
@@ -87,27 +73,6 @@ describe('useSubscriptionActions', () => {
|
||||
expect(isLoadingSupport.value).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks help-resource telemetry when messaging support in cloud', async () => {
|
||||
const { handleMessageSupport } = useSubscriptionActions()
|
||||
|
||||
await handleMessageSupport()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire telemetry when messaging support in OSS builds', async () => {
|
||||
mockIsCloud.value = false
|
||||
const { handleMessageSupport } = useSubscriptionActions()
|
||||
|
||||
await handleMessageSupport()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
|
||||
const { handleMessageSupport, isLoadingSupport } =
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -29,11 +30,13 @@ export function useSubscriptionActions() {
|
||||
const handleMessageSupport = async () => {
|
||||
try {
|
||||
isLoadingSupport.value = true
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
if (isCloud) {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'subscription'
|
||||
})
|
||||
}
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
} catch (error) {
|
||||
console.error('[useSubscriptionActions] Error contacting support:', error)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMissingMediaStore } from './missingMediaStore'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
@@ -97,6 +96,14 @@ describe('useMissingMediaStore', () => {
|
||||
expect(store.missingMediaNodeIds.has('2')).toBe(true)
|
||||
})
|
||||
|
||||
it('hasMissingMediaOnNode checks node presence', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('42', 'photo.png')])
|
||||
|
||||
expect(store.hasMissingMediaOnNode('42')).toBe(true)
|
||||
expect(store.hasMissingMediaOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('removeMissingMediaByWidget removes matching node+widget entry', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
|
||||
@@ -68,6 +68,10 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
missingMediaCandidates.value = media.length ? media : null
|
||||
}
|
||||
|
||||
function hasMissingMediaOnNode(nodeLocatorId: string): boolean {
|
||||
return missingMediaNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isContainerWithMissingMedia(node: LGraphNode): boolean {
|
||||
return activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
}
|
||||
@@ -153,6 +157,7 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
|
||||
hasMissingMediaOnNode,
|
||||
isContainerWithMissingMedia
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
@@ -170,7 +169,7 @@ export function scanNodeModelCandidates(
|
||||
function scanAssetWidget(
|
||||
node: { type: string },
|
||||
widget: IAssetWidget,
|
||||
executionId: NodeExecutionId,
|
||||
executionId: string,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
const value = widget.value
|
||||
@@ -191,7 +190,7 @@ function scanAssetWidget(
|
||||
function scanComboWidget(
|
||||
node: { type: string },
|
||||
widget: IComboWidget,
|
||||
executionId: NodeExecutionId,
|
||||
executionId: string,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory: ((nodeType: string) => string | undefined) | undefined
|
||||
): MissingModelCandidate | null {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
const mockNodeLocatorIdToNodeExecutionId = vi.hoisted(() =>
|
||||
vi.fn((nodeLocatorId: string) => nodeLocatorId)
|
||||
)
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) => `translated:${key}`),
|
||||
st: vi.fn((_key: string, fallback: string) => fallback)
|
||||
@@ -18,12 +12,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
nodeLocatorIdToNodeExecutionId: mockNodeLocatorIdToNodeExecutionId
|
||||
})
|
||||
}))
|
||||
|
||||
import { useMissingModelStore } from './missingModelStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -51,9 +39,6 @@ describe('missingModelStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.restoreAllMocks()
|
||||
mockNodeLocatorIdToNodeExecutionId.mockImplementation(
|
||||
(nodeLocatorId: string) => nodeLocatorId
|
||||
)
|
||||
})
|
||||
|
||||
describe('setMissingModels', () => {
|
||||
@@ -161,9 +146,7 @@ describe('missingModelStore', () => {
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 5))).toBe(
|
||||
true
|
||||
)
|
||||
expect(store.hasMissingModelOnNode('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when node has no missing model', () => {
|
||||
@@ -172,30 +155,12 @@ describe('missingModelStore', () => {
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '5' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 99))).toBe(
|
||||
false
|
||||
)
|
||||
expect(store.hasMissingModelOnNode('99')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no models are missing', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.hasMissingModelOnNode(createNodeLocatorId(null, 1))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('compares subgraph locators against missing model execution IDs', () => {
|
||||
const store = useMissingModelStore()
|
||||
const locatorId = createNodeLocatorId(
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
63
|
||||
)
|
||||
mockNodeLocatorIdToNodeExecutionId.mockReturnValueOnce('65:70:63')
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '65:70:63' })
|
||||
])
|
||||
|
||||
expect(store.hasMissingModelOnNode(locatorId)).toBe(true)
|
||||
expect(store.hasMissingModelOnNode('1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
@@ -20,7 +19,6 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
|
||||
*/
|
||||
export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const missingModelCandidates = ref<MissingModelCandidate[] | null>(null)
|
||||
const isRefreshingMissingModels = ref(false)
|
||||
@@ -195,10 +193,8 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
missingModelCandidates.value = [...existing, ...newModels]
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: NodeLocatorId): boolean {
|
||||
const executionId =
|
||||
workflowStore.nodeLocatorIdToNodeExecutionId(nodeLocatorId)
|
||||
return executionId ? missingModelNodeIds.value.has(executionId) : false
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
|
||||
function isWidgetMissingModel(nodeId: string, widgetName: string): boolean {
|
||||
|
||||
@@ -53,7 +53,6 @@ import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
function mockNode(
|
||||
id: number,
|
||||
@@ -139,9 +138,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
|
||||
it('uses executionId when available for nodeId', () => {
|
||||
vi.mocked(collectAllNodes).mockReturnValue([mockNode(1, 'Missing')])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(
|
||||
createNodeExecutionId(['exec-42'])
|
||||
)
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue('exec-42')
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
size="md"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
:autofocus="activeCategoryKey !== 'keybinding'"
|
||||
autofocus
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useWorkflowDraftStoreV2 } from '@/platform/workflow/persistence/stores/
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
import {
|
||||
createMockCanvas,
|
||||
@@ -888,75 +887,81 @@ describe('useWorkflowStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('executionIdToCurrentId', () => {
|
||||
it('should convert an execution ID to the active subgraph node ID', () => {
|
||||
const result = store.executionIdToCurrentId('123:456')
|
||||
expect(result).toBe('456')
|
||||
describe('nodeExecutionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should return undefined for execution IDs outside the active subgraph', () => {
|
||||
expect(() => store.executionIdToCurrentId('999:456')).not.toThrow()
|
||||
expect(store.executionIdToCurrentId('999:456')).toBeUndefined()
|
||||
it('should return simple node ID for root level nodes', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return undefined for malformed execution IDs', () => {
|
||||
expect(() => store.executionIdToCurrentId('123::456')).not.toThrow()
|
||||
expect(store.executionIdToCurrentId('123::456')).toBeUndefined()
|
||||
it('should return null for invalid execution IDs', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeId', () => {
|
||||
it('should extract node ID from NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe(456)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'node_1')
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
|
||||
)
|
||||
expect(result).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId(null, 123)
|
||||
)
|
||||
const result = store.nodeLocatorIdToNodeId('123')
|
||||
expect(result).toBe(123)
|
||||
|
||||
const stringResult = store.nodeLocatorIdToNodeId(
|
||||
createNodeLocatorId(null, 'node_1')
|
||||
)
|
||||
const stringResult = store.nodeLocatorIdToNodeId('node_1')
|
||||
expect(stringResult).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
// Need to mock isSubgraph to identify our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
|
||||
return obj === store.activeSubgraph
|
||||
})
|
||||
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
createNodeLocatorId('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 456)
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe('123:456')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
createNodeLocatorId(null, 123)
|
||||
)
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for unknown subgraph UUID', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
createNodeLocatorId('unknown-uuid-1234-5678-90ab-cdef12345678', 456)
|
||||
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -83,9 +83,12 @@ interface WorkflowStore {
|
||||
executionIdToCurrentId: (id: string) => string | undefined
|
||||
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
|
||||
nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId) => NodeId
|
||||
nodeExecutionIdToNodeLocatorId: (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
) => NodeLocatorId | null
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
|
||||
nodeLocatorIdToNodeExecutionId: (
|
||||
locatorId: NodeLocatorId,
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
) => NodeExecutionId | null
|
||||
}
|
||||
@@ -577,16 +580,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[]
|
||||
): Subgraph[] | undefined => {
|
||||
const [currentPart, ...remainingParts] = subgraphNodeIds
|
||||
if (currentPart === undefined) return []
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) return
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
|
||||
const childSubgraphs = getSubgraphsFromInstanceIds(subgraph, remainingParts)
|
||||
return childSubgraphs ? [subgraph, ...childSubgraphs] : undefined
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
//FIXME: use existing util function
|
||||
@@ -600,17 +604,17 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const executionPath = parseNodeExecutionId(id)?.map(String)
|
||||
if (!executionPath) return
|
||||
// Parse the execution ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
const nodeId = executionPath.at(-1)
|
||||
if (nodeId === undefined) return
|
||||
// Start from the root graph
|
||||
const graph = comfyApp.rootGraph
|
||||
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.rootGraph,
|
||||
executionPath.slice(0, -1)
|
||||
)
|
||||
if (subgraphs?.at(-1) === subgraph) return nodeId
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
@@ -628,7 +632,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const targetSubgraph = subgraph ?? activeSubgraph.value
|
||||
if (!targetSubgraph) {
|
||||
// Node is in the root graph, return the node ID as-is
|
||||
return createNodeLocatorId(null, nodeId)
|
||||
return String(nodeId)
|
||||
}
|
||||
|
||||
return createNodeLocatorId(targetSubgraph.id, nodeId)
|
||||
@@ -642,16 +646,55 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => {
|
||||
if (isSubgraph(node.graph))
|
||||
return createNodeLocatorId(node.graph.id, node.id)
|
||||
return createNodeLocatorId(null, node.id)
|
||||
return String(node.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an execution ID to a NodeLocatorId
|
||||
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
|
||||
* @returns The NodeLocatorId or null if conversion fails
|
||||
*/
|
||||
const nodeExecutionIdToNodeLocatorId = (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
): NodeLocatorId | null => {
|
||||
// Handle simple node IDs (root graph - no colons)
|
||||
if (!nodeExecutionId.includes(':')) {
|
||||
return nodeExecutionId
|
||||
}
|
||||
|
||||
const parts = parseNodeExecutionId(nodeExecutionId)
|
||||
if (!parts || parts.length === 0) return null
|
||||
|
||||
const nodeId = parts[parts.length - 1]
|
||||
const subgraphNodeIds = parts.slice(0, -1)
|
||||
|
||||
if (subgraphNodeIds.length === 0) {
|
||||
// Node is in root graph, return the node ID as-is
|
||||
return String(nodeId)
|
||||
}
|
||||
|
||||
try {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.rootGraph,
|
||||
subgraphNodeIds.map((id) => String(id))
|
||||
)
|
||||
const immediateSubgraph = subgraphs[subgraphs.length - 1]
|
||||
return createNodeLocatorId(immediateSubgraph.id, nodeId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the node ID from a NodeLocatorId
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @returns The local node ID
|
||||
* @returns The local node ID or null if invalid
|
||||
*/
|
||||
const nodeLocatorIdToNodeId = (locatorId: NodeLocatorId): NodeId => {
|
||||
return parseNodeLocatorId(locatorId)!.localNodeId
|
||||
const nodeLocatorIdToNodeId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
): NodeId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
return parsed?.localNodeId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -661,7 +704,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
* @returns The execution ID or null if the node is not accessible from the target context
|
||||
*/
|
||||
const nodeLocatorIdToNodeExecutionId = (
|
||||
locatorId: NodeLocatorId,
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
): NodeExecutionId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
@@ -671,7 +714,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
// If no subgraph UUID, this is a root graph node
|
||||
if (!subgraphUuid) {
|
||||
return createNodeExecutionId([localNodeId])
|
||||
return String(localNodeId)
|
||||
}
|
||||
|
||||
// Find the path from root to the subgraph with this UUID
|
||||
@@ -708,7 +751,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
comfyApp.rootGraph,
|
||||
path.slice(0, idx + 1).map((id) => String(id))
|
||||
)
|
||||
return subgraphs?.at(-1) === targetSubgraph
|
||||
return subgraphs[subgraphs.length - 1] === targetSubgraph
|
||||
})
|
||||
) {
|
||||
return null
|
||||
@@ -752,6 +795,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
executionIdToCurrentId,
|
||||
nodeIdToNodeLocatorId,
|
||||
nodeToNodeLocatorId,
|
||||
nodeExecutionIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeId,
|
||||
nodeLocatorIdToNodeExecutionId
|
||||
}
|
||||
|
||||
@@ -49,17 +49,6 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
|
||||
const { mockIsCloud, mockTrackTemplate } = vi.hoisted(() => ({
|
||||
mockIsCloud: { value: true },
|
||||
mockTrackTemplate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () =>
|
||||
mockIsCloud.value ? { trackTemplate: mockTrackTemplate } : null
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
@@ -69,9 +58,6 @@ describe('useTemplateWorkflows', () => {
|
||||
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockTrackTemplate.mockClear()
|
||||
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
@@ -299,30 +285,6 @@ describe('useTemplateWorkflows', () => {
|
||||
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
|
||||
})
|
||||
|
||||
it('tracks template telemetry on load in cloud builds', async () => {
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
await loadWorkflowTemplate('template1', 'default')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackTemplate).toHaveBeenCalledWith({
|
||||
workflow_name: 'template1',
|
||||
template_source: 'default'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fire template telemetry in OSS builds', async () => {
|
||||
mockIsCloud.value = false
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
await loadWorkflowTemplate('template1', 'default')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockTrackTemplate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle errors when loading templates', async () => {
|
||||
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import type {
|
||||
@@ -131,10 +132,12 @@ export function useTemplateWorkflows() {
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
useTelemetry()?.trackTemplate({
|
||||
workflow_name: id,
|
||||
template_source: sourceModule
|
||||
})
|
||||
if (isCloud) {
|
||||
useTelemetry()?.trackTemplate({
|
||||
workflow_name: id,
|
||||
template_source: sourceModule
|
||||
})
|
||||
}
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user