Compare commits

...

33 Commits

Author SHA1 Message Date
snomiao
902a13e61f chore: configure rollup visualizer reports 2025-10-15 05:45:30 +00:00
Johnpaul Chiwetelu
fb3ab88f04 fix Cloudbadge (#6063)
This pull request refactors how top bar badges are handled in the
application, making badge rendering extensible and moving cloud badge
logic into the extension system. The main changes include replacing the
old `CloudBetaBadge` component with a new, generic badge system,
introducing a Pinia store for badge management, and updating the
extension API to support top bar badges.

**Badge System Refactor and Extensibility**

* Replaced the hardcoded `CloudBetaBadge` with a new `TopbarBadges`
component, which dynamically renders badges from the store instead of
relying on the `isCloud` flag in `TopMenubar.vue`.
[[1]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL9-R9)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL43-R48)

* Renamed and refactored `CloudBetaBadge.vue` to `TopbarBadge.vue`,
making it accept a generic `badge` prop and removing i18n logic from the
component.

* Added a new `TopbarBadges.vue` component to render all badges from the
`topbarBadgeStore`.

**Badge Data Management**

* Introduced a new Pinia store `topbarBadgeStore` that aggregates top
bar badges from all extensions, enabling dynamic badge management.

**Extension System Integration**

* Updated the extension API (`ComfyExtension` interface) to support a
new `topbarBadges` property, allowing extensions to contribute badges to
the top bar.

* Added a core extension (`cloudBadge.ts`) that registers a "Comfy
Cloud" beta badge when running in a cloud environment, using the new
badge system.
[[1]](diffhunk://#diff-b7818ca9daae2411d56695777160b8132507f2a3ff4f700d2510453c8833ca75R1-R16)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R2)

**Type Definitions**

* Added a new `TopbarBadge` type to `comfy.ts` to define the structure
for top bar badges, supporting optional labels.
2025-10-15 05:27:19 +01:00
Terry Jia
476d6df1ca fix mask editor bug under vueNodes (#5953)
## Summary

fix mask editor issues on vueNodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5953-fix-mask-editor-bug-under-vueNodes-2856d73d3650810aa8a2e1a94c4d97a6)
by [Unito](https://www.unito.io)
2025-10-14 17:42:36 -07:00
Johnpaul Chiwetelu
7caad10e93 Badge for cloud environment (#6048)
This pull request introduces a new system for displaying
environment-specific badges in the application's top bar, with a focus
on supporting a "Comfy Cloud" badge in production environments. The
changes include new badge types, extension support, UI components, and
environment detection logic to ensure badges are only shown in
appropriate contexts.

**Topbar Badge System**

* Added a new `TopbarBadge` type and support for topbar badges in the
`ComfyExtension` interface to allow extensions to specify badges for the
top bar.
[[1]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R24-R31)
[[2]](diffhunk://#diff-c29886a1b0c982c6fff3545af0ca8ec269876c2cf3948f867d08c14032c04d66R85-R88)
* Created a Pinia store `topbarBadgeStore` to aggregate topbar badges
from all registered extensions for display.

**UI Integration**

* Added a new `TopbarBadges.vue` component to render topbar badges and
integrated it into the top menu bar UI.
[[1]](diffhunk://#diff-6f460b1398fd033a2059daca1f991c74ce572cef86046a3726d1b1a70a3a4325R1-R32)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL5-R14)
* Updated CSS variables and menu styling to support the new badge
visuals.
[[1]](diffhunk://#diff-71b6b57a56095b04e47c797a5016149b76b27971cab04b93f033f1f846e0f5a0R88-R89)
[[2]](diffhunk://#diff-b7d7bf1028f09fb907c09edf27631214d005c93b80eaff7cf15cfd53671b1e8aL5-R14)

**Environment Detection and Extension Registration**

* Added a runtime environment detection utility to determine if the app
is running in production or staging, replacing the previous build-time
constant approach.
* Registered a new `cloudBadge` extension that conditionally adds a
"Comfy Cloud" badge with a "BETA" label when running in production.
[[1]](diffhunk://#diff-b7818ca9daae2411d56695777160b8132507f2a3ff4f700d2510453c8833ca75R1-R15)
[[2]](diffhunk://#diff-236993d9e4213efe96d267c75c3292d32b93aa4dd6c3318d26a397e0ae56bc87R2)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6048-Badge-for-cloud-environment-28c6d73d365081188050ece527c3c8f3)
by [Unito](https://www.unito.io)

<img width="996" height="897" alt="Screenshot 2025-10-14 at 20 02 40"
src="https://github.com/user-attachments/assets/5a3258c5-87fc-46ae-ad23-7669696cb8b6"
/>
2025-10-15 00:44:32 +01:00
Christian Byrne
6ea96f071e [style] update design of keybinding badges in menus (#6059)
## Summary

Updates the keybinding badges in the context menu (from selection
toolbox and right click on Vue node) to align with [the
design](https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=3128-104039&m=dev)
exactly, including using the tokens from Figma variables.


https://github.com/user-attachments/assets/e37492f7-81a8-4598-bebb-56eb86b5dc56

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6059-style-update-design-of-keybinding-badges-in-menus-28c6d73d3650817784c8d8afac9ed8b8)
by [Unito](https://www.unito.io)
2025-10-14 16:02:04 -07:00
Christian Byrne
10af2300fa rework minimap, toolbox, and menu designs with unified theming (#6038)
## Summary

This PR redesigns the graph canvas interface components including
minimap, toolbox, and menu systems with updated spacing, colors, and
interaction patterns - using the design tokens directly from Figma,
which can be used elsewhere going forward.

There are some other changes to the designs, outlined
[here](https://www.notion.so/comfy-org/Update-Minimap-Menu-v2-2886d73d365080e88e12f8df027019c0):

- [x]  Update/standardize the padding between viewport and toolbox
- [x] Update toolbox component’s style to match the other floating menus
style (border radius, height, padding and follow theme colors)
- [x]  Expose the minimap button
- [x]  Remove the focus button and delete it’s keybinding
- [x]  Group the hand and the default cursor buttons


https://github.com/user-attachments/assets/92542e60-c32d-4a21-a6f6-e72837a70b17

## Review Focus

New CSS variables for cross-component theming consistency and
CanvasModeSelector component extraction for improved code organization.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6038-rework-minimap-toolbox-and-menu-designs-with-unified-theming-28b6d73d36508191a0c6cf8036d965c4)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-14 14:26:07 -07:00
Makki Shizu
2058967761 Fix Simplified Chinese Translation (#6039)
## Summary
Revise some traditional Chinese to simplified Chinese.
<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6039-Fix-Simplified-Chinese-Translation-28b6d73d365081aebba5f60893b75cb3)
by [Unito](https://www.unito.io)
2025-10-14 13:33:55 -07:00
Christian Byrne
d1af7c8256 fix terminal style (#6056)
## Summary

After #5292, at certain browser zoom levels, the xterm terminal did not
fill horizontal space properly, showing a gap with mismatched background
colors (`#171717` viewport vs `black` terminal content).

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/6049

## Problem

The hardcoded `#171717` terminal theme background only affected the
xterm viewport, while terminal content remained black, causing a visible
color mismatch at low zoom levels where the gap was more apparent.

Fixed by keeping original theme when not on desktop distribution.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6056-fix-terminal-style-28c6d73d36508132b521e5767f41d540)
by [Unito](https://www.unito.io)
2025-10-14 13:26:35 -07:00
Christian Byrne
5bc7c8a5c2 add aria labels on vue node widgets (2/2) (#6037)
## Summary

Continuation of https://github.com/Comfy-Org/ComfyUI_frontend/pull/6032

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6037-add-aria-labels-on-vue-node-widgets-2-2-28b6d73d365081d68795f5dfaca0b89a)
by [Unito](https://www.unito.io)
2025-10-14 11:33:15 -07:00
Arjan Singh
094d6e65a2 Trigger release workflow for 1.29.2 (#6046)
Empty commit to trigger release workflow after [skip ci] blocked
automatic release.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6046-Trigger-release-workflow-for-1-29-2-28c6d73d365081119ef7d8bf570d2804)
by [Unito](https://www.unito.io)
2025-10-13 20:01:38 -07:00
Comfy Org PR Bot
2808e0a437 1.29.2 (#6045)
## What's Changed

### 🚀 Features
- Add MediaAssetCard presentation components (#5878)
- Make Vue nodes' outputs/previews responsively sized and work with node
resizing (#5970)
- Allow connection to subgraphIOs in vue mode (#6016)
- Add distribution detection pattern (#6028)
- Make nodeData.widgets reactive (#6019)

### 🐛 Bug Fixes
- Fix FLOAT widget incrementing broken & disabled state styles on widget
number input (Vue) (#6036)
- Fix Vue node border styles in different states (executing, error,
selected) (#6018)
- Fix Vue node opacity conditions (user node opacity, bypass state,
muted state) (#6022)
- Fix: emit layout change for batch node bounds (#5939)
- Safer restoration of widgets_values on subgraph nodes (#6015)
- Fix(execution): reset progress state after runs to unfreeze tab
title/favicon (main) (#6026)
- Use type check instead of cast (#6041)

### 🎨 Style & Design
- [style] match widget border/outline styles with designs (#6021)
- [style] make Vue widget/slot/label width and spacing align with
designs (#6023)

###  Accessibility
- Add aria labels on vue node widgets (#6032)

### 🔧 Maintenance
- [refactor] adjust Vue node fixtures to not be coupled to Litegraph
(#6033)
- [refactor] reorganize devtools test nodes into modules (#6020)

### 🧪 Testing
- [test] add browser test for control+a selection of Vue nodes (#6031)

### 🔄 CI/CD
- [ci] fix update locales workflow (#6017)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.29.1...v1.29.2

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6045-1-29-2-28c6d73d3650817a8c36fba944ce69a8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: arjansingh <1598641+arjansingh@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-13 19:15:14 -07:00
Christian Byrne
95c2732de4 fix ctrl+alt+click to remove link on Vue nodes (#6035)
## Summary

Implements Ctrl+Alt+click batch disconnect functionality for Vue node
output slots to match LiteGraph behavior.

## Changes

- **Feature**: Add Ctrl+Alt+click handler in `useSlotLinkInteraction.ts`
to disconnect all links from output slots
- **Test**: Add test case in `linkInteraction.spec.ts` to verify batch
disconnect behavior
- Follows existing pattern from input slot disconnect implementation

## Implementation Details

The implementation:
- Checks for Ctrl+Alt+click on output slots with existing links
- Calls `resolvedNode.disconnectOutput(index)` to batch disconnect all
links
- Marks canvas as dirty and prevents event propagation
- Matches LiteGraph canvas behavior (`LGraphCanvas.ts:2727-2731`)
- Follows same pattern as existing input slot disconnect (lines 591-611)

Note: Test currently uses `dispatchEvent` for pointerdown with modifiers
and is failing. The feature implementation is correct and matches the
existing codebase patterns, but the test interaction needs debugging.
2025-10-13 18:58:21 -07:00
AustinMroz
e59d2dd8df Use type check instead of cast (#6041)
Under some infrequent circumstances, `audioWidget.value` is not a
string. Presumably if a workflow is loaded with a saved file choice that
does not exist and the value is set to undefined instead.

Instead of a cast, a proper type guard is used and the widget is not
updated if the new value is not a string.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6041-Use-type-check-instead-of-cast-28b6d73d365081249353f4f905769f89)
by [Unito](https://www.unito.io)
2025-10-13 11:52:03 -07:00
Christian Byrne
d54923f766 fix FLOAT widget incrementing broken & disabled state styles on widget number input (Vue) (#6036)
## Summary

Align Vue node number widgets with Figma by centralising button styling
and surfacing disabled-state tokens in the design system.

## Changes

- **What**: Added shared
[`useNumberWidgetButtonPt`](src/renderer/extensions/vueNodes/widgets/composables/useNumberWidgetButtonPt.ts)
helper so both PrimeVue `InputNumber` widgets reuse the same Tailwind
token classes, and added the `color/tokens/alpha` values in
[`packages/design-system/src/css/style.css`](packages/design-system/src/css/style.css#L89-L212)
so semantic aliases remain token-driven ([PrimeVue passthrough
docs](https://www.primefaces.org/primevue/passthrough) w
[`color-mix`](https://www.w3.org/TR/css-color-5/#color-mix))

## Review Focus

Confirm hover/active/disabled colours match the design tokens in both
light and dark themes and that float precision still respects the
safe-range guard.

<img width="1377" height="1150" alt="Screenshot from 2025-10-12
17-53-23"
src="https://github.com/user-attachments/assets/c7d34870-5d07-4ce1-9272-7def7ae813b6"
/>

<img width="1377" height="1150" alt="Screenshot from 2025-10-12
17-53-32"
src="https://github.com/user-attachments/assets/86872ec8-979b-4586-879c-41a126a5f932"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6036-fix-disabled-state-styles-on-Vue-widget-number-input-INT-and-FLOAT-widgets-28b6d73d365081f8aef7fa860b641f7d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-12 23:34:08 -07:00
Christian Byrne
c30f528d11 [refactor] adjust Vue node fixtures to not be coupled to Litegraph (#6033)
## Summary

Changes the Vue node test fixture to not rely on Litegraph internal
objects (which should eventually be fully decoupled from Vue nodes) and
instead interact with nodes using black-box approach that emulates user
actions (preferred appraoch for e2e tests).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6033-refactor-adjust-Vue-node-fixtures-to-not-be-coupled-to-Litegraph-28a6d73d3650817b8152d27dc4fe0017)
by [Unito](https://www.unito.io)
2025-10-12 19:56:42 -07:00
Christian Byrne
0497421349 add aria labels on vue node widgets (#6032)
## Summary

Adds aria labels to buttons and widgets without pre-existing text
labels.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6032-add-aria-labels-on-vue-node-widgets-28a6d73d36508198a1c0ef7098ad24e8)
by [Unito](https://www.unito.io)
2025-10-12 17:44:03 -07:00
Christian Byrne
01b4ad0dbb [test] add browser test for control+a selection of Vue nodes (#6031)
## Summary

Adds test case ensuring you can select all Vue nodes with `Ctrl`+`a`,
mirroring coverage of similar behavior when using Litegraph nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6031-test-add-browser-test-for-control-a-selection-of-Vue-nodes-28a6d73d365081079860c3a083a946ef)
by [Unito](https://www.unito.io)
2025-10-12 17:01:45 -07:00
Christian Byrne
31c85387ba [style] match widget border/outline styles with designs (#6021)
## Summary

Use semantic color variables from
https://github.com/Comfy-Org/ComfyUI_frontend/pull/6018 on widget
borders to match
[design](https://www.figma.com/design/vALUV83vIdBzEsTJAhQgXq/Comfy-Design-System?node-id=2-5739&m=dev)

The layouting of the widgets doesn't align yet, but it's somewhat
annoying to change the `WidgetSelect` height without using line height.
But, the gap should be 4 (16px) instead of 2, the height of the rows
should be 35px instead of 30px and the widgets should be 32px instead of
30px.

## Before

<img width="2061" height="1386" alt="Screenshot from 2025-10-11
12-23-24"
src="https://github.com/user-attachments/assets/5aa7ba1e-9309-4bd5-95b4-8d8e3d95b50b"
/>

<img width="2061" height="1386" alt="Screenshot from 2025-10-11
12-23-16"
src="https://github.com/user-attachments/assets/9dbabd1b-2174-4dfd-83c2-fef8178c7206"
/>

## After

<img width="2061" height="1386" alt="Screenshot from 2025-10-11
12-23-06"
src="https://github.com/user-attachments/assets/d0b0a611-e65b-462f-ad94-c42639502951"
/>

<img width="2061" height="1386" alt="Screenshot from 2025-10-11
12-22-57"
src="https://github.com/user-attachments/assets/64fb42c8-3d9a-4a2b-956f-482fcd63b64c"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6021-style-match-widget-border-outline-styles-with-designs-2896d73d365081d18dd9cca41cc2b95e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-12 00:32:51 -07:00
AustinMroz
8108aaa2d4 Allow connection to subgraphIOs in vue mode (#6016)
Adds support for link connections from nodes to subgraphInputs and
subgraphOutputs when in vue mode.

![vue-subgraphio](https://github.com/user-attachments/assets/5b1ef66f-d45a-40c7-ace0-932aaf811e1d)

Resolves #5706

Known Issues
- Creating a connection from a widget does not trigger an update of the
widget to the disabled state

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6016-Allow-connection-to-subgraphIOs-in-vue-mode-2896d73d3650816cbd88f645dced87df)
by [Unito](https://www.unito.io)
2025-10-11 23:29:10 -07:00
Christian Byrne
9c245e9c23 Add distribution detection pattern (#6028)
## Summary

Establishes distribution-specific code pattern using compile-time
constants and dead code elimination. Demonstrates with Help Center by
hiding extension manager and update buttons in cloud distribution.

Below commentary makes assumption that minifcation and tree-shaking is
enabled (which isn't true yet, but will be eventually).

## Changes

- **What**: Added `src/platform/distribution/types.ts` with distribution
detection via `__DISTRIBUTION__` variable
- **Build**: Vite replaces `__DISTRIBUTION__` at build time using
environment variables
- **Tree-shaking**: All code not relevant to target distribution is
DCR'd and eliminated from bundle
- **Example**: Help Center hides "Manager Extension" menu item and
"Update" buttons in cloud builds

## Pattern

This PR defines a `__DISTRIBUTION__` variable which gets replaced at
build time by Vite using environment variables. All code not relevant to
the given distribution is then DCR'd and tree-shaken.

For simple cases (like this Help Center PR), import `isCloud` and use
compile-time conditionals:

```typescript
import { isCloud } from '@/platform/distribution/types'

if (!isCloud) {
  items.push({
    key: 'manager',
    action: async () => {
      await useManagerState().openManager({ ... })
    }
  })
}
```

The code is DCR'd at build time so there's zero runtime overhead - we
don't even incur the `if (isCloud)` cost because Terser eliminates it.

For complex services later, we'll add interfaces and use an index.ts
that exports different implementations under the same alias per
distribution. It will resemble a DI container but simpler since we don't
need runtime discovery like backend devs do. This guarantees types and
makes testing easier.

Example for services:
```typescript
// src/platform/storage/index.ts
import { isCloud } from '@/platform/distribution/types'

if (isCloud) {
  export { CloudStorage as StorageService } from './cloud'
} else {
  export { LocalStorage as StorageService } from './local'
}
```

Example for component variants:
```typescript
// src/components/downloads/index.ts
import { isCloud } from '@/platform/distribution/types'

if (isCloud) {
  export { default as DownloadButton } from './DownloadButton.cloud.vue'
} else {
  export { default as DownloadButton } from './DownloadButton.desktop.vue'
}
```

## Implementation Details

Distribution types (`src/platform/distribution/types.ts`):
```typescript
type Distribution = 'desktop' | 'localhost' | 'cloud'

declare global {
  const __DISTRIBUTION__: Distribution
}

const DISTRIBUTION: Distribution = __DISTRIBUTION__
export const isCloud = DISTRIBUTION === 'cloud'
```

Vite configuration adds the define:
```typescript
const DISTRIBUTION = (process.env.DISTRIBUTION || 'localhost') as
  | 'desktop'
  | 'localhost'
  | 'cloud'

export default defineConfig({
  define: {
    __DISTRIBUTION__: JSON.stringify(DISTRIBUTION)
  }
})
```

## Build Commands

```bash
pnpm build                      # localhost (default)
DISTRIBUTION=cloud pnpm build   # cloud
DISTRIBUTION=desktop pnpm build # desktop
```

## Future Applications

This pattern can be used with auth or telemetry services - which will
guarantee all the telemetry code, for example, is not even in the code
distributed in OSS Comfy whatsoever while still being able to develop
off `main`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6028-Add-distribution-detection-pattern-28a6d73d365081b08767d395472cd1bc)
by [Unito](https://www.unito.io)
2025-10-11 23:10:15 -07:00
AustinMroz
cb40da612b Safer restoration of widgets_values on subgraph nodes (#6015)
Reordering linked widgets requires special attention on load to restore
widgets_values. The method which was merged was optimistic and
insufficient for some rarer edge cases.

Resolves #6014 

Fix was already included in #6009. Backport is not required.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6015-Safer-restoration-of-widgets_values-on-subgraph-nodes-2896d73d3650813a9162e8459e686981)
by [Unito](https://www.unito.io)
2025-10-11 23:09:29 -07:00
Christian Byrne
ddb3a0bfc6 fix Vue node opacity conditions (user node opacity, bypass state, muted state) (#6022)
## Summary

Fixed Vue node opacity calculation to properly combine global opacity
setting with muted/bypassed state opacity.

**Root Cause**: When global opacity setting was added as inline style
(481aa8252), it began overriding CSS `opacity-50` classes due to higher
specificity.

**Solution**: Modified `nodeOpacity` computed property to calculate
effective opacity as `globalOpacity * 0.5` for muted/bypassed states,
removing conflicting CSS classes.

## Changes

- **What**: Fixed [CSS specificity
conflict](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity)
where inline `opacity` style overrode `opacity-50` classes for
muted/bypassed nodes
- **Breaking**: None - restores intended opacity behavior

## Review Focus

Multiplicative opacity calculation ensuring muted/bypassed nodes apply
0.5 opacity on top of global opacity setting rather than being
overridden by it.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6022-fix-Vue-node-opacity-conditions-user-node-opacity-bypass-state-muted-state-2896d73d365081c290f1da37c195c2f5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-11 21:36:50 -07:00
AustinMroz
5773df6ef7 Make nodeData.widgets reactive (#6019)
Makes the litegraph `node.widgets` array `shallowReactive` and makes the
`nodeData.widgets` a `reactiveComputed` derived from the litegraph
widget data.

![reactive-widgets](https://github.com/user-attachments/assets/8eb8d712-8586-4f34-b699-30fc3dc3340b)


Making changes to the structure of litegraph items is somewhat
dangerous, but code search verifies that there are no custom nodes using
`defineProperty` on `node.widgets`

This fixes display of promoted widgets on subgraph node and any custom
nodes that dynamically add or remove widgets.

TODO:
- Investigate occasional dropped widgets.
- Some of this was confusion with `canvasOnly` widgets and widgets not
implemented in vue. Will keep investigating, but I'm not terribly
concerned with actual test cases and it being an objective improvement.
  
Known Issue:
- Node does not grow/shrink to fit changed widgets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6019-Make-nodeData-widgets-reactive-2896d73d3650815691b6ee370a86a22c)
by [Unito](https://www.unito.io)
2025-10-11 20:32:15 -07:00
Christian Byrne
bc281b2513 [style] make Vue widget/slot/label width and spacing align with designs (#6023)
Make the widths and spacing of the widgets/slots/labels match the
[design](https://www.figma.com/design/31uH3r4x3xbIctuRWYW6NM/V3---Vue-Nodes?node-id=6489-33817&m=dev)
which also better matches the interal layout of litegraph nodes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6023-style-make-Vue-widget-slot-label-width-and-spacing-align-with-designs-2896d73d365081a1a831f396cb4eafc8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-11 20:08:05 -07:00
Benjamin Lu
1d06b4d63b fix(execution): reset progress state after runs to unfreeze tab title/favicon (main) (#6026)
Cherry picked over from #6025, should've been made to target main to
begin with

## Summary
Fixes the browser tab progress and favicon remaining at ~14% after
workflow completion on `main` by resetting execution state when a run
ends (success, error, or interruption).

## Changes
- Add `execution_success` listener in the execution store
- Centralize terminal-state cleanup in `resetExecutionState()`
- Clear `nodeProgressStates`, queued prompt entry,
`_executingNodeProgress`, and set `activePromptId` to `null`
- Ensures `isIdle` becomes `true` post-run so tab title and favicon no
longer freeze mid-progress

resolves #6024

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6026-fix-execution-reset-progress-state-after-runs-to-unfreeze-tab-title-favicon-main-28a6d73d365081f188ebc2e69d936dd9)
by [Unito](https://www.unito.io)
2025-10-11 18:53:58 -07:00
Christian Byrne
14c07fd734 [refactor] reorganize devtools test nodes into modules (#6020)
## Summary

Refactored monolithic devtools node definitions into organized module
structure for better maintainability and separation of concerns.

## Changes

- **What**: Split 700+ line `dev_nodes.py` into modular structure under
`tools/devtools/nodes/` with categorized files: `errors.py`,
`inputs.py`, `models.py`, `remote.py`
- **Dependencies**: None

## Review Focus

Module import structure and ensure all node registrations are properly
preserved in the consolidated mappings.

**Before:**
```
tools/devtools/
├── __init__.py
└── dev_nodes.py (738 lines)
```

**After:**
```
tools/devtools/
├── __init__.py
├── dev_nodes.py (65 lines - imports only)
└── nodes/
    ├── __init__.py (consolidated mappings)
    ├── errors.py (error/debug nodes)
    ├── inputs.py (input/widget nodes)
    ├── models.py (model/patch nodes)
    └── remote.py (remote/combo nodes)
```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6020-refactor-reorganize-devtools-test-nodes-into-modules-2896d73d365081e89efef7e88ca8fee3)
by [Unito](https://www.unito.io)
2025-10-11 15:29:29 -07:00
Christian Byrne
7cc08e8e35 [ci] fix update locales workflow (#6017)
Similar to https://github.com/Comfy-Org/ComfyUI_frontend/pull/6005,
fixing the update-locales workflow by setting up the frontend before
launching ComfyUI server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6017-ci-fix-update-locales-workflow-2896d73d36508173aaf9e0eefe4f7660)
by [Unito](https://www.unito.io)
2025-10-11 15:21:34 -07:00
Jin Yi
9c0b3c4f7d Add MediaAssetCard presentation components (#5878)
## Summary

Implements a comprehensive media asset card component system for the
Asset Manager sidebar, enabling display and interaction with various
media types (images, videos, audio, and 3D models).

## Changes

### New Components
- **MediaAssetCard**: Main card component for displaying media assets
- **Media type-specific components**: Specialized display logic for each
media type
  - MediaImageTop/Bottom
  - MediaVideoTop/Bottom  
  - MediaAudioTop/Bottom
  - Media3DTop/Bottom
- **MediaAssetActions**: Top-left action buttons (delete, download, more
options)
- **MediaAssetMoreMenu**: Dropdown menu for additional actions
- **SquareChip**: Chip component for displaying duration and file format
with dark/light variants
- **MediaAssetButtonDivider**: Visual separator for button groups

### Features
- **Video playback**: Autoplay with native video controls
  - Dynamic duration chip positioning based on control visibility
  - Hides overlays when video is playing
- **Audio playback**: Audio icon with HTML5 audio element
  - Duration chip with consistent positioning
- **3D model support**: Icon display for 3D assets
- **Selection state**: Proper hover and selected state handling with CSS
priority fixes

### Architecture Improvements
- **Domain-Driven Design structure**: Organized under
`src/platform/mediaAsset/` following DDD principles
- **Provide/Inject pattern**: Eliminates props drilling with
MediaAssetKey InjectionKey
- **Composable pattern**: `useMediaAssetActions` manages all action
handlers
- **Type safety**: Comprehensive TypeScript types for media assets and
actions

### UI/UX Enhancements
- **CardTop component**: Added custom class props for slot positioning
- **SquareChip component**: Backdrop blur effects with variant system
- **Lazy loading**: Image optimization with LazyImage component
- **Responsive states**: Loading, selected, and hover states

### Utilities
- **formatDuration**: Converts milliseconds to human-readable format
(45s, 1m 23s, 1h 2m)

## Testing
- Comprehensive Storybook stories for all media types
- Grid layout examples
- Loading and selected state demonstrations

## File Structure
```
src/platform/assets/
├── components/
│   ├── MediaAssetCard.vue
│   ├── MediaAssetCard.stories.ts
│   ├── MediaAssetActions.vue
│   ├── MediaAssetMoreMenu.vue
│   ├── MediaAssetButtonDivider.vue
│   ├── MediaImageTop.vue
│   ├── MediaImageBottom.vue
│   ├── MediaVideoTop.vue
│   ├── MediaVideoBottom.vue
│   ├── MediaAudioTop.vue
│   ├── MediaAudioBottom.vue
│   ├── Media3DTop.vue
│   └── Media3DBottom.vue
├── composables/
│   └── useMediaAssetActions.ts
└── schemas/
    └── mediaAssetSchema.ts
```

## Screenshots

[media_asset_record.webm](https://github.com/user-attachments/assets/d13b5cc0-a262-4850-bb81-ca1daa0dd969)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-11 14:39:04 -07:00
Christian Byrne
bb83b0107c fix Vue node border styles in different states (executing, error, selected) (#6018)
- Use exact tokens from Figma
- Fix issue in which node is stuck in `executing` state after it errors

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6018-fix-Vue-node-border-styles-in-different-states-executing-error-selected-2896d73d365081f39000fc3e42811f0d)
by [Unito](https://www.unito.io)
2025-10-11 12:20:06 -07:00
Christian Byrne
a0c02dfca6 make Vue nodes' outputs/previews responsively sized and work with node resizing (#5970)
## Summary

Added dedicated component for sampling previews and change all image
outputs (outputs, videos, previews) to be responsive and respond to node
resizing.



https://github.com/user-attachments/assets/7e683d32-4914-460c-ba08-4573c40aef24

## Changes

- **What**: Implemented `LivePreview` component for mid-execution
sampling visualization with responsive layout system
- **Dependencies**: Added resize handle composable and transform state
integration

## Review Focus

Node resize interaction conflicts with canvas dragging, and image
dimension calculation performance during rapid sampling updates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5970-make-Vue-nodes-outputs-previews-responsively-sized-and-work-with-node-resizing-2866d73d365081508d53e6e286a9a3fe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-10-10 21:52:24 -07:00
Christian Byrne
e6534f17e6 fix: emit layout change for batch node bounds (#5939)
## Summary

Fixes issue where node size changes are not serialized by routing
DOM-driven node bounds updates through a single CRDT operation so Vue
node geometry stays synchronized with LiteGraph.

## Changes

- **What**: Added `BatchUpdateBoundsOperation` to the layout store,
applied it via the existing Yjs pipeline, notified link sync to
recompute touched nodes, and covered the path with a regression test

## Review Focus

Correctness of the new batch operation when multiple nodes update
simultaneously, especially remote replay/undo scenarios and link
geometry recomputation.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5939-fix-emit-layout-change-for-batch-node-bounds-2846d73d365081db8f8cca5bf7b85308)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-10-10 20:47:12 -07:00
Comfy Org PR Bot
7e3c04399a 1.29.1 (#6012)
## What's Changed

### 🚀 Features
- Implement DOMWidget for vue (#6006)
- Implement drop-on-canvas + linkconnectoradapter consolidation (#5898)
- Vuenodes/audio widgets (#5627)
- Allow reordering of linked subgraph widgets (#5981)
- Contextmenu Monkeypatch Standardization (#5977)
- Fix/vue nodes snap to grid (#5973)
- Select Vue Nodes After Drag (#5863)
- fix Vue node widgets should be in disabled state if their slots are
connected with a link (#5834)

### 🐛 Bug Fixes
- [bugfix] Fix update-playwright-expectations workflow missing frontend
build (#6005)
- Fix: Reset size when collapsing (#6004)
- fix: misc LOD polish (#6001)
- Fix: Allow uncoloring Vue Nodes (#5991)
- [ci] Fix detached HEAD state in Playwright update workflow (#5985)
- Close zoom menu when toggling minimap visibility (#5974)

### 🔧 Maintenance
- Devex: Improve dev server (#6002)
- CI: Add concurrency checks to PR workflows (#6000)
- [feat] Auto-remove New Browser Test Expectations label after workflow
completes (#5998)
- CI: Simplify update playwright expectations (maybe) (#5994)
- Lint: Add tailwind linter (#5984)
- [feat] Auto-remove claude-review label after CI review completes
(#5983)
- Fix CI: Remove explicit repository parameter causing non-reproducible
test results (#5950)
- refactor: Reorganize GitHub Actions for better reusability (#5949)
- devex: Update CODEOWNERS (#5999)
- Docs: Update agent instructions about style classes (#5990)
- Style: Fix move cursors that should be grabs (#5989)
- Workflow templates review (#5975)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.29.0...v1.29.1

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6012-1-29-1-2896d73d365081b08418f46934651c41)
by [Unito](https://www.unito.io)

Co-authored-by: arjansingh <1598641+arjansingh@users.noreply.github.com>
2025-10-10 20:29:49 -07:00
AustinMroz
2599136296 Implement DOMWidget for vue (#6006)
![vue-dom-widget](https://github.com/user-attachments/assets/d0c0e5f6-bacb-4fd9-957e-4f19e8071c3d)

Did testing on about a dozen custom nodes. Most just work.
- Some custom nodes have copy/pasted the `addDOMWidget` call with types
like `customtext` and get converted to textareas -> Not feasible to fix
here. Can open PRs into custom nodes if complaints arise.
- Only the KJNodes spline editor had mouse issues -> Can
investigate/open PR into KJNodes later.
- Many nodes don't resize gracefully. Probably best handled in a future
PR.
- Some expect to be handled like textareas. These currently have minsize
and don't scale.
- Others, like VHS previews, scale self properly, but don't update
height inside a drag operation -> node height can be set to less than
fit.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6006-Implement-DOMWidget-for-vue-2886d73d3650817ca497c15d87d70f4f)
by [Unito](https://www.unito.io)
2025-10-10 14:11:38 -07:00
160 changed files with 4410 additions and 1528 deletions

View File

@@ -18,14 +18,14 @@ jobs:
uses: actions/checkout@v5
# Setup playwright environment
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup ComfyUI Frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup ComfyUI Server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright

View File

@@ -0,0 +1,5 @@
# Bundle Size Reports
This directory is the output target for the Rollup visualizer report generated during `pnpm build`. The report file is emitted as `index.html`, and it can be published from the `.pages` directory if you want to surface the bundle analysis (for example on GitHub Pages).
Files in this directory are generated; regenerate them by re-running the production build.

View File

@@ -1,6 +1,5 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { test as base, expect } from '@playwright/test'
import dotenv from 'dotenv'
import * as fs from 'fs'
@@ -130,7 +129,8 @@ export class ComfyPage {
// Buttons
public readonly resetViewButton: Locator
public readonly queueButton: Locator
public readonly queueButton: Locator // Run button in Legacy UI
public readonly runButton: Locator // Run button (renamed "Queue" -> "Run")
// Inputs
public readonly workflowUploadInput: Locator
@@ -165,6 +165,9 @@ export class ComfyPage {
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.runButton = page
.getByTestId('queue-button')
.getByRole('button', { name: 'Run' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.visibleToasts = page.locator('.p-toast-message:visible')
@@ -1086,12 +1089,6 @@ export class ComfyPage {
const targetPosition = await targetSlot.getPosition()
// Debug: Log the positions we're trying to use
console.log('Drag positions:', {
source: sourcePosition,
target: targetPosition
})
await this.dragAndDrop(sourcePosition, targetPosition)
await this.nextFrame()
}

View File

@@ -3,6 +3,8 @@
*/
import type { Locator, Page } from '@playwright/test'
import { VueNodeFixture } from './utils/vueNodeFixtures'
export class VueNodeHelpers {
constructor(private page: Page) {}
@@ -106,6 +108,24 @@ export class VueNodeHelpers {
await this.page.keyboard.press('Backspace')
}
/**
* Return a DOM-focused VueNodeFixture for the first node matching the title.
* Resolves the node id up front so subsequent interactions survive title changes.
*/
async getFixtureByTitle(title: string): Promise<VueNodeFixture> {
const node = this.getNodeByTitle(title).first()
await node.waitFor({ state: 'visible' })
const nodeId = await node.evaluate((el) => el.getAttribute('data-node-id'))
if (!nodeId) {
throw new Error(
`Vue node titled "${title}" is missing its data-node-id attribute`
)
}
return new VueNodeFixture(this.getNodeLocator(nodeId))
}
/**
* Wait for Vue nodes to be rendered
*/

View File

@@ -1,131 +1,66 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
/**
* VueNodeFixture provides Vue-specific testing utilities for interacting with
* Vue node components. It bridges the gap between litegraph node references
* and Vue UI components.
*/
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
constructor(
private readonly nodeRef: NodeReference,
private readonly page: Page
) {}
constructor(private readonly locator: Locator) {}
/**
* Get the node's header element using data-testid
*/
async getHeader(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
get header(): Locator {
return this.locator.locator('[data-testid^="node-header-"]')
}
/**
* Get the node's title element
*/
async getTitleElement(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-title"]')
get title(): Locator {
return this.locator.locator('[data-testid="node-title"]')
}
get titleInput(): Locator {
return this.locator.locator('[data-testid="node-title-input"]')
}
get body(): Locator {
return this.locator.locator('[data-testid^="node-body-"]')
}
get collapseButton(): Locator {
return this.locator.locator('[data-testid="node-collapse-button"]')
}
get collapseIcon(): Locator {
return this.collapseButton.locator('i')
}
get root(): Locator {
return this.locator
}
/**
* Get the current title text
*/
async getTitle(): Promise<string> {
const titleElement = await this.getTitleElement()
return (await titleElement.textContent()) || ''
return (await this.title.textContent()) ?? ''
}
/**
* Set a new title by double-clicking and entering text
*/
async setTitle(newTitle: string): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill(newTitle)
async setTitle(value: string): Promise<void> {
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.fill(value)
await input.press('Enter')
}
/**
* Cancel title editing
*/
async cancelTitleEdit(): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await this.header.dblclick()
const input = this.titleInput
await expect(input).toBeVisible()
await input.press('Escape')
}
/**
* Check if the title is currently being edited
*/
async isEditingTitle(): Promise<boolean> {
const header = await this.getHeader()
const input = header.locator('[data-testid="node-title-input"]')
return await input.isVisible()
}
/**
* Get the collapse/expand button
*/
async getCollapseButton(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-collapse-button"]')
}
/**
* Toggle the node's collapsed state
*/
async toggleCollapse(): Promise<void> {
const button = await this.getCollapseButton()
await button.click()
await this.collapseButton.click()
}
/**
* Get the collapse icon element
*/
async getCollapseIcon(): Promise<Locator> {
const button = await this.getCollapseButton()
return button.locator('i')
}
/**
* Get the collapse icon's CSS classes
*/
async getCollapseIconClass(): Promise<string> {
const icon = await this.getCollapseIcon()
return (await icon.getAttribute('class')) || ''
return (await this.collapseIcon.getAttribute('class')) ?? ''
}
/**
* Check if the collapse button is visible
*/
async isCollapseButtonVisible(): Promise<boolean> {
const button = await this.getCollapseButton()
return await button.isVisible()
}
/**
* Get the node's body/content element
*/
async getBody(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
}
/**
* Check if the node body is visible (not collapsed)
*/
async isBodyVisible(): Promise<boolean> {
const body = await this.getBody()
return await body.isVisible()
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -39,15 +39,15 @@ test.describe('Graph Canvas Menu', () => {
)
})
test('Focus mode button is clickable and has correct test id', async ({
test('Toggle minimap button is clickable and has correct test id', async ({
comfyPage
}) => {
const focusButton = comfyPage.page.getByTestId('focus-mode-button')
await expect(focusButton).toBeVisible()
await expect(focusButton).toBeEnabled()
const minimapButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapButton).toBeVisible()
await expect(minimapButton).toBeEnabled()
// Test that the button can be clicked without error
await focusButton.click()
await minimapButton.click()
await comfyPage.nextFrame()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -3,10 +3,10 @@ import { expect } from '@playwright/test'
import type { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
@@ -786,16 +786,8 @@ test.describe('Viewport settings', () => {
// Screenshot the canvas element
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await toggleButton.click()
// close zoom menu
await zoomControlsButton.click()
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.menu.topbar.saveWorkflow('Workflow A')

View File

@@ -35,12 +35,6 @@ test.describe('Minimap', () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(toggleButton).toBeVisible()
@@ -51,13 +45,6 @@ test.describe('Minimap', () => {
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
// Open zoom controls dropdown first
const zoomControlsButton = comfyPage.page.getByTestId(
'zoom-controls-button'
)
await zoomControlsButton.click()
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
await expect(minimapContainer).toBeVisible()
@@ -67,22 +54,10 @@ test.describe('Minimap', () => {
await expect(minimapContainer).not.toBeVisible()
// Open zoom controls dropdown again
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Show Minimap')
await toggleButton.click()
await comfyPage.nextFrame()
await expect(minimapContainer).toBeVisible()
// Open zoom controls dropdown again to verify button text
await zoomControlsButton.click()
await comfyPage.nextFrame()
await expect(toggleButton).toContainText('Hide Minimap')
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -60,7 +60,6 @@ async function getInputLinkDetails(
)
}
// Test helpers to reduce repetition across cases
function slotLocator(
page: Page,
nodeId: NodeId,
@@ -789,6 +788,45 @@ test.describe('Vue Node Link Interaction', () => {
})
})
test('should batch disconnect all links with ctrl+alt+click on slot', async ({
comfyPage
}) => {
const clipNode = (await comfyPage.getNodeRefsByType('CLIPTextEncode'))[0]
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(clipNode && samplerNode).toBeTruthy()
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 1 },
() => comfyPage.nextFrame()
)
await connectSlots(
comfyPage.page,
{ nodeId: clipNode.id, index: 0 },
{ nodeId: samplerNode.id, index: 2 },
() => comfyPage.nextFrame()
)
const clipOutput = await clipNode.getOutput(0)
expect(await clipOutput.getLinkCount()).toBe(2)
const clipOutputSlot = slotLocator(comfyPage.page, clipNode.id, 0, false)
await clipOutputSlot.dispatchEvent('pointerdown', {
button: 0,
buttons: 1,
ctrlKey: true,
altKey: true,
shiftKey: false,
bubbles: true,
cancelable: true
})
await comfyPage.nextFrame()
expect(await clipOutput.getLinkCount()).toBe(0)
})
test.describe('Release actions (Shift-drop)', () => {
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -2,70 +2,46 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
await expect(vueNode.header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await vueNode.title.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await vueNode.titleInput.fill('This Should Be Cancelled')
await vueNode.titleInput.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
await expect(await vueNode.getTitle()).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const loadCheckpointNode = comfyPage.vueNodes
.getNodeByTitle('Load Checkpoint')
.first()
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()

View File

@@ -50,15 +50,23 @@ test.describe('Vue Node Selection', () => {
})
}
test('should select all nodes with ctrl+a', async ({ comfyPage }) => {
const initialCount = await comfyPage.vueNodes.getNodeCount()
expect(initialCount).toBeGreaterThan(0)
await comfyPage.canvas.press('Control+a')
const selectedCount = await comfyPage.vueNodes.getSelectedNodeCount()
expect(selectedCount).toBe(initialCount)
})
test('should select pinned node without dragging', async ({ comfyPage }) => {
const PIN_HOTKEY = 'p'
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
// Select a node by clicking its title
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
// Pin it using the hotkey (as a user would)
await comfyPage.page.keyboard.press(PIN_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')

View File

@@ -20,6 +20,9 @@ test.describe('Vue Node Bypass', () => {
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Node Collapse', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -10,43 +9,50 @@ test.describe('Vue Node Collapse', () => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.vueNodes.waitForNodes()
})
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Initially should not be collapsed
expect(await node.isCollapsed()).toBe(false)
const body = await vueNode.getBody()
const body = vueNode.body
await expect(body).toBeVisible()
const expandedBoundingBox = await vueNode.boundingBox()
if (!expandedBoundingBox)
throw new Error('Failed to get node bounding box before collapse')
// Collapse the node
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.nextFrame()
// Verify node content is hidden
const collapsedSize = await node.getSize()
await expect(body).not.toBeVisible()
const collapsedBoundingBox = await vueNode.boundingBox()
if (!collapsedBoundingBox)
throw new Error('Failed to get node bounding box after collapse')
expect(collapsedBoundingBox.height).toBeLessThan(expandedBoundingBox.height)
// Expand again
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.nextFrame()
await expect(body).toBeVisible()
// Size should be restored
const expandedSize = await node.getSize()
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
const expandedBoundingBoxAfter = await vueNode.boundingBox()
if (!expandedBoundingBoxAfter)
throw new Error('Failed to get node bounding box after expand')
expect(expandedBoundingBoxAfter.height).toBeGreaterThanOrEqual(
collapsedBoundingBox.height
)
})
test('should show collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Check initial expanded state icon
let iconClass = await vueNode.getCollapseIconClass()
@@ -66,9 +72,8 @@ test.describe('Vue Node Collapse', () => {
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const vueNode = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(vueNode.root).toBeVisible()
// Set custom title
await vueNode.setTitle('Test Sampler')
@@ -83,7 +88,6 @@ test.describe('Vue Node Collapse', () => {
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Verify title is still displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('Test Sampler')
await expect(vueNode.header).toContainText('Test Sampler')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-error/
const ERROR_CLASS = /border-node-stroke-error/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -17,16 +17,21 @@ test.describe('Vue Node Error', () => {
await comfyPage.setup()
await comfyPage.loadWorkflow('missing/missing_nodes')
// Close missing nodes warning dialog
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
state: 'hidden'
})
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
test('should display error state when node causes execution error', async ({
comfyPage
}) => {
await comfyPage.setup()
await comfyPage.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -4,7 +4,7 @@ import {
} from '../../../fixtures/ComfyPage'
const MUTE_HOTKEY = 'Control+m'
const MUTE_CLASS = /opacity-50/
const MUTE_OPACITY = '0.5'
test.describe('Vue Node Mute', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -19,10 +19,11 @@ test.describe('Vue Node Mute', () => {
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
})
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
@@ -35,11 +36,11 @@ test.describe('Vue Node Mute', () => {
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
await expect(ksamplerNode).not.toHaveCSS('opacity', MUTE_OPACITY)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -0,0 +1,51 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Widget Reactivity', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets.push(node.widgets[0])
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets[2] = node.widgets[0]
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['4']
node.widgets.splice(0, 0, node.widgets[0])
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'
)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.pop()
})
await expect(loadCheckpointNode).toHaveCount(5)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.length--
})
await expect(loadCheckpointNode).toHaveCount(4)
await comfyPage.page.evaluate(() => {
const node = window['graph']._nodes_by_id['3']
node.widgets.splice(0, 1)
})
await expect(loadCheckpointNode).toHaveCount(3)
})
})

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.29.0",
"version": "1.29.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -88,6 +88,7 @@
"nx": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",

View File

@@ -24,11 +24,14 @@
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
/* Spacing */
--spacing-xs: 8px;
/* Font Families */
--font-inter: 'Inter', sans-serif;
/* Palette Colors */
--color-charcoal-100: #55565e;
--color-charcoal-100: #171718;
--color-charcoal-200: #494a50;
--color-charcoal-300: #3c3d42;
--color-charcoal-400: #313235;
@@ -85,10 +88,29 @@
--color-bypass: #6a246a;
--color-error: #962a2a;
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--text-xxxs: 0.5625rem;
--text-xxxs--line-height: calc(1 / 0.5625);
--color-blue-selection: rgb(from var(--color-blue-100) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-100) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-100) r g b/ 0.1);
--color-modal-tag: rgb(from var(--color-gray-400) r g b/ 0.4);
--color-alpha-charcoal-600-30: color-mix(
in srgb,
var(--color-charcoal-600) 30%,
transparent
);
--color-alpha-stone-100-20: color-mix(
in srgb,
var(--color-stone-100) 20%,
transparent
);
--color-alpha-gray-500-50: color-mix(
in srgb,
var(--color-gray-500) 50%,
transparent
);
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
@@ -129,9 +151,20 @@
/* --- */
--accent-primary: var(--color-charcoal-700);
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
--interface-menu-keybind-surface-default: var(--color-gray-500);
--interface-panel-surface: var(--color-pure-white);
--interface-stroke: var(--color-gray-300);
--nav-background: var(--color-pure-white);
--node-border: var(--color-gray-300);
--node-component-border: var(--color-gray-400);
--node-component-disabled: var(--color-alpha-stone-100-20);
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-stone-200);
@@ -143,7 +176,7 @@
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-stone-200);
--node-component-surface-highlight: var(--color-stone-100);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-hovered: var(--color-gray-200);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-white);
--node-component-tooltip: var(--color-charcoal-700);
@@ -154,13 +187,34 @@
from var(--color-zinc-500) r g b / 10%
);
--node-component-widget-skeleton-surface: var(--color-zinc-300);
--node-stroke: var(--color-stone-100);
--node-divider: var(--color-sand-100);
--node-icon-disabled: var(--color-alpha-gray-500-50);
--node-stroke: var(--color-gray-400);
--node-stroke-selected: var(--color-accent-primary);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-stone-100);
--text-primary: var(--color-charcoal-700);
--input-surface: rgba(0, 0, 0, 0.15);
}
.dark-theme {
--accent-primary: var(--color-pure-white);
--backdrop: var(--color-neutral-900);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
--interface-menu-keybind-surface-default: var(--color-charcoal-200);
--interface-panel-surface: var(--color-charcoal-100);
--interface-stroke: var(--color-charcoal-400);
--nav-background: var(--color-charcoal-100);
--node-border: var(--color-charcoal-500);
--node-component-border: var(--color-stone-200);
--node-component-border-error: var(--color-danger-100);
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-slate-300);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-outline: var(--color-white);
@@ -169,19 +223,37 @@
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-400);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-800);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
--node-component-widget-skeleton-surface: var(--color-zinc-800);
--node-stroke: var(--color-slate-100);
--node-component-disabled: var(--color-alpha-charcoal-600-30);
--node-divider: var(--color-charcoal-500);
--node-icon-disabled: var(--color-alpha-stone-100-20);
--node-stroke: var(--color-stone-200);
--node-stroke-selected: var(--color-pure-white);
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-blue-100);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-pure-white);
--input-surface: rgba(130, 130, 130, 0.1);
}
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-active-surface: var(--button-active-surface);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(--interface-menu-component-surface-hovered);
--color-interface-menu-component-surface-selected: var(--interface-menu-component-surface-selected);
--color-interface-menu-keybind-surface-default: var(--interface-menu-keybind-surface-default);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
--color-node-component-border: var(--node-component-border);
--color-node-component-executing: var(--node-component-executing);
--color-node-component-header: var(--node-component-header);
@@ -213,7 +285,16 @@
--color-node-component-widget-skeleton-surface: var(
--node-component-widget-skeleton-surface
);
--color-node-component-disabled: var(--node-component-disabled);
--color-node-divider: var(--node-divider);
--color-node-icon-disabled: var(--node-icon-disabled);
--color-node-stroke: var(--node-stroke);
--color-node-stroke-selected: var(--node-stroke-selected);
--color-node-stroke-error: var(--node-stroke-error);
--color-node-stroke-executing: var(--node-stroke-executing);
--color-text-secondary: var(--text-secondary);
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
}
@custom-variant dark-theme {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
* - filename: 'file'
* - suffix: 'txt'
*/
function getFilenameDetails(fullFilename: string) {
export function getFilenameDetails(fullFilename: string) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.split('.').slice(0, -1).join('.'),
@@ -451,3 +451,26 @@ export function stringToLocale(locale: string): SupportedLocale {
? (locale as SupportedLocale)
: 'en'
}
export function formatDuration(milliseconds: number): string {
if (!milliseconds || milliseconds < 0) return '0s'
const totalSeconds = Math.floor(milliseconds / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const remainingSeconds = Math.floor(totalSeconds % 60)
const parts: string[] = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0) {
parts.push(`${minutes}m`)
}
if (remainingSeconds > 0 || parts.length === 0) {
parts.push(`${remainingSeconds}s`)
}
return parts.join(' ')
}

View File

@@ -12,6 +12,7 @@ const iconGroupClasses = cn(
'outline-hidden border-none p-0 rounded-lg',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'transition-all duration-200',
'cursor-pointer'
)
</script>

View File

@@ -1,7 +1,8 @@
<template>
<div class="relative inline-flex items-center">
<IconButton @click="toggle">
<i class="icon-[lucide--more-vertical] text-sm" />
<IconButton :size="size" :type="type" @click="toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" />
</IconButton>
<Popover
@@ -13,6 +14,8 @@
:close-on-escape="true"
unstyled
:pt="pt"
@show="$emit('menuOpened')"
@hide="$emit('menuClosed')"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
@@ -25,12 +28,28 @@
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
import IconButton from './IconButton.vue'
interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean
}
const popover = ref<InstanceType<typeof Popover>>()
const {
size = 'md',
type = 'secondary',
isVertical = false
} = defineProps<MoreButtonProps>()
defineEmits<{
menuOpened: []
menuClosed: []
}>()
const toggle = (event: Event) => {
popover.value?.toggle(event)
}
@@ -45,7 +64,7 @@ const pt = computed(() => ({
},
content: {
class: cn(
'mt-2 rounded-lg',
'mt-1 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',

View File

@@ -11,7 +11,15 @@ import CardTop from './CardTop.vue'
interface CardStoryArgs {
// CardContainer props
containerRatio: 'square' | 'portrait' | 'tallPortrait'
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
variant: 'default' | 'ghost' | 'outline'
rounded: 'none' | 'sm' | 'lg' | 'xl'
customAspectRatio?: string
hasBorder: boolean
hasBackground: boolean
hasShadow: boolean
hasCursor: boolean
customClass: string
maxWidth: number
minWidth: number
@@ -44,10 +52,44 @@ interface CardStoryArgs {
const meta: Meta<CardStoryArgs> = {
title: 'Components/Card/Card',
argTypes: {
containerRatio: {
containerSize: {
control: 'select',
options: ['square', 'portrait', 'tallPortrait'],
description: 'Card container aspect ratio'
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
description: 'Card container size preset'
},
variant: {
control: 'select',
options: ['default', 'ghost', 'outline'],
description: 'Card visual variant'
},
rounded: {
control: 'select',
options: ['none', 'sm', 'lg', 'xl'],
description: 'Border radius size'
},
customAspectRatio: {
control: 'text',
description: 'Custom aspect ratio (e.g., "16/9")'
},
hasBorder: {
control: 'boolean',
description: 'Add border styling'
},
hasBackground: {
control: 'boolean',
description: 'Add background styling'
},
hasShadow: {
control: 'boolean',
description: 'Add shadow styling'
},
hasCursor: {
control: 'boolean',
description: 'Add cursor pointer'
},
customClass: {
control: 'text',
description: 'Additional custom CSS classes'
},
topRatio: {
control: 'select',
@@ -149,8 +191,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
template: `
<div class="min-h-screen">
<CardContainer
:ratio="args.containerRatio"
class="max-w-[320px] mx-auto"
:size="args.containerSize"
:variant="args.variant"
:rounded="args.rounded"
:custom-aspect-ratio="args.customAspectRatio"
:has-border="args.hasBorder"
:has-background="args.hasBackground"
:has-shadow="args.hasShadow"
:has-cursor="args.hasCursor"
:class="args.customClass || 'max-w-[320px] mx-auto'"
>
<template #top>
<CardTop :ratio="args.topRatio">
@@ -205,7 +254,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template #bottom>
<CardBottom class="p-3 bg-neutral-100">
<CardBottom>
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
</CardBottom>
@@ -218,7 +267,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
export const Default: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
containerSize: 'portrait',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -243,7 +300,15 @@ export const Default: Story = {
export const SquareCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
@@ -268,7 +333,15 @@ export const SquareCard: Story = {
export const TallPortraitCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
containerSize: 'tall',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: true,
showTopRight: true,
@@ -293,7 +366,15 @@ export const TallPortraitCard: Story = {
export const ImageCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'portrait',
containerSize: 'portrait',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: true,
@@ -314,10 +395,50 @@ export const ImageCard: Story = {
}
}
export const MiniCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'mini',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: false,
title: 'Mini Asset',
description: '',
backgroundColor: '#06b6d4',
showImage: false,
imageUrl: '',
tags: ['Asset'],
showFileSize: true,
fileSize: '124 KB',
showFileType: false,
fileType: ''
}
}
export const MinimalCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'square',
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
@@ -338,10 +459,209 @@ export const MinimalCard: Story = {
}
}
export const GhostVariant: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'compact',
variant: 'ghost',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: true,
title: 'Workflow Template',
description: 'Ghost variant for workflow templates',
backgroundColor: '#10b981',
showImage: false,
imageUrl: '',
tags: ['Template'],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const OutlineVariant: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'outline',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: true,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Outline Card',
description: 'Card with outline variant styling',
backgroundColor: '#f59e0b',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const CustomAspectRatio: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
customAspectRatio: '16/9',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'landscape',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: true,
showTitle: true,
showDescription: false,
title: 'Wide Format Card',
description: '',
backgroundColor: '#8b5cf6',
showImage: false,
imageUrl: '',
tags: ['Wide'],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const RoundedNone: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'none',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Sharp Corners',
description: 'Card with no border radius',
backgroundColor: '#dc2626',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const RoundedXL: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'xl',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Extra Rounded',
description: 'Card with extra large border radius',
backgroundColor: '#059669',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const NoStylesCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerSize: 'regular',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: false,
hasBackground: false,
hasShadow: false,
hasCursor: true,
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
topRatio: 'square',
showTopLeft: false,
showTopRight: false,
showBottomLeft: false,
showBottomRight: false,
showTitle: true,
showDescription: true,
title: 'Custom Styled Card',
description: 'Card with all default styles removed and custom gradient',
backgroundColor: 'transparent',
showImage: false,
imageUrl: '',
tags: [],
showFileSize: false,
fileSize: '',
showFileType: false,
fileType: ''
}
}
export const FullFeaturedCard: Story = {
render: (args: CardStoryArgs) => createCardTemplate(args),
args: {
containerRatio: 'tallPortrait',
containerSize: 'tall',
variant: 'default',
rounded: 'lg',
customAspectRatio: '',
hasBorder: true,
hasBackground: true,
hasShadow: true,
hasCursor: true,
customClass: '',
topRatio: 'square',
showTopLeft: true,
showTopRight: true,

View File

@@ -8,26 +8,78 @@
<script setup lang="ts">
import { computed } from 'vue'
const { ratio = 'square', type } = defineProps<{
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
type?: string
import { cn } from '@/utils/tailwindUtil'
const {
size = 'regular',
variant = 'default',
rounded = 'md',
customAspectRatio,
hasBorder = true,
hasBackground = true,
hasShadow = true,
hasCursor = true,
class: customClass = ''
} = defineProps<{
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
variant?: 'default' | 'ghost' | 'outline'
rounded?: 'none' | 'md' | 'lg' | 'xl'
customAspectRatio?: string
hasBorder?: boolean
hasBackground?: boolean
hasShadow?: boolean
hasCursor?: boolean
class?: string
}>()
// Base structure classes
const structureClasses = 'flex flex-col overflow-hidden'
// Rounded corners
const roundedClasses = {
none: 'rounded-none',
md: 'rounded',
lg: 'rounded-lg',
xl: 'rounded-xl'
} as const
const containerClasses = computed(() => {
const baseClasses =
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
if (type === 'workflow-template-card') {
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
// Variant styles
const variantClasses = {
default: cn(
hasBackground && 'bg-white dark-theme:bg-zinc-800',
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
hasShadow && 'shadow-sm',
hasCursor && 'cursor-pointer'
),
ghost: cn(
hasCursor && 'cursor-pointer',
'p-2 transition-colors duration-200'
),
outline: cn(
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
hasCursor && 'cursor-pointer',
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
)
}
const ratioClasses = {
smallSquare: 'aspect-240/311',
square: 'aspect-256/308',
portrait: 'aspect-256/325',
tallPortrait: 'aspect-256/353'
}
// Size/aspect ratio
const aspectRatio = customAspectRatio
? `aspect-[${customAspectRatio}]`
: {
mini: 'aspect-100/120',
compact: 'aspect-240/311',
regular: 'aspect-256/308',
portrait: 'aspect-256/325',
tall: 'aspect-256/353'
}[size]
return `${baseClasses} ${ratioClasses[ratio]}`
return cn(
structureClasses,
roundedClasses[rounded],
variantClasses[variant],
aspectRatio,
customClass
)
})
</script>

View File

@@ -2,31 +2,27 @@
<div :class="topStyle">
<slot class="absolute top-0 left-0 h-full w-full"></slot>
<div
v-if="slots['top-left']"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
>
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
<slot name="top-left"></slot>
</div>
<div
v-if="slots['top-right']"
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
>
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
<slot name="top-right"></slot>
</div>
<div
v-if="slots['bottom-left']"
class="absolute bottom-2 left-2 flex flex-wrap justify-start gap-2"
>
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
<slot name="center-left"></slot>
</div>
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
<slot name="center-right"></slot>
</div>
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
<slot name="bottom-left"></slot>
</div>
<div
v-if="slots['bottom-right']"
class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-2"
>
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
<slot name="bottom-right"></slot>
</div>
</div>
@@ -35,10 +31,26 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const slots = useSlots()
const { ratio = 'square' } = defineProps<{
const {
ratio = 'square',
topLeftClass,
topRightClass,
centerLeftClass,
centerRightClass,
bottomLeftClass,
bottomRightClass
} = defineProps<{
ratio?: 'square' | 'landscape'
topLeftClass?: string
topRightClass?: string
centerLeftClass?: string
centerRightClass?: string
bottomLeftClass?: string
bottomRightClass?: string
}>()
const topStyle = computed(() => {
@@ -51,4 +63,26 @@ const topStyle = computed(() => {
return `${baseClasses} ${ratioClasses[ratio]}`
})
// Get default classes for each slot position
const defaultSlotClasses = {
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
'center-left':
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
'center-right':
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
}
// Compute all slot classes once and cache them
const slotClasses = computed(() => ({
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
}))
</script>

View File

@@ -1,13 +1,28 @@
<template>
<div
class="inline-flex shrink-0 items-center justify-center gap-1 rounded bg-[#D9D9D966]/40 px-2 py-1 text-xs font-bold text-white/90"
>
<slot name="icon" class="text-xs text-white/90"></slot>
<div :class="chipClasses">
<slot name="icon"></slot>
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
const { label } = defineProps<{
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
label: string
variant?: 'dark' | 'light'
}>()
const baseClasses =
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
}
const chipClasses = computed(() => {
return cn(baseClasses, variantStyles[variant])
})
</script>

View File

@@ -2,6 +2,7 @@
<div
ref="containerRef"
class="relative flex h-full w-full items-center justify-center overflow-hidden"
:class="containerClass"
>
<Skeleton
v-if="!isImageLoaded"
@@ -41,17 +42,20 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
import type { ClassValue } from '@/utils/tailwindUtil'
const {
src,
alt = '',
containerClass = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
containerClass?: ClassValue
imageClass?: ClassValue
imageStyle?: Record<string, any>
rootMargin?: string
}>()

View File

@@ -141,8 +141,10 @@
<CardContainer
v-for="n in isLoading ? 12 : 0"
:key="`initial-skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
size="compact"
variant="ghost"
rounded="lg"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
>
<template #top>
<CardTop ratio="landscape">
@@ -172,9 +174,11 @@
v-for="template in isLoading ? [] : displayTemplates"
:key="template.name"
ref="cardRefs"
ratio="smallSquare"
type="workflow-template-card"
size="compact"
variant="ghost"
rounded="lg"
:data-testid="`template-workflow-${template.name}`"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
@mouseenter="hoveredTemplate = template.name"
@mouseleave="hoveredTemplate = null"
@click="onLoadWorkflow(template)"
@@ -316,8 +320,10 @@
<CardContainer
v-for="n in isLoadingMore ? 6 : 0"
:key="`skeleton-${n}`"
ratio="smallSquare"
type="workflow-template-card"
size="compact"
variant="ghost"
rounded="lg"
class="hover:bg-white dark-theme:hover:bg-zinc-800"
>
<template #top>
<CardTop ratio="square">

View File

@@ -0,0 +1,126 @@
<template>
<Button
ref="buttonRef"
severity="secondary"
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
:style="buttonStyles"
@click="toggle"
>
<template #default>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
>
<i :class="currentModeIcon" class="block h-4 w-4" />
</div>
<i class="icon-[lucide--chevron-down] block h-4 w-4 pr-1.5" />
</div>
</template>
</Button>
<Popover
ref="popover"
:auto-z-index="true"
:base-z-index="1000"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="popoverPt"
>
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('select')"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--mouse-pointer-2] h-4 w-4" />
<span>{{ $t('graphCanvasMenu.select') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{
unlockCommandText
}}</span>
</div>
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="setMode('hand')"
>
<div class="flex items-center gap-2">
<i class="icon-[lucide--hand] h-4 w-4" />
<span>{{ $t('graphCanvasMenu.hand') }}</span>
</div>
<span class="text-[9px] text-text-primary">{{ lockCommandText }}</span>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
interface Props {
buttonStyles?: Record<string, string>
}
defineProps<Props>()
const buttonRef = ref<InstanceType<typeof Button>>()
const popover = ref<InstanceType<typeof Popover>>()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
const currentModeIcon = computed(() =>
isCanvasReadOnly.value
? 'icon-[lucide--hand]'
: 'icon-[lucide--mouse-pointer-2]'
)
const unlockCommandText = computed(() =>
commandStore
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Unlock'))
.toUpperCase()
)
const lockCommandText = computed(() =>
commandStore
.formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock'))
.toUpperCase()
)
const toggle = (event: Event) => {
const el = (buttonRef.value as any)?.$el || buttonRef.value
popover.value?.toggle(event, el)
}
const setMode = (mode: 'select' | 'hand') => {
if (mode === 'select' && isCanvasReadOnly.value) {
void commandStore.execute('Comfy.Canvas.Unlock')
} else if (mode === 'hand' && !isCanvasReadOnly.value) {
void commandStore.execute('Comfy.Canvas.Lock')
}
popover.value?.hide()
}
const popoverPt = computed(() => ({
root: {
class: 'absolute z-50 -translate-y-2'
},
content: {
class: [
'mb-2 text-text-primary',
'shadow-lg border border-node-border',
'bg-nav-background',
'rounded-lg',
'p-2 px-3',
'min-w-39',
'select-none'
]
}
}))
</script>

View File

@@ -10,41 +10,15 @@
></div>
<ButtonGroup
class="p-buttongroup-vertical absolute right-2 bottom-4 p-1 md:right-4"
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>
<Button
v-tooltip.top="selectTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
severity="secondary"
:aria-label="selectTooltip"
:pressed="isCanvasReadOnly"
icon="i-material-symbols:pan-tool-outline"
:class="selectButtonClass"
@click="() => commandStore.execute('Comfy.Canvas.Unlock')"
>
<template #icon>
<i class="icon-[lucide--mouse-pointer-2]" />
</template>
</Button>
<CanvasModeSelector
:button-styles="stringifiedMinimapStyles.buttonStyles"
/>
<Button
v-tooltip.top="handTooltip"
severity="secondary"
:aria-label="handTooltip"
:pressed="isCanvasUnlocked"
:class="handButtonClass"
:style="stringifiedMinimapStyles.buttonStyles"
@click="() => commandStore.execute('Comfy.Canvas.Lock')"
>
<template #icon>
<i class="icon-[lucide--hand]" />
</template>
</Button>
<!-- vertical line with bg E1DED5 -->
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
<Button
v-tooltip.top="fitViewTooltip"
@@ -52,11 +26,11 @@
icon="pi pi-expand"
:aria-label="fitViewTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
class="hover:bg-[#E7E6E6]! dark-theme:hover:bg-[#444444]!"
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<template #icon>
<i class="icon-[lucide--focus]" />
<i class="icon-[lucide--focus] h-4 w-4" />
</template>
</Button>
@@ -71,26 +45,26 @@
:style="stringifiedMinimapStyles.buttonStyles"
@click="toggleModal"
>
<span class="inline-flex text-xs">
<span class="inline-flex items-center gap-1 px-2 text-xs">
<span>{{ canvasStore.appScalePercentage }}%</span>
<i class="icon-[lucide--chevron-down]" />
<i class="icon-[lucide--chevron-down] h-4 w-4" />
</span>
</Button>
<div class="mx-2 my-1 w-px bg-[#E1DED5] dark-theme:bg-[#2E3037]" />
<div class="h-[27px] w-[1px] self-center bg-node-divider" />
<Button
ref="focusButton"
v-tooltip.top="focusModeTooltip"
ref="minimapButton"
v-tooltip.top="minimapTooltip"
severity="secondary"
:aria-label="focusModeTooltip"
data-testid="focus-mode-button"
:aria-label="minimapTooltip"
data-testid="toggle-minimap-button"
:style="stringifiedMinimapStyles.buttonStyles"
:class="focusButtonClass"
@click="() => commandStore.execute('Workspace.ToggleFocusMode')"
:class="minimapButtonClass"
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
>
<template #icon>
<i class="icon-[lucide--lightbulb]" />
<i class="icon-[lucide--map] h-4 w-4" />
</template>
</Button>
@@ -111,7 +85,7 @@
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
>
<template #icon>
<i class="icon-[lucide--route-off]" />
<i class="icon-[lucide--route-off] h-4 w-4" />
</template>
</Button>
</ButtonGroup>
@@ -131,8 +105,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import CanvasModeSelector from './CanvasModeSelector.vue'
import ZoomControlsModal from './modals/ZoomControlsModal.vue'
const { t } = useI18n()
@@ -141,21 +115,16 @@ const { formatKeySequence } = useCommandStore()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
const canvasInteractions = useCanvasInteractions()
const workspaceStore = useWorkspaceStore()
const minimap = useMinimap()
const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
const buttonGroupKeys = ['backgroundColor', 'borderRadius', '']
const buttonKeys = ['backgroundColor', 'borderRadius']
const buttonGroupKeys = ['borderRadius']
const buttonKeys = ['borderRadius']
const additionalButtonStyles = {
border: 'none',
width: '35px',
height: '35px',
'margin-right': '2px',
'margin-left': '2px'
border: 'none'
}
const containerStyles = minimap.containerStyles.value
@@ -176,72 +145,56 @@ const stringifiedMinimapStyles = computed(() => {
})
// Computed properties for reactive states
const isCanvasReadOnly = computed(() => canvasStore.canvas?.read_only ?? false)
const isCanvasUnlocked = computed(() => !isCanvasReadOnly.value)
const linkHidden = computed(
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
)
// Computed properties for command text
const unlockCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.Unlock')
).toUpperCase()
)
const lockCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.Lock')).toUpperCase()
)
const fitViewCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Comfy.Canvas.FitView')
).toUpperCase()
)
const focusCommandText = computed(() =>
const minimapCommandText = computed(() =>
formatKeySequence(
commandStore.getCommand('Workspace.ToggleFocusMode')
commandStore.getCommand('Comfy.Canvas.ToggleMinimap')
).toUpperCase()
)
// Computed properties for button classes and states
const selectButtonClass = computed(() =>
isCanvasUnlocked.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: ''
)
const handButtonClass = computed(() =>
isCanvasReadOnly.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: ''
)
const zoomButtonClass = computed(() => [
'w-16!',
isModalVisible.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: '',
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
'bg-interface-panel-surface',
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'h-8',
'w-15'
])
const focusButtonClass = computed(() => ({
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!': true,
'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!':
workspaceStore.focusMode
const minimapButtonClass = computed(() => ({
'bg-interface-panel-surface': true,
'hover:bg-button-hover-surface!': true,
'not-active:bg-button-active-surface!': settingStore.get(
'Comfy.Minimap.Visible'
),
'p-0': true,
'w-8': true,
'h-8': true
}))
// Computed properties for tooltip and aria-label texts
const selectTooltip = computed(
() => `${t('graphCanvasMenu.select')} (${unlockCommandText.value})`
)
const handTooltip = computed(
() => `${t('graphCanvasMenu.hand')} (${lockCommandText.value})`
)
const fitViewTooltip = computed(
() => `${t('graphCanvasMenu.fitView')} (${fitViewCommandText.value})`
)
const focusModeTooltip = computed(
() => `${t('graphCanvasMenu.focusMode')} (${focusCommandText.value})`
)
const fitViewTooltip = computed(() => {
const label = t('graphCanvasMenu.fitView')
const shortcut = fitViewCommandText.value
return shortcut ? `${label} (${shortcut})` : label
})
const minimapTooltip = computed(() => {
const label = settingStore.get('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
const shortcut = minimapCommandText.value
return shortcut ? `${label} (${shortcut})` : label
})
const linkVisibilityTooltip = computed(() =>
linkHidden.value
? t('graphCanvasMenu.showLinks')
@@ -253,10 +206,12 @@ const linkVisibilityAriaLabel = computed(() =>
: t('graphCanvasMenu.hideLinks')
)
const linkVisibleClass = computed(() => [
linkHidden.value
? 'not-active:dark-theme:bg-[#262729]! not-active:bg-[#E7E6E6]!'
: '',
'dark-theme:hover:bg-[#262729]! hover:bg-[#E7E6E6]!'
'bg-interface-panel-surface',
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'w-8',
'h-8'
])
onMounted(() => {
@@ -267,19 +222,3 @@ onBeforeUnmount(() => {
canvasStore.cleanupScaleSync()
})
</script>
<style scoped>
.p-buttongroup-vertical {
display: flex;
flex-direction: row;
z-index: 1200;
border-radius: var(--p-button-border-radius);
overflow: hidden;
border: 1px solid var(--p-panel-border-color);
}
.p-buttongroup-vertical .p-button {
margin: 0;
border-radius: 0;
}
</style>

View File

@@ -7,11 +7,10 @@
<Transition name="slide-up">
<Panel
v-if="visible"
class="selection-toolbox pointer-events-auto rounded-lg"
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
class="selection-toolbox pointer-events-auto rounded-lg border border-interface-stroke bg-interface-panel-surface"
:pt="{
header: 'hidden',
content: 'p-1 h-10 flex flex-row gap-1'
content: 'p-2 h-12 flex flex-row gap-1'
}"
@wheel="canvasInteractions.forwardEventToCanvas"
>
@@ -65,7 +64,6 @@ import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionTo
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
@@ -78,8 +76,6 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
const toolboxRef = ref<HTMLElement | undefined>()
const { visible } = useSelectionToolboxPosition(toolboxRef)

View File

@@ -1,118 +1,51 @@
<template>
<div
v-if="visible"
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit! md:right-11"
class="absolute right-2 bottom-[66px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
>
<div
class="w-4/5 rounded-lg border border-gray-200 bg-white p-4 shadow-lg dark-theme:border-gray-700 dark-theme:bg-[#2b2b2b]"
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
:style="filteredMinimapStyles"
@click.stop
>
<div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div class="flex flex-col gap-1">
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@mousedown="startRepeat('Comfy.Canvas.ZoomIn')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('graphCanvasMenu.zoomIn')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomInCommandText
}}</span>
</template>
</Button>
<span class="font-medium">{{ $t('graphCanvasMenu.zoomIn') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomInCommandText
}}</span>
</div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@mousedown="startRepeat('Comfy.Canvas.ZoomOut')"
@mouseup="stopRepeat"
@mouseleave="stopRepeat"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('graphCanvasMenu.zoomOut')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomOutCommandText
}}</span>
</template>
</Button>
<span class="font-medium">{{ $t('graphCanvasMenu.zoomOut') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomOutCommandText
}}</span>
</div>
<Button
severity="secondary"
text
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
<div
class="flex cursor-pointer items-center justify-between rounded px-3 py-2 text-sm hover:bg-node-component-surface-hovered"
@click="executeCommand('Comfy.Canvas.FitView')"
>
<template #default>
<span class="block text-sm font-medium">{{
$t('zoomControls.zoomToFit')
}}</span>
<span class="block text-sm text-gray-500">{{
zoomToFitCommandText
}}</span>
</template>
</Button>
<hr class="mb-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
<Button
severity="secondary"
text
data-testid="toggle-minimap-button"
:pt="{
root: {
class:
'flex items-center justify-between cursor-pointer p-2 rounded w-full text-left hover:bg-transparent! focus:bg-transparent! active:bg-transparent!'
},
label: {
class: 'flex flex-col items-start w-full'
}
}"
@click="executeCommand('Comfy.Canvas.ToggleMinimap')"
>
<template #default>
<span class="block text-sm font-medium">{{
minimapToggleText
}}</span>
<span class="block text-sm text-gray-500">{{
showMinimapCommandText
}}</span>
</template>
</Button>
<hr class="mt-1 border-[#E1DED5] dark-theme:border-[#2E3037]" />
<span class="font-medium">{{ $t('zoomControls.zoomToFit') }}</span>
<span class="text-[9px] text-text-primary">{{
zoomToFitCommandText
}}</span>
</div>
<div
ref="zoomInputContainer"
class="zoomInputContainer flex items-center rounded bg-[#E7E6E6] p-2 px-2 focus-within:bg-[#F3F3F3] dark-theme:bg-[#8282821A]"
class="zoomInputContainer flex items-center gap-1 rounded bg-input-surface p-2"
>
<InputNumber
ref="zoomInput"
@@ -122,12 +55,12 @@
:show-buttons="false"
:use-grouping="false"
:unstyled="true"
input-class="flex-1 bg-transparent border-none outline-hidden text-sm shadow-none my-0 "
input-class="bg-transparent border-none outline-hidden text-sm shadow-none my-0 w-full"
fluid
@input="applyZoom"
@keyup.enter="applyZoom"
/>
<span class="-ml-4 text-sm text-gray-500">%</span>
<span class="flex-shrink-0 text-sm text-text-primary">%</span>
</div>
</div>
</div>
@@ -136,18 +69,14 @@
<script setup lang="ts">
import type { InputNumberInputEvent } from 'primevue'
import { Button, InputNumber } from 'primevue'
import { InputNumber } from 'primevue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const minimap = useMinimap()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const { formatKeySequence } = useCommandStore()
@@ -158,19 +87,8 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const interval = ref<number | null>(null)
// Computed properties for reactive states
const minimapToggleText = computed(() =>
settingStore.get('Comfy.Minimap.Visible')
? t('zoomControls.hideMinimap')
: t('zoomControls.showMinimap')
)
const applyZoom = (val: InputNumberInputEvent) => {
const inputValue = val.value as number
if (isNaN(inputValue) || inputValue < 1 || inputValue > 1000) {
@@ -181,9 +99,6 @@ const applyZoom = (val: InputNumberInputEvent) => {
const executeCommand = (command: string) => {
void commandStore.execute(command)
if (command === 'Comfy.Canvas.ToggleMinimap') {
emit('close')
}
}
const startRepeat = (command: string) => {
@@ -215,9 +130,6 @@ const zoomOutCommandText = computed(() =>
const zoomToFitCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.FitView'))
)
const showMinimapCommandText = computed(() =>
formatKeySequence(commandStore.getCommand('Comfy.Canvas.ToggleMinimap'))
)
const zoomInput = ref<InstanceType<typeof InputNumber> | null>(null)
const zoomInputContainer = ref<HTMLDivElement | null>(null)
@@ -236,10 +148,6 @@ watch(
</script>
<style>
.zoomInputContainer:focus-within {
border: 1px solid rgb(204 204 204);
}
.dark-theme .zoomInputContainer:focus-within {
border: 1px solid rgb(204 204 204);
border: 1px solid var(--color-pure-white);
}
</style>

View File

@@ -6,12 +6,15 @@
<div
v-else
role="button"
class="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm hover:bg-gray-100 dark-theme:hover:bg-zinc-700"
class="group flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-left text-sm text-text-primary hover:bg-interface-menu-component-surface-hovered"
@click="handleClick"
>
<i v-if="option.icon" :class="[option.icon, 'h-4 w-4']" />
<span class="flex-1">{{ option.label }}</span>
<span v-if="option.shortcut" class="text-xs opacity-60">
<span
v-if="option.shortcut"
class="flex h-3.5 min-w-3.5 items-center justify-center rounded bg-interface-menu-keybind-surface-default px-1 py-0 text-xxs"
>
{{ option.shortcut }}
</span>
<i

View File

@@ -28,7 +28,6 @@
:key="`submenu-${option.label}`"
:ref="(el) => setSubmenuRef(`submenu-${option.label}`, el)"
:option="option"
:container-styles="containerStyles"
@submenu-click="handleSubmenuClick"
/>
</div>
@@ -55,7 +54,6 @@ import type {
} from '@/composables/graph/useMoreOptionsMenu'
import { useSubmenuPositioning } from '@/composables/graph/useSubmenuPositioning'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import MenuOptionItem from './MenuOptionItem.vue'
import SubmenuPopover from './SubmenuPopover.vue'
@@ -75,8 +73,6 @@ const currentSubmenu = ref<string | null>(null)
const { menuOptions, menuOptionsWithSubmenu, bump } = useMoreOptionsMenu()
const { toggleSubmenu, hideAllSubmenus } = useSubmenuPositioning()
const canvasInteractions = useCanvasInteractions()
const minimap = useMinimap()
const containerStyles = minimap.containerStyles
let lastLogTs = 0
const LOG_INTERVAL = 120 // ms
@@ -264,11 +260,9 @@ const pt = computed(() => ({
content: {
class: [
'mt-2 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: containerStyles.value.backgroundColor
}
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))

View File

@@ -56,13 +56,6 @@ import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
interface Props {
option: MenuOption
containerStyles: {
width: string
height: string
backgroundColor: string
border: string
borderRadius: string
}
}
interface Emits {
@@ -117,11 +110,9 @@ const submenuPt = computed(() => ({
content: {
class: [
'text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700'
],
style: {
backgroundColor: props.containerStyles.backgroundColor
}
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))
</script>

View File

@@ -135,6 +135,7 @@ import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
@@ -265,7 +266,7 @@ const moreMenuItem = computed(() =>
)
const menuItems = computed<MenuItem[]>(() => {
return [
const items: MenuItem[] = [
{
key: 'docs',
type: 'item',
@@ -305,8 +306,12 @@ const menuItems = computed<MenuItem[]>(() => {
void commandStore.execute('Comfy.ContactSupport')
emit('close')
}
},
{
}
]
// Extension manager - only in non-cloud distributions
if (!isCloud) {
items.push({
key: 'manager',
type: 'item',
icon: PuzzleIcon,
@@ -319,17 +324,20 @@ const menuItems = computed<MenuItem[]>(() => {
})
emit('close')
}
},
{
key: 'more',
type: 'item',
icon: '',
label: t('helpCenter.more'),
visible: hasVisibleMoreItems.value,
action: () => {}, // No action for more item
items: moreItems.value
}
]
})
}
items.push({
key: 'more',
type: 'item',
icon: '',
label: t('helpCenter.more'),
visible: hasVisibleMoreItems.value,
action: () => {}, // No action for more item
items: moreItems.value
})
return items
})
// Utility Functions
@@ -420,6 +428,9 @@ const formatReleaseDate = (dateString?: string): string => {
}
const shouldShowUpdateButton = (release: ReleaseNote): boolean => {
// Hide update buttons in cloud distribution
if (isCloud) return false
return (
releaseStore.shouldShowUpdateButton &&
release === releaseStore.recentReleases[0]

View File

@@ -2,15 +2,16 @@
<div>
<div
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
class="z-1001 flex h-[38px] w-full content-end"
class="z-1001 flex h-9.5 w-full content-end"
style="background: var(--border-color)"
>
<WorkflowTabs />
<TopbarBadges />
</div>
<div
v-show="showTopMenu"
ref="topMenuRef"
class="comfyui-menu flex items-center"
class="comfyui-menu flex items-center bg-gray-100"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<CommandMenubar />
@@ -44,9 +45,10 @@ import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
import TopbarBadges from './TopbarBadges.vue'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const showTopMenu = computed(

View File

@@ -0,0 +1,20 @@
<template>
<div class="flex items-center gap-2 bg-comfy-menu-secondary px-3">
<div
v-if="badge.label"
class="rounded-full bg-white px-1.5 py-0.5 text-xxxs font-semibold text-black"
>
{{ badge.label }}
</div>
<div class="font-inter text-sm font-extrabold text-slate-100">
{{ badge.text }}
</div>
</div>
</template>
<script setup lang="ts">
import type { TopbarBadge } from '@/types/comfy'
defineProps<{
badge: TopbarBadge
}>()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex">
<TopbarBadge
v-for="badge in topbarBadgeStore.badges"
:key="badge.text"
:badge
/>
</div>
</template>
<script lang="ts" setup>
import { useTopbarBadgeStore } from '@/stores/topbarBadgeStore'
import TopbarBadge from './TopbarBadge.vue'
const topbarBadgeStore = useTopbarBadgeStore()
</script>

View File

@@ -88,7 +88,7 @@
<template #content>
<!-- Card Examples -->
<div :style="gridStyle">
<CardContainer v-for="i in 100" :key="i" ratio="square">
<CardContainer v-for="i in 100" :key="i" size="regular">
<template #top>
<CardTop ratio="landscape">
<template #default>

View File

@@ -5,12 +5,14 @@ import { debounce } from 'es-toolkit/compat'
import type { Ref } from 'vue'
import { markRaw, onMounted, onUnmounted } from 'vue'
import { isDesktop } from '@/platform/distribution/types'
export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon()
const terminal = markRaw(
new Terminal({
convertEol: true,
theme: { background: '#171717' }
theme: isDesktop ? { background: '#171717' } : undefined
})
)
terminal.loadAddon(fitAddon)

View File

@@ -2,16 +2,19 @@
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactive } from 'vue'
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
@@ -38,6 +41,7 @@ export interface SafeWidgetData {
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
}
export interface VueNodeData {
@@ -130,43 +134,57 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
})
const safeWidgets = node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
Object.defineProperty(node, 'widgets', {
get() {
return reactiveWidgets
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(
() =>
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
}
}) ?? []
)
const nodeType =
node.type ||
node.constructor?.comfyClass ||

View File

@@ -162,12 +162,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
},
{
combo: {
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',

View File

@@ -115,11 +115,14 @@ const onConfigure = function (
this.arrange()
}
})
if (serialisedNode.properties?.proxyWidgets)
if (serialisedNode.properties?.proxyWidgets) {
this.properties.proxyWidgets = serialisedNode.properties.proxyWidgets
serialisedNode.widgets_values?.forEach((v, index) => {
if (v !== null) this.widgets[index].value = v
})
const parsed = parseProxyWidgets(serialisedNode.properties.proxyWidgets)
serialisedNode.widgets_values?.forEach((v, index) => {
const widget = this.widgets.find((w) => w.name == parsed[index][1])
if (v !== null && widget) widget.value = v
})
}
}
function newProxyWidget(

View File

@@ -0,0 +1,16 @@
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
// Only show badge when running in cloud environment
topbarBadges: isCloud
? [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
: undefined
})

View File

@@ -1,3 +1,5 @@
import { isCloud } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'
@@ -21,3 +23,7 @@ import './uploadAudio'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
if (isCloud) {
import('./cloudBadge')
}

View File

@@ -178,8 +178,9 @@ app.registerExtension({
audioUIWidget.options.canvasOnly = true
const onAudioWidgetUpdate = () => {
if (typeof audioWidget.value !== 'string') return
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value as string))
getResourceURL(...splitFilePath(audioWidget.value))
)
}
// Initially load default audio file to audioUIWidget.

View File

@@ -9,8 +9,19 @@
"downloadImage": "Download image",
"downloadVideo": "Download video",
"editOrMaskImage": "Edit or mask image",
"editImage": "Edit image",
"deleteImage": "Delete image",
"deleteAudioFile": "Delete audio file",
"removeImage": "Remove image",
"removeVideo": "Remove video",
"chart": "Chart",
"chartLowercase": "chart",
"file": "file",
"selectedFile": "Selected file",
"none": "None",
"markdown": "markdown",
"content": "content",
"audioProgress": "Audio progress",
"viewImageOfTotal": "View image {index} of {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
@@ -31,6 +42,7 @@
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"liveSamplingPreview": "Live sampling preview",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
"insert": "Insert",
@@ -192,7 +204,8 @@
"volume": "Volume",
"halfSpeed": "0.5x",
"1x": "1x",
"2x": "2x"
"2x": "2x",
"beta": "BETA"
},
"manager": {
"title": "Custom Nodes Manager",

View File

@@ -832,6 +832,11 @@
"guidance": {
"name": "guidance"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CLIPTextEncodeHiDream": {
@@ -871,6 +876,11 @@
"mt5xl": {
"name": "mt5xl"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CLIPTextEncodeLumina2": {
@@ -937,6 +947,11 @@
"empty_padding": {
"name": "empty_padding"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"CLIPTextEncodeSDXL": {
@@ -1475,10 +1490,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -1974,6 +1991,11 @@
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyHunyuanLatentVideo": {
@@ -1991,6 +2013,11 @@
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"EmptyImage": {
@@ -2113,6 +2140,11 @@
"batch_size": {
"name": "batch_size"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"Epsilon Scaling": {
@@ -2200,6 +2232,11 @@
"conditioning": {
"name": "conditioning"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"FluxGuidance": {
@@ -2211,6 +2248,11 @@
"guidance": {
"name": "guidance"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"FluxKontextImageScale": {
@@ -2220,6 +2262,11 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"FluxKontextMaxImageNode": {
@@ -2272,6 +2319,11 @@
"reference_latents_method": {
"name": "reference_latents_method"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"FluxKontextProImageNode": {
@@ -2629,6 +2681,10 @@
"name": "files",
"tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node."
},
"aspect_ratio": {
"name": "aspect_ratio",
"tooltip": "Defaults to matching the output image size to that of your input image, or otherwise generates 1:1 squares."
},
"control_after_generate": {
"name": "control after generate"
}
@@ -2870,10 +2926,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "latent"
"name": "latent",
"tooltip": null
}
}
},
@@ -2895,13 +2953,16 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
},
"2": {
"name": "latent"
"name": "latent",
"tooltip": null
}
}
},
@@ -3478,6 +3539,11 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"ImageYUVToRGB": {
@@ -3582,6 +3648,11 @@
"alpha": {
"name": "alpha"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"KarrasScheduler": {
@@ -4209,6 +4280,11 @@
"samples2": {
"name": "samples2"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentApplyOperation": {
@@ -4220,6 +4296,11 @@
"operation": {
"name": "operation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentApplyOperationCFG": {
@@ -4231,6 +4312,11 @@
"operation": {
"name": "operation"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentBatch": {
@@ -4242,6 +4328,11 @@
"samples2": {
"name": "samples2"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentBatchSeedBehavior": {
@@ -4253,6 +4344,11 @@
"seed_behavior": {
"name": "seed_behavior"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentBlend": {
@@ -4324,6 +4420,11 @@
"dim": {
"name": "dim"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentCrop": {
@@ -4361,6 +4462,11 @@
"amount": {
"name": "amount"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentFlip": {
@@ -4400,6 +4506,11 @@
"ratio": {
"name": "ratio"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentMultiply": {
@@ -4411,6 +4522,11 @@
"multiplier": {
"name": "multiplier"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentOperationSharpen": {
@@ -4425,6 +4541,11 @@
"alpha": {
"name": "alpha"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentOperationTonemapReinhard": {
@@ -4433,6 +4554,11 @@
"multiplier": {
"name": "multiplier"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentRotate": {
@@ -4455,6 +4581,11 @@
"samples2": {
"name": "samples2"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LatentUpscale": {
@@ -7701,6 +7832,10 @@
"name": "video",
"tooltip": "The reference video used to generate the output video. Must be at least 5 seconds long. Videos longer than 5s will be automatically trimmed. Only MP4 format supported."
},
"steps": {
"name": "steps",
"tooltip": "Number of inference steps"
},
"control_type": {
"name": "control_type"
},
@@ -8106,6 +8241,11 @@
"upscale_method": {
"name": "upscale_method"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"PerpNeg": {
@@ -8407,7 +8547,7 @@
},
"mask": {
"name": "mask",
"tooltip": "Use the mask to define areas in the video to replace"
"tooltip": "Use the mask to define areas in the video to replace."
},
"prompt_text": {
"name": "prompt_text"
@@ -8418,6 +8558,10 @@
"seed": {
"name": "seed"
},
"region_to_modify": {
"name": "region_to_modify",
"tooltip": "Plaintext description of the object / region to modify."
},
"control_after_generate": {
"name": "control after generate"
}
@@ -8635,6 +8779,14 @@
"mode": {
"name": "mode"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"Preview3D": {
@@ -10248,6 +10400,11 @@
"rescaling_scale": {
"name": "rescaling_scale"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SkipLayerGuidanceDiTSimple": {
@@ -10269,6 +10426,11 @@
"end_percent": {
"name": "end_percent"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SkipLayerGuidanceSD3": {
@@ -10290,6 +10452,11 @@
"end_percent": {
"name": "end_percent"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SolidMask": {
@@ -10329,6 +10496,14 @@
"image": {
"name": "image"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"tooltip": null
}
}
},
"SplitSigmas": {
@@ -11152,6 +11327,11 @@
"name": "image_interleave",
"tooltip": "How much the image influences things vs the text prompt. Higher number means more influence from the text prompt."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextEncodeQwenImageEdit": {
@@ -11379,6 +11559,11 @@
"clip_name3": {
"name": "clip_name3"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TripoConversionNode": {
@@ -11760,6 +11945,11 @@
"model_name": {
"name": "model_name"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"USOStyleReference": {

View File

@@ -171,10 +171,10 @@
"label": "自定义节点(测试版)"
},
"Comfy_Manager_CustomNodesManager_ShowLegacyCustomNodesMenu": {
"label": "自訂節點(舊版)"
"label": "自定义节点(旧版)"
},
"Comfy_Manager_ShowLegacyManagerMenu": {
"label": "管理員選單(舊版)"
"label": "管理员菜单(旧版)"
},
"Comfy_Manager_ShowMissingPacks": {
"label": "安装缺失的包"
@@ -279,13 +279,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切基本下方面板"
"label": "切基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切換檢視控制底部面板"
"label": "切换检视控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷鍵對話框"
"label": "示快捷键对话框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('3D Model')
}}</span>
</div>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,50 @@
<template>
<IconGroup>
<IconButton size="sm" @click="handleDelete">
<i class="icon-[lucide--trash-2] size-4" />
</IconButton>
<IconButton size="sm" @click="handleDownload">
<i class="icon-[lucide--download] size-4" />
</IconButton>
<MoreButton
size="sm"
@menu-opened="emit('menuStateChanged', true)"
@menu-closed="emit('menuStateChanged', false)"
>
<template #default="{ close }">
<MediaAssetMoreMenu :close="close" />
</template>
</MoreButton>
</IconGroup>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
const emit = defineEmits<{
menuStateChanged: [isOpen: boolean]
}>()
const { asset } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
}
}
</script>

View File

@@ -0,0 +1,4 @@
<template>
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
</template>
<script setup lang="ts"></script>

View File

@@ -0,0 +1,318 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'AssetLibrary/MediaAssetCard',
component: MediaAssetCard,
argTypes: {
context: {
control: 'select',
options: ['input', 'output']
},
loading: {
control: 'boolean'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Public sample media URLs
const SAMPLE_MEDIA = {
image1: 'https://i.imgur.com/OB0y6MR.jpg',
image2: 'https://i.imgur.com/CzXTtJV.jpg',
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
video:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
videoThumbnail:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg',
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
const sampleAsset: AssetMeta = {
id: 'asset-1',
name: 'sample-image.png',
kind: 'image',
duration: 3345,
size: 2048576,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: {
width: 1920,
height: 1080
},
tags: []
}
export const ImageAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'output', outputCount: 3 },
asset: sampleAsset,
loading: false
}
}
export const VideoAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-2',
name: 'Big_Buck_Bunny.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video file
dimensions: {
width: 1280,
height: 720
}
}
}
}
export const Model3DAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023
}
}
}
export const AudioAsset: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
id: 'asset-3',
name: 'SoundHelix-Song.mp3',
kind: 'audio',
size: 5242880,
src: SAMPLE_MEDIA.audio,
dimensions: undefined,
duration: 23180
}
}
}
export const LoadingState: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: sampleAsset,
loading: true
}
}
export const LongFileName: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
...sampleAsset,
name: 'very-long-file-name-that-should-be-truncated-in-the-ui-to-prevent-overflow.png'
}
}
}
export const SelectedState: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'output', outputCount: 2 },
asset: sampleAsset,
selected: true
}
}
export const WebMVideo: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-webm',
name: 'animated-clip.webm',
kind: 'video',
size: 3145728,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.image1, // Poster image
src: 'https://www.w3schools.com/html/movie.mp4', // Actual video
duration: 620,
dimensions: {
width: 640,
height: 360
},
tags: []
}
}
}
export const GifAnimation: Story = {
decorators: [
() => ({
template: '<div style="max-width: 280px;"><story /></div>'
})
],
args: {
context: { type: 'input' },
asset: {
id: 'asset-gif',
name: 'animation.gif',
kind: 'image',
size: 1572864,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/3o7aCTPPm4OHfRLSH6/giphy.gif',
dimensions: {
width: 480,
height: 270
},
tags: []
}
}
}
export const GridLayout: Story = {
render: () => ({
components: { MediaAssetCard },
setup() {
const assets: AssetMeta[] = [
{
id: 'grid-1',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image1,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-2',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image2,
dimensions: { width: 1920, height: 1080 },
tags: []
},
{
id: 'grid-3',
name: 'video-file.mp4',
kind: 'video',
size: 10485760,
duration: 13425,
created_at: Date.now().toString(),
preview_url: SAMPLE_MEDIA.videoThumbnail, // Poster image
src: SAMPLE_MEDIA.video, // Actual video
dimensions: { width: 1280, height: 720 },
tags: []
},
{
id: 'grid-4',
name: 'audio-file.mp3',
kind: 'audio',
size: 5242880,
duration: 180,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.audio,
tags: []
},
{
id: 'grid-5',
name: 'animation.gif',
kind: 'image',
size: 3145728,
duration: 1345,
created_at: Date.now().toString(),
src: 'https://media.giphy.com/media/l0HlNaQ6gWfllcjDO/giphy.gif',
dimensions: { width: 480, height: 360 },
tags: []
},
{
id: 'grid-6',
name: 'Asset-3d-model.glb',
kind: '3D',
size: 7340032,
src: '',
dimensions: undefined,
duration: 18023,
created_at: Date.now().toString(),
tags: []
},
{
id: 'grid-7',
name: 'image-file.jpg',
kind: 'image',
size: 2097152,
duration: 4500,
created_at: Date.now().toString(),
src: SAMPLE_MEDIA.image3,
dimensions: { width: 1920, height: 1080 },
tags: []
}
]
return { assets }
},
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; padding: 16px;">
<MediaAssetCard
v-for="asset in assets"
:key="asset.id"
:context="{ type: Math.random() > 0.5 ? 'input' : 'output', outputCount: Math.floor(Math.random() * 5) }"
:asset="asset"
/>
</div>
`
})
}

View File

@@ -0,0 +1,233 @@
<template>
<CardContainer
ref="cardContainerRef"
role="button"
:aria-label="
asset ? `${asset.name} - ${asset.kind} asset` : 'Loading asset'
"
:tabindex="loading ? -1 : 0"
size="mini"
variant="ghost"
rounded="lg"
:class="containerClasses"
@click="handleCardClick"
@keydown.enter="handleCardClick"
@keydown.space.prevent="handleCardClick"
>
<template #top>
<CardTop
ratio="square"
:bottom-left-class="durationChipClasses"
:bottom-right-class="durationChipClasses"
>
<!-- Loading State -->
<template v-if="loading">
<div
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
/>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<component
:is="getTopComponent(asset.kind)"
:asset="asset"
:context="context"
@view="actions.viewAsset(asset!.id)"
@download="actions.downloadAsset(asset!.id)"
@play="actions.playAsset(asset!.id)"
@video-playing-state-changed="isVideoPlaying = $event"
@video-controls-changed="showVideoControls = $event"
/>
</template>
<!-- Actions overlay (top-left) - show on hover or when menu is open, but not when video is playing -->
<template v-if="showActionsOverlay" #top-left>
<MediaAssetActions @menu-state-changed="isMenuOpen = $event" />
</template>
<!-- Zoom button (top-right) - show on hover, but not when video is playing -->
<template v-if="showZoomOverlay" #top-right>
<IconButton size="sm" @click="actions.viewAsset(asset!.id)">
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
</template>
<!-- Duration/Format chips (bottom-left) - hide when video is playing -->
<template v-if="showDurationChips" #bottom-left>
<SquareChip variant="light" :label="formattedDuration" />
<SquareChip v-if="fileFormat" variant="light" :label="fileFormat" />
</template>
<!-- Output count (bottom-right) - hide when video is playing -->
<template v-if="showOutputCount" #bottom-right>
<IconTextButton
type="secondary"
size="sm"
:label="context?.outputCount?.toString() ?? '0'"
@click="actions.openMoreOutputs(asset?.id || '')"
>
<template #icon>
<i class="icon-[lucide--layers] size-4" />
</template>
</IconTextButton>
</template>
</CardTop>
</template>
<template #bottom>
<CardBottom>
<!-- Loading State -->
<template v-if="loading">
<div class="flex flex-col items-center justify-between gap-1">
<div
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
/>
</div>
</template>
<!-- Content based on asset type -->
<template v-else-if="asset">
<component
:is="getBottomComponent(asset.kind)"
:asset="asset"
:context="context"
/>
</template>
</CardBottom>
</template>
</CardContainer>
</template>
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import { formatDuration } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type {
AssetContext,
AssetMeta,
MediaKind
} from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetActions from './MediaAssetActions.vue'
const mediaComponents = {
top: {
video: defineAsyncComponent(() => import('./MediaVideoTop.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioTop.vue')),
image: defineAsyncComponent(() => import('./MediaImageTop.vue')),
'3D': defineAsyncComponent(() => import('./Media3DTop.vue'))
},
bottom: {
video: defineAsyncComponent(() => import('./MediaVideoBottom.vue')),
audio: defineAsyncComponent(() => import('./MediaAudioBottom.vue')),
image: defineAsyncComponent(() => import('./MediaImageBottom.vue')),
'3D': defineAsyncComponent(() => import('./Media3DBottom.vue'))
}
}
function getTopComponent(kind: MediaKind) {
return mediaComponents.top[kind] || mediaComponents.top.image
}
function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const { context, asset, loading, selected } = defineProps<{
context: AssetContext
asset?: AssetMeta
loading?: boolean
selected?: boolean
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
provide(MediaAssetKey, {
asset: toRef(() => asset),
context: toRef(() => context),
isVideoPlaying,
showVideoControls
})
const containerClasses = computed(() => {
return cn(
'gap-1',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
)
})
const formattedDuration = computed(() => {
if (!asset?.duration) return ''
return formatDuration(asset.duration)
})
const fileFormat = computed(() => {
if (!asset?.name) return ''
const parts = asset.name.split('.')
return parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ''
})
const durationChipClasses = computed(() => {
if (asset?.kind === 'audio') {
return '-translate-y-11'
}
if (asset?.kind === 'video' && showVideoControls.value) {
return '-translate-y-16'
}
return ''
})
const showHoverActions = computed(() => {
return !loading && !!asset && (isHovered.value || isMenuOpen.value)
})
const showZoomButton = computed(() => {
return asset?.kind === 'image' || asset?.kind === '3D'
})
const showActionsOverlay = computed(() => {
return showHoverActions.value && !isVideoPlaying.value
})
const showZoomOverlay = computed(() => {
return showHoverActions.value && showZoomButton.value && !isVideoPlaying.value
})
const showDurationChips = computed(() => {
return !loading && asset?.duration && !isVideoPlaying.value
})
const showOutputCount = computed(() => {
return !loading && context?.outputCount && !isVideoPlaying.value
})
const handleCardClick = () => {
if (asset) {
actions.selectAsset(asset)
}
}
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div class="flex flex-col">
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Inspect asset"
@click="handleInspect"
>
<template #icon>
<i class="icon-[lucide--zoom-in] size-4" />
</template>
</IconTextButton>
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Add to current workflow"
@click="handleAddToWorkflow"
>
<template #icon>
<i class="icon-[comfy--node] size-4" />
</template>
</IconTextButton>
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Download"
@click="handleDownload"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Open as workflow in new tab"
@click="handleOpenWorkflow"
>
<template #icon>
<i class="icon-[comfy--workflow] size-4" />
</template>
</IconTextButton>
<IconTextButton
v-if="showWorkflowOptions"
type="transparent"
class="dark-theme:text-white"
label="Export workflow"
@click="handleExportWorkflow"
>
<template #icon>
<i class="icon-[lucide--file-output] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Copy job ID"
@click="handleCopyJobId"
>
<template #icon>
<i class="icon-[lucide--copy] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider />
<IconTextButton
type="transparent"
class="dark-theme:text-white"
label="Delete"
@click="handleDelete"
>
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
const { close } = defineProps<{
close: () => void
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const showWorkflowOptions = computed(() => {
return context.value.type
})
const handleInspect = () => {
if (asset.value) {
actions.viewAsset(asset.value.id)
}
close()
}
const handleAddToWorkflow = () => {
if (asset.value) {
actions.addWorkflow(asset.value.id)
}
close()
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset(asset.value.id)
}
close()
}
const handleOpenWorkflow = () => {
if (asset.value) {
actions.openWorkflow(asset.value.id)
}
close()
}
const handleExportWorkflow = () => {
if (asset.value) {
actions.exportWorkflow(asset.value.id)
}
close()
}
const handleCopyJobId = () => {
if (asset.value) {
actions.copyAssetUrl(asset.value.id)
}
close()
}
const handleDelete = () => {
if (asset.value) {
actions.deleteAsset(asset.value.id)
}
close()
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ formatSize(asset.size) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatSize, getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<div
class="flex h-full w-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('Audio')
}}</span>
</div>
<audio
controls
class="absolute bottom-0 left-0 w-full p-2"
:src="asset.src"
@click.stop
/>
</div>
</template>
<script setup lang="ts">
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="relative h-full w-full overflow-hidden rounded">
<LazyImage
v-if="asset.src"
:src="asset.src"
:alt="asset.name"
:container-class="'aspect-square'"
:image-class="'w-full h-full object-cover'"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
>
<i class="pi pi-image text-3xl text-gray-400" />
</div>
</div>
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
}>()
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col items-center gap-1">
<h3
class="m-0 line-clamp-1 text-sm font-bold text-zinc-900 dark-theme:text-white"
:title="asset.name"
>
{{ fileName }}
</h3>
<div class="flex items-center text-xs text-zinc-400">
<span>{{ asset.dimensions?.width }}x{{ asset.dimensions?.height }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getFilenameDetails } from '@/utils/formatUtil'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const fileName = computed(() => {
return getFilenameDetails(asset.name).filename
})
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div
class="relative h-full w-full overflow-hidden rounded bg-black"
@mouseenter="showControls = true"
@mouseleave="showControls = false"
>
<video
ref="videoRef"
:controls="showControls"
preload="none"
:poster="asset.preview_url"
class="relative h-full w-full object-contain"
@click.stop
@play="onVideoPlay"
@pause="onVideoPause"
>
<source :src="asset.src || ''" />
</video>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { AssetContext, AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{
asset: AssetMeta
context: AssetContext
}>()
const emit = defineEmits<{
play: [assetId: string]
videoPlayingStateChanged: [isPlaying: boolean]
videoControlsChanged: [showControls: boolean]
}>()
const videoRef = ref<HTMLVideoElement>()
const showControls = ref(true)
watch(showControls, (controlsVisible) => {
emit('videoControlsChanged', controlsVisible)
})
onMounted(() => {
emit('videoControlsChanged', showControls.value)
})
const onVideoPlay = () => {
showControls.value = true
emit('videoPlayingStateChanged', true)
}
const onVideoPause = () => {
emit('videoPlayingStateChanged', false)
}
</script>

View File

@@ -0,0 +1,62 @@
/* eslint-disable no-console */
import type { AssetMeta } from '../schemas/mediaAssetSchema'
export function useMediaAssetActions() {
const selectAsset = (asset: AssetMeta) => {
console.log('Asset selected:', asset)
}
const viewAsset = (assetId: string) => {
console.log('Viewing asset:', assetId)
}
const downloadAsset = (assetId: string) => {
console.log('Downloading asset:', assetId)
}
const deleteAsset = (assetId: string) => {
console.log('Deleting asset:', assetId)
}
const playAsset = (assetId: string) => {
console.log('Playing asset:', assetId)
}
const copyAssetUrl = (assetId: string) => {
console.log('Copy asset URL:', assetId)
}
const copyJobId = (jobId: string) => {
console.log('Copy job ID:', jobId)
}
const addWorkflow = (assetId: string) => {
console.log('Adding asset to workflow:', assetId)
}
const openWorkflow = (assetId: string) => {
console.log('Opening workflow for asset:', assetId)
}
const exportWorkflow = (assetId: string) => {
console.log('Exporting workflow for asset:', assetId)
}
const openMoreOutputs = (assetId: string) => {
console.log('Opening more outputs for asset:', assetId)
}
return {
selectAsset,
viewAsset,
downloadAsset,
deleteAsset,
playAsset,
copyAssetUrl,
copyJobId,
addWorkflow,
openWorkflow,
exportWorkflow,
openMoreOutputs
}
}

View File

@@ -0,0 +1,46 @@
import type { InjectionKey, Ref } from 'vue'
import { z } from 'zod'
import { assetItemSchema } from './assetSchema'
const zMediaKindSchema = z.enum(['video', 'audio', 'image', '3D'])
export type MediaKind = z.infer<typeof zMediaKindSchema>
const zDimensionsSchema = z.object({
width: z.number().positive(),
height: z.number().positive()
})
// Extend the base asset schema with media-specific fields
const zMediaAssetDisplayItemSchema = assetItemSchema.extend({
// New required fields
kind: zMediaKindSchema,
src: z.string().url(),
// New optional fields
duration: z.number().nonnegative().optional(),
dimensions: zDimensionsSchema.optional(),
jobId: z.string().optional(),
isMulti: z.boolean().optional()
})
// Asset context schema
const zAssetContextSchema = z.object({
type: z.enum(['input', 'output']),
outputCount: z.number().positive().optional() // Only for output context
})
// Export the inferred types
export type AssetMeta = z.infer<typeof zMediaAssetDisplayItemSchema>
export type AssetContext = z.infer<typeof zAssetContextSchema>
// Injection key for MediaAsset provide/inject pattern
interface MediaAssetProviderValue {
asset: Ref<AssetMeta | undefined>
context: Ref<AssetContext>
isVideoPlaying: Ref<boolean>
showVideoControls: Ref<boolean>
}
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
Symbol('mediaAsset')

View File

@@ -0,0 +1,20 @@
import { isElectron } from '@/utils/envUtil'
/**
* Distribution types and compile-time constants for managing
* multi-distribution builds (Desktop, Localhost, Cloud)
*/
type Distribution = 'desktop' | 'localhost' | 'cloud'
declare global {
const __DISTRIBUTION__: Distribution
}
/** Current distribution - replaced at compile time */
const DISTRIBUTION: Distribution = __DISTRIBUTION__
/** Distribution type checks */
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = !isDesktop && !isCloud

View File

@@ -5,6 +5,7 @@ import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { app } from '@/scripts/app'
import { isSubgraph } from '@/utils/typeGuardUtil'
// Keep one adapter per graph so rendering and interaction share state.
const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
@@ -130,6 +131,15 @@ export class LinkConnectorAdapter {
/** Drops moving links onto the canvas (no target). */
dropOnCanvas(event: CanvasPointerEvent): void {
//Add extra check for connection to subgraphInput/subgraphOutput
if (isSubgraph(this.network)) {
const { canvasX, canvasY } = event
const ioNode = this.network.getIoNodeOnPos?.(canvasX, canvasY)
if (ioNode) {
this.linkConnector.dropOnIoNode(ioNode, event)
return
}
}
this.linkConnector.dropOnNothing(event)
}

View File

@@ -12,6 +12,7 @@ import * as Y from 'yjs'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
@@ -871,6 +872,12 @@ class LayoutStoreImpl implements LayoutStore {
case 'deleteNode':
this.handleDeleteNode(operation as DeleteNodeOperation, change)
break
case 'batchUpdateBounds':
this.handleBatchUpdateBounds(
operation as BatchUpdateBoundsOperation,
change
)
break
case 'createLink':
this.handleCreateLink(operation as CreateLinkOperation, change)
break
@@ -1099,6 +1106,38 @@ class LayoutStoreImpl implements LayoutStore {
change.nodeIds.push(operation.nodeId)
}
private handleBatchUpdateBounds(
operation: BatchUpdateBoundsOperation,
change: LayoutChange
): void {
const spatialUpdates: Array<{ nodeId: NodeId; bounds: Bounds }> = []
for (const nodeId of operation.nodeIds) {
const data = operation.bounds[nodeId]
const ynode = this.ynodes.get(nodeId)
if (!ynode || !data) continue
ynode.set('position', { x: data.bounds.x, y: data.bounds.y })
ynode.set('size', {
width: data.bounds.width,
height: data.bounds.height
})
ynode.set('bounds', data.bounds)
spatialUpdates.push({ nodeId, bounds: data.bounds })
change.nodeIds.push(nodeId)
}
// Batch update spatial index for better performance
if (spatialUpdates.length > 0) {
this.spatialIndex.batchUpdate(spatialUpdates)
}
if (change.nodeIds.length) {
change.type = 'update'
}
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
@@ -1379,19 +1418,38 @@ class LayoutStoreImpl implements LayoutStore {
const originalSource = this.currentSource
this.currentSource = LayoutSource.Vue
this.ydoc.transact(() => {
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
const nodeIds: NodeId[] = []
const boundsRecord: BatchUpdateBoundsOperation['bounds'] = {}
this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds)
ynode.set('position', { x: bounds.x, y: bounds.y })
ynode.set('size', { width: bounds.width, height: bounds.height })
for (const { nodeId, bounds } of updates) {
const ynode = this.ynodes.get(nodeId)
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
boundsRecord[nodeId] = {
bounds,
previousBounds: currentLayout.bounds
}
}, this.currentActor)
nodeIds.push(nodeId)
}
if (!nodeIds.length) {
this.currentSource = originalSource
return
}
const operation: BatchUpdateBoundsOperation = {
type: 'batchUpdateBounds',
entity: 'node',
nodeIds,
bounds: boundsRecord,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
}
this.applyOperation(operation)
// Restore original source
this.currentSource = originalSource
}
}

View File

@@ -267,6 +267,11 @@ export function useLinkLayoutSync() {
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'batchUpdateBounds':
for (const nodeId of change.operation.nodeIds) {
recomputeLinksForNode(parseInt(nodeId))
}
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break

Some files were not shown because too many files have changed in this diff Show More