Compare commits

...

10 Commits

Author SHA1 Message Date
Alexander Brown
3006932287 Merge branch 'main' into fix/subgraph-io-drag-feedback 2026-05-18 16:06:55 -07:00
nav-tej
4e07fe3a43 feat(website): update Terms of Service to legal-approved 2026-05-13 copy (#12286)
*PR Created by the Glary-Bot Agent*

---

## Summary

Replaces the `tos.*` i18n keys in
`apps/website/src/i18n/translations.ts` with the legal-approved Terms of
Service copy from `Comfy - Terms of Service (GP 5.12.26).docx` and
surfaces an effective date below the hero on `/terms-of-service`.

- Restructures the ToS into 14 sections (intro + 13 numbered sections)
to match the new legal-approved structure.
- Adds two new keys, `tos.effectiveDateLabel` and `tos.effectiveDate`,
rendered as a centered `Effective Date: May 13, 2026` line between the
hero and the content (matches the pattern used on the Affiliate Program
Terms page).
- Subsection labels (*Right to Access and Use Comfy Products.*,
*Customer Data.*, etc.) render as h3 headings via the existing
`block.N.heading` shape — no changes to `ContentSection.vue` or
`contentSections.ts`.
- English page meta description tightened to reflect the new scope
(Comfy Products: Cloud, API, Enterprise — explicitly excluding Comfy
OSS).

## Verbatim legal copy

Per request, the copy is **verbatim from the legal-approved `.docx`**,
including:

- `[URL]` placeholder in §2.7 (Data Retention) where the legal doc has a
placeholder pending the real docs page.
- `[Address]` placeholder in §12.8 (Notices) where the legal doc has a
placeholder pending the finalized mailing address.
- Mixed casing in §8 (Disclaimer) and §9 (Limitation of Liability) —
e.g. `THE Comfy Products AND OUTPUT…`, `…TOTAL LIABILITY OF Comfy…` —
preserved exactly as the legal doc presents it.
- §11(c) cross-reference left as written.

These are intentional and flagged for follow-up with legal/docs before
publishing. I have **not** silently substituted real values for the
placeholders or normalized casing — that would be editing legal-approved
text.

## Chinese (zh-CN) handling

The legal-approved copy was provided in English only. To avoid serving
English text under a Chinese page shell:

- `apps/website/src/pages/zh-CN/terms-of-service.astro` is **removed**.
- `getRoutes()` in `apps/website/src/config/routes.ts` treats
`termsOfService` as locale-invariant, so the Chinese footer link emits
`/terms-of-service` directly — no redirect hop.
- `astro.config.ts` adds a redirect from `/zh-CN/terms-of-service` →
`/terms-of-service` as a safety net for any stale external/cached links.
- All `zh-CN` values on the new `tos.*` keys are filler (mirrored from
English) so the `Record<Locale, string>` type contract holds; they are
never served.

## Files changed

- `apps/website/src/i18n/translations.ts` — 73 old `tos.*` keys removed,
136 new keys added matching the .docx structure.
- `apps/website/src/pages/terms-of-service.astro` — imports `t`, renders
effective date, updates meta description.
- `apps/website/src/pages/zh-CN/terms-of-service.astro` — **removed**.
- `apps/website/astro.config.ts` — adds `/zh-CN/terms-of-service` →
`/terms-of-service` redirect.
- `apps/website/src/config/routes.ts` — `termsOfService` route stays
un-prefixed in non-English locales.

## Verification

- `pnpm --filter=@comfyorg/website typecheck` — 0 errors (2 pre-existing
hints in unrelated files).
- `pnpm --filter=@comfyorg/website build` — 279 pages built,
`/terms-of-service/` (English page) and `/zh-CN/terms-of-service/`
(redirect stub with `noindex` + `canonical`) both emitted.
- Pre-commit lint-staged ran `oxfmt`, `oxlint --type-aware`, `eslint
--fix`, and `pnpm typecheck` on every commit — all green.
- Rendered HTML spot-checked: English `/terms-of-service` contains the
new content with verbatim `[URL]` and `[Address]` placeholders; zh-CN
homepage footer now links directly to `/terms-of-service` (no redirect
hop); `/zh-CN/privacy-policy` and other locale routes still correctly
emit `/zh-CN/…` prefixes.
- Manual visual check via `astro preview` + Playwright — sidebar nav, h2
section titles, h3 subsection headings, paragraph wrapping, and inline
mailto/href anchors all render correctly. Screenshots attached.

## Code-review follow-ups addressed

- **zh-CN regression** — Page removed, route override added, redirect
kept as safety net.
- **Page description mismatch** — Updated meta description to reflect
new scope.
- **`docs.comfy.org/data-retention` 404** — Now matches the docx
placeholder `[URL]`; flagged to legal/docs.
- **Disclaimer / Liability casing** — Restored to match docx verbatim.
- **Mailing address** — Now matches the docx placeholder `[Address]`;
flagged to legal.
- **Section 11(c) cross-reference** — Left verbatim per legal doc.

## Scope notes

- English-only legal update per request — no Chinese rewrite, no schema
changes, no acceptance-tracking infrastructure.
- The signup-flow link on `platform.comfy.org` (`website` repo) already
points at `https://www.comfy.org/terms-of-service` and renders the new
copy at the same URL — no change needed there.

## Screenshots

![Top of /terms-of-service showing 'Effective Date: May 13, 2026' below
the hero and new section
nav](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833284897-43ef1f3a-5c7b-46eb-a73f-58bf38e857be.png)

![Section 2 (Comfy Products) showing h2 title and yellow-italic h3
subsection headings rendered from new tos.*.block.N.heading
keys](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/57f1c6025f3f147cdc8916ecdd3ecbba847dcc10a971e40c830996f5d3685373/pr-images/1778833285304-47094d22-2dea-4192-a00c-2a857d92e0ab.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12286-feat-website-update-Terms-of-Service-to-legal-approved-2026-05-13-copy-3616d73d3650815b9262f84d12655dfa)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-18 21:11:25 +00:00
Benjamin Lu
3e31de5bbb test: migrate MaxHistoryItems browser coverage (#12298)
## Summary

Migrates the MaxHistoryItems browser coverage to the accepted jobs route
fixture pattern.

## Changes

- **What**: Composes `jobsRouteFixture` into the queue settings spec and
removes the old `AssetsHelper` route setup.
- **What**: Adds a `responseLimit` option to `jobsRouteFixture` so tests
can match a requested history limit while intentionally returning more
jobs.
- **Dependencies**: None.

## Review Focus

The key behavior is preserving both FE-501 acceptance cases: `/api/jobs`
still receives the configured `limit`, and the queue panel still caps
rendered history even if the mocked backend returns more rows than
requested.

Fixes FE-501

## Screenshots (if applicable)

Not applicable.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12298-test-migrate-MaxHistoryItems-browser-coverage-3616d73d365081d6bf77fb205fcd51d4)
by [Unito](https://www.unito.io)
2026-05-18 18:59:20 +00:00
jaeone94
0558740c78 refactor: migrate default combo widget select to Reka (#12288)
## Summary

Migrate the default combo widget select from the PrimeVue `SelectPlus`
wrapper to a Reka `Combobox` implementation while preserving the
existing Comfy combo widget contract and the node-canvas dropdown
behavior.

## Changes

- **What**: Rewrites `WidgetSelectDefault.vue` on top of Reka
`ComboboxRoot`, `ComboboxTrigger`, `ComboboxInput`, `ComboboxContent`,
and `ComboboxItem`.
- **What**: Preserves the default combo widget surface: `v-model`,
`widget` prop, `aria-label` from the widget name/label,
`data-capture-wheel`, disabled state, placeholder/filter placeholder,
default slot controls, invalid current value display, array values,
dynamic/factory values, and `getOptionLabel` fallback behavior.
- **What**: Keeps dynamic `values` compatibility by refreshing
function-backed options when the dropdown opens, without re-evaluating
the factory on every search keystroke.
- **What**: Deletes the now-unused PrimeVue `SelectPlus.vue` wrapper and
removes the PrimeVue test plugin/stub path from the default widget
select tests.
- **What**: Updates App Mode dropdown clipping coverage and combo-widget
browser coverage to target the new Reka overlay/viewport structure.
- **Breaking**: No breaking change is intended for the documented Comfy
combo widget contract. This migration does not preserve incidental
PrimeVue `Select` prop pass-through from `widget.options`; that was a
side effect of wrapping PrimeVue rather than a stable widget API.
- **Dependencies**: No new dependencies.

## Review Focus

### Compatibility choices

The goal of this PR is a migration PR, not a broad behavior redesign.
The new implementation keeps the Comfy-specific combo contract rather
than attempting to emulate PrimeVue internals. In particular:

- `values` still accepts arrays and functions, and function values are
re-read on open to support dynamic/custom node option sources.
- `getOptionLabel(value) || value` is intentionally preserved to match
the sibling dropdown path and avoid turning an empty-string label into a
blank rendered option.
- Invalid/current values that are not present in the option list are
still rendered in the trigger instead of disappearing.
- `WidgetWithControl` continues to render its default slot in the
control area, with trigger text truncation preserved.
- App Mode `OverlayAppendToKey='body'` continues to map to a body portal
to avoid panel clipping.

### Visual alignment and screenshot updates

The previous PrimeVue implementation passed `size="small"`, which
injected internal `.p-select-sm .p-select-label` styling. That internal
PrimeVue style used its own small-select font size and padding,
overriding the surrounding widget sizing intent and making the select
trigger subtly taller with slightly larger text than nearby inline node
widget controls.

The Reka implementation intentionally keeps the normal widget styling
path instead of recreating that PrimeVue-specific internal override.
This means the trigger follows the same inline widget sizing direction
as neighboring controls rather than preserving the incidental PrimeVue
height/text-size delta. Because this is an expected visual difference
from the migration, the affected E2E screenshots should be recaptured
instead of treating the old PrimeVue select height as the target.

### Scrollbar and focus behavior

Reka provides the combobox/listbox semantics we want, including search,
arrow navigation, highlighted items, and Enter selection. The tricky
part is the canvas dropdown scrollbar behavior. The native Reka viewport
path hides/owns scrollbar behavior in a way that made it hard to
preserve the previous widget dropdown affordances, especially visible
scrollbars and mouse wheel capture over the node canvas.

To keep the previous behavior, this PR renders a dedicated scrollable
viewport inside `ComboboxContent` with the project scrollbar utilities
(`scrollbar-thin`, stable gutter, transparent track). That preserves
visible scroll affordance and allows wheel events over the dropdown to
scroll the list instead of zooming the canvas.

There was one Reka interaction to account for: pressing the native
scrollbar can be treated as a focus-outside event from the search input,
which previously closed the dropdown on mouse down or caused subsequent
wheel events to leak back to the canvas. The new
`useRestoreFocusOnViewportPointer` composable handles only that short
pointer gesture:

- viewport pointerdown marks a short-lived scrollbar/viewport
interaction,
- the next focus-outside event is prevented only if the search input can
be restored,
- the guard is cleared by `pointerup`, `pointercancel`, and a timeout so
normal outside clicks still close the dropdown.

### Tests and regression coverage

Unit coverage was updated around the new Reka implementation:

- option sources from arrays and functions,
- dynamic values refreshed on open but not on each search keystroke,
- selection updates and blank/undefined Reka emissions being ignored,
- search filtering and Reka keyboard selection behavior,
- disabled state, invalid current values, `getOptionLabel`, empty
results status, and WidgetWithControl slot preservation,
- composable coverage for pointerup, pointercancel, repeated pointerdown
listener cleanup, and no-input/no-op behavior.

Browser regression coverage now checks the canvas-specific interaction
surface:

- opening and selecting default combo widget options,
- wheel over the dropdown scrolls the list instead of zooming the
canvas,
- pressing the scrollbar does not close the dropdown,
- wheel capture still works after pressing the scrollbar,
- opening another node widget closes the previous dropdown,
- switching between node widgets preserves dropdown scroll capture,
- serialize/reload retains selected combo values.

## Screenshots (if applicable)
New
<img width="527" height="753" alt="스크린샷 2026-05-18 오전 1 36 27"
src="https://github.com/user-attachments/assets/2293d510-6965-4b84-9b12-b8528f8a734f"
/>

Old 
<img width="496" height="473" alt="스크린샷 2026-05-18 오전 1 35 57"
src="https://github.com/user-attachments/assets/47c0e28a-27df-44a6-81a8-14fcc1f3bd8f"
/>

Reka Supports Auto highlight top item on search (Search -> Enter ->
Select 👍)


https://github.com/user-attachments/assets/9d633dfc-c23a-4e7a-8d39-b044c219f1f3

The default combo widget trigger has a small intentional visual delta
from the old PrimeVue path because the Reka implementation does not
recreate PrimeVue's internal `size="small"` label override.


https://github.com/user-attachments/assets/a9053a14-e39e-4d5e-a846-dcf9aeb0caed



## Validation

- `pnpm format`
- `pnpm lint` (passes; existing warning-only lint output remains in
unrelated tests)
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm test:unit`

- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://127.0.0.1:5174 pnpm
exec playwright test
browser_tests/tests/vueNodes/widgets/combo/comboWidget.spec.ts
--project=chromium`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12288-refactor-migrate-default-combo-widget-select-to-Reka-3616d73d365081fd8742c038a7dc7851)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-05-18 02:58:37 +00:00
bymyself
7c1195e389 fix: guard subgraph drag bridge to SubgraphIONodeBase nodes only
Activate the canvas → Vue slot drag bridge only when the drag
originates from a SubgraphInput or SubgraphOutput node. Other
canvas-initiated drags (e.g., reroutes) no longer inappropriately
activate Vue slot drag UI state.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9838#discussion_r2927692347
2026-05-03 01:32:48 -07:00
GitHub Action
f370700fbd [automated] Apply ESLint and Oxfmt fixes 2026-03-12 22:10:53 +00:00
Christian Byrne
3cee74a0c5 fix: address CodeRabbit review feedback
- Remove unconditional canvas dirty during stable hover (perf)
  The `|| node.isPointerOver` condition caused setDirty on every
  pointer-move frame while hovering, even when state hadn't changed.
2026-03-12 15:06:35 -07:00
Arthur R Longbottom
f5167ec744 fix: strengthen test assertions and add missing docstrings
Replace weak negative assertions with explicit call-name filtering
to catch false passes. Add docstrings to inner bridge functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:05:54 -07:00
Arthur R Longbottom
180fed69d8 test: add unit tests for subgraph IO slot link events
- SubgraphInput.connect() fires node:slot-links:changed for widget inputs
- SubgraphInputNode._disconnectNodeInput() fires on disconnect
- LinkConnector.dropOnNothing() dispatches before-drop-on-canvas before
  dropped-on-canvas, and skips downstream when intercepted
- Remove duplicate raf.flush() in finishInteraction
2026-03-12 15:05:54 -07:00
Arthur R Longbottom
0d657402e7 fix: add visual feedback for subgraph IO drag interactions
Bridge canvas-initiated subgraph IO drags to Vue slot drag state and
vice versa, restoring slot dimming, proximity snap, dot highlights,
and drop-to-connect across the canvas/Vue rendering boundary.

Fixes #9010
2026-03-12 15:05:54 -07:00
52 changed files with 2227 additions and 497 deletions

View File

@@ -28,7 +28,8 @@ export default defineConfig({
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
'/customers/series-entertainment/',
'/zh-CN/terms-of-service': '/terms-of-service'
},
build: {
assets: '_website'

View File

@@ -20,11 +20,16 @@ const baseRoutes = {
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}

View File

@@ -2050,269 +2050,594 @@ const translations = {
},
// ── Terms of Service ──────────────────────────────────────────────
'tos.intro.label': { en: 'INTRO', 'zh-CN': '简介' },
'tos.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'tos.effectiveDate': {
en: 'May 13, 2026',
'zh-CN': 'May 13, 2026'
},
'tos.intro.label': { en: 'INTRO', 'zh-CN': 'INTRO' },
'tos.intro.block.0': {
en: 'Welcome to the ComfyUI offering, provided by Comfy Organization, Inc.',
'zh-CN': '欢迎使用由 Comfy Organization, Inc. 提供的 ComfyUI 产品。'
en: 'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).',
'zh-CN':
'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).'
},
'tos.intro.block.1': {
en: 'Please read these Terms of Service (these "Terms") carefully, as they constitute a legally binding agreement between Comfy Organization, Inc., a Delaware corporation ("Comfy Org," "We," "Us," or "Our"), and an end-user ("You" and "Your") and apply to Your use of the Services (as defined below). In case You are subscribing to the Services as a representative of or on behalf of an entity (e.g., Your employer, the "Client" or "Entity"), Your acceptance of these Terms also binds the Client or Entity, and any reference in these Terms to "You" shall also mean the "Client" or "Entity" and its affiliates.',
en: 'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.',
'zh-CN':
'请仔细阅读本服务条款(以下简称"条款"),因为它们构成 Comfy Organization, Inc.(一家特拉华州公司,以下简称"Comfy Org"、"我们")与最终用户("您")之间具有法律约束力的协议,并适用于您对服务(定义见下文)的使用。如果您以实体(例如您的雇主,即"客户"或"实体")的代表身份或代表其订阅服务,您对本条款的接受也约束该客户或实体,本条款中对"您"的任何引用也应指"客户"或"实体"及其关联方。'
'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.'
},
'tos.intro.block.2': {
en: 'You hereby agree to accept these Terms by (a) either using the Services, or (b) by opening an account under a username. BEFORE YOU DO EITHER OF THOSE, PLEASE READ THESE TERMS CAREFULLY. IF YOU DO NOT WANT TO AGREE TO THESE TERMS, YOU MUST NOT USE THE SERVICES OR SET UP AN ACCOUNT.',
en: 'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.',
'zh-CN':
'您特此同意通过以下方式接受本条款:(a) 使用服务,或 (b) 以用户名开设账户。在您执行上述任何操作之前,请仔细阅读本条款。如果您不同意本条款,则不得使用服务或设置账户。'
'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.'
},
'tos.intro.block.3': {
en: 'You also agree to abide by other Comfy Org rules and policies, including our Privacy Policy https://www.comfy.org/privacy-policy (which explains what information we collect from You and how we protect it) that are expressly incorporated into and are a part of these Terms. Please read them carefully.',
'zh-CN':
'您还同意遵守 Comfy Org 的其他规则和政策,包括我们的隐私政策 https://www.comfy.org/privacy-policy该政策说明了我们从您处收集的信息以及如何保护这些信息这些规则和政策明确纳入本条款并构成其组成部分。请仔细阅读。'
},
'tos.intro.block.4': {
en: 'Once you accept these Terms You are bound by them until they are terminated. See Section 10 (Term and Termination).',
'zh-CN':
'一旦您接受本条款,您将受其约束,直至条款终止。请参阅第 10 条(期限和终止)。'
},
'tos.intro.block.5': {
en: 'By accessing or using the Software or Services in any way, You represent that (1) You have read, understand, and hereby agree to be bound by these Terms, (2) You are of legal age to form a binding contract with Comfy Org, and (3) You have the authority to enter into these Terms personally or on behalf of the Client Entity. If You do not agree to be bound by, or cannot conform with, these Terms, You may not use the Services. You will be legally and financially responsible for all actions using or accessing the Services, including the actions of anyone You allow to access Your Account.',
'zh-CN':
'通过以任何方式访问或使用软件或服务,您声明:(1) 您已阅读、理解并特此同意受本条款的约束,(2) 您已达到与 Comfy Org 签订具有约束力的合同的法定年龄,(3) 您有权以个人身份或代表客户实体签订本条款。如果您不同意受本条款约束或无法遵守本条款,则不得使用服务。您将对使用或访问服务的所有行为承担法律和财务责任,包括您允许访问您账户的任何人的行为。'
},
'tos.intro.block.6': {
en: 'IF YOU ACCEPT THESE TERMS, YOU AND COMFY ORG AGREE TO RESOLVE DISPUTES IN BINDING, INDIVIDUAL ARBITRATION AND GIVE UP THE RIGHT TO GO TO COURT INDIVIDUALLY OR AS PART OF A CLASS ACTION.',
'zh-CN':
'如果您接受本条款,您和 COMFY ORG 同意通过具有约束力的个人仲裁解决争议,并放弃以个人身份或作为集体诉讼一部分提起诉讼的权利。'
},
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': '定义' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. 定义' },
'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': 'DEFINITIONS' },
'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. Definitions' },
'tos.definitions.block.0': {
en: '"Business User" mean an entity or individual using the Software or Services primarily for business, commercial, or professional purposes.',
en: '“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'"商业用户"指主要出于商业、贸易或专业目的使用软件或服务的实体或个人。'
'“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'tos.definitions.block.1': {
en: '"ComfyUI Branding" means the names, logos, and associated trademarks owned or in progress of being owned by Comfy Org, Inc.',
en: '“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'"ComfyUI 品牌"指 Comfy Org, Inc. 拥有或正在申请拥有的名称、标志和相关商标。'
'“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'tos.definitions.block.2': {
en: '"ComfyUI Software" or "Software" means the open-source software product named "ComfyUI," including its desktop applications, source code, and user interface elements.',
en: 'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.',
'zh-CN':
'"ComfyUI 软件"或"软件"指名为"ComfyUI"的开源软件产品,包括其桌面应用程序、源代码和用户界面元素。'
'Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.'
},
'tos.definitions.block.3': {
en: '"Customer Data" means any data, content, information, prompts, or workflows that You submit, upload, transmit, or process through the Software or Services.',
en: '“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'"客户数据"指您通过软件或服务提交、上传、传输或处理的任何数据、内容、信息、提示词或工作流。'
'“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'tos.definitions.block.4': {
en: '"Consumer User" means an individual using the Software or Services primarily for personal, family, or household purposes.',
'zh-CN': '"消费者用户"指主要出于个人、家庭或家用目的使用软件或服务的个人。'
en: 'Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'“Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'tos.definitions.block.5': {
en: '"Intellectual Property Rights" means all (i) patents, patent disclosures, and inventions (whether patentable or not), (ii) trademarks, (iii) copyrights and copyrightable works (including computer programs), and rights in data and databases, and (iv) all other intellectual property rights, in each case whether registered or unregistered and including all applications for, and renewals or extensions of, such rights, and all similar or equivalent rights or forms of protection in any part of the world.',
en: '“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'"知识产权"指所有 (i) 专利、专利披露和发明(无论是否可获得专利),(ii) 商标,(iii) 版权和可受版权保护的作品(包括计算机程序)以及数据和数据库权利,(iv) 所有其他知识产权,在每种情况下无论已注册或未注册,包括所有此类权利的申请、续展或延期,以及世界任何地区的所有类似或等同的权利或保护形式。'
'“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'tos.definitions.block.6': {
en: '"Open Source License" means the specific open-source license(s) governing the ComfyUI Software, primarily the GNU General Public License v3 (GPLv3) for its UI elements and potentially other components.',
en: '“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.',
'zh-CN':
'"开源许可证"指管辖 ComfyUI 软件的特定开源许可证,主要是用于其 UI 元素的 GNU 通用公共许可证第 3 版 (GPLv3) 以及可能适用于其他组件的许可证。'
'“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.'
},
'tos.definitions.block.7': {
en: '"Providers" means certain third-party service providers utilized by Comfy Org for certain functionality, including hosting and payment processing.',
en: '“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.',
'zh-CN':
'"提供商"指 Comfy Org 用于某些功能的特定第三方服务提供商,包括托管和支付处理。'
'“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.'
},
'tos.definitions.block.8': {
en: '"Services" means all current and future commercial and auxiliary services provided by Comfy Org in connection with the ComfyUI Software, including but not limited to:',
en: '“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'"服务"指 Comfy Org 与 ComfyUI 软件相关的所有当前和未来的商业及辅助服务,包括但不限于:'
'“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'tos.definitions.block.9': {
en: 'Commercial services:',
'zh-CN': '商业服务:'
en: '“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'tos.definitions.block.10': {
en: 'Comfy Cloud — paid and fully managed cloud based ComfyUI hosted in our data centers\nAPI Nodes — paid integrations with third-party API services available within ComfyUI\nSupport, Training, Consulting — paid services related to ComfyUI',
en: '“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'Comfy Cloud——付费的、完全托管的、基于云的 ComfyUI托管在我们的数据中心\nAPI 节点——ComfyUI 中可用的与第三方 API 服务的付费集成\n支持、培训、咨询——与 ComfyUI 相关的付费服务'
'“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'tos.definitions.block.11': {
en: 'Open source services:',
'zh-CN': '开源服务:'
en: '“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.',
'zh-CN':
'“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.'
},
'tos.definitions.block.12': {
en: 'Custom Node Registry — marketplace of custom nodes freely available to ComfyUI users\nAny other hosted experiences or tools offered by Comfy Org.',
en: '“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'自定义节点 Registry——ComfyUI 用户免费使用的自定义节点市场\nComfy Org 提供的任何其他托管体验或工具。'
'“User” means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'tos.license.label': { en: 'LICENSE', 'zh-CN': '许可' },
'tos.license.title': {
en: '2. ComfyUI Software License',
'zh-CN': '2. ComfyUI 软件许可'
'tos.comfy-products.label': {
en: 'COMFY PRODUCTS',
'zh-CN': 'COMFY PRODUCTS'
},
'tos.license.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'tos.comfy-products.block.0.heading': {
en: 'Right to Access and Use Comfy Products.',
'zh-CN': 'Right to Access and Use Comfy Products.'
},
'tos.comfy-products.block.1': {
en: 'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.'
},
'tos.license.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.2.heading': {
en: 'Customer Data.',
'zh-CN': 'Customer Data.'
},
'tos.comfy-products.block.3': {
en: 'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'tos.license.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.4.heading': {
en: 'No AI Training.',
'zh-CN': 'No AI Training.'
},
'tos.comfy-products.block.5': {
en: 'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'tos.using-services.label': { en: 'USAGE', 'zh-CN': '使用服务' },
'tos.using-services.title': {
en: '3. Using the Services',
'zh-CN': '3. 使用服务'
'tos.comfy-products.block.6.heading': {
en: 'Comfy OSS.',
'zh-CN': 'Comfy OSS.'
},
'tos.using-services.block.0': {
en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.',
'tos.comfy-products.block.7': {
en: 'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。'
'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.'
},
'tos.using-services.block.1': {
en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.',
'tos.comfy-products.block.8.heading': {
en: 'Partner Nodes.',
'zh-CN': 'Partner Nodes.'
},
'tos.comfy-products.block.9': {
en: 'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。'
'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'tos.using-services.block.2': {
en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.',
'tos.comfy-products.block.10.heading': {
en: 'Modification of Comfy Products.',
'zh-CN': 'Modification of Comfy Products.'
},
'tos.comfy-products.block.11': {
en: 'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。'
'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'tos.responsibilities.label': { en: 'RESPONSIBILITIES', 'zh-CN': '您的责任' },
'tos.responsibilities.title': {
en: '4. Your Responsibilities',
'zh-CN': '4. 您的责任'
'tos.comfy-products.block.12.heading': {
en: 'Data Retention and Deletion.',
'zh-CN': 'Data Retention and Deletion.'
},
'tos.responsibilities.block.0': {
en: 'You are responsible for your use of the Services and any content you create, share, or distribute through them. You agree to use the Services in a manner that is lawful, respectful, and consistent with these Terms. You are solely responsible for maintaining the security of your account credentials.',
'tos.comfy-products.block.13': {
en: 'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'您应对使用服务以及通过服务创建、共享或分发的任何内容负责。您同意以合法、尊重他人且符合本条款的方式使用服务。您全权负责维护账户凭据的安全。'
'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.'
},
'tos.restrictions.label': { en: 'RESTRICTIONS', 'zh-CN': '限制' },
'tos.restrictions.title': {
en: '5. Use Restrictions',
'zh-CN': '5. 使用限制'
'tos.customer-responsibilities.label': {
en: 'RESPONSIBILITIES',
'zh-CN': 'RESPONSIBILITIES'
},
'tos.restrictions.block.0': {
en: 'You agree not to misuse the Services. This includes, but is not limited to:',
'zh-CN': '您同意不滥用服务,包括但不限于:'
'tos.customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'tos.restrictions.block.1': {
en: 'Attempting to gain unauthorized access to any part of the Services\nUsing the Services to distribute malware, viruses, or harmful code\nInterfering with or disrupting the integrity or performance of the Services\nScraping, crawling, or using automated means to access the Services without permission\nPublishing custom nodes or workflows that contain malicious code or violate third-party rights',
'tos.customer-responsibilities.block.0.heading': {
en: 'Registration.',
'zh-CN': 'Registration.'
},
'tos.customer-responsibilities.block.1': {
en: 'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.',
'zh-CN':
'试图未经授权访问服务的任何部分\n利用服务传播恶意软件、病毒或有害代码\n干扰或破坏服务的完整性或性能\n未经许可使用自动化手段抓取或爬取服务\n发布包含恶意代码或侵犯第三方权利的自定义节点或工作流'
'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.'
},
'tos.accounts.label': { en: 'ACCOUNTS', 'zh-CN': '账户' },
'tos.accounts.title': {
en: '6. Accounts and User Information',
'zh-CN': '6. 账户和用户信息'
'tos.customer-responsibilities.block.2.heading': {
en: 'General Technology Restrictions.',
'zh-CN': 'General Technology Restrictions.'
},
'tos.accounts.block.0': {
en: 'Certain features of the Services may require you to create an account. You agree to provide accurate and complete information when creating your account and to keep this information up to date. You are responsible for all activity that occurs under your account. We reserve the right to suspend or terminate accounts that violate these Terms.',
'tos.customer-responsibilities.block.3': {
en: 'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'服务的某些功能可能要求您创建账户。您同意在创建账户时提供准确、完整的信息,并及时更新。您对账户下发生的所有活动负责。我们保留暂停或终止违反本条款的账户的权利。'
'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'tos.ip.label': { en: 'IP RIGHTS', 'zh-CN': '知识产权' },
'tos.ip.title': {
en: '7. Intellectual Property Rights',
'zh-CN': '7. 知识产权'
'tos.customer-responsibilities.block.4.heading': {
en: 'Acceptable Use; Prohibited Customer Data.',
'zh-CN': 'Acceptable Use; Prohibited Customer Data.'
},
'tos.ip.block.0': {
en: 'The Services, excluding open-source components, are owned by Comfy and are protected by intellectual property laws. The Comfy name, logo, and branding are trademarks of Comfy Org, Inc. You retain ownership of any User Content you create. By submitting User Content to the Services, you grant Comfy a non-exclusive, worldwide, royalty-free license to host, display, and distribute such content as necessary to operate the Services.',
'tos.customer-responsibilities.block.5': {
en: 'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'除开源组件外,服务归 Comfy 所有并受知识产权法保护。Comfy 名称、标志和品牌是 Comfy Org, Inc. 的商标。您保留您创建的任何用户内容的所有权。向服务提交用户内容即表示您授予 Comfy 一项非排他性、全球性、免版税的许可,以在运营服务所需的范围内托管、展示和分发此类内容。'
'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'tos.distribution.label': { en: 'DISTRIBUTION', 'zh-CN': '分发' },
'tos.distribution.title': {
en: '8. Model and Workflow Distribution',
'zh-CN': '8. 模型和工作流分发'
'tos.payment.label': { en: 'PAYMENT', 'zh-CN': 'PAYMENT' },
'tos.payment.title': { en: '4. Payment', 'zh-CN': '4. Payment' },
'tos.payment.block.0.heading': {
en: 'Plans; Fees; Free Tier.',
'zh-CN': 'Plans; Fees; Free Tier.'
},
'tos.distribution.block.0': {
en: 'When you distribute models, workflows, or custom nodes through the Registry or Services, you represent that you have the right to distribute such content and that it does not infringe any third-party rights. You are responsible for specifying an appropriate license for any content you distribute. Comfy does not claim ownership of content distributed through the Registry.',
'tos.payment.block.1': {
en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.',
'zh-CN':
'当您通过 Registry 或服务分发模型、工作流或自定义节点时您声明您有权分发此类内容且其不侵犯任何第三方权利。您有责任为分发的内容指定适当的许可证。Comfy 不主张对通过 Registry 分发的内容的所有权。'
'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.'
},
'tos.fees.label': { en: 'FEES', 'zh-CN': '费用' },
'tos.fees.title': { en: '9. Fees and Payment', 'zh-CN': '9. 费用和付款' },
'tos.fees.block.0': {
en: 'Certain Services may be offered for a fee. If you choose to use paid features, you agree to pay all applicable fees as described at the time of purchase. Fees are non-refundable except as required by law or as expressly stated in these Terms. Comfy reserves the right to change pricing with reasonable notice.',
'tos.payment.block.2.heading': {
en: 'Self-Serve Credit Card Billing.',
'zh-CN': 'Self-Serve Credit Card Billing.'
},
'tos.payment.block.3': {
en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.',
'zh-CN':
'某些服务可能需要付费。如果您选择使用付费功能则同意支付购买时所述的所有适用费用。除法律要求或本条款明确规定外费用不予退还。Comfy 保留在合理通知后变更定价的权利。'
'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.'
},
'tos.termination.label': { en: 'TERMINATION', 'zh-CN': '终止' },
'tos.termination.title': {
en: '10. Term and Termination',
'zh-CN': '10. 期限和终止'
'tos.payment.block.4.heading': {
en: 'Invoiced Billing.',
'zh-CN': 'Invoiced Billing.'
},
'tos.termination.block.0': {
en: 'These Terms remain in effect while you use the Services. You may stop using the Services at any time. Comfy may suspend or terminate your access to the Services at any time, with or without cause and with or without notice. Upon termination, your right to use the Services will immediately cease. Sections that by their nature should survive termination will continue to apply.',
'tos.payment.block.5': {
en: 'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.',
'zh-CN':
'在您使用服务期间本条款持续有效。您可随时停止使用服务。Comfy 可随时暂停或终止您对服务的访问,无论是否有原因,也无论是否事先通知。终止后,您使用服务的权利将立即终止。按其性质应在终止后继续有效的条款将继续适用。'
'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.'
},
'tos.warranties.label': { en: 'WARRANTIES', 'zh-CN': '免责' },
'tos.warranties.title': {
en: '11. Disclaimer of Warranties',
'zh-CN': '11. 免责声明'
'tos.payment.block.6.heading': {
en: 'Prepaid Credits.',
'zh-CN': 'Prepaid Credits.'
},
'tos.warranties.block.0': {
en: 'THE SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. COMFY DOES NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.',
'tos.payment.block.7': {
en: 'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'服务按"现状"和"可用"基础提供不附带任何形式的明示或暗示保证包括但不限于对适销性、特定用途适用性和非侵权性的暗示保证。Comfy 不保证服务将不间断、无错误或安全。'
'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': '责任限制' },
'tos.payment.block.8.heading': {
en: 'Taxes; Price Changes; No Refunds.',
'zh-CN': 'Taxes; Price Changes; No Refunds.'
},
'tos.payment.block.9': {
en: 'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.',
'zh-CN':
'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.'
},
'tos.payment.block.10.heading': {
en: 'Late Payments; Suspension.',
'zh-CN': 'Late Payments; Suspension.'
},
'tos.payment.block.11': {
en: 'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'tos.term-termination.label': {
en: 'TERM; TERMINATION',
'zh-CN': 'TERM; TERMINATION'
},
'tos.term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'tos.term-termination.block.0.heading': {
en: 'Termination of Agreement.',
'zh-CN': 'Termination of Agreement.'
},
'tos.term-termination.block.1': {
en: 'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.',
'zh-CN':
'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.'
},
'tos.term-termination.block.2.heading': {
en: 'Effect of Termination.',
'zh-CN': 'Effect of Termination.'
},
'tos.term-termination.block.3': {
en: 'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.',
'zh-CN':
'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customers access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customers use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.'
},
'tos.term-termination.block.4.heading': {
en: 'Survival.',
'zh-CN': 'Survival.'
},
'tos.term-termination.block.5': {
en: 'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'tos.confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'tos.confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'tos.confidentiality.block.0.heading': {
en: 'Definition of Confidential Information.',
'zh-CN': 'Definition of Confidential Information.'
},
'tos.confidentiality.block.1': {
en: '“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'tos.confidentiality.block.2.heading': {
en: 'Protection of Confidential Information.',
'zh-CN': 'Protection of Confidential Information.'
},
'tos.confidentiality.block.3': {
en: 'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'tos.confidentiality.block.4.heading': {
en: 'Compelled Disclosure.',
'zh-CN': 'Compelled Disclosure.'
},
'tos.confidentiality.block.5': {
en: 'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'tos.confidentiality.block.6.heading': {
en: 'Data Security.',
'zh-CN': 'Data Security.'
},
'tos.confidentiality.block.7': {
en: 'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'tos.proprietary-rights.label': {
en: 'PROPRIETARY RIGHTS',
'zh-CN': 'PROPRIETARY RIGHTS'
},
'tos.proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'tos.proprietary-rights.block.0.heading': {
en: 'Reservation of Rights.',
'zh-CN': 'Reservation of Rights.'
},
'tos.proprietary-rights.block.1': {
en: 'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'tos.proprietary-rights.block.2.heading': {
en: 'Feedback.',
'zh-CN': 'Feedback.'
},
'tos.proprietary-rights.block.3': {
en: 'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'tos.proprietary-rights.block.4.heading': {
en: 'Operational Metadata.',
'zh-CN': 'Operational Metadata.'
},
'tos.proprietary-rights.block.5': {
en: 'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'tos.disclaimer.label': { en: 'DISCLAIMER', 'zh-CN': 'DISCLAIMER' },
'tos.disclaimer.title': { en: '8. Disclaimer', 'zh-CN': '8. Disclaimer' },
'tos.disclaimer.block.0': {
en: 'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.',
'zh-CN':
'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.'
},
'tos.disclaimer.block.1': {
en: 'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.',
'zh-CN':
'Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customers use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.'
},
'tos.liability.label': { en: 'LIABILITY', 'zh-CN': 'LIABILITY' },
'tos.liability.title': {
en: '12. Limitation of Liability',
'zh-CN': '12. 责任限制'
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'tos.liability.block.0': {
en: "TO THE MAXIMUM EXTENT PERMITTED BY LAW, COMFY SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES RESULTING FROM YOUR USE OF THE SERVICES. COMFY'S TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNTS PAID BY YOU TO COMFY IN THE TWELVE MONTHS PRECEDING THE CLAIM.",
en: 'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.',
'zh-CN':
'在法律允许的最大范围内Comfy 不对任何间接、附带、特殊、后果性或惩罚性损害或任何利润或收入损失无论是直接还是间接产生的或任何数据、使用、商誉或其他无形损失承担责任。Comfy 的总责任不超过您在索赔前十二个月内向 Comfy 支付的金额。'
'WHEN PERMITTED BY LAW, COMFY, AND COMFYS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.'
},
'tos.indemnification.label': {
en: 'INDEMNIFICATION',
'zh-CN': 'INDEMNIFICATION'
},
'tos.indemnification.label': { en: 'INDEMNIFICATION', 'zh-CN': '赔偿' },
'tos.indemnification.title': {
en: '13. Indemnification',
'zh-CN': '13. 赔偿'
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'tos.indemnification.block.0': {
en: 'You agree to indemnify, defend, and hold harmless Comfy, its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses arising out of or in any way connected with your access to or use of the Services, your User Content, or your violation of these Terms.',
en: 'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.',
'zh-CN':
'您同意赔偿、辩护并使 Comfy 及其管理人员、董事、员工和代理人免受因您访问或使用服务、您的用户内容或您违反本条款而产生的或与之相关的任何索赔、责任、损害、损失和费用。'
'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfys prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.'
},
'tos.governing-law.label': { en: 'GOVERNING LAW', 'zh-CN': '适用法律' },
'tos.governing-law.title': {
en: '14. Governing Law and Dispute Resolution',
'zh-CN': '14. 适用法律和争议解决'
'tos.dispute-resolution.label': {
en: 'DISPUTE RESOLUTION',
'zh-CN': 'DISPUTE RESOLUTION'
},
'tos.governing-law.block.0': {
en: 'These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of laws principles. Any disputes arising under these Terms shall be resolved through binding arbitration in accordance with the rules of the American Arbitration Association, except that either party may seek injunctive relief in any court of competent jurisdiction.',
'tos.dispute-resolution.title': {
en: '11. Governing Law and Dispute Resolution',
'zh-CN': '11. Governing Law and Dispute Resolution'
},
'tos.dispute-resolution.block.0.heading': {
en: 'Governing Law.',
'zh-CN': 'Governing Law.'
},
'tos.dispute-resolution.block.1': {
en: 'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.',
'zh-CN':
'本条款受特拉华州法律管辖并据其解释,不适用其冲突法原则。因本条款引起的任何争议应根据美国仲裁协会的规则通过有约束力的仲裁解决,但任何一方均可在有管辖权的法院寻求禁令救济。'
'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': '其他' },
'tos.miscellaneous.title': { en: '15. Miscellaneous', 'zh-CN': '15. 其他' },
'tos.miscellaneous.block.0': {
en: 'These Terms constitute the entire agreement between you and Comfy regarding the Services. If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in effect. Our failure to enforce any right or provision of these Terms will not be considered a waiver. We may assign our rights under these Terms. You may not assign your rights without our prior written consent.',
'tos.dispute-resolution.block.2.heading': {
en: 'Binding Arbitration; JAMS.',
'zh-CN': 'Binding Arbitration; JAMS.'
},
'tos.dispute-resolution.block.3': {
en: 'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.',
'zh-CN':
'本条款构成您与 Comfy 之间关于服务的完整协议。如果本条款的任何条款被认定为不可执行,其余条款将继续有效。我们未能执行本条款的任何权利或条款不构成放弃。我们可以转让本条款下的权利。未经我们事先书面同意,您不得转让您的权利。'
'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'tos.contact.title': { en: 'Contact Us', 'zh-CN': '联系我们' },
'tos.dispute-resolution.block.4.heading': {
en: 'Exceptions; Injunctive Relief.',
'zh-CN': 'Exceptions; Injunctive Relief.'
},
'tos.dispute-resolution.block.5': {
en: 'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.',
'zh-CN':
'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.'
},
'tos.dispute-resolution.block.6.heading': {
en: 'Class Action Waiver.',
'zh-CN': 'Class Action Waiver.'
},
'tos.dispute-resolution.block.7': {
en: 'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.'
},
'tos.dispute-resolution.block.8.heading': {
en: 'Waiver of Jury Trial.',
'zh-CN': 'Waiver of Jury Trial.'
},
'tos.dispute-resolution.block.9': {
en: 'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.'
},
'tos.dispute-resolution.block.10.heading': {
en: 'Exclusive Forum for Court Proceedings.',
'zh-CN': 'Exclusive Forum for Court Proceedings.'
},
'tos.dispute-resolution.block.11': {
en: 'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.',
'zh-CN':
'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.'
},
'tos.dispute-resolution.block.12.heading': {
en: 'Confidentiality.',
'zh-CN': 'Confidentiality.'
},
'tos.dispute-resolution.block.13': {
en: 'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.',
'zh-CN':
'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.'
},
'tos.dispute-resolution.block.14.heading': {
en: 'Time Limit.',
'zh-CN': 'Time Limit.'
},
'tos.dispute-resolution.block.15': {
en: 'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.',
'zh-CN':
'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.'
},
'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': 'MISCELLANEOUS' },
'tos.miscellaneous.title': {
en: '12. Miscellaneous',
'zh-CN': '12. Miscellaneous'
},
'tos.miscellaneous.block.0.heading': {
en: 'Export Compliance.',
'zh-CN': 'Export Compliance.'
},
'tos.miscellaneous.block.1': {
en: 'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.',
'zh-CN':
'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.'
},
'tos.miscellaneous.block.2.heading': {
en: 'Publicity.',
'zh-CN': 'Publicity.'
},
'tos.miscellaneous.block.3': {
en: 'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'You agree that Comfy may refer to your name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'tos.miscellaneous.block.4.heading': {
en: 'Third-Party Infrastructure.',
'zh-CN': 'Third-Party Infrastructure.'
},
'tos.miscellaneous.block.5': {
en: 'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'tos.miscellaneous.block.6.heading': {
en: 'Assignment; Delegation.',
'zh-CN': 'Assignment; Delegation.'
},
'tos.miscellaneous.block.7': {
en: 'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'tos.miscellaneous.block.8.heading': {
en: 'Amendment; Waiver.',
'zh-CN': 'Amendment; Waiver.'
},
'tos.miscellaneous.block.9': {
en: 'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'tos.miscellaneous.block.10.heading': {
en: 'Relationship.',
'zh-CN': 'Relationship.'
},
'tos.miscellaneous.block.11': {
en: 'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'tos.miscellaneous.block.12.heading': {
en: 'Unenforceability.',
'zh-CN': 'Unenforceability.'
},
'tos.miscellaneous.block.13': {
en: 'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'tos.miscellaneous.block.14.heading': {
en: 'Notices.',
'zh-CN': 'Notices.'
},
'tos.miscellaneous.block.15': {
en: 'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.',
'zh-CN':
'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.'
},
'tos.miscellaneous.block.16.heading': {
en: 'Entire Agreement.',
'zh-CN': 'Entire Agreement.'
},
'tos.miscellaneous.block.17': {
en: 'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'tos.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
'tos.contact.title': { en: '13. Contact Us', 'zh-CN': '13. Contact Us' },
'tos.contact.block.0': {
en: 'If you have questions about these Terms, please contact us at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
en: 'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.',
'zh-CN':
'如果您对本条款有任何疑问,请通过 <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a> 与我们联系。'
'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.'
},
// Customers page

View File

@@ -2,13 +2,17 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import ContentSection from '../components/common/ContentSection.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service — Comfy"
description="Terms of Service for ComfyUI and related Comfy services."
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>
<HeroSection title="Terms of Service" />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('tos.effectiveDateLabel')}: {t('tos.effectiveDate')}
</p>
<ContentSection prefix="tos" client:load />
</BaseLayout>

View File

@@ -1,14 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ContentSection from '../../components/common/ContentSection.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="服务条款 — Comfy"
description="ComfyUI 及相关 Comfy 服务的服务条款。"
noindex
>
<HeroSection title="服务条款" />
<ContentSection prefix="tos" locale="zh-CN" client:load />
</BaseLayout>

View File

@@ -29,6 +29,7 @@ interface JobsListRoute {
jobs: readonly RawJobListItem[]
limit?: number
offset?: number
responseLimit?: number
}
interface JobsScenario {
@@ -75,15 +76,16 @@ function isJobsListRequest(url: URL, route: JobsListRoute): boolean {
function createJobsListResponse({
jobs,
limit = defaultJobsListLimit,
offset = defaultJobsListOffset
offset = defaultJobsListOffset,
responseLimit = limit
}: Omit<JobsListRoute, 'statuses'>): JobsListResponse {
const pageJobs = jobs.slice(offset, offset + limit)
const pageJobs = jobs.slice(offset, offset + responseLimit)
return {
jobs: pageJobs,
pagination: {
offset,
limit,
limit: responseLimit,
total: jobs.length,
has_more: offset + pageJobs.length < jobs.length
}
@@ -117,12 +119,14 @@ export class JobsRouteMocker {
async mockJobsHistory(
jobs: readonly RawJobListItem[],
limit = defaultJobsListLimit
limit = defaultJobsListLimit,
options: Pick<JobsListRoute, 'responseLimit'> = {}
): Promise<void> {
await this.mockJobsList({
statuses: terminalJobStatuses,
jobs,
limit
limit,
...options
})
}

View File

@@ -145,7 +145,9 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
subgraphEnterButton: 'subgraph-enter-button',
selectDefaultSearchInput: 'widget-select-default-search-input',
selectDefaultViewport: 'widget-select-default-viewport'
},
linear: {
centerPanel: 'linear-center-panel',

View File

@@ -3,6 +3,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSelectDropdown'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('App mode usage', () => {
test('Drag and Drop', async ({ comfyPage, comfyFiles }) => {
@@ -97,8 +98,9 @@ test.describe('App mode usage', () => {
})
await sampler.click()
await comfyPage.page.getByRole('searchbox').fill('uni')
await comfyPage.page.keyboard.press('ArrowDown')
await comfyPage.page
.getByTestId(TestIds.widgets.selectDefaultSearchInput)
.fill('uni')
await comfyPage.page.keyboard.press('Enter')
await expect(sampler).toHaveText('uni_pc')

View File

@@ -87,7 +87,9 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
const overlay = comfyPage.page
.getByTestId('widget-select-default-overlay')
.first()
await expect(overlay).toBeVisible()
await expect

View File

@@ -1,22 +1,36 @@
import { mergeTests } from '@playwright/test'
import type { Locator, Page, Request } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import {
createRouteMockJob,
jobsRouteFixture
} from '@e2e/fixtures/jobsRouteFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import { webSocketFixture } from '@e2e/fixtures/ws'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const test = mergeTests(comfyPageFixture, webSocketFixture, jobsRouteFixture)
const TOTAL_MOCK_JOBS = 20
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
const overflowJobsListRoutePattern = '**/api/jobs?*'
function createMockJobs(count: number): RawJobListItem[] {
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createRouteMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000
})
)
}
function isHistoryJobsRequest(url: string): boolean {
if (!url.includes('/api/jobs')) return false
@@ -44,21 +58,16 @@ function getJobListResults(page: Page): Locator {
test.describe('Queue settings', { tag: '@canvas' }, () => {
test.describe('Comfy.Queue.MaxHistoryItems', () => {
test.describe('limit query parameter', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(
createMockJobs(TOTAL_MOCK_JOBS)
)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('limit query parameter on /api/jobs reflects the setting', async ({
comfyPage,
getWebSocket
getWebSocket,
jobsRoutes
}) => {
const TARGET_LIMIT = 6
await jobsRoutes.mockJobsHistory(
createMockJobs(TOTAL_MOCK_JOBS),
TARGET_LIMIT
)
await comfyPage.settings.setSetting(
MAX_HISTORY_ITEMS_SETTING,
TARGET_LIMIT
@@ -73,39 +82,14 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
test('queue panel caps history items to the configured number', async ({
comfyPage,
getWebSocket
getWebSocket,
jobsRoutes
}) => {
// Add a mock route that returns all jobs regardless of the request's `limit` param
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await comfyPage.page.route(
overflowJobsListRoutePattern,
async (route) => {
const url = new URL(route.request().url())
if (!url.searchParams.get('status')?.includes('completed')) {
await route.continue()
return
}
const response = {
jobs: overflowJobs,
pagination: {
offset: 0,
limit: overflowJobs.length,
total: overflowJobs.length,
has_more: false
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
)
const VISIBLE_LIMIT = 6
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
await jobsRoutes.mockJobsHistory(overflowJobs, VISIBLE_LIMIT, {
responseLimit: overflowJobs.length
})
await comfyPage.settings.setSetting(
MAX_HISTORY_ITEMS_SETTING,
VISIBLE_LIMIT

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,9 +2,12 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Locator } from '@playwright/test'
test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
test('opens a dropdown that lists sampler options', async ({ comfyPage }) => {
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
async function openSamplerDropdown(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('vueNodes/linked-int-widget')
const samplerCombo = comfyPage.vueNodes
@@ -13,6 +16,120 @@ test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
await samplerCombo.click()
const viewport = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(viewport).toBeVisible()
return viewport
}
async function pressDropdownScrollbar(
comfyPage: ComfyPage,
viewport: Locator
) {
const { x, y } = await getScrollbarPressPoint(viewport)
await comfyPage.page.mouse.move(x, y)
await comfyPage.page.mouse.down()
await expect(viewport).toBeVisible()
await comfyPage.page.mouse.up()
}
async function getCanvasViewport(comfyPage: ComfyPage) {
return comfyPage.page.evaluate(() => ({
scale: window.app!.canvas.ds.scale,
offset: [...window.app!.canvas.ds.offset]
}))
}
async function getViewportBox(viewport: Locator) {
await expect.poll(() => viewport.boundingBox()).not.toBeNull()
const box = await viewport.boundingBox()
if (!box) {
throw new Error('Widget select viewport is not visible')
}
return box
}
async function getScrollbarPressPoint(viewport: Locator) {
await expect
.poll(() =>
viewport.evaluate(
(element) => element.scrollHeight > element.clientHeight
)
)
.toBe(true)
return viewport.evaluate((element) => {
const viewportElement = element as HTMLElement
const rect = viewportElement.getBoundingClientRect()
const scrollbarWidth =
viewportElement.offsetWidth - viewportElement.clientWidth
const scrollbarInset = scrollbarWidth > 0 ? scrollbarWidth / 2 : 2
return {
x: rect.right - scrollbarInset,
y: rect.top + Math.min(rect.height / 2, 20)
}
})
}
async function getMixedGraphSamplerCombos(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items')
await comfyPage.vueNodes.waitForNodes(3)
const nodes = comfyPage.vueNodes.getNodeByTitle('KSampler')
await expect(nodes).toHaveCount(3)
return {
firstSamplerCombo: nodes
.nth(0)
.getByRole('combobox', { name: 'sampler_name', exact: true }),
secondSamplerCombo: nodes
.nth(2)
.getByRole('combobox', { name: 'sampler_name', exact: true })
}
}
async function getActiveWidgetSelectViewport(comfyPage: ComfyPage) {
const viewport = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(viewport).toBeVisible()
return viewport
}
async function expectWheelScrollsDropdownWithoutMovingCanvas(
comfyPage: ComfyPage,
viewport: Locator
) {
const canvasViewportBefore = await getCanvasViewport(comfyPage)
await viewport.evaluate((el) => {
el.scrollTop = 0
})
const scrollBefore = 0
const box = await getViewportBox(viewport)
await comfyPage.page.mouse.move(
box.x + box.width / 2,
box.y + Math.min(box.height / 2, 40)
)
await comfyPage.page.mouse.wheel(0, 120)
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(scrollBefore)
const canvasViewportAfter = await getCanvasViewport(comfyPage)
expect(canvasViewportAfter).toEqual(canvasViewportBefore)
}
test('opens a dropdown that lists sampler options', async ({ comfyPage }) => {
await openSamplerDropdown(comfyPage)
// The option list should include at least a few known samplers
await expect(
comfyPage.page.getByRole('option', { name: 'euler', exact: true })
@@ -40,6 +157,99 @@ test.describe('Vue Combo Widget', { tag: '@vue-nodes' }, () => {
await expect(samplerCombo).toContainText('dpmpp_2m')
})
test('mouse wheel scrolls the dropdown list instead of zooming the canvas', async ({
comfyPage
}) => {
const viewport = await openSamplerDropdown(comfyPage)
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
})
test('keeps the dropdown open when the scrollbar is pressed', async ({
comfyPage
}) => {
const viewport = await openSamplerDropdown(comfyPage)
await pressDropdownScrollbar(comfyPage, viewport)
await expect(viewport).toBeVisible()
})
test('closes the dropdown when clicking outside', async ({ comfyPage }) => {
const viewport = await openSamplerDropdown(comfyPage)
await comfyPage.page.mouse.click(10, 10)
await expect(viewport).toBeHidden()
})
test('keeps wheel scrolling captured after the scrollbar is pressed', async ({
comfyPage
}) => {
const viewport = await openSamplerDropdown(comfyPage)
await pressDropdownScrollbar(comfyPage, viewport)
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
})
test('closes the previous dropdown when another node widget opens', async ({
comfyPage
}) => {
const { firstSamplerCombo, secondSamplerCombo } =
await getMixedGraphSamplerCombos(comfyPage)
await firstSamplerCombo.click()
const viewport = await getActiveWidgetSelectViewport(comfyPage)
await pressDropdownScrollbar(comfyPage, viewport)
await expect(viewport).toBeVisible()
await secondSamplerCombo.click()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
).toHaveCount(1)
await expect(
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
).toBeVisible()
})
test('preserves dropdown scroll capture when switching between node widgets', async ({
comfyPage
}) => {
const { firstSamplerCombo, secondSamplerCombo } =
await getMixedGraphSamplerCombos(comfyPage)
await firstSamplerCombo.click()
const viewport = await getActiveWidgetSelectViewport(comfyPage)
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
await pressDropdownScrollbar(comfyPage, viewport)
await expect(viewport).toBeVisible()
await expectWheelScrollsDropdownWithoutMovingCanvas(comfyPage, viewport)
await secondSamplerCombo.click()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.selectDefaultViewport)
).toHaveCount(1)
const secondViewport = await getActiveWidgetSelectViewport(comfyPage)
await expectWheelScrollsDropdownWithoutMovingCanvas(
comfyPage,
secondViewport
)
await pressDropdownScrollbar(comfyPage, secondViewport)
await expect(secondViewport).toBeVisible()
await expectWheelScrollsDropdownWithoutMovingCanvas(
comfyPage,
secondViewport
)
})
test('persists the selected combo value across a serialize and reload round-trip', async ({
comfyPage
}) => {

View File

@@ -161,6 +161,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistenceV2'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSubgraphDragBridge } from '@/renderer/core/canvas/links/useSubgraphDragBridge'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
@@ -463,6 +464,7 @@ useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useSubgraphDragBridge()
// Start watching for locale change after the initial value is loaded.
watch(

View File

@@ -1,19 +0,0 @@
<script>
import Select from 'primevue/select'
export default {
name: 'SelectPlus',
extends: Select,
emits: ['hide'],
methods: {
onOverlayLeave() {
this.unbindOutsideClickListener()
this.unbindScrollListener()
this.unbindResizeListener()
this.$emit('hide')
this.overlay = null
}
}
}
</script>

View File

@@ -445,6 +445,7 @@ export class LinkConnector {
this.state.connectingTo = 'input'
this._setLegacyLinks(false)
this.events.dispatch('connecting', { connectingTo: 'input' })
}
dragNewFromSubgraphOutput(
@@ -466,6 +467,7 @@ export class LinkConnector {
this.state.connectingTo = 'output'
this._setLegacyLinks(true)
this.events.dispatch('connecting', { connectingTo: 'output' })
}
/**
@@ -878,7 +880,10 @@ export class LinkConnector {
(link) => link instanceof MovingInputLink && link.disconnectOnDrop
).forEach((link) => (link as MovingLinkBase).disconnect())
if (this.renderLinks.length === 0) return
// For external event only.
const intercepted = this.events.dispatch('before-drop-on-canvas', event)
if (intercepted === false) return
const mayContinue = this.events.dispatch('dropped-on-canvas', event)
if (mayContinue === false) return

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import {
createMockCanvasPointerEvent,
createMockLGraphNode,
createMockLinkNetwork,
createMockNodeOutputSlot
} from '@/utils/__tests__/litegraphTestUtils'
const mockSetConnectingLinks = vi.fn()
type RenderLinkItem = LinkConnector['renderLinks'][number]
function createMockRenderLink(): RenderLinkItem {
const partial: Partial<RenderLinkItem> = {
toType: 'input',
fromPos: [0, 0],
fromSlotIndex: 0,
fromDirection: 0,
network: createMockLinkNetwork(),
node: createMockLGraphNode(),
fromSlot: createMockNodeOutputSlot(),
dragDirection: 0,
canConnectToInput: vi.fn().mockReturnValue(false),
canConnectToOutput: vi.fn().mockReturnValue(false),
canConnectToReroute: vi.fn().mockReturnValue(false),
connectToInput: vi.fn(),
connectToOutput: vi.fn(),
connectToSubgraphInput: vi.fn(),
connectToRerouteOutput: vi.fn(),
connectToSubgraphOutput: vi.fn(),
connectToRerouteInput: vi.fn()
}
return partial as RenderLinkItem
}
describe('LinkConnector.dropOnNothing event dispatch', () => {
let connector: LinkConnector
beforeEach(() => {
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})
test('dispatches before-drop-on-canvas before dropped-on-canvas', () => {
connector.renderLinks.push(createMockRenderLink())
const callOrder: string[] = []
connector.events.addEventListener('before-drop-on-canvas', () => {
callOrder.push('before-drop-on-canvas')
})
connector.events.addEventListener('dropped-on-canvas', () => {
callOrder.push('dropped-on-canvas')
})
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(callOrder).toEqual(['before-drop-on-canvas', 'dropped-on-canvas'])
})
test('skips dropped-on-canvas when before-drop-on-canvas is intercepted', () => {
connector.renderLinks.push(createMockRenderLink())
const droppedListener = vi.fn()
connector.events.addEventListener('before-drop-on-canvas', (e) => {
e.preventDefault()
})
connector.events.addEventListener('dropped-on-canvas', droppedListener)
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(droppedListener).not.toHaveBeenCalled()
})
test('does not dispatch events when renderLinks is empty', () => {
const beforeListener = vi.fn()
const droppedListener = vi.fn()
connector.events.addEventListener('before-drop-on-canvas', beforeListener)
connector.events.addEventListener('dropped-on-canvas', droppedListener)
connector.dropOnNothing(createMockCanvasPointerEvent(100, 100))
expect(beforeListener).not.toHaveBeenCalled()
expect(droppedListener).not.toHaveBeenCalled()
})
})

View File

@@ -15,6 +15,10 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
export interface LinkConnectorEventMap {
reset: boolean
connecting: {
connectingTo: 'input' | 'output'
}
'before-drop-links': {
renderLinks: RenderLink[]
event: CanvasPointerEvent
@@ -44,6 +48,7 @@ export interface LinkConnectorEventMap {
node: SubgraphInputNode | SubgraphOutputNode
event: CanvasPointerEvent
}
'before-drop-on-canvas': CanvasPointerEvent
'dropped-on-canvas': CanvasPointerEvent
'dropped-on-widget': {

View File

@@ -0,0 +1,100 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
describe('SubgraphInput.connect triggers node:slot-links:changed', () => {
subgraphTest(
'fires connected event when connecting to a widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const node = new LGraphNode('Target')
node.addInput('prompt', 'STRING')
node.inputs[0].widget = { name: 'prompt' }
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: true,
linkId: expect.any(Number)
})
}
)
subgraphTest(
'does not fire event when connecting to a non-widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const triggerSpy = vi.spyOn(subgraph, 'trigger')
const node = new LGraphNode('Target')
node.addInput('in', 'number')
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
'node:slot-links:changed'
)
}
)
})
describe('SubgraphInputNode._disconnectNodeInput triggers node:slot-links:changed', () => {
subgraphTest(
'fires disconnected event when disconnecting a widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const node = new LGraphNode('Target')
node.addInput('prompt', 'STRING')
node.inputs[0].widget = { name: 'prompt' }
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(link).toBeDefined()
const triggerSpy = vi.spyOn(subgraph, 'trigger')
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
expect(triggerSpy).toHaveBeenCalledWith('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: link!.id
})
}
)
subgraphTest(
'does not fire event when disconnecting a non-widget input',
({ simpleSubgraph }) => {
const subgraph = simpleSubgraph
const node = new LGraphNode('Target')
node.addInput('in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
expect(link).toBeDefined()
const triggerSpy = vi.spyOn(subgraph, 'trigger')
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link!)
expect(triggerSpy.mock.calls.map((c) => c[0])).not.toContain(
'node:slot-links:changed'
)
}
)
})

View File

@@ -136,6 +136,16 @@ export class SubgraphInput extends SubgraphSlot {
}
subgraph.incrementVersion()
if (slot.widget) {
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
subgraph.afterChange()

View File

@@ -180,7 +180,17 @@ export class SubgraphInputNode
}
}
const linkId = input.link
input.link = null
if (input.widget) {
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: node.inputs.indexOf(input),
connected: false,
linkId: linkId
})
}
subgraph.setDirtyCanvas(false, true)
if (!link) return

View File

@@ -140,7 +140,6 @@ 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)

View File

@@ -0,0 +1,360 @@
import { tryOnScopeDispose, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { SubgraphIONodeBase } from '@/lib/litegraph/src/subgraph/SubgraphIONodeBase'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { resolvePointerTarget } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useNodeSlotRegistryStore } from '@/renderer/extensions/vueNodes/stores/nodeSlotRegistryStore'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
const SNAP_CLASS = 'lg-slot--snap-target'
/**
* Bridges canvas-initiated subgraph IO drags to Vue slot drag state.
*
* When a drag starts from a SubgraphInput or SubgraphOutput node
* (canvas-drawn), the Vue slot components need to know about it so they
* can dim incompatible slots, snap links, and highlight compatible targets.
*/
export function useSubgraphDragBridge() {
const canvasStore = useCanvasStore()
const { canvas } = storeToRefs(canvasStore)
const {
state: dragState,
beginDrag,
endDrag,
setCandidate,
setCompatibleForKey
} = useSlotLinkDragUIState()
let cleanup: (() => void) | undefined
whenever(canvas, (lgCanvas) => {
cleanup?.()
cleanup = setupBridge(lgCanvas)
})
tryOnScopeDispose(() => {
cleanup?.()
cleanup = undefined
})
/** Wires up LinkConnector event listeners and returns a cleanup function. */
function setupBridge(lgCanvas: LGraphCanvas): () => void {
const linkConnector: LinkConnector = lgCanvas.linkConnector
let teardownDrag: (() => void) | undefined
let isBridgeDrag = false
const onConnecting = (
event: CustomEvent<{ connectingTo: 'input' | 'output' }>
) => {
teardownDrag?.()
teardownDrag = undefined
const { connectingTo } = event.detail
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const renderLink = adapter.renderLinks[0]
if (!renderLink) return
// Only bridge SubgraphInput/SubgraphOutput drags; other canvas drags
// (e.g., reroutes) should not activate Vue slot drag UI state.
if (!(renderLink.node instanceof SubgraphIONodeBase)) return
const sourceType: 'input' | 'output' =
connectingTo === 'input' ? 'output' : 'input'
isBridgeDrag = true
beginDrag(
{
nodeId: String(renderLink.node.id),
slotIndex: renderLink.fromSlotIndex,
type: sourceType,
direction: renderLink.fromDirection ?? LinkDirection.RIGHT,
position: {
x: renderLink.fromPos[0],
y: renderLink.fromPos[1]
}
},
-1
)
const allKeys = layoutStore.getAllSlotKeys()
for (const key of allKeys) {
const slotLayout = layoutStore.getSlotLayout(key)
if (!slotLayout) continue
if (slotLayout.type !== connectingTo) continue
const ok =
connectingTo === 'input'
? adapter.isInputValidDrop(slotLayout.nodeId, slotLayout.index)
: adapter.isOutputValidDrop(slotLayout.nodeId, slotLayout.index)
setCompatibleForKey(key, ok)
}
teardownDrag = startPointerTracking(lgCanvas, linkConnector)
}
const onBeforeDropOnCanvas = (event: CustomEvent) => {
if (!isBridgeDrag) return
const candidate = dragState.candidate
if (!candidate?.compatible) return
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const connected = connectToCandidate(
adapter.renderLinks,
adapter.network,
candidate,
linkConnector
)
if (connected) event.preventDefault()
}
const onReset = () => {
if (!isBridgeDrag) return
teardownDrag?.()
teardownDrag = undefined
isBridgeDrag = false
endDrag()
}
linkConnector.events.addEventListener('connecting', onConnecting)
linkConnector.events.addEventListener(
'before-drop-on-canvas',
onBeforeDropOnCanvas
)
linkConnector.events.addEventListener('reset', onReset)
return () => {
linkConnector.events.removeEventListener('connecting', onConnecting)
linkConnector.events.removeEventListener(
'before-drop-on-canvas',
onBeforeDropOnCanvas
)
linkConnector.events.removeEventListener('reset', onReset)
teardownDrag?.()
teardownDrag = undefined
if (isBridgeDrag) {
isBridgeDrag = false
endDrag()
}
}
}
/**
* Tracks pointer movement during a bridge drag to resolve Vue slot
* candidates, update snap positions, and toggle snap-target highlights.
* Returns a cleanup function that removes the listener and RAF batch.
*/
function startPointerTracking(
lgCanvas: LGraphCanvas,
linkConnector: LinkConnector
): () => void {
const ownerDoc = lgCanvas.getCanvasWindow().document
const session = createSlotLinkDragContext()
const slotRegistry = useNodeSlotRegistryStore()
let pendingMove: { clientX: number; clientY: number } | null = null
let highlightedSlotEl: HTMLElement | null = null
/** Resolves the Vue slot under the pointer and updates snap/highlight state. */
const processFrame = () => {
const data = pendingMove
if (!data) return
pendingMove = null
const adapter = createLinkConnectorAdapter()
if (!adapter) return
const graph = adapter.network
const target = resolvePointerTarget(data.clientX, data.clientY, null)
let hoveredSlotKey: string | null = null
let hoveredNodeId: NodeId | null = null
if (target === session.lastPointerEventTarget) {
hoveredSlotKey = session.lastPointerTargetSlotKey
hoveredNodeId = session.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target
.closest('.lg-slot, .lg-node-widget')
?.querySelector<HTMLElement>('[data-slot-key]')
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
hoveredNodeId = elWithNode?.dataset['nodeId'] ?? null
session.lastPointerEventTarget = target
session.lastPointerTargetSlotKey = hoveredSlotKey
session.lastPointerTargetNodeId = hoveredNodeId
}
const hoverChanged =
hoveredSlotKey !== session.lastHoverSlotKey ||
hoveredNodeId !== session.lastHoverNodeId
let candidate = dragState.candidate
if (hoverChanged) {
const context = { adapter, graph, session }
const slotCandidate = resolveSlotTargetCandidate(target, context)
const nodeCandidate = resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate?.compatible ? slotCandidate : nodeCandidate
session.lastHoverSlotKey = hoveredSlotKey
session.lastHoverNodeId = hoveredNodeId
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!slotCandidate.compatible)
}
if (nodeCandidate && !slotCandidate?.compatible) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!nodeCandidate.compatible)
}
}
const newCandidate = candidate?.compatible ? candidate : null
const newCandidateKey = newCandidate
? getSlotKey(
newCandidate.layout.nodeId,
newCandidate.layout.index,
newCandidate.layout.type === 'input'
)
: null
const candidateChanged = newCandidateKey !== session.lastCandidateKey
if (candidateChanged) {
setCandidate(newCandidate)
session.lastCandidateKey = newCandidateKey
updateSnapTargetHighlight(newCandidate)
}
const snapPos = newCandidate
? ([newCandidate.layout.position.x, newCandidate.layout.position.y] as [
number,
number
])
: undefined
const currentSnap = linkConnector.state.snapLinksPos
const snapPosChanged = snapPos
? !currentSnap ||
currentSnap[0] !== snapPos[0] ||
currentSnap[1] !== snapPos[1]
: !!currentSnap
if (snapPosChanged) {
linkConnector.state.snapLinksPos = snapPos
}
if (candidateChanged || snapPosChanged) {
app.canvas?.setDirty(true, true)
}
}
/** Toggles the `lg-slot--snap-target` CSS class on the candidate's slot element. */
function updateSnapTargetHighlight(candidate: SlotDropCandidate | null) {
if (highlightedSlotEl) {
highlightedSlotEl.classList.remove(SNAP_CLASS)
highlightedSlotEl = null
}
if (!candidate) return
const key = getSlotKey(
candidate.layout.nodeId,
candidate.layout.index,
candidate.layout.type === 'input'
)
const entry = slotRegistry
.getNode(candidate.layout.nodeId)
?.slots.get(key)
const groupEl = entry?.el?.parentElement
if (groupEl) {
groupEl.classList.add(SNAP_CLASS)
highlightedSlotEl = groupEl
}
}
const raf = createRafBatch(processFrame)
/** Buffers the latest pointer position and schedules a RAF frame. */
const onPointerMove = (e: PointerEvent) => {
pendingMove = { clientX: e.clientX, clientY: e.clientY }
raf.schedule()
}
ownerDoc.addEventListener('pointermove', onPointerMove, { capture: true })
return () => {
ownerDoc.removeEventListener('pointermove', onPointerMove, {
capture: true
})
raf.cancel()
if (highlightedSlotEl) {
highlightedSlotEl.classList.remove(SNAP_CLASS)
highlightedSlotEl = null
}
session.dispose()
}
}
}
/**
* Connects render links to the snapped Vue slot candidate.
* Returns `true` if at least one link was connected.
*/
function connectToCandidate(
links: ReadonlyArray<RenderLink>,
network: { getNodeById(id: NodeId): LGraphNode | null },
candidate: SlotDropCandidate,
linkConnector: LinkConnector
): boolean {
const node = network.getNodeById(candidate.layout.nodeId)
if (!node) return false
let connected = false
if (candidate.layout.type === 'input') {
const input = node.inputs?.[candidate.layout.index]
if (!input) return false
for (const link of links) {
if (link.toType !== 'input') continue
if (!link.canConnectToInput(node, input)) continue
link.connectToInput(node, input, linkConnector.events)
connected = true
}
} else {
const output = node.outputs?.[candidate.layout.index]
if (!output) return false
for (const link of links) {
if (link.toType !== 'output') continue
if (!link.canConnectToOutput(node, output)) continue
link.connectToOutput(node, output, linkConnector.events)
connected = true
}
}
return connected
}

View File

@@ -52,7 +52,7 @@ const slotClass = computed(() =>
'border border-solid border-node-component-slot-dot-outline',
props.multi
? 'h-6 w-3'
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
: 'size-3 cursor-crosshair group-hover/slot:scale-125 group-hover/slot:[--node-component-slot-dot-outline-opacity-mult:5] group-[.lg-slot--snap-target]/slot:scale-125 group-[.lg-slot--snap-target]/slot:[--node-component-slot-dot-outline-opacity-mult:5]'
)
)
</script>

View File

@@ -12,6 +12,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import {
clearCanvasPointerHistory,
@@ -33,6 +34,7 @@ import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/compos
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
import { isSubgraph } from '@/utils/typeGuardUtil'
interface SlotInteractionOptions {
nodeId: string
@@ -390,14 +392,44 @@ export function useSlotLinkInteraction({
dragContext.lastCandidateKey = newCandidateKey
}
let subgraphIOSnapPos: [number, number] | null = null
let subgraphIOHoverChanged = false
const graph = app.canvas?.graph
if (isSubgraph(graph)) {
const pointerEvent = { canvasX, canvasY } as CanvasPointerEvent
for (const node of [graph.inputNode, graph.outputNode]) {
if (!node) continue
const wasPointerOver = node.isPointerOver
node.onPointerMove(pointerEvent)
if (wasPointerOver !== node.isPointerOver) {
subgraphIOHoverChanged = true
}
if (node.isPointerOver) {
const slot = node.getSlotInPosition(canvasX, canvasY)
if (slot && slot.isPointerOver) {
subgraphIOSnapPos = slot.pos
}
}
}
}
let snapPosChanged = false
if (activeAdapter) {
const snapX = newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const snapX = subgraphIOSnapPos
? subgraphIOSnapPos[0]
: newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = subgraphIOSnapPos
? subgraphIOSnapPos[1]
: newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
snapPosChanged =
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
@@ -406,7 +438,8 @@ export function useSlotLinkInteraction({
}
}
const shouldRedraw = candidateChanged || snapPosChanged
const shouldRedraw =
candidateChanged || snapPosChanged || subgraphIOHoverChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
}
const raf = createRafBatch(processPointerMoveFrame)
@@ -517,8 +550,6 @@ export function useSlotLinkInteraction({
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true, true)

View File

@@ -1,7 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, fireEvent } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import type { SelectProps } from 'primevue/select'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -57,7 +55,7 @@ const WidgetSelectDefaultStub = defineComponent({
})
const globalConfig = {
plugins: [PrimeVue, createTestingPinia(), i18n],
plugins: [createTestingPinia(), i18n],
stubs: {
WidgetSelectDropdown: WidgetSelectDropdownStub,
WidgetSelectDefault: WidgetSelectDefaultStub,
@@ -74,11 +72,14 @@ describe('WidgetSelect Value Binding', () => {
vi.clearAllMocks()
})
type SelectWidgetOptions = {
values?: string[]
return_index?: boolean
}
const createSelectWidget = (
value: string = 'option1',
options: Partial<
SelectProps & { values?: string[]; return_index?: boolean }
> = {},
options: SelectWidgetOptions = {},
callback?: (value: string | undefined) => void,
spec?: ComboInputSpec
) =>
@@ -113,7 +114,7 @@ describe('WidgetSelect Value Binding', () => {
}
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
it('forwards model updates from the default select', async () => {
const widget = createSelectWidget('option1')
const onModelUpdate = renderComponent(widget, 'option1')
@@ -122,105 +123,6 @@ describe('WidgetSelect Value Binding', () => {
expect(onModelUpdate).toHaveBeenCalledWith('option2')
})
it('emits string value for different options', async () => {
const widget = createSelectWidget('option1')
const onModelUpdate = renderComponent(widget, 'option1')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, 'option3')
expect(onModelUpdate).toHaveBeenCalledWith('option3')
})
it('handles custom option values', async () => {
const customOptions = ['custom_a', 'custom_b', 'custom_c']
const widget = createSelectWidget('custom_a', { values: customOptions })
const onModelUpdate = renderComponent(widget, 'custom_a')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, 'custom_b')
expect(onModelUpdate).toHaveBeenCalledWith('custom_b')
})
it('handles missing callback gracefully', async () => {
const widget = createSelectWidget('option1', {}, undefined)
const onModelUpdate = renderComponent(widget, 'option1')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, 'option2')
expect(onModelUpdate).toHaveBeenCalledWith('option2')
})
it('handles value changes gracefully', async () => {
const widget = createSelectWidget('option1')
const onModelUpdate = renderComponent(widget, 'option1')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, 'option2')
expect(onModelUpdate).toHaveBeenCalledWith('option2')
})
})
describe('Option Handling', () => {
it('handles empty options array', () => {
const widget = createSelectWidget('', { values: [] })
renderComponent(widget, '')
expect(screen.getByTestId('widget-select-default')).toBeInTheDocument()
})
it('handles single option', () => {
const widget = createSelectWidget('only_option', {
values: ['only_option']
})
renderComponent(widget, 'only_option')
expect(screen.getByTestId('widget-select-default')).toBeInTheDocument()
})
it('handles options with special characters', async () => {
const specialOptions = [
'option with spaces',
'option@#$%',
'option/with\\slashes'
]
const widget = createSelectWidget(specialOptions[0], {
values: specialOptions
})
const onModelUpdate = renderComponent(widget, specialOptions[0])
const input = screen.getByTestId('select-input')
await fireEvent.update(input, specialOptions[1])
expect(onModelUpdate).toHaveBeenCalledWith(specialOptions[1])
})
})
describe('Edge Cases', () => {
it('handles selection of non-existent option gracefully', async () => {
const widget = createSelectWidget('option1')
const onModelUpdate = renderComponent(widget, 'option1')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, 'non_existent_option')
expect(onModelUpdate).toHaveBeenCalledWith('non_existent_option')
})
it('handles numeric string options correctly', async () => {
const numericOptions = ['1', '2', '10', '100']
const widget = createSelectWidget('1', { values: numericOptions })
const onModelUpdate = renderComponent(widget, '1')
const input = screen.getByTestId('select-input')
await fireEvent.update(input, '100')
expect(onModelUpdate).toHaveBeenCalledWith('100')
})
})
describe('node-type prop passing', () => {

View File

@@ -1,114 +1,379 @@
import { render, screen, waitFor } from '@testing-library/vue'
import { fireEvent, render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { defineComponent } from 'vue'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
noResultsFound: 'No results found',
search: 'Search'
}
}
}
})
const WidgetLayoutFieldStub = defineComponent({
name: 'WidgetLayoutField',
template: '<div><slot /></div>'
template: '<div class="relative"><slot /></div>'
})
const SelectPlusStub = defineComponent({
name: 'SelectPlus',
props: {
options: { type: Array, default: () => [] },
modelValue: { type: String, default: undefined }
},
emits: ['update:modelValue', 'show', 'filter'],
template: `<div data-testid="select-plus" :data-options="JSON.stringify(options)">
<button data-testid="trigger-show" @click="$emit('show')">show</button>
<button data-testid="trigger-filter" @click="$emit('filter')">filter</button>
</div>`
})
function getSelectOptions(): string[] {
const el = screen.getByTestId('select-plus')
return JSON.parse(el.getAttribute('data-options') ?? '[]')
}
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
describe('WidgetSelectDefault', () => {
const createWidget = (
values: unknown
values: unknown,
options: Record<string, unknown> = {}
): SimplifiedWidget<string | undefined> => ({
name: 'test_combo',
type: 'combo',
value: undefined,
options: { values } as SimplifiedWidget['options']
options: { values, ...options } as SimplifiedWidget['options']
})
function renderComponent(widget: SimplifiedWidget<string | undefined>) {
return render(WidgetSelectDefault, {
props: { widget },
function renderComponent(
widget: SimplifiedWidget<string | undefined>,
modelValue?: string,
slots?: Record<string, string>
) {
const onUpdate = vi.fn()
const user = userEvent.setup()
const result = render(WidgetSelectDefault, {
props: {
widget,
modelValue,
'onUpdate:modelValue': onUpdate
},
slots,
global: {
plugins: [PrimeVue],
plugins: [i18n],
stubs: {
SelectPlus: SelectPlusStub,
WidgetLayoutField: WidgetLayoutFieldStub
}
}
})
return { ...result, onUpdate, user }
}
describe('array-valued options', () => {
it('resolves options from a plain array', () => {
renderComponent(createWidget(['a', 'b', 'c']))
async function openDropdown(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByTestId('widget-select-default-trigger'))
await nextTick()
await flushPromises()
}
expect(getSelectOptions()).toEqual(['a', 'b', 'c'])
const optionLabels = () =>
screen.queryAllByRole('option').map((option) => option.textContent?.trim())
async function expectHighlightedOption(name: string) {
await waitFor(() => {
expect(screen.getByRole('option', { name })).toHaveAttribute(
'data-highlighted'
)
})
}
describe('option sources', () => {
it('resolves options from a plain array', async () => {
const { user } = renderComponent(createWidget(['a', 'b', 'c']))
await openDropdown(user)
expect(optionLabels()).toEqual(['a', 'b', 'c'])
})
it('reactively updates when widget prop changes', async () => {
const { rerender } = renderComponent(createWidget(['x', 'y']))
const { rerender, user } = renderComponent(createWidget(['x', 'y']))
await rerender({ widget: createWidget(['x', 'y', 'z']) })
await openDropdown(user)
expect(getSelectOptions()).toEqual(['x', 'y', 'z'])
})
})
describe('undefined/empty options', () => {
it('returns empty array when values is undefined', () => {
renderComponent(createWidget(undefined))
expect(getSelectOptions()).toEqual([])
})
})
describe('function-valued options', () => {
it('resolves options from a function', () => {
renderComponent(createWidget(() => ['a', 'b', 'c']))
expect(getSelectOptions()).toEqual(['a', 'b', 'c'])
expect(optionLabels()).toEqual(['x', 'y', 'z'])
})
it('re-evaluates function on show event', async () => {
it('returns empty options when values is undefined', async () => {
const { user } = renderComponent(createWidget(undefined))
await openDropdown(user)
expect(optionLabels()).toEqual([])
expect(screen.getByRole('status')).toHaveTextContent('No results found')
})
it('resolves options from a function', async () => {
const { user } = renderComponent(createWidget(() => ['a', 'b', 'c']))
await openDropdown(user)
expect(optionLabels()).toEqual(['a', 'b', 'c'])
})
it('falls back to empty options when function values throw', async () => {
const error = new Error('failed to load values')
const values = vi.fn(() => {
throw error
})
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
try {
const { user } = renderComponent(createWidget(values))
await openDropdown(user)
expect(optionLabels()).toEqual([])
expect(screen.getByRole('status')).toHaveTextContent('No results found')
expect(consoleError).toHaveBeenCalledWith(
'[WidgetSelectDefault] Failed to resolve options',
error
)
} finally {
consoleError.mockRestore()
}
})
it('re-evaluates function values when opened', async () => {
let items = ['x', 'y']
renderComponent(createWidget(() => items))
const { user } = renderComponent(createWidget(() => items))
items = ['x', 'y', 'z']
const user = userEvent.setup()
await user.click(screen.getByTestId('trigger-show'))
await openDropdown(user)
await waitFor(() => {
expect(getSelectOptions()).toEqual(['x', 'y', 'z'])
})
expect(optionLabels()).toEqual(['x', 'y', 'z'])
})
it('re-evaluates function on filter event', async () => {
let items = ['a']
renderComponent(createWidget(() => items))
it('does not re-evaluate function values on each search keystroke', async () => {
let items = ['alpha', 'bravo', 'charlie', 'delta', 'echo']
const values = vi.fn(() => items)
const { user } = renderComponent(createWidget(values))
items = ['a', 'b']
const user = userEvent.setup()
await user.click(screen.getByTestId('trigger-filter'))
await openDropdown(user)
values.mockClear()
items = ['alpha', 'bravo', 'charlie', 'delta', 'zeta']
await user.type(screen.getByRole('combobox', { name: 'Search' }), 'z')
await waitFor(() => {
expect(getSelectOptions()).toEqual(['a', 'b'])
expect(values).not.toHaveBeenCalled()
expect(optionLabels()).toEqual([])
})
it('does not remap array option labels on each search keystroke', async () => {
const getOptionLabel = vi.fn((value?: string | null) => value ?? '')
const { user } = renderComponent(
createWidget(['alpha', 'bravo', 'charlie', 'delta', 'echo'], {
getOptionLabel
})
)
await openDropdown(user)
getOptionLabel.mockClear()
await user.type(screen.getByRole('combobox', { name: 'Search' }), 'alp')
expect(getOptionLabel).not.toHaveBeenCalled()
})
})
describe('selection behavior', () => {
it('emits model updates when an option is selected', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['a', 'b', 'c']),
'a'
)
await openDropdown(user)
await user.click(screen.getByRole('option', { name: 'b' }))
expect(onUpdate).toHaveBeenCalledWith('b')
})
it('emits model updates when the current value is undefined', async () => {
const { onUpdate, user } = renderComponent(createWidget(['a', 'b', 'c']))
await openDropdown(user)
await user.click(screen.getByRole('option', { name: 'b' }))
expect(onUpdate).toHaveBeenCalledWith('b')
})
it('allows selecting an explicit empty string option', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['filled', ''], {
getOptionLabel: (value?: string | null) =>
value === '' ? 'Empty option' : value
}),
'filled'
)
await openDropdown(user)
await user.click(screen.getByRole('option', { name: 'Empty option' }))
expect(onUpdate).toHaveBeenCalledWith('')
})
it('selects the top filtered result when Enter is pressed after typing', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['alpha', 'bravo', 'charlie', 'delta', 'echo']),
''
)
await openDropdown(user)
await user.type(screen.getByRole('combobox', { name: 'Search' }), 'alp')
await expectHighlightedOption('alpha')
await user.keyboard('{Enter}')
expect(onUpdate).toHaveBeenCalledWith('alpha')
})
it('allows Arrow navigation followed by Enter to select a filtered result', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['alpha', 'alpine', 'bravo', 'charlie', 'delta']),
''
)
await openDropdown(user)
await user.type(screen.getByRole('combobox', { name: 'Search' }), 'al')
await expectHighlightedOption('alpha')
await user.keyboard('{ArrowDown}{Enter}')
expect(onUpdate).toHaveBeenCalledWith('alpine')
})
it('does not emit a blank value when Escape closes the dropdown', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['alpha', 'bravo', 'charlie', 'delta', 'echo']),
'alpha'
)
await openDropdown(user)
await user.keyboard('{Escape}')
expect(onUpdate).not.toHaveBeenCalledWith('')
})
it('does not select an empty string option when Escape closes the dropdown', async () => {
const { onUpdate, user } = renderComponent(
createWidget(['alpha', ''], {
getOptionLabel: (value?: string | null) =>
value === '' ? 'Empty option' : value
}),
'alpha'
)
await openDropdown(user)
await user.keyboard('{Escape}')
expect(onUpdate).not.toHaveBeenCalledWith('')
})
})
describe('focus restore behavior', () => {
it('keeps the search focused when viewport pointerdown causes focus outside', async () => {
const { user } = renderComponent(
createWidget(['alpha', 'bravo', 'charlie', 'delta', 'echo'])
)
const outsideButton = document.createElement('button')
document.body.append(outsideButton)
try {
await openDropdown(user)
const searchInput = screen.getByRole('combobox', { name: 'Search' })
const viewport = screen.getByTestId('widget-select-default-viewport')
// user-event does not model the raw viewport pointerdown that triggers
// this Reka focus-outside interaction.
// eslint-disable-next-line testing-library/prefer-user-event
await fireEvent.pointerDown(viewport)
outsideButton.focus()
await fireEvent.focusIn(outsideButton)
await waitFor(() => {
expect(searchInput).toHaveFocus()
})
expect(viewport).toBeVisible()
} finally {
outsideButton.remove()
}
})
})
describe('rendered state', () => {
it('shows the filter input only when there are more than four options', async () => {
const { user, rerender } = renderComponent(createWidget(['a', 'b', 'c']))
await openDropdown(user)
expect(
screen.queryByRole('combobox', { name: 'Search' })
).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await rerender({
widget: createWidget(['a', 'b', 'c', 'd', 'e'])
})
await openDropdown(user)
expect(screen.getByRole('combobox', { name: 'Search' })).toBeVisible()
})
it('marks the dropdown overlay as wheel-capturing for canvas interactions', async () => {
const { user } = renderComponent(
createWidget(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'])
)
await openDropdown(user)
expect(
screen.getByTestId('widget-select-default-overlay')
).toHaveAttribute('data-capture-wheel', 'true')
})
it('shows invalid current values as the trigger label', () => {
renderComponent(createWidget(['a', 'b']), 'missing')
expect(
screen.getByTestId('widget-select-default-trigger')
).toHaveTextContent('missing')
expect(
screen.getByTestId('widget-select-default-trigger')
).toHaveAttribute('aria-invalid', 'true')
})
it('disables the trigger when widget options are disabled', () => {
renderComponent(createWidget(['a'], { disabled: true }), 'a')
expect(screen.getByTestId('widget-select-default-trigger')).toBeDisabled()
})
it('uses getOptionLabel for trigger and option labels', async () => {
const { user } = renderComponent(
createWidget(['hash-a'], {
getOptionLabel: (value?: string | null) => `File: ${value}`
}),
'hash-a'
)
expect(
screen.getByTestId('widget-select-default-trigger')
).toHaveTextContent('File: hash-a')
await openDropdown(user)
expect(screen.getByRole('option', { name: 'File: hash-a' })).toBeVisible()
})
it('preserves the default slot for WidgetWithControl controls', () => {
renderComponent(createWidget(['a']), 'a', {
default: '<button data-testid="control-slot">control</button>'
})
expect(screen.getByTestId('control-slot')).toBeInTheDocument()
})
})
})

View File

@@ -1,38 +1,146 @@
<template>
<WidgetLayoutField :widget>
<SelectPlus
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
auto-filter-focus
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
@show="refreshOptions"
@filter="refreshOptions"
<ComboboxRoot
v-model:open="isOpen"
:model-value="comboboxValue"
:disabled
ignore-filter
selection-behavior="replace"
:reset-search-term-on-select="false"
@update:model-value="selectOption"
@update:open="handleOpenChange"
>
<template #dropdownicon>
<i
<ComboboxAnchor as-child>
<ComboboxTrigger as-child>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
:aria-label="widget.label || widget.name"
:aria-invalid="isInvalid || undefined"
:aria-expanded="isOpen"
:disabled
tabindex="0"
data-capture-wheel="true"
data-testid="widget-select-default-trigger"
:class="
cn(
WidgetInputBaseClass,
'flex h-7 w-full min-w-0 cursor-pointer items-center overflow-hidden outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-default disabled:opacity-50 disabled:hover:bg-component-node-widget-background',
isInvalid && 'ring-1 ring-destructive-background'
)
"
>
<span
:class="
cn(
'min-w-[4ch] flex-1 truncate pr-3 pl-1 text-left',
$slots.default && 'mr-5'
)
"
>
{{ selectedLabel || placeholder || '\u00a0' }}
</span>
<span
class="flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg"
>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 translate-x-1.5',
disabled
? 'bg-component-node-foreground-secondary'
: 'bg-muted-foreground'
)
"
aria-hidden="true"
/>
</span>
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal :to="portalTarget" :disabled="isPortalDisabled">
<ComboboxContent
data-capture-wheel="true"
data-testid="widget-select-default-overlay"
position="popper"
:side-offset="1"
align="start"
:class="
cn(
'icon-[lucide--chevron-down] size-4',
props.widget.options?.disabled
? 'bg-component-node-foreground-secondary'
: 'bg-muted-foreground'
'z-3000 overflow-hidden rounded-lg border border-solid border-border-default bg-base-background p-0 text-base-foreground shadow-md',
'min-w-(--reka-combobox-trigger-width)'
)
"
/>
</template>
</SelectPlus>
@keydown.escape.stop="handleOpenChange(false)"
@focus-outside="handleFocusOutside"
>
<div
v-if="isFilterable"
ref="searchInputContainerRef"
class="m-1 flex items-center gap-2 rounded-md border border-solid border-border-default px-2 py-1.5 transition-colors focus-within:border-primary-background"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
aria-hidden="true"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="filterPlaceholder"
auto-focus
:aria-label="$t('g.search')"
data-testid="widget-select-default-search-input"
class="w-full border-none bg-transparent text-xs outline-none"
/>
</div>
<div
data-testid="widget-select-default-viewport"
role="presentation"
class="flex max-h-56 min-w-full scrollbar-thin scrollbar-thumb-alpha-smoke-500-50 scrollbar-track-transparent scrollbar-gutter-stable flex-col gap-1 overflow-y-auto p-1 text-xs"
:style="viewportStyle"
@pointerdown.capture.self="handleViewportPointerDown"
>
<ComboboxItem
v-for="option in filteredOptions"
:key="option.key"
:value="option.comboboxValue"
:text-value="option.label"
:class="
cn(
'relative flex min-h-7 cursor-pointer items-center justify-between gap-3 rounded-sm p-2 outline-none select-none',
'hover:bg-secondary-background data-highlighted:bg-secondary-background',
'data-[state=checked]:bg-primary-background/20 data-[state=checked]:hover:bg-primary-background/20 data-[state=checked]:data-highlighted:bg-primary-background/30'
)
"
>
<span class="truncate">
{{ option.label }}
</span>
<ComboboxItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] size-3.5 text-base-foreground"
aria-hidden="true"
/>
</ComboboxItemIndicator>
</ComboboxItem>
<div
v-if="filteredOptions.length === 0"
role="status"
aria-live="polite"
class="p-2 text-xs text-muted-foreground"
>
{{ $t('g.noResultsFound') }}
</div>
</div>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<div class="absolute top-5 right-8 flex h-4 w-7 -translate-y-4/5">
<slot />
</div>
@@ -40,16 +148,23 @@
</template>
<script setup lang="ts">
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { CSSProperties } from 'vue'
import SelectPlus from '@/components/primevueOverride/SelectPlus.vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -58,40 +173,195 @@ interface Props {
widget: SimplifiedWidget<string | undefined>
}
const props = defineProps<Props>()
interface SelectOption {
comboboxValue: string
key: string
label: string
value: string
}
type SelectWidgetOptions = NonNullable<Props['widget']['options']> & {
filterPlaceholder?: string
}
const { widget } = defineProps<Props>()
// Reka reserves an empty string value for clearing the combobox. Encode values
// internally so custom-node combo options can still use '' like PrimeVue/legacy.
const COMBOBOX_VALUE_PREFIX = 'widget-select-value:'
const MAX_VISIBLE_OPTIONS = 7
function toComboboxValue(value: string) {
return `${COMBOBOX_VALUE_PREFIX}${value}`
}
function fromComboboxValue(value: string | undefined) {
if (value === undefined || !value.startsWith(COMBOBOX_VALUE_PREFIX)) {
return undefined
}
return value.slice(COMBOBOX_VALUE_PREFIX.length)
}
function resolveRawValues(values: unknown): unknown[] {
try {
const resolved = typeof values === 'function' ? values() : values
return Array.isArray(resolved) ? resolved : []
} catch (error) {
console.error('[WidgetSelectDefault] Failed to resolve options', error)
return []
}
}
function resolveValues(values: unknown): string[] {
if (typeof values === 'function') return values()
if (Array.isArray(values)) return values
return []
return resolveRawValues(values)
.filter((value) => value !== null && value !== undefined)
.map((value) => String(value))
}
const modelValue = defineModel<string | undefined>({
default(props: Props) {
const values = props.widget.options?.values
const resolved = typeof values === 'function' ? values() : values
return Array.isArray(resolved) ? (resolved[0] ?? '') : ''
default(modelProps: Props) {
try {
const values = modelProps.widget.options?.values
const resolved = typeof values === 'function' ? values() : values
const firstValue = Array.isArray(resolved)
? resolved.find((value) => value !== null && value !== undefined)
: undefined
return firstValue === undefined ? '' : String(firstValue)
} catch (error) {
console.error('[WidgetSelectDefault] Failed to resolve options', error)
return ''
}
}
})
// Transform compatibility props for overlay positioning
const searchQuery = ref('')
const optionsRefreshKey = ref(0)
const isOpen = ref(false)
const searchInputContainerRef = ref<HTMLElement>()
const { handleFocusOutside, handleViewportPointerDown } =
useRestoreFocusOnViewportPointer(focusSearchInput)
const transformCompatProps = useTransformCompatOverlayProps()
const refreshTrigger = ref(0)
function refreshOptions() {
refreshTrigger.value++
}
const selectOptions = computed(() => {
void refreshTrigger.value
return resolveValues(props.widget.options?.values)
})
const invalid = computed(
() => !!modelValue.value && !selectOptions.value.includes(modelValue.value)
const widgetOptions = computed(
() => widget.options as SelectWidgetOptions | undefined
)
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value,
...(invalid.value ? { placeholder: `${modelValue.value}` } : {})
const portalTarget = computed(() => {
const appendTo = transformCompatProps.value.appendTo
return appendTo === 'self' ? undefined : appendTo
})
const isPortalDisabled = computed(() => !portalTarget.value)
const disabled = computed(() => Boolean(widgetOptions.value?.disabled))
const placeholder = computed(() => widgetOptions.value?.placeholder ?? '')
const filterPlaceholder = computed(
() => widgetOptions.value?.filterPlaceholder ?? placeholder.value
)
function refreshOptions() {
optionsRefreshKey.value++
}
function getOptionLabel(value: string) {
const labeler = widgetOptions.value?.getOptionLabel
if (!labeler) return value
try {
return labeler(value) || value
} catch (error) {
console.error('[WidgetSelectDefault] Failed to map option label', error)
return value
}
}
const normalizedOptions = computed<SelectOption[]>(() => {
void optionsRefreshKey.value
return resolveValues(widgetOptions.value?.values).map((value, index) => ({
comboboxValue: toComboboxValue(value),
key: `${value}-${index}`,
label: getOptionLabel(value),
value
}))
})
const knownOptionValues = computed(
() => new Set(normalizedOptions.value.map((option) => option.value))
)
const isFilterable = computed(() => normalizedOptions.value.length > 4)
const filteredOptions = computed(() => {
if (!isFilterable.value) return normalizedOptions.value
const query = searchQuery.value.trim().toLocaleLowerCase()
if (!query) return normalizedOptions.value
return normalizedOptions.value.filter(
(option) =>
option.value.toLocaleLowerCase().includes(query) ||
option.label.toLocaleLowerCase().includes(query)
)
})
const viewportStyle = computed<CSSProperties>(() => ({
overflowY:
filteredOptions.value.length > MAX_VISIBLE_OPTIONS ? 'scroll' : 'auto',
scrollbarGutter: 'stable'
}))
const selectedOption = computed(() =>
normalizedOptions.value.find((option) => option.value === modelValue.value)
)
const comboboxValue = computed(() => {
const value = modelValue.value
if (value === undefined || !knownOptionValues.value.has(value)) return ''
return toComboboxValue(value)
})
const isInvalid = computed(
() =>
modelValue.value !== undefined &&
modelValue.value !== '' &&
!selectedOption.value
)
const selectedLabel = computed(() => {
if (selectedOption.value) return selectedOption.value.label
if (isInvalid.value) return String(modelValue.value)
return ''
})
function selectOption(rekaValue: string | undefined) {
const value = fromComboboxValue(rekaValue)
if (value === undefined || !knownOptionValues.value.has(value)) return
modelValue.value = value
searchQuery.value = ''
isOpen.value = false
}
function focusSearchInput() {
const input =
searchInputContainerRef.value?.querySelector<HTMLInputElement>('input')
if (!input) return false
input.focus({ preventScroll: true })
return true
}
function handleOpenChange(open: boolean) {
isOpen.value = open
if (open) {
refreshOptions()
} else {
searchQuery.value = ''
}
}
</script>

View File

@@ -0,0 +1,122 @@
import { render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { useRestoreFocusOnViewportPointer } from './useRestoreFocusOnViewportPointer'
function createFocusOutsideEvent() {
return new CustomEvent('focusoutside', {
cancelable: true,
detail: { originalEvent: new FocusEvent('focus') }
})
}
function renderHost({ renderInput = true } = {}) {
let api!: ReturnType<typeof useRestoreFocusOnViewportPointer>
const Host = defineComponent({
setup() {
const inputRef = ref<HTMLInputElement>()
api = useRestoreFocusOnViewportPointer(() => {
if (!inputRef.value) return false
inputRef.value.focus({ preventScroll: true })
return true
})
return {
inputRef,
renderInput
}
},
template: `
<div>
<input v-if="renderInput" ref="inputRef" data-testid="search-input" />
</div>
`
})
const result = render(Host)
return {
...result,
api
}
}
describe('useRestoreFocusOnViewportPointer', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('keeps focus inside the combobox after viewport pointerdown', () => {
const { api } = renderHost()
api.handleViewportPointerDown()
const event = createFocusOutsideEvent()
api.handleFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
expect(screen.getByTestId('search-input')).toHaveFocus()
})
it('allows focus outside when there is no input to restore', () => {
const { api } = renderHost({ renderInput: false })
api.handleViewportPointerDown()
const event = createFocusOutsideEvent()
api.handleFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('allows ordinary focus outside events', () => {
const { api } = renderHost()
const event = createFocusOutsideEvent()
api.handleFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('removes previous global listeners before tracking another pointerdown', () => {
const { api } = renderHost()
api.handleViewportPointerDown()
const removeEventListener = vi.spyOn(window, 'removeEventListener')
api.handleViewportPointerDown()
expect(removeEventListener).toHaveBeenCalledWith(
'pointerup',
expect.any(Function)
)
expect(removeEventListener).toHaveBeenCalledWith(
'pointercancel',
expect.any(Function)
)
})
it('clears the restore state after pointerup', () => {
const { api } = renderHost()
api.handleViewportPointerDown()
window.dispatchEvent(new Event('pointerup'))
const event = createFocusOutsideEvent()
api.handleFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('clears the restore state after pointercancel', () => {
const { api } = renderHost()
api.handleViewportPointerDown()
window.dispatchEvent(new Event('pointercancel'))
const event = createFocusOutsideEvent()
api.handleFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
})

View File

@@ -0,0 +1,56 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
interface PreventableEvent {
preventDefault(): void
}
/**
* Keeps a search input focused when pressing a dropdown viewport.
* Reka treats native scrollbar presses as focus-outside interactions, so this
* short-lived guard preserves list scrolling without blocking normal outside
* clicks after the pointer gesture completes.
*/
export function useRestoreFocusOnViewportPointer(
focusInput: MaybeRef<() => boolean>
) {
const isViewportPointerDownInFlight = ref(false)
let clearPointerDownTimer: number | undefined
function clearPointerDown() {
isViewportPointerDownInFlight.value = false
window.clearTimeout(clearPointerDownTimer)
clearPointerDownTimer = undefined
window.removeEventListener('pointerup', clearPointerDown)
window.removeEventListener('pointercancel', clearPointerDown)
}
function handleViewportPointerDown() {
clearPointerDown()
isViewportPointerDownInFlight.value = true
// Clear through timer and terminal pointer events so a canceled or missing
// pointerup cannot leave future outside interactions blocked.
clearPointerDownTimer = window.setTimeout(clearPointerDown, 0)
window.addEventListener('pointerup', clearPointerDown, { once: true })
window.addEventListener('pointercancel', clearPointerDown, { once: true })
}
function handleFocusOutside(event: PreventableEvent) {
if (!isViewportPointerDownInFlight.value) return
if (!unref(focusInput)()) {
clearPointerDown()
return
}
event.preventDefault()
clearPointerDown()
}
tryOnScopeDispose(clearPointerDown)
return {
handleFocusOutside,
handleViewportPointerDown
}
}