mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2026-09-02 13:07:07 +08:00
feat: add mobile interface for touch-optimized workflow management
- Implement responsive mobile-first UI with card-based workflow pipeline - Add touch-native interactions: tap-to-connect, long-press context menus - Create graph linearization engine to convert 2D workflows to 1D sequences - Add WebSocket/HTTP API client for real-time backend communication - Implement mobile route handler at /mobile with static file serving - Add comprehensive test suite with pytest for mobile interface validation - Support node editing, execution monitoring, and workflow management - Include responsive design with dark mode and gesture support The mobile interface transforms ComfyUI's 2D canvas into a scrollable pipeline optimized for mobile devices, enabling full workflow creation and execution on phones and tablets. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5612670ee4
commit
e8fa85ce5e
19
server.py
19
server.py
@ -711,6 +711,18 @@ class PromptServer():
|
|||||||
|
|
||||||
return web.Response(status=200)
|
return web.Response(status=200)
|
||||||
|
|
||||||
|
@routes.get("/mobile")
|
||||||
|
async def get_mobile(request):
|
||||||
|
mobile_path = os.path.join(os.path.dirname(__file__), "web_mobile", "index.html")
|
||||||
|
if os.path.exists(mobile_path):
|
||||||
|
response = web.FileResponse(mobile_path)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
return web.Response(status=404, text="Mobile interface not found")
|
||||||
|
|
||||||
async def setup(self):
|
async def setup(self):
|
||||||
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
||||||
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
||||||
@ -752,6 +764,13 @@ class PromptServer():
|
|||||||
web.static('/docs', embedded_docs_path)
|
web.static('/docs', embedded_docs_path)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Serve mobile interface static files
|
||||||
|
mobile_static_path = os.path.join(os.path.dirname(__file__), "web_mobile")
|
||||||
|
if os.path.exists(mobile_static_path):
|
||||||
|
self.app.add_routes([
|
||||||
|
web.static('/mobile_static', mobile_static_path)
|
||||||
|
])
|
||||||
|
|
||||||
self.app.add_routes([
|
self.app.add_routes([
|
||||||
web.static('/', self.web_root),
|
web.static('/', self.web_root),
|
||||||
])
|
])
|
||||||
|
|||||||
171
tests-unit/mobile_interface_test.py
Normal file
171
tests-unit/mobile_interface_test.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the mobile interface functionality.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileInterfaceFiles:
|
||||||
|
"""Test cases for mobile interface file structure and content."""
|
||||||
|
|
||||||
|
def test_mobile_html_structure(self):
|
||||||
|
"""Test that mobile HTML has required structure."""
|
||||||
|
mobile_html = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "web_mobile", "index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(mobile_html):
|
||||||
|
pytest.skip("Mobile HTML file not found")
|
||||||
|
|
||||||
|
with open(mobile_html, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check essential HTML structure
|
||||||
|
assert "<!DOCTYPE html>" in content
|
||||||
|
assert "<html" in content
|
||||||
|
assert "<head>" in content
|
||||||
|
assert "<body>" in content
|
||||||
|
assert "<title>ComfyUI Mobile</title>" in content
|
||||||
|
|
||||||
|
# Check required elements
|
||||||
|
assert 'id="app"' in content
|
||||||
|
assert 'id="workflowPipeline"' in content
|
||||||
|
assert 'id="queuePrompt"' in content
|
||||||
|
assert 'id="nodeDetailModal"' in content
|
||||||
|
assert 'class="action-bar"' in content
|
||||||
|
|
||||||
|
# Check CSS and JS includes
|
||||||
|
assert "/mobile_static/styles.css" in content
|
||||||
|
assert "/mobile_static/utils.js" in content
|
||||||
|
assert "/mobile_static/app.js" in content
|
||||||
|
assert "/mobile_static/mobile-interface.js" in content
|
||||||
|
|
||||||
|
def test_mobile_js_files_exist(self):
|
||||||
|
"""Test that all required JavaScript files exist."""
|
||||||
|
mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
|
||||||
|
|
||||||
|
if not os.path.exists(mobile_dir):
|
||||||
|
pytest.skip("Mobile directory not found")
|
||||||
|
|
||||||
|
required_js_files = [
|
||||||
|
"utils.js",
|
||||||
|
"graph-linearization.js",
|
||||||
|
"api-client.js",
|
||||||
|
"mobile-interface.js",
|
||||||
|
"app.js"
|
||||||
|
]
|
||||||
|
|
||||||
|
for js_file in required_js_files:
|
||||||
|
file_path = os.path.join(mobile_dir, js_file)
|
||||||
|
assert os.path.exists(file_path), f"Missing required JS file: {js_file}"
|
||||||
|
|
||||||
|
# Check file is not empty
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
assert len(content) > 0, f"JS file is empty: {js_file}"
|
||||||
|
|
||||||
|
def test_mobile_css_file_exists(self):
|
||||||
|
"""Test that the CSS file exists and has content."""
|
||||||
|
css_file = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "web_mobile", "styles.css"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(css_file):
|
||||||
|
pytest.skip("CSS file not found")
|
||||||
|
|
||||||
|
with open(css_file, 'r') as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
|
||||||
|
assert len(content) > 0, "CSS file is empty"
|
||||||
|
|
||||||
|
# Check for essential CSS classes
|
||||||
|
assert ".header" in content
|
||||||
|
assert ".workflow-pipeline" in content
|
||||||
|
assert ".node-card" in content
|
||||||
|
assert ".action-bar" in content
|
||||||
|
assert ".modal" in content
|
||||||
|
|
||||||
|
# Check for mobile-specific CSS
|
||||||
|
assert "@media" in content, "No responsive CSS found"
|
||||||
|
assert "touch" in content.lower(), "No touch-specific CSS found"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileInterfaceJavaScript:
|
||||||
|
"""Test cases for mobile interface JavaScript functionality."""
|
||||||
|
|
||||||
|
def test_javascript_classes_defined(self):
|
||||||
|
"""Test that required JavaScript classes are defined."""
|
||||||
|
js_files = {
|
||||||
|
"utils.js": ["Utils"],
|
||||||
|
"graph-linearization.js": ["GraphLinearization"],
|
||||||
|
"api-client.js": ["ComfyUIAPIClient"],
|
||||||
|
"mobile-interface.js": ["MobileInterface"],
|
||||||
|
"app.js": ["MobileApp"]
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
|
||||||
|
|
||||||
|
for js_file, expected_classes in js_files.items():
|
||||||
|
file_path = os.path.join(mobile_dir, js_file)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
pytest.skip(f"JS file not found: {js_file}")
|
||||||
|
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
for class_name in expected_classes:
|
||||||
|
assert f"class {class_name}" in content, \
|
||||||
|
f"Class {class_name} not found in {js_file}"
|
||||||
|
|
||||||
|
def test_javascript_exports(self):
|
||||||
|
"""Test that JavaScript files export classes to window."""
|
||||||
|
js_files = {
|
||||||
|
"utils.js": "Utils",
|
||||||
|
"graph-linearization.js": "GraphLinearization",
|
||||||
|
"api-client.js": "ComfyUIAPIClient",
|
||||||
|
"mobile-interface.js": "MobileInterface"
|
||||||
|
}
|
||||||
|
|
||||||
|
mobile_dir = os.path.join(os.path.dirname(__file__), "..", "web_mobile")
|
||||||
|
|
||||||
|
for js_file, class_name in js_files.items():
|
||||||
|
file_path = os.path.join(mobile_dir, js_file)
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
pytest.skip(f"JS file not found: {js_file}")
|
||||||
|
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
assert f"window.{class_name}" in content, \
|
||||||
|
f"Class {class_name} not exported to window in {js_file}"
|
||||||
|
|
||||||
|
def test_mobile_interface_initialization(self):
|
||||||
|
"""Test that mobile interface has proper initialization."""
|
||||||
|
app_js = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "web_mobile", "app.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(app_js):
|
||||||
|
pytest.skip("app.js not found")
|
||||||
|
|
||||||
|
with open(app_js, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for DOM ready event listener
|
||||||
|
assert "DOMContentLoaded" in content
|
||||||
|
|
||||||
|
# Check for MobileApp initialization
|
||||||
|
assert "new MobileApp()" in content
|
||||||
|
|
||||||
|
# Check for error handling
|
||||||
|
assert "try" in content and "catch" in content
|
||||||
|
|
||||||
|
# Check for proper cleanup
|
||||||
|
assert "beforeunload" in content
|
||||||
|
assert "cleanup" in content
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__])
|
||||||
472
web_mobile/api-client.js
Normal file
472
web_mobile/api-client.js
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
/**
|
||||||
|
* ComfyUI Mobile Interface - API Client
|
||||||
|
* Handles communication with ComfyUI backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ComfyUIAPIClient {
|
||||||
|
constructor() {
|
||||||
|
this.baseURL = window.location.origin;
|
||||||
|
this.websocket = null;
|
||||||
|
this.clientId = this.generateClientId();
|
||||||
|
this.isConnected = false;
|
||||||
|
this.eventListeners = new Map();
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 5;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
|
||||||
|
// Initialize WebSocket connection
|
||||||
|
this.initializeWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique client ID
|
||||||
|
* @returns {string} Client ID
|
||||||
|
*/
|
||||||
|
generateClientId() {
|
||||||
|
return 'mobile_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WebSocket connection
|
||||||
|
*/
|
||||||
|
initializeWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsURL = `${protocol}//${window.location.host}/ws?clientId=${this.clientId}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.websocket = new WebSocket(wsURL);
|
||||||
|
this.setupWebSocketHandlers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket initialization error:', error);
|
||||||
|
this.emit('connection_error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup WebSocket event handlers
|
||||||
|
*/
|
||||||
|
setupWebSocketHandlers() {
|
||||||
|
this.websocket.onopen = () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.emit('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
this.handleWebSocketMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WebSocket message parse error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onclose = (event) => {
|
||||||
|
console.log('WebSocket disconnected:', event.code, event.reason);
|
||||||
|
this.isConnected = false;
|
||||||
|
this.emit('disconnected');
|
||||||
|
|
||||||
|
// Attempt to reconnect if not a clean close
|
||||||
|
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
setTimeout(() => this.reconnect(), this.reconnectDelay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.emit('connection_error', error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming WebSocket messages
|
||||||
|
* @param {Object} data - Message data
|
||||||
|
*/
|
||||||
|
handleWebSocketMessage(data) {
|
||||||
|
const { type, data: messageData } = data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'status':
|
||||||
|
this.emit('status_update', messageData);
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
this.emit('progress_update', messageData);
|
||||||
|
break;
|
||||||
|
case 'executing':
|
||||||
|
this.emit('node_executing', messageData);
|
||||||
|
break;
|
||||||
|
case 'executed':
|
||||||
|
this.emit('node_executed', messageData);
|
||||||
|
break;
|
||||||
|
case 'execution_start':
|
||||||
|
this.emit('execution_start', messageData);
|
||||||
|
break;
|
||||||
|
case 'execution_success':
|
||||||
|
this.emit('execution_success', messageData);
|
||||||
|
break;
|
||||||
|
case 'execution_error':
|
||||||
|
this.emit('execution_error', messageData);
|
||||||
|
break;
|
||||||
|
case 'execution_cached':
|
||||||
|
this.emit('execution_cached', messageData);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Unknown WebSocket message type:', type, messageData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to WebSocket
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||||
|
|
||||||
|
this.initializeWebSocket();
|
||||||
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Exponential backoff
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add event listener
|
||||||
|
* @param {string} event - Event name
|
||||||
|
* @param {Function} callback - Callback function
|
||||||
|
*/
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.eventListeners.get(event).push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listener
|
||||||
|
* @param {string} event - Event name
|
||||||
|
* @param {Function} callback - Callback function
|
||||||
|
*/
|
||||||
|
off(event, callback) {
|
||||||
|
if (!this.eventListeners.has(event)) return;
|
||||||
|
|
||||||
|
const callbacks = this.eventListeners.get(event);
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit event to listeners
|
||||||
|
* @param {string} event - Event name
|
||||||
|
* @param {*} data - Event data
|
||||||
|
*/
|
||||||
|
emit(event, data) {
|
||||||
|
if (!this.eventListeners.has(event)) return;
|
||||||
|
|
||||||
|
this.eventListeners.get(event).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Event callback error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make HTTP API request
|
||||||
|
* @param {string} endpoint - API endpoint
|
||||||
|
* @param {Object} options - Request options
|
||||||
|
* @returns {Promise} Response promise
|
||||||
|
*/
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const url = `${this.baseURL}${endpoint}`;
|
||||||
|
const defaultOptions = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestOptions = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, requestOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
return await response.json();
|
||||||
|
} else {
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API request error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get system information
|
||||||
|
* @returns {Promise<Object>} System info
|
||||||
|
*/
|
||||||
|
async getSystemInfo() {
|
||||||
|
return await this.request('/system_stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available node types
|
||||||
|
* @returns {Promise<Object>} Node types
|
||||||
|
*/
|
||||||
|
async getNodeTypes() {
|
||||||
|
return await this.request('/object_info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue status
|
||||||
|
* @returns {Promise<Object>} Queue status
|
||||||
|
*/
|
||||||
|
async getQueueStatus() {
|
||||||
|
return await this.request('/queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get execution history
|
||||||
|
* @returns {Promise<Object>} Execution history
|
||||||
|
*/
|
||||||
|
async getHistory() {
|
||||||
|
return await this.request('/history');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a workflow prompt
|
||||||
|
* @param {Object} workflow - Workflow object
|
||||||
|
* @param {Object} options - Queue options
|
||||||
|
* @returns {Promise<Object>} Queue response
|
||||||
|
*/
|
||||||
|
async queuePrompt(workflow, options = {}) {
|
||||||
|
const payload = {
|
||||||
|
prompt: workflow,
|
||||||
|
client_id: this.clientId,
|
||||||
|
extra_data: options.extra_data || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.number !== undefined) {
|
||||||
|
payload.number = options.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.front !== undefined) {
|
||||||
|
payload.front = options.front;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.request('/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all queued prompts
|
||||||
|
* @returns {Promise<Object>} Cancel response
|
||||||
|
*/
|
||||||
|
async cancelQueue() {
|
||||||
|
return await this.request('/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ delete: ['*'] })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel specific prompt
|
||||||
|
* @param {string} promptId - Prompt ID to cancel
|
||||||
|
* @returns {Promise<Object>} Cancel response
|
||||||
|
*/
|
||||||
|
async cancelPrompt(promptId) {
|
||||||
|
return await this.request('/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ delete: [promptId] })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models
|
||||||
|
* @returns {Promise<Object>} Available models
|
||||||
|
*/
|
||||||
|
async getModels() {
|
||||||
|
return await this.request('/api/models');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file
|
||||||
|
* @param {File} file - File to upload
|
||||||
|
* @param {string} type - File type (input, temp, etc.)
|
||||||
|
* @returns {Promise<Object>} Upload response
|
||||||
|
*/
|
||||||
|
async uploadFile(file, type = 'input') {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('type', type);
|
||||||
|
|
||||||
|
return await this.request('/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {} // Let browser set Content-Type for FormData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file URL
|
||||||
|
* @param {string} filename - File name
|
||||||
|
* @param {string} type - File type
|
||||||
|
* @returns {string} File URL
|
||||||
|
*/
|
||||||
|
getFileURL(filename, type = 'input') {
|
||||||
|
return `${this.baseURL}/view?filename=${encodeURIComponent(filename)}&type=${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save workflow to server
|
||||||
|
* @param {Object} workflow - Workflow object
|
||||||
|
* @param {string} filename - File name
|
||||||
|
* @returns {Promise<Object>} Save response
|
||||||
|
*/
|
||||||
|
async saveWorkflow(workflow, filename) {
|
||||||
|
return await this.request('/api/workflows', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: filename,
|
||||||
|
workflow: workflow
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load workflow from server
|
||||||
|
* @param {string} filename - File name
|
||||||
|
* @returns {Promise<Object>} Workflow object
|
||||||
|
*/
|
||||||
|
async loadWorkflow(filename) {
|
||||||
|
return await this.request(`/api/workflows/${encodeURIComponent(filename)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available workflows
|
||||||
|
* @returns {Promise<Array>} Available workflows
|
||||||
|
*/
|
||||||
|
async getWorkflows() {
|
||||||
|
return await this.request('/api/workflows');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete workflow
|
||||||
|
* @param {string} filename - File name
|
||||||
|
* @returns {Promise<Object>} Delete response
|
||||||
|
*/
|
||||||
|
async deleteWorkflow(filename) {
|
||||||
|
return await this.request(`/api/workflows/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get embeddings
|
||||||
|
* @returns {Promise<Object>} Available embeddings
|
||||||
|
*/
|
||||||
|
async getEmbeddings() {
|
||||||
|
return await this.request('/embeddings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get extension list
|
||||||
|
* @returns {Promise<Object>} Available extensions
|
||||||
|
*/
|
||||||
|
async getExtensions() {
|
||||||
|
return await this.request('/extensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interrupt current execution
|
||||||
|
* @returns {Promise<Object>} Interrupt response
|
||||||
|
*/
|
||||||
|
async interrupt() {
|
||||||
|
return await this.request('/interrupt', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free memory
|
||||||
|
* @returns {Promise<Object>} Free memory response
|
||||||
|
*/
|
||||||
|
async freeMemory() {
|
||||||
|
return await this.request('/free', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device stats
|
||||||
|
* @returns {Promise<Object>} Device statistics
|
||||||
|
*/
|
||||||
|
async getDeviceStats() {
|
||||||
|
return await this.request('/api/device_stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate workflow
|
||||||
|
* @param {Object} workflow - Workflow to validate
|
||||||
|
* @returns {Promise<Object>} Validation result
|
||||||
|
*/
|
||||||
|
async validateWorkflow(workflow) {
|
||||||
|
return await this.request('/api/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ workflow })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom node info
|
||||||
|
* @returns {Promise<Object>} Custom node information
|
||||||
|
*/
|
||||||
|
async getCustomNodeInfo() {
|
||||||
|
return await this.request('/api/nodes');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected
|
||||||
|
* @returns {boolean} Connection status
|
||||||
|
*/
|
||||||
|
isWebSocketConnected() {
|
||||||
|
return this.isConnected && this.websocket && this.websocket.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close connections
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.websocket) {
|
||||||
|
this.websocket.close(1000, 'User disconnected');
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping server to check connectivity
|
||||||
|
* @returns {Promise<boolean>} True if server is responsive
|
||||||
|
*/
|
||||||
|
async ping() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseURL}/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other files
|
||||||
|
window.ComfyUIAPIClient = ComfyUIAPIClient;
|
||||||
491
web_mobile/app.js
Normal file
491
web_mobile/app.js
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
/**
|
||||||
|
* ComfyUI Mobile Interface - Main Application
|
||||||
|
* Initializes and manages the mobile interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
class MobileApp {
|
||||||
|
constructor() {
|
||||||
|
this.apiClient = null;
|
||||||
|
this.mobileInterface = null;
|
||||||
|
this.isInitialized = false;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the mobile application
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
|
console.log('Initializing ComfyUI Mobile Interface...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
this.showLoadingState();
|
||||||
|
|
||||||
|
// Initialize API client
|
||||||
|
this.apiClient = new ComfyUIAPIClient();
|
||||||
|
|
||||||
|
// Wait for initial connection
|
||||||
|
await this.waitForConnection();
|
||||||
|
|
||||||
|
// Initialize mobile interface
|
||||||
|
this.mobileInterface = new MobileInterface(this.apiClient);
|
||||||
|
|
||||||
|
// Setup global error handling
|
||||||
|
this.setupGlobalErrorHandling();
|
||||||
|
|
||||||
|
// Setup performance monitoring
|
||||||
|
this.setupPerformanceMonitoring();
|
||||||
|
|
||||||
|
// Setup viewport handling
|
||||||
|
this.setupViewportHandling();
|
||||||
|
|
||||||
|
// Initialize service worker if available
|
||||||
|
this.initializeServiceWorker();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('ComfyUI Mobile Interface initialized successfully');
|
||||||
|
|
||||||
|
// Hide loading state
|
||||||
|
this.hideLoadingState();
|
||||||
|
|
||||||
|
// Log initialization time
|
||||||
|
const initTime = Date.now() - this.startTime;
|
||||||
|
console.log(`Mobile interface initialized in ${initTime}ms`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize mobile interface:', error);
|
||||||
|
this.showErrorState(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for API connection
|
||||||
|
* @returns {Promise} Connection promise
|
||||||
|
*/
|
||||||
|
waitForConnection() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error('Connection timeout'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const checkConnection = () => {
|
||||||
|
if (this.apiClient.isWebSocketConnected()) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
// Listen for connection event
|
||||||
|
this.apiClient.on('connected', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.apiClient.on('connection_error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately and then wait for events
|
||||||
|
checkConnection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading state
|
||||||
|
*/
|
||||||
|
showLoadingState() {
|
||||||
|
document.body.classList.add('loading');
|
||||||
|
|
||||||
|
// Add loading overlay if not exists
|
||||||
|
if (!document.getElementById('loadingOverlay')) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'loadingOverlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Loading ComfyUI Mobile</h3>
|
||||||
|
<p>Connecting to server...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Add loading styles
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#loadingOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.loading-content h3 {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.loading-content p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide loading state
|
||||||
|
*/
|
||||||
|
hideLoadingState() {
|
||||||
|
document.body.classList.remove('loading');
|
||||||
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => overlay.remove(), 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error state
|
||||||
|
* @param {Error} error - Error object
|
||||||
|
*/
|
||||||
|
showErrorState(error) {
|
||||||
|
const errorOverlay = document.createElement('div');
|
||||||
|
errorOverlay.id = 'errorOverlay';
|
||||||
|
errorOverlay.innerHTML = `
|
||||||
|
<div class="error-content">
|
||||||
|
<div class="error-icon">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Connection Error</h3>
|
||||||
|
<p>${error.message}</p>
|
||||||
|
<button id="retryBtn" class="btn-primary">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button id="desktopBtn" class="btn-secondary">
|
||||||
|
<i class="fas fa-desktop"></i>
|
||||||
|
Switch to Desktop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add error styles
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#errorOverlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.error-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--error-color);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.error-content h3 {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.error-content p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.error-content button {
|
||||||
|
margin: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Replace loading overlay
|
||||||
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||||
|
if (loadingOverlay) {
|
||||||
|
loadingOverlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(errorOverlay);
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.getElementById('retryBtn').addEventListener('click', () => {
|
||||||
|
errorOverlay.remove();
|
||||||
|
this.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('desktopBtn').addEventListener('click', () => {
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup global error handling
|
||||||
|
*/
|
||||||
|
setupGlobalErrorHandling() {
|
||||||
|
// Handle uncaught errors
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
console.error('Global error:', event.error);
|
||||||
|
Utils.showToast('An error occurred: ' + event.error.message, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
console.error('Unhandled promise rejection:', event.reason);
|
||||||
|
Utils.showToast('An error occurred: ' + event.reason, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle API errors
|
||||||
|
this.apiClient.on('connection_error', (error) => {
|
||||||
|
console.error('API connection error:', error);
|
||||||
|
Utils.showToast('Connection error: ' + error.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup performance monitoring
|
||||||
|
*/
|
||||||
|
setupPerformanceMonitoring() {
|
||||||
|
// Monitor memory usage
|
||||||
|
if ('memory' in performance) {
|
||||||
|
setInterval(() => {
|
||||||
|
const memory = performance.memory;
|
||||||
|
if (memory.usedJSHeapSize > memory.jsHeapSizeLimit * 0.9) {
|
||||||
|
console.warn('High memory usage detected');
|
||||||
|
}
|
||||||
|
}, 30000); // Check every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor long tasks
|
||||||
|
if ('PerformanceObserver' in window) {
|
||||||
|
try {
|
||||||
|
const observer = new PerformanceObserver((list) => {
|
||||||
|
const entries = list.getEntries();
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.duration > 50) { // Tasks longer than 50ms
|
||||||
|
console.warn('Long task detected:', entry.duration + 'ms');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe({ entryTypes: ['longtask'] });
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Long task monitoring not supported');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup viewport handling
|
||||||
|
*/
|
||||||
|
setupViewportHandling() {
|
||||||
|
// Handle orientation changes
|
||||||
|
window.addEventListener('orientationchange', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Refresh layout after orientation change
|
||||||
|
if (this.mobileInterface) {
|
||||||
|
this.mobileInterface.refreshWorkflow();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle viewport changes (e.g., virtual keyboard)
|
||||||
|
const viewportHandler = Utils.debounce(() => {
|
||||||
|
const vh = window.innerHeight * 0.01;
|
||||||
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
window.addEventListener('resize', viewportHandler);
|
||||||
|
window.addEventListener('orientationchange', viewportHandler);
|
||||||
|
|
||||||
|
// Initial viewport setup
|
||||||
|
viewportHandler();
|
||||||
|
|
||||||
|
// Handle visibility changes
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
// App is hidden
|
||||||
|
console.log('App hidden');
|
||||||
|
} else {
|
||||||
|
// App is visible
|
||||||
|
console.log('App visible');
|
||||||
|
if (this.mobileInterface) {
|
||||||
|
this.mobileInterface.refreshWorkflow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize service worker
|
||||||
|
*/
|
||||||
|
async initializeServiceWorker() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
console.log('Service worker registered:', registration);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Service worker registration failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup keyboard shortcuts
|
||||||
|
*/
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Ctrl/Cmd + S to save
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.mobileInterface) {
|
||||||
|
this.mobileInterface.showSaveWorkflowDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + O to open
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'o') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.mobileInterface) {
|
||||||
|
this.mobileInterface.showLoadWorkflowDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to cancel connection
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (this.mobileInterface && this.mobileInterface.connectionState.active) {
|
||||||
|
this.mobileInterface.cancelConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space to queue prompt
|
||||||
|
if (e.key === ' ' && !e.target.matches('input, textarea')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.mobileInterface) {
|
||||||
|
this.mobileInterface.queueCurrentWorkflow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app statistics
|
||||||
|
* @returns {Object} App statistics
|
||||||
|
*/
|
||||||
|
getStatistics() {
|
||||||
|
return {
|
||||||
|
initialized: this.isInitialized,
|
||||||
|
initTime: Date.now() - this.startTime,
|
||||||
|
connected: this.apiClient ? this.apiClient.isWebSocketConnected() : false,
|
||||||
|
nodesCount: this.mobileInterface ? this.mobileInterface.linearizedNodes.length : 0,
|
||||||
|
memory: performance.memory ? {
|
||||||
|
used: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024),
|
||||||
|
total: Math.round(performance.memory.totalJSHeapSize / 1024 / 1024),
|
||||||
|
limit: Math.round(performance.memory.jsHeapSizeLimit / 1024 / 1024)
|
||||||
|
} : null,
|
||||||
|
viewport: Utils.getViewportSize(),
|
||||||
|
touchDevice: Utils.isTouchDevice()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup application
|
||||||
|
*/
|
||||||
|
cleanup() {
|
||||||
|
if (this.apiClient) {
|
||||||
|
this.apiClient.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
window.removeEventListener('error', this.handleError);
|
||||||
|
window.removeEventListener('unhandledrejection', this.handleRejection);
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('Mobile application cleaned up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the mobile app when DOM is ready
|
||||||
|
let mobileApp;
|
||||||
|
let mobileInterface;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Check if this is a mobile device or small screen
|
||||||
|
const isMobile = Utils.isTouchDevice() || window.innerWidth < 768;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
console.log('Mobile device detected, initializing mobile interface...');
|
||||||
|
} else {
|
||||||
|
console.log('Desktop device detected, mobile interface available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
mobileApp = new MobileApp();
|
||||||
|
await mobileApp.initialize();
|
||||||
|
|
||||||
|
// Make mobile interface globally available for debugging
|
||||||
|
mobileInterface = mobileApp.mobileInterface;
|
||||||
|
|
||||||
|
// Expose to global scope for debugging
|
||||||
|
window.mobileApp = mobileApp;
|
||||||
|
window.mobileInterface = mobileInterface;
|
||||||
|
|
||||||
|
// Setup keyboard shortcuts
|
||||||
|
mobileApp.setupKeyboardShortcuts();
|
||||||
|
|
||||||
|
// Log app statistics
|
||||||
|
console.log('App Statistics:', mobileApp.getStatistics());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle page unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (mobileApp) {
|
||||||
|
mobileApp.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle app install prompt (PWA)
|
||||||
|
let deferredPrompt;
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
|
||||||
|
// Show install button/banner
|
||||||
|
Utils.showToast('Install ComfyUI Mobile for offline access', 'info', 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle successful app installation
|
||||||
|
window.addEventListener('appinstalled', (evt) => {
|
||||||
|
console.log('App installed successfully');
|
||||||
|
Utils.showToast('ComfyUI Mobile installed successfully', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other contexts
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { MobileApp, Utils, GraphLinearization, ComfyUIAPIClient, MobileInterface };
|
||||||
|
}
|
||||||
562
web_mobile/graph-linearization.js
Normal file
562
web_mobile/graph-linearization.js
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
/**
|
||||||
|
* ComfyUI Mobile Interface - Graph Linearization
|
||||||
|
* Converts the 2D workflow graph into a linear execution sequence
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GraphLinearization {
|
||||||
|
constructor() {
|
||||||
|
this.nodeCache = new Map();
|
||||||
|
this.dependencyCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linearize a workflow graph using topological sort
|
||||||
|
* @param {Object} workflow - The workflow object containing nodes
|
||||||
|
* @returns {Array} Array of nodes in execution order
|
||||||
|
*/
|
||||||
|
linearizeWorkflow(workflow) {
|
||||||
|
if (!workflow || !workflow.nodes || Object.keys(workflow.nodes).length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear caches
|
||||||
|
this.nodeCache.clear();
|
||||||
|
this.dependencyCache.clear();
|
||||||
|
|
||||||
|
// Build dependency graph
|
||||||
|
const dependencyGraph = this.buildDependencyGraph(workflow);
|
||||||
|
|
||||||
|
// Perform topological sort
|
||||||
|
const sortedNodes = this.topologicalSort(dependencyGraph);
|
||||||
|
|
||||||
|
// Convert to linear array with additional metadata
|
||||||
|
return this.enrichLinearizedNodes(sortedNodes, workflow);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Graph linearization error:', error);
|
||||||
|
// Fallback to simple node order
|
||||||
|
return this.fallbackLinearization(workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build dependency graph from workflow
|
||||||
|
* @param {Object} workflow - The workflow object
|
||||||
|
* @returns {Map} Dependency graph
|
||||||
|
*/
|
||||||
|
buildDependencyGraph(workflow) {
|
||||||
|
const graph = new Map();
|
||||||
|
const nodes = workflow.nodes;
|
||||||
|
|
||||||
|
// Initialize all nodes in the graph
|
||||||
|
for (const nodeId in nodes) {
|
||||||
|
graph.set(nodeId, {
|
||||||
|
node: nodes[nodeId],
|
||||||
|
dependencies: new Set(),
|
||||||
|
dependents: new Set()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependencies based on connections
|
||||||
|
for (const nodeId in nodes) {
|
||||||
|
const node = nodes[nodeId];
|
||||||
|
|
||||||
|
if (node.inputs) {
|
||||||
|
for (const inputName in node.inputs) {
|
||||||
|
const input = node.inputs[inputName];
|
||||||
|
|
||||||
|
// Check if input is connected to another node
|
||||||
|
if (Array.isArray(input) && input.length >= 2) {
|
||||||
|
const sourceNodeId = input[0];
|
||||||
|
const sourceOutputIndex = input[1];
|
||||||
|
|
||||||
|
if (graph.has(sourceNodeId)) {
|
||||||
|
// Add dependency
|
||||||
|
graph.get(nodeId).dependencies.add(sourceNodeId);
|
||||||
|
graph.get(sourceNodeId).dependents.add(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform topological sort on dependency graph
|
||||||
|
* @param {Map} graph - Dependency graph
|
||||||
|
* @returns {Array} Sorted node IDs
|
||||||
|
*/
|
||||||
|
topologicalSort(graph) {
|
||||||
|
const result = [];
|
||||||
|
const inDegree = new Map();
|
||||||
|
const queue = [];
|
||||||
|
|
||||||
|
// Calculate in-degrees
|
||||||
|
for (const [nodeId, nodeData] of graph) {
|
||||||
|
inDegree.set(nodeId, nodeData.dependencies.size);
|
||||||
|
if (nodeData.dependencies.size === 0) {
|
||||||
|
queue.push(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process nodes with no dependencies first
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentNodeId = queue.shift();
|
||||||
|
result.push(currentNodeId);
|
||||||
|
|
||||||
|
// Reduce in-degree of dependent nodes
|
||||||
|
const currentNode = graph.get(currentNodeId);
|
||||||
|
for (const dependentId of currentNode.dependents) {
|
||||||
|
const newInDegree = inDegree.get(dependentId) - 1;
|
||||||
|
inDegree.set(dependentId, newInDegree);
|
||||||
|
|
||||||
|
if (newInDegree === 0) {
|
||||||
|
queue.push(dependentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cycles
|
||||||
|
if (result.length !== graph.size) {
|
||||||
|
console.warn('Cycle detected in workflow graph, using partial sort');
|
||||||
|
// Add remaining nodes
|
||||||
|
for (const [nodeId] of graph) {
|
||||||
|
if (!result.includes(nodeId)) {
|
||||||
|
result.push(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich linearized nodes with additional metadata
|
||||||
|
* @param {Array} sortedNodeIds - Sorted node IDs
|
||||||
|
* @param {Object} workflow - Original workflow
|
||||||
|
* @returns {Array} Enriched node array
|
||||||
|
*/
|
||||||
|
enrichLinearizedNodes(sortedNodeIds, workflow) {
|
||||||
|
const enrichedNodes = [];
|
||||||
|
const nodeTypes = this.getNodeTypes();
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedNodeIds.length; i++) {
|
||||||
|
const nodeId = sortedNodeIds[i];
|
||||||
|
const originalNode = workflow.nodes[nodeId];
|
||||||
|
|
||||||
|
if (!originalNode) continue;
|
||||||
|
|
||||||
|
const enrichedNode = {
|
||||||
|
id: nodeId,
|
||||||
|
type: originalNode.type || 'unknown',
|
||||||
|
title: this.getNodeTitle(originalNode),
|
||||||
|
executionOrder: i + 1,
|
||||||
|
inputs: this.processNodeInputs(originalNode, workflow.nodes),
|
||||||
|
outputs: this.processNodeOutputs(originalNode),
|
||||||
|
widgets: this.processNodeWidgets(originalNode),
|
||||||
|
status: 'idle',
|
||||||
|
category: this.getNodeCategory(originalNode.type, nodeTypes),
|
||||||
|
description: this.getNodeDescription(originalNode.type, nodeTypes),
|
||||||
|
position: originalNode.pos || [0, 0],
|
||||||
|
size: originalNode.size || [200, 100],
|
||||||
|
flags: originalNode.flags || {},
|
||||||
|
mode: originalNode.mode || 0,
|
||||||
|
originalNode: originalNode
|
||||||
|
};
|
||||||
|
|
||||||
|
enrichedNodes.push(enrichedNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process node inputs and resolve connections
|
||||||
|
* @param {Object} node - Node object
|
||||||
|
* @param {Object} allNodes - All nodes in the workflow
|
||||||
|
* @returns {Array} Processed inputs
|
||||||
|
*/
|
||||||
|
processNodeInputs(node, allNodes) {
|
||||||
|
const inputs = [];
|
||||||
|
|
||||||
|
if (node.inputs) {
|
||||||
|
for (const inputName in node.inputs) {
|
||||||
|
const input = node.inputs[inputName];
|
||||||
|
const inputInfo = {
|
||||||
|
name: inputName,
|
||||||
|
type: this.getInputType(node.type, inputName),
|
||||||
|
connected: false,
|
||||||
|
connection: null,
|
||||||
|
value: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if input is connected
|
||||||
|
if (Array.isArray(input) && input.length >= 2) {
|
||||||
|
const sourceNodeId = input[0];
|
||||||
|
const sourceOutputIndex = input[1];
|
||||||
|
const sourceNode = allNodes[sourceNodeId];
|
||||||
|
|
||||||
|
if (sourceNode) {
|
||||||
|
inputInfo.connected = true;
|
||||||
|
inputInfo.connection = {
|
||||||
|
sourceNodeId: sourceNodeId,
|
||||||
|
sourceNodeTitle: this.getNodeTitle(sourceNode),
|
||||||
|
sourceOutputIndex: sourceOutputIndex,
|
||||||
|
sourceOutputName: this.getOutputName(sourceNode, sourceOutputIndex)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Static value
|
||||||
|
inputInfo.value = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.push(inputInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process node outputs
|
||||||
|
* @param {Object} node - Node object
|
||||||
|
* @returns {Array} Processed outputs
|
||||||
|
*/
|
||||||
|
processNodeOutputs(node) {
|
||||||
|
const outputs = [];
|
||||||
|
const nodeType = node.type;
|
||||||
|
const outputInfo = this.getNodeOutputInfo(nodeType);
|
||||||
|
|
||||||
|
if (outputInfo && outputInfo.length > 0) {
|
||||||
|
outputInfo.forEach((output, index) => {
|
||||||
|
outputs.push({
|
||||||
|
name: output.name || `Output ${index + 1}`,
|
||||||
|
type: output.type || 'unknown',
|
||||||
|
index: index
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default output if no specific info available
|
||||||
|
outputs.push({
|
||||||
|
name: 'Output',
|
||||||
|
type: 'unknown',
|
||||||
|
index: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process node widgets (editable parameters)
|
||||||
|
* @param {Object} node - Node object
|
||||||
|
* @returns {Array} Processed widgets
|
||||||
|
*/
|
||||||
|
processNodeWidgets(node) {
|
||||||
|
const widgets = [];
|
||||||
|
|
||||||
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
|
const widgetInfo = this.getNodeWidgetInfo(node.type);
|
||||||
|
|
||||||
|
node.widgets_values.forEach((value, index) => {
|
||||||
|
const widget = {
|
||||||
|
name: widgetInfo[index]?.name || `Widget ${index + 1}`,
|
||||||
|
type: widgetInfo[index]?.type || 'text',
|
||||||
|
value: value,
|
||||||
|
index: index,
|
||||||
|
options: widgetInfo[index]?.options || {}
|
||||||
|
};
|
||||||
|
widgets.push(widget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node title for display
|
||||||
|
* @param {Object} node - Node object
|
||||||
|
* @returns {string} Node title
|
||||||
|
*/
|
||||||
|
getNodeTitle(node) {
|
||||||
|
if (node.title) return node.title;
|
||||||
|
if (node.type) return this.formatNodeType(node.type);
|
||||||
|
return 'Unknown Node';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format node type for display
|
||||||
|
* @param {string} type - Node type
|
||||||
|
* @returns {string} Formatted type
|
||||||
|
*/
|
||||||
|
formatNodeType(type) {
|
||||||
|
return type.replace(/([A-Z])/g, ' $1').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node category based on type
|
||||||
|
* @param {string} type - Node type
|
||||||
|
* @param {Object} nodeTypes - Node types definition
|
||||||
|
* @returns {string} Category
|
||||||
|
*/
|
||||||
|
getNodeCategory(type, nodeTypes) {
|
||||||
|
if (nodeTypes && nodeTypes[type] && nodeTypes[type].category) {
|
||||||
|
return nodeTypes[type].category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback categorization based on type name
|
||||||
|
if (type.includes('Load')) return 'loaders';
|
||||||
|
if (type.includes('Save')) return 'output';
|
||||||
|
if (type.includes('Sample')) return 'sampling';
|
||||||
|
if (type.includes('Encode') || type.includes('Decode')) return 'conditioning';
|
||||||
|
if (type.includes('Image')) return 'image';
|
||||||
|
if (type.includes('Model')) return 'models';
|
||||||
|
|
||||||
|
return 'misc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node description
|
||||||
|
* @param {string} type - Node type
|
||||||
|
* @param {Object} nodeTypes - Node types definition
|
||||||
|
* @returns {string} Description
|
||||||
|
*/
|
||||||
|
getNodeDescription(type, nodeTypes) {
|
||||||
|
if (nodeTypes && nodeTypes[type] && nodeTypes[type].description) {
|
||||||
|
return nodeTypes[type].description;
|
||||||
|
}
|
||||||
|
return `${this.formatNodeType(type)} node`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input type for a node input
|
||||||
|
* @param {string} nodeType - Node type
|
||||||
|
* @param {string} inputName - Input name
|
||||||
|
* @returns {string} Input type
|
||||||
|
*/
|
||||||
|
getInputType(nodeType, inputName) {
|
||||||
|
const nodeTypes = this.getNodeTypes();
|
||||||
|
if (nodeTypes && nodeTypes[nodeType] && nodeTypes[nodeType].input) {
|
||||||
|
const required = nodeTypes[nodeType].input.required || {};
|
||||||
|
const optional = nodeTypes[nodeType].input.optional || {};
|
||||||
|
|
||||||
|
if (required[inputName]) {
|
||||||
|
return Array.isArray(required[inputName]) ? required[inputName][0] : required[inputName];
|
||||||
|
}
|
||||||
|
if (optional[inputName]) {
|
||||||
|
return Array.isArray(optional[inputName]) ? optional[inputName][0] : optional[inputName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get output name for a node output
|
||||||
|
* @param {Object} node - Node object
|
||||||
|
* @param {number} outputIndex - Output index
|
||||||
|
* @returns {string} Output name
|
||||||
|
*/
|
||||||
|
getOutputName(node, outputIndex) {
|
||||||
|
const outputs = this.getNodeOutputInfo(node.type);
|
||||||
|
if (outputs && outputs[outputIndex]) {
|
||||||
|
return outputs[outputIndex].name;
|
||||||
|
}
|
||||||
|
return `Output ${outputIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node output information
|
||||||
|
* @param {string} nodeType - Node type
|
||||||
|
* @returns {Array} Output information
|
||||||
|
*/
|
||||||
|
getNodeOutputInfo(nodeType) {
|
||||||
|
const nodeTypes = this.getNodeTypes();
|
||||||
|
if (nodeTypes && nodeTypes[nodeType] && nodeTypes[nodeType].output) {
|
||||||
|
return nodeTypes[nodeType].output.map((type, index) => ({
|
||||||
|
name: Array.isArray(type) ? type[0] : type,
|
||||||
|
type: Array.isArray(type) ? type[0] : type
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node widget information
|
||||||
|
* @param {string} nodeType - Node type
|
||||||
|
* @returns {Array} Widget information
|
||||||
|
*/
|
||||||
|
getNodeWidgetInfo(nodeType) {
|
||||||
|
// This would be populated from the actual ComfyUI node definitions
|
||||||
|
// For now, return empty array as placeholder
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node types definition (placeholder)
|
||||||
|
* @returns {Object} Node types
|
||||||
|
*/
|
||||||
|
getNodeTypes() {
|
||||||
|
// This would be fetched from the ComfyUI API
|
||||||
|
// For now, return empty object as placeholder
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback linearization when topological sort fails
|
||||||
|
* @param {Object} workflow - Workflow object
|
||||||
|
* @returns {Array} Simple node array
|
||||||
|
*/
|
||||||
|
fallbackLinearization(workflow) {
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
for (const nodeId in workflow.nodes) {
|
||||||
|
const node = workflow.nodes[nodeId];
|
||||||
|
nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
type: node.type || 'unknown',
|
||||||
|
title: this.getNodeTitle(node),
|
||||||
|
executionOrder: parseInt(nodeId),
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
widgets: [],
|
||||||
|
status: 'idle',
|
||||||
|
category: 'misc',
|
||||||
|
description: 'Node',
|
||||||
|
position: node.pos || [0, 0],
|
||||||
|
size: node.size || [200, 100],
|
||||||
|
flags: node.flags || {},
|
||||||
|
mode: node.mode || 0,
|
||||||
|
originalNode: node
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by node ID as fallback
|
||||||
|
nodes.sort((a, b) => parseInt(a.id) - parseInt(b.id));
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get connection compatibility between two sockets
|
||||||
|
* @param {string} outputType - Output socket type
|
||||||
|
* @param {string} inputType - Input socket type
|
||||||
|
* @returns {boolean} True if compatible
|
||||||
|
*/
|
||||||
|
areSocketsCompatible(outputType, inputType) {
|
||||||
|
// Basic compatibility check - can be extended
|
||||||
|
if (outputType === inputType) return true;
|
||||||
|
|
||||||
|
// Special compatibility rules
|
||||||
|
const compatibilityRules = {
|
||||||
|
'MODEL': ['MODEL'],
|
||||||
|
'CLIP': ['CLIP'],
|
||||||
|
'VAE': ['VAE'],
|
||||||
|
'CONDITIONING': ['CONDITIONING'],
|
||||||
|
'LATENT': ['LATENT'],
|
||||||
|
'IMAGE': ['IMAGE'],
|
||||||
|
'MASK': ['MASK'],
|
||||||
|
'INT': ['INT', 'FLOAT'],
|
||||||
|
'FLOAT': ['FLOAT', 'INT'],
|
||||||
|
'STRING': ['STRING'],
|
||||||
|
'BOOLEAN': ['BOOLEAN'],
|
||||||
|
'*': ['*'] // Wildcard type
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputCompatible = compatibilityRules[outputType] || [];
|
||||||
|
return outputCompatible.includes(inputType) || inputType === '*' || outputType === '*';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find potential connections for a socket
|
||||||
|
* @param {Object} socket - Socket object
|
||||||
|
* @param {Array} allNodes - All nodes in the workflow
|
||||||
|
* @param {string} direction - 'input' or 'output'
|
||||||
|
* @returns {Array} Compatible sockets
|
||||||
|
*/
|
||||||
|
findCompatibleSockets(socket, allNodes, direction) {
|
||||||
|
const compatibleSockets = [];
|
||||||
|
|
||||||
|
for (const node of allNodes) {
|
||||||
|
const sockets = direction === 'input' ? node.outputs : node.inputs;
|
||||||
|
|
||||||
|
for (const targetSocket of sockets) {
|
||||||
|
const isCompatible = direction === 'input'
|
||||||
|
? this.areSocketsCompatible(socket.type, targetSocket.type)
|
||||||
|
: this.areSocketsCompatible(targetSocket.type, socket.type);
|
||||||
|
|
||||||
|
if (isCompatible && node.id !== socket.nodeId) {
|
||||||
|
compatibleSockets.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeTitle: node.title,
|
||||||
|
socket: targetSocket,
|
||||||
|
compatible: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compatibleSockets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate workflow for common issues
|
||||||
|
* @param {Array} linearizedNodes - Linearized nodes array
|
||||||
|
* @returns {Array} Validation issues
|
||||||
|
*/
|
||||||
|
validateWorkflow(linearizedNodes) {
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// Check for unconnected required inputs
|
||||||
|
for (const node of linearizedNodes) {
|
||||||
|
for (const input of node.inputs) {
|
||||||
|
if (input.required && !input.connected && input.value === null) {
|
||||||
|
issues.push({
|
||||||
|
type: 'error',
|
||||||
|
nodeId: node.id,
|
||||||
|
message: `Required input '${input.name}' is not connected`,
|
||||||
|
severity: 'high'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for isolated nodes
|
||||||
|
for (const node of linearizedNodes) {
|
||||||
|
const hasConnectedInputs = node.inputs.some(input => input.connected);
|
||||||
|
const hasConnectedOutputs = this.hasConnectedOutputs(node, linearizedNodes);
|
||||||
|
|
||||||
|
if (!hasConnectedInputs && !hasConnectedOutputs && node.type !== 'SaveImage') {
|
||||||
|
issues.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: node.id,
|
||||||
|
message: `Node '${node.title}' appears to be isolated`,
|
||||||
|
severity: 'medium'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if node has connected outputs
|
||||||
|
* @param {Object} node - Node to check
|
||||||
|
* @param {Array} allNodes - All nodes in the workflow
|
||||||
|
* @returns {boolean} True if has connected outputs
|
||||||
|
*/
|
||||||
|
hasConnectedOutputs(node, allNodes) {
|
||||||
|
for (const otherNode of allNodes) {
|
||||||
|
for (const input of otherNode.inputs) {
|
||||||
|
if (input.connected && input.connection.sourceNodeId === node.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other files
|
||||||
|
window.GraphLinearization = GraphLinearization;
|
||||||
119
web_mobile/index.html
Normal file
119
web_mobile/index.html
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ComfyUI Mobile</title>
|
||||||
|
<link rel="stylesheet" href="/mobile_static/styles.css">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<h1>ComfyUI Mobile</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="toggleView" class="btn-icon" title="Switch to Desktop View">
|
||||||
|
<i class="fas fa-desktop"></i>
|
||||||
|
</button>
|
||||||
|
<button id="queuePrompt" class="btn-primary" disabled>
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
Queue Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Status:</span>
|
||||||
|
<span id="connectionStatus" class="status-value">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Queue:</span>
|
||||||
|
<span id="queueSize" class="status-value">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workflow Pipeline -->
|
||||||
|
<main class="workflow-container">
|
||||||
|
<div id="workflowPipeline" class="workflow-pipeline">
|
||||||
|
<!-- Node cards will be dynamically inserted here -->
|
||||||
|
<div class="loading-message">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Loading workflow...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bottom Action Bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<button id="loadWorkflow" class="btn-secondary">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button id="saveWorkflow" class="btn-secondary">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button id="clearWorkflow" class="btn-secondary">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button id="addNode" class="btn-secondary">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
Add Node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node Detail Modal -->
|
||||||
|
<div id="nodeDetailModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">Edit Node</h2>
|
||||||
|
<button id="closeModal" class="btn-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody">
|
||||||
|
<!-- Node editing form will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="cancelEdit" class="btn-secondary">Cancel</button>
|
||||||
|
<button id="saveEdit" class="btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Sheet Menu -->
|
||||||
|
<div id="bottomSheet" class="bottom-sheet">
|
||||||
|
<div class="bottom-sheet-content">
|
||||||
|
<div class="bottom-sheet-header">
|
||||||
|
<h3 id="bottomSheetTitle">Node Actions</h3>
|
||||||
|
<button id="closeBottomSheet" class="btn-close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-sheet-body" id="bottomSheetBody">
|
||||||
|
<!-- Action buttons will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Manager Overlay -->
|
||||||
|
<div id="connectionOverlay" class="connection-overlay">
|
||||||
|
<div class="connection-message">
|
||||||
|
<i class="fas fa-link"></i>
|
||||||
|
<p>Tap a compatible input to connect</p>
|
||||||
|
<button id="cancelConnection" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/mobile_static/utils.js"></script>
|
||||||
|
<script src="/mobile_static/graph-linearization.js"></script>
|
||||||
|
<script src="/mobile_static/api-client.js"></script>
|
||||||
|
<script src="/mobile_static/mobile-interface.js"></script>
|
||||||
|
<script src="/mobile_static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1001
web_mobile/mobile-interface.js
Normal file
1001
web_mobile/mobile-interface.js
Normal file
File diff suppressed because it is too large
Load Diff
701
web_mobile/styles.css
Normal file
701
web_mobile/styles.css
Normal file
@ -0,0 +1,701 @@
|
|||||||
|
/* ComfyUI Mobile Interface Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #2196F3;
|
||||||
|
--primary-dark: #1976D2;
|
||||||
|
--secondary-color: #757575;
|
||||||
|
--success-color: #4CAF50;
|
||||||
|
--warning-color: #FF9800;
|
||||||
|
--error-color: #F44336;
|
||||||
|
--background-color: #fafafa;
|
||||||
|
--surface-color: #ffffff;
|
||||||
|
--text-primary: #212121;
|
||||||
|
--text-secondary: #757575;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
--shadow-elevated: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
--border-radius: 8px;
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.status-bar {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Workflow Container */
|
||||||
|
.workflow-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-pipeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Card */
|
||||||
|
.node-card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card:hover {
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.expanded {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.connecting {
|
||||||
|
background: rgba(33, 150, 243, 0.05);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card-header {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status.idle {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status.executing {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status.error {
|
||||||
|
background: #ffebee;
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-status.bypassed {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card-body {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.expanded .node-card-body {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-sockets {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-group h4 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--background-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket.compatible {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket.incompatible {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
border-color: var(--error-color);
|
||||||
|
color: var(--error-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket.connected {
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socket-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-reference {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: rgba(33, 150, 243, 0.05);
|
||||||
|
border: 1px solid rgba(33, 150, 243, 0.2);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-reference:hover {
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-widgets {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--background-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary, .btn-secondary, .btn-icon, .btn-close {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--background-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Bar */
|
||||||
|
.action-bar {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar .btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 80%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Sheet */
|
||||||
|
.bottom-sheet {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||||
|
box-shadow: 0 -4px 8px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet.active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-content {
|
||||||
|
max-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-header {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-header h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-action {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-action:hover {
|
||||||
|
background: var(--background-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-action i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection Overlay */
|
||||||
|
.connection-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 500;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-message {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
text-align: center;
|
||||||
|
border: 2px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-message i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-message p {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select, .form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--surface-color);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-range::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-container {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-sockets {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
margin: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch-friendly sizing */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.socket, .btn-secondary, .btn-primary {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card-header {
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-action {
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background-color: #121212;
|
||||||
|
--surface-color: #1e1e1e;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b0b0b0;
|
||||||
|
--border-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation utilities */
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
427
web_mobile/utils.js
Normal file
427
web_mobile/utils.js
Normal file
@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* ComfyUI Mobile Interface - Utility Functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Utility functions for mobile interface
|
||||||
|
class Utils {
|
||||||
|
/**
|
||||||
|
* Debounce function to limit rapid function calls
|
||||||
|
* @param {Function} func - Function to debounce
|
||||||
|
* @param {number} wait - Wait time in milliseconds
|
||||||
|
* @returns {Function} Debounced function
|
||||||
|
*/
|
||||||
|
static debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle function to limit function execution frequency
|
||||||
|
* @param {Function} func - Function to throttle
|
||||||
|
* @param {number} limit - Limit in milliseconds
|
||||||
|
* @returns {Function} Throttled function
|
||||||
|
*/
|
||||||
|
static throttle(func, limit) {
|
||||||
|
let inThrottle;
|
||||||
|
return function(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func.apply(this, args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => inThrottle = false, limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep clone an object
|
||||||
|
* @param {Object} obj - Object to clone
|
||||||
|
* @returns {Object} Cloned object
|
||||||
|
*/
|
||||||
|
static deepClone(obj) {
|
||||||
|
if (obj === null || typeof obj !== "object") return obj;
|
||||||
|
if (obj instanceof Date) return new Date(obj.getTime());
|
||||||
|
if (obj instanceof Array) return obj.map(item => Utils.deepClone(item));
|
||||||
|
if (typeof obj === "object") {
|
||||||
|
const clonedObj = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
clonedObj[key] = Utils.deepClone(obj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clonedObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique ID
|
||||||
|
* @returns {string} Unique ID
|
||||||
|
*/
|
||||||
|
static generateId() {
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size in human readable format
|
||||||
|
* @param {number} bytes - Size in bytes
|
||||||
|
* @returns {string} Formatted size
|
||||||
|
*/
|
||||||
|
static formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in human readable format
|
||||||
|
* @param {number} seconds - Duration in seconds
|
||||||
|
* @returns {string} Formatted duration
|
||||||
|
*/
|
||||||
|
static formatDuration(seconds) {
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
||||||
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device has touch support
|
||||||
|
* @returns {boolean} True if touch is supported
|
||||||
|
*/
|
||||||
|
static isTouchDevice() {
|
||||||
|
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get viewport dimensions
|
||||||
|
* @returns {Object} Viewport width and height
|
||||||
|
*/
|
||||||
|
static getViewportSize() {
|
||||||
|
return {
|
||||||
|
width: Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0),
|
||||||
|
height: Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element is in viewport
|
||||||
|
* @param {Element} element - Element to check
|
||||||
|
* @returns {boolean} True if element is visible
|
||||||
|
*/
|
||||||
|
static isElementInViewport(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top >= 0 &&
|
||||||
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth scroll to element
|
||||||
|
* @param {Element} element - Element to scroll to
|
||||||
|
* @param {Object} options - Scroll options
|
||||||
|
*/
|
||||||
|
static scrollToElement(element, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
};
|
||||||
|
element.scrollIntoView({ ...defaultOptions, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add CSS class with animation support
|
||||||
|
* @param {Element} element - Target element
|
||||||
|
* @param {string} className - Class name to add
|
||||||
|
* @param {number} duration - Animation duration
|
||||||
|
*/
|
||||||
|
static addClassAnimated(element, className, duration = 300) {
|
||||||
|
element.classList.add(className);
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.add('fade-in');
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove CSS class with animation support
|
||||||
|
* @param {Element} element - Target element
|
||||||
|
* @param {string} className - Class name to remove
|
||||||
|
* @param {number} duration - Animation duration
|
||||||
|
*/
|
||||||
|
static removeClassAnimated(element, className, duration = 300) {
|
||||||
|
element.style.transition = `opacity ${duration}ms ease`;
|
||||||
|
element.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.remove(className);
|
||||||
|
element.style.opacity = '';
|
||||||
|
element.style.transition = '';
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show toast notification
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
* @param {string} type - Toast type (success, error, warning, info)
|
||||||
|
* @param {number} duration - Display duration in milliseconds
|
||||||
|
*/
|
||||||
|
static showToast(message, type = 'info', duration = 3000) {
|
||||||
|
// Remove existing toasts
|
||||||
|
const existingToasts = document.querySelectorAll('.toast');
|
||||||
|
existingToasts.forEach(toast => toast.remove());
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="toast-content">
|
||||||
|
<i class="fas fa-${this.getToastIcon(type)}"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add toast styles if not already present
|
||||||
|
if (!document.querySelector('#toast-styles')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'toast-styles';
|
||||||
|
style.textContent = `
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
.toast-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.toast-success { border-left: 4px solid var(--success-color); }
|
||||||
|
.toast-error { border-left: 4px solid var(--error-color); }
|
||||||
|
.toast-warning { border-left: 4px solid var(--warning-color); }
|
||||||
|
.toast-info { border-left: 4px solid var(--primary-color); }
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { transform: translateX(-50%) translateY(-100%); opacity: 0; }
|
||||||
|
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto remove toast
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideUp 0.3s ease';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate icon for toast type
|
||||||
|
* @param {string} type - Toast type
|
||||||
|
* @returns {string} Icon name
|
||||||
|
*/
|
||||||
|
static getToastIcon(type) {
|
||||||
|
const icons = {
|
||||||
|
success: 'check-circle',
|
||||||
|
error: 'exclamation-circle',
|
||||||
|
warning: 'exclamation-triangle',
|
||||||
|
info: 'info-circle'
|
||||||
|
};
|
||||||
|
return icons[type] || 'info-circle';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local storage utility with error handling
|
||||||
|
*/
|
||||||
|
static storage = {
|
||||||
|
get(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('LocalStorage get error:', error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('LocalStorage set error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('LocalStorage remove error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('LocalStorage clear error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event emitter for custom events
|
||||||
|
*/
|
||||||
|
static events = {
|
||||||
|
listeners: new Map(),
|
||||||
|
|
||||||
|
on(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event).push(callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
off(event, callback) {
|
||||||
|
if (!this.listeners.has(event)) return;
|
||||||
|
const callbacks = this.listeners.get(event);
|
||||||
|
const index = callbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
callbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emit(event, data) {
|
||||||
|
if (!this.listeners.has(event)) return;
|
||||||
|
this.listeners.get(event).forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Event callback error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touch gesture detection
|
||||||
|
*/
|
||||||
|
static gesture = {
|
||||||
|
/**
|
||||||
|
* Add long press event listener
|
||||||
|
* @param {Element} element - Target element
|
||||||
|
* @param {Function} callback - Callback function
|
||||||
|
* @param {number} duration - Long press duration in ms
|
||||||
|
*/
|
||||||
|
longPress(element, callback, duration = 500) {
|
||||||
|
let timer;
|
||||||
|
let startTouch;
|
||||||
|
|
||||||
|
const start = (e) => {
|
||||||
|
startTouch = e.touches ? e.touches[0] : e;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
callback(e);
|
||||||
|
}, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (e) => {
|
||||||
|
const currentTouch = e.touches ? e.touches[0] : e;
|
||||||
|
const deltaX = Math.abs(currentTouch.clientX - startTouch.clientX);
|
||||||
|
const deltaY = Math.abs(currentTouch.clientY - startTouch.clientY);
|
||||||
|
|
||||||
|
// Cancel long press if moved too much
|
||||||
|
if (deltaX > 10 || deltaY > 10) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('touchstart', start);
|
||||||
|
element.addEventListener('mousedown', start);
|
||||||
|
element.addEventListener('touchend', end);
|
||||||
|
element.addEventListener('mouseup', end);
|
||||||
|
element.addEventListener('touchmove', move);
|
||||||
|
element.addEventListener('mousemove', move);
|
||||||
|
element.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add swipe event listener
|
||||||
|
* @param {Element} element - Target element
|
||||||
|
* @param {Function} callback - Callback function
|
||||||
|
* @param {number} threshold - Swipe threshold in pixels
|
||||||
|
*/
|
||||||
|
swipe(element, callback, threshold = 50) {
|
||||||
|
let startTouch;
|
||||||
|
let startTime;
|
||||||
|
|
||||||
|
const start = (e) => {
|
||||||
|
startTouch = e.touches ? e.touches[0] : e;
|
||||||
|
startTime = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = (e) => {
|
||||||
|
if (!startTouch) return;
|
||||||
|
|
||||||
|
const endTouch = e.changedTouches ? e.changedTouches[0] : e;
|
||||||
|
const deltaX = endTouch.clientX - startTouch.clientX;
|
||||||
|
const deltaY = endTouch.clientY - startTouch.clientY;
|
||||||
|
const deltaTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Only consider fast swipes
|
||||||
|
if (deltaTime > 300) return;
|
||||||
|
|
||||||
|
const absDeltaX = Math.abs(deltaX);
|
||||||
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (Math.max(absDeltaX, absDeltaY) > threshold) {
|
||||||
|
let direction;
|
||||||
|
if (absDeltaX > absDeltaY) {
|
||||||
|
direction = deltaX > 0 ? 'right' : 'left';
|
||||||
|
} else {
|
||||||
|
direction = deltaY > 0 ? 'down' : 'up';
|
||||||
|
}
|
||||||
|
callback(direction, { deltaX, deltaY, deltaTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
startTouch = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('touchstart', start);
|
||||||
|
element.addEventListener('mousedown', start);
|
||||||
|
element.addEventListener('touchend', end);
|
||||||
|
element.addEventListener('mouseup', end);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in other files
|
||||||
|
window.Utils = Utils;
|
||||||
Loading…
x
Reference in New Issue
Block a user