Compare commits

..

18 Commits

Author SHA1 Message Date
Jin Yi
54a8d913f8 [feat] Add copy job ID action and shared asset utilities
This commit introduces the copy job ID feature for media assets along with
shared utility functions that will be used across multiple asset actions.

Features:
- Copy job ID to clipboard with OSS/Cloud compatibility
- Uses useCopyToClipboard composable for consistent UX

Shared Utilities:
- assetTypeUtil: Extract asset type from tags
- assetUrlUtil: Construct asset URLs for download/view
- typeGuardUtil: Add isResultItemType type guard

Refactoring:
- Unified asset deletion logic with deleteAssetApi helper
- Replaced inline URL construction with getAssetUrl utility
- Improved error handling with Cloud-specific warnings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 12:34:34 +09:00
Alexander Brown
c43a4990a9 Fix: Vue Node Align/Distribute (#6712)
## Summary

Fixes the issue of the nodes not moving when in Vue mode (but changing
if switching back to litegraph)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6712-Fix-Vue-Node-Align-Distribute-2ad6d73d365081339aa6f61e18832bc4)
by [Unito](https://www.unito.io)
2025-11-15 18:20:43 -08:00
Jin Yi
8dd5a9900b [refactor] Unify Cloud/OSS Missing Nodes modal (#6673)
## Summary
- Merged separate Cloud and OSS workflow warning modals into single
unified modal
- Removed legacy LoadWorkflowWarning.vue
- Renamed CloudMissingNodes* components to MissingNodes* for clarity
- Environment branching now handled internally via isCloud flag
- Restructured i18n: removed loadWorkflowWarning, added
missingNodes.cloud/oss sections
- Improved OSS button styling to match Cloud consistency

## Key Changes
- **OSS**: "Open Manager" + "Install All" buttons
- **Cloud**: "Learn More" + "Got It" buttons (unchanged)
- Single unified modal displays different UI/text based on environment

## 📝 Note on File Renames

This PR renames the following files:

- `CloudMissingNodesHeader.vue` → `MissingNodesHeader.vue` (R053, 53%
similarity)
- `CloudMissingNodesContent.vue` → `MissingNodesContent.vue` (R067, 67%
similarity)
- `LoadWorkflowWarning.vue` → `MissingNodesFooter.vue` (R051, 51%
similarity)
- `CloudMissingNodesFooter.vue` → Deleted (replaced by new
MissingNodesFooter)

**Why GitHub PR UI doesn't show renames properly:**

GitHub detects renames only when file similarity is above 70%. In this
PR, the Cloud/OSS unification significantly modified file contents,
resulting in 51-67% similarity.

However, **Git history correctly records these as renames**. You can
verify with:

```bash
git show <commit-hash> --name-status
```

While GitHub UI shows "additions/deletions", these are actually rename +
modification operations.

## Test Plan
- [x] Test OSS mode: missing nodes modal shows "Open Manager" and
"Install All" buttons
- [x] Test Cloud mode: missing nodes modal shows "Learn More" and "Got
It" buttons
- [x] Verify Install All button functionality in OSS
- [x] Verify modal closes automatically after all nodes are installed
(OSS)


[missingnodes.webm](https://github.com/user-attachments/assets/36d3b4b0-ff8b-4b45-824c-3bc15d93f1a2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6673-refactor-Unify-Cloud-OSS-Missing-Nodes-modal-2aa6d73d365081a88827d0fa85db4c63)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-15 12:09:44 -08:00
Terry Jia
7a11dc59b6 [refactor] remove node as dependency in 3d node (#6707)
## Summary

This PR refactors the Load3d 3D rendering system to remove its direct
dependency on LGraphNode, making it a more decoupled and reusable
component. The core rendering engine is now framework-agnostic and can
be used in any context, not just within LiteGraph nodes.

## Changes

1. Decoupled Load3d from LGraphNode
  - Before: Load3d directly accessed node.widgets and node.properties
- After: Load3d accepts optional parameters and callbacks, delegating
node integration to the calling code

2. Event-Driven State Management
  - Removed internal storage from Load3d core components
- Camera, controls, and view helper managers now emit cameraChanged
events instead of directly storing state
- External code (e.g., useLoad3d) listens to events and handles
persistence to node.properties

3. Reactive Dimension Updates

- Introduced getDimensions callback to support reactive dimension
updates
- Fixes the issue where dimension changes in vueNodes mode required a
refresh
- The callback is invoked on every render to get fresh width/height
values

4. Improved Configuration System

- Load3DConfiguration now accepts properties: Dictionary<NodeProperty |
undefined> instead of custom storage
  interface
  - Uses official LiteGraph type definitions (Dictionary, NodeProperty)
  - More semantic parameter naming: storage → properties

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6707-refactor-remove-node-as-dependency-in-3d-node-2ab6d73d365081ffac1cdce354781ce8)
by [Unito](https://www.unito.io)
2025-11-15 06:36:36 -05:00
Jin Yi
ba768c32f3 fix: Prevent text selection in MediaAssetCard (#6708)
## Summary
- Prevent text selection when clicking or dragging MediaAssetCard
- Add `select-none` Tailwind class to prevent unwanted text highlighting

## Changes
- Changed class from `gap-1` to `gap-1 select-none` in MediaAssetCard
container

## Problem
When users click or drag on a MediaAssetCard, the text content (tags,
titles, descriptions, buttons) gets selected and highlighted, which
creates a poor user experience.

## Solution
Added the `select-none` Tailwind CSS class which applies `user-select:
none` to prevent text selection within the card during mouse
interactions.

## Test plan
- [x] Click on MediaAssetCard and verify text is not selected
- [x] Drag across MediaAssetCard and verify text is not highlighted
- [x] Verify card selection still works properly
- [x] Verify buttons and interactive elements still work

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6708-fix-Prevent-text-selection-in-MediaAssetCard-2ac6d73d365081f6bec2ffebad7cb7ed)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-15 05:58:57 +00:00
Jin Yi
e3f19ab856 feat: Add media type filtering to Media Asset Panel (#6701) 2025-11-15 05:20:00 +00:00
Terry Jia
a9f416233d feat: support open 3d viewer in media asset panel (#6703)
## Summary

Add support for previewing 3D assets directly in the Media Asset Panel.

## Changes

- **3D Asset Preview**: Clicking on 3D assets (`.glb`, `.gltf`, etc.) in
the Media Asset Panel now opens the full
  3D viewer

## Screenshots



https://github.com/user-attachments/assets/38808712-acc8-42aa-9f11-8d8bf2387b20

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6703-feat-support-open-3d-viewer-in-media-asset-panel-2ab6d73d3650811dbff9ecb570a0a878)
by [Unito](https://www.unito.io)
2025-11-14 14:31:58 -05:00
AustinMroz
b23a92b442 Fix pointer events passing through sideToolbar (#6700)
#6103 Made it so clicks in the gaps on the side toolbar would pass
through to the canvas. Since the gap has been removed from the side
toolbar, this would incorrectly allow the canvas to be dragged through
the toolbar as shown.

![unfix-mouse_00003](https://github.com/user-attachments/assets/301f9d61-b73b-448f-bfaa-d236509d7f1a)

Credit to @Kosinkadink for finding this.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6700-Fix-pointer-events-passing-through-sideToolbar-2ab6d73d365081d2948fceba909b7164)
by [Unito](https://www.unito.io)
2025-11-14 01:17:46 -08:00
Jin Yi
8c3caa77d6 fix: Add 3D file support to Media Asset Panel (#6699)
## Summary
Fix bug where 3D files were not displayed in the Media Asset Panel's
Generated tab

## Problem
- 3D files (`.obj`, `.fbx`, `.gltf`, `.glb`) appear correctly in
QueueSidebarTab
- 3D files do not appear in Media Asset Panel's Generated tab

## Root Cause
`ResultItemImpl.supportsPreview` getter only checked for Image, Video,
and Audio files, excluding 3D files. This caused:
1. 3D files to be filtered out in `TaskItemImpl.previewOutput`
2. Items with undefined `previewOutput` to be skipped in
`mapHistoryToAssets`
3. 3D files not appearing in the Media Asset Panel

## Solution
- Add `is3D` getter to `ResultItemImpl`
- Include 3D file support in `supportsPreview`
- Use `getMediaTypeFromFilename` utility to detect 3D file types based
on extension

## Changes
- `src/stores/queueStore.ts`:
  - Import `getMediaTypeFromFilename`
  - Add `is3D` getter
  - Update `supportsPreview` to include `|| this.is3D`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 00:24:03 -08:00
Jin Yi
ad6dda4435 feat: Add generation time sort options to Media Asset Panel (#6698)
## Summary
Add generation time-based sorting options to the Media Asset Panel

## Changes
- **New sorting options**:
  - Generation time (longest first) - Sort by longest execution time
  - Generation time (fastest first) - Sort by shortest execution time

- **Show only in Generated tab**: 
- Generation time sorting is only meaningful for output assets with
`executionTimeInSeconds` metadata
  - Implemented conditional rendering via `showGenerationTimeSort` prop

## Technical Details
- `useMediaAssetFiltering.ts`: 
  - Added `'longest'` and `'fastest'` to `SortOption` type
  - Added `getAssetExecutionTime` helper function
  - Implemented sorting logic using switch-case pattern
  
- `MediaAssetSortMenu.vue`: 
  - Added `showGenerationTimeSort` prop
- Generation time sort buttons placed inside `<template
v-if="showGenerationTimeSort">`
  
- `MediaAssetFilterBar.vue`: 
- Receives `showGenerationTimeSort` prop and passes it to
`MediaAssetSortMenu`
  
- `AssetsSidebarTab.vue`: 
- Passes `showGenerationTimeSort` prop based on `activeTab === 'output'`
  
- `src/locales/en/main.json`: 
  - Added `sortLongestFirst`: "Generation time (longest first)"
  - Added `sortFastestFirst`: "Generation time (fastest first)"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 07:09:32 +00:00
Jin Yi
c43cd287bb fix: Hide delete button for input assets in OSS and fix cloud download (#6697)
## Summary
Fix delete button visibility for input assets in OSS environment and
resolve 404 error when downloading assets in cloud.

## Changes

### 1. Improved Delete Button Visibility Logic
- **Problem**: In OSS environment, input files are sourced from local
folders and cannot be deleted
- **Solution**: Added `shouldShowDeleteButton` computed property to
conditionally hide delete buttons
- **Impact**: 
  - Input tab + Cloud: Delete button shown 
  - Input tab + OSS: Delete button hidden 
  - Output tab (all environments): Delete button shown 

### 2. Fixed Cloud Download 404 Error
- **Problem**: Downloading files from imported tab in cloud returned 404
error for `/api/view` endpoint
- **Root Cause**: In cloud environment, files are stored in external
storage (e.g., GCS) and `/api/view` endpoint is not available
- **Solution**: 
  - Cloud: Use `preview_url` directly for downloads
  - OSS/localhost: Continue using `/api/view` endpoint as before
- Applied the same logic to both single and bulk download operations

## Test Plan
- [ ] Verify delete button is hidden in input tab on OSS environment
- [ ] Verify delete button is shown in input tab on cloud environment
- [ ] Verify file downloads work correctly in cloud for both input and
output tabs
- [ ] Verify file downloads work correctly in OSS for output tab

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 22:57:43 -08:00
ComfyUI Wiki
b347dd1734 Centralized management of external links (#4471)
Update the desktop guide links to make them platform and locale-aware

Edited by Terry:
Refactor external link management by introducing a centralized
useExternalLink composable with automatic locale and platform detection
for documentation URLs.

- Created useExternalLink composable - A new centralized utility for
managing all external links
- Dynamic docs URL builder (buildDocsUrl) - Automatically constructs
docs.comfy.org URLs with:
  - Locale detection (Chinese vs English)
  - Platform detection (macOS vs Windows for desktop)
  - Flexible path construction with options

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4471-Add-platform-and-locale-aware-desktop-guide-URL-2346d73d3650815ea4a4dd64be575bbe)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2025-11-13 21:35:28 -08:00
Terry Jia
1a6913c466 fully refactor mask editor into vue-based (#6629)
## Summary

This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.

Net change: +1,700 lines (mostly tests)

## Changes

- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
    - useBrushDrawing - Brush rendering and drawing logic
    - useCanvasManager - Canvas lifecycle and operations
    - useCanvasTools - Tool-specific canvas operations
    - usePanAndZoom - Pan and zoom functionality
    - useToolManager - Tool selection and coordination
    - useKeyboard - Keyboard shortcuts
    - useMaskEditorLoader/Saver - Data loading and saving
    - useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage

## What This PR Does NOT Change

  Preserved Original Styling:
  - Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR

  Preserved Core Functionality:
  - Drawing algorithms and brush rendering logic remain unchanged
  - Pan/zoom calculations preserved
  - Canvas operations (composite modes, image processing) unchanged
  - Tool behaviors (brush, color select, paint bucket) identical
  - No changes to mask generation or export logic

DO NOT Review:
  -  CSS styling choices (preserved from original)
  - Drawing algorithm implementations (unchanged)
  -  Canvas rendering logic (ported as-is)
  - UI/UX changes (none exist)
  - Component library choices (future work)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
2025-11-13 20:57:03 -08:00
Jin Yi
f80fc4cf9a feat: Add sort functionality to Media Asset Panel (#6695)
## Overview
Adds sort functionality to the Media Asset Panel. Users can sort assets
by creation time in Cloud environments.

## Key Changes

### 1. Sort Functionality (Cloud Only)
- "Newest first" (most recent)
- "Oldest first" (oldest)
- Sorting based on `create_time` field (output assets)
- Sorting based on `created_at` field (input assets)
- Sort button is only displayed in Cloud environments

### 2. create_time Field Integration
**Related PR**: #6092

Implemented sort functionality using the `create_time` field introduced
in PR #6092. Applied the code from that PR directly to the following
files:

- `src/schemas/apiSchema.ts`: Added `create_time` field to `zExtraData`
- `src/stores/queueStore.ts`: Added `createTime` getter to
`TaskItemImpl`
- `src/platform/remote/comfyui/history/types/historyV2Types.ts`: Added
`create_time` to History V2 API response types
- `src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.ts`: Pass
through `create_time` in V2→V1 adapter
- `src/platform/assets/composables/media/assetMappers.ts`: Include
`create_time` in asset metadata

### 3. Component Structure Improvements
Created new components following existing component styles for
consistency:

- **`MediaAssetSearchBar.vue`**: Component combining existing SearchBox
with sort button
- **`AssetSortButton.vue`**: Same structure as `MoreButton.vue`
(IconButton + Popover)
- **`MediaAssetSortMenu.vue`**: Same style as `MediaAssetMoreMenu.vue`
(using IconTextButton)
- **`AssetsSidebarTab.vue`**: Refactored to use `MediaAssetSearchBar`

### 4. Utility Usage
- Improved sort logic using `es-toolkit`'s `sortBy`
- Follows project guidelines (CLAUDE.md)

## Technical Details

### History V2 API's create_time
- Cloud backend provides `create_time` (in milliseconds) through History
V2 API
- Enables accurate sorting by creation time
- For input assets, uses existing `created_at` (ISO string)

### Sort Implementation
Uses `es-toolkit`'s `sortBy` in `useMediaAssetFiltering` composable:

```typescript
// Get timestamp from asset (either create_time or created_at)
const getAssetTime = (asset: AssetItem): number => {
  return (
    (asset.user_metadata?.create_time as number) ??
    (asset.created_at ? new Date(asset.created_at).getTime() : 0)
  )
}

// Sort by time
if (sortBy.value === 'oldest') {
  return sortByUtil(searchFiltered.value, [getAssetTime])
} else {
  return sortByUtil(searchFiltered.value, [(asset) => -getAssetTime(asset)])
}
```

## Testing
-  Typecheck passed
-  Lint passed
-  Format passed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6695-feat-Add-sort-functionality-to-Media-Asset-Panel-2ab6d73d3650818c818ff3559875d869)
by [Unito](https://www.unito.io)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-14 04:56:04 +00:00
Christian Byrne
8b8f3538bf fix: template query param stripped during login views (#6677)
Fixes issue where query params from
https://github.com/Comfy-Org/ComfyUI_frontend/pull/6593 are stripped
during the login/signup views/flow by storing initial params in session
storage via router plugin.



https://github.com/user-attachments/assets/51642e8c-af5c-43ef-ab7d-133bc7e511aa




┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6677-fix-template-query-param-stripped-during-login-views-2aa6d73d365081a1bdc7d22b35f72a77)
by [Unito](https://www.unito.io)
2025-11-13 20:37:37 -08:00
Simula_r
ecd87ae0f4 feat: vue nodes onboarding toggle in menu (#6671)
## Summary

Added Nodes 2.0 menu items with a toggle. 
Updated copy in banner and toast to be more descriptive.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/85bf3ae4-0e0b-4e04-82c7-a26a73cbdd5b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6671-feat-vue-nodes-onboarding-toggle-in-menu-2aa6d73d3650817d8e5bef0ad0f8bebb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-13 20:19:18 -08:00
Christian Byrne
693d408c4a filter out templates that have custom nodes when not on cloud (temporary) (#6690)
Templates are being added that have custom nodes. As a temporary
measure, filter these out on local. In a followup, add a UX to allow
local users to do something like opt-in to use these or to show these on
the condition that the user has these custom nodes installed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6690-filter-out-templates-that-have-custom-nodes-when-not-on-cloud-temporary-2ab6d73d3650819981c0e9b26d34acff)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2025-11-13 20:31:09 -07:00
Jin Yi
1feee48284 [feat] Add search functionality to Media Asset Panel (#6691)
## Summary

Add search functionality to the Media Asset Panel, allowing users to
search for assets by filename.

## Changes

### 1. Search Feature
- Added SearchBox component to AssetsSidebarTab header
- Implemented fuzzy search using Fuse.js
- Works in both Imported and Generated tabs
- Search also available in folder view

### 2. New Composable: `useMediaAssetFiltering`
- Location: `src/platform/assets/composables/useMediaAssetFiltering.ts`
- Encapsulates search logic in a reusable composable
- Extensible structure for future filter and sort features
- Debounced search (50ms)

### 3. UX Improvements
- Search query automatically clears when switching tabs
- Search query automatically clears when exiting folder view

## Testing

-  TypeScript type check passed
-  ESLint/Oxlint passed
-  Lint-staged pre-commit hooks passed

## Modified Files

- `src/components/sidebar/tabs/AssetsSidebarTab.vue` - Added SearchBox
- `src/platform/assets/composables/useMediaAssetFiltering.ts` - New file
- `src/locales/en/main.json` - Added i18n key
(`sideToolbar.searchAssets`)

## Future Plans

- Add filter functionality (file type, date, etc.)
- Add sort functionality
- Switch to server-side search for OSS/Cloud (after Asset API and Job
API release)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6691-feat-Add-search-functionality-to-Media-Asset-Panel-2ab6d73d3650817b8b95f3450179524f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 20:00:46 -07:00
153 changed files with 8599 additions and 7059 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 118 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: 33 KiB

After

Width:  |  Height:  |  Size: 33 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: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -1346,3 +1346,466 @@ audio.comfy-audio.empty-audio-widget {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
.maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelLayer {
display: flex;
width: 100%;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
background: rgb(0 0 0 / 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
.maskEditor_toolPanelContainer {
width: 4rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelSeparator {
width: 100%;
height: 2px;
background: var(--border-color);
margin-top: 1.5em;
margin-bottom: 5px;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelToggleSwitch {
background: var(--p-surface-700);
}
.maskEditor_sidePanelToggleSwitch::before, .maskEditor_sidePanelToggleSwitch::after {
content: "";
}
.maskEditor_sidePanelToggleSwitch::before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch::before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--comfy-menu-bg);
left: 20px;
}
.dark-theme .maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown option:hover {
background: white;
}
.maskEditor_sidePanelDropdown option:active {
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelDropdown {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown option {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.dark-theme .maskEditor_sidePanelDropdown option:active {
background: var(--p-highlight-background);
}
.maskEditor_layerRow {
height: 50px;
width: 100%;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
.maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */

View File

@@ -32,7 +32,7 @@ defineOptions({
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
label: string
onClick: () => void
onClick?: () => void
}
const {

View File

@@ -35,7 +35,6 @@ import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
disableValidation?: boolean
}>()
const emit = defineEmits<{
@@ -102,8 +101,6 @@ const defaultValidateUrl = async (url: string): Promise<boolean> => {
}
const validateUrl = async (value: string) => {
if (props.disableValidation) return
if (validationState.value === ValidationState.LOADING) return
const url = cleanInput(value)

View File

@@ -29,7 +29,10 @@
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
import { useExternalLink } from '@/composables/useExternalLink'
const { t } = useI18n()
const { buildDocsUrl } = useExternalLink()
const { apiNodeNames, onLogin, onCancel } = defineProps<{
apiNodeNames: string[]
@@ -38,6 +41,9 @@ const { apiNodeNames, onLogin, onCancel } = defineProps<{
}>()
const handleLearnMoreClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
</script>

View File

@@ -1,37 +0,0 @@
<template>
<div class="flex w-full items-center justify-between gap-2 py-2 px-4">
<IconTextButton
:label="$t('cloud.missingNodes.learnMore')"
type="transparent"
size="sm"
icon-position="left"
@click="handleLearnMoreClick"
>
<template #icon>
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<TextButton
:label="$t('cloud.missingNodes.gotIt')"
type="secondary"
size="md"
@click="handleGotItClick"
/>
</div>
</template>
<script setup lang="ts">
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const handleLearnMoreClick = () => {
window.open('https://www.comfy.org/cloud', '_blank')
}
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-cloud-missing-nodes' })
}
</script>

View File

@@ -49,14 +49,14 @@ import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const props = defineProps<{
const { missingCoreNodes } = defineProps<{
missingCoreNodes: Record<string, LGraphNode[]>
}>()
const systemStatsStore = useSystemStatsStore()
const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0
return Object.keys(missingCoreNodes).length > 0
})
// Use computed for reactive version tracking
@@ -66,7 +66,7 @@ const currentComfyUIVersion = computed<string | null>(() => {
})
const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {
return Object.entries(missingCoreNodes).sort(([a], [b]) => {
// Sort by version in descending order (newest first)
return compare(b, a) // Reversed for descending order
})

View File

@@ -6,15 +6,18 @@
<!-- Description -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{ $t('cloud.missingNodes.description') }}
<br /><br />
{{ $t('cloud.missingNodes.priorityMessage') }}
{{
isCloud
? $t('missingNodes.cloud.description')
: $t('missingNodes.oss.description')
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- Missing Nodes List Wrapper -->
<div
class="flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<div
v-for="(node, i) in uniqueNodes"
@@ -24,13 +27,18 @@
<span class="text-xs">
{{ node.label }}
</span>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
</div>
</div>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{ $t('cloud.missingNodes.replacementInstruction') }}
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
</p>
</div>
</div>
@@ -40,12 +48,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { isCloud } from '@/platform/distribution/types'
import type { MissingNodeType } from '@/types/comfy'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes

View File

@@ -1,39 +1,42 @@
<template>
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="$t('loadWorkflowWarning.missingNodesTitle')"
:message="$t('loadWorkflowWarning.missingNodesDescription')"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
:options="uniqueNodes"
option-label="label"
scroll-height="100%"
class="comfy-missing-nodes"
:pt="{
list: { class: 'border-none' }
}"
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<template #option="slotProps">
<div class="align-items-center flex">
<span class="node-type">{{ slotProps.option.label }}</span>
<span v-if="slotProps.option.hint" class="node-hint">{{
slotProps.option.hint
}}</span>
<Button
v-if="slotProps.option.action"
:label="slotProps.option.action.text"
size="small"
outlined
@click="slotProps.option.action.callback"
/>
</div>
</template>
</ListBox>
<div v-if="showManagerButtons" class="flex justify-end py-3">
<IconTextButton
:label="$t('missingNodes.cloud.learnMore')"
type="transparent"
size="sm"
icon-position="left"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<template #icon>
<i class="icon-[lucide--info]"></i>
</template>
</IconTextButton>
<TextButton
:label="$t('missingNodes.cloud.gotIt')"
type="secondary"
size="md"
@click="handleGotItClick"
/>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<TextButton
:label="$t('g.openManager')"
type="transparent"
size="sm"
@click="openManager"
/>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
@@ -46,40 +49,32 @@
: $t('manager.installAllMissingNodes')
"
/>
<Button
:label="$t('g.openManager')"
size="small"
outlined
@click="openManager"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
// Get missing node packs from workflow with loading and error states
const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
@@ -91,27 +86,6 @@ const isInstalling = computed(() => {
)
})
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false
seenTypes.add(type)
return true
})
.map((node) => {
if (typeof node === 'object') {
return {
label: node.type,
hint: node.hint,
action: node.action
}
}
return { label: node }
})
})
// Show manager buttons unless manager is disabled
const showManagerButtons = computed(() => {
return managerState.shouldShowManagerButtons.value
@@ -129,9 +103,6 @@ const openManager = async () => {
})
}
const { t } = useI18n()
const dialogStore = useDialogStore()
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
return (
@@ -140,13 +111,14 @@ const allMissingNodesInstalled = computed(() => {
missingNodePacks.value?.length === 0
)
})
// Watch for completion and close dialog
// Watch for completion and close dialog (OSS mode only)
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled && showInstallAllButton.value) {
if (!isCloud && allInstalled && showInstallAllButton.value) {
// Use nextTick to ensure state updates are complete
await nextTick()
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
dialogStore.closeDialog({ key: 'global-missing-nodes' })
// Show success toast
useToastStore().add({
@@ -158,20 +130,3 @@ watch(allMissingNodesInstalled, async (allInstalled) => {
}
})
</script>
<style scoped>
.comfy-missing-nodes {
max-height: 300px;
overflow-y: auto;
}
.node-hint {
margin-left: 0.5rem;
font-style: italic;
color: var(--text-color-secondary);
}
:deep(.p-button) {
margin-left: auto;
}
</style>

View File

@@ -3,8 +3,16 @@
<div class="flex items-center gap-2">
<i class="icon-[lucide--triangle-alert] text-gold-600"></i>
<p class="m-0 text-sm">
{{ $t('cloud.missingNodes.title') }}
{{
isCloud
? $t('missingNodes.cloud.title')
: $t('missingNodes.oss.title')
}}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { isCloud } from '@/platform/distribution/types'
</script>

View File

@@ -123,6 +123,7 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -137,6 +138,7 @@ interface CreditHistoryItemData {
isPositive: boolean
}
const { buildDocsUrl } = useExternalLink()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
@@ -183,12 +185,17 @@ const handleMessageSupport = async () => {
}
const handleFaqClick = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/faq', '_blank')
window.open(
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
'_blank'
)
}
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
includeLocale: true
}),
'_blank'
)
}

View File

@@ -143,6 +143,7 @@ import type { CSSProperties, Component } from 'vue'
import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -168,15 +169,6 @@ interface MenuItem {
}
// Constants
const EXTERNAL_LINKS = {
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
MINUTE: 60 * 1000,
HOUR: 60 * 60 * 1000,
@@ -193,7 +185,8 @@ const SUBMENU_CONFIG = {
} as const
// Composables
const { t, locale } = useI18n()
const { t } = useI18n()
const { staticUrls, buildDocsUrl } = useExternalLink()
const releaseStore = useReleaseStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
@@ -230,11 +223,12 @@ const moreItems = computed<MenuItem[]>(() => {
visible: isElectron(),
action: () => {
trackResourceClick('docs', true)
const docsUrl =
electronAPI().getPlatform() === 'darwin'
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
openExternalLink(docsUrl)
openExternalLink(
buildDocsUrl('/installation/desktop', {
includeLocale: true,
platform: true
})
)
emit('close')
}
},
@@ -286,7 +280,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.docs'),
action: () => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.DOCS)
openExternalLink(buildDocsUrl('/', { includeLocale: true }))
emit('close')
}
},
@@ -297,7 +291,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: 'Discord',
action: () => {
trackResourceClick('discord', true)
openExternalLink(EXTERNAL_LINKS.DISCORD)
openExternalLink(staticUrls.discord)
emit('close')
}
},
@@ -308,7 +302,7 @@ const menuItems = computed<MenuItem[]>(() => {
label: t('helpCenter.github'),
action: () => {
trackResourceClick('github', true)
openExternalLink(EXTERNAL_LINKS.GITHUB)
openExternalLink(staticUrls.github)
emit('close')
}
},
@@ -533,25 +527,19 @@ const onReleaseClick = (release: ReleaseNote): void => {
trackResourceClick('release_notes', true)
void releaseStore.handleShowChangelog(release.version)
const versionAnchor = formatVersionAnchor(release.version)
const changelogUrl = `${getChangelogUrl()}#${versionAnchor}`
const changelogUrl = `${buildDocsUrl('/changelog', { includeLocale: true })}#${versionAnchor}`
openExternalLink(changelogUrl)
emit('close')
}
const onUpdate = (_: ReleaseNote): void => {
trackResourceClick('docs', true)
openExternalLink(EXTERNAL_LINKS.UPDATE_GUIDE)
openExternalLink(
buildDocsUrl('/installation/update_comfyui', { includeLocale: true })
)
emit('close')
}
// Generate language-aware changelog URL
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
telemetry?.trackHelpCenterOpened({ source: 'sidebar' })

View File

@@ -37,6 +37,7 @@
v-model:background-render-mode="viewer.backgroundRenderMode.value"
v-model:fov="viewer.fov.value"
:has-background-image="viewer.hasBackgroundImage.value"
:disable-background-upload="viewer.isStandaloneMode.value"
@update-background-image="viewer.handleBackgroundImageUpdate"
/>
</div>
@@ -91,13 +92,15 @@ import LightControls from '@/components/load3d/controls/viewer/ViewerLightContro
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
node: LGraphNode
node?: LGraphNode
modelUrl?: string
}>()
const viewerContentRef = ref<HTMLDivElement>()
@@ -106,20 +109,30 @@ const mainContentRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const viewer = useLoad3dService().getOrCreateViewer(toRaw(props.node))
const isStandaloneMode = !props.node && props.modelUrl
const viewer = props.node
? useLoad3dService().getOrCreateViewer(toRaw(props.node))
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
await viewer.handleModelDrop(file)
},
disabled: viewer.isPreview
disabled: viewer.isPreview.value || isStandaloneMode
})
onMounted(async () => {
const source = useLoad3dService().getLoad3d(props.node)
if (source && containerRef.value) {
await viewer.initializeViewer(containerRef.value, source)
if (!containerRef.value) return
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}
}
if (viewerContentRef.value) {
@@ -150,7 +163,9 @@ onMounted(async () => {
})
const handleCancel = () => {
viewer.restoreInitialState()
if (!isStandaloneMode) {
viewer.restoreInitialState()
}
useDialogStore().closeDialog()
}

View File

@@ -14,7 +14,7 @@
</label>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="!hasBackgroundImage && !disableBackgroundUpload">
<Button
severity="secondary"
:label="$t('load3d.uploadBackgroundImage')"
@@ -74,6 +74,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
defineProps<{
hasBackgroundImage?: boolean
disableBackgroundUpload?: boolean
}>()
const emit = defineEmits<{

View File

@@ -0,0 +1,97 @@
<template>
<div
id="maskEditor_brush"
:style="{
position: 'absolute',
opacity: brushOpacity,
width: `${brushSize}px`,
height: `${brushSize}px`,
left: `${brushLeft}px`,
top: `${brushTop}px`,
borderRadius: borderRadius,
pointerEvents: 'none',
zIndex: 1000
}"
>
<div
id="maskEditor_brushPreviewGradient"
:style="{
display: gradientVisible ? 'block' : 'none',
background: gradientBackground
}"
></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { containerRef } = defineProps<{
containerRef?: HTMLElement
}>()
const store = useMaskEditorStore()
const brushOpacity = computed(() => {
return store.brushVisible ? '1' : '0'
})
const brushRadius = computed(() => {
return store.brushSettings.size * store.zoomRatio
})
const brushSize = computed(() => {
return brushRadius.value * 2
})
const brushLeft = computed(() => {
const dialogRect = containerRef?.getBoundingClientRect()
const dialogOffsetLeft = dialogRect?.left || 0
return (
store.cursorPoint.x +
store.panOffset.x -
brushRadius.value -
dialogOffsetLeft
)
})
const brushTop = computed(() => {
const dialogRect = containerRef?.getBoundingClientRect()
const dialogOffsetTop = dialogRect?.top || 0
return (
store.cursorPoint.y +
store.panOffset.y -
brushRadius.value -
dialogOffsetTop
)
})
const borderRadius = computed(() => {
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
})
const gradientVisible = computed(() => {
return store.brushPreviewGradientVisible
})
const gradientBackground = computed(() => {
const hardness = store.brushSettings.hardness
if (hardness === 1) {
return 'rgba(255, 0, 0, 0.5)'
}
const midStop = hardness * 100
const outerStop = 100
return `radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, 0.25) ${midStop}%,
rgba(255, 0, 0, 0) ${outerStop}%
)`
})
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.brushSettings') }}
</h3>
<button
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
@click="resetToDefault"
>
{{ t('maskEditor.resetToDefault') }}
</button>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.brushShape')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<div
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
:style="{
background:
store.brushSettings.type === BrushShape.Arc
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Arc)"
></div>
<div
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
:style="{
background:
store.brushSettings.type === BrushShape.Rect
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Rect)"
></div>
</div>
</div>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.colorSelector')
}}</span>
<input type="color" :value="store.rgbColor" @input="onColorChange" />
</div>
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
/>
<SliderControl
:label="t('maskEditor.opacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.opacity"
@update:model-value="onOpacityChange"
/>
<SliderControl
:label="t('maskEditor.hardness')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.hardness"
@update:model-value="onHardnessChange"
/>
<SliderControl
:label="t('maskEditor.smoothingPrecision')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.smoothingPrecision"
@update:model-value="onSmoothingPrecisionChange"
/>
</div>
</template>
<script setup lang="ts">
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const setBrushShape = (shape: BrushShape) => {
store.brushSettings.type = shape
}
const onColorChange = (event: Event) => {
store.rgbColor = (event.target as HTMLInputElement).value
}
const onThicknessChange = (value: number) => {
store.setBrushSize(value)
}
const onOpacityChange = (value: number) => {
store.setBrushOpacity(value)
}
const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onSmoothingPrecisionChange = (value: number) => {
store.setBrushSmoothingPrecision(value)
}
const resetToDefault = () => {
store.resetBrushToDefault()
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.colorSelectSettings') }}
</h3>
<SliderControl
:label="t('maskEditor.tolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.colorSelectTolerance"
@update:model-value="onToleranceChange"
/>
<SliderControl
:label="t('maskEditor.selectionOpacity')"
:min="0"
:max="100"
:step="1"
:model-value="store.selectionOpacity"
@update:model-value="onSelectionOpacityChange"
/>
<ToggleControl
:label="t('maskEditor.livePreview')"
:model-value="store.colorSelectLivePreview"
@update:model-value="onLivePreviewChange"
/>
<ToggleControl
:label="t('maskEditor.applyToWholeImage')"
:model-value="store.applyWholeImage"
@update:model-value="onWholeImageChange"
/>
<DropdownControl
:label="t('maskEditor.method')"
:options="methodOptions"
:model-value="store.colorComparisonMethod"
@update:model-value="onMethodChange"
/>
<ToggleControl
:label="t('maskEditor.stopAtMask')"
:model-value="store.maskBoundary"
@update:model-value="onMaskBoundaryChange"
/>
<SliderControl
:label="t('maskEditor.maskTolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.maskTolerance"
@update:model-value="onMaskToleranceChange"
/>
</div>
</template>
<script setup lang="ts">
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import DropdownControl from './controls/DropdownControl.vue'
import SliderControl from './controls/SliderControl.vue'
import ToggleControl from './controls/ToggleControl.vue'
const store = useMaskEditorStore()
const methodOptions = Object.values(ColorComparisonMethod)
const onToleranceChange = (value: number) => {
store.setColorSelectTolerance(value)
}
const onSelectionOpacityChange = (value: number) => {
store.setSelectionOpacity(value)
}
const onLivePreviewChange = (value: boolean) => {
store.colorSelectLivePreview = value
}
const onWholeImageChange = (value: boolean) => {
store.applyWholeImage = value
}
const onMethodChange = (value: string | number) => {
store.colorComparisonMethod = value as ColorComparisonMethod
}
const onMaskBoundaryChange = (value: boolean) => {
store.maskBoundary = value
}
const onMaskToleranceChange = (value: number) => {
store.setMaskTolerance(value)
}
</script>

View File

@@ -0,0 +1,227 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.layers') }}
</h3>
<SliderControl
:label="t('maskEditor.maskOpacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.maskOpacity"
@update:model-value="onMaskOpacityChange"
/>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.maskBlendingOptions')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
>
<select
class="maskEditor_sidePanelDropdown"
:value="store.maskBlendMode"
@change="onBlendModeChange"
>
<option value="black">{{ t('maskEditor.black') }}</option>
<option value="white">{{ t('maskEditor.white') }}</option>
<option value="negative">{{ t('maskEditor.negative') }}</option>
</select>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.maskLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
}"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="maskLayerVisible"
@change="onMaskLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<svg viewBox="0 0 20 20" style="">
<path
class="cls-1"
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
/>
</svg>
</div>
<button
style="font-size: 12px"
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
:disabled="store.activeLayer === 'mask'"
@click="setActiveLayer('mask')"
>
{{ t('maskEditor.activateLayer') }}
</button>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.paintLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
}"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="paintLayerVisible"
@change="onPaintLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<svg viewBox="0 0 20 20">
<path
class="cls-1"
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
/>
<path
class="cls-1"
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
/>
</svg>
</div>
<button
style="font-size: 12px"
:style="{
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
display: showLayerButtons ? 'block' : 'none'
}"
:disabled="store.activeLayer === 'rgb'"
@click="setActiveLayer('rgb')"
>
{{ t('maskEditor.activateLayer') }}
</button>
</div>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.baseImageLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<input
type="checkbox"
class="maskEditor_sidePanelLayerCheckbox"
:checked="baseImageLayerVisible"
@change="onBaseImageLayerVisibilityChange"
/>
<div class="maskEditor_sidePanelLayerPreviewContainer">
<img
class="maskEditor_sidePanelImageLayerImage"
:src="baseImageSrc"
:alt="t('maskEditor.baseLayerPreview')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const { toolManager } = defineProps<{
toolManager?: ReturnType<typeof useToolManager>
}>()
const store = useMaskEditorStore()
const canvasManager = useCanvasManager()
const maskLayerVisible = ref(true)
const paintLayerVisible = ref(true)
const baseImageLayerVisible = ref(true)
const baseImageSrc = computed(() => {
return store.image?.src ?? ''
})
const showLayerButtons = computed(() => {
return store.currentTool === Tools.Eraser
})
const onMaskLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
maskLayerVisible.value = checked
const maskCanvas = store.maskCanvas
if (maskCanvas) {
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
}
}
const onPaintLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
paintLayerVisible.value = checked
const rgbCanvas = store.rgbCanvas
if (rgbCanvas) {
rgbCanvas.style.opacity = checked ? '1' : '0'
}
}
const onBaseImageLayerVisibilityChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
baseImageLayerVisible.value = checked
const imgCanvas = store.imgCanvas
if (imgCanvas) {
imgCanvas.style.opacity = checked ? '1' : '0'
}
}
const onMaskOpacityChange = (value: number) => {
store.setMaskOpacity(value)
const maskCanvas = store.maskCanvas
if (maskCanvas) {
maskCanvas.style.opacity = String(value)
}
maskLayerVisible.value = value !== 0
}
const onBlendModeChange = async (event: Event) => {
const value = (event.target as HTMLSelectElement).value
let blendMode: MaskBlendMode
switch (value) {
case 'white':
blendMode = MaskBlendMode.White
break
case 'negative':
blendMode = MaskBlendMode.Negative
break
default:
blendMode = MaskBlendMode.Black
}
store.maskBlendMode = blendMode
await canvasManager.updateMaskColor()
}
const setActiveLayer = (layer: ImageLayer) => {
toolManager?.setActiveLayer(layer)
}
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div
ref="containerRef"
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
>
<div
id="maskEditorCanvasContainer"
ref="canvasContainerRef"
@contextmenu.prevent
>
<canvas
ref="imgCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="rgbCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<canvas
ref="maskCanvasRef"
class="absolute top-0 left-0 w-full h-full"
@contextmenu.prevent
/>
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
</div>
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 overflow-hidden">
<ToolPanel
v-if="initialized"
ref="toolPanelRef"
:tool-manager="toolManager!"
/>
<PointerZone
v-if="initialized"
:tool-manager="toolManager!"
:pan-zoom="panZoom!"
/>
<SidePanel
v-if="initialized"
ref="sidePanelRef"
:tool-manager="toolManager!"
/>
</div>
</div>
<BrushCursor v-if="initialized" :container-ref="containerRef" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import { useToolManager } from '@/composables/maskeditor/useToolManager'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import BrushCursor from './BrushCursor.vue'
import PointerZone from './PointerZone.vue'
import SidePanel from './SidePanel.vue'
import ToolPanel from './ToolPanel.vue'
const { node } = defineProps<{
node: LGraphNode
}>()
const store = useMaskEditorStore()
const dataStore = useMaskEditorDataStore()
const dialogStore = useDialogStore()
const loader = useMaskEditorLoader()
const containerRef = ref<HTMLElement>()
const canvasContainerRef = ref<HTMLDivElement>()
const imgCanvasRef = ref<HTMLCanvasElement>()
const maskCanvasRef = ref<HTMLCanvasElement>()
const rgbCanvasRef = ref<HTMLCanvasElement>()
const canvasBackgroundRef = ref<HTMLDivElement>()
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
const initialized = ref(false)
const keyboard = useKeyboard()
const panZoom = usePanAndZoom()
let toolManager: ReturnType<typeof useToolManager> | null = null
let resizeObserver: ResizeObserver | null = null
const handleDragStart = (event: DragEvent) => {
if (event.ctrlKey) {
event.preventDefault()
}
}
const initUI = async () => {
if (!containerRef.value) {
console.error(
'[MaskEditorContent] Cannot initialize - missing required refs'
)
return
}
if (
!imgCanvasRef.value ||
!maskCanvasRef.value ||
!rgbCanvasRef.value ||
!canvasContainerRef.value ||
!canvasBackgroundRef.value
) {
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
return
}
store.maskCanvas = maskCanvasRef.value
store.rgbCanvas = rgbCanvasRef.value
store.imgCanvas = imgCanvasRef.value
store.canvasContainer = canvasContainerRef.value
store.canvasBackground = canvasBackgroundRef.value
try {
await loader.loadFromNode(node)
toolManager = useToolManager(keyboard, panZoom)
const imageLoader = useImageLoader()
const image = await imageLoader.loadImages()
await panZoom.initializeCanvasPanZoom(
image,
containerRef.value,
toolPanelRef.value?.$el as HTMLElement | undefined,
sidePanelRef.value?.$el as HTMLElement | undefined
)
store.canvasHistory.saveInitialState()
initialized.value = true
} catch (error) {
console.error('[MaskEditorContent] Initialization failed:', error)
dialogStore.closeDialog()
}
}
onMounted(() => {
keyboard.addListeners()
if (containerRef.value) {
resizeObserver = new ResizeObserver(async () => {
if (panZoom) {
await panZoom.invalidatePanZoom()
}
})
resizeObserver.observe(containerRef.value)
}
void initUI()
})
onBeforeUnmount(() => {
toolManager?.brushDrawing.saveBrushSettings()
keyboard?.removeListeners()
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
store.canvasHistory.clearStates()
store.resetState()
dataStore.reset()
})
</script>
<style scoped>
.maskEditor-dialog-root {
position: relative;
overflow: hidden;
}
.maskEditor-ui-container {
position: relative;
z-index: 1;
}
:deep(#maskEditorCanvasContainer) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.paintBucketSettings') }}
</h3>
<SliderControl
:label="t('maskEditor.tolerance')"
:min="0"
:max="255"
:step="1"
:model-value="store.paintBucketTolerance"
@update:model-value="onToleranceChange"
/>
<SliderControl
:label="t('maskEditor.fillOpacity')"
:min="0"
:max="100"
:step="1"
:model-value="store.fillOpacity"
@update:model-value="onFillOpacityChange"
/>
</div>
</template>
<script setup lang="ts">
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const onToleranceChange = (value: number) => {
store.setPaintBucketTolerance(value)
}
const onFillOpacityChange = (value: number) => {
store.setFillOpacity(value)
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div
ref="pointerZoneRef"
class="w-[calc(100%-4rem-220px)] h-full"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerLeave"
@pointerenter="handlePointerEnter"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@wheel="handleWheel"
@contextmenu.prevent
/>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { toolManager, panZoom } = defineProps<{
toolManager: ReturnType<typeof useToolManager>
panZoom: ReturnType<typeof usePanAndZoom>
}>()
const store = useMaskEditorStore()
const pointerZoneRef = ref<HTMLDivElement>()
onMounted(() => {
if (!pointerZoneRef.value) {
console.error('[PointerZone] Pointer zone ref not initialized')
return
}
store.pointerZone = pointerZoneRef.value
})
watch(
() => store.isPanning,
(isPanning) => {
if (!pointerZoneRef.value) return
if (isPanning) {
pointerZoneRef.value.style.cursor = 'grabbing'
} else {
toolManager.updateCursor()
}
}
)
const handlePointerDown = async (event: PointerEvent) => {
await toolManager.handlePointerDown(event)
}
const handlePointerMove = async (event: PointerEvent) => {
await toolManager.handlePointerMove(event)
}
const handlePointerUp = (event: PointerEvent) => {
void toolManager.handlePointerUp(event)
}
const handlePointerLeave = () => {
store.brushVisible = false
if (pointerZoneRef.value) {
pointerZoneRef.value.style.cursor = ''
}
}
const handlePointerEnter = () => {
toolManager.updateCursor()
}
const handleTouchStart = (event: TouchEvent) => {
panZoom.handleTouchStart(event)
}
const handleTouchMove = async (event: TouchEvent) => {
await panZoom.handleTouchMove(event)
}
const handleTouchEnd = (event: TouchEvent) => {
panZoom.handleTouchEnd(event)
}
const handleWheel = async (event: WheelEvent) => {
await panZoom.zoom(event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div class="maskEditor_sidePanel">
<div class="maskEditor_sidePanelContainer">
<component :is="currentPanelComponent" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import { Tools } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import BrushSettingsPanel from './BrushSettingsPanel.vue'
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
const currentPanelComponent = computed<Component>(() => {
const tool = useMaskEditorStore().currentTool
if (tool === Tools.MaskBucket) {
return PaintBucketSettingsPanel
} else if (tool === Tools.MaskColorFill) {
return ColorSelectSettingsPanel
} else {
return BrushSettingsPanel
}
})
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
>
<div class="w-full min-h-full">
<SettingsPanelContainer />
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
<ImageLayerSettingsPanel :tool-manager="toolManager" />
</div>
</div>
</template>
<script setup lang="ts">
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
import SettingsPanelContainer from './SettingsPanelContainer.vue'
const { toolManager } = defineProps<{
toolManager?: ReturnType<typeof useToolManager>
}>()
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
>
<div class="flex flex-col">
<div
v-for="tool in allTools"
:key="tool"
:class="[
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
]"
@click="onToolSelect(tool)"
>
<div
class="flex items-center justify-center"
v-html="iconsHtml[tool]"
></div>
<div class="maskEditor_toolPanelIndicator"></div>
</div>
</div>
<div
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
zoomText
}}</span>
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
dimensionsText
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
import type { Tools } from '@/extensions/core/maskeditor/types'
import { allTools } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const { toolManager } = defineProps<{
toolManager: ReturnType<typeof useToolManager>
}>()
const store = useMaskEditorStore()
const onToolSelect = (tool: Tools) => {
toolManager.switchTool(tool)
}
const currentTool = computed(() => store.currentTool)
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
const dimensionsText = computed(() => {
const img = store.image
return img ? `${img.width}x${img.height}` : ' '
})
const onResetZoom = () => {
store.resetZoom()
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<select
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
:value="modelValue"
@change="onChange"
>
<option
v-for="option in normalizedOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface DropdownOption {
label: string
value: string | number
}
interface Props {
label: string
options: string[] | DropdownOption[]
modelValue: string | number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
const normalizedOptions = computed((): DropdownOption[] => {
return props.options.map((option) => {
if (typeof option === 'string') {
return { label: option, value: option }
}
return option
})
})
const onChange = (event: Event) => {
const value = (event.target as HTMLSelectElement).value
emit('update:modelValue', value)
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<input
type="range"
class="maskEditor_sidePanelBrushRange"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
min: number
max: number
step?: number
modelValue: number
}
withDefaults(defineProps<Props>(), {
step: 1
})
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const onInput = (event: Event) => {
const value = Number((event.target as HTMLInputElement).value)
emit('update:modelValue', value)
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
label
}}</span>
<label class="maskEditor_sidePanelToggleContainer">
<input
type="checkbox"
class="maskEditor_sidePanelToggleCheckbox"
:checked="modelValue"
@change="onChange"
/>
<div class="maskEditor_sidePanelToggleSwitch"></div>
</label>
</div>
</template>
<script setup lang="ts">
interface Props {
label: string
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const onChange = (event: Event) => {
const checked = (event.target as HTMLInputElement).checked
emit('update:modelValue', checked)
}
</script>

View File

@@ -0,0 +1,126 @@
<template>
<div class="flex w-full items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
<div class="flex items-center gap-4">
<button
:class="iconButtonClass"
:title="t('maskEditor.undo')"
@click="onUndo"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.redo')"
@click="onRedo"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
class="cls-1"
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
/>
</svg>
</button>
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
<button :class="textButtonClass" @click="onClear">
{{ t('maskEditor.clear') }}
</button>
</div>
</div>
<div class="flex gap-3">
<Button
:label="saveButtonText"
icon="pi pi-check"
size="small"
:disabled="!saveEnabled"
@click="handleSave"
/>
<Button
:label="t('g.cancel')"
icon="pi pi-times"
size="small"
severity="secondary"
@click="handleCancel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
import { t } from '@/i18n'
import { useDialogStore } from '@/stores/dialogStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
const store = useMaskEditorStore()
const dialogStore = useDialogStore()
const canvasTools = useCanvasTools()
const saver = useMaskEditorSaver()
const saveButtonText = ref(t('g.save'))
const saveEnabled = ref(true)
const iconButtonClass =
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
const textButtonClass =
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
const onUndo = () => {
store.canvasHistory.undo()
}
const onRedo = () => {
store.canvasHistory.redo()
}
const onInvert = () => {
canvasTools.invertMask()
}
const onClear = () => {
canvasTools.clearMask()
}
const handleSave = async () => {
saveButtonText.value = t('g.saving')
saveEnabled.value = false
try {
store.brushVisible = false
await saver.save()
dialogStore.closeDialog()
} catch (error) {
console.error('[TopBarHeader] Save failed:', error)
store.brushVisible = true
saveButtonText.value = t('g.save')
saveEnabled.value = true
}
}
const handleCancel = () => {
dialogStore.closeDialog({ key: 'global-mask-editor' })
}
</script>

View File

@@ -1,5 +1,6 @@
<template>
<div
ref="menuButtonRef"
v-tooltip="{
value: t('sideToolbar.labels.menu'),
showDelay: 300,
@@ -29,6 +30,7 @@
>
<template #item="{ item, props }">
<a
v-if="item.key !== 'nodes-2.0-toggle'"
class="p-menubar-item-link px-4 py-2"
v-bind="props.action"
:href="item.url"
@@ -65,6 +67,34 @@
</span>
<i v-if="item.items" class="pi pi-angle-right ml-auto" />
</a>
<div
v-else
class="flex items-center justify-between px-4 py-2"
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
:aria-label="item.label"
:pt="{
root: {
style: {
width: '38px',
height: '20px'
}
},
handle: {
style: {
width: '16px',
height: '16px'
}
}
}"
@click.stop
@update:model-value="onNodes2ToggleChange"
/>
</div>
</template>
</TieredMenu>
</template>
@@ -73,6 +103,7 @@
import type { MenuItem } from 'primevue/menuitem'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -80,6 +111,7 @@ import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.
import ComfyLogo from '@/components/icons/ComfyLogo.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useCommandStore } from '@/stores/commandStore'
@@ -98,10 +130,19 @@ const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const dialogStore = useDialogStore()
const managerState = useManagerState()
const settingStore = useSettingStore()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
const menuButtonRef = ref<HTMLElement | null>(null)
const nodes2Enabled = computed({
get: () => settingStore.get('Comfy.VueNodes.Enabled') ?? false,
set: async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
}
})
const telemetry = useTelemetry()
@@ -164,6 +205,10 @@ const extraMenuItems = computed(() => [
label: t('menu.theme'),
items: themeMenuItems.value
},
{
key: 'nodes-2.0-toggle',
label: 'Nodes 2.0'
},
{ separator: true },
{
key: 'browse-templates',
@@ -281,6 +326,17 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
menuItemStore.menuItemHasActiveStateChildren[item.parentPath])
)
}
const handleNodes2ToggleClick = () => {
return false
}
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
})
}
</script>
<style scoped>

View File

@@ -1,7 +1,7 @@
<template>
<nav
ref="sideToolbarRef"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2"
class="side-tool-bar-container flex h-full flex-col items-center bg-transparent [.floating-sidebar]:-mr-2 pointer-events-auto"
:class="{
'small-sidebar': isSmall,
'connected-sidebar': isConnected,
@@ -145,7 +145,7 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
const isOverflowing = ref(false)
const groupClasses = computed(() =>
cn(
'sidebar-item-group pointer-events-auto flex flex-col items-center overflow-hidden flex-shrink-0' +
'sidebar-item-group flex flex-col items-center overflow-hidden flex-shrink-0' +
(isConnected.value ? '' : ' rounded-lg shadow-interface')
)
)

View File

@@ -10,7 +10,7 @@
>
<slot name="top" />
</div>
<div v-if="slots.header" class="px-4">
<div v-if="slots.header" class="px-4 pb-4">
<slot name="header" />
</div>
</div>

View File

@@ -2,7 +2,7 @@
<AssetsSidebarTemplate>
<template #top>
<span v-if="!isInFolderView" class="font-bold">
{{ $t('sideToolbar.mediaAssets') }}
{{ $t('sideToolbar.mediaAssets.title') }}
</span>
<div v-else class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -36,9 +36,16 @@
</div>
<!-- Normal Tab View -->
<TabList v-else v-model="activeTab" class="pt-4 pb-1">
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
<!-- Filter Bar -->
<MediaAssetFilterBar
v-model:search-query="searchQuery"
v-model:sort-by="sortBy"
v-model:media-type-filters="mediaTypeFilters"
:show-generation-time-sort="activeTab === 'output'"
/>
</template>
<template #body>
<!-- Loading state -->
@@ -66,7 +73,7 @@
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0.5rem',
padding: '0 0.5rem',
gap: '0.5rem'
}"
@approach-end="handleApproachEnd"
@@ -77,7 +84,7 @@
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="!isInFolderView"
:show-delete-button="shouldShowDeleteButton"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@@ -89,7 +96,7 @@
</template>
<template #footer>
<div
v-if="hasSelection && activeTab === 'output'"
v-if="hasSelection"
class="flex h-18 w-full items-center justify-between px-4"
>
<div>
@@ -117,7 +124,7 @@
</div>
<div class="flex gap-2">
<IconTextButton
v-if="!isInFolderView"
v-if="shouldShowDeleteButton"
:label="$t('mediaAsset.selection.deleteSelected')"
type="secondary"
icon-position="right"
@@ -157,16 +164,21 @@ import IconTextButton from '@/components/button/IconTextButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import { t } from '@/i18n'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -177,6 +189,13 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
if (activeTab.value === 'input' && !isCloud) return false
return true
})
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 0
@@ -228,13 +247,22 @@ const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const displayAssets = computed(() => {
// Base assets before search filtering
const baseAssets = computed(() => {
if (isInFolderView.value) {
return folderAssets.value
}
return mediaAssets.value
})
// Use media asset filtering composable
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
return filteredAssets.value
})
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
@@ -293,6 +321,8 @@ watch(
activeTab,
() => {
clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
// Reset pagination state when tab changes
void refreshAssets()
},
@@ -305,6 +335,25 @@ const handleAssetSelect = (asset: AssetItem) => {
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
if (mediaType === '3D') {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true
}
})
return
}
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
@@ -350,6 +399,7 @@ const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
folderAssets.value = []
searchQuery.value = ''
clearSelection()
}

View File

@@ -15,11 +15,24 @@
size="small"
:label="t('vueNodesMigration.button')"
text
@click="handleOpenSettings"
@click="switchBack"
/>
</div>
</template>
</Toast>
<Toast
group="vue-nodes-check-main-menu"
position="bottom-center"
class="w-auto"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigrationMainMenu.message')
}}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
@@ -29,20 +42,38 @@ import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import { useDialogService } from '@/services/dialogService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
const { t } = useI18n()
const toast = useToast()
const dialogService = useDialogService()
const isDismissed = useVueNodesMigrationDismissed()
const handleOpenSettings = () => {
dialogService.showSettingsDialog()
const switchBack = async () => {
await disableVueNodes()
toast.removeGroup('vue-nodes-migration')
isDismissed.value = true
showMainMenuToast()
}
const handleClose = () => {
isDismissed.value = true
showMainMenuToast()
}
const disableVueNodes = async () => {
await useSettingStore().set('Comfy.VueNodes.Enabled', false)
useTelemetry()?.trackUiButtonClicked({
button_id: `vue_nodes_migration_toast_switch_back_clicked`
})
}
const showMainMenuToast = () => {
useToastStore().add({
group: 'vue-nodes-check-main-menu',
severity: 'info',
life: 5000
})
}
</script>

View File

@@ -101,6 +101,7 @@ import UserAvatar from '@/components/common/UserAvatar.vue'
import UserCredit from '@/components/common/UserCredit.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
@@ -111,6 +112,8 @@ const emit = defineEmits<{
close: []
}>()
const { buildDocsUrl } = useExternalLink()
const planSettingsLabel = isCloud
? 'settingsCategories.PlanCredits'
: 'settingsCategories.Credits'
@@ -145,7 +148,9 @@ const handleTopUp = () => {
const handleOpenPartnerNodesInfo = () => {
window.open(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
includeLocale: true
}),
'_blank'
)
emit('close')

View File

@@ -22,7 +22,7 @@
<div>
<div class="mb-1">{{ t('auth.loginButton.tooltipHelp') }}</div>
<a
href="https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes"
:href="apiNodesOverviewUrl"
target="_blank"
class="text-neutral-500 hover:text-primary"
>{{ t('auth.loginButton.tooltipLearnMore') }}</a
@@ -37,9 +37,17 @@ import Popover from 'primevue/popover'
import { ref } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useExternalLink } from '@/composables/useExternalLink'
import { t } from '@/i18n'
const { isLoggedIn, handleSignIn } = useCurrentUser()
const { buildDocsUrl } = useExternalLink()
const apiNodesOverviewUrl = buildDocsUrl(
'/tutorials/api-nodes/overview#api-nodes',
{
includeLocale: true
}
)
const popoverRef = ref<InstanceType<typeof Popover> | null>(null)
let hideTimeout: ReturnType<typeof setTimeout> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null

View File

@@ -4,8 +4,8 @@
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center">
<i class="icon-[lucide--sparkles]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.message') }}</span>
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2 text-sm">{{ $t('vueNodesBanner.message') }}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"

View File

@@ -79,7 +79,8 @@ export function useNodeArrangement() {
return
}
alignNodes(selectedNodes, alignOption.value)
const newPositions = alignNodes(selectedNodes, alignOption.value)
canvasStore.canvas?.repositionNodesVueMode(newPositions)
canvasRefresh.refreshCanvas()
}
@@ -93,7 +94,8 @@ export function useNodeArrangement() {
return
}
distributeNodes(selectedNodes, distributeOption.value)
const newPositions = distributeNodes(selectedNodes, distributeOption.value)
canvasStore.canvas?.repositionNodesVueMode(newPositions)
canvasRefresh.refreshCanvas()
}

View File

@@ -25,6 +25,8 @@ function useVueNodeLifecycleIndividual() {
const isVueNodeToastDismissed = useVueNodesMigrationDismissed()
let hasShownMigrationToast = false
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -85,7 +87,12 @@ function useVueNodeLifecycleIndividual() {
ensureCorrectLayoutScale(
comfyApp.canvas?.graph?.extra.workflowRendererVersion
)
if (!wasEnabled && !isVueNodeToastDismissed.value) {
if (
wasEnabled === false &&
!isVueNodeToastDismissed.value &&
!hasShownMigrationToast
) {
hasShownMigrationToast = true
useToastStore().add({
group: 'vue-nodes-migration',
severity: 'info',

View File

@@ -0,0 +1,671 @@
import { ref } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import {
Tools,
BrushShape,
CompositionOperation
} from '@/extensions/core/maskeditor/types'
import type { Brush, Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useCoordinateTransform } from './useCoordinateTransform'
const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
try {
const brushString = JSON.stringify(brush)
setStorageValue(key, brushString)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
return JSON.parse(brushString) as Brush
} else {
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}
export function useBrushDrawing(initialSettings?: {
useDominantAxis?: boolean
brushAdjustmentSpeed?: number
}) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
maxSize: 8
})
const SMOOTHING_MAX_STEPS = 30
const SMOOTHING_MIN_STEPS = 2
const isDrawing = ref(false)
const isDrawingLine = ref(false)
const lineStartPoint = ref<Point | null>(null)
const smoothingCordsArray = ref<Point[]>([])
const smoothingLastDrawTime = ref(new Date())
const initialDraw = ref(true)
const brushStrokeCanvas = ref<HTMLCanvasElement | null>(null)
const brushStrokeCtx = ref<CanvasRenderingContext2D | null>(null)
const initialPoint = ref<Point | null>(null)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
if (cachedBrushSettings) {
store.setBrushSize(cachedBrushSettings.size)
store.setBrushOpacity(cachedBrushSettings.opacity)
store.setBrushHardness(cachedBrushSettings.hardness)
store.brushSettings.type = cachedBrushSettings.type
store.setBrushSmoothingPrecision(cachedBrushSettings.smoothingPrecision)
}
const createBrushStrokeCanvas = async (): Promise<void> => {
if (brushStrokeCanvas.value !== null) {
return
}
const maskCanvas = store.maskCanvas
if (!maskCanvas) {
throw new Error('Mask canvas not initialized')
}
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
brushStrokeCanvas.value = canvas
brushStrokeCtx.value = canvas.getContext('2d')!
}
const initShape = (compositionOperation: CompositionOperation) => {
const blendMode = store.maskBlendMode
const mask_ctx = store.maskCtx
const rgb_ctx = store.rgbCtx
if (!mask_ctx || !rgb_ctx) {
throw new Error('Canvas contexts are required')
}
mask_ctx.beginPath()
rgb_ctx.beginPath()
if (compositionOperation === CompositionOperation.SourceOver) {
mask_ctx.fillStyle = blendMode
mask_ctx.globalCompositeOperation = CompositionOperation.SourceOver
rgb_ctx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation === CompositionOperation.DestinationOut) {
mask_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
rgb_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
}
}
const formatRgba = (hex: string, alpha: number): string => {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const getCachedBrushTexture = (
radius: number,
hardness: number,
color: string,
opacity: number
): HTMLCanvasElement => {
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
if (brushTextureCache.has(cacheKey)) {
return brushTextureCache.get(cacheKey)!
}
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
const size = radius * 2
tempCanvas.width = size
tempCanvas.height = size
const centerX = size / 2
const centerY = size / 2
const hardRadius = radius * hardness
const imageData = tempCtx.createImageData(size, size)
const data = imageData.data
const { r, g, b } = parseToRgb(color)
const fadeRange = radius - hardRadius
for (let y = 0; y < size; y++) {
const dy = y - centerY
for (let x = 0; x < size; x++) {
const dx = x - centerX
const index = (y * size + x) * 4
// Calculate square distance (Chebyshev distance)
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
let pixelOpacity = 0
if (distFromEdge <= hardRadius) {
pixelOpacity = opacity
} else if (distFromEdge <= radius) {
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
pixelOpacity = opacity * (1 - fadeProgress)
}
data[index] = r
data[index + 1] = g
data[index + 2] = b
data[index + 3] = pixelOpacity * 255
}
}
tempCtx.putImageData(imageData, 0, 0)
brushTextureCache.set(cacheKey, tempCanvas)
return tempCanvas
}
const createBrushGradient = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
hardness: number,
color: string,
opacity: number,
isErasing: boolean
): CanvasGradient => {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
const { r, g, b } = parseToRgb(color)
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${opacity})`)
gradient.addColorStop(
hardness,
`rgba(${r}, ${g}, ${b}, ${opacity * 0.5})`
)
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`)
}
return gradient
}
const drawShapeOnContext = (
ctx: CanvasRenderingContext2D,
brushType: BrushShape,
x: number,
y: number,
radius: number
): void => {
ctx.beginPath()
if (brushType === BrushShape.Rect) {
ctx.rect(x - radius, y - radius, radius * 2, radius * 2)
} else {
ctx.arc(x, y, radius, 0, Math.PI * 2, false)
}
ctx.fill()
}
const drawRgbShape = (
ctx: CanvasRenderingContext2D,
point: Point,
brushType: BrushShape,
brushRadius: number,
hardness: number,
opacity: number
): void => {
const { x, y } = point
const rgbColor = store.rgbColor
if (brushType === BrushShape.Rect && hardness < 1) {
const rgbaColor = formatRgba(rgbColor, opacity)
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
rgbaColor,
opacity
)
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
if (hardness === 1) {
const rgbaColor = formatRgba(rgbColor, opacity)
ctx.fillStyle = rgbaColor
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
return
}
const gradient = createBrushGradient(
ctx,
x,
y,
brushRadius,
hardness,
rgbColor,
opacity,
false
)
ctx.fillStyle = gradient
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
}
const drawMaskShape = (
ctx: CanvasRenderingContext2D,
point: Point,
brushType: BrushShape,
brushRadius: number,
hardness: number,
opacity: number,
isErasing: boolean
): void => {
const { x, y } = point
const maskColor = store.maskColor
if (brushType === BrushShape.Rect && hardness < 1) {
const baseColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
baseColor,
opacity
)
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
if (hardness === 1) {
ctx.fillStyle = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
return
}
const maskColorHex = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
const gradient = createBrushGradient(
ctx,
x,
y,
brushRadius,
hardness,
maskColorHex,
opacity,
isErasing
)
ctx.fillStyle = gradient
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
}
const drawShape = (point: Point, overrideOpacity?: number) => {
const brush = store.brushSettings
const mask_ctx = store.maskCtx
const rgb_ctx = store.rgbCtx
if (!mask_ctx || !rgb_ctx) {
throw new Error('Canvas contexts are required')
}
const brushType = brush.type
const brushRadius = brush.size
const hardness = brush.hardness
const opacity = overrideOpacity ?? brush.opacity
const isErasing = mask_ctx.globalCompositeOperation === 'destination-out'
const currentTool = store.currentTool
const isRgbLayer = store.activeLayer === 'rgb'
if (
isRgbLayer &&
currentTool &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
drawRgbShape(rgb_ctx, point, brushType, brushRadius, hardness, opacity)
return
}
drawMaskShape(
mask_ctx,
point,
brushType,
brushRadius,
hardness,
opacity,
isErasing
)
}
const clampSmoothingPrecision = (value: number): number => {
return Math.min(Math.max(value, 1), 100)
}
const generateEquidistantPoints = (
points: Point[],
distance: number
): Point[] => {
const result: Point[] = []
const cumulativeDistances: number[] = [0]
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const dist = Math.hypot(dx, dy)
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
}
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
const numPoints = Math.floor(totalLength / distance)
for (let i = 0; i <= numPoints; i++) {
const targetDistance = i * distance
let idx = 0
while (
idx < cumulativeDistances.length - 1 &&
cumulativeDistances[idx + 1] < targetDistance
) {
idx++
}
if (idx >= points.length - 1) {
result.push(points[points.length - 1])
continue
}
const d0 = cumulativeDistances[idx]
const d1 = cumulativeDistances[idx + 1]
const t = (targetDistance - d0) / (d1 - d0)
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
result.push({ x, y })
}
return result
}
const drawWithBetterSmoothing = (point: Point): void => {
if (!smoothingCordsArray.value) {
smoothingCordsArray.value = []
}
const opacityConstant = 1 / (1 + Math.exp(3))
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
opacityConstant
smoothingCordsArray.value.push(point)
const POINTS_NR = 5
if (smoothingCordsArray.value.length < POINTS_NR) {
return
}
let totalLength = 0
const points = smoothingCordsArray.value
const len = points.length - 1
let dx, dy
for (let i = 0; i < len; i++) {
dx = points[i + 1].x - points[i].x
dy = points[i + 1].y - points[i].y
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const maxSteps = SMOOTHING_MAX_STEPS
const minSteps = SMOOTHING_MIN_STEPS
const smoothing = clampSmoothingPrecision(
store.brushSettings.smoothingPrecision
)
const normalizedSmoothing = (smoothing - 1) / 99
const stepNr = Math.round(
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
)
const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
if (stepNr > 0) {
interpolatedPoints = generateEquidistantPoints(
smoothingCordsArray.value,
distanceBetweenPoints
)
}
if (!initialDraw.value) {
const spliceIndex = interpolatedPoints.findIndex(
(p) =>
p.x === smoothingCordsArray.value[2].x &&
p.y === smoothingCordsArray.value[2].y
)
if (spliceIndex !== -1) {
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
}
}
for (const p of interpolatedPoints) {
drawShape(p, interpolatedOpacity)
}
if (!initialDraw.value) {
smoothingCordsArray.value = smoothingCordsArray.value.slice(2)
} else {
initialDraw.value = false
}
}
const drawLine = async (
p1: Point,
p2: Point,
compositionOp: CompositionOperation
): Promise<void> => {
try {
const brush_size = store.brushSettings.size
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const steps = Math.ceil(
distance / ((brush_size / store.brushSettings.smoothingPrecision) * 4)
)
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
1 / (1 + Math.exp(3))
initShape(compositionOp)
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = p1.x + (p2.x - p1.x) * t
const y = p1.y + (p2.y - p1.y) * t
const point = { x, y }
drawShape(point, interpolatedOpacity)
}
} catch (error) {
console.error('[useBrushDrawing] Failed to draw line:', error)
throw error
}
}
const startDrawing = async (event: PointerEvent): Promise<void> => {
isDrawing.value = true
try {
let compositionOp: CompositionOperation
const currentTool = store.currentTool
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
await createBrushStrokeCanvas()
if (currentTool === 'eraser' || event.buttons === 2) {
compositionOp = CompositionOperation.DestinationOut
} else {
compositionOp = CompositionOperation.SourceOver
}
if (event.shiftKey && lineStartPoint.value) {
isDrawingLine.value = true
await drawLine(lineStartPoint.value, coords_canvas, compositionOp)
} else {
isDrawingLine.value = false
initShape(compositionOp)
drawShape(coords_canvas)
}
lineStartPoint.value = coords_canvas
smoothingCordsArray.value = [coords_canvas]
smoothingLastDrawTime.value = new Date()
} catch (error) {
console.error('[useBrushDrawing] Failed to start drawing:', error)
isDrawing.value = false
isDrawingLine.value = false
}
}
const handleDrawing = async (event: PointerEvent): Promise<void> => {
const diff = performance.now() - smoothingLastDrawTime.value.getTime()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const currentTool = store.currentTool
if (diff > 20 && !isDrawing.value) {
requestAnimationFrame(() => {
try {
initShape(CompositionOperation.SourceOver)
drawShape(coords_canvas)
smoothingCordsArray.value.push(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
})
} else {
requestAnimationFrame(() => {
try {
if (currentTool === 'eraser' || event.buttons === 2) {
initShape(CompositionOperation.DestinationOut)
} else {
initShape(CompositionOperation.SourceOver)
}
drawWithBetterSmoothing(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
})
}
smoothingLastDrawTime.value = new Date()
}
const drawEnd = async (event: PointerEvent): Promise<void> => {
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
if (isDrawing.value) {
isDrawing.value = false
store.canvasHistory.saveState()
lineStartPoint.value = coords_canvas
initialDraw.value = true
}
}
const startBrushAdjustment = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
}
const handleBrushAdjustment = async (event: PointerEvent): Promise<void> => {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
const newSize = Math.max(
1,
Math.min(
100,
store.brushSettings.size +
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
store.brushSettings.hardness -
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
const saveBrushSettings = (): void => {
saveBrushToCache('maskeditor_brush_settings', store.brushSettings)
}
return {
startDrawing,
handleDrawing,
drawEnd,
startBrushAdjustment,
handleBrushAdjustment,
saveBrushSettings
}
}

View File

@@ -0,0 +1,136 @@
import { ref, computed } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
const canUndo = computed(
() => states.value.length > 1 && currentStateIndex.value > 0
)
const canRedo = computed(() => {
return (
states.value.length > 1 &&
currentStateIndex.value < states.value.length - 1
)
})
const saveInitialState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
requestAnimationFrame(saveInitialState)
return
}
if (!maskCanvas.width || !rgbCanvas.width) {
requestAnimationFrame(saveInitialState)
return
}
states.value = []
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value = 0
initialized.value = true
}
const saveState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
if (!initialized.value || currentStateIndex.value === -1) {
saveInitialState()
return
}
states.value = states.value.slice(0, currentStateIndex.value + 1)
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
if (states.value.length > maxStates) {
states.value.shift()
currentStateIndex.value--
}
}
const undo = () => {
if (!canUndo.value) {
alert('No more undo states available')
return
}
currentStateIndex.value--
restoreState(states.value[currentStateIndex.value])
}
const redo = () => {
if (!canRedo.value) {
alert('No more redo states available')
return
}
currentStateIndex.value++
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
maskCtx.putImageData(state.mask, 0, 0)
rgbCtx.putImageData(state.rgb, 0, 0)
}
const clearStates = () => {
states.value = []
currentStateIndex.value = -1
initialized.value = false
}
return {
canUndo,
canRedo,
saveInitialState,
saveState,
undo,
redo,
clearStates
}
}

View File

@@ -0,0 +1,121 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
export function useCanvasManager() {
const store = useMaskEditorStore()
const prepareMask = async (
image: HTMLImageElement,
maskCanvasEl: HTMLCanvasElement,
maskContext: CanvasRenderingContext2D
): Promise<void> => {
const maskColor = store.maskColor
maskContext.drawImage(image, 0, 0, maskCanvasEl.width, maskCanvasEl.height)
const maskData = maskContext.getImageData(
0,
0,
maskCanvasEl.width,
maskCanvasEl.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
const alpha = maskData.data[i + 3]
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
maskData.data[i + 3] = 255 - alpha
}
maskContext.globalCompositeOperation = 'source-over'
maskContext.putImageData(maskData, 0, 0)
}
const invalidateCanvas = async (
origImage: HTMLImageElement,
maskImage: HTMLImageElement,
paintImage: HTMLImageElement | null
): Promise<void> => {
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx, rgbCtx } = store
if (
!imgCanvas ||
!maskCanvas ||
!rgbCanvas ||
!imgCtx ||
!maskCtx ||
!rgbCtx
) {
throw new Error('Canvas elements or contexts not available')
}
imgCanvas.width = origImage.width
imgCanvas.height = origImage.height
maskCanvas.width = origImage.width
maskCanvas.height = origImage.height
rgbCanvas.width = origImage.width
rgbCanvas.height = origImage.height
imgCtx.drawImage(origImage, 0, 0, origImage.width, origImage.height)
if (paintImage) {
rgbCtx.drawImage(paintImage, 0, 0, paintImage.width, paintImage.height)
}
await prepareMask(maskImage, maskCanvas, maskCtx)
}
const setCanvasBackground = (): void => {
const canvasBackground = store.canvasBackground
if (!canvasBackground) return
if (store.maskBlendMode === MaskBlendMode.Black) {
canvasBackground.style.backgroundColor = 'rgba(0,0,0,1)'
} else if (store.maskBlendMode === MaskBlendMode.White) {
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
} else if (store.maskBlendMode === MaskBlendMode.Negative) {
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
}
}
const updateMaskColor = async (): Promise<void> => {
const { maskCanvas, maskCtx, maskColor, maskBlendMode, maskOpacity } = store
if (!maskCanvas || !maskCtx) return
if (maskBlendMode === MaskBlendMode.Negative) {
maskCanvas.style.mixBlendMode = 'difference'
maskCanvas.style.opacity = '1'
} else {
maskCanvas.style.mixBlendMode = 'initial'
maskCanvas.style.opacity = String(maskOpacity)
}
maskCtx.fillStyle = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
setCanvasBackground()
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
}
maskCtx.putImageData(maskData, 0, 0)
}
return {
invalidateCanvas,
updateMaskColor
}
}

View File

@@ -0,0 +1,486 @@
import { ref, watch } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
const getPixelAlpha = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number
): number => {
return data[(y * width + x) * 4 + 3]
}
const getPixelColor = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number
): { r: number; g: number; b: number } => {
const index = (y * width + x) * 4
return {
r: data[index],
g: data[index + 1],
b: data[index + 2]
}
}
const setPixel = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number,
alpha: number,
color: { r: number; g: number; b: number }
): void => {
const index = (y * width + x) * 4
data[index] = color.r
data[index + 1] = color.g
data[index + 2] = color.b
data[index + 3] = alpha
}
// Color comparison utilities
const rgbToHSL = (
r: number,
g: number,
b: number
): { h: number; s: number; l: number } => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
}
const rgbToLab = (rgb: {
r: number
g: number
b: number
}): {
l: number
a: number
b: number
} => {
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
r *= 100
g *= 100
b *= 100
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
const xn = 95.047
const yn = 100.0
const zn = 108.883
const xyz = [x / xn, y / yn, z / zn]
for (let i = 0; i < xyz.length; i++) {
xyz[i] =
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
}
return {
l: 116 * xyz[1] - 16,
a: 500 * (xyz[0] - xyz[1]),
b: 200 * (xyz[1] - xyz[2])
}
}
const isPixelInRangeSimple = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const distance = Math.sqrt(
Math.pow(pixel.r - target.r, 2) +
Math.pow(pixel.g - target.g, 2) +
Math.pow(pixel.b - target.b, 2)
)
return distance <= tolerance
}
const isPixelInRangeHSL = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const pixelHSL = rgbToHSL(pixel.r, pixel.g, pixel.b)
const targetHSL = rgbToHSL(target.r, target.g, target.b)
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
const distance = Math.sqrt(
Math.pow((hueDiff / 360) * 255, 2) +
Math.pow((satDiff / 100) * 255, 2) +
Math.pow((lightDiff / 100) * 255, 2)
)
return distance <= tolerance
}
const isPixelInRangeLab = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const pixelLab = rgbToLab(pixel)
const targetLab = rgbToLab(target)
const deltaE = Math.sqrt(
Math.pow(pixelLab.l - targetLab.l, 2) +
Math.pow(pixelLab.a - targetLab.a, 2) +
Math.pow(pixelLab.b - targetLab.b, 2)
)
const normalizedDeltaE = (deltaE / 100) * 255
return normalizedDeltaE <= tolerance
}
const isPixelInRange = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number,
method: ColorComparisonMethod
): boolean => {
switch (method) {
case ColorComparisonMethod.Simple:
return isPixelInRangeSimple(pixel, target, tolerance)
case ColorComparisonMethod.HSL:
return isPixelInRangeHSL(pixel, target, tolerance)
case ColorComparisonMethod.LAB:
return isPixelInRangeLab(pixel, target, tolerance)
default:
return isPixelInRangeSimple(pixel, target, tolerance)
}
}
export function useCanvasTools() {
const store = useMaskEditorStore()
const lastColorSelectPoint = ref<Point | null>(null)
const paintBucketFill = (point: Point): void => {
const ctx = store.maskCtx
const canvas = store.maskCanvas
if (!ctx || !canvas) return
const startX = Math.floor(point.x)
const startY = Math.floor(point.y)
const width = canvas.width
const height = canvas.height
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
return
}
const imageData = ctx.getImageData(0, 0, width, height)
const data = imageData.data
const targetAlpha = getPixelAlpha(data, startX, startY, width)
const isFillMode = targetAlpha !== 255
if (targetAlpha === -1) return
const maskColor = store.maskColor
const tolerance = store.paintBucketTolerance
const fillOpacity = Math.floor((store.fillOpacity / 100) * 255)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(width * height)
const shouldProcessPixel = (
currentAlpha: number,
targetAlpha: number,
tolerance: number,
isFillMode: boolean
): boolean => {
if (currentAlpha === -1) return false
if (isFillMode) {
return (
currentAlpha !== 255 &&
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
} else {
return (
currentAlpha === 255 ||
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
}
}
if (shouldProcessPixel(targetAlpha, targetAlpha, tolerance, isFillMode)) {
stack.push([startX, startY])
}
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * width + x
if (visited[visitedIndex]) continue
const currentAlpha = getPixelAlpha(data, x, y, width)
if (
!shouldProcessPixel(currentAlpha, targetAlpha, tolerance, isFillMode)
) {
continue
}
visited[visitedIndex] = 1
setPixel(data, x, y, width, isFillMode ? fillOpacity : 0, maskColor)
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
if (!visited[ny * width + nx]) {
const alpha = getPixelAlpha(data, nx, ny, width)
if (shouldProcessPixel(alpha, targetAlpha, tolerance, isFillMode)) {
stack.push([nx, ny])
}
}
}
checkNeighbor(x - 1, y)
checkNeighbor(x + 1, y)
checkNeighbor(x, y - 1)
checkNeighbor(x, y + 1)
}
ctx.putImageData(imageData, 0, 0)
store.canvasHistory.saveState()
}
const colorSelectFill = async (point: Point): Promise<void> => {
const maskCtx = store.maskCtx
const imgCtx = store.imgCtx
const imgCanvas = store.imgCanvas
if (!maskCtx || !imgCtx || !imgCanvas) return
const width = imgCanvas.width
const height = imgCanvas.height
lastColorSelectPoint.value = point
const maskData = maskCtx.getImageData(0, 0, width, height)
const maskDataArray = maskData.data
const imageDataArray = imgCtx.getImageData(0, 0, width, height).data
const tolerance = store.colorSelectTolerance
const method = store.colorComparisonMethod
const maskColor = store.maskColor
const selectOpacity = Math.floor((store.selectionOpacity / 100) * 255)
const applyWholeImage = store.applyWholeImage
const maskBoundary = store.maskBoundary
const maskTolerance = store.maskTolerance
if (applyWholeImage) {
const targetPixel = getPixelColor(
imageDataArray,
Math.floor(point.x),
Math.floor(point.y),
width
)
const CHUNK_SIZE = 10000
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (
isPixelInRange(
getPixelColor(imageDataArray, x, y, width),
targetPixel,
tolerance,
method
)
) {
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
}
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
} else {
const startX = Math.floor(point.x)
const startY = Math.floor(point.y)
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
return
}
const targetPixel = getPixelColor(imageDataArray, startX, startY, width)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(width * height)
stack.push([startX, startY])
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * width + x
if (
visited[visitedIndex] ||
!isPixelInRange(
getPixelColor(imageDataArray, x, y, width),
targetPixel,
tolerance,
method
)
) {
continue
}
visited[visitedIndex] = 1
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
if (visited[ny * width + nx]) return
if (
!isPixelInRange(
getPixelColor(imageDataArray, nx, ny, width),
targetPixel,
tolerance,
method
)
)
return
if (
maskBoundary &&
255 - getPixelAlpha(maskDataArray, nx, ny, width) <= maskTolerance
)
return
stack.push([nx, ny])
}
checkNeighbor(x - 1, y)
checkNeighbor(x + 1, y)
checkNeighbor(x, y - 1)
checkNeighbor(x, y + 1)
}
}
maskCtx.putImageData(maskData, 0, 0)
store.canvasHistory.saveState()
}
const invertMask = (): void => {
const ctx = store.maskCtx
const canvas = store.maskCanvas
if (!ctx || !canvas) return
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
data[i + 3] = 255 - alpha
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
ctx.putImageData(imageData, 0, 0)
store.canvasHistory.saveState()
}
const clearMask = (): void => {
const maskCtx = store.maskCtx
const maskCanvas = store.maskCanvas
const rgbCtx = store.rgbCtx
const rgbCanvas = store.rgbCanvas
if (maskCtx && maskCanvas) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
}
if (rgbCtx && rgbCanvas) {
rgbCtx.clearRect(0, 0, rgbCanvas.width, rgbCanvas.height)
}
store.canvasHistory.saveState()
}
const clearLastColorSelectPoint = () => {
lastColorSelectPoint.value = null
}
watch(
[
() => store.colorSelectTolerance,
() => store.colorComparisonMethod,
() => store.selectionOpacity
],
async () => {
if (
lastColorSelectPoint.value &&
store.colorSelectLivePreview &&
store.canUndo
) {
store.canvasHistory.undo()
await colorSelectFill(lastColorSelectPoint.value)
}
}
)
return {
paintBucketFill,
colorSelectFill,
clearLastColorSelectPoint,
invertMask,
clearMask
}
}

View File

@@ -0,0 +1,79 @@
import { createSharedComposable } from '@vueuse/core'
import { unref } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
interface Point {
x: number
y: number
}
function useCoordinateTransformInternal() {
const store = useMaskEditorStore()
const screenToCanvas = (clientPoint: Point): Point => {
const pointerZoneEl = unref(store.pointerZone)
const canvasContainerEl = unref(store.canvasContainer)
const canvasEl = unref(store.maskCanvas)
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
console.warn('screenToCanvas called before elements are available')
return { x: 0, y: 0 }
}
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
const canvasRect = canvasEl.getBoundingClientRect()
const absoluteX = pointerZoneRect.left + clientPoint.x
const absoluteY = pointerZoneRect.top + clientPoint.y
const canvasX = absoluteX - canvasContainerRect.left
const canvasY = absoluteY - canvasContainerRect.top
const scaleX = canvasEl.width / canvasRect.width
const scaleY = canvasEl.height / canvasRect.height
const x = canvasX * scaleX
const y = canvasY * scaleY
return { x, y }
}
const canvasToScreen = (canvasPoint: Point): Point => {
const pointerZoneEl = unref(store.pointerZone)
const canvasContainerEl = unref(store.canvasContainer)
const canvasEl = unref(store.maskCanvas)
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
console.warn('canvasToScreen called before elements are available')
return { x: 0, y: 0 }
}
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
const canvasRect = canvasEl.getBoundingClientRect()
const scaleX = canvasRect.width / canvasEl.width
const scaleY = canvasRect.height / canvasEl.height
const displayX = canvasPoint.x * scaleX
const displayY = canvasPoint.y * scaleY
const absoluteX = canvasContainerRect.left + displayX
const absoluteY = canvasContainerRect.top + displayY
const x = absoluteX - pointerZoneRect.left
const y = absoluteY - pointerZoneRect.top
return { x, y }
}
return {
screenToCanvas,
canvasToScreen
}
}
export const useCoordinateTransform = createSharedComposable(
useCoordinateTransformInternal
)

View File

@@ -0,0 +1,54 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { createSharedComposable } from '@vueuse/core'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
function useImageLoaderInternal() {
const store = useMaskEditorStore()
const dataStore = useMaskEditorDataStore()
const canvasManager = useCanvasManager()
const loadImages = async (): Promise<HTMLImageElement> => {
const inputData = dataStore.inputData
if (!inputData) {
throw new Error('No input data available in dataStore')
}
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx } = store
if (!imgCanvas || !maskCanvas || !rgbCanvas || !imgCtx || !maskCtx) {
throw new Error('Canvas elements or contexts not available')
}
imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height)
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
const baseImage = inputData.baseLayer.image
const maskImage = inputData.maskLayer.image
const paintImage = inputData.paintLayer?.image
maskCanvas.width = baseImage.width
maskCanvas.height = baseImage.height
rgbCanvas.width = baseImage.width
rgbCanvas.height = baseImage.height
store.image = baseImage
await canvasManager.invalidateCanvas(
baseImage,
maskImage,
paintImage || null
)
await canvasManager.updateMaskColor()
return baseImage
}
return {
loadImages
}
}
export const useImageLoader = createSharedComposable(useImageLoaderInternal)

View File

@@ -0,0 +1,62 @@
import { ref } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useKeyboard() {
const store = useMaskEditorStore()
const keysDown = ref<string[]>([])
const isKeyDown = (key: string): boolean => {
return keysDown.value.includes(key)
}
const clearKeys = (): void => {
keysDown.value = []
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (!keysDown.value.includes(event.key)) {
keysDown.value.push(event.key)
}
if (event.key === ' ') {
event.preventDefault()
const activeElement = document.activeElement as HTMLElement
if (activeElement && activeElement.blur) {
activeElement.blur()
}
}
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toUpperCase()
if ((key === 'Y' && !event.shiftKey) || (key === 'Z' && event.shiftKey)) {
store.canvasHistory.redo()
} else if (key === 'Z' && !event.shiftKey) {
store.canvasHistory.undo()
}
}
}
const handleKeyUp = (event: KeyboardEvent): void => {
keysDown.value = keysDown.value.filter((key) => key !== event.key)
}
const addListeners = (): void => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', clearKeys)
}
const removeListeners = (): void => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', clearKeys)
}
return {
isKeyDown,
addListeners,
removeListeners
}
}

View File

@@ -0,0 +1,310 @@
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import type { ImageRef, ImageLayer } from '@/stores/maskEditorDataStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
// Private image utility functions
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
interface MaskLayersResponse {
painted_masked?: string
painted?: string
paint?: string
mask?: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
function imageLayerFilenamesIfApplicable(
inputImageFilename: string
): ImageLayerFilenames | undefined {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return {
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
}
}
function toRef(filename: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
type: 'input'
}
}
function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
const params = new URLSearchParams()
params.set('filename', props.ref.filename)
if (props.ref.subfolder) {
params.set('subfolder', props.ref.subfolder)
}
if (props.ref.type) {
params.set('type', props.ref.type)
}
const pathPlusQueryParams = api.apiURL(
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.crossOrigin = 'anonymous'
imageElement.src = pathPlusQueryParams
return imageElement.src
}
export function useMaskEditorLoader() {
const dataStore = useMaskEditorDataStore()
const nodeOutputStore = useNodeOutputStore()
const loadFromNode = async (node: LGraphNode): Promise<void> => {
dataStore.setLoading(true)
try {
validateNode(node)
const nodeImageUrl = getNodeImageUrl(node)
const nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
const fileToQuery = widgetFilename || nodeImageRef.filename
let maskLayersFromApi: MaskLayersResponse | undefined
if (isCloud) {
try {
const response = await api.fetchApi(
`/files/mask-layers?filename=${fileToQuery}`
)
if (response.ok) {
maskLayersFromApi = await response.json()
}
} catch (error) {
// Fallback to pattern matching if API call fails
}
}
let imageLayerFilenames = imageLayerFilenamesIfApplicable(
nodeImageRef.filename
)
if (maskLayersFromApi) {
const baseFile =
maskLayersFromApi.painted_masked || maskLayersFromApi.painted
if (baseFile) {
imageLayerFilenames = {
maskedImage: baseFile,
paint: maskLayersFromApi.paint || '',
paintedImage: maskLayersFromApi.painted || '',
paintedMaskedImage: maskLayersFromApi.painted_masked || baseFile
}
}
}
const baseImageUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: nodeImageUrl
const sourceRef = imageLayerFilenames?.maskedImage
? parseImageRef(baseImageUrl)
: nodeImageRef
let paintLayerUrl: string | null = null
if (maskLayersFromApi?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
} else if (imageLayerFilenames?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
}
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
loadImageLayer(baseImageUrl, 'rgb'),
loadImageLayer(baseImageUrl, 'a'),
paintLayerUrl
? loadPaintLayer(paintLayerUrl)
: Promise.resolve(undefined)
])
dataStore.inputData = {
baseLayer,
maskLayer,
paintLayer,
sourceRef,
nodeId: node.id
}
dataStore.sourceNode = node
dataStore.setLoading(false)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to load from node'
console.error('[MaskEditorLoader]', errorMessage, error)
dataStore.setLoading(false, errorMessage)
throw error
}
}
function validateNode(node: LGraphNode): void {
if (!node) {
throw new Error('Node is null or undefined')
}
const hasImages = node.imgs?.length || node.previewMediaType === 'image'
if (!hasImages) {
throw new Error('Node has no images')
}
}
function getNodeImageUrl(node: LGraphNode): string {
if (node.images?.[0]) {
const img = node.images[0]
const params = new URLSearchParams({
filename: img.filename,
type: img.type || 'output',
subfolder: img.subfolder || ''
})
return api.apiURL(`/view?${params.toString()}`)
}
const outputs = nodeOutputStore.getNodeOutputs(node)
if (outputs?.images?.[0]) {
const img = outputs.images[0]
if (!img.filename) {
throw new Error('nodeOutputStore image missing filename')
}
const params = new URLSearchParams()
params.set('filename', img.filename)
params.set('type', img.type || 'output')
params.set('subfolder', img.subfolder || '')
return api.apiURL(`/view?${params.toString()}`)
}
if (node.imgs?.length) {
const index = node.imageIndex ?? 0
const imgSrc = node.imgs[index].src
if (imgSrc && !imgSrc.startsWith('data:')) {
return imgSrc
}
}
throw new Error('Unable to get image URL from node')
}
function parseImageRef(url: string): ImageRef {
try {
const urlObj = new URL(url)
const filename = urlObj.searchParams.get('filename')
if (!filename) {
throw new Error('Image URL missing filename parameter')
}
return {
filename,
subfolder: urlObj.searchParams.get('subfolder') || undefined,
type: urlObj.searchParams.get('type') || undefined
}
} catch (error) {
try {
const urlObj = new URL(url, window.location.origin)
const filename = urlObj.searchParams.get('filename')
if (!filename) {
throw new Error('Image URL missing filename parameter')
}
return {
filename,
subfolder: urlObj.searchParams.get('subfolder') || undefined,
type: urlObj.searchParams.get('type') || undefined
}
} catch (e) {
throw new Error(`Invalid image URL: ${url}`)
}
}
}
async function loadImageLayer(
url: string,
channel?: 'rgb' | 'a'
): Promise<ImageLayer> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
urlObj = new URL(url, window.location.origin)
}
if (channel) {
urlObj.searchParams.delete('channel')
urlObj.searchParams.set('channel', channel)
}
const finalUrl = urlObj.toString()
const image = await loadImage(finalUrl)
return { image, url: finalUrl }
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Failed to load image: ${url}`))
img.src = url
})
}
async function loadPaintLayer(url: string): Promise<ImageLayer> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
urlObj = new URL(url, window.location.origin)
}
const finalUrl = urlObj.toString()
const image = await loadImage(finalUrl)
return { image, url: finalUrl }
}
return {
loadFromNode
}
}

View File

@@ -0,0 +1,401 @@
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type {
EditorOutputData,
EditorOutputLayer,
ImageRef
} from '@/stores/maskEditorDataStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
// Private layer filename functions
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
function imageLayerFilenamesByTimestamp(
timestamp: number
): ImageLayerFilenames {
return {
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `clipspace-painted-masked-${timestamp}.png`
}
}
export function useMaskEditorSaver() {
const dataStore = useMaskEditorDataStore()
const editorStore = useMaskEditorStore()
const nodeOutputStore = useNodeOutputStore()
const save = async (): Promise<void> => {
const sourceNode = dataStore.sourceNode as LGraphNode
if (!sourceNode || !dataStore.inputData) {
throw new Error('No source node or input data')
}
try {
const outputData = await prepareOutputData()
dataStore.outputData = outputData
await updateNodePreview(sourceNode, outputData)
await uploadAllLayers(outputData)
updateNodeWithServerReferences(sourceNode, outputData)
app.graph.setDirtyCanvas(true)
} catch (error) {
console.error('[MaskEditorSaver] Save failed:', error)
throw error
}
}
async function prepareOutputData(): Promise<EditorOutputData> {
const maskCanvas = editorStore.maskCanvas
const paintCanvas = editorStore.rgbCanvas
const imgCanvas = editorStore.imgCanvas
if (!maskCanvas || !paintCanvas || !imgCanvas) {
throw new Error('Canvas not initialized')
}
const timestamp = Date.now()
const filenames = imageLayerFilenamesByTimestamp(timestamp)
const [maskedImage, paintLayer, paintedImage, paintedMaskedImage] =
await Promise.all([
createMaskedImage(imgCanvas, maskCanvas, filenames.maskedImage),
createPaintLayer(paintCanvas, filenames.paint),
createPaintedImage(imgCanvas, paintCanvas, filenames.paintedImage),
createPaintedMaskedImage(
imgCanvas,
paintCanvas,
maskCanvas,
filenames.paintedMaskedImage
)
])
return {
maskedImage,
paintLayer,
paintedImage,
paintedMaskedImage
}
}
async function createMaskedImage(
imgCanvas: HTMLCanvasElement,
maskCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintLayer(
paintCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = cloneCanvas(paintCanvas)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintedImage(
imgCanvas: HTMLCanvasElement,
paintCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintedMaskedImage(
imgCanvas: HTMLCanvasElement,
paintCanvas: HTMLCanvasElement,
maskCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef
)
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef
)
outputData.maskedImage.ref = actualMaskedRef
outputData.paintLayer.ref = actualPaintRef
outputData.paintedImage.ref = actualPaintedRef
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function updateNodePreview(
node: LGraphNode,
outputData: EditorOutputData
): Promise<void> {
const canvas = outputData.paintedMaskedImage.canvas
const dataUrl = canvas.toDataURL('image/png')
const mainImg = await loadImageFromUrl(dataUrl)
node.imgs = [mainImg]
app.graph.setDirtyCanvas(true)
}
function updateNodeWithServerReferences(
node: LGraphNode,
outputData: EditorOutputData
): void {
const mainRef = outputData.paintedMaskedImage.ref
node.images = [mainRef]
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
// Widget value format differs between Cloud and OSS:
// - Cloud: JUST the filename (subfolder handled by backend)
// - OSS: subfolder/filename (traditional format)
let widgetValue: string
if (isCloud) {
widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
} else {
widgetValue =
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
mainRef.filename +
(mainRef.type ? ` [${mainRef.type}]` : '')
}
imageWidget.value = widgetValue
if (node.properties) {
node.properties['image'] = widgetValue
}
if (node.widgets_values && node.widgets) {
const widgetIndex = node.widgets.indexOf(imageWidget)
if (widgetIndex >= 0) {
node.widgets_values[widgetIndex] = widgetValue
}
}
imageWidget.callback?.(widgetValue)
}
nodeOutputStore.updateNodeImages(node)
}
function loadImageFromUrl(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = (error) => {
console.error('[MaskEditorSaver] Failed to load image:', url, error)
reject(new Error(`Failed to load image: ${url}`))
}
img.src = url
})
}
function cloneCanvas(source: HTMLCanvasElement): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = source.width
canvas.height = source.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(source, 0, 0)
return canvas
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error('Failed to create blob from canvas'))
}, 'image/png')
})
}
function createFileRef(filename: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
type: 'input'
}
}
return {
save
}
}

View File

@@ -0,0 +1,416 @@
import { ref, watch } from 'vue'
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function usePanAndZoom() {
const store = useMaskEditorStore()
const DOUBLE_TAP_DELAY = 300
const lastTwoFingerTap = ref(0)
const isTouchZooming = ref(false)
const lastTouchZoomDistance = ref(0)
const lastTouchMidPoint = ref<Point>({ x: 0, y: 0 })
const lastTouchPoint = ref<Point>({ x: 0, y: 0 })
const zoom_ratio = ref(1)
const interpolatedZoomRatio = ref(1)
const pan_offset = ref<Offset>({ x: 0, y: 0 })
const mouseDownPoint = ref<Point | null>(null)
const initialPan = ref<Offset>({ x: 0, y: 0 })
const canvasContainer = ref<HTMLElement | null>(null)
const maskCanvas = ref<HTMLCanvasElement | null>(null)
const rgbCanvas = ref<HTMLCanvasElement | null>(null)
const rootElement = ref<HTMLElement | null>(null)
const toolPanelElement = ref<HTMLElement | null>(null)
const sidePanelElement = ref<HTMLElement | null>(null)
const image = ref<HTMLImageElement | null>(null)
const imageRootWidth = ref(0)
const imageRootHeight = ref(0)
const cursorPoint = ref<Point>({ x: 0, y: 0 })
const penPointerIdList = ref<number[]>([])
const getTouchDistance = (touches: TouchList): number => {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
const getTouchMidpoint = (touches: TouchList): Point => {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
const updateCursorPosition = (clientPoint: Point): void => {
const cursorX = clientPoint.x - pan_offset.value.x
const cursorY = clientPoint.y - pan_offset.value.y
cursorPoint.value = { x: cursorX, y: cursorY }
store.setCursorPoint({ x: cursorX, y: cursorY })
}
const handleDoubleTap = (): void => {
store.canvasHistory.undo()
}
const invalidatePanZoom = async (): Promise<void> => {
// Single validation check upfront
if (
!image.value?.width ||
!image.value?.height ||
!pan_offset.value ||
!zoom_ratio.value
) {
console.warn('Missing required properties for pan/zoom')
return
}
const raw_width = image.value.width * zoom_ratio.value
const raw_height = image.value.height * zoom_ratio.value
if (!canvasContainer.value) {
canvasContainer.value = store.canvasContainer
}
if (!canvasContainer.value) return
Object.assign(canvasContainer.value.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${pan_offset.value.x}px`,
top: `${pan_offset.value.y}px`
})
if (!rgbCanvas.value) {
rgbCanvas.value = store.rgbCanvas
}
if (rgbCanvas.value) {
if (
rgbCanvas.value.width !== image.value.width ||
rgbCanvas.value.height !== image.value.height
) {
rgbCanvas.value.width = image.value.width
rgbCanvas.value.height = image.value.height
}
rgbCanvas.value.style.width = `${raw_width}px`
rgbCanvas.value.style.height = `${raw_height}px`
}
store.setPanOffset(pan_offset.value)
store.setZoomRatio(zoom_ratio.value)
}
const handlePanStart = (event: PointerEvent): void => {
mouseDownPoint.value = { x: event.clientX, y: event.clientY }
store.isPanning = true
initialPan.value = { ...pan_offset.value }
}
const handlePanMove = async (event: PointerEvent): Promise<void> => {
if (mouseDownPoint.value === null) {
throw new Error('mouseDownPoint is null')
}
const deltaX = mouseDownPoint.value.x - event.clientX
const deltaY = mouseDownPoint.value.y - event.clientY
const pan_x = initialPan.value.x - deltaX
const pan_y = initialPan.value.y - deltaY
pan_offset.value = { x: pan_x, y: pan_y }
await invalidatePanZoom()
}
const handleSingleTouchPan = async (touch: Touch): Promise<void> => {
if (lastTouchPoint.value === null) {
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
return
}
const deltaX = touch.clientX - lastTouchPoint.value.x
const deltaY = touch.clientY - lastTouchPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
await invalidatePanZoom()
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
}
const handleTouchStart = (event: TouchEvent): void => {
event.preventDefault()
if (penPointerIdList.value.length > 0) return
store.brushVisible = false
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - lastTwoFingerTap.value
if (tapTimeDiff < DOUBLE_TAP_DELAY) {
handleDoubleTap()
lastTwoFingerTap.value = 0
} else {
lastTwoFingerTap.value = currentTime
isTouchZooming.value = true
lastTouchZoomDistance.value = getTouchDistance(event.touches)
lastTouchMidPoint.value = getTouchMidpoint(event.touches)
}
} else if (event.touches.length === 1) {
lastTouchPoint.value = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
const handleTouchMove = async (event: TouchEvent): Promise<void> => {
event.preventDefault()
if (penPointerIdList.value.length > 0) return
lastTwoFingerTap.value = 0
if (isTouchZooming.value && event.touches.length === 2) {
const newDistance = getTouchDistance(event.touches)
const zoomFactor = newDistance / lastTouchZoomDistance.value
const oldZoom = zoom_ratio.value
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
const midpoint = getTouchMidpoint(event.touches)
if (lastTouchMidPoint.value) {
const deltaX = midpoint.x - lastTouchMidPoint.value.x
const deltaY = midpoint.y - lastTouchMidPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
}
if (maskCanvas.value === null) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += touchX - touchX * scaleFactor
pan_offset.value.y += touchY - touchY * scaleFactor
await invalidatePanZoom()
lastTouchZoomDistance.value = newDistance
lastTouchMidPoint.value = midpoint
} else if (event.touches.length === 1) {
await handleSingleTouchPan(event.touches[0])
}
}
const handleTouchEnd = (event: TouchEvent): void => {
event.preventDefault()
const lastTouch = event.touches[0]
if (lastTouch) {
lastTouchPoint.value = {
x: lastTouch.clientX,
y: lastTouch.clientY
}
} else {
isTouchZooming.value = false
lastTouchMidPoint.value = { x: 0, y: 0 }
}
}
const zoom = async (event: WheelEvent): Promise<void> => {
const cursorPosition = { x: event.clientX, y: event.clientY }
const oldZoom = zoom_ratio.value
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
if (!maskCanvas.value) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const mouseX = cursorPosition.x - rect.left
const mouseY = cursorPosition.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += mouseX - mouseX * scaleFactor
pan_offset.value.y += mouseY - mouseY * scaleFactor
await invalidatePanZoom()
const newImageWidth = maskCanvas.value.clientWidth
const zoomRatio = newImageWidth / imageRootWidth.value
interpolatedZoomRatio.value = zoomRatio
store.displayZoomRatio = zoomRatio
updateCursorPosition(cursorPosition)
}
const getPanelDimensions = (): {
sidePanelWidth: number
toolPanelWidth: number
} => {
const toolPanelWidth =
toolPanelElement.value?.getBoundingClientRect().width || 64
const sidePanelWidth =
sidePanelElement.value?.getBoundingClientRect().width || 220
return { sidePanelWidth, toolPanelWidth }
}
const smoothResetView = async (duration: number = 500): Promise<void> => {
if (!image.value || !rootElement.value) return
const startZoom = zoom_ratio.value
const startPan = { ...pan_offset.value }
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth =
rootElement.value.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = rootElement.value.clientHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / image.value.width
const zoomRatioHeight = availableHeight / image.value.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
const aspectRatio = image.value.width / image.value.height
let finalWidth: number
let finalHeight: number
const targetPan = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = async (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
zoom_ratio.value = startZoom + (targetZoom - startZoom) * eased
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
await invalidatePanZoom()
const interpolatedRatio = startZoom + (1.0 - startZoom) * eased
store.displayZoomRatio = interpolatedRatio
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
interpolatedZoomRatio.value = 1.0
}
const initializeCanvasPanZoom = async (
img: HTMLImageElement,
root: HTMLElement,
toolPanel?: HTMLElement | null,
sidePanel?: HTMLElement | null
): Promise<void> => {
rootElement.value = root
toolPanelElement.value = toolPanel || null
sidePanelElement.value = sidePanel || null
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth = root.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = root.clientHeight
const zoomRatioWidth = availableWidth / img.width
const zoomRatioHeight = availableHeight / img.height
const aspectRatio = img.width / img.height
let finalWidth: number
let finalHeight: number
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
panOffset.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
panOffset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (image.value === null) {
image.value = img
}
imageRootWidth.value = finalWidth
imageRootHeight.value = finalHeight
zoom_ratio.value = Math.min(zoomRatioWidth, zoomRatioHeight)
pan_offset.value = panOffset
penPointerIdList.value = []
await invalidatePanZoom()
}
watch(
() => store.resetZoomTrigger,
async () => {
if (interpolatedZoomRatio.value === 1) return
await smoothResetView()
}
)
return {
initializeCanvasPanZoom,
handlePanStart,
handlePanMove,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
updateCursorPosition,
zoom,
invalidatePanZoom
}
}

View File

@@ -0,0 +1,228 @@
import { ref, watch } from 'vue'
import type {
Point,
ImageLayer,
ToolInternalSettings
} from '@/extensions/core/maskeditor/types'
import { Tools } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushDrawing } from './useBrushDrawing'
import { useCanvasTools } from './useCanvasTools'
import { useCoordinateTransform } from './useCoordinateTransform'
import type { useKeyboard } from './useKeyboard'
import type { usePanAndZoom } from './usePanAndZoom'
import { app } from '@/scripts/app'
export function useToolManager(
keyboard: ReturnType<typeof useKeyboard>,
panZoom: ReturnType<typeof usePanAndZoom>
) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const brushDrawing = useBrushDrawing({
useDominantAxis: app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
),
brushAdjustmentSpeed: app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
})
const canvasTools = useCanvasTools()
const mouseDownPoint = ref<Point | null>(null)
const toolSettings: Record<Tools, Partial<ToolInternalSettings>> = {
[Tools.MaskPen]: {
newActiveLayerOnSet: 'mask'
},
[Tools.Eraser]: {},
[Tools.PaintPen]: {
newActiveLayerOnSet: 'rgb'
},
[Tools.MaskBucket]: {
cursor: "url('/cursor/paintBucket.png') 30 25, auto",
newActiveLayerOnSet: 'mask'
},
[Tools.MaskColorFill]: {
cursor: "url('/cursor/colorSelect.png') 15 25, auto",
newActiveLayerOnSet: 'mask'
}
}
const setActiveLayer = (layer: ImageLayer) => {
store.activeLayer = layer
const currentTool = store.currentTool
const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill]
if (maskOnlyTools.includes(currentTool) && layer === 'rgb') {
switchTool(Tools.PaintPen)
}
if (currentTool === Tools.PaintPen && layer === 'mask') {
switchTool(Tools.MaskPen)
}
}
const switchTool = (tool: Tools) => {
store.currentTool = tool
const newActiveLayer = toolSettings[tool].newActiveLayerOnSet
if (newActiveLayer) {
store.activeLayer = newActiveLayer
}
const cursor = toolSettings[tool].cursor
const pointerZone = store.pointerZone
if (cursor && pointerZone) {
store.brushVisible = false
pointerZone.style.cursor = cursor
} else if (pointerZone) {
store.brushVisible = true
pointerZone.style.cursor = 'none'
}
}
const updateCursor = () => {
const currentTool = store.currentTool
const cursor = toolSettings[currentTool].cursor
const pointerZone = store.pointerZone
if (cursor && pointerZone) {
store.brushVisible = false
pointerZone.style.cursor = cursor
} else if (pointerZone) {
store.brushVisible = true
pointerZone.style.cursor = 'none'
}
store.brushPreviewGradientVisible = false
}
watch(
() => store.currentTool,
(newTool) => {
if (newTool !== Tools.MaskColorFill) {
canvasTools.clearLastColorSelectPoint()
}
}
)
const handlePointerDown = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
if (event.pointerType === 'touch') return
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
panZoom.handlePanStart(event)
store.brushVisible = false
return
}
if (store.currentTool === Tools.PaintPen && event.button === 0) {
await brushDrawing.startDrawing(event)
return
}
if (store.currentTool === Tools.PaintPen && event.buttons === 1) {
await brushDrawing.handleDrawing(event)
return
}
if (store.currentTool === Tools.MaskBucket && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(offset)
canvasTools.paintBucketFill(coords_canvas)
return
}
if (store.currentTool === Tools.MaskColorFill && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(offset)
await canvasTools.colorSelectFill(coords_canvas)
return
}
if (event.altKey && event.button === 2) {
store.isAdjustingBrush = true
await brushDrawing.startBrushAdjustment(event)
return
}
const isDrawingTool = [
Tools.MaskPen,
Tools.Eraser,
Tools.PaintPen
].includes(store.currentTool)
if ([0, 2].includes(event.button) && isDrawingTool) {
await brushDrawing.startDrawing(event)
return
}
}
const handlePointerMove = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
if (event.pointerType === 'touch') return
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
await panZoom.handlePanMove(event)
return
}
const isDrawingTool = [
Tools.MaskPen,
Tools.Eraser,
Tools.PaintPen
].includes(store.currentTool)
if (!isDrawingTool) return
if (
store.isAdjustingBrush &&
(store.currentTool === Tools.MaskPen ||
store.currentTool === Tools.Eraser) &&
event.altKey &&
event.buttons === 2
) {
await brushDrawing.handleBrushAdjustment(event)
return
}
if (event.buttons === 1 || event.buttons === 2) {
await brushDrawing.handleDrawing(event)
return
}
}
const handlePointerUp = async (event: PointerEvent): Promise<void> => {
store.isPanning = false
store.brushVisible = true
if (event.pointerType === 'touch') return
updateCursor()
store.isAdjustingBrush = false
await brushDrawing.drawEnd(event)
mouseDownPoint.value = null
}
return {
switchTool,
setActiveLayer,
updateCursor,
handlePointerDown,
handlePointerMove,
handlePointerUp,
brushDrawing
}
}

View File

@@ -1,6 +1,7 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
DEFAULT_DARK_COLOR_PALETTE,
@@ -76,6 +77,7 @@ export function useCoreCommands(): ComfyCommand[] {
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const telemetry = useTelemetry()
const { staticUrls, buildDocsUrl } = useExternalLink()
const bottomPanelStore = useBottomPanelStore()
@@ -761,10 +763,7 @@ export function useCoreCommands(): ComfyCommand[] {
is_external: true,
source: 'menu'
})
window.open(
'https://github.com/comfyanonymous/ComfyUI/issues',
'_blank'
)
window.open(staticUrls.githubIssues, '_blank')
}
},
{
@@ -779,7 +778,7 @@ export function useCoreCommands(): ComfyCommand[] {
is_external: true,
source: 'menu'
})
window.open('https://docs.comfy.org/', '_blank')
window.open(buildDocsUrl('/', { includeLocale: true }), '_blank')
}
},
{
@@ -794,7 +793,7 @@ export function useCoreCommands(): ComfyCommand[] {
is_external: true,
source: 'menu'
})
window.open('https://www.comfy.org/discord', '_blank')
window.open(staticUrls.discord, '_blank')
}
},
{
@@ -861,7 +860,7 @@ export function useCoreCommands(): ComfyCommand[] {
is_external: true,
source: 'menu'
})
window.open('https://forum.comfy.org/', '_blank')
window.open(staticUrls.forum, '_blank')
}
},
{

View File

@@ -0,0 +1,99 @@
import { computed } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { useI18n } from 'vue-i18n'
/**
* Composable for building docs.comfy.org URLs with automatic locale and platform detection
*
* @example
* ```ts
* const { buildDocsUrl } = useExternalLink()
*
* // Simple usage
* const changelogUrl = buildDocsUrl('/changelog', { includeLocale: true })
* // => 'https://docs.comfy.org/zh-CN/changelog' (if Chinese)
*
* // With platform detection
* const desktopUrl = buildDocsUrl('/installation/desktop', {
* includeLocale: true,
* platform: true
* })
* // => 'https://docs.comfy.org/zh-CN/installation/desktop/macos' (if Chinese + macOS)
* ```
*/
export function useExternalLink() {
const { locale } = useI18n()
const isChinese = computed(() => {
return locale.value === 'zh' || locale.value === 'zh-TW'
})
const platform = computed(() => {
if (!isElectron()) {
return null
}
const electronPlatform = electronAPI().getPlatform()
return electronPlatform === 'darwin' ? 'macos' : 'windows'
})
/**
* Build a docs.comfy.org URL with optional locale and platform
*
* @param path - The path after the domain (e.g., '/installation/desktop')
* @param options - Options for building the URL
* @param options.includeLocale - Whether to include locale prefix (default: false)
* @param options.platform - Whether to include platform suffix (default: false)
* @returns The complete docs URL
*
* @example
* ```ts
* buildDocsUrl('/changelog') // => 'https://docs.comfy.org/changelog'
* buildDocsUrl('/changelog', { includeLocale: true }) // => 'https://docs.comfy.org/zh-CN/changelog' (if Chinese)
* buildDocsUrl('/installation/desktop', { includeLocale: true, platform: true })
* // => 'https://docs.comfy.org/zh-CN/installation/desktop/macos' (if Chinese + macOS)
* ```
*/
const buildDocsUrl = (
path: string,
options: {
includeLocale?: boolean
platform?: boolean
} = {}
): string => {
const { includeLocale = false, platform: includePlatform = false } = options
let url = 'https://docs.comfy.org'
if (includeLocale && isChinese.value) {
url += '/zh-CN'
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`
url += normalizedPath
if (includePlatform && platform.value) {
url = url.endsWith('/') ? url : `${url}/`
url += platform.value
}
return url
}
const staticUrls = {
// Static external URLs
discord: 'https://www.comfy.org/discord',
github: 'https://github.com/comfyanonymous/ComfyUI',
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',
comfyOrg: 'https://www.comfy.org/'
}
return {
buildDocsUrl,
staticUrls
}
}

View File

@@ -7,6 +7,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
CameraConfig,
CameraState,
CameraType,
LightConfig,
MaterialMode,
@@ -16,8 +17,10 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
@@ -68,10 +71,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const node = rawNode as LGraphNode
try {
load3d = new Load3d(containerRef, {
node
})
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
@@ -79,6 +78,27 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview.value = true
}
load3d = new Load3d(containerRef, {
width: widthWidget?.value as number | undefined,
height: heightWidget?.value as number | undefined,
// Provide dynamic dimension getter for reactive updates
getDimensions:
widthWidget && heightWidget
? () => ({
width: widthWidget.value as number,
height: heightWidget.value as number
})
: undefined,
onContextMenu: (event) => {
const menuOptions = app.canvas.getNodeMenuOptions(node)
new LiteGraph.ContextMenu(menuOptions, {
event,
title: node.type,
extra: node
})
}
})
await restoreConfigurationsFromNode(node)
node.onMouseEnter = function () {
@@ -487,8 +507,26 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
hasRecording.value = recordingDuration.value > 0
}
},
animationListChange: (newValue: any) => {
animationListChange: (newValue: AnimationItem[]) => {
animations.value = newValue
},
cameraChanged: (cameraState: CameraState) => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) {
const node = rawNode as LGraphNode
if (!node.properties) node.properties = {}
const cameraConfigProp = node.properties['Camera Config']
if (cameraConfigProp) {
;(cameraConfigProp as CameraConfig).state = cameraState
} else {
node.properties['Camera Config'] = {
cameraType: cameraConfig.value.cameraType,
fov: cameraConfig.value.fov,
state: cameraState
}
}
}
}
} as const

View File

@@ -4,6 +4,7 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
BackgroundRenderModeType,
CameraState,
CameraType,
MaterialMode,
UpDirection
@@ -20,14 +21,18 @@ interface Load3dViewerState {
cameraType: CameraType
fov: number
lightIntensity: number
cameraState: any
cameraState: CameraState | null
backgroundImage: string
backgroundRenderMode: BackgroundRenderModeType
upDirection: UpDirection
materialMode: MaterialMode
}
export const useLoad3dViewer = (node: LGraphNode) => {
/**
* @param node Optional node - if provided, viewer works in node mode with apply/restore
* If not provided, viewer works in standalone mode for asset preview
*/
export const useLoad3dViewer = (node?: LGraphNode) => {
const backgroundColor = ref('')
const showGrid = ref(true)
const cameraType = ref<CameraType>('perspective')
@@ -40,6 +45,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
const materialMode = ref<MaterialMode>('original')
const needApplyChanges = ref(true)
const isPreview = ref(false)
const isStandaloneMode = ref(false)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -166,18 +172,31 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
})
/**
* Initialize viewer in node mode (with source Load3d)
*/
const initializeViewer = async (
containerRef: HTMLElement,
source: Load3d
) => {
if (!containerRef) return
if (!containerRef || !node) return
sourceLoad3d = source
try {
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
load3d = new Load3d(containerRef, {
node: node,
disablePreview: true,
width: width ? (toRaw(width).value as number) : undefined,
height: height ? (toRaw(height).value as number) : undefined,
getDimensions:
width && height
? () => ({
width: width.value as number,
height: height.value as number
})
: undefined,
isViewerMode: true
})
@@ -245,16 +264,6 @@ export const useLoad3dViewer = (node: LGraphNode) => {
upDirection: upDirection.value,
materialMode: materialMode.value
}
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (width && height) {
load3d.setTargetSize(
toRaw(width).value as number,
toRaw(height).value as number
)
}
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
@@ -263,6 +272,42 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
}
/**
* Initialize viewer in standalone mode (for asset preview)
*/
const initializeStandaloneViewer = async (
containerRef: HTMLElement,
modelUrl: string
) => {
if (!containerRef) return
try {
isStandaloneMode.value = true
load3d = new Load3d(containerRef, {
width: 800,
height: 600,
isViewerMode: true
})
await load3d.loadModel(modelUrl)
backgroundColor.value = '#282828'
showGrid.value = true
cameraType.value = 'perspective'
fov.value = 75
lightIntensity.value = 1
backgroundRenderMode.value = 'tiled'
upDirection.value = 'original'
materialMode.value = 'original'
isPreview.value = true
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
}
}
const exportModel = async (format: string) => {
if (!load3d) return
@@ -289,6 +334,8 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
const restoreInitialState = () => {
if (!node) return
const nodeValue = node
needApplyChanges.value = false
@@ -324,7 +371,7 @@ export const useLoad3dViewer = (node: LGraphNode) => {
}
const applyChanges = async () => {
if (!sourceLoad3d || !load3d) return false
if (!node || !sourceLoad3d || !load3d) return false
const viewerCameraState = load3d.getCameraState()
const nodeValue = node
@@ -378,6 +425,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
return
}
if (!node) {
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
@@ -403,6 +454,10 @@ export const useLoad3dViewer = (node: LGraphNode) => {
return
}
if (!node) {
return
}
try {
const resourceFolder =
(node.properties['Resource Folder'] as string) || ''
@@ -460,9 +515,11 @@ export const useLoad3dViewer = (node: LGraphNode) => {
materialMode,
needApplyChanges,
isPreview,
isStandaloneMode,
// Methods
initializeViewer,
initializeStandaloneViewer,
exportModel,
handleResize,
handleMouseEnter,

View File

@@ -1,5 +1,6 @@
import log from 'loglevel'
import { useExternalLink } from '@/composables/useExternalLink'
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -8,13 +9,6 @@ import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
// Desktop documentation URLs
const DESKTOP_DOCS = {
WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
MACOS: 'https://docs.comfy.org/installation/desktop/macos'
} as const
;(async () => {
if (!isElectron()) return
@@ -22,6 +16,7 @@ const DESKTOP_DOCS = {
const desktopAppVersion = await electronAPI.getElectronVersion()
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const { staticUrls, buildDocsUrl } = useExternalLink()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
// Add a delay to allow changes to take effect before restarting.
@@ -165,11 +160,13 @@ const DESKTOP_DOCS = {
label: 'Desktop User Guide',
icon: 'pi pi-book',
function() {
const docsUrl =
electronAPI.getPlatform() === 'darwin'
? DESKTOP_DOCS.MACOS
: DESKTOP_DOCS.WINDOWS
window.open(docsUrl, '_blank')
window.open(
buildDocsUrl('/installation/desktop', {
includeLocale: true,
platform: true
}),
'_blank'
)
}
},
{
@@ -304,7 +301,7 @@ const DESKTOP_DOCS = {
aboutPageBadges: [
{
label: 'ComfyUI_desktop v' + desktopAppVersion,
url: 'https://github.com/Comfy-Org/electron',
url: staticUrls.githubElectron,
icon: 'pi pi-github'
}
]

View File

@@ -317,7 +317,7 @@ useExtensionService().registerExtension({
const cameraConfig = node.properties['Camera Config'] as any
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d)
const config = new Load3DConfiguration(load3d, node.properties)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
@@ -444,7 +444,7 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3d(node).waitForLoad3d((load3d) => {
const config = new Load3DConfiguration(load3d)
const config = new Load3DConfiguration(load3d, node.properties)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')

View File

@@ -5,8 +5,7 @@ import {
type CameraManagerInterface,
type CameraState,
type CameraType,
type EventManagerInterface,
type NodeStorageInterface
type EventManagerInterface
} from './interfaces'
export class CameraManager implements CameraManagerInterface {
@@ -17,7 +16,6 @@ export class CameraManager implements CameraManagerInterface {
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private nodeStorage: NodeStorageInterface
private controls: OrbitControls | null = null
@@ -45,12 +43,10 @@ export class CameraManager implements CameraManagerInterface {
constructor(
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface,
nodeStorage: NodeStorageInterface
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.eventManager = eventManager
this.nodeStorage = nodeStorage
this.perspectiveCamera = new THREE.PerspectiveCamera(
this.DEFAULT_PERSPECTIVE_CAMERA.fov,
@@ -82,17 +78,7 @@ export class CameraManager implements CameraManagerInterface {
if (this.controls) {
this.controls.addEventListener('end', () => {
const cameraState = this.getCameraState()
const cameraConfig = this.nodeStorage.loadNodeProperty(
'Camera Config',
{
cameraType: this.getCurrentCameraType(),
fov: this.perspectiveCamera.fov
}
)
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
this.eventManager.emitEvent('cameraChanged', this.getCameraState())
})
}
}

View File

@@ -3,25 +3,20 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import {
type ControlsManagerInterface,
type EventManagerInterface,
type NodeStorageInterface
type EventManagerInterface
} from './interfaces'
export class ControlsManager implements ControlsManagerInterface {
controls: OrbitControls
// @ts-expect-error unused variable
private eventManager: EventManagerInterface
private nodeStorage: NodeStorageInterface
private camera: THREE.Camera
constructor(
renderer: THREE.WebGLRenderer,
camera: THREE.Camera,
eventManager: EventManagerInterface,
nodeStorage: NodeStorageInterface
eventManager: EventManagerInterface
) {
this.eventManager = eventManager
this.nodeStorage = nodeStorage
this.camera = camera
const container = renderer.domElement.parentElement || renderer.domElement
@@ -44,15 +39,7 @@ export class ControlsManager implements ControlsManagerInterface {
: 'orthographic'
}
const cameraConfig = this.nodeStorage.loadNodeProperty('Camera Config', {
cameraType: cameraState.cameraType,
fov:
this.camera instanceof THREE.PerspectiveCamera
? (this.camera as THREE.PerspectiveCamera).fov
: 75
})
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
this.eventManager.emitEvent('cameraChanged', cameraState)
})
}

View File

@@ -2,10 +2,13 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
CameraConfig,
CameraState,
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -13,14 +16,17 @@ import { api } from '@/scripts/api'
type Load3DConfigurationSettings = {
loadFolder: string
modelWidget: IBaseWidget
cameraState?: any
cameraState?: CameraState
width?: IBaseWidget
height?: IBaseWidget
bgImagePath?: string
}
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
constructor(
private load3d: Load3d,
private properties?: Dictionary<NodeProperty | undefined>
) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
@@ -62,7 +68,7 @@ class Load3DConfiguration {
private setupModelHandling(
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: any
cameraState?: CameraState
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
@@ -110,48 +116,48 @@ class Load3DConfiguration {
}
private loadSceneConfig(): SceneConfig {
const defaultConfig: SceneConfig = {
if (this.properties && 'Scene Config' in this.properties) {
return this.properties['Scene Config'] as SceneConfig
}
return {
showGrid: useSettingStore().get('Comfy.Load3D.ShowGrid'),
backgroundColor:
'#' + useSettingStore().get('Comfy.Load3D.BackgroundColor'),
backgroundImage: ''
}
const config = this.load3d.loadNodeProperty('Scene Config', defaultConfig)
this.load3d.node.properties['Scene Config'] = config
return config
} as SceneConfig
}
private loadCameraConfig(): CameraConfig {
const defaultConfig: CameraConfig = {
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
fov: 35
if (this.properties && 'Camera Config' in this.properties) {
return this.properties['Camera Config'] as CameraConfig
}
const config = this.load3d.loadNodeProperty('Camera Config', defaultConfig)
this.load3d.node.properties['Camera Config'] = config
return config
return {
cameraType: useSettingStore().get('Comfy.Load3D.CameraType'),
fov: 35
} as CameraConfig
}
private loadLightConfig(): LightConfig {
const defaultConfig: LightConfig = {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
if (this.properties && 'Light Config' in this.properties) {
return this.properties['Light Config'] as LightConfig
}
const config = this.load3d.loadNodeProperty('Light Config', defaultConfig)
this.load3d.node.properties['Light Config'] = config
return config
return {
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
} as LightConfig
}
private loadModelConfig(): ModelConfig {
const defaultConfig: ModelConfig = {
upDirection: 'original',
materialMode: 'original'
if (this.properties && 'Model Config' in this.properties) {
return this.properties['Model Config'] as ModelConfig
}
const config = this.load3d.loadNodeProperty('Model Config', defaultConfig)
this.load3d.node.properties['Model Config'] = config
return config
return {
upDirection: 'original',
materialMode: 'original'
} as ModelConfig
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {
@@ -188,7 +194,10 @@ class Load3DConfiguration {
this.load3d.setMaterialMode(config.materialMode)
}
private createModelUpdateHandler(loadFolder: string, cameraState?: any) {
private createModelUpdateHandler(
loadFolder: string,
cameraState?: CameraState
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
@@ -209,7 +218,7 @@ class Load3DConfiguration {
const modelConfig = this.loadModelConfig()
this.applyModelConfig(modelConfig)
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
if (isFirstLoad && cameraState) {
try {
this.load3d.setCameraState(cameraState)
} catch (error) {
@@ -230,8 +239,8 @@ class Load3DConfiguration {
const subfolderParts = pathParts.slice(1, -1)
const subfolder = subfolderParts.join('/')
if (subfolder) {
this.load3d.node.properties['Resource Folder'] = subfolder
if (subfolder && this.properties) {
this.properties['Resource Folder'] = subfolder
}
}
}

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -9,7 +7,6 @@ import { EventManager } from './EventManager'
import { LightingManager } from './LightingManager'
import { LoaderManager } from './LoaderManager'
import { ModelExporter } from './ModelExporter'
import { NodeStorage } from './NodeStorage'
import { RecordingManager } from './RecordingManager'
import { SceneManager } from './SceneManager'
import { SceneModelManager } from './SceneModelManager'
@@ -21,17 +18,16 @@ import {
type MaterialMode,
type UpDirection
} from './interfaces'
import { app } from '@/scripts/app'
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
protected animationFrameId: number | null = null
node: LGraphNode
private loadingPromise: Promise<void> | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
nodeStorage: NodeStorage
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
@@ -59,23 +55,16 @@ class Load3d {
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
node: {} as LGraphNode
}
) {
this.node = options.node || ({} as LGraphNode)
constructor(container: Element | HTMLElement, options: Load3DOptions = {}) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
if (options.width && options.height) {
this.targetWidth = options.width
this.targetHeight = options.height
this.targetAspectRatio = options.width / options.height
}
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
@@ -87,7 +76,6 @@ class Load3d {
container.appendChild(this.renderer.domElement)
this.eventManager = new EventManager()
this.nodeStorage = new NodeStorage(this.node)
this.sceneManager = new SceneManager(
this.renderer,
@@ -96,17 +84,12 @@ class Load3d {
this.eventManager
)
this.cameraManager = new CameraManager(
this.renderer,
this.eventManager,
this.nodeStorage
)
this.cameraManager = new CameraManager(this.renderer, this.eventManager)
this.controlsManager = new ControlsManager(
this.renderer,
this.cameraManager.activeCamera,
this.eventManager,
this.nodeStorage
this.eventManager
)
this.cameraManager.setControls(this.controlsManager.controls)
@@ -120,7 +103,7 @@ class Load3d {
this.renderer,
this.getActiveCamera.bind(this),
this.getControls.bind(this),
this.nodeStorage
this.eventManager
)
this.modelManager = new SceneModelManager(
@@ -221,13 +204,9 @@ class Load3d {
}
private showNodeContextMenu(event: MouseEvent): void {
const menuOptions = app.canvas.getNodeMenuOptions(this.node)
new LiteGraph.ContextMenu(menuOptions, {
event,
title: this.node.type,
extra: this.node
})
if (this.onContextMenuCallback) {
this.onContextMenuCallback(event)
}
}
getEventManager(): EventManager {
@@ -259,6 +238,17 @@ class Load3d {
return this.recordingManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
private shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
@@ -280,18 +270,16 @@ class Load3d {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
if (shouldMaintainAspectRatio) {
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
@@ -321,7 +309,7 @@ class Load3d {
const renderAspectRatio = renderWidth / renderHeight
this.cameraManager.updateAspectRatio(renderAspectRatio)
} else {
// Preview3D: fill the entire container
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
@@ -459,13 +447,7 @@ class Load3d {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
// Calculate the actual render area based on target aspect ratio
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
if (shouldMaintainAspectRatio) {
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
@@ -486,7 +468,7 @@ class Load3d {
renderHeight
)
} else {
// For Preview3D mode without aspect ratio constraints
// No aspect ratio constraints: fill container
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
@@ -609,6 +591,7 @@ class Load3d {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
@@ -636,20 +619,16 @@ class Load3d {
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Check if we have width/height widgets (Load3D nodes) or if it's viewer mode
const widthWidget = this.node.widgets?.find((w) => w.name === 'width')
const heightWidget = this.node.widgets?.find((w) => w.name === 'height')
const shouldMaintainAspectRatio =
(widthWidget && heightWidget) || this.isViewerMode
if (shouldMaintainAspectRatio) {
// Load3D or viewer mode: maintain aspect ratio
if (widthWidget && heightWidget) {
this.targetWidth = widthWidget.value as number
this.targetHeight = heightWidget.value as number
this.targetAspectRatio = this.targetWidth / this.targetHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const containerAspectRatio = containerWidth / containerHeight
let renderWidth: number
let renderHeight: number
@@ -666,7 +645,7 @@ class Load3d {
this.cameraManager.handleResize(renderWidth, renderHeight)
this.sceneManager.handleResize(renderWidth, renderHeight)
} else {
// Preview3D: use container dimensions directly
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
@@ -679,10 +658,6 @@ class Load3d {
return this.sceneManager.captureScene(width, height)
}
loadNodeProperty(name: string, defaultValue: any) {
return this.nodeStorage.loadNodeProperty(name, defaultValue)
}
public async startRecording(): Promise<void> {
this.viewHelperManager.visibleViewHelper(false)

View File

@@ -1,32 +0,0 @@
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type NodeStorageInterface } from './interfaces'
export class NodeStorage implements NodeStorageInterface {
private node: LGraphNode
constructor(node: LGraphNode = {} as LGraphNode) {
this.node = node
}
storeNodeProperty(name: string, value: any): void {
if (this.node && this.node.properties) {
this.node.properties[name] = value
}
}
loadNodeProperty(name: string, defaultValue: any): any {
if (
!this.node ||
!this.node.properties ||
!(name in this.node.properties)
) {
return defaultValue
}
return this.node.properties[name]
}
setNode(node: LGraphNode): void {
this.node = node
}
}

View File

@@ -3,7 +3,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import {
type NodeStorageInterface,
type EventManagerInterface,
type ViewHelperManagerInterface
} from './interfaces'
@@ -13,7 +13,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
private getActiveCamera: () => THREE.Camera
private getControls: () => OrbitControls
private nodeStorage: NodeStorageInterface
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
@@ -21,12 +21,12 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
getControls: () => OrbitControls,
nodeStorage: NodeStorageInterface
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.nodeStorage = nodeStorage
this.eventManager = eventManager
}
init(): void {}
@@ -87,18 +87,7 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
: 'orthographic'
}
const cameraConfig = this.nodeStorage.loadNodeProperty(
'Camera Config',
{
cameraType: cameraState.cameraType,
fov:
this.getActiveCamera() instanceof THREE.PerspectiveCamera
? (this.getActiveCamera() as THREE.PerspectiveCamera).fov
: 75
}
)
cameraConfig.state = cameraState
this.nodeStorage.storeNodeProperty('Camera Config', cameraConfig)
this.eventManager.emitEvent('cameraChanged', cameraState)
}
}
}

View File

@@ -7,9 +7,6 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type MaterialMode = 'original' | 'normal' | 'wireframe' | 'depth'
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
@@ -49,10 +46,19 @@ export interface EventCallback {
}
export interface Load3DOptions {
node?: LGraphNode
inputSpec?: CustomInputSpec
disablePreview?: boolean
// Optional target dimensions for aspect ratio control
width?: number
height?: number
// Dynamic dimension provider (called on every render)
// Use this for reactive dimensions that change over time
getDimensions?: () => { width: number; height: number } | null
// Viewer mode flag (affects aspect ratio behavior)
isViewerMode?: boolean
// Optional context menu callback
onContextMenu?: (event: MouseEvent) => void
}
export interface CaptureResult {
@@ -121,11 +127,6 @@ export interface EventManagerInterface {
emitEvent(event: string, data?: any): void
}
export interface NodeStorageInterface {
storeNodeProperty(name: string, value: any): void
loadNodeProperty(name: string, defaultValue: any): any
}
export interface AnimationManagerInterface extends BaseManager {
currentAnimation: THREE.AnimationMixer | null
animationActions: THREE.AnimationAction[]

View File

@@ -1,25 +1,62 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '../../scripts/app'
import { ComfyApp } from '../../scripts/app'
import { ClipspaceDialog } from './clipspace'
import { MaskEditorDialog } from './maskeditor/MaskEditorDialog'
import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import { MaskEditorDialogOld } from './maskEditorOld'
import { ClipspaceDialog } from './clipspace'
// Import styles to inject into document
import './maskeditor/styles'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
console.error('[MaskEditor] No node provided')
return
}
if (!node.imgs?.length && node.previewMediaType !== 'image') {
console.error('[MaskEditor] Node has no images')
return
}
// Function to open the mask editor
function openMaskEditor(): void {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
const dlg = MaskEditorDialog.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
// Use new refactored editor
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
} else {
// Use old editor
ComfyApp.copyToClipspace(node)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = node
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
@@ -33,21 +70,12 @@ function isOpened(): boolean {
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
return MaskEditorDialog.instance?.isOpened?.() ?? false
return useDialogStore().isDialogOpen('global-mask-editor')
} else {
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
}
}
// Ensure boolean return type for context predicate
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
@@ -97,15 +125,7 @@ app.registerExtension({
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (
!selectedNode.imgs?.length &&
selectedNode.previewMediaType !== 'image'
)
return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
openMaskEditor()
openMaskEditor(selectedNode)
}
},
{
@@ -122,24 +142,40 @@ app.registerExtension({
}
],
init() {
ComfyApp.open_maskeditor = openMaskEditor
ComfyApp.maskeditor_is_opended = isOpened
// Support for old editor clipspace integration
const openMaskEditorFromClipspace = () => {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (!useNewEditor) {
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
ClipspaceDialog.registerButton(
'MaskEditor',
context_predicate,
openMaskEditor
openMaskEditorFromClipspace
)
}
})
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const maskEditor = MaskEditorDialog.getInstance()
if (!maskEditor) return
const messageBroker = maskEditor.getMessageBroker()
const oldBrushSize = (await messageBroker.pull('brushSettings')).size
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
messageBroker.publish('setBrushSize', newBrushSize)
messageBroker.publish('updateBrushPreview')
store.setBrushSize(newBrushSize)
}

View File

@@ -1,131 +0,0 @@
import type { MaskEditorDialog } from './MaskEditorDialog'
import type { MessageBroker } from './managers/MessageBroker'
export class CanvasHistory {
// @ts-expect-error unused variable
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private rgbCanvas!: HTMLCanvasElement
private rgbCtx!: CanvasRenderingContext2D
private states: { mask: ImageData; rgb: ImageData }[] = []
private currentStateIndex: number = -1
private maxStates: number = 20
private initialized: boolean = false
constructor(maskEditor: MaskEditorDialog, maxStates = 20) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.maxStates = maxStates
this.createListeners()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
this.rgbCtx = await this.messageBroker.pull('rgbCtx')
}
private createListeners() {
this.messageBroker.subscribe('saveState', () => this.saveState())
this.messageBroker.subscribe('undo', () => this.undo())
this.messageBroker.subscribe('redo', () => this.redo())
}
clearStates() {
this.states = []
this.currentStateIndex = -1
this.initialized = false
}
async saveInitialState() {
await this.pullCanvas()
if (
!this.canvas.width ||
!this.canvas.height ||
!this.rgbCanvas.width ||
!this.rgbCanvas.height
) {
// Canvas not ready yet, defer initialization
requestAnimationFrame(() => this.saveInitialState())
return
}
this.clearStates()
const maskState = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const rgbState = this.rgbCtx.getImageData(
0,
0,
this.rgbCanvas.width,
this.rgbCanvas.height
)
this.states.push({ mask: maskState, rgb: rgbState })
this.currentStateIndex = 0
this.initialized = true
}
saveState() {
// Ensure we have an initial state
if (!this.initialized || this.currentStateIndex === -1) {
this.saveInitialState()
return
}
this.states = this.states.slice(0, this.currentStateIndex + 1)
const maskState = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const rgbState = this.rgbCtx.getImageData(
0,
0,
this.rgbCanvas.width,
this.rgbCanvas.height
)
this.states.push({ mask: maskState, rgb: rgbState })
this.currentStateIndex++
if (this.states.length > this.maxStates) {
this.states.shift()
this.currentStateIndex--
}
}
undo() {
if (this.states.length > 1 && this.currentStateIndex > 0) {
this.currentStateIndex--
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more undo states available')
}
}
redo() {
if (
this.states.length > 1 &&
this.currentStateIndex < this.states.length - 1
) {
this.currentStateIndex++
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more redo states available')
}
}
restoreState(state: { mask: ImageData; rgb: ImageData }) {
if (state && this.initialized) {
this.ctx.putImageData(state.mask, 0, 0)
this.rgbCtx.putImageData(state.rgb, 0, 0)
}
}
}

View File

@@ -1,404 +0,0 @@
import { t } from '@/i18n'
import { api } from '../../../scripts/api'
import { ComfyApp } from '../../../scripts/app'
import { $el, ComfyDialog } from '../../../scripts/ui'
import { ClipspaceDialog } from '../clipspace'
import { imageLayerFilenamesByTimestamp } from './utils/maskEditorLayerFilenames'
import { CanvasHistory } from './CanvasHistory'
import { CompositionOperation } from './types'
import type { Ref } from './types'
import {
UIManager,
ToolManager,
PanAndZoomManager,
KeyboardManager,
MessageBroker
} from './managers'
import { BrushTool, PaintBucketTool, ColorSelectTool } from './tools'
import {
ensureImageFullyLoaded,
removeImageRgbValuesAndInvertAlpha,
createCanvasCopy,
getCanvas2dContext,
combineOriginalImageAndPaint,
toRef,
mkFileUrl,
requestWithRetries,
replaceClipspaceImages
} from './utils'
export class MaskEditorDialog extends ComfyDialog {
static instance: MaskEditorDialog | null = null
//new
private uiManager!: UIManager
// @ts-expect-error unused variable
private toolManager!: ToolManager
// @ts-expect-error unused variable
private panAndZoomManager!: PanAndZoomManager
// @ts-expect-error unused variable
private brushTool!: BrushTool
private paintBucketTool!: PaintBucketTool
private colorSelectTool!: ColorSelectTool
private canvasHistory!: CanvasHistory
private messageBroker!: MessageBroker
private keyboardManager!: KeyboardManager
private rootElement!: HTMLElement
private imageURL!: string
private isLayoutCreated: boolean = false
private isOpen: boolean = false
//variables needed?
last_display_style: string | null = null
constructor() {
super()
this.rootElement = $el(
'div.maskEditor_hidden',
{ parent: document.body },
[]
)
this.element = this.rootElement
}
static getInstance() {
if (!ComfyApp.clipspace || !ComfyApp.clipspace.imgs) {
throw new Error('No clipspace images found')
}
const currentSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
if (
!MaskEditorDialog.instance ||
currentSrc !== MaskEditorDialog.instance.imageURL
) {
if (MaskEditorDialog.instance) MaskEditorDialog.instance.destroy()
MaskEditorDialog.instance = new MaskEditorDialog()
MaskEditorDialog.instance.imageURL = currentSrc
}
return MaskEditorDialog.instance
}
override async show() {
this.cleanup()
if (!this.isLayoutCreated) {
// layout
this.messageBroker = new MessageBroker()
this.canvasHistory = new CanvasHistory(this, 20)
this.paintBucketTool = new PaintBucketTool(this)
this.brushTool = new BrushTool(this)
this.panAndZoomManager = new PanAndZoomManager(this)
this.toolManager = new ToolManager(this)
this.keyboardManager = new KeyboardManager(this)
this.uiManager = new UIManager(this.rootElement, this)
this.colorSelectTool = new ColorSelectTool(this)
// replacement of onClose hook since close is not real close
const self = this
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'style'
) {
if (
self.last_display_style &&
self.last_display_style != 'none' &&
self.element.style.display == 'none'
) {
//self.brush.style.display = 'none'
ComfyApp.onClipspaceEditorClosed()
}
self.last_display_style = self.element.style.display
}
})
})
const config = { attributes: true }
observer.observe(this.rootElement, config)
this.isLayoutCreated = true
await this.uiManager.setlayout()
}
//this.zoomAndPanManager.reset()
this.rootElement.id = 'maskEditor'
this.rootElement.style.display = 'flex'
this.element.style.display = 'flex'
await this.uiManager.initUI()
this.paintBucketTool.initPaintBucketTool()
this.colorSelectTool.initColorSelectTool()
await this.canvasHistory.saveInitialState()
this.isOpen = true
if (ComfyApp.clipspace && ComfyApp.clipspace.imgs) {
this.uiManager.setSidebarImage()
}
this.keyboardManager.addListeners()
}
private cleanup() {
// Remove all maskEditor elements
const maskEditors = document.querySelectorAll('[id^="maskEditor"]')
maskEditors.forEach((element) => element.remove())
// Remove brush elements specifically
const brushElements = document.querySelectorAll('#maskEditor_brush')
brushElements.forEach((element) => element.remove())
}
destroy() {
this.isLayoutCreated = false
this.isOpen = false
this.canvasHistory.clearStates()
this.keyboardManager.removeListeners()
this.cleanup()
this.close()
MaskEditorDialog.instance = null
}
isOpened() {
return this.isOpen
}
async save() {
const imageCanvas = this.uiManager.getImgCanvas()
const maskCanvas = this.uiManager.getMaskCanvas()
const maskCanvasCtx = getCanvas2dContext(maskCanvas)
const paintCanvas = this.uiManager.getRgbCanvas()
const image = this.uiManager.getImage()
try {
await ensureImageFullyLoaded(maskCanvas.toDataURL())
} catch (error) {
console.error('Error loading mask image:', error)
return
}
const unrefinedMaskImageData = maskCanvasCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskOnlyData = new ImageData(
removeImageRgbValuesAndInvertAlpha(unrefinedMaskImageData.data),
unrefinedMaskImageData.width,
unrefinedMaskImageData.height
)
// We create an undisplayed copy so as not to alter the original--displayed--canvas
const [refinedMaskCanvas, refinedMaskCanvasCtx] =
createCanvasCopy(maskCanvas)
refinedMaskCanvasCtx.globalCompositeOperation =
CompositionOperation.SourceOver
refinedMaskCanvasCtx.putImageData(refinedMaskOnlyData, 0, 0)
const timestamp = Math.round(performance.now())
const filenames = imageLayerFilenamesByTimestamp(timestamp)
const refs = {
maskedImage: toRef(filenames.maskedImage),
paint: toRef(filenames.paint),
paintedImage: toRef(filenames.paintedImage),
paintedMaskedImage: toRef(filenames.paintedMaskedImage)
}
const [paintedImageCanvas] = combineOriginalImageAndPaint({
originalImage: imageCanvas,
paint: paintCanvas
})
replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint])
const originalImageUrl = new URL(image.src)
this.uiManager.setBrushOpacity(0)
const originalImageFilename = originalImageUrl.searchParams.get('filename')
if (!originalImageFilename)
throw new Error(
"Expected original image URL to have a `filename` query parameter, but couldn't find it."
)
const originalImageRef: Partial<Ref> = {
filename: originalImageFilename,
subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined,
type: originalImageUrl.searchParams.get('type') ?? undefined
}
const mkFormData = (
blob: Blob,
filename: string,
originalImageRefOverride?: Partial<Ref>
) => {
const formData = new FormData()
formData.append('image', blob, filename)
formData.append(
'original_ref',
JSON.stringify(originalImageRefOverride ?? originalImageRef)
)
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
return formData
}
const canvasToFormData = (
canvas: HTMLCanvasElement,
filename: string,
originalImageRefOverride?: Partial<Ref>
) => {
const blob = this.dataURLToBlob(canvas.toDataURL())
return mkFormData(blob, filename, originalImageRefOverride)
}
const formDatas = {
// Note: this canvas only contains mask data (no image), but during the upload process, the backend combines the mask with the original_image. Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
maskedImage: canvasToFormData(refinedMaskCanvas, filenames.maskedImage),
paint: canvasToFormData(paintCanvas, filenames.paint),
paintedImage: canvasToFormData(
paintedImageCanvas,
filenames.paintedImage
),
paintedMaskedImage: canvasToFormData(
refinedMaskCanvas,
filenames.paintedMaskedImage,
refs.paintedImage
)
}
this.uiManager.setSaveButtonText(t('g.saving'))
this.uiManager.setSaveButtonEnabled(false)
this.keyboardManager.removeListeners()
try {
await this.uploadMask(
refs.maskedImage,
formDatas.maskedImage,
'selectedIndex'
)
await this.uploadImage(refs.paint, formDatas.paint)
await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false)
// IMPORTANT: We using `uploadMask` here, because the backend combines the mask with the painted image during the upload process. We do NOT want to combine the mask with the original image on the frontend, because the spec for CanvasRenderingContext2D does not allow for setting pixels to transparent while preserving their RGB values.
// See: <https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData#data_loss_due_to_browser_optimization>
// It is possible that WebGL contexts can achieve this, but WebGL is extremely complex, and the backend functionality is here for this purpose!
// Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
await this.uploadMask(
refs.paintedMaskedImage,
formDatas.paintedMaskedImage,
'combinedIndex'
)
ComfyApp.onClipspaceEditorSave()
this.destroy()
} catch (error) {
console.error('Error during upload:', error)
this.uiManager.setSaveButtonText(t('g.save'))
this.uiManager.setSaveButtonEnabled(true)
this.keyboardManager.addListeners()
}
}
getMessageBroker() {
return this.messageBroker
}
// Helper function to convert a data URL to a Blob object
private dataURLToBlob(dataURL: string) {
const parts = dataURL.split(';base64,')
const contentType = parts[0].split(':')[1]
const byteString = atob(parts[1])
const arrayBuffer = new ArrayBuffer(byteString.length)
const uint8Array = new Uint8Array(arrayBuffer)
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i)
}
return new Blob([arrayBuffer], { type: contentType })
}
private async uploadImage(
filepath: Ref,
formData: FormData,
isPaintLayer = true
) {
const success = await requestWithRetries(() =>
api.fetchApi('/upload/image', {
method: 'POST',
body: formData
})
)
if (!success) {
throw new Error('Upload failed.')
}
if (!isPaintLayer) {
ClipspaceDialog.invalidatePreview()
return success
}
try {
const paintedIndex = ComfyApp.clipspace?.paintedIndex
if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) {
// Create and set new image
const newImage = new Image()
newImage.crossOrigin = 'anonymous'
newImage.src = mkFileUrl({ ref: filepath, preview: true })
ComfyApp.clipspace.imgs[paintedIndex] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[paintedIndex] = filepath
}
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
}
private async uploadMask(
filepath: Ref,
formData: FormData,
clipspaceLocation: 'selectedIndex' | 'combinedIndex'
) {
const success = await requestWithRetries(() =>
api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
)
if (!success) {
throw new Error('Upload failed.')
}
try {
const nameOfIndexToSaveTo = (
{
selectedIndex: 'selectedIndex',
combinedIndex: 'combinedIndex'
} as const
)[clipspaceLocation]
if (!nameOfIndexToSaveTo) return
const indexToSaveTo = ComfyApp.clipspace?.[nameOfIndexToSaveTo]
if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return
// Create and set new image
const newImage = new Image()
newImage.crossOrigin = 'anonymous'
newImage.src = mkFileUrl({ ref: filepath, preview: true })
ComfyApp.clipspace.imgs[indexToSaveTo] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[indexToSaveTo] = filepath
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
}
}

View File

@@ -1,67 +0,0 @@
import type { MaskEditorDialog } from '../MaskEditorDialog'
import type { MessageBroker } from './MessageBroker'
export class KeyboardManager {
private keysDown: string[] = []
// @ts-expect-error unused variable
private maskEditor: MaskEditorDialog
private messageBroker: MessageBroker
// Bound functions, for use in addListeners and removeListeners
private handleKeyDownBound = this.handleKeyDown.bind(this)
private handleKeyUpBound = this.handleKeyUp.bind(this)
private clearKeysBound = this.clearKeys.bind(this)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addPullTopics()
}
private addPullTopics() {
// isKeyPressed
this.messageBroker.createPullTopic('isKeyPressed', (key: string) =>
Promise.resolve(this.isKeyDown(key))
)
}
addListeners() {
document.addEventListener('keydown', this.handleKeyDownBound)
document.addEventListener('keyup', this.handleKeyUpBound)
window.addEventListener('blur', this.clearKeysBound)
}
removeListeners() {
document.removeEventListener('keydown', this.handleKeyDownBound)
document.removeEventListener('keyup', this.handleKeyUpBound)
window.removeEventListener('blur', this.clearKeysBound)
}
private clearKeys() {
this.keysDown = []
}
private handleKeyDown(event: KeyboardEvent) {
if (!this.keysDown.includes(event.key)) {
this.keysDown.push(event.key)
}
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toUpperCase()
// Redo: Ctrl + Y, or Ctrl + Shift + Z
if ((key === 'Y' && !event.shiftKey) || (key == 'Z' && event.shiftKey)) {
this.messageBroker.publish('redo')
} else if (key === 'Z' && !event.shiftKey) {
this.messageBroker.publish('undo')
}
}
}
private handleKeyUp(event: KeyboardEvent) {
this.keysDown = this.keysDown.filter((key) => key !== event.key)
}
private isKeyDown(key: string) {
return this.keysDown.includes(key)
}
}

View File

@@ -1,183 +0,0 @@
import type { Callback } from '../types'
export class MessageBroker {
private pushTopics: Record<string, Callback[]> = {}
private pullTopics: Record<string, (data?: any) => Promise<any>> = {}
constructor() {
this.registerListeners()
}
// Push
private registerListeners() {
// Register listeners
this.createPushTopic('panStart')
this.createPushTopic('paintBucketFill')
this.createPushTopic('saveState')
this.createPushTopic('brushAdjustmentStart')
this.createPushTopic('drawStart')
this.createPushTopic('panMove')
this.createPushTopic('updateBrushPreview')
this.createPushTopic('brushAdjustment')
this.createPushTopic('draw')
this.createPushTopic('paintBucketCursor')
this.createPushTopic('panCursor')
this.createPushTopic('drawEnd')
this.createPushTopic('zoom')
this.createPushTopic('undo')
this.createPushTopic('redo')
this.createPushTopic('cursorPoint')
this.createPushTopic('panOffset')
this.createPushTopic('zoomRatio')
this.createPushTopic('getMaskCanvas')
this.createPushTopic('getCanvasContainer')
this.createPushTopic('screenToCanvas')
this.createPushTopic('isKeyPressed')
this.createPushTopic('isCombinationPressed')
this.createPushTopic('setPaintBucketTolerance')
this.createPushTopic('setBrushSize')
this.createPushTopic('setBrushHardness')
this.createPushTopic('setBrushOpacity')
this.createPushTopic('setBrushShape')
this.createPushTopic('initZoomPan')
this.createPushTopic('setTool')
this.createPushTopic('setActiveLayer')
this.createPushTopic('pointerDown')
this.createPushTopic('pointerMove')
this.createPushTopic('pointerUp')
this.createPushTopic('wheel')
this.createPushTopic('initPaintBucketTool')
this.createPushTopic('setBrushVisibility')
this.createPushTopic('setBrushPreviewGradientVisibility')
this.createPushTopic('handleTouchStart')
this.createPushTopic('handleTouchMove')
this.createPushTopic('handleTouchEnd')
this.createPushTopic('colorSelectFill')
this.createPushTopic('setColorSelectTolerance')
this.createPushTopic('setLivePreview')
this.createPushTopic('updateCursor')
this.createPushTopic('setColorComparisonMethod')
this.createPushTopic('clearLastPoint')
this.createPushTopic('setWholeImage')
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
this.createPushTopic('setRGBColor')
this.createPushTopic('paintedurl')
this.createPushTopic('setSelectionOpacity')
this.createPushTopic('setFillOpacity')
}
/**
* Creates a new push topic (listener is notified)
*
* @param {string} topicName - The name of the topic to create.
* @throws {Error} If the topic already exists.
*/
createPushTopic(topicName: string) {
if (this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic already exists')
}
this.pushTopics[topicName] = []
}
/**
* Subscribe a callback function to the given topic.
*
* @param {string} topicName - The name of the topic to subscribe to.
* @param {Callback} callback - The callback function to be subscribed.
* @throws {Error} If the topic does not exist.
*/
subscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].push(callback)
}
/**
* Removes a callback function from the list of subscribers for a given topic.
*
* @param {string} topicName - The name of the topic to unsubscribe from.
* @param {Callback} callback - The callback function to remove from the subscribers list.
* @throws {Error} If the topic does not exist in the list of topics.
*/
unsubscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic does not exist')
}
const index = this.pushTopics[topicName].indexOf(callback)
if (index > -1) {
this.pushTopics[topicName].splice(index, 1)
}
}
/**
* Publishes data to a specified topic with variable number of arguments.
* @param {string} topicName - The name of the topic to publish to.
* @param {...any[]} args - Variable number of arguments to pass to subscribers
* @throws {Error} If the specified topic does not exist.
*/
publish(topicName: string, ...args: any[]) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].forEach((callback) => {
callback(...args)
})
}
// Pull
/**
* Creates a new pull topic (listener must request data)
*
* @param {string} topicName - The name of the topic to create.
* @param {() => Promise<any>} callBack - The callback function to be called when data is requested.
* @throws {Error} If the topic already exists.
*/
createPullTopic(topicName: string, callBack: (data?: any) => Promise<any>) {
if (this.topicExists(this.pullTopics, topicName)) {
throw new Error('Topic already exists')
}
this.pullTopics[topicName] = callBack
}
/**
* Requests data from a specified pull topic.
* @param {string} topicName - The name of the topic to request data from.
* @returns {Promise<any>} - The data from the pull topic.
* @throws {Error} If the specified topic does not exist.
*/
async pull(topicName: string, data?: any): Promise<any> {
if (!this.topicExists(this.pullTopics, topicName)) {
throw new Error('Topic does not exist')
}
const callBack = this.pullTopics[topicName]
try {
const result = await callBack(data)
return result
} catch (error) {
console.error(`Error pulling data from topic "${topicName}":`, error)
throw error
}
}
// Helper Methods
/**
* Checks if a topic exists in the given topics object.
* @param {Record<string, any>} topics - The topics object to check.
* @param {string} topicName - The name of the topic to check.
* @returns {boolean} - True if the topic exists, false otherwise.
*/
private topicExists(topics: Record<string, any>, topicName: string): boolean {
return topics.hasOwnProperty(topicName)
}
}

View File

@@ -1,496 +0,0 @@
import type { MaskEditorDialog, Point, Offset } from '../types'
import { MessageBroker } from './MessageBroker'
export class PanAndZoomManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
DOUBLE_TAP_DELAY: number = 300
lastTwoFingerTap: number = 0
isTouchZooming: boolean = false
lastTouchZoomDistance: number = 0
lastTouchMidPoint: Point = { x: 0, y: 0 }
lastTouchPoint: Point = { x: 0, y: 0 }
zoom_ratio: number = 1
interpolatedZoomRatio: number = 1
pan_offset: Offset = { x: 0, y: 0 }
mouseDownPoint: Point | null = null
initialPan: Offset = { x: 0, y: 0 }
canvasContainer: HTMLElement | null = null
maskCanvas: HTMLCanvasElement | null = null
rgbCanvas: HTMLCanvasElement | null = null
rootElement: HTMLElement | null = null
image: HTMLImageElement | null = null
imageRootWidth: number = 0
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
penPointerIdList: number[] = []
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe(
'initZoomPan',
async (args: [HTMLImageElement, HTMLElement]) => {
await this.initializeCanvasPanZoom(args[0], args[1])
}
)
this.messageBroker.subscribe('panStart', async (event: PointerEvent) => {
this.handlePanStart(event)
})
this.messageBroker.subscribe('panMove', async (event: PointerEvent) => {
this.handlePanMove(event)
})
this.messageBroker.subscribe('zoom', async (event: WheelEvent) => {
this.zoom(event)
})
this.messageBroker.subscribe('cursorPoint', async (point: Point) => {
this.updateCursorPosition(point)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
if (event.pointerType === 'pen')
this.penPointerIdList.push(event.pointerId)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
if (event.pointerType === 'pen') {
const index = this.penPointerIdList.indexOf(event.pointerId)
if (index > -1) this.penPointerIdList.splice(index, 1)
}
})
this.messageBroker.subscribe(
'handleTouchStart',
async (event: TouchEvent) => {
this.handleTouchStart(event)
}
)
this.messageBroker.subscribe(
'handleTouchMove',
async (event: TouchEvent) => {
this.handleTouchMove(event)
}
)
this.messageBroker.subscribe(
'handleTouchEnd',
async (event: TouchEvent) => {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'cursorPoint',
async () => this.cursorPoint
)
this.messageBroker.createPullTopic('zoomRatio', async () => this.zoom_ratio)
this.messageBroker.createPullTopic('panOffset', async () => this.pan_offset)
}
handleTouchStart(event: TouchEvent) {
event.preventDefault()
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.messageBroker.publish('setBrushVisibility', false)
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - this.lastTwoFingerTap
if (tapTimeDiff < this.DOUBLE_TAP_DELAY) {
// Double tap detected
this.handleDoubleTap()
this.lastTwoFingerTap = 0 // Reset to prevent triple-tap
} else {
this.lastTwoFingerTap = currentTime
// Existing two-finger touch logic
this.isTouchZooming = true
this.lastTouchZoomDistance = this.getTouchDistance(event.touches)
const midpoint = this.getTouchMidpoint(event.touches)
this.lastTouchMidPoint = midpoint
}
} else if (event.touches.length === 1) {
this.lastTouchPoint = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
async handleTouchMove(event: TouchEvent) {
event.preventDefault()
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.lastTwoFingerTap = 0
if (this.isTouchZooming && event.touches.length === 2) {
// Handle zooming
const newDistance = this.getTouchDistance(event.touches)
const zoomFactor = newDistance / this.lastTouchZoomDistance
const oldZoom = this.zoom_ratio
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
// Calculate the midpoint of the two touches
const midpoint = this.getTouchMidpoint(event.touches)
// Handle panning - calculate the movement of the midpoint
if (this.lastTouchMidPoint) {
const deltaX = midpoint.x - this.lastTouchMidPoint.x
const deltaY = midpoint.y - this.lastTouchMidPoint.y
// Apply the pan
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
}
// Get touch position relative to the container
if (this.maskCanvas === null) {
this.maskCanvas = await this.messageBroker.pull('maskCanvas')
}
const rect = this.maskCanvas!.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
// Calculate new pan position based on zoom
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += touchX - touchX * scaleFactor
this.pan_offset.y += touchY - touchY * scaleFactor
this.invalidatePanZoom()
this.lastTouchZoomDistance = newDistance
this.lastTouchMidPoint = midpoint
} else if (event.touches.length === 1) {
// Handle single touch pan
this.handleSingleTouchPan(event.touches[0])
}
}
handleTouchEnd(event: TouchEvent) {
event.preventDefault()
const lastTouch = event.touches[0]
// if all touches are removed, lastTouch will be null
if (lastTouch) {
this.lastTouchPoint = {
x: lastTouch.clientX,
y: lastTouch.clientY
}
} else {
this.isTouchZooming = false
this.lastTouchMidPoint = { x: 0, y: 0 }
}
}
private getTouchDistance(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
private getTouchMidpoint(touches: TouchList) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
private async handleSingleTouchPan(touch: Touch) {
if (this.lastTouchPoint === null) {
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
return
}
const deltaX = touch.clientX - this.lastTouchPoint.x
const deltaY = touch.clientY - this.lastTouchPoint.y
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
await this.invalidatePanZoom()
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
}
private updateCursorPosition(clientPoint: Point) {
var cursorX = clientPoint.x - this.pan_offset.x
var cursorY = clientPoint.y - this.pan_offset.y
this.cursorPoint = { x: cursorX, y: cursorY }
}
//prob redundant
handleDoubleTap() {
this.messageBroker.publish('undo')
// Add any additional logic needed after undo
}
async zoom(event: WheelEvent) {
// Store original cursor position
const cursorPoint = { x: event.clientX, y: event.clientY }
// zoom canvas
const oldZoom = this.zoom_ratio
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
const maskCanvas = await this.messageBroker.pull('maskCanvas')
// Get mouse position relative to the container
const rect = maskCanvas.getBoundingClientRect()
const mouseX = cursorPoint.x - rect.left
const mouseY = cursorPoint.y - rect.top
console.log(oldZoom, newZoom)
// Calculate new pan position
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += mouseX - mouseX * scaleFactor
this.pan_offset.y += mouseY - mouseY * scaleFactor
// Update pan and zoom immediately
await this.invalidatePanZoom()
const newImageWidth = maskCanvas.clientWidth
const zoomRatio = newImageWidth / this.imageRootWidth
this.interpolatedZoomRatio = zoomRatio
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
// Update cursor position with new pan values
this.updateCursorPosition(cursorPoint)
// Update brush preview after pan/zoom is complete
requestAnimationFrame(() => {
this.messageBroker.publish('updateBrushPreview')
})
}
private async smoothResetView(duration: number = 500) {
// Store initial state
const startZoom = this.zoom_ratio
const startPan = { ...this.pan_offset }
// Panel dimensions
const sidePanelWidth = 220
const toolPanelWidth = 64
const topBarHeight = 44
// Calculate available space
const availableWidth =
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = this.rootElement!.clientHeight - topBarHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / this.image!.width
const zoomRatioHeight = availableHeight / this.image!.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
// Calculate final dimensions
const aspectRatio = this.image!.width / this.image!.height
let finalWidth = 0
let finalHeight = 0
// Calculate target pan position
const targetPan = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing out for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Calculate intermediate zoom and pan values
const currentZoom = startZoom + (targetZoom - startZoom) * eased
this.zoom_ratio = currentZoom
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
this.invalidatePanZoom()
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
this.messageBroker.publish(
'setZoomText',
`${Math.round(interpolatedZoomRatio * 100)}%`
)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
this.interpolatedZoomRatio = 1.0
}
async initializeCanvasPanZoom(
image: HTMLImageElement,
rootElement: HTMLElement
) {
// Get side panel width
let sidePanelWidth = 220
const toolPanelWidth = 64
let topBarHeight = 44
this.rootElement = rootElement
// Calculate available width accounting for both side panels
let availableWidth =
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
let aspectRatio = image.width / image.height
let finalWidth = 0
let finalHeight = 0
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (this.image === null) {
this.image = image
}
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
this.penPointerIdList = []
await this.invalidatePanZoom()
}
async invalidatePanZoom() {
// Single validation check upfront
if (
!this.image?.width ||
!this.image?.height ||
!this.pan_offset ||
!this.zoom_ratio
) {
console.warn('Missing required properties for pan/zoom')
return
}
// Now TypeScript knows these are non-null
const raw_width = this.image.width * this.zoom_ratio
const raw_height = this.image.height * this.zoom_ratio
// Get canvas container
this.canvasContainer ??=
await this.messageBroker?.pull('getCanvasContainer')
if (!this.canvasContainer) return
// Apply styles
Object.assign(this.canvasContainer.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${this.pan_offset.x}px`,
top: `${this.pan_offset.y}px`
})
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
if (this.rgbCanvas) {
// Ensure the canvas has the proper dimensions
if (
this.rgbCanvas.width !== this.image.width ||
this.rgbCanvas.height !== this.image.height
) {
this.rgbCanvas.width = this.image.width
this.rgbCanvas.height = this.image.height
}
// Make sure the style dimensions match the container
this.rgbCanvas.style.width = `${raw_width}px`
this.rgbCanvas.style.height = `${raw_height}px`
}
}
private handlePanStart(event: PointerEvent) {
this.messageBroker.pull('screenToCanvas', {
x: event.offsetX,
y: event.offsetY
})
this.mouseDownPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('panCursor', true)
this.initialPan = this.pan_offset
return
}
private handlePanMove(event: PointerEvent) {
if (this.mouseDownPoint === null) throw new Error('mouseDownPoint is null')
let deltaX = this.mouseDownPoint.x - event.clientX
let deltaY = this.mouseDownPoint.y - event.clientY
let pan_x = this.initialPan.x - deltaX
let pan_y = this.initialPan.y - deltaY
this.pan_offset = { x: pan_x, y: pan_y }
this.invalidatePanZoom()
}
}

View File

@@ -1,182 +0,0 @@
import type { MaskEditorDialog, Point } from '../types'
import { Tools } from '../types'
import { MessageBroker } from './MessageBroker'
export class ToolManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
mouseDownPoint: Point | null = null
currentTool: Tools = Tools.MaskPen
isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe('setTool', async (tool: Tools) => {
this.setTool(tool)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
this.handlePointerDown(event)
})
this.messageBroker.subscribe('pointerMove', async (event: PointerEvent) => {
this.handlePointerMove(event)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
this.handlePointerUp(event)
})
this.messageBroker.subscribe('wheel', async (event: WheelEvent) => {
this.handleWheelEvent(event)
})
}
private async addPullTopics() {
this.messageBroker.createPullTopic('currentTool', async () =>
this.getCurrentTool()
)
}
//tools
setTool(tool: Tools) {
this.currentTool = tool
if (tool != Tools.MaskColorFill) {
this.messageBroker.publish('clearLastPoint')
}
}
getCurrentTool() {
return this.currentTool
}
private async handlePointerDown(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
// Pan canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panStart', event)
this.messageBroker.publish('setBrushVisibility', false)
return
}
// RGB painting
if (this.currentTool === Tools.PaintPen && event.button === 0) {
this.messageBroker.publish('drawStart', event)
this.messageBroker.publish('saveState')
return
}
// RGB painting
if (this.currentTool === Tools.PaintPen && event.buttons === 1) {
this.messageBroker.publish('draw', event)
return
}
//paint bucket
if (this.currentTool === Tools.MaskBucket && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('paintBucketFill', coords_canvas)
this.messageBroker.publish('saveState')
return
}
if (this.currentTool === Tools.MaskColorFill && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('colorSelectFill', coords_canvas)
return
}
// (brush resize/change hardness) Check for alt + right mouse button
if (event.altKey && event.button === 2) {
this.isAdjustingBrush = true
this.messageBroker.publish('brushAdjustmentStart', event)
return
}
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
this.currentTool
)
//drawing
if ([0, 2].includes(event.button) && isDrawingTool) {
this.messageBroker.publish('drawStart', event)
return
}
}
private async handlePointerMove(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
this.messageBroker.publish('updateBrushPreview')
//move the canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panMove', event)
return
}
//prevent drawing with other tools
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
this.currentTool
)
if (!isDrawingTool) return
// alt + right mouse button hold brush adjustment
if (
this.isAdjustingBrush &&
(this.currentTool === Tools.MaskPen ||
this.currentTool === Tools.Eraser) &&
event.altKey &&
event.buttons === 2
) {
this.messageBroker.publish('brushAdjustment', event)
return
}
//draw with pen or eraser
if (event.buttons == 1 || event.buttons == 2) {
this.messageBroker.publish('draw', event)
return
}
}
private handlePointerUp(event: PointerEvent) {
this.messageBroker.publish('panCursor', false)
if (event.pointerType === 'touch') return
this.messageBroker.publish('updateCursor')
this.isAdjustingBrush = false
this.messageBroker.publish('drawEnd', event)
this.mouseDownPoint = null
}
private handleWheelEvent(event: WheelEvent) {
this.messageBroker.publish('zoom', event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
export { UIManager } from './UIManager'
export { ToolManager } from './ToolManager'
export { PanAndZoomManager } from './PanAndZoomManager'
export { KeyboardManager } from './KeyboardManager'
export { MessageBroker } from './MessageBroker'

View File

@@ -1,738 +0,0 @@
const styles = `
#maskEditorContainer {
display: fixed;
}
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
#maskEditor {
display: block;
width: 100%;
height: 100vh;
left: 0;
z-index: 8888;
position: fixed;
background: rgba(50,50,50,0.75);
backdrop-filter: blur(10px);
overflow: hidden;
user-select: none;
--mask-editor-top-bar-height: 44px;
}
#maskEditor_sidePanelContainer {
height: 100%;
width: 220px;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditor_sidePanel {
background: var(--comfy-menu-bg);
height: 100%;
display: flex;
align-items: center;
overflow-y: auto;
width: 220px;
padding: 0 10px;
}
#maskEditor_sidePanelContent {
width: 100%;
}
#maskEditor_sidePanelShortcuts {
display: flex;
flex-direction: row;
width: 100%;
margin-top: 10px;
gap: 10px;
justify-content: center;
}
.maskEditor_sidePanelIconButton {
width: 40px;
height: 40px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
}
.maskEditor_sidePanelIconButton:hover {
background-color: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 10px;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
#maskEditor_sidePanelBrushShapeContainer {
display: flex;
width: 180px;
height: 50px;
border: 1px solid var(--border-color);
pointer-events: auto;
background: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
#maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
#maskEditor_sidePanelImageLayerSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
align-items: center;
}
.maskEditor_sidePanelLayer {
display: flex;
width: 100%;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
#maskEditor_sidePanelMaskLayerBlendingContainer {
width: 80px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
#maskEditor_sidePanelMaskLayerBlendingSelect {
width: 80px;
height: 30px;
border: 1px solid var(--border-color);
background-color: rgba(0, 0, 0, 0.2);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_sidePanelClearCanvasButton {
width: 180px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
}
#maskEditor_sidePanelHorizontalButtonContainer {
display: flex;
gap: 10px;
height: 40px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_toolPanel {
height: 100%;
width: 4rem;
z-index: 8888;
background: var(--comfy-menu-bg);
display: flex;
flex-direction: column;
}
.maskEditor_toolPanelContainer {
width: 4rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
#maskEditor_sidePanelPaintBucketSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 10px;
}
#canvasBackground {
background: white;
width: 100%;
height: 100%;
}
#maskEditor_sidePanelButtonsContainer {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.maskEditor_sidePanelSeparator {
width: 100%;
height: 2px;
background: var(--border-color);
margin-top: 1.5em;
margin-bottom: 5px;
}
#maskEditor_pointerZone {
width: calc(100% - 4rem - 220px);
height: 100%;
}
#maskEditor_uiContainer {
width: 100%;
height: 100%;
position: absolute;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
#imageCanvas {
width: 100%;
height: 100%;
}
#maskCanvas {
width: 100%;
height: 100%;
}
#maskEditor_uiHorizontalContainer {
width: 100%;
height: calc(100% - var(--mask-editor-top-bar-height));
display: flex;
}
#maskEditor_topBar {
display: flex;
height: var(--mask-editor-top-bar-height);
align-items: center;
background: var(--comfy-menu-bg);
shrink: 0;
}
#maskEditor_topBarTitle {
margin: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
font-size: 1.2em;
}
#maskEditor_topBarButtonContainer {
display: flex;
gap: 10px;
margin-right: 0.5rem;
position: absolute;
right: 0;
width: 100%;
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
#maskEditor_sidePanelColorSelectSettings {
flex-direction: column;
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
#maskEditor_sidePanelColorSelectMethodSelect {
position: absolute;
right: 0;
height: 30px;
border-radius: 0;
border: 1px solid var(--border-color);
background: rgba(0,0,0,0.2);
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
#maskEditor_paintBucketSettings {
display: none;
}
#maskEditor_colorSelectSettings {
display: none;
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_toggle_bg_dark {
background: var(--p-surface-700);
}
.maskEditor_toggle_bg_light {
background: var(--p-surface-300);
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.maskEditor_sidePanelToggleSwitch:before, .maskEditor_sidePanelToggleSwitch:after {
content: "";
}
.maskEditor_sidePanelToggleSwitch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_dark:before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_light:before {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch:before {
left: 20px;
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown_dark {
border: 1px solid var(--p-form-field-border-color);
background: var(--p-surface-900);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_dark option {
background: var(--p-surface-900);
}
.maskEditor_sidePanelDropdown_dark:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.maskEditor_sidePanelDropdown_dark option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_dark option:active {
background: var(--p-highlight-background);
}
.maskEditor_sidePanelDropdown_light {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_light option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown_light:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown_light option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_light option:active {
background: var(--p-surface-300);
}
.maskEditor_layerRow {
height: 50px;
width: 100%;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
#maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
.maskEditor_toolPanelZoomIndicator {
width: 4rem;
height: 4rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: var(--p-button-text-secondary-color);
position: absolute;
bottom: 0;
transition: background-color 0.2s;
}
#maskEditor_toolPanelDimensionsText {
font-size: 12px;
}
#maskEditor_topBarSaveButton {
background: var(--p-primary-color) !important;
color: var(--p-button-primary-color) !important;
}
#maskEditor_topBarSaveButton:hover {
background: var(--p-primary-hover-color) !important;
}
`
// Inject styles into document
const styleSheet = document.createElement('style')
styleSheet.type = 'text/css'
styleSheet.innerText = styles
document.head.appendChild(styleSheet)

View File

@@ -1,779 +0,0 @@
import QuickLRU from '@alloc/quick-lru'
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
import { app } from '@/scripts/app'
import {
BrushShape,
CompositionOperation,
MaskBlendMode,
Tools,
type Brush,
type ImageLayer,
type Point
} from '../types'
import { loadBrushFromCache, saveBrushToCache } from '../utils/brushCache'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class BrushTool {
brushSettings: Brush //this saves the current brush settings
maskBlendMode: MaskBlendMode
isDrawing: boolean = false
isDrawingLine: boolean = false
lineStartPoint: Point | null = null
smoothingPrecision: number = 10
smoothingCordsArray: Point[] = []
smoothingLastDrawTime!: Date
maskCtx: CanvasRenderingContext2D | null = null
rgbCtx: CanvasRenderingContext2D | null = null
initialDraw: boolean = true
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
maxSize: 8 // Reasonable limit for brush texture variations?
})
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
private static readonly SMOOTHING_MAX_STEPS = 30
private static readonly SMOOTHING_MIN_STEPS = 2
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
initialPoint: Point | null = null
useDominantAxis: boolean = false
brushAdjustmentSpeed: number = 1.0
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
private rgbColor: string = '#FF0000' // Default color
private activeLayer: ImageLayer = 'mask'
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
this.useDominantAxis = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
)
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
if (cachedBrushSettings) {
this.brushSettings = cachedBrushSettings
} else {
this.brushSettings = {
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
smoothingPrecision: 10
}
}
this.maskBlendMode = MaskBlendMode.Black
}
private createListeners() {
//setters
this.messageBroker.subscribe('setBrushSize', (size: number) =>
this.setBrushSize(size)
)
this.messageBroker.subscribe('setBrushOpacity', (opacity: number) =>
this.setBrushOpacity(opacity)
)
this.messageBroker.subscribe('setBrushHardness', (hardness: number) =>
this.setBrushHardness(hardness)
)
this.messageBroker.subscribe('setBrushShape', (type: BrushShape) =>
this.setBrushType(type)
)
this.messageBroker.subscribe(
'setActiveLayer',
(layer: ImageLayer) => (this.activeLayer = layer)
)
this.messageBroker.subscribe(
'setBrushSmoothingPrecision',
(precision: number) => this.setBrushSmoothingPrecision(precision)
)
this.messageBroker.subscribe('setRGBColor', (color: string) => {
this.rgbColor = color
})
//brush adjustment
this.messageBroker.subscribe(
'brushAdjustmentStart',
(event: PointerEvent) => this.startBrushAdjustment(event)
)
this.messageBroker.subscribe('brushAdjustment', (event: PointerEvent) =>
this.handleBrushAdjustment(event)
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.startDrawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
)
this.messageBroker.subscribe('drawEnd', (event: PointerEvent) =>
this.drawEnd(event)
)
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'brushSize',
async () => this.brushSettings.size
)
this.messageBroker.createPullTopic(
'brushOpacity',
async () => this.brushSettings.opacity
)
this.messageBroker.createPullTopic(
'brushHardness',
async () => this.brushSettings.hardness
)
this.messageBroker.createPullTopic(
'brushType',
async () => this.brushSettings.type
)
this.messageBroker.createPullTopic(
'brushSmoothingPrecision',
async () => this.brushSettings.smoothingPrecision
)
this.messageBroker.createPullTopic(
'maskBlendMode',
async () => this.maskBlendMode
)
this.messageBroker.createPullTopic(
'brushSettings',
async () => this.brushSettings
)
}
private async createBrushStrokeCanvas() {
if (this.brushStrokeCanvas !== null) {
return
}
const maskCanvas =
await this.messageBroker.pull<HTMLCanvasElement>('maskCanvas')
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
this.brushStrokeCanvas = canvas
this.brushStrokeCtx = canvas.getContext('2d')!
}
private async startDrawing(event: PointerEvent) {
this.isDrawing = true
let compositionOp: CompositionOperation
let currentTool = await this.messageBroker.pull('currentTool')
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
await this.createBrushStrokeCanvas()
//set drawing mode
if (currentTool === Tools.Eraser || event.buttons == 2) {
compositionOp = CompositionOperation.DestinationOut //eraser
} else {
compositionOp = CompositionOperation.SourceOver //pen
}
if (event.shiftKey && this.lineStartPoint) {
this.isDrawingLine = true
this.drawLine(this.lineStartPoint, coords_canvas, compositionOp)
} else {
this.isDrawingLine = false
this.init_shape(compositionOp)
this.draw_shape(coords_canvas)
}
this.lineStartPoint = coords_canvas
this.smoothingCordsArray = [coords_canvas] //used to smooth the drawing line
this.smoothingLastDrawTime = new Date()
}
private async handleDrawing(event: PointerEvent) {
var diff = performance.now() - this.smoothingLastDrawTime.getTime()
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
let currentTool = await this.messageBroker.pull('currentTool')
if (diff > 20 && !this.isDrawing)
requestAnimationFrame(() => {
this.init_shape(CompositionOperation.SourceOver)
this.draw_shape(coords_canvas)
this.smoothingCordsArray.push(coords_canvas)
})
else
requestAnimationFrame(() => {
if (currentTool === Tools.Eraser || event.buttons == 2) {
this.init_shape(CompositionOperation.DestinationOut)
} else {
this.init_shape(CompositionOperation.SourceOver)
}
//use drawWithSmoothing for better performance or change step in drawWithBetterSmoothing
this.drawWithBetterSmoothing(coords_canvas)
})
this.smoothingLastDrawTime = new Date()
}
private async drawEnd(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
if (this.isDrawing) {
this.isDrawing = false
this.messageBroker.publish('saveState')
this.lineStartPoint = coords_canvas
this.initialDraw = true
}
}
private clampSmoothingPrecision(value: number): number {
return Math.min(Math.max(value, 1), 100)
}
private drawWithBetterSmoothing(point: Point) {
// Add current point to the smoothing array
if (!this.smoothingCordsArray) {
this.smoothingCordsArray = []
}
const opacityConstant = 1 / (1 + Math.exp(3))
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
opacityConstant
this.smoothingCordsArray.push(point)
// Keep a moving window of points for the spline
const POINTS_NR = 5
if (this.smoothingCordsArray.length < POINTS_NR) {
return
}
// Calculate total length more efficiently
let totalLength = 0
const points = this.smoothingCordsArray
const len = points.length - 1
// Use local variables for better performance
let dx, dy
for (let i = 0; i < len; i++) {
dx = points[i + 1].x - points[i].x
dy = points[i + 1].y - points[i].y
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
const smoothing = this.clampSmoothingPrecision(
this.brushSettings.smoothingPrecision
)
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
// Optionality to use exponential curve
const stepNr = Math.round(
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
)
// Calculate step distance capped by brush size
const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
if (stepNr > 0) {
//this calculation needs to be improved
interpolatedPoints = this.generateEquidistantPoints(
this.smoothingCordsArray,
distanceBetweenPoints // Distance between interpolated points
)
}
if (!this.initialDraw) {
// Remove the first 3 points from the array to avoid drawing the same points twice
const spliceIndex = interpolatedPoints.findIndex(
(point) =>
point.x === this.smoothingCordsArray[2].x &&
point.y === this.smoothingCordsArray[2].y
)
if (spliceIndex !== -1) {
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
}
}
// Draw all interpolated points
for (const point of interpolatedPoints) {
this.draw_shape(point, interpolatedOpacity)
}
if (!this.initialDraw) {
// initially draw on all 5 points, then remove the first 3 points to go into 2 new, 3 old points cycle
this.smoothingCordsArray = this.smoothingCordsArray.slice(2)
} else {
this.initialDraw = false
}
}
private async drawLine(
p1: Point,
p2: Point,
compositionOp: CompositionOperation
) {
const brush_size = await this.messageBroker.pull<number>('brushSize')
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const steps = Math.ceil(
distance / ((brush_size / this.brushSettings.smoothingPrecision) * 4)
) // Adjust for smoother lines
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
1 / (1 + Math.exp(3))
this.init_shape(compositionOp)
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = p1.x + (p2.x - p1.x) * t
const y = p1.y + (p2.y - p1.y) * t
const point = { x: x, y: y }
this.draw_shape(point, interpolatedOpacity)
}
}
//brush adjustment
private async startBrushAdjustment(event: PointerEvent) {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
this.messageBroker.publish('setBrushPreviewGradientVisibility', true)
this.initialPoint = coords_canvas
this.isBrushAdjusting = true
return
}
private async handleBrushAdjustment(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
const delta_x = coords_canvas.x - this.initialPoint!.x
const delta_y = coords_canvas.y - this.initialPoint!.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
// New dominant axis logic
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
console.log(this.useDominantAxis)
if (this.useDominantAxis) {
// New setting flag
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0 // Configurable threshold
if (ratio > threshold) {
finalDeltaY = 0 // X is dominant
} else if (ratio < 1 / threshold) {
finalDeltaX = 0 // Y is dominant
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
// Rest of the function remains the same
const newSize = Math.max(
1,
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
)
const newHardness = Math.max(
0,
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
}
//helper functions
private async draw_shape(point: Point, overrideOpacity?: number) {
const brushSettings: Brush = this.brushSettings
const maskCtx =
this.maskCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
const rgbCtx =
this.rgbCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
const brushType = await this.messageBroker.pull('brushType')
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
const size = brushSettings.size
const brushSettingsSliderOpacity = brushSettings.opacity
const opacity =
overrideOpacity == undefined
? brushSettingsSliderOpacity
: overrideOpacity
const hardness = brushSettings.hardness
const x = point.x
const y = point.y
const brushRadius = size
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
const currentTool = await this.messageBroker.pull('currentTool')
// Helper function to get or create cached brush texture
const getCachedBrushTexture = (
radius: number,
hardness: number,
color: string,
opacity: number
): HTMLCanvasElement => {
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
if (BrushTool.brushTextureCache.has(cacheKey)) {
return BrushTool.brushTextureCache.get(cacheKey)!
}
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
const size = radius * 2
tempCanvas.width = size
tempCanvas.height = size
const centerX = size / 2
const centerY = size / 2
const hardRadius = radius * hardness
const imageData = tempCtx.createImageData(size, size)
const data = imageData.data
const { r, g, b } = parseToRgb(color)
// Pre-calculate values to avoid repeated computations
const fadeRange = radius - hardRadius
for (let y = 0; y < size; y++) {
const dy = y - centerY
for (let x = 0; x < size; x++) {
const dx = x - centerX
const index = (y * size + x) * 4
// Calculate square distance (Chebyshev distance)
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
let pixelOpacity = 0
if (distFromEdge <= hardRadius) {
pixelOpacity = opacity
} else if (distFromEdge <= radius) {
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
pixelOpacity = opacity * (1 - fadeProgress)
}
data[index] = r
data[index + 1] = g
data[index + 2] = b
data[index + 3] = pixelOpacity * 255
}
}
tempCtx.putImageData(imageData, 0, 0)
// Cache the texture
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
return tempCanvas
}
// RGB brush logic
if (
this.activeLayer === 'rgb' &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
if (brushType === BrushShape.Rect && hardness < 1) {
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
rgbaColor,
opacity
)
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
rgbCtx.fillStyle = rgbaColor
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
gradient.addColorStop(0, rgbaColor)
gradient.addColorStop(
hardness,
this.formatRgba(this.rgbColor, opacity * 0.5)
)
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
rgbCtx.fillStyle = gradient
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
// Mask brush logic
if (brushType === BrushShape.Rect && hardness < 1) {
const baseColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
baseColor,
opacity
)
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
const solidColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
maskCtx.fillStyle = solidColor
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
gradient.addColorStop(
0,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
hardness,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
)
gradient.addColorStop(
1,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
}
maskCtx.fillStyle = gradient
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
}
private formatRgba(hex: string, alpha: number): string {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
private async init_shape(compositionOperation: CompositionOperation) {
const maskBlendMode =
await this.messageBroker.pull<MaskBlendMode>('maskBlendMode')
const maskCtx =
this.maskCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
const rgbCtx =
this.rgbCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
maskCtx.beginPath()
rgbCtx.beginPath()
// For both contexts, set the composite operation based on the passed parameter
// This ensures right-click always works for erasing
if (compositionOperation == CompositionOperation.SourceOver) {
maskCtx.fillStyle = maskBlendMode
maskCtx.globalCompositeOperation = CompositionOperation.SourceOver
rgbCtx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation == CompositionOperation.DestinationOut) {
maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut
rgbCtx.globalCompositeOperation = CompositionOperation.DestinationOut
}
}
private generateEquidistantPoints(
points: Point[],
distance: number
): Point[] {
const result: Point[] = []
const cumulativeDistances: number[] = [0]
// Calculate cumulative distances between points
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const dist = Math.hypot(dx, dy)
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
}
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
const numPoints = Math.floor(totalLength / distance)
for (let i = 0; i <= numPoints; i++) {
const targetDistance = i * distance
let idx = 0
// Find the segment where the target distance falls
while (
idx < cumulativeDistances.length - 1 &&
cumulativeDistances[idx + 1] < targetDistance
) {
idx++
}
if (idx >= points.length - 1) {
result.push(points[points.length - 1])
continue
}
const d0 = cumulativeDistances[idx]
const d1 = cumulativeDistances[idx + 1]
const t = (targetDistance - d0) / (d1 - d0)
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
result.push({ x, y })
}
return result
}
private setBrushSize(size: number) {
this.brushSettings.size = size
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushOpacity(opacity: number) {
this.brushSettings.opacity = opacity
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushHardness(hardness: number) {
this.brushSettings.hardness = hardness
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushType(type: BrushShape) {
this.brushSettings.type = type
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushSmoothingPrecision(precision: number) {
this.brushSettings.smoothingPrecision = precision
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
}
export { BrushTool }

View File

@@ -1,464 +0,0 @@
import { ColorComparisonMethod, type Point } from '../types'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class ColorSelectTool {
// @ts-expect-error unused variable
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private width: number | null = null
private height: number | null = null
private canvas!: HTMLCanvasElement
private maskCTX!: CanvasRenderingContext2D
private imageCTX!: CanvasRenderingContext2D
private maskData: Uint8ClampedArray | null = null
private imageData: Uint8ClampedArray | null = null
private tolerance: number = 20
private livePreview: boolean = false
private lastPoint: Point | null = null
private colorComparisonMethod: ColorComparisonMethod =
ColorComparisonMethod.Simple
private applyWholeImage: boolean = false
private maskBoundry: boolean = false
private maskTolerance: number = 0
private selectOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
async initColorSelectTool() {
await this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('imgCanvas')
this.maskCTX = await this.messageBroker.pull('maskCtx')
this.imageCTX = await this.messageBroker.pull('imageCtx')
}
private createListeners() {
this.messageBroker.subscribe('colorSelectFill', (point: Point) =>
this.fillColorSelection(point)
)
this.messageBroker.subscribe(
'setColorSelectTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('setLivePreview', (livePreview: boolean) =>
this.setLivePreview(livePreview)
)
this.messageBroker.subscribe(
'setColorComparisonMethod',
(method: ColorComparisonMethod) => this.setComparisonMethod(method)
)
this.messageBroker.subscribe('clearLastPoint', () => this.clearLastPoint())
this.messageBroker.subscribe('setWholeImage', (applyWholeImage: boolean) =>
this.setApplyWholeImage(applyWholeImage)
)
this.messageBroker.subscribe('setMaskBoundary', (maskBoundry: boolean) =>
this.setMaskBoundary(maskBoundry)
)
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
this.setMaskTolerance(maskTolerance)
)
// Add new listener for opacity setting
this.messageBroker.subscribe('setSelectionOpacity', (opacity: number) =>
this.setSelectOpacity(opacity)
)
}
private async addPullTopics() {
this.messageBroker.createPullTopic(
'getLivePreview',
async () => this.livePreview
)
}
private getPixel(x: number, y: number): { r: number; g: number; b: number } {
const index = (y * this.width! + x) * 4
return {
r: this.imageData![index],
g: this.imageData![index + 1],
b: this.imageData![index + 2]
}
}
private getMaskAlpha(x: number, y: number): number {
return this.maskData![(y * this.width! + x) * 4 + 3]
}
private isPixelInRange(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
switch (this.colorComparisonMethod) {
case ColorComparisonMethod.Simple:
return this.isPixelInRangeSimple(pixel, target)
case ColorComparisonMethod.HSL:
return this.isPixelInRangeHSL(pixel, target)
case ColorComparisonMethod.LAB:
return this.isPixelInRangeLab(pixel, target)
default:
return this.isPixelInRangeSimple(pixel, target)
}
}
private isPixelInRangeSimple(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
//calculate the euclidean distance between the two colors
const distance = Math.sqrt(
Math.pow(pixel.r - target.r, 2) +
Math.pow(pixel.g - target.g, 2) +
Math.pow(pixel.b - target.b, 2)
)
return distance <= this.tolerance
}
private isPixelInRangeHSL(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
// Convert RGB to HSL
const pixelHSL = this.rgbToHSL(pixel.r, pixel.g, pixel.b)
const targetHSL = this.rgbToHSL(target.r, target.g, target.b)
// Compare mainly hue and saturation, be more lenient with lightness
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
const distance = Math.sqrt(
Math.pow((hueDiff / 360) * 255, 2) +
Math.pow((satDiff / 100) * 255, 2) +
Math.pow((lightDiff / 100) * 255, 2)
)
return distance <= this.tolerance
}
private rgbToHSL(
r: number,
g: number,
b: number
): { h: number; s: number; l: number } {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0,
s = 0,
l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
}
private isPixelInRangeLab(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
const pixelLab = this.rgbToLab(pixel)
const targetLab = this.rgbToLab(target)
// Calculate Delta E (CIE76 formula)
const deltaE = Math.sqrt(
Math.pow(pixelLab.l - targetLab.l, 2) +
Math.pow(pixelLab.a - targetLab.a, 2) +
Math.pow(pixelLab.b - targetLab.b, 2)
)
const normalizedDeltaE = (deltaE / 100) * 255
return normalizedDeltaE <= this.tolerance
}
private rgbToLab(rgb: { r: number; g: number; b: number }): {
l: number
a: number
b: number
} {
// First convert RGB to XYZ
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
r *= 100
g *= 100
b *= 100
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
// Then XYZ to Lab
const xn = 95.047
const yn = 100.0
const zn = 108.883
const xyz = [x / xn, y / yn, z / zn]
for (let i = 0; i < xyz.length; i++) {
xyz[i] =
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
}
return {
l: 116 * xyz[1] - 16,
a: 500 * (xyz[0] - xyz[1]),
b: 200 * (xyz[1] - xyz[2])
}
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.maskData![index] = color.r // R
this.maskData![index + 1] = color.g // G
this.maskData![index + 2] = color.b // B
this.maskData![index + 3] = alpha // A
}
async fillColorSelection(point: Point) {
this.width = this.canvas.width
this.height = this.canvas.height
this.lastPoint = point
// Get image data
const maskData = this.maskCTX.getImageData(0, 0, this.width, this.height)
this.maskData = maskData.data
this.imageData = this.imageCTX.getImageData(
0,
0,
this.width,
this.height
).data
if (this.applyWholeImage) {
// Process entire image
const targetPixel = this.getPixel(
Math.floor(point.x),
Math.floor(point.y)
)
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
// Use TypedArrays for better performance
const width = this.width!
const height = this.height!
// Process in chunks for better performance
const CHUNK_SIZE = 10000
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
}
}
// Allow UI updates between chunks
await new Promise((resolve) => setTimeout(resolve, 0))
}
} else {
// Original flood fill logic
let startX = Math.floor(point.x)
let startY = Math.floor(point.y)
if (
startX < 0 ||
startX >= this.width ||
startY < 0 ||
startY >= this.height
) {
return
}
const pixel = this.getPixel(startX, startY)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
stack.push([startX, startY])
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (
visited[visitedIndex] ||
!this.isPixelInRange(this.getPixel(x, y), pixel)
) {
continue
}
visited[visitedIndex] = 1
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
// Inline direction checks for better performance
if (
x > 0 &&
!visited[y * this.width + (x - 1)] &&
this.isPixelInRange(this.getPixel(x - 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x - 1, y) > this.maskTolerance
) {
stack.push([x - 1, y])
}
}
if (
x < this.width - 1 &&
!visited[y * this.width + (x + 1)] &&
this.isPixelInRange(this.getPixel(x + 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x + 1, y) > this.maskTolerance
) {
stack.push([x + 1, y])
}
}
if (
y > 0 &&
!visited[(y - 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y - 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y - 1) > this.maskTolerance
) {
stack.push([x, y - 1])
}
}
if (
y < this.height - 1 &&
!visited[(y + 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y + 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y + 1) > this.maskTolerance
) {
stack.push([x, y + 1])
}
}
}
}
this.maskCTX.putImageData(maskData, 0, 0)
this.messageBroker.publish('saveState')
this.maskData = null
this.imageData = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
setLivePreview(livePreview: boolean): void {
this.livePreview = livePreview
}
setComparisonMethod(method: ColorComparisonMethod): void {
this.colorComparisonMethod = method
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
clearLastPoint() {
this.lastPoint = null
}
setApplyWholeImage(applyWholeImage: boolean): void {
this.applyWholeImage = applyWholeImage
}
setMaskBoundary(maskBoundry: boolean): void {
this.maskBoundry = maskBoundry
}
setMaskTolerance(maskTolerance: number): void {
this.maskTolerance = maskTolerance
}
// Add method to set opacity
setSelectOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.selectOpacity = Math.floor((opacity / 100) * 255)
// Update preview if applicable
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
}
export { ColorSelectTool }

View File

@@ -1,265 +0,0 @@
import type { Point } from '../types'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class PaintBucketTool {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private width: number | null = null
private height: number | null = null
private imageData: ImageData | null = null
private data: Uint8ClampedArray | null = null
private tolerance: number = 5
private fillOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
initPaintBucketTool() {
this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
}
private createListeners() {
this.messageBroker.subscribe(
'setPaintBucketTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
// Add new listener for opacity setting
this.messageBroker.subscribe('setFillOpacity', (opacity: number) =>
this.setFillOpacity(opacity)
)
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'getTolerance',
async () => this.tolerance
)
// Add pull topic for fillOpacity
this.messageBroker.createPullTopic(
'getFillOpacity',
async () => (this.fillOpacity / 255) * 100
)
}
// Add method to set opacity
setFillOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.fillOpacity = Math.floor((opacity / 100) * 255)
}
private getPixel(x: number, y: number): number {
return this.data![(y * this.width! + x) * 4 + 3]
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.data![index] = color.r // R
this.data![index + 1] = color.g // G
this.data![index + 2] = color.b // B
this.data![index + 3] = alpha // A
}
private shouldProcessPixel(
currentAlpha: number,
targetAlpha: number,
tolerance: number,
isFillMode: boolean
): boolean {
if (currentAlpha === -1) return false
if (isFillMode) {
// Fill mode: process pixels that are empty/similar to target
return (
currentAlpha !== 255 &&
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
} else {
// Erase mode: process pixels that are filled/similar to target
return (
currentAlpha === 255 ||
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
}
}
private async floodFill(point: Point): Promise<void> {
let startX = Math.floor(point.x)
let startY = Math.floor(point.y)
this.width = this.canvas.width
this.height = this.canvas.height
if (
startX < 0 ||
startX >= this.width ||
startY < 0 ||
startY >= this.height
) {
return
}
this.imageData = this.ctx.getImageData(0, 0, this.width, this.height)
this.data = this.imageData.data
const targetAlpha = this.getPixel(startX, startY)
const isFillMode = targetAlpha !== 255 // Determine mode based on clicked pixel
if (targetAlpha === -1) return
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
if (
this.shouldProcessPixel(
targetAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([startX, startY])
}
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (visited[visitedIndex]) continue
const currentAlpha = this.getPixel(x, y)
if (
!this.shouldProcessPixel(
currentAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
continue
}
visited[visitedIndex] = 1
// Set alpha to fillOpacity for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? this.fillOpacity : 0, maskColor)
// Check neighbors
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= this.width! || ny < 0 || ny >= this.height!) return
if (!visited[ny * this.width! + nx]) {
const alpha = this.getPixel(nx, ny)
if (
this.shouldProcessPixel(
alpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([nx, ny])
}
}
}
checkNeighbor(x - 1, y) // Left
checkNeighbor(x + 1, y) // Right
checkNeighbor(x, y - 1) // Up
checkNeighbor(x, y + 1) // Down
}
this.ctx.putImageData(this.imageData, 0, 0)
this.imageData = null
this.data = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
}
getTolerance(): number {
return this.tolerance
}
//invert mask
private invertMask() {
const imageData = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const data = imageData.data
// Find first non-transparent pixel to get mask color
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
// Process each pixel
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
// Invert alpha channel (0 becomes 255, 255 becomes 0)
data[i + 3] = 255 - alpha
// If this was originally transparent (now opaque), fill with mask color
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
this.ctx.putImageData(imageData, 0, 0)
this.messageBroker.publish('saveState')
}
}
export { PaintBucketTool }

View File

@@ -1,3 +0,0 @@
export { PaintBucketTool } from './PaintBucketTool'
export { ColorSelectTool } from './ColorSelectTool'
export { BrushTool } from './BrushTool'

View File

@@ -19,7 +19,8 @@ export const allTools = [
Tools.MaskColorFill
]
export const allImageLayers = ['mask', 'rgb'] as const
const allImageLayers = ['mask', 'rgb'] as const
export type ImageLayer = (typeof allImageLayers)[number]
export interface ToolInternalSettings {
@@ -62,14 +63,3 @@ export interface Brush {
hardness: number
smoothingPrecision: number
}
export type Callback = (data?: any) => void
export type Ref = { filename: string; subfolder?: string; type?: string }
// Forward declaration for MaskEditorDialog
export interface MaskEditorDialog {
getMessageBroker(): any // Will be MessageBroker, but avoiding circular dependency
save(): void
destroy(): void
}

View File

@@ -1,32 +0,0 @@
import { debounce } from 'es-toolkit/compat'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import type { Brush } from '../types'
export const saveBrushToCache = debounce(function (
key: string,
brush: Brush
): void {
try {
const brushString = JSON.stringify(brush)
setStorageValue(key, brushString)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
export function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
const brush = JSON.parse(brushString) as Brush
console.log('Loaded brush from cache:', brush)
return brush
} else {
console.log('No brush found in cache.')
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}

View File

@@ -1,39 +0,0 @@
export const getCanvas2dContext = (
canvas: HTMLCanvasElement
): CanvasRenderingContext2D => {
const ctx = canvas.getContext('2d', { willReadFrequently: true })
// Safe with the way we use canvases
if (!ctx) throw new Error('Failed to get 2D context from canvas')
return ctx
}
export const createCanvasCopy = (
canvas: HTMLCanvasElement
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
const newCanvas = document.createElement('canvas')
const newCanvasCtx = getCanvas2dContext(newCanvas)
newCanvas.width = canvas.width
newCanvas.height = canvas.height
newCanvasCtx.clearRect(0, 0, canvas.width, canvas.height)
newCanvasCtx.drawImage(
canvas,
0,
0,
canvas.width,
canvas.height,
0,
0,
canvas.width,
canvas.height
)
return [newCanvas, newCanvasCtx]
}
export const combineOriginalImageAndPaint = (
canvases: Record<'originalImage' | 'paint', HTMLCanvasElement>
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
const { originalImage, paint } = canvases
const [resultCanvas, resultCanvasCtx] = createCanvasCopy(originalImage)
resultCanvasCtx.drawImage(paint, 0, 0)
return [resultCanvas, resultCanvasCtx]
}

View File

@@ -1,32 +0,0 @@
import { ComfyApp } from '@/scripts/app'
import type { Ref } from '../types'
/**
* Note: the images' positions are important here. What the positions mean is hardcoded in `src/scripts/app.ts` in the `copyToClipspace` method.
* - `newMainOutput` should be the fully composited image: base image + mask (in the alpha channel) + paint.
* - The first array element of `extraImagesShownButNotOutputted` should be JUST the paint layer, with a transparent background.
* - It is possible to add more images in the clipspace array, but is not useful currently.
* With this configuration, the MaskEditor will properly load the paint layer separately from the base image, ensuring it is editable.
* */
export const replaceClipspaceImages = (
newMainOutput: Ref,
otherImagesInClipspace?: Ref[]
) => {
try {
if (!ComfyApp?.clipspace?.widgets?.length) return
const firstImageWidgetIndex = ComfyApp.clipspace.widgets.findIndex(
(obj) => obj?.name === 'image'
)
const firstImageWidget = ComfyApp.clipspace.widgets[firstImageWidgetIndex]
if (!firstImageWidget) return
ComfyApp!.clipspace!.widgets![firstImageWidgetIndex].value = newMainOutput
otherImagesInClipspace?.forEach((extraImage, extraImageIndex) => {
const extraImageWidgetIndex = firstImageWidgetIndex + extraImageIndex + 1
ComfyApp!.clipspace!.widgets![extraImageWidgetIndex].value = extraImage
})
} catch (err) {
console.warn('Failed to set widget value:', err)
}
}

View File

@@ -1,62 +0,0 @@
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { Ref } from '../types'
export const ensureImageFullyLoaded = (src: string) =>
new Promise<void>((resolve, reject) => {
const maskImage = new Image()
maskImage.src = src
maskImage.onload = () => resolve()
maskImage.onerror = reject
})
const isAlphaValue = (index: number) => index % 4 === 3
export const removeImageRgbValuesAndInvertAlpha = (
imageData: Uint8ClampedArray
) => imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0))
export const toRef = (filename: string): Ref => ({
filename,
subfolder: 'clipspace',
type: 'input'
})
export const mkFileUrl = (props: { ref: Ref; preview?: boolean }) => {
const pathPlusQueryParams = api.apiURL(
'/view?' +
new URLSearchParams(props.ref).toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.src = pathPlusQueryParams
return imageElement.src
}
export const requestWithRetries = async (
mkRequest: () => Promise<Response>,
maxRetries: number = 3
): Promise<{ success: boolean }> => {
let attempt = 0
let success = false
while (attempt < maxRetries && !success) {
try {
const response = await mkRequest()
if (response.ok) {
success = true
} else {
console.log('Failed to upload mask:', response)
}
} catch (error) {
console.error(`Upload attempt ${attempt + 1} failed:`, error)
attempt++
if (attempt < maxRetries) {
console.log('Retrying upload...')
} else {
console.log('Max retries reached. Upload failed.')
}
}
}
return { success }
}

View File

@@ -1,14 +0,0 @@
export {
toRef,
mkFileUrl,
ensureImageFullyLoaded,
removeImageRgbValuesAndInvertAlpha,
requestWithRetries
} from './image'
export { imageLayerFilenamesIfApplicable } from './maskEditorLayerFilenames'
export {
getCanvas2dContext,
createCanvasCopy,
combineOriginalImageAndPaint
} from './canvas'
export { replaceClipspaceImages } from './clipspace'

View File

@@ -1,29 +0,0 @@
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
export const imageLayerFilenamesByTimestamp = (
timestamp: number
): ImageLayerFilenames => ({
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
})
export const imageLayerFilenamesIfApplicable = (
inputImageFilename: string
): ImageLayerFilenames | undefined => {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return imageLayerFilenamesByTimestamp(timestamp)
}

View File

@@ -81,7 +81,7 @@ useExtensionService().registerExtension({
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d)
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(fileInfo['type'], filePath)
}

View File

@@ -49,6 +49,7 @@ import type {
ISlotType,
LinkNetwork,
LinkSegment,
NewNodePosition,
NullableProperties,
Point,
Positionable,
@@ -1011,7 +1012,8 @@ export class LGraphCanvas
direction: Direction,
align_to?: LGraphNode
): void {
alignNodes(Object.values(nodes), direction, align_to)
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
}
@@ -1031,11 +1033,12 @@ export class LGraphCanvas
})
function inner_clicked(value: string) {
alignNodes(
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction,
node
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
}
}
@@ -1055,10 +1058,11 @@ export class LGraphCanvas
})
function inner_clicked(value: string) {
alignNodes(
const newPositions = alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
}
}
@@ -1079,10 +1083,11 @@ export class LGraphCanvas
function inner_clicked(value: string) {
const canvas = LGraphCanvas.active_canvas
distributeNodes(
const newPositions = distributeNodes(
Object.values(canvas.selected_nodes),
value === 'Horizontally'
)
canvas.repositionNodesVueMode(newPositions)
canvas.setDirty(true, true)
}
}
@@ -8557,10 +8562,7 @@ export class LGraphCanvas
) {
const mutations = this.initLayoutMutations()
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
const nodesToMove: Array<{
node: LGraphNode
newPos: { x: number; y: number }
}> = []
const nodesToMove: NewNodePosition[] = []
// First, collect all the moves we need to make
for (const item of allItems) {
@@ -8586,4 +8588,9 @@ export class LGraphCanvas
// Now apply all the node moves at once
this.applyNodePositionUpdates(nodesToMove, mutations)
}
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
const mutations = this.initLayoutMutations()
this.applyNodePositionUpdates(nodesToReposition, mutations)
}
}

View File

@@ -254,7 +254,13 @@ type KeysOfType<T, Match> = Exclude<
/** The names of all (optional) methods and functions in T */
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
export interface NewNodePosition {
node: LGraphNode
newPos: {
x: number
y: number
}
}
export interface IBoundaryNodes {
top: LGraphNode
right: LGraphNode

View File

@@ -1,5 +1,5 @@
import type { LGraphNode } from '../LGraphNode'
import type { Direction, IBoundaryNodes } from '../interfaces'
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
/**
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
@@ -43,9 +43,9 @@ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
export function distributeNodes(
nodes: LGraphNode[],
horizontal?: boolean
): void {
): NewNodePosition[] {
const nodeCount = nodes?.length
if (!(nodeCount > 1)) return
if (!(nodeCount > 1)) return []
const index = horizontal ? 0 : 1
@@ -68,6 +68,16 @@ export function distributeNodes(
node.pos[index] = startAt + gap * i
startAt += node.size[index]
}
const newPositions = sorted.map(
(node): NewNodePosition => ({
node,
newPos: {
x: node.pos[0],
y: node.pos[1]
}
})
)
return newPositions
}
/**
@@ -80,32 +90,56 @@ export function alignNodes(
nodes: LGraphNode[],
direction: Direction,
align_to?: LGraphNode
): void {
if (!nodes) return
): NewNodePosition[] {
if (!nodes) return []
const boundary =
align_to === undefined
? getBoundaryNodes(nodes)
: { top: align_to, right: align_to, bottom: align_to, left: align_to }
if (boundary === null) return
if (boundary === null) return []
for (const node of nodes) {
const nodePositions = nodes.map((node): NewNodePosition => {
switch (direction) {
case 'right':
node.pos[0] =
boundary.right.pos[0] + boundary.right.size[0] - node.size[0]
break
return {
node,
newPos: {
x: boundary.right.pos[0] + boundary.right.size[0] - node.size[0],
y: node.pos[1]
}
}
case 'left':
node.pos[0] = boundary.left.pos[0]
break
return {
node,
newPos: {
x: boundary.left.pos[0],
y: node.pos[1]
}
}
case 'top':
node.pos[1] = boundary.top.pos[1]
break
return {
node,
newPos: {
x: node.pos[0],
y: boundary.top.pos[1]
}
}
case 'bottom':
node.pos[1] =
boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
break
return {
node,
newPos: {
x: node.pos[0],
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
}
}
}
})
for (const { node, newPos } of nodePositions) {
node.pos[0] = newPos.x
node.pos[1] = newPos.y
}
return nodePositions
}

View File

@@ -289,7 +289,7 @@
"lastUpdated": "Last Updated",
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"installAllMissingNodes": "Install All",
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
@@ -618,8 +618,19 @@
"workflows": "Workflows",
"templates": "Templates",
"assets": "Assets",
"mediaAssets": "Media Assets",
"mediaAssets": {
"title": "Media Assets",
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first",
"sortLongestFirst": "Generation time (longest first)",
"sortFastestFirst": "Generation time (fastest first)",
"filterImage": "Image",
"filterVideo": "Video",
"filterAudio": "Audio",
"filter3D": "3D"
},
"backToAssets": "Back to all assets",
"searchAssets": "Search assets...",
"labels": {
"queue": "Queue",
"nodes": "Nodes",
@@ -873,29 +884,42 @@
"cancelled": "Cancelled"
},
"maskEditor": {
"Invert": "Invert",
"Clear": "Clear",
"Brush Settings": "Brush Settings",
"Brush Shape": "Brush Shape",
"Thickness": "Thickness",
"Opacity": "Opacity",
"Hardness": "Hardness",
"Smoothing Precision": "Smoothing Precision",
"Reset to Default": "Reset to Default",
"Paint Bucket Settings": "Paint Bucket Settings",
"Tolerance": "Tolerance",
"Fill Opacity": "Fill Opacity",
"Color Select Settings": "Color Select Settings",
"Selection Opacity": "Selection Opacity",
"Live Preview": "Live Preview",
"Apply to Whole Image": "Apply to Whole Image",
"Method": "Method",
"Stop at mask": "Stop at mask",
"Mask Tolerance": "Mask Tolerance",
"Layers": "Layers",
"Mask Layer": "Mask Layer",
"Mask Opacity": "Mask Opacity",
"Image Layer": "Image Layer"
"title": "Mask Editor",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",
"redo": "Redo",
"clickToResetZoom": "Click to reset zoom",
"brushSettings": "Brush Settings",
"brushShape": "Brush Shape",
"colorSelector": "Color Selector",
"thickness": "Thickness",
"opacity": "Opacity",
"hardness": "Hardness",
"smoothingPrecision": "Smoothing Precision",
"resetToDefault": "Reset to Default",
"paintBucketSettings": "Paint Bucket Settings",
"tolerance": "Tolerance",
"fillOpacity": "Fill Opacity",
"colorSelectSettings": "Color Select Settings",
"selectionOpacity": "Selection Opacity",
"livePreview": "Live Preview",
"applyToWholeImage": "Apply to Whole Image",
"method": "Method",
"stopAtMask": "Stop at mask",
"maskTolerance": "Mask Tolerance",
"layers": "Layers",
"maskLayer": "Mask Layer",
"maskOpacity": "Mask Opacity",
"imageLayer": "Image Layer",
"maskBlendingOptions": "Mask Blending Options",
"paintLayer": "Paint Layer",
"baseImageLayer": "Base Image Layer",
"activateLayer": "Activate Layer",
"baseLayerPreview": "Base layer preview",
"black": "Black",
"white": "White",
"negative": "Negative"
},
"commands": {
"runWorkflow": "Run workflow",
@@ -1399,13 +1423,6 @@
"missingModels": "Missing Models",
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"loadWorkflowWarning": {
"missingNodesTitle": "Some Nodes Are Missing",
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type cant be found.",
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"
},
"versionMismatchWarning": {
"title": "Version Compatibility Warning",
"frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires version {requiredVersion} or higher.",
@@ -1984,37 +2001,12 @@
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Upload model",
"uploadModelFromCivitai": "Upload a model from Civitai",
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription2": "Only links from https://civitai.com are supported at the moment",
"uploadModelDescription3": "Max file size: 1 GB",
"civitaiLinkLabel": "Civitai model download link",
"civitaiLinkPlaceholder": "Paste link here",
"civitaiLinkExample": "Example: https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor",
"confirmModelDetails": "Confirm Model Details",
"fileName": "File Name",
"fileSize": "File Size",
"modelName": "Model Name",
"modelNamePlaceholder": "Enter a name for this model",
"tags": "Tags",
"tagsPlaceholder": "e.g., models, checkpoint",
"tagsHelp": "Separate tags with commas",
"upload": "Upload",
"uploadingModel": "Uploading model...",
"uploadSuccess": "Model uploaded successfully!",
"uploadFailed": "Upload failed",
"modelAssociatedWithLink": "The model associated with the link you provided:",
"whatTypeOfModel": "What type of model is this?",
"selectModelType": "Select model type",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"modelUploaded": "Model uploaded!",
"findInLibrary": "Find it in the {type} section of the models library.",
"finish": "Finish",
"allModels": "All Models",
"allCategory": "All {category}",
"unknown": "Unknown",
"fileFormats": "File formats",
"baseModels": "Base models",
"filterBy": "Filter by",
"sortBy": "Sort by",
"sortAZ": "A-Z",
"sortZA": "Z-A",
@@ -2052,7 +2044,8 @@
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets"
}
},
"noJobIdFound": "No job ID found for this asset"
},
"actionbar": {
"dockToTop": "Dock to top",
@@ -2068,24 +2061,30 @@
}
}
},
"vueNodesMigration": {
"message": "Prefer the classic node design?",
"button": "Open Settings"
},
"vueNodesBanner": {
"message": "Nodes just got a new look and feel",
"message": "Introducing Nodes 2.0 More flexible workflows, powerful new widgets, built for extensibility",
"tryItOut": "Try it out"
},
"cloud": {
"missingNodes": {
"vueNodesMigration": {
"message": "Prefer the legacy design?",
"button": "Switch back"
},
"vueNodesMigrationMainMenu": {
"message": "Switch back to Nodes 2.0 anytime from the main menu."
},
"missingNodes": {
"cloud": {
"title": "These nodes aren't available on Comfy Cloud yet",
"description": "This workflow uses custom nodes that aren't supported in the Cloud version yet.",
"priorityMessage": "We've automatically flagged these nodes so we can prioritize adding them.",
"missingNodes": "Missing Nodes",
"replacementInstruction": "In the meantime, replace these nodes (highlighted red on the canvas) with supported ones if possible, or try a different workflow.",
"learnMore": "Learn more",
"gotIt": "Ok, got it",
"cannotRun": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow. "
"gotIt": "Ok, got it"
},
"oss": {
"title": "This workflow has missing nodes",
"description": "This workflow uses custom nodes you haven't installed yet.",
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
}
}
}
}

View File

@@ -73,14 +73,11 @@ import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vue'
import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useDialogStore } from '@/stores/dialogStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -95,7 +92,6 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
@@ -193,15 +189,6 @@ const { flags } = useFeatureFlags()
const isUploadButtonEnabled = computed(() => flags.modelUploadButtonEnabled)
function handleUploadClick() {
dialogStore.showDialog({
key: 'upload-model',
headerComponent: UploadModelDialogHeader,
component: UploadModelDialog,
props: {
onUploadSuccess: async () => {
await execute()
}
}
})
// Will be implemented in the future commit
}
</script>

View File

@@ -248,7 +248,7 @@ provide(MediaAssetKey, {
const containerClasses = computed(() =>
cn(
'gap-1',
'gap-1 select-none',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'

View File

@@ -0,0 +1,74 @@
<template>
<div class="flex gap-3 pt-2">
<SearchBox
:model-value="searchQuery"
:placeholder="$t('sideToolbar.searchAssets')"
size="lg"
@update:model-value="handleSearchChange"
/>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters="mediaTypeFilters"
:close="close"
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<AssetSortButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetSortMenu
:sort-by="sortBy"
:show-generation-time-sort
:close="close"
@update:sort-by="handleSortChange"
/>
</template>
</AssetSortButton>
</div>
</template>
<script setup lang="ts">
import SearchBox from '@/components/input/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
import AssetSortButton from './MediaAssetSortButton.vue'
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
const { showGenerationTimeSort = false } = defineProps<{
searchQuery: string
sortBy: 'newest' | 'oldest' | 'longest' | 'fastest'
showGenerationTimeSort?: boolean
mediaTypeFilters: string[]
}>()
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:sortBy': [value: 'newest' | 'oldest' | 'longest' | 'fastest']
'update:mediaTypeFilters': [value: string[]]
}>()
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')
}
const handleSortChange = (
value: 'newest' | 'oldest' | 'longest' | 'fastest'
) => {
emit('update:sortBy', value)
}
const handleMediaTypeFiltersChange = (value: string[]) => {
emit('update:mediaTypeFilters', value)
}
</script>

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