mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2026-09-02 13:27:06 +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)
|
||||
|
||||
@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):
|
||||
timeout = aiohttp.ClientTimeout(total=None) # no timeout
|
||||
self.client_session = aiohttp.ClientSession(timeout=timeout)
|
||||
@ -752,6 +764,13 @@ class PromptServer():
|
||||
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([
|
||||
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