Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2000af6ca7 | ||
|
|
7e36007933 |
@@ -0,0 +1,52 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
We are committed to creating a welcoming and inclusive community for everyone. We believe that a diverse and respectful community is essential for fostering creativity and innovation. We expect all members of our community to adhere to this Code of Conduct.
|
||||
|
||||
## Our Expectations
|
||||
|
||||
This Code of Conduct applies to all interactions within the mtb community, including:
|
||||
|
||||
* Public communication channels (e.g., GitHub issues, pull requests, discussions, social media)
|
||||
* Private communication channels (e.g., direct messages, email)
|
||||
* In-person events (if any)
|
||||
|
||||
We expect all members to:
|
||||
|
||||
* **Be respectful and considerate:** Treat others with kindness and empathy.
|
||||
* **Be inclusive:** Welcome and respect people of all backgrounds, identities, and experiences.
|
||||
* **Be constructive:** Focus on providing helpful and positive feedback.
|
||||
* **Be mindful of your language:** Avoid using offensive, discriminatory, or harassing language.
|
||||
* **Respect privacy:** Do not share personal information without consent.
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
The following behaviors are not tolerated:
|
||||
|
||||
* Offensive, discriminatory, or harassing language or conduct
|
||||
* Personal attacks or insults
|
||||
* Spamming or trolling
|
||||
* Sharing of malicious or inappropriate content
|
||||
* Disrupting the community or hindering collaboration
|
||||
* Violating the privacy of others
|
||||
|
||||
## Reporting Violations
|
||||
|
||||
If you experience or witness a violation of this Code of Conduct, please report it to @melmass. All reports will be treated confidentially and investigated promptly.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Violations of this Code of Conduct may result in the following actions:
|
||||
|
||||
* Warning
|
||||
* Removal from the community
|
||||
* Ban from the community
|
||||
|
||||
## License
|
||||
[](code_of_conduct.md)
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions or concerns about this Code of Conduct, please contact @melmass.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Contributing to mtb
|
||||
|
||||
Thank you for your interest in contributing to mtb! We appreciate your help in making this project better. This document outlines how you can contribute to the project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This project is a collection of custom nodes for ComfyUI, tailored specifically for animation workflows. It aims to provide a streamlined and user-friendly experience for creating animations within the ComfyUI environment.
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
We welcome all kinds of contributions! Here's how you can get involved:
|
||||
|
||||
* **Bug Reports:** If you encounter any issues, please create a new issue on GitHub. Please include clear steps to reproduce the bug, along with any relevant error messages, workflows or screenshots.
|
||||
* **Feature Requests:** Have an idea for a new node or feature? Create a new issue to discuss it! Please describe the feature in detail, and explain how it would benefit the project.
|
||||
* **Documentation Improvements:** Help us improve the documentation by fixing errors, adding examples, or clarifying explanations.
|
||||
* **Code Contributions:** We welcome contributions to the codebase! Please see the "Development Setup" and "File Structure" sections below for more information.
|
||||
* **Testing:** Help us ensure the stability and reliability of the project by testing new features and bug fixes.
|
||||
* **Refactoring:** Help us improve the codebase by refactoring existing code to improve readability, maintainability, and performance.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```sh
|
||||
git clone --recursive https://github.com/melmass/comfy_mtb
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
Understanding the project structure is crucial for making effective contributions.
|
||||
|
||||
* **`./nodes/*.py`:** This directory contains the definitions for all custom nodes. Nodes are automatically registered when a file defines an array named `__nodes__` containing the node classes. Make sure your node follows the ComfyUI node definition structure.
|
||||
* **`./web/*.js`:** This directory contains all the frontend JavaScript code for the extension's user interface.
|
||||
* **`./wiki`:** This directory is a Git submodule that contains the project's Wiki documentation, written in Markdown. Node documentation should be created or updated in the corresponding Markdown files within this submodule. This is then referenced by the UI for in-GUI help
|
||||
|
||||
## Coding Style
|
||||
|
||||
We use **Ruff** for code formatting to ensure consistency. Please run Ruff on your code before submitting a pull request. No specific configuration is required, so the default Ruff settings will be used.
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
1. **Create a Branch:** Create a new branch for your feature or fix. Use a descriptive branch name (e.g., `feature/new-node`, `fix/bug-in-ui`). **Do not fork the main branch directly.**
|
||||
2. **Make Changes:** Implement your changes in your branch.
|
||||
3. **Run Tests:** (Add instructions on how to run tests if available.)
|
||||
4. **Format Code:** Run Ruff on your code to ensure it is properly formatted.
|
||||
5. **Create a Pull Request:** Submit a pull request to the `main` branch. Please provide a clear and concise description of your changes.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
We are committed to creating a welcoming and inclusive community. We expect all contributors to adhere to a respectful and professional code of conduct. (Consider adding a link to a CODE_OF_CONDUCT.md file or a standard code of conduct.)
|
||||
|
||||
## Tools and Libraries
|
||||
|
||||
* **Python:** The primary programming language for this project.
|
||||
* **ComfyUI:** The underlying framework for the custom nodes.
|
||||
|
||||
## Current Focus
|
||||
|
||||
We are currently focused on a major refactor to clean up the project's codebase. Contributions related to this effort are particularly welcome!
|
||||
|
||||
## Thank You!
|
||||
|
||||
Thank you for considering contributing to mtb! Your contributions are greatly appreciated. We look forward to reviewing your pull requests!
|
||||
|
||||
+156
-44
@@ -10,6 +10,8 @@
|
||||
__version__ = "0.3.0"
|
||||
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.web_request import Request
|
||||
|
||||
@@ -247,7 +249,7 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
with contextlib.suppress(ImportError):
|
||||
from cachetools import TTLCache
|
||||
|
||||
img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
# img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
prompt_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
|
||||
node_dependency_mapping = get_node_dependencies()
|
||||
@@ -362,29 +364,132 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from asyncio import Semaphore
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
image_thread_pool = ThreadPoolExecutor(
|
||||
max_workers=4, thread_name_prefix="img_worker"
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_image_with_timeout(
|
||||
file_path, preview_params=None, channel=None, timeout=10
|
||||
):
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
image_thread_pool,
|
||||
get_cached_image,
|
||||
file_path,
|
||||
preview_params,
|
||||
channel,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
yield result
|
||||
except asyncio.TimeoutError:
|
||||
print(f"Image processing timed out for {file_path}")
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error processing image {file_path}: {str(e)}")
|
||||
raise
|
||||
|
||||
async def get_image_response(
|
||||
file, filename: str, preview_info=None, channel=None
|
||||
):
|
||||
try:
|
||||
async with get_image_with_timeout(
|
||||
file, preview_info, channel
|
||||
) as img:
|
||||
return web.Response(
|
||||
body=img,
|
||||
content_type="image/webp" if preview_info else "image/png",
|
||||
headers={"Content-Disposition": f'filename="{filename}"'},
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return web.Response(status=504, text="Image processing timed out")
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
class LRUCache:
|
||||
def __init__(self, capacity: int):
|
||||
self.cache = OrderedDict()
|
||||
self.capacity = capacity
|
||||
|
||||
def get(self, key) -> Any:
|
||||
if key not in self.cache:
|
||||
return None
|
||||
self.cache.move_to_end(key)
|
||||
return self.cache[key]
|
||||
|
||||
def put(self, key, value: Any) -> None:
|
||||
if key in self.cache:
|
||||
self.cache.move_to_end(key)
|
||||
self.cache[key] = value
|
||||
if len(self.cache) > self.capacity:
|
||||
self.cache.popitem(last=False)
|
||||
|
||||
img_cache = LRUCache(capacity=100)
|
||||
|
||||
def get_cached_image(file_path: str, preview_params=None, channel=None):
|
||||
cache_key = (file_path, preview_params, channel)
|
||||
if img_cache and (cache_key in img_cache):
|
||||
return img_cache[cache_key]
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
info = img.info
|
||||
if preview_params:
|
||||
img = process_preview(img, preview_params)
|
||||
if channel:
|
||||
img = process_channel(img, channel)
|
||||
if prompt_cache:
|
||||
prompt_cache[cache_key] = info
|
||||
try:
|
||||
if img_cache:
|
||||
img_cache[cache_key] = img.getvalue()
|
||||
return img_cache[cache_key]
|
||||
cached_value = img_cache.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
return img.getvalue()
|
||||
with Image.open(file_path) as img:
|
||||
info = img.info
|
||||
if preview_params:
|
||||
img = process_preview(img, preview_params)
|
||||
if channel:
|
||||
img = process_channel(img, channel)
|
||||
|
||||
result = img.getvalue()
|
||||
|
||||
try:
|
||||
if prompt_cache:
|
||||
prompt_cache[cache_key] = info
|
||||
if img_cache:
|
||||
img_cache.put(cache_key, result)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Failed to cache image {file_path}: {str(e)}"
|
||||
)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error processing image {file_path}: {str(e)}")
|
||||
raise
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, requests_per_second):
|
||||
self.requests_per_second = requests_per_second
|
||||
self.semaphore = Semaphore(requests_per_second)
|
||||
self.timestamps = []
|
||||
|
||||
async def acquire(self):
|
||||
await self.semaphore.acquire()
|
||||
now = time.time()
|
||||
self.timestamps.append(now)
|
||||
|
||||
# Remove old timestamps
|
||||
self.timestamps = [t for t in self.timestamps if now - t < 1.0]
|
||||
|
||||
if len(self.timestamps) >= self.requests_per_second:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
def release(self):
|
||||
self.semaphore.release()
|
||||
|
||||
rate_limiter = RateLimiter(requests_per_second=10)
|
||||
|
||||
def process_preview(img: Image.Image, preview_params):
|
||||
image_format, quality, width = preview_params
|
||||
@@ -437,41 +542,44 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
# to load workflows in the sidebar
|
||||
@PromptServer.instance.routes.get("/mtb/view")
|
||||
async def view_image(request: Request):
|
||||
import folder_paths
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
filename = request.rel_url.query.get("filename")
|
||||
if not filename:
|
||||
return web.Response(status=404)
|
||||
await rate_limiter.acquire()
|
||||
|
||||
filename, output_dir = folder_paths.annotated_filepath(filename)
|
||||
if filename[0] == "/" or ".." in filename:
|
||||
return web.Response(status=400)
|
||||
filename = request.rel_url.query.get("filename")
|
||||
if not filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
if output_dir is None:
|
||||
rtype = request.rel_url.query.get("type", "output")
|
||||
output_dir = folder_paths.get_directory_by_type(rtype)
|
||||
filename, output_dir = folder_paths.annotated_filepath(filename)
|
||||
if filename[0] == "/" or ".." in filename:
|
||||
return web.Response(status=400)
|
||||
|
||||
if output_dir is None:
|
||||
return web.Response(status=400)
|
||||
if output_dir is None:
|
||||
rtype = request.rel_url.query.get("type", "output")
|
||||
output_dir = folder_paths.get_directory_by_type(rtype)
|
||||
|
||||
if "subfolder" in request.rel_url.query:
|
||||
full_output_dir = os.path.join(
|
||||
output_dir, request.rel_url.query["subfolder"]
|
||||
)
|
||||
if (
|
||||
os.path.commonpath(
|
||||
(os.path.abspath(full_output_dir), output_dir)
|
||||
if output_dir is None:
|
||||
return web.Response(status=400)
|
||||
|
||||
if "subfolder" in request.rel_url.query:
|
||||
full_output_dir = os.path.join(
|
||||
output_dir, request.rel_url.query["subfolder"]
|
||||
)
|
||||
!= output_dir
|
||||
):
|
||||
return web.Response(status=403)
|
||||
output_dir = full_output_dir
|
||||
if (
|
||||
os.path.commonpath(
|
||||
(os.path.abspath(full_output_dir), output_dir)
|
||||
)
|
||||
!= output_dir
|
||||
):
|
||||
return web.Response(status=403)
|
||||
output_dir = full_output_dir
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
file = os.path.join(output_dir, filename)
|
||||
filename = os.path.basename(filename)
|
||||
file = os.path.join(output_dir, filename)
|
||||
|
||||
if not os.path.isfile(file):
|
||||
return web.Response(status=404)
|
||||
if not os.path.isfile(file):
|
||||
return web.Response(status=404)
|
||||
|
||||
ret_workflow = request.rel_url.query.get("workflow")
|
||||
|
||||
@@ -509,9 +617,13 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
width = request.rel_url.query.get("width")
|
||||
preview_info = (image_format, quality, width)
|
||||
|
||||
channel = request.rel_url.query.get("channel")
|
||||
channel = request.rel_url.query.get("channel")
|
||||
|
||||
return await get_image_response(file, filename, preview_info, channel)
|
||||
return await get_image_response(
|
||||
file, filename, preview_info, channel
|
||||
)
|
||||
finally:
|
||||
rate_limiter.release()
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb/server-info")
|
||||
async def get_debug(request: Request):
|
||||
|
||||
+6
-1
@@ -74,7 +74,12 @@ def ACTIONS_getUserImageFolders():
|
||||
input_subdirs = [x.name for x in input_dir.iterdir() if x.is_dir()]
|
||||
output_subdirs = [x.name for x in output_dir.iterdir() if x.is_dir()]
|
||||
|
||||
return {"input": input_subdirs, "output": output_subdirs}
|
||||
return {
|
||||
"input_root": input_dir.as_posix(),
|
||||
"input": input_subdirs,
|
||||
"output": output_subdirs,
|
||||
"output_root": output_dir.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def ACTIONS_getUserVideos(
|
||||
|
||||
@@ -546,9 +546,7 @@ PIL_FILTER_MAP = {
|
||||
# region TENSOR Utilities
|
||||
def to_numpy(image: torch.Tensor) -> npt.NDArray[np.uint8]:
|
||||
"""Converts a tensor to a ndarray with proper scaling and type conversion."""
|
||||
log.debug(f"Converting tensor to numpy array with shape {image.shape}")
|
||||
np_array = np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8)
|
||||
log.debug(f"Numpy array shape after conversion: {np_array.shape}")
|
||||
return np_array
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
|
||||
if (!window.MTB) {
|
||||
window.MTB = {}
|
||||
}
|
||||
// #region base utils
|
||||
|
||||
// - crude uuid
|
||||
@@ -276,6 +279,10 @@ export const getNamedWidget = (node, ...names) => {
|
||||
* @returns {{to:LGraphNode, from:LGraphNode, type:'error' | 'incoming' | 'outgoing'}}
|
||||
*/
|
||||
export const nodesFromLink = (node, link) => {
|
||||
if (typeof link === 'number') {
|
||||
console.log('Resolving link from id', link)
|
||||
link = app.graph.links[link]
|
||||
}
|
||||
const fromNode = app.graph.getNodeById(link.origin_id)
|
||||
const toNode = app.graph.getNodeById(link.target_id)
|
||||
|
||||
@@ -1068,6 +1075,66 @@ export const addDocumentation = (
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region canvas / drawing
|
||||
|
||||
// calculate convex hull (Graham)
|
||||
export function getConvexHull(points) {
|
||||
if (points.length < 3) return points
|
||||
|
||||
// find the bottommost point (and leftmost if tied)
|
||||
let bottom = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (
|
||||
points[i][1] < points[bottom][1] ||
|
||||
(points[i][1] === points[bottom][1] && points[i][0] < points[bottom][0])
|
||||
) {
|
||||
bottom = i
|
||||
}
|
||||
}
|
||||
// swap bottom point to first position
|
||||
;[points[0], points[bottom]] = [points[bottom], points[0]]
|
||||
|
||||
// sort points by polar angle with respect to base point
|
||||
const basePoint = points[0]
|
||||
points.sort((a, b) => {
|
||||
if (a === basePoint) return -1
|
||||
if (b === basePoint) return 1
|
||||
|
||||
const angleA = Math.atan2(a[1] - basePoint[1], a[0] - basePoint[0])
|
||||
const angleB = Math.atan2(b[1] - basePoint[1], b[0] - basePoint[0])
|
||||
|
||||
if (angleA < angleB) return -1
|
||||
if (angleA > angleB) return 1
|
||||
|
||||
// if angles are equal, sort by distance
|
||||
const distA = (a[0] - basePoint[0]) ** 2 + (a[1] - basePoint[1]) ** 2
|
||||
const distB = (b[0] - basePoint[0]) ** 2 + (b[1] - basePoint[1]) ** 2
|
||||
return distA - distB
|
||||
})
|
||||
|
||||
// build convex hull
|
||||
const stack = [points[0], points[1]]
|
||||
for (let i = 2; i < points.length; i++) {
|
||||
while (
|
||||
stack.length > 1 &&
|
||||
!isLeftTurn(stack[stack.length - 2], stack[stack.length - 1], points[i])
|
||||
) {
|
||||
stack.pop()
|
||||
}
|
||||
stack.push(points[i])
|
||||
}
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
function isLeftTurn(p1, p2, p3) {
|
||||
return (
|
||||
(p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0]) > 0
|
||||
)
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region node extensions
|
||||
|
||||
/**
|
||||
@@ -1142,6 +1209,8 @@ export const runAction = async (name, ...args) => {
|
||||
const res = await req.json()
|
||||
return res.result
|
||||
}
|
||||
|
||||
window.MTB.run = runAction
|
||||
export const getServerInfo = async () => {
|
||||
const res = await api.fetchApi('/mtb/server-info')
|
||||
return await res.json()
|
||||
|
||||
+465
-164
@@ -2,6 +2,7 @@
|
||||
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
import { infoLogger, successLogger, errorLogger } from './comfy_shared.js'
|
||||
|
||||
import * as shared from './comfy_shared.js'
|
||||
|
||||
@@ -12,32 +13,140 @@ import {
|
||||
makeSelect,
|
||||
makeSlider,
|
||||
renderSidebar,
|
||||
ContextMenu,
|
||||
} from './mtb_ui.js'
|
||||
|
||||
let currentAbortController = null
|
||||
|
||||
/** cursor/offset of where we are at */
|
||||
const offset = 0
|
||||
|
||||
/** width of the images in the grid */
|
||||
let currentWidth = 200
|
||||
|
||||
let currentMode = 'input'
|
||||
let subfolder = ''
|
||||
let currentSort = 'None'
|
||||
|
||||
const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
|
||||
let clientOnce = false
|
||||
|
||||
/** reference to the dom element receiving the images */
|
||||
let imgGrid = undefined
|
||||
|
||||
/** currently loaded image (as object urls) */
|
||||
let loaded_images = undefined
|
||||
|
||||
/**
|
||||
* stores the user's full local path to input/output directory
|
||||
* This is then used to feed VHS Load Image (from path)
|
||||
*/
|
||||
let userDirectories = undefined
|
||||
|
||||
// const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
|
||||
const VIDEO_NODES = ['VHS_LoadVideo']
|
||||
const PROCESSED_PROMPT_IDS = new Set()
|
||||
|
||||
|
||||
let contextMenu = undefined
|
||||
|
||||
function debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
infoLogger('Debouncing method')
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedGetUrls = async (ms = 250) => {
|
||||
if (loaded_images === undefined) {
|
||||
return await getUrls(subfolder)
|
||||
}
|
||||
debounce(async (subfolder) => {
|
||||
const urls = await getUrls(subfolder)
|
||||
infoLogger('Loaded URLs (debounced): ', urls)
|
||||
if (urls) {
|
||||
loaded_images = await getImgsFromUrls(urls, imgGrid)
|
||||
infoLogger('Loaded Images (debounced): ', loaded_images)
|
||||
}
|
||||
}, ms)
|
||||
|
||||
return loaded_images
|
||||
}
|
||||
|
||||
/** Callback on clicking an image in the grid */
|
||||
const updateImage = (node, image) => {
|
||||
if (IMAGE_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'image')
|
||||
if (w) {
|
||||
w.value = image
|
||||
w.callback()
|
||||
switch (node.type) {
|
||||
case 'LoadImage': {
|
||||
if (subfolder && subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentMode === 'output') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Outputs not supported',
|
||||
detail:
|
||||
"The LoadImage node doesn't support loading outputs, use VHS Load Image Path and I'll resolve the full path.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
// if (IMAGE_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'image')
|
||||
if (w) {
|
||||
w.value = image
|
||||
w.callback()
|
||||
}
|
||||
//}
|
||||
break
|
||||
}
|
||||
} else if (VIDEO_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'video')
|
||||
if (w) {
|
||||
node.updateParameters({ filename: image }, true)
|
||||
case 'VHS_LoadImagePath': {
|
||||
let value = image
|
||||
|
||||
if (!userDirectories?.output) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'User output directory not resolved',
|
||||
detail: "We couldn't resolve the image full path.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (subfolder && subfolder !== '') {
|
||||
value = `${subfolder}/${image}`
|
||||
}
|
||||
value = `${userDirectories.output}/${value}`
|
||||
|
||||
const w = node.widgets?.find((w) => w.name === 'image')
|
||||
if (w) {
|
||||
console.log(w)
|
||||
w.value = value
|
||||
// TODO: VHS needs explicity value passsed here
|
||||
w.callback(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case VIDEO_NODES.includes(node.type): {
|
||||
const w = node.widgets?.find((w) => w.name === 'video')
|
||||
if (w) {
|
||||
node.updateParameters({ filename: image }, true)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
console.warn('No method to update', node.type)
|
||||
}
|
||||
} else {
|
||||
console.warn('No method to update', node.type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,94 +218,265 @@ const updateOutputsGrid = async () => {
|
||||
}
|
||||
|
||||
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
|
||||
const imgs = []
|
||||
if (urls === undefined) {
|
||||
return imgs
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort()
|
||||
}
|
||||
const elem = currentMode === 'video' ? 'video' : 'img'
|
||||
infoLogger('getting images from urls', urls)
|
||||
|
||||
for (const [key, url] of Object.entries(urls)) {
|
||||
const a = makeElement(elem)
|
||||
a.src = url
|
||||
a.width = currentWidth
|
||||
if (currentMode === 'input') {
|
||||
a.onclick = (_e) => {
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
currentAbortController = new AbortController()
|
||||
const { signal } = currentAbortController
|
||||
const imgs = []
|
||||
if (!urls) return imgs
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
} else if (currentMode === 'output') {
|
||||
a.onclick = (_e) => {
|
||||
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const loadingIndicator = document.createElement('div')
|
||||
loadingIndicator.className = 'mtb-loading-indicator'
|
||||
if (target) target.appendChild(loadingIndicator)
|
||||
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Outputs not supported',
|
||||
detail:
|
||||
'For now only inputs can be clicked to load the image on the active LoadImage node.',
|
||||
life: 5000,
|
||||
const totalImages = Object.keys(urls).length
|
||||
let loadedCount = 0
|
||||
const updateLoadingStatus = () => {
|
||||
loadingIndicator.textContent = `Loaded ${loadedCount} of ${totalImages} images`
|
||||
}
|
||||
updateLoadingStatus()
|
||||
|
||||
try {
|
||||
const loadImage = async (key, url) => {
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch ${key}: ${response.status}`)
|
||||
|
||||
return null
|
||||
}
|
||||
// throw new Error(`HTTP error! status: ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
const imgUrl = URL.createObjectURL(blob)
|
||||
|
||||
const elem = makeElement(currentMode === 'video' ? 'video' : 'img')
|
||||
elem.src = imgUrl
|
||||
elem.width = currentWidth
|
||||
|
||||
// cleanup
|
||||
elem.onload = () => URL.revokeObjectURL(imgUrl)
|
||||
elem.onerror = () => URL.revokeObjectURL(imgUrl)
|
||||
|
||||
// Add click handler for input mode
|
||||
// if (currentMode === 'input') {
|
||||
// elem.onclick = (_e) => {
|
||||
// Your existing click handler code
|
||||
// }
|
||||
// }
|
||||
|
||||
// Add context menu
|
||||
elem.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
const contextMenuItems = [
|
||||
{
|
||||
label: 'Add Node with Image',
|
||||
icon: '🖼',
|
||||
action: () => {
|
||||
const node = app.graph.createNode('LoadImage')
|
||||
updateImage(node, key)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Load Workflow from Image',
|
||||
icon: '📋',
|
||||
action: async () => {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
const data = await response.blob()
|
||||
// Assuming you have a function to extract workflow from image metadata
|
||||
const workflow = await extractWorkflowFromImage(data)
|
||||
if (workflow) {
|
||||
app.loadGraphData(workflow)
|
||||
}
|
||||
} catch (error) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load workflow from image',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'View Full Image',
|
||||
icon: '🔍',
|
||||
action: () => {
|
||||
window.open(url, '_blank')
|
||||
},
|
||||
},
|
||||
]
|
||||
contextMenu.show(e.pageX, e.pageY, contextMenuItems, {
|
||||
elem,
|
||||
key,
|
||||
url,
|
||||
})
|
||||
})
|
||||
}
|
||||
} else {
|
||||
a.autoplay = true
|
||||
|
||||
a.muted = true
|
||||
a.loop = true
|
||||
a.onclick = (_e) => {
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
elem.onclick = (_e) => {
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (!selected || Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail: 'Please select a node first.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(selected)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
loadedCount++
|
||||
updateLoadingStatus()
|
||||
|
||||
return elem
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Fetch aborted')
|
||||
return null
|
||||
}
|
||||
console.error('Error loading image:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
imgs.push(a)
|
||||
}
|
||||
if (target !== undefined) {
|
||||
if (options.prepend) target.prepend(...imgs)
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < Object.entries(urls).length; i += BATCH_SIZE) {
|
||||
const batch = Object.entries(urls).slice(i, i + BATCH_SIZE)
|
||||
const loadedImages = await Promise.all(
|
||||
batch.map(([key, url]) => loadImage(key, url)),
|
||||
)
|
||||
|
||||
const validImages = loadedImages.filter((img) => img !== null)
|
||||
imgs.push(...validImages)
|
||||
|
||||
if (target) {
|
||||
target.append(...validImages)
|
||||
}
|
||||
}
|
||||
|
||||
return imgs
|
||||
|
||||
// return
|
||||
// const elem = currentMode === 'video' ? 'video' : 'img'
|
||||
|
||||
for (const [key, url] of Object.entries(urls)) {
|
||||
const a = makeElement(elem)
|
||||
a.src = url
|
||||
a.width = currentWidth
|
||||
|
||||
const selected = app.canvas.selected_nodes
|
||||
|
||||
if (currentMode === 'input') {
|
||||
a.onclick = (_e) => {
|
||||
// if (subfolder !== '') {
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Subfolder not supported',
|
||||
// detail: "The LoadImage node doesn't support subfolders",
|
||||
// life: 5000,
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
} else if (currentMode === 'output') {
|
||||
a.onclick = (_e) => {
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
return
|
||||
}
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
|
||||
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
|
||||
// if (subfolder !== '') {
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Subfolder not supported',
|
||||
// detail: "The LoadImage node doesn't support subfolders",
|
||||
// life: 5000,
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// app.extensionManager.toast.add({
|
||||
// severity: 'warn',
|
||||
// summary: 'Outputs not supported',
|
||||
// detail:
|
||||
// 'For now only inputs can be clicked to load the image on the active LoadImage node.',
|
||||
// life: 5000,
|
||||
// })
|
||||
}
|
||||
} else {
|
||||
a.autoplay = true
|
||||
|
||||
a.muted = true
|
||||
a.loop = true
|
||||
a.onclick = (_e) => {
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
imgs.push(a)
|
||||
}
|
||||
if (target !== undefined) {
|
||||
if (options.prepend) target.prepend(...imgs)
|
||||
else target.append(...imgs)
|
||||
}
|
||||
return imgs
|
||||
} finally {
|
||||
// Keep loading indicator visible for a moment after completion
|
||||
setTimeout(() => {
|
||||
if (target && loadingIndicator.parentNode === target) {
|
||||
loadingIndicator.remove()
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
// Helper function to extract workflow from image metadata
|
||||
async function extractWorkflowFromImage(blob) {
|
||||
// Implementation depends on how the workflow data is stored in the image
|
||||
// This is just a placeholder
|
||||
try {
|
||||
// You might need to use ExifReader or similar library to extract metadata
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to extract workflow:', error)
|
||||
return null
|
||||
}
|
||||
return imgs
|
||||
}
|
||||
|
||||
const getModes = async () => {
|
||||
@@ -205,7 +485,7 @@ const getModes = async () => {
|
||||
}
|
||||
const getUrls = async (subfolder) => {
|
||||
const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000
|
||||
console.log('Sidebar count', count)
|
||||
console.debug('Sidebar count', count)
|
||||
if (currentMode === 'video') {
|
||||
const output = await shared.runAction(
|
||||
'getUserVideos',
|
||||
@@ -228,6 +508,99 @@ const getUrls = async (subfolder) => {
|
||||
return output || {}
|
||||
}
|
||||
|
||||
const build_ui = async (el) => {
|
||||
if (el.parentNode) {
|
||||
el.parentNode.style.overflowY = 'clip'
|
||||
}
|
||||
|
||||
const allModes = await getModes()
|
||||
|
||||
const input_modes = allModes.input.map((m) => `input - ${m}`)
|
||||
const output_modes = allModes.output.map((m) => `output - ${m}`)
|
||||
|
||||
if (!userDirectories) {
|
||||
userDirectories = {
|
||||
input: allModes.input_root,
|
||||
output: allModes.output_root,
|
||||
}
|
||||
infoLogger('User directories', userDirectories)
|
||||
}
|
||||
// const urls = await getUrls()
|
||||
// const urls = await debouncedGetUrls(subfolder)
|
||||
|
||||
const cont = makeElement('div.mtb_sidebar')
|
||||
|
||||
contextMenu = new ContextMenu(cont)
|
||||
imgGrid = makeElement('div.mtb_img_grid')
|
||||
const selector = makeSelect(
|
||||
['input', 'output', 'video', ...output_modes, ...input_modes],
|
||||
currentMode,
|
||||
)
|
||||
|
||||
selector.addEventListener('change', async (e) => {
|
||||
let newMode = e.target.value
|
||||
let changed = false
|
||||
let newSub = ''
|
||||
if (newMode !== 'input' && newMode !== 'output') {
|
||||
if (newMode.startsWith('input - ')) {
|
||||
newSub = newMode.replace('input - ', '')
|
||||
newMode = 'input'
|
||||
} else if (newMode.startsWith('output - ')) {
|
||||
newSub = newMode.replace('output - ', '')
|
||||
newMode = 'output'
|
||||
}
|
||||
}
|
||||
changed = newMode !== currentMode || newSub !== subfolder
|
||||
currentMode = newMode
|
||||
subfolder = newSub
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
// const urls = await getUrls(subfolder)
|
||||
debouncedGetUrls(subfolder)
|
||||
// if (urls) {
|
||||
// loaded_images = getImgsFromUrls(urls, imgGrid)
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const imgTools = makeElement('div.mtb_tools')
|
||||
const orderSelect = makeSelect(
|
||||
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
|
||||
currentSort,
|
||||
)
|
||||
|
||||
orderSelect.addEventListener('change', async (e) => {
|
||||
const newSort = e.target.value
|
||||
const changed = newSort !== currentSort
|
||||
currentSort = newSort
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
// const urls = await getUrls(subfolder)
|
||||
// const urls = debouncedGetUrls(subfolder)
|
||||
// const urls = await getUrls(subfolder)
|
||||
debouncedGetUrls(subfolder)
|
||||
// if (urls) {
|
||||
// loaded_images = getImgsFromUrls(urls, imgGrid)
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
|
||||
imgTools.appendChild(orderSelect)
|
||||
imgTools.appendChild(sizeSlider)
|
||||
|
||||
loaded_images = getImgsFromUrls(urls, imgGrid)
|
||||
// infoLogger({ loaded_images })
|
||||
|
||||
sizeSlider.addEventListener('input', (e) => {
|
||||
currentWidth = e.target.value
|
||||
for (const img of loaded_images) {
|
||||
img.style.width = `${e.target.value}px`
|
||||
}
|
||||
})
|
||||
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
|
||||
}
|
||||
|
||||
//NOTE: do not load if using the old ui
|
||||
if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||
// NOTE: removed this for now since I'm not actually exposing anything a client
|
||||
@@ -247,8 +620,8 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||
// },
|
||||
init: () => {
|
||||
let handle
|
||||
const version = window?.__COMFYUI_FRONTEND_VERSION__
|
||||
console.log(`%c ${version}`, 'background: orange; color: white;')
|
||||
// const version = window?.__COMFYUI_FRONTEND_VERSION__
|
||||
// console.log(`%c ${version}`, 'background: orange; color: white;')
|
||||
|
||||
ensureMTBStyles()
|
||||
|
||||
@@ -320,81 +693,9 @@ if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||
handle = undefined
|
||||
}
|
||||
|
||||
if (el.parentNode) {
|
||||
el.parentNode.style.overflowY = 'clip'
|
||||
if (!loaded_images) {
|
||||
await build_ui(el)
|
||||
}
|
||||
|
||||
const allModes = await getModes()
|
||||
const input_modes = allModes.input.map((m) => `input - ${m}`)
|
||||
const output_modes = allModes.output.map((m) => `output - ${m}`)
|
||||
const urls = await getUrls()
|
||||
let imgs = {}
|
||||
|
||||
const cont = makeElement('div.mtb_sidebar')
|
||||
|
||||
const imgGrid = makeElement('div.mtb_img_grid')
|
||||
const selector = makeSelect(
|
||||
['input', 'output', 'video', ...output_modes, ...input_modes],
|
||||
currentMode,
|
||||
)
|
||||
|
||||
selector.addEventListener('change', async (e) => {
|
||||
let newMode = e.target.value
|
||||
let changed = false
|
||||
let newSub = ''
|
||||
if (newMode !== 'input' && newMode !== 'output') {
|
||||
if (newMode.startsWith('input - ')) {
|
||||
newSub = newMode.replace('input - ', '')
|
||||
newMode = 'input'
|
||||
} else if (newMode.startsWith('output - ')) {
|
||||
newSub = newMode.replace('output - ', '')
|
||||
newMode = 'output'
|
||||
}
|
||||
}
|
||||
changed = newMode !== currentMode || newSub !== subfolder
|
||||
currentMode = newMode
|
||||
subfolder = newSub
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
const urls = await getUrls(subfolder)
|
||||
if (urls) {
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const imgTools = makeElement('div.mtb_tools')
|
||||
const orderSelect = makeSelect(
|
||||
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
|
||||
currentSort,
|
||||
)
|
||||
|
||||
orderSelect.addEventListener('change', async (e) => {
|
||||
const newSort = e.target.value
|
||||
const changed = newSort !== currentSort
|
||||
currentSort = newSort
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
const urls = await getUrls(subfolder)
|
||||
if (urls) {
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
|
||||
imgTools.appendChild(orderSelect)
|
||||
imgTools.appendChild(sizeSlider)
|
||||
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
|
||||
sizeSlider.addEventListener('input', (e) => {
|
||||
currentWidth = e.target.value
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${e.target.value}px`
|
||||
}
|
||||
})
|
||||
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
|
||||
app.api.addEventListener('status', async () => {
|
||||
if (currentMode !== 'output') return
|
||||
updateOutputsGrid()
|
||||
|
||||
@@ -174,16 +174,101 @@ export const ensureMTBStyles = () => {
|
||||
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
|
||||
background-color: ${S.accent};
|
||||
}
|
||||
`
|
||||
const contextMenus = `
|
||||
.mtb_context_menu {
|
||||
position: fixed;
|
||||
background: var(--comfy-input-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px 0;
|
||||
min-width: 150px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.mtb-context-menu-item {
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mtb-context-menu-item:hover {
|
||||
background: var(--comfy-input-hover);
|
||||
}
|
||||
|
||||
.mtb-loading-indicator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--comfy-input-bg);
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
z-index: 100;
|
||||
}
|
||||
`
|
||||
addNamedStyleSheet(
|
||||
'mtb_ui',
|
||||
`
|
||||
${common}
|
||||
${inputs}
|
||||
${contextMenus}
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
export class ContextMenu {
|
||||
constructor(parent) {
|
||||
this.menu = makeElement('div.mtb_context_menu', { display: 'none' })
|
||||
|
||||
const body = parent || document.body
|
||||
|
||||
body.appendChild(this.menu)
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.menu.contains(e.target)) {
|
||||
this.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
show(x, y, items, context) {
|
||||
this.menu.innerHTML = ''
|
||||
for (const item of items) {
|
||||
const menuItem = makeElement('div.mtb-context-menu-item')
|
||||
if (item.icon) {
|
||||
const icon = makeElement(`i.${item.icon}`)
|
||||
menuItem.appendChild(icon)
|
||||
}
|
||||
menuItem.appendChild(document.createTextNode(item.label))
|
||||
menuItem.onclick = () => {
|
||||
item.action(context)
|
||||
this.hide()
|
||||
}
|
||||
this.menu.appendChild(menuItem)
|
||||
}
|
||||
|
||||
this.menu.style.display = 'block'
|
||||
const rect = this.menu.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
x = Math.min(x, viewportWidth - rect.width)
|
||||
y = Math.min(y, viewportHeight - rect.height)
|
||||
|
||||
this.menu.style.left = `${x}px`
|
||||
this.menu.style.top = `${y}px`
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.menu.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an element with a div
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user