Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release
# Conflicts: # routes/chat_routes.py # routes/session_routes.py # src/agent_loop.py # src/agent_tools/filesystem_tools.py # src/teacher_escalation.py # src/tool_capabilities.py # src/tool_execution.py # tests/test_mcp_add_server_args_validation.py # tests/test_token_cache_atomic_swap.py
@@ -3,16 +3,16 @@
|
||||
// ES6 module — entry point, no exports (wires all modules together)
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import uiModule from './js/ui.js?v=20260916largetoolscroll1';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js?v=20260715startupcalm2';
|
||||
import fileHandlerModule from './js/fileHandler.js?v=20260909mobileattachmentedit1';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js';
|
||||
import presetsModule from './js/presets.js?v=20260908personaname1';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
|
||||
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
|
||||
import documentModule from './js/document.js?v=20260815approvalsave1';
|
||||
import chatModule from './js/chat.js?v=20260917toolttft1';
|
||||
import compareModule from './js/compare/index.js?v=20260909mobilepaneaddscroll1';
|
||||
import documentModule from './js/document.js?v=20260916docctx2';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import {
|
||||
@@ -22,23 +22,30 @@ import {
|
||||
settleSessionHydration
|
||||
} from './js/startupShell.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260914metricssummary1';
|
||||
// Keep this specifier identical to every consumer (especially chat.js).
|
||||
// Different query strings create separate ES-module instances with separate
|
||||
// current-session state, so the picker can display one model while chat sends
|
||||
// through another session.
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import { startBackgroundToolJobs } from './js/backgroundToolJobs.js';
|
||||
import memoryModule from './js/memory.js';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
import adminModule from './js/admin.js?v=20260716openrouter3';
|
||||
import settingsModule from './js/settings.js?v=20260815approvalsave1';
|
||||
import galleryModule from './js/gallery.js?v=20260910promptcopy1';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=20260829chatstyle12';
|
||||
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
|
||||
import calendarModule from './js/calendar.js?v=20260914emailsource11';
|
||||
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
|
||||
import adminModule from './js/admin.js?v=20260929csssplit';
|
||||
import settingsModule from './js/settings.js?v=20260912writingstyle3';
|
||||
// Eagerly bind unified minimize/restore behavior across all tool modals.
|
||||
import './js/modalManager.js?v=20260723compareicon2';
|
||||
import './js/modalManager.js';
|
||||
import './js/chipScroll.js?v=20260903calendarchips1';
|
||||
import './js/mobileBulkSelect.js?v=20260910selecthold1';
|
||||
// Desktop window tiling — drag a modal near an edge/corner to snap.
|
||||
import './js/tileManager.js';
|
||||
import themeModule from './js/theme.js';
|
||||
import './js/tileManager.js?v=20260910responsivebounds1';
|
||||
import themeModule from './js/theme.js?v=20260911organsrain1';
|
||||
// IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier
|
||||
// every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query
|
||||
// mismatch makes the browser load cookbook.js twice as separate modules (two
|
||||
@@ -46,12 +53,12 @@ import themeModule from './js/theme.js';
|
||||
// unversioned so this can't recur.
|
||||
import cookbookModule from './js/cookbook.js';
|
||||
import groupModule from './js/group.js';
|
||||
import * as researchPanelModule from './js/research/panel.js?v=20260630researchthumb';
|
||||
import * as researchPanelModule from './js/research/panel.js?v=20260913researchrailerrors1';
|
||||
import ttsModule from './js/tts-ai.js';
|
||||
import spinnerModule from './js/spinner.js';
|
||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
|
||||
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js?v=20260829chatstyle12';
|
||||
import { getSettings } from './js/appConfig.js';
|
||||
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
|
||||
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260910sidebarbounce1';
|
||||
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
@@ -176,8 +183,9 @@ function initRailHoverLabels() {
|
||||
'rail-email': 'Email',
|
||||
'rail-gallery': 'Gallery',
|
||||
'rail-archive': 'Library',
|
||||
'rail-memory': 'Brain',
|
||||
'rail-memory': 'Memory',
|
||||
'rail-notes': 'Notes',
|
||||
'rail-skills': 'Skills',
|
||||
'rail-tasks': 'Tasks',
|
||||
'rail-theme': 'Theme',
|
||||
'rail-settings': 'Settings',
|
||||
@@ -237,12 +245,12 @@ async function _refreshDefaultChat() {
|
||||
// synchronously; later reads should call _refreshDefaultChat() first.
|
||||
_refreshDefaultChat();
|
||||
|
||||
async function _createDirectChatFromPreferredModel() {
|
||||
async function _createDirectChatFromPreferredModel(opts = {}) {
|
||||
if (!sessionModule) return false;
|
||||
|
||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId, { source: pending.source || 'manual' });
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId, { source: pending.source || 'manual', keepDocument: !!opts.keepDocument });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -250,20 +258,20 @@ async function _createDirectChatFromPreferredModel() {
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
if (current && current.endpoint_url && current.model && current.endpoint_id) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id, { keepDocument: !!opts.keepDocument });
|
||||
return true;
|
||||
}
|
||||
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc) {
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default', keepDocument: !!opts.keepDocument });
|
||||
return true;
|
||||
}
|
||||
|
||||
const withModel = sessions.filter(s => s.endpoint_url && s.model);
|
||||
if (withModel.length > 0) {
|
||||
const last = withModel[0]; // sessions are sorted by recent
|
||||
sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id);
|
||||
sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id, { keepDocument: !!opts.keepDocument });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -290,6 +298,9 @@ function initializeEventListeners() {
|
||||
|
||||
// Paste handler
|
||||
window.addEventListener('paste', async (e)=>{
|
||||
// Document editors own image paste. The global chat attachment listener
|
||||
// must not stage the same clipboard file a second time.
|
||||
if (e.defaultPrevented || e.target?.closest?.('#doc-editor-pane, [contenteditable="true"]')) return;
|
||||
if (!e.clipboardData) return;
|
||||
let changed = false;
|
||||
for (const item of e.clipboardData.items){
|
||||
@@ -329,6 +340,12 @@ function initializeEventListeners() {
|
||||
// Scrolling
|
||||
el('chat-history').addEventListener('scroll', uiModule.debounce(() => {
|
||||
const box = el('chat-history');
|
||||
// scrollHistory() advances in several animation frames. Its early frames
|
||||
// are intentionally not at the bottom yet, so treating those events as a
|
||||
// user scroll cancels the animation before a synthesis below a large tool
|
||||
// trace can become visible. Wheel/touch handlers still disable follow
|
||||
// mode immediately for real user input.
|
||||
if (uiModule.isAutoScrolling?.()) return;
|
||||
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
|
||||
uiModule.setAutoScroll(atBottom);
|
||||
}, 100));
|
||||
@@ -355,7 +372,8 @@ function initializeEventListeners() {
|
||||
const link = e.target.closest('a.chat-link');
|
||||
if (!link) return;
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('#') && sessionModule) {
|
||||
const isListExpander = /^#(?:notes|skills|memories|events|sessions)-more-/.test(href || '');
|
||||
if (href && href.startsWith('#') && !isListExpander && sessionModule) {
|
||||
e.preventDefault();
|
||||
sessionModule.selectSession(href.slice(1));
|
||||
}
|
||||
@@ -392,19 +410,55 @@ function initializeEventListeners() {
|
||||
|
||||
const exportMenu = el('export-dropdown-menu');
|
||||
if (exportDlBtn && exportMenu) {
|
||||
exportDlBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Keep Settings available from the chat menu even before a new chat has
|
||||
// received its first prompt (when the header has no session actions yet).
|
||||
if (!el('export-settings-btn')) {
|
||||
const settingsItem = document.createElement('div');
|
||||
settingsItem.className = 'export-dropdown-item';
|
||||
settingsItem.id = 'export-settings-btn';
|
||||
settingsItem.classList.add('export-settings-text-only');
|
||||
settingsItem.innerHTML = '<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09A1.65 1.65 0 0 0 19.4 15z"></path></svg></span><span>Settings</span>';
|
||||
const deleteItem = exportMenu.querySelector('#export-delete-btn');
|
||||
if (deleteItem) exportMenu.insertBefore(settingsItem, deleteItem);
|
||||
else exportMenu.appendChild(settingsItem);
|
||||
settingsItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (window.chatModule?.openContextSettings
|
||||
&& await window.chatModule.openContextSettings()) return;
|
||||
if (typeof settingsModule !== 'undefined' && settingsModule?.open) settingsModule.open();
|
||||
else if (typeof adminModule !== 'undefined' && adminModule?.open) adminModule.open();
|
||||
else if (window.settingsModule?.open) window.settingsModule.open();
|
||||
else if (window.adminModule?.open) window.adminModule.open();
|
||||
});
|
||||
}
|
||||
const openExportMenuFrom = (anchorEl, { align = 'right' } = {}) => {
|
||||
if (exportMenu.classList.contains('open')) {
|
||||
exportMenu.classList.remove('open');
|
||||
} else {
|
||||
// Move menu to body so it's not affected by ancestor transforms
|
||||
if (exportMenu.parentElement !== document.body) document.body.appendChild(exportMenu);
|
||||
const rect = exportDlBtn.getBoundingClientRect();
|
||||
const rect = (anchorEl || exportDlBtn).getBoundingClientRect();
|
||||
exportMenu.style.top = (rect.bottom + 4) + 'px';
|
||||
exportMenu.style.left = 'auto';
|
||||
exportMenu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
if (align === 'left') {
|
||||
exportMenu.style.left = Math.max(8, rect.left) + 'px';
|
||||
exportMenu.style.right = 'auto';
|
||||
} else {
|
||||
exportMenu.style.left = 'auto';
|
||||
exportMenu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
}
|
||||
exportMenu.classList.add('open');
|
||||
if (align === 'left') {
|
||||
const menuRect = exportMenu.getBoundingClientRect();
|
||||
if (menuRect.right > window.innerWidth - 8) {
|
||||
exportMenu.style.left = `${Math.max(8, window.innerWidth - menuRect.width - 8)}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
exportDlBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openExportMenuFrom(exportDlBtn);
|
||||
});
|
||||
document.addEventListener('click', () => exportMenu.classList.remove('open'));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -429,8 +483,17 @@ function initializeEventListeners() {
|
||||
if (currentMeta) {
|
||||
currentMeta.style.cursor = 'pointer';
|
||||
currentMeta.addEventListener('click', (e) => {
|
||||
const incognitoChk = el('incognito-toggle');
|
||||
const mobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
|
||||
if (mobile && incognitoChk && incognitoChk.checked) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const incognitoBtnForTitle = el('incognito-btn');
|
||||
if (incognitoBtnForTitle) incognitoBtnForTitle.click();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
exportDlBtn.click();
|
||||
openExportMenuFrom(currentMeta, { align: 'left' });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -496,20 +559,6 @@ function initializeEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Export menu: Compact current chat context
|
||||
const exportCompactBtn = el('export-compact-btn');
|
||||
if (exportCompactBtn) {
|
||||
exportCompactBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (window.compactCurrentChatContext) {
|
||||
await window.compactCurrentChatContext();
|
||||
} else {
|
||||
uiModule.showError('Compact action is not ready yet');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export: PDF
|
||||
const exportPdfBtn = el('export-pdf-btn');
|
||||
if (exportPdfBtn) {
|
||||
@@ -535,33 +584,6 @@ function initializeEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Export: Save to Docs
|
||||
const exportDocBtn = el('export-doc-btn');
|
||||
if (exportDocBtn) {
|
||||
exportDocBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
try {
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
const texts = _serializeChatTranscript();
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
|
||||
const title = meta?.name || 'Untitled';
|
||||
const res = await fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, title, content: texts }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
const doc = await res.json();
|
||||
if (documentModule) documentModule.loadDocument(doc.id);
|
||||
uiModule.showToast('Saved to documents');
|
||||
} catch (err) {
|
||||
console.error('Save to docs failed:', err);
|
||||
uiModule.showError('Failed to save to documents');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export menu: Delete current chat
|
||||
const exportDeleteBtn = el('export-delete-btn');
|
||||
if (exportDeleteBtn) {
|
||||
@@ -1094,6 +1116,14 @@ function initializeEventListeners() {
|
||||
}
|
||||
});
|
||||
}
|
||||
const notesNewNoteBtn = el('notes-new-note-btn');
|
||||
if (notesNewNoteBtn) {
|
||||
notesNewNoteBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
notesModule?.newNote?.();
|
||||
});
|
||||
}
|
||||
// Refresh notes due-reminder badge on load and every 5 minutes
|
||||
if (notesModule && notesModule.refreshDueBadge) {
|
||||
notesModule.refreshDueBadge();
|
||||
@@ -1220,6 +1250,7 @@ function initializeEventListeners() {
|
||||
setTimeout(_goFullscreen, 200);
|
||||
},
|
||||
'/memory': () => document.getElementById('tool-memory-btn')?.click(),
|
||||
'/skills': () => document.getElementById('tool-skills-btn')?.click(),
|
||||
'/gallery': () => document.getElementById('tool-gallery-btn')?.click(),
|
||||
'/tasks': () => document.getElementById('tool-tasks-btn')?.click(),
|
||||
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
||||
@@ -1675,9 +1706,24 @@ function initializeEventListeners() {
|
||||
const toolMemoryBtn = el('tool-memory-btn');
|
||||
if (toolMemoryBtn && memoryModal) {
|
||||
toolMemoryBtn.addEventListener('click', () => {
|
||||
memoryModal.classList.remove('hidden');
|
||||
if (memoryModule && memoryModule.renderMemoryList) memoryModule.renderMemoryList();
|
||||
if (memoryModule && memoryModule.updateMemoryCount) memoryModule.updateMemoryCount();
|
||||
if (memoryModule && memoryModule.openMemoryModal) memoryModule.openMemoryModal('browse');
|
||||
else {
|
||||
memoryModal.classList.remove('hidden');
|
||||
if (memoryModule && memoryModule.renderMemoryList) memoryModule.renderMemoryList();
|
||||
if (memoryModule && memoryModule.updateMemoryCount) memoryModule.updateMemoryCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sidebar Skills button
|
||||
const toolSkillsBtn = el('tool-skills-btn');
|
||||
if (toolSkillsBtn && memoryModal) {
|
||||
toolSkillsBtn.addEventListener('click', () => {
|
||||
if (memoryModule && memoryModule.openMemoryModal) memoryModule.openMemoryModal('skills');
|
||||
else {
|
||||
memoryModal.classList.remove('hidden');
|
||||
document.querySelector('.memory-tab[data-memory-tab="skills"]')?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1836,8 +1882,10 @@ function initializeEventListeners() {
|
||||
if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
|
||||
// Workspace pill + overflow entry are agent-only - hide immediately (no flash).
|
||||
try { workspaceModule.applyMode(mode); } catch (_) {}
|
||||
// Delay tool glow-up for a staggered effect
|
||||
setTimeout(() => applyModeToToggles(mode), 500);
|
||||
// Tool state is request authority, so synchronize it immediately. A
|
||||
// delayed callback could overwrite a user's fast toggle click and send
|
||||
// a different allow_bash/allow_web_search value than the UI showed.
|
||||
applyModeToToggles(mode);
|
||||
}
|
||||
window.__odysseusSetChatMode = setMode;
|
||||
agentBtn.addEventListener('click', () => {
|
||||
@@ -2178,13 +2226,12 @@ function initializeEventListeners() {
|
||||
menu.classList.add('closing');
|
||||
plusBtn.classList.remove('expanded');
|
||||
if (pickerWrap) pickerWrap.style.visibility = '';
|
||||
// Item delays max at 0.18s + 0.20s anim = 0.38s for items, container
|
||||
// delay 0.16s + 0.22s = 0.38s. 400ms covers both with margin.
|
||||
// Keep closing nearly immediate so the tools button feels direct.
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
menu.classList.remove('closing');
|
||||
if (ownerWrap) ownerWrap.appendChild(menu); // restore from <body> portal
|
||||
}, 400);
|
||||
}, 100);
|
||||
}
|
||||
// Close menu when clicking any item inside it. preventDefault on pointerdown
|
||||
// so tapping an item (e.g. Attach files) doesn't steal focus from the message
|
||||
@@ -2568,9 +2615,15 @@ function initializeEventListeners() {
|
||||
// ── Group toggle button (chatbox indicator) — click to deactivate ──
|
||||
const groupToggleBtn = el('group-toggle-btn');
|
||||
if (groupToggleBtn) {
|
||||
groupToggleBtn.addEventListener('click', () => {
|
||||
_syncGroupIndicator(false);
|
||||
groupModule.stopGroup();
|
||||
groupToggleBtn.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.tool-indicator-x')) {
|
||||
_syncGroupIndicator(false);
|
||||
groupModule.stopGroup();
|
||||
return;
|
||||
}
|
||||
if (presetsModule && presetsModule.openCustomPresetModal) {
|
||||
presetsModule.openCustomPresetModal('group');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2592,14 +2645,16 @@ function initializeEventListeners() {
|
||||
if (incognitoBtn) {
|
||||
incognitoBtn.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
incognitoBtn.addEventListener('click', () => {
|
||||
// Don't toggle mid-chat — incognito only changeable from welcome screen
|
||||
const ws = el('welcome-screen');
|
||||
if (ws && ws.classList.contains('hidden')) return;
|
||||
const chk = el('incognito-toggle');
|
||||
// Starting Nobody is only available from the welcome screen, but ending
|
||||
// it must work after the first message too. The active header/title
|
||||
// controls depend on this path once the welcome button is hidden.
|
||||
if (ws && ws.classList.contains('hidden') && !chk.checked) return;
|
||||
chk.checked = !chk.checked;
|
||||
incognitoBtn.classList.toggle('active', chk.checked);
|
||||
const tipEl = el('welcome-tip');
|
||||
incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory, no history saved';
|
||||
incognitoBtn.title = chk.checked ? 'Disable Nobody mode' : 'Enable Nobody mode — no memory or skill injection, no history saved';
|
||||
const welcomeName = document.querySelector('.welcome-name');
|
||||
if (chk.checked) {
|
||||
try {
|
||||
@@ -2623,7 +2678,13 @@ function initializeEventListeners() {
|
||||
welcomeSub.textContent = "Who am I? I'm nobody.";
|
||||
welcomeSub.style.display = '';
|
||||
}
|
||||
if (tipEl) { tipEl.dataset.originalTip = tipEl.textContent; tipEl.textContent = 'Temporary session \u2014 won\u2019t be saved and no memory activation.'; tipEl.style.opacity = '0.5'; tipEl.style.marginTop = '8px'; }
|
||||
if (tipEl) {
|
||||
tipEl.dataset.originalTip = tipEl.textContent;
|
||||
tipEl.classList.add('nobody-session-note');
|
||||
tipEl.innerHTML = '<span>No memory or skill activation.</span><span>Temporary session \u2014 won\u2019t be saved.</span>';
|
||||
tipEl.style.opacity = '0.5';
|
||||
tipEl.style.marginTop = '16px';
|
||||
}
|
||||
// Default to plain chat: disable tools visually, switch to chat mode.
|
||||
// IMPORTANT: don't overwrite the user's persisted per-mode tool prefs
|
||||
// (`web_agent`, `bash_agent`, `web_chat`, `bash_chat`). Nobody mode is
|
||||
@@ -2673,7 +2734,13 @@ function initializeEventListeners() {
|
||||
}
|
||||
welcomeSub2.style.display = '';
|
||||
}
|
||||
if (tipEl && tipEl.dataset.originalTip) { tipEl.textContent = tipEl.dataset.originalTip; tipEl.style.opacity = ''; tipEl.style.marginTop = ''; }
|
||||
if (tipEl && Object.prototype.hasOwnProperty.call(tipEl.dataset, 'originalTip')) {
|
||||
tipEl.textContent = tipEl.dataset.originalTip;
|
||||
delete tipEl.dataset.originalTip;
|
||||
tipEl.classList.remove('nobody-session-note');
|
||||
tipEl.style.opacity = '';
|
||||
tipEl.style.marginTop = '';
|
||||
}
|
||||
// Heal any previously-persisted false values from the old Nobody bug
|
||||
// so agent-mode defaults (web/bash ON) come back.
|
||||
const _ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
|
||||
@@ -3145,6 +3212,7 @@ function initializeEventListeners() {
|
||||
|
||||
function injectMinimizeButton(modal) {
|
||||
if (!modal || !modal.classList || !modal.classList.contains('modal')) return;
|
||||
if (modal.dataset.noMinimize === 'true') return;
|
||||
if (modal.id && SKIP_IDS.has(modal.id)) return;
|
||||
// Modals managed by the new modalManager (Modals.register) get their own
|
||||
// .modal-minimize-btn and chips via the .minimized-dock-chip system.
|
||||
@@ -3313,13 +3381,40 @@ function initializeEventListeners() {
|
||||
async function _handleNewChatAction({ preferModel = true, focus = true } = {}) {
|
||||
if (!sessionModule) return;
|
||||
if (_closeCompareIfActive()) return;
|
||||
const hasOpenDocument = !!(
|
||||
documentModule
|
||||
&& documentModule.isPanelOpen
|
||||
&& documentModule.isPanelOpen()
|
||||
&& documentModule.getCurrentDocId
|
||||
&& documentModule.getCurrentDocId()
|
||||
);
|
||||
const shouldCarryDocument = hasOpenDocument && !!(
|
||||
uiModule?.styledConfirm
|
||||
? await uiModule.styledConfirm('Bring open document to new chat?', {
|
||||
title: 'New chat',
|
||||
confirmText: 'Bring →',
|
||||
cancelText: 'Drop',
|
||||
})
|
||||
: window.confirm('Bring open document to new chat?')
|
||||
);
|
||||
_deactivateIncognito();
|
||||
// Clear character on new chat
|
||||
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
|
||||
// Clear research mode if active
|
||||
const _resChk = el('research-toggle');
|
||||
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
|
||||
if (preferModel && await _createDirectChatFromPreferredModel()) return;
|
||||
if (preferModel && await _createDirectChatFromPreferredModel({ keepDocument: shouldCarryDocument })) {
|
||||
if (shouldCarryDocument) {
|
||||
try {
|
||||
await sessionModule.materializePendingSession();
|
||||
await documentModule.moveActiveDocumentToCurrentChat?.({ quiet: true });
|
||||
} catch (e) {
|
||||
console.warn('Failed to carry document into new chat:', e);
|
||||
uiModule?.showError?.('Could not bring document into the new chat');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// No models at all — show welcome screen
|
||||
_startFreshChat();
|
||||
const docBtn3 = el('overflow-doc-btn');
|
||||
@@ -3346,17 +3441,8 @@ function initializeEventListeners() {
|
||||
// the default model attaches when they hit send.
|
||||
const mobileNewChat = el('mobile-new-chat-btn');
|
||||
if (mobileNewChat) {
|
||||
mobileNewChat.addEventListener('click', () => {
|
||||
if (!sessionModule) return;
|
||||
if (_closeCompareIfActive()) return;
|
||||
_deactivateIncognito();
|
||||
_startFreshChat();
|
||||
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
|
||||
// Focus the composer synchronously so mobile keyboards pop open.
|
||||
// iOS Safari only honours programmatic focus inside the original click
|
||||
// callback — a setTimeout breaks the user-gesture chain.
|
||||
const _input = el('message-input');
|
||||
if (_input) { try { _input.focus(); } catch (_) {} }
|
||||
mobileNewChat.addEventListener('click', async () => {
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3373,6 +3459,11 @@ function initializeEventListeners() {
|
||||
if (sidebarNewChatBtn) {
|
||||
sidebarNewChatBtn.addEventListener('click', async (e) => {
|
||||
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
if (window.innerWidth < 768) {
|
||||
el('sidebar')?.classList.add('hidden');
|
||||
el('sidebar-backdrop')?.classList.remove('visible');
|
||||
window.syncRailSide?.();
|
||||
}
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
@@ -3707,6 +3798,8 @@ function startOdysseusApp() {
|
||||
searchModule.init();
|
||||
chatModule.init(API_BASE);
|
||||
chatModule.initListeners();
|
||||
startBackgroundToolJobs({ getSessionId: () => sessionModule.getCurrentSessionId(),
|
||||
addMessage: (...args) => chatRenderer.addMessage(...args), base: API_BASE });
|
||||
groupModule.init(API_BASE);
|
||||
// Initialize compare module
|
||||
if (compareModule) {
|
||||
@@ -3737,15 +3830,16 @@ function startOdysseusApp() {
|
||||
|
||||
// Rail tool buttons — delegate to sidebar tool buttons
|
||||
const _railToolMap = {
|
||||
'rail-compare': 'tool-compare-btn',
|
||||
'rail-research': 'tool-research-btn',
|
||||
'rail-cookbook': 'tool-cookbook-btn',
|
||||
'rail-archive': 'tool-library-btn',
|
||||
'rail-gallery': 'tool-gallery-btn',
|
||||
'rail-tasks': 'tool-tasks-btn',
|
||||
'rail-calendar': 'tool-calendar-btn',
|
||||
'rail-notes': 'tool-notes-btn',
|
||||
'rail-compare': 'tool-compare-btn',
|
||||
'rail-cookbook': 'tool-cookbook-btn',
|
||||
'rail-research': 'tool-research-btn',
|
||||
'rail-gallery': 'tool-gallery-btn',
|
||||
'rail-archive': 'tool-library-btn',
|
||||
'rail-memory': 'tool-memory-btn',
|
||||
'rail-notes': 'tool-notes-btn',
|
||||
'rail-skills': 'tool-skills-btn',
|
||||
'rail-tasks': 'tool-tasks-btn',
|
||||
'rail-theme': 'tool-theme-btn',
|
||||
'rail-email': 'email-section-title',
|
||||
};
|
||||
@@ -4030,12 +4124,18 @@ function startOdysseusApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
// New chat mode — empty input, no attachments, no STT
|
||||
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
|
||||
if (sessionModule) {
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
// New chat mode — empty input, no attachments, no STT
|
||||
if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') {
|
||||
const isAlreadyNewChat = document.getElementById('chat-container')?.classList.contains('welcome-active');
|
||||
if (isAlreadyNewChat) {
|
||||
if (messageInput) messageInput.focus();
|
||||
_updateSendBtnIcon();
|
||||
return;
|
||||
}
|
||||
if (sessionModule) {
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
} else {
|
||||
@@ -4076,13 +4176,18 @@ function startOdysseusApp() {
|
||||
e.preventDefault();
|
||||
// Flush the debounced icon update so dataset.mode reflects the current
|
||||
// text state. Without this, a fast type-and-Enter would still see the
|
||||
// stale 'newchat' mode and open a new chat instead of sending.
|
||||
try { _updateSendBtnIcon(); } catch {}
|
||||
if (sendBtn && sendBtn.dataset.mode === 'newchat') {
|
||||
const railNew = el('rail-new-session');
|
||||
if (railNew) railNew.click();
|
||||
return;
|
||||
}
|
||||
// stale 'newchat' mode and open a new chat instead of sending.
|
||||
try { _updateSendBtnIcon(); } catch {}
|
||||
if (sendBtn && sendBtn.dataset.mode === 'newchat') {
|
||||
const isAlreadyNewChat = document.getElementById('chat-container')?.classList.contains('welcome-active');
|
||||
if (isAlreadyNewChat) {
|
||||
if (messageInput) messageInput.focus();
|
||||
return;
|
||||
}
|
||||
const railNew = el('rail-new-session');
|
||||
if (railNew) railNew.click();
|
||||
return;
|
||||
}
|
||||
if (_isForegroundChatBusy() && messageInput.value && messageInput.value.trim()) {
|
||||
if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) {
|
||||
return;
|
||||
@@ -4167,12 +4272,14 @@ function startOdysseusApp() {
|
||||
}
|
||||
|
||||
chatContainer.addEventListener('dragover', (e) => {
|
||||
if (e.target?.closest?.('#doc-editor-pane')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_showDropHighlight();
|
||||
});
|
||||
|
||||
chatContainer.addEventListener('drop', async (e) => {
|
||||
if (e.target?.closest?.('#doc-editor-pane')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_hideDropHighlight();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* 00-tokens - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
/* ============================================ */
|
||||
/* Odysseus UI — Consolidated Stylesheet */
|
||||
/* ============================================ */
|
||||
|
||||
|
||||
/* ── Variables ──
|
||||
*
|
||||
* Theme-public variables (override via theme.js or custom themes):
|
||||
* Core: --bg, --fg, --panel, --border, --red
|
||||
* Syntax: --hl-keyword, --hl-string, --hl-comment, --hl-function,
|
||||
* --hl-number, --hl-builtin, --hl-variable, --hl-params,
|
||||
* --hl-bg, --hl-fg
|
||||
* Accents: --accent-primary, --accent-error (set by theme.js)
|
||||
* Semantic: --color-error, --color-success, --color-warning, --color-danger,
|
||||
* --color-accent, --color-muted, --color-muted-alt
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--bg: #282c34;
|
||||
--fg: #9cdef2;
|
||||
--panel: #111;
|
||||
--border: #355a66;
|
||||
--red: #e06c75;
|
||||
/* Were `var(--green)` / `var(--warn)` — self-referential, so they
|
||||
resolved to invalid and every site fell back to its own literal
|
||||
(or, for sites with no fallback, painted as transparent/inherit).
|
||||
Anchor them to real hex so the token layer actually works. */
|
||||
--green: #50fa7b;
|
||||
--warn: #f0ad4e;
|
||||
|
||||
/* Syntax highlighting */
|
||||
--hl-bg: #1e2228;
|
||||
--hl-fg: #9cdef2;
|
||||
--hl-keyword: #c678dd;
|
||||
--hl-string: #e5c07b;
|
||||
--hl-comment: #828997;
|
||||
--hl-function: #61afef;
|
||||
--hl-number: #d19a66;
|
||||
--hl-builtin: #56b6c2;
|
||||
--hl-variable: #abb2bf;
|
||||
--hl-params: #a8c0d4;
|
||||
|
||||
/* Semantic colors */
|
||||
--color-error: #ff4444;
|
||||
--color-error-light: #ff6666;
|
||||
--color-success: #4caf50;
|
||||
--color-warning: #f0ad4e;
|
||||
--color-danger: #c0392b;
|
||||
--color-recording: #ff3b30;
|
||||
--color-recording-hover: #d63031;
|
||||
--color-muted: #888;
|
||||
--color-muted-alt: #6b7280;
|
||||
--color-accent: #00aaff;
|
||||
--color-agent-active: #00ff00;
|
||||
--color-brand-blue: #3b82f6;
|
||||
--color-blind-orange: #ff9800;
|
||||
--color-save-green: var(--color-success);
|
||||
--color-link-hover: #66c7ff;
|
||||
--color-subheader: #6b8a94;
|
||||
--select-bg: var(--bg);
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: color-mix(in srgb, var(--panel) 74%, var(--bg));
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--accent, var(--red)) 24%, var(--panel));
|
||||
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
|
||||
themes can override without touching the goal CSS. */
|
||||
--accent-warm: #d19a66;
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--bg: #f5f5f5;
|
||||
--fg: #2b2b2b;
|
||||
--panel: #fff;
|
||||
--border: #bbb;
|
||||
--hl-bg: #f9f9f9;
|
||||
--hl-fg: #2b2b2b;
|
||||
--hl-keyword: #7928a1;
|
||||
--hl-string: #986801;
|
||||
--hl-comment: #6a737d;
|
||||
--hl-function: #005cc5;
|
||||
--hl-number: #986801;
|
||||
--hl-builtin: #0070a0;
|
||||
--hl-variable: #383a42;
|
||||
--hl-params: #4a4f5c;
|
||||
--select-bg: #eaeaea;
|
||||
--select-fg: var(--fg);
|
||||
--select-option-bg: var(--panel);
|
||||
--select-option-fg: var(--fg);
|
||||
--select-option-active-bg: color-mix(in srgb, var(--red) 16%, var(--panel));
|
||||
}
|
||||
|
||||
/* ── Reset & Base ── */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: none; }
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Keep the base padding transition for older layout paths that still adjust
|
||||
the body directly. Edge docks reserve workspace room on the flex panes
|
||||
below so left + right docks can coexist without skewing the whole body. */
|
||||
transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
font-family: var(--font-family, 'Fira Code', monospace);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Self-hosted Fira Code font */
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--red);
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--panel);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(in srgb, var(--red) 80%, white);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: var(--red) var(--panel);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.red-text { color: var(--red); }
|
||||
|
||||
/* ── Density Overrides ── */
|
||||
|
||||
:root.density-compact { font-size: 13px; }
|
||||
:root.density-compact .msg { padding: 6px 10px; margin-bottom: 4px; }
|
||||
:root.density-compact .list-item { padding: 4px 8px; }
|
||||
:root.density-compact .sidebar .section { padding: 0; }
|
||||
:root.density-spacious { font-size: 16px; }
|
||||
:root.density-spacious .msg { padding: 14px 18px; margin-bottom: 12px; }
|
||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||
:root.density-spacious .sidebar .section { padding: 0; }
|
||||
@@ -0,0 +1,839 @@
|
||||
/* 12-gallery - part of the former static/style.css.
|
||||
*
|
||||
* The stylesheet is split by area, but the split is mechanical: rules were
|
||||
* moved verbatim and NOT regrouped. The numeric prefix is the load order, and
|
||||
* that order is what preserves the cascade. A rule sits in the file its
|
||||
* original position fell into, so an area's rules are not all in its file and
|
||||
* a file is not only that area's rules.
|
||||
*
|
||||
* Do not reorder the <link> tags in static/index.html, and do not move a rule
|
||||
* between files: with 1,151 redeclarations and 1,886 !important in this
|
||||
* cascade, either can change which declaration wins.
|
||||
*
|
||||
* static/index.html and the PRECACHE list in static/sw.js must agree on this
|
||||
* order. tests/test_static_stylesheet_manifest.py and
|
||||
* tests/test_css_computed_style_snapshot.py fail if that drifts.
|
||||
*/
|
||||
.research-synapse.rs-phase-reading .rs-phase-chip,
|
||||
.research-synapse.rs-phase-analyzing .rs-phase-chip { color: var(--gold, #d4a72c); }
|
||||
.research-synapse.rs-phase-writing .rs-phase-chip { color: var(--color-success, #4caf50); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.research-synapse .rs-live-dot,
|
||||
.research-synapse .rs-pulse,
|
||||
.research-synapse .rs-node-new,
|
||||
.research-synapse .rs-node-expanded,
|
||||
.research-synapse .rs-edge-firing { animation: none !important; }
|
||||
}
|
||||
|
||||
/* ── Raw findings items (inside sources-style box) ── */
|
||||
.finding-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.finding-item .source-link {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.finding-summary {
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.5;
|
||||
color: var(--fg-dim, var(--fg));
|
||||
opacity: 0.85;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.finding-item:last-child .finding-summary {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Gallery Editor
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Tab bar */
|
||||
.gallery-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.gallery-tab {
|
||||
padding: 8px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--fg);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.gallery-tab:hover { opacity: 0.85; }
|
||||
.gallery-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--red);
|
||||
}
|
||||
|
||||
/* Narrow modal tab strips should stay on one row. Resized docked windows can
|
||||
be much narrower than the viewport, so this cannot live only in mobile media
|
||||
queries. */
|
||||
.cookbook-tabs,
|
||||
.memory-tabs,
|
||||
.admin-tabs,
|
||||
.lib-tabs,
|
||||
.gallery-tabs,
|
||||
.preset-tabs {
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cookbook-tabs::-webkit-scrollbar,
|
||||
.memory-tabs::-webkit-scrollbar,
|
||||
.admin-tabs::-webkit-scrollbar,
|
||||
.lib-tabs::-webkit-scrollbar,
|
||||
.gallery-tabs::-webkit-scrollbar,
|
||||
.preset-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.cookbook-tabs > *,
|
||||
.memory-tabs > *,
|
||||
.admin-tabs > *,
|
||||
.lib-tabs > *,
|
||||
.gallery-tabs > *,
|
||||
.preset-tabs > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cookbook-tab,
|
||||
.memory-tab,
|
||||
.admin-tab,
|
||||
.lib-tab,
|
||||
.gallery-tab,
|
||||
.preset-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.gallery-tab {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@container (max-width: 360px) {
|
||||
.cookbook-tab:has(svg),
|
||||
.memory-tab:has(svg),
|
||||
.admin-tab:has(svg),
|
||||
.lib-tab:has(svg),
|
||||
.gallery-tab:has(svg),
|
||||
.preset-tab:has(svg) {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.cookbook-tab:has(svg) svg,
|
||||
.memory-tab:has(svg) svg,
|
||||
.admin-tab:has(svg) svg,
|
||||
.lib-tab:has(svg) svg,
|
||||
.gallery-tab:has(svg) svg,
|
||||
.preset-tab:has(svg) svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0 !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.memory-tab:has(svg) .memory-count,
|
||||
.gallery-tab:has(svg) .gallery-tab-label,
|
||||
.gallery-tab:has(svg) .gallery-tab-close,
|
||||
.cookbook-tab:has(svg) .cookbook-tab-count,
|
||||
.preset-tab:has(svg) .preset-count {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* Icon + label layout inside each tab. */
|
||||
.gallery-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-tab-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.gallery-tab.active .gallery-tab-icon { opacity: 1; }
|
||||
/* Close × on the Edit tab — appears on hover. */
|
||||
.gallery-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
/* Use a slightly larger glyph at a larger line-height so the × sits
|
||||
visually centered — the U+00D7 character has uneven em-box metrics. */
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding-bottom: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.gallery-tab:hover .gallery-tab-close,
|
||||
.gallery-tab.active .gallery-tab-close { opacity: 0.65; }
|
||||
/* Hide the close × entirely on the Edit tab when there's no edit open. */
|
||||
.gallery-tab[data-tab="editor"]:not(.has-edit) .gallery-tab-close { display: none !important; }
|
||||
.gallery-tab-close:hover { opacity: 1 !important; background: color-mix(in srgb, var(--red) 25%, transparent); color: var(--red); }
|
||||
/* Inline rename input shown when the Edit tab is double-clicked. */
|
||||
.gallery-tab-rename-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 3px;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 1px 4px;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Albums tab — grid of album cards with cover thumbnails. */
|
||||
.gallery-albums-container {
|
||||
padding: 12px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
border: 2px dashed transparent;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.gallery-settings-container {
|
||||
padding: 8px 4px;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-albums-container.gallery-dragover {
|
||||
border-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 5%, transparent);
|
||||
}
|
||||
.gallery-albums-empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 48px 16px;
|
||||
opacity: 0.7;
|
||||
font-size: 13px;
|
||||
}
|
||||
.gallery-albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery-album-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.gallery-album-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--red);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.gallery-album-menu-btn {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
width: 24px; height: 24px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg) 70%, transparent);
|
||||
color: var(--fg);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
z-index: 2;
|
||||
backdrop-filter: blur(4px);
|
||||
/* Plain "…" text — the previous SVG was rendering as a flat black
|
||||
block on some themes when its currentColor didn't contrast. */
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
.gallery-album-card:hover .gallery-album-menu-btn,
|
||||
.gallery-album-menu-btn:focus-visible { opacity: 1; }
|
||||
.gallery-album-menu-btn:hover { background: var(--bg); }
|
||||
.gallery-album-menu-pop {
|
||||
position: absolute;
|
||||
top: 34px; right: 6px;
|
||||
min-width: 140px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 4px;
|
||||
z-index: 3;
|
||||
}
|
||||
.gallery-album-menu-pop[hidden] { display: none; }
|
||||
.gallery-album-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
background: color-mix(in srgb, var(--fg) 5%, var(--bg));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-album-placeholder {
|
||||
opacity: 0.4;
|
||||
font-size: 40px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.gallery-album-info {
|
||||
padding: 8px 10px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.gallery-album-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-album-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.gallery-album-card-add {
|
||||
border-style: dashed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.gallery-album-card-add:hover { opacity: 1; }
|
||||
.gallery-album-action-tile {
|
||||
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
|
||||
}
|
||||
|
||||
/* Edit-tab empty state — shown before any image is loaded. */
|
||||
.gallery-editor-landing {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 28px 16px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-landing h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.gallery-editor-heading > svg { opacity: 0.6; flex: 0 0 auto; }
|
||||
/* "vision model" link in the AI-tagging description → opens Settings. */
|
||||
.ge-vision-link {
|
||||
color: var(--accent, var(--red));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ge-vision-link:hover { opacity: 0.8; }
|
||||
.ge-alpha-tag {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 15%, transparent);
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
display: flex; gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Bigger primary action buttons specifically inside the editor landing —
|
||||
the gallery-wide .gallery-select-btn is a compact toolbar style which is
|
||||
too small for the empty-state hero. */
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
padding: 7px 22px 17px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Visible template picker: every canvas ratio is inspectable without opening
|
||||
a native menu, and its SVG outline previews the resulting canvas shape. */
|
||||
.gallery-editor-template-picker {
|
||||
width: min(760px, 96%);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.gallery-editor-template-picker h4 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(105px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-template-option {
|
||||
height: 72px;
|
||||
min-width: 0;
|
||||
padding: 6px 7px;
|
||||
display: grid;
|
||||
grid-template-rows: 28px auto auto;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gallery-editor-template-option svg { color: var(--accent, var(--red)); }
|
||||
.gallery-editor-template-option:hover,
|
||||
.gallery-editor-template-option:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 7%, var(--panel));
|
||||
}
|
||||
.gallery-editor-template-option span {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gallery-editor-template-option small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
opacity: 0.52;
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.gallery-editor-landing {
|
||||
justify-content: flex-start;
|
||||
gap: 7px;
|
||||
padding: 18px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.gallery-editor-landing-actions {
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
padding: 6px 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gallery-editor-template-picker {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.gallery-editor-template-options {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
/* The common square, widescreen, portrait, and Instagram presets are
|
||||
enough to start on a phone. Keep the print and 4K presets on desktop. */
|
||||
.gallery-editor-template-option:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Saved-drafts grid on the editor landing. Surfaces every persisted
|
||||
in-progress project so the user can resume without re-opening the
|
||||
original photo. Each card: thumbnail + name + last-saved hint + ×. */
|
||||
.gallery-editor-drafts {
|
||||
width: min(720px, 92%);
|
||||
margin: 28px auto 0; /* centered */
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-editor-drafts {
|
||||
position: relative;
|
||||
}
|
||||
.gallery-editor-drafts-loading {
|
||||
position: absolute;
|
||||
inset: 30px 0 0 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.gallery-editor-drafts-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center; /* center the title / search / select */
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.55;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
height: 26px; /* same thickness as the Select button */
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-search:focus { outline: none; border-color: var(--red); }
|
||||
/* The shared .gallery-select-btn has a -4px top margin + tall asymmetric padding
|
||||
meant for the main gallery toolbar; in this header it makes the button a
|
||||
different height / offset from the search box. Normalize so it lines up. */
|
||||
.gallery-editor-drafts-header .gallery-select-btn {
|
||||
margin-top: 0 !important;
|
||||
height: 26px; /* match the search bar */
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cookbook-official-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--fg);
|
||||
font-size: 10px;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.cookbook-official-filter > span:first-child {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 9px;
|
||||
}
|
||||
.cookbook-official-filter .toggle {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
.cookbook-official-filter .toggle .slider:before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
.cookbook-official-filter .toggle input:checked + .slider:before {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
#gallery-editor-drafts-select { margin-left: auto; flex-shrink: 0; }
|
||||
#gallery-editor-drafts-select.active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
|
||||
border-color: var(--accent, var(--red));
|
||||
color: var(--accent, var(--red));
|
||||
}
|
||||
/* Graceful exit when a project card is deleted — fade + shrink before re-render. */
|
||||
.gallery-editor-draft-card.gallery-draft-removing {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
transition: opacity 0.24s ease, transform 0.24s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Draft select dot uses the standard small .gallery-select-dot look (same as the
|
||||
photo grid) — no oversized white-ring checkbox. */
|
||||
.gallery-editor-drafts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.gallery-drafts-just-opened > .gallery-editor-draft-card {
|
||||
animation: section-domino-in 0.36s cubic-bezier(0.22, 1.61, 0.36, 1) backwards;
|
||||
}
|
||||
.gallery-drafts-just-opened > :nth-child(1) { animation-delay: 0.02s; }
|
||||
.gallery-drafts-just-opened > :nth-child(2) { animation-delay: 0.04s; }
|
||||
.gallery-drafts-just-opened > :nth-child(3) { animation-delay: 0.06s; }
|
||||
.gallery-drafts-just-opened > :nth-child(4) { animation-delay: 0.08s; }
|
||||
.gallery-drafts-just-opened > :nth-child(5) { animation-delay: 0.10s; }
|
||||
.gallery-drafts-just-opened > :nth-child(6) { animation-delay: 0.12s; }
|
||||
.gallery-drafts-just-opened > :nth-child(7) { animation-delay: 0.14s; }
|
||||
.gallery-drafts-just-opened > :nth-child(8) { animation-delay: 0.16s; }
|
||||
.gallery-drafts-just-opened > :nth-child(9) { animation-delay: 0.18s; }
|
||||
.gallery-drafts-just-opened > :nth-child(10) { animation-delay: 0.20s; }
|
||||
.gallery-drafts-just-opened > :nth-child(11) { animation-delay: 0.22s; }
|
||||
.gallery-drafts-just-opened > :nth-child(12) { animation-delay: 0.24s; }
|
||||
.gallery-drafts-just-opened > :nth-child(13) { animation-delay: 0.26s; }
|
||||
.gallery-drafts-just-opened > :nth-child(14) { animation-delay: 0.28s; }
|
||||
.gallery-drafts-just-opened > :nth-child(15) { animation-delay: 0.30s; }
|
||||
.gallery-drafts-just-opened > :nth-child(16) { animation-delay: 0.32s; }
|
||||
.gallery-drafts-just-opened > :nth-child(17) { animation-delay: 0.34s; }
|
||||
.gallery-drafts-just-opened > :nth-child(18) { animation-delay: 0.36s; }
|
||||
.gallery-drafts-just-opened > :nth-child(19) { animation-delay: 0.38s; }
|
||||
.gallery-drafts-just-opened > :nth-child(20) { animation-delay: 0.40s; }
|
||||
.gallery-drafts-just-opened > :nth-child(n+21) { animation-delay: 0.42s; }
|
||||
.gallery-drafts-view-toggle {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.gallery-drafts-view-toggle button {
|
||||
width: 25px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
}
|
||||
.gallery-drafts-view-toggle button:last-child { border-right: 0; }
|
||||
.gallery-drafts-view-toggle button:hover { color: var(--fg); }
|
||||
.gallery-drafts-view-toggle button.active {
|
||||
height: 22px;
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-thumb {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete {
|
||||
position: static;
|
||||
opacity: 0.55;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.gallery-editor-draft-delete-trash { display: none; }
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-trash {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-error, #f44);
|
||||
background: color-mix(in srgb, var(--color-error, #f44) 10%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.gallery-editor-draft-card:hover {
|
||||
border-color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||
}
|
||||
.gallery-editor-draft-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-draft-thumb-empty {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
color-mix(in srgb, var(--fg) 4%, transparent),
|
||||
color-mix(in srgb, var(--fg) 4%, transparent) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
}
|
||||
.gallery-editor-draft-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.gallery-editor-draft-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-meta {
|
||||
font-size: 10px;
|
||||
opacity: 0.55;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
.gallery-editor-draft-delete {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.gallery-editor-draft-delete:hover { background: var(--red); }
|
||||
/* On very narrow phones, give project search its own row instead of
|
||||
compressing the title and view controls into an overflowing header. */
|
||||
@media (max-width: 360px) {
|
||||
.gallery-editor-drafts-header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.gallery-editor-drafts-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.gallery-editor-drafts-search {
|
||||
order: 10;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
#gallery-editor-drafts-select {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
/* Editor layout */
|
||||
.gallery-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Prevent the editor's own scroll/touch from chaining out to the
|
||||
gallery modal or the page body — critical on mobile so touching
|
||||
the canvas doesn't slide the whole page. */
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
/* Pin the topbar to the top of the editor so its action buttons remain
|
||||
visible if the gallery modal body itself ends up scrolling. */
|
||||
.ge-topbar { position: sticky; top: 0; z-index: 5; }
|
||||
|
||||
/* Top bar */
|
||||
.ge-topbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: thin;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.ge-topbar-left, .ge-topbar-right {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ge-topbar-right > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ge-topbar .dropdown[popover] {
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
bottom: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-draft-status {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: color-mix(in srgb, var(--fg) 52%, transparent);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
@@ -0,0 +1,980 @@
|
||||
/* Document library and editor, image gallery, image editor.
|
||||
*
|
||||
* This is an eagerly loaded tail segment of the app stylesheet cascade. The
|
||||
* former static/style.css now lives in the numbered 00-17 fragments; these
|
||||
* three pre-existing panel files follow those fragments without changing
|
||||
* their relative order. Nothing here is lazy-loaded.
|
||||
*
|
||||
* LOAD ORDER IS PART OF THE CONTRACT. static/index.html and the PRECACHE list
|
||||
* in static/sw.js must agree:
|
||||
* 1. static/css/00-tokens.css through static/css/17-research.css,
|
||||
* in numeric order
|
||||
* 2. static/css/documents-gallery-editor.css
|
||||
* 3. static/css/email-calendar-notes-tasks.css
|
||||
* 4. static/css/cookbook-research-memory-settings.css
|
||||
*
|
||||
* Rules in this file retain their existing cascade position. Shared rules
|
||||
* formerly kept in static/style.css now live in the numbered fragments.
|
||||
*/
|
||||
|
||||
/* Document library search-term highlight — clear, high-contrast. */
|
||||
mark.doclib-search-hl {
|
||||
background: var(--accent, #e8a830);
|
||||
color: var(--bg, #1a1a1a);
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Research bulk bar — right-align the action buttons (keep All + count on
|
||||
the left). Cancel now lives on the Select toggle, so Archive anchors. */
|
||||
#doclib-research-bulk #doclib-research-bulk-archive {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Drafts bulk bar defaults to justify-content:flex-end (whole row hugs the
|
||||
right). Reset it so All + count sit on the left and only the action button
|
||||
is pushed right — matching every other bulk bar. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-divider.doc-divider-handle-idle .doc-divider-collapse {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-divider:hover .doc-divider-collapse { opacity: 0.92; }
|
||||
/* Smooth entrance — slide in from the left + fade up so it doesn't snap into
|
||||
place when fullscreen activates. The chevron's vertical centering uses
|
||||
translateY(-50%), so the animation has to keep that part. */
|
||||
@keyframes doc-fs-chevron-in {
|
||||
from { opacity: 0; transform: translateY(-50%) translateX(-18px); }
|
||||
to { opacity: 0.85; transform: translateY(-50%) translateX(0); }
|
||||
}
|
||||
.doc-editor-pane.doc-pane-leaving {
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pane-leave {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(24px); opacity: 0; }
|
||||
}
|
||||
.doc-tab-scroll.is-at-left {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 18px), transparent 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-right {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 18px, #000 100%);
|
||||
}
|
||||
.doc-tab-scroll.is-at-left.is-at-right {
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.doc-tab-scroll::-webkit-scrollbar { display: none; }
|
||||
#doc-tab-right,
|
||||
#doc-tab-left {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
/* Ghost tab shown in the empty state (new, not-yet-saved document). Muted +
|
||||
italic so it reads as a placeholder, and non-interactive so clicking it
|
||||
can't hit the tab handlers (it has no data-doc-id). */
|
||||
.doc-tab.doc-tab-ghost {
|
||||
/* New, not-yet-saved doc tab. It already carries .active, so it shows the
|
||||
accent underline like any active tab — that's all we want. The old dashed
|
||||
border + italic/dim "pending" styling looked weird, so they're gone. */
|
||||
pointer-events: none;
|
||||
}
|
||||
.doc-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--red);
|
||||
}
|
||||
@keyframes doc-stream-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.doc-updated-flash {
|
||||
animation: doc-update-flash 0.6s ease-out;
|
||||
}
|
||||
@keyframes doc-update-flash {
|
||||
0% { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||
100% { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.doc-langpicker-trigger.doc-langpicker-email-compact {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0 0 0 6px;
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
/* The "T" icon stays centered in the fixed-size button so it grows
|
||||
symmetrically (from the center) instead of jumping around as the size
|
||||
changes. The S/M/L letter is pinned to the bottom-right corner, absolutely
|
||||
positioned so it never shifts the icon either. */
|
||||
#doc-fontsize-btn { transform: translateY(-1px); }
|
||||
#doc-fontsize-btn svg { flex: 0 0 auto; }
|
||||
.doc-overflow-wrapper { order: -1; }
|
||||
.doc-overflow-toggle { opacity: 0.5; }
|
||||
.doc-md-toolbar.md-preview-active,
|
||||
.doc-md-toolbar.md-write-active {
|
||||
overflow: visible;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.doc-md-toolbar #doc-render-view-toggle .md-view-opt {
|
||||
height: 28px;
|
||||
}
|
||||
/* Some toolbar buttons baked in svg{opacity:0.7} via inline styles, which
|
||||
compounded with the button's own opacity to look almost invisible.
|
||||
Force the inline svg opacity back to 1 inside the toolbar — the button
|
||||
wrapper's 0.85 is the only fade we need. */
|
||||
.doc-md-toolbar button svg[style*="opacity"] { opacity: 1 !important; }
|
||||
/* No black tap-flash / focus background on the doc toolbar + tab controls —
|
||||
they're icon buttons, the opacity change is the only feedback we want. */
|
||||
.doc-md-toolbar button,
|
||||
.doc-tab-new,
|
||||
.doc-tab-arrow,
|
||||
.doc-tab-play {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
/* Pull the icon down toward the "sign" label so they read as one unit. */
|
||||
#doc-pdf-add-sign-btn svg { transform: translateY(2px); }
|
||||
.doc-outline-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-outline-target {
|
||||
animation: doc-outline-target-flash 1.05s ease-out;
|
||||
}
|
||||
@keyframes doc-outline-target-flash {
|
||||
0%, 28% { background-color: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
.doc-rich-slash-count {
|
||||
opacity: 0.48;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-rich-slash-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Find marks live in the syntax-highlight overlay, which sits at
|
||||
z-index:0 under a transparent textarea — so they're always visible
|
||||
through the text layer. The previous color-mix variant could
|
||||
compute to near-invisible on themes with dark/desaturated accents;
|
||||
forced solid colors + !important so it ALWAYS pops. */
|
||||
mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
.doc-editor-highlight::-webkit-scrollbar { display: none; }
|
||||
/* Arrow pointing toward the editor */
|
||||
.doc-suggestion-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--panel);
|
||||
border-left: 2px solid var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
transform: rotate(45deg);
|
||||
z-index: 1;
|
||||
}
|
||||
.doc-suggestion-close::before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2 2L8 8M8 2L2 8' stroke='black' stroke-width='1.8' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.doc-suggestion-accept:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.doc-editor-wrap.animating::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 6px; right: 8px;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
animation: doc-pulse 0.8s ease-in-out infinite;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes doc-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
}
|
||||
.doc-diff-content { }
|
||||
@media (hover: none) {
|
||||
.doc-preview-hover-edit {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
#doclib-research-search { position: relative; top: -1.5px; }
|
||||
/* Restore sits on the right of the Delete in archive previews. */
|
||||
.doclib-chat-restore-btn { margin-left: auto; }
|
||||
/* Delete + Archive pin to the left of the actions row; the
|
||||
`margin-right: auto` on the last left-side button (Archive) pushes
|
||||
Copy + Open to the right. Delete sits furthest left. */
|
||||
.doclib-chat-archive-btn { margin-right: auto; }
|
||||
@keyframes doclib-expand {
|
||||
from { opacity: 0.5; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Hide the section headers while a card is expanded (full-screen reader). */
|
||||
.doclib-grid:has(.doclib-card-expanded) > .skills-section-header { display: none; }
|
||||
.skill-card:not(.doclib-card-expanded) .skill-card-tags { display: none; }
|
||||
.doclib-chip-scroll-frame > .chip-scroll-strip:empty {
|
||||
display: none;
|
||||
}
|
||||
.skill-card.doclib-card-expanded .skill-kebab-btn { display: none; }
|
||||
.skill-card.doclib-card-expanded .skill-chevron-up { display: inline-flex; }
|
||||
/* Size the expanded skill card to its content (footer sits right under the
|
||||
preview) but cap at the modal height so a long SKILL.md scrolls instead
|
||||
of overflowing. The inherited `.doclib-card-expanded { flex: 1 }` forced
|
||||
the card to fill the whole modal, which left a big empty void between a
|
||||
short skill's text and the footer. */
|
||||
.skill-card.doclib-card-expanded {
|
||||
flex: 0 1 auto !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.doclib-inline-load-more::before {
|
||||
display: none;
|
||||
}
|
||||
#doclib-selected-count {
|
||||
color: color-mix(in srgb, var(--fg) 50%, transparent);
|
||||
font-size: 10px;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#gallery-bulk-count,
|
||||
#gallery-editor-drafts-bulk-count {
|
||||
top: -1px !important;
|
||||
}
|
||||
#gallery-select-btn { position: relative; top: 4px !important; font-size: 12px; padding: 10px 11px 12px; min-width: 76px; justify-content: center; }
|
||||
#gallery-albums-search { top: -4px; }
|
||||
#gallery-albums-select-btn { top: -4px !important; }
|
||||
#gallery-bulk-delete,
|
||||
#gallery-albums-bulk-delete,
|
||||
#gallery-editor-drafts-bulk-delete {
|
||||
color: var(--color-error, #f44) !important;
|
||||
}
|
||||
.gallery-detail-image video {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
display: block;
|
||||
}
|
||||
.gallery-card:hover .gallery-fav-btn { opacity: 1; }
|
||||
.gallery-card:hover .gallery-dl-btn { opacity: 1; }
|
||||
@media (hover: none) {
|
||||
.gallery-dl-btn { opacity: 0.55; }
|
||||
}
|
||||
/* Space the user's tag chips off the "add tag" input below them. */
|
||||
#gallery-user-tag-chips { margin-bottom: 4px; }
|
||||
.gallery-user-chip:hover .gallery-tag-x { opacity: 0.85; }
|
||||
@keyframes gallery-skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.gallery-card:hover .gallery-card-overlay,
|
||||
.gallery-card.gallery-card-focus .gallery-card-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Strong accent ring on the whole photo when selected. Paint it as a
|
||||
foreground pseudo-element so the thumbnail cannot cover the feedback. */
|
||||
.gallery-card:has(.gallery-select-dot.selected) {
|
||||
border-color: var(--accent-primary, var(--red));
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
border: 3px solid var(--accent-primary, var(--red));
|
||||
border-radius: inherit;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gallery-card:has(.gallery-select-dot.selected) .gallery-select-dot {
|
||||
z-index: 4;
|
||||
}
|
||||
.gallery-detail-action.danger:hover {
|
||||
border-color: var(--color-error, #e55);
|
||||
}
|
||||
.gallery-detail-image img,
|
||||
.gallery-detail-image video,
|
||||
.gallery-detail-img-frame img,
|
||||
.gallery-detail-img-frame video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-nav { opacity: 0.85; }
|
||||
@media (min-width: 769px) {
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) {
|
||||
height: 92vh;
|
||||
}
|
||||
.gallery-modal-content:has(#gallery-detail[style*="flex"]) > .modal-body,
|
||||
.gallery-images-container:has(> #gallery-detail[style*="flex"]),
|
||||
#gallery-images-container:has(> #gallery-detail[style*="flex"]) > #gallery-detail {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
|
||||
.gallery-detail-nav-disabled {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.gallery-detail-nav { opacity: 0.7; }
|
||||
}
|
||||
.gallery-detail-inline-link:hover { text-decoration: underline; }
|
||||
.gallery-detail-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.gallery-tag-input-wrap .gallery-tag-input { padding-right: 24px; }
|
||||
.gallery-name-wrap:focus-within .gallery-name-enter { display: inline-flex; }
|
||||
.gallery-name-wrap:focus-within .gallery-detail-name-input { padding-right: 28px; }
|
||||
.gallery-album-cover img {
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.gallery-editor-landing p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
font-size: 13px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.gallery-editor-landing-actions .gallery-select-btn:hover { opacity: 1; }
|
||||
/* Saved-project selection uses the same bulk-bar rhythm as Photos. */
|
||||
#gallery-editor-drafts-bulk {
|
||||
margin-top: 0;
|
||||
}
|
||||
.gallery-editor-draft-card.selected {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.gallery-editor-draft-card.select-mode { cursor: pointer; }
|
||||
.gallery-drafts-view-toggle button svg {
|
||||
display: block;
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-card {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 24px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 48px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.gallery-editor-drafts-grid.list-view .gallery-editor-draft-delete-x { display: none; }
|
||||
.gallery-editor-draft-card:focus-visible {
|
||||
outline: 2px solid var(--accent, var(--red));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gallery-editor-draft-card:hover .gallery-editor-draft-delete { opacity: 1; }
|
||||
.ge-topbar.ge-topbar-menu-open { z-index: 10006; }
|
||||
/* Toolbar (left) */
|
||||
@keyframes ge-layer-action-flash {
|
||||
from { box-shadow: inset 0 0 0 2px var(--red); background-color: color-mix(in srgb, var(--red) 24%, transparent); }
|
||||
to { box-shadow: inset 0 0 0 2px transparent; }
|
||||
}
|
||||
.ge-layer-action-flash { animation: ge-layer-action-flash 650ms ease-out; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ge-layer-action-flash { animation-duration: 1ms; }
|
||||
}
|
||||
.ge-tool-btn .ge-tool-label {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ge-tool-btn:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-tool-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/* Highlight pill shifted 2 px left of the button bounds so it sits
|
||||
under the icon + label (which are also nudged left). Same total
|
||||
width as before — just slid. */
|
||||
inset: 0 2px 0 -2px;
|
||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||
border-radius: 6px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ge-btn-ai:hover .ge-btn-ai-mark { opacity: 1; }
|
||||
.ge-tool-btn.has-selection .ge-tool-clear { display: inline-flex; }
|
||||
.ge-canvas-area.ge-rulers-visible {
|
||||
padding: 18px 0 0 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ge-main-canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
.ge-canvas-area.ge-pan-ready,
|
||||
.ge-canvas-area.ge-pan-ready .ge-main-canvas {
|
||||
cursor: grab !important;
|
||||
}
|
||||
.ge-canvas-area.ge-panning,
|
||||
.ge-canvas-area.ge-panning .ge-main-canvas {
|
||||
cursor: grabbing !important;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
.ge-ai-command-collapsed .ge-ai-command-head,
|
||||
.ge-ai-command-collapsed .ge-ai-command-form,
|
||||
.ge-ai-command-collapsed .ge-ai-command-suggestions,
|
||||
.ge-ai-command-collapsed .ge-ai-command-status {
|
||||
display: none;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.ge-ai-command-collapsed {
|
||||
bottom: 8px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
.ge-ai-command-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-busy .ge-ai-command-run::after {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, #fff 42%, transparent);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: ge-ai-command-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes ge-ai-command-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.ge-ai-command-suggestions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.ge-ai-command-status:empty {
|
||||
display: none;
|
||||
}
|
||||
/* Each shortcut row: key chips + description. The key chips are usually
|
||||
chained with "+" so we use a small horizontal gap between siblings,
|
||||
plus a larger left margin on the trailing text via the kbd:last-of-type
|
||||
sibling rule — keeps "Ctrl+Shift+D Deselect" readable instead of glued. */
|
||||
.ge-shortcuts-col > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 0;
|
||||
opacity: 0.85;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Push the description text away from the last kbd by adding margin
|
||||
to any text node that follows a kbd. Flex+wrap handles wrap on narrow. */
|
||||
.ge-shortcuts-col > div kbd + kbd { margin-left: 0; }
|
||||
.ge-shortcuts-col > div kbd:last-of-type { margin-right: 6px; }
|
||||
.ge-shortcuts-card kbd,
|
||||
#ge-shortcuts-popover kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #cc6a3a) 55%, transparent);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent, #cc6a3a) 14%, transparent);
|
||||
color: var(--accent, #cc6a3a);
|
||||
min-width: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ge-quick-mask-bar[hidden] { display: none; }
|
||||
.ge-marquee-dimensions[hidden] { display: none; }
|
||||
.ge-layer-geometry-section .ge-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ge-layer-geometry-grid input::-webkit-inner-spin-button,
|
||||
.ge-layer-geometry-grid input::-webkit-outer-spin-button { appearance: none; }
|
||||
#ge-brush-controls select,
|
||||
#ge-eyedropper-section select {
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
.ge-gradient-extra-stop .ge-text-field { min-width: 0; }
|
||||
.ge-shape-gradient-add-stop { align-self: flex-start; }
|
||||
.ge-text-font-field { grid-column: span 1; }
|
||||
#ge-text-rasterize { align-self: flex-end; }
|
||||
.ge-shape-types .ge-text-toggle { flex: 1 1 25%; }
|
||||
#ge-shape-rasterize { align-self: flex-end; }
|
||||
.ge-direct-text-editor:focus { outline: 1px solid color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.ge-transform-popup.ge-transform-popup-dragging .ge-transform-popup-head { cursor: grabbing; }
|
||||
/* Force the title to take all middle space so the [_][×] cluster
|
||||
stays pinned to the right edge. */
|
||||
.ge-transform-popup-head .ge-adj-title { flex: 1 1 auto !important; }
|
||||
.ge-transform-popup-minimised .ge-transform-popup-body,
|
||||
.ge-transform-popup-minimised .ge-transform-popup-hint {
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-popup-input::-webkit-outer-spin-button,
|
||||
.ge-transform-popup-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
.ge-transform-position-field .ge-transform-popup-input { width: 68px; }
|
||||
.ge-transform-input-locked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ge-transform-input-locked + .ge-transform-spin { display: none; }
|
||||
.ge-transform-preserve-field[hidden] { display: none; }
|
||||
/* Hide the controls wrapper entirely when every tool section inside is
|
||||
hidden — otherwise the padding + border draws an empty band above the
|
||||
layers panel even when no tool needs sliders/buttons. */
|
||||
.ge-controls:not(:has(> *:not([style*="display: none"]):not([style*="display:none"]))) {
|
||||
display: none;
|
||||
}
|
||||
/* Strip the native <input type="color"> chrome (border + padding around
|
||||
the swatch in each engine) so the visible color fills the circle. */
|
||||
.ge-color-picker::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-color-picker::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ge-color-picker.cp-swatch-input::selection { background: transparent; }
|
||||
.ge-size-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-webkit-slider-thumb,
|
||||
.ge-size-slider.is-using::-webkit-slider-thumb,
|
||||
.ge-size-slider:active::-webkit-slider-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-size-slider::-moz-range-thumb {
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none; cursor: pointer;
|
||||
}
|
||||
.ge-size-slider:hover::-moz-range-thumb,
|
||||
.ge-size-slider.is-using::-moz-range-thumb,
|
||||
.ge-size-slider:active::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
}
|
||||
.ge-eraser-row label > span[id$="-label"]:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
}
|
||||
.ge-eraser-row > span[id$="-label"].ge-slider-value:hover {
|
||||
background: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
.ge-merge-menu[hidden] { display: none; }
|
||||
/* Inpaint Brush size slider — slightly larger thumb than the rest of
|
||||
the panel since it's the primary interactive control. */
|
||||
#ge-inpaint-brush-slider::-webkit-slider-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-webkit-slider-thumb,
|
||||
#ge-inpaint-brush-slider:active::-webkit-slider-thumb { width: 18px; height: 18px; }
|
||||
#ge-inpaint-brush-slider::-moz-range-thumb { width: 12px; height: 12px; }
|
||||
#ge-inpaint-brush-slider:hover::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider.is-using::-moz-range-thumb,
|
||||
#ge-inpaint-brush-slider:active::-moz-range-thumb { width: 18px; height: 18px; }
|
||||
.ge-adj-popup .ge-adj-title { flex: 1 1 auto; }
|
||||
.ge-adj-popup .ge-adj-head:active { cursor: grabbing; }
|
||||
.ge-fx-dock-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.ge-history-head:active { cursor: grabbing; }
|
||||
@keyframes ge-canvas-spin { to { transform: rotate(360deg); } }
|
||||
.ge-inline-swatch::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.ge-inline-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-inline-swatch::-moz-color-swatch { border: none; border-radius: 2px; }
|
||||
.ge-adj-cb-row .ge-adj-cb-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
display: inline-block; flex-shrink: 0;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg) 20%, transparent);
|
||||
}
|
||||
.ge-adj-cb-tone-picker {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.ge-adj-hist-details {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ge-adj-hist-details > summary {
|
||||
font-size: 11px;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.ge-adj-hist-details > summary::-webkit-details-marker { display: none; }
|
||||
.ge-adj-hist-details > summary::before {
|
||||
content: '▸ ';
|
||||
display: inline-block;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ge-mask-sub-item.active .ge-layer-name { opacity: 1; font-style: normal; font-weight: 600; }
|
||||
.ge-mask-sub-item.active .ge-mask-inline-thumb { opacity: 1; }
|
||||
/* Opacity preview — translucent disk */
|
||||
#ge-eraser-preview-opacity { opacity: 0.5; }
|
||||
/* Flow preview — dotted ring to suggest discrete stamping */
|
||||
#ge-eraser-preview-flow {
|
||||
background: none;
|
||||
border: 2px dotted var(--fg);
|
||||
opacity: 0.7;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
/* Softness preview — blurred edge */
|
||||
#ge-eraser-preview-softness {
|
||||
background: radial-gradient(circle, var(--fg) 0%, var(--fg) 30%, transparent 75%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Wand feather preview — solid disk that visibly softens at its edge
|
||||
as the user drags the Feather slider. JS sets --feather-blur. */
|
||||
#ge-wand-feather-preview {
|
||||
filter: blur(var(--feather-blur, 0px));
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ge-mask-vis-btn.visible:hover { opacity: 1; }
|
||||
.ge-topbar .ge-btn,
|
||||
.ge-topbar .ge-btn-sm { text-transform: uppercase; }
|
||||
.ge-stacked-btn .ge-stacked-glyph {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
}
|
||||
.ge-layer-selection-bar[hidden] { display: none; }
|
||||
.ge-layer-selection-bar .ge-icon-btn:hover { opacity: 1; }
|
||||
.ge-save-menu[hidden] { display: none; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon[hidden] { display: none !important; }
|
||||
.ge-stacked-btn .ge-stacked-glyph .ge-save-state-icon:not([hidden]) { display: block; }
|
||||
.ge-resize-menu[hidden],
|
||||
.ge-edge-menu[hidden],
|
||||
.ge-view-menu[hidden],
|
||||
.ge-image-menu[hidden],
|
||||
.ge-selection-menu[hidden],
|
||||
.ge-filter-menu[hidden] { display: none; }
|
||||
.ge-selection-save-row .ge-icon-btn { width: 28px; height: 27px; }
|
||||
.ge-export-matte .ge-color-picker { width: 28px; height: 28px; }
|
||||
.ge-export-footer > div { display: flex; gap: 6px; }
|
||||
.ge-export-estimate { opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||
.ge-canvas-prompt-custom-label { margin-top: 14px; }
|
||||
.ge-canvas-anchor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 1px;
|
||||
background: color-mix(in srgb, var(--fg) 38%, transparent);
|
||||
}
|
||||
.ge-canvas-anchor.active::after { background: var(--red); }
|
||||
.ge-canvas-lock-option input { accent-color: var(--red); }
|
||||
.ge-canvas-preset-icon { margin-bottom: 1px; }
|
||||
/* Center grip dots so the handle reads as draggable. */
|
||||
.ge-panel-resize::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px;
|
||||
height: 24px;
|
||||
background: color-mix(in srgb, var(--fg) 35%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.ge-slider-bubble[hidden] { display: none; }
|
||||
/* Tail pointing DOWN at the slider (bubble sits above). */
|
||||
.ge-slider-bubble::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -4px;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--red);
|
||||
clip-path: polygon(0 0, 100% 0, 50% 100%);
|
||||
}
|
||||
.ge-layer-thumb canvas {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ge-layers-merge-menu[hidden] { display: none; }
|
||||
.ge-layer-item:not(.active) .ge-layer-inline-thumb { opacity: 0.86; }
|
||||
.ge-layer-opacity-row .ge-layer-lock-btn { flex: 0 0 24px; width: 24px; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.ge-layer-item.grouped { padding-left: calc(20px + (var(--layer-group-depth, 1) - 1) * 14px); }
|
||||
.ge-layer-vis.visible:hover { opacity: 1; }
|
||||
.ge-layer-item.clipped .ge-layer-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* Shrink the slider thumb — the native default is ~16-20 px which looks
|
||||
massive next to a 3 px track in the compact layer row. */
|
||||
.ge-layer-opacity::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
/* Match the track: shrink only after a 0.5s grace period. */
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity::-moz-range-thumb {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--red);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: width 0.12s ease 0.5s, height 0.12s ease 0.5s;
|
||||
}
|
||||
.ge-layer-opacity:hover::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:active::-webkit-slider-thumb,
|
||||
.ge-layer-opacity.dragging::-webkit-slider-thumb,
|
||||
.ge-layer-opacity:hover::-moz-range-thumb,
|
||||
.ge-layer-opacity:active::-moz-range-thumb,
|
||||
.ge-layer-opacity.dragging::-moz-range-thumb {
|
||||
transition: width 0.12s ease 0s, height 0.12s ease 0s;
|
||||
}
|
||||
.ge-layer-controls .ge-mask-density {
|
||||
width: 26px;
|
||||
min-width: 24px;
|
||||
height: 4px;
|
||||
flex: 0 0 26px;
|
||||
align-self: center;
|
||||
}
|
||||
.ge-mask-properties > summary {
|
||||
box-sizing: border-box;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
opacity: 0.4;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.ge-mask-properties > summary::-webkit-details-marker { display: none; }
|
||||
.ge-mask-properties > summary > svg { width: 12px; height: 12px; flex: none; }
|
||||
.ge-mask-sub-item:has(.ge-mask-properties[open]) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
.ge-mask-property-row output { text-align: right; opacity: .7; }
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-group-row > .ge-layer-controls {
|
||||
display: none;
|
||||
}
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active-parent > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-group-row.active > .ge-layer-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
.ge-layer-tools[hidden] { display: none; }
|
||||
.ge-layer-tools .ge-layer-btn { min-width: 26px; }
|
||||
@media (max-width: 700px) {
|
||||
/* Preserve the compact grip while giving long-press reorder a usable
|
||||
touch target. */
|
||||
.ge-layer-drag {
|
||||
width: 28px;
|
||||
min-height: 30px;
|
||||
margin-left: -6px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
.ge-layer-align-menu .ge-layer-lock-option { grid-template-columns: minmax(0, 1fr); }
|
||||
.ge-crop-apply input[type="number"]::-webkit-outer-spin-button,
|
||||
.ge-crop-apply input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ge-brush-cursor::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 3px; height: 3px;
|
||||
margin: -1.5px 0 0 -1.5px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* Marks remain solid so they pop through the otherwise-invisible
|
||||
overlay; outlined extra-bright for the currently-focused one. */
|
||||
body.doc-find-active mark.doc-find-mark {
|
||||
background: var(--accent) !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
body.doc-find-active mark.doc-find-mark.current {
|
||||
background: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--fg) !important;
|
||||
outline: 1px solid var(--fg) !important;
|
||||
}
|
||||
.doc-email-header:not(.doc-email-header-collapsed) .doc-email-collapse-summary {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed {
|
||||
gap: 0;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: none;
|
||||
}
|
||||
/* Documents footer — X + Undo stay on the LEFT, Copy/Export group pushed
|
||||
to the RIGHT. Email composer keeps its original flex-end layout. */
|
||||
#doc-actions-footer {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.doc-save-button[data-save-state="dirty"] .doc-save-state-dirty,
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving,
|
||||
.doc-save-button[data-save-state="saved"] .doc-save-state-saved,
|
||||
.doc-save-button[data-save-state="error"] .doc-save-state-error { display: block; }
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving {
|
||||
animation: doc-save-spin 800ms linear infinite;
|
||||
}
|
||||
@keyframes doc-save-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.doc-save-button[data-save-state="saving"] .doc-save-state-saving { animation: none; }
|
||||
}
|
||||
.doc-stats-popover[hidden] { display: none; }
|
||||
.doc-stats-row strong {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.doc-email-richbody:empty::before {
|
||||
content: "Write your email\2026";
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.doc-rich-empty-import {
|
||||
top: 106px;
|
||||
}
|
||||
}
|
||||
.doc-email-richbody.richtext-mode:empty::before {
|
||||
content: "Start writing\2026";
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul,
|
||||
.doc-email-richbody.richtext-mode ol {
|
||||
padding-left: 1.75em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode ul:not(.rich-checklist) > li,
|
||||
.doc-email-richbody.richtext-mode ol > li {
|
||||
padding-inline-start: 0.2em;
|
||||
}
|
||||
.doc-email-richbody.richtext-mode pre code {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.doc-email-richbody figure.richtext-image .richtext-image-caption {
|
||||
margin-top: 6px;
|
||||
max-width: 100%;
|
||||
color: color-mix(in srgb, var(--fg) 68%, transparent);
|
||||
font-size: 0.84em;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.doc-email-richbody a { color: var(--accent, #4af); }
|
||||
.doc-email-richbody blockquote {
|
||||
margin: 6px 0; padding-left: 10px;
|
||||
border-left: 3px solid var(--border); opacity: 0.8;
|
||||
}
|
||||
.doc-email-richbody p { margin: 0.5em 0; }
|
||||
#doc-email-more-btn svg { opacity: 0.8; }
|
||||
@media (min-width: 769px) {
|
||||
#doc-email-header #doc-email-collapse-btn.doc-email-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
#doc-email-header.doc-email-header-collapsed .doc-email-fields {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
.doclib-section-title-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
color: var(--accent, var(--red));
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -13,7 +13,6 @@
|
||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||
browser-default styling that didn't match the Odysseus theme. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
(function(){
|
||||
@@ -77,19 +76,20 @@
|
||||
}
|
||||
// Apply font early
|
||||
if (t && t.font) {
|
||||
var font = /^(gohu|GohuFont)$/.test(t.font) ? "mono" : t.font;
|
||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
|
||||
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
|
||||
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
|
||||
if (fm[font]) { s.setProperty('--font-family', fm[font]); }
|
||||
else { s.setProperty('--font-family', "'" + font.replace(/'/g,'') + "', sans-serif"); }
|
||||
}
|
||||
// Apply density class on html
|
||||
if (t && t.density && t.density !== 'comfortable') {
|
||||
document.documentElement.classList.add('density-' + t.density);
|
||||
}
|
||||
// Apply UI text-size scale early (global accessibility pref, independent
|
||||
// of the active theme) so there's no flash on load.
|
||||
// The optional UI scale control was removed; clear its legacy state so
|
||||
// old sessions cannot remain stuck at the larger zoom level.
|
||||
try {
|
||||
var _us = localStorage.getItem('odysseus-ui-scale');
|
||||
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
|
||||
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
|
||||
localStorage.removeItem('odysseus-ui-scale');
|
||||
} catch(e){}
|
||||
// Restore the sidebar mode before first paint. Otherwise the icon rail
|
||||
// and full sidebar can both flash visible until sidebar-layout.js runs.
|
||||
@@ -174,6 +174,7 @@
|
||||
'/cookbook': 'Cookbook — Odysseus',
|
||||
'/email': 'Email — Odysseus',
|
||||
'/memory': 'Memory — Odysseus',
|
||||
'/skills': 'Skills — Odysseus',
|
||||
'/gallery': 'Gallery — Odysseus',
|
||||
'/tasks': 'Tasks — Odysseus',
|
||||
'/library': 'Library — Odysseus',
|
||||
@@ -245,10 +246,34 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
|
||||
<link rel="modulepreload" href="/static/js/ui.js">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||
</head>
|
||||
@@ -294,20 +319,20 @@
|
||||
</script>
|
||||
<!-- Memory Management Modal -->
|
||||
<div id="memory-modal" class="modal hidden">
|
||||
<div class="modal-content memory-modal-content" role="dialog" aria-label="Brain" style="background:var(--bg)">
|
||||
<div class="modal-content memory-modal-content" role="dialog" aria-label="Memory" style="background:var(--bg)">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>Brain</h4>
|
||||
<h4><svg data-memory-scope="memory" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg><svg data-memory-scope="skills" hidden width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span id="memory-modal-title-text">Memory</span></h4>
|
||||
<button class="close-btn" id="close-memory-modal" aria-label="Close memory modal">✖</button>
|
||||
</div>
|
||||
<div class="modal-body memory-modal-body">
|
||||
<div class="memory-tabs">
|
||||
<button class="memory-tab active" data-memory-tab="browse"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Memories <span id="memory-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span></button>
|
||||
<button class="memory-tab" data-memory-tab="skills"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Skills <span id="skills-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span></button>
|
||||
<button class="memory-tab active" data-memory-tab="browse" data-memory-scope="memory"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Memories <span id="memory-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span></button>
|
||||
<button class="memory-tab" data-memory-tab="skills" data-memory-scope="skills"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Skills <span id="skills-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span></button>
|
||||
<button class="memory-tab" data-memory-tab="add"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>Add</button>
|
||||
<button class="memory-tab" data-memory-tab="settings"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>
|
||||
</div>
|
||||
<!-- ── Browse tab ── -->
|
||||
<div class="memory-tab-panel" data-memory-panel="browse">
|
||||
<div class="memory-tab-panel" data-memory-panel="browse" data-memory-scope="memory">
|
||||
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Memories <span id="memory-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
@@ -332,11 +357,11 @@
|
||||
<div class="memory-sort-menu" id="memory-sort-menu" role="listbox" hidden></div>
|
||||
</div>
|
||||
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
|
||||
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories" style="position:relative;left:-2px;"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories" style="position:relative;left:-2px;"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="11" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
</div>
|
||||
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" aria-label="Search memories" />
|
||||
<div id="memory-category-filters" class="memory-category-filters">
|
||||
<button class="memory-cat-chip active" data-cat="all">all</button>
|
||||
<button class="skills-summary-chip memory-filter-chip active" data-cat="all"><span>All</span><strong>0</strong></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="memory-bulk-bar" class="memory-bulk-bar hidden">
|
||||
@@ -351,7 +376,7 @@
|
||||
</div>
|
||||
<!-- ── Add tab ── -->
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="add">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="memory">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:var(--accent, var(--red));"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Add Memory</h2>
|
||||
<span style="flex:1"></span>
|
||||
@@ -371,7 +396,7 @@
|
||||
<button type="button" id="new-memory-add-btn" class="theme-io-btn" title="Save this memory" style="flex:none;height:28px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="skills">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:var(--accent, var(--red));"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
|
||||
</div>
|
||||
@@ -406,7 +431,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Skills tab ── -->
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="skills">
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="skills" data-memory-scope="skills">
|
||||
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Skills <span id="skills-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
@@ -420,6 +445,7 @@
|
||||
<select id="skills-sort" class="memory-sort-select">
|
||||
<optgroup label="Sort">
|
||||
<option value="sort:confidence">Confidence</option>
|
||||
<option value="sort:saved-turns">Most saved turns</option>
|
||||
<option value="sort:uses">Most used</option>
|
||||
<option value="sort:alpha">A-Z</option>
|
||||
<option value="sort:recent">Recent</option>
|
||||
@@ -428,6 +454,7 @@
|
||||
<option value="filter:all">All skills</option>
|
||||
<option value="filter:drafts">Drafts only</option>
|
||||
<option value="filter:published">Published only</option>
|
||||
<option value="filter:binned">Bin</option>
|
||||
<option value="filter:conf95">Confidence ≤ 95%</option>
|
||||
<option value="filter:conf90">Confidence ≤ 90%</option>
|
||||
<option value="filter:conf85">Confidence ≤ 85%</option>
|
||||
@@ -436,19 +463,17 @@
|
||||
<option value="filter:conf70">Confidence ≤ 70%</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Audit</button>
|
||||
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:0;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="14" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
</div>
|
||||
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" aria-label="Search skills" />
|
||||
</div>
|
||||
<div id="skills-summary" class="skills-summary-strip" aria-label="Skill audit summary"></div>
|
||||
<div id="skills-audit-panel" class="skills-audit-panel hidden"></div>
|
||||
<div id="skills-bulk-bar" class="memory-bulk-bar hidden">
|
||||
<label class="memory-bulk-check-all" style="position:relative;top:0px;"><input type="checkbox" id="skills-select-all" /> All</label>
|
||||
<span id="skills-selected-count">0 Selected</span>
|
||||
<button id="skills-bulk-publish" class="memory-toolbar-btn" disabled title="Publish selected drafts" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="20 6 9 17 4 12"/></svg>Approve</button>
|
||||
<button id="skills-bulk-audit" class="memory-toolbar-btn" disabled title="Audit selected draft skills"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polygon points="5 3 19 12 5 21 5 3"/></svg>Audit</button>
|
||||
<button id="skills-bulk-delete-nonpassing" class="memory-toolbar-btn danger" disabled title="Delete selected duplicates, generic/irrelevant skills, failed audits, and skills below threshold"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11l4 4"/><path d="M14 11l-4 4"/></svg>Delete non passing</button>
|
||||
<button id="skills-bulk-delete" class="memory-toolbar-btn danger" disabled><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
|
||||
<button id="skills-bulk-delete-nonpassing" class="memory-toolbar-btn danger" disabled title="Move selected duplicates, failed audits, and low-confidence skills to the bin"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11l4 4"/><path d="M14 11l-4 4"/></svg>Bin non passing</button>
|
||||
<button id="skills-bulk-delete" class="memory-toolbar-btn danger" disabled title="Move selected skills to the bin"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Bin</button>
|
||||
<button id="skills-bulk-cancel" class="memory-toolbar-btn" title="Cancel (Esc)" style="padding:3px 6px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div id="skills-list" class="memory-list"></div>
|
||||
@@ -456,14 +481,14 @@
|
||||
</div>
|
||||
<!-- ── Settings tab ── -->
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="settings">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="memory">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract memories</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-memory-toggle" checked /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px">Automatically extract memories from conversations.</span>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="skills">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract skills</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-skills-toggle" /><span class="admin-slider"></span></label>
|
||||
@@ -471,7 +496,7 @@
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Automatically draft reusable skills from your workflows. Audit all can publish passing skills using the threshold below.</span>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">The library can grow; cleanup retires weak/duplicate skills only after review.</span>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="skills">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-approve skills</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-approve-skills-toggle" checked /><span class="admin-slider"></span></label>
|
||||
@@ -485,9 +510,9 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card" data-memory-scope="skills">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M12 5v14"/><polyline points="6 11 12 17 18 11"/><path d="M5 20h14"/></svg>Inject Skills</h2>
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><polyline points="6 11 12 17 18 11"/><path d="M5 20h14"/></svg>Inject Skills</h2>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
|
||||
@@ -645,13 +670,6 @@
|
||||
<option value="spacious">Spacious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Text size</label>
|
||||
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
|
||||
<option value="100">Default</option>
|
||||
<option value="125">Larger</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group" id="theme-frosted-group">
|
||||
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
|
||||
<label class="admin-switch" style="margin-top:4px;">
|
||||
@@ -673,6 +691,8 @@
|
||||
<option value="petals">Petals</option>
|
||||
<option value="sparkles">Sparkles</option>
|
||||
<option value="embers">Embers</option>
|
||||
<option value="starfield-depth">Starfield Depth</option>
|
||||
<option value="ascii-fireflies">ASCII Fireflies</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group" style="flex:0 0 auto;align-items:center;">
|
||||
@@ -692,6 +712,10 @@
|
||||
<label class="theme-fd-label">Size</label>
|
||||
<input type="range" id="theme-bg-size" class="theme-fd-range" min="30" max="250" step="10" value="100">
|
||||
</div>
|
||||
<div class="theme-fd-group" id="theme-bg-speed-group" style="flex:1 1 0;">
|
||||
<label class="theme-fd-label">Speed</label>
|
||||
<input type="range" id="theme-bg-speed" class="theme-fd-range" min="5" max="250" step="5" value="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-top:8px;">
|
||||
@@ -702,8 +726,8 @@
|
||||
</div>
|
||||
<div class="theme-save-error" id="theme-save-error"></div>
|
||||
<div class="theme-io-row" style="margin-top:6px;">
|
||||
<button id="theme-import-btn" class="theme-io-btn" title="Import a theme from JSON">⤒ Import</button>
|
||||
<button id="theme-export-btn" class="theme-io-btn" title="Export current colors as JSON">⤓ Export</button>
|
||||
<button id="theme-import-btn" class="theme-io-btn" title="Import a theme from JSON" style="height:26px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Import</button>
|
||||
<button id="theme-export-btn" class="theme-io-btn" title="Export current colors as JSON" style="height:26px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Export</button>
|
||||
</div>
|
||||
<textarea id="theme-import-area" class="theme-import-area hidden" placeholder="Paste theme JSON here..." rows="3"></textarea>
|
||||
<div class="theme-import-actions hidden" id="theme-import-actions">
|
||||
@@ -739,8 +763,9 @@
|
||||
<button class="icon-rail-btn" id="rail-email" title="Email"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-gallery" title="Gallery"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-archive" title="Library"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M9 7h6M9 11h4"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-memory" title="Brain"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-memory" title="Memory"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-notes" title="Notes"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-skills" title="Skills"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-tasks" title="Tasks"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg></button>
|
||||
<button class="icon-rail-btn" id="rail-theme" title="Theme"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 0 0 20 5 5 0 0 0 5-5 3 3 0 0 0-3-3h-2a3 3 0 0 1-3-3 5 5 0 0 1 5-5"/></svg></button>
|
||||
<div style="flex:1"></div>
|
||||
@@ -772,7 +797,7 @@
|
||||
|
||||
<div class="section" id="sessions-section">
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title" id="chats-section-title"><svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span id="chats-section-label" class="section-title-label">Chats</span><span id="chats-notif-dot" class="sidebar-notif-dot" style="display:none"></span></span>
|
||||
<span class="section-title" id="chats-section-title"><svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span id="chats-section-label" class="section-title-label">Chats</span></span>
|
||||
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
|
||||
<button type="button" class="section-header-btn list-item-plus-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
|
||||
<span aria-hidden="true" style="display:inline-block;width:13px;height:13px;"></span>
|
||||
@@ -792,10 +817,10 @@
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="active">Last Active</div>
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="newest">Newest First</div>
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="group">By Folder</div>
|
||||
<div class="dropdown-item sort-dropdown-item sort-dropdown-sep" id="auto-sort-sessions-row" style="display:flex;align-items:center;padding:0;">
|
||||
<span id="auto-sort-sessions-btn" style="flex:1;padding:5px 10px 5px 4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg></span>
|
||||
<span>Group</span>
|
||||
<div class="dropdown-item sort-dropdown-item sort-dropdown-sep ai-sort-option" id="auto-sort-sessions-row" style="display:flex;align-items:center;padding:0;">
|
||||
<span id="auto-sort-sessions-btn" title="Use AI to organize chats into folders" style="flex:1;padding:5px 10px 5px 4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg></span>
|
||||
<span>AI sort</span>
|
||||
<span class="auto-sort-spinner" style="display:none;">Sorting...</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -803,10 +828,14 @@
|
||||
↑↓ Rearrange <span class="rearrange-check" style="float:right; opacity:0;">•</span>
|
||||
</div>
|
||||
<div class="dropdown-item sort-dropdown-item" id="session-select-from-dropdown">
|
||||
● Select
|
||||
<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Keep the notification at the header's right edge even when the
|
||||
hover-only Manage control is visually hidden. -->
|
||||
<span id="chats-notif-dot" class="sidebar-notif-dot" style="display:none"></span>
|
||||
</div>
|
||||
<div id="session-bulk-bar" class="session-bulk-bar hidden">
|
||||
<span id="session-select-all-dot" style="cursor:pointer;font-size:14px;opacity:0.4;user-select:none;">○</span><input type="checkbox" id="session-select-all" style="display:none;"><span style="font-size:10px;cursor:pointer;opacity:0.5;" id="session-select-all-label">All</span>
|
||||
@@ -824,22 +853,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden dropdown for session actions -->
|
||||
<div id="session-actions-dropdown" class="dropdown hidden">
|
||||
<div class="dropdown-item" id="rename-session-option">
|
||||
<h4>Rename</h4>
|
||||
<p>Change the session name</p>
|
||||
</div>
|
||||
<div class="dropdown-item" id="delete-session-option">
|
||||
<h4>Delete</h4>
|
||||
<p>Remove this session permanently</p>
|
||||
</div>
|
||||
<div class="dropdown-item" id="memory-session-option">
|
||||
<h4>Memory</h4>
|
||||
<p>Extract memories from this session</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="email-section">
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title" id="email-section-title" style="cursor:pointer;" title="Open email inbox"><svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg><span>Email</span></span>
|
||||
@@ -860,16 +873,6 @@
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title"><svg class="section-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Tools</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-memory-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink:0;opacity:0.5;">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
</svg>
|
||||
<span class="grow">Brain</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-calendar-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -920,6 +923,16 @@
|
||||
<span class="list-item-plus-label">document</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-item" id="tool-memory-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink:0;opacity:0.5;">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
</svg>
|
||||
<span class="grow">Memory</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-notes-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
@@ -929,6 +942,18 @@
|
||||
<path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/>
|
||||
</svg>
|
||||
<span class="grow">Notes</span>
|
||||
<button type="button" class="list-item-plus-btn sidebar-new-item-btn" id="notes-new-note-btn" title="New note">
|
||||
<svg class="list-item-plus-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span class="list-item-plus-label">new</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-item" id="tool-skills-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="flex-shrink:0;opacity:0.5;">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
<span class="grow">Skills</span>
|
||||
</div>
|
||||
<div class="list-item" id="tool-tasks-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
@@ -979,12 +1004,12 @@
|
||||
<h1 class="a11y-visually-hidden">Odysseus</h1>
|
||||
<div class="chat-top-bar">
|
||||
<button type="button" class="incognito-indicator" id="incognito-indicator" title="Nobody mode active — click to deactivate" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg></button>
|
||||
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><button type="button" class="chat-context-pill" id="chat-context-pill" title="Chat context" hidden><span class="chat-context-dot"></span><span id="chat-context-pill-label">0%</span></button><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-compact-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M7 12h10"/><path d="M10 17h4"/></svg></span><span class="export-compact-pill">Compact</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div><div class="export-dropdown-item dropdown-item-danger" id="export-delete-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span><span>Delete Chat</span></div></div></span></div> </div>
|
||||
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><button type="button" class="chat-context-pill" id="chat-context-pill" title="Chat context" hidden><span class="chat-context-dot"></span><span id="chat-context-pill-label">0%</span></button><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-compact-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M7 12h10"/><path d="M10 17h4"/></svg></span><span class="export-compact-pill">Compact</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item dropdown-item-danger" id="export-delete-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></span><span>Delete Chat</span></div></div></span></div> </div>
|
||||
<div id="welcome-screen">
|
||||
<div class="welcome-name"><svg class="welcome-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>Odysseus</div>
|
||||
<div class="welcome-sub" id="welcome-sub">New chat ready.</div>
|
||||
<div class="welcome-tip" id="welcome-tip"></div>
|
||||
<button type="button" class="incognito-btn" id="incognito-btn" title="Enable Nobody mode — no memory, no history saved">
|
||||
<div class="welcome-sub" id="welcome-sub">A voyage begins.</div>
|
||||
<div class="welcome-tip" id="welcome-tip" role="note"></div>
|
||||
<button type="button" class="incognito-btn" id="incognito-btn" title="Enable Nobody mode — no memory or skill injection, no history saved">
|
||||
<svg class="eye-open" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
@@ -999,26 +1024,24 @@
|
||||
(function(){
|
||||
var mobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
var desktop = [
|
||||
'Tip: Press Ctrl+K to search across all your conversations.',
|
||||
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
|
||||
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
|
||||
'Tip: Drag and drop files onto the chat to attach them.',
|
||||
'Tip: Right-click a session for rename, delete, and memory options.',
|
||||
'Tip: Ctrl+K searches chats.',
|
||||
'Tip: Ctrl+Alt+B toggles the sidebar.',
|
||||
'Tip: Drop files here to attach them.',
|
||||
'Tip: Open a chat’s actions menu to rename, archive, or delete it.',
|
||||
'Tip: Double-click a chat name to rename it.',
|
||||
];
|
||||
var phone = [
|
||||
'Tip: Long-press a session for rename, delete, and memory options.',
|
||||
'Tip: Tap the eye icon for Nobody mode — no history saved.',
|
||||
'Tip: Switch to Agent mode for web search and code execution.',
|
||||
'Tip: Use Compare mode to test different models side by side.',
|
||||
'Tip: Attach images or files using the + button next to the input.',
|
||||
'Tip: Long-press a chat for actions.',
|
||||
'Tip: Nobody mode saves no history.',
|
||||
'Tip: Agent mode can use tools.',
|
||||
'Tip: Compare tests models side by side.',
|
||||
'Tip: Use + to attach files.',
|
||||
];
|
||||
var tips = mobile ? phone : desktop;
|
||||
var el = document.getElementById('welcome-tip');
|
||||
if (el) {
|
||||
el.textContent = tips[Math.floor(Math.random() * tips.length)];
|
||||
}
|
||||
if (el) el.textContent = tips[Math.floor(Math.random() * tips.length)];
|
||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||
if (d.version) window._appVersion = d.version;
|
||||
if (d.build || d.version) window._appVersion = d.build || d.version;
|
||||
}).catch(function(){});
|
||||
})();
|
||||
</script>
|
||||
@@ -1116,13 +1139,6 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1140,6 +1156,13 @@
|
||||
</svg>
|
||||
<span>Prompt</span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Web search (magnifying glass) -->
|
||||
@@ -1211,6 +1234,16 @@
|
||||
<input type="checkbox" id="plan-toggle" style="display:none;">
|
||||
</div>
|
||||
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
|
||||
<!-- Retain the bound control while the Chat Context popup is closed. -->
|
||||
<div id="reasoning-effort-home" hidden>
|
||||
<div class="reasoning-effort-wrap" id="reasoning-effort-wrap" style="display:none;">
|
||||
<button type="button" class="reasoning-effort-btn" id="reasoning-effort-btn" title="Reasoning effort" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="reasoning-effort-prefix">Reasoning effort</span><span class="reasoning-effort-current" id="reasoning-effort-current">Default</span>
|
||||
<svg class="reasoning-effort-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="reasoning-effort-menu hidden" id="reasoning-effort-menu" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Character (custom preset) modal -->
|
||||
<div id="custom-preset-modal" class="modal hidden">
|
||||
@@ -1232,21 +1265,11 @@
|
||||
<textarea id="inject-prefix" rows="2" placeholder="Added before your message" style="margin-bottom:8px"></textarea>
|
||||
<label for="inject-suffix">Suffix</label>
|
||||
<textarea id="inject-suffix" rows="2" placeholder="Added after your message" style="margin-bottom:12px"></textarea>
|
||||
<div class="preset-slider-row">
|
||||
<label>Temperature <span class="preset-hint-icon" title="Controls randomness. Lower values give focused, deterministic answers (good for code). Higher values give more creative, varied responses.">?</span></label>
|
||||
<span class="preset-slider-value" id="temp-value">1.0</span>
|
||||
</div>
|
||||
<input type="range" class="preset-range" id="custom-temperature" min="0" max="2" step="0.1" value="1.0">
|
||||
<div class="preset-temp-hints">
|
||||
<span>Precise / Code</span>
|
||||
<span>Balanced</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
<div class="preset-slider-row">
|
||||
<label>Max Tokens <span class="preset-hint-icon" title="Maximum length of the AI response. 'No limit' lets the model decide when to stop.">?</span></label>
|
||||
<span class="preset-slider-value" id="tokens-value">No limit</span>
|
||||
</div>
|
||||
<input type="range" class="preset-range" id="custom-max-tokens" min="256" max="8448" step="256" value="8448">
|
||||
<input type="hidden" id="custom-temperature" value="1.0">
|
||||
<span id="temp-value" hidden>1.0</span>
|
||||
<input type="hidden" id="custom-thinking-mode" value="">
|
||||
<input type="hidden" id="custom-max-tokens" value="8448">
|
||||
<span id="tokens-value" hidden>No limit</span>
|
||||
</div>
|
||||
<!-- Prompt (character/persona) tab -->
|
||||
<div class="preset-chartab" data-chartab-panel="character" style="display:none">
|
||||
@@ -1265,6 +1288,11 @@
|
||||
<button type="button" id="reset-character-btn" class="char-action-btn" title="Reset to default" style="margin-top:-6px !important">↺ Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="vis-row persona-display-name-row" for="custom-show-persona-name" title="Show the persona name in the chat header">
|
||||
<span class="vis-label">Show persona name in chat</span>
|
||||
<input type="checkbox" id="custom-show-persona-name" checked>
|
||||
<span class="vis-switch"></span>
|
||||
</label>
|
||||
<label for="custom-system-prompt">System prompt</label>
|
||||
<div class="char-prompt-wrap">
|
||||
<textarea id="custom-system-prompt" rows="4" placeholder="Write rough notes and click Expand, or leave empty"></textarea>
|
||||
@@ -1297,7 +1325,7 @@
|
||||
|
||||
|
||||
</main>
|
||||
<button id="scroll-bottom-btn" class="scroll-nav-btn" title="Scroll to bottom">▼</button>
|
||||
<button id="scroll-bottom-btn" class="scroll-nav-btn" title="Scroll to bottom"><span class="scroll-nav-caret" aria-hidden="true">▾</span></button>
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
const container = document.getElementById('chat-history');
|
||||
@@ -1522,6 +1550,15 @@
|
||||
<span>System</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Build provenance, pinned below the scrolling nav so it stays at
|
||||
the bottom-left of the panel: the version this build registers
|
||||
and the commit the running process actually loaded. Populated
|
||||
from /api/version; stays hidden until that answers. -->
|
||||
<div class="settings-sidebar-build admin-only" id="settings-sidebar-build" hidden>
|
||||
<span class="settings-sidebar-build-version" id="settings-sidebar-build-version"></span>
|
||||
<span class="settings-sidebar-build-commit" id="settings-sidebar-build-commit"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
|
||||
@@ -1690,13 +1727,16 @@
|
||||
again once the core experience is faster. -->
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Used when AI drafts email replies. Keep this email-specific: greetings, sign-off, tone, and length.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">General voice for normal documents. Email greetings and sign-offs remain under Email → Writing Style.</div>
|
||||
<div class="settings-col">
|
||||
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
||||
<div class="settings-row" style="margin-top:4px">
|
||||
<span id="set-email-style-msg" style="font-size:11px;"></span>
|
||||
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
|
||||
<button class="admin-btn-add" id="set-email-style-save">Save</button>
|
||||
<textarea id="set-document-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. Concise and conversational. Prefer short paragraphs, plain language, and concrete examples."></textarea>
|
||||
<div class="settings-row" style="margin-top:4px;align-items:center">
|
||||
<span id="set-document-style-msg" style="font-size:11px;min-height:18px;display:inline-flex;align-items:center;"></span>
|
||||
<input id="set-document-style-file" type="file" hidden accept=".txt,.md,.markdown,.pdf,.doc,.docx,.odt,.rtf,.html,.htm,.csv,.tsv,.json,.yaml,.yml">
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;gap:6px">
|
||||
<button class="admin-btn-add" id="set-document-style-extract">Extract from file</button>
|
||||
<button class="admin-btn-add" id="set-document-style-save" style="display:inline-flex;align-items:center;gap:5px"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>Save</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1853,9 +1893,14 @@
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg></span>
|
||||
<span class="vis-label">Brain</span>
|
||||
<span class="vis-label">Memory</span>
|
||||
<input type="checkbox" checked data-ui-key="tool-memory"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
||||
<span class="vis-label">Skills</span>
|
||||
<input type="checkbox" checked data-ui-key="tool-skills"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
|
||||
<span class="vis-label">Calendar</span>
|
||||
@@ -1933,7 +1978,7 @@
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||||
<span class="vis-label">Incognito Mode <span class="vis-hint">No memory, no history saved</span></span>
|
||||
<span class="vis-label">Incognito Mode <span class="vis-hint">No memory or skill injection, no history saved</span></span>
|
||||
<input type="checkbox" checked data-ui-key="incognito-btn"><span class="vis-switch"></span>
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
@@ -2056,16 +2101,22 @@
|
||||
|
||||
<!-- ═══ EMAIL TAB ═══ -->
|
||||
<div data-settings-panel="email" class="hidden">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card settings-email-settings-legacy" aria-hidden="true">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>Email Settings</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Auto reply, newsletter unsubscribe, and writing style live in the Email window.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-library-settings" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M9 18l6-6-6-6"/></svg>Open Email Settings</button>
|
||||
<div data-email-settings-legacy-host class="email-settings-page email-settings-page-host">
|
||||
<div class="email-settings-page-head"></div>
|
||||
<div class="email-settings-body"><div class="email-settings-loading"><span class="email-settings-loading-css-whirlpool" aria-hidden="true"></span><span>Loading</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card settings-email-default-card" id="settings-email-default-card" hidden>
|
||||
<h2 class="settings-email-default-title"><span><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>Email Settings</span><div id="settings-email-default-host"></div></h2>
|
||||
<div class="admin-toggle-sub email-settings-support-copy" style="margin:0 0 8px;">Set the default email and choose which settings apply here.</div>
|
||||
<div class="settings-email-default-spacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Add Email Accounts</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Open Integrations</button>
|
||||
@@ -2075,11 +2126,19 @@
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
|
||||
<div class="admin-toggle-sub email-settings-support-copy" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-tasks" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Open Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card settings-email-preferences-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83 2.83l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0 .33 1.82 1.65 1.65 0 0 0 1.51 1Z"/></svg>Email Preferences</h2>
|
||||
<div id="settings-email-preferences" class="email-settings-page email-settings-page-host">
|
||||
<div class="email-settings-page-head"></div>
|
||||
<div class="email-settings-body"><div class="email-settings-loading"><span class="email-settings-loading-css-whirlpool" aria-hidden="true"></span><span>Loading</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ REMINDERS TAB ═══ -->
|
||||
@@ -2289,6 +2348,7 @@
|
||||
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
|
||||
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
|
||||
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
|
||||
<option value="https://api.featherless.ai/v1" data-logo="featherless">Featherless.ai</option>
|
||||
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
|
||||
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
|
||||
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
|
||||
@@ -2395,11 +2455,16 @@
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
Terminal Logs
|
||||
Logs
|
||||
</h2>
|
||||
<div class="admin-toggle-sub settings-system-logs-toggle-sub">Live diagnostic logs and system output from the Odysseus process.</div>
|
||||
<div class="admin-toggle-sub settings-system-logs-toggle-sub">Terminal output and task notifications.</div>
|
||||
|
||||
<div class="settings-col settings-system-logs-col">
|
||||
<div class="settings-system-logs-tabs" role="tablist" aria-label="System logs">
|
||||
<button type="button" class="settings-system-logs-tab active" id="system-log-terminal-tab" role="tab" aria-selected="true" aria-controls="system-log-terminal-panel" data-system-log-tab="terminal">Terminal logs</button>
|
||||
<button type="button" class="settings-system-logs-tab" id="system-log-notifications-tab" role="tab" aria-selected="false" aria-controls="system-log-notifications-panel" data-system-log-tab="notifications">Notification logs</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-col settings-system-logs-col" id="system-log-terminal-panel" role="tabpanel" aria-labelledby="system-log-terminal-tab">
|
||||
<!-- Controls row -->
|
||||
<div class="settings-system-logs-controls">
|
||||
<!-- Search input -->
|
||||
@@ -2443,6 +2508,24 @@
|
||||
<div class="settings-system-logs-placeholder">Initializing logs terminal viewer...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-col settings-system-notification-logs-col hidden" id="system-log-notifications-panel" role="tabpanel" aria-labelledby="system-log-notifications-tab" hidden>
|
||||
<div class="settings-system-logs-controls">
|
||||
<input type="text" id="notification-log-search-input" placeholder="Search notifications..." class="settings-system-logs-search">
|
||||
<select id="notification-log-status-select" class="settings-system-logs-select">
|
||||
<option value="ALL">All Statuses</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="error">Errors</option>
|
||||
</select>
|
||||
<button type="button" class="admin-btn-sm" id="notification-log-refresh-btn" title="Refresh notification logs">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="notification-log-container">
|
||||
<div class="settings-system-logs-placeholder">No notification logs loaded.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
|
||||
@@ -2554,38 +2637,38 @@
|
||||
|
||||
<!-- Load modules in this order -->
|
||||
<script type="module" src="/static/js/storage.js"></script>
|
||||
<script type="module" src="/static/js/ui.js"></script>
|
||||
<script type="module" src="/static/js/ui.js?v=20260916largetoolscroll1"></script>
|
||||
<script type="module" src="/static/js/markdown.js"></script>
|
||||
<script type="module" src="/static/js/dragSort.js"></script>
|
||||
<script type="module" src="/static/js/sessions.js"></script>
|
||||
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
|
||||
<script type="module" src="/static/js/skills.js"></script>
|
||||
<script type="module" src="/static/js/memory.js"></script>
|
||||
<script type="module" src="/static/js/skills.js?v=20260909kebabconsistency1"></script>
|
||||
<script type="module" src="/static/js/tourHints.js"></script>
|
||||
<script type="module" src="/static/js/tourAutoplay.js"></script>
|
||||
<script type="module" src="/static/js/fileHandler.js"></script>
|
||||
<script type="module" src="/static/js/fileHandler.js?v=20260909mobileattachmentedit1"></script>
|
||||
<script type="module" src="/static/js/voiceRecorder.js"></script>
|
||||
<script type="module" src="/static/js/models.js?v=20260715startupcalm2"></script> <!-- This must come BEFORE app.js -->
|
||||
<script type="module" src="/static/js/models.js"></script> <!-- This must come BEFORE app.js -->
|
||||
<script type="module" src="/static/js/rag.js"></script>
|
||||
<script type="module" src="/static/js/presets.js"></script>
|
||||
<script type="module" src="/static/js/presets.js?v=20260908personaname1"></script>
|
||||
<script type="module" src="/static/js/search.js"></script>
|
||||
<script type="module" src="/static/js/spinner.js"></script>
|
||||
<script type="module" src="/static/js/tts-ai.js"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
|
||||
<script type="module" src="/static/js/document.js?v=20260916docctx2"></script>
|
||||
<script type="module" src="/static/js/gallery.js?v=20260910promptcopy1"></script>
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260914metricssummary1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js?v=20260831richtexttools91"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260914pdfstrip1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260917toolttft1"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/theme.js"></script>
|
||||
<script type="module" src="/static/js/theme.js?v=20260911organsrain1"></script>
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260815approvalsave1"></script>
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260815toolapproval4"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -137,7 +137,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. |
|
||||
| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. |
|
||||
| **`emailInbox.js`** / **`emailLibrary/`** | Email inbox reader and library modal. `emailLibrary.js` is a re-export wrapper; `emailLibrary/index.js` owns the panel, list and cards, with `settingsPage.js`, `unsubscribe.js`, `reader.js`, `menus.js`, `bodyRender.js`, `attachments.js` and `aiReply.js` alongside it plus the `utils.js` / `signatureFold.js` / `replyRecipients.js` / `state.js` leaves. |
|
||||
| **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. |
|
||||
| **`tasks.js`** | Scheduled task/recurring LLM job UI. |
|
||||
| **`notes.js`** | Notes and todo panel, reminders, pinboard. |
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Stable semantic ordering for item-level action menus. */
|
||||
const COMMON_ACTION_ORDER = [
|
||||
{ rank: 200, pattern: /^(rename|change name)\b/i },
|
||||
{ rank: 650, pattern: /^select\b/i },
|
||||
{ rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i },
|
||||
{ rank: 500, pattern: /^(copy|clone|duplicate)\b/i },
|
||||
{ rank: 550, pattern: /^(export|download)\b/i },
|
||||
{ rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i },
|
||||
{ rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i },
|
||||
{ rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i },
|
||||
{ rank: 1000, pattern: /^cancel\b/i },
|
||||
];
|
||||
|
||||
// Canonical Select glyph for item-level menus. Toolbar toggles may use the
|
||||
// smaller version, but dropdown rows should all use this exact SVG.
|
||||
export const SELECT_MENU_ICON = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||
|
||||
export function actionMenuRank(item) {
|
||||
if (Number.isFinite(item?.menuOrder)) return item.menuOrder;
|
||||
const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim();
|
||||
return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100;
|
||||
}
|
||||
|
||||
export function orderActionMenuItems(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, index, rank: actionMenuRank(item) }))
|
||||
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||
.map(entry => entry.item);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// singleton via /api/assistant/session and hands it to selectSession() so we
|
||||
// reuse the full existing chat render path.
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { selectSession } from './sessions.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
|
||||
@@ -120,7 +120,7 @@ function _esc(s) {
|
||||
|
||||
// Tool groups for the tool selector UI
|
||||
const TOOL_GROUPS = {
|
||||
'Email': ['list_emails', 'read_email', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'],
|
||||
'Email': ['list_emails', 'read_email', 'download_attachment', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'],
|
||||
'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'],
|
||||
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
||||
'Code': ['bash', 'python', 'write_file'],
|
||||
@@ -420,6 +420,10 @@ export async function openAssistantSettings() {
|
||||
// ── Chat-header affordances when the assistant session is active ───────────
|
||||
|
||||
async function _ensureHeaderAffordances(sessionId) {
|
||||
return;
|
||||
/* Legacy header-gear implementation removed; assistant controls now live
|
||||
in the Tasks modal. */
|
||||
/*
|
||||
try {
|
||||
const settings = await _getSettings();
|
||||
if (settings?.crew?.session_id !== sessionId) return;
|
||||
@@ -437,6 +441,7 @@ async function _ensureHeaderAffordances(sessionId) {
|
||||
gear.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
gear.addEventListener('click', openAssistantSettings);
|
||||
headerRight.appendChild(gear);
|
||||
*/
|
||||
}
|
||||
|
||||
// Run a short polling check after session loads so we can add the gear button
|
||||
@@ -458,7 +463,7 @@ function _watchForAssistantActivation() {
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function _boot() {
|
||||
_watchForAssistantActivation();
|
||||
document.getElementById('assistant-header-gear')?.remove();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/** Reconcile server-delivered background replies without resetting chat DOM. */
|
||||
import { createWhirlpool } from './spinner.js';
|
||||
|
||||
const researchSpinners = new WeakMap();
|
||||
function stopResearchSpinner(node) {
|
||||
researchSpinners.get(node)?.destroy();
|
||||
researchSpinners.delete(node);
|
||||
node.querySelector('[data-research-spinner]')?.replaceChildren();
|
||||
}
|
||||
|
||||
export function deliveryMessages(jobs, existingIds) {
|
||||
const seen = new Set(existingIds);
|
||||
return jobs.flatMap(job => {
|
||||
const message = job.status === 'delivered' && job.message;
|
||||
const id = message?.metadata?._db_id;
|
||||
if (!id || seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
return [message];
|
||||
});
|
||||
}
|
||||
|
||||
export function researchCardState(job) {
|
||||
if (job.outcome === 'error') return { tone: 'error', label: 'Research failed', detail: 'Open research for details.' };
|
||||
if (job.outcome === 'no_sources') return { tone: 'error', label: 'No sources found', detail: 'This run did not return source-backed findings.' };
|
||||
if (job.status === 'delivered') return { tone: 'done', label: 'Research finished', detail: `${job.source_count ?? '—'} sources · See the update in chat.` };
|
||||
if (job.status === 'ready') return { tone: 'running', label: 'Preparing chat update', detail: 'The report is ready. You can keep chatting.' };
|
||||
const p = job.progress || {};
|
||||
const phase = { probing: 'Checking model', planning: 'Planning', searching: 'Searching', reading: 'Reading sources', analyzing: 'Analyzing findings', writing: 'Writing report' }[p.phase] || 'Starting research';
|
||||
const round = p.round ? `Round ${p.round}${job.rounds ? `/${job.rounds}` : ''} · ` : '';
|
||||
return { tone: 'running', label: phase, detail: `${round}${p.total_sources ?? 0} sources · You can keep chatting.` };
|
||||
}
|
||||
|
||||
export function renderResearchCards(box, jobs) {
|
||||
const visible = jobs.filter(j => j.tool === 'research' && /^[A-Za-z0-9_-]+$/.test(j.id) && j.status !== 'discarded');
|
||||
let region = box.querySelector('[data-background-tools-status]');
|
||||
if (!visible.length) {
|
||||
if (region) for (const card of region.children) stopResearchSpinner(card);
|
||||
region?.remove(); return;
|
||||
}
|
||||
if (!region) {
|
||||
region = document.createElement('section');
|
||||
region.dataset.backgroundToolsStatus = '';
|
||||
region.className = 'background-tools-status agent-thread has-top';
|
||||
region.setAttribute('aria-label', 'Chat research');
|
||||
box.append(region);
|
||||
}
|
||||
region.classList.toggle('streaming', visible.some(job => researchCardState(job).tone === 'running'));
|
||||
|
||||
// Keep the live rail immediately after the AI's synthesized response that
|
||||
// contains the matching research link. Appending it to the chat history
|
||||
// root can place it above the user's next prompt as the conversation grows.
|
||||
const origin = [...visible].reverse().map(job => {
|
||||
const href = `#research-${job.id}`;
|
||||
return [...box.querySelectorAll('.msg-ai')]
|
||||
.find(message => [...message.querySelectorAll('a[href]')].some(candidate => candidate.getAttribute('href') === href));
|
||||
}).find(Boolean);
|
||||
if (origin) origin.insertAdjacentElement('afterend', region);
|
||||
|
||||
const keep = new Set(visible.map(job => job.id));
|
||||
for (const card of Array.from(region.children)) if (!keep.has(card.dataset.jobId)) {
|
||||
stopResearchSpinner(card);
|
||||
card.remove();
|
||||
}
|
||||
for (const job of visible) {
|
||||
let card = Array.from(region.children).find(node => node.dataset.jobId === job.id);
|
||||
if (!card) {
|
||||
card = document.createElement('article');
|
||||
card.dataset.jobId = job.id;
|
||||
// Only constant markup; model-authored topics are assigned as text below.
|
||||
card.innerHTML = '<div class="agent-thread-dot" aria-hidden="true"></div><button type="button" class="agent-thread-header" aria-expanded="false"><span class="agent-thread-icon" aria-hidden="true"></span><span class="agent-thread-tool">Research</span><span class="agent-thread-status chat-research-background">BG task <span data-research-spinner aria-hidden="true"></span></span><span class="agent-thread-status" data-stage role="status"></span><span class="agent-thread-chevron" aria-hidden="true"></span></button><div class="agent-thread-content"><div class="research-job-query"></div><div class="chat-research-detail"></div><a class="chat-research-open">Open research</a></div>';
|
||||
const header = card.querySelector('.agent-thread-header');
|
||||
const content = card.querySelector('.agent-thread-content');
|
||||
content.id = `chat-research-details-${job.id}`;
|
||||
header.setAttribute('aria-controls', content.id);
|
||||
header.addEventListener('click', event => {
|
||||
// Own this disclosure; do not also trigger the chat's delegated toggle.
|
||||
event.stopPropagation();
|
||||
header.setAttribute('aria-expanded', String(card.classList.toggle('open')));
|
||||
});
|
||||
region.append(card);
|
||||
}
|
||||
const state = researchCardState(job);
|
||||
card.className = `agent-thread-node chat-research-card ${state.tone}${card.classList.contains('open') ? ' open' : ''}`;
|
||||
const setText = (selector, text) => {
|
||||
const node = card.querySelector(selector);
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
};
|
||||
setText('.research-job-query', job.query || 'Research');
|
||||
setText('[data-stage]', state.label);
|
||||
setText('.agent-thread-icon', state.tone === 'error' ? '✗' : state.tone === 'done' ? '✓' : '·');
|
||||
setText('.chat-research-detail', state.detail);
|
||||
if (state.tone === 'running' && !researchSpinners.has(card)) {
|
||||
const spinner = createWhirlpool(16);
|
||||
card.querySelector('[data-research-spinner]').append(spinner.element);
|
||||
researchSpinners.set(card, spinner);
|
||||
} else if (state.tone !== 'running') stopResearchSpinner(card);
|
||||
card.querySelector('a').href = `#research-${job.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackgroundToolJobs({ getSessionId, addMessage, base = '' }) {
|
||||
let inFlight = false;
|
||||
const busy = () => Boolean(document.querySelector('#chat-history .msg-ai.streaming'));
|
||||
const tick = async () => {
|
||||
const sid = getSessionId();
|
||||
if (inFlight || !sid || document.hidden || window.__odysseusSessionReadyId !== sid) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const response = await fetch(`${base}/api/research/chat-jobs/${encodeURIComponent(sid)}`, { credentials: 'same-origin' });
|
||||
if (!response.ok) return;
|
||||
const { jobs = [] } = await response.json();
|
||||
if (getSessionId() !== sid || window.__odysseusSessionReadyId !== sid) return;
|
||||
const box = document.querySelector('#chat-history');
|
||||
if (!box) return;
|
||||
renderResearchCards(box, jobs);
|
||||
if (busy()) return;
|
||||
const ids = Array.from(box.querySelectorAll('[data-db-id]'), node => node.dataset.dbId);
|
||||
for (const message of deliveryMessages(jobs, ids)) {
|
||||
addMessage(message.role, message.content, message.metadata?.model, message.metadata);
|
||||
}
|
||||
} catch { /* A transient poll error must not disrupt foreground chat. */ }
|
||||
finally { inFlight = false; }
|
||||
};
|
||||
const timer = setInterval(tick, 3000);
|
||||
const onVisible = () => { if (!document.hidden) void tick(); };
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
void tick();
|
||||
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); };
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// `start()` kicks off the poll loop + permission request. Call once from
|
||||
// the calendar's entry module.
|
||||
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// SSE event handlers extracted from chat.js handleChatSubmit
|
||||
// Handles: ui_control events, background stream management
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import Storage from './storage.js';
|
||||
import themeModule from './theme.js';
|
||||
import themeModule from './theme.js?v=20260911organsrain1';
|
||||
import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js?v=20260815approvalsave1';
|
||||
import documentModule from './document.js?v=20260916docctx2';
|
||||
|
||||
// Tool approvals are control-plane submits for the current chat. chat.js
|
||||
// deliberately leaves the composer untouched, then programmatically clicks the
|
||||
@@ -129,6 +129,7 @@ export function handleUIControl(uiData) {
|
||||
if (bg.effectColor && tm2.applyBgEffectColor) { tm2.applyBgEffectColor(bg.effectColor); opts.bgEffectColor = bg.effectColor; }
|
||||
if (bg.effectIntensity != null && tm2.applyBgEffectIntensity) { tm2.applyBgEffectIntensity(bg.effectIntensity); opts.bgEffectIntensity = bg.effectIntensity; }
|
||||
if (bg.effectSize != null && tm2.applyBgEffectSize) { tm2.applyBgEffectSize(bg.effectSize); opts.bgEffectSize = bg.effectSize; }
|
||||
if (bg.effectSpeed != null && tm2.applyBgEffectSpeed) { tm2.applyBgEffectSpeed(bg.effectSpeed); opts.bgEffectSpeed = bg.effectSpeed; }
|
||||
if (bg.frosted != null && tm2.applyFrostedGlass) { tm2.applyFrostedGlass(bg.frosted); opts.frosted = bg.frosted; }
|
||||
}
|
||||
if (tm2.saveCustomTheme) tm2.saveCustomTheme(name, colors2, Object.keys(opts).length ? opts : undefined);
|
||||
@@ -161,7 +162,7 @@ export function handleUIControl(uiData) {
|
||||
// the 12s active-poll.
|
||||
var rsid = uiData.research_session_id || uiData.session_id;
|
||||
if (rsid) {
|
||||
import('./research/jobs.js').then(function(mod) {
|
||||
import('./research/jobs.js?v=20260910researcherrorpersist1').then(function(mod) {
|
||||
var fn = mod.adoptSession || (mod.default && mod.default.adoptSession);
|
||||
if (fn) fn(rsid);
|
||||
}).catch(function(){});
|
||||
@@ -175,17 +176,24 @@ export function handleUIControl(uiData) {
|
||||
} else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') {
|
||||
var panel = uiData.panel;
|
||||
if (panel === 'documents') {
|
||||
import('./documentLibrary.js').then(function(mod) {
|
||||
import('./documentLibrary.js?v=20260911librarybulkdelete1').then(function(mod) {
|
||||
var fn = mod.openLibrary || (mod.default && mod.default.openLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'gallery') {
|
||||
import('./gallery.js').then(function(mod) {
|
||||
import('./gallery.js?v=20260910promptcopy1').then(function(mod) {
|
||||
var fn = mod.openGallery || (mod.default && mod.default.openGallery);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'calendar') {
|
||||
import('./calendar.js?v=20260914emailsource11').then(function(mod) {
|
||||
var viewFn = mod.openCalendarView || (mod.default && mod.default.openCalendarView);
|
||||
var fn = mod.openCalendar || (mod.default && mod.default.openCalendar);
|
||||
if (viewFn && (uiData.view || uiData.target_date)) viewFn(uiData.view || 'month', uiData.target_date || '');
|
||||
else if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'email') {
|
||||
import('./emailLibrary.js?v=20260815approvalsave1').then(function(mod) {
|
||||
import('./emailLibrary.js?v=20260915trashmove2').then(function(mod) {
|
||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
@@ -197,19 +205,36 @@ export function handleUIControl(uiData) {
|
||||
} else if (panel === 'cookbook') {
|
||||
import('./cookbook.js').then(function(mod) {
|
||||
var fn = mod.open || (mod.default && mod.default.open);
|
||||
if (fn) fn();
|
||||
if (fn) fn(uiData.view ? { tab: uiData.view } : undefined);
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'notes') {
|
||||
import('./notes.js').then(function(mod) {
|
||||
import('./notes.js?v=20260911notesselectioncancel1').then(function(mod) {
|
||||
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'theme' || panel === 'themes') {
|
||||
import('./theme.js?v=20260911organsrain1').then(function(mod) {
|
||||
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
|
||||
var modal = document.getElementById('theme-modal');
|
||||
if (modal && modal.classList.contains('hidden') && fn) fn();
|
||||
else if (!modal && fn) fn();
|
||||
else if (modal) modal.classList.remove('hidden');
|
||||
}).catch(function(){
|
||||
var btn = document.getElementById('tool-theme-btn') || document.getElementById('rail-theme');
|
||||
if (btn) btn.click();
|
||||
});
|
||||
} else if (panel === 'memories' || panel === 'skills' || panel === 'settings') {
|
||||
// These live in the sidebar / settings drawer — most just need
|
||||
// an existing button click.
|
||||
var ids = { memories: 'tool-memory-btn', skills: 'skills-btn', settings: 'open-settings-btn' };
|
||||
var ids = { memories: 'tool-memory-btn', skills: 'tool-skills-btn', settings: 'open-settings-btn' };
|
||||
var btn = document.getElementById(ids[panel]);
|
||||
if (btn) btn.click();
|
||||
if (panel === 'settings') {
|
||||
import('./settings.js?v=20260912writingstyle3').then(function(mod) {
|
||||
var fn = mod.open || (mod.default && mod.default.open);
|
||||
if (fn) fn();
|
||||
else if (btn) btn.click();
|
||||
}).catch(function(){ if (btn) btn.click(); });
|
||||
} else if (btn) btn.click();
|
||||
}
|
||||
|
||||
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
|
||||
@@ -234,7 +259,7 @@ export function handleUIControl(uiData) {
|
||||
} catch (e) {
|
||||
console.warn('open_email_reply existing draft update failed:', e);
|
||||
}
|
||||
import('./emailInbox.js?v=20260815approvalsave1').then(function(mod) {
|
||||
import('./emailInbox.js?v=20260914aireply4').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
@@ -273,6 +298,14 @@ export function notifyStreamComplete(sessionId, query) {
|
||||
* Insert a clickable in-chat toast when a background stream finishes.
|
||||
*/
|
||||
export function insertStreamDoneToast(sessionId, query) {
|
||||
if (
|
||||
sessionModule
|
||||
&& sessionModule.getCurrentSessionId
|
||||
&& sessionModule.getCurrentSessionId() !== sessionId
|
||||
) {
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Response ready in another chat', 4000);
|
||||
return;
|
||||
}
|
||||
var box = document.getElementById('chat-history');
|
||||
if (!box) return;
|
||||
var sessions = sessionModule ? sessionModule.getSessions() : [];
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// DOM-free view-model + renderer for per-account ChatGPT Subscription usage.
|
||||
//
|
||||
// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a
|
||||
// normalized, credential-free payload. This module turns it into something the
|
||||
// Settings "Added Models" card can render, defensively: unknown buckets are
|
||||
// kept, missing windows are tolerated and nothing (reset times, percentages)
|
||||
// is ever invented.
|
||||
|
||||
function _num(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _clampPercent(value) {
|
||||
const n = _num(value);
|
||||
if (n === null) return null;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function _defaultEsc(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds) {
|
||||
const secs = _num(totalSeconds);
|
||||
if (secs === null) return '';
|
||||
if (secs <= 0) return 'now';
|
||||
const days = Math.floor(secs / 86400);
|
||||
const hours = Math.floor((secs % 86400) / 3600);
|
||||
const minutes = Math.floor((secs % 3600) / 60);
|
||||
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
|
||||
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
export function formatResetIn(resetsAt, nowSeconds) {
|
||||
const at = _num(resetsAt);
|
||||
if (at === null || at <= 0) return '';
|
||||
const now = _num(nowSeconds);
|
||||
if (now === null) return '';
|
||||
const delta = at - now;
|
||||
if (delta <= 0) return 'resets now';
|
||||
return `resets in ${formatDuration(delta)}`;
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
const n = _clampPercent(value);
|
||||
if (n === null) return '';
|
||||
const rounded = Math.round(n);
|
||||
return `${rounded}%`;
|
||||
}
|
||||
|
||||
export function planDisplayName(planType) {
|
||||
if (typeof planType !== 'string' || !planType.trim()) return '';
|
||||
const key = planType.trim().toLowerCase();
|
||||
const known = {
|
||||
free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
|
||||
business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
|
||||
guest: 'Guest',
|
||||
};
|
||||
if (known[key]) return known[key];
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function _windowViewModel(win, nowSeconds) {
|
||||
if (!win || typeof win !== 'object') return null;
|
||||
const used = _clampPercent(win.used_percent);
|
||||
const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
|
||||
const windowMinutes = _num(win.window_minutes);
|
||||
const resetsAt = _num(win.resets_at);
|
||||
return {
|
||||
kind: typeof win.kind === 'string' ? win.kind : '',
|
||||
name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
|
||||
usedPercent: used,
|
||||
remainingPercent: remaining,
|
||||
usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
|
||||
remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
|
||||
windowMinutes,
|
||||
resetsAt,
|
||||
resetLabel: formatResetIn(resetsAt, nowSeconds),
|
||||
exhausted: used !== null && used >= 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a render-ready view model from the backend usage payload.
|
||||
*
|
||||
* `payload` is the JSON body of the usage route. `nowSeconds` is injected so
|
||||
* reset countdowns are deterministic in tests.
|
||||
*/
|
||||
export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const account = (payload && typeof payload.account === 'object' && payload.account) || {};
|
||||
const base = {
|
||||
authId: typeof account.auth_id === 'string' ? account.auth_id : '',
|
||||
label: typeof account.label === 'string' ? account.label : '',
|
||||
name: typeof account.name === 'string' ? account.name : '',
|
||||
};
|
||||
if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
|
||||
const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
|
||||
const messages = {
|
||||
reauth: 'Usage unavailable — account may need reconnecting',
|
||||
rate_limited: 'Usage temporarily unavailable (rate limited)',
|
||||
timeout: 'Usage unavailable (timed out)',
|
||||
network: 'Usage unavailable (network)',
|
||||
malformed: 'Usage unavailable (unexpected response)',
|
||||
upstream: 'Usage unavailable',
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
available: false,
|
||||
reason,
|
||||
reconnectSuggested: !!(payload && payload.reconnect_suggested),
|
||||
message: messages[reason] || 'Usage unavailable',
|
||||
plan: '',
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
const usage = payload.usage;
|
||||
const limits = [];
|
||||
const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
|
||||
rawLimits.forEach((limit) => {
|
||||
if (!limit || typeof limit !== 'object') return;
|
||||
const windows = (Array.isArray(limit.windows) ? limit.windows : [])
|
||||
.map((w) => _windowViewModel(w, nowSeconds))
|
||||
.filter(Boolean);
|
||||
const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
|
||||
const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
|
||||
limits.push({
|
||||
limitId,
|
||||
title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
|
||||
modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
|
||||
limitReached: limit.limit_reached === true,
|
||||
windows,
|
||||
});
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
available: true,
|
||||
reason: '',
|
||||
reconnectSuggested: false,
|
||||
message: '',
|
||||
plan: planDisplayName(usage.plan_type),
|
||||
ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
|
||||
rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
|
||||
cached: payload.usage.cached === true,
|
||||
fetchedAt: _num(usage.fetched_at),
|
||||
limits,
|
||||
hasWindows: limits.some((l) => l.windows.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _barHtml(win, esc) {
|
||||
const used = win.usedPercent === null ? 0 : win.usedPercent;
|
||||
const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage-row" data-usage-window="${esc(win.kind || win.name)}">` +
|
||||
`<div class="adm-chatgpt-usage-head">` +
|
||||
`<span class="adm-chatgpt-usage-name">${esc(win.name)}</span>` +
|
||||
`<span class="adm-chatgpt-usage-remaining">${esc(win.remainingLabel || win.usedLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="adm-chatgpt-usage-bar adm-chatgpt-usage-${tone}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
|
||||
(win.usedPercent === null ? '' : ` aria-valuenow="${Math.round(used)}"`) +
|
||||
` aria-label="${esc(win.name)} ${esc(win.usedLabel)}">` +
|
||||
`<span class="adm-chatgpt-usage-fill" style="width:${Math.max(0, Math.min(100, used)).toFixed(0)}%"></span>` +
|
||||
`</div>` +
|
||||
`<div class="adm-chatgpt-usage-meta">` +
|
||||
`<span>${esc(win.usedLabel)}</span>` +
|
||||
(win.resetLabel ? `<span>${esc(win.resetLabel)}</span>` : '') +
|
||||
`</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the compact usage card body for one account. `esc` is the host
|
||||
* page's HTML escaper (defaults to a local one). Buttons carry data
|
||||
* attributes with the exact auth/endpoint ids so handlers target only this
|
||||
* account.
|
||||
*/
|
||||
export function renderUsageCardHtml(viewModel, options = {}) {
|
||||
const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc;
|
||||
const vm = viewModel || {};
|
||||
const authId = esc(vm.authId || '');
|
||||
const endpointId = esc(options.endpointId || '');
|
||||
const includeReconnect = options.includeReconnect !== false;
|
||||
const buttons =
|
||||
`<div class="adm-chatgpt-usage-actions">` +
|
||||
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-usage-refresh="${authId}" data-chatgpt-endpoint-id="${endpointId}">Refresh usage</button>` +
|
||||
(includeReconnect ? `<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` : '') +
|
||||
`</div>`;
|
||||
if (!vm.available) {
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage adm-chatgpt-usage-unavailable" data-adm-chatgpt-usage="${authId}">` +
|
||||
`<div class="adm-chatgpt-usage-status">${esc(vm.message || 'Usage unavailable')}</div>` +
|
||||
buttons +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
const plan = vm.plan ? `<span class="admin-badge adm-chatgpt-plan">${esc(vm.plan)}</span>` : '';
|
||||
const limitsHtml = (vm.limits || []).map((limit) => {
|
||||
const title = limit.title
|
||||
? `<div class="adm-chatgpt-usage-limit-title">${esc(limit.title)}${limit.modelSlug ? ` <span class="adm-chatgpt-usage-model">${esc(limit.modelSlug)}</span>` : ''}</div>`
|
||||
: '';
|
||||
return `<div class="adm-chatgpt-usage-limit" data-usage-limit="${esc(limit.limitId)}">${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '<div class="adm-chatgpt-usage-status">No rate-limit windows reported</div>'}</div>`;
|
||||
}).join('');
|
||||
const blocked = vm.ordinaryUsageAllowed === false
|
||||
? `<div class="adm-chatgpt-usage-status adm-chatgpt-usage-blocked">Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}</div>`
|
||||
: '';
|
||||
return (
|
||||
`<div class="adm-chatgpt-usage" data-adm-chatgpt-usage="${authId}">` +
|
||||
(plan ? `<div class="adm-chatgpt-usage-plan">${plan}</div>` : '') +
|
||||
blocked +
|
||||
limitsHtml +
|
||||
buttons +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */
|
||||
export function isChatgptSubscriptionEndpoint(ep) {
|
||||
return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id);
|
||||
}
|
||||
|
||||
/** Owner-visible account title, e.g. "ChatGPT · codex00". */
|
||||
export function accountTitle(ep) {
|
||||
if (!ep) return '';
|
||||
if (ep.account_label) return `ChatGPT · ${ep.account_label}`;
|
||||
return ep.name || 'ChatGPT Subscription';
|
||||
}
|
||||
|
||||
export default {
|
||||
buildUsageViewModel,
|
||||
renderUsageCardHtml,
|
||||
formatResetIn,
|
||||
formatDuration,
|
||||
formatPercent,
|
||||
planDisplayName,
|
||||
isChatgptSubscriptionEndpoint,
|
||||
accountTitle,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
// Shared horizontal scrolling for filter/tag chip rows.
|
||||
|
||||
const CHIP_STRIP_SELECTOR = [
|
||||
'.skills-summary-strip',
|
||||
'#memory-category-filters',
|
||||
'.memory-category-filters:has(> .memory-cat-chip)',
|
||||
'.doclib-lang-chips',
|
||||
'.doclib-chips',
|
||||
'.notes-labels-bar',
|
||||
'.tasks-activity-filters',
|
||||
'.gallery-tag-chips',
|
||||
'.gallery-album-chips',
|
||||
'.gallery-ai-tags',
|
||||
'.cal-filters',
|
||||
].join(',');
|
||||
|
||||
function initChipStrip(strip) {
|
||||
if (!strip || strip.dataset.chipScrollBound === '1') return;
|
||||
const parent = strip.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
strip.dataset.chipScrollBound = '1';
|
||||
strip.classList.add('chip-scroll-strip');
|
||||
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'doclib-chip-scroll-frame';
|
||||
parent.insertBefore(frame, strip);
|
||||
frame.appendChild(strip);
|
||||
|
||||
const makeArrow = (direction, label) => {
|
||||
const button = document.createElement('button');
|
||||
const glyph = document.createElement('span');
|
||||
button.type = 'button';
|
||||
button.className = `doclib-chip-scroll-arrow ${direction}`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = label;
|
||||
glyph.className = 'doclib-chip-scroll-arrow-glyph';
|
||||
glyph.textContent = direction === 'left' ? '\u2039' : '\u203a';
|
||||
button.appendChild(glyph);
|
||||
button.addEventListener('click', () => {
|
||||
strip.scrollBy({ left: direction === 'left' ? -180 : 180, behavior: 'smooth' });
|
||||
});
|
||||
frame.appendChild(button);
|
||||
return button;
|
||||
};
|
||||
|
||||
const left = makeArrow('left', 'Show previous tags');
|
||||
const right = makeArrow('right', 'Show more tags');
|
||||
let pointerId = null;
|
||||
let startX = 0;
|
||||
let startScroll = 0;
|
||||
let dragging = false;
|
||||
let suppressClick = false;
|
||||
|
||||
const sync = () => {
|
||||
const stripHidden = strip.hidden || getComputedStyle(strip).display === 'none';
|
||||
frame.style.display = stripHidden ? 'none' : '';
|
||||
if (stripHidden) {
|
||||
frame.classList.remove('has-overflow');
|
||||
left.hidden = true;
|
||||
right.hidden = true;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(0, strip.scrollWidth - strip.clientWidth);
|
||||
frame.classList.toggle('has-overflow', max > 1);
|
||||
left.hidden = max <= 1 || strip.scrollLeft <= 1;
|
||||
right.hidden = max <= 1 || strip.scrollLeft >= max - 1;
|
||||
};
|
||||
|
||||
strip.addEventListener('scroll', sync, { passive: true });
|
||||
strip.addEventListener('pointerdown', (event) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
|
||||
pointerId = event.pointerId;
|
||||
startX = event.clientX;
|
||||
startScroll = strip.scrollLeft;
|
||||
dragging = false;
|
||||
suppressClick = false;
|
||||
});
|
||||
strip.addEventListener('pointermove', (event) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
const delta = event.clientX - startX;
|
||||
if (!dragging && Math.abs(delta) <= 4) return;
|
||||
if (!dragging) {
|
||||
dragging = true;
|
||||
suppressClick = true;
|
||||
strip.classList.add('is-pointer-dragging');
|
||||
strip.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
if (event.cancelable) event.preventDefault();
|
||||
strip.scrollLeft = startScroll - delta;
|
||||
});
|
||||
const stopDragging = (event) => {
|
||||
if (pointerId !== event.pointerId) return;
|
||||
if (dragging && strip.hasPointerCapture?.(event.pointerId)) {
|
||||
strip.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
pointerId = null;
|
||||
dragging = false;
|
||||
strip.classList.remove('is-pointer-dragging');
|
||||
sync();
|
||||
};
|
||||
strip.addEventListener('pointerup', stopDragging);
|
||||
strip.addEventListener('pointercancel', stopDragging);
|
||||
strip.addEventListener('click', (event) => {
|
||||
if (!suppressClick) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
suppressClick = false;
|
||||
}, true);
|
||||
|
||||
if (typeof ResizeObserver === 'function') new ResizeObserver(sync).observe(strip);
|
||||
new MutationObserver(sync).observe(strip, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'hidden', 'style'],
|
||||
});
|
||||
requestAnimationFrame(sync);
|
||||
}
|
||||
|
||||
function scanChipStrips(root = document) {
|
||||
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(CHIP_STRIP_SELECTOR)) initChipStrip(root);
|
||||
root.querySelectorAll?.(CHIP_STRIP_SELECTOR).forEach(initChipStrip);
|
||||
}
|
||||
|
||||
export function initChipScrollRows() {
|
||||
if (window._chipScrollRowsBound) return;
|
||||
window._chipScrollRowsBound = true;
|
||||
|
||||
['touchstart', 'touchmove'].forEach(type => {
|
||||
document.addEventListener(type, event => {
|
||||
if (event.target.closest?.('.chip-scroll-strip')) event.stopPropagation();
|
||||
}, true);
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
scanChipStrips();
|
||||
new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
record.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) scanChipStrips(node);
|
||||
});
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
else start();
|
||||
}
|
||||
|
||||
initChipScrollRows();
|
||||
@@ -1,6 +1,6 @@
|
||||
// static/js/codeRunner.js
|
||||
|
||||
import * as uiModule from './ui.js';
|
||||
import * as uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
/**
|
||||
* In-browser code runner for Python (Pyodide), JavaScript, and HTML
|
||||
@@ -33,6 +33,11 @@ function showLoading(panel, msg) {
|
||||
panel.innerHTML = `<div class="code-runner-loading">${msg}</div>`;
|
||||
}
|
||||
|
||||
function showEmpty(panel) {
|
||||
panel.innerHTML = '<div class="code-runner-empty">Nothing to run. Add some code first.</div>';
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show output text in the panel
|
||||
*/
|
||||
@@ -48,7 +53,9 @@ function showOutput(panel, text, isError) {
|
||||
const cbtn = document.createElement('button');
|
||||
cbtn.type = 'button';
|
||||
cbtn.className = 'code-runner-copy-inline';
|
||||
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy';
|
||||
cbtn.title = 'Copy output';
|
||||
cbtn.setAttribute('aria-label', 'Copy output');
|
||||
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
cbtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -67,14 +74,18 @@ function showOutput(panel, text, isError) {
|
||||
if (!ok && navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (uiModule.showToast) uiModule.showToast('Copied');
|
||||
cbtn.textContent = 'Copied!';
|
||||
setTimeout(() => { cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy'; }, 1500);
|
||||
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
setTimeout(() => { cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; }, 1500);
|
||||
}).catch(() => { if (uiModule.showToast) uiModule.showToast('Copy failed'); });
|
||||
return;
|
||||
}
|
||||
if (uiModule.showToast) uiModule.showToast(ok ? 'Copied' : 'Copy failed');
|
||||
const orig = cbtn.innerHTML;
|
||||
cbtn.textContent = ok ? 'Copied!' : 'Copy failed';
|
||||
if (ok) {
|
||||
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
} else {
|
||||
cbtn.textContent = 'Copy failed';
|
||||
}
|
||||
setTimeout(() => { cbtn.innerHTML = orig; }, 1500);
|
||||
});
|
||||
// Button lives directly in the panel — no wrapping bar. The panel is
|
||||
@@ -381,12 +392,15 @@ export function runHTML(code, panel) {
|
||||
export function run(btn) {
|
||||
const code = btn.getAttribute('data-code');
|
||||
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
|
||||
if (!code) return;
|
||||
|
||||
const pre = btn.closest('pre');
|
||||
if (!pre) return;
|
||||
|
||||
const panel = getOrCreatePanel(pre);
|
||||
if (!code || !code.trim()) {
|
||||
showEmpty(panel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') {
|
||||
runServer(code, panel, 'bash');
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// their .value stays the source of truth, and we dispatch 'input'
|
||||
// events so existing listeners keep working.
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const LS_RECENT = 'odysseus-recent-colors';
|
||||
const MAX_RECENT = 12;
|
||||
|
||||
@@ -110,10 +112,13 @@ function buildPopover() {
|
||||
<div class="cp-row">
|
||||
<div class="cp-preview"></div>
|
||||
<input type="text" class="cp-hex" maxlength="7" spellcheck="false" autocomplete="off">
|
||||
<button class="cp-copy" title="Copy color" type="button" aria-label="Copy color">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
<button class="cp-eyedropper" title="Eyedropper" type="button">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 22l4-4m0 0l3-3 5 5-3 3a2 2 0 01-2.8 0l-2.2-2.2a2 2 0 010-2.8z"/>
|
||||
<path d="M14 8l3-3a3 3 0 014.2 4.2l-3 3-4.2-4.2z"/>
|
||||
<path d="M12 2.5S5 10.1 5 14.5a7 7 0 0014 0C19 10.1 12 2.5 12 2.5Z"/>
|
||||
<path d="M9 16.5c.6 1.2 1.6 1.9 3 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -177,6 +182,56 @@ function setFromHex(hex) {
|
||||
_h = v.h; _s = v.s; _v = v.v;
|
||||
}
|
||||
|
||||
function cssColorToHex(value) {
|
||||
const match = String(value || '').match(/rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)(?:[, /]+\s*([\d.]+%?))?\s*\)/i);
|
||||
if (!match) return null;
|
||||
const alpha = match[4] == null ? 1 : (match[4].endsWith('%') ? parseFloat(match[4]) / 100 : parseFloat(match[4]));
|
||||
if (!Number.isFinite(alpha) || alpha <= 0) return null;
|
||||
return rgbToHex(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]));
|
||||
}
|
||||
|
||||
function colorAtPoint(x, y) {
|
||||
const candidates = document.elementsFromPoint(x, y)
|
||||
.filter(el => el !== _popover && !_popover?.contains(el));
|
||||
for (const el of candidates) {
|
||||
const style = getComputedStyle(el);
|
||||
for (const value of [style.backgroundColor, style.borderTopColor, style.color]) {
|
||||
const hex = cssColorToHex(value);
|
||||
if (hex) return hex;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openFallbackEyedropper() {
|
||||
return new Promise((resolve) => {
|
||||
const hint = document.createElement('div');
|
||||
hint.textContent = 'Click a visible color to sample · Esc to cancel';
|
||||
hint.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:2147483647;padding:5px 9px;border:1px solid var(--border,#555);border-radius:5px;background:var(--bg,#222);color:var(--fg,#fff);font:12px sans-serif;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.35)';
|
||||
document.body.appendChild(hint);
|
||||
const finish = (value) => {
|
||||
document.removeEventListener('click', onClick, true);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
hint.remove();
|
||||
resolve(value);
|
||||
};
|
||||
const onClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
finish(colorAtPoint(event.clientX, event.clientY));
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', onClick, true);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────
|
||||
// Window-level pointer listeners — installed ONCE, not per-popover, so they
|
||||
// don't leak when the popover is rebuilt on every open.
|
||||
@@ -197,6 +252,7 @@ function wireHandlers(p) {
|
||||
const sl = p.querySelector('.cp-sl');
|
||||
const hue = p.querySelector('.cp-hue');
|
||||
const hex = p.querySelector('.cp-hex');
|
||||
const copy = p.querySelector('.cp-copy');
|
||||
const eye = p.querySelector('.cp-eyedropper');
|
||||
|
||||
const onDown = (type) => (e) => {
|
||||
@@ -221,6 +277,29 @@ function wireHandlers(p) {
|
||||
if (e.key === 'Escape') { close(); }
|
||||
});
|
||||
|
||||
copy.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const value = hsvToHex(_h, _s, _v);
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch (_) {
|
||||
hex.focus();
|
||||
hex.select();
|
||||
document.execCommand('copy');
|
||||
hex.setSelectionRange(hex.value.length, hex.value.length);
|
||||
}
|
||||
copy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
copy.title = 'Copied';
|
||||
copy.setAttribute('aria-label', 'Copied');
|
||||
setTimeout(() => {
|
||||
if (!copy.isConnected) return;
|
||||
copy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
||||
copy.title = 'Copy color';
|
||||
copy.setAttribute('aria-label', 'Copy color');
|
||||
}, 900);
|
||||
});
|
||||
|
||||
p.addEventListener('click', (e) => {
|
||||
const sw = e.target.closest('.cp-swatch');
|
||||
if (sw && sw.dataset.hex) {
|
||||
@@ -230,8 +309,7 @@ function wireHandlers(p) {
|
||||
}
|
||||
});
|
||||
|
||||
if (window.EyeDropper) {
|
||||
eye.addEventListener('click', async (ev) => {
|
||||
eye.addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
// Suppress the outside-click close while the OS eyedropper is open.
|
||||
// Without this, the user's pixel-pick fires a window click that
|
||||
@@ -239,7 +317,9 @@ function wireHandlers(p) {
|
||||
const wasOnOutside = _onOutside;
|
||||
_detachOutsideHandlers();
|
||||
try {
|
||||
const r = await new window.EyeDropper().open();
|
||||
const r = window.EyeDropper
|
||||
? await new window.EyeDropper().open()
|
||||
: { sRGBHex: await openFallbackEyedropper() };
|
||||
if (r && r.sRGBHex) {
|
||||
setFromHex(r.sRGBHex);
|
||||
applyToInput(true);
|
||||
@@ -258,10 +338,8 @@ function wireHandlers(p) {
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
eye.disabled = true;
|
||||
eye.style.opacity = '0.3';
|
||||
eye.title = 'Eyedropper not supported in this browser';
|
||||
if (!window.EyeDropper) {
|
||||
eye.title = 'Click a visible UI color to sample it';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +369,14 @@ function commitCurrent() {
|
||||
|
||||
// ── Open / close ──────────────────────────────────────────────────────
|
||||
function position(p, anchor) {
|
||||
p.style.zIndex = String(topPortalZ());
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) {
|
||||
p.style.left = '50%';
|
||||
p.style.top = '50%';
|
||||
p.style.transform = 'translate(-50%, -50%)';
|
||||
return;
|
||||
}
|
||||
p.style.transform = '';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const pRect = p.getBoundingClientRect();
|
||||
let left = rect.left;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
export const EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
export const EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
export const SAVE_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>';
|
||||
export const CHAT_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
export const CHAT_ICON = '<svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
export const ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
export const ICON_REROLL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
|
||||
export const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
@@ -29,7 +29,7 @@ export const VOTES_STORAGE_KEY = 'odysseus-compare-votes';
|
||||
export const VOTES_MAX = 200;
|
||||
export const POOL_STORAGE_KEY = 'odysseus-shuffle-pool-excluded';
|
||||
|
||||
// ── Evaluation prompt templates ──
|
||||
// ── Comparison prompt templates ──
|
||||
//
|
||||
// Five high-signal prompts per category — each picked to differentiate models
|
||||
// on a distinct capability. The Visual / SVG-render prompt in `chat` ends with
|
||||
@@ -41,8 +41,11 @@ export const EVAL_PROMPTS = {
|
||||
// ── ★ Featured — prompts that have actually broken frontier models ──
|
||||
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
|
||||
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||
{ sub: '★ Featured', label: 'Clock angle', answer: '100°', prompt: 'At exactly 7:20, what is the smaller angle between the hour hand and minute hand of an analog clock? Explain the movement of both hands precisely. End with only the angle, including the degree symbol, on its own line.' },
|
||||
{ sub: '★ Featured', label: 'Painted cube', answer: '24', prompt: 'A 4×4×4 cube is painted on every outside face, then cut into 64 unit cubes. How many unit cubes have exactly two painted faces? Explain how you count them, then end with the final number on its own line.' },
|
||||
|
||||
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
|
||||
{ sub: 'Visual explain', label: 'TCP handshake', prompt: 'Output one complete self-contained HTML file in a single ```html block with no prose outside it. Visually explain a TCP connection from client to server using a clear inline SVG sequence: SYN, SYN-ACK, ACK, data transfer, and FIN. Show packet direction, sequence/acknowledgment numbers, and a short plain-language annotation at every step. Use theme-aware CSS variables with a readable dark default, responsive layout, no external assets, and no JavaScript.' },
|
||||
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
|
||||
{ sub: 'Visual explain', label: 'Butterfly ASCII', prompt: 'Explain the butterfly lifecycle using ASCII art. Produce four separate frames in fenced code blocks, in order: egg, caterpillar, chrysalis, adult butterfly. Each frame must be drawn with monospace ASCII characters only and be visually recognizable as the creature/stage. Below each frame add one playful one-line caption (no longer than 15 words) describing what is happening at that stage.' },
|
||||
],
|
||||
@@ -57,8 +60,16 @@ export const EVAL_PROMPTS = {
|
||||
{ sub: 'Web tasks', label: 'Multi-step', prompt: 'Search the web for the current population of the 3 largest cities in the world, then calculate what percentage of the world\'s total population lives in those cities.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Fact check', prompt: 'Fact-check these claims: 1) The Great Wall of China is visible from space. 2) Humans only use 10% of their brains. 3) Lightning never strikes the same place twice. Cite sources.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Compare prices', prompt: 'Find and compare the pricing, features, and limitations of the top 3 cloud GPU providers for machine learning training. Create a markdown comparison table.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'Primary source', prompt: 'Find the latest official release notes for Python and summarize the most important developer-facing changes. Prefer the official Python documentation over blogs. Include source links.', toggles: ['web'] },
|
||||
{ sub: 'Web tasks', label: 'JS-heavy page', prompt: 'Find a reliable source for the latest stable Chrome version, then verify it against at least one secondary source. Explain any mismatch clearly and cite both sources.', toggles: ['web'] },
|
||||
{ sub: 'Research', label: 'Paper trail', prompt: 'Find two recent papers about long-context language models, extract each paper\'s claimed contribution, and compare the evidence quality in a short table. Cite the papers.', toggles: ['web'] },
|
||||
{ sub: 'Research', label: 'Source quality', prompt: 'Research whether AI coding assistants improve developer productivity. Reject shallow listicles, use at least one empirical study, and state the strongest caveat.', toggles: ['web'] },
|
||||
{ sub: 'Code tasks', label: 'Script + run', prompt: 'Write a Python script that generates a bar chart of the 5 most common programming languages in 2025 and save it as chart.png. Then run it.' },
|
||||
{ sub: 'Code tasks', label: 'Debug + test', prompt: 'Create a minimal failing test for a function that should parse ISO dates, implement the parser, then run the test and report the result.' },
|
||||
{ sub: 'Code tasks', label: 'Inspect files', prompt: 'Inspect the current project files, identify the main frontend entry point, and summarize the boot order with exact file names.' },
|
||||
{ sub: 'Code tasks', label: 'CLI summarize', prompt: 'Use shell commands to find the 10 largest JavaScript files in this repo and explain which one looks most worth splitting first.' },
|
||||
{ sub: 'Math', label: 'Proof + verify', prompt: 'Prove that the square root of 2 is irrational. Then write a Python program that approximates it using Newton\'s method to 50 decimal places and verify.' },
|
||||
{ sub: 'Math', label: 'Monte Carlo', prompt: 'Estimate pi with a Monte Carlo simulation in Python, run it, then explain how sample size changes the error.' },
|
||||
],
|
||||
html: [
|
||||
{ sub: 'Games', label: 'Snake', prompt: 'Output a complete HTML file (```html block) for a Snake game. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, neon green snake on dark grid, glowing food, score counter, speed increases, game over + restart. Skip any explanation, just output the code.' },
|
||||
|
||||
@@ -17,27 +17,29 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
ICON_PARALLEL, ICON_SEQUENTIAL,
|
||||
EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
|
||||
SEND_SVG, VOTES_STORAGE_KEY,
|
||||
} from './icons.js';
|
||||
} from './icons.js?v=20260908compareprompts1';
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260903compareprobe4';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260908panestatspopup2';
|
||||
import {
|
||||
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
|
||||
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
|
||||
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
|
||||
mountMobilePaneTabs, syncShuffleButtonPlacement,
|
||||
paneSettingsButtonHtml, togglePaneSettings,
|
||||
registerPaneActions,
|
||||
} from './panes.js';
|
||||
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
} from './panes.js?v=20260908compareheader1';
|
||||
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
// ── External dependency imports ──
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from '../sessions.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
@@ -78,6 +80,18 @@ function _compareModeLabel() {
|
||||
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
}
|
||||
|
||||
function _paneModeBadgeHtml(paneIdx) {
|
||||
const mode = String(state._compareMode || 'chat');
|
||||
if (mode !== 'search') return '';
|
||||
const label = 'Search';
|
||||
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
|
||||
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
|
||||
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
|
||||
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
|
||||
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
|
||||
'</span>';
|
||||
}
|
||||
|
||||
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
|
||||
const target = mode === 'agent' ? 'agent' : 'chat';
|
||||
const toggleState = Storage.loadToggleState();
|
||||
@@ -109,10 +123,50 @@ function _syncCompareModeFromToolbar(mode) {
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||||
}
|
||||
document.querySelectorAll('.compare-pane .pane-mode-badge').forEach((badge) => {
|
||||
const template = document.createElement('template');
|
||||
const paneIdx = Number(badge.closest('.compare-pane')?.dataset.pane || 0);
|
||||
template.innerHTML = _paneModeBadgeHtml(paneIdx);
|
||||
const replacement = template.content.firstElementChild;
|
||||
if (replacement) badge.replaceWith(replacement);
|
||||
else badge.remove();
|
||||
});
|
||||
const evalWrap = document.getElementById('cmp-eval-wrap');
|
||||
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
|
||||
}
|
||||
|
||||
function _showPaneStatsPopup(summary) {
|
||||
document.querySelectorAll('.pane-stats-popup').forEach((el) => el._dismiss ? el._dismiss() : el.remove());
|
||||
const values = String(summary.textContent || '').split(' · ').filter(Boolean);
|
||||
if (!values.length) return;
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'pane-stats-popup';
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-label', 'Response statistics');
|
||||
popup.innerHTML = '<div class="pane-stats-popup-title">Response stats</div>' + values.map((value) => {
|
||||
let label = 'Value';
|
||||
let display = value;
|
||||
if (value.startsWith('TTFT ')) { label = 'First token'; display = value.slice(5); }
|
||||
else if (value.endsWith(' tok')) { label = 'Output'; display = value.replace(/ tok$/, ' tokens'); }
|
||||
else if (value.endsWith('/s')) label = 'Speed';
|
||||
else if (value.endsWith('% ctx')) { label = 'Context'; display = value.replace(/ ctx$/, ''); }
|
||||
else if (value.startsWith('$')) label = 'Cost';
|
||||
return '<div class="pane-stats-popup-row"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(display) + '</strong></div>';
|
||||
}).join('');
|
||||
document.body.appendChild(popup);
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - popupRect.width - 8)) + 'px';
|
||||
popup.style.top = (rect.bottom + popupRect.height + 6 <= window.innerHeight
|
||||
? rect.bottom + 6
|
||||
: Math.max(8, rect.top - popupRect.height - 6)) + 'px';
|
||||
summary.setAttribute('aria-expanded', 'true');
|
||||
bindMenuDismiss(popup, () => {
|
||||
popup.remove();
|
||||
summary.setAttribute('aria-expanded', 'false');
|
||||
}, (event) => !popup.contains(event.target) && event.target !== summary);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ── closeCompare ──
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -260,8 +314,12 @@ async function _buildCompareUI() {
|
||||
sessionIds.push(data.id);
|
||||
}
|
||||
state._paneSessionIds = sessionIds;
|
||||
state._paneGenerationSettings = sessionIds.map(() => ({
|
||||
thinking_mode: '', temperature_override: null, max_tokens_override: null,
|
||||
}));
|
||||
} else {
|
||||
state._paneSessionIds = [];
|
||||
state._paneGenerationSettings = [];
|
||||
}
|
||||
state._paneMetrics = state._selectedModels.map(() => null);
|
||||
state._abortControllers = state._selectedModels.map(() => null);
|
||||
@@ -371,11 +429,10 @@ async function _buildCompareUI() {
|
||||
|
||||
const checkBtn = document.createElement('button');
|
||||
checkBtn.id = 'compare-check-btn';
|
||||
checkBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span style="font-size:11px;margin-left:3px;">Probe</span>';
|
||||
checkBtn.innerHTML = '<svg class="compare-check-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span class="compare-check-label">Probe</span>';
|
||||
checkBtn.title = 'Probe unverified models with a small test request';
|
||||
checkBtn.style.cssText = _btnCSS;
|
||||
checkBtn.addEventListener('click', () => _checkUnprobed());
|
||||
headerActions.appendChild(checkBtn);
|
||||
|
||||
// Check button is dynamic: only visible when at least one selected model
|
||||
// hasn't been probed yet. Show right after add/change, hide after success.
|
||||
@@ -389,6 +446,7 @@ async function _buildCompareUI() {
|
||||
// (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
|
||||
|
||||
const exportWrap = document.createElement('div');
|
||||
exportWrap.className = 'compare-export-wrap';
|
||||
exportWrap.style.cssText = 'position:relative;display:inline-flex;';
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.id = 'compare-export-btn';
|
||||
@@ -400,15 +458,41 @@ async function _buildCompareUI() {
|
||||
_toggleExportMenu(exportBtn);
|
||||
});
|
||||
exportWrap.appendChild(exportBtn);
|
||||
headerActions.appendChild(exportWrap);
|
||||
|
||||
const shuffleBtn = document.createElement('button');
|
||||
shuffleBtn.id = 'compare-shuffle-btn';
|
||||
shuffleBtn.innerHTML = ICON_DICE + '<span style="font-size:11px;margin-left:3px;">Shuffle</span>';
|
||||
shuffleBtn.title = 'Shuffle pane positions';
|
||||
shuffleBtn.style.cssText = _btnCSS;
|
||||
shuffleBtn.addEventListener('click', () => shufflePanePositions());
|
||||
headerActions.appendChild(shuffleBtn);
|
||||
shuffleBtn.addEventListener('click', () => {
|
||||
shufflePanePositions();
|
||||
syncShuffleButtonPlacement(false);
|
||||
});
|
||||
|
||||
const moreWrap = document.createElement('div');
|
||||
moreWrap.className = 'compare-more-wrap';
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'compare-more-btn';
|
||||
moreBtn.type = 'button';
|
||||
moreBtn.title = 'Compare actions';
|
||||
moreBtn.setAttribute('aria-label', 'Compare actions');
|
||||
moreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
|
||||
const moreMenu = document.createElement('div');
|
||||
moreMenu.className = 'compare-more-menu';
|
||||
moreMenu.append(exportWrap, shuffleBtn, checkBtn);
|
||||
moreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
moreMenu.classList.toggle('is-open');
|
||||
moreBtn.setAttribute('aria-expanded', moreMenu.classList.contains('is-open') ? 'true' : 'false');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!moreWrap.contains(e.target)) {
|
||||
moreMenu.classList.remove('is-open');
|
||||
moreBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}, true);
|
||||
moreWrap.append(moreBtn, moreMenu);
|
||||
headerActions.appendChild(moreWrap);
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.id = 'compare-add-btn';
|
||||
@@ -416,21 +500,24 @@ async function _buildCompareUI() {
|
||||
addBtn.title = 'Add model pane';
|
||||
addBtn.style.cssText = _btnCSS;
|
||||
addBtn.addEventListener('click', () => _addPane(addBtn));
|
||||
headerActions.appendChild(addBtn);
|
||||
addBtn.className = 'compare-add-flap';
|
||||
addBtn.setAttribute('aria-label', 'Add model pane');
|
||||
|
||||
const addMenuBtn = document.createElement('button');
|
||||
addMenuBtn.id = 'compare-add-menu-btn';
|
||||
addMenuBtn.type = 'button';
|
||||
addMenuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>Add model</span>';
|
||||
addMenuBtn.title = 'Add model pane';
|
||||
addMenuBtn.addEventListener('click', () => _addPane(addMenuBtn));
|
||||
moreMenu.append(addMenuBtn, shuffleBtn, checkBtn, exportWrap);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'compare-close-btn';
|
||||
closeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
closeBtn.className = 'close-btn compare-close-btn';
|
||||
closeBtn.innerHTML = '';
|
||||
closeBtn.title = 'Close compare mode';
|
||||
// Match Export/Score/Shuffle/Model styling so the X sits flush with
|
||||
// the rest of the toolbar instead of being a 24×24 bordered square.
|
||||
closeBtn.style.cssText = _btnCSS;
|
||||
closeBtn.addEventListener('click', () => deactivate(true));
|
||||
headerActions.appendChild(closeBtn);
|
||||
|
||||
// Move Export to the far left of the action cluster (per user preference).
|
||||
headerActions.insertBefore(exportWrap, headerActions.firstChild);
|
||||
|
||||
headerBar.appendChild(headerActions);
|
||||
container.appendChild(headerBar);
|
||||
state._compareElements.push(headerBar);
|
||||
@@ -449,16 +536,26 @@ async function _buildCompareUI() {
|
||||
pane.dataset.pane = String(i);
|
||||
pane.innerHTML =
|
||||
'<div class="pane-header">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<div class="pane-header-row pane-header-primary">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<div class="pane-primary-actions">' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="pane-header-row pane-header-secondary">' +
|
||||
'<div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
@@ -472,6 +569,11 @@ async function _buildCompareUI() {
|
||||
grid.appendChild(pane);
|
||||
}
|
||||
grid.addEventListener('click', (e) => {
|
||||
const summary = e.target.closest('.pane-summary');
|
||||
if (summary && summary.textContent.trim()) {
|
||||
_showPaneStatsPopup(summary);
|
||||
return;
|
||||
}
|
||||
const voteBtn = e.target.closest('.pane-vote-btn');
|
||||
if (voteBtn) {
|
||||
e.stopPropagation();
|
||||
@@ -480,7 +582,7 @@ async function _buildCompareUI() {
|
||||
handleVote(idx);
|
||||
return;
|
||||
}
|
||||
const actionBtn = e.target.closest('.pane-action-btn');
|
||||
const actionBtn = e.target.closest('[data-action]');
|
||||
if (actionBtn) {
|
||||
e.stopPropagation();
|
||||
const action = actionBtn.dataset.action;
|
||||
@@ -490,6 +592,7 @@ async function _buildCompareUI() {
|
||||
else if (action === 'reroll') rerollPane(idx);
|
||||
else if (action === 'expand') toggleExpandPane(idx, actionBtn);
|
||||
else if (action === 'preview') togglePanePreview(idx);
|
||||
else if (action === 'settings') togglePaneSettings(idx, actionBtn);
|
||||
else if (action === 'close') _removePane(idx);
|
||||
return;
|
||||
}
|
||||
@@ -500,7 +603,17 @@ async function _buildCompareUI() {
|
||||
_showModelSwapDropdown(idx, titleBtn);
|
||||
}
|
||||
});
|
||||
grid.addEventListener('keydown', (e) => {
|
||||
const summary = e.target.closest('.pane-summary');
|
||||
if (summary && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
_showPaneStatsPopup(summary);
|
||||
}
|
||||
});
|
||||
container.appendChild(grid);
|
||||
grid.appendChild(addBtn);
|
||||
const mobileTabs = mountMobilePaneTabs(container, grid, (anchor) => _addPane(anchor));
|
||||
state._compareElements.push(mobileTabs);
|
||||
state._compareElements.push(grid);
|
||||
|
||||
// 10. Vote bar placeholder
|
||||
@@ -523,7 +636,7 @@ async function _buildCompareUI() {
|
||||
const msgTA = document.getElementById('message');
|
||||
if (msgTA) {
|
||||
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
|
||||
requestAnimationFrame(() => msgTA.focus());
|
||||
if (window.innerWidth > 768) requestAnimationFrame(() => msgTA.focus());
|
||||
}
|
||||
|
||||
// Eval-prompts picker — sits inside the message box at top-right (where
|
||||
@@ -1074,23 +1187,28 @@ function _toggleExportMenu(btn) {
|
||||
const r = btn.getBoundingClientRect();
|
||||
const m = document.createElement('div');
|
||||
m.className = 'compare-export-menu';
|
||||
m.style.cssText = 'position:fixed;z-index:10001;top:' + (r.bottom + 4) + 'px;left:' + r.left + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;display:flex;flex-direction:column;min-width:170px;';
|
||||
const inCompareMenu = !!btn.closest('.compare-more-menu');
|
||||
const menuLeft = inCompareMenu ? r.right + 6 : r.left;
|
||||
const menuTop = inCompareMenu ? r.top : r.bottom + 4;
|
||||
m.style.cssText = 'position:fixed;z-index:10001;top:' + menuTop + 'px;left:' + menuLeft + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 0 1px color-mix(in srgb,var(--fg) 5%,transparent);padding:6px;font-size:12px;display:flex;flex-direction:column;gap:2px;min-width:170px;backdrop-filter:blur(12px);';
|
||||
const opts = [
|
||||
{ label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) },
|
||||
{ label: 'Download .md', fn: () => _exportDownloadMarkdown() },
|
||||
{ label: 'Print / Save PDF', fn: () => _exportPrint() },
|
||||
{ label: 'Copy as Markdown', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>', fn: () => _exportCopyMarkdown(btn) },
|
||||
{ label: 'Download .md', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', fn: () => _exportDownloadMarkdown() },
|
||||
{ label: 'Print / Save PDF', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>', fn: () => _exportPrint() },
|
||||
];
|
||||
for (const o of opts) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.textContent = o.label;
|
||||
item.style.cssText = 'background:none;border:none;color:var(--fg);text-align:left;padding:8px 12px;border-radius:6px;cursor:pointer;font:inherit;font-size:12px;';
|
||||
item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
item.addEventListener('mouseleave', () => { item.style.background = 'none'; });
|
||||
item.className = 'compare-export-item';
|
||||
item.innerHTML = o.icon + '<span>' + o.label + '</span>';
|
||||
item.style.cssText = 'background:none;border:1px solid transparent;color:var(--fg);text-align:left;padding:8px 10px;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:1.3;';
|
||||
item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
|
||||
m.appendChild(item);
|
||||
}
|
||||
document.body.appendChild(m);
|
||||
const mr = m.getBoundingClientRect();
|
||||
if (mr.right > window.innerWidth - 8) m.style.left = Math.max(8, r.left - mr.width - 6) + 'px';
|
||||
if (mr.bottom > window.innerHeight - 8) m.style.top = Math.max(8, window.innerHeight - mr.height - 8) + 'px';
|
||||
_exportMenuEl = m;
|
||||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
@@ -1237,10 +1355,10 @@ function _setupEvalPicker() {
|
||||
btn.type = 'button';
|
||||
btn.id = 'cmp-eval-btn';
|
||||
btn.className = 'cmp-eval-btn';
|
||||
btn.title = 'Insert an evaluation prompt';
|
||||
btn.title = 'Insert a comparison prompt';
|
||||
btn.innerHTML =
|
||||
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
|
||||
+ '<span class="cmp-eval-label">Eval prompts</span>'
|
||||
+ '<span class="cmp-eval-label">Prompt sets</span>'
|
||||
+ '<svg class="cmp-eval-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
|
||||
const menu = document.createElement('div');
|
||||
@@ -1293,8 +1411,9 @@ function _setupEvalPicker() {
|
||||
const ta = document.getElementById('message');
|
||||
if (ta) {
|
||||
ta.value = decodeURIComponent(item.dataset.prompt);
|
||||
ta.dataset.comparePromptPreset = 'true';
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
ta.focus();
|
||||
if (window.innerWidth > 768) ta.focus();
|
||||
}
|
||||
const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : '';
|
||||
_showExpectedAnswer(ans);
|
||||
@@ -1324,9 +1443,8 @@ function _setupEvalPicker() {
|
||||
wrap._renderItems = _renderItems;
|
||||
inputTop.appendChild(wrap);
|
||||
|
||||
// Expected-answer chip — placed above the chat-input-bar (outside it), so
|
||||
// it floats over the compare grid right before the message box. Shows when
|
||||
// a graded prompt is picked so the eval-runner can verify model output.
|
||||
// Expected-answer chip placed above the input when a prompt includes a
|
||||
// known answer, making side-by-side comparison easier.
|
||||
const hintChip = document.createElement('div');
|
||||
hintChip.className = 'cmp-eval-expected hidden';
|
||||
hintChip.id = 'cmp-eval-expected';
|
||||
@@ -1365,9 +1483,23 @@ function _setupEvalPicker() {
|
||||
// pane ✓/✗ badges never appeared. The chip is only cleared via its
|
||||
// own dismiss button (or when the user picks a new eval).
|
||||
const ta = document.getElementById('message');
|
||||
const clearPresetBtn = document.createElement('button');
|
||||
clearPresetBtn.type = 'button';
|
||||
clearPresetBtn.className = 'cmp-eval-clear';
|
||||
clearPresetBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Clear prompt</span>';
|
||||
clearPresetBtn.addEventListener('click', () => {
|
||||
if (!ta) return;
|
||||
ta.value = '';
|
||||
delete ta.dataset.comparePromptPreset;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
_showExpectedAnswer('');
|
||||
if (window.innerWidth > 768) ta.focus();
|
||||
});
|
||||
inputTop.appendChild(clearPresetBtn);
|
||||
const _syncEvalVisibility = () => {
|
||||
const hasText = ta && ta.value.trim().length > 0;
|
||||
wrap.style.display = hasText ? 'none' : '';
|
||||
clearPresetBtn.style.display = hasText && ta.dataset.comparePromptPreset === 'true' ? '' : 'none';
|
||||
if (hasText) menu.classList.add('hidden');
|
||||
};
|
||||
if (ta) ta.addEventListener('input', _syncEvalVisibility);
|
||||
@@ -1376,6 +1508,7 @@ function _setupEvalPicker() {
|
||||
// Stash cleanup so cleanupResults() can detach the doc listener and
|
||||
// restore the model-picker when compare deactivates.
|
||||
wrap._cleanup = () => {
|
||||
clearPresetBtn.remove();
|
||||
document.removeEventListener('click', _onDocClick);
|
||||
if (ta) ta.removeEventListener('input', _syncEvalVisibility);
|
||||
if (modelWrap) modelWrap.style.display = prevModelDisplay || '';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/models.js — model classification, fetching, display names, persistence
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { sortModelObjects } from '../modelSort.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// compare/panes.js — pane lifecycle, actions, layout
|
||||
import state from './state.js';
|
||||
import { _persistSelections } from './models.js';
|
||||
import { buildVoteBar } from './vote.js';
|
||||
import { buildVoteBar } from './vote.js?v=20260828resendcaldrag1';
|
||||
import {
|
||||
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
|
||||
ICON_PLAY, ICON_CODE, SEND_SVG,
|
||||
} from './icons.js';
|
||||
ICON_PLAY, ICON_CODE, SEND_SVG, ICON_DICE,
|
||||
} from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import Storage from '../storage.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
@@ -33,6 +33,342 @@ function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearc
|
||||
/** Slot label: A/B/C in parallel mode, 1/2/3 in sequential. */
|
||||
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
|
||||
|
||||
/** Keep Shuffle compact until a larger comparison makes it useful as a
|
||||
* direct action. It returns to the kebab after being used. */
|
||||
function syncShuffleButtonPlacement(showDirect = state._selectedModels.length > 2) {
|
||||
const shuffleBtn = document.getElementById('compare-shuffle-btn');
|
||||
const moreMenu = document.querySelector('.compare-more-menu');
|
||||
const moreWrap = document.querySelector('.compare-more-wrap');
|
||||
const headerActions = moreWrap?.parentElement;
|
||||
if (!shuffleBtn || !moreMenu || !moreWrap || !headerActions) return;
|
||||
if (showDirect) {
|
||||
if (shuffleBtn.parentElement !== headerActions) headerActions.insertBefore(shuffleBtn, moreWrap);
|
||||
} else if (shuffleBtn.parentElement !== moreMenu) {
|
||||
moreMenu.appendChild(shuffleBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function _paneModeBadgeHtml(paneIdx) {
|
||||
const mode = String(state._compareMode || 'chat');
|
||||
if (mode !== 'search') return '';
|
||||
const label = ({ agent: 'Agent', search: 'Search', research: 'Research' }[mode] || 'Chat');
|
||||
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
|
||||
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
|
||||
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
|
||||
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
|
||||
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
|
||||
'</span>';
|
||||
}
|
||||
|
||||
const PANE_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>';
|
||||
|
||||
function paneSettingsButtonHtml(paneIdx) {
|
||||
return '<button type="button" class="pane-action-btn pane-settings-btn" data-action="settings" data-pane="' + Number(paneIdx) + '" title="Inference settings" aria-label="Inference settings" aria-haspopup="dialog" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>';
|
||||
}
|
||||
|
||||
function _paneModelRequiresThinking(paneIdx) {
|
||||
const modelId = String(state._selectedModels[paneIdx]?.model || '').toLowerCase().split(':', 1)[0];
|
||||
return modelId === 'x-ai/grok-4.5' || modelId === 'grok-4.5';
|
||||
}
|
||||
|
||||
async function _savePaneGenerationSettings(paneIdx, change) {
|
||||
const sid = state._paneSessionIds[paneIdx];
|
||||
if (!sid) return false;
|
||||
const current = state._paneGenerationSettings[paneIdx] || {};
|
||||
const next = {
|
||||
thinking_mode: current.thinking_mode || '',
|
||||
temperature_override: current.temperature_override ?? null,
|
||||
max_tokens_override: current.max_tokens_override ?? null,
|
||||
...change,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${state.API_BASE}/api/session/${encodeURIComponent(sid)}/generation-settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
state._paneGenerationSettings[paneIdx] = { ...next, ...await res.json() };
|
||||
return true;
|
||||
} catch (err) {
|
||||
uiModule.showError(`Could not save pane settings: ${err.message || err}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePaneSettings(paneIdx, anchorBtn) {
|
||||
const existing = document.querySelector('.compare-pane-settings-popup');
|
||||
if (existing) {
|
||||
const same = existing.dataset.pane === String(paneIdx);
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
|
||||
if (same) return;
|
||||
}
|
||||
const settings = state._paneGenerationSettings[paneIdx] || {
|
||||
thinking_mode: '', temperature_override: null, max_tokens_override: null,
|
||||
};
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'chat-context-popup compare-pane-settings-popup';
|
||||
popup.dataset.pane = String(paneIdx);
|
||||
popup.setAttribute('role', 'dialog');
|
||||
popup.setAttribute('aria-label', 'Inference settings');
|
||||
popup.innerHTML = '<div class="chat-context-popup-title">Inference settings</div>';
|
||||
|
||||
const thinkingRequired = _paneModelRequiresThinking(paneIdx);
|
||||
const thinkingOn = thinkingRequired || settings.thinking_mode === 'on';
|
||||
const thinkingRow = document.createElement('div');
|
||||
thinkingRow.className = 'chat-context-toggle-row';
|
||||
thinkingRow.innerHTML = '<div class="chat-context-toggle-copy"><span>Thinking</span><span class="chat-context-toggle-state">' + (thinkingRequired ? 'Required' : (thinkingOn ? 'On' : 'Off')) + '</span></div>';
|
||||
const thinkingToggle = document.createElement('button');
|
||||
thinkingToggle.type = 'button';
|
||||
thinkingToggle.className = `chat-context-toggle${thinkingOn ? ' active' : ''}`;
|
||||
thinkingToggle.setAttribute('role', 'switch');
|
||||
thinkingToggle.setAttribute('aria-checked', thinkingOn ? 'true' : 'false');
|
||||
thinkingToggle.disabled = thinkingRequired;
|
||||
if (thinkingRequired) thinkingToggle.title = 'This model requires reasoning';
|
||||
thinkingToggle.addEventListener('click', async () => {
|
||||
if (thinkingRequired) return;
|
||||
const next = !thinkingToggle.classList.contains('active');
|
||||
thinkingToggle.disabled = true;
|
||||
if (await _savePaneGenerationSettings(paneIdx, { thinking_mode: next ? 'on' : 'off' })) {
|
||||
thinkingToggle.classList.toggle('active', next);
|
||||
thinkingToggle.setAttribute('aria-checked', next ? 'true' : 'false');
|
||||
thinkingRow.querySelector('.chat-context-toggle-state').textContent = next ? 'On' : 'Off';
|
||||
}
|
||||
thinkingToggle.disabled = false;
|
||||
});
|
||||
thinkingRow.appendChild(thinkingToggle);
|
||||
popup.appendChild(thinkingRow);
|
||||
|
||||
const addSlider = (label, value, min, max, step, formatter, key, normalize) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-context-threshold-row';
|
||||
row.innerHTML = '<div class="chat-context-threshold-top"><span>' + label + '</span><span>' + formatter(value) + '</span></div>';
|
||||
const input = document.createElement('input');
|
||||
Object.assign(input, { type: 'range', min: String(min), max: String(max), step: String(step), value: String(value), className: 'chat-context-threshold-slider preset-range' });
|
||||
input.addEventListener('input', () => { row.querySelector('.chat-context-threshold-top span:last-child').textContent = formatter(Number(input.value)); });
|
||||
input.addEventListener('change', async () => {
|
||||
input.disabled = true;
|
||||
await _savePaneGenerationSettings(paneIdx, { [key]: normalize(Number(input.value)) });
|
||||
input.disabled = false;
|
||||
});
|
||||
row.appendChild(input);
|
||||
popup.appendChild(row);
|
||||
};
|
||||
addSlider('Temperature', settings.temperature_override ?? 1, 0, 2, 0.1, value => Number(value).toFixed(1), 'temperature_override', value => value);
|
||||
addSlider('Max tokens', settings.max_tokens_override ?? 8448, 256, 8448, 256, value => value > 8192 ? 'No limit' : Number(value).toLocaleString(), 'max_tokens_override', value => value > 8192 ? null : value);
|
||||
|
||||
document.body.appendChild(popup);
|
||||
const rect = anchorBtn.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
popup.style.left = Math.max(margin, Math.min(rect.left, window.innerWidth - popupRect.width - margin)) + 'px';
|
||||
popup.style.top = Math.max(margin, Math.min(rect.bottom + 5, window.innerHeight - popupRect.height - margin)) + 'px';
|
||||
anchorBtn.setAttribute('aria-expanded', 'true');
|
||||
bindMenuDismiss(popup, () => {
|
||||
popup.remove();
|
||||
anchorBtn.setAttribute('aria-expanded', 'false');
|
||||
}, event => !popup.contains(event.target) && event.target !== anchorBtn);
|
||||
}
|
||||
|
||||
function _isMobileCompare() {
|
||||
return window.matchMedia('(max-width: 768px)').matches;
|
||||
}
|
||||
|
||||
function _mobilePaneLabel(index) {
|
||||
if (state._blindMode) return 'Model ' + _slotChar(index);
|
||||
return state._selectedModels[index]?.name || 'Model ' + (index + 1);
|
||||
}
|
||||
|
||||
/** Keep the phone tab strip and its single visible pane in sync. */
|
||||
function refreshMobilePaneTabs(preferredIndex = state._activeMobilePane) {
|
||||
const tabs = document.querySelector('.compare-mobile-tabs');
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
if (!tabs || !grid) return;
|
||||
|
||||
const panes = Array.from(grid.querySelectorAll(':scope > .compare-pane'));
|
||||
if (!panes.length) {
|
||||
tabs.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
state._activeMobilePane = Math.max(0, Math.min(Number(preferredIndex) || 0, panes.length - 1));
|
||||
const retained = new Set();
|
||||
panes.forEach((pane, index) => {
|
||||
const paneId = 'cmp-pane-' + index;
|
||||
const tabId = 'cmp-mobile-tab-' + index;
|
||||
pane.id = paneId;
|
||||
pane.setAttribute('role', 'tabpanel');
|
||||
pane.setAttribute('aria-labelledby', tabId);
|
||||
|
||||
let tab = tabs.querySelector(`[data-pane="${index}"]`);
|
||||
if (!tab) {
|
||||
tab = document.createElement('button');
|
||||
tab.type = 'button';
|
||||
tab.className = 'compare-mobile-tab';
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.innerHTML = '<span class="compare-mobile-tab-slot"></span><span class="compare-mobile-tab-label"></span><span class="compare-mobile-tab-state" aria-hidden="true"></span>';
|
||||
}
|
||||
retained.add(tab);
|
||||
tab.id = tabId;
|
||||
tab.dataset.pane = String(index);
|
||||
tab.setAttribute('aria-controls', paneId);
|
||||
tab.querySelector('.compare-mobile-tab-slot').textContent = _slotChar(index);
|
||||
tab.querySelector('.compare-mobile-tab-label').textContent = _mobilePaneLabel(index);
|
||||
tab.classList.toggle('is-streaming', pane.classList.contains('is-streaming'));
|
||||
tab.classList.toggle('is-awaiting-input', pane.classList.contains('is-awaiting-input'));
|
||||
tab.classList.toggle('is-done', pane.classList.contains('is-done'));
|
||||
tab.classList.toggle('is-failed', pane.classList.contains('is-failed'));
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
tabs.querySelectorAll('.compare-mobile-tab').forEach(tab => {
|
||||
if (!retained.has(tab)) tab.remove();
|
||||
});
|
||||
|
||||
let addButton = tabs.querySelector('.compare-mobile-add');
|
||||
if (!addButton && typeof tabs._onAddPane === 'function') {
|
||||
addButton = document.createElement('button');
|
||||
addButton.type = 'button';
|
||||
addButton.className = 'compare-mobile-add';
|
||||
addButton.title = 'Add model';
|
||||
addButton.setAttribute('aria-label', 'Add model');
|
||||
addButton.innerHTML = '<span aria-hidden="true">+</span><span>Add</span>';
|
||||
addButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
tabs._onAddPane(addButton);
|
||||
});
|
||||
}
|
||||
if (addButton) {
|
||||
addButton.disabled = !!state._streaming;
|
||||
addButton.style.display = panes.length >= 8 ? 'none' : '';
|
||||
tabs.appendChild(addButton);
|
||||
}
|
||||
|
||||
const mobile = _isMobileCompare();
|
||||
panes.forEach((pane, index) => {
|
||||
const active = index === state._activeMobilePane;
|
||||
pane.classList.toggle('compare-pane-mobile-active', active);
|
||||
if (mobile) pane.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||
else pane.removeAttribute('aria-hidden');
|
||||
});
|
||||
tabs.querySelectorAll('.compare-mobile-tab').forEach((tab, index) => {
|
||||
const active = index === state._activeMobilePane;
|
||||
tab.classList.toggle('active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
function activateMobilePane(index, { focus = false } = {}) {
|
||||
state._activeMobilePane = index;
|
||||
refreshMobilePaneTabs(index);
|
||||
const tab = document.querySelector(`.compare-mobile-tab[data-pane="${state._activeMobilePane}"]`);
|
||||
if (tab && _isMobileCompare()) {
|
||||
tab.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
if (focus) tab.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount a horizontally scrollable tab row without unmounting inactive streams. */
|
||||
function mountMobilePaneTabs(container, grid, onAddPane) {
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'compare-mobile-tabs';
|
||||
tabs.setAttribute('role', 'tablist');
|
||||
tabs.setAttribute('aria-label', 'Compared models');
|
||||
tabs._onAddPane = onAddPane;
|
||||
container.insertBefore(tabs, grid);
|
||||
|
||||
const onClick = (event) => {
|
||||
const tab = event.target.closest('.compare-mobile-tab');
|
||||
if (tab) activateMobilePane(Number(tab.dataset.pane));
|
||||
};
|
||||
const onKeyDown = (event) => {
|
||||
if (!event.target.closest('.compare-mobile-tab')) return;
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const last = Math.max(0, state._selectedModels.length - 1);
|
||||
let next = state._activeMobilePane;
|
||||
if (event.key === 'ArrowLeft') next = Math.max(0, next - 1);
|
||||
if (event.key === 'ArrowRight') next = Math.min(last, next + 1);
|
||||
if (event.key === 'Home') next = 0;
|
||||
if (event.key === 'End') next = last;
|
||||
activateMobilePane(next, { focus: true });
|
||||
};
|
||||
const onResize = () => refreshMobilePaneTabs();
|
||||
tabs.addEventListener('click', onClick);
|
||||
tabs.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// On phones, swipe across the response card to move to the adjacent model.
|
||||
// Keep the gesture vertical-scroll friendly and don't steal touches from
|
||||
// buttons, links, selects, or editable response content.
|
||||
let swipeStart = null;
|
||||
const onTouchStart = (event) => {
|
||||
if (!_isMobileCompare() || event.touches.length !== 1) {
|
||||
swipeStart = null;
|
||||
return;
|
||||
}
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (target?.closest('button, a, select, input, textarea, [contenteditable="true"]')) {
|
||||
swipeStart = null;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
swipeStart = { x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
const onTouchEnd = (event) => {
|
||||
if (!swipeStart || !_isMobileCompare() || event.changedTouches.length !== 1) return;
|
||||
const touch = event.changedTouches[0];
|
||||
const dx = touch.clientX - swipeStart.x;
|
||||
const dy = touch.clientY - swipeStart.y;
|
||||
swipeStart = null;
|
||||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.25) return;
|
||||
const last = Math.max(0, state._selectedModels.length - 1);
|
||||
const next = dx < 0
|
||||
? Math.min(last, state._activeMobilePane + 1)
|
||||
: Math.max(0, state._activeMobilePane - 1);
|
||||
if (next !== state._activeMobilePane) activateMobilePane(next);
|
||||
};
|
||||
grid.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
grid.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
|
||||
const observer = new MutationObserver(() => refreshMobilePaneTabs());
|
||||
observer.observe(grid, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-pane'],
|
||||
});
|
||||
tabs._cleanup = () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
grid.removeEventListener('touchstart', onTouchStart);
|
||||
grid.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
refreshMobilePaneTabs(0);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
function _showShuffleNotice() {
|
||||
const grid = document.querySelector('.compare-grid');
|
||||
if (!grid) return;
|
||||
grid.querySelector('.compare-shuffle-notice')?.remove();
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'compare-shuffle-notice';
|
||||
notice.setAttribute('role', 'status');
|
||||
notice.setAttribute('aria-live', 'polite');
|
||||
notice.innerHTML = '<span class="compare-shuffle-notice-icon" aria-hidden="true">' + ICON_DICE + '</span><span>Shuffling</span>';
|
||||
grid.appendChild(notice);
|
||||
requestAnimationFrame(() => notice.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
notice.classList.remove('show');
|
||||
notice.addEventListener('transitionend', () => notice.remove(), { once: true });
|
||||
setTimeout(() => notice.remove(), 220);
|
||||
}, 1060);
|
||||
}
|
||||
|
||||
// ── Stop / reroll ──
|
||||
|
||||
function stopAll() {
|
||||
@@ -44,6 +380,9 @@ function stopAll() {
|
||||
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
|
||||
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
|
||||
});
|
||||
document.querySelectorAll('.compare-pane').forEach(pane => {
|
||||
pane.classList.remove('is-streaming', 'is-awaiting-input');
|
||||
});
|
||||
}
|
||||
|
||||
function stopPane(paneIdx) {
|
||||
@@ -55,6 +394,8 @@ function stopPane(paneIdx) {
|
||||
// Hide stop button, show reroll
|
||||
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (pane) {
|
||||
pane.classList.remove('is-streaming', 'is-awaiting-input', 'is-done');
|
||||
pane.classList.add('is-failed');
|
||||
const stopBtn = pane.querySelector('.pane-stop-btn');
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
@@ -98,6 +439,8 @@ async function rerollPane(paneIdx, overrideTimeout) {
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const timer = document.getElementById('cmp-timer-' + paneIdx);
|
||||
if (timer) timer.textContent = '';
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
|
||||
// Search mode: re-query the search provider
|
||||
if (state._compareMode === 'search') {
|
||||
@@ -339,6 +682,8 @@ async function _addPane(anchorBtn) {
|
||||
// chat-container is wider than the viewport.
|
||||
const btnRect = anchorBtn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.right = 'auto';
|
||||
dropdown.style.bottom = 'auto';
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const margin = 8;
|
||||
@@ -353,16 +698,18 @@ async function _addPane(anchorBtn) {
|
||||
const ddRect = dropdown.getBoundingClientRect();
|
||||
const ddW = ddRect.width;
|
||||
const ddH = ddRect.height;
|
||||
// Horizontal: align dropdown's right edge with the button's, then
|
||||
// clamp so the dropdown stays within [margin, vw - margin].
|
||||
// Align the dropdown's right edge with the button, then clamp so it stays
|
||||
// within the viewport. This keeps the picker attached to the Add flap
|
||||
// instead of opening at an unrelated edge of the compare surface.
|
||||
let left = btnRect.right - ddW;
|
||||
if (left + ddW > vw - margin) left = vw - margin - ddW;
|
||||
if (left < margin) left = margin;
|
||||
left = Math.max(margin, Math.min(left, vw - margin - ddW));
|
||||
// Vertical: drop below the button if there's room, otherwise above.
|
||||
const spaceBelow = vh - btnRect.bottom;
|
||||
const spaceAbove = btnRect.top;
|
||||
let top;
|
||||
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
|
||||
if (anchorBtn.classList.contains('compare-add-flap')) {
|
||||
top = Math.max(margin, Math.min(btnRect.bottom + 6, vh - margin - ddH));
|
||||
} else if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
|
||||
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
|
||||
} else {
|
||||
top = Math.max(margin, btnRect.top - 4 - ddH);
|
||||
@@ -397,6 +744,7 @@ async function _createAndAppendPane(m) {
|
||||
// Update arrays
|
||||
state._selectedModels.push({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' });
|
||||
state._paneSessionIds.push(data.id);
|
||||
state._paneGenerationSettings.push({ thinking_mode: '', temperature_override: null, max_tokens_override: null });
|
||||
state._paneMetrics.push(null);
|
||||
state._abortControllers.push(null);
|
||||
_persistSelections();
|
||||
@@ -409,16 +757,17 @@ async function _createAndAppendPane(m) {
|
||||
pane.dataset.pane = String(i);
|
||||
pane.innerHTML =
|
||||
'<div class="pane-header">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
|
||||
'<div class="pane-header-row pane-header-primary"><button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button><div class="pane-primary-actions">' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
|
||||
'</div>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
|
||||
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
|
||||
@@ -436,6 +785,14 @@ async function _createAndAppendPane(m) {
|
||||
// Update grid columns
|
||||
const n = state._selectedModels.length;
|
||||
grid.dataset.cols = String(Math.min(n, 4));
|
||||
syncShuffleButtonPlacement(n > 2);
|
||||
refreshMobilePaneTabs(i);
|
||||
if (_isMobileCompare()) {
|
||||
activateMobilePane(i);
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector('.compare-mobile-add')?.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// Update header label
|
||||
const headerSpan = document.querySelector('.compare-active > div:first-child span');
|
||||
@@ -448,19 +805,6 @@ async function _createAndAppendPane(m) {
|
||||
// Rebuild vote bar
|
||||
buildVoteBar(n);
|
||||
|
||||
// Prompt to shuffle in blind mode — tooltip bubble next to Shuffle button
|
||||
if (state._blindMode && n > 2) {
|
||||
const shuffleBtn = document.getElementById('compare-shuffle-btn');
|
||||
if (shuffleBtn) {
|
||||
const bubble = document.createElement('div');
|
||||
bubble.style.cssText = 'position:absolute;top:100%;right:0;margin-top:6px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:5px 10px;font-size:11px;white-space:nowrap;z-index:10000;box-shadow:0 4px 12px rgba(0,0,0,0.25);pointer-events:none;opacity:0;transition:opacity 0.2s;';
|
||||
bubble.textContent = 'Shuffle models?';
|
||||
shuffleBtn.style.position = 'relative';
|
||||
shuffleBtn.appendChild(bubble);
|
||||
requestAnimationFrame(() => { bubble.style.opacity = '1'; });
|
||||
setTimeout(() => { bubble.style.opacity = '0'; setTimeout(() => bubble.remove(), 200); }, 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a pane from the compare grid. If only 1 remains, exit compare mode. */
|
||||
@@ -479,6 +823,7 @@ function _removePane(paneIdx) {
|
||||
// Remove from arrays
|
||||
state._selectedModels.splice(paneIdx, 1);
|
||||
state._paneSessionIds.splice(paneIdx, 1);
|
||||
state._paneGenerationSettings.splice(paneIdx, 1);
|
||||
state._paneMetrics.splice(paneIdx, 1);
|
||||
state._abortControllers.splice(paneIdx, 1);
|
||||
_persistSelections();
|
||||
@@ -495,6 +840,7 @@ function _removePane(paneIdx) {
|
||||
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
|
||||
|
||||
const n = state._selectedModels.length;
|
||||
syncShuffleButtonPlacement(n > 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
const pane = document.createElement('div');
|
||||
@@ -502,17 +848,17 @@ function _removePane(paneIdx) {
|
||||
pane.dataset.pane = String(i);
|
||||
pane.innerHTML =
|
||||
'<div class="pane-header">' +
|
||||
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button>' +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
|
||||
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
|
||||
'<div class="pane-header-row pane-header-primary"><button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">▾</span></button><div class="pane-primary-actions">' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
paneSettingsButtonHtml(i) +
|
||||
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
|
||||
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
|
||||
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
|
||||
'<div class="pane-actions">' +
|
||||
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
|
||||
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
|
||||
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
|
||||
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
|
||||
'</div>' +
|
||||
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
|
||||
'</div>' +
|
||||
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
|
||||
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
|
||||
@@ -538,12 +884,16 @@ function _removePane(paneIdx) {
|
||||
|
||||
// Rebuild vote bar
|
||||
buildVoteBar(n);
|
||||
refreshMobilePaneTabs(Math.min(paneIdx, n - 1));
|
||||
}
|
||||
|
||||
/** Show a dropdown under the pane title to swap the model for that pane. */
|
||||
function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
// Don't allow swaps while streaming
|
||||
if (state._streaming) return;
|
||||
if (state._streaming) {
|
||||
uiModule.showToast('Stop the response or wait for it to finish before changing models.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing dropdown
|
||||
const existing = document.querySelector('.pane-model-dropdown');
|
||||
@@ -572,18 +922,6 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
// Update the model for this pane and persist
|
||||
state._selectedModels[paneIdx] = {
|
||||
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
|
||||
};
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
|
||||
// Delete old session, create new one
|
||||
const oldSid = state._paneSessionIds[paneIdx];
|
||||
if (oldSid) {
|
||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
const fd = new FormData();
|
||||
// Blind mode: neutral slot name only — never leak the model (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name));
|
||||
@@ -593,12 +931,29 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
fd.append('endpoint_id', m.endpointId);
|
||||
fd.append('skip_validation', 'true');
|
||||
}
|
||||
let newSessionId = '';
|
||||
try {
|
||||
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
state._paneSessionIds[paneIdx] = data.id;
|
||||
newSessionId = data.id || '';
|
||||
if (!newSessionId) throw new Error('Missing session id');
|
||||
} catch (err) {
|
||||
console.error('Failed to create session for swapped model:', err);
|
||||
if (uiModule?.showError) uiModule.showError('Failed to swap compare model: ' + (err?.message || 'unknown'));
|
||||
return;
|
||||
}
|
||||
|
||||
const oldSid = state._paneSessionIds[paneIdx];
|
||||
state._selectedModels[paneIdx] = {
|
||||
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
|
||||
};
|
||||
state._paneSessionIds[paneIdx] = newSessionId;
|
||||
await _savePaneGenerationSettings(paneIdx, {});
|
||||
_persistSelections();
|
||||
if (window._updateCheckBtnState) window._updateCheckBtnState();
|
||||
if (oldSid) {
|
||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
// Update title display
|
||||
@@ -619,6 +974,9 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
|
||||
const badge = document.getElementById('cmp-badge-' + paneIdx);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
refreshMobilePaneTabs(paneIdx);
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
@@ -663,6 +1021,7 @@ function shufflePanePositions() {
|
||||
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
|
||||
const n = state._selectedModels.length;
|
||||
if (n < 2) return;
|
||||
_showShuffleNotice();
|
||||
|
||||
// Fisher-Yates shuffle to get new order
|
||||
const indices = Array.from({ length: n }, (_, i) => i);
|
||||
@@ -674,6 +1033,7 @@ function shufflePanePositions() {
|
||||
// Reorder internal state
|
||||
const newModels = indices.map(i => state._selectedModels[i]);
|
||||
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
|
||||
const newGenerationSettings = indices.map(i => state._paneGenerationSettings[i]);
|
||||
const newMetrics = indices.map(i => state._paneMetrics[i]);
|
||||
|
||||
// Collect pane contents (HTML) before swapping
|
||||
@@ -689,6 +1049,7 @@ function shufflePanePositions() {
|
||||
// Apply shuffled state
|
||||
state._selectedModels = newModels;
|
||||
state._paneSessionIds = newSessionIds;
|
||||
state._paneGenerationSettings = newGenerationSettings;
|
||||
state._paneMetrics = newMetrics;
|
||||
|
||||
// Spin the shuffle button dice icon
|
||||
@@ -755,7 +1116,10 @@ function shufflePanePositions() {
|
||||
state._blindMode = true;
|
||||
|
||||
// Rebuild vote bar with new labels
|
||||
setTimeout(() => buildVoteBar(n), 250);
|
||||
setTimeout(() => {
|
||||
buildVoteBar(n);
|
||||
refreshMobilePaneTabs();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function resetCompare() {
|
||||
@@ -764,6 +1128,13 @@ function resetCompare() {
|
||||
|
||||
// Clear last prompt so vote buttons are disabled until next prompt
|
||||
state._lastPrompt = '';
|
||||
state._expectedAnswer = '';
|
||||
const expected = document.getElementById('cmp-eval-expected');
|
||||
if (expected) {
|
||||
expected.classList.add('hidden');
|
||||
const value = expected.querySelector('.cmp-eval-expected-value');
|
||||
if (value) value.textContent = '';
|
||||
}
|
||||
|
||||
// Reset finish badges, titles, winner/loser state
|
||||
state._finishOrder = 0;
|
||||
@@ -772,12 +1143,16 @@ function resetCompare() {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const badge = document.getElementById('cmp-badge-' + i);
|
||||
if (badge) { badge.textContent = ''; badge.style.color = ''; }
|
||||
const summary = document.getElementById('cmp-summary-' + i);
|
||||
if (summary) { summary.textContent = ''; summary.title = ''; }
|
||||
const titleEl = document.getElementById('cmp-title-' + i);
|
||||
if (titleEl) {
|
||||
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
|
||||
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">▾</span>';
|
||||
}
|
||||
if (panes[i]) { panes[i].classList.remove('winner', 'loser'); }
|
||||
if (panes[i]) {
|
||||
panes[i].classList.remove('winner', 'loser', 'is-streaming', 'is-awaiting-input', 'is-done', 'is-failed');
|
||||
}
|
||||
|
||||
// Clear all messages from pane history
|
||||
const hist = document.getElementById('cmp-history-' + i);
|
||||
@@ -814,4 +1189,10 @@ export {
|
||||
_showModelSwapDropdown,
|
||||
shufflePanePositions,
|
||||
resetCompare,
|
||||
mountMobilePaneTabs,
|
||||
syncShuffleButtonPlacement,
|
||||
activateMobilePane,
|
||||
refreshMobilePaneTabs,
|
||||
paneSettingsButtonHtml,
|
||||
togglePaneSettings,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// compare/probe.js — model probe/check system
|
||||
import state from './state.js';
|
||||
import { WAVE_FRAMES } from './icons.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { WAVE_FRAMES } from './icons.js?v=20260908compareprompts1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
|
||||
function _clearProbeWaves() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// compare/scoreboard.js — vote history display
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { VOTES_STORAGE_KEY } from './icons.js';
|
||||
import themeModule from '../theme.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { VOTES_STORAGE_KEY } from './icons.js?v=20260908compareprompts1';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -120,7 +120,7 @@ export function showScoreboard() {
|
||||
|
||||
if (sorted.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;';
|
||||
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;font-size:calc(1em - 1px);';
|
||||
empty.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!';
|
||||
wrap.appendChild(empty);
|
||||
} else {
|
||||
@@ -185,7 +185,7 @@ export function showScoreboard() {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
// Inline confirmation
|
||||
const confirmRow = document.createElement('div');
|
||||
confirmRow.style.cssText = 'display:flex;gap:8px;justify-content:center;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
|
||||
confirmRow.style.cssText = 'display:flex;gap:8px;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
|
||||
const confirmLabel = document.createElement('span');
|
||||
confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
confirmLabel.textContent = 'Clear all vote history?';
|
||||
@@ -202,9 +202,10 @@ export function showScoreboard() {
|
||||
noBtn.className = 'cmp-btn-secondary';
|
||||
noBtn.style.cssText = 'padding:4px 12px;border-radius:4px;font-size:12px;';
|
||||
noBtn.addEventListener('click', () => confirmRow.remove());
|
||||
confirmRow.appendChild(confirmLabel);
|
||||
confirmRow.appendChild(yesBtn);
|
||||
confirmRow.appendChild(noBtn);
|
||||
const actions = document.createElement('span');
|
||||
actions.style.cssText = 'display:inline-flex;align-items:center;gap:8px;margin-left:auto;';
|
||||
actions.append(noBtn, yesBtn);
|
||||
confirmRow.append(confirmLabel, actions);
|
||||
// Replace button with confirmation
|
||||
clearBtn.style.display = 'none';
|
||||
clearBtn.parentElement.appendChild(confirmRow);
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import state from './state.js';
|
||||
import Storage from '../storage.js';
|
||||
import { fetchModels, _persistSelections, getExcludedModels } from './models.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js?v=20260908compareprompts1';
|
||||
import { _clearProbeWaves } from './probe.js';
|
||||
import uiModule from '../ui.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import themeModule from '../theme.js?v=20260911organsrain1';
|
||||
|
||||
const escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -153,7 +153,7 @@ async function showModelSelector() {
|
||||
uiModule.showToast('Mode: ' + (state._parallel ? 'Parallel' : 'Sequential'));
|
||||
_updateModeLabel();
|
||||
_setModeHint(state._parallel
|
||||
? '<span style="color:#5b8def">Parallel</span>: all models answer at once, side by side.'
|
||||
? '<span style="color:var(--accent, var(--red))">Parallel</span>: all models answer at once, side by side.'
|
||||
: '<span style="color:#e0a050">Sequential</span>: models answer one at a time.');
|
||||
});
|
||||
toggleRow.appendChild(parallelBtn);
|
||||
@@ -295,7 +295,7 @@ async function showModelSelector() {
|
||||
const parts = [];
|
||||
if (state._blindMode) parts.push('<span style="color:var(--color-blind-orange)">Blind</span>');
|
||||
parts.push(state._parallel
|
||||
? '<span style="color:#5b8def">Parallel</span>'
|
||||
? '<span style="color:var(--accent, var(--red))">Parallel</span>'
|
||||
: '<span style="color:#e0a050">Sequential</span>');
|
||||
if (_shuffled) parts.push('<span style="color:var(--red)">Shuffle</span>');
|
||||
if (state._saveOnClose) parts.push('<span style="color:var(--color-save-green)">Save</span>');
|
||||
@@ -403,6 +403,7 @@ async function showModelSelector() {
|
||||
// Validate saved selections against available models (done after models load)
|
||||
let _needsValidation = selections.length > 0;
|
||||
let addBtn = null;
|
||||
let startBtn = null;
|
||||
let _shuffled = false;
|
||||
_updateModeLabel(); // initial readout (Blind + Parallel on by default)
|
||||
|
||||
@@ -419,6 +420,68 @@ async function showModelSelector() {
|
||||
};
|
||||
}
|
||||
|
||||
function _selectionKey(sel) {
|
||||
if (!sel) return '';
|
||||
const provider = sel.searchProvider || '';
|
||||
return [sel.model || '', sel.endpointId || '', sel.endpoint || '', provider].join('|');
|
||||
}
|
||||
|
||||
function _duplicateSelectionKeys() {
|
||||
const counts = new Map();
|
||||
selections.filter(Boolean).forEach(sel => {
|
||||
const key = _selectionKey(sel);
|
||||
if (!key) return;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
});
|
||||
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key));
|
||||
}
|
||||
|
||||
function _appendSelectionMeta(row, sel, duplicateKeys) {
|
||||
if (!sel || _shuffled) return;
|
||||
const key = _selectionKey(sel);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'cmp-model-meta';
|
||||
const bits = [];
|
||||
if (duplicateKeys.has(key)) {
|
||||
row.classList.add('cmp-model-row-duplicate');
|
||||
bits.push('<span class="cmp-model-meta-warning">Duplicate selection</span>');
|
||||
}
|
||||
if (!bits.length) return;
|
||||
meta.innerHTML = bits.join('');
|
||||
row.appendChild(meta);
|
||||
}
|
||||
|
||||
function _updateStartReadiness() {
|
||||
if (!startBtn || !_modelsLoaded) return;
|
||||
const duplicates = _duplicateSelectionKeys();
|
||||
// Duplicate models are valid compare inputs. Keep the warning on the
|
||||
// rows, but never block the Start flow because the user can choose to
|
||||
// proceed through the probe fallback.
|
||||
startBtn.disabled = false;
|
||||
startBtn.style.opacity = '1';
|
||||
startBtn.title = duplicates.size > 0 ? 'Duplicate selections will run as separate panes' : '';
|
||||
}
|
||||
|
||||
function _expandModelSlot(slotIdx) {
|
||||
const row = listContainer.querySelector(`.cmp-model-row[data-slot-index="${slotIdx}"]`);
|
||||
if (!row) return;
|
||||
row.classList.add('cmp-model-row-swap-target');
|
||||
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
|
||||
const searchable = row.querySelector('.cmp-model-picker-input');
|
||||
if (searchable) {
|
||||
searchable.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const select = row.querySelector('.cmp-model-primary-select');
|
||||
if (!select) return;
|
||||
select.focus({ preventScroll: true });
|
||||
if (typeof select.showPicker === 'function') {
|
||||
try { select.showPicker(); } catch (_) { /* focus still identifies the slot */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a searchable model picker (used when >5 models) */
|
||||
function _buildSearchablePicker(modelList, currentSel, slotIdx, onSelect) {
|
||||
const wrap = document.createElement('div');
|
||||
@@ -427,7 +490,7 @@ async function showModelSelector() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search models\u2026';
|
||||
input.className = 'cmp-form-control';
|
||||
input.className = 'cmp-form-control cmp-model-picker-input';
|
||||
input.style.cssText = 'width:100%;box-sizing:border-box;';
|
||||
// Mobile: suppress the on-screen keyboard so tapping the picker
|
||||
// opens the dropdown but doesn't shove a keyboard up over the list.
|
||||
@@ -594,6 +657,7 @@ async function showModelSelector() {
|
||||
selections.forEach((sel, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cmp-model-row';
|
||||
row.dataset.slotIndex = String(idx);
|
||||
if (_seqStepS) row.style.marginLeft = (idx * _seqStepS) + 'px';
|
||||
|
||||
// Left label: number/letter or blind eye icon
|
||||
@@ -618,7 +682,7 @@ async function showModelSelector() {
|
||||
row.appendChild(picker);
|
||||
} else {
|
||||
const modelSelect = document.createElement('select');
|
||||
modelSelect.className = 'cmp-form-control';
|
||||
modelSelect.className = 'cmp-form-control cmp-model-primary-select';
|
||||
modelSelect.style.flex = '1';
|
||||
chatModels.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -647,26 +711,29 @@ async function showModelSelector() {
|
||||
});
|
||||
provSelect.addEventListener('change', () => {
|
||||
try { selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
|
||||
renderModelRows();
|
||||
});
|
||||
try { if (!selections[idx]) selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
|
||||
row.appendChild(provSelect);
|
||||
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
|
||||
|
||||
// X remove button when >2 slots
|
||||
if (selections.length > 2) {
|
||||
// X remove button when more than one slot remains
|
||||
if (selections.length > 1) {
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.type = 'button';
|
||||
rmBtn.textContent = '\u00d7';
|
||||
rmBtn.className = 'cmp-rm-btn';
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); state._searchSynthModels.splice(idx, 1); renderModelRows(); });
|
||||
row.appendChild(rmBtn);
|
||||
}
|
||||
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
|
||||
return;
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
|
||||
_updateStartReadiness();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Chat / Image / Agent / Research mode: show model dropdowns ──
|
||||
@@ -705,6 +772,7 @@ async function showModelSelector() {
|
||||
selections.forEach((sel, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cmp-model-row';
|
||||
row.dataset.slotIndex = String(idx);
|
||||
if (_seqStep) row.style.marginLeft = (idx * _seqStep) + 'px';
|
||||
|
||||
// Left label: number/letter or blind eye icon
|
||||
@@ -727,6 +795,7 @@ async function showModelSelector() {
|
||||
const picker = _buildSearchablePicker(filtered, sel, idx, (chosen) => {
|
||||
selections[idx] = chosen;
|
||||
_remindShuffle();
|
||||
renderModelRows();
|
||||
});
|
||||
if (!selections[idx]) {
|
||||
const fallback = filtered[Math.min(idx, filtered.length - 1)];
|
||||
@@ -735,7 +804,7 @@ async function showModelSelector() {
|
||||
row.appendChild(picker);
|
||||
} else {
|
||||
const select = document.createElement('select');
|
||||
select.className = 'cmp-form-control';
|
||||
select.className = 'cmp-form-control cmp-model-primary-select';
|
||||
select.style.flex = '1';
|
||||
filtered.forEach((m, mi) => {
|
||||
const opt = buildOption(m);
|
||||
@@ -749,6 +818,7 @@ async function showModelSelector() {
|
||||
select.addEventListener('change', () => {
|
||||
try { selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model select parse failed:', e); }
|
||||
_remindShuffle();
|
||||
renderModelRows();
|
||||
});
|
||||
try { if (!selections[idx]) selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model init parse failed:', e); }
|
||||
row.appendChild(select);
|
||||
@@ -767,19 +837,24 @@ async function showModelSelector() {
|
||||
else if (!state._searchSynthModels[idx] && pi === 0) optEl.selected = true;
|
||||
provSelect.appendChild(optEl);
|
||||
});
|
||||
provSelect.addEventListener('change', () => { state._searchSynthModels[idx] = provSelect.value; });
|
||||
provSelect.addEventListener('change', () => {
|
||||
state._searchSynthModels[idx] = provSelect.value;
|
||||
renderModelRows();
|
||||
});
|
||||
if (!state._searchSynthModels[idx]) state._searchSynthModels[idx] = provSelect.value;
|
||||
row.appendChild(provSelect);
|
||||
}
|
||||
|
||||
// X remove button when >2 slots
|
||||
if (selections.length > 2) {
|
||||
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
|
||||
|
||||
// X remove button when more than one slot remains
|
||||
if (selections.length > 1) {
|
||||
const rmBtn = document.createElement('button');
|
||||
rmBtn.type = 'button';
|
||||
rmBtn.textContent = '\u00d7';
|
||||
rmBtn.className = 'cmp-rm-btn';
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
|
||||
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
|
||||
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); if (state._searchSynthModels.length > idx) state._searchSynthModels.splice(idx, 1); renderModelRows(); });
|
||||
row.appendChild(rmBtn);
|
||||
}
|
||||
@@ -787,6 +862,7 @@ async function showModelSelector() {
|
||||
listContainer.appendChild(row);
|
||||
});
|
||||
if (addBtn) addBtn.style.display = (selections.length >= 8) ? 'none' : '';
|
||||
_updateStartReadiness();
|
||||
}
|
||||
|
||||
// Default to 2 empty slots if no saved selections
|
||||
@@ -795,7 +871,7 @@ async function showModelSelector() {
|
||||
addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.style.cssText = 'display:none;align-items:center;gap:6px;background:none;border:1px dashed var(--border);color:var(--fg);border-radius:6px;cursor:pointer;padding:6px 12px;font-size:0.82em;opacity:0.6;transition:all 0.15s;margin-bottom:16px;width:100%;justify-content:center;';
|
||||
addBtn.textContent = '+ Add Model';
|
||||
addBtn.innerHTML = '<span style="color:var(--accent,var(--red));position:relative;left:-2px;">+</span><span>Add Model</span>';
|
||||
addBtn.addEventListener('mouseenter', () => { addBtn.style.opacity = '1'; });
|
||||
addBtn.addEventListener('mouseleave', () => { addBtn.style.opacity = '0.6'; });
|
||||
addBtn.addEventListener('click', () => {
|
||||
@@ -840,7 +916,7 @@ async function showModelSelector() {
|
||||
// Scoreboard button
|
||||
const scoreBtn = document.createElement('button');
|
||||
scoreBtn.type = 'button';
|
||||
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
|
||||
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:0px;position:relative;top:2px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
|
||||
scoreBtn.style.cssText = 'margin-left:auto;padding:4px 10px;background:transparent;color:var(--fg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.82em;opacity:0.7;position:relative;top:-5px;';
|
||||
scoreBtn.addEventListener('mouseenter', () => { scoreBtn.style.opacity = '1'; });
|
||||
scoreBtn.addEventListener('mouseleave', () => { scoreBtn.style.opacity = '0.7'; });
|
||||
@@ -857,7 +933,7 @@ async function showModelSelector() {
|
||||
footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:14px 16px 10px;border-top:1px solid var(--border);';
|
||||
// Cancel button removed — the overlay's X / outside-click / Esc all
|
||||
// dismiss the popup, so the footer Cancel was redundant.
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn = document.createElement('button');
|
||||
startBtn.innerHTML = _CMP_START_LABEL;
|
||||
startBtn.className = 'research-start-btn';
|
||||
startBtn.disabled = true;
|
||||
@@ -924,7 +1000,11 @@ async function showModelSelector() {
|
||||
probeOverlay.className = 'compare-probe-overlay';
|
||||
const probeCard = document.createElement('div');
|
||||
probeCard.className = 'compare-probe-card';
|
||||
probeCard.innerHTML = '<div class="compare-probe-title">Checking models...</div>';
|
||||
probeCard.innerHTML = '<div class="compare-probe-title"><span class="compare-probe-title-label">Preround check:</span> <span class="compare-probe-title-status">Checking models...</span></div>';
|
||||
const _setProbeTitle = (message) => {
|
||||
const status = probeCard.querySelector('.compare-probe-title-status');
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
let _probeSkipped = false;
|
||||
const probeList = document.createElement('div');
|
||||
probeList.className = 'compare-probe-list';
|
||||
@@ -950,6 +1030,9 @@ async function showModelSelector() {
|
||||
probeList.appendChild(row);
|
||||
});
|
||||
probeCard.appendChild(probeList);
|
||||
const probeFeedback = document.createElement('div');
|
||||
probeFeedback.className = 'compare-probe-feedback';
|
||||
probeCard.appendChild(probeFeedback);
|
||||
const skipBtn = document.createElement('button');
|
||||
skipBtn.textContent = 'Skip';
|
||||
skipBtn.className = 'cmp-btn-secondary';
|
||||
@@ -1046,18 +1129,22 @@ async function showModelSelector() {
|
||||
if (nameEl) nameEl.textContent = row._realName;
|
||||
}
|
||||
// Remove old detail/actions if retrying
|
||||
const oldDetail = row.nextElementSibling;
|
||||
if (oldDetail && oldDetail.classList.contains('compare-probe-detail')) oldDetail.remove();
|
||||
// Error + actions below the row
|
||||
const oldDetail = probeFeedback.querySelector(`[data-probe-detail="${idx}"]`);
|
||||
if (oldDetail) oldDetail.remove();
|
||||
// Keep diagnostic feedback below the complete model list.
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'compare-probe-detail';
|
||||
detail.style.cssText = 'grid-column:1/-1;display:flex;align-items:flex-start;gap:6px;padding:4px 10px 6px;font-size:10px;opacity:0.6;background:color-mix(in srgb, var(--color-error, #f44) 5%, transparent);border-radius:4px;margin-top:-2px;';
|
||||
detail.dataset.probeDetail = String(idx);
|
||||
const detailIcon = document.createElement('span');
|
||||
detailIcon.className = 'compare-probe-detail-icon';
|
||||
const errSpan = document.createElement('span');
|
||||
// Truncate long error messages
|
||||
const errText = (result.error || 'Failed');
|
||||
detailIcon.textContent = /insufficient balance/i.test(errText) ? '$' : '!';
|
||||
detail.appendChild(detailIcon);
|
||||
errSpan.textContent = errText.length > 80 ? errText.slice(0, 80) + '...' : errText;
|
||||
errSpan.title = errText;
|
||||
errSpan.style.cssText = 'flex:1;line-height:1.4;';
|
||||
errSpan.className = 'compare-probe-detail-message';
|
||||
detail.appendChild(errSpan);
|
||||
// Track timeout for retry doubling
|
||||
if (!row._probeTimeout) row._probeTimeout = 15000;
|
||||
@@ -1065,7 +1152,7 @@ async function showModelSelector() {
|
||||
const retryBtn = document.createElement('button');
|
||||
retryBtn.className = 'compare-probe-action-btn';
|
||||
const retryLabel = result.error === 'Timeout' ? `Retry ${Math.round(row._probeTimeout / 1000)}s` : 'Retry';
|
||||
retryBtn.textContent = retryLabel;
|
||||
retryBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66"/><polyline points="20 4 20 11 13 11"/></svg><span>' + escapeHtml(retryLabel) + '</span>';
|
||||
retryBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
detail.remove();
|
||||
@@ -1084,7 +1171,7 @@ async function showModelSelector() {
|
||||
});
|
||||
const swapBtn = document.createElement('button');
|
||||
swapBtn.className = 'compare-probe-action-btn';
|
||||
swapBtn.textContent = 'Swap';
|
||||
swapBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/></svg><span>Swap</span>';
|
||||
swapBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_clearProbeWaves();
|
||||
@@ -1093,10 +1180,12 @@ async function showModelSelector() {
|
||||
startBtn.disabled = false;
|
||||
startBtn.innerHTML = _CMP_START_LABEL;
|
||||
startBtn.style.opacity = '1';
|
||||
renderModelRows();
|
||||
_expandModelSlot(idx);
|
||||
});
|
||||
detail.appendChild(retryBtn);
|
||||
detail.appendChild(swapBtn);
|
||||
row.after(detail);
|
||||
probeFeedback.appendChild(detail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,8 +1275,7 @@ async function showModelSelector() {
|
||||
: (state._searchSynthModels || []).map(p => typeof p === 'string' ? { id: p, label: p } : null).filter(Boolean);
|
||||
|
||||
if (providers.length > 0) {
|
||||
const titleEl = probeOverlay.querySelector('.compare-probe-title');
|
||||
titleEl.textContent = 'Checking search providers...';
|
||||
_setProbeTitle('Checking search providers...');
|
||||
|
||||
// Add provider rows
|
||||
const providerRows = [];
|
||||
@@ -1250,7 +1338,7 @@ async function showModelSelector() {
|
||||
// Don't hide the Skip button here — collapsing its space made the
|
||||
// card shrink and the title + rows jump ("quick cut"). On success the
|
||||
// whole overlay fades out a moment later, so just leave it in place.
|
||||
probeOverlay.querySelector('.compare-probe-title').textContent = 'All ready!';
|
||||
_setProbeTitle('All ready!');
|
||||
setTimeout(() => {
|
||||
probeOverlay.style.transition = 'opacity 0.3s ease';
|
||||
probeOverlay.style.opacity = '0';
|
||||
@@ -1264,21 +1352,18 @@ async function showModelSelector() {
|
||||
probeList.querySelectorAll('.compare-probe-row.fail').forEach(row => {
|
||||
failedNames.push(row.querySelector('.compare-probe-name').textContent);
|
||||
});
|
||||
const titleEl = probeOverlay.querySelector('.compare-probe-title');
|
||||
titleEl.textContent = failedNames.length <= 2
|
||||
_setProbeTitle(failedNames.length <= 2
|
||||
? failedNames.join(' & ') + ' failed'
|
||||
: `${failCount} models failed`;
|
||||
: `${failCount} models failed`);
|
||||
const btnRow = document.createElement('div');
|
||||
btnRow.style.cssText = 'display:flex;gap:8px;justify-content:center;margin-top:12px;';
|
||||
btnRow.className = 'compare-probe-footer';
|
||||
const goBackBtn = document.createElement('button');
|
||||
goBackBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="15 18 9 12 15 6"/></svg>Go Back';
|
||||
goBackBtn.className = 'cmp-btn-secondary';
|
||||
goBackBtn.style.cssText = 'padding:5px 12px;font-size:12px;display:inline-flex;align-items:center;';
|
||||
goBackBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg><span>Go Back</span>';
|
||||
goBackBtn.className = 'cmp-btn-secondary compare-probe-footer-btn';
|
||||
goBackBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; });
|
||||
const startAnywayBtn = document.createElement('button');
|
||||
startAnywayBtn.textContent = 'Start Anyway';
|
||||
startAnywayBtn.className = 'cmp-btn-primary';
|
||||
startAnywayBtn.style.cssText = 'padding:5px 12px;font-size:12px;';
|
||||
startAnywayBtn.innerHTML = _CMP_PLAY_ICON + '<span>Start Anyway</span>';
|
||||
startAnywayBtn.className = 'cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway';
|
||||
startAnywayBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); cleanup(true); });
|
||||
btnRow.appendChild(goBackBtn);
|
||||
btnRow.appendChild(startAnywayBtn);
|
||||
|
||||
@@ -14,6 +14,7 @@ const state = {
|
||||
// (sequential mode otherwise always picks pane 1)
|
||||
_selectedModels: [], // [{model, endpoint, endpointId, name}, ...]
|
||||
_paneSessionIds: [], // session IDs for each pane
|
||||
_paneGenerationSettings: [], // per-pane thinking / temperature / token overrides
|
||||
_paneMetrics: [], // metrics per pane from last round
|
||||
_abortControllers: [], // per-pane abort controllers
|
||||
_sidebarWasHidden: false,
|
||||
@@ -33,6 +34,7 @@ const state = {
|
||||
_fetchModelsCacheTime: 0,
|
||||
_expectedAnswer: '', // when an eval prompt with `answer` is picked,
|
||||
// stream.js reads this and stamps ✓/✗ per pane
|
||||
_activeMobilePane: 0, // visible pane in the phone tab/card layout
|
||||
};
|
||||
|
||||
/** Reset transient state to defaults — useful for clean restarts. */
|
||||
@@ -44,10 +46,12 @@ export function reset() {
|
||||
state._abortControllers.forEach(c => { if (c) c.abort(); });
|
||||
state._abortControllers = [];
|
||||
state._paneSessionIds = [];
|
||||
state._paneGenerationSettings = [];
|
||||
state._paneMetrics = [];
|
||||
state._compareElements = [];
|
||||
state._hasVisibleResults = false;
|
||||
state._lastPrompt = '';
|
||||
state._activeMobilePane = 0;
|
||||
state._cachedModels = [];
|
||||
state._probed = new Set();
|
||||
state._cachedProviders = null;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
|
||||
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
|
||||
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import presetsModule from '../presets.js?v=20260908personaname1';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -55,6 +55,61 @@ function _syncCompareBusyFromPanes() {
|
||||
_setCompareBusy((state._abortControllers || []).some(Boolean));
|
||||
}
|
||||
|
||||
function _compareTimezoneHeaders() {
|
||||
const headers = { 'X-Tz-Offset': String(-new Date().getTimezoneOffset()) };
|
||||
try {
|
||||
headers['X-Tz-Name'] = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch (_) {
|
||||
headers['X-Tz-Name'] = '';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function _compactNumber(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '';
|
||||
if (Math.abs(num) >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||
if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
|
||||
return String(Math.round(num));
|
||||
}
|
||||
|
||||
function _formatCost(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '';
|
||||
if (num < 0.001) return '<$0.001';
|
||||
return '$' + (num < 0.01 ? num.toFixed(4) : num.toFixed(3));
|
||||
}
|
||||
|
||||
function _setPaneSummary(paneIdx, metrics, cost) {
|
||||
const summary = document.getElementById('cmp-summary-' + paneIdx);
|
||||
if (!summary) return;
|
||||
if (!metrics) {
|
||||
summary.textContent = '';
|
||||
summary.title = '';
|
||||
return;
|
||||
}
|
||||
const outputTokens = metrics.output_tokens;
|
||||
const responseTime = metrics.response_time ?? metrics.total_time;
|
||||
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
|
||||
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
|
||||
const numericOutput = Number(outputTokens);
|
||||
const numericTime = Number(responseTime);
|
||||
const numericTps = Number(explicitTps);
|
||||
const derivedTps = Number.isFinite(numericTps)
|
||||
? numericTps
|
||||
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
|
||||
? numericOutput / numericTime
|
||||
: null;
|
||||
const bits = [];
|
||||
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) bits.push('TTFT ' + Number(ttft).toFixed(3) + 's');
|
||||
if (outputTokens != null && outputTokens !== 'undefined') bits.push(_compactNumber(outputTokens) + ' tok');
|
||||
if (derivedTps != null) bits.push((derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(1).replace(/\.0$/, '')) + '/s');
|
||||
if (metrics.context_percent > 0) bits.push(metrics.context_percent + '% ctx');
|
||||
if (cost !== null && cost !== undefined) bits.push(_formatCost(cost));
|
||||
summary.textContent = bits.join(' · ');
|
||||
summary.title = bits.length ? 'Response summary: ' + bits.join(', ') : '';
|
||||
}
|
||||
|
||||
function _appendPaneMessage(hist, role, text) {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
@@ -302,6 +357,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// Show stop button for this pane
|
||||
const _paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneEl) {
|
||||
_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');
|
||||
_paneEl.classList.add('is-streaming');
|
||||
_setPaneSummary(paneIdx, null, null);
|
||||
const _stopBtn = _paneEl.querySelector('.pane-stop-btn');
|
||||
if (_stopBtn) _stopBtn.style.display = '';
|
||||
}
|
||||
@@ -407,7 +465,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
|
||||
const response = await fetch(`${state.API_BASE}/api/chat_stream`, {
|
||||
method: 'POST', body: fd, signal: ac.signal
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: _compareTimezoneHeaders(),
|
||||
signal: ac.signal
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
@@ -476,6 +537,8 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// ── Pane-local question / approval selector ──
|
||||
} else if (json.type === 'ask_user') {
|
||||
awaitingChoice = true;
|
||||
const paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (paneEl) paneEl.classList.add('is-awaiting-input');
|
||||
_renderPaneAskUserCard(
|
||||
paneIdx,
|
||||
sessionId,
|
||||
@@ -597,7 +660,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||
currentToolBlock.className = 'agent-thread-node' + (ok ? '' : ' error');
|
||||
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
|
||||
// The chevron is drawn by CSS; leaving a literal ▶ here created
|
||||
// two arrows in the completed tool rows.
|
||||
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron"></span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
|
||||
currentToolBlock.querySelector('.agent-thread-header').addEventListener('click', () => currentToolBlock.classList.toggle('open'));
|
||||
currentToolBlock = null;
|
||||
// Reset text element so next deltas create a fresh container
|
||||
@@ -654,6 +719,12 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
finalTarget.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
|
||||
// Preserve the client-side time-to-first-token measurement with the
|
||||
// server metrics so it appears in the compare summary and footer.
|
||||
if (metrics && _ttft > 0 && metrics.client_ttft == null) {
|
||||
metrics.client_ttft = Number((_ttft / 1000).toFixed(3));
|
||||
}
|
||||
|
||||
// ── Show play button if response contains HTML ──
|
||||
if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
|
||||
|
||||
@@ -735,6 +806,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const outputTokens = metrics.output_tokens;
|
||||
const responseTime = metrics.response_time ?? metrics.total_time;
|
||||
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
|
||||
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
|
||||
const numericOutput = Number(outputTokens);
|
||||
const numericTime = Number(responseTime);
|
||||
const numericTps = Number(explicitTps);
|
||||
@@ -747,6 +819,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
? (derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(2).replace(/\.?0+$/, ''))
|
||||
: null;
|
||||
const parts = [];
|
||||
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) {
|
||||
parts.push('TTFT ' + Number(ttft).toFixed(3) + 's');
|
||||
}
|
||||
if (outputTokens != null && outputTokens !== 'undefined') {
|
||||
parts.push(outputTokens + ' tokens');
|
||||
}
|
||||
@@ -759,6 +834,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// Add per-request cost and cost per 1000
|
||||
const _model = metrics.model || (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model) || '';
|
||||
const _cost = getModelCost(_model, metrics.input_tokens || 0, metrics.output_tokens || 0);
|
||||
_setPaneSummary(paneIdx, metrics, _cost);
|
||||
// Build the metrics span with optional cost and context
|
||||
span.textContent = parts.join(' | ');
|
||||
if (_cost !== null) {
|
||||
@@ -779,6 +855,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
footer.appendChild(span);
|
||||
aiMsgEl.appendChild(footer);
|
||||
}
|
||||
const footerMetrics = aiMsgEl?.querySelector('.msg-footer:last-child .response-metrics');
|
||||
if (footerMetrics) {
|
||||
const thinkingMode = state._paneGenerationSettings[paneIdx]?.thinking_mode;
|
||||
if (thinkingMode === 'off') {
|
||||
const thinkingState = document.createElement('span');
|
||||
thinkingState.className = 'response-thinking-state';
|
||||
thinkingState.textContent = (footerMetrics.textContent ? ' | ' : '') + 'Thinking off';
|
||||
footerMetrics.appendChild(thinkingState);
|
||||
}
|
||||
footerMetrics.dataset.action = 'settings';
|
||||
footerMetrics.dataset.pane = String(paneIdx);
|
||||
footerMetrics.setAttribute('role', 'button');
|
||||
footerMetrics.setAttribute('tabindex', '0');
|
||||
footerMetrics.title = 'Response details and inference settings';
|
||||
}
|
||||
if (hist) hist.scrollTop = hist.scrollHeight;
|
||||
|
||||
} catch (error) {
|
||||
@@ -827,6 +918,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
// Hide stop button, show response action buttons
|
||||
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
|
||||
if (_paneElFinal) {
|
||||
_paneElFinal.classList.remove('is-streaming');
|
||||
_paneElFinal.classList.toggle('is-awaiting-input', awaitingChoice);
|
||||
_paneElFinal.classList.toggle('is-done', streamOk && !awaitingChoice);
|
||||
_paneElFinal.classList.toggle('is-failed', !streamOk && !awaitingChoice);
|
||||
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
|
||||
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
|
||||
if (!awaitingChoice && accumulated.trim()) {
|
||||
@@ -870,7 +965,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
}
|
||||
// Show copy/reroll buttons now that response exists
|
||||
const paneEl = document.querySelector('.compare-pane:nth-child(' + (paneIdx + 1) + ')');
|
||||
if (paneEl) paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
if (paneEl && !awaitingChoice && accumulated.trim()) {
|
||||
paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,9 +1007,12 @@ function _stampGradeBadge(paneIdx, response, expected) {
|
||||
badge.className = 'pane-grade-badge ' + (pass ? 'pass' : 'fail');
|
||||
badge.title = pass ? 'Response contains the expected answer' : 'Expected answer not found in response';
|
||||
badge.textContent = pass ? '✓' : '✗';
|
||||
// Insert just before the finish badge if present, else after the title
|
||||
// The two-row pane header keeps result badges inside .pane-stats.
|
||||
// Always insert relative to the finish badge's actual parent.
|
||||
const finBadge = header.querySelector('.pane-finish-badge');
|
||||
if (finBadge) header.insertBefore(badge, finBadge);
|
||||
const stats = header.querySelector('.pane-stats');
|
||||
if (finBadge?.parentNode) finBadge.parentNode.insertBefore(badge, finBadge);
|
||||
else if (stats) stats.appendChild(badge);
|
||||
else header.appendChild(badge);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import Storage from '../storage.js';
|
||||
import state from './state.js';
|
||||
import { _modelDisplayNames } from './models.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import uiModule from '../ui.js';
|
||||
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js';
|
||||
import { showScoreboard } from './scoreboard.js';
|
||||
import { getModelCost } from '../chatRenderer.js?v=20260914metricssummary1';
|
||||
import uiModule from '../ui.js?v=20260916largetoolscroll1';
|
||||
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js?v=20260908compareprompts1';
|
||||
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -74,7 +74,7 @@ function buildVoteBar(n) {
|
||||
// before a prompt) since viewing the scoreboard is always allowed.
|
||||
const scoreBtn = document.createElement('button');
|
||||
scoreBtn.className = 'compare-vote-btn compare-score-btn';
|
||||
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Score';
|
||||
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span class="compare-score-label">Score</span>';
|
||||
scoreBtn.title = 'Scoreboard';
|
||||
scoreBtn.addEventListener('click', () => showScoreboard());
|
||||
bar.insertBefore(scoreBtn, tieBtn); // furthest left, before Tie
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
// Plain specifier (no ?v=) — must match every other cookbook.js importer so the
|
||||
// browser loads it once. See cookbook-hwfit.js.
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
|
||||
// Tiny HTML-escape — keeps the file standalone instead of leaning on a
|
||||
// shared helper that may not be exported from this module's import surface.
|
||||
@@ -30,6 +30,21 @@ function _diagEsc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function _diagFetchWithTimeout(input, init = {}, timeoutMs = 75000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const parentSignal = init.signal;
|
||||
const abortFromParent = () => controller.abort();
|
||||
if (parentSignal) {
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
else parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abortFromParent);
|
||||
});
|
||||
}
|
||||
|
||||
// Pick an icon for a diagnosis-action button based on the label. The icon
|
||||
// renders on the LEFT of the button text. Keeps the strokes consistent
|
||||
// across the set so they read as one family.
|
||||
@@ -74,7 +89,7 @@ export function openCookbookDependencies(pkgName = '', opts = {}) {
|
||||
function _openCookbookDependencies(pkgName = '', opts = {}) {
|
||||
const cookbook = window.cookbookModule;
|
||||
if (cookbook && typeof cookbook.open === 'function') {
|
||||
cookbook.open({ tab: 'Dependencies' });
|
||||
cookbook.open({ tab: 'Dependencies', dependencyModel: opts.model || '' });
|
||||
} else {
|
||||
document.getElementById('tool-cookbook-btn')?.click();
|
||||
}
|
||||
@@ -1056,7 +1071,7 @@ export async function _runQuickCmd(panel, cmd) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await _diagFetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
// importer uses. A query mismatch loads cookbook.js twice as two separate modules
|
||||
// (two _envState objects), which silently sent downloads to the wrong server.
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
@@ -41,11 +41,26 @@ import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
// the Dependencies API reports. Used to look up "is this backend installed
|
||||
// on the target server" before firing a launch.
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', mlx_image: 'mflux', diffusers: 'diffusers' };
|
||||
function _dependencyPkgForModel(runBackend, modelName = '') {
|
||||
const nm = String(modelName || '').toLowerCase();
|
||||
if (runBackend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
|
||||
if (runBackend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
|
||||
return _BACKEND_PKG[runBackend];
|
||||
|
||||
function _fetchHwfitWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const parentSignal = init.signal;
|
||||
const abortFromParent = () => controller.abort();
|
||||
if (parentSignal) {
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
else parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abortFromParent);
|
||||
});
|
||||
}
|
||||
function _dependencyPkgForModel(runBackend, modelData = {}) {
|
||||
// A catalog may explicitly declare a runtime package. Do not infer it from
|
||||
// model names: users can run new models without a frontend code change.
|
||||
const declared = typeof modelData === 'string' ? '' : String(modelData?.dependency_package || '').trim();
|
||||
return declared || _BACKEND_PKG[runBackend];
|
||||
}
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
@@ -82,8 +97,9 @@ function _wireServerColorPicker(entry) {
|
||||
const color = item.dataset.color || '';
|
||||
select.value = color;
|
||||
const label = item.querySelector('span:last-child')?.textContent || 'Auto';
|
||||
const labelEl = btn.querySelector('.cookbook-srv-color-label');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
btn.title = `Change server color (currently ${label})`;
|
||||
btn.setAttribute('aria-label', `Change server color (currently ${label})`);
|
||||
wrap.title = `Change server color (currently ${label})`;
|
||||
const swatch = item.style.getPropertyValue('--swatch-color') || color;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(swatch.trim())) {
|
||||
entry.style.setProperty('--cookbook-server-color', swatch.trim());
|
||||
@@ -94,23 +110,33 @@ function _wireServerColorPicker(entry) {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', close);
|
||||
const onDocumentClick = () => close();
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
// Settings rows are rebuilt when Cookbook state is refreshed. Keep the
|
||||
// document listener tied to its row so a detached picker can be collected.
|
||||
entry._cleanupColorPicker = () => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-launch: ask the deps API whether the chosen backend is present on
|
||||
// the target server. Returns true if it's good to go, false if we should
|
||||
// block and route the user into Dependencies.
|
||||
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
|
||||
const pkgName = _dependencyPkgForModel(runBackend, modelName);
|
||||
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelData) {
|
||||
const modelName = typeof modelData === 'string' ? modelData : modelData?.name;
|
||||
const pkgName = _dependencyPkgForModel(runBackend, modelData);
|
||||
if (!pkgName) return true; // unknown backend — don't block
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (runBackend) params.set('backend', runBackend);
|
||||
if (modelName) params.set('model_hint', modelName);
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
if (port) params.set('ssh_port', String(port));
|
||||
if (envPath) params.set('venv', envPath);
|
||||
}
|
||||
const r = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params : ''));
|
||||
const r = await _fetchHwfitWithTimeout('/api/cookbook/packages' + (params.toString() ? '?' + params : ''));
|
||||
if (!r.ok) return true; // An unavailable probe must not falsely block launch.
|
||||
const d = await r.json();
|
||||
const pkg = (d.packages || []).find(p => p.name === pkgName);
|
||||
if (pkg && pkg.installed) return true;
|
||||
@@ -137,6 +163,7 @@ export let _cachedModelIds = null; // repo IDs already downloaded
|
||||
// checks this before rendering so a stale response can't clobber a newer one
|
||||
// after the user has switched servers.
|
||||
let _hwfitFetchToken = 0;
|
||||
let _hwfitRequestController = null;
|
||||
let _dismissedHwChips = new Set();
|
||||
// Permanently removed (X-clicked) chips. Separate from _dismissedHwChips
|
||||
// so the ranker treats "off" and "removed" the same (both ignore the
|
||||
@@ -582,14 +609,25 @@ function _applyEngineFilter(models) {
|
||||
// into per-tag hwfit rows so they slot into the main list grid alongside HF
|
||||
// scan results.
|
||||
let _ollamaLibCache = null;
|
||||
let _ollamaLibInFlight = null;
|
||||
async function _ensureOllamaLib() {
|
||||
if (_ollamaLibCache) return _ollamaLibCache;
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/ollama/library');
|
||||
const data = await res.json();
|
||||
_ollamaLibCache = Array.isArray(data?.models) ? data.models : [];
|
||||
} catch { _ollamaLibCache = []; }
|
||||
return _ollamaLibCache;
|
||||
if (_ollamaLibInFlight) return _ollamaLibInFlight;
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const res = await _fetchHwfitWithTimeout('/api/cookbook/ollama/library');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
_ollamaLibCache = Array.isArray(data?.models) ? data.models : [];
|
||||
} catch { _ollamaLibCache = []; }
|
||||
return _ollamaLibCache;
|
||||
})();
|
||||
_ollamaLibInFlight = pending;
|
||||
const clearPending = () => {
|
||||
if (_ollamaLibInFlight === pending) _ollamaLibInFlight = null;
|
||||
};
|
||||
pending.then(clearPending, clearPending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Convert an Ollama library entry's sizes into per-tag hwfit rows. Shape
|
||||
@@ -666,6 +704,9 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
|
||||
|
||||
export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
const _tk = ++_hwfitFetchToken;
|
||||
_hwfitRequestController?.abort();
|
||||
const _requestController = new AbortController();
|
||||
_hwfitRequestController = _requestController;
|
||||
const allowNetwork = fresh || opts.allowNetwork !== false;
|
||||
const keepPrevious = !!opts.keepPrevious;
|
||||
const forceRevalidate = !!opts.forceRevalidate;
|
||||
@@ -754,6 +795,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
// Only fetch cached model IDs when server changes, not on every search/sort
|
||||
const remoteKey = _currentServerValue();
|
||||
if (!_cachedModelIds || _lastCacheHost() !== remoteKey) {
|
||||
const _cacheFetchToken = _tk;
|
||||
const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost);
|
||||
const _cachePort = _cacheSrv?.port || '';
|
||||
const _cacheParams = new URLSearchParams();
|
||||
@@ -762,9 +804,13 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (_cachePort) _cacheParams.set('ssh_port', _cachePort);
|
||||
if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform);
|
||||
}
|
||||
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
_fetchHwfitWithTimeout(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' }, 20000)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(d => {
|
||||
if (_cacheFetchToken !== _hwfitFetchToken) return;
|
||||
if (d && d.error) throw new Error(d.error);
|
||||
// Exclude stalled (download-shell) entries — a 12 KB README-only
|
||||
// folder shouldn't count as "downloaded" in the Scan/Download list.
|
||||
@@ -781,6 +827,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
if (_cacheFetchToken !== _hwfitFetchToken) return;
|
||||
console.warn('Cached model marker scan failed:', err);
|
||||
_setLastCacheHost('');
|
||||
});
|
||||
@@ -824,6 +871,8 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
|
||||
// Image models use a separate registry/endpoint.
|
||||
const isImageMode = useCase === 'image_gen';
|
||||
const officialOnly = document.getElementById('cookbook-hf-official-only')?.checked;
|
||||
if (officialOnly) params.set('official_only', 'true');
|
||||
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
|
||||
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
|
||||
}
|
||||
@@ -836,7 +885,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (_fitOnly) params.set('fit_only', '1');
|
||||
}
|
||||
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
|
||||
const res = await fetch(endpoint);
|
||||
const res = await _fetchHwfitWithTimeout(endpoint, { signal: _requestController.signal }, 30000);
|
||||
// A newer scan started while this one was in flight (user switched servers
|
||||
// mid-probe) — drop this stale response so it can't clobber the new one.
|
||||
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
|
||||
@@ -857,7 +906,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (!isImageMode && quantPref && !data.error && Array.isArray(data.models) && data.models.length === 0) {
|
||||
const fallbackParams = new URLSearchParams(params);
|
||||
fallbackParams.delete('quant');
|
||||
const fallbackRes = await fetch(`/api/hwfit/models?${fallbackParams}`);
|
||||
const fallbackRes = await _fetchHwfitWithTimeout(`/api/hwfit/models?${fallbackParams}`, { signal: _requestController.signal }, 30000);
|
||||
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
|
||||
if (fallbackRes.ok) {
|
||||
const fallbackData = await fallbackRes.json();
|
||||
@@ -882,6 +931,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
is_image_gen: true,
|
||||
quant: m.quant || m.default_quant || 'BF16',
|
||||
quant_repo: m.quant_repo || null,
|
||||
runtime_adapter: m.runtime_adapter || m.runtime_adapter_type || '',
|
||||
}));
|
||||
}
|
||||
wp.destroy();
|
||||
@@ -970,6 +1020,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
}
|
||||
} catch (e) {
|
||||
wp.destroy();
|
||||
if (_tk !== _hwfitFetchToken) return;
|
||||
// Same stale-while-revalidate rule: only surface the error if we have nothing
|
||||
// already on screen from the cache.
|
||||
if (!_cached) _hwfitShowError(list, remoteHost, e.message);
|
||||
@@ -1014,7 +1065,7 @@ function _renderHwVisibilityWarning(sys) {
|
||||
const panel = document.getElementById('hwfit-manual-panel');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
const manualBtn = document.getElementById('hwfit-hw-manual-btn');
|
||||
if (manualBtn) manualBtn.textContent = 'CANCEL';
|
||||
if (manualBtn) manualBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>CANCEL</span>';
|
||||
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
@@ -1052,6 +1103,16 @@ function _renderHwVisibilityWarning(sys) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stop scans that are no longer useful after Cookbook is closed. The fetch
|
||||
// token also invalidates any completion that races the abort signal.
|
||||
export function _cancelHwfitRequests() {
|
||||
_hwfitFetchToken++;
|
||||
_hwfitRequestController?.abort();
|
||||
_hwfitRequestController = null;
|
||||
clearTimeout(_hwfitDebounce);
|
||||
_hwfitDebounce = null;
|
||||
}
|
||||
|
||||
export function _hwfitRenderHw(el, sys) {
|
||||
if (!el || !sys) return;
|
||||
// Cache system info globally so other modules can read VRAM without refetching
|
||||
@@ -1180,7 +1241,7 @@ export function _hwfitRenderHw(el, sys) {
|
||||
btn.closest('.hwfit-hw-chip-row')?.remove();
|
||||
document.getElementById('hwfit-manual-panel')?.classList.add('hidden');
|
||||
const manualBtn = document.getElementById('hwfit-hw-manual-btn');
|
||||
if (manualBtn) manualBtn.textContent = 'EDIT';
|
||||
if (manualBtn) manualBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 0 3 3L7 19l-4 1 1-4Z"/></svg><span>EDIT</span>';
|
||||
_resetGpuToggleState();
|
||||
_hwfitCache = null;
|
||||
_hwfitFetch(true);
|
||||
@@ -1202,7 +1263,9 @@ function _wireManualHardwareControls(el) {
|
||||
const panel = document.getElementById('hwfit-manual-panel');
|
||||
if (!btn || !panel) return;
|
||||
const syncManualButton = () => {
|
||||
btn.textContent = panel.classList.contains('hidden') ? 'EDIT' : 'CANCEL';
|
||||
btn.innerHTML = panel.classList.contains('hidden')
|
||||
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 0 3 3L7 19l-4 1 1-4Z"/></svg><span>EDIT</span>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>CANCEL</span>';
|
||||
};
|
||||
const clearManual = () => {
|
||||
_saveManualHwState(null);
|
||||
@@ -1288,7 +1351,7 @@ export const _hwfitColumns = [
|
||||
{ key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' },
|
||||
{ key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' },
|
||||
{ key: 'score', label: 'Score', cls: 'hwfit-c-score' },
|
||||
{ key: null, label: 'Mode', cls: 'hwfit-c-mode' },
|
||||
{ key: null, label: 'Engine', cls: 'hwfit-c-mode' },
|
||||
];
|
||||
|
||||
function _sortHwfitRows(models) {
|
||||
@@ -1331,6 +1394,10 @@ function _sortHwfitRows(models) {
|
||||
export function _hwfitRenderList(el, models) {
|
||||
if (!el) return;
|
||||
models = _sortHwfitRows(models);
|
||||
const shouldPlayDomino = !el.querySelector('.hwfit-row[data-model]');
|
||||
el.classList.remove('cookbook-hwfit-models-just-loaded');
|
||||
clearTimeout(el._hwfitDominoTimer);
|
||||
el._hwfitDominoTimer = null;
|
||||
if (!models.length) {
|
||||
// Disambiguate WHY the list is empty so capable servers don't read as "too weak":
|
||||
// active filters vs. a likely under-reported probe vs. genuinely low hardware.
|
||||
@@ -1436,6 +1503,20 @@ export function _hwfitRenderList(el, models) {
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
// Paint the complete table first, then let it settle in on the next frame.
|
||||
// This avoids the large unstyled-looking text flash when a remote scan
|
||||
// finishes, while keeping sorting/rerendering synchronous.
|
||||
el.classList.remove('cookbook-model-list-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('cookbook-model-list-fade');
|
||||
if (shouldPlayDomino) {
|
||||
void el.offsetWidth;
|
||||
el.classList.add('cookbook-hwfit-models-just-loaded');
|
||||
el._hwfitDominoTimer = setTimeout(() => {
|
||||
el.classList.remove('cookbook-hwfit-models-just-loaded');
|
||||
el._hwfitDominoTimer = null;
|
||||
}, 950);
|
||||
}
|
||||
// Click row → expand inline action panel. Exception: Ollama rows skip the
|
||||
// expand panel (no HF metadata to power it) and just fill the Download
|
||||
// input with the `<name>:<size>` tag — one click → ready to pull.
|
||||
@@ -1737,7 +1818,7 @@ export function _expandModelRow(row, modelData) {
|
||||
if (_stopBtn) {
|
||||
_stopBtn.click();
|
||||
} else {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchHwfitWithTimeout('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1791,7 +1872,7 @@ export function _expandModelRow(row, modelData) {
|
||||
const _wrappedCheck = _qrHostStr
|
||||
? `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${_qrHostStr} "bash -lc ${JSON.stringify(_coreCheck)}"`
|
||||
: `bash -lc ${JSON.stringify(_coreCheck)}`;
|
||||
const _chkRes = await fetch('/api/shell/exec', {
|
||||
const _chkRes = await _fetchHwfitWithTimeout('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1922,7 +2003,7 @@ export function _expandModelRow(row, modelData) {
|
||||
host,
|
||||
(_srv && _srv.port) || undefined,
|
||||
_envState.envPath || '',
|
||||
modelData.name,
|
||||
modelData,
|
||||
);
|
||||
if (!_ok) {
|
||||
quickRunBtn.disabled = false;
|
||||
@@ -1939,10 +2020,11 @@ export function _expandModelRow(row, modelData) {
|
||||
hf_token: _envState.hfToken || undefined,
|
||||
gpus: _envState.gpus || cudaDevices || undefined,
|
||||
platform: _envState.platform || undefined,
|
||||
runtime_adapter: modelData.runtime_adapter || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/model/serve', {
|
||||
const res = await _fetchHwfitWithTimeout('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -1950,7 +2032,7 @@ export function _expandModelRow(row, modelData) {
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
const shortName = modelData.name.split('/').pop();
|
||||
_addTask(data.session_id, shortName, 'serve', { _cmd: cmd, model: modelData.name, backend: runBackend, remote_host: host });
|
||||
_addTask(data.session_id, shortName, 'serve', { _cmd: cmd, model: modelData.name, backend: runBackend, runtime_adapter: modelData.runtime_adapter || '', remote_host: host });
|
||||
_renderRunningTab();
|
||||
uiModule.showToast(`Launching ${shortName}...`);
|
||||
// Switch to Running tab
|
||||
@@ -2001,21 +2083,22 @@ export function _expandModelRow(row, modelData) {
|
||||
const _HWFIT_ENGINE_GLYPHS = {
|
||||
'': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"></path><path d="M14 4l4 9 3-9"></path></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
|
||||
};
|
||||
|
||||
function _hwfitEngineGlyph(value) {
|
||||
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
|
||||
return _HWFIT_ENGINE_GLYPHS[value] ?? _HWFIT_ENGINE_GLYPHS[''];
|
||||
}
|
||||
|
||||
const _HWFIT_USECASE_GLYPHS = {
|
||||
general: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>',
|
||||
multimodal: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
|
||||
image_gen: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M21 15l-5-5L5 21"></path></svg>',
|
||||
trending: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>',
|
||||
};
|
||||
|
||||
function _hwfitUsecaseGlyph(value) {
|
||||
@@ -2086,12 +2169,18 @@ function _bindHwfitUsecasePicker(usecase) {
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
usecase.addEventListener('change', syncButton);
|
||||
document.addEventListener('click', (ev) => {
|
||||
const onDocumentClick = (ev) => {
|
||||
if (!wrap.contains(ev.target)) setOpen(false);
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
};
|
||||
const onDocumentKeydown = (ev) => {
|
||||
if (ev.key === 'Escape') setOpen(false);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
wrap._cleanupUsecasePicker = () => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
document.removeEventListener('keydown', onDocumentKeydown);
|
||||
};
|
||||
renderMenu();
|
||||
}
|
||||
|
||||
@@ -2150,12 +2239,18 @@ function _bindHwfitEnginePicker(engine) {
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
engine.addEventListener('change', syncButton);
|
||||
document.addEventListener('click', (ev) => {
|
||||
const onDocumentClick = (ev) => {
|
||||
if (!wrap.contains(ev.target)) setOpen(false);
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
};
|
||||
const onDocumentKeydown = (ev) => {
|
||||
if (ev.key === 'Escape') setOpen(false);
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
wrap._cleanupEnginePicker = () => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
document.removeEventListener('keydown', onDocumentKeydown);
|
||||
};
|
||||
renderMenu();
|
||||
}
|
||||
|
||||
@@ -2169,21 +2264,33 @@ export function _hwfitInit() {
|
||||
const remote = document.getElementById('hwfit-host');
|
||||
_syncCtxControl();
|
||||
if (uc) _bindHwfitUsecasePicker(uc);
|
||||
if (uc) uc.addEventListener('change', () => _hwfitFetch());
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
if (uc && !uc.dataset.hwfitScanBound) {
|
||||
uc.dataset.hwfitScanBound = '1';
|
||||
uc.addEventListener('change', () => _hwfitFetch());
|
||||
}
|
||||
if (sort && !sort.dataset.hwfitScanBound) {
|
||||
sort.dataset.hwfitScanBound = '1';
|
||||
sort.addEventListener('change', () => _hwfitFetch());
|
||||
}
|
||||
if (qpref && !qpref.dataset.hwfitScanBound) {
|
||||
qpref.dataset.hwfitScanBound = '1';
|
||||
qpref.addEventListener('change', () => _hwfitFetch());
|
||||
}
|
||||
// Engine filter is a pure client-side view filter over the already-fetched
|
||||
// list (HF + Ollama merged), so just re-render from cache.
|
||||
const engine = document.getElementById('hwfit-engine');
|
||||
if (engine) _bindHwfitEnginePicker(engine);
|
||||
if (engine) engine.addEventListener('change', () => {
|
||||
const list = document.getElementById('hwfit-list');
|
||||
if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) {
|
||||
_hwfitRenderList(list, _applyEngineFilter(_hwfitCache.models));
|
||||
} else {
|
||||
_hwfitFetch();
|
||||
}
|
||||
});
|
||||
if (engine && !engine.dataset.hwfitScanBound) {
|
||||
engine.dataset.hwfitScanBound = '1';
|
||||
engine.addEventListener('change', () => {
|
||||
const list = document.getElementById('hwfit-list');
|
||||
if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) {
|
||||
_hwfitRenderList(list, _applyEngineFilter(_hwfitCache.models));
|
||||
} else {
|
||||
_hwfitFetch();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (ctx && !ctx.dataset.bound) {
|
||||
ctx.dataset.bound = '1';
|
||||
ctx.addEventListener('input', () => {
|
||||
@@ -2242,10 +2349,13 @@ export function _hwfitInit() {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (search) search.addEventListener('input', () => {
|
||||
clearTimeout(_hwfitDebounce);
|
||||
_hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
|
||||
});
|
||||
if (search && !search.dataset.hwfitScanBound) {
|
||||
search.dataset.hwfitScanBound = '1';
|
||||
search.addEventListener('input', () => {
|
||||
clearTimeout(_hwfitDebounce);
|
||||
_hwfitDebounce = setTimeout(() => _hwfitFetch(), 400);
|
||||
});
|
||||
}
|
||||
// HF token save is owned by cookbook.js (_wireTabEvents) — do not wire a
|
||||
// second change/input handler here. The old duplicate ran after cookbook.js
|
||||
// cleared the input on save and overwrote _envState.hfToken with "", so the
|
||||
@@ -2347,7 +2457,7 @@ export function _hwfitInit() {
|
||||
setMsg('Testing SSH...');
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/test-ssh', {
|
||||
const res = await _fetchHwfitWithTimeout('/api/cookbook/test-ssh', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, ssh_port: port || undefined }),
|
||||
@@ -2393,7 +2503,7 @@ export function _hwfitInit() {
|
||||
}
|
||||
|
||||
async function _fetchCookbookSshKey(generate = false) {
|
||||
const res = await fetch('/api/cookbook/ssh-key', {
|
||||
const res = await _fetchHwfitWithTimeout('/api/cookbook/ssh-key', {
|
||||
method: generate ? 'POST' : 'GET',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
@@ -2481,8 +2591,10 @@ export function _hwfitInit() {
|
||||
_defBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const key = _defBtn.dataset.srvKey || '';
|
||||
// Toggle off if it's already the default; otherwise make it the default.
|
||||
_envState.defaultServer = (_envState.defaultServer === key) ? '' : key;
|
||||
// Match the model-directory selector: this is an exclusive choice,
|
||||
// so clicking the active option keeps it selected rather than clearing
|
||||
// the default and leaving Cookbook without a landing server.
|
||||
_envState.defaultServer = key;
|
||||
_persistEnvState();
|
||||
document.querySelectorAll('.cookbook-srv-default').forEach(b => {
|
||||
const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer;
|
||||
@@ -2648,7 +2760,7 @@ export function _hwfitInit() {
|
||||
const origText = setupBtn.textContent;
|
||||
setupBtn.textContent = 'Installing...';
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/setup', {
|
||||
const res = await _fetchHwfitWithTimeout('/api/cookbook/setup', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host, ssh_port: port || undefined }),
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
// What Fits? + Saved presets, inline action panels
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis, _runQuickCmd, ERROR_PATTERNS } from './cookbook-diagnosis.js';
|
||||
import { RECIPE_BACKENDS, recipesForBackend, pickRecipe, recipeCommands, RECIPE_DEFAULT_VARIANT } from './cookbook-deps-recipes.js';
|
||||
import { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState } from './cookbook-hwfit.js';
|
||||
import { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState, _cancelHwfitRequests } from './cookbook-hwfit.js';
|
||||
|
||||
// Sub-modules
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
|
||||
import {
|
||||
initServe,
|
||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, _cancelCachedModelScan,
|
||||
} from './cookbookServe.js';
|
||||
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
@@ -39,20 +39,14 @@ import { topPortalZ } from './toolWindowZOrder.js';
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
let _dependenciesFetchId = 0;
|
||||
let _dependenciesRequestController = null;
|
||||
let _dependenciesModelHint = '';
|
||||
|
||||
// Global, once: tag chip rows (.doclib-lang-chips) scroll horizontally on mobile.
|
||||
// Stop their touch events (capture phase, before any ancestor sees them) so a
|
||||
// sideways tag scroll never triggers a swipe-to-change-tab / swipe-dismiss
|
||||
// gesture in ANY modal (cookbook, document library, etc.). We don't preventDefault,
|
||||
// so the browser's native horizontal scroll of the chips still works.
|
||||
if (typeof window !== 'undefined' && !window._tagScrollGuardWired) {
|
||||
window._tagScrollGuardWired = true;
|
||||
['touchstart', 'touchmove'].forEach(evt => {
|
||||
document.addEventListener(evt, (e) => {
|
||||
const t = e.target;
|
||||
if (t && t.closest && t.closest('.doclib-lang-chips')) e.stopPropagation();
|
||||
}, true);
|
||||
});
|
||||
function _fetchCookbookUiWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Radio-style check marking which model directory is a server's download target.
|
||||
@@ -1071,24 +1065,11 @@ export function _persistEnvState() {
|
||||
async function _fetchDependencies() {
|
||||
const list = document.getElementById('cookbook-deps-list');
|
||||
if (!list) return;
|
||||
// Use the shared whirlpool spinner so the user sees the request is in
|
||||
// flight (the package list takes a few seconds to enumerate on slow links).
|
||||
list.innerHTML = '';
|
||||
let _spin = null;
|
||||
try {
|
||||
const sp = (await import('./spinner.js')).default;
|
||||
_spin = sp.createWhirlpool(22);
|
||||
_spin.element.classList.add('cookbook-section-loading-wp');
|
||||
_spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;';
|
||||
list.appendChild(_spin.element);
|
||||
const label = document.createElement('div');
|
||||
label.className = 'hwfit-loading';
|
||||
label.textContent = 'Loading packages…';
|
||||
label.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;';
|
||||
list.appendChild(label);
|
||||
} catch {
|
||||
list.innerHTML = '<div class="hwfit-loading">Loading packages...</div>';
|
||||
}
|
||||
const fetchId = ++_dependenciesFetchId;
|
||||
_dependenciesRequestController?.abort();
|
||||
const depController = new AbortController();
|
||||
_dependenciesRequestController = depController;
|
||||
const depTimer = setTimeout(() => depController.abort(), 20000);
|
||||
try {
|
||||
// Resolve the target server from the deps dropdown so remote-target
|
||||
// packages are checked on THAT server's venv (not just the local host).
|
||||
@@ -1115,24 +1096,55 @@ async function _fetchDependencies() {
|
||||
if (_depBackend && _hwfitCache?._scannedHost === _depHost) {
|
||||
_pkgParams.set('backend', _depBackend);
|
||||
}
|
||||
if (_cachedModelIds && _cachedModelIds.size) {
|
||||
const _hint = Array.from(_cachedModelIds)
|
||||
.filter(id => /krea/i.test(String(id || '')))
|
||||
.slice(0, 20)
|
||||
.join(',');
|
||||
if (_dependenciesModelHint || (_cachedModelIds && _cachedModelIds.size)) {
|
||||
const _hintIds = [
|
||||
_dependenciesModelHint,
|
||||
...(_cachedModelIds ? Array.from(_cachedModelIds) : []),
|
||||
].filter((id, index, ids) => id && ids.indexOf(id) === index).slice(0, 20);
|
||||
const _hint = _hintIds.join(',');
|
||||
if (_hint) _pkgParams.set('model_hint', _hint);
|
||||
}
|
||||
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''));
|
||||
const scanSig = _pkgParams.toString() || 'local';
|
||||
const preserveRows = !!list.querySelector('.cookbook-dep-row')
|
||||
&& list.dataset.cookbookDepsScanSig === scanSig;
|
||||
list.querySelectorAll('.cookbook-deps-loading').forEach(el => el.remove());
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'cookbook-deps-loading';
|
||||
loading.style.cssText = preserveRows
|
||||
? 'display:flex;align-items:center;justify-content:center;gap:6px;padding:7px 0;color:var(--fg-muted);font-size:11px;'
|
||||
: 'display:flex;flex-direction:column;align-items:center;gap:6px;padding:24px 0;color:var(--fg-muted);font-size:11px;';
|
||||
try {
|
||||
const sp = (await import('./spinner.js')).default;
|
||||
const spin = sp.createWhirlpool(preserveRows ? 16 : 22);
|
||||
spin.element.classList.add('cookbook-section-loading-wp');
|
||||
spin.element.style.cssText = `display:block;width:${preserveRows ? 16 : 22}px;height:${preserveRows ? 16 : 22}px;`;
|
||||
loading.appendChild(spin.element);
|
||||
} catch {}
|
||||
const loadingLabel = document.createElement('span');
|
||||
loadingLabel.textContent = preserveRows ? 'Refreshing packages…' : 'Loading packages…';
|
||||
loading.appendChild(loadingLabel);
|
||||
if (preserveRows) {
|
||||
list.prepend(loading);
|
||||
} else {
|
||||
list.replaceChildren(loading);
|
||||
}
|
||||
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''), {
|
||||
credentials: 'same-origin',
|
||||
signal: depController.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}${resp.statusText ? ` ${resp.statusText}` : ''}`);
|
||||
const data = await resp.json();
|
||||
if (fetchId !== _dependenciesFetchId) return;
|
||||
list.dataset.cookbookDepsScanSig = scanSig;
|
||||
const pkgs = data.packages || [];
|
||||
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
||||
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||
const _systemInstallable = new Set(['tmux']);
|
||||
const _systemInstallable = new Set(['tmux', 'libreoffice']);
|
||||
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false && pkg.name !== 'llama_cpp') {
|
||||
if (pkg.installed && pkg.pip_update_available === false && !['llama_cpp', 'vllm', 'sglang'].includes(pkg.name)) {
|
||||
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
|
||||
}
|
||||
@@ -1155,12 +1167,12 @@ async function _fetchDependencies() {
|
||||
// icon (the name alone is fine for librosa, hf_transfer, etc.).
|
||||
const _DEP_GLYPHS = {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
|
||||
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
|
||||
@@ -1176,18 +1188,10 @@ async function _fetchDependencies() {
|
||||
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
|
||||
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
|
||||
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
|
||||
// Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it
|
||||
// matches the LLM category tag's pill look, and lives to the LEFT of the
|
||||
// category tag. llama_cpp uses the /api/cookbook/rebuild-engine flow
|
||||
// (clear cached binary so next serve recompiles); vllm/sglang use the
|
||||
// diagnosis-style `_launchServeTask` with `pip install --force-reinstall`
|
||||
// so the user can watch the pip install in the Running tab.
|
||||
let _rebuildBtn = '';
|
||||
if (pkg.name === 'vllm' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="vllm" title="Force-reinstall vLLM (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
} else if (pkg.name === 'sglang' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
}
|
||||
// Reinstall actions live in the Installed dropdown, alongside Update.
|
||||
// Keeping them in one menu avoids a second action pill changing the row
|
||||
// layout on narrow screens.
|
||||
const _rebuildBtn = '';
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
@@ -1204,7 +1208,7 @@ async function _fetchDependencies() {
|
||||
// instead of forcing them out to a shell to apt/pacman/dnf.
|
||||
const _bdm = Array.isArray(pkg.build_deps_missing) ? pkg.build_deps_missing : [];
|
||||
const _buildDepsBtn = _bdm.length
|
||||
? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>`
|
||||
? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps cookbook-dep-build-deps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>`
|
||||
: '';
|
||||
// Partial-state row (replaces the cryptic yellow "Partial ▾" tag).
|
||||
// Renders inline as a yellow banner with two clear actions: one-tap
|
||||
@@ -1303,11 +1307,15 @@ async function _fetchDependencies() {
|
||||
`<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>`;
|
||||
const _section = (title, note, items) =>
|
||||
items.length ? _sectionHeader(title, note) + _rowsHtml(items) : '';
|
||||
// Keep the dependency panel predictable as new runtime packages are added.
|
||||
// This is presentation order only; the API remains free to return packages
|
||||
// in probe order and unknown categories still render at the end.
|
||||
const _depCategoryOrder = ['System', 'Tools', 'LLM', 'Image', 'Audio', 'Other'];
|
||||
const _pkgOrder = {
|
||||
System: ['tmux', 'docker'],
|
||||
Tools: ['hf_transfer'],
|
||||
LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm'],
|
||||
Image: ['diffusers', 'krea_diffusers', 'transformers', 'sam_mask', 'mflux', 'boogu_image_mlx', 'mlx_vlm'],
|
||||
Tools: ['hf_transfer', 'playwright', 'office_docs', 'pymupdf', 'libreoffice'],
|
||||
LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm', 'APFEL'],
|
||||
Image: ['diffusers', 'krea_diffusers', 'mflux', 'boogu_image_mlx', 'mlx_lama_swift', 'mlx_ddcolor_swift', 'mlx_vlm', 'transformers', 'sam_mask', 'rembg', 'realesrgan', 'psd_tools'],
|
||||
};
|
||||
const _sortDeps = (items, category) => {
|
||||
const order = _pkgOrder[category] || [];
|
||||
@@ -1319,6 +1327,10 @@ async function _fetchDependencies() {
|
||||
return ar - br || String(a.name || '').localeCompare(String(b.name || ''));
|
||||
});
|
||||
};
|
||||
const _orderedDepCategories = (byCat) => [
|
||||
..._depCategoryOrder,
|
||||
...Array.from(byCat.keys()).filter(cat => !_depCategoryOrder.includes(cat)).sort((a, b) => String(a).localeCompare(String(b))),
|
||||
];
|
||||
const _serverDepsHtml = (items) => {
|
||||
const byCat = new Map();
|
||||
for (const item of items) {
|
||||
@@ -1327,14 +1339,14 @@ async function _fetchDependencies() {
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
const order = _orderedDepCategories(byCat);
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
if (cat === 'Image') {
|
||||
const mlxNames = new Set(['mflux', 'boogu_image_mlx', 'mlx_vlm']);
|
||||
const general = catItems.filter(p => !mlxNames.has(p.name));
|
||||
const mlx = catItems.filter(p => mlxNames.has(p.name));
|
||||
const isMlxImageRuntime = (p) => p.name === 'mflux' || p.name === 'boogu_image_mlx' || String(p.name || '').startsWith('mlx_');
|
||||
const general = catItems.filter(p => !isMlxImageRuntime(p));
|
||||
const mlx = catItems.filter(isMlxImageRuntime);
|
||||
parts.push(_sectionHeader('Image', 'Diffusers and shared image tooling.'));
|
||||
if (general.length) parts.push(_rowsHtml(general));
|
||||
if (mlx.length) {
|
||||
@@ -1367,7 +1379,7 @@ async function _fetchDependencies() {
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [_sectionHeader('Odysseus app', 'Run inside the Odysseus app itself.')];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
const order = _orderedDepCategories(byCat);
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
@@ -1470,7 +1482,7 @@ async function _fetchDependencies() {
|
||||
env_prefix: envPrefix || undefined,
|
||||
platform: targetPlatform || undefined,
|
||||
};
|
||||
const res = await fetch('/api/model/serve', {
|
||||
const res = await _fetchCookbookUiWithTimeout('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqBody),
|
||||
@@ -1546,7 +1558,7 @@ async function _fetchDependencies() {
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
platform: _envState.platform || undefined,
|
||||
};
|
||||
const res = await fetch('/api/model/serve', {
|
||||
const res = await _fetchCookbookUiWithTimeout('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqBody),
|
||||
@@ -1607,7 +1619,7 @@ async function _fetchDependencies() {
|
||||
const _p = _getPort(_envState.remoteHost);
|
||||
if (_p) body.ssh_port = _p;
|
||||
}
|
||||
const res = await fetch('/api/cookbook/install-system-deps', {
|
||||
const res = await _fetchCookbookUiWithTimeout('/api/cookbook/install-system-deps', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -1757,7 +1769,7 @@ async function _fetchDependencies() {
|
||||
platform: _envState.platform || undefined,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/model/serve', {
|
||||
const res = await _fetchCookbookUiWithTimeout('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqBody),
|
||||
@@ -1793,7 +1805,7 @@ async function _fetchDependencies() {
|
||||
statusEl.textContent = updateSource ? 'Updating...' : 'Clearing...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/rebuild-engine', {
|
||||
const res = await _fetchCookbookUiWithTimeout('/api/cookbook/rebuild-engine', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -1873,6 +1885,14 @@ async function _fetchDependencies() {
|
||||
});
|
||||
dropdown.appendChild(source);
|
||||
}
|
||||
if (rowPkgName === 'vllm' || rowPkgName === 'sglang') {
|
||||
const reinstall = document.createElement('div');
|
||||
reinstall.className = 'dropdown-item-compact cookbook-dep-reinstall';
|
||||
reinstall.dataset.reinstallPkg = rowPkgName;
|
||||
reinstall.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Reinstall</span>`;
|
||||
reinstall.title = `Force-reinstall ${rowPkgName} without dependencies.`;
|
||||
dropdown.appendChild(reinstall);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||
@@ -1888,7 +1908,11 @@ async function _fetchDependencies() {
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
if (fetchId !== _dependenciesFetchId) return;
|
||||
list.innerHTML = `<div class="hwfit-loading">Error loading packages: ${esc(err.message)}</div>`;
|
||||
} finally {
|
||||
clearTimeout(depTimer);
|
||||
if (_dependenciesRequestController === depController) _dependenciesRequestController = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1938,10 +1962,15 @@ async function _refreshScanDownloadTarget() {
|
||||
const btn = document.getElementById('hwfit-hw-refresh-btn');
|
||||
if (btn && btn.disabled) return;
|
||||
const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue();
|
||||
const originalHtml = btn?.innerHTML || '';
|
||||
let completed = false;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.55';
|
||||
btn.style.cursor = 'wait';
|
||||
const wp = spinnerModule.createWhirlpool(13);
|
||||
btn.innerHTML = '';
|
||||
btn.appendChild(wp.element);
|
||||
}
|
||||
try {
|
||||
if (selectedVal) _applyServerSelection(selectedVal);
|
||||
@@ -1958,6 +1987,17 @@ async function _refreshScanDownloadTarget() {
|
||||
_hwfitFetch(true),
|
||||
_fetchCachedModels(true),
|
||||
]);
|
||||
completed = true;
|
||||
if (btn) {
|
||||
btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
btn.style.color = 'var(--green, #50fa7b)';
|
||||
setTimeout(() => {
|
||||
if (btn.isConnected) {
|
||||
btn.innerHTML = originalHtml;
|
||||
btn.style.color = '';
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
if (uiModule?.showToast) uiModule.showToast('Refreshed selected server');
|
||||
} catch (e) {
|
||||
console.warn('[cookbook] scan/download refresh failed', e);
|
||||
@@ -1967,6 +2007,7 @@ async function _refreshScanDownloadTarget() {
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = '';
|
||||
btn.style.cursor = '';
|
||||
if (!completed) btn.innerHTML = originalHtml;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2138,13 +2179,26 @@ function _wireTabEvents(body) {
|
||||
if (scanBtn) {
|
||||
scanBtn.addEventListener('click', async () => {
|
||||
if (scanBtn.disabled) return;
|
||||
const originalHtml = scanBtn.innerHTML;
|
||||
scanBtn.disabled = true;
|
||||
scanBtn.classList.add('spinning');
|
||||
const wp = spinnerModule.createWhirlpool(13);
|
||||
scanBtn.innerHTML = '';
|
||||
scanBtn.appendChild(wp.element);
|
||||
try {
|
||||
await _fetchCachedModels(true);
|
||||
scanBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
scanBtn.style.color = 'var(--green, #50fa7b)';
|
||||
setTimeout(() => {
|
||||
if (scanBtn.isConnected) {
|
||||
scanBtn.innerHTML = originalHtml;
|
||||
scanBtn.style.color = '';
|
||||
}
|
||||
}, 900);
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.classList.remove('spinning');
|
||||
if (!scanBtn.querySelector('polyline')) scanBtn.innerHTML = originalHtml;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2169,10 +2223,30 @@ function _wireTabEvents(body) {
|
||||
setAdvancedOpen(hwAdvancedPanel.classList.contains('hidden'));
|
||||
});
|
||||
hwAdvancedPanel.addEventListener('click', (ev) => ev.stopPropagation());
|
||||
document.addEventListener('click', () => setAdvancedOpen(false));
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') setAdvancedOpen(false);
|
||||
});
|
||||
// _renderRecipes() replaces this DOM on every Cookbook reopen/sync. Keep
|
||||
// dismissal delegated and installed once; otherwise each render leaves a
|
||||
// document listener holding the old detached panel alive.
|
||||
if (!document._cookbookAdvancedDismissWired) {
|
||||
document._cookbookAdvancedDismissWired = true;
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (ev.target?.closest?.('#hwfit-advanced-btn, #hwfit-advanced-panel')) return;
|
||||
const currentPanel = document.getElementById('hwfit-advanced-panel');
|
||||
const currentBtn = document.getElementById('hwfit-advanced-btn');
|
||||
if (!currentPanel || !currentBtn) return;
|
||||
currentPanel.classList.add('hidden');
|
||||
currentBtn.classList.remove('active');
|
||||
currentBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
const currentPanel = document.getElementById('hwfit-advanced-panel');
|
||||
const currentBtn = document.getElementById('hwfit-advanced-btn');
|
||||
if (!currentPanel || !currentBtn) return;
|
||||
currentPanel.classList.add('hidden');
|
||||
currentBtn.classList.remove('active');
|
||||
currentBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
|
||||
@@ -2269,6 +2343,17 @@ function _wireTabEvents(body) {
|
||||
dot.style.display = active ? '' : 'none';
|
||||
dot.classList.remove('selected');
|
||||
});
|
||||
// In Launch/What-Fits, the same control can read Cancel while a model
|
||||
// row is expanded. Cancel must close that row too, not only selection.
|
||||
if (!active) {
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
const activeRow = modal?.querySelector('.hwfit-row-active');
|
||||
const activePanel = activeRow?.nextElementSibling?.classList.contains('hwfit-action-panel')
|
||||
? activeRow.nextElementSibling : activeRow?.parentElement?.querySelector('.hwfit-action-panel');
|
||||
activePanel?._cleanupServePanel?.();
|
||||
activePanel?.remove();
|
||||
activeRow?.classList.remove('hwfit-row-active');
|
||||
}
|
||||
_updateBulkCount();
|
||||
});
|
||||
|
||||
@@ -2400,7 +2485,7 @@ function _wireTabEvents(body) {
|
||||
dlGgufQuant.dataset.repo = repo;
|
||||
dlGgufNote.textContent = '';
|
||||
try {
|
||||
const res = await fetch(`/api/cookbook/hf-gguf-files?repo_id=${encodeURIComponent(repo)}`, { credentials: 'same-origin' });
|
||||
const res = await _fetchCookbookUiWithTimeout(`/api/cookbook/hf-gguf-files?repo_id=${encodeURIComponent(repo)}`, { credentials: 'same-origin' }, 30000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.ok) throw new Error(data.error || 'scan failed');
|
||||
@@ -2584,7 +2669,7 @@ function _wireTabEvents(body) {
|
||||
// Toggle via class so CSS transition animates the height/opacity
|
||||
// — display:none was an instant on/off and felt jarring.
|
||||
dlFoldBody.classList.toggle('is-folded', folded);
|
||||
dlFoldChevron.textContent = folded ? '▸' : '▾';
|
||||
dlFoldChevron.style.transform = folded ? 'rotate(-90deg)' : 'rotate(0deg)';
|
||||
dlFold.classList.toggle('is-folded', folded);
|
||||
if (persist) {
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '1' : '0'); } catch {}
|
||||
@@ -2594,17 +2679,24 @@ function _wireTabEvents(body) {
|
||||
const folded = dlFoldBody.classList.contains('is-folded');
|
||||
_setFolded(!folded);
|
||||
});
|
||||
// Auto-fold on any downward scroll inside the cookbook modal. Do not
|
||||
// auto-expand on upward/top scroll — once the user collapses Download,
|
||||
// it should stay collapsed until the header is clicked again.
|
||||
// Auto-fold on downward scroll, and restore the section when the primary
|
||||
// Cookbook scroller reaches its top again. Desktop scrolls on the body;
|
||||
// mobile scrolls on the modal content.
|
||||
const _maybeFold = () => {
|
||||
if (dlFoldBody.classList.contains('is-folded')) return;
|
||||
_setFolded(true, /* persist */ false);
|
||||
};
|
||||
const _maybeUnfoldAtTop = () => {
|
||||
if (!dlFoldBody.classList.contains('is-folded')) return;
|
||||
_setFolded(false, /* persist */ false);
|
||||
};
|
||||
// Capture phase so scrolls on nested scrollers (.hwfit-list,
|
||||
// .cookbook-body, .modal-content) all hit us.
|
||||
const _modal = dlFold.closest('#cookbook-modal') || document;
|
||||
const _body = _modal.querySelector?.('.cookbook-body');
|
||||
const _content = _modal.querySelector?.('.modal-content');
|
||||
const _lastY = new WeakMap();
|
||||
const _isPrimaryScroller = (tgt) => tgt === _body || tgt === _content || tgt === _modal;
|
||||
_modal.addEventListener('scroll', (e) => {
|
||||
const tgt = e.target;
|
||||
if (!tgt || typeof tgt.scrollTop !== 'number') return;
|
||||
@@ -2614,16 +2706,41 @@ function _wireTabEvents(body) {
|
||||
if (dlFoldBody.contains && (tgt === dlFoldBody || dlFoldBody.contains(tgt))) return;
|
||||
const y = tgt.scrollTop;
|
||||
const prev = _lastY.get(tgt) || 0;
|
||||
if (y > prev) _maybeFold();
|
||||
const isPrimaryScroller = _isPrimaryScroller(tgt);
|
||||
// Ignore small layout/bounce movements. Folding is a navigation aid, so
|
||||
// it should only react after the user has actually started scrolling.
|
||||
if (y > prev + 12 && isPrimaryScroller) _maybeFold();
|
||||
_lastY.set(tgt, y);
|
||||
}, true);
|
||||
// Collapsing the body can clamp the primary scroll host to 0 without
|
||||
// dispatching a second scroll event. Catch the user's upward gesture at
|
||||
// that boundary so a folded section still reopens as intended.
|
||||
const _mainScrollAtTop = () => [_body, _content].some((host) => (
|
||||
host && host.scrollHeight > host.clientHeight + 1 && host.scrollTop <= 1
|
||||
));
|
||||
_modal.addEventListener('wheel', (e) => {
|
||||
// Reopen only on a deliberate extra upward gesture while already at the
|
||||
// top; reaching scrollTop=0 alone is too sensitive on mobile/trackpads.
|
||||
if (e.deltaY < -8 && _mainScrollAtTop()) _maybeUnfoldAtTop();
|
||||
}, { capture: true, passive: true });
|
||||
let _touchStartY = null;
|
||||
_modal.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) _touchStartY = e.touches[0].clientY;
|
||||
}, { capture: true, passive: true });
|
||||
_modal.addEventListener('touchend', (e) => {
|
||||
const endY = e.changedTouches[0]?.clientY;
|
||||
const pulledDown = Number.isFinite(_touchStartY) && Number.isFinite(endY) && endY - _touchStartY > 24;
|
||||
_touchStartY = null;
|
||||
if (pulledDown && _mainScrollAtTop()) _maybeUnfoldAtTop();
|
||||
}, { capture: true, passive: true });
|
||||
}
|
||||
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
|
||||
const hfArrow = document.getElementById('cookbook-hf-latest-arrow');
|
||||
const hfList = document.getElementById('cookbook-hf-latest-list');
|
||||
const hfRefresh = document.getElementById('cookbook-hf-latest-refresh');
|
||||
if (hfToggle && hfList) {
|
||||
if (hfList) {
|
||||
let _loaded = false;
|
||||
const officialOnly = document.getElementById('cookbook-hf-official-only');
|
||||
if (officialOnly) {
|
||||
try { officialOnly.checked = localStorage.getItem('cookbook_hf_official_only_v1') === '1'; } catch {}
|
||||
}
|
||||
// Per-server VRAM cache so we don't re-probe on every expand
|
||||
const _hwCache = {};
|
||||
function _hfModelLooksAwqLike(m) {
|
||||
@@ -2654,7 +2771,7 @@ function _wireTabEvents(body) {
|
||||
if (host) qp.set('host', host);
|
||||
if (sshPort) qp.set('ssh_port', sshPort);
|
||||
if (platform) qp.set('platform', platform);
|
||||
const r = await fetch(`/api/hwfit/system?${qp}`);
|
||||
const r = await _fetchCookbookUiWithTimeout(`/api/hwfit/system?${qp}`, {}, 20000);
|
||||
if (r.ok) {
|
||||
const sys = await r.json();
|
||||
const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() };
|
||||
@@ -2666,6 +2783,10 @@ function _wireTabEvents(body) {
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
async function _loadLatest() {
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || 'general';
|
||||
const useCaseLabel = { general: 'Standard', multimodal: 'Vision', image_gen: 'Image' }[useCase] || 'Standard';
|
||||
const latestTitle = document.getElementById('cookbook-hf-latest-title');
|
||||
if (latestTitle) latestTitle.textContent = `Trending · ${useCaseLabel}`;
|
||||
// Match the Dependencies loader: whirlpool spinner + text label so the
|
||||
// user gets immediate feedback while the scan runs.
|
||||
hfList.innerHTML = '';
|
||||
@@ -2688,7 +2809,17 @@ function _wireTabEvents(body) {
|
||||
try {
|
||||
let lastErr = '';
|
||||
const _fetchLatest = async (v) => {
|
||||
const res = await fetch(`/api/cookbook/hf-latest?vram_gb=${v}&limit=10`);
|
||||
// Trending is intentionally broad; the other picker modes retain
|
||||
// their pipeline-specific HF filtering.
|
||||
const pipeline = useCase === 'image_gen'
|
||||
? 'text-to-image'
|
||||
: useCase === 'multimodal' ? 'image-text-to-text'
|
||||
: useCase === 'general' ? 'text-generation' : '';
|
||||
const params = new URLSearchParams({ vram_gb: String(v), limit: '20' });
|
||||
if (pipeline) params.set('pipeline', pipeline);
|
||||
if (officialOnly?.checked) params.set('official_only', 'true');
|
||||
const res = await _fetchCookbookUiWithTimeout(`/api/cookbook/hf-latest?${params}`, {}, 30000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (data.error) lastErr = data.error; // HF API timeout/rate-limit etc.
|
||||
return data.models || [];
|
||||
@@ -2716,14 +2847,19 @@ function _wireTabEvents(body) {
|
||||
if (m.downloads) meta.push(`${m.downloads.toLocaleString()} downloads`);
|
||||
const date = m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : '';
|
||||
if (date) meta.push(date);
|
||||
html += `<div class="doclib-card memory-item cookbook-hf-latest-card" data-repo="${esc(m.repo_id)}" style="cursor:pointer;">`;
|
||||
html += `<div style="flex:1;min-width:0;">`;
|
||||
html += `<div class="memory-item-title">${esc(shortName)} <a href="https://huggingface.co/${esc(m.repo_id)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF \u2197</a></div>`;
|
||||
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${meta.join(' \u00b7 ')}</div>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="hwfit-row cookbook-hf-latest-card" data-repo="${esc(m.repo_id)}" style="cursor:pointer;">`;
|
||||
html += `<span class="hwfit-col hwfit-fit" style="color:var(--accent,var(--red));">Trending</span>`;
|
||||
html += `<span class="hwfit-col hwfit-name">${esc(shortName)} <a href="https://huggingface.co/${esc(m.repo_id)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF \u2197</a></span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-vram">${m.needed_vram_gb ? `~${m.needed_vram_gb}G` : '?'}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-params">?</span><span class="hwfit-col hwfit-c-quant">?</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-ctx">—</span><span class="hwfit-col hwfit-c-speed">—</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-score">${m.downloads ? m.downloads.toLocaleString() : '?'}</span><span class="hwfit-col hwfit-c-mode">${esc(meta[0] || '')}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
hfList.innerHTML = html;
|
||||
hfList.classList.remove('cookbook-model-list-fade');
|
||||
void hfList.offsetWidth;
|
||||
hfList.classList.add('cookbook-model-list-fade');
|
||||
// Wire card clicks → fill download input
|
||||
hfList.querySelectorAll('.cookbook-hf-latest-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
@@ -2738,23 +2874,14 @@ function _wireTabEvents(body) {
|
||||
hfList.innerHTML = '<div class="hwfit-loading">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
hfToggle.addEventListener('click', () => {
|
||||
const isOpen = hfList.style.display !== 'none';
|
||||
hfList.style.display = isOpen ? 'none' : 'flex';
|
||||
if (hfArrow) hfArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
if (!isOpen && !_loaded) {
|
||||
officialOnly?.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('cookbook_hf_official_only_v1', e.target.checked ? '1' : '0'); } catch {}
|
||||
if (document.getElementById('hwfit-usecase')?.value === 'trending') {
|
||||
_loaded = true;
|
||||
_loadLatest();
|
||||
}
|
||||
});
|
||||
if (hfRefresh) hfRefresh.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_loaded = true;
|
||||
_loadLatest();
|
||||
// If list is hidden, open it
|
||||
if (hfList.style.display === 'none') {
|
||||
hfList.style.display = 'flex';
|
||||
if (hfArrow) hfArrow.style.transform = 'rotate(90deg)';
|
||||
} else {
|
||||
_hwfitFetch();
|
||||
}
|
||||
});
|
||||
// Re-fetch when a server dropdown changes — different server = different
|
||||
@@ -2767,6 +2894,15 @@ function _wireTabEvents(body) {
|
||||
};
|
||||
document.getElementById('hwfit-dl-server')?.addEventListener('change', _onServerChange);
|
||||
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange);
|
||||
document.getElementById('hwfit-usecase')?.addEventListener('change', () => {
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || 'general';
|
||||
const regularList = document.getElementById('hwfit-list');
|
||||
const isTrending = useCase === 'trending';
|
||||
if (regularList) regularList.style.display = isTrending ? 'none' : '';
|
||||
hfList.style.display = isTrending ? 'flex' : 'none';
|
||||
_loaded = false;
|
||||
if (isTrending) { _loaded = true; _loadLatest(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Browse Ollama library popup removed — Engine = Ollama in the
|
||||
@@ -2781,7 +2917,8 @@ function _wireTabEvents(body) {
|
||||
async function _loadOllama(refresh = false) {
|
||||
olList.innerHTML = '<div class="hwfit-loading" style="opacity:0.5;font-size:11px;text-align:center;padding:12px;">Loading…</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/cookbook/ollama/library${refresh ? '?refresh=1' : ''}`);
|
||||
const res = await _fetchCookbookUiWithTimeout(`/api/cookbook/ollama/library${refresh ? '?refresh=1' : ''}`, {}, 30000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (!models.length) {
|
||||
@@ -2894,8 +3031,8 @@ function _wireTabEvents(body) {
|
||||
// forceRemote renders an editable remote entry even before a host is typed
|
||||
// (a new server's host is empty, which would otherwise read as "Local").
|
||||
export function _serverDefaultHtml(active) {
|
||||
const check = active ? '<span class="hwfit-hf-check cookbook-srv-default-check" title="Default server" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">✓</span>' : '';
|
||||
return `${check}<span class="cookbook-srv-default-label">default</span>`;
|
||||
const icon = active ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF;
|
||||
return `<span class="cookbook-srv-default-icon" aria-hidden="true">${icon}</span><span class="cookbook-srv-default-label">default</span>`;
|
||||
}
|
||||
|
||||
export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
@@ -2937,7 +3074,7 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
html += `</span>`;
|
||||
html += `<div class="cookbook-server-row">`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-name" value="${esc(s.name || (isLocal ? 'Local' : ''))}" placeholder="Name (optional)" style="width:92px;flex-shrink:0;" />`;
|
||||
html += `<span class="cookbook-srv-color-wrap has-color" title="Server color"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span><span class="cookbook-srv-color-label">${esc(selectedColorLabel)}</span><svg class="cookbook-srv-color-caret" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button><div class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`;
|
||||
html += `<span class="cookbook-srv-color-wrap has-color" title="Change server color (currently ${esc(selectedColorLabel)})"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" title="Change server color (currently ${esc(selectedColorLabel)})" aria-label="Change server color (currently ${esc(selectedColorLabel)})" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span></button><div class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:184px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
|
||||
html += `<input type="text" class="hwfit-sf cookbook-srv-port" value="${esc(s.port || '')}" placeholder="Port" title="SSH port (default 22)" style="width:48px;flex-shrink:0;" ${isLocal ? 'readonly' : ''} />`;
|
||||
html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`;
|
||||
@@ -2958,14 +3095,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
html += `<span class="cookbook-modeldir-tag${isDefault ? ' cookbook-modeldir-default' : ''}${isTarget ? ' cookbook-modeldir-target' : ''}" data-dir-idx="${j}" data-dir="${esc(modelDirs[j])}">${dlBtn} ${esc(modelDirs[j])}${rmBtn}</span>`;
|
||||
}
|
||||
html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`;
|
||||
html += `</div>`;
|
||||
const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
|
||||
const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`;
|
||||
html += `<div class="cookbook-server-bottom-actions">`;
|
||||
if (isNew) {
|
||||
// A brand-new server: Save (confirm) sits where Delete would be; Cancel is
|
||||
// top-right in the title. Save confirms with a checkmark (auto-saves on edit too).
|
||||
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
} else if (!isLocal) {
|
||||
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
|
||||
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
|
||||
html += `<button class="cookbook-server-save-btn" title="Save server changes" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
@@ -2994,7 +3132,7 @@ function _renderRecipes() {
|
||||
|
||||
// Tabs
|
||||
html += '<div class="cookbook-tabs">';
|
||||
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="vertical-align:-1px;margin-right:3px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:3px;"><path d="M12 22c4.4 0 8-3.1 8-7.2 0-3.8-2.5-6.8-5.3-9.8.1 2.2-.5 3.8-1.8 5.2.1-3.5-1.2-6.1-3-8.2.1 4-5.9 7.4-5.9 12.8C4 18.9 7.6 22 12 22Z"></path></svg>Launch</button>';
|
||||
html += '<button class="cookbook-tab active" data-backend="Search"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="7 14 12 19 17 14"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="5" y1="21" x2="19" y2="21"/></svg>Download</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Dependencies"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Dependencies</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Settings"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>';
|
||||
@@ -3008,7 +3146,7 @@ function _renderRecipes() {
|
||||
// State persisted to localStorage so the fold survives reloads.
|
||||
const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">';
|
||||
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Direct Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
|
||||
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Direct Download<svg id="cookbook-dl-tab-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;transition:transform 0.15s;margin-left:8px;opacity:0.85;transform:rotate(${_dlTabFolded ? '-90deg' : '0deg'});"><polyline points="6 9 12 15 18 9"></polyline></svg></h2>`;
|
||||
html += '</div>';
|
||||
html += `<div id="cookbook-dl-tab-fold-body" class="${_dlTabFolded ? 'is-folded' : ''}">`;
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download from <a href="https://huggingface.co/models" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:1px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>HuggingFace</a> by pasting model link, or download directly in the Scan section below.</p>';
|
||||
@@ -3063,20 +3201,6 @@ function _renderRecipes() {
|
||||
// but that duplicated the Engine filter (which already has Ollama). The
|
||||
// standalone UI is gone — to find Ollama models, set Engine = Ollama in
|
||||
// the Scan / Download section below.
|
||||
// Latest HF models that fit — collapsible card list
|
||||
html += `<div style="margin-top:5px;position:relative;top:-11px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:28px;font-size:11px;display:flex;align-items:center;gap:6px;border-radius:5px;">`;
|
||||
// Trending-up icon (accent) so the section reads as "what's hot".
|
||||
html += `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;pointer-events:none;"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>`;
|
||||
html += `<span style="pointer-events:none;flex:1;">Trending models that fit your hardware</span>`;
|
||||
// Chevron moved to the RIGHT \u2014 collapsed = pointing right, expanded
|
||||
// = rotated 90deg into a down chevron (handled by existing toggle CSS).
|
||||
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;opacity:0.6;font-size:11px;">\u25B8</span>`;
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;overscroll-behavior:contain;flex-direction:column;gap:4px;"></div>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
|
||||
|
||||
// Search section
|
||||
@@ -3100,7 +3224,8 @@ function _renderRecipes() {
|
||||
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="display:none;height:28px;">';
|
||||
html += '<option value="general" selected>Standard</option>';
|
||||
html += '<option value="multimodal">Vision</option>';
|
||||
html += '<option value="image_gen">Image</option></select>';
|
||||
html += '<option value="image_gen">Image</option>';
|
||||
html += '<option value="trending">Trending</option></select>';
|
||||
html += '<button type="button" class="cookbook-field-input hwfit-usecase-btn" data-hwfit-usecase-btn aria-haspopup="listbox" aria-expanded="false" title="Model type">';
|
||||
html += '<span class="hwfit-usecase-btn-icon" data-hwfit-usecase-icon aria-hidden="true"></span>';
|
||||
html += '<span class="hwfit-usecase-btn-label" data-hwfit-usecase-label>Standard</span>';
|
||||
@@ -3109,9 +3234,10 @@ function _renderRecipes() {
|
||||
html += '<div class="hwfit-usecase-menu" data-hwfit-usecase-menu role="listbox" hidden></div>';
|
||||
html += '</span>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg><span>EDIT</span></button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-advanced-btn" id="hwfit-advanced-btn" title="Scan settings" aria-label="Scan settings" aria-expanded="false" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg></button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-5px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
html += '<label class="cookbook-official-filter" title="Show only models from recognized first-party provider namespaces" style="margin-left:auto;"><span>Official only</span><span class="toggle"><input type="checkbox" id="cookbook-hf-official-only" aria-label="Show official models only" /><span class="slider"></span></span></label>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
@@ -3164,11 +3290,13 @@ function _renderRecipes() {
|
||||
html += '<label>VRAM per GPU<input class="hwfit-manual-vram" type="text" inputmode="decimal" placeholder="8 GB"></label>';
|
||||
html += '<label>Total RAM<input class="hwfit-manual-ram" type="text" inputmode="decimal" placeholder="32 GB"></label>';
|
||||
html += '<select class="hwfit-manual-backend"><option value="cuda">CUDA</option><option value="rocm">ROCm</option></select>';
|
||||
html += '<button type="button" class="hwfit-hw-manual-save">✓ Apply</button>';
|
||||
html += '<button type="button" class="hwfit-hw-manual-clear">× Clear</button>';
|
||||
html += '<button type="button" class="hwfit-hw-manual-save"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg><span>Apply</span></button>';
|
||||
html += '<button type="button" class="hwfit-hw-manual-clear"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Clear</span></button>';
|
||||
html += '</div>';
|
||||
html += '<div id="hwfit-hw-row" style="display:none;align-items:center;gap:4px;margin-top:3px;padding-top:2px;"><span style="font-size:10px;padding:2px 8px;border-radius:10px;background:color-mix(in srgb, var(--fg) 8%, transparent);color:var(--fg);opacity:0.7;white-space:nowrap;flex-shrink:0;position:relative;top:-1px;">Detected hardware</span><div class="hwfit-hw" id="hwfit-hw" style="flex:1;"></div></div>';
|
||||
html += '<div class="hwfit-list" id="hwfit-list"></div>';
|
||||
// Trending reuses this result area when selected in the model-type picker.
|
||||
html += '<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;flex-direction:column;gap:4px;"></div>';
|
||||
// Footer: link to the public discussion where users can request additions
|
||||
// to the curated model list. Sits below the list so it reads as a callout
|
||||
// after browsing, not a header.
|
||||
@@ -3186,7 +3314,7 @@ function _renderRecipes() {
|
||||
html += '<div class="cookbook-group hidden" data-backend-group="Serve">';
|
||||
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-2px;margin-right:6px;color:var(--accent, var(--red));"><path d="M12 22c4.4 0 8-3.1 8-7.2 0-3.8-2.5-6.8-5.3-9.8.1 2.2-.5 3.8-1.8 5.2.1-3.5-1.2-6.1-3-8.2.1 4-5.9 7.4-5.9 12.8C4 18.9 7.6 22 12 22Z"></path></svg>Launch <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
|
||||
html += '</div>';
|
||||
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
|
||||
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
|
||||
@@ -3196,9 +3324,10 @@ function _renderRecipes() {
|
||||
html += '</div>';
|
||||
html += '<div style="display:flex;gap:4px;align-items:center;margin-top:4px;">';
|
||||
html += '<select class="memory-sort-select" id="hwfit-cache-server" style="height:24px;">' + _buildServerOpts(true) + '</select>';
|
||||
html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">';
|
||||
html += '<span class="cookbook-sort-select-wrap" title="Sort cached models"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="14" y2="12"></line><line x1="4" y1="18" x2="9" y2="18"></line></svg>';
|
||||
html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;padding-left:24px;">';
|
||||
html += '<option value="name">Name</option><option value="size-desc">Size \u2193</option><option value="size-asc">Size \u2191</option><option value="recent">Recent</option>';
|
||||
html += '</select>';
|
||||
html += '</select></span>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-cache-scan" title="Refresh cached models on selected server" aria-label="Refresh cached models on selected server"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
html += '</div>';
|
||||
html += '<div class="memory-toolbar" style="margin-top:8px;">';
|
||||
@@ -3281,6 +3410,17 @@ function _renderRecipes() {
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
// Server rows are rebuilt on every render; release picker listeners before
|
||||
// replacing them so detached rows do not stay reachable through `document`.
|
||||
body.querySelectorAll('.cookbook-server-entry').forEach(entry => {
|
||||
entry._cleanupColorPicker?.();
|
||||
});
|
||||
body.querySelectorAll('.hwfit-usecase-wrap').forEach(wrap => {
|
||||
wrap._cleanupUsecasePicker?.();
|
||||
});
|
||||
body.querySelectorAll('.hwfit-engine-wrap').forEach(wrap => {
|
||||
wrap._cleanupEnginePicker?.();
|
||||
});
|
||||
body.innerHTML = html;
|
||||
_wireTabEvents(body);
|
||||
|
||||
@@ -3296,22 +3436,41 @@ import * as Modals from './modalManager.js';
|
||||
let _rendered = false;
|
||||
|
||||
let _closeGen = 0;
|
||||
let _cookbookOpenGeneration = 0;
|
||||
|
||||
// ESC while a Serve card is expanded should collapse just that card, not
|
||||
// close the whole Cookbook modal. Capture-phase so we run before the
|
||||
// modal manager's global ESC-to-close handler and can stop it.
|
||||
if (typeof window !== 'undefined' && !window._cookbookServeEscBound) {
|
||||
window._cookbookServeEscBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
// Serve bulk selection owns Escape while active. Exit selection first;
|
||||
// only a second Escape should close the Cookbook modal.
|
||||
const selectBtn = modal.querySelector('#hwfit-cache-select');
|
||||
if (selectBtn?.classList.contains('active')) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
selectBtn.classList.remove('active');
|
||||
selectBtn.textContent = 'Select';
|
||||
modal.querySelector('#serve-bulk-bar')?.classList.add('hidden');
|
||||
modal.querySelectorAll('.serve-select-cb').forEach((dot) => {
|
||||
dot.style.display = 'none';
|
||||
dot.classList.remove('selected');
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Layer 1: a model row in the scan/download list is highlighted —
|
||||
// deselect it before doing anything else.
|
||||
const activeRow = modal.querySelector('.hwfit-row-active');
|
||||
if (activeRow) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
const activePanel = activeRow.parentElement?.querySelector('.hwfit-action-panel');
|
||||
activePanel?._cleanupServePanel?.();
|
||||
activePanel?.remove();
|
||||
activeRow.classList.remove('hwfit-row-active');
|
||||
return;
|
||||
}
|
||||
@@ -3320,7 +3479,9 @@ if (typeof window !== 'undefined' && !window._cookbookServeEscBound) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
// Collapse the card (mirror the toggle-close path in cookbookServe.js).
|
||||
expanded.querySelector('.hwfit-serve-panel')?.remove();
|
||||
const expandedPanel = expanded.querySelector('.hwfit-serve-panel');
|
||||
expandedPanel?._cleanupServePanel?.();
|
||||
expandedPanel?.remove();
|
||||
expanded.classList.remove('doclib-card-expanded');
|
||||
expanded.style.flexDirection = '';
|
||||
expanded.style.alignItems = '';
|
||||
@@ -3332,6 +3493,7 @@ if (typeof window !== 'undefined' && !window._cookbookServeEscBound) {
|
||||
export async function open(opts) {
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
if (!modal) return;
|
||||
_dependenciesModelHint = String(opts?.dependencyModel || '').trim();
|
||||
// Run any post-open intent (switch tab, prefill search, etc) after the
|
||||
// current render pass so the target elements exist.
|
||||
const _applyIntent = () => {
|
||||
@@ -3348,6 +3510,20 @@ export async function open(opts) {
|
||||
const s = document.getElementById('serve-search');
|
||||
if (s) { s.value = opts.serveSearch; s.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
}
|
||||
const focusSelector = opts.focusSession
|
||||
? `[data-session-id="${CSS.escape(String(opts.focusSession))}"]`
|
||||
: opts.focusRepo
|
||||
? `[data-repo="${CSS.escape(String(opts.focusRepo))}"]`
|
||||
: '';
|
||||
if (focusSelector) {
|
||||
setTimeout(() => {
|
||||
const row = modal.querySelector(focusSelector);
|
||||
if (!row) return;
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
row.classList.add('cookbook-chat-target');
|
||||
setTimeout(() => row.classList.remove('cookbook-chat-target'), 1800);
|
||||
}, 80);
|
||||
}
|
||||
};
|
||||
// If minimized, restore in place — preserve all state
|
||||
if (Modals.isMinimized('cookbook-modal')) {
|
||||
@@ -3363,6 +3539,7 @@ export async function open(opts) {
|
||||
}
|
||||
_setCookbookOpening(true);
|
||||
try {
|
||||
const _openGeneration = ++_cookbookOpenGeneration;
|
||||
// Invalidate any pending close() animation handlers so they won't re-hide us
|
||||
_closeGen++;
|
||||
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
|
||||
@@ -3382,47 +3559,45 @@ export async function open(opts) {
|
||||
restoreFn: () => { _renderRunningTab(); },
|
||||
});
|
||||
_wireCookbookDrag(modal);
|
||||
await _syncFromServer();
|
||||
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
|
||||
// (a different object reference than this module's), then mirrors the merged
|
||||
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
|
||||
// on a successful sync it holds the freshly-fetched servers; on failure it
|
||||
// holds the last-known state. Gating this on `!synced` left the render's
|
||||
// _envState empty whenever sync succeeded → "servers don't show".
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
|
||||
// Honour a user-set default server: always land on it when Cookbook opens, so
|
||||
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = (_envState.servers || []).find(s => s.host === _dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
const _hydrateLocalCookbookState = () => {
|
||||
// _syncFromServer() owns a separate state object and mirrors it to
|
||||
// localStorage. Hydrating from that mirror keeps the first paint usable
|
||||
// even when the network request is still pending.
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = (_envState.servers || []).find(s => s.host === _dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
|
||||
// HF token, presets) is always reflected. Gating this to once-per-page used
|
||||
// to freeze a stale/empty servers list whenever the first sync raced or
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
// Guard the render passes: a single broken task card must not throw out of
|
||||
// open() and leave the modal stuck hidden (it has no catch, so the panel
|
||||
// would silently never appear). Show the window regardless; log and move on.
|
||||
try { _renderRecipes(); } catch (e) { console.error('[cookbook] renderRecipes failed', e); }
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
try { _renderRunningTab(); } catch (e) { console.error('[cookbook] renderRunningTab failed', e); }
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
|
||||
};
|
||||
const _renderCookbookShell = () => {
|
||||
try { _renderRecipes(); } catch (e) { console.error('[cookbook] renderRecipes failed', e); }
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
try { _renderRunningTab(); } catch (e) { console.error('[cookbook] renderRunningTab failed', e); }
|
||||
};
|
||||
const _cookbookStateSignature = () => {
|
||||
let presets = '', serveState = '', favorites = '';
|
||||
try {
|
||||
presets = localStorage.getItem(STORAGE_KEY) || '';
|
||||
serveState = localStorage.getItem(SERVE_STATE_KEY) || '';
|
||||
favorites = localStorage.getItem(SERVE_FAVORITES_KEY) || '';
|
||||
} catch {}
|
||||
return JSON.stringify([_envStateForStorage(), presets, serveState, favorites]);
|
||||
};
|
||||
|
||||
// Paint immediately from the last known state. The authoritative state sync
|
||||
// continues below and reconciles the shell after it completes.
|
||||
_hydrateLocalCookbookState();
|
||||
_renderCookbookShell();
|
||||
const _initialStateSignature = _cookbookStateSignature();
|
||||
const _stateSync = _syncFromServer();
|
||||
|
||||
if (_content) {
|
||||
// Put the panel in its entering state before it becomes visible. On
|
||||
// mobile, showing first and adding the class a frame later can paint the
|
||||
// sheet at its final position, which makes the slide-up look like a snap.
|
||||
_content.classList.add('cookbook-modal-entering');
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
@@ -3433,6 +3608,34 @@ export async function open(opts) {
|
||||
}, { once: true });
|
||||
}
|
||||
setTimeout(_applyIntent, 0);
|
||||
|
||||
// Reconcile once the server responds, but never replace a form while the
|
||||
// user is actively typing in it. Running/Serve have their own state-sync
|
||||
// listeners; the full shell refresh handles server/settings changes in the
|
||||
// other tabs when no field is being edited.
|
||||
_stateSync.then((synced) => {
|
||||
if (!synced || modal.classList.contains('hidden') || _openGeneration !== _cookbookOpenGeneration) return;
|
||||
const activeTab = modal.querySelector('.cookbook-tab.active')?.dataset?.backend || '';
|
||||
const active = document.activeElement;
|
||||
if (active?.closest?.('.cookbook-settings-stack, input, textarea, select, [contenteditable="true"]')) return;
|
||||
_hydrateLocalCookbookState();
|
||||
if (_cookbookStateSignature() === _initialStateSignature) return;
|
||||
// _syncFromServer dispatches cookbook:state-synced before its promise
|
||||
// resolves; that listener already performs the targeted Running/Serve
|
||||
// refresh. Avoid rendering those tabs a second time here.
|
||||
if (activeTab === 'Running' || activeTab === 'Serve') return;
|
||||
_renderCookbookShell();
|
||||
const tab = modal.querySelector(`.cookbook-tab[data-backend="${CSS.escape(activeTab)}"]`);
|
||||
if (tab && !tab.classList.contains('active')) tab.click();
|
||||
setTimeout(_applyIntent, 0);
|
||||
}).catch((e) => console.warn('[cookbook] background state sync failed', e));
|
||||
|
||||
_rendered = true;
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
|
||||
} finally {
|
||||
_setCookbookOpening(false);
|
||||
}
|
||||
@@ -3460,6 +3663,11 @@ function _wireCookbookDrag(modal) {
|
||||
}
|
||||
|
||||
function _doClose() {
|
||||
_dependenciesFetchId++;
|
||||
_dependenciesRequestController?.abort();
|
||||
_dependenciesRequestController = null;
|
||||
_cancelHwfitRequests();
|
||||
_cancelCachedModelScan();
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
if (!modal) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// panel rendering, command building
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
@@ -32,6 +32,13 @@ let _saveTasks;
|
||||
|
||||
// Storage keys
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const _downloadStartsInFlight = new Set();
|
||||
|
||||
function _fetchDownloadControlWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// ── Panel field helpers ──
|
||||
|
||||
@@ -279,7 +286,7 @@ export function _wirePanelEvents(panel, model, backend) {
|
||||
const outputText = panel.querySelector('.cookbook-output-pre')?.textContent || '';
|
||||
const tmuxMatch = outputText.match(/Started tmux session: (cookbook-[a-f0-9]+)/);
|
||||
if (tmuxMatch) {
|
||||
fetch('/api/shell/exec', {
|
||||
_fetchDownloadControlWithTimeout('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -585,7 +592,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
const _sshSf = _zh ? `'` : '';
|
||||
const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : '';
|
||||
const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
|
||||
const _r = await fetch('/api/shell/exec', {
|
||||
const _r = await _fetchDownloadControlWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
|
||||
@@ -618,8 +625,17 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The task list is persisted only after the POST returns. Guard the gap so
|
||||
// rapid clicks (or two touch events) cannot start duplicate downloads before
|
||||
// either request has registered its task.
|
||||
const startKey = `${targetHost}\n${payload.repo_id}`;
|
||||
if (_downloadStartsInFlight.has(startKey)) {
|
||||
uiModule.showToast(`${shortName} download is already starting`);
|
||||
return;
|
||||
}
|
||||
_downloadStartsInFlight.add(startKey);
|
||||
try {
|
||||
const res = await fetch('/api/model/download', {
|
||||
const res = await _fetchDownloadControlWithTimeout('/api/model/download', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -640,6 +656,8 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
uiModule.showToast(`Downloading ${taskName}...`);
|
||||
} catch (e) {
|
||||
uiModule.showToast('Download failed: ' + e.message, 9000);
|
||||
} finally {
|
||||
_downloadStartsInFlight.delete(startKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// stop/restart, diagnosis, auto-fix, background monitor
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
@@ -14,9 +14,13 @@ import { topPortalZ } from './toolWindowZOrder.js';
|
||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||
// the word "error" in the sidebar — a server the user stopped or one that
|
||||
// quit cleanly reads as "stopped", not "error".
|
||||
function _isFinishedDownload(status, type) {
|
||||
return type === 'download' && (status === 'done' || status === 'completed');
|
||||
}
|
||||
|
||||
function _statusLabel(status, type) {
|
||||
if (status === 'running' && type === 'download') return 'downloading';
|
||||
if (status === 'done' && type === 'download') return 'finished';
|
||||
if (_isFinishedDownload(status, type)) return 'finished';
|
||||
if (status === 'error') return 'stopped';
|
||||
return status || '';
|
||||
}
|
||||
@@ -529,14 +533,14 @@ function _nextAvailablePort() {
|
||||
|
||||
async function _removeEndpointByUrl(baseUrl) {
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
const res = await _fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
if (!res.ok) return;
|
||||
const endpoints = await res.json();
|
||||
const hostPort = baseUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
||||
const ep = endpoints.find(e => e.base_url === baseUrl)
|
||||
|| endpoints.find(e => e.base_url.includes(hostPort));
|
||||
if (ep) {
|
||||
await fetch(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
await _fetchWithTimeout(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
_refreshModelsAfterEndpointChange();
|
||||
}
|
||||
} catch {}
|
||||
@@ -696,7 +700,7 @@ async function _startQueuedDownload(task) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/model/download', {
|
||||
const res = await _fetchWithTimeout('/api/model/download', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(task.payload),
|
||||
@@ -1190,6 +1194,32 @@ function _startWaveSync() {
|
||||
|
||||
function _registerWaveEl(el) { _waveEls.add(el); _startWaveSync(); }
|
||||
|
||||
// Running cards are rebuilt when status changes, server groups change, or a
|
||||
// task is cleared. Dispose card-owned work before removing those DOM nodes so
|
||||
// re-renders cannot leave orphaned uptime timers, tmux streams, or wave nodes
|
||||
// running in the background.
|
||||
function _disposeTaskCard(el) {
|
||||
if (!el) return;
|
||||
if (el._abort) {
|
||||
try { el._abort.abort(); } catch {}
|
||||
el._abort = null;
|
||||
}
|
||||
if (el._uptimeInterval) {
|
||||
clearInterval(el._uptimeInterval);
|
||||
el._uptimeInterval = null;
|
||||
}
|
||||
if (el._longPressTimer) {
|
||||
clearTimeout(el._longPressTimer);
|
||||
el._longPressTimer = null;
|
||||
}
|
||||
const wave = el.querySelector?.('.cookbook-task-wave');
|
||||
if (wave) _waveEls.delete(wave);
|
||||
if (!_waveEls.size && _waveTimer) {
|
||||
clearInterval(_waveTimer);
|
||||
_waveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications ──
|
||||
|
||||
function _showCookbookNotif(isError = false) {
|
||||
@@ -1348,11 +1378,11 @@ function _syncToServer() {
|
||||
const favorites = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]');
|
||||
state.serveFavorites = Array.isArray(favorites) ? favorites.filter(Boolean).map(String) : [];
|
||||
} catch {}
|
||||
await fetch('/api/cookbook/state', {
|
||||
await _fetchWithTimeout('/api/cookbook/state', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_stripStateSecrets(state)),
|
||||
});
|
||||
}, 15000);
|
||||
} catch {}
|
||||
}, 400);
|
||||
}
|
||||
@@ -1386,9 +1416,11 @@ function _normalizeState(state) {
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function _syncFromServer() {
|
||||
let _syncFromServerInFlight = null;
|
||||
|
||||
async function _syncFromServerImpl() {
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/state', { credentials: 'same-origin' });
|
||||
const res = await _fetchWithTimeout('/api/cookbook/state', { credentials: 'same-origin' }, 15000);
|
||||
if (!res.ok) return false;
|
||||
const state = _normalizeState(await res.json());
|
||||
if (!state || !state.env) return false;
|
||||
@@ -1450,6 +1482,20 @@ export async function _syncFromServer() {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Boot, modal-open, focus, and visibility refreshes can converge on the same
|
||||
// state endpoint. Share the active request so two callers cannot race with
|
||||
// different snapshots or trigger duplicate state-sync renders.
|
||||
export function _syncFromServer() {
|
||||
if (_syncFromServerInFlight) return _syncFromServerInFlight;
|
||||
const pending = _syncFromServerImpl();
|
||||
const clearPending = () => {
|
||||
if (_syncFromServerInFlight === pending) _syncFromServerInFlight = null;
|
||||
};
|
||||
pending.then(clearPending, clearPending);
|
||||
_syncFromServerInFlight = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ── Retry download ──
|
||||
|
||||
// Bounded auto-retry counter for downloads, keyed by model — network blips on
|
||||
@@ -1464,7 +1510,7 @@ async function _retryTask(el, task) {
|
||||
const badge = el?.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = 'restarting...'; badge.className = 'cookbook-task-status'; }
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
@@ -1492,7 +1538,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') {
|
||||
// the plain, reliable downloader for this and any further attempt (it resumes
|
||||
// from the cached .incomplete files, so no progress is lost).
|
||||
const _payload = { ...(payload || {}), disable_hf_transfer: true };
|
||||
const res = await fetch('/api/model/download', {
|
||||
const res = await _fetchWithTimeout('/api/model/download', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_payload),
|
||||
@@ -1566,7 +1612,7 @@ export async function _serveAutoFix(panel, envVar) {
|
||||
|
||||
const killCmd = _tmuxCmd(task, `kill-session -t ${taskId}`);
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: killCmd }),
|
||||
@@ -1629,7 +1675,7 @@ export async function _serveAutoRetryReplace(panel, flag, value) {
|
||||
if (!_guardServeRetry(panel, taskEl)) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${taskId}`) }),
|
||||
@@ -1671,7 +1717,7 @@ export async function _serveAutoRetryRemove(panel, flag) {
|
||||
if (!_guardServeRetry(panel, taskEl)) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${taskId}`) }),
|
||||
@@ -1704,7 +1750,7 @@ export async function _serveAutoRetry(panel, flag) {
|
||||
if (!_guardServeRetry(panel, taskEl)) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${taskId}`) }),
|
||||
@@ -1886,7 +1932,7 @@ async function _confirmGpuPreflight(reqBody, shortName, repo, cmd) {
|
||||
if (reqBody.remote_host) params.set('host', reqBody.remote_host);
|
||||
if (reqBody.ssh_port) params.set('ssh_port', reqBody.ssh_port);
|
||||
try {
|
||||
const res = await fetch(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
const res = await _fetchWithTimeout(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
@@ -1927,7 +1973,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
try {
|
||||
const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
|
||||
if (_old && _old.type === 'serve') {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(_old) }),
|
||||
@@ -1949,7 +1995,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
const _tm = _t.payload._cmd.match(/--port[=\s]+(\d+)/) || _t.payload._cmd.match(/(?:^|\s)-p[=\s]+(\d+)/);
|
||||
if ((_tm ? _tm[1] : '') === _newPort && (_t.remoteHost || '') === _host) {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(_t) }),
|
||||
@@ -2002,7 +2048,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
uiModule.showToast('Launch cancelled — GPU is already in use');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/model/serve', {
|
||||
const res = await _fetchWithTimeout('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqBody),
|
||||
@@ -2081,6 +2127,11 @@ export function _renderRunningTab() {
|
||||
if (sb.style.display === 'none' && sb.id) _collapsedSectionIds.add(sb.id);
|
||||
});
|
||||
|
||||
// The section bodies below are rebuilt on every render. Stop all work owned
|
||||
// by their existing cards before removing them; otherwise each refresh adds
|
||||
// another uptime interval and reconnect loop for the same task.
|
||||
body.querySelectorAll('.cookbook-task').forEach(_disposeTaskCard);
|
||||
|
||||
const tasks = _loadTasks();
|
||||
const hasContent = tasks.length > 0;
|
||||
// Count anything that's really active: explicit 'running'/'queued' status,
|
||||
@@ -2260,8 +2311,7 @@ export function _renderRunningTab() {
|
||||
toRemove.forEach(t => {
|
||||
const el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
if (el) {
|
||||
if (el._abort) el._abort.abort();
|
||||
if (el._uptimeInterval) clearInterval(el._uptimeInterval);
|
||||
_disposeTaskCard(el);
|
||||
el.style.transition = 'opacity 0.35s ease, transform 0.35s ease';
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateX(-10px)';
|
||||
@@ -2269,7 +2319,13 @@ export function _renderRunningTab() {
|
||||
});
|
||||
// After the animation, remove the cards and tidy up the now-empty section.
|
||||
setTimeout(() => {
|
||||
toRemove.forEach(t => document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`)?.remove());
|
||||
toRemove.forEach(t => {
|
||||
const el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
if (el) {
|
||||
_disposeTaskCard(el);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
// If this server's section is now empty (only finished tasks lived here),
|
||||
// remove the whole section so its header/title doesn't linger.
|
||||
const _sk = (host || 'local').replace(/[^a-zA-Z0-9-]/g, '_');
|
||||
@@ -2330,14 +2386,13 @@ export function _renderRunningTab() {
|
||||
const task = tasks.find(t => t.sessionId === id);
|
||||
if (task) {
|
||||
el.dataset.status = task.status;
|
||||
const isDone = task.status === 'done';
|
||||
// Type chip doubles as the "finished" badge once a task completes — both
|
||||
// download and serve show the same green FINISHED chip.
|
||||
const typeChip = el.querySelector('.cookbook-task-type');
|
||||
if (typeChip) {
|
||||
// Only DOWNLOAD tasks flip to "finished" when done — serve tasks keep
|
||||
// saying "serve" because the model is still running on that port.
|
||||
const isDoneDl = isDone && task.type === 'download';
|
||||
const isDoneDl = _isFinishedDownload(task.status, task.type);
|
||||
typeChip.textContent = isDoneDl ? 'finished' : task.type;
|
||||
typeChip.classList.toggle('cookbook-task-type-done', isDoneDl);
|
||||
}
|
||||
@@ -2379,7 +2434,7 @@ export function _renderRunningTab() {
|
||||
}
|
||||
}
|
||||
if (!task) {
|
||||
if (el._uptimeInterval) { clearInterval(el._uptimeInterval); el._uptimeInterval = null; }
|
||||
_disposeTaskCard(el);
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
@@ -2400,7 +2455,7 @@ export function _renderRunningTab() {
|
||||
const logoName = task.type === 'download' ? (task.payload?.repo_id || task.name) : task.name;
|
||||
el.innerHTML = `
|
||||
<div class="cookbook-task-header">
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-type${_isFinishedDownload(task.status, task.type) ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc(_isFinishedDownload(task.status, task.type) ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(logoName)}${esc(displayName)}</span>
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span>${_canLaunchDownloadedTask(task) ? '<button type="button" class="cookbook-task-serve-btn" title="Open in Launch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Launch</span></button>' : ''}<span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
|
||||
@@ -2521,7 +2576,7 @@ export function _renderRunningTab() {
|
||||
// Otherwise: real clear. Kill the tmux session as belt-and-suspenders,
|
||||
// then animate out + remove the row.
|
||||
try {
|
||||
fetch('/api/shell/exec', {
|
||||
_fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
|
||||
@@ -2568,20 +2623,21 @@ export function _renderRunningTab() {
|
||||
if (menuBtn) {
|
||||
// Long-press detection on the card: ~500ms hold without scroll movement
|
||||
// re-uses the menu button's click path (so we don't duplicate logic).
|
||||
let _lpTimer = null;
|
||||
let _lpStartY = 0;
|
||||
let _lpCanceled = false;
|
||||
const _lpStart = (e) => {
|
||||
_lpCanceled = false;
|
||||
_lpStartY = (e.touches?.[0]?.clientY) ?? 0;
|
||||
_lpTimer = setTimeout(() => {
|
||||
if (el._longPressTimer) clearTimeout(el._longPressTimer);
|
||||
el._longPressTimer = setTimeout(() => {
|
||||
el._longPressTimer = null;
|
||||
if (_lpCanceled) return;
|
||||
_lpCanceled = true; // suppress the subsequent click-through
|
||||
try { menuBtn.click(); } catch {}
|
||||
}, 500);
|
||||
};
|
||||
const _lpCancel = () => {
|
||||
if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; }
|
||||
if (el._longPressTimer) { clearTimeout(el._longPressTimer); el._longPressTimer = null; }
|
||||
};
|
||||
const _lpMove = (e) => {
|
||||
const y = (e.touches?.[0]?.clientY) ?? 0;
|
||||
@@ -2654,7 +2710,7 @@ export function _renderRunningTab() {
|
||||
const baseUrl = `http://${host}:${port}/v1`;
|
||||
try {
|
||||
// Check existing first — offer to overwrite if present
|
||||
const eps = await (await fetch('/api/model-endpoints', { credentials: 'same-origin' })).json();
|
||||
const eps = await (await _fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' })).json();
|
||||
const existing = eps.find(e => e.base_url === baseUrl);
|
||||
if (existing) {
|
||||
uiModule.showToast(`Already registered as "${existing.name}"`);
|
||||
@@ -2673,7 +2729,7 @@ export function _renderRunningTab() {
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
if (_isImageServeTask(task)) fd.append('model_type', 'image');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
const res = await _fetchWithTimeout('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (res.ok) {
|
||||
task._endpointAdded = true;
|
||||
_updateTask(task.sessionId, { _endpointAdded: true });
|
||||
@@ -2885,7 +2941,7 @@ export function _renderRunningTab() {
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
@@ -2894,7 +2950,7 @@ export function _renderRunningTab() {
|
||||
}
|
||||
// Gracefully stop (C-c, then kill the session) so it's fully down...
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
@@ -2916,7 +2972,7 @@ export function _renderRunningTab() {
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
@@ -2925,7 +2981,7 @@ export function _renderRunningTab() {
|
||||
}
|
||||
let killOk = true;
|
||||
try {
|
||||
const r = await fetch('/api/shell/exec', {
|
||||
const r = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
@@ -2936,7 +2992,7 @@ export function _renderRunningTab() {
|
||||
// there was nothing to kill. Verify the session is actually gone.
|
||||
if (task.sessionId && isLive) {
|
||||
try {
|
||||
const probe = await fetch('/api/shell/exec', {
|
||||
const probe = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`) }),
|
||||
@@ -2961,11 +3017,11 @@ export function _renderRunningTab() {
|
||||
_removeEndpointByUrl(endpointUrl);
|
||||
const modelName = task.payload.model || task.name || '';
|
||||
if (modelName) {
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
_fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(eps => {
|
||||
const ep = eps.find(e => e.name === modelName || e.base_url === endpointUrl);
|
||||
if (ep) fetch(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' }).then(() => _refreshModelsAfterEndpointChange());
|
||||
if (ep) _fetchWithTimeout(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' }).then(() => _refreshModelsAfterEndpointChange());
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -3055,7 +3111,7 @@ async function _reconnectTask(el, task) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `capture-pane -t ${task.sessionId} -p -S -500`), timeout: 15 }),
|
||||
@@ -3069,7 +3125,7 @@ async function _reconnectTask(el, task) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const verify = await fetch('/api/shell/exec', {
|
||||
const verify = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`) }),
|
||||
@@ -3268,7 +3324,7 @@ async function _reconnectTask(el, task) {
|
||||
if (!fresh) return;
|
||||
let stillAlive = false;
|
||||
try {
|
||||
const probe = await fetch('/api/shell/exec', {
|
||||
const probe = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`), timeout: 5 }),
|
||||
@@ -3386,7 +3442,7 @@ async function _reconnectTask(el, task) {
|
||||
badge.className = 'cookbook-task-status cookbook-task-error';
|
||||
_showCookbookNotif(true);
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
|
||||
@@ -3404,7 +3460,7 @@ async function _reconnectTask(el, task) {
|
||||
// Don't overwrite env_prefix — task.payload already has the correct
|
||||
// "source <path>" form. The bare envPath would miss the `source` and
|
||||
// the venv never activates (so hf CLI falls off PATH).
|
||||
const res = await fetch('/api/model/download', {
|
||||
const res = await _fetchWithTimeout('/api/model/download', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dlPayload),
|
||||
@@ -3501,7 +3557,7 @@ async function _reconnectTask(el, task) {
|
||||
uiModule.showToast(`Download interrupted — retrying (${_dlN + 1}/${_DL_MAX_AUTO_RETRY}), resumes where it stopped…`, 6000);
|
||||
const _p = task.payload, _nm = task.name;
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
|
||||
@@ -3543,7 +3599,7 @@ async function _reconnectTask(el, task) {
|
||||
const _sb2 = el.querySelector('.cookbook-task-serve-btn'); if (_sb2) _sb2.style.display = '';
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
fetch('/api/shell/exec', {
|
||||
_fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
|
||||
@@ -3627,7 +3683,7 @@ async function _reconnectTask(el, task) {
|
||||
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||
}
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
_fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(async (eps) => {
|
||||
// Match only exact base_url — don't dedup by friendly name,
|
||||
@@ -3660,7 +3716,7 @@ async function _reconnectTask(el, task) {
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
_appendPinnedServeModel(fd, task);
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
return _fetchWithTimeout('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res && res.ok) {
|
||||
@@ -3809,6 +3865,16 @@ function _canBackgroundPoll() {
|
||||
return _claimBackgroundLeader();
|
||||
}
|
||||
|
||||
// Remote status and endpoint probes can hang while a host is waking up or
|
||||
// unreachable. Bound each background request so one socket cannot hold the
|
||||
// monitor's in-flight lock forever.
|
||||
function _fetchWithTimeout(input, init = {}, timeoutMs = 15000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(input, { ...init, signal: controller.signal })
|
||||
.finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Reachability check for running serve tasks. The tmux pane can stay alive
|
||||
// while the model server inside it has crashed (so no "Process exited" line
|
||||
// ever appears) — leaving the card showing "running" forever. So we actively
|
||||
@@ -3839,8 +3905,8 @@ async function _checkServeReachability() {
|
||||
let eps = [], probe = {};
|
||||
try {
|
||||
[eps, probe] = await Promise.all([
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []),
|
||||
fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).then(r => r.json()).catch(() => ({})),
|
||||
_fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []),
|
||||
_fetchWithTimeout('/api/model-endpoints/probe-local', { credentials: 'same-origin' }, 20000).then(r => r.json()).catch(() => ({})),
|
||||
]);
|
||||
for (const task of serveTasks) {
|
||||
const host = _connectHostFromRemote(task.remoteHost);
|
||||
@@ -3967,7 +4033,8 @@ function _refreshServerDots() {
|
||||
// time-throttled inside (background-monitor path).
|
||||
let _selfHealRan = false;
|
||||
let _selfHealLastTs = 0;
|
||||
export async function _selfHealStaleTasks(opts = {}) {
|
||||
let _selfHealInFlight = null;
|
||||
async function _selfHealStaleTasksImpl(opts = {}) {
|
||||
// Open-path call: one-shot per page load.
|
||||
if (opts.oneShot) {
|
||||
if (_selfHealRan) return;
|
||||
@@ -4002,7 +4069,7 @@ export async function _selfHealStaleTasks(opts = {}) {
|
||||
let flipped = 0;
|
||||
for (const t of candidates) {
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await _fetchWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(t, `has-session -t ${t.sessionId}`), timeout: 5 }),
|
||||
@@ -4038,6 +4105,16 @@ export async function _selfHealStaleTasks(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function _selfHealStaleTasks(opts = {}) {
|
||||
if (_selfHealInFlight) return _selfHealInFlight;
|
||||
const run = _selfHealStaleTasksImpl(opts);
|
||||
const tracked = run.finally(() => {
|
||||
if (_selfHealInFlight === tracked) _selfHealInFlight = null;
|
||||
});
|
||||
_selfHealInFlight = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export function _startBackgroundMonitor() {
|
||||
if (_bgMonitorInterval) return;
|
||||
_bgMonitorInterval = setInterval(() => {
|
||||
@@ -4088,10 +4165,10 @@ async function _probeEndpointUntilOnline(epId, host, port) {
|
||||
try {
|
||||
// Hit the probe endpoint — it re-probes server-side and updates
|
||||
// cached_models. We consume (and discard) the SSE stream.
|
||||
const probeRes = await fetch(`/api/model-endpoints/${epId}/probe`, { credentials: 'same-origin' }).catch(() => null);
|
||||
const probeRes = await _fetchWithTimeout(`/api/model-endpoints/${epId}/probe`, { credentials: 'same-origin' }, 20000).catch(() => null);
|
||||
if (probeRes && probeRes.status === 404) return;
|
||||
if (probeRes) await probeRes.text().catch(() => {});
|
||||
const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
|
||||
const eps = await _fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
|
||||
const ep = (eps || []).find(e => e.id === epId);
|
||||
if (ep && (ep.models || []).length) {
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
|
||||
@@ -4114,7 +4191,7 @@ async function _pollBackgroundStatus() {
|
||||
// yet (e.g. agent-spawned downloads/serves). Without this merge,
|
||||
// _syncToServer keeps clobbering server-added tasks on every poll.
|
||||
try {
|
||||
const stateRes = await fetch('/api/cookbook/state', { credentials: 'same-origin' });
|
||||
const stateRes = await _fetchWithTimeout('/api/cookbook/state', { credentials: 'same-origin' });
|
||||
if (stateRes.ok) {
|
||||
const serverState = await stateRes.json();
|
||||
const serverTasks = (serverState && Array.isArray(serverState.tasks)) ? serverState.tasks : [];
|
||||
@@ -4137,7 +4214,7 @@ async function _pollBackgroundStatus() {
|
||||
}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
const res = await fetch('/api/cookbook/tasks/status', { credentials: 'same-origin' });
|
||||
const res = await _fetchWithTimeout('/api/cookbook/tasks/status', { credentials: 'same-origin' }, 30000);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const tasks = data.tasks || [];
|
||||
@@ -4294,7 +4371,7 @@ async function _pollBackgroundStatus() {
|
||||
const _cmd = localTask?.payload?._cmd || '';
|
||||
const _supportsTools = _cmd.includes('--enable-auto-tool-choice') || _isDiffusion === false && /(?:^|\s)(?:deepseek|gpt-[45o]|claude|gemini|qwen3|qwen2\.5|mixtral|llama-[34]|minimax|kimi|hermes|glm-4)/i.test(t.model);
|
||||
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
_fetchWithTimeout('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(eps => {
|
||||
const hostPort = `${host}:${port}`;
|
||||
@@ -4320,7 +4397,7 @@ async function _pollBackgroundStatus() {
|
||||
_appendPinnedServeModel(fd, localTask || { name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } });
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
if (_supportsTools) fd.append('supports_tools', 'true');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
return _fetchWithTimeout('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res && res.ok) {
|
||||
|
||||
@@ -29,6 +29,21 @@ try { (function () {
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function fetchWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const parentSignal = init.signal;
|
||||
const abortFromParent = () => controller.abort();
|
||||
if (parentSignal) {
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
else parentSignal.addEventListener("abort", abortFromParent, { once: true });
|
||||
}
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener("abort", abortFromParent);
|
||||
});
|
||||
}
|
||||
|
||||
// Cached handle to the ui.js showToast function. Bound lazily on
|
||||
// first use because ui.js is an ES module — it's not on `window`
|
||||
// unless something else has explicitly exposed it.
|
||||
@@ -36,7 +51,7 @@ try { (function () {
|
||||
async function _getToast() {
|
||||
if (_toastFn) return _toastFn;
|
||||
try {
|
||||
const m = await import("/static/js/ui.js");
|
||||
const m = await import("/static/js/ui.js?v=20260916largetoolscroll1");
|
||||
_toastFn = m.default?.showToast || m.showToast || null;
|
||||
} catch (_) { _toastFn = null; }
|
||||
return _toastFn;
|
||||
@@ -55,7 +70,7 @@ try { (function () {
|
||||
let _tasksMod = null;
|
||||
async function _getTasksMod() {
|
||||
if (_tasksMod) return _tasksMod;
|
||||
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
|
||||
try { _tasksMod = await import("/static/js/tasks.js?v=20260914taskmodel1"); } catch (_) {}
|
||||
return _tasksMod;
|
||||
}
|
||||
async function openTaskInTasksTab(taskId) {
|
||||
@@ -274,7 +289,7 @@ try { (function () {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
try {
|
||||
const r = await fetch("/api/tasks", {
|
||||
const r = await fetchWithTimeout("/api/tasks", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -293,11 +308,11 @@ try { (function () {
|
||||
// Best-effort: if anything here fails, we still consider the
|
||||
// task creation a success (the task itself works regardless).
|
||||
try {
|
||||
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
|
||||
const calsRes = await fetchWithTimeout("/api/calendar/calendars", { credentials: "same-origin" });
|
||||
const calsBody = calsRes.ok ? await calsRes.json() : {};
|
||||
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
|
||||
if (!cookbookCal) {
|
||||
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
|
||||
const mk = await fetchWithTimeout("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
});
|
||||
if (mk.ok) {
|
||||
@@ -328,7 +343,7 @@ try { (function () {
|
||||
color: "#3b82f6",
|
||||
};
|
||||
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
|
||||
const evRes = await fetch("/api/calendar/events", {
|
||||
const evRes = await fetchWithTimeout("/api/calendar/events", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(evBody),
|
||||
@@ -350,7 +365,7 @@ try { (function () {
|
||||
// the task never got the cookbook_event_uid marker and the
|
||||
// server-side delete-cascade had nothing to follow when the
|
||||
// user later deleted the task.
|
||||
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
|
||||
await fetchWithTimeout(`/api/tasks/${encodeURIComponent(data.id)}`, {
|
||||
method: "PUT", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: updatedPrompt }),
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
// command building, preset slots, launch logic
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { modelColor } from './chatRenderer.js?v=20260914metricssummary1';
|
||||
import {
|
||||
bindMenuDismiss,
|
||||
dismissOrRemove,
|
||||
bindExpandedCardDismiss,
|
||||
unbindExpandedCardDismiss,
|
||||
} from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
@@ -41,6 +46,21 @@ let _launchServeTask;
|
||||
let _retryDownload;
|
||||
let _nextAvailablePort;
|
||||
|
||||
function _fetchCookbookWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const parentSignal = init.signal;
|
||||
const abortFromParent = () => controller.abort();
|
||||
if (parentSignal) {
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
else parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abortFromParent);
|
||||
});
|
||||
}
|
||||
|
||||
// Storage keys
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
@@ -48,6 +68,15 @@ const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v3_ltx_video';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
let _cachedModelsFetchId = 0;
|
||||
let _cachedModelsRequestController = null;
|
||||
|
||||
function _syncServeStats() {
|
||||
const stats = document.getElementById('serve-stats');
|
||||
if (!stats) return;
|
||||
const count = _cachedAllModels.length;
|
||||
stats.textContent = `${count} model${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
|
||||
@@ -833,7 +862,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
if (target.venv) params.set('venv', target.venv);
|
||||
}
|
||||
if (repo) params.set('model_hint', repo);
|
||||
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const pkg = (data.packages || []).find(p => p.name === packageName);
|
||||
@@ -860,7 +889,7 @@ function _filterCachedList() {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (!list) return;
|
||||
const activeTag = tagContainer?.querySelector('.memory-cat-chip.active')?.dataset.serveTag || '';
|
||||
const activeTag = tagContainer?.querySelector('[data-serve-tag].active')?.dataset.serveTag || '';
|
||||
const searchVal = (document.getElementById('serve-search')?.value || '').toLowerCase().trim();
|
||||
const isFamily = activeTag.startsWith('fam:');
|
||||
const familyVal = isFamily ? activeTag.slice(4) : '';
|
||||
@@ -1105,7 +1134,7 @@ function _rerenderCachedModels() {
|
||||
const allModels = _cachedAllModels;
|
||||
const _h = (text) => `<span class="hwfit-hint" title="${text}">?</span>`;
|
||||
|
||||
const activeTag = tagContainer?.querySelector('.memory-cat-chip.active')?.dataset.serveTag || '';
|
||||
const activeTag = tagContainer?.querySelector('[data-serve-tag].active')?.dataset.serveTag || '';
|
||||
const searchVal = (document.getElementById('serve-search')?.value || '').toLowerCase().trim();
|
||||
|
||||
const sortVal = document.getElementById('serve-sort')?.value || 'name';
|
||||
@@ -1148,21 +1177,24 @@ function _rerenderCachedModels() {
|
||||
html += `<div class="doclib-card memory-item${_isFavorite ? ' memory-pinned cookbook-serve-favorite-model' : ''}" data-repo="${esc(m.repo_id)}" data-tag="${m._tag || ''}" data-family="${m._family || ''}" style="cursor:pointer;">`;
|
||||
html += `<span class="serve-select-cb memory-select-dot" style="display:${isSelectMode ? 'inline-block' : 'none'};cursor:pointer;"></span>`;
|
||||
html += `<div style="flex:1;min-width:0;">`;
|
||||
const _mc = modelColor(m.repo_id) || '';
|
||||
// Keep a provider family visually consistent across its variants. Unknown
|
||||
// local models retain their own stable color so they remain distinguishable.
|
||||
const _mc = modelColor(m._family || m.repo_id) || '';
|
||||
const _runningPill = _isActivelyServing(m.repo_id)
|
||||
? ` <span class="cookbook-serve-running-pill is-clickable" title="This model is currently being served — click to open in Running" data-repo="${esc(m.repo_id)}" role="button" tabindex="0">running</span>`
|
||||
: '';
|
||||
const _downloadingPill = _isDownloading
|
||||
? ` <span class="cookbook-serve-downloading-pill${_isDlActive ? '' : ' is-stalled'}" title="${_isDlActive ? 'Download in progress' : 'Download stalled — retry to resume'}">${_isDlActive ? 'downloading' : 'stalled'}</span>`
|
||||
: '';
|
||||
const _favoritePill = _isFavorite ? ' <span class="memory-cat-badge memory-cat-pinned cookbook-serve-fav-badge">pinned</span>' : '';
|
||||
html += `<div class="memory-item-title cookbook-serve-title"${_mc ? ` style="color:${_mc}"` : ''}><span class="cookbook-serve-title-name">${modelLogo(m.repo_id)}${esc(shortName)}</span>${_favoritePill}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}${_downloadingPill}</div>`;
|
||||
const _favoritePill = _isFavorite ? ' <span class="memory-cat-badge memory-cat-pinned cookbook-serve-fav-badge"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg></span>' : '';
|
||||
html += `<div class="memory-item-title cookbook-serve-title"${_mc ? ` style="color:${_mc}"` : ''}><span class="cookbook-serve-title-name">${modelLogo(m.repo_id)}${esc(shortName)}</span>${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_favoritePill}${_runningPill}${_downloadingPill}</div>`;
|
||||
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaParts.join(' \u00b7 ')}</div>`;
|
||||
html += `</div>`;
|
||||
const _bk = _detectBackend(m).backend;
|
||||
const _bkIco = _bk === 'llamacpp' ? '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M7 3C5.5 5 5 8 5 11v7c0 1.5 1 3 3 3h1v-4h6v4h1c2 0 3-1.5 3-3v-7c0-3-.5-6-2-8l-1 3c-.5-2-1.5-4-3-5-.5 2-1 3-1.5 3S11 3.5 10.5 2L7 3z" fill="currentColor"/><circle cx="9" cy="11" r="1.5" fill="var(--bg,#1a1a2e)"/><circle cx="15" cy="11" r="1.5" fill="var(--bg,#1a1a2e)"/></svg>'
|
||||
: _bk === 'diffusers' ? '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zM6 9c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm0 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm6 4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" fill="currentColor"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 4l8 16 8-16h-4l-4 8-4-8z" fill="currentColor"/></svg>';
|
||||
html += `<span class="doclib-card-chevron" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>`;
|
||||
html += `<span class="cookbook-card-backend" data-detected="${_bk}">${_bkIco}</span>`;
|
||||
html += `<div class="memory-item-actions"><button type="button" class="memory-item-btn hwfit-cached-menu-btn" title="Actions" aria-label="Model actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>`;
|
||||
html += `</div>`;
|
||||
@@ -1172,9 +1204,9 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Wire tag chips
|
||||
if (tagContainer) {
|
||||
tagContainer.querySelectorAll('.memory-cat-chip').forEach(chip => {
|
||||
tagContainer.querySelectorAll('[data-serve-tag]').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
tagContainer.querySelectorAll('.memory-cat-chip').forEach(c => c.classList.remove('active'));
|
||||
tagContainer.querySelectorAll('[data-serve-tag]').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
_filterCachedList();
|
||||
});
|
||||
@@ -1187,13 +1219,21 @@ function _rerenderCachedModels() {
|
||||
const menuBtn = item.querySelector('.hwfit-cached-menu-btn');
|
||||
if (!menuBtn || item.dataset.lpWired === '1') return;
|
||||
item.dataset.lpWired = '1';
|
||||
let _t = null;
|
||||
let _y = 0;
|
||||
const _cancel = () => { if (_t) { clearTimeout(_t); _t = null; } };
|
||||
const _cancel = () => {
|
||||
if (item._longPressTimer) {
|
||||
clearTimeout(item._longPressTimer);
|
||||
item._longPressTimer = null;
|
||||
}
|
||||
};
|
||||
item.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('button, a, input, textarea, .hwfit-cached-dropdown')) return;
|
||||
_y = e.touches?.[0]?.clientY ?? 0;
|
||||
_t = setTimeout(() => { _t = null; try { menuBtn.click(); } catch {} }, 500);
|
||||
_cancel();
|
||||
item._longPressTimer = setTimeout(() => {
|
||||
item._longPressTimer = null;
|
||||
try { menuBtn.click(); } catch {}
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
item.addEventListener('touchmove', (e) => {
|
||||
const y = e.touches?.[0]?.clientY ?? 0;
|
||||
@@ -1357,9 +1397,11 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Toggle — close if already open
|
||||
if (item.classList.contains('doclib-card-expanded')) {
|
||||
unbindExpandedCardDismiss(item);
|
||||
const existingPanel = item.querySelector('.hwfit-serve-panel');
|
||||
existingPanel?._cleanupRuntimeReadiness?.();
|
||||
existingPanel?._cleanupServePanel?.();
|
||||
existingPanel?.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
@@ -1371,9 +1413,11 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Collapse any other expanded
|
||||
list.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
||||
unbindExpandedCardDismiss(c);
|
||||
const openPanel = c.querySelector('.hwfit-serve-panel');
|
||||
openPanel?._cleanupRuntimeReadiness?.();
|
||||
openPanel?._cleanupServePanel?.();
|
||||
openPanel?.remove();
|
||||
c.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
c.classList.remove('doclib-card-expanded');
|
||||
c.style.flexDirection = '';
|
||||
c.style.alignItems = '';
|
||||
@@ -1454,10 +1498,9 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
|
||||
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
|
||||
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
|
||||
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
||||
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
||||
// Build save slots
|
||||
const _allPresets = _loadPresets();
|
||||
@@ -1815,7 +1858,7 @@ function _rerenderCachedModels() {
|
||||
// creates a ScheduledTask (action=cookbook_serve), so the schedule
|
||||
// ends up in the existing Tasks UI for edit/delete/pause.
|
||||
panelHtml += `<span class="hwfit-serve-launch-group">`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><path d="M12 22c4.4 0 8-3.1 8-7.2 0-3.8-2.5-6.8-5.3-9.8.1 2.2-.5 3.8-1.8 5.2.1-3.5-1.2-6.1-3-8.2.1 4-5.9 7.4-5.9 12.8C4 18.9 7.6 22 12 22Z"></path></svg>Launch</button>`;
|
||||
// Chevron points DOWN because the schedule form opens beneath the
|
||||
// panel — the arrow signals the direction of motion, not menu state.
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule-arrow" type="button" aria-haspopup="menu" aria-label="More launch actions" title="More launch actions"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
|
||||
@@ -1824,8 +1867,23 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `</div>`;
|
||||
|
||||
item.classList.add('doclib-card-expanded');
|
||||
bindExpandedCardDismiss(item, () => {
|
||||
unbindExpandedCardDismiss(item);
|
||||
const openPanel = item.querySelector('.hwfit-serve-panel');
|
||||
openPanel?._cleanupServePanel?.();
|
||||
openPanel?.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
list.style.minHeight = '';
|
||||
list.style.maxHeight = '';
|
||||
});
|
||||
item.style.flexDirection = 'column';
|
||||
item.style.alignItems = 'stretch';
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.querySelector('.cookbook-serve-title')?.insertAdjacentHTML('beforeend', '<button type="button" class="doclib-card-collapse-chevron" aria-label="Collapse launch configuration" title="Collapse launch configuration"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button>');
|
||||
item.insertAdjacentHTML('beforeend', panelHtml);
|
||||
const panel = item.querySelector('.hwfit-serve-panel');
|
||||
// Scroll the serve panel into view within its nearest scrollable ancestor
|
||||
@@ -1990,7 +2048,7 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_serverByVal?.(target.serverKey || host) || (_es.servers || []).find(s => s.host === host) || {}).port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch(`/api/hwfit/profiles?${params}`);
|
||||
const res = await _fetchCookbookWithTimeout(`/api/hwfit/profiles?${params}`, {}, 30000);
|
||||
const data = await res.json();
|
||||
const ctxMax = Number(data && data.model_ctx_max) || 0;
|
||||
const weightsGb = Number(data && data.model_weights_gb) || 0;
|
||||
@@ -2091,9 +2149,12 @@ function _rerenderCachedModels() {
|
||||
// RAM-spillover, with a plain-language health/speed hint. Lets you tell at
|
||||
// a glance whether the chosen config fits VRAM (fast) or is paging into
|
||||
// system RAM over PCIe (slow). AMD sysfs reports gtt_used_mb for spillover.
|
||||
let _vramMonitorInFlight = false;
|
||||
async function _refreshVramMonitor() {
|
||||
const el = panel.querySelector('.hwfit-vram-readout');
|
||||
if (!el || !document.body.contains(el)) return false; // panel closed → stop
|
||||
if (_vramMonitorInFlight) return true;
|
||||
_vramMonitorInFlight = true;
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const params = new URLSearchParams();
|
||||
@@ -2102,7 +2163,8 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''));
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''), {}, 10000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const gpus = Array.isArray(data) ? data : (data.gpus || []);
|
||||
if (!gpus.length) { el.textContent = 'no GPU detected'; el.style.color = ''; return true; }
|
||||
@@ -2131,6 +2193,8 @@ function _rerenderCachedModels() {
|
||||
el.textContent = 'unavailable';
|
||||
el.style.color = '';
|
||||
return true;
|
||||
} finally {
|
||||
_vramMonitorInFlight = false;
|
||||
}
|
||||
}
|
||||
_refreshVramMonitor();
|
||||
@@ -2146,11 +2210,11 @@ function _rerenderCachedModels() {
|
||||
// custom picker so the dropdown lists "[V] vLLM", "[⚡] SGLang", etc.
|
||||
const _BACKEND_GLYPHS = {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
|
||||
@@ -2163,13 +2227,14 @@ function _rerenderCachedModels() {
|
||||
const _backendMenu = panel.querySelector('[data-backend-menu]');
|
||||
const _backendBtnLabel = panel.querySelector('[data-backend-label]');
|
||||
const _backendBtnIconSlot = _backendBtn?.querySelector('[data-backend-icon-slot]');
|
||||
let _cleanupBackendDismiss = () => {};
|
||||
|
||||
function _setBackendBtnState(v) {
|
||||
if (!_backendBtn) return;
|
||||
const opt = _backendSource?.querySelector(`option[value="${CSS.escape(v)}"]`);
|
||||
const label = opt ? opt.textContent : v;
|
||||
if (_backendBtnLabel) _backendBtnLabel.textContent = label;
|
||||
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] || _BACKEND_GLYPHS.vllm;
|
||||
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] ?? _BACKEND_GLYPHS.vllm;
|
||||
}
|
||||
|
||||
function _renderBackendMenu() {
|
||||
@@ -2177,7 +2242,7 @@ function _rerenderCachedModels() {
|
||||
const items = Array.from(_backendSource.options).map(o => ({ value: o.value, label: o.textContent }));
|
||||
_backendMenu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="hwfit-backend-item" data-value="${it.value}" style="all:unset;display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border-radius:5px;font-size:12px;cursor:pointer;color:var(--fg);box-sizing:border-box;">
|
||||
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] || _BACKEND_GLYPHS.vllm}</span>
|
||||
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] ?? _BACKEND_GLYPHS.vllm}</span>
|
||||
<span class="hwfit-backend-item-label" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
@@ -2216,15 +2281,21 @@ function _rerenderCachedModels() {
|
||||
if (_backendMenu.hidden) _openBackendMenu();
|
||||
else _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('click', (ev) => {
|
||||
const _onBackendOutsideClick = (ev) => {
|
||||
if (!_backendMenu.hidden && !_backendPicker?.contains(ev.target)) _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
};
|
||||
const _onBackendEscape = (ev) => {
|
||||
if (ev.key === 'Escape' && !_backendMenu.hidden) {
|
||||
ev.stopPropagation();
|
||||
_closeBackendMenu();
|
||||
}
|
||||
}, { capture: true });
|
||||
};
|
||||
document.addEventListener('click', _onBackendOutsideClick);
|
||||
document.addEventListener('keydown', _onBackendEscape, { capture: true });
|
||||
_cleanupBackendDismiss = () => {
|
||||
document.removeEventListener('click', _onBackendOutsideClick);
|
||||
document.removeEventListener('keydown', _onBackendEscape, { capture: true });
|
||||
};
|
||||
}
|
||||
_renderBackendMenu();
|
||||
_setBackendBtnState(_backendSource?.value || defaultBackend);
|
||||
@@ -2556,7 +2627,7 @@ function _rerenderCachedModels() {
|
||||
if (p.favorite) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'memory-cat-badge memory-cat-pinned cookbook-saved-fav-badge';
|
||||
badge.textContent = 'pinned';
|
||||
badge.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
||||
it.appendChild(badge);
|
||||
}
|
||||
if (p.confirmedWorking) {
|
||||
@@ -2823,10 +2894,23 @@ function _rerenderCachedModels() {
|
||||
panel._gpuProbe.popup.remove();
|
||||
panel._gpuProbe.popup = null;
|
||||
}
|
||||
// The outside-click listener is installed on the next tick so the
|
||||
// opening click cannot immediately dismiss the popup. Keep its
|
||||
// cleanup with the popup; otherwise every open/close cycle leaves a
|
||||
// document listener behind.
|
||||
if (panel._gpuProbe.outsideAttachTimer) {
|
||||
clearTimeout(panel._gpuProbe.outsideAttachTimer);
|
||||
panel._gpuProbe.outsideAttachTimer = null;
|
||||
}
|
||||
if (panel._gpuProbe.outsideHandler) {
|
||||
document.removeEventListener('mousedown', panel._gpuProbe.outsideHandler, true);
|
||||
panel._gpuProbe.outsideHandler = null;
|
||||
}
|
||||
};
|
||||
panel._closeProbePopup = _closeProbePopup;
|
||||
|
||||
const _doKill = async (pid, sig, hostVal) => {
|
||||
const res = await fetch('/api/cookbook/kill-pid', {
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid, signal: sig, host: hostVal || null }),
|
||||
@@ -2915,13 +2999,14 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
|
||||
// Click outside closes the popup
|
||||
setTimeout(() => {
|
||||
panel._gpuProbe.outsideAttachTimer = setTimeout(() => {
|
||||
panel._gpuProbe.outsideAttachTimer = null;
|
||||
const outside = (ev) => {
|
||||
if (!popup.contains(ev.target) && ev.target !== anchorBtn) {
|
||||
_closeProbePopup();
|
||||
document.removeEventListener('mousedown', outside, true);
|
||||
}
|
||||
};
|
||||
panel._gpuProbe.outsideHandler = outside;
|
||||
document.addEventListener('mousedown', outside, true);
|
||||
}, 0);
|
||||
};
|
||||
@@ -2933,7 +3018,7 @@ function _rerenderCachedModels() {
|
||||
const params = new URLSearchParams();
|
||||
if (remoteHost) params.set('host', remoteHost);
|
||||
const url = '/api/cookbook/gpus' + (params.toString() ? '?' + params.toString() : '');
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
const res = await _fetchCookbookWithTimeout(url, { credentials: 'same-origin' }, 10000);
|
||||
let data;
|
||||
try { data = await res.json(); } catch (_) { data = {}; }
|
||||
if (!res.ok) {
|
||||
@@ -3049,7 +3134,7 @@ function _rerenderCachedModels() {
|
||||
// First pass: SIGTERM
|
||||
const hostVal = panel._gpuProbe.host;
|
||||
const results = await Promise.all(pids.map(p =>
|
||||
fetch('/api/cookbook/kill-pid', {
|
||||
_fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid: p.pid, signal: 'TERM', host: hostVal || null }),
|
||||
@@ -3073,7 +3158,7 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
if (!await window.styledConfirm(`${survivors.length} process(es) survived SIGTERM:\n\n${survivors.map(p => p.pid + ' (' + p.name + ')').join(', ')}\n\nForce-kill with SIGKILL?`, { confirmText: 'SIGKILL', danger: true })) return;
|
||||
const killResults = await Promise.all(survivors.map(p =>
|
||||
fetch('/api/cookbook/kill-pid', {
|
||||
_fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid: p.pid, signal: 'KILL', host: hostVal || null }),
|
||||
@@ -3245,38 +3330,56 @@ function _rerenderCachedModels() {
|
||||
// tapping the row to toggle it shut). Mobile users wanted an explicit
|
||||
// "back out" affordance next to Launch.
|
||||
const _collapsePanel = () => {
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
|
||||
try {
|
||||
panel._cleanupServePanel?.();
|
||||
} catch (err) {
|
||||
console.warn('Serve panel cleanup failed:', err);
|
||||
} finally {
|
||||
panel.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
|
||||
}
|
||||
};
|
||||
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
});
|
||||
// Esc anywhere on the page closes the open serve panel. Skips when
|
||||
// the user is typing in a field — they want Esc to deselect / blur
|
||||
// those, not collapse the form they're configuring.
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
});
|
||||
// Esc anywhere on the page closes the open serve panel. This mirrors
|
||||
// the global Cookbook handler and keeps the launch expansion easy to
|
||||
// dismiss even when a form control currently has focus.
|
||||
const _onEscClose = (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
if (!panel.isConnected) {
|
||||
document.removeEventListener('keydown', _onEscClose, true);
|
||||
return;
|
||||
}
|
||||
const t = ev.target;
|
||||
const inField = t && (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable
|
||||
);
|
||||
if (inField) return;
|
||||
// Skip when one of the dropdown/menu popovers is open — the
|
||||
// popovers handle their own Esc and use stopPropagation, so any
|
||||
// Esc that bubbles here means nothing else claimed it.
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
};
|
||||
// Listen on the panel itself as well as the document. The panel-level
|
||||
// capture listener runs before controls or nested menus can consume
|
||||
// Escape, so the expanded launch card always has a reliable close path.
|
||||
panel.addEventListener('keydown', _onEscClose, true);
|
||||
document.addEventListener('keydown', _onEscClose, true);
|
||||
panel._cleanupServePanel = () => {
|
||||
clearInterval(_vramTimer);
|
||||
panel._closeProbePopup?.();
|
||||
_cleanupBackendDismiss?.();
|
||||
panel.removeEventListener('keydown', _onEscClose, true);
|
||||
document.removeEventListener('keydown', _onEscClose, true);
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
};
|
||||
|
||||
// Launch button
|
||||
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
|
||||
@@ -3395,7 +3498,7 @@ function _rerenderCachedModels() {
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
@@ -3455,7 +3558,7 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_serverByVal?.(launchTarget.serverKey || _gh) || {}).port;
|
||||
if (_sp) _gp.set('ssh_port', _sp);
|
||||
}
|
||||
const _gr = await fetch('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' });
|
||||
const _gr = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' }, 10000);
|
||||
if (_gr.ok) {
|
||||
const _gd = await _gr.json();
|
||||
_hwGpus = Array.isArray(_gd) ? _gd : (_gd.gpus || []);
|
||||
@@ -3567,7 +3670,7 @@ function _rerenderCachedModels() {
|
||||
_probeParams.set('host', _probeHost);
|
||||
if (launchTarget.port) _probeParams.set('ssh_port', launchTarget.port);
|
||||
}
|
||||
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
|
||||
const _probeRes = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' }, 10000);
|
||||
const _probeData = await _probeRes.json();
|
||||
const _probeGpus = Array.isArray(_probeData) ? _probeData : (_probeData.gpus || []);
|
||||
if (!_probeGpus.length) {
|
||||
@@ -3603,7 +3706,7 @@ function _rerenderCachedModels() {
|
||||
const _cmd = _portHost
|
||||
? `ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_sshPrefix(launchTarget.port)}${_portHost} ${JSON.stringify(_checkInner)}`
|
||||
: _checkInner;
|
||||
const _res = await fetch('/api/shell/exec', {
|
||||
const _res = await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _cmd }),
|
||||
@@ -3863,7 +3966,7 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
itemEl.appendChild(ov);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
@@ -3891,6 +3994,7 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
if (itemEl.parentElement) itemEl.remove();
|
||||
// Drop from the in-memory list so a re-render/filter doesn't resurrect it.
|
||||
_cachedAllModels = _cachedAllModels.filter(x => x.repo_id !== repo);
|
||||
_syncServeStats();
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showError('Delete failed: ' + (e && e.message ? e.message : e));
|
||||
@@ -4028,9 +4132,29 @@ export async function openServePanelForRepo(repo, fields) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function _playCachedModelDomino(list) {
|
||||
if (!list || !list.querySelector('.doclib-card')) return;
|
||||
list.classList.remove('cookbook-serve-models-just-loaded');
|
||||
void list.offsetWidth;
|
||||
list.classList.add('cookbook-serve-models-just-loaded');
|
||||
clearTimeout(list._serveModelDominoTimer);
|
||||
list._serveModelDominoTimer = setTimeout(() => {
|
||||
list.classList.remove('cookbook-serve-models-just-loaded');
|
||||
list._serveModelDominoTimer = null;
|
||||
}, 950);
|
||||
}
|
||||
|
||||
// ── Fetch cached models from server ──
|
||||
|
||||
function _renderCachedModelsData(list, data, host) {
|
||||
// A scan rebuild replaces these cards. Cancel pending mobile long-presses
|
||||
// before their DOM nodes are discarded, so detached cards cannot open menus.
|
||||
list.querySelectorAll('.memory-item').forEach(item => {
|
||||
if (item._longPressTimer) {
|
||||
clearTimeout(item._longPressTimer);
|
||||
item._longPressTimer = null;
|
||||
}
|
||||
});
|
||||
// CHANGELOG: 'ready' already excludes partial downloads;
|
||||
// show every complete model regardless of size/backend.
|
||||
const ready = (data.models || []).filter(m => m.status === 'ready');
|
||||
@@ -4038,6 +4162,7 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const downloading = (data.models || []).filter(m => m.status === 'downloading');
|
||||
const allModels = [...ready, ...downloading];
|
||||
_cachedAllModels = allModels;
|
||||
_syncServeStats();
|
||||
|
||||
if (!allModels.length) {
|
||||
if (!host) {
|
||||
@@ -4089,45 +4214,49 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) {
|
||||
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
|
||||
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
|
||||
let tagHtml = `<button class="skills-summary-chip active" data-serve-tag=""><span>All</span><strong>${allModels.length}</strong></button>`;
|
||||
for (const t of tagOrder) {
|
||||
if (!_tagMap[t]) continue;
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
|
||||
tagHtml += `<button class="skills-summary-chip" data-serve-tag="${esc(t)}"><span>${esc(t)}</span><strong>${_tagMap[t]}</strong></button>`;
|
||||
}
|
||||
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedFamilies.length) {
|
||||
for (const [fam, count] of sortedFamilies) {
|
||||
const logo = providerLogo(fam);
|
||||
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
|
||||
tagHtml += `<button class="skills-summary-chip" data-serve-tag="fam:${esc(fam)}"><span class="cookbook-serve-tag-label">${logoHtml}${esc(fam)}</span><strong>${count}</strong></button>`;
|
||||
}
|
||||
}
|
||||
tagContainer.innerHTML = tagHtml;
|
||||
}
|
||||
|
||||
_rerenderCachedModels();
|
||||
_playCachedModelDomino(list);
|
||||
}
|
||||
|
||||
export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
async function _fetchCachedModelsImpl(fresh = false, opts = {}) {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
if (!list) return;
|
||||
const fetchId = ++_cachedModelsFetchId;
|
||||
_cachedModelsRequestController?.abort();
|
||||
const _requestController = new AbortController();
|
||||
_cachedModelsRequestController = _requestController;
|
||||
const allowNetwork = fresh || opts.allowNetwork !== false;
|
||||
|
||||
list.innerHTML = '';
|
||||
const _dlWp = spinnerModule.createWhirlpool(22);
|
||||
_dlWp.element.classList.add('cookbook-section-loading-wp');
|
||||
_dlWp.element.style.width = '22px';
|
||||
_dlWp.element.style.height = '22px';
|
||||
const _dlWrap = document.createElement('div');
|
||||
_dlWrap.className = 'hwfit-loading';
|
||||
_dlWrap.className = 'hwfit-loading cookbook-cached-scan-loading';
|
||||
_dlWrap.style.cssText = 'flex-direction:column;gap:6px;';
|
||||
_dlWrap.appendChild(_dlWp.element);
|
||||
const _dlLabel = document.createElement('div');
|
||||
_dlLabel.textContent = 'Scanning cached models…';
|
||||
_dlLabel.style.cssText = 'opacity:0.5;font-size:11px;';
|
||||
_dlWrap.appendChild(_dlLabel);
|
||||
list.appendChild(_dlWrap);
|
||||
|
||||
let scanSig = '';
|
||||
let preserveRows = false;
|
||||
try {
|
||||
let host = _envState.remoteHost || '';
|
||||
let selectedServer = null;
|
||||
@@ -4180,26 +4309,49 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
if (host) { qp.set('host', host); const _sp4 = _getPort(host); if (_sp4) qp.set('ssh_port', _sp4); const _plat = _getPlatform(host); if (_plat) qp.set('platform', _plat); }
|
||||
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
|
||||
const params = qp.toString() ? `?${qp}` : '';
|
||||
const scanSig = params || 'local';
|
||||
scanSig = params || 'local';
|
||||
const hasModelRows = !!list.querySelector('.memory-item[data-repo]');
|
||||
preserveRows = hasModelRows && list.dataset.cookbookScanSig === scanSig;
|
||||
if (!preserveRows) list.innerHTML = '';
|
||||
else list.querySelector('.cookbook-cached-scan-loading')?.remove();
|
||||
_dlWrap.classList.toggle('cookbook-cached-scan-loading-inline', preserveRows);
|
||||
if (preserveRows) {
|
||||
_dlWrap.style.cssText = 'flex-direction:row;align-items:center;justify-content:center;gap:6px;padding:8px 0;';
|
||||
_dlWp.element.style.width = '16px';
|
||||
_dlWp.element.style.height = '16px';
|
||||
_dlLabel.textContent = 'Refreshing cached models…';
|
||||
}
|
||||
list.appendChild(_dlWrap);
|
||||
const cached = fresh ? null : _readCachedModelScan(scanSig);
|
||||
if (cached) {
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
_dlWp.destroy();
|
||||
list.dataset.cookbookScanSig = scanSig;
|
||||
_renderCachedModelsData(list, cached, host);
|
||||
return;
|
||||
}
|
||||
if (!allowNetwork) {
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
_dlWp.destroy();
|
||||
const wp = spinnerModule.createWhirlpool(22);
|
||||
list.innerHTML = '<div class="hwfit-loading serve-empty-auto-scan" style="flex-direction:column;gap:8px;text-align:center;"><div class="serve-empty-auto-wp"></div><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Scanning this server\'s model cache…</div></div>';
|
||||
list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element);
|
||||
setTimeout(() => {
|
||||
if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
|
||||
if (fetchId === _cachedModelsFetchId && list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
|
||||
}, 60);
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) tagContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/model/cached${params}`);
|
||||
const res = await _fetchCookbookWithTimeout(`/api/model/cached${params}`, { credentials: 'same-origin', signal: _requestController.signal }, 30000);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
let msg = '';
|
||||
@@ -4213,12 +4365,28 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
_writeCachedModelScan(scanSig, data);
|
||||
_dlWp.destroy();
|
||||
list.dataset.cookbookScanSig = scanSig;
|
||||
_renderCachedModelsData(list, data, host);
|
||||
} catch (e) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
if (fetchId !== _cachedModelsFetchId) return;
|
||||
if (preserveRows) {
|
||||
list.querySelector('.cookbook-cached-scan-error')?.remove();
|
||||
list.insertAdjacentHTML('beforeend', `<div class="cookbook-cached-scan-error" style="display:flex;align-items:center;justify-content:center;gap:8px;padding:7px 0;color:var(--color-warning,#f0ad4e);font-size:11px;"><span>Refresh failed: ${esc(e.message)}</span><button type="button" class="hwfit-gpu-btn cookbook-cached-scan-retry" style="height:24px;padding:2px 8px;">Retry</button></div>`);
|
||||
list.querySelector('.cookbook-cached-scan-retry')?.addEventListener('click', () => {
|
||||
_fetchCachedModels(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `<div class="hwfit-loading" style="flex-direction:column;gap:8px;text-align:center;"><div style="color:var(--red);font-weight:600;">Cached model scan failed</div><div style="font-size:11px;opacity:0.65;max-width:420px;line-height:1.4;">${esc(e.message)}</div><button type="button" class="hwfit-gpu-btn serve-empty-scan-btn" style="height:26px;padding:3px 10px;">Retry</button></div>`;
|
||||
list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => {
|
||||
_fetchCachedModels(true);
|
||||
@@ -4226,6 +4394,37 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Several startup/state-sync paths can request the same cache scan together.
|
||||
// Share that request per selected server so stale-response protection does not
|
||||
// merely discard duplicate SSH work after it has already happened.
|
||||
const _cachedModelsInFlight = new Map();
|
||||
export function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
const cacheServer = document.getElementById('hwfit-cache-server');
|
||||
const key = [
|
||||
cacheServer?.value || 'local',
|
||||
_envState.remoteHost || '',
|
||||
opts.allowNetwork === false ? 'offline' : 'network',
|
||||
].join('|');
|
||||
const existing = _cachedModelsInFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const pending = _fetchCachedModelsImpl(fresh, opts);
|
||||
_cachedModelsInFlight.set(key, pending);
|
||||
const clearPending = () => {
|
||||
if (_cachedModelsInFlight.get(key) === pending) _cachedModelsInFlight.delete(key);
|
||||
};
|
||||
pending.then(clearPending, clearPending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Closing the modal should not leave an SSH/cache scan running in the
|
||||
// background. Incrementing the fetch id suppresses a completion that races
|
||||
// with the abort signal.
|
||||
export function _cancelCachedModelScan() {
|
||||
_cachedModelsFetchId++;
|
||||
_cachedModelsRequestController?.abort();
|
||||
_cachedModelsRequestController = null;
|
||||
}
|
||||
|
||||
/** Filter presets matching a model repo */
|
||||
function _presetsForModel(presets, repo) {
|
||||
const short = repo.split('/').pop();
|
||||
@@ -4303,3 +4502,51 @@ document.addEventListener('click', (e) => {
|
||||
const repo = pill.dataset.repo || '';
|
||||
if (repo) _openRunningTabForRepo(repo);
|
||||
});
|
||||
|
||||
// Handle Cancel at the document level too. The serve panel is built after an
|
||||
// async cached-model lookup, so a fast click can arrive before its local
|
||||
// listener is attached.
|
||||
if (!window._cookbookServeCancelBound) {
|
||||
window._cookbookServeCancelBound = true;
|
||||
const closeFromCancel = (e) => {
|
||||
const button = e.target.closest?.('.hwfit-serve-cancel, .hwfit-action-panel button');
|
||||
if (!button) return;
|
||||
const panel = button.closest('.hwfit-serve-panel');
|
||||
const item = panel?.closest('.memory-item[data-repo]');
|
||||
const actionPanel = button.closest('.hwfit-action-panel');
|
||||
const isCancel = button.classList.contains('hwfit-serve-cancel')
|
||||
|| button.textContent.trim().toLowerCase() === 'cancel';
|
||||
if (!isCancel) return;
|
||||
if (actionPanel) {
|
||||
const row = actionPanel.previousElementSibling;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
actionPanel.remove();
|
||||
row?.classList.remove('hwfit-row-active');
|
||||
return;
|
||||
}
|
||||
if (!panel || !item) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
try {
|
||||
panel._cleanupServePanel?.();
|
||||
} catch (err) {
|
||||
console.warn('Serve panel cleanup failed:', err);
|
||||
} finally {
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
const list = item.closest('.hwfit-cached-list');
|
||||
if (list) {
|
||||
list.style.minHeight = '';
|
||||
list.style.maxHeight = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
// Capture pointerdown as well as click so touch interaction cannot be
|
||||
// consumed by the expanded card's row handler before Cancel runs.
|
||||
document.addEventListener('pointerdown', closeFromCancel, true);
|
||||
document.addEventListener('click', closeFromCancel, true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Pure Markdown outline parsing kept separate from the editor DOM so heading
|
||||
// detection can be tested without mounting the full document workspace.
|
||||
|
||||
function cleanHeadingLabel(value) {
|
||||
return String(value || '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~`]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseMarkdownOutline(source) {
|
||||
const text = String(source || '').replace(/\r\n?/g, '\n');
|
||||
const lines = text.split('\n');
|
||||
const offsets = [];
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
offsets.push(offset);
|
||||
offset += line.length + 1;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
let fence = null;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1][0];
|
||||
if (!fence) fence = marker;
|
||||
else if (fence === marker) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (fence) continue;
|
||||
|
||||
const atx = line.match(/^ {0,3}(#{1,6})[\t ]+(.+?)\s*$/);
|
||||
if (atx) {
|
||||
const rawLabel = atx[2].replace(/[\t ]+#+[\t ]*$/, '');
|
||||
const label = cleanHeadingLabel(rawLabel);
|
||||
if (label) {
|
||||
entries.push({
|
||||
level: atx[1].length,
|
||||
text: label,
|
||||
start: offsets[index],
|
||||
end: offsets[index] + line.length,
|
||||
line: index,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const setext = line.match(/^ {0,3}(=+|-+)[\t ]*$/);
|
||||
if (setext && index > 0) {
|
||||
const previous = lines[index - 1];
|
||||
const label = cleanHeadingLabel(previous.trim());
|
||||
if (label && !/^ {0,3}(?:[-*_][\t ]*){3,}$/.test(previous)) {
|
||||
entries.push({
|
||||
level: setext[1][0] === '=' ? 1 : 2,
|
||||
text: label,
|
||||
start: offsets[index - 1],
|
||||
end: offsets[index - 1] + previous.length,
|
||||
line: index - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Unicode-aware text statistics shared by source and rich-text documents.
|
||||
|
||||
let _wordSegmenter;
|
||||
let _graphemeSegmenter;
|
||||
|
||||
function _segmenter(granularity) {
|
||||
if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') return null;
|
||||
try {
|
||||
if (granularity === 'word') {
|
||||
_wordSegmenter ||= new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||
return _wordSegmenter;
|
||||
}
|
||||
_graphemeSegmenter ||= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
return _graphemeSegmenter;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function countDocumentWords(value) {
|
||||
const text = String(value ?? '');
|
||||
if (!text.trim()) return 0;
|
||||
const segmenter = _segmenter('word');
|
||||
if (segmenter) {
|
||||
let count = 0;
|
||||
for (const segment of segmenter.segment(text)) {
|
||||
if (segment.isWordLike) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
return (text.match(/[\p{L}\p{N}\p{M}]+(?:['’][\p{L}\p{N}\p{M}]+)*/gu) || []).length;
|
||||
}
|
||||
|
||||
export function countDocumentCharacters(value) {
|
||||
const text = String(value ?? '');
|
||||
const segmenter = _segmenter('grapheme');
|
||||
if (segmenter) return Array.from(segmenter.segment(text)).length;
|
||||
return Array.from(text).length;
|
||||
}
|
||||
|
||||
export function getDocumentStats(value) {
|
||||
const text = String(value ?? '').replace(/\r\n?/g, '\n');
|
||||
const words = countDocumentWords(text);
|
||||
const characters = countDocumentCharacters(text);
|
||||
const charactersNoSpaces = countDocumentCharacters(text.replace(/\s/gu, ''));
|
||||
const lineText = text.replace(/\n+$/u, '');
|
||||
const lines = lineText ? lineText.split('\n').length : 0;
|
||||
return {
|
||||
words,
|
||||
characters,
|
||||
charactersNoSpaces,
|
||||
lines,
|
||||
readingMinutes: words ? Math.max(1, Math.ceil(words / 225)) : 0,
|
||||
};
|
||||
}
|
||||
@@ -184,6 +184,7 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
let _touchItem = null;
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (_pointerId !== null) return;
|
||||
// Don't start on buttons/inputs.
|
||||
if (e.target.closest('button, input, select, a')) return;
|
||||
// Respect handleSelector on touch too — long-press anywhere was
|
||||
@@ -240,7 +241,68 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Modern mobile browsers expose touch as pointer events. Use pointer
|
||||
// capture for pen input so a reorder continues even when the row becomes
|
||||
// absolutely positioned or the pen leaves its original handle. Finger
|
||||
// gestures stay on the touch path below because browsers may cancel a
|
||||
// synthetic pointer stream while preserving touch events.
|
||||
let _pointerId = null;
|
||||
function onPointerDown(e) {
|
||||
if (e.pointerType !== 'pen') return;
|
||||
if (_touchTimer || draggedEl) return;
|
||||
if (e.target.closest('button, input, select, a')) return;
|
||||
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
|
||||
const item = e.target.closest(itemSelector);
|
||||
if (!item || !container.contains(item)) return;
|
||||
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
|
||||
_pointerId = e.pointerId;
|
||||
_touchItem = item;
|
||||
_touchStartY = e.clientY;
|
||||
try { container.setPointerCapture(_pointerId); } catch {}
|
||||
_touchTimer = setTimeout(() => {
|
||||
_touchTimer = null;
|
||||
if (!_touchItem) return;
|
||||
if (navigator.vibrate) navigator.vibrate(30);
|
||||
startDrag(_touchStartY, _touchItem);
|
||||
_touchItem.classList.add('touch-dragging');
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (e.pointerId !== _pointerId) return;
|
||||
if (_touchTimer) {
|
||||
if (Math.abs(e.clientY - _touchStartY) > 10) {
|
||||
clearTimeout(_touchTimer);
|
||||
_touchTimer = null;
|
||||
_touchItem = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (draggedEl) {
|
||||
e.preventDefault();
|
||||
moveDrag(e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerEnd(e) {
|
||||
if (e.pointerId !== _pointerId) return;
|
||||
if (_touchTimer) {
|
||||
clearTimeout(_touchTimer);
|
||||
_touchTimer = null;
|
||||
_touchItem = null;
|
||||
} else if (draggedEl) {
|
||||
draggedEl.classList.remove('touch-dragging');
|
||||
endDrag();
|
||||
}
|
||||
try { container.releasePointerCapture(_pointerId); } catch {}
|
||||
_pointerId = null;
|
||||
}
|
||||
|
||||
container.addEventListener('mousedown', onMouseDown);
|
||||
container.addEventListener('pointerdown', onPointerDown);
|
||||
container.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
container.addEventListener('pointerup', onPointerEnd);
|
||||
container.addEventListener('pointercancel', onPointerEnd);
|
||||
container.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
container.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
container.addEventListener('touchend', onTouchEnd);
|
||||
@@ -249,6 +311,10 @@ export function enable(containerId, itemSelector, options = {}) {
|
||||
const instance = {
|
||||
cleanup: () => {
|
||||
container.removeEventListener('mousedown', onMouseDown);
|
||||
container.removeEventListener('pointerdown', onPointerDown);
|
||||
container.removeEventListener('pointermove', onPointerMove);
|
||||
container.removeEventListener('pointerup', onPointerEnd);
|
||||
container.removeEventListener('pointercancel', onPointerEnd);
|
||||
container.removeEventListener('touchstart', onTouchStart);
|
||||
container.removeEventListener('touchmove', onTouchMove);
|
||||
container.removeEventListener('touchend', onTouchEnd);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/** First-class adjustment-layer rendering and retained metadata helpers. */
|
||||
import { applyAdjustment } from './fx/pixel-pass.js';
|
||||
import { defaultAdjParams } from './layer-helpers.js';
|
||||
import { renderWithLayerMasks } from './composite-helpers.js';
|
||||
|
||||
const TYPES = new Set([
|
||||
'brightness-contrast',
|
||||
'exposure',
|
||||
'white-balance',
|
||||
'hue-saturation',
|
||||
'vibrance',
|
||||
'black-white',
|
||||
'shadows-highlights',
|
||||
'levels',
|
||||
'curves',
|
||||
'color-balance',
|
||||
'selective-color',
|
||||
'gradient-map',
|
||||
]);
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function normalizeAdjustmentData(value) {
|
||||
const type = TYPES.has(value?.type) ? value.type : 'levels';
|
||||
const defaults = defaultAdjParams(type);
|
||||
const params = value?.params && typeof value.params === 'object'
|
||||
? { ...clone(defaults), ...clone(value.params) }
|
||||
: clone(defaults);
|
||||
if (type === 'levels') {
|
||||
params.channels = {
|
||||
...clone(defaults.channels),
|
||||
...(params.channels || {}),
|
||||
};
|
||||
for (const name of Object.keys(defaults.channels)) {
|
||||
params.channels[name] = { ...clone(defaults.channels[name]), ...(params.channels[name] || {}) };
|
||||
}
|
||||
}
|
||||
if (type === 'curves') {
|
||||
params.points = {
|
||||
...clone(defaults.points),
|
||||
...(params.points || {}),
|
||||
};
|
||||
}
|
||||
if (type === 'selective-color') {
|
||||
params.ranges = {
|
||||
...clone(defaults.ranges),
|
||||
...(params.ranges || {}),
|
||||
};
|
||||
for (const name of Object.keys(defaults.ranges)) {
|
||||
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
|
||||
}
|
||||
}
|
||||
return { type, params };
|
||||
}
|
||||
|
||||
export function adjustmentSpec(layer) {
|
||||
if (layer?._stagedAdj) return normalizeAdjustmentData(layer._stagedAdj);
|
||||
return normalizeAdjustmentData(layer?.adjustment);
|
||||
}
|
||||
|
||||
function documentCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the already-rendered content under an adjustment layer with its
|
||||
* adjusted equivalent. Opacity, blend mode, masks, clipping, and group scope
|
||||
* are applied by drawing back into the current stack target.
|
||||
*/
|
||||
export function drawAdjustmentLayer(target, editorState, layer, clippingBase, renderLayer) {
|
||||
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas) return false;
|
||||
// During comparison the underlying stack is already on the target. Leaving
|
||||
// it untouched shows the true before state without creating a history entry.
|
||||
if (layer._adjCompare) return true;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
// Large live previews use a bounded working surface. The staged layer is
|
||||
// marked only while its popup is open; committed rendering stays full-size
|
||||
// so export and reopen remain pixel-accurate.
|
||||
const maxPreviewEdge = 1400;
|
||||
const previewScale = layer._adjPreview
|
||||
? Math.min(1, maxPreviewEdge / Math.max(width, height))
|
||||
: 1;
|
||||
const sourceWidth = Math.max(1, Math.round(width * previewScale));
|
||||
const sourceHeight = Math.max(1, Math.round(height * previewScale));
|
||||
const source = documentCanvas(sourceWidth, sourceHeight);
|
||||
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
|
||||
let adjusted = applyAdjustment(source, adjustmentSpec(layer));
|
||||
if (previewScale !== 1) {
|
||||
const expanded = documentCanvas(width, height);
|
||||
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
|
||||
adjusted = expanded;
|
||||
}
|
||||
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
|
||||
|
||||
if (layer.clipped && clippingBase) {
|
||||
const clipped = documentCanvas(width, height);
|
||||
const clippedCtx = clipped.getContext('2d');
|
||||
clippedCtx.drawImage(adjusted, 0, 0);
|
||||
clippedCtx.globalCompositeOperation = 'destination-in';
|
||||
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
|
||||
clippedCtx.drawImage(renderLayer(clippingBase), offset.x, offset.y);
|
||||
clippedCtx.globalCompositeOperation = 'source-over';
|
||||
adjusted = clipped;
|
||||
}
|
||||
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Async adjustment rendering for live previews and large documents. */
|
||||
export async function drawAdjustmentLayerAsync(target, editorState, layer, clippingBase, renderLayer, shouldContinue = () => true) {
|
||||
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas || !shouldContinue()) return false;
|
||||
if (layer._adjCompare) return true;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
const maxPreviewEdge = 1400;
|
||||
const previewScale = layer._adjPreview
|
||||
? Math.min(1, maxPreviewEdge / Math.max(width, height))
|
||||
: 1;
|
||||
const sourceWidth = Math.max(1, Math.round(width * previewScale));
|
||||
const sourceHeight = Math.max(1, Math.round(height * previewScale));
|
||||
const source = documentCanvas(sourceWidth, sourceHeight);
|
||||
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
|
||||
let adjusted = await renderAdjustmentAsync(source, adjustmentSpec(layer), shouldContinue);
|
||||
if (!shouldContinue() || !adjusted) return false;
|
||||
if (previewScale !== 1) {
|
||||
if (!shouldContinue()) return false;
|
||||
const expanded = documentCanvas(width, height);
|
||||
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
|
||||
adjusted = expanded;
|
||||
}
|
||||
if (!shouldContinue()) return false;
|
||||
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
|
||||
if (!shouldContinue()) return false;
|
||||
if (layer.clipped && clippingBase) {
|
||||
const clipped = documentCanvas(width, height);
|
||||
const clippedCtx = clipped.getContext('2d');
|
||||
clippedCtx.drawImage(adjusted, 0, 0);
|
||||
clippedCtx.globalCompositeOperation = 'destination-in';
|
||||
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
|
||||
const baseOutput = await renderLayer(clippingBase);
|
||||
if (!shouldContinue() || !baseOutput) return false;
|
||||
clippedCtx.drawImage(baseOutput, offset.x, offset.y);
|
||||
clippedCtx.globalCompositeOperation = 'source-over';
|
||||
adjusted = clipped;
|
||||
}
|
||||
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
|
||||
target.globalCompositeOperation = layer.blendMode || 'source-over';
|
||||
target.drawImage(adjusted, 0, 0);
|
||||
target.globalAlpha = 1;
|
||||
target.globalCompositeOperation = 'source-over';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function renderAdjustmentAsync(source, adjustment, shouldContinue) {
|
||||
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
|
||||
return applyAdjustment(source, adjustment);
|
||||
}
|
||||
let sourceBitmap;
|
||||
try {
|
||||
sourceBitmap = await createImageBitmap(source);
|
||||
} catch {
|
||||
return applyAdjustment(source, adjustment);
|
||||
}
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
return null;
|
||||
}
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./adjustments-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch {
|
||||
return shouldContinue() ? applyAdjustment(source, adjustment) : null;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
sourceBitmap.close?.();
|
||||
resolve(result);
|
||||
};
|
||||
worker.onmessage = event => {
|
||||
const { bitmap, error } = event.data || {};
|
||||
if (error || !bitmap || !shouldContinue()) {
|
||||
bitmap?.close?.();
|
||||
finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
|
||||
return;
|
||||
}
|
||||
const output = documentCanvas(source.width, source.height);
|
||||
output.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
finish(output);
|
||||
};
|
||||
// A stale worker must not trigger a full-resolution fallback. The caller
|
||||
// will discard it, and doing the work here makes slider scrubbing worse.
|
||||
worker.onerror = () => finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
|
||||
worker.postMessage({ source: sourceBitmap, adjustment }, [sourceBitmap]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Worker adapter for the shared pure adjustment pixel pass. */
|
||||
|
||||
let applyAdjustment;
|
||||
|
||||
async function render(source, adjustment) {
|
||||
if (!applyAdjustment) {
|
||||
// pixel-pass only needs document.createElement('canvas'), so provide the
|
||||
// smallest worker-local adapter instead of maintaining a second renderer.
|
||||
self.document = { createElement: () => new OffscreenCanvas(source.width, source.height) };
|
||||
({ applyAdjustment } = await import('./fx/pixel-pass.js'));
|
||||
}
|
||||
const input = new OffscreenCanvas(source.width, source.height);
|
||||
input.getContext('2d').drawImage(source, 0, 0);
|
||||
return applyAdjustment(input, adjustment);
|
||||
}
|
||||
|
||||
self.onmessage = async event => {
|
||||
try {
|
||||
const { source, adjustment } = event.data;
|
||||
const output = await render(source, adjustment);
|
||||
const bitmap = output.transferToImageBitmap();
|
||||
self.postMessage({ bitmap }, [bitmap]);
|
||||
} catch (error) {
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
}
|
||||
};
|
||||
@@ -41,27 +41,52 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
|
||||
getSelectedAIEndpoint, ensureActiveMaskLayer,
|
||||
saveState, createLayer, composite, renderLayerPanel,
|
||||
saveState, createLayer, composite, flatten, renderLayerPanel, renderLayer,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
|
||||
async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) {
|
||||
// Pre-check: build the union mask the AI will receive and verify
|
||||
// at least one pixel is painted.
|
||||
const preMerged = buildMergedMaskCanvas();
|
||||
if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
const pmCtx = preMerged.getContext('2d');
|
||||
const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data;
|
||||
let hasMask = false;
|
||||
for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } }
|
||||
if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
let preMerged = buildMergedMaskCanvas();
|
||||
const hasPixels = canvas => canvas && canvas.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data.some((value, index) => index % 4 === 3 && value > 0);
|
||||
let layerSource = null;
|
||||
if (!hasPixels(preMerged)) {
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
if (!layer || layer.kind === 'adjustment') {
|
||||
uiModule?.showToast('Select an image layer to inpaint');
|
||||
return;
|
||||
}
|
||||
const rendered = renderLayer?.(layer) || layer.canvas;
|
||||
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
layerSource = document.createElement('canvas');
|
||||
layerSource.width = state.imgWidth;
|
||||
layerSource.height = state.imgHeight;
|
||||
layerSource.getContext('2d').drawImage(rendered, offset.x, offset.y);
|
||||
preMerged = document.createElement('canvas');
|
||||
preMerged.width = state.imgWidth;
|
||||
preMerged.height = state.imgHeight;
|
||||
const ctx = preMerged.getContext('2d');
|
||||
ctx.drawImage(layerSource, 0, 0);
|
||||
// Preserve transparent cutouts; empty layers use their full bounds.
|
||||
if (hasPixels(preMerged)) ctx.globalCompositeOperation = 'source-in';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(offset.x, offset.y, rendered.width, rendered.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
if (!hasPixels(preMerged)) {
|
||||
uiModule?.showToast('The selected layer is outside the canvas');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const btn = document.getElementById(btnId);
|
||||
const btnLabel = labelId ? document.getElementById(labelId) : null;
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Inpaint cancelled'));
|
||||
if (btnLabel) btnLabel.textContent = busyLabel;
|
||||
let runWp = null;
|
||||
try {
|
||||
@@ -110,16 +135,7 @@ export function wireInpaintButtons({
|
||||
} catch (_) { /* overlay is decorative */ }
|
||||
try {
|
||||
// Flatten current image.
|
||||
const flatCanvas = document.createElement('canvas');
|
||||
flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight;
|
||||
const flatCtx = flatCanvas.getContext('2d');
|
||||
for (const layer of state.layers) {
|
||||
if (!layer.visible) continue;
|
||||
flatCtx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
flatCtx.drawImage(layer.canvas, off.x, off.y);
|
||||
}
|
||||
flatCtx.globalAlpha = 1;
|
||||
const flatCanvas = layerSource || flatten();
|
||||
// Dilate the user's brush mask before sending to the model.
|
||||
// The AI fills a small buffer zone around the brush, so the
|
||||
// post-gen Edge feather slider has AI content to fade INTO
|
||||
@@ -132,7 +148,7 @@ export function wireInpaintButtons({
|
||||
// This way, if the user built up the inpaint region across
|
||||
// multiple masks, the final generation sees the combined
|
||||
// region instead of just the currently-active mask.
|
||||
const mergedMask = buildMergedMaskCanvas() || state.maskCanvas;
|
||||
const mergedMask = preMerged;
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
@@ -141,6 +157,7 @@ export function wireInpaintButtons({
|
||||
baseSnap.height = state.imgHeight;
|
||||
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify((() => {
|
||||
@@ -161,8 +178,9 @@ export function wireInpaintButtons({
|
||||
// unfeathered (AI image + hard mask) on the layer so the live
|
||||
// Feather slider can re-derive the alpha on each input event
|
||||
// without re-running the model.
|
||||
const resultImg = new Image();
|
||||
resultImg.onload = () => {
|
||||
const resultImg = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
@@ -181,9 +199,9 @@ export function wireInpaintButtons({
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
const maskSnap = document.createElement('canvas');
|
||||
maskSnap.width = state.maskCanvas.width;
|
||||
maskSnap.height = state.maskCanvas.height;
|
||||
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
maskSnap.width = mergedMask.width;
|
||||
maskSnap.height = mergedMask.height;
|
||||
maskSnap.getContext('2d').drawImage(mergedMask, 0, 0);
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
@@ -196,7 +214,9 @@ export function wireInpaintButtons({
|
||||
// on each sub-row's eye icon.
|
||||
for (const ly of state.layers) {
|
||||
if (!ly.masks || !ly.masks.length) continue;
|
||||
for (const mk of ly.masks) mk.visible = false;
|
||||
for (const mk of ly.masks) {
|
||||
if (mk.mode !== 'layer') mk.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
@@ -234,15 +254,11 @@ export function wireInpaintButtons({
|
||||
console.error('[inpaint] render error', renderErr);
|
||||
if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000);
|
||||
}
|
||||
};
|
||||
resultImg.onerror = (e) => {
|
||||
console.error('[inpaint] base64 decode failed', e);
|
||||
if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000);
|
||||
};
|
||||
resultImg.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
|
||||
if (!operation.signal.aborted && uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
if (btnLabel) btnLabel.textContent = idleLabel;
|
||||
if (runWp) { try { runWp.destroy(); } catch (_) {} }
|
||||
@@ -270,7 +286,9 @@ export function wireInpaintButtons({
|
||||
// SDXL inpaint pipelines literally try to draw the prompt, so we
|
||||
// send a generic surroundings-matching prompt and crank strength.
|
||||
document.getElementById('ge-inpaint-remove').addEventListener('click', async () => {
|
||||
const sel = getSelectedAIEndpoint('inpaint');
|
||||
let sel;
|
||||
try { sel = getSelectedAIEndpoint('inpaint'); }
|
||||
catch (error) { uiModule?.showToast(error.message); return; }
|
||||
const ep = (sel.endpoint || '').toLowerCase();
|
||||
const isOpenAI = ep.includes('api.openai.com');
|
||||
let prompt, strength;
|
||||
|
||||
@@ -27,11 +27,18 @@
|
||||
import { state } from './state.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export function resolveInpaintModel(select) {
|
||||
const options = Array.from(select?.options || []).filter(option =>
|
||||
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
||||
const explicit = options.find(option => option.value === select.value);
|
||||
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
|
||||
}
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
// - inpaint: image+mask edit (inpaint / img2img)
|
||||
// Some models do only one (e.g. dall-e-3 = gen-only, no edits API).
|
||||
function modelCaps(modelId, endpointName, endpointType) {
|
||||
export function modelCaps(modelId, endpointName, endpointType) {
|
||||
const id = (modelId || '').toLowerCase();
|
||||
const name = (endpointName || '').toLowerCase();
|
||||
const type = (endpointType || '').toLowerCase();
|
||||
@@ -41,7 +48,9 @@ function modelCaps(modelId, endpointName, endpointType) {
|
||||
// OpenAI image family.
|
||||
if (/dall-e-3/.test(id)) return { gen: true, inpaint: false };
|
||||
if (/dall-e-2/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/gpt-image/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/(?:^|\/)(?:gpt-image[^/]*|gpt-[^/]*-image[^/]*|gemini-[^/]*image[^/]*|qwen-image[^/]*)$/.test(id)) {
|
||||
return { gen: true, inpaint: true };
|
||||
}
|
||||
// Diffusion families — most generic SD/SDXL/Flux base models
|
||||
// support both via diffusers.
|
||||
if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) {
|
||||
@@ -95,13 +104,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const prevGenValue = aiGenSelect?.value || '';
|
||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||
const endpoints = await res.json();
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
let firstInpaint = null;
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
for (const ep of endpoints) {
|
||||
@@ -137,10 +146,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||
// Prefer dedicated inpaint/edit models for default selection.
|
||||
if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) {
|
||||
firstInpaint = value;
|
||||
}
|
||||
}
|
||||
// Per-tool selectors get every img2img-capable entry. Both
|
||||
// caps.inpaint AND caps.gen models work for harmonize /
|
||||
@@ -165,7 +170,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
if (aiInpaintSelect) {
|
||||
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
|
||||
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
|
||||
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
|
||||
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
||||
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
||||
}
|
||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||
// bottom of every model dropdown.
|
||||
@@ -221,7 +227,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
// Fetch failed — still give the user the affordance to set up
|
||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||
// hint about what to do next.
|
||||
const fallback = '<option value="">Auto</option><option value="" disabled>──────────</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Capture repeat clicks before tool handlers can start another request.
|
||||
export function beginAIOperation(button, onCancel) {
|
||||
const controller = new AbortController();
|
||||
const title = button.title;
|
||||
button.disabled = false;
|
||||
button.title = 'Cancel running action';
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
const cancel = event => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
button.addEventListener('click', cancel, true);
|
||||
return {
|
||||
signal: controller.signal,
|
||||
finish() {
|
||||
button.removeEventListener('click', cancel, true);
|
||||
button.title = title;
|
||||
button.removeAttribute('aria-busy');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeAIImage(base64, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const cleanup = () => {
|
||||
image.onload = image.onerror = null;
|
||||
signal.removeEventListener('abort', abort);
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(new DOMException('Cancelled', 'AbortError'));
|
||||
};
|
||||
if (signal.aborted) { abort(); return; }
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.onload = () => { cleanup(); resolve(image); };
|
||||
image.onerror = () => { cleanup(); reject(new Error('Failed to decode result image')); };
|
||||
image.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
* @returns {{ buildSelectionHintMask: () => string | null }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { selectionMaskToDocument } from './selection-mask.js';
|
||||
|
||||
export function wireRembgAndSharpen({
|
||||
applyImageTool, openCookbookForDependency,
|
||||
@@ -69,9 +70,9 @@ export function wireRembgAndSharpen({
|
||||
// which to hide after a successful cutout.
|
||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// applyImageTool finishes after fetch but the new layer is added
|
||||
// inside img.onload (one tick later). Poll for up to 60 frames
|
||||
// (~1s) for the new layer to appear before we auto-hide.
|
||||
// applyImageTool resolves after the returned image is decoded and the
|
||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
||||
// with alternate runners that may still commit on the next frame.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
@@ -204,9 +205,13 @@ export function wireRembgAndSharpen({
|
||||
const w = state.imgWidth, h = state.imgHeight;
|
||||
if (state.wandMask && state.wandLayerId) {
|
||||
const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 };
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
c.getContext('2d').drawImage(state.wandMask, off.x, off.y);
|
||||
const c = selectionMaskToDocument(
|
||||
state.wandMask,
|
||||
state.wandMaskSpace || 'layer',
|
||||
off,
|
||||
w,
|
||||
h,
|
||||
);
|
||||
return c.toDataURL('image/png').split(',')[1];
|
||||
}
|
||||
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
* @returns {(endpoint: string, extraPayload: object, layerName: string, btn: HTMLButtonElement, opts?: { busyLabel?: string }) => Promise<void>}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
const KNOWN_DEPS = ['realesrgan', 'rembg'];
|
||||
|
||||
@@ -45,7 +46,7 @@ export function createApplyImageTool({
|
||||
return async function applyImageTool(endpoint, extraPayload, layerName, btn, opts) {
|
||||
const origHTML = btn.innerHTML;
|
||||
const origWidth = btn.offsetWidth; // lock width so the button doesn't jump
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Cancelled'));
|
||||
btn.classList.add('ge-btn-processing');
|
||||
btn.style.minWidth = origWidth + 'px';
|
||||
// Swap label for a "<verbing>…" text + whirlpool while the
|
||||
@@ -67,18 +68,19 @@ export function createApplyImageTool({
|
||||
// Tool-specific model picker — pulled from the per-tool select
|
||||
// (harmonize/style) if available, otherwise the global
|
||||
// fallback. Derived from the endpoint URL.
|
||||
if (!extraPayload._endpoint) {
|
||||
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
|
||||
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
|
||||
const sel = getSelectedAIEndpoint(type);
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
try {
|
||||
if (!extraPayload._endpoint) {
|
||||
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
|
||||
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
|
||||
const sel = getSelectedAIEndpoint(type);
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
const flatCanvas = flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -91,9 +93,10 @@ export function createApplyImageTool({
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (!data.image) throw new Error('No image returned');
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
{
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
layer.ctx.drawImage(img, 0, 0);
|
||||
@@ -102,10 +105,9 @@ export function createApplyImageTool({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
|
||||
};
|
||||
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load result', 6000); };
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} catch (e) {
|
||||
if (operation.signal.aborted) return;
|
||||
// Detect known failure modes and surface an action-toast.
|
||||
const msg = (e?.message || '').toLowerCase();
|
||||
const needsImg2Img = (
|
||||
@@ -137,6 +139,7 @@ export function createApplyImageTool({
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('ge-btn-processing');
|
||||
try { btnSpinner?.destroy(); } catch {}
|
||||
|
||||
@@ -25,16 +25,18 @@
|
||||
* renderLayerPanel: () => void,
|
||||
* spinnerModule: object,
|
||||
* uiModule: object,
|
||||
* openAdjustmentLayer: (type: string, anchor: HTMLElement) => void,
|
||||
* }} deps
|
||||
*
|
||||
* @returns {{ addEmptyLayer: () => void }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireAIToolsMisc({
|
||||
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
|
||||
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
|
||||
spinnerModule, uiModule,
|
||||
spinnerModule, uiModule, openAdjustmentLayer,
|
||||
}) {
|
||||
// ── Harmonize sliders — Color match + Seam fix ──
|
||||
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
|
||||
@@ -103,7 +105,7 @@ export function wireAIToolsMisc({
|
||||
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ge-upscale-ai');
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Upscale cancelled'));
|
||||
let upWp = null;
|
||||
try {
|
||||
upWp = spinnerModule.createWhirlpool(14);
|
||||
@@ -118,6 +120,7 @@ export function wireAIToolsMisc({
|
||||
const flat = flatten();
|
||||
const imageB64 = flat.toDataURL('image/png').split(',')[1];
|
||||
const res = await fetch('/api/image/upscale-local', {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: imageB64, scale: 2 }),
|
||||
@@ -125,8 +128,9 @@ export function wireAIToolsMisc({
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const newW = img.width, newH = img.height;
|
||||
@@ -143,17 +147,18 @@ export function wireAIToolsMisc({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast(`AI upscaled to ${newW}×${newH}`);
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('AI upscale failed: ' + e.message);
|
||||
if (!operation.signal.aborted) uiModule.showToast('AI upscale failed: ' + e.message);
|
||||
} finally {
|
||||
operation.finish();
|
||||
try { upWp?.destroy(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHTML;
|
||||
}
|
||||
try { upWp?.destroy(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHTML;
|
||||
});
|
||||
|
||||
// ── Style transfer ──
|
||||
@@ -165,7 +170,9 @@ export function wireAIToolsMisc({
|
||||
const prompt = document.getElementById('ge-style-prompt').value.trim();
|
||||
if (!prompt) { uiModule.showToast('Enter a style prompt'); return; }
|
||||
const strength = parseInt(document.getElementById('ge-style-strength').value) / 100;
|
||||
btn.disabled = true; btn.textContent = 'Applying...';
|
||||
const originalHTML = btn.innerHTML;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Style transfer cancelled'));
|
||||
btn.textContent = 'Applying...';
|
||||
try {
|
||||
const flat = flatten();
|
||||
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
|
||||
@@ -173,12 +180,13 @@ export function wireAIToolsMisc({
|
||||
fd.append('image', blob, 'style.png');
|
||||
fd.append('prompt', prompt);
|
||||
fd.append('strength', String(strength));
|
||||
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd, signal: operation.signal });
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
|
||||
@@ -188,15 +196,17 @@ export function wireAIToolsMisc({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast('Style applied');
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('Style transfer failed: ' + e.message);
|
||||
if (!operation.signal.aborted) uiModule.showToast('Style transfer failed: ' + e.message);
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Apply Style';
|
||||
});
|
||||
|
||||
// ── Add empty layer (used by the layer-panel header button + the
|
||||
@@ -210,7 +220,52 @@ export function wireAIToolsMisc({
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
}
|
||||
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
|
||||
const addButton = document.getElementById('ge-add-layer');
|
||||
addButton?.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.querySelector('.ge-add-layer-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ge-add-layer-menu ge-frosted';
|
||||
menu.innerHTML = `
|
||||
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
|
||||
<span class="ge-add-layer-divider"></span>
|
||||
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol">▥</span><span>Levels</span></button>
|
||||
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol">⌁</span><span>Curves</span></button>
|
||||
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol">☼</span><span>Exposure</span></button>
|
||||
<button type="button" data-adjustment-type="white-balance"><span class="ge-add-layer-symbol">◑</span><span>White Balance</span></button>
|
||||
<button type="button" data-adjustment-type="brightness-contrast"><span class="ge-add-layer-symbol">◐</span><span>Brightness / Contrast</span></button>
|
||||
<button type="button" data-adjustment-type="hue-saturation"><span class="ge-add-layer-symbol">◉</span><span>Hue / Saturation</span></button>
|
||||
<button type="button" data-adjustment-type="vibrance"><span class="ge-add-layer-symbol">⌁</span><span>Vibrance</span></button>
|
||||
<button type="button" data-adjustment-type="black-white"><span class="ge-add-layer-symbol">◐</span><span>Black & White</span></button>
|
||||
<button type="button" data-adjustment-type="shadows-highlights"><span class="ge-add-layer-symbol">◐</span><span>Shadows / Highlights</span></button>
|
||||
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol">◒</span><span>Color Balance</span></button>
|
||||
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol">◎</span><span>Selective Color</span></button>
|
||||
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol">▰</span><span>Gradient Map</span></button>
|
||||
`;
|
||||
document.body.appendChild(menu);
|
||||
const rect = addButton.getBoundingClientRect();
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
|
||||
const close = () => {
|
||||
menu.remove();
|
||||
document.removeEventListener('pointerdown', away, true);
|
||||
};
|
||||
const away = pointerEvent => {
|
||||
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
|
||||
};
|
||||
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
|
||||
menu.addEventListener('click', clickEvent => {
|
||||
const button = clickEvent.target.closest('button');
|
||||
if (!button) return;
|
||||
clickEvent.preventDefault();
|
||||
clickEvent.stopPropagation();
|
||||
if (button.dataset.layerKind === 'raster') addEmptyLayer();
|
||||
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
|
||||
close();
|
||||
});
|
||||
});
|
||||
|
||||
return { addEmptyLayer };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Device-independent brush sampling. Rendering stays in stroke-pipeline.js;
|
||||
* this module only turns irregular pointer input into evenly spaced samples.
|
||||
*/
|
||||
|
||||
export function normalisePressure(value) {
|
||||
const pressure = Number(value);
|
||||
if (!Number.isFinite(pressure) || pressure <= 0) return 1;
|
||||
return Math.max(0.01, Math.min(1, pressure));
|
||||
}
|
||||
|
||||
export function pressureValue(base, pressure, enabled, minimum = 0.08) {
|
||||
if (!enabled) return base;
|
||||
return base * Math.max(minimum, normalisePressure(pressure));
|
||||
}
|
||||
|
||||
export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit }) {
|
||||
let emitted = null;
|
||||
let filtered = null;
|
||||
let remainder = 0;
|
||||
|
||||
const output = (sample) => {
|
||||
emitted = { ...sample };
|
||||
emit(emitted);
|
||||
};
|
||||
|
||||
const begin = (sample) => {
|
||||
emitted = null;
|
||||
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
remainder = 0;
|
||||
output(filtered);
|
||||
};
|
||||
|
||||
const add = (sample) => {
|
||||
const raw = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
if (!filtered || !emitted) return begin(raw);
|
||||
const smoothing = Math.max(0, Math.min(0.95, Number(getSmoothing()) || 0));
|
||||
const response = 1 - smoothing;
|
||||
filtered = {
|
||||
x: filtered.x + (raw.x - filtered.x) * response,
|
||||
y: filtered.y + (raw.y - filtered.y) * response,
|
||||
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
|
||||
};
|
||||
const start = { ...emitted };
|
||||
const dx = filtered.x - start.x;
|
||||
const dy = filtered.y - start.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (!distance) return;
|
||||
const diameter = Math.max(1, Number(getDiameter()) || 1);
|
||||
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
|
||||
let travelled = spacing - remainder;
|
||||
while (travelled <= distance) {
|
||||
const t = travelled / distance;
|
||||
output({
|
||||
x: start.x + dx * t,
|
||||
y: start.y + dy * t,
|
||||
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
|
||||
});
|
||||
travelled += spacing;
|
||||
}
|
||||
remainder = Math.max(0, distance - (travelled - spacing));
|
||||
};
|
||||
|
||||
const end = (sample) => {
|
||||
if (sample && emitted) {
|
||||
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
|
||||
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
|
||||
}
|
||||
emitted = null;
|
||||
filtered = null;
|
||||
remainder = 0;
|
||||
};
|
||||
|
||||
return { begin, add, end };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const STORAGE_KEY = 'odysseus.editor.brush-presets.v1';
|
||||
|
||||
const BUILT_INS = [
|
||||
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
|
||||
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
|
||||
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
|
||||
];
|
||||
|
||||
export function loadBrushPresets(storage = globalThis.localStorage) {
|
||||
let custom = [];
|
||||
try {
|
||||
const parsed = JSON.parse(storage?.getItem(STORAGE_KEY) || '[]');
|
||||
if (Array.isArray(parsed)) custom = parsed.filter(item => item && typeof item.name === 'string');
|
||||
} catch {}
|
||||
return [...BUILT_INS.map(item => ({ ...item, builtIn: true })), ...custom];
|
||||
}
|
||||
|
||||
export function saveCustomBrushPresets(presets, storage = globalThis.localStorage) {
|
||||
const custom = (presets || []).filter(item => item && !item.builtIn);
|
||||
storage?.setItem(STORAGE_KEY, JSON.stringify(custom));
|
||||
}
|
||||
|
||||
export function captureBrushPreset(state, name) {
|
||||
return {
|
||||
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: String(name || 'Brush').trim() || 'Brush',
|
||||
size: state.brushSize,
|
||||
opacity: state.brushOpacity,
|
||||
flow: state.brushFlow,
|
||||
softness: state.brushSoftness,
|
||||
spacing: state.brushSpacing,
|
||||
smoothing: state.brushSmoothing,
|
||||
blendMode: state.brushBlendMode,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyBrushPreset(state, preset) {
|
||||
if (!preset) return;
|
||||
state.brushSize = preset.size;
|
||||
state.brushOpacity = preset.opacity;
|
||||
state.brushFlow = preset.flow;
|
||||
state.brushSoftness = preset.softness;
|
||||
state.brushSpacing = preset.spacing;
|
||||
state.brushSmoothing = preset.smoothing;
|
||||
state.brushBlendMode = preset.blendMode || 'source-over';
|
||||
}
|
||||
@@ -12,6 +12,22 @@
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div id="ge-pen-section" style="display:none;">
|
||||
<div class="ge-section-title">Pen Selection</div>
|
||||
<div class="ge-control-row ge-actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-commit" title="Close path and make selection (Enter)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> Make selection</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
|
||||
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
||||
<div class="ge-layer-geometry-grid">
|
||||
<label><span>X</span><input id="ge-layer-x" type="number" step="1" inputmode="numeric" /></label>
|
||||
<label><span>Y</span><input id="ge-layer-y" type="number" step="1" inputmode="numeric" /></label>
|
||||
<label><span>W</span><input id="ge-layer-width" type="number" readonly title="Edit width with Transform" /></label>
|
||||
<label><span>H</span><input id="ge-layer-height" type="number" readonly title="Edit height with Transform" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ge-brush-controls">
|
||||
<div class="ge-control-row" id="ge-color-row">
|
||||
<label>Color</label>
|
||||
@@ -20,9 +36,204 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<div class="ge-control-row">
|
||||
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
|
||||
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
|
||||
</div>
|
||||
<div class="ge-section-title">Stroke</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
|
||||
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
|
||||
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-brush-blend">Blend</label>
|
||||
<select id="ge-brush-blend">
|
||||
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option><option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option><option value="color">Color</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-pressure-row">
|
||||
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
|
||||
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
|
||||
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
|
||||
</div>
|
||||
<div class="ge-control-row ge-brush-preset-row">
|
||||
<select id="ge-brush-preset" aria-label="Brush preset"></select>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-brush-preset-delete" title="Delete selected preset" aria-label="Delete selected preset">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-gradient-section" id="ge-gradient-section" style="display:none;">
|
||||
<div class="ge-section-title">Gradient</div>
|
||||
<div class="ge-control-row"><label for="ge-gradient-type">Type</label><select id="ge-gradient-type"><option value="linear-gradient">Linear</option><option value="radial-gradient">Radial</option></select></div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-gradient-start" value="${color}" /></label>
|
||||
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-gradient-end" value="#ffffff" /></label>
|
||||
</div>
|
||||
<div class="ge-control-row ge-gradient-mid-row">
|
||||
<span class="ge-gradient-mid-toggle"><span>Midpoint</span><label class="toggle-switch"><input type="checkbox" id="ge-gradient-mid-enabled" /><span class="toggle-slider"></span></label></span>
|
||||
<input type="color" class="ge-color-picker" id="ge-gradient-mid" value="#808080" title="Midpoint color" disabled />
|
||||
<input type="range" id="ge-gradient-mid-position" min="1" max="99" value="50" title="Midpoint position" disabled />
|
||||
<span id="ge-gradient-mid-position-label">50%</span>
|
||||
</div>
|
||||
<div class="ge-gradient-extra-stops" id="ge-gradient-extra-stops"></div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-gradient-add-stop" id="ge-gradient-add-stop">Add stop</button>
|
||||
<div class="ge-control-row"><label for="ge-gradient-end-alpha">End opacity <span id="ge-gradient-end-alpha-label">100%</span></label><input type="range" id="ge-gradient-end-alpha" min="0" max="100" value="100" /></div>
|
||||
<div class="ge-control-row"><label for="ge-gradient-opacity">Opacity <span id="ge-gradient-opacity-label">100%</span></label><input type="range" id="ge-gradient-opacity" min="0" max="100" value="100" /></div>
|
||||
<p class="ge-section-hint">Drag across the active layer. Switch tools or press Esc to cancel.</p>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-eyedropper-section" style="display:none;">
|
||||
<div class="ge-section-title">Eyedropper</div>
|
||||
<div class="ge-eyedropper-live" id="ge-eyedropper-live" aria-live="polite">
|
||||
<canvas class="ge-eyedropper-loupe" id="ge-eyedropper-loupe" width="84" height="84" aria-hidden="true"></canvas>
|
||||
<span class="ge-eyedropper-live-swatch" id="ge-eyedropper-live-swatch" aria-hidden="true"></span>
|
||||
<span class="ge-eyedropper-live-values">
|
||||
<code id="ge-eyedropper-live-value">Move over the canvas</code>
|
||||
<span id="ge-eyedropper-live-rgb">RGB --</span>
|
||||
<span id="ge-eyedropper-live-hsl">HSL --</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
|
||||
</div>
|
||||
<div class="ge-text-section" id="ge-text-section" style="display:none;">
|
||||
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field ge-text-font-field"><span>Font</span>
|
||||
<select id="ge-text-font">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="system-ui">System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-text-size-field"><span>Size</span>
|
||||
<input id="ge-text-size" type="number" min="1" max="2000" value="48" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-control-row ge-text-format-row">
|
||||
<input type="color" class="ge-color-picker" id="ge-text-color" value="${color}" title="Text color" aria-label="Text color" />
|
||||
<button type="button" class="ge-text-toggle" id="ge-text-bold" title="Bold" aria-pressed="false"><strong>B</strong></button>
|
||||
<button type="button" class="ge-text-toggle" id="ge-text-italic" title="Italic" aria-pressed="false"><em>I</em></button>
|
||||
<span class="ge-text-format-sep"></span>
|
||||
<button type="button" class="ge-text-toggle active" data-text-align="left" title="Align left" aria-pressed="true">⇤</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">↔</button>
|
||||
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">⇥</button>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Line</span>
|
||||
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Spacing</span>
|
||||
<input id="ge-text-letter-spacing" type="number" min="-100" max="500" step="0.5" value="0" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Frame</span>
|
||||
<input id="ge-text-frame-width" type="number" min="1" max="10000" step="1" value="320" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Height</span>
|
||||
<input id="ge-text-frame-height" type="number" min="0" max="10000" step="1" value="0" />
|
||||
</label>
|
||||
<label class="ge-text-field"><span>Vertical</span>
|
||||
<select id="ge-text-vertical-align"><option value="top">Top</option><option value="middle">Middle</option><option value="bottom">Bottom</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Stroke</span>
|
||||
<input id="ge-text-stroke-width" type="number" min="0" max="100" step="1" value="0" />
|
||||
</label>
|
||||
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
|
||||
</div>
|
||||
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
|
||||
<div class="ge-section-title">Shape</div>
|
||||
<div class="ge-shape-types" role="group" aria-label="Shape type">
|
||||
<button type="button" class="ge-text-toggle active" data-shape-type="rectangle" title="Rectangle" aria-pressed="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="1"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="ellipse" title="Ellipse" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="line" title="Line" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="19" x2="20" y2="5"/></svg></button>
|
||||
<button type="button" class="ge-text-toggle" data-shape-type="polygon" title="Polygon" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 9 7-3.5 11h-11L3 10Z"/></svg></button>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
|
||||
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
|
||||
</div>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Fill type</span>
|
||||
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
|
||||
</label>
|
||||
<label class="ge-text-field ge-shape-gradient-angle-field" hidden><span>Angle</span><input id="ge-shape-gradient-angle" type="number" min="-36000" max="36000" step="1" value="0" /></label>
|
||||
</div>
|
||||
<div class="ge-text-grid ge-shape-gradient-fields" hidden>
|
||||
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-start" value="#ffffff" /></label>
|
||||
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-end" value="#000000" /></label>
|
||||
<label class="ge-text-field"><span>Midpoint</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-mid" value="#808080" /></label>
|
||||
<label class="ge-text-field"><span>Position</span><input id="ge-shape-gradient-mid-position" type="number" min="1" max="99" step="1" value="50" /></label>
|
||||
<label class="ge-text-field ge-shape-gradient-mid-enabled"><span>Use midpoint</span><input id="ge-shape-gradient-mid-enabled" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="ge-shape-gradient-extra-stops" id="ge-shape-gradient-extra-stops"></div>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-shape-gradient-add-stop" id="ge-shape-gradient-add-stop">Add stop</button>
|
||||
<div class="ge-text-grid">
|
||||
<label class="ge-text-field"><span>Width</span><input id="ge-shape-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
|
||||
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
|
||||
</div>
|
||||
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
|
||||
</div>
|
||||
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn" data-marquee-shape="ellipse" title="Elliptical marquee" aria-pressed="false">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg>
|
||||
</button>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-marquee-constraint-row">
|
||||
<label for="ge-marquee-constraint">Style</label>
|
||||
<select id="ge-marquee-constraint" title="Marquee sizing style">
|
||||
<option value="free">Free</option>
|
||||
<option value="ratio">Fixed ratio</option>
|
||||
<option value="size">Fixed size</option>
|
||||
</select>
|
||||
<div class="ge-marquee-dimensions" hidden>
|
||||
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||
<label>H <input type="number" id="ge-marquee-height" min="1" step="1" value="1" inputmode="decimal" /></label>
|
||||
<button type="button" class="ge-icon-btn" id="ge-marquee-swap" title="Swap width and height" aria-label="Swap width and height">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4"/><path d="M4 7h16"/><path d="m8 21-4-4 4-4"/><path d="M20 17H4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next lasso combines with the current selection.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">−</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
|
||||
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
|
||||
@@ -34,7 +245,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
@@ -50,6 +261,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
|
||||
</div>
|
||||
@@ -58,6 +270,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
|
||||
@@ -83,7 +296,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
@@ -99,6 +312,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
|
||||
</div>
|
||||
@@ -108,6 +322,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection">∩</button>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
||||
@@ -128,6 +343,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
|
||||
</div>
|
||||
@@ -230,6 +446,17 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<p class="ge-section-hint" style="margin-top:0;">
|
||||
<strong class="ge-clone-hint-desktop">Alt-click</strong><strong class="ge-clone-hint-mobile">Double-tap</strong> to set source · drag to paint
|
||||
</p>
|
||||
<div class="ge-clone-source-status" id="ge-clone-source-status" aria-live="polite">
|
||||
<span id="ge-clone-source-label">No source selected</span>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
|
||||
</div>
|
||||
<div class="ge-control-row">
|
||||
<label for="ge-clone-sample-mode">Sample</label>
|
||||
<select id="ge-clone-sample-mode">
|
||||
<option value="active-layer">Active layer</option>
|
||||
<option value="composite">All visible layers</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
|
||||
@@ -247,7 +474,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
|
||||
<div class="ge-section-title">Brush</div>
|
||||
<div class="ge-section-title"><span>Brush</span></div>
|
||||
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
|
||||
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
|
||||
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
|
||||
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-eraser-row">
|
||||
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
|
||||
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
|
||||
@@ -387,6 +619,10 @@ export function layerPanelHTML() {
|
||||
return `<div class="ge-layers-header">
|
||||
<span class="ge-layers-grab"></span>
|
||||
<span class="ge-layers-title">Layers</span>
|
||||
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
</button>
|
||||
@@ -396,6 +632,49 @@ export function layerPanelHTML() {
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-add-layer" title="Add empty layer">+ Add</button>
|
||||
</div><div class="ge-layers-list" id="ge-layers-list"></div>`;
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-blend-row">
|
||||
<label for="ge-layer-blend">Blend</label>
|
||||
<select id="ge-layer-blend" title="Active layer blend mode">
|
||||
<option value="source-over">Normal</option>
|
||||
<option value="multiply">Multiply</option>
|
||||
<option value="screen">Screen</option>
|
||||
<option value="overlay">Overlay</option>
|
||||
<option value="soft-light">Soft Light</option>
|
||||
<option value="hard-light">Hard Light</option>
|
||||
<option value="darken">Darken</option>
|
||||
<option value="lighten">Lighten</option>
|
||||
<option value="color-dodge">Color Dodge</option>
|
||||
<option value="color-burn">Color Burn</option>
|
||||
<option value="difference">Difference</option>
|
||||
<option value="exclusion">Exclusion</option>
|
||||
<option value="hue">Hue</option>
|
||||
<option value="saturation">Saturation</option>
|
||||
<option value="color">Color</option>
|
||||
<option value="luminosity">Luminosity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
|
||||
<span id="ge-layer-selection-count">2 layers</span>
|
||||
<button class="ge-icon-btn" id="ge-selected-visibility" title="Toggle selected visibility" aria-label="Toggle selected visibility">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn" id="ge-selected-lock" title="Toggle selected lock" aria-label="Toggle selected lock">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn" id="ge-selected-align" title="Align or distribute selected layers" aria-label="Align or distribute selected layers">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16M7 12h10M4 19h16"/><path d="M12 3v18"/></svg>
|
||||
</button>
|
||||
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
|
||||
</div>
|
||||
<div class="ge-layers-list" id="ge-layers-list"></div>
|
||||
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* el.querySelector after appending.
|
||||
*/
|
||||
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
/** Keyboard-shortcuts popover. */
|
||||
export function shortcutsPopupHTML() {
|
||||
return `
|
||||
@@ -22,16 +24,16 @@ export function shortcutsPopupHTML() {
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Tools</h5>
|
||||
<div><kbd>V</kbd> Move</div>
|
||||
<div><kbd>T</kbd> Transform</div>
|
||||
<div><kbd>H</kbd> Hand <span style="opacity:0.5">(hold Space temporarily)</span></div>
|
||||
<div><kbd>Arrow</kbd> Move 1 px <span style="opacity:0.5">(Shift = 10 px)</span></div>
|
||||
<div><kbd>T</kbd> Text</div>
|
||||
<div><kbd>B</kbd> Brush</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.clone}</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
|
||||
<div><kbd>L</kbd> Lasso</div>
|
||||
<div><kbd>W</kbd> Wand</div>
|
||||
<div><kbd>M</kbd> Inpaint</div>
|
||||
<div><kbd>E</kbd> Eraser</div>
|
||||
<div><kbd>${TOOL_SHORTCUTS.marquee}</kbd> Marquee</div>
|
||||
<div><kbd>C</kbd> Crop</div>
|
||||
<div><kbd>S</kbd> Sharpen</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Edit</h5>
|
||||
@@ -40,16 +42,18 @@ export function shortcutsPopupHTML() {
|
||||
<div><kbd>Ctrl</kbd>+<kbd>S</kbd> Save</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size…</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
<h5>Selection</h5>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>A</kbd> Select All</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy to layer</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut lasso</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Delete pixels</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>D</kbd> Deselect</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>C</kbd> Copy</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>X</kbd> Cut</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>J</kbd> Copy selection to layer / duplicate layer</div>
|
||||
<div><kbd>Delete</kbd> Delete pixels</div>
|
||||
<div><kbd>Esc</kbd> Cancel selection / crop</div>
|
||||
</div>
|
||||
<div class="ge-shortcuts-col">
|
||||
@@ -89,8 +93,19 @@ export function historyPanelHTML(historyIcon) {
|
||||
export function canvasSizePromptHTML() {
|
||||
return `
|
||||
<div class="modal-content ge-canvas-prompt">
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New canvas</h4></div>
|
||||
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
|
||||
<div class="modal-body">
|
||||
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
|
||||
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1920x1080"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="1"></rect></svg><span>Landscape</span><strong>1920 × 1080</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1920"><svg class="ge-canvas-preset-icon" width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="1"></rect></svg><span>Story</span><strong>1080 × 1920</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1350"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="1"></rect></svg><span>Portrait</span><strong>1080 × 1350</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="2480x3508"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M6 2h9l3 3v17H6zM15 2v4h4"></path></svg><span>A4</span><strong>2480 × 3508</strong></button>
|
||||
<button type="button" class="ge-canvas-preset" data-canvas-preset="3840x2160"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="1"></rect><path d="M8 22h8M12 20v2"></path></svg><span>4K</span><strong>3840 × 2160</strong></button>
|
||||
</div>
|
||||
<div class="ge-canvas-prompt-section-label ge-canvas-prompt-custom-label">Custom size</div>
|
||||
<label class="ge-canvas-units-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v16H4z"></path><path d="M8 4v16M16 4v16M4 8h16M4 16h16"></path></svg><span>Units</span></span><select id="ge-canvas-prompt-units"><option value="px">Pixels</option><option value="percent">Percent</option></select></label>
|
||||
<div class="ge-canvas-prompt-row">
|
||||
<label class="ge-canvas-prompt-field">
|
||||
<span>Width</span>
|
||||
@@ -102,9 +117,19 @@ export function canvasSizePromptHTML() {
|
||||
<input type="text" id="ge-canvas-prompt-h" inputmode="numeric" value="1024">
|
||||
</label>
|
||||
</div>
|
||||
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
|
||||
<div class="ge-canvas-anchor-options">
|
||||
<div class="ge-canvas-anchor-label">Anchor</div>
|
||||
<div class="ge-canvas-anchor-grid" role="group" aria-label="Canvas anchor">
|
||||
${[['0,0','Top left'],['0.5,0','Top'],['1,0','Top right'],['0,0.5','Left'],['0.5,0.5','Center'],['1,0.5','Right'],['0,1','Bottom left'],['0.5,1','Bottom'],['1,1','Bottom right']].map(([value, label], index) => `<button type="button" class="ge-canvas-anchor${index === 0 ? ' active' : ''}" data-canvas-anchor="${value}" title="${label}" aria-label="${label}"></button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-canvas-resize-options">
|
||||
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
|
||||
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer ge-canvas-prompt-footer">
|
||||
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
|
||||
<button class="confirm-btn confirm-btn-secondary" id="ge-canvas-prompt-cancel">Cancel</button>
|
||||
<button class="confirm-btn confirm-btn-primary" id="ge-canvas-prompt-ok">Create</button>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,10 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
// docked inside the right panel above the layers list.
|
||||
if (window.innerWidth <= 700 && state.container) {
|
||||
state.container.appendChild(controls);
|
||||
// Start canvas-first on phones. The active tool button brings this
|
||||
// sheet back and minimizes Layers, so the two bottom sheets never
|
||||
// compete for the same pointer events on initial open.
|
||||
controls.classList.add('dismissed');
|
||||
}
|
||||
|
||||
// Move every slider-row's value chip out of its <label> and place
|
||||
@@ -102,6 +106,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
{
|
||||
const header = layerPanel.querySelector('.ge-layers-header');
|
||||
if (header) {
|
||||
const claimLayerSheet = () => {
|
||||
if (window.innerWidth <= 700) controls.classList.add('dismissed');
|
||||
};
|
||||
let sy = 0, sx = 0, dragging = false, didSwipe = false;
|
||||
header.addEventListener('touchstart', (e) => {
|
||||
if (window.innerWidth > 700) return;
|
||||
@@ -121,6 +128,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
// expanded → peek → minimized (swipe down)
|
||||
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
|
||||
didSwipe = true;
|
||||
claimLayerSheet();
|
||||
const isExpanded = rightPanel.classList.contains('expanded');
|
||||
const isMinimized = rightPanel.classList.contains('minimized');
|
||||
if (dy < 0) {
|
||||
@@ -143,6 +151,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
|
||||
if (window.innerWidth > 700) return;
|
||||
if (e.target.closest('button')) return;
|
||||
if (didSwipe) { didSwipe = false; return; }
|
||||
claimLayerSheet();
|
||||
// Click cycles between peek and expanded; minimized comes
|
||||
// back to peek (so a tap on the handle always reveals at
|
||||
// least the active layer row).
|
||||
|
||||
@@ -9,32 +9,46 @@
|
||||
* @param {{
|
||||
* currentTool: string,
|
||||
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
|
||||
* onClearSelection: (which: 'lasso'|'wand') => void,
|
||||
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
|
||||
* }} ctx
|
||||
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
|
||||
*/
|
||||
import { TOOL_SHORTCUTS } from '../tool-shortcuts.js';
|
||||
|
||||
export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'ge-toolbar';
|
||||
const tools = [
|
||||
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢', key: 'T' },
|
||||
{ id: 'move', label: 'Move', icon: '✥' },
|
||||
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>' },
|
||||
{ id: 'crop', label: 'Crop', icon: '✂' },
|
||||
{ id: 'transform', label: 'Transform', icon: '⤢' },
|
||||
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>' },
|
||||
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'B' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
|
||||
{ id: 'brush', label: 'Brush', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>' },
|
||||
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>' },
|
||||
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>' },
|
||||
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>' },
|
||||
{ id: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>' },
|
||||
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>' },
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
|
||||
{ id: 'pen', label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈', key: 'S' },
|
||||
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈' },
|
||||
];
|
||||
const toolKeyMap = {};
|
||||
for (const t of tools) {
|
||||
t.key = TOOL_SHORTCUTS[t.id];
|
||||
if (t.sep) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'ge-tool-sep';
|
||||
@@ -47,6 +61,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
|
||||
btn.dataset.tool = t.id;
|
||||
btn.title = t.label + (t.key ? ` (${t.key})` : '');
|
||||
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
|
||||
// Heavy 4-point AI star marker for AI-backed tools — sits just to
|
||||
// the left of the icon so the user can spot AI vs local tools at a
|
||||
// glance now that the "AI Tools" separator is gone.
|
||||
@@ -56,7 +71,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
|
||||
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
|
||||
'</span>'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Build the editor's top bar (undo/redo/history, zoom group, Image
|
||||
* Build the editor's top bar (undo/redo/history, Image
|
||||
* menu, Filter menu, Selection-edge menu, Shortcuts, Import, Save).
|
||||
*
|
||||
* Pure DOM — no module state, no event listeners. All wiring is done
|
||||
@@ -26,34 +26,41 @@ export function buildTopbar() {
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
|
||||
<span class="ge-stacked-label">HISTORY</span>
|
||||
</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out">−</button>
|
||||
<span class="ge-zoom-stack">
|
||||
<span class="ge-zoom-glyph">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</span>
|
||||
<span class="ge-zoom-label">100%</span>
|
||||
</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in">+</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-fit" title="Fit to view" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg></span>
|
||||
<span class="ge-stacked-label">FIT</span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
|
||||
<span class="ge-stacked-label">BEFORE</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Actual size" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph">1:1</span>
|
||||
<span class="ge-stacked-label">SCALE</span>
|
||||
</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-view-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">VIEW</span>
|
||||
</button>
|
||||
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check">✓</span><span>Rulers</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check">✓</span><span>Grid</span></button>
|
||||
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check">✓</span><span>Snap</span></button>
|
||||
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check">✓</span><span>Snap to grid</span></button>
|
||||
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-image-wrap">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">Image ▾</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">IMAGE</span>
|
||||
</button>
|
||||
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-image-action="resize">
|
||||
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas…</span>
|
||||
<span>Canvas Size...</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-image-action="image-size">
|
||||
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
|
||||
<span>Image Size...</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Transform</div>
|
||||
<button class="dropdown-item-compact" data-image-action="rotate-90">
|
||||
@@ -74,9 +81,61 @@ export function buildTopbar() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-selection-wrap">
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">SELECT</span>
|
||||
</button>
|
||||
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="ge-selection-save-row">
|
||||
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
|
||||
<button type="button" class="ge-icon-btn" data-selection-action="save" title="Save current selection" aria-label="Save current selection">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-filter-submenu-label">Saved selections</div>
|
||||
<div id="ge-saved-selection-list" class="ge-saved-selection-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-filter-wrap">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">Filter ▾</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret">▾</span></span>
|
||||
<span class="ge-stacked-label">FILTER</span>
|
||||
</button>
|
||||
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
|
||||
<div class="ge-filter-submenu-label">Retained effects</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
<span>Gaussian Blur (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
|
||||
<span class="dropdown-icon" aria-hidden="true">◈</span>
|
||||
<span>Sharpen (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
|
||||
<span class="dropdown-icon" aria-hidden="true">◐</span>
|
||||
<span>Color Overlay (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
|
||||
<span class="dropdown-icon" aria-hidden="true">◒</span>
|
||||
<span>Drop Shadow (retained)…</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
|
||||
<span class="dropdown-icon" aria-hidden="true">□</span>
|
||||
<span>Stroke (retained)…</span>
|
||||
</button>
|
||||
<div class="ge-filter-submenu-label">Presets</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
|
||||
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
|
||||
<div class="ge-filter-submenu-label">Blur</div>
|
||||
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
|
||||
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
|
||||
@@ -92,10 +151,17 @@ export function buildTopbar() {
|
||||
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-import-topbar" title="Import image as layer">+ Import</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
|
||||
<span class="ge-stacked-label">IMPORT</span>
|
||||
</button>
|
||||
<div class="ge-save-wrap">
|
||||
<button class="ge-btn ge-btn-primary" id="ge-save-menu-btn" title="Save options" style="display:inline-flex;align-items:center;gap:4px;">Save
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
|
||||
<span class="ge-stacked-glyph">
|
||||
<svg hidden class="ge-save-state-icon ge-save-state-dirty" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 3h12l4 4v14H4z"/><path d="M8 3v6h8V3"/><path d="m16 3 6 6m0-6-6 6" stroke="var(--fg)" stroke-width="3"/></svg>
|
||||
<svg class="ge-save-state-icon ge-save-state-saved" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span class="ge-stacked-label ge-save-label" id="ge-draft-status" role="status" aria-live="polite">Saved</span>
|
||||
</button>
|
||||
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
|
||||
<div class="dropdown-section-label">Image</div>
|
||||
@@ -109,9 +175,9 @@ export function buildTopbar() {
|
||||
<span>Save as copy</span>
|
||||
<span class="dropdown-shortcut">Ctrl+Shift+S</span>
|
||||
</button>
|
||||
<button class="dropdown-item-compact" id="ge-download" title="Download PNG to your computer">
|
||||
<button class="dropdown-item-compact" id="ge-download" title="Export an image to your computer">
|
||||
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
|
||||
<span>Download PNG</span>
|
||||
<span>Export image...</span>
|
||||
</button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
<div class="dropdown-section-label">Project</div>
|
||||
@@ -129,3 +195,22 @@ export function buildTopbar() {
|
||||
`;
|
||||
return topBar;
|
||||
}
|
||||
|
||||
export function buildZoomFooter() {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'ge-editor-footer';
|
||||
footer.innerHTML = `
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
|
||||
<span class="ge-zoom-stack" aria-live="off">
|
||||
<span class="ge-zoom-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="ge-zoom-label">100%</span>
|
||||
</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Switch to actual size" aria-label="Fit view; switch to actual size" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph">1:1</span><span class="ge-stacked-label">FIT</span>
|
||||
</button>
|
||||
</div>`;
|
||||
return footer;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,23 @@ export function transformPopupHTML() {
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-popup-body">
|
||||
<div class="ge-transform-field ge-transform-position-field">
|
||||
<label for="ge-transform-x" title="Transform center X">X</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-x" step="1" aria-label="Transform center X" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-x">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Move left">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Move right">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-transform-field ge-transform-position-field">
|
||||
<label for="ge-transform-y" title="Transform center Y">Y</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-y" step="1" aria-label="Transform center Y" />
|
||||
<span class="ge-transform-spin" data-spin-for="ge-transform-y">
|
||||
<button type="button" data-spin="down" tabindex="-1" aria-label="Move up">−</button>
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Move down">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ge-row-break ge-transform-position-break"></div>
|
||||
<div class="ge-transform-field">
|
||||
<label>W</label>
|
||||
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
|
||||
@@ -48,9 +65,24 @@ export function transformPopupHTML() {
|
||||
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
|
||||
</span>
|
||||
</div>
|
||||
<span class="ge-transform-quick" aria-label="Transform actions">
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-v" title="Flip vertically" aria-label="Flip vertically">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m7 8 5-4 5 4"/><path d="m7 16 5 4 5-4"/></svg>
|
||||
</button>
|
||||
<button type="button" class="ge-transform-quick-btn" id="ge-transform-rot-90" title="Rotate 90 degrees (Shift: counter-clockwise)" aria-label="Rotate 90 degrees">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
|
||||
</div>
|
||||
<label class="ge-transform-preserve-field">
|
||||
<input type="checkbox" id="ge-transform-preserve-source" />
|
||||
<span>Preserve source pixels</span>
|
||||
</label>
|
||||
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
* Touch:
|
||||
* touchstart 1 finger → beginDraw
|
||||
* touchmove 1 finger → continueDraw
|
||||
* touchend / touchcancel → endDraw
|
||||
* touchend → endDraw; touchcancel → cancelDraw
|
||||
* touchstart 2 fingers → pinch-zoom + 2-finger pan
|
||||
*
|
||||
* Pan (any free space around the canvas):
|
||||
* pointerdown / pointermove / pointerup on canvas-area, skipping
|
||||
* the canvas + transform overlay + UI elements above them. Sets
|
||||
* canvasArea.dataset.panX/Y + CSS transform on both canvases.
|
||||
* Pan:
|
||||
* Hand tool / held Space / middle mouse can drag directly over the
|
||||
* image. Empty workspace remains draggable with any tool. The same
|
||||
* path drives one-finger Hand-tool panning on touch screens.
|
||||
*
|
||||
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
|
||||
* the pan offset.
|
||||
@@ -30,19 +30,70 @@
|
||||
* beginDraw: (e: Event) => void,
|
||||
* continueDraw: (e: Event) => void,
|
||||
* endDraw: (e?: Event) => void,
|
||||
* cancelDraw?: () => void,
|
||||
* updateBrushCursor: (e: Event) => void,
|
||||
* syncZoomControls?: () => void,
|
||||
* }} ctx
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
applyCanvasPan,
|
||||
isDirectPanIntent,
|
||||
nextPanOffset,
|
||||
syncPanCursor,
|
||||
} from './canvas-navigation.js';
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
|
||||
let canvasWindowBindings;
|
||||
|
||||
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
|
||||
canvasWindowBindings?.abort();
|
||||
canvasWindowBindings = new AbortController();
|
||||
const { signal } = canvasWindowBindings;
|
||||
let suppressMouseUntil = 0;
|
||||
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
|
||||
// the WINDOW so a drag can continue (and end) past the canvas edge.
|
||||
// Critical for the Resize tool where users overshoot.
|
||||
state.mainCanvas.addEventListener('mousedown', beginDraw);
|
||||
window.addEventListener('mousemove', continueDraw);
|
||||
window.addEventListener('mouseup', endDraw);
|
||||
state.mainCanvas.addEventListener('mousedown', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
if (isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
|
||||
beginDraw(e);
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
continueDraw(e);
|
||||
}, { signal });
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (Date.now() < suppressMouseUntil) return;
|
||||
endDraw(e);
|
||||
}, { signal });
|
||||
// Preserve pressure and browser-coalesced samples for pen input.
|
||||
// Compatibility mouse events are briefly suppressed to avoid a
|
||||
// duplicate stroke after pointerup.
|
||||
let activePenId = null;
|
||||
state.mainCanvas.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType !== 'pen') return;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
activePenId = e.pointerId;
|
||||
try { state.mainCanvas.setPointerCapture(activePenId); } catch {}
|
||||
beginDraw(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
state.mainCanvas.addEventListener('pointermove', (e) => {
|
||||
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
|
||||
continueDraw(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
const endPen = (e) => {
|
||||
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
|
||||
if (e.type === 'pointercancel') cancelDraw?.();
|
||||
else endDraw(e);
|
||||
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
|
||||
activePenId = null;
|
||||
suppressMouseUntil = Date.now() + 500;
|
||||
e.preventDefault();
|
||||
};
|
||||
state.mainCanvas.addEventListener('pointerup', endPen);
|
||||
state.mainCanvas.addEventListener('pointercancel', endPen);
|
||||
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
|
||||
// surrounding canvas-area so the user can begin a lasso path in
|
||||
// the empty space around the image. Other tools stay canvas-only.
|
||||
@@ -52,12 +103,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
beginDraw(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseenter', (e) => {
|
||||
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].includes(state.tool)) updateBrushCursor(e);
|
||||
if (state.tool === 'eyedropper') updateEyedropperPreview?.(e);
|
||||
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(state.tool)) updateBrushCursor(e);
|
||||
});
|
||||
state.mainCanvas.addEventListener('mouseleave', () => {
|
||||
// Only hide the brush-cursor overlay on leave — DO NOT end the
|
||||
// drag, so the user can drag a resize handle past the canvas edge.
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
if (state.tool === 'eyedropper') updateEyedropperPreview?.(null, true);
|
||||
if (state.tool === 'transform' && state.hoveredHandle) {
|
||||
state.hoveredHandle = null;
|
||||
state.mainCanvas.style.cursor = 'default';
|
||||
// The frame is drawn on a separate overlay, so clear its hover state
|
||||
// explicitly when the pointer leaves the source canvas.
|
||||
continueDraw?.({ clientX: -1, clientY: -1, target: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Touch — single finger draws; two fingers pan + pinch-zoom.
|
||||
@@ -74,13 +134,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const dy = t2.clientY - t1.clientY;
|
||||
return { cx, cy, dist: Math.hypot(dx, dy) };
|
||||
};
|
||||
const applyCanvasOffset = (x, y) => {
|
||||
canvasArea.dataset.panX = String(x);
|
||||
canvasArea.dataset.panY = String(y);
|
||||
const t = `translate3d(${x}px, ${y}px, 0)`;
|
||||
state.mainCanvas.style.transform = t;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = t;
|
||||
};
|
||||
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
|
||||
state.mainCanvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length >= 2) {
|
||||
@@ -91,13 +145,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
multiStartDist = info.dist;
|
||||
multiStartZoom = state.zoom;
|
||||
multiStartCenter = { x: info.cx, y: info.cy };
|
||||
multiStartPan = {
|
||||
x: parseFloat(canvasArea.dataset.panX || '0') || 0,
|
||||
y: parseFloat(canvasArea.dataset.panY || '0') || 0,
|
||||
};
|
||||
multiStartPan = { x: state.panX || 0, y: state.panY || 0 };
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
if (state.tool === 'hand') return;
|
||||
beginDraw(e);
|
||||
}, { passive: false });
|
||||
state.mainCanvas.addEventListener('touchmove', (e) => {
|
||||
@@ -113,10 +165,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
const label = state.container.querySelector('.ge-zoom-label');
|
||||
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
|
||||
syncZoomControls?.();
|
||||
onViewportChange?.();
|
||||
}
|
||||
const dx = info.cx - multiStartCenter.x;
|
||||
const dy = info.cy - multiStartCenter.y;
|
||||
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
|
||||
onViewportChange?.();
|
||||
return;
|
||||
}
|
||||
if (multiActive) return;
|
||||
@@ -131,67 +185,119 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
|
||||
});
|
||||
state.mainCanvas.addEventListener('touchcancel', () => {
|
||||
multiActive = false;
|
||||
endDraw();
|
||||
cancelDraw?.();
|
||||
});
|
||||
|
||||
// Press-and-drag in the empty space AROUND the canvas pans the
|
||||
// canvas + overlay via CSS transform. Works even when the image
|
||||
// fits the viewport (no scroll needed). Skips presses on the canvas
|
||||
// itself (the canvas owns its own drawing input) or on UI elements
|
||||
// above it.
|
||||
// Direct pan gestures own the image as well as the surrounding area.
|
||||
// With other tools only empty workspace pans, preserving normal edit input.
|
||||
let panning = false;
|
||||
let pid = null;
|
||||
let transformPointerId = null;
|
||||
let startX = 0, startY = 0;
|
||||
const getOffset = () => {
|
||||
const v = canvasArea.dataset.panX || '0';
|
||||
const u = canvasArea.dataset.panY || '0';
|
||||
return { x: parseFloat(v) || 0, y: parseFloat(u) || 0 };
|
||||
};
|
||||
let startPanX = 0, startPanY = 0;
|
||||
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
|
||||
const applyOffset = (x, y) => {
|
||||
canvasArea.dataset.panX = String(x);
|
||||
canvasArea.dataset.panY = String(y);
|
||||
const t = `translate3d(${x}px, ${y}px, 0)`;
|
||||
state.mainCanvas.style.transform = t;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = t;
|
||||
const result = applyCanvasPan(state, canvasArea, x, y);
|
||||
onViewportChange?.();
|
||||
return result;
|
||||
};
|
||||
canvasArea.addEventListener('pointerdown', (e) => {
|
||||
if (state.tool === 'lasso') return;
|
||||
if (e.target === state.mainCanvas || e.target === state.transformOverlay) return;
|
||||
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
|
||||
if (state.tool === 'lasso' && !directPan) return;
|
||||
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
|
||||
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
|
||||
if (onImage && !directPan) return;
|
||||
// During an active transform the corner/rotation handles render
|
||||
// OUTSIDE the canvas (over the surrounding area), and the overlay is
|
||||
// pointer-events:none — so a grab on an outside handle lands here.
|
||||
// Route it to the transform tool (getHandleAt works in image space,
|
||||
// even for points beyond the canvas) instead of panning the canvas.
|
||||
if (state.transformActive) {
|
||||
if (state.transformActive && !directPan) {
|
||||
beginDraw(e);
|
||||
// Only swallow the event (skip pan) if a handle was grabbed OR the
|
||||
// layer-move fallback engaged; otherwise let the pan logic below
|
||||
// run so empty space still pans while the transform tool is open.
|
||||
if (state.transformHandle || state.moving) return;
|
||||
if (state.transformHandle || state.moving) {
|
||||
// Mouse drags already continue on window mousemove. Pen/touch drags
|
||||
// that start on an outside-canvas handle need pointer capture so the
|
||||
// session survives leaving the editor surface.
|
||||
if (e.pointerType && e.pointerType !== 'mouse') {
|
||||
transformPointerId = e.pointerId;
|
||||
try { canvasArea.setPointerCapture(transformPointerId); } catch {}
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const off = getOffset();
|
||||
panning = true;
|
||||
pid = e.pointerId;
|
||||
startX = e.clientX - off.x;
|
||||
startY = e.clientY - off.y;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startPanX = off.x;
|
||||
startPanY = off.y;
|
||||
try { canvasArea.setPointerCapture(pid); } catch {}
|
||||
canvasArea.style.cursor = 'grabbing';
|
||||
state.navigationPanning = true;
|
||||
syncPanCursor(state, canvasArea, true);
|
||||
e.preventDefault();
|
||||
});
|
||||
canvasArea.addEventListener('pointermove', (e) => {
|
||||
if (transformPointerId !== null && e.pointerId === transformPointerId) {
|
||||
continueDraw(e);
|
||||
return;
|
||||
}
|
||||
if (!panning || e.pointerId !== pid) return;
|
||||
applyOffset(e.clientX - startX, e.clientY - startY);
|
||||
const next = nextPanOffset(
|
||||
{ x: startX, y: startY },
|
||||
{ x: startPanX, y: startPanY },
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
);
|
||||
applyOffset(next.x, next.y);
|
||||
});
|
||||
const endPan = () => {
|
||||
const endPan = (e) => {
|
||||
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
|
||||
const capturedId = transformPointerId;
|
||||
transformPointerId = null;
|
||||
if (e?.type === 'pointercancel') cancelDraw?.();
|
||||
else endDraw(e);
|
||||
try { canvasArea.releasePointerCapture(capturedId); } catch {}
|
||||
}
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
try { canvasArea.releasePointerCapture(pid); } catch {}
|
||||
pid = null;
|
||||
canvasArea.style.cursor = '';
|
||||
state.navigationPanning = false;
|
||||
syncPanCursor(state, canvasArea, false);
|
||||
};
|
||||
canvasArea.addEventListener('pointerup', endPan);
|
||||
canvasArea.addEventListener('pointercancel', endPan);
|
||||
// Treat focus loss as release: preserve the work already drawn, but never
|
||||
// resume the gesture when the user returns without a fresh pointer press.
|
||||
window.addEventListener('blur', () => {
|
||||
if (!state.editorOpen || !canvasArea.contains(state.mainCanvas)) return;
|
||||
endDraw();
|
||||
if (activePenId !== null) {
|
||||
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
|
||||
activePenId = null;
|
||||
}
|
||||
multiActive = false;
|
||||
endPan();
|
||||
state.spacePanActive = false;
|
||||
syncPanCursor(state, canvasArea, false);
|
||||
if (state.cursorEl) state.cursorEl.style.display = 'none';
|
||||
}, { signal });
|
||||
// Reset offset whenever zoom/fit changes the canvas size.
|
||||
canvasArea._resetPan = () => applyOffset(0, 0);
|
||||
const navigation = {
|
||||
resetPan: canvasArea._resetPan,
|
||||
setTemporaryPan(active) {
|
||||
state.spacePanActive = !!active;
|
||||
syncPanCursor(state, canvasArea, panning);
|
||||
},
|
||||
updateCursor() {
|
||||
syncPanCursor(state, canvasArea, panning);
|
||||
},
|
||||
};
|
||||
navigation.updateCursor();
|
||||
return navigation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Canvas viewport navigation shared by mouse, pen, and touch input. */
|
||||
export function isDirectPanIntent(tool, temporaryPan, button = 0) {
|
||||
return tool === 'hand' || temporaryPan === true || button === 1;
|
||||
}
|
||||
|
||||
export function nextPanOffset(startPointer, startPan, currentPointer) {
|
||||
return {
|
||||
x: startPan.x + currentPointer.x - startPointer.x,
|
||||
y: startPan.y + currentPointer.y - startPointer.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCanvasPan(state, canvasArea, x, y) {
|
||||
const panX = Number.isFinite(Number(x)) ? Number(x) : 0;
|
||||
const panY = Number.isFinite(Number(y)) ? Number(y) : 0;
|
||||
state.panX = panX;
|
||||
state.panY = panY;
|
||||
canvasArea.dataset.panX = String(panX);
|
||||
canvasArea.dataset.panY = String(panY);
|
||||
const transform = `translate3d(${panX}px, ${panY}px, 0)`;
|
||||
if (state.mainCanvas) state.mainCanvas.style.transform = transform;
|
||||
if (state.selectionOverlay) state.selectionOverlay.style.transform = transform;
|
||||
if (state.transformOverlay) state.transformOverlay.style.transform = transform;
|
||||
return { x: panX, y: panY };
|
||||
}
|
||||
|
||||
export function syncPanCursor(state, canvasArea, panning = false) {
|
||||
const ready = state.tool === 'hand' || state.spacePanActive === true;
|
||||
canvasArea.classList.toggle('ge-pan-ready', ready && !panning);
|
||||
canvasArea.classList.toggle('ge-panning', panning);
|
||||
}
|
||||
@@ -14,11 +14,15 @@
|
||||
* fitZoom: () => void,
|
||||
* showCanvasLoading: (label: string) => void,
|
||||
* hideCanvasLoading: () => void,
|
||||
* renderLayerPanel: () => void,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { flipDocument, rotateDocument } from './document-geometry.js';
|
||||
|
||||
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
|
||||
export function createCanvasTransforms({
|
||||
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
|
||||
}) {
|
||||
return {
|
||||
/**
|
||||
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
|
||||
@@ -34,61 +38,14 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
|
||||
if (!state.layers.length) return;
|
||||
saveState(`Rotate ${deg}°`);
|
||||
showCanvasLoading('Rotating…');
|
||||
const oldW = state.imgWidth, oldH = state.imgHeight;
|
||||
const swap = (deg === 90 || deg === 270);
|
||||
const newW = swap ? oldH : oldW;
|
||||
const newH = swap ? oldW : oldH;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
for (const layer of state.layers) {
|
||||
const lw = layer.canvas.width, lh = layer.canvas.height;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
// Layer centre in old image coords.
|
||||
const cx = off.x + lw / 2;
|
||||
const cy = off.y + lh / 2;
|
||||
// Rotate the centre around the old image centre and
|
||||
// translate so the new image centre lands at (newW/2, newH/2).
|
||||
const dx = cx - oldW / 2;
|
||||
const dy = cy - oldH / 2;
|
||||
const nx = dx * cos - dy * sin + newW / 2;
|
||||
const ny = dx * sin + dy * cos + newH / 2;
|
||||
// New per-layer dims: swap when 90/270.
|
||||
const newLw = swap ? lh : lw;
|
||||
const newLh = swap ? lw : lh;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = newLw; tmp.height = newLh;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.translate(newLw / 2, newLh / 2);
|
||||
tctx.rotate(rad);
|
||||
tctx.drawImage(layer.canvas, -lw / 2, -lh / 2);
|
||||
layer.canvas.width = newLw;
|
||||
layer.canvas.height = newLh;
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// The adjustment-render caches are keyed only by the adjustment
|
||||
// signature, which rotation doesn't change — so composite would draw
|
||||
// the STALE pre-rotation cache (the "had to click twice" bug). Drop
|
||||
// them so the next composite re-renders from the rotated canvas.
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinalKey = null;
|
||||
state.layerOffsets.set(layer.id, {
|
||||
x: Math.round(nx - newLw / 2),
|
||||
y: Math.round(ny - newLh / 2),
|
||||
});
|
||||
}
|
||||
state.imgWidth = newW;
|
||||
state.imgHeight = newH;
|
||||
state.mainCanvas.width = newW;
|
||||
state.mainCanvas.height = newH;
|
||||
if (state.maskCanvas) {
|
||||
state.maskCanvas.width = newW;
|
||||
state.maskCanvas.height = newH;
|
||||
}
|
||||
const result = rotateDocument(state, deg);
|
||||
const sizeLabel = document.getElementById('ge-canvas-size');
|
||||
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
|
||||
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
|
||||
fitZoom();
|
||||
composite();
|
||||
renderLayerPanel?.();
|
||||
} finally {
|
||||
hideCanvasLoading();
|
||||
}
|
||||
@@ -103,30 +60,9 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
|
||||
flipAll(axis) {
|
||||
if (!state.layers.length) return;
|
||||
saveState(axis === 'h' ? 'Flip horizontal' : 'Flip vertical');
|
||||
for (const layer of state.layers) {
|
||||
const lw = layer.canvas.width, lh = layer.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = lw; tmp.height = lh;
|
||||
const tctx = tmp.getContext('2d');
|
||||
tctx.save();
|
||||
if (axis === 'h') { tctx.translate(lw, 0); tctx.scale(-1, 1); }
|
||||
else { tctx.translate(0, lh); tctx.scale(1, -1); }
|
||||
tctx.drawImage(layer.canvas, 0, 0);
|
||||
tctx.restore();
|
||||
layer.ctx.clearRect(0, 0, lw, lh);
|
||||
layer.ctx.drawImage(tmp, 0, 0);
|
||||
// Invalidate the adjustment-render caches (keyed by adjustment sig only)
|
||||
// so composite redraws from the flipped canvas, not a stale cache.
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinalKey = null;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
if (axis === 'h') {
|
||||
state.layerOffsets.set(layer.id, { x: state.imgWidth - off.x - lw, y: off.y });
|
||||
} else {
|
||||
state.layerOffsets.set(layer.id, { x: off.x, y: state.imgHeight - off.y - lh });
|
||||
}
|
||||
}
|
||||
flipDocument(state, axis);
|
||||
composite();
|
||||
renderLayerPanel?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,58 +22,74 @@
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* handleImportedImage: (img: HTMLImageElement) => void,
|
||||
* handleImportedImage: (img: HTMLImageElement, sourceName?: string) => void,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
|
||||
|
||||
let clipboardBindings;
|
||||
|
||||
export function wireClipboardAndDrop({
|
||||
container, saveState, createLayer, renderLayerPanel, composite,
|
||||
handleImportedImage, uiModule,
|
||||
}) {
|
||||
clipboardBindings?.abort();
|
||||
clipboardBindings = new AbortController();
|
||||
const { signal } = clipboardBindings;
|
||||
// ── Paste ──
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (!state.editorOpen || state.container !== container) return;
|
||||
// Editable fields keep native paste. The Clipboard button focuses the
|
||||
// canvas container before asking for a keyboard paste when read() is
|
||||
// unavailable on an insecure origin.
|
||||
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
|
||||
|
||||
function pasteAsLayer(imgSource, label) {
|
||||
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
|
||||
if (!state.editorOpen) return; // user closed mid-paste
|
||||
saveState();
|
||||
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
|
||||
layer.ctx.drawImage(imgSource, 0, 0);
|
||||
// Selection clipboard data is already a complete layer-sized surface.
|
||||
// Keep it source-backed with an identity matrix so future transforms do
|
||||
// not repeatedly resample the pasted pixels.
|
||||
layer.kind = 'placed';
|
||||
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, offset.x, offset.y], label || 'Pasted');
|
||||
const rendered = renderPlacedLayer(layer);
|
||||
state.layerOffsets.set(layer.id, rendered.offset);
|
||||
state.layers.push(layer);
|
||||
state.activeLayerId = layer.id;
|
||||
state.tool = 'move';
|
||||
state.selectedLayerIds = [layer.id];
|
||||
state.activeGroupId = null;
|
||||
const tb = state.container?.querySelector('.ge-toolbar');
|
||||
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
|
||||
tb?.querySelector('[data-tool="move"]')?.click();
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
}
|
||||
|
||||
// Check internal clipboard first (from Ctrl+C lasso/wand).
|
||||
if (state.internalClipboard) {
|
||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to system clipboard.
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const blob = item.getAsFile();
|
||||
const blob = imageItem.getAsFile();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
|
||||
// External clipboard images follow the same source-backed import path
|
||||
// as files, gallery images, and drops. Internal selection clipboard
|
||||
// content is handled above as an identity placed layer.
|
||||
img.onload = () => { handleImportedImage(img, 'Pasted image'); URL.revokeObjectURL(url); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); uiModule?.showToast('Failed to load clipboard image'); };
|
||||
img.src = url;
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}, true); // capture phase so we beat chat input
|
||||
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
}
|
||||
}, { capture: true, signal });
|
||||
|
||||
// ── Drag-and-drop ──
|
||||
// Visual drop-zone overlay appears mid-drag; routes via
|
||||
|
||||
@@ -72,6 +72,7 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
|
||||
for (const ly of layers) {
|
||||
if (!ly.masks || !ly.masks.length) continue;
|
||||
for (const mk of ly.masks) {
|
||||
if (mk.mode === 'layer') continue;
|
||||
if (!mk.visible) continue;
|
||||
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
|
||||
ctx.drawImage(mk.canvas, 0, 0);
|
||||
@@ -81,3 +82,35 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return anyMask ? out : null;
|
||||
}
|
||||
|
||||
|
||||
/** Apply visible true layer masks to an already-adjusted layer canvas. */
|
||||
export function renderWithLayerMasks(source, layer, layerOffset = { x: 0, y: 0 }) {
|
||||
const masks = (layer?.masks || []).filter(mask => mask.mode === 'layer' && mask.visible !== false);
|
||||
if (!source || masks.length === 0) return source;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = source.width;
|
||||
out.height = source.height;
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
for (const mask of masks) {
|
||||
ctx.globalAlpha = Number.isFinite(Number(mask.density))
|
||||
? Math.max(0, Math.min(1, Number(mask.density)))
|
||||
: 1;
|
||||
const feather = Number.isFinite(Number(mask.feather))
|
||||
? Math.max(0, Math.min(200, Number(mask.feather)))
|
||||
: 0;
|
||||
ctx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
|
||||
if (mask.space === 'document') {
|
||||
ctx.drawImage(mask.canvas, -(layerOffset.x || 0), -(layerOffset.y || 0));
|
||||
} else {
|
||||
const offset = mask.offset && typeof mask.offset === 'object' ? mask.offset : { x: 0, y: 0 };
|
||||
ctx.drawImage(mask.canvas, Number(offset.x) || 0, Number(offset.y) || 0);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Shared lifecycle guard for frame-style pointer interactions. */
|
||||
|
||||
export function createDirectManipulationSession({
|
||||
name = 'interaction',
|
||||
getContext = () => null,
|
||||
isContextCurrent = () => true,
|
||||
onCancel = () => {},
|
||||
} = {}) {
|
||||
let active = null;
|
||||
let nextId = 1;
|
||||
|
||||
const pointerMatches = event => {
|
||||
if (!active || active.pointerId == null || event?.pointerId == null) return true;
|
||||
return active.pointerId === event.pointerId;
|
||||
};
|
||||
|
||||
const releaseCapture = session => {
|
||||
if (!session?.captureTarget || session.pointerId == null) return;
|
||||
try { session.captureTarget.releasePointerCapture(session.pointerId); } catch {}
|
||||
};
|
||||
|
||||
const contextIsCurrent = () => active && isContextCurrent(active.context, active.data);
|
||||
|
||||
function begin(event, data = {}, options = {}) {
|
||||
if (active) cancel('restarted');
|
||||
const pointerId = Number.isFinite(event?.pointerId) ? event.pointerId : null;
|
||||
const captureTarget = options.captureTarget || null;
|
||||
active = {
|
||||
id: nextId++,
|
||||
name,
|
||||
pointerId,
|
||||
captureTarget,
|
||||
context: getContext(),
|
||||
data,
|
||||
};
|
||||
if (captureTarget && pointerId != null) {
|
||||
try { captureTarget.setPointerCapture(pointerId); } catch {}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function update(event, callback) {
|
||||
if (!active || !pointerMatches(event)) return false;
|
||||
if (!contextIsCurrent()) {
|
||||
cancel('stale-context');
|
||||
return false;
|
||||
}
|
||||
callback?.(active.data, active);
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit(event, callback) {
|
||||
if (!active || !pointerMatches(event)) return false;
|
||||
if (!contextIsCurrent()) {
|
||||
cancel('stale-context');
|
||||
return false;
|
||||
}
|
||||
const completed = active;
|
||||
active = null;
|
||||
releaseCapture(completed);
|
||||
callback?.(completed.data, completed);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancelled') {
|
||||
if (!active) return false;
|
||||
const cancelled = active;
|
||||
active = null;
|
||||
releaseCapture(cancelled);
|
||||
onCancel(cancelled.data, reason, cancelled);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
begin,
|
||||
update,
|
||||
commit,
|
||||
cancel,
|
||||
get active() { return active; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* Lossless, versioned editor document serialization.
|
||||
*
|
||||
* Draft autosave and downloaded project files must use this same boundary.
|
||||
* Keeping a second hand-written layer shape is how masks and adjustments were
|
||||
* previously dropped when a document was reopened.
|
||||
*/
|
||||
import { normalizeAdjustmentData } from './adjustment-layer.js';
|
||||
import { normalizeEffect } from './effects.js';
|
||||
import { normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js';
|
||||
|
||||
export const EDITOR_DOCUMENT_VERSION = 15;
|
||||
export const EDITOR_PROJECT_MAX_BYTES = 256 * 1024 * 1024;
|
||||
export const EDITOR_MAX_DIMENSION = 32768;
|
||||
export const EDITOR_MAX_DOCUMENT_PIXELS = 100_000_000;
|
||||
export const EDITOR_MAX_SURFACE_PIXELS = 300_000_000;
|
||||
export const EDITOR_MAX_LAYERS = 256;
|
||||
export const EDITOR_MAX_MASKS_PER_LAYER = 64;
|
||||
export const EDITOR_MAX_GROUPS = 128;
|
||||
export const EDITOR_MAX_SAVED_SELECTIONS = 64;
|
||||
|
||||
const MAX_SERIALIZED_GUIDES = 1000;
|
||||
const SUPPORTED_BLEND_MODES = new Set([
|
||||
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
|
||||
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
|
||||
'hue', 'saturation', 'color', 'luminosity',
|
||||
]);
|
||||
|
||||
function normalizeGuideValues(values, fallback = []) {
|
||||
const source = Array.isArray(values) ? values : fallback;
|
||||
const normalized = [];
|
||||
for (const raw of Array.isArray(source) ? source : []) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) continue;
|
||||
if (!normalized.some(existing => Math.abs(existing - value) < 0.0001)) normalized.push(value);
|
||||
if (normalized.length >= MAX_SERIALIZED_GUIDES) break;
|
||||
}
|
||||
return normalized.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function normalizeEditorView(view, fallback = {}) {
|
||||
const source = view && typeof view === 'object' ? view : {};
|
||||
const fallbackGuides = fallback.guides && typeof fallback.guides === 'object'
|
||||
? fallback.guides
|
||||
: { vertical: [], horizontal: [] };
|
||||
const guides = source.guides && typeof source.guides === 'object' ? source.guides : {};
|
||||
const rawGridSize = Number(source.gridSize ?? fallback.gridSize ?? 16);
|
||||
return {
|
||||
rulersVisible: Boolean(source.rulersVisible ?? fallback.rulersVisible ?? true),
|
||||
gridVisible: Boolean(source.gridVisible ?? fallback.gridVisible ?? false),
|
||||
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
|
||||
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
|
||||
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
|
||||
guides: {
|
||||
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
|
||||
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDocumentValue(value, fallback) {
|
||||
try {
|
||||
return value == null ? fallback : JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorDocumentError extends Error {
|
||||
constructor(message, code = 'invalid-document') {
|
||||
super(message);
|
||||
this.name = 'EditorDocumentError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const MIGRATIONS = new Map([
|
||||
[1, document => ({
|
||||
...document,
|
||||
v: 2,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
offset: { x: 0, y: 0 },
|
||||
...layer,
|
||||
dataUrl: layer?.dataUrl || layer?.dataURL || null,
|
||||
})),
|
||||
})],
|
||||
[2, document => ({
|
||||
...document,
|
||||
v: 3,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
adjustments: {},
|
||||
adjLayers: [],
|
||||
activeMaskId: null,
|
||||
masks: [],
|
||||
...layer,
|
||||
})),
|
||||
})],
|
||||
[3, document => ({
|
||||
...document,
|
||||
v: 4,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
kind: 'raster',
|
||||
text: null,
|
||||
...layer,
|
||||
masks: (layer?.masks || []).map(mask => ({
|
||||
mode: 'selection',
|
||||
...mask,
|
||||
space: mask?.space || (mask?.mode === 'layer' ? 'layer' : 'document'),
|
||||
})),
|
||||
})),
|
||||
})],
|
||||
[4, document => ({
|
||||
...document,
|
||||
v: 5,
|
||||
view: normalizeEditorView(document.view),
|
||||
})],
|
||||
[5, document => ({
|
||||
...document,
|
||||
v: 6,
|
||||
groups: [],
|
||||
})],
|
||||
[6, document => ({
|
||||
...document,
|
||||
v: 7,
|
||||
layers: (document.layers || []).map(layer => ({ clipped: false, ...layer })),
|
||||
})],
|
||||
[7, document => ({
|
||||
...document,
|
||||
v: 8,
|
||||
groups: (document.groups || []).map(group => ({ parentId: null, ...group })),
|
||||
})],
|
||||
[8, document => ({
|
||||
...document,
|
||||
v: 9,
|
||||
groups: (document.groups || []).map(group => ({ masks: [], activeMaskId: null, ...group })),
|
||||
})],
|
||||
[9, document => ({
|
||||
...document,
|
||||
v: 10,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
locks: { pixels: false, transparency: false, position: false },
|
||||
...layer,
|
||||
})),
|
||||
})],
|
||||
[10, document => ({
|
||||
...document,
|
||||
v: 11,
|
||||
savedSelections: [],
|
||||
})],
|
||||
[11, document => ({
|
||||
...document,
|
||||
v: 12,
|
||||
layers: (document.layers || []).map(layer => ({
|
||||
...layer,
|
||||
masks: (layer.masks || []).map(mask => mask?.mode === 'layer' ? {
|
||||
linked: true,
|
||||
offset: { x: 0, y: 0 },
|
||||
...mask,
|
||||
} : mask),
|
||||
})),
|
||||
})],
|
||||
[12, document => ({
|
||||
...document,
|
||||
v: 13,
|
||||
layers: (document.layers || []).map(layer => ({ placed: null, ...layer })),
|
||||
})],
|
||||
[13, document => ({
|
||||
...document,
|
||||
v: 14,
|
||||
layers: (document.layers || []).map(layer => ({ adjustment: null, ...layer })),
|
||||
})],
|
||||
[14, document => ({
|
||||
...document,
|
||||
v: 15,
|
||||
layers: (document.layers || []).map(layer => ({ effects: [], ...layer })),
|
||||
groups: (document.groups || []).map(group => ({ effects: [], ...group })),
|
||||
})],
|
||||
]);
|
||||
|
||||
function migrateEditorDocument(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new EditorDocumentError('Project root must be a JSON object.');
|
||||
}
|
||||
const storedVersion = raw.v == null ? 1 : Number(raw.v);
|
||||
if (!Number.isInteger(storedVersion) || storedVersion < 1) {
|
||||
throw new EditorDocumentError('Project has an invalid document version.', 'invalid-version');
|
||||
}
|
||||
if (storedVersion > EDITOR_DOCUMENT_VERSION) {
|
||||
throw new EditorDocumentError(
|
||||
`This project uses version ${storedVersion}; this editor supports up to version ${EDITOR_DOCUMENT_VERSION}.`,
|
||||
'future-version',
|
||||
);
|
||||
}
|
||||
let document = { ...raw };
|
||||
while (document.v == null || document.v < EDITOR_DOCUMENT_VERSION) {
|
||||
const version = document.v == null ? 1 : document.v;
|
||||
const migrate = MIGRATIONS.get(version);
|
||||
if (!migrate) throw new EditorDocumentError(`No migration is available for project version ${version}.`, 'missing-migration');
|
||||
document = migrate(document);
|
||||
}
|
||||
return { document, storedVersion };
|
||||
}
|
||||
|
||||
function positiveDimension(value, fallback = 0) {
|
||||
const numeric = Math.round(Number(value ?? fallback));
|
||||
return Number.isFinite(numeric) && numeric > 0 && numeric <= EDITOR_MAX_DIMENSION ? numeric : null;
|
||||
}
|
||||
|
||||
function isEmbeddedPng(value) {
|
||||
return typeof value === 'string' && value.startsWith('data:image/png;base64,');
|
||||
}
|
||||
|
||||
function finiteCoordinate(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && Math.abs(numeric) <= 1_000_000 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function normalizePlacedRecord(source) {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
|
||||
const sourceWidth = positiveDimension(source.sourceWidth);
|
||||
const sourceHeight = positiveDimension(source.sourceHeight);
|
||||
const matrix = Array.isArray(source.matrix) && source.matrix.length === 6
|
||||
? source.matrix.map(Number)
|
||||
: null;
|
||||
if (!sourceWidth || !sourceHeight || !matrix?.every(Number.isFinite) || !isEmbeddedPng(source.sourceDataUrl)) {
|
||||
return null;
|
||||
}
|
||||
if (matrix.some(value => Math.abs(value) > 1_000_000)) return null;
|
||||
return {
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sourceName: typeof source.sourceName === 'string'
|
||||
? source.sourceName.slice(0, 200)
|
||||
: 'Placed image',
|
||||
sourceDataUrl: source.sourceDataUrl,
|
||||
matrix,
|
||||
};
|
||||
}
|
||||
|
||||
/** Migrate and validate untrusted draft/project JSON before allocating canvases. */
|
||||
export function prepareEditorDocument(raw) {
|
||||
const { document: migrated, storedVersion } = migrateEditorDocument(raw);
|
||||
const warnings = [];
|
||||
const warn = message => {
|
||||
if (warnings.length < 50) warnings.push(message);
|
||||
};
|
||||
const width = positiveDimension(migrated.imgWidth);
|
||||
const height = positiveDimension(migrated.imgHeight);
|
||||
if (!width || !height) {
|
||||
throw new EditorDocumentError(`Project dimensions must be between 1 and ${EDITOR_MAX_DIMENSION} pixels.`, 'invalid-dimensions');
|
||||
}
|
||||
if (width * height > EDITOR_MAX_DOCUMENT_PIXELS) {
|
||||
throw new EditorDocumentError('Project canvas exceeds the 100 megapixel safety limit.', 'pixel-budget');
|
||||
}
|
||||
if (!Array.isArray(migrated.layers)) {
|
||||
throw new EditorDocumentError('Project is missing its layer list.', 'invalid-layers');
|
||||
}
|
||||
if (migrated.layers.length > EDITOR_MAX_LAYERS) {
|
||||
throw new EditorDocumentError(`Project exceeds the ${EDITOR_MAX_LAYERS}-layer limit.`, 'layer-budget');
|
||||
}
|
||||
|
||||
let surfacePixels = width * height;
|
||||
let embeddedCharacters = 0;
|
||||
let recoveredId = 1;
|
||||
const usedIds = new Set();
|
||||
const layers = [];
|
||||
for (let index = 0; index < migrated.layers.length; index += 1) {
|
||||
const source = migrated.layers[index];
|
||||
const label = typeof source?.name === 'string' && source.name.trim()
|
||||
? source.name.trim().slice(0, 200)
|
||||
: `Layer ${index + 1}`;
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
warn(`${label} was skipped because its record is invalid.`);
|
||||
continue;
|
||||
}
|
||||
const canvasW = positiveDimension(source.canvasW, width);
|
||||
const canvasH = positiveDimension(source.canvasH, height);
|
||||
if (!canvasW || !canvasH) {
|
||||
warn(`${label} was skipped because its canvas dimensions are invalid.`);
|
||||
continue;
|
||||
}
|
||||
const textIsValid = source.kind === 'text' && source.text && typeof source.text === 'object' && !Array.isArray(source.text);
|
||||
const shapeIsValid = source.kind === 'shape' && source.shape && typeof source.shape === 'object' && !Array.isArray(source.shape);
|
||||
const adjustmentIsValid = source.kind === 'adjustment' && source.adjustment && typeof source.adjustment === 'object' && !Array.isArray(source.adjustment);
|
||||
const placed = source.kind === 'placed' ? normalizePlacedRecord(source.placed) : null;
|
||||
const placedIsValid = source.kind === 'placed' && !!placed;
|
||||
if (source.kind && source.kind !== 'raster' && source.kind !== 'text' && source.kind !== 'shape' && source.kind !== 'placed' && source.kind !== 'adjustment') {
|
||||
warn(`${label} used unsupported layer type "${String(source.kind).slice(0, 40)}" and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'text' && !textIsValid) {
|
||||
warn(`${label} had invalid editable text metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'shape' && !shapeIsValid) {
|
||||
warn(`${label} had invalid editable shape metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'adjustment' && !adjustmentIsValid) {
|
||||
warn(`${label} had invalid adjustment metadata and was recovered as raster pixels.`);
|
||||
} else if (source.kind === 'placed' && !placedIsValid) {
|
||||
warn(`${label} had a missing or corrupt placed source and was recovered from its raster preview.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && !textIsValid && !shapeIsValid && !placedIsValid && !adjustmentIsValid) {
|
||||
warn(`${label} was skipped because its pixel data is missing or corrupt.`);
|
||||
continue;
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && textIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its editable text data.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && shapeIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its editable shape data.`);
|
||||
}
|
||||
if (!isEmbeddedPng(source.dataUrl) && placedIsValid) {
|
||||
warn(`${label} had no valid preview pixels and will be rebuilt from its placed source.`);
|
||||
}
|
||||
embeddedCharacters += typeof source.dataUrl === 'string' ? source.dataUrl.length : 0;
|
||||
surfacePixels += canvasW * canvasH;
|
||||
if (placedIsValid) {
|
||||
embeddedCharacters += placed.sourceDataUrl.length;
|
||||
surfacePixels += placed.sourceWidth * placed.sourceHeight;
|
||||
}
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `layer-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${label} received a replacement layer id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
const masks = [];
|
||||
const maskRecords = Array.isArray(source.masks) ? source.masks : [];
|
||||
if (maskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
|
||||
warn(`${label} has more than ${EDITOR_MAX_MASKS_PER_LAYER} masks; extras were skipped.`);
|
||||
}
|
||||
for (let maskIndex = 0; maskIndex < Math.min(maskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
|
||||
const mask = maskRecords[maskIndex];
|
||||
const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Mask ${maskIndex + 1}`;
|
||||
const maskW = positiveDimension(mask?.canvasW, width);
|
||||
const maskH = positiveDimension(mask?.canvasH, height);
|
||||
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
|
||||
warn(`${label} / ${maskLabel} was skipped because its mask data is invalid.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += mask.dataUrl.length;
|
||||
surfacePixels += maskW * maskH;
|
||||
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
|
||||
if (!maskId || usedIds.has(maskId)) {
|
||||
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
|
||||
warn(`${label} / ${maskLabel} received a replacement mask id.`);
|
||||
}
|
||||
usedIds.add(maskId);
|
||||
const mode = mask.mode === 'layer' ? 'layer' : 'selection';
|
||||
masks.push({
|
||||
...mask,
|
||||
id: maskId,
|
||||
name: maskLabel,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode,
|
||||
space: mask.space === 'layer' || mask.space === 'document'
|
||||
? mask.space
|
||||
: (mode === 'layer' ? 'layer' : 'document'),
|
||||
linked: mode === 'layer' ? mask.linked !== false : true,
|
||||
offset: mode === 'layer' ? {
|
||||
x: finiteCoordinate(mask.offset?.x),
|
||||
y: finiteCoordinate(mask.offset?.y),
|
||||
} : { x: 0, y: 0 },
|
||||
canvasW: maskW,
|
||||
canvasH: maskH,
|
||||
});
|
||||
}
|
||||
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const offset = source.offset && typeof source.offset === 'object' ? source.offset : {};
|
||||
const opacity = Number(source.opacity);
|
||||
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
|
||||
if (source.blendMode && blendMode !== source.blendMode) {
|
||||
warn(`${label} used unsupported blend mode "${String(source.blendMode).slice(0, 40)}" and was reset to Normal.`);
|
||||
}
|
||||
layers.push({
|
||||
...source,
|
||||
id,
|
||||
name: label,
|
||||
visible: source.visible !== false,
|
||||
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
|
||||
locked: !!source.locked,
|
||||
locks: {
|
||||
pixels: !!source.locks?.pixels,
|
||||
transparency: !!source.locks?.transparency,
|
||||
position: !!source.locks?.position,
|
||||
},
|
||||
clipped: !!source.clipped,
|
||||
blendMode,
|
||||
kind: textIsValid ? 'text' : (shapeIsValid ? 'shape' : (placedIsValid ? 'placed' : (adjustmentIsValid ? 'adjustment' : 'raster'))),
|
||||
text: textIsValid ? source.text : null,
|
||||
shape: shapeIsValid ? source.shape : null,
|
||||
adjustment: adjustmentIsValid ? normalizeAdjustmentData(source.adjustment) : null,
|
||||
effects: Array.isArray(source.effects) ? source.effects.map(normalizeEffect) : [],
|
||||
placed: placedIsValid ? placed : null,
|
||||
canvasW,
|
||||
canvasH,
|
||||
offset: { x: finiteCoordinate(offset.x), y: finiteCoordinate(offset.y) },
|
||||
masks,
|
||||
activeMaskId: masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
|
||||
adjustments: source.adjustments && typeof source.adjustments === 'object' ? source.adjustments : {},
|
||||
adjLayers: Array.isArray(source.adjLayers) ? source.adjLayers : [],
|
||||
});
|
||||
}
|
||||
if (!layers.length) {
|
||||
throw new EditorDocumentError('No recoverable layers were found in this project.', 'no-layers');
|
||||
}
|
||||
|
||||
const activeLayerId = layers.some(layer => layer.id === migrated.activeLayerId)
|
||||
? migrated.activeLayerId
|
||||
: layers[layers.length - 1].id;
|
||||
const groups = [];
|
||||
const claimedLayerIds = new Set();
|
||||
const validLayerIds = new Set(layers.map(layer => layer.id));
|
||||
const groupRecords = Array.isArray(migrated.groups) ? migrated.groups : [];
|
||||
if (groupRecords.length > EDITOR_MAX_GROUPS) {
|
||||
warn(`Project has more than ${EDITOR_MAX_GROUPS} groups; extras were skipped.`);
|
||||
}
|
||||
for (let index = 0; index < Math.min(groupRecords.length, EDITOR_MAX_GROUPS); index += 1) {
|
||||
const source = groupRecords[index];
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||
warn(`Group ${index + 1} was skipped because its record is invalid.`);
|
||||
continue;
|
||||
}
|
||||
const layerIds = [];
|
||||
for (const id of Array.isArray(source.layerIds) ? source.layerIds : []) {
|
||||
if (validLayerIds.has(id) && !claimedLayerIds.has(id)) {
|
||||
claimedLayerIds.add(id);
|
||||
layerIds.push(id);
|
||||
}
|
||||
}
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `group-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} received a replacement group id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
const groupMasks = [];
|
||||
const groupMaskRecords = Array.isArray(source.masks) ? source.masks : [];
|
||||
if (groupMaskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} has too many masks; extras were skipped.`);
|
||||
}
|
||||
for (let maskIndex = 0; maskIndex < Math.min(groupMaskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
|
||||
const mask = groupMaskRecords[maskIndex];
|
||||
const maskLabel = typeof mask?.name === 'string' && mask.name.trim()
|
||||
? mask.name.trim().slice(0, 200)
|
||||
: `Group Mask ${maskIndex + 1}`;
|
||||
const maskW = positiveDimension(mask?.canvasW, width);
|
||||
const maskH = positiveDimension(mask?.canvasH, height);
|
||||
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} was skipped because its mask data is invalid.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += mask.dataUrl.length;
|
||||
surfacePixels += maskW * maskH;
|
||||
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
|
||||
if (!maskId || usedIds.has(maskId)) {
|
||||
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} received a replacement mask id.`);
|
||||
}
|
||||
usedIds.add(maskId);
|
||||
groupMasks.push({
|
||||
id: maskId,
|
||||
name: maskLabel,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode: 'group',
|
||||
space: 'document',
|
||||
canvasW: maskW,
|
||||
canvasH: maskH,
|
||||
dataUrl: mask.dataUrl,
|
||||
});
|
||||
}
|
||||
const opacity = Number(source.opacity);
|
||||
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
|
||||
if (source.blendMode && blendMode !== source.blendMode) {
|
||||
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} used an unsupported blend mode and was reset to Normal.`);
|
||||
}
|
||||
groups.push({
|
||||
id,
|
||||
name: String(source.name || `Group ${index + 1}`).trim().slice(0, 200) || `Group ${index + 1}`,
|
||||
layerIds,
|
||||
parentId: typeof source.parentId === 'string' && source.parentId.trim()
|
||||
? source.parentId.trim().slice(0, 100)
|
||||
: null,
|
||||
visible: source.visible !== false,
|
||||
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
|
||||
blendMode,
|
||||
locked: !!source.locked,
|
||||
collapsed: !!source.collapsed,
|
||||
...(Array.isArray(source.effects) && source.effects.length
|
||||
? { effects: source.effects.map(serializeEffect) }
|
||||
: {}),
|
||||
masks: groupMasks,
|
||||
activeMaskId: groupMasks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
|
||||
});
|
||||
}
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const savedSelections = [];
|
||||
const selectionRecords = Array.isArray(migrated.savedSelections) ? migrated.savedSelections : [];
|
||||
if (selectionRecords.length > EDITOR_MAX_SAVED_SELECTIONS) {
|
||||
warn(`Project has more than ${EDITOR_MAX_SAVED_SELECTIONS} saved selections; extras were skipped.`);
|
||||
}
|
||||
for (let index = 0; index < Math.min(selectionRecords.length, EDITOR_MAX_SAVED_SELECTIONS); index += 1) {
|
||||
const source = selectionRecords[index];
|
||||
const name = typeof source?.name === 'string' && source.name.trim()
|
||||
? source.name.trim().slice(0, 100)
|
||||
: `Selection ${index + 1}`;
|
||||
if (!source || typeof source !== 'object' || source.canvasW !== width || source.canvasH !== height || !isEmbeddedPng(source.dataUrl)) {
|
||||
warn(`${name} was skipped because its saved selection data is invalid or has the wrong dimensions.`);
|
||||
continue;
|
||||
}
|
||||
embeddedCharacters += source.dataUrl.length;
|
||||
surfacePixels += width * height;
|
||||
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
|
||||
if (!id || usedIds.has(id)) {
|
||||
do { id = `selection-recovered-${recoveredId++}`; } while (usedIds.has(id));
|
||||
warn(`${name} received a replacement selection id.`);
|
||||
}
|
||||
usedIds.add(id);
|
||||
savedSelections.push({ id, name, canvasW: width, canvasH: height, dataUrl: source.dataUrl });
|
||||
}
|
||||
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
|
||||
throw new EditorDocumentError('Project layers, masks, and saved selections exceed the safe in-memory pixel budget.', 'surface-budget');
|
||||
}
|
||||
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
|
||||
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
|
||||
}
|
||||
const validGroupIds = new Set(groups.map(group => group.id));
|
||||
const groupsById = new Map(groups.map(group => [group.id, group]));
|
||||
for (const group of groups) {
|
||||
if (group.parentId === group.id || (group.parentId && !validGroupIds.has(group.parentId))) {
|
||||
warn(`${group.name} referenced a missing or invalid parent group and was moved to the top level.`);
|
||||
group.parentId = null;
|
||||
}
|
||||
}
|
||||
for (const group of groups) {
|
||||
const visited = new Set([group.id]);
|
||||
let cursor = group;
|
||||
while (cursor.parentId) {
|
||||
if (visited.has(cursor.parentId)) {
|
||||
warn(`${group.name} contained a cyclic group relationship and was moved to the top level.`);
|
||||
group.parentId = null;
|
||||
break;
|
||||
}
|
||||
visited.add(cursor.parentId);
|
||||
cursor = groupsById.get(cursor.parentId);
|
||||
if (!cursor) break;
|
||||
}
|
||||
}
|
||||
// A group may contain only child groups. Remove only empty leaves after the
|
||||
// complete parent graph is available, then repeat for newly empty parents.
|
||||
while (true) {
|
||||
const parentIds = new Set(groups.map(group => group.parentId).filter(Boolean));
|
||||
const emptyIndex = groups.findIndex(group => !group.layerIds.length && !parentIds.has(group.id));
|
||||
if (emptyIndex < 0) break;
|
||||
const [empty] = groups.splice(emptyIndex, 1);
|
||||
warn(`${empty.name} was skipped because it has no recoverable layers or child groups.`);
|
||||
}
|
||||
const scopeByLayer = new Map();
|
||||
for (const group of groups) {
|
||||
for (const id of group.layerIds) scopeByLayer.set(id, group.id);
|
||||
}
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
if (!layer.clipped) continue;
|
||||
const previous = layers[index - 1];
|
||||
const scope = scopeByLayer.get(layer.id) || null;
|
||||
if (!previous || (scopeByLayer.get(previous.id) || null) !== scope) {
|
||||
layer.clipped = false;
|
||||
warn(`${layer.name} had no clipping base in its layer scope and was released.`);
|
||||
}
|
||||
}
|
||||
if (storedVersion < EDITOR_DOCUMENT_VERSION) {
|
||||
warnings.unshift(`Project upgraded from version ${storedVersion} to ${EDITOR_DOCUMENT_VERSION}.`);
|
||||
}
|
||||
return {
|
||||
document: {
|
||||
...migrated,
|
||||
v: EDITOR_DOCUMENT_VERSION,
|
||||
imgWidth: width,
|
||||
imgHeight: height,
|
||||
activeLayerId,
|
||||
view: normalizeEditorView(migrated.view),
|
||||
groups,
|
||||
savedSelections,
|
||||
layers,
|
||||
},
|
||||
warnings,
|
||||
migratedFrom: storedVersion < EDITOR_DOCUMENT_VERSION ? storedVersion : null,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasDataUrl(canvas) {
|
||||
if (!canvas || typeof canvas.toDataURL !== 'function') return null;
|
||||
try {
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEffect(effect) {
|
||||
const normalized = normalizeEffect(effect);
|
||||
if (!normalized.mask) return normalized;
|
||||
return {
|
||||
...normalized,
|
||||
mask: {
|
||||
id: normalized.mask.id,
|
||||
name: normalized.mask.name,
|
||||
visible: normalized.mask.visible !== false,
|
||||
canvasW: normalized.mask.canvas?.width || normalized.mask.canvasW || 0,
|
||||
canvasH: normalized.mask.canvas?.height || normalized.mask.canvasH || 0,
|
||||
dataUrl: canvasDataUrl(normalized.mask.canvas),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEditorDocument(state, extras = {}) {
|
||||
return {
|
||||
v: EDITOR_DOCUMENT_VERSION,
|
||||
imageId: state.imageId || null,
|
||||
imgWidth: state.imgWidth,
|
||||
imgHeight: state.imgHeight,
|
||||
activeLayerId: state.activeLayerId || null,
|
||||
nextLayerId: state.nextLayerId,
|
||||
view: normalizeEditorView(state),
|
||||
...extras,
|
||||
savedSelections: (state.savedSelections || []).slice(0, EDITOR_MAX_SAVED_SELECTIONS).map(selection => ({
|
||||
id: selection.id,
|
||||
name: String(selection.name || 'Selection').slice(0, 100),
|
||||
canvasW: selection.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: selection.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(selection.canvas),
|
||||
})),
|
||||
groups: (state.layerGroups || []).map(group => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
layerIds: [...(group.layerIds || [])],
|
||||
parentId: group.parentId || null,
|
||||
visible: group.visible !== false,
|
||||
opacity: typeof group.opacity === 'number' ? group.opacity : 1,
|
||||
blendMode: group.blendMode || 'source-over',
|
||||
locked: !!group.locked,
|
||||
collapsed: !!group.collapsed,
|
||||
activeMaskId: group.activeMaskId || null,
|
||||
...(Array.isArray(group.effects) && group.effects.length
|
||||
? { effects: group.effects.map(serializeEffect) }
|
||||
: {}),
|
||||
masks: (group.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
mode: 'group',
|
||||
space: 'document',
|
||||
canvasW: mask.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: mask.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
layers: (state.layers || []).map(layer => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible !== false,
|
||||
opacity: typeof layer.opacity === 'number' ? layer.opacity : 1,
|
||||
locked: !!layer.locked,
|
||||
locks: {
|
||||
pixels: !!layer.locks?.pixels,
|
||||
transparency: !!layer.locks?.transparency,
|
||||
position: !!layer.locks?.position,
|
||||
},
|
||||
clipped: !!layer.clipped,
|
||||
isBase: !!layer.isBase,
|
||||
blendMode: layer.blendMode || 'source-over',
|
||||
kind: layer.kind || 'raster',
|
||||
text: cloneDocumentValue(layer.text, null),
|
||||
shape: cloneDocumentValue(layer.shape, null),
|
||||
adjustment: cloneDocumentValue(layer.adjustment, null),
|
||||
effects: Array.isArray(layer.effects) ? layer.effects.map(serializeEffect) : [],
|
||||
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
|
||||
sourceWidth: layer.placed.sourceWidth || layer.placed.sourceCanvas.width,
|
||||
sourceHeight: layer.placed.sourceHeight || layer.placed.sourceCanvas.height,
|
||||
sourceName: String(layer.placed.sourceName || 'Placed image').slice(0, 200),
|
||||
sourceDataUrl: canvasDataUrl(layer.placed.sourceCanvas),
|
||||
matrix: Array.isArray(layer.placed.matrix) ? layer.placed.matrix.map(Number) : [1, 0, 0, 1, 0, 0],
|
||||
} : null,
|
||||
canvasW: layer.canvas?.width || 0,
|
||||
canvasH: layer.canvas?.height || 0,
|
||||
offset: { ...(state.layerOffsets?.get(layer.id) || { x: 0, y: 0 }) },
|
||||
dataUrl: canvasDataUrl(layer.canvas),
|
||||
adjustments: cloneDocumentValue(layer.adjustments, {}),
|
||||
adjLayers: cloneDocumentValue(layer.adjLayers, []),
|
||||
activeMaskId: layer.activeMaskId || null,
|
||||
masks: (layer.masks || []).map(mask => ({
|
||||
id: mask.id,
|
||||
name: mask.name,
|
||||
visible: mask.visible !== false,
|
||||
density: normalizeMaskDensity(mask.density),
|
||||
feather: normalizeMaskFeather(mask.feather),
|
||||
// Existing masks are AI/selection regions. A future true layer mask
|
||||
// uses mode="layer" without changing old saved documents.
|
||||
mode: mask.mode || 'selection',
|
||||
space: mask.space || (mask.mode === 'layer' ? 'layer' : 'document'),
|
||||
linked: mask.mode === 'layer' ? mask.linked !== false : true,
|
||||
offset: mask.mode === 'layer'
|
||||
? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y) }
|
||||
: { x: 0, y: 0 },
|
||||
canvasW: mask.canvas?.width || state.imgWidth || 0,
|
||||
canvasH: mask.canvas?.height || state.imgHeight || 0,
|
||||
dataUrl: canvasDataUrl(mask.canvas),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function nextLayerIdFromDocument(data) {
|
||||
const stored = Number(data?.nextLayerId);
|
||||
if (Number.isInteger(stored) && stored > 0) return stored;
|
||||
let max = 0;
|
||||
for (const layer of data?.layers || []) {
|
||||
const match = String(layer?.id || '').match(/(\d+)$/);
|
||||
if (match) max = Math.max(max, Number(match[1]));
|
||||
for (const mask of layer?.masks || []) {
|
||||
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
|
||||
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
|
||||
}
|
||||
}
|
||||
for (const group of data?.groups || []) {
|
||||
const match = String(group?.id || '').match(/(\d+)$/);
|
||||
if (match) max = Math.max(max, Number(match[1]));
|
||||
for (const mask of group?.masks || []) {
|
||||
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
|
||||
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Document-wide geometry operations.
|
||||
*
|
||||
* Layer pixels live in layer coordinates, while AI/selection masks live in
|
||||
* document coordinates. Keeping those spaces explicit here prevents crop and
|
||||
* resize commands from updating the visible pixels while leaving masks and
|
||||
* selections behind.
|
||||
*/
|
||||
|
||||
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
|
||||
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
|
||||
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function renderWindow(source, x, y, width, height) {
|
||||
const out = makeCanvas(width, height);
|
||||
out.getContext('2d').drawImage(source, -x, -y);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTranslated(source, width, height, x, y) {
|
||||
const out = makeCanvas(width, height);
|
||||
out.getContext('2d').drawImage(source, x, y);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderScaled(source, width, height, smoothingQuality = 'high') {
|
||||
const out = makeCanvas(width, height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = smoothingQuality;
|
||||
ctx.drawImage(source, 0, 0, out.width, out.height);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderRotated(source, degrees) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
const swap = normalized === 90 || normalized === 270;
|
||||
const out = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.translate(out.width / 2, out.height / 2);
|
||||
ctx.rotate((normalized * Math.PI) / 180);
|
||||
ctx.drawImage(source, -source.width / 2, -source.height / 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderFlipped(source, axis) {
|
||||
const out = makeCanvas(source.width, source.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.save();
|
||||
if (axis === 'h') {
|
||||
ctx.translate(out.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
} else {
|
||||
ctx.translate(0, out.height);
|
||||
ctx.scale(1, -1);
|
||||
}
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.restore();
|
||||
return out;
|
||||
}
|
||||
|
||||
function replaceCanvas(holder, rendered) {
|
||||
holder.canvas.width = rendered.width;
|
||||
holder.canvas.height = rendered.height;
|
||||
holder.ctx = holder.canvas.getContext('2d');
|
||||
holder.ctx.clearRect(0, 0, rendered.width, rendered.height);
|
||||
holder.ctx.drawImage(rendered, 0, 0);
|
||||
}
|
||||
|
||||
function replaceStandaloneCanvas(canvas, rendered) {
|
||||
canvas.width = rendered.width;
|
||||
canvas.height = rendered.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, rendered.width, rendered.height);
|
||||
ctx.drawImage(rendered, 0, 0);
|
||||
}
|
||||
|
||||
function invalidateLayerCaches(layer) {
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = null;
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = null;
|
||||
layer._stagedAdj = null;
|
||||
layer._editingAdjId = null;
|
||||
}
|
||||
|
||||
function syncActiveMask(editorState) {
|
||||
const activeGroup = (editorState.layerGroups || []).find(group => group.id === editorState.activeGroupId);
|
||||
const groupMask = activeGroup?.masks?.find(item => item.id === activeGroup.activeMaskId) || null;
|
||||
if (groupMask) {
|
||||
editorState.maskCanvas = groupMask.canvas;
|
||||
editorState.maskCtx = groupMask.ctx;
|
||||
return;
|
||||
}
|
||||
const active = editorState.layers.find(layer => layer.id === editorState.activeLayerId);
|
||||
const mask = active?.masks?.find(item => item.id === active.activeMaskId) || null;
|
||||
editorState.maskCanvas = mask?.canvas || null;
|
||||
editorState.maskCtx = mask?.ctx || null;
|
||||
}
|
||||
|
||||
function syncDocumentCanvas(editorState, width, height) {
|
||||
editorState.imgWidth = width;
|
||||
editorState.imgHeight = height;
|
||||
if (editorState.mainCanvas) {
|
||||
editorState.mainCanvas.width = width;
|
||||
editorState.mainCanvas.height = height;
|
||||
editorState.mainCtx = editorState.mainCanvas.getContext('2d');
|
||||
}
|
||||
editorState.documentCompositeCanvas = null;
|
||||
editorState.compositeMaskUnion = null;
|
||||
editorState.wandSrcCache = null;
|
||||
syncActiveMask(editorState);
|
||||
}
|
||||
|
||||
function normalizedGuideValues(values, limit, mapper = value => value) {
|
||||
const output = [];
|
||||
for (const raw of Array.isArray(values) ? values : []) {
|
||||
const value = mapper(Number(raw));
|
||||
if (!Number.isFinite(value) || value < 0 || value > limit) continue;
|
||||
if (!output.some(existing => Math.abs(existing - value) < 0.0001)) output.push(value);
|
||||
}
|
||||
return output.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function transformGuides(editorState, vertical, horizontal) {
|
||||
editorState.guides = {
|
||||
vertical: normalizedGuideValues(vertical.values, vertical.limit, vertical.map),
|
||||
horizontal: normalizedGuideValues(horizontal.values, horizontal.limit, horizontal.map),
|
||||
};
|
||||
}
|
||||
|
||||
function clipPolygonAxis(points, inside, intersect) {
|
||||
if (!points.length) return [];
|
||||
const output = [];
|
||||
let previous = points[points.length - 1];
|
||||
let previousInside = inside(previous);
|
||||
for (const current of points) {
|
||||
const currentInside = inside(current);
|
||||
if (currentInside !== previousInside) output.push(intersect(previous, current));
|
||||
if (currentInside) output.push(current);
|
||||
previous = current;
|
||||
previousInside = currentInside;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Clip a polygon to a document rectangle using Sutherland-Hodgman. */
|
||||
export function clipPolygonToRect(points, width, height) {
|
||||
let out = (points || []).map(point => ({ x: point.x, y: point.y }));
|
||||
const xAt = (value) => (a, b) => {
|
||||
const dx = b.x - a.x;
|
||||
const t = dx === 0 ? 0 : (value - a.x) / dx;
|
||||
return { x: value, y: a.y + (b.y - a.y) * t };
|
||||
};
|
||||
const yAt = (value) => (a, b) => {
|
||||
const dy = b.y - a.y;
|
||||
const t = dy === 0 ? 0 : (value - a.y) / dy;
|
||||
return { x: a.x + (b.x - a.x) * t, y: value };
|
||||
};
|
||||
out = clipPolygonAxis(out, p => p.x >= 0, xAt(0));
|
||||
out = clipPolygonAxis(out, p => p.x <= width, xAt(width));
|
||||
out = clipPolygonAxis(out, p => p.y >= 0, yAt(0));
|
||||
out = clipPolygonAxis(out, p => p.y <= height, yAt(height));
|
||||
return out.length >= 3 ? out : [];
|
||||
}
|
||||
|
||||
function normalizeCropRect(editorState, rect) {
|
||||
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
|
||||
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
|
||||
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
|
||||
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function forEachStoredSelection(editorState, callback) {
|
||||
for (const selection of editorState.savedSelections || []) {
|
||||
if (selection?.canvas) callback(selection.canvas);
|
||||
}
|
||||
if (editorState.lastSelection?.canvas) callback(editorState.lastSelection.canvas);
|
||||
}
|
||||
|
||||
/** Crop layer pixels, masks, and transient selections as one document. */
|
||||
export function cropDocument(editorState, rect) {
|
||||
if (!editorState.imgWidth || !editorState.imgHeight) return null;
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
const { x, y, width, height } = normalizeCropRect(editorState, rect);
|
||||
const oldOffsets = new Map(editorState.layerOffsets);
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const offset = oldOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const retainedText = layer.kind === 'text' && layer.text;
|
||||
const retainedShape = layer.kind === 'shape' && layer.shape;
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'placed' && layer.placed) {
|
||||
layer.placed = transformPlacedData(layer.placed, [1, 0, 0, 1, -x, -y]);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else if (!retainedText && !retainedShape) {
|
||||
replaceCanvas(layer, renderWindow(layer.canvas, x - offset.x, y - offset.y, width, height));
|
||||
}
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
if ((retainedText || retainedShape || layer.kind === 'placed') && !documentSpace) continue;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
const sourceX = documentSpace ? x : x - offset.x - (Number(maskOffset.x) || 0);
|
||||
const sourceY = documentSpace ? y : y - offset.y - (Number(maskOffset.y) || 0);
|
||||
replaceCanvas(mask, renderWindow(mask.canvas, sourceX, sourceY, width, height));
|
||||
mask.space = documentSpace ? 'document' : 'layer';
|
||||
if (!documentSpace) mask.offset = { x: 0, y: 0 };
|
||||
}
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || ((retainedText || retainedShape)
|
||||
? { x: offset.x - x, y: offset.y - y }
|
||||
: { x: 0, y: 0 }));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderWindow(mask.canvas, x, y, width, height));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) {
|
||||
const sourceLayerOffset = oldOffsets.get(editorState.wandLayerId) || { x: 0, y: 0 };
|
||||
const documentSpace = editorState.wandMaskSpace === 'document' || (
|
||||
!editorState.wandMaskSpace && editorState.wandMask.width === oldWidth && editorState.wandMask.height === oldHeight
|
||||
);
|
||||
const sourceX = documentSpace ? x : x - sourceLayerOffset.x;
|
||||
const sourceY = documentSpace ? y : y - sourceLayerOffset.y;
|
||||
replaceStandaloneCanvas(
|
||||
editorState.wandMask,
|
||||
renderWindow(editorState.wandMask, sourceX, sourceY, width, height),
|
||||
);
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderWindow(canvas, x, y, width, height));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x - x, y: editorState.wandLastSeed.y - y };
|
||||
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < width && seed.y < height ? seed : null;
|
||||
}
|
||||
editorState.lassoPoints = clipPolygonToRect(
|
||||
(editorState.lassoPoints || []).map(point => ({ x: point.x - x, y: point.y - y })),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: width, map: value => value - x },
|
||||
{ values: guides.horizontal, limit: height, map: value => value - y },
|
||||
);
|
||||
editorState.cropRect = null;
|
||||
editorState.cropStart = null;
|
||||
editorState.cropEnd = null;
|
||||
syncDocumentCanvas(editorState, width, height);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/** Resize the actual image content, including every layer-space/document-space mask. */
|
||||
export function resizeImageDocument(editorState, width, height, options = {}) {
|
||||
const newWidth = Math.max(1, Math.round(width));
|
||||
const newHeight = Math.max(1, Math.round(height));
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
if (!oldWidth || !oldHeight) return null;
|
||||
const scaleX = newWidth / oldWidth;
|
||||
const scaleY = newHeight / oldHeight;
|
||||
const quality = options.smoothingQuality || 'high';
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const targetLayerWidth = Math.max(1, Math.round(layer.canvas.width * scaleX));
|
||||
const targetLayerHeight = Math.max(1, Math.round(layer.canvas.height * scaleY));
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) scaleTextLayer(layer, scaleX, scaleY);
|
||||
else if (layer.kind === 'shape' && layer.shape) scaleShapeLayer(layer, scaleX, scaleY);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
layer.placed = transformPlacedData(layer.placed, [scaleX, 0, 0, scaleY, 0, 0]);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderScaled(layer.canvas, targetLayerWidth, targetLayerHeight, quality));
|
||||
const layerWidth = layer.canvas.width;
|
||||
const layerHeight = layer.canvas.height;
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
const targetMaskWidth = documentSpace
|
||||
? newWidth
|
||||
: Math.max(1, Math.round(mask.canvas.width * scaleX));
|
||||
const targetMaskHeight = documentSpace
|
||||
? newHeight
|
||||
: Math.max(1, Math.round(mask.canvas.height * scaleY));
|
||||
replaceCanvas(
|
||||
mask,
|
||||
renderScaled(mask.canvas, targetMaskWidth, targetMaskHeight, quality),
|
||||
);
|
||||
mask.space = documentSpace ? 'document' : 'layer';
|
||||
if (!documentSpace) {
|
||||
mask.offset = {
|
||||
x: Math.round((Number(mask.offset?.x) || 0) * scaleX),
|
||||
y: Math.round((Number(mask.offset?.y) || 0) * scaleY),
|
||||
};
|
||||
}
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || {
|
||||
x: Math.round(offset.x * scaleX),
|
||||
y: Math.round(offset.y * scaleY),
|
||||
});
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderScaled(mask.canvas, newWidth, newHeight, quality));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) {
|
||||
replaceStandaloneCanvas(
|
||||
editorState.wandMask,
|
||||
renderScaled(
|
||||
editorState.wandMask,
|
||||
editorState.wandMaskSpace === 'document' ? newWidth : Math.max(1, Math.round(editorState.wandMask.width * scaleX)),
|
||||
editorState.wandMaskSpace === 'document' ? newHeight : Math.max(1, Math.round(editorState.wandMask.height * scaleY)),
|
||||
quality,
|
||||
),
|
||||
);
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderScaled(canvas, newWidth, newHeight, quality));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
x: editorState.wandLastSeed.x * scaleX,
|
||||
y: editorState.wandLastSeed.y * scaleY,
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
|
||||
x: point.x * scaleX,
|
||||
y: point.y * scaleY,
|
||||
}));
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => value * scaleX },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => value * scaleY },
|
||||
);
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight, scaleX, scaleY };
|
||||
}
|
||||
|
||||
/** Change document bounds without resampling layer pixels. Origin stays top-left. */
|
||||
export function resizeCanvasDocument(editorState, width, height, options = {}) {
|
||||
const newWidth = Math.max(1, Math.round(width));
|
||||
const newHeight = Math.max(1, Math.round(height));
|
||||
const anchorX = Math.max(0, Math.min(1, Number(options.anchorX) || 0));
|
||||
const anchorY = Math.max(0, Math.min(1, Number(options.anchorY) || 0));
|
||||
const shiftX = Math.round((newWidth - editorState.imgWidth) * anchorX);
|
||||
const shiftY = Math.round((newHeight - editorState.imgHeight) * anchorY);
|
||||
for (const layer of editorState.layers) {
|
||||
for (const mask of layer.masks || []) {
|
||||
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
|
||||
if (!documentSpace) continue;
|
||||
replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
mask.space = 'document';
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, {
|
||||
x: (Number(offset.x) || 0) + shiftX,
|
||||
y: (Number(offset.y) || 0) + shiftY,
|
||||
});
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
}
|
||||
if (editorState.wandMask && editorState.wandMaskSpace === 'document') {
|
||||
replaceStandaloneCanvas(editorState.wandMask, renderTranslated(editorState.wandMask, newWidth, newHeight, shiftX, shiftY));
|
||||
}
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderTranslated(canvas, newWidth, newHeight, shiftX, shiftY));
|
||||
});
|
||||
editorState.lassoPoints = clipPolygonToRect(
|
||||
(editorState.lassoPoints || []).map(point => ({ x: point.x + shiftX, y: point.y + shiftY })),
|
||||
newWidth,
|
||||
newHeight,
|
||||
);
|
||||
if (editorState.wandLastSeed) {
|
||||
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x + shiftX, y: editorState.wandLastSeed.y + shiftY };
|
||||
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < newWidth && seed.y < newHeight ? seed : null;
|
||||
}
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => value + shiftX },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => value + shiftY },
|
||||
);
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight };
|
||||
}
|
||||
|
||||
function rotatedPoint(point, degrees, oldWidth, oldHeight) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (normalized === 90) return { x: oldHeight - point.y, y: point.x };
|
||||
if (normalized === 180) return { x: oldWidth - point.x, y: oldHeight - point.y };
|
||||
if (normalized === 270) return { x: point.y, y: oldWidth - point.x };
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
function rotatedOffset(offset, width, height, degrees, oldWidth, oldHeight) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (normalized === 90) return { x: oldHeight - offset.y - height, y: offset.x };
|
||||
if (normalized === 180) return { x: oldWidth - offset.x - width, y: oldHeight - offset.y - height };
|
||||
if (normalized === 270) return { x: offset.y, y: oldWidth - offset.x - width };
|
||||
return { x: offset.x, y: offset.y };
|
||||
}
|
||||
|
||||
/** Rotate every layer, mask, offset, and active selection by a right angle. */
|
||||
export function rotateDocument(editorState, degrees) {
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
if (![90, 180, 270].includes(normalized)) return null;
|
||||
const oldWidth = editorState.imgWidth;
|
||||
const oldHeight = editorState.imgHeight;
|
||||
const newWidth = normalized === 180 ? oldWidth : oldHeight;
|
||||
const newHeight = normalized === 180 ? oldHeight : oldWidth;
|
||||
|
||||
for (const layer of editorState.layers) {
|
||||
const oldLayerWidth = layer.canvas.width;
|
||||
const oldLayerHeight = layer.canvas.height;
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
|
||||
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
const matrix = normalized === 90
|
||||
? [0, 1, -1, 0, oldHeight, 0]
|
||||
: normalized === 180
|
||||
? [-1, 0, 0, -1, oldWidth, oldHeight]
|
||||
: [0, -1, 1, 0, 0, oldWidth];
|
||||
layer.placed = transformPlacedData(layer.placed, matrix);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderRotated(layer.canvas, normalized));
|
||||
for (const mask of layer.masks || []) {
|
||||
const oldMaskWidth = mask.canvas.width;
|
||||
const oldMaskHeight = mask.canvas.height;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
|
||||
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
|
||||
const ox = Number(maskOffset.x) || 0;
|
||||
const oy = Number(maskOffset.y) || 0;
|
||||
if (normalized === 90) {
|
||||
mask.offset = { x: oldLayerHeight - oy - oldMaskHeight, y: ox };
|
||||
} else if (normalized === 180) {
|
||||
mask.offset = { x: oldLayerWidth - ox - oldMaskWidth, y: oldLayerHeight - oy - oldMaskHeight };
|
||||
} else {
|
||||
mask.offset = { x: oy, y: oldLayerWidth - ox - oldMaskWidth };
|
||||
}
|
||||
}
|
||||
}
|
||||
editorState.layerOffsets.set(layer.id, placedOffset ||
|
||||
rotatedOffset(offset, oldLayerWidth, oldLayerHeight, normalized, oldWidth, oldHeight));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderRotated(mask.canvas, normalized));
|
||||
}
|
||||
|
||||
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderRotated(editorState.wandMask, normalized));
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderRotated(canvas, normalized));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
...rotatedPoint(editorState.wandLastSeed, normalized, oldWidth, oldHeight),
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point =>
|
||||
rotatedPoint(point, normalized, oldWidth, oldHeight));
|
||||
const guides = editorState.guides || {};
|
||||
if (normalized === 90) {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.horizontal, limit: newWidth, map: value => oldHeight - value },
|
||||
{ values: guides.vertical, limit: newHeight },
|
||||
);
|
||||
} else if (normalized === 180) {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.vertical, limit: newWidth, map: value => oldWidth - value },
|
||||
{ values: guides.horizontal, limit: newHeight, map: value => oldHeight - value },
|
||||
);
|
||||
} else {
|
||||
transformGuides(
|
||||
editorState,
|
||||
{ values: guides.horizontal, limit: newWidth },
|
||||
{ values: guides.vertical, limit: newHeight, map: value => oldWidth - value },
|
||||
);
|
||||
}
|
||||
syncDocumentCanvas(editorState, newWidth, newHeight);
|
||||
return { width: newWidth, height: newHeight, degrees: normalized };
|
||||
}
|
||||
|
||||
/** Flip every layer, mask, offset, and active selection across the document. */
|
||||
export function flipDocument(editorState, axis) {
|
||||
if (axis !== 'h' && axis !== 'v') return null;
|
||||
const width = editorState.imgWidth;
|
||||
const height = editorState.imgHeight;
|
||||
for (const layer of editorState.layers) {
|
||||
const layerWidth = layer.canvas.width;
|
||||
const layerHeight = layer.canvas.height;
|
||||
let placedOffset = null;
|
||||
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
|
||||
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
|
||||
else if (layer.kind === 'placed' && layer.placed) {
|
||||
const matrix = axis === 'h'
|
||||
? [-1, 0, 0, 1, width, 0]
|
||||
: [1, 0, 0, -1, 0, height];
|
||||
layer.placed = transformPlacedData(layer.placed, matrix);
|
||||
placedOffset = renderPlacedLayer(layer)?.offset || null;
|
||||
} else replaceCanvas(layer, renderFlipped(layer.canvas, axis));
|
||||
for (const mask of layer.masks || []) {
|
||||
const maskWidth = mask.canvas.width;
|
||||
const maskHeight = mask.canvas.height;
|
||||
const maskOffset = mask.offset || { x: 0, y: 0 };
|
||||
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
|
||||
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
|
||||
const ox = Number(maskOffset.x) || 0;
|
||||
const oy = Number(maskOffset.y) || 0;
|
||||
mask.offset = axis === 'h'
|
||||
? { x: layerWidth - ox - maskWidth, y: oy }
|
||||
: { x: ox, y: layerHeight - oy - maskHeight };
|
||||
}
|
||||
}
|
||||
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
editorState.layerOffsets.set(layer.id, placedOffset || (axis === 'h'
|
||||
? { x: width - offset.x - layerWidth, y: offset.y }
|
||||
: { x: offset.x, y: height - offset.y - layerHeight }));
|
||||
invalidateLayerCaches(layer);
|
||||
}
|
||||
for (const group of editorState.layerGroups || []) {
|
||||
for (const mask of group.masks || []) replaceCanvas(mask, renderFlipped(mask.canvas, axis));
|
||||
}
|
||||
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderFlipped(editorState.wandMask, axis));
|
||||
forEachStoredSelection(editorState, canvas => {
|
||||
replaceStandaloneCanvas(canvas, renderFlipped(canvas, axis));
|
||||
});
|
||||
if (editorState.wandLastSeed) {
|
||||
editorState.wandLastSeed = {
|
||||
...editorState.wandLastSeed,
|
||||
x: axis === 'h' ? width - editorState.wandLastSeed.x : editorState.wandLastSeed.x,
|
||||
y: axis === 'v' ? height - editorState.wandLastSeed.y : editorState.wandLastSeed.y,
|
||||
};
|
||||
}
|
||||
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
|
||||
x: axis === 'h' ? width - point.x : point.x,
|
||||
y: axis === 'v' ? height - point.y : point.y,
|
||||
}));
|
||||
const guides = editorState.guides || {};
|
||||
transformGuides(
|
||||
editorState,
|
||||
{
|
||||
values: guides.vertical,
|
||||
limit: width,
|
||||
map: axis === 'h' ? value => width - value : value => value,
|
||||
},
|
||||
{
|
||||
values: guides.horizontal,
|
||||
limit: height,
|
||||
map: axis === 'v' ? value => height - value : value => value,
|
||||
},
|
||||
);
|
||||
syncDocumentCanvas(editorState, width, height);
|
||||
return { width, height, axis };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* Worker implementation for retained-effect rasterization. */
|
||||
import { LAYER_STYLES, renderLayerStyle, renderDropShadow } from './layer-styles.js';
|
||||
|
||||
function copyCanvas(source) {
|
||||
const out = new OffscreenCanvas(source.width, source.height);
|
||||
out.getContext('2d').drawImage(source, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sharpenCanvas(source, amount) {
|
||||
if (!amount) return copyCanvas(source);
|
||||
const out = new OffscreenCanvas(source.width, source.height);
|
||||
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
|
||||
const dst = out.getContext('2d').createImageData(source.width, source.height);
|
||||
const a = Math.max(0, Math.min(1, Number(amount) || 0));
|
||||
const stride = source.width * 4;
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = (y * source.width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const center = src.data[at + channel] * (1 + 4 * a);
|
||||
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
|
||||
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
|
||||
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
|
||||
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
|
||||
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
|
||||
}
|
||||
dst.data[at + 3] = src.data[at + 3];
|
||||
}
|
||||
}
|
||||
out.getContext('2d').putImageData(dst, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function maskedContribution(effectCanvas, mask) {
|
||||
if (!mask) return effectCanvas;
|
||||
const out = new OffscreenCanvas(effectCanvas.width, effectCanvas.height);
|
||||
const ctx = out.getContext('2d');
|
||||
ctx.drawImage(effectCanvas, 0, 0);
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(mask, 0, 0, out.width, out.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
return out;
|
||||
}
|
||||
|
||||
function gradientColor(value, alpha = 1) {
|
||||
const raw = String(value || '').replace(/^#/, '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
|
||||
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
|
||||
return `rgba(${channels.join(',')},${alpha})`;
|
||||
}
|
||||
|
||||
function renderEffects(source, effects, masks) {
|
||||
let current = copyCanvas(source);
|
||||
for (let index = 0; index < effects.length; index += 1) {
|
||||
const effect = effects[index];
|
||||
if (effect.visible === false || effect.opacity <= 0) continue;
|
||||
const next = new OffscreenCanvas(current.width, current.height);
|
||||
const ctx = next.getContext('2d');
|
||||
if (LAYER_STYLES[effect.type]) {
|
||||
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
|
||||
} else if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
} else if (effect.type === 'sharpen') {
|
||||
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
|
||||
} else if (effect.type === 'color-overlay') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.globalCompositeOperation = effect.params.blendMode;
|
||||
ctx.fillStyle = effect.params.color;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
} else if (effect.type === 'drop-shadow') {
|
||||
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.width;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
||||
const p = effect.params;
|
||||
const gradient = effect.type === 'radial-gradient'
|
||||
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
|
||||
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
|
||||
for (const stop of p.stops || []) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
current = blended;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
self.onmessage = event => {
|
||||
try {
|
||||
const { source, effects, masks } = event.data;
|
||||
const output = renderEffects(source, effects || [], masks || []);
|
||||
const bitmap = output.transferToImageBitmap();
|
||||
self.postMessage({ bitmap }, [bitmap]);
|
||||
} catch (error) {
|
||||
self.postMessage({ error: String(error?.message || error) });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
/** Retained, non-destructive layer effects. */
|
||||
import { LAYER_STYLES, styleParams, renderLayerStyle, renderDropShadow } from './layer-styles.js';
|
||||
|
||||
export function effectsWithPreview(owner) {
|
||||
const effects = owner.effects || [];
|
||||
const preview = owner._effectPreview;
|
||||
if (!preview) return effects;
|
||||
const index = preview.id ? effects.findIndex(effect => effect.id === preview.id) : -1;
|
||||
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
|
||||
}
|
||||
|
||||
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
||||
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
|
||||
|
||||
export const EFFECT_PRESETS = {
|
||||
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
|
||||
'crisp-detail': { type: 'sharpen', params: { amount: 0.35 } },
|
||||
'soft-shadow': { type: 'drop-shadow', params: { color: '#000000', opacity: 0.3, blur: 8, x: 2, y: 3 } },
|
||||
'white-outline': { type: 'stroke', params: { color: '#ffffff', opacity: 0.85, width: 2 } },
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeEffectMask(mask) {
|
||||
if (!mask || typeof mask !== 'object' || Array.isArray(mask)) return null;
|
||||
return {
|
||||
id: String(mask.id || `effect-mask-${Math.random().toString(36).slice(2, 9)}`),
|
||||
name: String(mask.name || 'Effect Mask'),
|
||||
visible: mask.visible !== false,
|
||||
canvas: mask.canvas || null,
|
||||
ctx: mask.ctx || null,
|
||||
canvasW: Number(mask.canvasW) || mask.canvas?.width || 0,
|
||||
canvasH: Number(mask.canvasH) || mask.canvas?.height || 0,
|
||||
dataUrl: typeof mask.dataUrl === 'string' ? mask.dataUrl : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultEffectParams(type) {
|
||||
if (LAYER_STYLES[type]) return styleParams(type);
|
||||
if (type === 'gaussian-blur') return { radius: 6 };
|
||||
if (type === 'sharpen') return { amount: 0.5 };
|
||||
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
|
||||
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
|
||||
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
|
||||
if (type === 'linear-gradient' || type === 'radial-gradient') return {
|
||||
x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stops: [{ position: 0, color: '#e06c75', alpha: 1 }, { position: 100, color: '#ffffff', alpha: 1 }],
|
||||
opacity: 1,
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
export function effectPreset(name) {
|
||||
const preset = EFFECT_PRESETS[name];
|
||||
return preset ? { type: preset.type, params: clone(preset.params) } : null;
|
||||
}
|
||||
|
||||
export function normalizeEffect(effect) {
|
||||
const type = EFFECT_TYPES.has(effect?.type) ? effect.type : 'gaussian-blur';
|
||||
const defaults = defaultEffectParams(type);
|
||||
const params = effect?.params && typeof effect.params === 'object'
|
||||
? { ...defaults, ...clone(effect.params) }
|
||||
: defaults;
|
||||
if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params));
|
||||
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
|
||||
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
|
||||
if (type === 'color-overlay') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
|
||||
}
|
||||
if (type === 'drop-shadow') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
|
||||
params.opacity = Math.max(0, Math.min(1, params.opacity));
|
||||
params.blur = Math.max(0, Math.min(200, params.blur));
|
||||
params.x = Math.max(-200, Math.min(200, params.x));
|
||||
params.y = Math.max(-200, Math.min(200, params.y));
|
||||
}
|
||||
if (type === 'stroke') {
|
||||
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
params.width = Math.max(0, Math.min(100, Number(params.width) || 0));
|
||||
}
|
||||
if (type === 'linear-gradient' || type === 'radial-gradient') {
|
||||
for (const key of ['x1', 'y1', 'x2', 'y2']) {
|
||||
const value = Number(params[key]);
|
||||
params[key] = Number.isFinite(value) ? Math.max(-2_000_000, Math.min(2_000_000, value)) : defaults[key];
|
||||
}
|
||||
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
|
||||
const stops = Array.isArray(params.stops) ? params.stops : defaults.stops;
|
||||
params.stops = stops.map(stop => ({
|
||||
position: Math.max(0, Math.min(100, Number(stop?.position) || 0)),
|
||||
color: /^#[0-9a-f]{6}$/i.test(stop?.color) ? stop.color : '#000000',
|
||||
alpha: Math.max(0, Math.min(1, Number(stop?.alpha ?? 1) || 0)),
|
||||
})).sort((a, b) => a.position - b.position).slice(0, 12);
|
||||
if (params.stops.length < 2) params.stops = clone(defaults.stops);
|
||||
}
|
||||
return {
|
||||
id: String(effect?.id || `effect-${Math.random().toString(36).slice(2, 9)}`),
|
||||
type,
|
||||
name: String(effect?.name || effectLabel(type)),
|
||||
visible: effect?.visible !== false,
|
||||
opacity: Math.max(0, Math.min(1, Number(effect?.opacity ?? 1) || 0)),
|
||||
mask: normalizeEffectMask(effect?.mask),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export function effectLabel(type) {
|
||||
if (LAYER_STYLES[type]) return LAYER_STYLES[type].label;
|
||||
return {
|
||||
'gaussian-blur': 'Gaussian Blur',
|
||||
sharpen: 'Sharpen',
|
||||
'color-overlay': 'Color Overlay',
|
||||
'drop-shadow': 'Drop Shadow',
|
||||
'stroke': 'Stroke',
|
||||
'linear-gradient': 'Gradient',
|
||||
'radial-gradient': 'Radial Gradient',
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
function copyCanvas(source) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = source.width;
|
||||
canvas.height = source.height;
|
||||
canvas.getContext('2d').drawImage(source, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function gradientColor(value, alpha = 1) {
|
||||
const raw = String(value || '').replace(/^#/, '');
|
||||
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
|
||||
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
|
||||
return `rgba(${channels.join(',')},${alpha})`;
|
||||
}
|
||||
|
||||
function renderGradient(ctx, effect, width, height) {
|
||||
const p = effect.params;
|
||||
const gradient = effect.type === 'radial-gradient'
|
||||
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
|
||||
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
|
||||
for (const stop of p.stops) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function applyEffectMask(source, effected, mask) {
|
||||
if (!mask?.canvas || !source || !effected) return effected;
|
||||
const masked = document.createElement('canvas');
|
||||
masked.width = effected.width;
|
||||
masked.height = effected.height;
|
||||
const mctx = masked.getContext('2d');
|
||||
mctx.drawImage(effected, 0, 0);
|
||||
mctx.globalCompositeOperation = 'destination-in';
|
||||
mctx.drawImage(mask.canvas, 0, 0, masked.width, masked.height);
|
||||
mctx.globalCompositeOperation = 'source-over';
|
||||
return masked;
|
||||
}
|
||||
|
||||
function sharpenCanvas(source, amount, shouldContinue = () => true) {
|
||||
if (!amount) return copyCanvas(source);
|
||||
const out = document.createElement('canvas');
|
||||
out.width = source.width;
|
||||
out.height = source.height;
|
||||
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
|
||||
const dst = out.getContext('2d').createImageData(source.width, source.height);
|
||||
const a = Math.max(0, Math.min(1, Number(amount) || 0));
|
||||
const stride = source.width * 4;
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
if ((y & 7) === 0 && !shouldContinue()) return null;
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = (y * source.width + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel += 1) {
|
||||
const center = src.data[at + channel] * (1 + 4 * a);
|
||||
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
|
||||
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
|
||||
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
|
||||
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
|
||||
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
|
||||
}
|
||||
dst.data[at + 3] = src.data[at + 3];
|
||||
}
|
||||
}
|
||||
out.getContext('2d').putImageData(dst, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render effects in list order without mutating the source layer. */
|
||||
export function renderEffects(source, effects = [], shouldContinue = () => true) {
|
||||
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
||||
let current = copyCanvas(source);
|
||||
for (const raw of effects) {
|
||||
if (!shouldContinue()) return current;
|
||||
const effect = normalizeEffect(raw);
|
||||
if (!effect.visible || effect.opacity <= 0) continue;
|
||||
const next = document.createElement('canvas');
|
||||
next.width = current.width;
|
||||
next.height = current.height;
|
||||
const ctx = next.getContext('2d');
|
||||
if (LAYER_STYLES[effect.type]) {
|
||||
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
|
||||
} else if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
} else if (effect.type === 'sharpen') {
|
||||
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
|
||||
if (!sharpened) return current;
|
||||
ctx.drawImage(sharpened, 0, 0);
|
||||
} else if (effect.type === 'color-overlay') {
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.globalCompositeOperation = effect.params.blendMode;
|
||||
ctx.fillStyle = effect.params.color;
|
||||
ctx.fillRect(0, 0, next.width, next.height);
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (effect.type === 'drop-shadow') {
|
||||
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
ctx.shadowBlur = effect.params.width;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.globalAlpha = 1;
|
||||
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
|
||||
renderGradient(ctx, effect, next.width, next.height);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const contribution = effect.mask?.canvas && effect.mask.visible !== false
|
||||
? applyEffectMask(current, next, effect.mask)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
current = blended;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize retained effects off the main thread when browser canvas workers
|
||||
* are available. The synchronous renderer remains the compatibility fallback.
|
||||
*/
|
||||
export async function renderEffectsAsync(source, effects = [], shouldContinue = () => true) {
|
||||
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
|
||||
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
|
||||
return renderEffects(source, effects, shouldContinue);
|
||||
}
|
||||
if (!shouldContinue()) return source;
|
||||
let sourceBitmap;
|
||||
const maskBitmaps = [];
|
||||
try {
|
||||
sourceBitmap = await createImageBitmap(source);
|
||||
for (const effect of effects) {
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return null;
|
||||
}
|
||||
maskBitmaps.push(effect?.mask?.canvas && effect.mask.visible !== false
|
||||
? await createImageBitmap(effect.mask.canvas)
|
||||
: null);
|
||||
}
|
||||
} catch {
|
||||
sourceBitmap?.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
||||
}
|
||||
if (!shouldContinue()) {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return source;
|
||||
}
|
||||
let worker;
|
||||
try {
|
||||
worker = new Worker(new URL('./effects-worker.js', import.meta.url), { type: 'module' });
|
||||
} catch {
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
|
||||
}
|
||||
const payloadEffects = effects.map(raw => {
|
||||
const effect = normalizeEffect(raw);
|
||||
return { ...effect, mask: null };
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
sourceBitmap.close?.();
|
||||
for (const bitmap of maskBitmaps) bitmap?.close?.();
|
||||
resolve(result);
|
||||
};
|
||||
worker.onmessage = event => {
|
||||
const { bitmap, error } = event.data || {};
|
||||
if (error || !bitmap || !shouldContinue()) {
|
||||
bitmap?.close?.();
|
||||
finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : source);
|
||||
return;
|
||||
}
|
||||
const output = document.createElement('canvas');
|
||||
output.width = source.width;
|
||||
output.height = source.height;
|
||||
output.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
finish(output);
|
||||
};
|
||||
// A stale worker must not trigger a full-resolution fallback while a newer
|
||||
// preview is already queued.
|
||||
worker.onerror = () => finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : null);
|
||||
worker.postMessage({ source: sourceBitmap, effects: payloadEffects, masks: maskBitmaps }, [
|
||||
sourceBitmap,
|
||||
...maskBitmaps.filter(Boolean),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
export const EFFECT_TYPES_LIST = [...EFFECT_TYPES];
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Controlled image export dialog and canvas encoding helpers. */
|
||||
|
||||
const FORMAT_META = {
|
||||
png: { mime: 'image/png', extension: 'png', quality: undefined },
|
||||
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
|
||||
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
|
||||
};
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
export function normalizeExportSettings(settings, sourceWidth, sourceHeight) {
|
||||
const format = FORMAT_META[settings?.format] ? settings.format : 'png';
|
||||
return {
|
||||
format,
|
||||
width: Math.round(clampNumber(settings?.width, 1, 16384, sourceWidth)),
|
||||
height: Math.round(clampNumber(settings?.height, 1, 16384, sourceHeight)),
|
||||
quality: clampNumber(settings?.quality, 0.01, 1, FORMAT_META[format].quality ?? 1),
|
||||
transparency: format !== 'jpeg' && settings?.transparency !== false,
|
||||
matte: /^#[0-9a-f]{6}$/i.test(settings?.matte || '') ? settings.matte : '#ffffff',
|
||||
filename: String(settings?.filename || 'edited-image').trim() || 'edited-image',
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareExportCanvas(sourceCanvas, settings) {
|
||||
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = normalized.width;
|
||||
canvas.height = normalized.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
if (!normalized.transparency) {
|
||||
ctx.fillStyle = normalized.matte;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function encodeExportCanvas(sourceCanvas, settings) {
|
||||
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
|
||||
const output = prepareExportCanvas(sourceCanvas, normalized);
|
||||
const meta = FORMAT_META[normalized.format];
|
||||
return new Promise((resolve, reject) => {
|
||||
output.toBlob(
|
||||
blob => blob ? resolve({ blob, settings: normalized, extension: meta.extension }) : reject(new Error('Image encoding failed')),
|
||||
meta.mime,
|
||||
meta.quality == null ? undefined : normalized.quality,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Estimating...';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function openExportDialog({
|
||||
sourceCanvas,
|
||||
defaultName = 'edited-image',
|
||||
title = 'Export Image',
|
||||
submitLabel = 'Export',
|
||||
attachColorPicker = null,
|
||||
returnFocus = null,
|
||||
}) {
|
||||
return new Promise(resolve => {
|
||||
const previouslyFocused = returnFocus || document.activeElement;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ge-export-overlay';
|
||||
overlay.innerHTML = `
|
||||
<form class="ge-export-dialog" role="dialog" aria-modal="true" aria-labelledby="ge-export-title">
|
||||
<header class="ge-export-head">
|
||||
<h2 id="ge-export-title">${String(title).replace(/[<>&]/g, '')}</h2>
|
||||
<button type="button" class="ge-export-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="ge-export-body">
|
||||
<div class="ge-export-preview-wrap"><canvas class="ge-export-preview"></canvas></div>
|
||||
<div class="ge-export-fields">
|
||||
<label class="ge-export-field"><span>Filename</span><input id="ge-export-filename" type="text" value="${String(defaultName).replace(/[<>&"']/g, '')}" /></label>
|
||||
<div class="ge-export-field"><span>Format</span><div class="ge-export-format" role="group" aria-label="Export format">
|
||||
<button type="button" class="active" data-format="png">PNG</button>
|
||||
<button type="button" data-format="jpeg">JPEG</button>
|
||||
<button type="button" data-format="webp">WebP</button>
|
||||
</div></div>
|
||||
<div class="ge-export-dimensions">
|
||||
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
|
||||
<button type="button" class="ge-export-link active" aria-label="Lock aspect ratio" title="Lock aspect ratio" aria-pressed="true">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
</button>
|
||||
<label class="ge-export-field"><span>Height</span><input id="ge-export-height" type="number" min="1" max="16384" value="${sourceCanvas.height}" /></label>
|
||||
</div>
|
||||
<label class="ge-export-field ge-export-quality"><span>Quality <output>90%</output></span><input id="ge-export-quality" type="range" min="1" max="100" value="90" /></label>
|
||||
<div class="ge-export-surface-row">
|
||||
<label class="ge-export-toggle"><input id="ge-export-transparency" type="checkbox" checked /><span>Transparency</span></label>
|
||||
<label class="ge-export-matte"><span>Matte</span><input id="ge-export-matte" class="ge-color-picker" type="color" value="#ffffff" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ge-export-footer"><span class="ge-export-estimate">Estimating...</span><div><button type="button" class="ge-btn ge-btn-sm ge-export-cancel">Cancel</button><button type="submit" class="ge-btn ge-btn-primary">${String(submitLabel).replace(/[<>&]/g, '')}</button></div></footer>
|
||||
</form>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const dialog = overlay.querySelector('.ge-export-dialog');
|
||||
const preview = overlay.querySelector('.ge-export-preview');
|
||||
const filename = overlay.querySelector('#ge-export-filename');
|
||||
const widthInput = overlay.querySelector('#ge-export-width');
|
||||
const heightInput = overlay.querySelector('#ge-export-height');
|
||||
const qualityInput = overlay.querySelector('#ge-export-quality');
|
||||
const qualityField = overlay.querySelector('.ge-export-quality');
|
||||
const qualityOutput = qualityField.querySelector('output');
|
||||
const transparency = overlay.querySelector('#ge-export-transparency');
|
||||
const matte = overlay.querySelector('#ge-export-matte');
|
||||
const matteField = overlay.querySelector('.ge-export-matte');
|
||||
const estimate = overlay.querySelector('.ge-export-estimate');
|
||||
const link = overlay.querySelector('.ge-export-link');
|
||||
const ratio = sourceCanvas.width / sourceCanvas.height;
|
||||
let format = 'png';
|
||||
let ratioLocked = true;
|
||||
let estimateTimer = null;
|
||||
let estimateVersion = 0;
|
||||
|
||||
try { attachColorPicker?.(matte); } catch {}
|
||||
|
||||
const settings = () => normalizeExportSettings({
|
||||
format,
|
||||
width: widthInput.value,
|
||||
height: heightInput.value,
|
||||
quality: Number(qualityInput.value) / 100,
|
||||
transparency: transparency.checked,
|
||||
matte: matte.value,
|
||||
filename: filename.value,
|
||||
}, sourceCanvas.width, sourceCanvas.height);
|
||||
|
||||
const renderPreview = () => {
|
||||
const current = settings();
|
||||
const max = 420;
|
||||
const scale = Math.min(max / current.width, max / current.height, 1);
|
||||
preview.width = Math.max(1, Math.round(current.width * scale));
|
||||
preview.height = Math.max(1, Math.round(current.height * scale));
|
||||
const ctx = preview.getContext('2d');
|
||||
// Settings can change without changing dimensions, so do not let the
|
||||
// previous matte or alpha state bleed into the next preview.
|
||||
ctx.clearRect(0, 0, preview.width, preview.height);
|
||||
if (!current.transparency) {
|
||||
ctx.fillStyle = current.matte;
|
||||
ctx.fillRect(0, 0, preview.width, preview.height);
|
||||
}
|
||||
ctx.drawImage(sourceCanvas, 0, 0, preview.width, preview.height);
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
const lossy = format !== 'png';
|
||||
qualityField.hidden = !lossy;
|
||||
transparency.disabled = format === 'jpeg';
|
||||
if (format === 'jpeg') transparency.checked = false;
|
||||
matteField.classList.toggle('disabled', transparency.checked && format !== 'jpeg');
|
||||
qualityOutput.textContent = `${qualityInput.value}%`;
|
||||
renderPreview();
|
||||
estimate.textContent = 'Estimating...';
|
||||
clearTimeout(estimateTimer);
|
||||
const version = ++estimateVersion;
|
||||
estimateTimer = setTimeout(async () => {
|
||||
try {
|
||||
const encoded = await encodeExportCanvas(sourceCanvas, settings());
|
||||
if (version === estimateVersion) estimate.textContent = formatBytes(encoded.blob.size);
|
||||
} catch {
|
||||
if (version === estimateVersion) estimate.textContent = 'Estimate unavailable';
|
||||
}
|
||||
}, 180);
|
||||
};
|
||||
|
||||
overlay.querySelectorAll('[data-format]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
format = button.dataset.format;
|
||||
overlay.querySelectorAll('[data-format]').forEach(candidate => {
|
||||
candidate.classList.toggle('active', candidate.dataset.format === format);
|
||||
});
|
||||
update();
|
||||
});
|
||||
});
|
||||
link.addEventListener('click', () => {
|
||||
ratioLocked = !ratioLocked;
|
||||
link.classList.toggle('active', ratioLocked);
|
||||
link.setAttribute('aria-pressed', ratioLocked ? 'true' : 'false');
|
||||
});
|
||||
widthInput.addEventListener('input', () => {
|
||||
if (ratioLocked) heightInput.value = String(Math.max(1, Math.round(Number(widthInput.value || 1) / ratio)));
|
||||
update();
|
||||
});
|
||||
heightInput.addEventListener('input', () => {
|
||||
if (ratioLocked) widthInput.value = String(Math.max(1, Math.round(Number(heightInput.value || 1) * ratio)));
|
||||
update();
|
||||
});
|
||||
[qualityInput, transparency, matte].forEach(input => input.addEventListener('input', update));
|
||||
|
||||
const close = result => {
|
||||
clearTimeout(estimateTimer);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
overlay.remove();
|
||||
if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus({ preventScroll: true });
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const onKey = event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(null); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
overlay.querySelector('.ge-export-close').addEventListener('click', () => close(null));
|
||||
overlay.querySelector('.ge-export-cancel').addEventListener('click', () => close(null));
|
||||
overlay.addEventListener('click', event => { if (event.target === overlay) close(null); });
|
||||
dialog.addEventListener('submit', event => { event.preventDefault(); close(settings()); });
|
||||
update();
|
||||
filename.select();
|
||||
});
|
||||
}
|
||||
@@ -43,11 +43,13 @@ import modalManager from '../../modalManager.js';
|
||||
import {
|
||||
ADJ_ICONS,
|
||||
adjLayerLabel,
|
||||
adjustmentPresetOptions,
|
||||
adjustmentPresetParams,
|
||||
defaultAdjParams,
|
||||
} from '../layer-helpers.js';
|
||||
import { drawHistogram } from './histogram.js';
|
||||
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
|
||||
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
|
||||
function suppressLayerGhostTap() {
|
||||
window.__geSuppressLayerTapUntil = Date.now() + 650;
|
||||
}
|
||||
@@ -104,6 +106,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
}
|
||||
|
||||
function openFxPopup(layer, anchorEl) {
|
||||
if (layer?.kind === 'adjustment') {
|
||||
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
|
||||
return;
|
||||
}
|
||||
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
|
||||
// `state` is a shared singleton that survives editor close/reopen,
|
||||
// so a stale `fxMenuEl` from a previous session (whose detached
|
||||
@@ -135,9 +141,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
menu.style.pointerEvents = 'auto';
|
||||
const items = [
|
||||
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
|
||||
{ type: 'exposure', label: 'Exposure' },
|
||||
{ type: 'white-balance', label: 'White Balance' },
|
||||
{ type: 'hue-saturation', label: 'Hue / Saturation' },
|
||||
{ type: 'vibrance', label: 'Vibrance' },
|
||||
{ type: 'black-white', label: 'Black & White' },
|
||||
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
|
||||
{ type: 'levels', label: 'Levels' },
|
||||
{ type: 'curves', label: 'Curves' },
|
||||
{ type: 'color-balance', label: 'Color Balance' },
|
||||
{ type: 'selective-color', label: 'Selective Color' },
|
||||
{ type: 'gradient-map', label: 'Gradient Map' },
|
||||
];
|
||||
menu.innerHTML = items.map(i =>
|
||||
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
|
||||
@@ -257,7 +271,21 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
suppressLayerGhostTap();
|
||||
const layer = state.adjPopupEl._layer;
|
||||
if (layer) {
|
||||
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (layer._stagedAdj) layer._stagedAdj = null;
|
||||
if (layer._newAdjustmentLayer) {
|
||||
const index = state.layers.findIndex(item => item.id === layer.id);
|
||||
if (index >= 0) state.layers.splice(index, 1);
|
||||
state.layerOffsets.delete(layer.id);
|
||||
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
|
||||
state.undoStack.length = layer._newAdjustmentUndoLength;
|
||||
}
|
||||
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
|
||||
layer._newAdjustmentLayer = false;
|
||||
renderLayerPanel();
|
||||
}
|
||||
if (layer._editingAdjId) layer._editingAdjId = null;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -277,11 +305,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
closeAdjPopup();
|
||||
// Editing an existing sub-layer? Pre-load its params as the staged
|
||||
// preview and mark the popup so Apply updates instead of appending.
|
||||
const firstClass = layer?.kind === 'adjustment';
|
||||
const editing = !!existingAdj;
|
||||
const presetOptions = adjustmentPresetOptions(type);
|
||||
const startParams = editing
|
||||
? JSON.parse(JSON.stringify(existingAdj.params))
|
||||
: defaultAdjParams(type);
|
||||
layer._stagedAdj = { type, params: startParams };
|
||||
layer._adjPreview = true;
|
||||
state.adjPreviewLayer = layer;
|
||||
if (editing) {
|
||||
// Hide the existing sub-layer from the render stack so the
|
||||
// staged preview shows correctly without doubling the effect.
|
||||
@@ -303,8 +335,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<button class="ge-adj-min" type="button" title="Minimise">−</button>
|
||||
</span>
|
||||
</div>
|
||||
<label class="ge-adj-preset-field">Preset
|
||||
<select class="ge-adj-preset-select">
|
||||
<option value="custom"${editing ? ' selected' : ''}>Custom</option>
|
||||
${presetOptions.map(name => `<option value="${name}"${!editing && name === 'Default' ? ' selected' : ''}>${name}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-adj-body" data-adj-body></div>
|
||||
<div class="ge-adj-foot">
|
||||
<button class="ge-btn ge-btn-sm ge-adj-reset-btn" data-adj-action="reset">Reset</button>
|
||||
<span class="ge-adj-foot-spacer"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-compare-btn" data-adj-action="compare" aria-pressed="false">Compare</button>
|
||||
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
|
||||
</div>
|
||||
@@ -330,6 +371,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
const body = pop.querySelector('[data-adj-body]');
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
body.addEventListener('input', () => {
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'custom';
|
||||
});
|
||||
const popupRect = pop.getBoundingClientRect();
|
||||
if (!window.matchMedia('(max-width: 820px)').matches) {
|
||||
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
|
||||
pop.style.top = `${clampedTop}px`;
|
||||
}
|
||||
|
||||
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
|
||||
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
|
||||
@@ -391,14 +441,52 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
e.stopPropagation();
|
||||
closeAdjPopup();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._stagedAdj.params = defaultAdjParams(type);
|
||||
const presetSelect = pop.querySelector('.ge-adj-preset-select');
|
||||
if (presetSelect) presetSelect.value = 'Default';
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const preset = e.currentTarget.value;
|
||||
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
|
||||
layer._adjFinalKey = null;
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, pop);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
layer._adjCompare = !layer._adjCompare;
|
||||
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
|
||||
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
suppressLayerGhostTap();
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
if (!layer._newAdjustmentLayer) {
|
||||
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
|
||||
}
|
||||
const params = layer._stagedAdj.params;
|
||||
layer._stagedAdj = null;
|
||||
if (editing) {
|
||||
layer._adjPreview = false;
|
||||
layer._adjCompare = false;
|
||||
if (firstClass) {
|
||||
layer.adjustment = { type, params };
|
||||
layer.name = layer.name || adjLayerLabel(type);
|
||||
layer._newAdjustmentLayer = false;
|
||||
layer._newAdjustmentUndoLength = null;
|
||||
} else if (editing) {
|
||||
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
|
||||
if (existing) existing.params = params;
|
||||
layer._editingAdjId = null;
|
||||
@@ -426,6 +514,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
state.adjRafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.adjRafPending = false;
|
||||
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
});
|
||||
@@ -433,6 +522,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
|
||||
function buildAdjBody(layer, type, body, popEl) {
|
||||
const p = layer._stagedAdj.params;
|
||||
const levelValues = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
if (channel === 'rgb') return p;
|
||||
if (!p.channels) p.channels = {};
|
||||
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
|
||||
return p.channels[channel];
|
||||
};
|
||||
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||
const sliderRow = (key, label, min, max, value, suffix) => `
|
||||
<div class="ge-adj-row" data-adj-key="${key}">
|
||||
@@ -449,14 +545,46 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
|
||||
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
|
||||
`;
|
||||
} else if (type === 'exposure') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
|
||||
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
|
||||
`;
|
||||
} else if (type === 'white-balance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
|
||||
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
|
||||
`;
|
||||
} else if (type === 'hue-saturation') {
|
||||
const hSlider = Math.round(p.hue);
|
||||
const sSlider = Math.round((p.saturation - 1) * 100);
|
||||
body.innerHTML = `
|
||||
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
|
||||
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
|
||||
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
|
||||
`;
|
||||
} else if (type === 'vibrance') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
|
||||
<p class="ge-adj-help">Boosts muted colors more than already-saturated colors.</p>
|
||||
`;
|
||||
} else if (type === 'black-white') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
|
||||
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
|
||||
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
|
||||
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
|
||||
<p class="ge-adj-help">Mixes the source channels into a neutral grayscale result.</p>
|
||||
`;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
body.innerHTML = `
|
||||
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
|
||||
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
|
||||
<p class="ge-adj-help">Recover detail in dark and bright tones without shifting midtones.</p>
|
||||
`;
|
||||
} else if (type === 'levels') {
|
||||
const values = levelValues();
|
||||
// Histogram canvas + sliders. Histogram is computed from the
|
||||
// layer's pixel data (after any adjLayers below this one) so
|
||||
// the user is matching levels against what they're really seeing.
|
||||
@@ -464,6 +592,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// vertical space; open by default on desktop.
|
||||
const isMobile = window.matchMedia('(max-width: 820px)').matches;
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${(p.channel || 'rgb') === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
|
||||
<summary>Histogram</summary>
|
||||
<div class="ge-adj-hist-wrap">
|
||||
@@ -474,20 +610,60 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-adj-clip-status" aria-live="polite"></div>
|
||||
</details>
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
|
||||
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
|
||||
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
|
||||
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
|
||||
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
|
||||
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
|
||||
`;
|
||||
const hist = body.querySelector('.ge-adj-histogram');
|
||||
drawHistogram(hist, layer);
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const clipStatus = body.querySelector('.ge-adj-clip-status');
|
||||
const renderHistogram = () => {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const stats = drawHistogram(hist, histogramLayer, channel);
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
};
|
||||
renderHistogram();
|
||||
wireHistogramHandles(body, layer, type);
|
||||
// Redraw histogram when the user opens the disclosure (canvas
|
||||
// dimensions are layout-dependent).
|
||||
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
|
||||
if (e.target.open) drawHistogram(hist, layer);
|
||||
if (e.target.open) renderHistogram();
|
||||
});
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'curves') {
|
||||
if (!p.points) p.points = defaultAdjParams('curves').points;
|
||||
if (!p.channel) p.channel = 'rgb';
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Channel
|
||||
<select class="ge-adj-channel-select">
|
||||
<option value="rgb"${p.channel === 'rgb' ? ' selected' : ''}>RGB</option>
|
||||
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
|
||||
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
|
||||
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ge-curves-wrap">
|
||||
<canvas class="ge-curves-canvas" width="280" height="180" aria-label="Editable tone curve"></canvas>
|
||||
</div>
|
||||
<p class="ge-adj-help">Click the curve to add a point. Drag to adjust. Double-click a point to remove it.</p>
|
||||
`;
|
||||
const canvas = body.querySelector('.ge-curves-canvas');
|
||||
wireCurveEditor(canvas, layer);
|
||||
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
|
||||
p.channel = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'color-balance') {
|
||||
// Color-tinted slider ends so the user sees what direction does what.
|
||||
@@ -527,6 +703,49 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
body.innerHTML = '';
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'selective-color') {
|
||||
if (!p.range) p.range = 'reds';
|
||||
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
|
||||
const values = p.ranges[p.range];
|
||||
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
|
||||
body.innerHTML = `
|
||||
<label class="ge-adj-channel-field">Colors
|
||||
<select class="ge-adj-selective-range">
|
||||
${ranges.map(name => `<option value="${name}"${p.range === name ? ' selected' : ''}>${name[0].toUpperCase() + name.slice(1)}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
|
||||
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
|
||||
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
|
||||
${sliderRow('black', 'Black', -100, 100, values.black, '')}
|
||||
`;
|
||||
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
|
||||
p.range = event.target.value;
|
||||
buildAdjBody(layer, type, body, popEl);
|
||||
});
|
||||
} else if (type === 'gradient-map') {
|
||||
body.innerHTML = `
|
||||
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
|
||||
<div class="ge-gradient-color-row">
|
||||
<label>Shadows <input type="color" data-gradient-key="shadows" value="${p.shadows}"></label>
|
||||
<label>Highlights <input type="color" data-gradient-key="highlights" value="${p.highlights}"></label>
|
||||
</div>
|
||||
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
|
||||
<label class="ge-adj-toggle-row"><span>Reverse</span><input type="checkbox" data-gradient-reverse${p.reverse ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
|
||||
`;
|
||||
body.querySelectorAll('[data-gradient-key]').forEach(input => {
|
||||
input.addEventListener('input', () => {
|
||||
p[input.dataset.gradientKey] = input.value;
|
||||
const preview = body.querySelector('.ge-gradient-map-preview');
|
||||
preview?.style.setProperty('--ge-gradient-start', p.shadows);
|
||||
preview?.style.setProperty('--ge-gradient-end', p.highlights);
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
});
|
||||
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
|
||||
p.reverse = event.target.checked;
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
}
|
||||
// Wire all sliders.
|
||||
body.querySelectorAll('input[type="range"]').forEach(sl => {
|
||||
@@ -550,13 +769,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
function revertAdjKey(layer, type, key) {
|
||||
const defaults = defaultAdjParams(type);
|
||||
const p = layer._stagedAdj.params;
|
||||
if (type === 'brightness-contrast' || type === 'hue-saturation') {
|
||||
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
|
||||
p[key] = defaults[key];
|
||||
} else if (type === 'levels') {
|
||||
p[key] = defaults[key];
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
|
||||
values[key] = defaultValues[key];
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = defaults[tone][ch];
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = defaults.ranges[p.range][key];
|
||||
}
|
||||
layer._adjFinalKey = null;
|
||||
composite();
|
||||
@@ -573,21 +797,142 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
p[key] = 1 + raw / 100;
|
||||
} else if (key === 'hue') {
|
||||
p.hue = raw; display = raw + ' °';
|
||||
} else if (key === 'lightness') {
|
||||
p.lightness = raw;
|
||||
}
|
||||
} else if (type === 'exposure') {
|
||||
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
|
||||
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
|
||||
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
|
||||
} else if (type === 'white-balance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'vibrance') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'black-white') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'shadows-highlights') {
|
||||
p[key] = raw;
|
||||
} else if (type === 'levels') {
|
||||
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
|
||||
const values = channel === 'rgb' ? p : p.channels[channel];
|
||||
if (key === 'gamma') {
|
||||
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
|
||||
} else {
|
||||
p[key] = raw;
|
||||
values[key] = raw;
|
||||
}
|
||||
} else if (type === 'color-balance') {
|
||||
const [tone, ch] = key.split('-');
|
||||
p[tone][ch] = raw;
|
||||
} else if (type === 'selective-color') {
|
||||
p.ranges[p.range][key] = raw;
|
||||
} else if (type === 'gradient-map') {
|
||||
p.midpoint = raw;
|
||||
display = raw + '%';
|
||||
}
|
||||
if (valEl) valEl.textContent = display;
|
||||
scheduleAdjRefresh(layer);
|
||||
}
|
||||
|
||||
function wireCurveEditor(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
const params = layer._stagedAdj.params;
|
||||
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
|
||||
const points = () => params.points[params.channel] || params.points.rgb;
|
||||
const canvasPoint = event => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
|
||||
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
|
||||
};
|
||||
};
|
||||
const draw = () => {
|
||||
const source = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? getAdjustmentSource(layer)
|
||||
: layer.canvas;
|
||||
drawHistogram(canvas, { canvas: source });
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.12)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let index = 1; index < 4; index += 1) {
|
||||
const x = Math.round(width * index / 4) + .5;
|
||||
const y = Math.round(height * index / 4) + .5;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
|
||||
}
|
||||
const list = points();
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
list.forEach((point, index) => {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
for (const point of list) {
|
||||
const x = point[0] / 255 * width;
|
||||
const y = height - point[1] / 255 * height;
|
||||
ctx.fillStyle = '#0b0d10';
|
||||
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
};
|
||||
const nearestIndex = event => {
|
||||
const point = canvasPoint(event);
|
||||
let nearest = -1;
|
||||
let distance = 14;
|
||||
points().forEach((candidate, index) => {
|
||||
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
|
||||
if (next < distance) { distance = next; nearest = index; }
|
||||
});
|
||||
return nearest;
|
||||
};
|
||||
canvas.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
const list = points();
|
||||
let index = nearestIndex(event);
|
||||
if (index < 0) {
|
||||
const point = canvasPoint(event);
|
||||
list.push([Math.round(point.x), Math.round(point.y)]);
|
||||
list.sort((a, b) => a[0] - b[0]);
|
||||
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
const move = moveEvent => {
|
||||
const next = canvasPoint(moveEvent);
|
||||
const current = list[index];
|
||||
const endpoint = index === 0 || index === list.length - 1;
|
||||
current[0] = endpoint
|
||||
? (index === 0 ? 0 : 255)
|
||||
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
|
||||
current[1] = Math.round(next.y);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const up = () => {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
};
|
||||
canvas.addEventListener('pointermove', move);
|
||||
canvas.addEventListener('pointerup', up);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
canvas.addEventListener('dblclick', event => {
|
||||
const index = nearestIndex(event);
|
||||
const list = points();
|
||||
if (index <= 0 || index >= list.length - 1) return;
|
||||
list.splice(index, 1);
|
||||
draw();
|
||||
scheduleAdjRefresh(layer);
|
||||
});
|
||||
draw();
|
||||
}
|
||||
|
||||
// Position the three histogram triangle handles by current staged
|
||||
// values + wire pointer drags.
|
||||
function wireHistogramHandles(bodyEl, layer, type) {
|
||||
@@ -595,9 +940,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const canvas = bodyEl.querySelector('.ge-adj-histogram');
|
||||
if (!wrap || !canvas) return;
|
||||
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
|
||||
const levelParams = () => {
|
||||
const root = layer._stagedAdj.params;
|
||||
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
|
||||
return channel === 'rgb' ? root : root.channels[channel];
|
||||
};
|
||||
const placeHandles = () => {
|
||||
const w = canvas.getBoundingClientRect().width;
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
const xB = (p.inBlack / 255) * w;
|
||||
const xW = (p.inWhite / 255) * w;
|
||||
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
|
||||
@@ -623,7 +973,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
const onMove = (ev) => {
|
||||
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
|
||||
const v = Math.round((x / rect.width) * 255);
|
||||
const p = layer._stagedAdj.params;
|
||||
const p = levelParams();
|
||||
if (which === 'inBlack') {
|
||||
p.inBlack = Math.min(p.inWhite - 1, v);
|
||||
} else if (which === 'inWhite') {
|
||||
@@ -642,14 +992,26 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
|
||||
// Update visible slider rows + value labels.
|
||||
const updateRow = (key, displayVal) => {
|
||||
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
|
||||
const values = levelParams();
|
||||
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
|
||||
const val = sl?.parentElement.querySelector('.ge-adj-value');
|
||||
if (val) val.textContent = displayVal;
|
||||
};
|
||||
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
|
||||
drawHistogram(canvas, layer);
|
||||
const values = levelParams();
|
||||
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
|
||||
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
|
||||
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
|
||||
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
|
||||
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
|
||||
: layer;
|
||||
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
|
||||
? layer._stagedAdj.params.channel : 'rgb';
|
||||
const stats = drawHistogram(canvas, histogramLayer, channel);
|
||||
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
|
||||
if (clipStatus) {
|
||||
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
|
||||
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
|
||||
}
|
||||
scheduleAdjRefresh(layer);
|
||||
};
|
||||
const onUp = () => {
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
* canvas: HTMLCanvasElement,
|
||||
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
|
||||
* }} layer Source layer.
|
||||
* @param {'rgb'|'red'|'green'|'blue'} channel Channel to inspect. `rgb` uses luminance.
|
||||
* @returns {{black: number, white: number, samples: number}}
|
||||
*/
|
||||
export function drawHistogram(canvas, layer) {
|
||||
if (!canvas) return;
|
||||
export function drawHistogram(canvas, layer, channel = 'rgb') {
|
||||
if (!canvas) return { black: 0, white: 0, samples: 0 };
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
@@ -33,11 +35,21 @@ export function drawHistogram(canvas, layer) {
|
||||
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
|
||||
|
||||
const hist = new Uint32Array(256);
|
||||
let samples = 0;
|
||||
let black = 0;
|
||||
let white = 0;
|
||||
for (let i = 0; i < img.length; i += 4) {
|
||||
if (img[i + 3] < 8) continue; // skip near-transparent
|
||||
// Rec. 709 luminance — common choice for histograms in photo editors.
|
||||
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
|
||||
hist[Math.min(255, Y)]++;
|
||||
const value = channel === 'red' ? img[i]
|
||||
: channel === 'green' ? img[i + 1]
|
||||
: channel === 'blue' ? img[i + 2]
|
||||
// Rec. 709 luminance — common choice for composite histograms.
|
||||
: 0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2];
|
||||
const bucket = Math.max(0, Math.min(255, value | 0));
|
||||
hist[bucket]++;
|
||||
samples++;
|
||||
if (bucket === 0) black++;
|
||||
if (bucket === 255) white++;
|
||||
}
|
||||
let peak = 1;
|
||||
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
|
||||
@@ -64,4 +76,9 @@ export function drawHistogram(canvas, layer) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
||||
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
|
||||
}
|
||||
return {
|
||||
black: samples ? black / samples * 100 : 0,
|
||||
white: samples ? white / samples * 100 : 0,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
|
||||
* Apply a Brightness/Contrast, Black & White, Hue/Saturation, Levels, Curves, or Color Balance
|
||||
* adjustment to a source canvas and return a fresh canvas with the
|
||||
* result. Pure pixel math — no DOM, no module state.
|
||||
*
|
||||
@@ -11,8 +11,111 @@
|
||||
* { type: 'brightness-contrast', params: { brightness, contrast } }
|
||||
* { type: 'hue-saturation', params: { hue, saturation } }
|
||||
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
|
||||
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
|
||||
* { type: 'color-balance', params: { shadows, midtones, highlights } }
|
||||
*/
|
||||
function clampByte(value) {
|
||||
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
|
||||
}
|
||||
|
||||
function finiteOr(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function levelLut(values = {}) {
|
||||
const inLow = Math.max(0, Math.min(254, finiteOr(values.inBlack, 0)));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, finiteOr(values.inWhite, 255)));
|
||||
const gamma = Math.max(0.1, finiteOr(values.gamma, 1));
|
||||
const outLow = Math.max(0, Math.min(255, finiteOr(values.outBlack, 0)));
|
||||
const outHigh = Math.max(outLow, Math.min(255, finiteOr(values.outWhite, 255)));
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
const normalized = Math.max(0, Math.min(1, (value - inLow) / (inHigh - inLow)));
|
||||
lut[value] = clampByte(Math.pow(normalized, 1 / gamma) * (outHigh - outLow) + outLow);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function normalizedCurvePoints(points) {
|
||||
const clean = (Array.isArray(points) ? points : [])
|
||||
.filter(point => Array.isArray(point) && point.length >= 2)
|
||||
.map(point => [clampByte(Number(point[0]) || 0), clampByte(Number(point[1]) || 0)])
|
||||
.sort((a, b) => a[0] - b[0]);
|
||||
if (!clean.length || clean[0][0] !== 0) clean.unshift([0, clean[0]?.[1] ?? 0]);
|
||||
if (clean[clean.length - 1][0] !== 255) clean.push([255, clean[clean.length - 1]?.[1] ?? 255]);
|
||||
const unique = [];
|
||||
for (const point of clean) {
|
||||
if (unique.length && unique[unique.length - 1][0] === point[0]) unique[unique.length - 1] = point;
|
||||
else unique.push(point);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function curveLut(points) {
|
||||
const clean = normalizedCurvePoints(points);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
let segment = 0;
|
||||
for (let value = 0; value < 256; value += 1) {
|
||||
while (segment < clean.length - 2 && value > clean[segment + 1][0]) segment += 1;
|
||||
const left = clean[segment];
|
||||
const right = clean[Math.min(segment + 1, clean.length - 1)];
|
||||
const span = Math.max(1, right[0] - left[0]);
|
||||
const amount = Math.max(0, Math.min(1, (value - left[0]) / span));
|
||||
lut[value] = clampByte(left[1] + (right[1] - left[1]) * amount);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return [0, 0, lightness];
|
||||
const delta = max - min;
|
||||
const saturation = lightness > .5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === r) hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) hue = ((b - r) / delta + 2) / 6;
|
||||
else hue = ((r - g) / delta + 4) / 6;
|
||||
return [hue, saturation, lightness];
|
||||
}
|
||||
|
||||
function hueChannel(p, q, t) {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
if (!s) return [l * 255, l * 255, l * 255];
|
||||
const q = l < .5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return [hueChannel(p, q, h + 1 / 3) * 255, hueChannel(p, q, h) * 255, hueChannel(p, q, h - 1 / 3) * 255];
|
||||
}
|
||||
|
||||
function parseHexColor(value, fallback) {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(String(value || ''));
|
||||
const hex = match ? match[1] : fallback;
|
||||
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
function hueDistance(a, b) {
|
||||
const distance = Math.abs(a - b);
|
||||
return Math.min(distance, 1 - distance);
|
||||
}
|
||||
|
||||
function selectiveRangeWeight(name, hue, saturation, lightness) {
|
||||
if (name === 'blacks') return Math.max(0, Math.min(1, (0.55 - lightness) / 0.45));
|
||||
if (name === 'neutrals') return Math.max(0, Math.min(1, (1 - saturation) * (1 - Math.abs(lightness - .5) * 1.5)));
|
||||
const centers = { reds: 0, yellows: 1 / 6, greens: 1 / 3, cyans: .5, blues: 2 / 3, magentas: 5 / 6 };
|
||||
const distance = hueDistance(hue, centers[name] ?? 0);
|
||||
return Math.max(0, 1 - distance * 6) * saturation;
|
||||
}
|
||||
|
||||
export function applyAdjustment(srcCanvas, adj) {
|
||||
const w = srcCanvas.width, h = srcCanvas.height;
|
||||
const out = document.createElement('canvas');
|
||||
@@ -27,37 +130,129 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params;
|
||||
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
octx.filter = 'none';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Levels + Color Balance need per-pixel math.
|
||||
// The remaining adjustments need deterministic per-pixel math so preview,
|
||||
// flattening, and reopened documents produce the same result.
|
||||
octx.drawImage(srcCanvas, 0, 0);
|
||||
const img = octx.getImageData(0, 0, w, h);
|
||||
const d = img.data;
|
||||
|
||||
if (adj.type === 'exposure') {
|
||||
const p = adj.params || {};
|
||||
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
|
||||
const offset = finiteOr(p.offset, 0);
|
||||
const gamma = Math.max(.1, finiteOr(p.gamma, 1));
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 1] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 1] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
d[i + 2] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 2] / 255 * multiplier + offset)), 1 / gamma) * 255);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'white-balance') {
|
||||
const p = adj.params || {};
|
||||
const temperature = finiteOr(p.temperature, 0) / 100;
|
||||
const tint = finiteOr(p.tint, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = clampByte(d[i] * (1 + temperature * .28 + tint * .09));
|
||||
d[i + 1] = clampByte(d[i + 1] * (1 - tint * .18));
|
||||
d[i + 2] = clampByte(d[i + 2] * (1 - temperature * .28 + tint * .09));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'hue-saturation') {
|
||||
const p = adj.params || {};
|
||||
const hueShift = finiteOr(p.hue, 0) / 360;
|
||||
const saturationScale = Math.max(0, finiteOr(p.saturation, 1));
|
||||
const lightnessShift = finiteOr(p.lightness, 0) / 100;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
|
||||
hue = (hue + hueShift + 1) % 1;
|
||||
saturation = Math.max(0, Math.min(1, saturation * saturationScale));
|
||||
lightness = Math.max(0, Math.min(1, lightness + lightnessShift));
|
||||
const rgb = hslToRgb(hue, saturation, lightness);
|
||||
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'vibrance') {
|
||||
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
|
||||
const adjustedSaturation = amount >= 0
|
||||
? saturation + (1 - saturation) * amount
|
||||
: saturation * (1 + amount);
|
||||
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
|
||||
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'black-white') {
|
||||
const p = adj.params || {};
|
||||
const red = finiteOr(p.red, 30) / 100;
|
||||
const green = finiteOr(p.green, 59) / 100;
|
||||
const blue = finiteOr(p.blue, 11) / 100;
|
||||
const constant = finiteOr(p.constant, 0) * 2.55;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const gray = d[i] * red + d[i + 1] * green + d[i + 2] * blue + constant;
|
||||
d[i] = clampByte(gray);
|
||||
d[i + 1] = clampByte(gray);
|
||||
d[i + 2] = clampByte(gray);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'shadows-highlights') {
|
||||
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
|
||||
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
|
||||
const toneAdjust = (value, amount, weight) => amount >= 0
|
||||
? value + (255 - value) * amount * weight
|
||||
: value + value * amount * weight;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
|
||||
const shadowWeight = (1 - luminance) ** 2;
|
||||
const highlightWeight = luminance ** 2;
|
||||
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
|
||||
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
|
||||
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'levels') {
|
||||
const l = adj.params;
|
||||
const inLow = Math.max(0, Math.min(254, l.inBlack));
|
||||
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
|
||||
const gamma = Math.max(0.1, l.gamma || 1);
|
||||
const outLow = Math.max(0, Math.min(255, l.outBlack));
|
||||
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
|
||||
const inv = 1.0 / gamma;
|
||||
const span = (outHigh - outLow);
|
||||
const lut = new Uint8ClampedArray(256);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let t = (v - inLow) / (inHigh - inLow);
|
||||
if (t < 0) t = 0; else if (t > 1) t = 1;
|
||||
t = Math.pow(t, inv);
|
||||
lut[v] = Math.round(t * span + outLow);
|
||||
}
|
||||
const master = levelLut(l);
|
||||
const red = levelLut(l.channels?.red);
|
||||
const green = levelLut(l.channels?.green);
|
||||
const blue = levelLut(l.channels?.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'curves') {
|
||||
const points = adj.params?.points || {};
|
||||
const master = curveLut(points.rgb);
|
||||
const red = curveLut(points.red);
|
||||
const green = curveLut(points.green);
|
||||
const blue = curveLut(points.blue);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
d[i] = red[master[d[i]]];
|
||||
d[i + 1] = green[master[d[i + 1]]];
|
||||
d[i + 2] = blue[master[d[i + 2]]];
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
@@ -95,6 +290,43 @@ export function applyAdjustment(srcCanvas, adj) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'selective-color') {
|
||||
const ranges = adj.params?.ranges || {};
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
let r = d[i], g = d[i + 1], b = d[i + 2];
|
||||
const [hue, saturation, lightness] = rgbToHsl(r, g, b);
|
||||
for (const [name, values] of Object.entries(ranges)) {
|
||||
const weight = selectiveRangeWeight(name, hue, saturation, lightness) * .65;
|
||||
if (weight <= 0) continue;
|
||||
const black = finiteOr(values.black, 0) / 100;
|
||||
r += (-finiteOr(values.cyan, 0) / 100 * 255 - black * r) * weight;
|
||||
g += (-finiteOr(values.magenta, 0) / 100 * 255 - black * g) * weight;
|
||||
b += (-finiteOr(values.yellow, 0) / 100 * 255 - black * b) * weight;
|
||||
}
|
||||
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (adj.type === 'gradient-map') {
|
||||
const p = adj.params || {};
|
||||
let shadows = parseHexColor(p.shadows, '000000');
|
||||
let highlights = parseHexColor(p.highlights, 'ffffff');
|
||||
if (p.reverse) [shadows, highlights] = [highlights, shadows];
|
||||
const midpoint = Math.max(.01, Math.min(.99, finiteOr(p.midpoint, 50) / 100));
|
||||
const exponent = Math.log(.5) / Math.log(midpoint);
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
|
||||
const amount = Math.pow(luminance, exponent);
|
||||
d[i] = clampByte(shadows[0] + (highlights[0] - shadows[0]) * amount);
|
||||
d[i + 1] = clampByte(shadows[1] + (highlights[1] - shadows[1]) * amount);
|
||||
d[i + 2] = clampByte(shadows[2] + (highlights[2] - shadows[2]) * amount);
|
||||
}
|
||||
octx.putImageData(img, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -215,14 +447,16 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
const editingId = layer._editingAdjId || null;
|
||||
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
|
||||
const staged = layer._stagedAdj;
|
||||
if (stack.length === 0 && !staged) {
|
||||
if (stack.length === 0 && (!staged || layer._adjCompare)) {
|
||||
layer._adjFinalKey = '';
|
||||
return layer.canvas;
|
||||
}
|
||||
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
|
||||
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
|
||||
(editingId ? `|E:${editingId}` : '');
|
||||
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
|
||||
const compare = !!layer._adjCompare;
|
||||
const fullSig = `${sig}|C:${compare ? 1 : 0}`;
|
||||
if (layer._adjFinal && layer._adjFinalKey === fullSig) return layer._adjFinal;
|
||||
let cur = layer.canvas;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
for (const adj of stack) {
|
||||
@@ -240,10 +474,10 @@ export function renderLayerWithAdjLayers(layer) {
|
||||
cur = blend;
|
||||
}
|
||||
}
|
||||
if (staged) {
|
||||
if (staged && !compare) {
|
||||
cur = applyAdjustment(cur, staged);
|
||||
}
|
||||
layer._adjFinal = cur;
|
||||
layer._adjFinalKey = sig;
|
||||
layer._adjFinalKey = fullSig;
|
||||
return cur;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Shared normalization for editable linear-gradient stops. */
|
||||
|
||||
export const MAX_GRADIENT_STOPS = 12;
|
||||
|
||||
function finite(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function color(value, fallback) {
|
||||
const text = String(value || '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
export function legacyGradientStops({
|
||||
start = '#ffffff',
|
||||
mid = '#808080',
|
||||
midEnabled = false,
|
||||
midPosition = 50,
|
||||
end = '#000000',
|
||||
} = {}) {
|
||||
const stops = [{ position: 0, color: color(start, '#ffffff') }];
|
||||
if (midEnabled) {
|
||||
stops.push({
|
||||
position: finite(midPosition, 50, 1, 99),
|
||||
color: color(mid, '#808080'),
|
||||
});
|
||||
}
|
||||
stops.push({ position: 100, color: color(end, '#000000') });
|
||||
return stops;
|
||||
}
|
||||
|
||||
export function normalizeGradientStops(value, legacy = {}) {
|
||||
const source = Array.isArray(value) && value.length ? value : legacyGradientStops(legacy);
|
||||
const stops = source.map((stop, index) => ({
|
||||
position: finite(stop?.position, index === 0 ? 0 : 100, 0, 100),
|
||||
color: color(stop?.color, index === 0 ? '#ffffff' : '#000000'),
|
||||
}));
|
||||
stops.sort((a, b) => a.position - b.position);
|
||||
|
||||
const unique = [];
|
||||
for (const stop of stops) {
|
||||
const previous = unique.at(-1);
|
||||
if (previous && Math.abs(previous.position - stop.position) < 0.0001) {
|
||||
unique[unique.length - 1] = stop;
|
||||
} else {
|
||||
unique.push(stop);
|
||||
}
|
||||
}
|
||||
if (!unique.length || unique[0].position > 0) {
|
||||
unique.unshift({ position: 0, color: unique[0]?.color || '#ffffff' });
|
||||
} else {
|
||||
unique[0].position = 0;
|
||||
}
|
||||
if (unique.at(-1).position < 100) {
|
||||
unique.push({ position: 100, color: unique.at(-1)?.color || '#000000' });
|
||||
} else {
|
||||
unique.at(-1).position = 100;
|
||||
}
|
||||
const first = unique[0];
|
||||
const last = unique.at(-1);
|
||||
return [first, ...unique.slice(1, -1).slice(0, MAX_GRADIENT_STOPS - 2), last];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Adaptive bounds for raw ImageData history snapshots. */
|
||||
|
||||
export const MAX_HISTORY_ENTRIES = 30;
|
||||
export const MAX_HISTORY_BYTES = 192 * 1024 * 1024;
|
||||
|
||||
function imageDataBytes(imageData) {
|
||||
return Number(imageData?.data?.byteLength || imageData?.data?.length || 0);
|
||||
}
|
||||
|
||||
function pixelSlots(snapshot) {
|
||||
const slots = [];
|
||||
const add = (object, key = 'imageData') => { if (object?.[key]) slots.push([object, key]); };
|
||||
add(snapshot?.wand); add(snapshot?.lastSelection);
|
||||
for (const item of snapshot?.savedSelections || []) add(item);
|
||||
for (const group of snapshot?.layerGroups || []) {
|
||||
for (const mask of group.masks || []) add(mask);
|
||||
for (const effect of group.effects || []) add(effect.mask);
|
||||
}
|
||||
for (const layer of snapshot?.layers || []) {
|
||||
add(layer); add(layer.placed, 'sourceImageData');
|
||||
for (const mask of layer.masks || []) add(mask);
|
||||
for (const effect of layer.effects || []) add(effect.mask);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function shareSnapshotPixels(snapshot, previous) {
|
||||
if (!previous) return snapshot;
|
||||
const candidates = pixelSlots(previous).map(([object, key]) => object[key]);
|
||||
for (const [object, key] of pixelSlots(snapshot)) {
|
||||
const image = object[key];
|
||||
const match = candidates.find(other => {
|
||||
if (image === other) return true;
|
||||
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
|
||||
for (let i = 0; i < image.data.length; i++) if (image.data[i] !== other.data[i]) return false;
|
||||
return true;
|
||||
});
|
||||
if (match) object[key] = match;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function historyByteSize(stack) {
|
||||
const buffers = new Set();
|
||||
let bytes = 0;
|
||||
for (const snapshot of stack) {
|
||||
const slots = pixelSlots(snapshot);
|
||||
if (!slots.length) { bytes += snapshotByteSize(snapshot); continue; }
|
||||
for (const [object, key] of slots) {
|
||||
const image = object[key], buffer = image.data.buffer || image.data;
|
||||
if (!buffers.has(buffer)) { buffers.add(buffer); bytes += buffer.byteLength || imageDataBytes(image); }
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function snapshotByteSize(snapshot) {
|
||||
if (!snapshot) return 0;
|
||||
if (Number.isFinite(snapshot._bytes)) return snapshot._bytes;
|
||||
let bytes = imageDataBytes(snapshot.wand?.imageData);
|
||||
bytes += imageDataBytes(snapshot.lastSelection?.imageData);
|
||||
for (const selection of snapshot.savedSelections || []) bytes += imageDataBytes(selection.imageData);
|
||||
for (const group of snapshot.layerGroups || []) {
|
||||
for (const mask of group.masks || []) bytes += imageDataBytes(mask.imageData);
|
||||
}
|
||||
for (const layer of snapshot.layers || []) {
|
||||
bytes += imageDataBytes(layer.imageData);
|
||||
bytes += imageDataBytes(layer.placed?.sourceImageData);
|
||||
for (const mask of layer.masks || []) bytes += imageDataBytes(mask.imageData);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function trimHistoryStack(
|
||||
stack,
|
||||
maxEntries = MAX_HISTORY_ENTRIES,
|
||||
maxBytes = MAX_HISTORY_BYTES,
|
||||
) {
|
||||
while (stack.length > maxEntries) stack.shift();
|
||||
let bytes = historyByteSize(stack);
|
||||
// Keep the newest state even when one snapshot alone exceeds the budget.
|
||||
while (stack.length > 1 && bytes > maxBytes) {
|
||||
stack.shift();
|
||||
bytes = historyByteSize(stack);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -8,19 +8,21 @@
|
||||
* Enter confirm in-progress transform
|
||||
* Esc cancel transform / lasso / crop (in priority order)
|
||||
* Ctrl+Z undo (Shift adds redo)
|
||||
* Ctrl+Shift+D deselect (clears wand + lasso)
|
||||
* Ctrl+D deselect (clears wand + lasso)
|
||||
* Ctrl+S save (Shift = save as / export to gallery)
|
||||
* Ctrl+Shift+T open resize popup
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* Ctrl+Alt+A select all canvas (lasso polygon = full bounds)
|
||||
* Ctrl+C/X copy / cut wand or lasso selection (image clipboard
|
||||
* + internal clipboard)
|
||||
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
|
||||
* Ctrl+Alt+G create/release clipping mask
|
||||
* Ctrl+A select all canvas
|
||||
* Ctrl+C/X copy / cut the selected surface (image clipboard
|
||||
* + internal clipboard, preserving its document offset)
|
||||
* Ctrl+V (handled by the paste event listener)
|
||||
* Tool keys (V, B, E, L, …) → toolbar click
|
||||
* Hold Space temporarily pan without changing the active tool
|
||||
* [ / ] shrink / grow brush size proportionally
|
||||
* D, C, M (when lasso has 3+ points) → delete / copy / convert mask
|
||||
* Delete / Backspace (wand or lasso) → delete pixels
|
||||
*
|
||||
* @param {{
|
||||
@@ -33,6 +35,7 @@
|
||||
* toggleShortcuts: (show?: boolean) => void,
|
||||
* confirmTransform: () => void,
|
||||
* cancelTransform: () => void,
|
||||
* nudgeTransform: (dx: number, dy: number) => boolean,
|
||||
* startTransform: () => void,
|
||||
* resizeCustomPrompt: () => void,
|
||||
* addEmptyLayer: () => void,
|
||||
@@ -42,31 +45,99 @@
|
||||
* wandCopyToNewLayer: () => void,
|
||||
* lassoDeleteSelection: () => void,
|
||||
* lassoCopyToLayer: () => void,
|
||||
* lassoToMask: () => void,
|
||||
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
|
||||
* drawLassoOverlay: () => void,
|
||||
* copyPixelsToClipboard: (options: {cut: boolean}) => Promise<void>,
|
||||
* activeLayer: () => object | null,
|
||||
* deleteSelectedLayers: () => boolean | Promise<boolean>,
|
||||
* duplicateActiveLayer: () => boolean,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
import { createMarqueeMask } from './selection-mask.js';
|
||||
|
||||
let keyboardBindings;
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
keyboardBindings?.abort();
|
||||
keyboardBindings = new AbortController();
|
||||
const { signal } = keyboardBindings;
|
||||
const {
|
||||
toolbar, toolKeyMap,
|
||||
composite, saveState, undo, redo,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
|
||||
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
|
||||
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
|
||||
invertSelection,
|
||||
wandDeleteSelection, wandCopyToNewLayer,
|
||||
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
|
||||
buildLassoMask, drawLassoOverlay,
|
||||
activeLayer, uiModule,
|
||||
wandDeleteSelection, wandCopyToNewLayer, copyPixelsToClipboard,
|
||||
lassoDeleteSelection, lassoCopyToLayer,
|
||||
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
|
||||
setTemporaryPan,
|
||||
nudgeActiveLayer, endLayerNudge,
|
||||
toggleQuickMask, nudgeSelection,
|
||||
deselectSelection, fillSelection,
|
||||
} = deps;
|
||||
|
||||
const isTypingTarget = (target) => target && (
|
||||
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' || target.isContentEditable
|
||||
);
|
||||
|
||||
const releaseTemporaryPan = () => setTemporaryPan?.(false);
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.code === 'Space') releaseTemporaryPan();
|
||||
if (e.key.startsWith('Arrow')) endLayerNudge?.();
|
||||
}, { signal });
|
||||
window.addEventListener('blur', releaseTemporaryPan, { signal });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!state.editorOpen) return;
|
||||
if (!state.editorOpen || e.defaultPrevented || e.isComposing) return;
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
// Fields and text layers own native editing, including undo and clipboard.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.repeat) fillSelection?.();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'Space' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setTemporaryPan?.(true);
|
||||
return;
|
||||
}
|
||||
if (!isTypingTarget(e.target) && state.tool === 'marquee' && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeSelection?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isTypingTarget(e.target) && state.transformActive && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeTransform?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!isTypingTarget(e.target) && ['move', 'transform'].includes(state.tool) && e.key.startsWith('Arrow')) {
|
||||
const amount = e.shiftKey ? 10 : 1;
|
||||
const delta = {
|
||||
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
|
||||
ArrowUp: [0, -amount], ArrowDown: [0, amount],
|
||||
}[e.key];
|
||||
if (delta && nudgeActiveLayer?.(...delta)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// `?` toggles the cheatsheet. Don't fire while typing in a text
|
||||
// field — the user might be typing a prompt with a `?`.
|
||||
if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
|
||||
@@ -85,33 +156,27 @@ export function wireKeyboardShortcuts(deps) {
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
if (state.wandMask || state.lassoPoints.length) {
|
||||
e.preventDefault();
|
||||
if (state.wandMask) {
|
||||
saveState();
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
}
|
||||
if (state.lassoPoints.length) {
|
||||
state.lassoPoints = [];
|
||||
state.lassoActive = false;
|
||||
}
|
||||
composite();
|
||||
}
|
||||
if (e.key.toLowerCase() === 'z') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (e.shiftKey) redo(); else undo();
|
||||
return;
|
||||
}
|
||||
// Preserve the old chord as an alias while supporting the familiar one.
|
||||
if (!e.altKey && e.key.toLowerCase() === 'd') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
deselectSelection?.();
|
||||
return;
|
||||
}
|
||||
// Save shortcuts — match the hints shown in the Save dropdown.
|
||||
if ((e.key === 's' || e.key === 'S') && !e.altKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById(e.shiftKey ? 'ge-export-gallery' : 'ge-save')?.click();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); resizeCustomPrompt(); }
|
||||
if (e.altKey && e.key === 't') { e.preventDefault(); startTransform(); }
|
||||
// Ctrl+Alt+I — invert current selection. Uses e.code so
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
|
||||
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
|
||||
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
|
||||
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
|
||||
// don't break the match.
|
||||
if (e.altKey && e.code === 'KeyI') {
|
||||
@@ -119,126 +184,60 @@ export function wireKeyboardShortcuts(deps) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+J — new empty layer.
|
||||
if (e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addEmptyLayer();
|
||||
return;
|
||||
}
|
||||
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
|
||||
// clipboard + new layer + erase. Ctrl+C = copy.
|
||||
// (Legacy `&& !_wandActive` clause referenced an undeclared
|
||||
// variable — removed; the wand is selection-only and has no
|
||||
// "active drag" state.)
|
||||
if (state.wandMask) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'x' || e.key === 'c')) {
|
||||
e.preventDefault();
|
||||
const isCut = e.key === 'x';
|
||||
const src = state.layers.find(l => l.id === state.wandLayerId);
|
||||
if (!src) return;
|
||||
// Clip source by wand mask into a temp canvas.
|
||||
const w = src.canvas.width, h = src.canvas.height;
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
tCtx.drawImage(src.canvas, 0, 0);
|
||||
tCtx.globalCompositeOperation = 'destination-in';
|
||||
tCtx.drawImage(state.wandMask, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (isCut) {
|
||||
// Cut also moves the selection to a new layer + erases source.
|
||||
wandCopyToNewLayer();
|
||||
wandDeleteSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((e.key === 'x' || e.key === 'c') && state.lassoPoints.length >= 3) {
|
||||
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
|
||||
// existing implementation, which preserves masks and effects.
|
||||
if (!e.altKey && e.code === 'KeyJ') {
|
||||
e.preventDefault();
|
||||
const layer = activeLayer();
|
||||
if (!layer) return;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
|
||||
const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const mask = buildLassoMask(w, h, off.x, off.y, feather, grow);
|
||||
const srcData = layer.ctx.getImageData(0, 0, w, h);
|
||||
const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
|
||||
// Build clipped image.
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w; tmp.height = h;
|
||||
const tCtx = tmp.getContext('2d');
|
||||
const outData = tCtx.createImageData(w, h);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
const mv = maskData.data[i * 4] / 255;
|
||||
if (mv > 0) {
|
||||
outData.data[i*4] = srcData.data[i*4];
|
||||
outData.data[i*4+1] = srcData.data[i*4+1];
|
||||
outData.data[i*4+2] = srcData.data[i*4+2];
|
||||
outData.data[i*4+3] = Math.round(srcData.data[i*4+3] * mv);
|
||||
}
|
||||
}
|
||||
tCtx.putImageData(outData, 0, 0);
|
||||
state.internalClipboard = tmp;
|
||||
const isCut = e.key === 'x';
|
||||
tmp.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => {
|
||||
uiModule.showToast(isCut ? 'Cut to clipboard' : 'Copied to clipboard');
|
||||
}).catch(() => uiModule.showToast(isCut ? 'Cut (editor only)' : 'Copied (editor only)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
if (e.key === 'x') {
|
||||
const savedPts = [...state.lassoPoints];
|
||||
state.lassoPoints = savedPts;
|
||||
lassoDeleteSelection();
|
||||
} else {
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
}
|
||||
e.stopPropagation();
|
||||
if (state.wandMask) wandCopyToNewLayer();
|
||||
else if (state.lassoPoints.length >= 3) lassoCopyToLayer();
|
||||
else duplicateActiveLayer?.();
|
||||
return;
|
||||
}
|
||||
// Ctrl+C with no active selection → copy the entire active layer
|
||||
// to the system clipboard as a PNG. Gives a "just copy this image"
|
||||
// shortcut without having to lasso-select-all first. The
|
||||
// selection-aware Ctrl+C paths above run first (wand + lasso),
|
||||
// so this only fires when neither is active.
|
||||
if (e.key === 'c' && !e.shiftKey && !state.wandMask && state.lassoPoints.length < 3) {
|
||||
const layer = activeLayer();
|
||||
if (layer && layer.canvas && layer.canvas.width > 0) {
|
||||
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
|
||||
if (e.altKey && e.code === 'KeyG') {
|
||||
const row = [...document.querySelectorAll('.ge-layer-item[data-layer-id]')]
|
||||
.find(item => item.dataset.layerId === state.activeLayerId);
|
||||
const button = row?.querySelector('.ge-layer-clip-btn');
|
||||
if (button && !button.disabled) {
|
||||
e.preventDefault();
|
||||
layer.canvas.toBlob(blob => {
|
||||
if (blob && navigator.clipboard?.write) {
|
||||
navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
.then(() => uiModule.showToast('Layer copied to clipboard'))
|
||||
.catch(() => uiModule.showToast('Copy failed (clipboard permission denied?)'));
|
||||
}
|
||||
}, 'image/png');
|
||||
return;
|
||||
e.stopPropagation();
|
||||
button.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+Alt+A = select all canvas.
|
||||
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
if (!e.altKey && (e.key.toLowerCase() === 'c' || e.key.toLowerCase() === 'x')) {
|
||||
e.preventDefault();
|
||||
state.lassoPoints = [
|
||||
{ x: 0, y: 0 }, { x: state.imgWidth, y: 0 },
|
||||
{ x: state.imgWidth, y: state.imgHeight }, { x: 0, y: state.imgHeight },
|
||||
];
|
||||
state.lassoActive = false;
|
||||
e.stopPropagation();
|
||||
void copyPixelsToClipboard({ cut: e.key.toLowerCase() === 'x' });
|
||||
return;
|
||||
}
|
||||
// Keep Ctrl+Alt+A as an alias for existing users.
|
||||
if (e.key.toLowerCase() === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
saveState('Select all');
|
||||
state.wandMask = createMarqueeMask(
|
||||
state.imgWidth,
|
||||
state.imgHeight,
|
||||
{ x: 0, y: 0, w: state.imgWidth, h: state.imgHeight },
|
||||
'rectangle',
|
||||
);
|
||||
state.wandLayerId = state.activeLayerId;
|
||||
state.wandMaskSpace = 'document';
|
||||
state.selectionSource = 'marquee';
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = [];
|
||||
composite();
|
||||
drawLassoOverlay();
|
||||
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
|
||||
}
|
||||
// Ctrl+V handled by the paste event listener.
|
||||
@@ -246,11 +245,50 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
// Tool shortcuts (only when not typing in an input).
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
|
||||
// Delete pixels for a selection, otherwise delete the selected layer(s).
|
||||
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (state.wandMask) {
|
||||
e.preventDefault();
|
||||
wandDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
e.preventDefault();
|
||||
lassoDeleteSelection();
|
||||
return;
|
||||
}
|
||||
const layer = activeLayer?.();
|
||||
const activeMask = layer?.activeMaskId &&
|
||||
layer.masks?.some(mask => mask.id === layer.activeMaskId);
|
||||
const group = state.activeGroupId &&
|
||||
state.layerGroups?.find(item => item.id === state.activeGroupId);
|
||||
const activeGroupMask = group?.activeMaskId &&
|
||||
group.masks?.some(mask => mask.id === group.activeMaskId);
|
||||
if (state.transformActive || state.cropping || state.cropMoving ||
|
||||
state.marqueeActive || state.selectionMoving || state.lassoActive ||
|
||||
activeMask || activeGroupMask || state.maskInspectMode) return;
|
||||
if (deleteSelectedLayers) {
|
||||
e.preventDefault();
|
||||
deleteSelectedLayers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'q') {
|
||||
e.preventDefault();
|
||||
toggleQuickMask?.();
|
||||
return;
|
||||
}
|
||||
const toolId = toolKeyMap[e.key.toLowerCase()];
|
||||
if (toolId) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
|
||||
if (toolBtn) toolBtn.click();
|
||||
return;
|
||||
}
|
||||
// Bracket keys for brush size — ±10% multiplier mirrors the
|
||||
// exponential slider curve so each press feels the same at any
|
||||
@@ -260,12 +298,5 @@ export function wireKeyboardShortcuts(deps) {
|
||||
state.brushSize = Math.max(1, Math.min(800, Math.round(state.brushSize * factor)));
|
||||
try { brushSizeSync(null); } catch {}
|
||||
}
|
||||
// Lasso shortcuts (when selection exists).
|
||||
if (state.lassoPoints.length >= 3) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'd') { e.preventDefault(); lassoDeleteSelection(); }
|
||||
if (e.key === 'c') { e.preventDefault(); lassoCopyToLayer(); }
|
||||
if (e.key === 'm') { e.preventDefault(); lassoToMask(); }
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
}
|
||||
|
||||