Odysseus v1.0

This commit is contained in:
pewdiepie-archdaemon
2026-05-31 23:58:26 +09:00
commit e5c99a5eee
421 changed files with 271349 additions and 0 deletions

4034
static/app.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2223
static/index.html Normal file

File diff suppressed because it is too large Load Diff

116
static/js/MODULE_SUMMARY.md Normal file
View File

@@ -0,0 +1,116 @@
# Module Organization Summary
## Purpose
This document describes what each JavaScript module is responsible for.
---
## Core Modules (in static/js/)
### 1. **ui.js**
- UI helper functions and utilities
- Toast notifications (`showToast`, `showError`)
- Element getter (`el()`)
- Clipboard operations (`copyToClipboard`)
- Scroll management (`scrollHistory`, `setAutoScroll`)
- Auto-resize textarea
- Debounce utility
### 2. **markdown.js**
- Markdown processing and rendering
- Convert markdown to HTML (`mdToHtml`)
- Code block handling with syntax highlighting
- Content rendering for message arrays
- Text cleanup (`squashOutsideCode`)
### 3. **session.js**
- Session/chat management
- Create, load, delete, switch sessions
- Session history loading
- Direct chat creation with models
- Session renaming
### 4. **memory.js**
- AI memory management
- Load, add, edit, delete memories
- Memory search/filtering
- Memory UI rendering
- Memory count updates
### 5. **fileHandler.js**
- File attachment handling
- File picker dialog
- File upload to server
- Attachment strip rendering
- Pending files management
- File preview/removal
### 6. **voiceRecorder.js**
- Voice recording functionality
- Start/stop recording
- Audio file creation
- Microphone permission handling
- Recording UI updates
### 7. **models.js**
- Model scanning and display
- Local model discovery (ports 8000-8010)
- Provider management (OpenAI)
- Model selection UI
### 8. **rag.js**
- RAG (Retrieval Augmented Generation) management
- Load personal documents
- Add directories to RAG
- Display included files/directories
### 9. **presets.js**
- Conversation preset management
- Load, save, activate presets
- Custom preset configuration
- Temperature, tokens, system prompt settings
### 10. **search.js**
- Web search settings
- Provider selection (DuckDuckGo, Brave, SearXNG)
- API key management
- Save/load search configuration
### 11. **chat.js** ⭐ (The Big One)
- Main chat functionality
- Message handling (`addMessage`)
- Chat submission (`handleChatSubmit`)
- Streaming response handling
- Performance metrics display
- Abort request management
- Loading states and error handling
---
## Main Application File
### **app.js**
- Application initialization
- Event listener setup
- Drag & drop handlers
- Keyboard shortcuts
- Module initialization
- Global configuration (API_BASE)
- Coordinates all modules together
---
## Dependency Order (Load Order in HTML)
```html
<script src="/static/js/sessions.js"></script> <!-- 1. Sessions first -->
<script src="/static/js/memory.js"></script> <!-- 2. Memory -->
<script src="/static/js/markdown.js"></script> <!-- 3. Markdown -->
<script src="/static/js/ui.js"></script> <!-- 4. UI utilities -->
<script src="/static/js/fileHandler.js"></script> <!-- 5. File handling -->
<script src="/static/js/voiceRecorder.js"></script> <!-- 6. Voice -->
<script src="/static/js/models.js"></script> <!-- 7. Models -->
<script src="/static/js/rag.js"></script> <!-- 8. RAG -->
<script src="/static/js/presets.js"></script> <!-- 9. Presets -->
<script src="/static/js/search.js"></script> <!-- 10. Search -->
<script src="/static/js/chat.js"></script> <!-- 11. Chat -->
<script src="/static/app.js"></script> <!-- 12. Main app LAST -->

1834
static/js/admin.js Normal file

File diff suppressed because it is too large Load Diff

475
static/js/assistant.js Normal file
View File

@@ -0,0 +1,475 @@
// Personal Assistant — sidebar entry, settings modal, and chat-header extras.
//
// The Assistant is just a specially-flagged CrewMember whose pinned Session
// lives alongside normal chats. The sidebar button resolves the per-user
// 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 { selectSession } from './sessions.js';
const API = '/api/assistant';
let _cachedSettings = null; // most recent GET /api/assistant/settings payload
let _modalEl = null;
async function _fetchJSON(url, opts = {}) {
const res = await fetch(url, { credentials: 'same-origin', ...opts });
if (!res.ok) throw new Error(`${url}${res.status}`);
return res.json();
}
export async function openAssistantChat() {
try {
const info = await _fetchJSON(`${API}/session`);
if (!info?.session_id) {
uiModule.showToast('Assistant session unavailable');
return;
}
await selectSession(info.session_id);
// Refresh settings cache so the header buttons / gear act on fresh data.
_cachedSettings = null;
} catch (e) {
console.error('openAssistantChat failed:', e);
uiModule.showToast('Could not open assistant');
}
}
async function _getSettings(force = false) {
if (!force && _cachedSettings) return _cachedSettings;
_cachedSettings = await _fetchJSON(`${API}/settings`);
return _cachedSettings;
}
async function _saveSettings(payload) {
const res = await fetch(`${API}/settings`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`PATCH ${API}/settings → ${res.status}`);
_cachedSettings = await res.json();
return _cachedSettings;
}
async function _listTimezones() {
try {
const { timezones } = await _fetchJSON(`${API}/available-timezones`);
return timezones || ['UTC'];
} catch {
return ['UTC'];
}
}
async function _runCheckInNow(taskId) {
try {
await fetch(`${API}/run/${encodeURIComponent(taskId)}`, {
method: 'POST',
credentials: 'same-origin',
});
uiModule.showToast('Check-in running…');
} catch (e) {
console.error(e);
uiModule.showToast('Could not run check-in');
}
}
// ── Settings modal ─────────────────────────────────────────────────────────
function _closeModal() {
if (_modalEl) {
_modalEl.classList.add('hidden');
_modalEl.style.display = '';
}
}
function _ensureModalEl() {
if (_modalEl) return _modalEl;
const modal = document.createElement('div');
modal.id = 'assistant-settings-modal';
modal.className = 'modal hidden';
modal.innerHTML = `
<div class="modal-content" style="max-width:640px;width:96%;">
<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;"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg>
Assistant settings
</h4>
<button class="close-btn" id="assistant-settings-close">✖</button>
</div>
<div class="modal-body" id="assistant-settings-body">
<div class="hwfit-loading">Loading…</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector('#assistant-settings-close').addEventListener('click', _closeModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) _closeModal();
});
_modalEl = modal;
return modal;
}
function _esc(s) {
return (s || '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
// 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'],
'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'],
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
'Code': ['bash', 'python', 'write_file'],
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'],
};
async function _fetchEndpoints() {
try {
const eps = await _fetchJSON('/api/model-endpoints');
return Array.isArray(eps) ? eps : [];
} catch { return []; }
}
function _renderSettingsBody(body, data, tzList) {
const crew = data.crew || {};
const checkIns = data.check_ins || [];
const enabledTools = new Set(crew.enabled_tools || []);
const tzOptions = tzList.map((z) =>
`<option value="${_esc(z)}"${z === crew.timezone ? ' selected' : ''}>${_esc(z)}</option>`
).join('');
const checkInsHTML = checkIns.map((c) => `
<div class="assistant-checkin-row" data-task-id="${_esc(c.id)}">
<div class="assistant-checkin-head">
<input type="checkbox" class="assistant-checkin-enabled" ${c.enabled ? 'checked' : ''} title="Enable this check-in" />
<input type="text" class="assistant-checkin-name" value="${_esc(c.name)}" placeholder="Name" />
<input type="time" class="assistant-checkin-time" value="${_esc(c.scheduled_time || '')}" />
<button type="button" class="assistant-checkin-run" title="Run now">Run now</button>
</div>
<textarea class="assistant-checkin-prompt" rows="3" placeholder="Prompt for this check-in">${_esc(c.prompt || '')}</textarea>
<div class="assistant-checkin-meta">
${c.next_run ? `next run: ${_esc(c.next_run)}` : ''}
${c.last_run ? ` · last run: ${_esc(c.last_run)}` : ''}
${typeof c.run_count === 'number' ? ` · ${c.run_count} runs` : ''}
</div>
</div>`).join('');
// Tool selector grouped by category
let toolsHTML = '';
for (const [group, tools] of Object.entries(TOOL_GROUPS)) {
toolsHTML += `<div class="assistant-tool-group"><span class="assistant-tool-group-label">${_esc(group)}</span>`;
for (const t of tools) {
const checked = enabledTools.has(t) ? ' checked' : '';
const label = t.replace(/_/g, ' ');
toolsHTML += `<label class="assistant-tool-item"><input type="checkbox" class="assistant-tool-cb" value="${_esc(t)}"${checked} /><span>${_esc(label)}</span></label>`;
}
toolsHTML += '</div>';
}
body.innerHTML = `
<div class="assistant-settings-form">
<label class="assistant-field">
<span>Name</span>
<input type="text" id="assistant-name" value="${_esc(crew.name)}" placeholder="Assistant" />
</label>
<div class="assistant-field">
<span style="display:flex;align-items:center;gap:8px;">Personality
<select id="assistant-character-pick" style="font-size:11px;padding:1px 6px;border:1px solid var(--border);border-radius:3px;background:var(--bg);color:var(--fg);max-width:180px;">
<option value="">-- pick from character --</option>
</select>
</span>
<textarea id="assistant-personality" rows="6" placeholder="Describe the assistant's personality, tone, and behavior...">${_esc(crew.personality || '')}</textarea>
</div>
<div class="assistant-field-row">
<label class="assistant-field">
<span>Timezone</span>
<select id="assistant-timezone">
<option value=""${!crew.timezone ? ' selected' : ''}>(default -- UTC)</option>
${tzOptions}
</select>
</label>
</div>
<div class="assistant-field-row">
<label class="assistant-field" style="flex:1;">
<span>Model endpoint</span>
<select id="assistant-endpoint" style="width:100%;">
<option value="">(loading...)</option>
</select>
</label>
<label class="assistant-field" style="flex:1;">
<span>Model</span>
<select id="assistant-model" style="width:100%;">
<option value="${_esc(crew.model || '')}">${_esc(crew.model || '(default)')}</option>
</select>
</label>
</div>
<div class="assistant-field">
<span style="display:flex;align-items:center;gap:8px;">Tools
<button type="button" id="assistant-tools-all" class="assistant-tools-toggle" style="font-size:10px;opacity:0.5;cursor:pointer;background:none;border:1px solid var(--border);border-radius:3px;padding:1px 6px;">all</button>
<button type="button" id="assistant-tools-none" class="assistant-tools-toggle" style="font-size:10px;opacity:0.5;cursor:pointer;background:none;border:1px solid var(--border);border-radius:3px;padding:1px 6px;">none</button>
</span>
<div class="assistant-tools-grid" id="assistant-tools-grid">
${toolsHTML}
</div>
</div>
<div class="assistant-checkins">
<h5>Daily check-ins</h5>
${checkInsHTML || '<div style="opacity:0.6;">No check-ins configured.</div>'}
</div>
<div class="assistant-settings-actions">
<button type="button" class="cal-btn" id="assistant-settings-cancel">Cancel</button>
<button type="button" class="cal-btn cal-btn-primary" id="assistant-settings-save">Save</button>
</div>
</div>
`;
// ── Populate model/endpoint dropdowns ──
const epSelect = body.querySelector('#assistant-endpoint');
const modelSelect = body.querySelector('#assistant-model');
_fetchEndpoints().then(endpoints => {
let epHTML = '<option value="">(use session default)</option>';
for (const ep of endpoints) {
if (!ep.is_enabled) continue;
const url = ep.base_url || '';
const name = ep.name || url;
const sel = (crew.endpoint_url && url.includes(crew.endpoint_url.replace('/v1', '').replace(/\/$/, ''))) ? ' selected' : '';
epHTML += `<option value="${_esc(url)}"${sel}>${_esc(name)}</option>`;
}
epSelect.innerHTML = epHTML;
// When endpoint changes, load its models
epSelect.addEventListener('change', async () => {
const url = epSelect.value;
if (!url) { modelSelect.innerHTML = '<option value="">(default)</option>'; return; }
const ep = endpoints.find(e => e.base_url === url);
if (!ep) return;
modelSelect.innerHTML = '<option value="">loading...</option>';
try {
const models = await _fetchJSON(`/api/model-endpoints/${ep.id}/models`);
let mHTML = '';
for (const m of (models.models || models || [])) {
const mid = typeof m === 'string' ? m : (m.id || m.name || '');
if (!mid) continue;
const sel = mid === crew.model ? ' selected' : '';
mHTML += `<option value="${_esc(mid)}"${sel}>${_esc(mid.split('/').pop())}</option>`;
}
modelSelect.innerHTML = mHTML || '<option value="">(no models)</option>';
} catch { modelSelect.innerHTML = '<option value="">(failed)</option>'; }
});
// Trigger initial model load if endpoint is pre-selected
if (epSelect.value) epSelect.dispatchEvent(new Event('change'));
});
// ── Tool toggle buttons ──
body.querySelector('#assistant-tools-all')?.addEventListener('click', () => {
body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = true; });
});
body.querySelector('#assistant-tools-none')?.addEventListener('click', () => {
body.querySelectorAll('.assistant-tool-cb').forEach(cb => { cb.checked = false; });
});
// ── Character picker — populate from presets + templates ──
const charPick = body.querySelector('#assistant-character-pick');
const personalityEl = body.querySelector('#assistant-personality');
if (charPick && personalityEl) {
(async () => {
try {
const [presetsRaw, templates] = await Promise.all([
_fetchJSON('/api/presets').catch(() => ({})),
_fetchJSON('/api/presets/templates').catch(() => []),
]);
// Presets API returns a dict keyed by preset ID, not an array
const allPresets = [];
if (presetsRaw && typeof presetsRaw === 'object' && !Array.isArray(presetsRaw)) {
for (const [key, val] of Object.entries(presetsRaw)) {
if (val && typeof val === 'object' && val.system_prompt) {
allPresets.push({ ...val, _key: key });
}
}
} else if (Array.isArray(presetsRaw)) {
allPresets.push(...presetsRaw);
}
const allTemplates = Array.isArray(templates) ? templates : [];
let opts = '<option value="">-- pick from character --</option>';
if (allPresets.length) {
opts += '<optgroup label="Presets">';
for (const p of allPresets) {
if (!p.system_prompt) continue;
const name = p.character_name || p.name || p._key || 'Unnamed';
opts += `<option value="preset:${_esc(p._key || p.name || '')}">${_esc(name)}</option>`;
}
opts += '</optgroup>';
}
if (allTemplates.length) {
opts += '<optgroup label="Characters">';
for (const t of allTemplates) {
if (!t.system_prompt && !t.personality) continue;
const name = t.character_name || t.name || 'Unnamed';
opts += `<option value="template:${_esc(t.id || t.name || '')}">${_esc(name)}</option>`;
}
opts += '</optgroup>';
}
charPick.innerHTML = opts;
charPick._presets = allPresets;
charPick._templates = allTemplates;
} catch {}
})();
charPick.addEventListener('change', () => {
const val = charPick.value;
if (!val) return;
const [type, id] = val.split(':', 2);
let prompt = '';
let name = '';
if (type === 'preset') {
const p = (charPick._presets || []).find(x => (x._key || x.name || x.id) === id);
if (p) { prompt = p.system_prompt || p.personality || ''; name = p.character_name || p.name || p._key || ''; }
} else if (type === 'template') {
const t = (charPick._templates || []).find(x => (x.id || x.name) === id);
if (t) { prompt = t.system_prompt || t.personality || ''; name = t.character_name || t.name || ''; }
}
if (prompt) personalityEl.value = prompt;
const nameEl = body.querySelector('#assistant-name');
if (name && nameEl) nameEl.value = name;
charPick.selectedIndex = 0;
});
}
// ── Event wiring ──
body.querySelector('#assistant-settings-cancel').addEventListener('click', _closeModal);
body.querySelector('#assistant-settings-save').addEventListener('click', async () => {
const selectedTools = [];
body.querySelectorAll('.assistant-tool-cb:checked').forEach(cb => selectedTools.push(cb.value));
const payload = {
name: body.querySelector('#assistant-name').value.trim(),
personality: body.querySelector('#assistant-personality').value,
timezone: body.querySelector('#assistant-timezone').value || null,
model: body.querySelector('#assistant-model').value || null,
endpoint_url: body.querySelector('#assistant-endpoint').value || null,
enabled_tools: selectedTools,
check_ins: Array.from(body.querySelectorAll('.assistant-checkin-row')).map((row) => ({
id: row.dataset.taskId,
name: row.querySelector('.assistant-checkin-name').value.trim(),
scheduled_time: row.querySelector('.assistant-checkin-time').value,
prompt: row.querySelector('.assistant-checkin-prompt').value,
enabled: row.querySelector('.assistant-checkin-enabled').checked,
})),
};
try {
await _saveSettings(payload);
uiModule.showToast('Assistant settings saved');
_closeModal();
} catch (e) {
console.error(e);
uiModule.showToast('Save failed');
}
});
body.querySelectorAll('.assistant-checkin-run').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const row = btn.closest('.assistant-checkin-row');
if (!row?.dataset.taskId) return;
const taskId = row.dataset.taskId;
btn.disabled = true;
btn.textContent = 'Running...';
await _runCheckInNow(taskId);
_closeModal();
// Poll until done, then navigate to assistant chat
const sid = _cachedSettings?.crew?.session_id;
const _poll = setInterval(async () => {
try {
const res = await fetch(`${API}/run-status/${encodeURIComponent(taskId)}`, { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
if (data.status === 'done' || data.status === 'error') {
clearInterval(_poll);
// Hard navigate to force full reload of the session
if (sid) {
window.location.href = window.location.pathname + '#' + sid;
window.location.reload();
}
}
} catch {}
}, 2000);
setTimeout(() => clearInterval(_poll), 90000);
});
});
}
export async function openAssistantSettings() {
const modal = _ensureModalEl();
modal.classList.remove('hidden');
modal.style.display = 'flex';
const body = modal.querySelector('#assistant-settings-body');
body.innerHTML = '<div class="hwfit-loading">Loading…</div>';
try {
const [data, tzList] = await Promise.all([_getSettings(true), _listTimezones()]);
_renderSettingsBody(body, data, tzList);
} catch (e) {
console.error(e);
body.innerHTML = '<div style="padding:12px;opacity:0.6;">Could not load assistant settings.</div>';
}
}
// Sidebar wiring removed — Assistant chat + settings now live as
// Activity / Settings tabs inside the Tasks modal (see tasks.js). The
// exports below are still used by tasks.js to surface those views.
// ── Chat-header affordances when the assistant session is active ───────────
async function _ensureHeaderAffordances(sessionId) {
try {
const settings = await _getSettings();
if (settings?.crew?.session_id !== sessionId) return;
} catch {
return;
}
const headerRight = document.querySelector('.chat-header-right, #chat-header .actions, .chat-header');
if (!headerRight) return;
if (headerRight.querySelector('#assistant-header-gear')) return;
const gear = document.createElement('button');
gear.id = 'assistant-header-gear';
gear.type = 'button';
gear.title = 'Assistant settings';
gear.className = 'chat-header-btn';
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
// once the chat header DOM is in place. Fire-and-forget.
function _watchForAssistantActivation() {
let retries = 0;
const interval = setInterval(async () => {
retries += 1;
const activeSessionId = window.sessionModule?.getActiveSession?.()?.id
|| document.body.dataset.activeSessionId
|| null;
if (activeSessionId) {
await _ensureHeaderAffordances(activeSessionId);
}
if (retries > 120) clearInterval(interval); // ~2 minutes
}, 1000);
}
// ── Boot ───────────────────────────────────────────────────────────────────
function _boot() {
_watchForAssistantActivation();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _boot);
} else {
_boot();
}
const assistantModule = {
openAssistantChat,
openAssistantSettings,
};
export default assistantModule;

3318
static/js/calendar.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
// static/js/calendar/reminders.js
//
// Browser-notification poller for calendar reminder notes. Self-contained:
// module-private `_notifFired` Set tracks which note IDs we've already
// notified, persisted to localStorage. Polls `/api/notes?label=calendar`
// every 60 seconds and fires a Notification + toast for any note whose
// `due_date` is in the past but within the staleness window.
//
// `start()` kicks off the poll loop + permission request. Call once from
// the calendar's entry module.
import uiModule from '../ui.js';
const API_BASE = window.location.origin;
let _notifFired = new Set(JSON.parse(localStorage.getItem('cal-notif-fired') || '[]'));
// Compute a fresh, system-clock-accurate notification body. Tries the
// note's `event_dtstart` first (set by _createEventReminder); falls back
// to scrubbing stale time tokens out of items[0].text so legacy
// reminders don't show "in 29 min" at 9pm.
function _formatReminderBody(note) {
const dtstartRaw = note.event_dtstart || note.eventDtstart || null;
if (dtstartRaw) {
const start = new Date(dtstartRaw);
if (!isNaN(start.getTime())) {
const now = new Date();
const mins = Math.round((start - now) / 60000);
const when = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
let when2 = '';
const sameDay = start.toDateString() === now.toDateString();
if (!sameDay) when2 = ' ' + start.toLocaleDateString([], { month: 'short', day: 'numeric' });
if (mins >= 1 && mins <= 60) return `Starts in ${mins} min (${when}${when2})`;
if (mins === 0) return `Starting now (${when}${when2})`;
if (mins > 60) {
const h = Math.round(mins / 60);
return `Starts in ${h} hour${h === 1 ? '' : 's'} (${when}${when2})`;
}
if (mins >= -60) return `Started ${Math.abs(mins)} min ago (${when}${when2})`;
return `Was scheduled for ${when}${when2}`;
}
}
// Legacy notes (no event_dtstart). Scrub stale relative-time strings.
let body = (note.items || []).map(i => i.text).join('\n') || note.content || '';
body = body.replace(/\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b/gi, '').trim();
body = body.replace(/\(\s*\d{1,2}:\d{2}\s*\)/g, '').trim();
body = body.replace(/\s{2,}/g, ' ');
return body;
}
// Only fire a reminder if `due` was within this many minutes BEFORE now.
// Stops a fresh browser (empty `cal-notif-fired` localStorage) from spamming
// every 2-week-old reminder on first poll. Anything older is silently
// marked fired so it doesn't keep getting picked up.
const _REMINDER_STALENESS_MIN = 5;
async function _pollReminders() {
try {
const res = await fetch(`${API_BASE}/api/notes?label=calendar`, { credentials: 'same-origin' });
if (!res.ok) return;
const notes = await res.json();
const now = new Date();
const stalenessMs = _REMINDER_STALENESS_MIN * 60 * 1000;
for (const note of notes) {
if (!note.due_date || _notifFired.has(note.id)) continue;
const due = new Date(note.due_date);
if (isNaN(due)) continue;
if (due > now) continue; // not yet due
const ageMs = now - due;
if (ageMs > stalenessMs) {
// Too old to fire — mark as seen so we don't recheck every minute.
_notifFired.add(note.id);
continue;
}
_notifFired.add(note.id);
const body = _formatReminderBody(note);
fetch(`${API_BASE}/api/notes/fire-reminder`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
note_id: note.id,
title: note.title || 'Calendar Reminder',
body,
}),
}).catch(() => {});
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(note.title || 'Calendar Reminder', {
body,
icon: '/static/favicon.png',
tag: `cal-remind-${note.id}`,
});
}
if (uiModule.showToast) uiModule.showToast((note.title || 'Calendar Reminder') + (body ? ' — ' + body : ''));
}
// Persist fired set (keep last 200)
const arr = [..._notifFired].slice(-200);
localStorage.setItem('cal-notif-fired', JSON.stringify(arr));
} catch (_) {}
}
let _started = false;
// Idempotent: safe to call multiple times. Kicks off permission request
// and the 60s poll loop on first call.
export function startReminderPoll() {
if (_started) return;
_started = true;
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
_pollReminders();
setInterval(_pollReminders, 60000);
}

126
static/js/calendar/utils.js Normal file
View File

@@ -0,0 +1,126 @@
// static/js/calendar/utils.js
//
// Pure constants + zero-state helpers for the calendar UI.
// No DOM, no fetch, no global mutable state — safe to import anywhere.
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
export const MON_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const CAL_PALETTE = [
'var(--accent)', '#5b8abf', '#bf6b5b', '#5bbf7a', '#bf9a5b',
'#9a5bbf', '#5bbfb8', '#bf8a5b', '#7070c0', '#bf5b8a',
];
export const CAL_COLORS = [
{ name: 'default', hex: '' },
// Pale/pastel palette — softer event tints.
{ name: 'red', hex: '#f0b5ba' },
{ name: 'orange', hex: '#e8ccb2' },
{ name: 'yellow', hex: '#f2dfbd' },
{ name: 'green', hex: '#cce0bc' },
{ name: 'blue', hex: '#b0d7f7' },
{ name: 'purple', hex: '#e2bcee' },
{ name: 'teal', hex: '#abdbe0' },
{ name: 'pink', hex: '#f0b5cc' },
// Custom — mirrors the notes color picker. Clicking opens a file picker
// and the chosen image URL is stored as a `bg:<url>` sentinel.
{ name: 'custom', hex: 'custom' },
];
export const _CAL_CUSTOM_GRADIENT = 'conic-gradient(from 0deg, #e06c75, #d19a66, #e5c07b, #98c379, #61afef, #c678dd, #e06c75)';
// Per-event-type accent palette. Used by the colored dots in month/year
// grids and the chip stripe behind agenda rows.
export const _TYPE_PALETTE = {
'!': '#e5a33a', // important — amber, less harsh than red
work: '#5b8abf',
personal: '#a07ae0',
health: '#e06c75',
travel: '#e5a33a',
meal: '#d8b974',
social: '#82c882',
admin: '#888888',
other: '#6b9cb5',
untagged: '#555',
};
// SVG icon literals reused across the calendar UI.
export const _trashIcon = '<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>';
export const _moreIcon = '<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>';
export const _bellIcon = '<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
// ── background CSS helpers ──
export function _isCalBgImage(c) {
return typeof c === 'string' && c.startsWith('bg:');
}
export function _calBgImageUrl(c) {
return _isCalBgImage(c) ? c.slice(3) : '';
}
// Returns a value safe to drop into `style="background:..."`. Falls back to
// the calendar default for bg-image events in spots where an image would be
// too small to render usefully (small grid dots, multi-day bars).
export function _calBgCss(c, fallback) {
if (_isCalBgImage(c)) {
const u = _calBgImageUrl(c);
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
}
return c || fallback || 'var(--accent)';
}
// ── date helpers ──
// `YYYY-MM-DD` string from a Date.
export function _ds(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
export function _addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + n);
return _ds(d);
}
export function _shiftDT(iso, days) {
const d = new Date(iso);
d.setDate(d.getDate() + days);
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
}
// Current user's UTC offset as `±HH:MM`. Used to stamp event payloads so
// the backend can interpret naive datetimes in the user's tz.
export function _tzOffset() {
const o = -new Date().getTimezoneOffset();
const sign = o >= 0 ? '+' : '-';
const h = String(Math.floor(Math.abs(o) / 60)).padStart(2, '0');
const m = String(Math.abs(o) % 60).padStart(2, '0');
return `${sign}${h}:${m}`;
}
// For naive datetimes (no tz suffix), display the date portion as written —
// TimeTree and many sync tools store "local time" without an offset, so
// re-interpreting them via the user's tz would shift days.
//
// For tz-aware ISO (`Z` or `±HH:MM`), parse as an absolute instant and
// bucket by the USER's local date. Without this an event at
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
export function _localDateOf(isoStr) {
if (!isoStr) return '';
if (isoStr.length === 10) return isoStr;
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
const d = new Date(isoStr);
if (!isNaN(d)) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
}
return isoStr.slice(0, 10);
}

350
static/js/censor.js Normal file
View File

@@ -0,0 +1,350 @@
// static/js/censor.js
/**
* Sensitive Information Censor Module
* Detects emails, passwords, API keys, tokens, etc. in chat responses
* and blurs them. Click to reveal individual items.
*/
let _enabled = true;
let _observer = null;
const PREF_KEY = 'odysseus-sensitive-blur';
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
// Patterns that indicate sensitive data
const PATTERNS = [
// Emails
{ re: /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g, label: 'email' },
// API key prefixes (common services)
{ re: /\b(sk-[a-zA-Z0-9]{20,}|pk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{36,}|gho_[a-zA-Z0-9]{36,}|glpat-[a-zA-Z0-9\-_]{20,}|xox[bpras]-[a-zA-Z0-9\-]{10,}|npm_[a-zA-Z0-9]{36,}|AKIA[A-Z0-9]{12,})\b/g, label: 'api-key' },
// Bearer tokens
{ re: /Bearer\s+[A-Za-z0-9._\-]{20,}/g, label: 'token' },
// Generic tokens/secrets in key=value or key: value patterns
// Credentials with delimiters (key: value, key=value, key value)
{ re: /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)[\s]*[:=]\s*["']?[^\s"'<]{4,}["']?/gi, label: 'credential' },
// Credentials in tabular/label-value format (Password xyzABC123)
{ re: /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)\s{2,}[^\s<]{4,}/gi, label: 'credential' },
// Value after a line starting with password-like label
{ re: /(?:^|\n)\s*(?:password|passwd|secret|api[_\-]?key|token|private[_\-]?key)[\t ]*\n\s*([^\s<]{4,})/gim, label: 'credential' },
// SSH / PEM private keys (inline)
{ re: /-----BEGIN\s[\w\s]*PRIVATE KEY-----[\s\S]*?-----END\s[\w\s]*PRIVATE KEY-----/g, label: 'private-key' },
// Long hex strings (32+ chars) that look like hashes/tokens
{ re: /\b[0-9a-f]{32,}\b/gi, label: 'hash' },
// JWT tokens (three dot-separated base64 segments)
{ re: /\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b/g, label: 'jwt' },
// IP addresses with ports (internal networks)
{ re: /\b(?:10\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}(?::\d+)?\b/g, label: 'internal-ip' },
];
export function init() {
// Load enabled state from feature flags
_loadState();
window.addEventListener('odysseus-sensitive-blur-change', (e) => {
setEnabled(e.detail?.enabled !== false);
});
// Set up click handler for reveals (delegated)
document.addEventListener('click', (e) => {
const el = e.target.closest('.censored-item');
if (!el) return;
e.preventDefault();
e.stopPropagation();
el.classList.toggle('revealed');
});
}
function _loadState() {
// Check admin feature flag
fetch('/api/auth/features', { credentials: 'same-origin' })
.then(r => r.json())
.then(features => {
_enabled = features.sensitive_filter !== false && _prefEnabled();
// Start observer after loading state
_startObserver();
})
.catch(() => {
// Default: enabled
_enabled = _prefEnabled();
_startObserver();
});
}
function _startObserver() {
if (_observer) return;
// Observe chat-history, compare panes, and split panes for new messages
_observer = new MutationObserver((mutations) => {
if (!_enabled) return;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
// Process any .body elements within newly added nodes
if (node.classList && node.classList.contains('body')) {
_scheduleProcess(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('.msg .body, .msg-ai .body').forEach(b => _scheduleProcess(b));
}
}
}
});
// Observe the entire main area for new messages
const targets = [
document.getElementById('chat-container'),
document.getElementById('chat-history'),
].filter(Boolean);
targets.forEach(t => {
_observer.observe(t, { childList: true, subtree: true });
});
}
// Debounce processing — content may still be streaming
const _pending = new WeakSet();
function _scheduleProcess(el) {
if (_pending.has(el)) return;
_pending.add(el);
// Wait for streaming to settle — process after a short delay
// Re-process periodically during streaming
let attempts = 0;
const maxAttempts = 30;
const interval = setInterval(() => {
_processElement(el);
attempts++;
if (attempts >= maxAttempts) clearInterval(interval);
}, 2000);
// Also process once immediately (catches non-streaming content)
setTimeout(() => _processElement(el), 100);
// Final pass after streaming likely done
setTimeout(() => {
clearInterval(interval);
_processElement(el);
_pending.delete(el);
}, 60000);
}
// Labels that indicate the NEXT value should be censored
const SENSITIVE_LABELS = /^(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret|token|credentials?)$/i;
function _processElement(el) {
if (!_enabled || !el) return;
if (el.closest && el.closest('.setup-guide-no-censor')) return;
// --- Pass 1: Pattern-based censoring on text nodes ---
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
const textNodes = [];
let node;
while ((node = walker.nextNode())) {
if (node.parentElement.closest('.setup-guide-no-censor')) continue;
if (node.parentElement.closest('pre:not(.censored-item), .censored-item')) continue;
textNodes.push(node);
}
for (const textNode of textNodes) {
const text = textNode.textContent;
if (!text || text.trim().length < 4) continue;
const matches = [];
for (const pattern of PATTERNS) {
pattern.re.lastIndex = 0;
let m;
while ((m = pattern.re.exec(text)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length, text: m[0], label: pattern.label });
}
}
if (matches.length === 0) continue;
matches.sort((a, b) => a.start - b.start);
const deduped = [matches[0]];
for (let i = 1; i < matches.length; i++) {
const prev = deduped[deduped.length - 1];
if (matches[i].start < prev.end) {
if (matches[i].end > prev.end) prev.end = matches[i].end;
} else {
deduped.push(matches[i]);
}
}
const frag = document.createDocumentFragment();
let lastIdx = 0;
for (const match of deduped) {
if (match.start > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, match.start)));
}
const span = document.createElement('span');
span.className = 'censored-item';
span.dataset.type = match.label;
span.title = 'Click to reveal ' + match.label;
span.textContent = match.text;
frag.appendChild(span);
lastIdx = match.end;
}
if (lastIdx < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIdx)));
}
textNode.parentNode.replaceChild(frag, textNode);
}
// --- Pass 2: Context-aware label/value censoring ---
// Finds elements where text matches a sensitive label, then censors
// the adjacent sibling or next text content as a value.
_contextCensor(el);
}
function _contextCensor(el) {
// Strategy 1: Walk all elements looking for sensitive labels
const allElements = el.querySelectorAll('td, th, dt, dd, span, strong, b, em, li, p, div');
for (let i = 0; i < allElements.length; i++) {
const elem = allElements[i];
if (elem.closest('.setup-guide-no-censor')) continue;
if (elem.closest('.censored-item, pre')) continue;
const txt = (elem.textContent || '').trim();
if (!SENSITIVE_LABELS.test(txt)) continue;
// Found a label — censor value via multiple strategies
let censored = false;
// A) Next text sibling node (e.g. <strong>Password</strong> value123)
let sibling = elem.nextSibling;
while (sibling && !censored) {
if (sibling.nodeType === 3) { // text node
const val = sibling.textContent.trim();
if (val.length >= 4 && !SENSITIVE_LABELS.test(val)) {
const span = document.createElement('span');
span.className = 'censored-item';
span.dataset.type = 'credential';
span.title = 'Click to reveal credential';
span.textContent = sibling.textContent;
sibling.parentNode.replaceChild(span, sibling);
censored = true;
}
} else if (sibling.nodeType === 1 && !sibling.closest('.censored-item')) {
// Element sibling — censor its text
const val = sibling.textContent.trim();
if (val.length >= 4 && !SENSITIVE_LABELS.test(val)) {
_censorAllText(sibling);
censored = true;
}
}
sibling = censored ? null : sibling.nextSibling;
}
// B) Parent's next element sibling (for <td>/<dd> pairs)
if (!censored) {
const parent = elem.parentElement;
if (parent) {
const nextEl = parent.nextElementSibling;
if (nextEl && !nextEl.closest('.censored-item')) {
const val = nextEl.textContent.trim();
if (val.length >= 2 && !SENSITIVE_LABELS.test(val)) {
_censorAllText(nextEl);
censored = true;
}
}
}
}
// C) Same parent, next text node after this element
if (!censored && elem.parentElement) {
const parent = elem.parentElement;
let found = false;
for (let c = 0; c < parent.childNodes.length; c++) {
const child = parent.childNodes[c];
if (child === elem) { found = true; continue; }
if (!found) continue;
if (child.nodeType === 3 && child.textContent.trim().length >= 4) {
const val = child.textContent.trim();
if (!SENSITIVE_LABELS.test(val)) {
const span = document.createElement('span');
span.className = 'censored-item';
span.dataset.type = 'credential';
span.title = 'Click to reveal credential';
span.textContent = child.textContent;
child.parentNode.replaceChild(span, child);
break;
}
}
}
}
}
// Strategy 2: Full-text scan for label-value patterns across lines
// Get the full text, find patterns like "Password\n value" or "Password: value"
const fullText = el.textContent || '';
const labelValueRe = /(?:password|passwd|secret|api[_\-]?key|access[_\-]?token|private[_\-]?key|client[_\-]?secret|token|auth[_\-]?token)\s*[:\s]\s*(\S{4,})/gi;
let m;
while ((m = labelValueRe.exec(fullText)) !== null) {
const value = m[1];
// Find and censor this value string in text nodes
_censorValueInElement(el, value);
}
}
function _censorValueInElement(el, value) {
if (!value || value.length < 4) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
let node;
while ((node = walker.nextNode())) {
if (node.parentElement.closest('.setup-guide-no-censor')) continue;
if (node.parentElement.closest('pre:not(.censored-item), .censored-item')) continue;
const idx = node.textContent.indexOf(value);
if (idx < 0) continue;
// Split text node and wrap the value
const before = node.textContent.slice(0, idx);
const after = node.textContent.slice(idx + value.length);
const frag = document.createDocumentFragment();
if (before) frag.appendChild(document.createTextNode(before));
const span = document.createElement('span');
span.className = 'censored-item';
span.dataset.type = 'credential';
span.title = 'Click to reveal credential';
span.textContent = value;
frag.appendChild(span);
if (after) frag.appendChild(document.createTextNode(after));
node.parentNode.replaceChild(frag, node);
return; // One replacement per call to avoid walker issues
}
}
function _censorAllText(el) {
// Wrap all text content in a censored span
if (el.querySelector('.censored-item')) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
const nodes = [];
let n;
while ((n = walker.nextNode())) {
if (n.parentElement.closest('.setup-guide-no-censor')) continue;
if (n.parentElement.closest('.censored-item, pre')) continue;
if (n.textContent.trim().length >= 2) nodes.push(n);
}
for (const tn of nodes) {
const span = document.createElement('span');
span.className = 'censored-item';
span.dataset.type = 'credential';
span.title = 'Click to reveal credential';
span.textContent = tn.textContent;
tn.parentNode.replaceChild(span, tn);
}
}
/** Manually censor a specific element (for dynamically loaded content) */
export function censorElement(el) {
if (!_enabled) return;
_processElement(el);
}
/** Toggle censoring on/off (client-side) */
export function setEnabled(enabled) {
_enabled = enabled;
if (!enabled) {
// Reveal all currently censored items
document.querySelectorAll('.censored-item').forEach(el => el.classList.add('revealed'));
} else {
document.querySelectorAll('.censored-item').forEach(el => el.classList.remove('revealed'));
}
}
export function isEnabled() {
return _enabled;
}
const censorModule = { init, censorElement, setEnabled, isEnabled };
export default censorModule;

4488
static/js/chat.js Normal file

File diff suppressed because it is too large Load Diff

2303
static/js/chatRenderer.js Normal file

File diff suppressed because it is too large Load Diff

276
static/js/chatStream.js Normal file
View File

@@ -0,0 +1,276 @@
// static/js/chatStream.js
// SSE event handlers extracted from chat.js handleChatSubmit
// Handles: ui_control events, background stream management
import uiModule from './ui.js';
import Storage from './storage.js';
import themeModule from './theme.js';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
*/
export function handleUIControl(uiData) {
var uiEvent = uiData.ui_event || uiData;
var esc = uiModule.esc;
try {
if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') {
var toggleMap = {
web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle',
research: 'research-toggle', incognito: 'incognito-toggle',
};
var btnMap = {
web: 'web-toggle-btn', bash: 'bash-toggle-btn', rag: 'rag-indicator-btn',
};
var chkId = toggleMap[uiData.toggle_name];
var btnId = btnMap[uiData.toggle_name];
if (uiData.toggle_name === 'rag' && window._syncRagIndicator) {
window._syncRagIndicator(!!uiData.state);
} else {
if (chkId) {
var chk = document.getElementById(chkId);
if (chk) chk.checked = !!uiData.state;
}
if (btnId) {
var btn = document.getElementById(btnId);
if (btn) btn.classList.toggle('active', !!uiData.state);
}
}
var ts = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts[uiData.toggle_name] = !!uiData.state;
Storage.setJSON(Storage.KEYS.TOGGLES, ts);
} else if (uiEvent === 'set_mode' || uiData.ui_event === 'set_mode') {
var modeVal = uiData.mode;
var agentBtn = document.getElementById('mode-agent-btn');
var chatBtn = document.getElementById('mode-chat-btn');
if (agentBtn && chatBtn) {
agentBtn.classList.toggle('active', modeVal === 'agent');
chatBtn.classList.toggle('active', modeVal !== 'agent');
}
var ts2 = Storage.getJSON(Storage.KEYS.TOGGLES, {});
ts2.mode = modeVal;
Storage.setJSON(Storage.KEYS.TOGGLES, ts2);
document.querySelectorAll('[data-mode-tool]').forEach(function(b) {
b.style.display = modeVal === 'agent' ? '' : 'none';
});
} else if (uiEvent === 'switch_model' || uiData.ui_event === 'switch_model') {
var modelDisplay = document.querySelector('.current-model-name, #current-model');
if (modelDisplay) modelDisplay.textContent = uiData.model;
} else if (uiEvent === 'set_theme' || uiData.ui_event === 'set_theme') {
var tm = themeModule;
if (tm && tm.THEMES && tm.applyColors && tm.save) {
var themeName = uiData.theme_name;
if (themeName === 'chatgpt') themeName = 'gpt'; // renamed preset
var customThemes = tm.getCustomThemes ? tm.getCustomThemes() : {};
var colors = tm.THEMES[themeName] || customThemes[themeName] || uiData.colors;
if (colors) {
tm.applyColors(colors);
tm.save(themeName, colors);
var grid = document.getElementById('themeGrid');
if (grid) {
grid.querySelectorAll('.theme-swatch').forEach(function(s) { s.classList.remove('active'); });
var sw = grid.querySelector('[data-theme="' + themeName + '"]');
if (sw) sw.classList.add('active');
}
}
}
} else if (uiEvent === 'create_theme' || uiData.ui_event === 'create_theme') {
var tm2 = themeModule;
if (tm2 && tm2.applyColors && tm2.save) {
var colors2 = uiData.colors;
var name = uiData.theme_name || 'custom';
if (colors2) {
tm2.applyColors(colors2);
tm2.save(name, colors2);
// Background effects (animated pattern / frosted glass) the model
// optionally set — apply them live and persist with the theme so
// they survive re-applying it later.
var bg = uiData.bg || null;
var opts = {};
if (bg) {
if (bg.pattern && tm2.applyBgPattern) { tm2.applyBgPattern(bg.pattern); opts.bgPattern = bg.pattern; }
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.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);
}
}
} else if (uiEvent === 'highlight' || uiData.ui_event === 'highlight') {
document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); });
document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); });
var target = document.querySelector(uiData.selector);
if (target) {
target.classList.add('odysseus-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
if (uiData.label) {
var lbl = document.createElement('div');
lbl.className = 'odysseus-hl-label';
lbl.textContent = uiData.label;
if (!target.style.position) target.style.position = 'relative';
target.appendChild(lbl);
}
}
} else if (uiEvent === 'clear_highlight' || uiData.ui_event === 'clear_highlight') {
document.querySelectorAll('.odysseus-highlight').forEach(function(e) { e.classList.remove('odysseus-highlight'); });
document.querySelectorAll('.odysseus-hl-label').forEach(function(e) { e.remove(); });
} else if (uiEvent === 'research_started' || uiData.ui_event === 'research_started') {
// Agent kicked off deep research — adopt the session into the
// sidebar immediately so the user sees it without waiting for
// the 12s active-poll.
var rsid = uiData.research_session_id || uiData.session_id;
if (rsid) {
import('./research/jobs.js').then(function(mod) {
var fn = mod.adoptSession || (mod.default && mod.default.adoptSession);
if (fn) fn(rsid);
}).catch(function(){});
// The clickable "Open in Deep Research" link is now emitted by the
// agent loop as a `#research-<id>` markdown anchor in the assistant's
// response text — it renders as a regular clickable chat link AND
// persists across refresh (saved with the message). No ephemeral
// chip injection needed here anymore.
}
} else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') {
var panel = uiData.panel;
if (panel === 'documents') {
import('./documentLibrary.js').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) {
var fn = mod.openGallery || (mod.default && mod.default.openGallery);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'email') {
import('./emailLibrary.js').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'sessions') {
import('./sessions.js').then(function(mod) {
var fn = mod.openLibrary || (mod.default && mod.default.openLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'cookbook') {
import('./cookbook.js').then(function(mod) {
var fn = mod.open || (mod.default && mod.default.open);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'notes') {
import('./notes.js').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 === '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 btn = document.getElementById(ids[panel]);
if (btn) btn.click();
}
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
import('./emailInbox.js').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply');
}).catch(function(e) {
console.warn('open_email_reply failed:', e);
});
}
} catch(e) {
console.warn('ui_control handler error:', e);
}
}
/**
* Notify user when a background stream completes.
*/
export function notifyStreamComplete(sessionId, query) {
var isHidden = document.hidden;
var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId;
if (!isHidden && !isOtherSession) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
var body = query ? 'Response to "' + query.substring(0, 60) + '" is ready' : 'Your chat response has completed';
var notification = new Notification('Response Complete', {
body: body,
tag: 'stream-' + sessionId,
});
notification.onclick = function() {
window.focus();
if (isOtherSession && sessionModule) {
sessionModule.selectSession(sessionId);
}
notification.close();
};
setTimeout(function() { notification.close(); }, 10000);
}
/**
* Insert a clickable in-chat toast when a background stream finishes.
*/
export function insertStreamDoneToast(sessionId, query) {
var box = document.getElementById('chat-history');
if (!box) return;
var sessions = sessionModule ? sessionModule.getSessions() : [];
var sess = sessions.find(function(s) { return s.id === sessionId; });
var name = sess ? sess.name : 'another session';
var preview = query ? '"' + query.substring(0, 50) + (query.length > 50 ? '...' : '') + '"' : '';
var div = document.createElement('div');
div.className = 'msg msg-system stream-done-toast';
div.innerHTML = '<div class="body">'
+ '<span class="stream-done-indicator">●</span>'
+ '<span>Response ready in <strong>' + (name || 'session').replace(/</g, '&lt;') + '</strong>'
+ (preview ? ' &mdash; ' + preview.replace(/</g, '&lt;') : '')
+ '</span>'
+ '</div>';
div.addEventListener('click', function() {
if (sessionModule) sessionModule.selectSession(sessionId);
});
box.appendChild(div);
uiModule.scrollHistory();
}
/**
* Notify when research completes (browser notification).
*/
export function notifyResearchComplete(sessionId, query) {
var isHidden = document.hidden;
var isOtherSession = sessionModule && sessionModule.getCurrentSessionId() !== sessionId;
if (!isHidden && !isOtherSession) return;
if (!('Notification' in window) || Notification.permission !== 'granted') return;
var body = query ? 'Research on "' + query.substring(0, 60) + '" is ready' : 'Your deep research has completed';
var notification = new Notification('Research Complete', {
body: body,
tag: 'research-' + sessionId,
});
notification.onclick = function() {
window.focus();
if (isOtherSession && sessionModule) {
sessionModule.selectSession(sessionId);
}
notification.close();
};
setTimeout(function() { notification.close(); }, 10000);
}
const chatStream = {
handleUIControl,
notifyStreamComplete,
insertStreamDoneToast,
notifyResearchComplete,
};
export default chatStream;

398
static/js/codeRunner.js Normal file
View File

@@ -0,0 +1,398 @@
// static/js/codeRunner.js
import * as uiModule from './ui.js';
/**
* In-browser code runner for Python (Pyodide), JavaScript, and HTML
*/
let pyodideInstance = null;
let pyodideLoading = false;
const pyodideQueue = [];
/**
* Get or create an output panel below the <pre> element
*/
function getOrCreatePanel(pre) {
let panel = pre.nextElementSibling;
if (panel && panel.classList.contains('code-runner-output')) {
panel.innerHTML = '';
panel.style.display = 'block';
return panel;
}
panel = document.createElement('div');
panel.className = 'code-runner-output';
pre.parentNode.insertBefore(panel, pre.nextSibling);
return panel;
}
/**
* Show a loading message in the panel
*/
function showLoading(panel, msg) {
panel.innerHTML = `<div class="code-runner-loading">${msg}</div>`;
}
/**
* Show output text in the panel
*/
function showOutput(panel, text, isError) {
const el = document.createElement('pre');
el.className = isError ? 'code-runner-pre code-runner-error' : 'code-runner-pre';
el.textContent = text;
panel.innerHTML = '';
panel.appendChild(el);
// Copy button — visible labeled pill at the top-right of the panel
// itself (no separate footer / divider, no tiny icon corner).
if (text) {
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.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
let ok = false;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;';
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length);
ok = document.execCommand && document.execCommand('copy');
ta.remove();
} catch (_) {}
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);
}).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';
setTimeout(() => { cbtn.innerHTML = orig; }, 1500);
});
// Button lives directly in the panel — no wrapping bar. The panel is
// position:relative so the button can sit absolute-top-right of it.
panel.appendChild(cbtn);
}
if (isError) {
setTimeout(() => { if (panel) panel.style.display = 'none'; }, 7000);
}
}
/**
* Legacy absolute-positioned copy button — replaced by the inline bar in
* showOutput. Kept here as no-op so any earlier callers don't crash.
*/
function addCopyBtn_unused(panel, text) {
if (!text) return;
const btn = document.createElement('button');
btn.type = 'button'; // Default <button> type is 'submit' — explicit "button" avoids any accidental form submission.
btn.className = 'code-runner-copy';
btn.title = 'Copy output';
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"><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>';
btn.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
// Synchronous copy via a hidden textarea + execCommand — this is the
// single most reliable path across browsers / non-secure contexts /
// mobile Firefox. Run BEFORE any async navigator.clipboard attempt so
// the user-gesture context is preserved.
let ok = false;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;';
document.body.appendChild(ta);
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length);
ok = document.execCommand && document.execCommand('copy');
ta.remove();
} catch (_) {}
// As a backup, also try the modern clipboard API (won't hurt if the
// legacy path already copied).
if (!ok && navigator.clipboard && window.isSecureContext) {
try { await navigator.clipboard.writeText(text); ok = true; } catch (_) {}
}
if (uiModule && uiModule.showToast) {
uiModule.showToast(ok ? 'Copied' : 'Copy failed');
}
const _orig = btn.innerHTML;
btn.innerHTML = '<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="20 6 9 17 4 12"/></svg>';
btn.classList.add('copied');
setTimeout(() => { btn.innerHTML = _orig; btn.classList.remove('copied'); }, 1500);
});
panel.prepend(btn);
}
/**
* Add a collapse/close button to the panel.
* Disabled \u2014 the run-output panel is now closed via the unified Code\u2194Run
* toggle in the editor footer, so a separate X was redundant + cluttered.
*/
function addCloseBtn(_panel) { /* no-op */ }
/**
* Lazy-load Pyodide from CDN
*/
function loadPyodide() {
if (pyodideInstance) return Promise.resolve(pyodideInstance);
if (pyodideLoading) {
return new Promise((resolve, reject) => {
pyodideQueue.push({ resolve, reject });
});
}
pyodideLoading = true;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.js';
script.onload = () => {
window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/' })
.then(py => {
pyodideInstance = py;
pyodideLoading = false;
pyodideQueue.forEach(q => q.resolve(py));
pyodideQueue.length = 0;
resolve(py);
})
.catch(err => {
pyodideLoading = false;
pyodideQueue.forEach(q => q.reject(err));
pyodideQueue.length = 0;
reject(err);
});
};
script.onerror = () => {
pyodideLoading = false;
const err = new Error('Failed to load Pyodide');
pyodideQueue.forEach(q => q.reject(err));
pyodideQueue.length = 0;
reject(err);
};
document.head.appendChild(script);
});
}
/**
* Run Python code via Pyodide
*/
export async function runPython(code, panel) {
showLoading(panel, 'Loading Python runtime (first time ~10 MB)...');
let py;
try {
py = await loadPyodide();
} catch (e) {
showOutput(panel, 'Failed to load Python runtime: ' + e.message, true);
addCloseBtn(panel);
return;
}
showLoading(panel, 'Running...');
const wrapper = `
import sys, io
_stdout = io.StringIO()
_stderr = io.StringIO()
sys.stdout = _stdout
sys.stderr = _stderr
try:
exec(${JSON.stringify(code)})
except Exception as _e:
_stderr.write(str(_e))
finally:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
(_stdout.getvalue(), _stderr.getvalue())
`;
try {
const result = await Promise.race([
py.runPythonAsync(wrapper),
new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out (10 s)')), 10000))
]);
const stdout = result.toJs ? result.toJs()[0] : (result[0] || '');
const stderr = result.toJs ? result.toJs()[1] : (result[1] || '');
if (result.destroy) result.destroy();
panel.innerHTML = '';
if (stderr) {
showOutput(panel, stderr, true);
} else if (stdout) {
showOutput(panel, stdout, false);
} else {
showOutput(panel, '(no output)', false);
}
} catch (e) {
showOutput(panel, e.message, true);
}
addCloseBtn(panel);
}
/**
* Run JavaScript code in a sandboxed iframe
*/
export function runJavaScript(code, panel) {
showLoading(panel, 'Running...');
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.sandbox = 'allow-scripts';
document.body.appendChild(iframe);
let settled = false;
const cleanup = () => {
if (iframe.parentNode) iframe.remove();
};
const failsafe = setTimeout(() => {
if (!settled) {
settled = true;
showOutput(panel, 'Execution timed out (10 s)', true);
addCloseBtn(panel);
cleanup();
}
}, 15000);
const onMessage = (e) => {
if (e.source !== iframe.contentWindow) return;
if (settled) return;
settled = true;
clearTimeout(failsafe);
window.removeEventListener('message', onMessage);
const data = e.data;
panel.innerHTML = '';
if (data.error) {
showOutput(panel, data.error, true);
} else if (data.logs && data.logs.length > 0) {
showOutput(panel, data.logs.join('\n'), false);
} else {
showOutput(panel, '(no output)', false);
}
addCloseBtn(panel);
cleanup();
};
window.addEventListener('message', onMessage);
const wrappedCode = `
<!DOCTYPE html><html><body><script>
var _logs = [];
var _origLog = console.log;
console.log = function() { _logs.push([].map.call(arguments, function(a) { try { return typeof a === 'object' ? JSON.stringify(a) : String(a); } catch(e) { return String(a); } }).join(' ')); };
console.warn = function() { _logs.push('[warn] ' + [].map.call(arguments, String).join(' ')); };
console.error = function() { _logs.push('[error] ' + [].map.call(arguments, String).join(' ')); };
try {
var _timer = setTimeout(function() { parent.postMessage({error:'Execution timed out (10 s)'},'*'); }, 10000);
${code.replace(/<\/script>/gi, '<\\/script>')}
clearTimeout(_timer);
parent.postMessage({logs: _logs}, '*');
} catch(e) {
parent.postMessage({error: e.toString()}, '*');
}
<\/script></body></html>`;
iframe.srcdoc = wrappedCode;
}
/**
* Run code server-side via POST /api/shell/exec
*/
export async function runServer(code, panel, lang) {
showLoading(panel, 'Running on server...');
var command;
if (lang === 'python' || lang === 'py') {
command = 'python3 -c ' + JSON.stringify(code);
} else {
command = 'bash -c ' + JSON.stringify(code);
}
try {
var res = await fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: command }),
});
var data = await res.json();
panel.innerHTML = '';
if (data.stderr && data.stderr.trim()) {
showOutput(panel, data.stderr, true);
if (data.stdout && data.stdout.trim()) {
var stdoutEl = document.createElement('pre');
stdoutEl.className = 'code-runner-pre';
stdoutEl.textContent = data.stdout;
panel.appendChild(stdoutEl);
}
} else if (data.stdout && data.stdout.trim()) {
showOutput(panel, data.stdout, false);
} else {
showOutput(panel, '(no output)' + (data.exit_code ? ' — exit code ' + data.exit_code : ''), !data.exit_code ? false : true);
}
if (data.exit_code && data.exit_code !== 0) {
var exitEl = document.createElement('div');
exitEl.style.cssText = 'font-size:0.75rem;opacity:0.5;padding:2px 8px;';
exitEl.textContent = 'Exit code: ' + data.exit_code;
panel.appendChild(exitEl);
}
} catch (e) {
showOutput(panel, 'Execution failed: ' + e.message, true);
}
addCloseBtn(panel);
}
/**
* Run HTML code in its own popup window
*/
export function runHTML(code, panel) {
panel.innerHTML = '';
const win = window.open('', '_blank', 'width=800,height=600,menubar=no,toolbar=no,location=no,status=no');
if (!win) {
showOutput(panel, 'Popup blocked — please allow popups for this site.', true);
addCloseBtn(panel);
return;
}
win.document.open();
win.document.write(code);
win.document.close();
showOutput(panel, 'Opened in new window', false);
addCloseBtn(panel);
}
/**
* Main entry point — called when a Run button is clicked
*/
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 (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') {
runServer(code, panel, 'bash');
} else if (lang === 'python' || lang === 'py') {
runServer(code, panel, 'python');
} else if (lang === 'javascript' || lang === 'js') {
runJavaScript(code, panel);
} else if (lang === 'html') {
runHTML(code, panel);
}
}
const codeRunnerModule = { run, runPython, runJavaScript, runHTML, runServer };
export default codeRunnerModule;

453
static/js/colorPicker.js Normal file
View File

@@ -0,0 +1,453 @@
// In-house color picker with live-feedback HSV square, hue bar,
// eyedropper, recent colors, and harmony suggestions.
// Non-invasive: wraps existing <input type="color"> elements —
// their .value stays the source of truth, and we dispatch 'input'
// events so existing listeners keep working.
const LS_RECENT = 'odysseus-recent-colors';
const MAX_RECENT = 12;
let _popover = null;
let _input = null;
let _h = 0, _s = 100, _v = 100; // HSV
let _drag = null; // 'sl' | 'hue' | null
let _onOutside = null;
// ── Color math ────────────────────────────────────────────────────────
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function hexToRgb(hex) {
hex = String(hex || '').replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(hex)) return { r: 0, g: 0, b: 0 };
const n = parseInt(hex, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(v =>
Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')
).join('');
}
function rgbToHsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h;
const s = max === 0 ? 0 : d / max;
const v = max;
if (d === 0) h = 0;
else if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
return { h: h * 60, s: s * 100, v: v * 100 };
}
function hsvToRgb(h, s, v) {
h = ((h % 360) + 360) % 360;
h /= 60; s /= 100; v /= 100;
const i = Math.floor(h);
const f = h - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
let r, g, b;
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
}
function hsvToHex(h, s, v) { const { r, g, b } = hsvToRgb(h, s, v); return rgbToHex(r, g, b); }
function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g, b); }
// ── Storage ───────────────────────────────────────────────────────────
function getRecents() {
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
catch { return []; }
}
function addRecent(hex) {
if (!/^#[0-9a-f]{6}$/i.test(hex)) return;
let recents = getRecents().filter(c => c.toLowerCase() !== hex.toLowerCase());
recents.unshift(hex.toLowerCase());
recents = recents.slice(0, MAX_RECENT);
try { localStorage.setItem(LS_RECENT, JSON.stringify(recents)); } catch {}
}
// ── Suggestions based on current color (5 harmony swatches) ──────────
function computeSuggestions() {
// Complement, analogous ±30°, split-complement (+150), tone shift
return [
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
];
}
// ── Popover build ─────────────────────────────────────────────────────
function buildPopover() {
const p = document.createElement('div');
p.className = 'cp-popover';
p.innerHTML = `
<div class="cp-sl" data-drag="sl">
<div class="cp-sl-white"></div>
<div class="cp-sl-black"></div>
<div class="cp-sl-handle"></div>
</div>
<div class="cp-hue" data-drag="hue">
<div class="cp-hue-handle"></div>
</div>
<div class="cp-row">
<div class="cp-preview"></div>
<input type="text" class="cp-hex" maxlength="7" spellcheck="false" autocomplete="off">
<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"/>
</svg>
</button>
</div>
<div class="cp-section-label">Suggestions</div>
<div class="cp-swatches cp-suggestions"></div>
<div class="cp-section-label">Recent</div>
<div class="cp-swatches cp-recent"></div>
`;
document.body.appendChild(p);
wireHandlers(p);
return p;
}
// ── UI sync ───────────────────────────────────────────────────────────
function syncUI() {
if (!_popover) return;
const sl = _popover.querySelector('.cp-sl');
const slH = _popover.querySelector('.cp-sl-handle');
const hue = _popover.querySelector('.cp-hue');
const hueH = _popover.querySelector('.cp-hue-handle');
const hex = _popover.querySelector('.cp-hex');
const preview = _popover.querySelector('.cp-preview');
const pureHue = hsvToHex(_h, 100, 100);
sl.style.background = pureHue; // base hue — white/black layers stacked on top via CSS
slH.style.left = (_s) + '%';
slH.style.top = (100 - _v) + '%';
hueH.style.left = (_h / 360 * 100) + '%';
const current = hsvToHex(_h, _s, _v);
preview.style.background = current;
if (document.activeElement !== hex) hex.value = current;
// Suggestions
const sContainer = _popover.querySelector('.cp-suggestions');
const sugs = computeSuggestions();
sContainer.innerHTML = sugs.map(s =>
`<button class="cp-swatch" title="${s.label}: ${s.hex}" data-hex="${s.hex}" style="background:${s.hex}"></button>`
).join('');
// Recents
const rContainer = _popover.querySelector('.cp-recent');
const recs = getRecents();
rContainer.innerHTML = recs.length
? recs.map(h => `<button class="cp-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
: '<div class="cp-recent-empty">(none yet)</div>';
}
function applyToInput(pushChange) {
if (!_input) return;
const hex = hsvToHex(_h, _s, _v);
_input.value = hex; // setter also updates style.background
if (pushChange) _input.dispatchEvent(new Event('input', { bubbles: true }));
syncUI();
}
function setFromHex(hex) {
const v = hexToHsv(hex);
_h = v.h; _s = v.s; _v = v.v;
}
// ── Handlers ──────────────────────────────────────────────────────────
// Window-level pointer listeners — installed ONCE, not per-popover, so they
// don't leak when the popover is rebuilt on every open.
let _windowPointerInstalled = false;
function _installWindowPointer() {
if (_windowPointerInstalled) return;
_windowPointerInstalled = true;
window.addEventListener('pointermove', (e) => { if (_drag) handleDrag(e); });
window.addEventListener('pointerup', () => {
if (_drag) {
_drag = null;
commitCurrent();
}
});
}
function wireHandlers(p) {
const sl = p.querySelector('.cp-sl');
const hue = p.querySelector('.cp-hue');
const hex = p.querySelector('.cp-hex');
const eye = p.querySelector('.cp-eyedropper');
const onDown = (type) => (e) => {
_drag = type;
handleDrag(e);
e.preventDefault();
};
sl.addEventListener('pointerdown', onDown('sl'));
hue.addEventListener('pointerdown', onDown('hue'));
_installWindowPointer();
hex.addEventListener('input', () => {
let v = hex.value.trim();
if (!v.startsWith('#')) v = '#' + v;
if (/^#[0-9a-f]{6}$/i.test(v)) {
setFromHex(v);
applyToInput(true);
}
});
hex.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { commitCurrent(); close(); }
if (e.key === 'Escape') { close(); }
});
p.addEventListener('click', (e) => {
const sw = e.target.closest('.cp-swatch');
if (sw && sw.dataset.hex) {
setFromHex(sw.dataset.hex);
applyToInput(true);
commitCurrent();
}
});
if (window.EyeDropper) {
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
// hits our document-capture listener and closes the popover.
const wasOnOutside = _onOutside;
_detachOutsideHandlers();
try {
const r = await new window.EyeDropper().open();
if (r && r.sRGBHex) {
setFromHex(r.sRGBHex);
applyToInput(true);
commitCurrent();
}
} catch (_) { /* user cancelled */ }
// Re-arm outside-click handler after a frame so the eyedropper's
// own pick-click doesn't immediately re-close us.
if (wasOnOutside && _popover) {
requestAnimationFrame(() => {
if (!_popover) return;
_onOutside = wasOnOutside;
_onEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
document.addEventListener('click', _onOutside, true);
document.addEventListener('keydown', _onEsc, true);
});
}
});
} else {
eye.disabled = true;
eye.style.opacity = '0.3';
eye.title = 'Eyedropper not supported in this browser';
}
}
function handleDrag(e) {
if (_drag === 'sl') {
const sl = _popover.querySelector('.cp-sl');
const r = sl.getBoundingClientRect();
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
const y = clamp((e.clientY - r.top) / r.height, 0, 1);
_s = x * 100;
_v = (1 - y) * 100;
applyToInput(true);
} else if (_drag === 'hue') {
const hue = _popover.querySelector('.cp-hue');
const r = hue.getBoundingClientRect();
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
_h = x * 360;
applyToInput(true);
}
}
function commitCurrent() {
if (!_input) return;
addRecent(_input.value);
syncUI();
}
// ── Open / close ──────────────────────────────────────────────────────
function position(p, anchor) {
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 6;
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
if (top + pRect.height > window.innerHeight - 8) top = rect.top - pRect.height - 6;
if (left < 8) left = 8;
if (top < 8) top = 8;
p.style.left = left + 'px';
p.style.top = top + 'px';
}
let _onEsc = null;
function _detachOutsideHandlers() {
if (_onOutside) {
document.removeEventListener('click', _onOutside, true);
document.removeEventListener('mousedown', _onOutside, true);
document.removeEventListener('pointerdown', _onOutside, true);
_onOutside = null;
}
if (_onEsc) {
document.removeEventListener('keydown', _onEsc, true);
_onEsc = null;
}
}
function _destroyPopover() {
_detachOutsideHandlers();
if (_popover && _popover.parentNode) {
_popover.parentNode.removeChild(_popover);
}
_popover = null;
_input = null;
_drag = null;
}
function open(inputEl) {
// Always tear down any previous popover so we never inherit stale state
// (orphaned listeners, hidden-but-mispositioned div, etc.).
_destroyPopover();
_popover = buildPopover();
_input = inputEl;
setFromHex(inputEl.value || '#000000');
_popover.style.display = 'block';
_popover.style.visibility = 'visible';
_popover.style.opacity = '1';
_popover.style.pointerEvents = 'auto';
// Let it render with its natural size, then position
requestAnimationFrame(() => {
if (_popover && _input) position(_popover, _input);
});
syncUI();
_onOutside = (e) => {
if (_drag) return; // ignore during drag
if (!_popover) return;
if (_popover.contains(e.target)) return;
if (e.target === _input) return;
// If the click landed on a modal close button (X), swallow it so the
// popover-close doesn't also dismiss the enclosing modal. The user
// wants their first click to just close the color picker.
const closeBtn = e.target.closest && e.target.closest('.close-btn, [aria-label*="lose" i]');
if (closeBtn) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
}
close();
};
_onEsc = (e) => {
if (e.key === 'Escape') {
// Same idea for the keyboard: Escape closes the picker first; the
// modal's own Esc handler only fires on the next press.
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
close();
}
};
// Defer install so the click that opened us doesn't immediately close us.
// Use requestAnimationFrame instead of setTimeout(0) to be sure the current
// click event has fully bubbled before we register the listener.
requestAnimationFrame(() => {
document.addEventListener('click', _onOutside, true);
// pointerdown fires before click on touch devices, and reliably even
// when the tap target swallows the click. Catching it ensures
// outside-touches close the picker on mobile.
document.addEventListener('pointerdown', _onOutside, true);
document.addEventListener('keydown', _onEsc, true);
});
}
function close() {
_destroyPopover();
}
// ── Attach to inputs ──────────────────────────────────────────────────
// Standard setter we need to call after wrapping .value with a custom setter.
const _NATIVE_VALUE_DESC = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
function _syncSwatch(el) {
const v = _NATIVE_VALUE_DESC.get.call(el);
if (/^#[0-9a-f]{6}$/i.test(v || '')) el.style.background = v;
}
export function attachColorPicker(inputEl) {
if (!inputEl || inputEl.dataset.cpAttached === '1') return;
inputEl.dataset.cpAttached = '1';
// Neutralize the native color dialog by changing the element's type.
// Existing `.value` reads + `input` event listeners continue to work.
const initialAttr = inputEl.getAttribute('value');
const initial = inputEl.value || initialAttr || '#000000';
inputEl.setAttribute('data-cp-original-type', inputEl.type || 'color');
inputEl.type = 'text';
inputEl.readOnly = true;
inputEl.classList.add('cp-swatch-input');
// Wrap .value so ANY assignment (from theme.js applyColors etc.) auto-updates the swatch bg.
Object.defineProperty(inputEl, 'value', {
configurable: true,
get() { return _NATIVE_VALUE_DESC.get.call(this); },
set(v) {
_NATIVE_VALUE_DESC.set.call(this, v);
_syncSwatch(this);
},
});
// Apply initial value so swatch shows color even before any programmatic set.
inputEl.value = initial;
// Use mousedown so we fire BEFORE any document-level click handler
// (e.g. our own _onOutside listener) can decide to close.
inputEl.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// If the same input already has the picker open, close it (toggle).
// Otherwise always (re)open — never get stuck in a "won't reopen" state.
if (_input === inputEl && _popover) {
close();
} else {
open(inputEl);
}
});
// Suppress the trailing click so it can't bubble to overlays/listeners.
inputEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
export function initColorPickers(root = document) {
root.querySelectorAll('input[type="color"]').forEach(attachColorPicker);
}
// Re-run on new inputs that may mount after init
export function refreshColorPickers(root = document) {
initColorPickers(root);
}

View File

@@ -0,0 +1,77 @@
// compare/icons.js — SVG icons, prompt templates, and constants
// ── SVG Icons ──
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 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>';
export const ICON_COLLAPSE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
export const ICON_DICE = '<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="2" y="2" width="20" height="20" rx="3"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/><circle cx="16" cy="8" r="1.5" fill="currentColor"/><circle cx="8" cy="16" r="1.5" fill="currentColor"/><circle cx="16" cy="16" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
export const ICON_PLAY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>';
export const ICON_CODE = '<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="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
export const ICON_CLOSE = '<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="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
// Parallel = lines side by side, Sequential = numbered list
export const ICON_PARALLEL = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
export const ICON_SEQUENTIAL = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>';
export const SEND_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
// ── Animation ──
export const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
// ── Storage keys & limits ──
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 ──
//
// Five high-signal prompts per category — each picked to differentiate models
// on a distinct capability. The Visual / SVG-render prompt in `chat` ends with
// the subject as the last words, so swapping "a pelican riding a bicycle" for
// anything else is a one-line edit.
export const EVAL_PROMPTS = {
chat: [
// ── ★ 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: '4 pours: 7→5, 5→3, 3→7, 5→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: '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: '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.' },
],
code: [
{ sub: 'Algorithms', label: 'LRU cache', prompt: 'Implement an LRU cache with O(1) get and put operations. Support a configurable max capacity. Write it in any language with full comments.' },
{ sub: 'Debugging', label: 'Race condition', prompt: 'This Go code has a race condition. Find it, explain why it happens, and fix it:\n\nvar counter int\nfunc increment(wg *sync.WaitGroup) {\n defer wg.Done()\n for i := 0; i < 1000; i++ {\n counter++\n }\n}' },
{ sub: 'Debugging', label: 'Security review', prompt: 'Review this code for bugs, security issues, and performance problems:\n\napp.get("/user/:id", (req, res) => {\n const query = `SELECT * FROM users WHERE id = ${req.params.id}`;\n db.query(query, (err, result) => {\n res.json(result[0]);\n });\n});' },
{ sub: 'Architecture', label: 'URL shortener', prompt: 'Design a URL shortener service. Cover the API, database schema, and how you would handle 1000 requests per second.' },
{ sub: 'Refactoring', label: 'Clean up', prompt: 'Refactor this code to be more idiomatic and efficient:\n\nresults = []\nfor i in range(len(data)):\n if data[i]["status"] == "active":\n if data[i]["score"] > 50:\n results.append(data[i]["name"].upper())' },
],
agent: [
{ 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: '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: '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.' },
],
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.' },
{ sub: 'Games', label: 'Breakout', prompt: 'Output a complete HTML file (```html block) for a Breakout brick breaker game. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, colorful gradient brick rows, glowing paddle, ball with trail, score + lives, particle explosions on break. Skip any explanation, just output the code.' },
{ sub: 'Animation', label: 'Solar system', prompt: 'Output a complete HTML file (```html block) for an animated solar system. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, glowing Sun center, 8 planets orbiting at correct relative speeds with real colors, orbit trails, starfield background, labels on hover. Skip any explanation, just output the code.' },
{ sub: 'Animation', label: 'Matrix rain', prompt: 'Output a complete HTML file (```html block) for the Matrix digital rain effect. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Full-screen canvas, green katakana characters falling at varying speeds, glowing heads, fading trails, scan-line overlay. Skip any explanation, just output the code.' },
{ sub: 'Generative', label: 'Fractal tree', prompt: 'Output a complete HTML file (```html block) for an interactive fractal tree. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, tree grows from bottom with recursive branches, sliders for angle/depth/length/wind, gradient colors from brown trunk to green leaves. Skip any explanation, just output the code.' },
],
search: [
{ sub: 'Factual', label: 'Current events', prompt: 'latest AI regulation news 2025' },
{ sub: 'Technical', label: 'Programming', prompt: 'Rust vs Go performance benchmarks 2025' },
{ sub: 'Research', label: 'Academic', prompt: 'transformer architecture improvements since attention is all you need' },
{ sub: 'Comparison', label: 'GPU providers', prompt: 'cloud GPU providers pricing comparison 2025' },
{ sub: 'Factual', label: 'Science', prompt: 'CRISPR gene therapy breakthroughs' },
],
};

1476
static/js/compare/index.js Normal file

File diff suppressed because it is too large Load Diff

103
static/js/compare/models.js Normal file
View File

@@ -0,0 +1,103 @@
// compare/models.js — model classification, fetching, display names, persistence
import Storage from '../storage.js';
import state from './state.js';
import uiModule from '../ui.js';
var escapeHtml = uiModule.esc;
// ── Model classification constants ──
const NON_CHAT_PREFIXES = ['tts-', 'whisper-', 'text-embedding-', 'text-moderation-', 'moderation-', 'embedding'];
const NON_CHAT_SUFFIXES = ['deep-research', '-online'];
const IMAGE_PREFIXES = ['dall-e-3', 'gpt-image', 'chatgpt-image'];
const DEPRECATED_IMAGE = ['dall-e-2'];
function classifyModel(id) {
const lower = id.toLowerCase();
if (DEPRECATED_IMAGE.some(p => lower.startsWith(p))) return 'other';
if (IMAGE_PREFIXES.some(p => lower.startsWith(p))) return 'image';
if (NON_CHAT_PREFIXES.some(p => lower.startsWith(p))) return 'other';
if (NON_CHAT_SUFFIXES.some(p => lower.endsWith(p) || lower.includes(p))) return 'other';
return 'chat';
}
/** Build display names for selected models, adding endpoint name when the same model appears from multiple providers. */
function _modelDisplayNames(models) {
const nameCount = {};
for (const m of models) {
const short = m.name || m.model.split('/').pop();
nameCount[short] = (nameCount[short] || 0) + 1;
}
return models.map(m => {
const short = m.name || m.model.split('/').pop();
if (nameCount[short] > 1 && m.endpointName) return short + ' (' + escapeHtml(m.endpointName) + ')';
return short;
});
}
/** Save selected models and synth models to localStorage, keyed by compare mode. */
function _persistSelections() {
if (state._selectedModels.length > 0) {
Storage.setJSON('odysseus-compare-selections-' + (state._compareMode || 'chat'), state._selectedModels);
}
if ((state._compareMode === 'search' || state._compareMode === 'research') && state._searchSynthModels) {
Storage.setJSON('odysseus-compare-synth-' + state._compareMode, state._searchSynthModels);
}
}
// ── Model fetching with cache ──
const MODELS_CACHE_TTL = 30000; // 30 seconds
/** Fetch available models from API. */
async function fetchModels() {
const now = Date.now();
if (state._fetchModelsCache && (now - state._fetchModelsCacheTime) < MODELS_CACHE_TTL) {
return state._fetchModelsCache;
}
const res = await fetch(`${state.API_BASE}/api/models`);
const data = await res.json();
const models = [];
if (data.items && data.items.length > 0) {
data.items.forEach(item => {
const displayNames = item.models_display || item.models || [];
const extraDisplay = item.models_extra_display || item.models_extra || [];
// Curated list (item.models) takes priority; non-curated extras come
// after so newer/uncatalogued models (e.g. deepseek-v4-pro) still show.
(item.models || []).forEach((mid, i) => {
models.push({
id: mid,
url: item.url,
name: (displayNames[i] || mid).split('/').pop(),
endpointId: item.endpoint_id || null,
endpointName: item.endpoint_name || '',
type: classifyModel(mid),
});
});
(item.models_extra || []).forEach((mid, i) => {
models.push({
id: mid,
url: item.url,
name: (extraDisplay[i] || mid).split('/').pop(),
endpointId: item.endpoint_id || null,
endpointName: item.endpoint_name || '',
type: classifyModel(mid),
});
});
});
}
state._fetchModelsCache = models;
state._fetchModelsCacheTime = now;
return models;
}
// ── Shuffle pool persistence ──
const POOL_STORAGE_KEY = 'odysseus-shuffle-pool-excluded';
function getExcludedModels() {
return Storage.getJSON(POOL_STORAGE_KEY, []);
}
function setExcludedModels(arr) {
Storage.setJSON(POOL_STORAGE_KEY, arr);
}
export { classifyModel, _modelDisplayNames, fetchModels, _persistSelections, getExcludedModels, setExcludedModels };

826
static/js/compare/panes.js Normal file
View File

@@ -0,0 +1,826 @@
// compare/panes.js — pane lifecycle, actions, layout
import state from './state.js';
import { _persistSelections } from './models.js';
import { buildVoteBar } from './vote.js';
import {
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
ICON_PLAY, ICON_CODE, SEND_SVG,
} from './icons.js';
import { _clearProbeWaves } from './probe.js';
import Storage from '../storage.js';
import uiModule from '../ui.js';
import spinnerModule from '../spinner.js';
var escapeHtml = uiModule.esc;
// ── Lazy-registered functions from compare.js (avoids circular imports) ──
let _setSendBtn = null;
let _deactivate = null;
let _streamToPane = null;
let _renderSearchResults = null;
let _fetchModels = null;
/** Register external functions that live in compare.js or sibling modules. */
function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearchResults, fetchModels }) {
if (setSendBtn) _setSendBtn = setSendBtn;
if (deactivate) _deactivate = deactivate;
if (streamToPane) _streamToPane = streamToPane;
if (renderSearchResults) _renderSearchResults = renderSearchResults;
if (fetchModels) _fetchModels = fetchModels;
}
/** 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); }
// ── Stop / reroll ──
function stopAll() {
state._abortControllers.forEach(ac => { if (ac) ac.abort(); });
state._abortControllers = [];
state._streaming = false;
if (_setSendBtn) _setSendBtn('send');
// Re-enable header buttons
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
});
}
function stopPane(paneIdx) {
const ac = state._abortControllers[paneIdx];
if (ac) {
ac.abort();
state._abortControllers[paneIdx] = null;
}
// Hide stop button, show reroll
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (pane) {
const stopBtn = pane.querySelector('.pane-stop-btn');
if (stopBtn) stopBtn.style.display = 'none';
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
// Remove spinner if present
const hist = document.getElementById('cmp-history-' + paneIdx);
if (hist) {
const lastAi = hist.querySelector('.msg-ai:last-child');
if (lastAi && lastAi._spinner) { lastAi._spinner.destroy(); lastAi._spinner = null; }
const body = lastAi && lastAi.querySelector('.body');
if (body && !body.textContent.trim()) {
body.innerHTML = '<span style="opacity:0.4;font-style:italic;">Stopped</span>';
}
}
}
async function rerollPane(paneIdx, overrideTimeout) {
// Allow reroll even while other panes stream — just stop this pane first
if (state._abortControllers[paneIdx]) stopPane(paneIdx);
const hist = document.getElementById('cmp-history-' + paneIdx);
// Reset preview state
const _ri = document.getElementById('cmp-iframe-' + paneIdx);
if (_ri) { _ri.srcdoc = ''; _ri.style.display = 'none'; _ri._htmlCode = null; }
const _rp = document.getElementById('cmp-preview-' + paneIdx);
if (_rp) { _rp.style.display = 'none'; _rp.classList.remove('active'); }
if (hist) hist.style.display = '';
if (!hist) return;
const userBodies = hist.querySelectorAll('.msg-user .body');
const firstUserText = userBodies.length > 0 ? userBodies[0].textContent : '';
if (!firstUserText) return;
// Clear all messages and start fresh
hist.innerHTML = '';
const userMsg = document.createElement('div');
userMsg.className = 'msg msg-user';
userMsg.innerHTML = '<div class="role">You</div><div class="body">' + escapeHtml(firstUserText) + '</div>';
hist.appendChild(userMsg);
// Reset badge and timer
const badge = document.getElementById('cmp-badge-' + paneIdx);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const timer = document.getElementById('cmp-timer-' + paneIdx);
if (timer) timer.textContent = '';
// Search mode: re-query the search provider
if (state._compareMode === 'search') {
const aiMsg = document.createElement('div');
aiMsg.className = 'msg msg-ai';
aiMsg.innerHTML = '<div class="role">Search</div><div class="body"></div>';
const aiBody = aiMsg.querySelector('.body');
if (spinnerModule) {
const spinner = spinnerModule.create('Searching...', 'right');
aiBody.appendChild(spinner.createElement());
spinner.start();
}
hist.appendChild(aiMsg);
hist.scrollTop = hist.scrollHeight;
const m = state._selectedModels[paneIdx];
const fd = new FormData();
fd.append('query', firstUserText);
fd.append('provider', m.model);
fd.append('count', '10');
try {
const ac = new AbortController();
state._abortControllers[paneIdx] = ac;
const t0 = performance.now();
const res = await fetch(`${state.API_BASE}/api/search/query`, { method: 'POST', body: fd, signal: ac.signal });
const data = await res.json();
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
aiBody.innerHTML = '';
if (data.error) {
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(data.error) + '</div>';
} else if (!data.results || data.results.length === 0) {
aiBody.innerHTML = '<div style="color:color-mix(in srgb, var(--fg) 50%, transparent);font-size:0.85em;font-style:italic;">No results found</div>';
} else {
aiBody.appendChild(_renderSearchResults(data));
}
const footer = document.createElement('div');
footer.className = 'msg-footer';
const span = document.createElement('span');
span.className = 'response-metrics';
const parts = [];
if (data.results) parts.push(data.results.length + ' results');
parts.push(elapsed + 's');
span.textContent = parts.join(' | ');
footer.appendChild(span);
aiMsg.appendChild(footer);
} catch (err) {
aiBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Error: ' + escapeHtml(err.message) + '</div>';
}
state._abortControllers[paneIdx] = null;
hist.scrollTop = hist.scrollHeight;
return;
}
// Chat/agent mode: stream via session
const aiMsg = document.createElement('div');
aiMsg.className = 'msg msg-ai';
aiMsg.innerHTML = '<div class="role">AI</div><div class="body"></div>';
const aiBody = aiMsg.querySelector('.body');
if (spinnerModule) {
const label = overrideTimeout ? 'Retrying (' + overrideTimeout + 's)...' : 'Re-rolling...';
const spinner = spinnerModule.create(label, 'right');
aiBody.appendChild(spinner.createElement());
spinner.start();
aiMsg._spinner = spinner;
}
hist.appendChild(aiMsg);
hist.scrollTop = hist.scrollHeight;
const opts = { skipBadge: true };
if (overrideTimeout) opts.timeout = overrideTimeout;
await _streamToPane(paneIdx, state._paneSessionIds[paneIdx], firstUserText, aiMsg, opts);
}
// ── Expand / preview / copy ──
function toggleExpandPane(paneIdx, btn) {
const grid = document.querySelector('.compare-grid');
if (!grid) return;
const panes = grid.querySelectorAll('.compare-pane');
const target = panes[paneIdx];
if (!target) return;
if (target.classList.contains('expanded')) {
target.classList.remove('expanded');
panes.forEach(p => { p.style.display = ''; });
if (btn) btn.innerHTML = ICON_EXPAND;
} else {
target.classList.add('expanded');
panes.forEach((p, i) => { if (i !== paneIdx) p.style.display = 'none'; });
if (btn) btn.innerHTML = ICON_COLLAPSE;
}
}
/**
* After streaming finishes, check for HTML code in the response.
* If found, show the play button in the header. User clicks to run.
*/
function _autoPreviewHtml(paneIdx, accumulated) {
if (!accumulated) return;
const htmlCode = _extractHtmlFromText(accumulated);
if (!htmlCode) return;
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
const previewBtn = document.getElementById('cmp-preview-' + paneIdx);
if (!iframe || !previewBtn) return;
// Store the HTML on the iframe for when user clicks play
iframe._htmlCode = htmlCode;
// Show the play button
previewBtn.style.display = '';
previewBtn.innerHTML = ICON_PLAY;
previewBtn.title = 'Run preview';
}
/** Toggle between iframe preview and code view for a pane. */
function togglePanePreview(paneIdx) {
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
const hist = document.getElementById('cmp-history-' + paneIdx);
const btn = document.getElementById('cmp-preview-' + paneIdx);
if (!iframe || !hist || !btn) return;
const showingPreview = iframe.style.display !== 'none';
if (showingPreview) {
// Switch to code view
iframe.style.display = 'none';
hist.style.display = '';
btn.innerHTML = ICON_PLAY;
btn.title = 'Run preview';
btn.classList.remove('active');
} else {
// Switch to preview — load on first click
if (iframe._htmlCode) iframe.srcdoc = iframe._htmlCode;
iframe.style.display = '';
hist.style.display = 'none';
btn.innerHTML = ICON_CODE;
btn.title = 'Show code';
btn.classList.add('active');
}
}
/** Extract full HTML document from raw accumulated text. */
function _extractHtmlFromText(text) {
// 1. Try markdown code fences
const fenceRe = /`{3,}(?:html)?\s*\r?\n([\s\S]*?)`{3,}/gi;
let match;
while ((match = fenceRe.exec(text)) !== null) {
const code = match[1].trim();
if (/<!doctype\s+html|<html[\s>]/i.test(code)) return code;
}
// 2. Bare HTML
const bare = text.match(/(<!doctype\s+html[\s\S]*<\/html>)/i)
|| text.match(/(<html[\s>][\s\S]*<\/html>)/i);
if (bare) return bare[1].trim();
return null;
}
async function copyPaneResponse(paneIdx) {
const hist = document.getElementById('cmp-history-' + paneIdx);
if (!hist) return;
const aiMsgs = hist.querySelectorAll('.msg-ai');
if (aiMsgs.length === 0) return;
const lastAi = aiMsgs[aiMsgs.length - 1];
// For image panes, copy the prompt text
const text = lastAi._imageData ? (lastAi._imageData.prompt || '') : (lastAi.querySelector('.body')?.textContent || '');
try { await navigator.clipboard.writeText(text); }
catch (e) {
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); ta.remove();
}
if (uiModule) uiModule.showToast(lastAi._imageData ? 'Prompt copied!' : 'Copied!');
}
// ── Add / create / remove panes ──
/** Show a model picker dropdown anchored to the "+" button in the pane header. */
async function _addPane(anchorBtn) {
if (state._streaming) return;
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
if (!filtered.length) return;
// Toggle existing dropdown
const existing = document.querySelector('.add-pane-dropdown');
if (existing) { existing.remove(); return; }
const dropdown = document.createElement('div');
dropdown.className = 'add-pane-dropdown';
// Search input for large model lists
if (filtered.length >= 5) {
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search models\u2026';
searchInput.className = 'add-pane-search';
searchInput.addEventListener('input', () => {
const q = searchInput.value.toLowerCase().trim();
dropdown.querySelectorAll('.pane-model-item').forEach(item => {
item.style.display = item.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
searchInput.addEventListener('click', (e) => e.stopPropagation());
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const first = dropdown.querySelector('.pane-model-item:not([style*="display: none"])');
if (first) first.click();
}
});
dropdown.appendChild(searchInput);
// Desktop: auto-focus the search box so the user can start typing.
// Mobile: skip — auto-focus pops the on-screen keyboard and covers
// the model list. The user can tap the search box if they want to
// filter, otherwise they just tap a model directly.
if (window.innerWidth > 768) setTimeout(() => searchInput.focus(), 0);
}
filtered.forEach(m => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pane-model-item';
const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
item.textContent = label;
const alreadyUsed = state._selectedModels.some(s => s.model === m.id && s.endpointId === m.endpointId);
if (alreadyUsed) item.classList.add('current');
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
await _createAndAppendPane(m);
});
dropdown.appendChild(item);
});
// Position dropdown relative to the viewport (position: fixed) so it
// can't end up off-screen even when the toolbar has scrolled or the
// chat-container is wider than the viewport.
const btnRect = anchorBtn.getBoundingClientRect();
dropdown.style.position = 'fixed';
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
// Render off-screen first so we can measure the dropdown's actual size.
// Clamp the width to the viewport up front so long model names can't push
// the dropdown off the screen edge, and lift z-index above the panes.
dropdown.style.left = '-9999px';
dropdown.style.top = '0';
dropdown.style.maxWidth = (vw - margin * 2) + 'px';
dropdown.style.zIndex = '100000';
document.body.appendChild(dropdown);
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].
let left = btnRect.right - ddW;
if (left + ddW > vw - margin) left = vw - margin - ddW;
if (left < margin) left = margin;
// 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) {
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
} else {
top = Math.max(margin, btnRect.top - 4 - ddH);
}
dropdown.style.left = left + 'px';
dropdown.style.top = top + 'px';
dropdown.style.right = 'auto';
dropdown.style.bottom = 'auto';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
/** Create a new pane for the given model and append it to the compare grid. */
async function _createAndAppendPane(m) {
const i = state._selectedModels.length; // New index
// Create session
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
fd.append('endpoint_id', m.endpointId);
fd.append('skip_validation', 'true');
}
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
if (!res.ok) return;
const data = await res.json();
// 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._paneMetrics.push(null);
state._abortControllers.push(null);
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
// Build pane DOM
const label = state._blindMode ? 'Model ' + _slotChar(i) : m.name;
const pane = document.createElement('div');
pane.className = 'compare-pane';
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">&#x25BE;</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>' +
'<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>' +
'</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>' +
'<div class="pane-vote-footer">' +
'<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' +
'<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
'</button>' +
'</div>';
// Append to grid
const grid = document.querySelector('.compare-grid');
grid.appendChild(pane);
// Update grid columns
const n = state._selectedModels.length;
grid.dataset.cols = String(Math.min(n, 4));
// Update header label
const headerSpan = document.querySelector('.compare-active > div:first-child span');
if (headerSpan) {
const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
headerSpan.textContent = 'Comparing' + modeLabel +
(state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout';
}
// 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. */
function _removePane(paneIdx) {
if (state._streaming) return;
// Abort if streaming
if (state._abortControllers[paneIdx]) state._abortControllers[paneIdx].abort();
// Delete the session
const sid = state._paneSessionIds[paneIdx];
if (sid) {
fetch(`${state.API_BASE}/api/session/${sid}`, { method: 'DELETE' }).catch(() => {});
}
// Remove from arrays
state._selectedModels.splice(paneIdx, 1);
state._paneSessionIds.splice(paneIdx, 1);
state._paneMetrics.splice(paneIdx, 1);
state._abortControllers.splice(paneIdx, 1);
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
// If no panes left, exit compare mode
if (state._selectedModels.length === 0) {
if (_deactivate) _deactivate(true);
return;
}
// Rebuild pane DOM — re-index all panes so IDs stay consistent
const grid = document.querySelector('.compare-grid');
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
const n = state._selectedModels.length;
for (let i = 0; i < n; i++) {
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
const pane = document.createElement('div');
pane.className = 'compare-pane';
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">&#x25BE;</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-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>' +
'</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>' +
'<div class="pane-vote-footer">' +
'<button class="pane-vote-btn" data-pane="' + i + '" type="button" disabled style="opacity:0.4;">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:-2px;"><polyline points="20 6 9 17 4 12"/></svg>' +
'<span class="pane-vote-label">Vote ' + escapeHtml(label) + '</span>' +
'</button>' +
'</div>';
grid.appendChild(pane);
}
// Update grid columns
grid.dataset.cols = String(Math.min(n, 4));
// Update header label
const headerSpan = document.querySelector('.compare-active > div:first-child span');
if (headerSpan) {
const modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
headerSpan.textContent = 'Comparing' + modeLabel +
(state._blindMode ? ' (blind)' : '') + ' \u00b7 ' + state._timeout + 's timeout';
}
// Rebuild vote bar
buildVoteBar(n);
}
/** 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;
// Remove any existing dropdown
const existing = document.querySelector('.pane-model-dropdown');
if (existing) { existing.remove(); return; }
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
if (filtered.length === 0) return;
const dropdown = document.createElement('div');
dropdown.className = 'pane-model-dropdown';
filtered.forEach(m => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'pane-model-item';
const label = m.endpointName ? m.name + ' (' + m.endpointName + ')' : m.name;
item.textContent = label;
// Highlight current model
if (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model === m.id
&& state._selectedModels[paneIdx].endpointId === m.endpointId) {
item.classList.add('current');
}
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
// 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();
fd.append('name', '[CMP] ' + m.name);
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
fd.append('endpoint_id', m.endpointId);
fd.append('skip_validation', 'true');
}
try {
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
const data = await res.json();
state._paneSessionIds[paneIdx] = data.id;
} catch (err) {
console.error('Failed to create session for swapped model:', err);
}
// Update title display
const titleEl = document.getElementById('cmp-title-' + paneIdx);
if (titleEl) {
const displayName = state._blindMode
? 'Model ' + _slotChar(paneIdx)
: m.name;
titleEl.innerHTML = escapeHtml(displayName) + ' <span class="pane-title-caret">&#x25BE;</span>';
}
// Clear pane history for fresh start
const hist = document.getElementById('cmp-history-' + paneIdx);
if (hist) { hist.innerHTML = ''; hist.style.display = ''; }
const iframe = document.getElementById('cmp-iframe-' + paneIdx);
if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; }
const previewBtn = document.getElementById('cmp-preview-' + paneIdx);
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
const badge = document.getElementById('cmp-badge-' + paneIdx);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
});
dropdown.appendChild(item);
});
// Position relative to the viewport (fixed) and append to document.body so
// the dropdown can't be clipped by the narrow pane's overflow or run off the
// screen edge on mobile (matches the "+" add-pane picker behaviour).
const rect = titleBtn.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight, margin = 8;
dropdown.style.position = 'fixed';
dropdown.style.zIndex = '100000';
dropdown.style.maxWidth = (vw - margin * 2) + 'px';
dropdown.style.overflowY = 'auto';
dropdown.style.left = '-9999px';
dropdown.style.top = '0';
document.body.appendChild(dropdown);
const ddRect = dropdown.getBoundingClientRect();
const ddW = ddRect.width, ddH = ddRect.height;
let left = rect.left;
if (left + ddW > vw - margin) left = vw - margin - ddW;
if (left < margin) left = margin;
const spaceBelow = vh - rect.bottom, spaceAbove = rect.top;
let top;
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
top = Math.min(rect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
} else {
top = Math.max(margin, rect.top - 4 - ddH);
}
dropdown.style.left = left + 'px';
dropdown.style.top = top + 'px';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// ── Shuffle / reset ──
function shufflePanePositions() {
if (state._streaming) return;
// Remove shuffle prompt bubble if present
const shuffleBtn = document.getElementById('compare-shuffle-btn');
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
const n = state._selectedModels.length;
if (n < 2) return;
// Fisher-Yates shuffle to get new order
const indices = Array.from({ length: n }, (_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
// Reorder internal state
const newModels = indices.map(i => state._selectedModels[i]);
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
const newMetrics = indices.map(i => state._paneMetrics[i]);
// Collect pane contents (HTML) before swapping
const paneContents = [];
const paneClasses = [];
for (let i = 0; i < n; i++) {
const hist = document.getElementById('cmp-history-' + i);
paneContents.push(hist ? hist.innerHTML : '');
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
paneClasses.push(pane ? { winner: pane.classList.contains('winner'), loser: pane.classList.contains('loser') } : {});
}
// Apply shuffled state
state._selectedModels = newModels;
state._paneSessionIds = newSessionIds;
state._paneMetrics = newMetrics;
// Spin the shuffle button dice icon
const shuffleBtn2 = document.getElementById('compare-shuffle-btn');
if (shuffleBtn2) {
const diceSvg = shuffleBtn2.querySelector('svg');
if (diceSvg) {
diceSvg.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
diceSvg.style.transform = 'rotate(360deg)';
setTimeout(() => { diceSvg.style.transition = ''; diceSvg.style.transform = ''; }, 400);
}
}
// Shake panes and flash titles
for (let i = 0; i < n; i++) {
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
if (pane) {
pane.style.animation = 'pane-shake 0.3s ease';
pane.addEventListener('animationend', () => { pane.style.animation = ''; }, { once: true });
}
const titleEl = document.getElementById('cmp-title-' + i);
if (titleEl) {
titleEl.style.transition = 'opacity 0.12s ease, transform 0.12s ease';
titleEl.style.opacity = '0.3';
titleEl.style.transform = 'scale(0.9)';
titleEl.innerHTML = '?';
}
const hist = document.getElementById('cmp-history-' + i);
if (hist) {
hist.style.transition = 'opacity 0.15s ease';
hist.style.opacity = '0';
}
}
setTimeout(() => {
for (let i = 0; i < n; i++) {
const hist = document.getElementById('cmp-history-' + i);
const pane = document.querySelector(`.compare-pane[data-pane="${i}"]`);
const titleEl = document.getElementById('cmp-title-' + i);
const badge = document.getElementById('cmp-badge-' + i);
const src = indices[i];
if (hist) hist.innerHTML = paneContents[src];
if (titleEl) {
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">&#x25BE;</span>';
titleEl.style.transition = 'opacity 0.25s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)';
titleEl.style.opacity = '1';
titleEl.style.transform = 'scale(1)';
}
if (badge) { badge.textContent = ''; badge.style.color = ''; }
if (pane) {
pane.classList.toggle('winner', !!paneClasses[src].winner);
pane.classList.toggle('loser', !!paneClasses[src].loser);
}
if (hist) {
hist.style.transition = 'opacity 0.25s ease';
hist.style.opacity = '1';
}
}
}, 200);
// Re-enable blind mode after shuffle
state._blindMode = true;
// Rebuild vote bar with new labels
setTimeout(() => buildVoteBar(n), 250);
}
function resetCompare() {
if (state._streaming) stopAll();
const n = state._selectedModels.length;
// Clear last prompt so vote buttons are disabled until next prompt
state._lastPrompt = '';
// Reset finish badges, titles, winner/loser state
state._finishOrder = 0;
state._paneMetrics = new Array(n).fill(null);
const panes = document.querySelectorAll('.compare-pane');
for (let i = 0; i < n; i++) {
const badge = document.getElementById('cmp-badge-' + i);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
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">&#x25BE;</span>';
}
if (panes[i]) { panes[i].classList.remove('winner', 'loser'); }
// Clear all messages from pane history
const hist = document.getElementById('cmp-history-' + i);
if (hist) { hist.innerHTML = ''; hist.style.display = ''; }
// Reset iframe preview
const iframe = document.getElementById('cmp-iframe-' + i);
if (iframe) { iframe.srcdoc = ''; iframe.style.display = 'none'; iframe._htmlCode = null; }
const previewBtn = document.getElementById('cmp-preview-' + i);
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
}
// Re-enable vote bar
buildVoteBar(n);
// Focus input for next prompt
const ta = document.getElementById('message');
if (ta) ta.focus();
}
export {
registerPaneActions,
stopAll,
stopPane,
rerollPane,
toggleExpandPane,
togglePanePreview,
_autoPreviewHtml,
_extractHtmlFromText,
copyPaneResponse,
_addPane,
_createAndAppendPane,
_removePane,
_showModelSwapDropdown,
shufflePanePositions,
resetCompare,
};

View File

@@ -0,0 +1,78 @@
// compare/probe.js — model probe/check system
import state from './state.js';
import { WAVE_FRAMES } from './icons.js';
import uiModule from '../ui.js';
import spinnerModule from '../spinner.js';
function _clearProbeWaves() {
const rows = document.querySelectorAll('.compare-probe-row');
rows.forEach(r => { if (r._waveInterval) { clearInterval(r._waveInterval); r._waveInterval = null; } });
}
async function _checkUnprobed() {
const unprobed = state._selectedModels.filter(m => !state._probed.has(m.model));
if (unprobed.length === 0) {
if (uiModule) uiModule.showToast('All models verified');
return;
}
// Whirlpool loader on the Probe button while the check runs.
const _btn = document.getElementById('compare-check-btn');
let _btnHTML = null, _wp = null;
if (_btn) {
_btnHTML = _btn.innerHTML;
_btn.disabled = true;
_btn.style.opacity = '0.7';
try {
_wp = spinnerModule.createWhirlpool(14);
_btn.innerHTML = '';
_btn.appendChild(_wp.element);
} catch (_) { /* spinner best-effort */ }
}
// Quick inline probe — show toast with results
const isBlind = state._blindMode;
let ok = 0, fail = 0;
try {
for (const m of unprobed) {
try {
const _imageModelPrefixes = ['dall-e', 'gpt-image', 'chatgpt-image', 'stable-diffusion', 'sdxl', 'flux', 'midjourney'];
if (_imageModelPrefixes.some(p => m.model.toLowerCase().includes(p))) {
state._probed.add(m.model);
ok++;
continue;
}
const res = await fetch(`${state.API_BASE}/api/probe-selected`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ models: [{ endpoint_id: m.endpointId || '', model: m.model, endpoint: m.endpoint || '' }] }),
});
const data = await res.json();
const result = (data.results || [])[0];
if (result && result.status === 'ok') {
state._probed.add(m.model);
ok++;
} else {
fail++;
const name = isBlind ? 'a model' : (m.name || m.model.split('/').pop());
if (uiModule) uiModule.showToast(`${name} failed: ${result?.error || 'unknown'}`, 5000);
}
} catch (e) {
fail++;
}
}
if (fail === 0) {
if (uiModule) uiModule.showToast(`${ok} model${ok > 1 ? 's' : ''} verified`);
}
} finally {
// Restore the Probe button (its label/visibility is refreshed below).
if (_btn) {
_btn.disabled = false;
_btn.style.opacity = '';
if (_btnHTML !== null) _btn.innerHTML = _btnHTML;
}
if (window._updateCheckBtnState) window._updateCheckBtnState();
}
}
export { _clearProbeWaves, _checkUnprobed };

View File

@@ -0,0 +1,223 @@
// 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';
const escapeHtml = uiModule.esc;
// Type icons for the mode tabs — match the Compare selector's tab icons.
const _TYPE_ICONS = {
chat: '<svg width="15" height="15" 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>',
agent: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
search: '<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="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
research: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
};
/** Detect search provider names to fix legacy votes without mode. */
const _searchProviderNames = new Set(['brave search', 'duckduckgo', 'google', 'searxng', 'bing', 'tavily']);
/** Guess the compare mode for a vote record (legacy votes lack a mode field). */
function _guessVoteMode(v) {
if (v.mode) return v.mode;
// Legacy vote — check if models look like search providers
if (v.models && v.models.some(m => _searchProviderNames.has(m.toLowerCase()))) return 'search';
return 'chat';
}
export function showScoreboard() {
// Remove existing overlay if present
const existing = document.getElementById('scoreboard-overlay');
if (existing) existing.remove();
const votes = Storage.getJSON(VOTES_STORAGE_KEY, []);
// Build modal
const overlay = document.createElement('div');
overlay.id = 'scoreboard-overlay';
overlay.className = 'modal';
overlay.style.zIndex = '10001';
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Esc handling lives in the global "close topmost popup" handler (app.js)
// so the scoreboard closes first without also dismissing the compare
// window beneath it.
const content = document.createElement('div');
content.className = 'modal-content';
content.style.maxWidth = '520px';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
title.innerHTML = '<svg width="16" height="16" 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;"><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';
title.style.margin = '0';
const closeX = document.createElement('button');
closeX.className = 'close-btn';
closeX.innerHTML = '&#x2716;';
closeX.addEventListener('click', () => overlay.remove());
header.appendChild(title);
header.appendChild(closeX);
content.appendChild(header);
const body = document.createElement('div');
body.className = 'modal-body';
body.style.padding = '12px 16px';
// Mobile: add bottom padding so the Clear History button isn't hidden behind
// Firefox's bottom URL bar / the home-indicator safe area.
if (window.innerWidth <= 768) {
body.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + 72px)';
body.style.overflowY = 'auto';
}
// Mode tabs
const modes = ['chat', 'agent', 'search', 'research'];
const modeLabels = { chat: 'Chat', agent: 'Agent', search: 'Search', research: 'Research' };
const tabBar = document.createElement('div');
tabBar.className = 'compare-mode-tabs';
tabBar.style.marginBottom = '12px';
let activeMode = 'chat';
function renderScoreTable() {
// Clear previous table
const prev = body.querySelector('.scoreboard-wrap');
if (prev) {
// The Clear button was moved INTO the wrap on a prior render — rescue it
// back to the body before removing the wrap, otherwise it's destroyed
// with the wrap and never re-found (it vanished after visiting an empty
// mode like Images and switching back).
const clr = prev.querySelector('.scoreboard-clear-btn');
if (clr) body.appendChild(clr);
prev.remove();
}
const wrap = document.createElement('div');
wrap.className = 'scoreboard-wrap';
const filtered = votes.filter(v => _guessVoteMode(v) === activeMode);
// Aggregate
const stats = {};
for (const v of filtered) {
for (let mi = 0; mi < v.models.length; mi++) {
const m = v.models[mi];
if (!stats[m]) stats[m] = { wins: 0, losses: 0, ties: 0, games: 0, totalCost: 0, costCount: 0 };
stats[m].games++;
if (v.winner === 'tie') stats[m].ties++;
else if (v.winner === m) stats[m].wins++;
else stats[m].losses++;
if (v.costs && v.costs[mi] != null) {
stats[m].totalCost += v.costs[mi];
stats[m].costCount++;
}
}
}
const sorted = Object.entries(stats).sort((a, b) => {
const rateA = a[1].games ? a[1].wins / a[1].games : 0;
const rateB = b[1].games ? b[1].wins / b[1].games : 0;
return rateB - rateA;
});
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.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!';
wrap.appendChild(empty);
} else {
const table = document.createElement('table');
table.className = 'scoreboard-table';
const thead = document.createElement('thead');
thead.innerHTML = '<tr><th>Model</th><th>Win%</th><th>W</th><th>L</th><th>T</th><th>Games</th><th>$/1k</th></tr>';
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const [name, s] of sorted) {
const pct = s.games ? Math.round((s.wins / s.games) * 100) : 0;
const avgCost = s.costCount ? (s.totalCost / s.costCount) * 1000 : null;
const costStr = avgCost !== null ? ('$' + (avgCost < 1 ? avgCost.toFixed(2) : avgCost.toFixed(0))) : '—';
const tr = document.createElement('tr');
tr.innerHTML =
'<td class="scoreboard-model">' + escapeHtml(name) + '</td>' +
'<td class="scoreboard-pct"><strong>' + pct + '%</strong></td>' +
'<td>' + s.wins + '</td><td>' + s.losses + '</td><td>' + s.ties + '</td>' +
'<td>' + s.games + '</td>' +
'<td style="color:var(--color-success, #4caf50);" title="Avg estimated cost per 1,000 responses">' + costStr + '</td>';
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrap.appendChild(table);
}
const total = document.createElement('div');
total.style.cssText = 'font-size:0.8em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:12px;text-align:center;';
total.textContent = filtered.length + ' vote' + (filtered.length !== 1 ? 's' : '') + ' recorded';
wrap.appendChild(total);
// Move clear button into wrap so it stays at bottom
const existingClear = body.querySelector('.scoreboard-clear-btn');
if (existingClear) wrap.appendChild(existingClear);
body.appendChild(wrap);
}
modes.forEach(mode => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'compare-mode-tab' + (mode === activeMode ? ' active' : '');
tab.innerHTML = (_TYPE_ICONS[mode] || '') + '<span class="compare-toggle-label">' + modeLabels[mode] + '</span>';
tab.addEventListener('click', () => {
activeMode = mode;
tabBar.querySelectorAll('.compare-mode-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderScoreTable();
});
tabBar.appendChild(tab);
});
body.appendChild(tabBar);
renderScoreTable();
// Clear history button
const clearBtn = document.createElement('button');
clearBtn.className = 'scoreboard-clear-btn';
clearBtn.textContent = 'Clear History';
clearBtn.style.cssText = 'display:block;margin:16px 0 4px auto;padding:4px 12px;background:none;border:1px solid var(--border);color:var(--fg);border-radius:4px;cursor:pointer;font-size:11px;opacity:0.4;transition:opacity 0.15s;';
clearBtn.addEventListener('mouseenter', () => { clearBtn.style.opacity = '1'; });
clearBtn.addEventListener('mouseleave', () => { clearBtn.style.opacity = '0.6'; });
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);';
const confirmLabel = document.createElement('span');
confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;';
confirmLabel.textContent = 'Clear all vote history?';
const yesBtn = document.createElement('button');
yesBtn.textContent = 'Clear';
yesBtn.style.cssText = 'padding:4px 12px;background:var(--red);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600;';
yesBtn.addEventListener('click', () => {
Storage.setJSON(VOTES_STORAGE_KEY, []);
overlay.remove();
showScoreboard();
});
const noBtn = document.createElement('button');
noBtn.textContent = 'Cancel';
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);
// Replace button with confirmation
clearBtn.style.display = 'none';
clearBtn.parentElement.appendChild(confirmRow);
});
body.appendChild(clearBtn);
content.appendChild(body);
overlay.appendChild(content);
document.body.appendChild(overlay);
if (themeModule && themeModule.makeDraggable) {
themeModule.makeDraggable(content, header);
}
}
export default { showScoreboard };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
// compare/state.js — shared mutable state for compare modules
const state = {
API_BASE: '',
isActive: false,
_streaming: false,
_blindMode: true,
_saveOnClose: false,
_continueChat: false,
_timeout: 300, // seconds
_finishOrder: 0,
_paneElapsed: [], // per-pane total ms; populated on finish so the
// Fastest badge can be awarded by actual time
// (sequential mode otherwise always picks pane 1)
_selectedModels: [], // [{model, endpoint, endpointId, name}, ...]
_paneSessionIds: [], // session IDs for each pane
_paneMetrics: [], // metrics per pane from last round
_abortControllers: [], // per-pane abort controllers
_sidebarWasHidden: false,
_compareElements: [], // elements we added to container (for cleanup)
_savedToggles: null, // tool toggle states saved before compare
_savedIndicatorDisplay: {}, // display state of toolbar indicators before compare
_savedMode: 'chat', // agent/chat mode saved before compare
_hasVisibleResults: false, // compare results still on screen after close
_compareMode: 'chat', // 'chat', 'agent', 'search', or 'research'
_lastPrompt: '', // last prompt sent (for rematch)
_cachedModels: [], // cached model list for pane dropdowns
_probed: new Set(), // model IDs that have been successfully probed
_cachedProviders: null, // cached search providers for search mode
_searchSynthModels: null, // per-pane synthesis models for search mode
_parallel: true, // true = run all panes at once, false = one at a time
_fetchModelsCache: null,
_fetchModelsCacheTime: 0,
_expectedAnswer: '', // when an eval prompt with `answer` is picked,
// stream.js reads this and stamps ✓/✗ per pane
};
/** Reset transient state to defaults — useful for clean restarts. */
export function reset() {
state._streaming = false;
state._finishOrder = 0;
state._paneElapsed = [];
state._abortControllers.forEach(c => { if (c) c.abort(); });
state._abortControllers = [];
state._paneSessionIds = [];
state._paneMetrics = [];
state._compareElements = [];
state._hasVisibleResults = false;
state._lastPrompt = '';
state._cachedModels = [];
state._probed = new Set();
state._cachedProviders = null;
state._fetchModelsCache = null;
state._fetchModelsCacheTime = 0;
}
export default state;

695
static/js/compare/stream.js Normal file
View File

@@ -0,0 +1,695 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost } from '../chatRenderer.js';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
import presetsModule from '../presets.js';
var escapeHtml = uiModule.esc;
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
/** Register external functions that live in compare.js. */
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
_rerollPane = rerollPane;
_autoPreviewHtml = autoPreviewHtml;
}
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
function _formatMs(ms) {
if (ms < 1000) return Math.round(ms) + 'ms';
if (ms < 10000) return (ms / 1000).toFixed(2) + 's';
return (ms / 1000).toFixed(1) + 's';
}
/** Build a DOM container of search-result cards from a search response. Returns an HTMLElement. */
function _renderSearchResults(data) {
const container = document.createElement('div');
container.className = 'compare-search-results';
(data.results || []).forEach(r => {
const card = document.createElement('div');
card.className = 'compare-search-result';
const titleLink = document.createElement('a');
titleLink.href = r.url || '#';
titleLink.target = '_blank';
titleLink.rel = 'noopener';
titleLink.className = 'search-result-title';
titleLink.textContent = r.title || 'Untitled';
card.appendChild(titleLink);
if (r.snippet) {
const s = document.createElement('div');
s.className = 'search-result-snippet';
s.textContent = r.snippet;
card.appendChild(s);
}
if (r.url) {
const u = document.createElement('div');
u.className = 'search-result-url';
u.textContent = r.url;
card.appendChild(u);
}
container.appendChild(card);
});
return container;
}
/** Run synthesis for a search pane — sends search results to an LLM for analysis. */
async function _runSynthForPane(modelToUse, synthPrompt, synthBody, spinner, hist) {
// Create temp session for synthesis
const fd = new FormData();
fd.append('name', 'Synthesis');
fd.append('endpoint_url', modelToUse.endpoint || '');
fd.append('model', modelToUse.model || '');
if (modelToUse.endpointId) {
fd.append('endpoint_id', modelToUse.endpointId);
fd.append('skip_validation', 'true');
}
try {
const createRes = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
if (!createRes.ok) {
const errData = await createRes.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to create session');
}
const createData = await createRes.json();
const synthAc = new AbortController();
state._abortControllers.push(synthAc);
const streamRes = await fetch(`${state.API_BASE}/api/chat_stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: createData.id, message: synthPrompt }),
signal: synthAc.signal,
});
if (spinner) spinner.stop();
synthBody.innerHTML = '';
const reader = streamRes.body.getReader();
const decoder = new TextDecoder();
let synthText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
try {
const d = JSON.parse(line.slice(6));
if (d.delta) {
synthText += d.delta;
if (markdownModule && synthText.trim()) {
synthBody.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(synthText)
);
} else {
synthBody.textContent = synthText;
}
hist.scrollTop = hist.scrollHeight;
}
} catch (e) {}
}
}
}
// Final highlight
if (window.hljs) synthBody.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
// Cleanup temp session
fetch(`${state.API_BASE}/api/session/${createData.id}`, { method: 'DELETE' }).catch(() => {});
} catch (e) {
if (spinner) spinner.stop();
synthBody.innerHTML = '<div style="color:var(--color-error);font-size:0.85em;">Synthesis failed: ' + escapeHtml(e.message) + '</div>';
}
}
/** Stream an SSE response into a compare pane. Handles text, tool blocks, images, metrics. */
async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
opts = opts || {};
const aiBody = aiMsgEl ? aiMsgEl.querySelector('.body') : null;
const hist = aiMsgEl ? aiMsgEl.parentElement : null;
if (!aiBody) return;
const ac = new AbortController();
state._abortControllers[paneIdx] = ac;
// Show stop button for this pane
const _paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneEl) {
const _stopBtn = _paneEl.querySelector('.pane-stop-btn');
if (_stopBtn) _stopBtn.style.display = '';
}
let accumulated = '';
let metrics = null;
let timedOut = false;
let streamOk = false;
let currentToolBlock = null; // track active agent tool block
// Idle timeout — abort only if no data is received for this many seconds.
// Long generations (SVG, big code) are fine as long as the stream stays
// active. opts.timeout may still tighten this for specific paths.
const effectiveTimeout = opts.timeout || state._timeout;
let timeoutId = setTimeout(() => { timedOut = true; ac.abort(); }, effectiveTimeout * 1000);
const _resetIdleTimeout = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => { timedOut = true; ac.abort(); }, effectiveTimeout * 1000);
};
// Live timer
const _timerStart = performance.now();
let _ttft = 0; // time to first token
let _timerDone = false;
const _timerEl = document.getElementById('cmp-timer-' + paneIdx);
let _rafId = 0;
function _tickTimer() {
if (_timerDone) return;
const elapsed = performance.now() - _timerStart;
if (_timerEl) _timerEl.textContent = _formatMs(elapsed);
_rafId = requestAnimationFrame(_tickTimer);
}
_rafId = requestAnimationFrame(_tickTimer);
// Throttled markdown render — re-rendering the entire growing buffer on
// every token is O(n²) total work. Coalesce updates so we paint at most
// every ~80ms. The final render still runs at end-of-stream for quality.
let _renderPending = false;
let _renderLastAt = 0;
const _RENDER_THROTTLE_MS = 80;
function _scheduleLiveRender(target) {
if (_renderPending) return;
const now = performance.now();
const elapsed = now - _renderLastAt;
const delay = elapsed >= _RENDER_THROTTLE_MS ? 0 : _RENDER_THROTTLE_MS - elapsed;
_renderPending = true;
setTimeout(() => {
_renderPending = false;
_renderLastAt = performance.now();
if (markdownModule && accumulated.trim()) {
target.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(accumulated)
);
} else {
target.textContent = accumulated;
}
if (hist) hist.scrollTop = hist.scrollHeight;
}, delay);
}
try {
const fd = new FormData();
fd.append('message', message);
fd.append('session', sessionId);
// Compare mode determines what tools/features are enabled
const isAgent = state._compareMode === 'agent';
const isResearch = state._compareMode === 'research';
// Agent mode: enable all tools (web, bash, etc.)
if (isAgent) {
fd.append('mode', 'agent');
fd.append('allow_web_search', 'true');
fd.append('allow_bash', 'true');
} else if (isResearch) {
fd.append('use_research', 'true');
} else {
// Chat/Image: pure chat only — no tools, no search, no bash, no RAG.
// Explicitly send mode='chat' so the backend's compare_mode strip
// (chat_routes.py line 385) actually triggers — otherwise the form
// field was missing and chat_mode defaulted to "", which meant
// bash/python/web_search were never added to disabled_tools and
// models would still attempt to run Python.
fd.append('mode', 'chat');
fd.append('use_rag', 'false');
}
const incognitoChk = document.getElementById('incognito-toggle');
if (incognitoChk && incognitoChk.checked) {
fd.append('incognito', 'true');
}
// Disable document tool and memory injection in compare mode
fd.append('no_documents', 'true');
fd.append('no_memory', 'true');
// Tell backend this is compare mode — strip all non-toggled tools
fd.append('compare_mode', 'true');
// Forward preset if selected
if (presetsModule && presetsModule.getSelectedPreset()) {
fd.append('preset_id', presetsModule.getSelectedPreset());
}
const response = await fetch(`${state.API_BASE}/api/chat_stream`, {
method: 'POST', body: fd, signal: ac.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
_resetIdleTimeout(); // any chunk = stream is alive
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const json = JSON.parse(data);
if (json.type === 'metrics') {
metrics = json.data;
// ── Research progress (spinner updates) ──
} else if (json.type === 'research_progress') {
const rp = json.data;
const spinner = aiMsgEl._spinner;
if (spinner) {
if (rp.phase === 'searching') {
const q = rp.queries ? `${rp.queries} queries` : '';
const s = rp.total_sources ? ` · ${rp.total_sources} sources` : '';
spinner.updateMessage(`R${rp.round || '?'}: Searching${q ? ' (' + q + ')' : ''}${s}`);
} else if (rp.phase === 'reading') {
spinner.updateMessage(`R${rp.round || '?'}: Reading ${rp.new_sources || ''} pages`);
} else if (rp.phase === 'analyzing') {
spinner.updateMessage(`R${rp.round || '?'}: Analyzing ${rp.total_findings || 0} findings`);
} else if (rp.phase === 'writing') {
spinner.updateMessage(`Writing report · ${rp.total_sources || 0} sources`);
} else if (rp.phase === 'error') {
spinner.updateMessage(rp.message || 'Research error');
}
}
// ── Research sources / Web sources (compact sources box) ──
} else if (json.type === 'research_sources' || json.type === 'web_sources') {
const sources = json.data || [];
if (sources.length > 0) {
const label = json.type === 'research_sources' ? 'Research' : 'Web';
const box = document.createElement('div');
box.className = 'compare-sources-box';
box.innerHTML = '<span class="sources-label">' + sources.length + ' ' + label + ' sources</span>';
box.title = sources.map(s => s.title || s.url).join('\n');
// Replace spinner with sources + new spinner
aiBody.innerHTML = '';
aiBody.appendChild(box);
if (spinnerModule) {
const newSpinner = spinnerModule.create('Generating response...', 'right');
aiBody.appendChild(newSpinner.createElement());
newSpinner.start();
aiMsgEl._spinner = newSpinner;
}
}
// ── Tool start (bash, web search agent tool) ──
} else if (json.type === 'tool_start') {
// Finalize any accumulated text before the tool block
if (accumulated.trim() && aiMsgEl._textEl) {
if (markdownModule) {
aiMsgEl._textEl.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(accumulated));
if (window.hljs) aiMsgEl._textEl.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
}
// Destroy spinner if still present
if (aiMsgEl._spinner && aiMsgEl._spinner.element) {
aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
// Clean up spinner element but keep sources box + text
const spinnerEl = aiBody.querySelector('.spinner-wrapper, .mini-spinner');
if (spinnerEl) spinnerEl.remove();
}
const toolName = json.tool || 'tool';
const cmd = json.command || '';
// Image generation: show ASCII spinner instead of compact tool block
if (toolName === 'generate_image' && spinnerModule) {
aiBody.innerHTML = '';
const imgSpinner = spinnerModule.create('Generating image...', 'right');
aiBody.appendChild(imgSpinner.createElement());
imgSpinner.start();
aiMsgEl._imgSpinner = imgSpinner;
currentToolBlock = null;
} else {
// Agent thread node — matches main chat style
const _toolLabels = { bash: 'Terminal', python: 'Python', web_search: 'Web Search', read_file: 'Read File', write_file: 'Write File' };
const toolLabel = _toolLabels[toolName.toLowerCase()] || toolName;
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
const node = document.createElement('div');
node.className = 'agent-thread-node running';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
// Animate wave
const waveEl = node.querySelector('.agent-thread-wave');
if (waveEl) {
const waveFrames = WAVE_FRAMES;
let waveIdx = 0;
node._waveInterval = setInterval(() => { waveIdx = (waveIdx + 1) % waveFrames.length; waveEl.textContent = waveFrames[waveIdx]; }, 100);
}
aiBody.appendChild(node);
currentToolBlock = node;
}
if (hist) hist.scrollTop = hist.scrollHeight;
// ── Tool output (image or non-image) ──
} else if (json.type === 'tool_output') {
if (json.image_url) {
// Stop image spinner and render generated image in pane
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
aiBody.innerHTML = '';
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = json.image_url;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(img.src, '_blank'));
aiBody.appendChild(img);
if (json.image_prompt) {
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
caption.textContent = json.image_prompt;
aiBody.appendChild(caption);
}
// Show model name below image (hidden in blind mode until vote)
if (json.image_model && !state._blindMode) {
const modelLabel = document.createElement('div');
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
modelLabel.textContent = json.image_model;
aiBody.appendChild(modelLabel);
}
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
} else if (currentToolBlock) {
// Stop wave animation
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
const ok = (json.exit_code === 0 || json.exit_code == null);
const cmd = json.command || '';
const _toolLabels2 = { bash: 'Terminal', python: 'Python', web_search: 'Web Search', read_file: 'Read File', write_file: 'Write File' };
const tLabel = _toolLabels2[(json.tool || '').toLowerCase()] || json.tool || '';
let outHtml = '';
if (json.output && json.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${escapeHtml(json.output)}</pre></details>`;
}
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>`;
currentToolBlock.querySelector('.agent-thread-header').addEventListener('click', () => currentToolBlock.classList.toggle('open'));
currentToolBlock = null;
// Reset text element so next deltas create a fresh container
aiMsgEl._textEl = null;
accumulated = '';
}
if (hist) hist.scrollTop = hist.scrollHeight;
} else if (json.delta) {
// Skip text deltas if we already rendered an image
if (aiMsgEl._imageData) continue;
// Capture TTFT on very first text delta
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
// On first delta, destroy spinner and prepare text area
if (!accumulated && aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
// Keep sources box if present, clear everything else
const srcBox = aiBody.querySelector('.compare-sources-box');
aiBody.innerHTML = '';
if (srcBox) aiBody.appendChild(srcBox);
// Add text container
const textEl = document.createElement('div');
textEl.className = 'compare-text-content';
aiBody.appendChild(textEl);
aiMsgEl._textEl = textEl;
}
// After a tool block, create a new text container for continuing text
if (!accumulated && !aiMsgEl._textEl) {
const textEl = document.createElement('div');
textEl.className = 'compare-text-content';
aiBody.appendChild(textEl);
aiMsgEl._textEl = textEl;
}
accumulated += json.delta;
const target = aiMsgEl._textEl || aiBody;
_scheduleLiveRender(target);
}
} catch (e) { console.warn('Compare stream render error:', e); }
}
}
streamOk = true;
// Destroy any remaining spinner
if (aiMsgEl._spinner && aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
// Final render
const finalTarget = aiMsgEl._textEl || aiBody;
if (markdownModule && accumulated.trim()) {
finalTarget.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(accumulated)
);
}
if (window.hljs) {
finalTarget.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
// ── Show play button if response contains HTML ──
if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
// Metrics footer
if (aiMsgEl && aiMsgEl._imageData) {
// Image-specific footer with actions + metrics
const imgD = aiMsgEl._imageData;
const footer = document.createElement('div');
footer.className = 'msg-footer';
// Action buttons (copy prompt + download)
const actions = document.createElement('span');
actions.className = 'msg-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'footer-copy-btn';
copyBtn.type = 'button';
copyBtn.title = 'Copy prompt';
copyBtn.textContent = '\u2398';
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const txt = imgD.prompt || '';
if (navigator.clipboard) navigator.clipboard.writeText(txt).catch(() => {});
else { const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
copyBtn.textContent = '\u2713';
setTimeout(() => { copyBtn.textContent = '\u2398'; }, 1500);
if (uiModule) uiModule.showToast('Prompt copied!');
});
actions.appendChild(copyBtn);
const dlBtn = document.createElement('button');
dlBtn.className = 'footer-copy-btn';
dlBtn.type = 'button';
dlBtn.title = 'Download image';
dlBtn.textContent = '\u2913';
dlBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const resp = await fetch(imgD.url);
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (imgD.prompt || 'image').slice(0, 40).replace(/[^a-zA-Z0-9 ]/g, '') + '.png';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
dlBtn.textContent = '\u2713';
setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500);
} catch { dlBtn.textContent = '\u2717'; setTimeout(() => { dlBtn.textContent = '\u2913'; }, 1500); }
});
actions.appendChild(dlBtn);
footer.appendChild(actions);
// Metrics — hide in blind mode to avoid revealing model identity
if (!state._blindMode) {
const span = document.createElement('span');
span.className = 'response-metrics';
const parts = [];
if (imgD.model) parts.push(imgD.model.split('/').pop());
if (imgD.size) parts.push(imgD.size);
if (imgD.quality) parts.push(imgD.quality);
if (metrics && metrics.response_time) parts.push(metrics.response_time + 's');
const costFn = window.chatModule && window.chatModule.getImageCost;
if (costFn) {
const cost = costFn(imgD.model, imgD.quality, imgD.size);
if (cost !== null) parts.push('$' + (cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)));
}
span.textContent = parts.join(' \u00b7 ');
footer.appendChild(span);
}
aiMsgEl.appendChild(footer);
} else if (metrics && aiMsgEl) {
const footer = document.createElement('div');
footer.className = 'msg-footer';
const span = document.createElement('span');
span.className = 'response-metrics';
let text = metrics.output_tokens + ' tokens | ' + metrics.tokens_per_second + ' tok/s';
// 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);
// Build the metrics span with optional cost and context
span.textContent = text;
if (_cost !== null) {
const _cost1k = _cost * 1000;
const costSpan = document.createElement('span');
costSpan.style.color = 'var(--color-success, #4caf50)';
costSpan.title = 'Estimated cost per 1,000 responses like this one';
costSpan.textContent = ' | $' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
span.appendChild(costSpan);
}
if (metrics.context_percent > 0) {
const ctx = document.createElement('span');
ctx.textContent = ' | ' + metrics.context_percent + '% ctx';
if (metrics.context_percent >= 85) ctx.style.color = 'var(--color-error)';
else if (metrics.context_percent >= 70) ctx.style.color = '#ff9900';
span.appendChild(ctx);
}
footer.appendChild(span);
aiMsgEl.appendChild(footer);
}
if (hist) hist.scrollTop = hist.scrollHeight;
} catch (error) {
if (error.name === 'AbortError') {
if (timedOut) {
if (accumulated.trim()) {
if (markdownModule) {
aiBody.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(accumulated));
}
}
const notice = document.createElement('div');
notice.style.cssText = 'color:#ff9800;font-size:0.8em;margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;';
const text = document.createElement('span');
text.style.fontStyle = 'italic';
text.textContent = 'Timed out after ' + effectiveTimeout + 's' + (accumulated.trim() ? ' \u2014 response may be incomplete' : '');
notice.appendChild(text);
const retryBtn = document.createElement('button');
retryBtn.textContent = 'Retry +' + effectiveTimeout + 's';
retryBtn.style.cssText = 'background:rgba(255,152,0,0.15);border:1px solid #ff9800;color:#ff9800;border-radius:4px;cursor:pointer;padding:2px 8px;font-size:0.9em;white-space:nowrap;transition:all 0.15s;';
retryBtn.addEventListener('mouseenter', () => { retryBtn.style.background = 'rgba(255,152,0,0.3)'; });
retryBtn.addEventListener('mouseleave', () => { retryBtn.style.background = 'rgba(255,152,0,0.15)'; });
retryBtn.addEventListener('click', () => { if (_rerollPane) _rerollPane(paneIdx, effectiveTimeout * 2); });
notice.appendChild(retryBtn);
aiBody.appendChild(notice);
} else {
if (!accumulated.trim()) aiBody.innerHTML = '<div style="color:#f0ad4e;font-size:0.9em;">Cancelled.</div>';
}
} else {
console.error('Compare stream error:', error);
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
}
} finally {
clearTimeout(timeoutId);
_timerDone = true;
cancelAnimationFrame(_rafId);
// Show final time with TTFT
const _totalMs = performance.now() - _timerStart;
if (_timerEl) {
// TTFT removed from the header per user request — just show total time.
_timerEl.textContent = _formatMs(_totalMs);
}
state._abortControllers[paneIdx] = null;
// Hide stop button, show response action buttons
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneElFinal) {
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
if (accumulated.trim()) {
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
state._paneMetrics[paneIdx] = metrics;
state._paneElapsed[paneIdx] = _totalMs;
if (!opts.skipBadge) {
if (streamOk) {
state._finishOrder++;
if (state._parallel) {
// Parallel: all panes started at the same instant, so first
// to finish is genuinely the fastest.
if (state._finishOrder === 1) addFinishBadge(paneIdx);
} else {
// Sequential: panes run one after another, so "first to
// finish" is meaningless (it's just whoever ran first).
// Wait until all panes are done, then badge whichever had
// the lowest measured per-pane elapsed time.
const total = state._selectedModels.length;
const finished = state._paneElapsed.filter(v => typeof v === 'number').length;
if (finished >= total) {
let winnerIdx = -1, winnerMs = Infinity;
for (let i = 0; i < total; i++) {
const v = state._paneElapsed[i];
if (typeof v === 'number' && v < winnerMs) { winnerMs = v; winnerIdx = i; }
}
if (winnerIdx >= 0) addFinishBadge(winnerIdx);
}
}
} else {
// Timed out or errored — show failed badge
const badge = document.getElementById('cmp-badge-' + paneIdx);
if (badge) { badge.textContent = timedOut ? 'Timeout' : 'Failed'; badge.style.color = 'var(--color-error)'; }
}
}
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
if (streamOk && state._expectedAnswer) {
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
}
// 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 = '');
}
}
/**
* Auto-grade a pane's response against the eval prompt's expected answer.
* Heuristic: lowercased substring match, plus a number-extraction fallback
* so "the answer is 882" matches expected "882".
* Skips meta answers like "count the words yourself…".
*/
function _stampGradeBadge(paneIdx, response, expected) {
const norm = (s) => String(s).toLowerCase().replace(/\s+/g, ' ').trim();
const r = norm(response);
const e = norm(expected);
if (!r || !e) return;
// Skip non-checkable instructions
if (e.includes('yourself') || e.includes('verify') || e.length > 120) return;
let pass = r.includes(e);
if (!pass) {
// Numeric fallback — find first number in expected, look for it standalone in response
const m = expected.match(/-?\d[\d,]*(?:\.\d+)?/);
if (m) {
const n = m[0].replace(/,/g, '');
const re = new RegExp('(?<![\\d.])' + n.replace('.', '\\.') + '(?![\\d.])');
pass = re.test(response);
}
}
const paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (!paneEl) return;
const header = paneEl.querySelector('.pane-header');
if (!header) return;
// Remove any prior grade badge (re-roll case)
const prev = header.querySelector('.pane-grade-badge');
if (prev) prev.remove();
const badge = document.createElement('span');
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
const finBadge = header.querySelector('.pane-finish-badge');
if (finBadge) header.insertBefore(badge, finBadge);
else header.appendChild(badge);
}
export { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions };

254
static/js/compare/vote.js Normal file
View File

@@ -0,0 +1,254 @@
// compare/vote.js — voting, revealing, confetti
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';
var escapeHtml = uiModule.esc;
// ── Helpers imported lazily to avoid circular deps ──
// stopAll and resetCompare live in compare.js; caller must register them.
let _stopAll = null;
let _resetCompare = null;
/** Register external functions that live in compare.js (avoids circular imports). */
function registerCompareActions({ stopAll, resetCompare }) {
_stopAll = stopAll;
_resetCompare = resetCompare;
}
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
function addFinishBadge(paneIdx) {
const hist = document.getElementById('cmp-history-' + paneIdx);
if (!hist) return;
// Find the last AI message's footer
const lastAi = hist.querySelector('.msg-ai:last-of-type');
const footer = lastAi && lastAi.querySelector('.msg-footer');
if (footer) {
const badge = document.createElement('span');
badge.className = 'pane-finish-badge';
badge.textContent = ' · Fastest';
footer.querySelector('.response-metrics')?.appendChild(badge);
}
}
/** Build vote/action bar. The per-model "vote for this" buttons live
* inside each pane's footer now — this bar carries only the shared
* actions (Tie, Reveal, Reset). */
function buildVoteBar(n) {
const bar = document.getElementById('compare-vote-bar');
if (!bar) return;
bar.classList.remove('hidden');
bar.innerHTML = '';
// Vote buttons are disabled until a prompt has been sent.
const noPrompt = !state._lastPrompt;
// Sync per-pane vote button state to match the prompt-sent / blind-mode
// state — these elements were created when the panes were built, but
// their enabled/labelled state needs to refresh whenever this bar is
// (re)built (e.g. after sending the first prompt or revealing models).
for (let i = 0; i < n; i++) {
const paneBtn = document.querySelector('.compare-pane[data-pane="' + i + '"] .pane-vote-btn');
if (!paneBtn) continue;
paneBtn.disabled = noPrompt;
paneBtn.style.opacity = noPrompt ? '0.4' : '';
const label = state._blindMode
? 'Vote ' + _slotChar(i)
: 'Vote ' + state._selectedModels[i].name;
paneBtn.querySelector('.pane-vote-label').textContent = label;
}
const tieBtn = document.createElement('button');
tieBtn.className = 'compare-vote-btn compare-vote-tie';
tieBtn.textContent = 'Tie';
if (noPrompt) { tieBtn.disabled = true; tieBtn.style.opacity = '0.25'; }
tieBtn.addEventListener('click', () => handleVote(-1));
bar.appendChild(tieBtn);
// Scoreboard button — sits next to Tie. Stays enabled even after a vote (and
// 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.title = 'Scoreboard';
scoreBtn.addEventListener('click', () => showScoreboard());
bar.insertBefore(scoreBtn, tieBtn); // furthest left, before Tie
if (state._blindMode) {
const revealBtn = document.createElement('button');
revealBtn.className = 'compare-vote-btn';
revealBtn.style.opacity = noPrompt ? '0.25' : '0.5';
revealBtn.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;"><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>Reveal';
if (noPrompt) revealBtn.disabled = true;
revealBtn.addEventListener('click', () => handleVote(-2));
bar.appendChild(revealBtn);
}
// Add Model button
// Reset button (always)
const resetBtn = document.createElement('button');
resetBtn.className = 'compare-vote-btn compare-rematch-btn';
resetBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px;"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>Reset';
resetBtn.addEventListener('click', () => { if (_resetCompare) _resetCompare(); });
bar.appendChild(resetBtn);
}
/** Persist a vote record to localStorage and fire-and-forget to backend. */
function _saveVote(winnerIdx) {
const modelNames = _modelDisplayNames(state._selectedModels);
const winner = winnerIdx === -1 ? 'tie' : modelNames[winnerIdx];
// Calculate per-model costs
const costs = state._selectedModels.map((m, i) => {
const pm = state._paneMetrics[i];
if (!pm) return null;
return getModelCost(pm.model || m.model, pm.input_tokens || 0, pm.output_tokens || 0);
});
const record = {
models: modelNames,
winner: winner,
prompt: state._lastPrompt,
blind: state._blindMode,
mode: state._compareMode || 'chat',
timestamp: Date.now(),
costs: costs,
};
// localStorage persistence
const votes = Storage.getJSON(VOTES_STORAGE_KEY, []);
votes.push(record);
if (votes.length > VOTES_MAX) votes.splice(0, votes.length - VOTES_MAX);
Storage.setJSON(VOTES_STORAGE_KEY, votes);
// Fire-and-forget POST to backend
try {
fetch(`${state.API_BASE}/api/compare/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: state._lastPrompt,
models: modelNames,
winner: winner,
is_blind: state._blindMode,
}),
}).catch(() => {}); // silently ignore errors
} catch (_) {}
}
/** Reveal model names in pane headers. Highlights winner if one was picked. */
function handleVote(winnerIdx) {
const displayNames = _modelDisplayNames(state._selectedModels);
// Reveal only — just show names, keep vote buttons active
if (winnerIdx === -2) {
for (let i = 0; i < state._selectedModels.length; i++) {
const el = document.getElementById('cmp-title-' + i);
if (el) el.innerHTML = '<strong>' + escapeHtml(displayNames[i]) + '</strong> <span class="pane-title-caret">&#x25BE;</span>';
const hist = document.getElementById('cmp-history-' + i);
if (hist) hist.querySelectorAll('.msg-ai .role').forEach(roleEl => {
if (roleEl.textContent.trim() === 'AI') roleEl.textContent = displayNames[i];
});
}
return;
}
// Guard against double-voting — the per-pane vote buttons (.pane-vote-btn)
// aren't covered by the .compare-vote-btn disable below, so without this a
// user could spam a pane's vote button and record a score on every click.
if (state._voted) return;
state._voted = true;
// Persist vote
_saveVote(winnerIdx);
// Stop any still-streaming panes (user voted early)
if (state._streaming && _stopAll) _stopAll();
const panes = document.querySelectorAll('.compare-pane');
for (let i = 0; i < state._selectedModels.length; i++) {
const el = document.getElementById('cmp-title-' + i);
const pane = panes[i];
if (!el) continue;
const name = displayNames[i];
const isWinner = winnerIdx === i;
const isTie = winnerIdx === -1;
let html = '';
const caret = ' <span class="pane-title-caret">&#x25BE;</span>';
if (isWinner) html = '<span style="color:var(--red);margin-right:4px;">&#x2605;</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--red);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:-2px;">Winner!</span>' + caret;
else if (isTie) html = '<span style="opacity:0.5;margin-right:4px;">=</span><strong>' + escapeHtml(name) + '</strong>' + caret;
else html = '<strong>' + escapeHtml(name) + '</strong>' + caret;
el.innerHTML = html;
if (pane) {
if (isWinner) { pane.classList.add('winner'); }
else if (winnerIdx >= 0) pane.classList.add('loser'); }
}
// Swap "AI" role labels to real model names in each pane's messages
for (let i = 0; i < state._selectedModels.length; i++) {
const hist = document.getElementById('cmp-history-' + i);
if (!hist) continue;
hist.querySelectorAll('.msg-ai .role').forEach(roleEl => {
if (roleEl.textContent.trim() === 'AI') {
roleEl.textContent = displayNames[i];
}
});
}
// Disable vote buttons but keep reset active — include the per-pane vote
// buttons (.pane-vote-btn) so they can't be spammed after a vote.
document.querySelectorAll('.compare-vote-btn:not(.compare-rematch-btn):not(.compare-score-btn), .pane-vote-btn').forEach(b => {
b.disabled = true; b.style.opacity = '0.4';
});
// Confetti burst at the winner's pane header
if (winnerIdx >= 0) {
const titleEl = document.getElementById('cmp-title-' + winnerIdx);
if (titleEl) {
const rect = titleEl.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
spawnConfetti(cx, cy, 50);
setTimeout(() => spawnConfetti(cx - 30, cy, 25), 150);
setTimeout(() => spawnConfetti(cx + 30, cy, 25), 300);
}
}
}
/** Spawn confetti particles from a point. */
function spawnConfetti(cx, cy, count) {
const colors = ['#ffd700', '#ff6b6b', '#5b8def', '#51cf66', '#ff922b', '#cc5de8', '#22b8cf', '#fff'];
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'confetti-piece';
const color = colors[Math.floor(Math.random() * colors.length)];
const size = 5 + Math.random() * 8;
const isCircle = Math.random() > 0.5;
el.style.width = size + 'px';
el.style.height = (isCircle ? size : size * 0.6) + 'px';
el.style.background = color;
el.style.borderRadius = isCircle ? '50%' : '2px';
el.style.left = cx + 'px';
el.style.top = cy + 'px';
const angle = Math.random() * Math.PI * 2;
const speed = 60 + Math.random() * 160;
const dx = Math.cos(angle) * speed;
const dy = Math.sin(angle) * speed - 100;
const duration = 1.0 + Math.random() * 1.0;
el.animate([
{ transform: 'translate(0, 0) rotate(0deg) scale(1)', opacity: 1 },
{ transform: `translate(${dx}px, ${dy + 200}px) rotate(${400 + Math.random() * 400}deg) scale(0)`, opacity: 0 }
], { duration: duration * 1000, easing: 'cubic-bezier(0.15, 0.6, 0.35, 1)', fill: 'forwards' });
document.body.appendChild(el);
setTimeout(() => el.remove(), duration * 1000 + 50);
}
}
export { _saveVote, handleVote, buildVoteBar, addFinishBadge, spawnConfetti, registerCompareActions };

View File

@@ -0,0 +1,486 @@
// ============================================
// COOKBOOK DIAGNOSIS SUB-MODULE
// Error pattern matching and diagnosis UI
// ============================================
import {
_envState,
_loadTasks,
_removeTask,
_launchServeTask,
_buildEnvPrefix,
_sshCmd,
_setPanelField,
_setPanelCheckbox,
_copyText,
_persistEnvState,
_tmuxCmd,
_serveAutoRetry,
_serveAutoRetryReplace,
_serveAutoRetryRemove,
_serveAutoFix,
// 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 spinnerModule from './spinner.js';
// ── Error diagnosis ──
// Infer the gated base repo that single-file checkpoints need configs from
function _inferBaseRepo(text) {
if (!text) return null;
const t = text.toLowerCase();
if (t.includes('sd3.5') || t.includes('stable-diffusion-3.5')) return 'stabilityai/stable-diffusion-3.5-large';
if (t.includes('sd3') || t.includes('stable-diffusion-3')) return 'stabilityai/stable-diffusion-3-medium-diffusers';
if (t.includes('flux')) return 'black-forest-labs/FLUX.1-schnell';
if (t.includes('sdxl') || t.includes('stable-diffusion-xl')) return 'stabilityai/stable-diffusion-xl-base-1.0';
return null;
}
export const ERROR_PATTERNS = [
{
pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i,
message: 'No GPU memory left for KV cache after loading model.',
fixes: [
{ label: 'Retry with GPU mem 0.95', action: (panel) => _serveAutoRetryReplace(panel, '--gpu-memory-utilization', '0.95') },
{ label: 'Retry with context 2048', action: (panel) => _serveAutoRetryReplace(panel, '--max-model-len', '2048') },
{ label: 'Retry with more GPUs (TP=8)', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '8') },
],
},
{
pattern: /warming up sampler|max_num_seqs.*gpu_memory_utilization/i,
message: 'OOM during warmup. Lower GPU memory or max sequences.',
fixes: [
{ label: 'Retry with GPU mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--gpu-memory-utilization', '0.80') },
{ label: 'Retry with --max-num-seqs 64', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 64') },
{ label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') },
],
},
{
pattern: /CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory/i,
message: 'GPU ran out of memory. Try more GPUs (higher TP) or lower context.',
fixes: [
{ label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
{ label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
{ label: 'Retry with GPU mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--gpu-memory-utilization', '0.80') },
{ label: 'Retry with context 4096', action: (panel) => _serveAutoRetryReplace(panel, '--max-model-len', '4096') },
{ label: 'Retry with --enforce-eager', action: (panel) => _serveAutoRetry(panel, '--enforce-eager') },
],
},
{
pattern: /not divisible by weight quantization|quantization block/i,
message: 'Model quantization format incompatible with this vLLM version. Try a different quant (AWQ) or update vLLM.',
fixes: [
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm', 'pip-update', cmd);
}},
],
},
{
pattern: /not divisib|must be divisible|attention heads.*divisible/i,
message: 'Tensor parallel size incompatible with model dimensions.',
fixes: [
{ label: 'Retry with TP=1', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '1') },
{ label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
{ label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
],
},
{
pattern: /Too large swap space|swap space.*total CPU memory/i,
message: 'Swap space too large for available CPU memory.',
fixes: [
{ label: 'Retry without swap', action: (panel) => _serveAutoRetryRemove(panel, '--swap-space') },
{ label: 'Retry with swap 1', action: (panel) => _serveAutoRetryReplace(panel, '--swap-space', '1') },
],
},
{
pattern: /swap space|not enough.*memory.*cpu|Cannot allocate memory/i,
message: 'Not enough CPU RAM or swap space.',
fixes: [
{ label: 'Retry without swap', action: (panel) => _serveAutoRetryRemove(panel, '--swap-space') },
{ label: 'Lower max context to 4096', action: (panel) => _setPanelField(panel, 'ctx', '4096') },
],
},
{
pattern: /unrecognized arguments:\s*--swap-space/i,
message: '--swap-space was removed in newer vLLM versions. Remove it from the command.',
fixes: [
{ label: 'Retry without swap', action: (panel) => _serveAutoRetryRemove(panel, '--swap-space') },
],
},
{
pattern: /Address already in use|bind.*address.*in use/i,
message: 'Port is already in use. Another server may be running.',
fixes: [
{ label: 'Kill existing vLLM', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
{ label: 'Use port 8001', action: (panel) => _setPanelField(panel, 'port', '8001') },
],
},
{
pattern: /No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid/i,
message: 'No GPUs visible. Check your GPU selection or driver.',
fixes: [
{ label: 'Clear GPU selection (use all)', action: (panel) => {
_setPanelField(panel, 'gpus', '');
_envState.gpus = '';
_persistEnvState();
}},
],
},
{
pattern: /403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review/i,
message: 'Gated model. Your HF token IS being sent — but its account must be granted access first: open the model page, accept the license, and wait for approval (Meta models can take a while).',
// Extract repo name from error text to build HF link
_repoPattern: /Access to model\s+(\S+)\s+is restricted|gated repo.*?huggingface\.co\/([^\s/]+\/[^\s/]+)/i,
fixes: [
{ label: 'Request access on HF', action: (panel, _text) => {
const m = _text && (_text.match(/Access to model\s+(\S+)\s+is restricted/i) || _text.match(/huggingface\.co\/([^\s/]+\/[^\s/]+)/i));
const repo = m && (m[1] || m[2]);
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
}},
{ label: 'Check HF Token', action: (panel) => {
const el = panel.querySelector('[data-field="hf_token"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
}},
],
},
{
pattern: /Weights for this component appear to be missing|load the component before passing/i,
message: 'Single-file checkpoint needs a base model for missing components (text encoder, VAE). The base model may be gated — accept the license and set your HF token.',
fixes: [
{ label: 'Request access to base model', action: (panel, _text) => {
// Extract gated repo from error, or infer from model name
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
const base = _text && _text.match(/config=([^\s,)]+)/i);
const model = _text && _text.match(/load model from\s+(\S+)/i);
const repo = (gated && gated[1]) || (base && base[1]) || _inferBaseRepo(_text);
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else if (model && model[1]) window.open('https://huggingface.co/' + model[1].replace(/[.]$/, ''), '_blank');
}},
{ label: 'Check HF Token', action: (panel) => {
const el = panel.querySelector('[data-field="hf_token"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
}},
],
},
{
pattern: /Entry Not Found.*model_index\.json|Could not load model.*Check diffusers/i,
message: 'Single-file model — needs base config from a gated repo. Accept the license and set your HF token.',
fixes: [
{ label: 'Request access to base model', action: (panel, _text) => {
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
const repo = (gated && gated[1]) || _inferBaseRepo(_text);
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
}},
{ label: 'Check HF Token', action: (panel) => {
const el = panel.querySelector('[data-field="hf_token"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
}},
],
},
{
pattern: /does not appear to have a file named|not a valid model|No such file or directory.*model/i,
message: 'Model path or ID not found.',
fixes: [
{ label: 'Check model name', action: (panel) => {
const header = panel.querySelector('.hwfit-panel-model');
if (header) header.style.color = 'var(--red)';
}},
],
},
{
pattern: /NCCL error|ncclSystemError|ncclInternalError/i,
message: 'Multi-GPU communication (NCCL) failed.',
fixes: [
{ label: 'Set TP to 1 (single GPU)', action: (panel) => _setPanelField(panel, 'tp', '1') },
{ label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) },
],
},
{
pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i,
message: 'Context length too large for available GPU memory.',
fixes: [
{ label: 'Lower to 8192', action: (panel) => _setPanelField(panel, 'ctx', '8192') },
{ label: 'Lower to 4096', action: (panel) => _setPanelField(panel, 'ctx', '4096') },
{ label: 'Lower to 2048', action: (panel) => _setPanelField(panel, 'ctx', '2048') },
],
},
{
pattern: /vllm.*command not found|No module named vllm/i,
message: 'vLLM is not installed or not in PATH.',
fixes: [
{ label: 'Check environment is set', action: (panel) => {
const el = panel.querySelector('[data-field="env_type"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
}},
],
},
{
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
message: 'SGLang is not installed or not in PATH. Open Cookbook → Dependencies and install sglang on this server.',
fixes: [
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
],
},
{
pattern: /flashinfer.*version.*does not match|flashinfer-cubin version/i,
message: 'FlashInfer version mismatch.',
fixes: [
{ label: 'Auto-fix: bypass version check', action: (panel) => _serveAutoFix(panel, 'FLASHINFER_DISABLE_VERSION_CHECK=1'), autofix: true },
{ label: 'Fix properly: pip install matching version', action: () => {} },
],
},
{
pattern: /torch\.cuda\.is_available\(\).*False|No CUDA runtime/i,
message: 'CUDA not available in this environment.',
fixes: [],
},
{
pattern: /Engine core initialization failed/i,
message: 'vLLM engine failed to start. Check the error above.',
fixes: [
{ label: 'Retry with --enforce-eager', action: (panel) => _serveAutoRetry(panel, '--enforce-eager'), autofix: true },
{ label: 'Retry with context 4096', action: (panel) => _serveAutoRetry(panel, '--max-model-len 4096'), autofix: true },
{ label: 'Lower context to 4096', action: (panel) => _setPanelField(panel, 'ctx', '4096') },
{ label: 'Lower GPU mem to 0.80', action: (panel) => _setPanelField(panel, 'gpu_mem', '0.80') },
],
},
{
pattern: /weight_loader.*unexpected keyword|Unexpected key.*state_dict/i,
message: 'Model format incompatible with this vLLM version.',
fixes: [
{ label: 'Try trust remote code', action: (panel) => _setPanelCheckbox(panel, 'trust_remote', true) },
],
},
{
pattern: /enable-auto-tool-choice requires --tool-call-parser/i,
message: 'Auto tool choice needs a tool call parser.',
fixes: [
{ label: 'Retry with --tool-call-parser hermes', action: (panel) => _serveAutoRetry(panel, '--tool-call-parser hermes'), autofix: true },
],
},
{
pattern: /Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load/i,
message: 'Model requires custom code. Enable --trust-remote-code.',
fixes: [
{ label: 'Retry with --trust-remote-code', action: (panel) => _serveAutoRetry(panel, '--trust-remote-code'), autofix: true },
],
},
{
pattern: /does not recognize this architecture|model type.*but Transformers does not/i,
message: 'Model architecture too new for installed vLLM/transformers.',
fixes: [
{ label: 'Try --trust-remote-code', action: (panel) => _serveAutoRetry(panel, '--trust-remote-code'), autofix: true },
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm transformers' : 'pip install -U vllm transformers';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
// Run in tmux so it doesn't timeout
const name = 'update-vllm';
_launchServeTask(name, 'pip-update', cmd);
}},
],
},
{
pattern: /ollama.*command not found/i,
message: 'Ollama is not installed on this server. Run: curl -fsSL https://ollama.com/install.sh | sh',
fixes: [
{ label: 'Copy install command', action: () => _copyText('curl -fsSL https://ollama.com/install.sh | sh') },
],
},
{
pattern: /llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'/i,
message: 'llama-cpp-python server is not installed. Run: pip install "llama-cpp-python[server]"',
fixes: [
{ label: 'Copy install command', action: () => _copyText('pip install "llama-cpp-python[server]"') },
],
},
{
pattern: /diffusers.*No module named|diffusers.*command not found/i,
message: 'Diffusers is not installed. Run: pip install diffusers transformers accelerate',
fixes: [
{ label: 'Copy install command', action: () => _copyText('pip install diffusers transformers accelerate') },
],
},
{
pattern: /Triton kernels.*Failed to import|cannot import name '\w+' from 'triton_kernels/i,
message: 'Triton kernels version mismatch. Non-fatal warning — model will still run, just without optimized MoE kernels.',
fixes: [
{ label: 'Update triton on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U triton triton-kernels' : 'pip install -U triton triton-kernels';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-triton', 'pip-update', cmd);
}},
],
},
{
pattern: /No space left on device|Disk quota exceeded|ENOSPC/i,
message: 'Disk full on the server. Free up space before retrying.',
fixes: [
{ label: 'Check HF cache size', action: (panel) => _runQuickCmd(panel, 'du -sh ~/.cache/huggingface 2>/dev/null') },
],
},
{
pattern: /Connection refused|Could not connect|Connection reset by peer/i,
message: 'Network connection failed. Server may be unreachable or HuggingFace is down.',
fixes: [
{ label: 'Test HF connectivity', action: (panel) => _runQuickCmd(panel, 'curl -sI https://huggingface.co 2>&1 | head -3') },
],
},
{
pattern: /attention_sink|sliding.window.*not supported|sliding_window.*incompatible/i,
message: 'Model uses attention features unsupported in this vLLM version.',
fixes: [
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm', 'pip-update', cmd);
}},
],
},
{
// Tail-only + healthy-server suppression. tmux capture-pane returns the
// entire scrollback every poll, so a one-shot startup traceback would
// otherwise stick on the panel forever even while the server happily
// serves /v1/models. Only fire if the traceback is in recent output AND
// the server isn't currently logging healthy traffic.
match: (text) => {
const TAIL = text.slice(-4096);
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
// Healthy markers in the tail mean whatever blew up has been recovered
// from — the server is up and answering requests.
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
return true;
},
message: 'Python traceback detected — may be a handled error, check logs.',
fixes: [
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
],
},
];
export function _diagnose(text) {
for (const entry of ERROR_PATTERNS) {
const hit = entry.match ? entry.match(text) : entry.pattern.test(text);
if (hit) return entry;
}
return null;
}
export function _showDiagnosis(panel, diagnosis, sourceText) {
if (panel._lastDiagMsg === diagnosis.message) return;
if (panel._diagDismissed === diagnosis.message) return; // stay dismissed until new error
panel._lastDiagMsg = diagnosis.message;
let diag = panel.querySelector('.cookbook-diagnosis');
if (!diag) {
diag = document.createElement('div');
diag.className = 'cookbook-diagnosis';
const output = panel.querySelector('.cookbook-output-pre');
if (output) output.after(diag);
else panel.appendChild(diag);
}
diag.classList.remove('hidden');
diag.innerHTML = '';
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
const msg = document.createElement('div');
msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message;
header.appendChild(msg);
const dismiss = document.createElement('button');
dismiss.className = 'close-btn';
dismiss.style.cssText = 'width:16px;height:16px;font-size:9px;flex-shrink:0;';
dismiss.textContent = '\u2715';
dismiss.addEventListener('click', () => { panel._diagDismissed = diagnosis.message; _clearDiagnosis(panel); });
header.appendChild(dismiss);
diag.appendChild(header);
if (diagnosis.fixes && diagnosis.fixes.length) {
const row = document.createElement('div');
row.className = 'cookbook-diag-fixes';
for (const fix of diagnosis.fixes) {
const btn = document.createElement('button');
btn.className = 'cookbook-btn cookbook-diag-btn';
btn.textContent = fix.label;
btn.addEventListener('click', async () => {
if (btn.dataset.busy) return;
btn.dataset.busy = '1';
// Spinner feedback while the fix runs (kill + relaunch takes a moment).
const _orig = btn.textContent;
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
btn.textContent = '';
btn.appendChild(wp.element);
const _lbl = document.createElement('span');
_lbl.textContent = _orig;
_lbl.style.verticalAlign = 'middle';
btn.appendChild(_lbl);
try {
await fix.action(panel, sourceText);
} catch (e) {
console.error('[cookbook] diagnosis fix failed', e);
} finally {
// Retries animate the whole card away (button goes with it). For fixes
// that leave the card in place, restore the label.
if (btn.isConnected) { try { wp.destroy(); } catch {} btn.textContent = _orig; delete btn.dataset.busy; }
}
});
row.appendChild(btn);
}
diag.appendChild(row);
}
}
export function _clearDiagnosis(panel) {
panel._lastDiagMsg = null;
const diag = panel.querySelector('.cookbook-diagnosis');
if (diag) { diag.innerHTML = ''; diag.classList.add('hidden'); }
}
// ── Quick command ──
export async function _runQuickCmd(panel, cmd) {
let fullCmd = cmd;
if (_envState.remoteHost) {
fullCmd = _sshCmd(_envState.remoteHost, cmd);
}
const diag = panel.querySelector('.cookbook-diagnosis');
if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; }
try {
const res = await fetch('/api/shell/stream', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
});
if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`;
} catch (e) {
if (diag) diag.textContent = `Error: ${e.message}`;
}
}

1610
static/js/cookbook-hwfit.js Normal file

File diff suppressed because it is too large Load Diff

1817
static/js/cookbook.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
// ============================================
// COOKBOOK DOWNLOAD SUB-MODULE
// Download tab: SSE streaming, model download,
// panel rendering, command building
// ============================================
import uiModule from './ui.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
// Shared state/functions injected by init()
let _envState;
let _sshCmd;
let _getPort;
let _getPlatform;
let _isWindows;
let _buildEnvPrefix;
let _buildServeCmd;
let _detectBackend;
let _detectToolParser;
let _loadPresets;
let _savePresets;
let _copyText;
let _persistEnvState;
let modelLogo;
let esc;
let _addTask;
let _renderRunningTab;
let _loadTasks;
let _saveTasks;
// Storage keys
const SERVE_STATE_KEY = 'cookbook-serve-state';
// ── Panel field helpers ──
export function _setPanelField(panel, field, value) {
const input = panel.querySelector(`[data-field="${field}"]`);
if (!input) return;
if (input.tagName === 'SELECT') {
input.value = value;
} else if (input.type === 'checkbox') {
input.checked = !!value;
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
export function _setPanelCheckbox(panel, field, checked) {
const cb = panel.querySelector(`[data-field="${field}"]`);
if (cb) {
cb.checked = checked;
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// ── Command builder: download ──
export function _buildDownloadCmd(model, backend) {
let cmd = '';
if (backend === 'ollama') {
cmd = `ollama pull ${model.name.split('/').pop().toLowerCase()}`;
} else {
const repo = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].repo : model.name;
const includeArg = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? `, allow_patterns=["*${model.quant || ''}*"]` : '';
// Reflect the server's download target in the preview (matches the real
// download path built server-side). '' = default HF cache.
const _dlDir = (_envState.servers.find(s => s.host === (_envState.remoteHost || '')) || {}).downloadDir || '';
const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : '';
const _py = _isWindows() ? 'python' : 'python3';
cmd = `${_py} -u -c "
import sys, time, os
os.environ['HF_HUB_DISABLE_PROGRESS_BARS']='0'
os.environ['TQDM_DISABLE']='0'
_lp={}
class T:
def __init__(s,*a,**k):
s.it=a[0] if a else k.get('iterable');s.total=k.get('total');s.desc=k.get('desc','');s.n=0;s.st=time.time();s._c=False
if s.it is not None and s.total is None:
try: s.total=len(s.it)
except: pass
def __iter__(s):
if s.it is None: return
for i in s.it: yield i; s.update(1)
def __enter__(s): return s
def __exit__(s,*a): s.close()
def __len__(s): return s.total or 0
def update(s,n=1):
s.n+=n;t=s.total or 0
if t==0: return
now=time.time();k=id(s)
if now-_lp.get(k,0)<0.5 and s.n<t: return
_lp[k]=now;p=int(100*s.n/t);e=now-s.st;sp=s.n/e if e>0 else 0;d=(s.desc or '').strip()
if t>=1073741824: ds=f'{s.n/1073741824:.2f}';ts=f'{t/1073741824:.2f}GB';ss=f'{sp/1048576:.1f}MB/s'
elif t>=1048576: ds=f'{s.n/1048576:.1f}';ts=f'{t/1048576:.1f}MB';ss=f'{sp/1048576:.1f}MB/s'
else: ds=str(s.n);ts=str(t);ss=f'{sp:.0f}/s'
f=int(20*s.n/t);bar='#'*f+'-'*(20-f)
print(f'FILE {d} [{bar}] {p}% {ds}/{ts} {ss}',flush=True)
def set_description(s,d=None,refresh=True): s.desc=d or ''
def set_postfix(s,*a,**k): pass
def set_postfix_str(s,st='',refresh=True): pass
def reset(s,total=None): s.n=0;s.total=total if total is not None else s.total;s.st=time.time()
def refresh(s): pass
def close(s): s._c=True
def clear(s): pass
def display(s,msg=None,pos=None): pass
@property
def format_dict(s): return {'n':s.n,'total':s.total,'elapsed':time.time()-s.st}
import tqdm;tqdm.tqdm=T
try: import tqdm.auto;tqdm.auto.tqdm=T
except: pass
try:
import huggingface_hub.utils;huggingface_hub.utils.tqdm=T
if hasattr(huggingface_hub.utils,'_tqdm'): huggingface_hub.utils._tqdm.tqdm=T
except: pass
from huggingface_hub import snapshot_download
repo='${repo}'
print(f'START {repo}',flush=True)
try:
path=snapshot_download(repo${includeArg}${_localDirArg})
print(f'DONE {path}',flush=True)
except Exception as e:
print(f'ERROR {e}',file=sys.stderr,flush=True);sys.exit(1)
"`;
}
const prefix = _buildEnvPrefix();
let full = prefix ? prefix + ' ' + cmd : cmd;
if (_envState.remoteHost) {
full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
}
return full;
}
// ── Panel rendering helpers ──
function _getPanelFields(panel) {
const vals = {};
panel.querySelectorAll('.hwfit-f').forEach(el => {
const key = el.dataset.field;
if (!key) return;
if (el.type === 'checkbox') {
vals[key] = el.checked;
} else {
vals[key] = el.value;
}
});
return vals;
}
function _syncEnvFromPanel(panel) {
const f = _getPanelFields(panel);
if (f.env_type !== undefined) _envState.env = f.env_type;
if (f.env_path !== undefined) _envState.envPath = f.env_path;
if (f.hf_token !== undefined) _envState.hfToken = f.hf_token;
if (f.gpus !== undefined) _envState.gpus = f.gpus;
}
export function _wirePanelEvents(panel, model, backend) {
// Populate env fields from _envState
const envFields = {
env_type: _envState.env || 'none',
env_path: _envState.envPath || '',
hf_token: _envState.hfToken || '',
gpus: _envState.gpus || '',
};
for (const [field, val] of Object.entries(envFields)) {
const el = panel.querySelector(`[data-field="${field}"]`);
if (el && val) el.value = val;
}
// All inputs: update cmd preview + sync env state
panel.querySelectorAll('.hwfit-f').forEach(input => {
const evts = input.tagName === 'SELECT' ? ['change'] : ['input', 'change'];
for (const evt of evts) {
input.addEventListener(evt, () => {
_updatePanelCmd(panel, model, backend);
const f = input.dataset.field;
if (f === 'env_type') { _envState.env = input.value; _persistEnvState(); }
else if (f === 'env_path') { _envState.envPath = input.value; _persistEnvState(); }
else if (f === 'hf_token') { _envState.hfToken = input.value; _persistEnvState(); }
else if (f === 'gpus') { _envState.gpus = input.value; _persistEnvState(); }
});
}
});
// Download button
const dlBtn = panel.querySelector('.hwfit-dl-btn');
if (dlBtn) {
dlBtn.addEventListener('click', () => {
if (backend === 'ollama') {
_runPanelCmd(panel, _buildDownloadCmd(model, backend), { timeout: 0 });
} else {
_runModelDownload(panel, model, backend);
}
});
}
// Stop button
const stopBtn = panel.querySelector('.hwfit-stop-btn');
if (stopBtn) {
stopBtn.addEventListener('click', () => {
if (panel._cookbookAbort) panel._cookbookAbort.abort();
});
}
// Kill & close output button
const killBtn = panel.querySelector('.cookbook-output-kill');
if (killBtn) {
killBtn.addEventListener('click', () => {
if (panel._cookbookAbort) panel._cookbookAbort.abort();
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', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: `tmux kill-session -t ${tmuxMatch[1]} 2>/dev/null` }),
}).catch(() => {});
}
const wrap = panel.querySelector('.cookbook-output-wrap');
if (wrap) wrap.classList.add('hidden');
const output = panel.querySelector('.cookbook-output-pre');
if (output) output.textContent = '';
_clearDiagnosis(panel);
});
}
// Copy button
const copyBtn = panel.querySelector('.hwfit-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const cmd = panel.querySelector('.hwfit-panel-cmd')?.textContent || '';
_copyText(cmd).then(() => {
copyBtn.textContent = 'Copied';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
});
});
}
// Save button
const saveBtn = panel.querySelector('.hwfit-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const shortName = model.name.split('/').pop() || model.name;
const name = prompt('Preset name:', shortName);
if (!name) return;
const fields = _getPanelFields(panel);
const presets = _loadPresets();
presets.push({ name, model: model.name, backend, fields });
_savePresets(presets);
uiModule.showToast('Preset saved');
});
}
// Output copy button
const outputCopyBtn = panel.querySelector('.cookbook-output-copy');
if (outputCopyBtn) {
outputCopyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const text = panel.querySelector('.cookbook-output-pre')?.textContent || '';
_copyText(text).then(() => {
const origHTML = outputCopyBtn.innerHTML;
outputCopyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
outputCopyBtn.classList.add('copied');
setTimeout(() => {
outputCopyBtn.innerHTML = origHTML;
outputCopyBtn.classList.remove('copied');
}, 1500);
});
});
}
}
function _updatePanelCmd(panel, model, backend) {
const pre = panel.querySelector('.hwfit-panel-cmd');
if (!pre) return;
const f = _getPanelFields(panel);
_syncEnvFromPanel(panel);
if (backend === 'llamacpp') {
f._gguf_path = (model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].file || 'model.gguf'
: 'model.gguf';
}
const cmd = _buildServeCmd(f, model.name, backend);
const prefix = _buildEnvPrefix();
let full = prefix ? prefix + ' ' + cmd : cmd;
if (f.extra && f.extra.trim()) full += ' ' + f.extra.trim();
if (_envState.remoteHost) full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
pre.textContent = full;
}
// ── SSE streaming ──
export async function _runPanelCmd(panel, cmd, opts = {}) {
const outputWrap = panel.querySelector('.cookbook-output-wrap');
const output = panel.querySelector('.cookbook-output-pre');
if (outputWrap) outputWrap.classList.remove('hidden');
output.classList.remove('cookbook-output-error');
output.textContent = '';
_clearDiagnosis(panel);
const controller = new AbortController();
panel._cookbookAbort = controller;
const serveBtn = panel.querySelector('.hwfit-serve-btn');
const stopBtn = panel.querySelector('.hwfit-stop-btn');
if (serveBtn) serveBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = '';
let fullOutput = '';
const payload = { command: cmd };
if (opts.timeout !== undefined) payload.timeout = opts.timeout;
if (opts.use_pty) payload.use_pty = true;
if (opts.use_tmux) payload.use_tmux = true;
try {
const res = await fetch('/api/shell/stream', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!res.ok) {
output.classList.add('cookbook-output-error');
output.textContent = 'HTTP ' + res.status + ': ' + (await res.text());
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let exitCode = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, idx);
buf = buf.slice(idx + 2);
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.data !== undefined) {
const isProgress = /^FILE .+\d+%/.test(ev.data) || /\d+%\|/.test(ev.data);
if (isProgress && output.textContent) {
const lines = output.textContent.split('\n');
const lastLine = lines[lines.length - 1] || '';
const curFile = ev.data.match(/^FILE\s+(\S+)/)?.[1];
const prevFile = lastLine.match(/^FILE\s+(\S+)/)?.[1];
if (curFile && prevFile && curFile === prevFile) {
lines[lines.length - 1] = ev.data;
output.textContent = lines.join('\n');
} else {
output.textContent += '\n' + ev.data;
}
} else {
output.textContent += (output.textContent ? '\n' : '') + ev.data;
}
output.scrollTop = output.scrollHeight;
fullOutput += ev.data + '\n';
const diag = _diagnose(fullOutput);
if (diag) _showDiagnosis(panel, diag, fullOutput);
}
if (ev.exit_code !== undefined) {
exitCode = ev.exit_code;
}
} catch (_) {}
}
}
}
if (!output.textContent) output.textContent = '(no output)';
if (exitCode !== null && exitCode !== 0) {
output.classList.add('cookbook-output-error');
const diag = _diagnose(fullOutput);
if (diag) _showDiagnosis(panel, diag, fullOutput);
}
} catch (err) {
if (err.name === 'AbortError') {
output.textContent += (output.textContent ? '\n' : '') + '(stopped)';
} else {
output.classList.add('cookbook-output-error');
output.textContent += (output.textContent ? '\n' : '') + 'Request failed: ' + err.message;
}
} finally {
if (serveBtn) serveBtn.style.display = '';
if (stopBtn) stopBtn.style.display = 'none';
delete panel._cookbookAbort;
}
}
// ── Model download (dedicated endpoint, tmux-backed) ──
export async function _runModelDownload(panel, model, backend, hostOverride) {
const repo = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].repo : (model.quant_repo || model.name);
const include = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? `*${model.quant || ''}*` : null;
_syncEnvFromPanel(panel);
// The host is whatever the caller resolved from the dropdown the user picked
// (passed explicitly as hostOverride). We do NOT trust _envState.remoteHost
// here: there can be more than one copy of the cookbook state in memory and
// they disagree on the active host. The servers LIST is consistent, so we look
// up the matching server to get its env / path / platform / port.
let host;
if (hostOverride !== undefined) {
host = hostOverride || '';
} else {
// No explicit host passed: resolve from the visible server dropdown rather
// than _envState.remoteHost (unreliable — multiple state copies disagree).
const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
// Dropdown values are host strings now ('local' for local); resolve by host
// (numeric fallback for any stale value).
const _ssv = ssEl ? ssEl.value : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
} else if (ssEl && ssEl.value === 'local') {
host = '';
} else {
host = _envState.remoteHost || '';
}
}
const srv = _envState.servers.find(s => s.host === host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows();
const payload = { repo_id: repo };
if (include) payload.include = include;
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
if (platform) payload.platform = platform;
// If this server has a directory flagged as the download target, send it so
// the backend downloads into <dir>/<model> instead of the default HF cache.
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
if (isWin) {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + envPath;
}
} else {
if (env === 'venv' && envPath) {
payload.env_prefix = 'source ' + (envPath.endsWith('/bin/activate') ? envPath : envPath + '/bin/activate');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + envPath;
}
}
const shortName = (model.name || repo).split('/').pop();
const targetHost = host || 'local';
const tasks = _loadTasks();
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
if (activeOnHost) {
const queueId = `queue-${Date.now().toString(36)}`;
const allTasks = _loadTasks();
allTasks.push({ id: queueId, sessionId: queueId, name: shortName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
return;
}
try {
const res = await fetch('/api/model/download', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
uiModule.showToast('Download failed: HTTP ' + res.status);
return;
}
const data = await res.json();
if (!data.ok) {
uiModule.showToast('Download failed: ' + (data.error || ''));
return;
}
_addTask(data.session_id, shortName, 'download', payload);
uiModule.showToast(`Downloading ${shortName}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message);
}
}
// ── Init ──
export function initDownload(shared) {
_envState = shared._envState;
_sshCmd = shared._sshCmd;
_getPort = shared._getPort;
_getPlatform = shared._getPlatform;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_buildServeCmd = shared._buildServeCmd;
_detectBackend = shared._detectBackend;
_detectToolParser = shared._detectToolParser;
_loadPresets = shared._loadPresets;
_savePresets = shared._savePresets;
_copyText = shared._copyText;
_persistEnvState = shared._persistEnvState;
modelLogo = shared.modelLogo;
esc = shared.esc;
_addTask = shared._addTask;
_renderRunningTab = shared._renderRunningTab;
_loadTasks = shared._loadTasks;
_saveTasks = shared._saveTasks;
}

2703
static/js/cookbookRunning.js Normal file

File diff suppressed because it is too large Load Diff

1614
static/js/cookbookServe.js Normal file

File diff suppressed because it is too large Load Diff

9254
static/js/document.js Normal file

File diff suppressed because it is too large Load Diff

3320
static/js/documentLibrary.js Normal file

File diff suppressed because it is too large Load Diff

265
static/js/dragSort.js Normal file
View File

@@ -0,0 +1,265 @@
// static/js/dragSort.js
/**
* Vertical drag-and-drop sorting with magnetic snap behavior
*/
import Storage from './storage.js';
const instances = new Map();
/**
* Make a container's children sortable via vertical drag
*/
export function enable(containerId, itemSelector, options = {}) {
const container = document.getElementById(containerId);
if (!container) {
console.warn('[DragSort] container not found:', containerId);
return;
}
// Allow multiple instances per container via instanceKey
const key = options.instanceKey || containerId;
// Clean up previous instance
if (instances.has(key)) {
instances.get(key).cleanup();
instances.delete(key);
}
const config = {
onReorder: options.onReorder || null,
handleSelector: options.handleSelector || null,
excludeSelector: options.excludeSelector || null,
storageKey: options.storageKey || null,
};
let draggedEl = null;
let placeholder = null;
let offsetY = 0;
let items = [];
function getItems() {
let all = Array.from(container.querySelectorAll(itemSelector));
if (config.excludeSelector) {
all = all.filter(el => !el.matches(config.excludeSelector));
}
return all;
}
function createPlaceholder(height) {
const ph = document.createElement('div');
ph.className = 'drag-placeholder';
ph.style.height = height + 'px';
ph.style.margin = '4px 0';
return ph;
}
// --- Shared drag logic ---
function startDrag(clientY, item) {
const rect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const relativeTop = rect.top - containerRect.top + container.scrollTop;
const relativeLeft = rect.left - containerRect.left;
offsetY = clientY - rect.top;
items = getItems();
draggedEl = item;
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
placeholder = createPlaceholder(rect.height);
item.parentNode.insertBefore(placeholder, item);
item.classList.add('dragging');
Object.assign(item.style, {
position: 'absolute',
width: rect.width + 'px',
left: relativeLeft + 'px',
top: relativeTop + 'px',
zIndex: '9999',
pointerEvents: 'none',
margin: '0',
boxSizing: 'border-box',
transition: 'none'
});
}
function moveDrag(clientY) {
if (!draggedEl) return;
const containerRect = container.getBoundingClientRect();
const newTop = clientY - offsetY - containerRect.top + container.scrollTop;
draggedEl.style.top = newTop + 'px';
const otherItems = items.filter(i => i !== draggedEl);
const dragRect = draggedEl.getBoundingClientRect();
const dragCenter = dragRect.top + dragRect.height / 2;
let insertBefore = null;
for (const item of otherItems) {
const rect = item.getBoundingClientRect();
const itemCenter = rect.top + rect.height / 2;
if (dragCenter < itemCenter) {
insertBefore = item;
break;
}
}
if (insertBefore) {
if (placeholder.nextElementSibling !== insertBefore) {
container.insertBefore(placeholder, insertBefore);
}
} else if (otherItems.length > 0) {
const lastItem = otherItems[otherItems.length - 1];
if (placeholder !== lastItem.nextElementSibling) {
container.insertBefore(placeholder, lastItem.nextElementSibling);
}
}
}
function endDrag() {
if (!draggedEl) return;
const phRect = placeholder.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const snapTop = phRect.top - containerRect.top + container.scrollTop;
draggedEl.style.transition = 'top 0.08s ease-out';
draggedEl.style.top = snapTop + 'px';
setTimeout(() => {
placeholder.parentNode.insertBefore(draggedEl, placeholder);
placeholder.remove();
draggedEl.classList.remove('dragging');
draggedEl.style.cssText = '';
if (config.storageKey) {
const ids = getItems().map(el =>
el.dataset.sessionId || el.dataset.modelId || el.dataset.filePath
).filter(Boolean);
if (ids.length) {
Storage.setJSON(config.storageKey, ids);
}
}
if (config.onReorder) {
config.onReorder(getItems());
}
draggedEl = null;
placeholder = null;
items = [];
}, 80);
}
// --- Mouse events ---
function onMouseDown(e) {
if (e.button !== 0) 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;
e.preventDefault();
e.stopPropagation();
startDrag(e.clientY, item);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e) { moveDrag(e.clientY); }
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
endDrag();
}
// --- Touch events (long-press to start) ---
let _touchTimer = null;
let _touchStartY = 0;
let _touchItem = null;
function onTouchStart(e) {
// Don't start on buttons/inputs.
if (e.target.closest('button, input, select, a')) return;
// Respect handleSelector on touch too — long-press anywhere was
// unintentionally letting users start a reorder from the whole row.
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;
_touchItem = item;
const startY = e.touches[0].clientY;
_touchStartY = startY;
// Long-press: 400ms hold to initiate drag
_touchTimer = setTimeout(() => {
_touchTimer = null;
if (!_touchItem) return;
// Haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
startDrag(startY, _touchItem);
_touchItem.classList.add('touch-dragging');
}, 400);
}
function onTouchMove(e) {
// If long-press hasn't fired yet, cancel if finger moved too much
if (_touchTimer) {
const dy = Math.abs(e.touches[0].clientY - _touchStartY);
if (dy > 10) {
clearTimeout(_touchTimer);
_touchTimer = null;
_touchItem = null;
}
return;
}
// If dragging, prevent scroll and move
if (draggedEl) {
e.preventDefault();
moveDrag(e.touches[0].clientY);
}
}
function onTouchEnd() {
if (_touchTimer) {
clearTimeout(_touchTimer);
_touchTimer = null;
_touchItem = null;
return;
}
if (draggedEl) {
draggedEl.classList.remove('touch-dragging');
endDrag();
}
}
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('touchstart', onTouchStart, { passive: true });
container.addEventListener('touchmove', onTouchMove, { passive: false });
container.addEventListener('touchend', onTouchEnd);
container.addEventListener('touchcancel', onTouchEnd);
const instance = {
cleanup: () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('touchstart', onTouchStart);
container.removeEventListener('touchmove', onTouchMove);
container.removeEventListener('touchend', onTouchEnd);
container.removeEventListener('touchcancel', onTouchEnd);
},
refresh: () => { items = getItems(); }
};
instances.set(key, instance);
return instance;
}
const dragSortModule = { enable };
export default dragSortModule;
window.dragSortModule = dragSortModule;

View File

@@ -0,0 +1,374 @@
/**
* AI inpaint subsystem — Generate, Remove, and Outpaint variants
* all share a single `runInpaint` core; only the prompt, strength,
* and button-target differ. Returns a wireInpaintButtons() function
* to attach handlers to the three buttons (#ge-inpaint-run,
* #ge-inpaint-remove, #ge-inpaint-outpaint).
*
* runInpaint:
* - Build a union mask from every visible mask sub-layer (across
* all parent layers) — the model sees the COMBINED region,
* not just the currently-active mask.
* - Dilate the mask ~padPx so the model fills a buffer zone the
* post-gen Feather/Edge slider can fade into.
* - POST flattened canvas + dilated mask to /api/image/inpaint.
* - Drop the result as a new layer, snapshot the AI image + hard
* mask on the layer for live edge tuning, hide every
* contributing mask sub-layer, reveal the post-gen Feather +
* Edge Stroke sliders capped at ±padPx.
*
* Remove: detects OpenAI vs SDXL backend and swaps the prompt
* (gpt-image-1 follows "remove …" semantically; SDXL has to be
* prompted with a fill description + strength 0.99).
*
* Outpaint: auto-generates a mask covering empty (transparent)
* regions of the flattened composite, dilates it 12px inward
* so the model sees adjacent opaque pixels as context, runs
* inpaint, then restores the user's previous mask drawing.
*
* @param {{
* buildMergedMaskCanvas: () => HTMLCanvasElement | null,
* dilateMask: (src: HTMLCanvasElement, px: number) => HTMLCanvasElement,
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
* getSelectedAIEndpoint: (type: string) => { endpoint?: string, model?: string },
* ensureActiveMaskLayer: () => object | null,
* saveState: (label?: string) => void,
* createLayer: (name: string, w: number, h: number) => object,
* composite: () => void,
* renderLayerPanel: () => void,
* spinnerModule: object,
* uiModule: object | null,
* }} deps
*/
import { state } from './state.js';
export function wireInpaintButtons({
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
getSelectedAIEndpoint, ensureActiveMaskLayer,
saveState, createLayer, composite, renderLayerPanel,
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; }
const btn = document.getElementById(btnId);
const btnLabel = labelId ? document.getElementById(labelId) : null;
btn.disabled = true;
if (btnLabel) btnLabel.textContent = busyLabel;
let runWp = null;
try {
runWp = spinnerModule.createWhirlpool(14);
runWp.element.style.cssText = 'margin:0;flex-shrink:0;';
btn.appendChild(runWp.element);
} catch (_) { /* spinner is optional */ }
// Canvas-overlay whirlpool — visual feedback right where the
// user's working, since the run button lives in the side panel
// and may be out of view at high zoom. Positioned over the
// mask's centroid in viewport coords.
let canvasWp = null;
let canvasWpEl = null;
try {
const area = state.container && state.container.querySelector('.ge-canvas-area');
const mainRect = state.mainCanvas.getBoundingClientRect();
if (area && mainRect.width && mainRect.height) {
// Find the mask's bbox so we can centre the whirlpool over it.
let cx = state.imgWidth / 2, cy = state.imgHeight / 2;
try {
const merged = buildMergedMaskCanvas();
if (merged) {
const d = merged.getContext('2d').getImageData(0, 0, merged.width, merged.height).data;
let minX = merged.width, maxX = 0, minY = merged.height, maxY = 0;
for (let y = 0; y < merged.height; y += 4) {
for (let x = 0; x < merged.width; x += 4) {
if (d[(y * merged.width + x) * 4 + 3] > 0) {
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
}
}
if (maxX >= minX) { cx = (minX + maxX) / 2; cy = (minY + maxY) / 2; }
}
} catch {}
const scaleX = mainRect.width / state.mainCanvas.width;
const scaleY = mainRect.height / state.mainCanvas.height;
const vpX = mainRect.left + cx * scaleX;
const vpY = mainRect.top + cy * scaleY;
canvasWp = spinnerModule.create('', 'clean', 'whirlpool');
canvasWpEl = canvasWp.createElement();
canvasWpEl.style.cssText = `position:fixed;left:${vpX}px;top:${vpY}px;transform:translate(-50%,-50%);z-index:12;pointer-events:none;`;
document.body.appendChild(canvasWpEl);
canvasWp.start();
}
} 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;
// 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
// instead of fading straight to the original. The ORIGINAL
// (un-dilated) mask is cached on the layer — the feather blur
// expands outward from that boundary into the dilated AI region.
const padPx = Math.min(80, Math.max(20, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.04)));
// Merge every visible mask sub-layer (across all parent
// layers) into a single union mask before sending to the AI.
// 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 dilatedMask = dilateMask(mergedMask, padPx);
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
const res = await fetch('/api/image/inpaint', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify((() => {
const sel = getSelectedAIEndpoint('inpaint');
return { image: imageB64, mask: maskB64, prompt, width: state.imgWidth, height: state.imgHeight, strength, feather: 0, _endpoint: sel.endpoint, _model: sel.model };
})()),
});
if (!res.ok) {
let errDetail = res.statusText;
try { const errBody = await res.json(); errDetail = errBody.detail || errBody.error || errDetail; } catch {}
throw new Error(errDetail);
}
const data = await res.json();
if (data.error) throw new Error(data.error);
if (!data.image) throw new Error('No image returned from inpaint endpoint');
// Load result as a new layer and clip with the user-drawn mask
// so only the inpainted region is visible. Cache the
// 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 = () => {
if (!state.editorOpen) return; // user closed mid-decode
try {
saveState('Inpaint result');
// OpenAI returns at one of its allowed sizes (1024²,
// 1024×1536, 1536×1024) which often differs from our
// canvas. Scale to canvas size with smoothing so the
// inpaint blends in regardless of source dims.
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
resultLayer.ctx.imageSmoothingEnabled = true;
resultLayer.ctx.imageSmoothingQuality = 'high';
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
// Snapshot the AI result + hard mask used for this run.
const aiSnap = document.createElement('canvas');
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);
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
state.layers.push(resultLayer);
state.activeLayerId = resultLayer.id;
state.lastInpaintLayerId = resultLayer.id;
// Hide every mask sub-layer that contributed to the
// generation so the red overlay doesn't cover the result —
// but KEEP the mask pixels intact, and reflect "hidden"
// 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;
}
composite();
renderLayerPanel();
// Reveal post-generation Feather + Edge Stroke sliders.
// Cap Edge Stroke at ±padPx so the slider can't ask for
// more AI buffer than we generated.
const fRow = document.getElementById('ge-inpaint-postfeather-row');
const fSlider = document.getElementById('ge-feather-slider');
const fLabel = document.getElementById('ge-feather-label');
// Divider + heading are always visible; once Generate
// succeeds we hide the "Available after Generate" hint.
const divEl = document.getElementById('ge-inpaint-postedge-divider');
const titleEl = document.getElementById('ge-inpaint-postedge-title');
const hintEl = document.getElementById('ge-inpaint-postedge-hint');
if (divEl) divEl.style.display = '';
if (titleEl) titleEl.style.display = '';
if (hintEl) hintEl.style.display = 'none';
if (fRow) fRow.style.display = '';
if (fSlider) fSlider.value = '0';
if (fLabel) fLabel.textContent = '0px';
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
const eSlider = document.getElementById('ge-edgestroke-slider');
const eLabel = document.getElementById('ge-edgestroke-label');
if (eRow) eRow.style.display = '';
if (eSlider) {
eSlider.max = String(padPx);
eSlider.min = String(-padPx);
eSlider.value = '0';
}
if (eLabel) eLabel.textContent = '0px';
if (uiModule) uiModule.showToast('Inpaint complete — drag Edge feather / Edge stroke to blend', 5000);
} catch (renderErr) {
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);
} finally {
btn.disabled = false;
if (btnLabel) btnLabel.textContent = idleLabel;
if (runWp) { try { runWp.destroy(); } catch (_) {} }
if (canvasWp) { try { canvasWp.destroy(); } catch (_) {} }
if (canvasWpEl) { try { canvasWpEl.remove(); } catch (_) {} }
window.dispatchEvent(new CustomEvent('ge:inpaint-done'));
}
}
// Generate.
document.getElementById('ge-inpaint-run').addEventListener('click', async () => {
const prompt = document.getElementById('ge-inpaint-prompt')?.value?.trim();
if (!prompt) { if (uiModule) uiModule.showToast('Enter a prompt for inpainting'); return; }
const strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100;
await runInpaint({
prompt, strength,
btnId: 'ge-inpaint-run',
labelId: 'ge-inpaint-run-label',
idleLabel: 'Generate', busyLabel: 'Generating',
});
});
// Remove — detects backend type and substitutes a content-aware
// fill prompt. gpt-image-1 understands "remove …" semantically;
// 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');
const ep = (sel.endpoint || '').toLowerCase();
const isOpenAI = ep.includes('api.openai.com');
let prompt, strength;
if (isOpenAI) {
const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim();
prompt = userP
? `Remove ${userP}. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.`
: 'Remove the masked area. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.';
strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100;
} else {
// SDXL inpaint: describe the surroundings, not what's there.
// Crank strength to ensure the model fully overwrites the
// masked region — at low strength it would denoise toward
// what was there.
prompt = 'seamless natural background, photorealistic, continuation of surrounding scene, empty area, no objects, no people, no text, clean';
strength = 0.99;
}
await runInpaint({
prompt, strength,
btnId: 'ge-inpaint-remove',
labelId: 'ge-inpaint-remove-label',
idleLabel: 'Remove', busyLabel: 'Removing',
});
});
// Outpaint — auto-generate a mask covering empty (transparent)
// areas of the flattened composite, then run inpaint to fill them
// seamlessly. Mask is dilated ~12px so the AI sees adjacent
// opaque pixels as context. Ignores the user's drawn mask.
document.getElementById('ge-inpaint-outpaint').addEventListener('click', async () => {
// 1) Flatten visible layers to detect alpha=0 (empty) regions.
const flat = document.createElement('canvas');
flat.width = state.imgWidth; flat.height = state.imgHeight;
const fctx = flat.getContext('2d');
for (const layer of state.layers) {
if (!layer.visible) continue;
fctx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
fctx.drawImage(layer.canvas, off.x, off.y);
}
fctx.globalAlpha = 1;
const flatData = fctx.getImageData(0, 0, state.imgWidth, state.imgHeight).data;
// 2) White wherever the composite is transparent.
const maskRaw = document.createElement('canvas');
maskRaw.width = state.imgWidth; maskRaw.height = state.imgHeight;
const mrCtx = maskRaw.getContext('2d');
const mrImg = mrCtx.createImageData(state.imgWidth, state.imgHeight);
let emptyCount = 0;
for (let i = 0; i < flatData.length; i += 4) {
if (flatData[i + 3] === 0) {
mrImg.data[i] = 255;
mrImg.data[i + 1] = 255;
mrImg.data[i + 2] = 255;
mrImg.data[i + 3] = 255;
emptyCount++;
}
}
if (emptyCount === 0) {
if (uiModule) uiModule.showToast('No empty areas to outpaint — canvas is fully covered.');
return;
}
mrCtx.putImageData(mrImg, 0, 0);
// 3) Dilate the mask outward 12px so it overlaps a band of
// opaque pixels — context for the model to blend cleanly.
const expanded = document.createElement('canvas');
expanded.width = state.imgWidth; expanded.height = state.imgHeight;
const ectx = expanded.getContext('2d');
ectx.filter = 'blur(12px)';
ectx.drawImage(maskRaw, 0, 0);
ectx.filter = 'none';
const expData = ectx.getImageData(0, 0, state.imgWidth, state.imgHeight);
for (let i = 0; i < expData.data.length; i += 4) {
const a = expData.data[i + 3];
const v = a > 6 ? 255 : 0;
expData.data[i] = v;
expData.data[i + 1] = v;
expData.data[i + 2] = v;
expData.data[i + 3] = v;
}
ectx.putImageData(expData, 0, 0);
// 4) Temporarily replace the active mask sub-layer with the
// outpaint mask. Snapshot the previous so we can restore.
const mask = ensureActiveMaskLayer();
if (!mask) { if (uiModule) uiModule.showToast('No active layer for outpaint'); return; }
const savedMask = mask.ctx.getImageData(0, 0, mask.canvas.width, mask.canvas.height);
mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height);
mask.ctx.drawImage(expanded, 0, 0);
// 5) Prompt: prefer user input, else a generic fill.
const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim();
const prompt = userP || 'seamless natural continuation of the surrounding image, photorealistic, matching style, no objects, no people, no text';
const strength = 0.99;
try {
await runInpaint({
prompt, strength,
btnId: 'ge-inpaint-outpaint',
labelId: 'ge-inpaint-outpaint-label',
idleLabel: 'Outpaint', busyLabel: 'Outpainting',
});
} finally {
// Restore the user's previous mask drawing so subsequent
// Generate/Remove operates on what they actually drew.
mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height);
mask.ctx.putImageData(savedMask, 0, 0);
composite();
}
});
}

View File

@@ -0,0 +1,272 @@
/**
* AI model dropdown loader — fetches available model endpoints from
* the backend and populates the editor's three model-select surfaces:
*
* #ge-ai-model — global Gen picker
* #ge-ai-inpaint — inpaint picker
* select.ge-tool-model[data-ge-tool-model="…"]
* — per-tool pickers (harmonize / upscale / style /
* sharpen / etc.)
*
* Each model is filtered through a small capability classifier so the
* Gen dropdown only sees text-to-image models, the inpaint dropdown
* only sees image+mask edit models, and the per-tool dropdowns get
* everything img2img-capable.
*
* Every picker ends with a "+ Serve a model in Cookbook…" sentinel —
* choosing it opens Cookbook → Serve filtered to image models, then
* reverts the picker to its prior value (so it's an action, not a
* selectable model).
*
* @param {{
* container: HTMLElement,
* apiBase: string,
* openCookbookForImg2img: () => void,
* }} deps
*/
import { state } from './state.js';
// 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) {
const id = (modelId || '').toLowerCase();
const name = (endpointName || '').toLowerCase();
const type = (endpointType || '').toLowerCase();
// Reject anything obviously text-only.
const textOnly = /(?:^|[/\-_:])(gpt-?[345]|gpt-oss|claude|llama|qwen[^-]*chat|chat$|instruct$|coder)/i;
if (textOnly.test(id) && !/image/i.test(id)) return { gen: false, inpaint: false };
// 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 };
// 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)) {
const isInpaintModel = /inpaint|edit|fill/i.test(id) || /inpaint|edit|fill/i.test(name);
return { gen: !isInpaintModel || /base/i.test(id), inpaint: true };
}
// Self-hosted diffusion server: model id often matches the repo
// name; trust the endpoint name hint.
if (type === 'image') {
if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true };
return { gen: true, inpaint: true };
}
if (/inpaint|edit|fill/i.test(name)) return { gen: false, inpaint: true };
if (/diffus|flux|sd|image/i.test(name)) return { gen: true, inpaint: true };
// Editor image tools should be conservative. Unknown LLM/chat models
// do not belong in image generation or inpaint pickers.
return { gen: false, inpaint: false };
}
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
// option — catches clicks regardless of whether loadAIModels has
// rewired the individual select yet and survives any innerHTML
// reset later.
container.addEventListener('change', (e) => {
const sel = e.target.closest('select');
if (!sel) return;
if (sel.value !== '__serve_cookbook__') return;
// Revert to the previous selection so the sentinel isn't "stuck".
const prev = sel._prevServeValue ?? '';
sel.value = prev;
openCookbookForImg2img();
});
// Track prior value so we can restore it after the sentinel fires.
container.addEventListener('focus', (e) => {
const sel = e.target.closest('select');
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
}, true);
const aiGenSelect = document.getElementById('ge-ai-model');
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
// The global Gen model dropdown was removed from the editor topbar;
// only bail if there's nothing to populate at all (neither the Gen
// select nor the inpaint select nor any per-tool select).
if (!aiGenSelect && !aiInpaintSelect &&
!document.querySelector('select.ge-tool-model')) return;
async function loadAIModels(opts = {}) {
try {
const selectBaseUrl = opts.selectBaseUrl || '';
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/model-endpoints`);
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) {
if (!ep.is_enabled) continue;
const hasListedModels = Array.isArray(ep.models) && ep.models.length;
const models = hasListedModels ? ep.models : [''];
const isImageEndpoint = (ep.model_type || '').toLowerCase() === 'image';
// Image/inpaint endpoints can be called by URL even when their
// /models cache is still empty, so don't strand a freshly served
// Cookbook model as "(offline)" in the editor picker.
const epUsable = !!ep.online || isImageEndpoint;
for (const modelId of models) {
const caps = modelCaps(modelId || ep.name, ep.name, ep.model_type);
if (!caps.gen && !caps.inpaint) continue;
// Encode "<base_url>::<model_id>" so the value carries both pieces.
const value = `${ep.base_url}::${modelId}`;
const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url);
const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : '';
const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`;
if (caps.gen && aiGenSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
aiGenSelect.appendChild(opt);
if (epUsable && !firstGen) firstGen = value;
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedGen) selectedGen = value;
}
if (caps.inpaint && aiInpaintSelect) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
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 /
// style / upscale (anything that can do img2img).
if (caps.inpaint || caps.gen) {
for (const ts of perToolSelects) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
opt.disabled = !epUsable;
ts.appendChild(opt);
}
}
}
}
const hasValue = (sel, value) => !!value && [...sel.options].some(o => o.value === value);
if (aiGenSelect) {
if (selectedGen) aiGenSelect.value = selectedGen;
else if (hasValue(aiGenSelect, prevGenValue)) aiGenSelect.value = prevGenValue;
else if (firstGen) aiGenSelect.value = firstGen;
}
if (aiInpaintSelect) {
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
}
// Append the "Serve a model in Cookbook…" sentinel at the
// bottom of every model dropdown.
const appendServeSentinel = (sel) => {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '──────────';
sel.appendChild(sep);
const serveOpt = document.createElement('option');
serveOpt.value = '__serve_cookbook__';
serveOpt.textContent = '+ Serve a model in Cookbook…';
sel.appendChild(serveOpt);
};
for (const ts of perToolSelects) appendServeSentinel(ts);
if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
// Wire the sentinel on the Gen + Inpaint selects too.
const wireServeSentinel = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServeSentinel(aiGenSelect);
wireServeSentinel(aiInpaintSelect);
// Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) {
const key = 'ge-tool-model-' + ts.dataset.geToolModel;
try {
const saved = localStorage.getItem(key);
if (saved && [...ts.options].some(o => o.value === saved)) {
ts.value = saved;
}
} catch {}
let prevValue = ts.value;
ts.addEventListener('change', () => {
if (ts.value === '__serve_cookbook__') {
ts.value = prevValue;
openCookbookForImg2img();
return;
}
prevValue = ts.value;
try { localStorage.setItem(key, ts.value); } catch {}
});
}
} catch (e) {
// 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>';
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
const wireServe = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
}
}
loadAIModels();
const onModelEndpointsUpdated = (e) => {
if (!container.isConnected) {
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
// so a model served via Cookbook mid-edit shows up without having to
// close and reopen the editor. Debounced to one refresh per 3s so
// rapid open/close doesn't hammer the endpoint. Preserves the
// current selection across the reload.
let _lastModelRefresh = 0;
const refreshOnOpen = (e) => {
const sel = e.target.closest('#ge-ai-inpaint, select.ge-tool-model');
if (!sel) return;
const now = Date.now();
if (now - _lastModelRefresh < 3000) return;
_lastModelRefresh = now;
const keep = sel.value;
loadAIModels().then(() => {
// Restore the prior selection if it still exists.
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
});
};
container.addEventListener('mousedown', refreshOnOpen, true);
}

View File

@@ -0,0 +1,230 @@
/**
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
* tuner that runs on the most-recent rembg cutout.
*
* rembg-run button: flatten + POST to /api/image/remove-bg with an
* optional hint_mask if the user has a wand/lasso selection
* active. After the new layer lands, hides every previously-
* visible layer so the cutout reads cleanly, and binds the
* live-tuner to the new layer.
*
* Live edge-cleanup tuner: snapshots the pristine cutout the
* moment it lands; subsequent feather/grow slider tweaks
* rebuild the layer's alpha from that snapshot WITHOUT
* re-running the model.
* - Grow > 0 → blur snap alpha, threshold low (32) → grow.
* - Grow < 0 → blur snap alpha, threshold high (200) → shrink.
* - Feather > 0 → blur the whole layer (alpha + RGB) so the
* edge softens AND the residual color fringe from the
* original background gets blurred away.
*
* Sharpen: small slider + button; just calls _applyImageTool
* against /api/image/sharpen.
*
* buildSelectionHintMask: pure-ish utility — returns a base64 PNG
* (no data: prefix) of the active wand or lasso selection, or
* null. Returned so other wand-rembg call sites can use it.
*
* @param {{
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
* openCookbookForDependency: (pkg: string) => void,
* composite: () => void,
* renderLayerPanel: () => void,
* uiModule: object,
* }} deps
*
* @returns {{ buildSelectionHintMask: () => string | null }}
*/
import { state } from './state.js';
export function wireRembgAndSharpen({
applyImageTool, openCookbookForDependency,
composite, renderLayerPanel, uiModule,
}) {
// ── Sharpen ──
const sharpenPrev = document.getElementById('ge-sharpen-preview');
if (sharpenPrev) sharpenPrev.style.opacity = '0.5';
document.getElementById('ge-sharpen-amount')?.addEventListener('input', (e) => {
document.getElementById('ge-sharpen-label').textContent = e.target.value + '%';
if (sharpenPrev) sharpenPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
});
document.getElementById('ge-sharpen-run')?.addEventListener('click', () => {
const amount = parseInt(document.getElementById('ge-sharpen-amount')?.value || '50');
applyImageTool('/api/image/sharpen', { amount }, 'Sharpened', document.getElementById('ge-sharpen-run'));
});
// ── Bg Remove ──
document.getElementById('ge-rembg-install-link')?.addEventListener('click', () => {
openCookbookForDependency('rembg');
});
document.getElementById('ge-rembg-run')?.addEventListener('click', async () => {
const payload = {};
const hint = buildSelectionHintMask();
if (hint) payload.hint_mask = hint;
// NB: edge_feather / edge_grow are applied CLIENT-side so the
// sliders can re-tune the cutout without re-running the model.
const btn = document.getElementById('ge-rembg-run');
const before = state.layers.length;
// Snapshot which layers were visible BEFORE the run so we know
// 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.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
frames++;
}
if (state.layers.length > before) {
const newLayer = state.layers[state.layers.length - 1];
bindRembgLiveTuner(newLayer);
// Auto-hide underlying layers so the user sees just the
// cutout — the eye toggles back on if they re-enable manually.
for (const layer of state.layers) {
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
layer.visible = false;
}
}
composite();
renderLayerPanel();
}
// Reset sliders so the new cutout starts clean.
const f = document.getElementById('ge-rembg-feather');
const g = document.getElementById('ge-rembg-grow');
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
});
// ── Live edge-cleanup tuner ──
// Snapshots the pristine cutout the moment it lands; slider tweaks
// rebuild alpha from that snapshot.
function bindRembgLiveTuner(layer) {
if (!layer) return;
const w = layer.canvas.width, h = layer.canvas.height;
const snap = document.createElement('canvas');
snap.width = w; snap.height = h;
snap.getContext('2d').drawImage(layer.canvas, 0, 0);
state.rembgLiveLayer = layer;
state.rembgLiveSnap = snap;
rembgApplyEdgeNow(); // initial pass (no-op at 0/0)
}
let rembgRaf = null;
function scheduleRembgApply() {
if (rembgRaf) return;
rembgRaf = requestAnimationFrame(() => { rembgRaf = null; rembgApplyEdgeNow(); });
}
function rembgApplyEdgeNow() {
if (!state.rembgLiveLayer || !state.rembgLiveSnap) return;
const feather = parseInt(document.getElementById('ge-rembg-feather')?.value || '0', 10);
const grow = parseInt(document.getElementById('ge-rembg-grow')?.value || '0', 10);
const layer = state.rembgLiveLayer;
const snap = state.rembgLiveSnap;
const w = snap.width, h = snap.height;
const lctx = layer.ctx;
// 1) Start fresh from the pristine cutout snapshot.
lctx.clearRect(0, 0, w, h);
lctx.drawImage(snap, 0, 0);
// 2) Edge ±N — dilate / erode alpha via blur+threshold:
// grow > 0 → low threshold (32) → halo counts as opaque → grows.
// grow < 0 → high threshold (200) → only solid interior → shrinks.
// RGB is kept; only alpha is replaced.
if (grow !== 0) {
const blurC = document.createElement('canvas');
blurC.width = w; blurC.height = h;
const bctx = blurC.getContext('2d');
bctx.filter = `blur(${Math.abs(grow)}px)`;
bctx.drawImage(snap, 0, 0);
bctx.filter = 'none';
const blurred = bctx.getImageData(0, 0, w, h).data;
const layerData = lctx.getImageData(0, 0, w, h);
const out = layerData.data;
const thr = grow > 0 ? 32 : 200;
for (let i = 0; i < out.length; i += 4) {
out[i + 3] = blurred[i + 3] >= thr ? 255 : 0;
}
lctx.putImageData(layerData, 0, 0);
}
// 3) Feather softens whatever edge we have now. Blur the entire
// layer (alpha + RGB) — alpha gets smooth falloff, RGB gets a
// faint blur at the edge which actually helps hide residual
// colour fringing from the original background.
if (feather > 0) {
const fC = document.createElement('canvas');
fC.width = w; fC.height = h;
const fctx = fC.getContext('2d');
fctx.filter = `blur(${feather}px)`;
fctx.drawImage(layer.canvas, 0, 0);
fctx.filter = 'none';
lctx.clearRect(0, 0, w, h);
lctx.drawImage(fC, 0, 0);
}
composite();
}
// ── Slider preview swatches + wiring ──
const rembgFeatherPrev = document.getElementById('ge-rembg-feather-preview');
const rembgGrowPrev = document.getElementById('ge-rembg-grow-preview');
function syncRembgFeather(v) {
if (!rembgFeatherPrev) return;
const inner = Math.max(0, 50 - v * 2.5);
rembgFeatherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
}
function syncRembgGrow(v) {
if (!rembgGrowPrev) return;
// -10..+10 → scale 0.6 .. 1.4 so the swatch visibly grows/shrinks.
const s = 1 + v * 0.04;
rembgGrowPrev.style.transform = `scale(${s})`;
rembgGrowPrev.style.background = v < 0 ? 'color-mix(in srgb, var(--fg) 40%, transparent)' : 'var(--fg)';
}
syncRembgFeather(2);
syncRembgGrow(0);
document.getElementById('ge-rembg-feather')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
document.getElementById('ge-rembg-feather-label').textContent = v + 'px';
syncRembgFeather(v);
scheduleRembgApply();
});
document.getElementById('ge-rembg-grow')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
document.getElementById('ge-rembg-grow-label').textContent = (v >= 0 ? '+' : '') + v + 'px';
syncRembgGrow(v);
scheduleRembgApply();
});
// ── Selection-hint mask builder (used here + by wand-rembg) ──
// Full-image white-on-transparent mask PNG (base64, no `data:`
// prefix) of whichever selection is active — wand first, lasso
// second. Returns null if neither has a selection.
function buildSelectionHintMask() {
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);
return c.toDataURL('image/png').split(',')[1];
}
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
for (let i = 1; i < state.lassoPoints.length; i++) {
ctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
}
ctx.closePath();
ctx.fill();
return c.toDataURL('image/png').split(',')[1];
}
return null;
}
return { buildSelectionHintMask };
}

View File

@@ -0,0 +1,147 @@
/**
* Shared AI-tool runner. Used by Sharpen / Harmonize / Upscale / Style /
* Bg-Remove / etc. — every tool that flattens the document, POSTs the
* PNG to a server-side image endpoint, and drops the result back in
* as a new layer.
*
* Handles all the orchestration around the request:
*
* - Button busy state: swap label for "<verbing>…" + whirlpool
* spinner, lock width so the button doesn't visually jump.
* - Endpoint+model selection from the tool's own picker (or the
* global fallback) so the backend knows which model to invoke.
* - Response handling: decode the returned PNG, push it as a new
* layer, save state, composite, refresh the layer panel.
* - Error reporting: surface failures via toast. Detects "needs
* img2img server" and "package not installed" failure modes and
* surfaces an action-toast that opens Cookbook to fix.
*
* @param {{
* flatten: () => HTMLCanvasElement,
* saveState: (label?: string) => void,
* createLayer: (name: string, w: number, h: number) => object,
* composite: () => void,
* renderLayerPanel: () => void,
* deriveBusyLabel: (layerName: string) => string,
* getSelectedAIEndpoint: (type: string | null) => { endpoint?: string, model?: string },
* openCookbookForDependency: (pkg: string) => void,
* openCookbookForImg2img: () => void,
* spinnerModule: object,
* uiModule: object | null,
* }} deps
*
* @returns {(endpoint: string, extraPayload: object, layerName: string, btn: HTMLButtonElement, opts?: { busyLabel?: string }) => Promise<void>}
*/
import { state } from './state.js';
const KNOWN_DEPS = ['realesrgan', 'rembg'];
export function createApplyImageTool({
flatten, saveState, createLayer, composite, renderLayerPanel,
deriveBusyLabel, getSelectedAIEndpoint,
openCookbookForDependency, openCookbookForImg2img,
spinnerModule, uiModule,
}) {
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;
btn.classList.add('ge-btn-processing');
btn.style.minWidth = origWidth + 'px';
// Swap label for a "<verbing>…" text + whirlpool while the
// request runs. Falls back to deriving a busy label from
// layerName when the caller didn't supply one.
const busyLabel = (opts && opts.busyLabel) || deriveBusyLabel(layerName);
btn.innerHTML = '';
let btnSpinner = null;
try {
btnSpinner = spinnerModule.create('', 'clean', 'whirlpool');
const sp = btnSpinner.createElement();
btn.appendChild(sp);
const txt = document.createElement('span');
txt.className = 'ge-btn-busy-label';
txt.textContent = busyLabel;
btn.appendChild(txt);
btnSpinner.start();
} catch { btn.textContent = busyLabel; }
// 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 {
const flatCanvas = flatten();
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const body = { image: imageB64, ...extraPayload };
const res = await fetch(endpoint, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
let err = res.statusText;
try { const e = await res.json(); err = e.detail || e.error || err; } catch {}
throw new Error(err);
}
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)
saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
layer.ctx.drawImage(img, 0, 0);
state.layers.push(layer);
state.activeLayerId = layer.id;
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) {
// Detect known failure modes and surface an action-toast.
const msg = (e?.message || '').toLowerCase();
const needsImg2Img = (
msg.includes('img2img') ||
msg.includes('diffusion server') ||
msg.includes("doesn't expose")
);
let depMatch = null;
for (const pkg of KNOWN_DEPS) {
if (msg.includes(`${pkg} not installed`) || msg.includes(`no module named '${pkg}'`)) {
depMatch = pkg; break;
}
}
if (uiModule) {
if (depMatch && uiModule.showToast.length >= 2) {
uiModule.showToast(layerName + ' failed: ' + depMatch + ' is not installed on the server.', {
duration: 9000,
action: `Install ${depMatch}`,
onAction: () => openCookbookForDependency(depMatch),
});
} else if (needsImg2Img && uiModule.showToast.length >= 2) {
uiModule.showToast(layerName + ' failed: ' + e.message, {
duration: 9000,
action: 'Open Cookbook',
onAction: () => openCookbookForImg2img(),
});
} else {
uiModule.showToast(layerName + ' failed: ' + e.message, 6000);
}
}
} finally {
btn.disabled = false;
btn.classList.remove('ge-btn-processing');
try { btnSpinner?.destroy(); } catch {}
btn.innerHTML = origHTML;
btn.style.minWidth = '';
}
};
}

View File

@@ -0,0 +1,216 @@
/**
* Misc AI-tool wiring — the three AI tools that don't share the
* inpaint pipeline:
*
* Harmonize: Reinhard color transfer on a body mask (no AI redraw)
* + an optional narrow inpaint on a seam mask if the
* "Seam fix" slider > 0.
* Canvas 2×/4× Upscale: in-browser bicubic resampling, no server.
* AI Upscale: Real-ESRGAN via /api/image/upscale-local.
* Style Transfer: img2img via /api/gallery/style-transfer.
*
* Plus the small `_addEmptyLayer` helper and its toolbar wiring,
* since it lived next to these.
*
* @param {{
* apiBase: string,
* buildLayerBodyMask: (featherPx: number) => string | null,
* buildSeamMask: (featherPx: number) => string | null,
* applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise<void>,
* flatten: () => HTMLCanvasElement,
* saveState: (label?: string) => void,
* fitZoom: () => void,
* composite: () => void,
* createLayer: (name, w, h) => object,
* renderLayerPanel: () => void,
* spinnerModule: object,
* uiModule: object,
* }} deps
*
* @returns {{ addEmptyLayer: () => void }}
*/
import { state } from './state.js';
export function wireAIToolsMisc({
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
spinnerModule, uiModule,
}) {
// ── Harmonize sliders — Color match + Seam fix ──
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
const harmSeamPrev = document.getElementById('ge-harmonize-seam-preview');
document.getElementById('ge-harmonize-color')?.addEventListener('input', (e) => {
document.getElementById('ge-harmonize-color-label').textContent = (e.target.value / 100).toFixed(2);
if (harmColorPrev) harmColorPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
});
document.getElementById('ge-harmonize-seam')?.addEventListener('input', (e) => {
document.getElementById('ge-harmonize-seam-label').textContent = (e.target.value / 100).toFixed(2);
if (harmSeamPrev) harmSeamPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2);
});
// Harmonize button — two-stage:
// 1) Reinhard color transfer on body mask (no AI redraw)
// 2) Optional narrow inpaint on seam mask (if seam_fix > 0)
document.getElementById('ge-harmonize-run')?.addEventListener('click', () => {
const prompt = document.getElementById('ge-harmonize-prompt')?.value?.trim() || 'photorealistic, natural lighting, seamless blend';
const color_match = (parseInt(document.getElementById('ge-harmonize-color')?.value || '65')) / 100;
const seam_fix = (parseInt(document.getElementById('ge-harmonize-seam')?.value || '0')) / 100;
const bodyFeather = Math.max(6, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.012));
const seamFeather = Math.max(8, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.015));
const body_mask = buildLayerBodyMask(bodyFeather);
const seam_mask = seam_fix > 0.01 ? buildSeamMask(seamFeather) : null;
// Harmonize needs a non-base layer to color-match against the
// background. Without one, the server would fall back to legacy
// whole-image img2img — i.e. regenerate the whole photo. Block
// that and tell the user what's missing.
if (!body_mask) {
if (uiModule) uiModule.showToast('Harmonize needs a second layer pasted/imported over the base photo — nothing to color-match against.', 6000);
return;
}
const payload = { prompt, color_match, seam_fix, body_mask };
if (seam_mask) payload.seam_mask = seam_mask;
applyImageTool('/api/image/harmonize', payload, 'Harmonized', document.getElementById('ge-harmonize-run'));
});
// ── Canvas upscale (bicubic) ──
function canvasUpscale(factor) {
saveState(`Upscale ${factor}×`);
const newW = state.imgWidth * factor;
const newH = state.imgHeight * factor;
state.layers.forEach(l => {
const tmp = document.createElement('canvas');
tmp.width = newW; tmp.height = newH;
const tCtx = tmp.getContext('2d');
tCtx.imageSmoothingEnabled = true;
tCtx.imageSmoothingQuality = 'high';
tCtx.drawImage(l.canvas, 0, 0, newW, newH);
l.canvas.width = newW; l.canvas.height = newH;
l.ctx.drawImage(tmp, 0, 0);
});
if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; }
state.imgWidth = newW; state.imgHeight = newH;
state.mainCanvas.width = newW; state.mainCanvas.height = newH;
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
uiModule.showToast(`Upscaled ${factor}× to ${newW}×${newH}`);
}
document.getElementById('ge-upscale-2x')?.addEventListener('click', () => canvasUpscale(2));
document.getElementById('ge-upscale-4x')?.addEventListener('click', () => canvasUpscale(4));
// ── AI upscale (Real-ESRGAN, no diffusion server required) ──
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-upscale-ai');
const origHTML = btn.innerHTML;
btn.disabled = true;
let upWp = null;
try {
upWp = spinnerModule.createWhirlpool(14);
upWp.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;margin-right:6px;width:14px;height:14px;';
btn.innerHTML = '';
btn.appendChild(upWp.element);
const lbl = document.createElement('span');
lbl.textContent = 'Upscaling…';
btn.appendChild(lbl);
} catch (_) { btn.textContent = 'Upscaling…'; }
try {
const flat = flatten();
const imageB64 = flat.toDataURL('image/png').split(',')[1];
const res = await fetch('/api/image/upscale-local', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageB64, scale: 2 }),
});
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = new Image();
img.onload = () => {
if (!state.editorOpen) return;
saveState();
const newW = img.width, newH = img.height;
const layer = createLayer('AI Upscaled', newW, newH);
layer.ctx.drawImage(img, 0, 0);
state.layers.push(layer);
state.activeLayerId = layer.id;
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 sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
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);
}
try { upWp?.destroy(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = origHTML;
});
// ── Style transfer ──
document.getElementById('ge-style-strength')?.addEventListener('input', (e) => {
document.getElementById('ge-style-strength-label').textContent = (parseInt(e.target.value) / 100).toFixed(2);
});
document.getElementById('ge-style-run')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-style-run');
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...';
try {
const flat = flatten();
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
const fd = new FormData();
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 });
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.image) {
const img = new Image();
img.onload = () => {
if (!state.editorOpen) return;
saveState();
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
layer.ctx.drawImage(img, 0, 0, state.imgWidth, state.imgHeight);
state.layers.push(layer);
state.activeLayerId = layer.id;
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);
}
btn.disabled = false; btn.textContent = 'Apply Style';
});
// ── Add empty layer (used by the layer-panel header button + the
// Ctrl+Alt+J keyboard shortcut). Returned so keyboard-shortcuts.js
// can call it through the same path. ──
function addEmptyLayer() {
saveState('Add layer');
const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight);
state.layers.push(layer);
state.activeLayerId = layer.id;
renderLayerPanel();
composite();
}
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
return { addEmptyLayer };
}

366
static/js/editor/build/controls.js vendored Normal file
View File

@@ -0,0 +1,366 @@
/**
* Build the editor's right-panel controls innerHTML.
*
* Returns the string — caller creates the wrapper element, attaches its
* own touch / swipe-to-dismiss listeners, then sets innerHTML. Per-tool
* sections are all toggled `display:none` here; the tool-switch handler
* in galleryEditor.js shows the section matching the active tool.
*
* @param {{ color: string, brushSize: number, wandTolerance: number }} ctx
* @returns {string}
*/
export function controlsHTML({ color, brushSize, wandTolerance }) {
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
return `
<div id="ge-brush-controls">
<div class="ge-control-row" id="ge-color-row">
<label>Color</label>
<input type="color" class="ge-color-picker" value="${color}" />
</div>
<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>
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
<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>
<input type="range" id="ge-lasso-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-grow" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-grow-preview" aria-hidden="true"></span>
<label>Edge stroke <span id="ge-lasso-grow-label">0px</span></label>
<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)">
<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>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-delete" title="Delete selected pixels from the layer">
<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>
Delete
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-copy" title="Copy selection to new layer">
<svg width="12" height="12" 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>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-mask" title="Convert selection to inpaint mask">
<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>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
</div>
<div class="ge-wand-section" id="ge-wand-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next click combines with the current selection. Shift / Alt held during a click override this for one click.">
<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>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
<label>Tolerance <span id="ge-wand-tol-label">${wandTolerance}</span></label>
<button type="button" class="ge-btn ge-btn-sm ge-wand-live-btn" id="ge-wand-live" title="Retune selection while dragging tolerance" aria-pressed="false">Live</button>
<input type="range" id="ge-wand-tolerance" min="0" max="100" value="${wandTolerance}" />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-wand-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-wand-feather-label">0px</span></label>
<input type="range" id="ge-wand-feather" min="0" max="200" value="0" title="Soften the selection edge — feathers the mask alpha." />
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-wand-refine-grow" style="display:none;">
<span class="ge-eraser-preview" id="ge-wand-grow-preview" aria-hidden="true"></span>
<label>Edge stroke <span id="ge-wand-grow-label">0px</span></label>
<input type="range" id="ge-wand-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-mask-vis-btn visible" id="ge-wand-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-wand-clear" title="Clear the selection">
<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)">
<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>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-delete" title="Delete selected pixels from the layer">
<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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
Erase
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-copy" title="Copy selection to a new layer">
<svg width="12" height="12" 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>
Copy Layer
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-mask" title="Add selection to the inpaint mask">
<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>
</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>
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
<div class="ge-inpaint-popover-head" data-inpaint-drag>
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
<button class="ge-inpaint-popover-close" id="ge-inpaint-popover-close" type="button" title="Close inpaint panel" aria-label="Close inpaint panel">&times;</button>
</div>
<div class="ge-section-title ge-section-title-with-help"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
<p class="ge-section-hint" style="margin-top:0;">
Generates or removes from the mask you have selected. Set <strong>Strength</strong> before and adjust <strong>Edge feather / stroke</strong> after.
</p>
<div class="ge-section-title" style="margin-top:8px;display:flex;align-items:center;gap:6px;">
<span>Mask Brush</span>
<input type="color" class="ge-color-picker ge-inpaint-mask-color" value="#ff6e6e" title="Mask overlay color — purely visual, the model still sees a hard mask either way." />
</div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="Hold Ctrl+Alt to flip temporarily for a single stroke.">
<button type="button" class="ge-btn ge-btn-sm ge-inpaint-mode-btn active" id="ge-inpaint-mode-paint" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<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"><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>
Paint
</button>
<button type="button" class="ge-btn ge-btn-sm ge-inpaint-mode-btn" id="ge-inpaint-mode-erase" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
<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"><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>
Erase
</button>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-inpaint-brush-preview" aria-hidden="true"></span>
<label>Mask Brush Size <span id="ge-inpaint-brush-label">${brushSize}px</span></label>
<input type="range" id="ge-inpaint-brush-slider" min="0" max="1000" value="${brushSliderValue}" title="Brush diameter (log scale 1→800px). Use [ and ] for ±10%." />
</div>
<div class="ge-control-row ge-actions ge-inpaint-mask-row" style="margin-top:4px;">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-mask-vis-btn visible" id="ge-mask-vis" title="Hide mask">
<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="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 id="ge-mask-vis-label">Hide</span>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-inpaint-invert" title="Invert mask">
<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>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-inpaint-clear" title="Clear mask">
<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>
</div>
<hr class="ge-section-divider" />
<div class="ge-section-title" style="margin-top:8px;"><span>PROMPT</span></div>
<input type="text" class="ge-inpaint-prompt" id="ge-inpaint-prompt" placeholder="What to fill the masked area with..." />
<div class="ge-control-row ge-inpaint-model-row" style="margin-top:6px;">
<label for="ge-ai-inpaint">Model</label>
<select id="ge-ai-inpaint" class="ge-ai-model" title="Model for inpainting">
<option value="">Auto</option>
<option value="" disabled>──────────</option>
<option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row" style="margin-top:6px;">
<span class="ge-eraser-preview" id="ge-strength-preview" aria-hidden="true"></span>
<label>Strength <span id="ge-strength-label">0.75</span><span class="ge-section-help" tabindex="0" role="img" aria-label="Strength help" title="How much the AI redraws inside the mask. 0 = no change · 1 = full re-generation from your prompt. Recommended: 0.91.0 to add/replace an object, 0.60.8 to change material or color, 0.30.5 for subtle touch-ups. Default 0.75 works for most edits.">?</span></label>
<input type="range" id="ge-strength-slider" min="10" max="100" value="75" title="How much the AI redraws inside the mask (0 = no change, 1 = full diffusion)." />
</div>
<div class="ge-control-row ge-actions" style="margin-top:6px;display:flex;gap:6px;align-items:center;min-width:0;">
<button class="ge-btn ge-btn-primary ge-btn-ai" id="ge-inpaint-run" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Fill the masked area with what your prompt describes.">
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
<span id="ge-inpaint-run-label">Generate</span>
</button>
<button class="ge-btn ge-btn-ai" id="ge-inpaint-remove" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Erase the masked content and fill with the surrounding background. Ignores your prompt.">
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
<span id="ge-inpaint-remove-label">Remove</span>
</button>
<button class="ge-btn ge-btn-ai" id="ge-inpaint-outpaint" style="flex:1 1 0;display:inline-flex;align-items:center;justify-content:center;gap:6px;" title="Fill the empty (transparent) areas of the canvas with AI-generated content that blends with the existing image. Ignores your brush mask.">
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
<span id="ge-inpaint-outpaint-label">Outpaint</span>
</button>
</div>
<hr class="ge-section-divider" id="ge-inpaint-postedge-divider" style="margin-top:14px;" />
<div class="ge-section-title ge-section-title-with-help" id="ge-inpaint-postedge-title"><span>POSTPROCESS</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Live edge trimming for the last Inpaint Result layer. Edge feather softens the alpha boundary; Edge stroke expands (+) or contracts () the visible edge into the AI buffer that was generated around your brush.">?</span></div>
<p class="ge-section-hint" id="ge-inpaint-postedge-hint" style="margin-top:0;opacity:0.45;">
Available after Generate.
</p>
<div class="ge-control-row ge-eraser-row" id="ge-inpaint-postfeather-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-feather-preview" aria-hidden="true"></span>
<label>Edge feather <span id="ge-feather-label">0px</span></label>
<input type="range" id="ge-feather-slider" min="0" max="200" value="0" title="Blurs the inpaint result's alpha edge — drag to blend the AI fill into the surrounding image. Updates live." />
</div>
<div class="ge-control-row ge-eraser-row" id="ge-inpaint-edgestroke-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-edgestroke-preview" aria-hidden="true"></span>
<label>Edge stroke <span id="ge-edgestroke-label">0px</span></label>
<input type="range" id="ge-edgestroke-slider" min="-80" max="80" value="0" title="Expand (+) or contract () the inpaint layer's edge before feathering. Uses the AI buffer generated around your brush." />
</div>
</div>
<div class="ge-eraser-section" id="ge-clone-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>Clone</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How clone works" title="Alt-click (desktop) or double-tap (mobile) somewhere on the canvas to set the sample source. Then drag elsewhere to clone those pixels onto the active layer. The source point moves with your brush so the offset stays constant. Size / Opacity / Flow / Softness come from the Brush panel.">?</span></div>
<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-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>
<input type="range" id="ge-clone-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-clone-flow-label">100%</span></label>
<input type="range" id="ge-clone-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-clone-softness-label">100%</span></label>
<input type="range" id="ge-clone-softness" min="0" max="300" value="100" title="Soft brush edge — blurs each stamp for a feathered fade." />
</div>
</div>
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
<div class="ge-section-title">Brush</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>
<input type="range" id="ge-brush-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-brush-flow-label">100%</span></label>
<input type="range" id="ge-brush-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-brush-softness-label">100%</span></label>
<input type="range" id="ge-brush-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha for a feathered fade at the perimeter." />
</div>
</div>
<div class="ge-eraser-section" id="ge-eraser-section" style="display:none;">
<div class="ge-section-title">Eraser</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-eraser-opacity-label">100%</span></label>
<input type="range" id="ge-eraser-opacity" min="10" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-flow" aria-hidden="true"></span>
<label>Flow <span id="ge-eraser-flow-label">100%</span></label>
<input type="range" id="ge-eraser-flow" min="5" max="100" value="100" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-eraser-preview-softness" aria-hidden="true"></span>
<label>Softness <span id="ge-eraser-softness-label">100%</span></label>
<input type="range" id="ge-eraser-softness" min="0" max="300" value="100" title="Soft brush edge — blurs the stroke's alpha so the eraser fades out at the perimeter." />
</div>
</div>
<div class="ge-sharpen-section" id="ge-sharpen-section" style="display:none;">
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-sharpen-preview" aria-hidden="true"></span>
<label>Amount <span id="ge-sharpen-label">50%</span></label>
<input type="range" id="ge-sharpen-amount" min="10" max="100" value="50" />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;">
<button class="ge-btn ge-btn-primary" id="ge-sharpen-run">Sharpen</button>
</div>
</div>
<div class="ge-rembg-section" id="ge-rembg-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>Background Remove</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Runs an ML model that keeps whatever it learned to call the foreground (usually a person, product, or animal). If you have a Lasso or Wand selection active, it's used as a hint — the model only looks inside that region and anything outside is forced transparent.">?</span></div>
<div class="ge-dep-notice" id="ge-rembg-dep-missing" style="display:none;">
<div class="ge-dep-notice-text">
<strong>rembg not installed.</strong>
Background Remove needs the <code>rembg</code> package on this
server. Click to install it from Cookbook → Dependencies.
</div>
<button type="button" class="ge-btn ge-btn-sm" id="ge-rembg-install-link">Install rembg</button>
</div>
<div class="ge-control-row ge-actions" id="ge-rembg-run-row">
<button class="ge-btn ge-btn-primary ge-btn-ai" id="ge-rembg-run">
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
Bg Remove
</button>
</div>
<hr class="ge-section-divider" />
<div class="ge-section-title ge-section-title-with-help"><span>Edge cleanup</span><span class="ge-section-help" tabindex="0" role="img" aria-label="What this does" title="Live-applied to the last Bg Removed layer. Feather softens the edge; Edge nudges it inward () or outward (+).">?</span></div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-rembg-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-rembg-feather-label">0px</span></label>
<input type="range" id="ge-rembg-feather" min="0" max="20" value="0" />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-rembg-grow-preview" aria-hidden="true"></span>
<label>Edge <span id="ge-rembg-grow-label">0px</span></label>
<input type="range" id="ge-rembg-grow" min="-10" max="10" value="0" />
</div>
</div>
<div class="ge-import-section" id="ge-import-section" style="display:none;">
<p style="font-size:10px;opacity:0.5;margin:0 0 6px;">Import an image as a new layer. Drag to position it.</p>
<div class="ge-control-row ge-actions">
<button class="ge-btn" id="ge-import-file">File</button>
<button class="ge-btn" id="ge-import-paste">Clipboard</button>
<button class="ge-btn" id="ge-import-gallery">Gallery</button>
</div>
</div>
<div class="ge-harmonize-section" id="ge-harmonize-section" style="display:none;">
<div class="ge-section-title">Harmonize <span class="ge-section-help" tabindex="0" role="img" title="Blends pasted layers into the base photo. Color match shifts the layer's lighting/tone to match its surroundings (no pixel redraw). Seam fix uses inpaint to clean jagged cutout edges (needs a self-hosted img2img/inpaint model).">?</span></div>
<div class="ge-control-row ge-tool-model-row">
<label>Model</label>
<select class="ge-tool-model" data-ge-tool-model="harmonize" title="Model for harmonize">
<option value="">Auto</option>
</select>
</div>
<div class="ge-control-row">
<label style="font-size:11px;opacity:0.6;">Prompt (only used if Seam fix &gt; 0)</label>
</div>
<input type="text" class="ge-inpaint-prompt" id="ge-harmonize-prompt" placeholder="photorealistic, natural lighting, seamless blend..." />
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-harmonize-color-preview" aria-hidden="true"></span>
<label>Color match <span id="ge-harmonize-color-label">0.65</span></label>
<input type="range" id="ge-harmonize-color" min="0" max="100" value="65" title="How much of the Reinhard color/luminance shift to apply. 0 = no shift, 1 = fully match surroundings." />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-harmonize-seam-preview" aria-hidden="true"></span>
<label>Seam fix <span id="ge-harmonize-seam-label">0.00</span></label>
<input type="range" id="ge-harmonize-seam" min="0" max="100" value="0" title="Strength of the narrow inpaint pass on the alpha edge band. 0 = off, 1 = max blend at boundary." />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;">
<button class="ge-btn ge-btn-primary" id="ge-harmonize-run">Harmonize</button>
</div>
</div>
<div class="ge-style-section" id="ge-style-section" style="display:none;">
<p style="font-size:10px;opacity:0.5;margin:0 0 6px;">Apply an art style to the image using img2img. Requires a running diffusion model.</p>
<div class="ge-control-row ge-tool-model-row">
<label>Model</label>
<select class="ge-tool-model" data-ge-tool-model="style" title="Model for Style transfer">
<option value="">Auto</option>
</select>
</div>
<div class="ge-control-row">
<label style="font-size:11px;opacity:0.6;">Style prompt</label>
</div>
<input type="text" class="ge-inpaint-prompt" id="ge-style-prompt" placeholder="oil painting, impressionist, Van Gogh..." />
<div class="ge-control-row">
<label style="font-size:11px;opacity:0.6;">Strength <span id="ge-style-strength-label">0.55</span></label>
<input type="range" id="ge-style-strength" min="10" max="90" value="55" style="flex:1;" />
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;">
<button class="ge-btn ge-btn-primary" id="ge-style-run">Apply Style</button>
</div>
</div>
`;
}
/**
* Layer-panel header markup. Static; static IDs are wired by the caller.
* @returns {string}
*/
export function layerPanelHTML() {
return `<div class="ge-layers-header">
<span class="ge-layers-grab"></span>
<span class="ge-layers-title">Layers</span>
<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>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-all" title="Merge all" aria-label="Merge all">
<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 3v6M9 6l3-3 3 3M3 14h18M12 14v7M9 18l3 3 3-3"/></svg>
</button>
<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>`;
}

View File

@@ -0,0 +1,112 @@
/**
* Static markup for misc floating popups that live above the canvas.
*
* All pure DOM. Caller wires every ID via document.getElementById /
* el.querySelector after appending.
*/
/** Keyboard-shortcuts popover. */
export function shortcutsPopupHTML() {
return `
<div id="ge-shortcuts-handle" style="display:flex;align-items:center;gap:6px;margin:-4px -6px 4px;padding:4px 6px;cursor:grab;user-select:none;touch-action:none;">
<span style="display:inline-flex;flex-direction:column;gap:2px;margin-right:2px;opacity:0.35;">
<span style="display:block;width:18px;height:2px;border-radius:1px;background:currentColor;"></span>
<span style="display:block;width:18px;height:2px;border-radius:1px;background:currentColor;"></span>
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.8"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
<strong style="font-size:12px;letter-spacing:0.3px;">Editor Shortcuts</strong>
<span style="flex:1"></span>
<button id="ge-shortcuts-close" class="ge-btn ge-btn-sm" style="padding:0 6px;height:20px;line-height:1;background:none;border:none;opacity:0.55;cursor:pointer;color:var(--fg);">✖</button>
</div>
<div class="ge-shortcuts-grid">
<div class="ge-shortcuts-col">
<h5>Tools</h5>
<div><kbd>V</kbd> Move</div>
<div><kbd>T</kbd> Transform</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>L</kbd> Lasso</div>
<div><kbd>W</kbd> Wand</div>
<div><kbd>M</kbd> Inpaint</div>
<div><kbd>E</kbd> Eraser</div>
<div><kbd>C</kbd> Crop</div>
<div><kbd>S</kbd> Sharpen</div>
</div>
<div class="ge-shortcuts-col">
<h5>Edit</h5>
<div><kbd>Ctrl</kbd>+<kbd>Z</kbd> Undo</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd> Redo</div>
<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>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>Esc</kbd> Cancel selection / crop</div>
</div>
<div class="ge-shortcuts-col">
<h5>Brush / Mask</h5>
<div><kbd>[</kbd> Brush size </div>
<div><kbd>]</kbd> Brush size +</div>
<div>Drag tolerance slider → live wand retune</div>
</div>
</div>
<div style="margin-top:8px;font-size:10px;opacity:0.5;text-align:center;">Press <kbd>?</kbd> or click the keyboard icon to toggle.</div>
`;
}
/**
* History panel — sidebar listing all undo entries.
* @param {string} historyIcon Inline SVG markup for the title icon.
*/
export function historyPanelHTML(historyIcon) {
return `
<div class="ge-history-head" data-history-drag>
<span class="ge-adj-icon">${historyIcon}</span>
<span class="ge-history-title">History</span>
<span class="ge-head-btns">
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span>
</div>
<div class="ge-history-list" id="ge-history-list"></div>
`;
}
/**
* Empty-canvas size-prompt modal — body markup (caller controls show /
* hide and wires the Cancel / Create buttons).
*/
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-body">
<div class="ge-canvas-prompt-row">
<label class="ge-canvas-prompt-field">
<span>Width</span>
<input type="text" id="ge-canvas-prompt-w" inputmode="numeric" value="1024">
</label>
<span class="ge-canvas-prompt-x">×</span>
<label class="ge-canvas-prompt-field">
<span>Height</span>
<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>
<div class="modal-footer">
<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>
</div>`;
}

View File

@@ -0,0 +1,200 @@
/**
* Build the right-hand panel (controls + layers) — DOM creation,
* controls innerHTML population, mobile bottom-sheet swipe behavior,
* controls-panel re-parenting on mobile, slider value-chip layout
* normalization, layer-panel header + mobile peek/expand swipe, and
* the panel-width drag-resize handle.
*
* Owns its own event listeners (touch swipe gestures, mouse resize
* drag). Returns the `rightPanel` element + the `panelResize` handle
* + the inner `controls` element so the caller can wire any post-
* mount tweaks. Reads state.container (for mobile re-parenting) and
* state.color / state.brushSize / state.wandTolerance (initial slider
* values).
*
* @param {{
* controlsHTML: (ctx: {color, brushSize, wandTolerance}) => string,
* layerPanelHTML: () => string,
* }} build
*
* @returns {{
* rightPanel: HTMLDivElement,
* controls: HTMLDivElement,
* layerPanel: HTMLDivElement,
* panelResize: HTMLDivElement,
* }}
*/
import { state } from '../state.js';
export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
const rightPanel = document.createElement('div');
rightPanel.className = 'ge-right-panel';
// Controls section.
const controls = document.createElement('div');
controls.className = 'ge-controls';
// Swipe-down to dismiss on mobile. Tap the same tool again to bring
// the sheet back. Only the top ~40 px (grab handle area) initiates
// the gesture so taps on inputs/sliders inside the panel still work.
{
let sy = 0, dragging = false;
controls.addEventListener('touchstart', (e) => {
if (window.innerWidth > 700) return;
const rect = controls.getBoundingClientRect();
const t = e.touches[0];
// Only engage if touch starts in the top grab zone.
if (t.clientY - rect.top > 40) return;
sy = t.clientY;
dragging = true;
controls.style.transition = 'none';
}, { passive: true });
controls.addEventListener('touchmove', (e) => {
if (!dragging) return;
const dy = e.touches[0].clientY - sy;
if (dy > 0) controls.style.transform = `translateY(${dy}px)`;
}, { passive: true });
controls.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
const dy = e.changedTouches[0].clientY - sy;
controls.style.transition = '';
controls.style.transform = '';
if (dy > 60) controls.classList.add('dismissed');
});
}
controls.innerHTML = controlsHTML({
color: state.color,
brushSize: state.brushSize,
wandTolerance: state.wandTolerance,
});
rightPanel.appendChild(controls);
// Mobile only (≤ 700 px — matches the .ge-editor-body column-stack
// breakpoint): the right panel becomes a transformed bottom-sheet,
// so any position:fixed descendant gets trapped by the transform
// and rides along with the panel. Re-parent the controls panel to
// the editor root so it can truly fix to the viewport bottom
// regardless of the layers-sheet state. On desktop, controls stay
// docked inside the right panel above the layers list.
if (window.innerWidth <= 700 && state.container) {
state.container.appendChild(controls);
}
// Move every slider-row's value chip out of its <label> and place
// it AFTER the slider, so the value sits on the right edge of the
// row instead of being smashed against the slider track on the left.
controls.querySelectorAll('.ge-eraser-row').forEach(row => {
const valueSpan = row.querySelector('label > span[id$="-label"]');
const slider = row.querySelector('input[type="range"]');
if (valueSpan && slider) {
valueSpan.classList.add('ge-slider-value');
slider.after(valueSpan);
}
});
// Layer panel.
const layerPanel = document.createElement('div');
layerPanel.className = 'ge-layers';
layerPanel.innerHTML = layerPanelHTML();
rightPanel.appendChild(layerPanel);
// Mobile: tap the header grab handle or swipe up/down to toggle
// the layers sheet between peek and expanded. The peek state
// always shows the active layer so users never lose access to it.
{
const header = layerPanel.querySelector('.ge-layers-header');
if (header) {
let sy = 0, sx = 0, dragging = false, didSwipe = false;
header.addEventListener('touchstart', (e) => {
if (window.innerWidth > 700) return;
if (e.target.closest('button')) return;
sy = e.touches[0].clientY;
sx = e.touches[0].clientX;
dragging = true;
didSwipe = false;
}, { passive: true });
header.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
const dy = e.changedTouches[0].clientY - sy;
const dx = Math.abs(e.changedTouches[0].clientX - sx);
// Real swipe — three states cycle by direction:
// minimized → peek → expanded (swipe up)
// expanded → peek → minimized (swipe down)
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
didSwipe = true;
const isExpanded = rightPanel.classList.contains('expanded');
const isMinimized = rightPanel.classList.contains('minimized');
if (dy < 0) {
if (isMinimized) {
rightPanel.classList.remove('minimized');
} else if (!isExpanded) {
rightPanel.classList.add('expanded');
}
} else {
if (isExpanded) {
rightPanel.classList.remove('expanded');
} else if (!isMinimized) {
rightPanel.classList.add('minimized');
}
}
e.preventDefault();
}
});
header.addEventListener('click', (e) => {
if (window.innerWidth > 700) return;
if (e.target.closest('button')) return;
if (didSwipe) { didSwipe = false; return; }
// 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).
if (rightPanel.classList.contains('minimized')) {
rightPanel.classList.remove('minimized');
} else {
rightPanel.classList.toggle('expanded');
}
});
}
}
// Horizontal drag handle on the LEFT edge of the right panel — drag
// left to widen, right to narrow. Persists chosen width in
// localStorage so it survives reopens. (Earlier version was a
// vertical-drag for height; horizontal feels more natural since
// cramped LAYER ROWS are about width, not height.)
const panelResize = document.createElement('div');
panelResize.className = 'ge-panel-resize';
panelResize.title = 'Drag to resize panel';
rightPanel.appendChild(panelResize);
try {
const savedW = parseInt(localStorage.getItem('ge-right-panel-width') || '', 10);
if (savedW && savedW > 160 && savedW < 800) rightPanel.style.flex = `0 0 ${savedW}px`;
} catch {}
let panelResizing = false;
let panelStartX = 0;
let panelStartW = 0;
panelResize.addEventListener('mousedown', (e) => {
panelResizing = true;
panelStartX = e.clientX;
panelStartW = rightPanel.getBoundingClientRect().width;
e.preventDefault();
document.body.style.cursor = 'ew-resize';
});
document.addEventListener('mousemove', (e) => {
if (!panelResizing) return;
// Dragging left → wider panel (the panel sits on the right of
// the editor, so a leftward drag pulls its left edge left).
const delta = panelStartX - e.clientX;
const next = Math.max(160, Math.min(window.innerWidth - 200, panelStartW + delta));
rightPanel.style.flex = `0 0 ${next}px`;
});
document.addEventListener('mouseup', () => {
if (!panelResizing) return;
panelResizing = false;
document.body.style.cursor = '';
try {
const w = Math.round(rightPanel.getBoundingClientRect().width);
localStorage.setItem('ge-right-panel-width', String(w));
} catch {}
});
return { rightPanel, controls, layerPanel, panelResize };
}

View File

@@ -0,0 +1,73 @@
/**
* Build the editor's left-side tool palette.
*
* Pure DOM construction — no module state. The big tool-switch logic
* (cursor swap, control-section toggle, transform entry, inpaint
* mask plumbing, etc.) stays in the caller and arrives here as the
* `onSelectTool` callback.
*
* @param {{
* currentTool: string,
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
* onClearSelection: (which: 'lasso'|'wand') => void,
* }} ctx
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
*/
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' },
{ 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' },
{ 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' },
{ 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: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
{ id: 'sharpen', ai: true, label: 'Sharpen', icon: '◈', key: 'S' },
];
const toolKeyMap = {};
for (const t of tools) {
if (t.sep) {
const sep = document.createElement('div');
sep.className = 'ge-tool-sep';
sep.textContent = t.label;
toolbar.appendChild(sep);
continue;
}
if (t.key) toolKeyMap[t.key.toLowerCase()] = t.id;
const btn = document.createElement('button');
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
btn.dataset.tool = t.id;
btn.title = t.label + (t.key ? ` (${t.key})` : '');
// 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.
const aiStar = t.ai ? '<span class="ge-tool-ai" title="AI">✦</span>' : '';
btn.classList.toggle('is-ai', !!t.ai);
// Selection-clear badge — rendered only for tools that can hold a
// selection (lasso, wand). Inpaint masks are first-class sub-layers
// now so they get their own delete-X in the layer panel.
const clearBadge = (t.id === 'lasso' || t.id === 'wand')
? '<span class="ge-tool-clear" title="Clear selection" 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>'
: '';
btn.innerHTML = `${aiStar}<span class="ge-tool-icon"${t.small ? ' style="font-size:14px"' : ''}>${t.icon}</span><span class="ge-tool-label">${t.label}</span>${clearBadge}`;
// Clear-badge click stops propagation so the tool itself doesn't
// toggle; the actual clear is handled by the caller.
btn.querySelector('.ge-tool-clear')?.addEventListener('click', (ev) => {
ev.stopPropagation();
onClearSelection(ev.currentTarget.dataset.clearTool);
});
btn.addEventListener('click', () => onSelectTool(t.id, btn, toolbar));
toolbar.appendChild(btn);
}
return { toolbar, toolKeyMap };
}

View File

@@ -0,0 +1,131 @@
/**
* Build the editor's top bar (undo/redo/history, zoom group, Image
* menu, Filter menu, Selection-edge menu, Shortcuts, Import, Save).
*
* Pure DOM — no module state, no event listeners. All wiring is done
* by the caller via `document.getElementById(...)` against the IDs
* baked into the markup.
*
* @returns {HTMLDivElement}
*/
export function buildTopbar() {
const topBar = document.createElement('div');
topBar.className = 'ge-topbar';
topBar.innerHTML = `
<div class="ge-topbar-left">
<span class="ge-alpha-badge" title="This editor is in active development — expect rough edges">ALPHA</span>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-undo" title="Undo">
<span class="ge-stacked-glyph">↩</span>
<span class="ge-stacked-label">UNDO</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-redo" title="Redo">
<span class="ge-stacked-glyph">↪</span>
<span class="ge-stacked-label">REDO</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-history-btn" title="History — click an entry to jump to that state" aria-label="History">
<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">&minus;</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>
<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-image-wrap">
<button class="ge-btn ge-btn-sm" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">Image ▾</button>
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
<button class="dropdown-item-compact" data-image-action="resize">
<span class="dropdown-icon">⤢</span>
<span>Canvas…</span>
</button>
<div class="ge-filter-submenu-label">Transform</div>
<button class="dropdown-item-compact" data-image-action="rotate-90">
<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="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 9 15 9"/></svg></span>
<span>Rotate 90° CW</span>
</button>
<button class="dropdown-item-compact" data-image-action="rotate-180">
<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"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></span>
<span>Rotate 180°</span>
</button>
<button class="dropdown-item-compact" data-image-action="flip-h">
<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="M21 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="7 11 4 7 7 3"/><polyline points="17 11 20 7 17 3"/></svg></span>
<span>Flip horizontal</span>
</button>
<button class="dropdown-item-compact" data-image-action="flip-v">
<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="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><line x1="3" y1="12" x2="21" y2="12"/><polyline points="11 7 7 4 3 7"/><polyline points="11 17 7 20 3 17"/></svg></span>
<span>Flip vertical</span>
</button>
</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>
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
<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>
<span>Gaussian Blur…</span>
</button>
<button class="dropdown-item-compact" data-filter-action="blur-zoom">
<span class="dropdown-icon ge-blur-icon ge-blur-zoom" aria-hidden="true"></span>
<span>Zoom Blur…</span>
</button>
</div>
</div>
<span class="ge-topbar-sep"></span>
<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>
<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>
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
<div class="dropdown-section-label">Image</div>
<button class="dropdown-item-compact" id="ge-save" title="Overwrite the original image">
<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="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></span>
<span>Save over original</span>
<span class="dropdown-shortcut">Ctrl+S</span>
</button>
<button class="dropdown-item-compact" id="ge-export-gallery" title="Save as a new image in the gallery">
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg></span>
<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">
<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>
</button>
<div class="dropdown-section-divider"></div>
<div class="dropdown-section-label">Project</div>
<button class="dropdown-item-compact" id="ge-save-project" title="Save layered project (.json) — keeps every layer editable for later">
<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="3" y="3" width="13" height="13" rx="1"/><rect x="8" y="8" width="13" height="13" rx="1"/></svg></span>
<span>Save project (.json)</span>
</button>
<button class="dropdown-item-compact" id="ge-load-project" title="Open a previously-saved project file">
<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span>Load project…</span>
</button>
</div>
</div>
</div>
`;
return topBar;
}

View File

@@ -0,0 +1,109 @@
/**
* Static markup for the Transform popup that floats over the canvas
* when the user activates the Resize/Transform tool.
*
* Pure DOM — no module state, no event listeners. The caller wires all
* IDs via document.getElementById / pop.querySelector.
*
* @returns {string}
*/
export function transformPopupHTML() {
return `
<div class="ge-adj-head ge-transform-popup-head" data-transform-drag>
<span class="ge-adj-icon">
<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="3 7 7 3 11 7"/><line x1="7" y1="3" x2="7" y2="21"/><polyline points="21 17 17 21 13 17"/><line x1="17" y1="21" x2="17" y2="3"/></svg>
</span>
<span class="ge-adj-title">Transform</span>
<button type="button" id="ge-transform-aspect" class="ge-transform-aspect-btn" 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"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</button>
<span class="ge-head-btns">
<button class="ge-adj-min" type="button" title="Minimise" id="ge-transform-min">&minus;</button>
<button class="ge-adj-close" type="button" title="Cancel" id="ge-transform-cancel">&times;</button>
</span>
</div>
<div class="ge-transform-popup-body">
<div class="ge-transform-field">
<label>W</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
<span class="ge-transform-spin" data-spin-for="ge-transform-w">
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease width"></button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase width">+</button>
</span>
</div>
<div class="ge-transform-field">
<label>H</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-h" step="1" />
<span class="ge-transform-spin" data-spin-for="ge-transform-h">
<button type="button" data-spin="down" tabindex="-1" aria-label="Decrease height"></button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Increase height">+</button>
</span>
</div>
<div class="ge-row-break"></div>
<div class="ge-transform-field">
<label>↻</label>
<input type="number" class="ge-transform-popup-input ge-transform-popup-input-rot" id="ge-transform-rot" step="1" value="0" />
<span class="ge-transform-spin" data-spin-for="ge-transform-rot">
<button type="button" data-spin="down" tabindex="-1" aria-label="Rotate -1°"></button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
</span>
</div>
<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>
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
`;
}
/**
* Wire a `<span class="ge-transform-spin">…<button data-spin="up|down"/>…</span>`
* group with tap-to-tick + hold-to-repeat. After 1.5 s the repeat
* accelerates from 70ms→30ms intervals so users can rapidly scrub a
* numeric field without mashing the button.
*
* On each tick, the helper looks up the target `<input>` by the
* spin-group's `data-spin-for` attribute and dispatches an `input`
* event so the rest of the popup's wiring picks up the change.
*
* @param {HTMLElement} root Element that owns one or more spin groups
* (e.g. the transform popup).
*/
export function attachSpinRepeat(root) {
root.querySelectorAll('.ge-transform-spin button').forEach(btn => {
const tick = (shift) => {
const targetId = btn.parentElement?.dataset?.spinFor;
if (!targetId) return;
const input = root.querySelector('#' + CSS.escape(targetId));
if (!input || input.readOnly) return;
const step = shift ? 10 : 1;
const cur = parseInt(input.value, 10) || 0;
const next = btn.dataset.spin === 'up' ? cur + step : cur - step;
input.value = String(next);
input.dispatchEvent(new Event('input', { bubbles: true }));
};
let holdTimeout = null, repeatInterval = null, started = 0;
btn.addEventListener('pointerdown', (e) => {
e.preventDefault();
tick(e.shiftKey);
started = Date.now();
holdTimeout = setTimeout(() => {
repeatInterval = setInterval(() => {
tick(false);
if (Date.now() - started > 1500 && repeatInterval) {
clearInterval(repeatInterval);
repeatInterval = setInterval(() => tick(false), 30);
}
}, 70);
}, 350);
});
const endHold = () => {
if (holdTimeout) clearTimeout(holdTimeout);
if (repeatInterval) clearInterval(repeatInterval);
holdTimeout = null; repeatInterval = null;
};
btn.addEventListener('pointerup', endHold);
btn.addEventListener('pointerleave', endHold);
btn.addEventListener('pointercancel', endHold);
});
}

View File

@@ -0,0 +1,21 @@
/**
* Convert a pointer event's client coordinates into the canvas's
* internal pixel coordinates, accounting for current display scale.
*
* Handles both mouse and the first finger of a touch event.
*
* @param {MouseEvent|TouchEvent} e
* @param {HTMLCanvasElement} canvas
* @returns {{x: number, y: number}}
*/
export function canvasCoords(e, canvas) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
};
}

View File

@@ -0,0 +1,197 @@
/**
* Canvas event wiring — mouse, touch (including pinch-zoom on two
* fingers), and the canvas-area pan handler.
*
* Mouse:
* mousedown on canvas → beginDraw
* mousemove on window → continueDraw (window so a drag can
* continue past the canvas edge)
* mouseup on window → endDraw
* mouseenter/mouseleave → show/hide the brush-cursor overlay
* mousedown on canvas-area (NOT on the canvas itself, lasso only)
* → beginDraw (lasso starts outside canvas)
*
* Touch:
* touchstart 1 finger → beginDraw
* touchmove 1 finger → continueDraw
* touchend / touchcancel → endDraw
* 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.
*
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
* the pan offset.
*
* @param {{
* canvasArea: HTMLDivElement,
* beginDraw: (e: Event) => void,
* continueDraw: (e: Event) => void,
* endDraw: (e?: Event) => void,
* updateBrushCursor: (e: Event) => void,
* syncZoomControls?: () => void,
* }} ctx
*/
import { state } from './state.js';
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
// 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);
// 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.
canvasArea.addEventListener('mousedown', (e) => {
if (state.tool !== 'lasso') return;
if (e.target === state.mainCanvas) return; // already handled
beginDraw(e);
});
state.mainCanvas.addEventListener('mouseenter', (e) => {
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].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';
});
// Touch — single finger draws; two fingers pan + pinch-zoom.
let multiActive = false;
let multiStartDist = 0;
let multiStartZoom = 1;
let multiStartCenter = { x: 0, y: 0 };
let multiStartPan = { x: 0, y: 0 };
const touchInfo = (e) => {
const t1 = e.touches[0], t2 = e.touches[1];
const cx = (t1.clientX + t2.clientX) / 2;
const cy = (t1.clientY + t2.clientY) / 2;
const dx = t2.clientX - t1.clientX;
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;
};
state.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length >= 2) {
// End any in-progress single-finger draw before switching modes.
if (!multiActive) endDraw();
multiActive = true;
const info = touchInfo(e);
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,
};
return;
}
if (multiActive) return;
beginDraw(e);
}, { passive: false });
state.mainCanvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (multiActive && e.touches.length >= 2) {
const info = touchInfo(e);
const ratio = info.dist / Math.max(1, multiStartDist);
const newZoom = Math.max(0.1, Math.min(5, multiStartZoom * ratio));
if (Math.abs(newZoom - state.zoom) > 0.001) {
state.zoom = newZoom;
state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.();
}
const dx = info.cx - multiStartCenter.x;
const dy = info.cy - multiStartCenter.y;
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
return;
}
if (multiActive) return;
continueDraw(e);
}, { passive: false });
state.mainCanvas.addEventListener('touchend', (e) => {
if (multiActive) {
if (e.touches.length < 2) multiActive = false;
return;
}
endDraw(e);
});
state.mainCanvas.addEventListener('touchcancel', () => {
multiActive = false;
endDraw();
});
// 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.
let panning = false;
let pid = 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 };
};
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;
};
canvasArea.addEventListener('pointerdown', (e) => {
if (state.tool === 'lasso') return;
if (e.target === state.mainCanvas || e.target === state.transformOverlay) 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;
// 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) {
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;
}
const off = getOffset();
panning = true;
pid = e.pointerId;
startX = e.clientX - off.x;
startY = e.clientY - off.y;
try { canvasArea.setPointerCapture(pid); } catch {}
canvasArea.style.cursor = 'grabbing';
e.preventDefault();
});
canvasArea.addEventListener('pointermove', (e) => {
if (!panning || e.pointerId !== pid) return;
applyOffset(e.clientX - startX, e.clientY - startY);
});
const endPan = () => {
if (!panning) return;
panning = false;
try { canvasArea.releasePointerCapture(pid); } catch {}
pid = null;
canvasArea.style.cursor = '';
};
canvasArea.addEventListener('pointerup', endPan);
canvasArea.addEventListener('pointercancel', endPan);
// Reset offset whenever zoom/fit changes the canvas size.
canvasArea._resetPan = () => applyOffset(0, 0);
}

View File

@@ -0,0 +1,132 @@
/**
* Whole-document transforms: rotate by 90/180/270° or flip horizontal/
* vertical. These mutate every layer's canvas + the offset map + the
* document's overall width/height so the result feels like the whole
* image rotated as one piece.
*
* Pure-ish — reads/writes shared state directly; the factory takes a
* small dep bag for the orchestration plumbing (undo snapshot, canvas
* loading overlay, fit-zoom-to-viewport, composite redraw).
*
* @param {{
* saveState: (label?: string) => void,
* composite: () => void,
* fitZoom: () => void,
* showCanvasLoading: (label: string) => void,
* hideCanvasLoading: () => void,
* }} deps
*/
import { state } from './state.js';
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
return {
/**
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
* swap canvas dimensions. Each layer is rotated around its own
* centre, then its centre is rotated around the old image centre
* and translated into the new image's frame.
*
* Wrapped in requestAnimationFrame because the rotation pass can
* block the UI for 0.52 s on big images — the spinner overlay
* paints before we block.
*/
rotateAll(deg) {
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 sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
} finally {
hideCanvasLoading();
}
});
},
/**
* Mirror every layer horizontally ('h') or vertically ('v').
* Canvas dimensions don't change. Each layer offset is reflected
* around the image centre.
*/
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 });
}
}
composite();
},
};
}

View File

@@ -0,0 +1,24 @@
/**
* Paint a transparency-checkerboard pattern across the given canvas
* context. The editor uses this beneath every layer pass so empty
* (transparent) areas of the document are visible.
*
* Pure function — depends only on its arguments.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} w Width in canvas pixels.
* @param {number} h Height in canvas pixels.
*/
export function drawCheckerboard(ctx, w, h) {
const size = 10;
ctx.fillStyle = '#ccc';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#fff';
for (let y = 0; y < h; y += size) {
for (let x = 0; x < w; x += size) {
if ((Math.floor(x / size) + Math.floor(y / size)) % 2 === 0) {
ctx.fillRect(x, y, size, size);
}
}
}
}

View File

@@ -0,0 +1,133 @@
/**
* Paste + drag-and-drop import handlers. Both add an image to the
* editor as a new layer:
*
* - Paste (Ctrl+V): checks `state.internalClipboard` first (set by
* lasso copy/cut), then falls back to the system clipboard's
* `image/*` items. Layer is named "Pasted Selection" or "Pasted"
* and becomes active; the tool snaps to Move so the user can
* reposition it immediately.
* - Drop: any `image/*` file dragged from the OS / another tab.
* Shows a "Drop image to add as new layer" overlay mid-drag. Each
* dropped image is routed through `handleImportedImage` so canvas-
* resize prompts + undo history work the same as the toolbar
* Import button.
*
* Both gated by `state.editorOpen` so they're inert when the editor
* is closed (other listeners on the page get first dibs).
*
* @param {{
* container: HTMLElement,
* saveState: (label?: string) => void,
* createLayer: (name: string, w: number, h: number) => object,
* renderLayerPanel: () => void,
* composite: () => void,
* handleImportedImage: (img: HTMLImageElement) => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
export function wireClipboardAndDrop({
container, saveState, createLayer, renderLayerPanel, composite,
handleImportedImage, uiModule,
}) {
// ── Paste ──
window.addEventListener('paste', (e) => {
if (!state.editorOpen) return;
function pasteAsLayer(imgSource, label) {
if (!state.editorOpen) return; // user closed mid-paste
saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
layer.ctx.drawImage(imgSource, 0, 0);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.tool = 'move';
const tb = state.container?.querySelector('.ge-toolbar');
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
renderLayerPanel();
composite();
uiModule.showToast('Pasted as new layer');
}
// Check internal clipboard first (from Ctrl+C lasso/wand).
if (state.internalClipboard) {
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 url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
img.src = url;
break;
}
}, true); // capture phase so we beat chat input
// ── Drag-and-drop ──
// Visual drop-zone overlay appears mid-drag; routes via
// handleImportedImage so the import respects canvas resizing rules
// + saves history (same path as the toolbar Import button).
const dropZone = container;
if (!dropZone) return;
let dragDepth = 0;
const hasFileType = (dt) => dt && Array.from(dt.types || []).some(t => t === 'Files');
const showOverlay = () => {
if (!state.editorOpen) return;
let ov = dropZone.querySelector('.ge-drop-overlay');
if (!ov) {
ov = document.createElement('div');
ov.className = 'ge-drop-overlay';
ov.innerHTML = '<div class="ge-drop-overlay-msg">Drop image to add as new layer</div>';
dropZone.appendChild(ov);
}
ov.style.display = '';
};
const hideOverlay = () => {
const ov = dropZone.querySelector('.ge-drop-overlay');
if (ov) ov.style.display = 'none';
};
dropZone.addEventListener('dragenter', (e) => {
if (!state.editorOpen || !hasFileType(e.dataTransfer)) return;
e.preventDefault();
dragDepth++;
showOverlay();
});
dropZone.addEventListener('dragover', (e) => {
if (!state.editorOpen || !hasFileType(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
dropZone.addEventListener('dragleave', () => {
if (!state.editorOpen) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) hideOverlay();
});
dropZone.addEventListener('drop', (e) => {
if (!state.editorOpen) return;
dragDepth = 0;
hideOverlay();
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type.startsWith('image/'));
if (!files.length) return;
e.preventDefault();
e.stopPropagation();
for (const f of files) {
const url = URL.createObjectURL(f);
const img = new Image();
img.onload = () => { handleImportedImage(img); URL.revokeObjectURL(url); };
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
});
}

View File

@@ -0,0 +1,83 @@
/**
* Pure composite helpers — flatten a layer list into a single canvas
* for thumbnails / merged-mask use.
*
* Both helpers are stateless: the caller passes everything they need
* (layer list, canvas dimensions, an offsets lookup). The legacy
* gallery editor's module-level functions wrap these with their own
* state.
*/
/**
* Cheap downscaled preview composited from all visible layers.
* Returns a JPEG dataURL, or null when there's nothing to draw.
*
* @param {Array<{visible: boolean, opacity: number, id: string, canvas: HTMLCanvasElement}>} layers
* @param {number} imgW Document width in canvas pixels.
* @param {number} imgH Document height in canvas pixels.
* @param {Map<string,{x:number,y:number}>} offsets Layer offsets, keyed by id.
* @param {number} maxDim Longest-edge target in CSS pixels.
* @param {number} quality JPEG quality 0..1.
* @returns {string|null}
*/
export function buildThumbnail(layers, imgW, imgH, offsets, maxDim, quality = 0.6) {
if (!imgW || !imgH) return null;
try {
const scale = Math.min(1, maxDim / Math.max(imgW, imgH));
const tw = Math.max(1, Math.round(imgW * scale));
const th = Math.max(1, Math.round(imgH * scale));
const c = document.createElement('canvas');
c.width = tw; c.height = th;
const ctx = c.getContext('2d');
for (const layer of layers) {
if (!layer.visible) continue;
ctx.globalAlpha = layer.opacity;
const off = offsets.get(layer.id) || { x: 0, y: 0 };
ctx.drawImage(
layer.canvas,
off.x * scale, off.y * scale,
layer.canvas.width * scale, layer.canvas.height * scale,
);
}
ctx.globalAlpha = 1;
return c.toDataURL('image/jpeg', quality);
} catch (_) {
return null;
}
}
/**
* Union of every visible mask sub-layer across `layers`, rendered as a
* binary white canvas the size of the document.
*
* `lighter` composite = additive — overlapping pixels stay clamped at
* 255, so wherever any mask painted, the result is solid white.
* Returns null when no mask layer contributed any pixels (so the caller
* can early-out cleanly).
*
* @param {Array<{masks?: Array<{visible: boolean, canvas: HTMLCanvasElement}>}>} layers
* @param {number} imgW
* @param {number} imgH
* @returns {HTMLCanvasElement|null}
*/
export function buildMergedMaskCanvas(layers, imgW, imgH) {
if (!imgW || !imgH) return null;
const out = document.createElement('canvas');
out.width = imgW;
out.height = imgH;
const ctx = out.getContext('2d');
ctx.globalCompositeOperation = 'lighter';
let anyMask = false;
for (const ly of layers) {
if (!ly.masks || !ly.masks.length) continue;
for (const mk of ly.masks) {
if (!mk.visible) continue;
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
ctx.drawImage(mk.canvas, 0, 0);
anyMask = true;
}
}
ctx.globalCompositeOperation = 'source-over';
return anyMask ? out : null;
}

View File

@@ -0,0 +1,118 @@
/**
* Pure blur renderers shared by the editor's live-preview popups.
*
* Each export matches the `renderer(snap, params, dst)` signature
* expected by `_applyLiveBlur` in galleryEditor.js — `snap` is the
* pre-blur snapshot canvas, `params` is the slider values object, and
* `dst` is the 2D context to draw the final result into. No module
* state.
*/
/**
* Gaussian blur with clamp-to-edge sampling.
*
* Canvas `filter: blur()` naively blends with TRANSPARENT pixels outside
* the image which fades the borders out. To match Photoshop's
* "Edge: Clamp" Gaussian we pad the source onto a larger buffer with
* the edge pixels stretched into the margin (4 strips + 4 corners),
* blur the padded buffer, then copy only the original-size centre back.
*
* @param {HTMLCanvasElement} snap
* @param {{ radius: number }} v
* @param {CanvasRenderingContext2D} dst
*/
export function gaussianBlur(snap, v, dst) {
if (!v.radius || v.radius <= 0) { dst.drawImage(snap, 0, 0); return; }
const r = v.radius;
const w = snap.width, h = snap.height;
// Margin needs to cover the kernel's effective reach — most
// engines saturate within ~2× the radius.
const m = Math.ceil(r * 2 + 4);
const pad = document.createElement('canvas');
pad.width = w + m * 2;
pad.height = h + m * 2;
const pctx = pad.getContext('2d');
pctx.drawImage(snap, m, m);
// Edge strips: drawImage with src height=1 (or width=1) into a
// dst region of size `m` stretches the edge pixels into the
// margin — same effect as clamp-to-edge sampling.
pctx.drawImage(snap, 0, 0, w, 1, m, 0, w, m);
pctx.drawImage(snap, 0, h - 1, w, 1, m, m + h, w, m);
pctx.drawImage(snap, 0, 0, 1, h, 0, m, m, h);
pctx.drawImage(snap, w - 1, 0, 1, h, m + w, m, m, h);
// Corners — stretch the corner pixel into an m×m block.
pctx.drawImage(snap, 0, 0, 1, 1, 0, 0, m, m);
pctx.drawImage(snap, w - 1, 0, 1, 1, m + w, 0, m, m);
pctx.drawImage(snap, 0, h - 1, 1, 1, 0, m + h, m, m);
pctx.drawImage(snap, w - 1, h - 1, 1, 1, m + w, m + h, m, m);
// Blur the padded buffer and crop the original-size centre back.
const out = document.createElement('canvas');
out.width = pad.width;
out.height = pad.height;
const octx = out.getContext('2d');
octx.filter = `blur(${r}px)`;
octx.drawImage(pad, 0, 0);
octx.filter = 'none';
dst.drawImage(out, m, m, w, h, 0, 0, w, h);
}
/**
* Zoom blur — radial smear from the canvas centre. 16 scaled copies at
* low alpha approximate a Gaussian zoom blur.
*
* @param {HTMLCanvasElement} snap
* @param {{ strength: number }} v
* @param {CanvasRenderingContext2D} dst
*/
export function zoomBlur(snap, v, dst) {
const w = snap.width, h = snap.height;
const steps = 16;
dst.drawImage(snap, 0, 0);
dst.globalAlpha = 0.18;
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const scale = 1 + (v.strength / 200) * t;
const sw = w * scale, sh = h * scale;
dst.drawImage(snap, (w - sw) / 2, (h - sh) / 2, sw, sh);
}
dst.globalAlpha = 1;
}
/**
* Motion blur — directional smear along a user-chosen angle.
*
* Each shifted stamp is rendered at globalAlpha = 1/steps with
* globalCompositeOperation = 'lighter' (additive) into an offscreen
* accumulator, then blitted onto `dst`. Lighter adds premultiplied src
* to dst, so N stamps each contributing snap.RGB/N sum to snap.RGB and
* alpha sums to 1. Source-over blending would cause colour wash-out
* because each stamp would blend over the dst instead of summing into
* it. Using an accumulator keeps `dst` clean if anything throws mid-way.
*
* @param {HTMLCanvasElement} snap
* @param {{ length: number, angle: number }} v
* @param {CanvasRenderingContext2D} dst
*/
export function motionBlur(snap, v, dst) {
const w = snap.width, h = snap.height;
const rad = (v.angle * Math.PI) / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
// Step count = roughly one sample per pixel of length, capped
// so very long blurs don't tank performance.
const steps = Math.max(4, Math.min(80, Math.round(v.length)));
const acc = document.createElement('canvas');
acc.width = w; acc.height = h;
const actx = acc.getContext('2d');
actx.globalCompositeOperation = 'lighter';
actx.globalAlpha = 1 / steps;
for (let i = 0; i < steps; i++) {
const t = (i / Math.max(1, steps - 1)) - 0.5;
actx.drawImage(snap, dx * v.length * t, dy * v.length * t);
}
actx.globalCompositeOperation = 'source-over';
actx.globalAlpha = 1;
dst.drawImage(acc, 0, 0);
}

View File

@@ -0,0 +1,70 @@
/**
* Edge feather / edge delete via a two-pass chamfer distance transform.
*
* Operates in-place on the supplied ImageData. For each opaque pixel,
* compute the (approximate) distance to the nearest transparent pixel
* OR canvas edge. Pixels within `width` of that boundary either get
* faded (`hardDelete=false`) or fully cleared (`hardDelete=true`).
*
* @param {ImageData} imgData
* @param {number} width Feather radius in pixels.
* @param {boolean} hardDelete If true, clear pixels inside the band
* instead of fading.
*/
export function edgeFeather(imgData, width, hardDelete) {
const w = imgData.width;
const h = imgData.height;
const d = imgData.data;
const dist = new Float32Array(w * h);
dist.fill(width + 1);
// Seed: transparent pixels are at distance 0.
for (let i = 0; i < w * h; i++) {
if (d[i * 4 + 3] === 0) dist[i] = 0;
}
// Two-pass chamfer distance transform.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (dist[i] === 0) continue;
let min = dist[i];
if (x > 0) min = Math.min(min, dist[i - 1] + 1);
if (y > 0) min = Math.min(min, dist[(y - 1) * w + x] + 1);
dist[i] = min;
}
}
for (let y = h - 1; y >= 0; y--) {
for (let x = w - 1; x >= 0; x--) {
const i = y * w + x;
if (dist[i] === 0) continue;
let min = dist[i];
if (x < w - 1) min = Math.min(min, dist[i + 1] + 1);
if (y < h - 1) min = Math.min(min, dist[(y + 1) * w + x] + 1);
dist[i] = min;
}
}
// Treat the canvas border itself as a boundary.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const edgeDist = Math.min(x, y, w - 1 - x, h - 1 - y);
const i = y * w + x;
dist[i] = Math.min(dist[i], edgeDist);
}
}
// Apply.
for (let i = 0; i < w * h; i++) {
if (d[i * 4 + 3] === 0) continue;
const edgeDist = dist[i];
if (edgeDist < width) {
if (hardDelete) {
d[i * 4 + 3] = 0;
} else {
const fade = edgeDist / width;
d[i * 4 + 3] = Math.round(d[i * 4 + 3] * fade);
}
}
}
}

View File

@@ -0,0 +1,677 @@
/**
* FX / adjustment-popup machinery — the per-layer Brightness/Contrast,
* Hue/Saturation, Levels, and Color-Balance editor.
*
* Self-contained subsystem with three external touchpoints:
*
* - `composite()` redraw the canvas after every staged change
* - `saveState(label)` push an undo entry on Apply
* - `renderLayerPanel()` refresh the layer panel after add/edit
*
* Lifecycle:
*
* FX button on layer row → openFxPopup(layer, anchor)
* → small chooser menu (B/C, H/S, Levels, Color Balance)
* → openAdjPopup(layer, type, anchor[, existingAdj])
* → buildAdjBody renders the type-specific sliders + histogram
* → sliders / histogram handles mutate `layer._stagedAdj.params`
* → composite() previews live via the adjLayers stack
* → Apply commits to layer.adjLayers + saveState() + renderLayerPanel()
* → Cancel / Esc drops the staged state
*
* Popups can be minimised → modalManager dock chip → click chip to
* restore. Re-opening a committed sub-layer (from the layer panel's
* adj-row click) calls `editAdjLayer` which re-opens openAdjPopup
* with the existing sub-layer's params staged for editing.
*
* @param {{
* composite: () => void,
* saveState: (label?: string) => void,
* renderLayerPanel: () => void,
* }} deps
*
* @returns {{
* openFxPopup, openAdjPopup, editAdjLayer,
* closeFxPopup, closeFxMenu, closeAdjPopup,
* ensureFxDock, ensureAdjustments,
* syncFxPanelToActiveLayerIfPresent,
* minimiseAdjPopup,
* }}
*/
import { state } from '../state.js';
import modalManager from '../../modalManager.js';
import {
ADJ_ICONS,
adjLayerLabel,
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
function closeFxPopup() {
if (state.fxPopupEl) {
state.fxPopupEl.remove();
state.fxPopupEl = null;
state.fxPopupLayerId = null;
}
}
function ensureAdjustments(layer) {
// Older layers (loaded from saved projects) may be missing the
// adjustments structure entirely. Pad with identity values.
if (!layer.adjustments) layer.adjustments = {};
const a = layer.adjustments;
if (a.brightness === undefined) a.brightness = 1;
if (a.contrast === undefined) a.contrast = 1;
if (a.saturation === undefined) a.saturation = 1;
if (a.hue === undefined) a.hue = 0;
if (!a.levels) a.levels = { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
if (!a.colorBalance) a.colorBalance = {
shadows: { r: 0, g: 0, b: 0 },
midtones: { r: 0, g: 0, b: 0 },
highlights: { r: 0, g: 0, b: 0 },
};
return a;
}
// Floating dock for minimised FX popups — lives at bottom-right.
function ensureFxDock() {
let dock = document.getElementById('ge-fx-dock');
if (!dock) {
dock = document.createElement('div');
dock.id = 'ge-fx-dock';
document.body.appendChild(dock);
}
return dock;
}
function closeFxMenu() {
if (state.fxMenuEl) {
if (state.fxMenuEl._escHandler) {
document.removeEventListener('keydown', state.fxMenuEl._escHandler, true);
}
if (state.fxMenuEl._awayHandler) {
document.removeEventListener('pointerdown', state.fxMenuEl._awayHandler, true);
}
state.fxMenuEl.remove();
state.fxMenuEl = null;
}
document.getElementById('ge-fx-menu-backdrop')?.remove();
}
function openFxPopup(layer, anchorEl) {
// 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
// element still carries a now-recycled `_layerId`) used to make
// this guard fire and silently swallow the first click. Verify the
// element is still in the document before treating it as "open".
if (state.fxMenuEl && document.body.contains(state.fxMenuEl) &&
state.fxMenuEl._layerId === layer.id) { closeFxMenu(); return; }
closeFxMenu();
if (!layer.adjLayers) layer.adjLayers = [];
const backdrop = document.createElement('div');
backdrop.id = 'ge-fx-menu-backdrop';
backdrop.style.cssText = 'position:fixed;inset:0;z-index:10001;background:transparent;pointer-events:auto;touch-action:none;';
document.body.appendChild(backdrop);
backdrop.addEventListener('pointerdown', (ev) => {
ev.preventDefault();
ev.stopPropagation();
closeFxMenu();
}, true);
backdrop.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
}, true);
const menu = document.createElement('div');
menu.className = 'ge-fx-menu ge-frosted';
menu._layerId = layer.id;
menu._ignoreActivationUntil = Date.now() + 350;
menu.style.zIndex = '10002';
menu.style.pointerEvents = 'auto';
const items = [
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
{ type: 'hue-saturation', label: 'Hue / Saturation' },
{ type: 'levels', label: 'Levels' },
{ type: 'color-balance', label: 'Color Balance' },
];
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>`
).join('');
document.body.appendChild(menu);
state.fxMenuEl = menu;
const activateMenuItem = (btn, ev) => {
ev?.preventDefault?.();
ev?.stopPropagation?.();
if (Date.now() < (menu._ignoreActivationUntil || 0)) return;
if (!btn || btn.dataset.opening === '1') return;
btn.dataset.opening = '1';
const type = btn.dataset.fxType;
closeFxMenu();
openAdjPopup(layer, type, anchorEl);
};
menu.addEventListener('pointerdown', (ev) => {
ev.stopPropagation();
}, true);
menu.addEventListener('pointerup', (ev) => {
const btn = ev.target.closest('.ge-fx-menu-item');
if (btn) activateMenuItem(btn, ev);
else ev.stopPropagation();
}, true);
menu.addEventListener('click', (ev) => {
const btn = ev.target.closest('.ge-fx-menu-item');
if (btn) activateMenuItem(btn, ev);
else ev.stopPropagation();
}, true);
const isMobile = window.matchMedia('(max-width: 820px)').matches;
const r = isMobile ? null : anchorEl?.getBoundingClientRect?.();
if (isMobile) {
menu.style.left = '';
menu.style.top = '';
menu.style.right = '';
menu.style.bottom = '';
} else if (r) {
const menuW = 220;
const menuH = menu.offsetHeight || 200;
const rightX = r.right + 4;
const leftX = r.left - menuW - 4;
const fitsRight = rightX + menuW <= window.innerWidth - 8;
let left = fitsRight ? rightX : Math.max(8, leftX);
left = Math.min(window.innerWidth - menuW - 8, Math.max(8, left));
menu.style.left = left + 'px';
let top = r.top;
if (top + menuH > window.innerHeight - 8) top = r.bottom - menuH;
top = Math.min(window.innerHeight - menuH - 8, Math.max(8, top));
menu.style.top = top + 'px';
}
menu.querySelectorAll('.ge-fx-menu-item').forEach(btn => {
const activate = (ev) => {
activateMenuItem(btn, ev);
};
btn.addEventListener('pointerup', activate);
btn.addEventListener('click', activate);
});
// Esc closes the menu, capture-phase + stopPropagation so the
// gallery modal's own Esc handler doesn't fire too.
const onKey = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeFxMenu();
document.removeEventListener('keydown', onKey, true);
}
};
document.addEventListener('keydown', onKey, true);
menu._escHandler = onKey;
}
// Hide an adj popup and drop a chip into the FX dock. Click the chip
// to restore the popup in its previous position with staged state
// intact (we do NOT clear staged on minimise).
function minimiseAdjPopup(pop) {
if (!pop) return;
const type = pop._type;
const r = pop.getBoundingClientRect();
pop._stashLeft = r.left;
pop._stashTop = r.top;
pop.style.display = 'none';
if (state.adjPopupEl === pop) state.adjPopupEl = null;
const popupId = pop._modalId || `ge-fx-popup-${Math.random().toString(36).slice(2, 8)}`;
pop._modalId = popupId;
modalManager.register(popupId, {
label: adjLayerLabel(type),
icon: ADJ_ICONS[type] || '',
restoreFn: () => {
pop.style.left = pop._stashLeft + 'px';
pop.style.top = pop._stashTop + 'px';
pop.style.display = '';
if (state.adjPopupEl && state.adjPopupEl !== pop) {
const other = state.adjPopupEl;
state.adjPopupEl = other;
closeAdjPopup();
}
state.adjPopupEl = pop;
},
closeFn: () => {
state.adjPopupEl = pop;
closeAdjPopup();
modalManager.unregister(popupId);
},
});
modalManager.minimize(popupId);
}
// Re-open an existing committed adjustment sub-layer for editing.
// Pre-loads its params as the staged state; Apply updates in place.
function editAdjLayer(layer, adj, anchorEl) {
openAdjPopup(layer, adj.type, anchorEl, adj);
}
function closeAdjPopup() {
if (state.adjPopupEl) {
suppressLayerGhostTap();
const layer = state.adjPopupEl._layer;
if (layer) {
if (layer._stagedAdj) layer._stagedAdj = null;
if (layer._editingAdjId) layer._editingAdjId = null;
layer._adjFinalKey = null;
composite();
}
if (state.adjPopupEl._escHandler) {
document.removeEventListener('keydown', state.adjPopupEl._escHandler, true);
}
if (state.adjPopupEl._modalId) {
try { modalManager.unregister(state.adjPopupEl._modalId); } catch {}
}
state.adjPopupEl.remove();
state.adjPopupEl = null;
}
}
function openAdjPopup(layer, type, anchorEl, existingAdj) {
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 editing = !!existingAdj;
const startParams = editing
? JSON.parse(JSON.stringify(existingAdj.params))
: defaultAdjParams(type);
layer._stagedAdj = { type, params: startParams };
if (editing) {
// Hide the existing sub-layer from the render stack so the
// staged preview shows correctly without doubling the effect.
layer._editingAdjId = existingAdj.id;
layer._adjFinalKey = null;
}
const pop = document.createElement('div');
pop.className = 'ge-adj-popup ge-frosted';
pop.style.zIndex = '10003';
pop._layer = layer;
pop._type = type;
pop._anchorEl = anchorEl;
pop._existingAdj = existingAdj || null;
pop.innerHTML = `
<div class="ge-adj-head" data-adj-drag>
<span class="ge-adj-icon">${ADJ_ICONS[type] || ''}</span>
<span class="ge-adj-title">${adjLayerLabel(type)}</span>
<span class="ge-head-btns">
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span>
</div>
<div class="ge-adj-body" data-adj-body></div>
<div class="ge-adj-foot">
<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>
`;
document.body.appendChild(pop);
state.adjPopupEl = pop;
const r = anchorEl?.getBoundingClientRect?.();
const pw = type === 'color-balance' ? 340 : 320;
// Prefer right of anchor; fall back to left if no room.
let left;
if (r) {
const rightX = r.right + 8;
const leftX = r.left - pw - 8;
const fitsRight = rightX + pw <= window.innerWidth - 8;
left = fitsRight ? rightX : Math.max(8, leftX);
} else {
left = (window.innerWidth - pw) / 2;
}
const top = r ? Math.max(8, r.top - 20) : 60;
pop.style.left = left + 'px';
pop.style.top = top + 'px';
const body = pop.querySelector('[data-adj-body]');
buildAdjBody(layer, type, body, pop);
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
// Drag by head — anywhere except buttons. Mobile pins via !important
// rules; setProperty with 'important' lets inline styles win during drag.
const head = pop.querySelector('[data-adj-drag]');
if (head) {
const isMobile = window.matchMedia('(max-width: 820px)').matches;
const setPos = (x, y) => {
if (isMobile) {
pop.style.setProperty('left', x + 'px', 'important');
pop.style.setProperty('top', y + 'px', 'important');
pop.style.setProperty('right', 'auto', 'important');
pop.style.setProperty('bottom', 'auto', 'important');
pop.style.setProperty('width', 'auto', 'important');
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
} else {
pop.style.left = x + 'px';
pop.style.top = y + 'px';
}
};
head.style.touchAction = 'none';
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const r0 = pop.getBoundingClientRect();
head.setPointerCapture(e.pointerId);
head.style.cursor = 'grabbing';
const onMove = (ev) => {
const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX)));
const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)));
setPos(nx, ny);
};
const onUp = () => {
head.releasePointerCapture(e.pointerId);
head.style.cursor = '';
head.removeEventListener('pointermove', onMove);
head.removeEventListener('pointerup', onUp);
};
head.addEventListener('pointermove', onMove);
head.addEventListener('pointerup', onUp);
});
}
// Esc closes; capture-phase + stopPropagation so the gallery modal's
// own Esc handler doesn't fire too.
const onKey = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeAdjPopup();
document.removeEventListener('keydown', onKey, true);
}
};
document.addEventListener('keydown', onKey, true);
pop._escHandler = onKey;
pop.querySelector('[data-adj-action="cancel"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
closeAdjPopup();
});
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
suppressLayerGhostTap();
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
const params = layer._stagedAdj.params;
layer._stagedAdj = null;
if (editing) {
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
if (existing) existing.params = params;
layer._editingAdjId = null;
} else {
if (!layer.adjLayers) layer.adjLayers = [];
layer.adjLayers.push({
id: 'adj-' + Math.random().toString(36).slice(2, 9),
type,
name: adjLayerLabel(type),
visible: true,
opacity: 1,
params,
});
}
layer._adjFinalKey = null;
composite();
renderLayerPanel();
closeAdjPopup();
});
}
// rAF-throttled live preview while sliders are dragged.
function scheduleAdjRefresh(layer) {
if (state.adjRafPending) return;
state.adjRafPending = true;
requestAnimationFrame(() => {
state.adjRafPending = false;
layer._adjFinalKey = null;
composite();
});
}
function buildAdjBody(layer, type, body, popEl) {
const p = layer._stagedAdj.params;
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}">
<label>${label}</label>
<input type="range" min="${min}" max="${max}" value="${value}" data-key="${key}" />
<span class="ge-adj-value">${value}${suffix || ''}</span>
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div>
`;
if (type === 'brightness-contrast') {
const bSlider = Math.round((p.brightness - 1) * 100);
const cSlider = Math.round((p.contrast - 1) * 100);
body.innerHTML = `
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
`;
} 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, '')}
`;
} else if (type === 'levels') {
// 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.
// <details> wrapper is collapsed by default on mobile to save
// vertical space; open by default on desktop.
const isMobile = window.matchMedia('(max-width: 820px)').matches;
body.innerHTML = `
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
<summary>Histogram</summary>
<div class="ge-adj-hist-wrap">
<canvas class="ge-adj-histogram" width="280" height="80"></canvas>
<div class="ge-adj-hist-handles">
<div class="ge-adj-hist-handle hist-h-black" data-handle="inBlack" title="Input black — drag"></div>
<div class="ge-adj-hist-handle hist-h-gamma" data-handle="gamma" title="Gamma — drag"></div>
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
</div>
</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, '')}
`;
const hist = body.querySelector('.ge-adj-histogram');
drawHistogram(hist, layer);
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);
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
const cbRow = (key, leftCol, rightCol, label, value) => `
<div class="ge-adj-row ge-adj-cb-row" data-adj-key="${key}">
<span class="ge-adj-cb-dot" style="background:${leftCol}"></span>
<input type="range" min="-100" max="100" value="${value}" data-key="${key}" />
<span class="ge-adj-cb-dot" style="background:${rightCol}"></span>
<span class="ge-adj-value">${value}</span>
<button class="ge-adj-revert" type="button" title="Reset this slider" data-revert-key="${key}">${revertIcon}</button>
</div>
`;
// Tone picker: one tone group visible at a time. Remember the
// last picked tone on the popup so re-renders (revert button
// etc.) keep it.
const tone = popEl._cbTone || 'shadows';
popEl._cbTone = tone;
const toneSliders = (t) => `
${cbRow(`${t}-r`, '#00d2d2', '#ff5555', 'Cyan ↔ Red', p[t].r)}
${cbRow(`${t}-g`, '#d855d8', '#55d855', 'Magenta ↔ Green', p[t].g)}
${cbRow(`${t}-b`, '#e6e64a', '#4a78ff', 'Yellow ↔ Blue', p[t].b)}
`;
body.innerHTML = `
<div class="ge-adj-cb-tone-picker">
<select class="ge-adj-cb-tone-select">
<option value="shadows"${tone === 'shadows' ? ' selected' : ''}>Shadows</option>
<option value="midtones"${tone === 'midtones' ? ' selected' : ''}>Midtones</option>
<option value="highlights"${tone === 'highlights' ? ' selected' : ''}>Highlights</option>
</select>
</div>
<div class="ge-adj-cb-sliders" data-cb-tone="${tone}">
${toneSliders(tone)}
</div>
`;
body.querySelector('.ge-adj-cb-tone-select')?.addEventListener('change', (e) => {
popEl._cbTone = e.target.value;
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
sl.addEventListener('input', () => onAdjSliderInput(layer, type, sl));
});
// Per-slider revert buttons.
body.querySelectorAll('.ge-adj-revert').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.revertKey;
revertAdjKey(layer, type, key);
// Rebuild body so values + histogram refresh.
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
});
}
// Reset a single slider key back to identity. Updates staged params
// and triggers a composite refresh.
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (type === 'brightness-contrast' || type === 'hue-saturation') {
p[key] = defaults[key];
} else if (type === 'levels') {
p[key] = defaults[key];
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
}
layer._adjFinalKey = null;
composite();
}
function onAdjSliderInput(layer, type, sl) {
const key = sl.dataset.key;
const raw = parseInt(sl.value, 10);
const valEl = sl.parentElement.querySelector('.ge-adj-value');
const p = layer._stagedAdj.params;
let display = String(raw);
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
p[key] = 1 + raw / 100;
} else if (key === 'hue') {
p.hue = raw; display = raw + ' °';
}
} else if (type === 'levels') {
if (key === 'gamma') {
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
} else {
p[key] = raw;
}
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = raw;
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
}
// Position the three histogram triangle handles by current staged
// values + wire pointer drags.
function wireHistogramHandles(bodyEl, layer, type) {
const wrap = bodyEl.querySelector('.ge-adj-hist-wrap');
const canvas = bodyEl.querySelector('.ge-adj-histogram');
if (!wrap || !canvas) return;
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
const placeHandles = () => {
const w = canvas.getBoundingClientRect().width;
const p = layer._stagedAdj.params;
const xB = (p.inBlack / 255) * w;
const xW = (p.inWhite / 255) * w;
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
// from gamma's log scale (1 = midpoint, 0.1 = far right, 10 = far left).
const gammaT = 1 - (Math.log(p.gamma || 1) / Math.log(10) * 0.5 + 0.5);
const xG = xB + (xW - xB) * gammaT;
const set = (sel, x) => {
const el = bodyEl.querySelector(sel);
if (el) el.style.left = (x - 6) + 'px';
};
set('.hist-h-black', xB);
set('.hist-h-gamma', xG);
set('.hist-h-white', xW);
};
placeHandles();
handles.forEach(h => {
h.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
h.setPointerCapture(e.pointerId);
const which = h.dataset.handle;
const rect = canvas.getBoundingClientRect();
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;
if (which === 'inBlack') {
p.inBlack = Math.min(p.inWhite - 1, v);
} else if (which === 'inWhite') {
p.inWhite = Math.max(p.inBlack + 1, v);
} else if (which === 'gamma') {
const xB = (p.inBlack / 255) * rect.width;
const xW = (p.inWhite / 255) * rect.width;
const span = Math.max(1, xW - xB);
let t = (x - xB) / span;
t = Math.max(0.01, Math.min(0.99, t));
// Invert the placeHandles mapping: t = 1 - (log10(g)*0.5+0.5).
const log10g = -((t - 0.5) * 2);
p.gamma = Math.pow(10, log10g);
}
placeHandles();
// 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 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);
scheduleAdjRefresh(layer);
};
const onUp = () => {
h.releasePointerCapture(e.pointerId);
h.removeEventListener('pointermove', onMove);
h.removeEventListener('pointerup', onUp);
};
h.addEventListener('pointermove', onMove);
h.addEventListener('pointerup', onUp);
});
});
}
// Legacy sidebar-FX panel sync — FX now lives in a per-layer popup;
// stubbed so any stale callers don't error.
function syncFxPanelToActiveLayerIfPresent() { /* no-op */ }
return {
openFxPopup, openAdjPopup, editAdjLayer,
closeFxPopup, closeFxMenu, closeAdjPopup,
ensureFxDock, ensureAdjustments,
syncFxPanelToActiveLayerIfPresent,
minimiseAdjPopup,
};
}

View File

@@ -0,0 +1,38 @@
/**
* Pure helpers that translate between the editor's adjustment-slider
* UI and CSS `filter` strings / canvas-filter multipliers.
*/
/**
* Build a CSS `filter` string from a layer's `adjustments` object.
* Returns '' when every value is at identity so the composite path
* can skip the filter entirely.
*
* @param {{
* brightness?: number, contrast?: number,
* saturation?: number, hue?: number,
* }|null|undefined} adj
*/
export function layerFilterString(adj) {
if (!adj) return '';
const parts = [];
if (adj.brightness !== undefined && adj.brightness !== 1) parts.push(`brightness(${adj.brightness})`);
if (adj.contrast !== undefined && adj.contrast !== 1) parts.push(`contrast(${adj.contrast})`);
if (adj.saturation !== undefined && adj.saturation !== 1) parts.push(`saturate(${adj.saturation})`);
if (adj.hue !== undefined && adj.hue !== 0) parts.push(`hue-rotate(${adj.hue}deg)`);
return parts.join(' ');
}
/**
* Convert a stored filter multiplier (brightness/contrast/saturation
* are 0..2 with 1.0 = identity; hue is degrees, -180..+180) into the
* UI slider's -100..+100 (or -180..+180 for hue) range.
*/
export function fxFilterToSlider(key, value) {
if (key === 'brightness' || key === 'contrast' || key === 'saturation') {
return Math.round(((value ?? 1) - 1) * 100);
}
if (key === 'hue') return Math.round(value ?? 0);
return 0;
}

View File

@@ -0,0 +1,67 @@
/**
* Draw a luminance histogram of a layer's pixels onto the given
* canvas. Sampling is capped at ~400×400 so the call stays cheap on
* very large images.
*
* If the layer has a staged Levels adjustment
* (`layer._stagedAdj.params` with `inBlack` / `inWhite`), the two
* endpoint markers are drawn over the bars.
*
* @param {HTMLCanvasElement} canvas The histogram canvas to render into.
* @param {{
* canvas: HTMLCanvasElement,
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
* }} layer Source layer.
*/
export function drawHistogram(canvas, layer) {
if (!canvas) return;
const w = canvas.width, h = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
// Down-sample huge images so the histogram stays interactive on 8k+
// photos. ~400×400 is enough to characterise the distribution.
const src = layer.canvas;
const sw = src.width, sh = src.height;
const maxSamples = 400;
const sampleW = Math.min(maxSamples, sw);
const sampleH = Math.min(maxSamples, sh);
const tmp = document.createElement('canvas');
tmp.width = sampleW; tmp.height = sampleH;
const tctx = tmp.getContext('2d');
tctx.drawImage(src, 0, 0, sampleW, sampleH);
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
const hist = new Uint32Array(256);
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)]++;
}
let peak = 1;
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
// Background.
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.fillRect(0, 0, w, h);
// Bars. sqrt-scaled so the long tails (specular highlights, deep
// shadows) stay visible even when the central mass dominates.
ctx.fillStyle = 'rgba(255,255,255,0.55)';
for (let i = 0; i < 256; i++) {
const x = (i / 256) * w;
const bh = Math.pow(hist[i] / peak, 0.5) * h;
ctx.fillRect(x, h - bh, w / 256 + 0.5, bh);
}
// Endpoint markers (input black / input white) from a staged Levels
// adjustment, if one is in flight.
const p = layer._stagedAdj?.params;
if (p) {
ctx.fillStyle = 'rgba(0,0,0,0.9)';
ctx.fillRect((p.inBlack / 256) * w, 0, 1, h);
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
}
}

View File

@@ -0,0 +1,249 @@
/**
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
* adjustment to a source canvas and return a fresh canvas with the
* result. Pure pixel math — no DOM, no module state.
*
* Used by the editor's per-layer FX stack: each `adjLayer` calls
* `applyAdjustment(prevCanvas, adjLayer)` and the result feeds the
* next layer in the stack.
*
* Adjustment shape:
* { type: 'brightness-contrast', params: { brightness, contrast } }
* { type: 'hue-saturation', params: { hue, saturation } }
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
* { type: 'color-balance', params: { shadows, midtones, highlights } }
*/
export function applyAdjustment(srcCanvas, adj) {
const w = srcCanvas.width, h = srcCanvas.height;
const out = document.createElement('canvas');
out.width = w; out.height = h;
const octx = out.getContext('2d');
// B/C and H/S can use the fast browser-native CSS filter pipeline.
if (adj.type === 'brightness-contrast') {
const p = adj.params;
octx.filter = `brightness(${p.brightness}) contrast(${p.contrast})`;
octx.drawImage(srcCanvas, 0, 0);
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.
octx.drawImage(srcCanvas, 0, 0);
const img = octx.getImageData(0, 0, w, h);
const d = img.data;
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);
}
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]];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'color-balance') {
const cb = adj.params;
const scale = 0.6;
const s = cb.shadows, m = cb.midtones, hi = cb.highlights;
const sR = s.r*scale, sG = s.g*scale, sB = s.b*scale;
const mR = m.r*scale, mG = m.g*scale, mB = m.b*scale;
const hR = hi.r*scale, hG = hi.g*scale, hB = hi.b*scale;
// Bell-curve tone weights so each pixel's shift is proportional to
// how "shadow", "midtone", or "highlight" its luminance is.
const wS = new Float32Array(256), wM = new Float32Array(256), wH = new Float32Array(256);
const sig = 0.25;
for (let v = 0; v < 256; v++) {
const t = v / 255;
wS[v] = Math.exp(-(t*t) / (2*sig*sig));
wM[v] = Math.exp(-((t-0.5)*(t-0.5)) / (2*sig*sig));
wH[v] = Math.exp(-((1-t)*(1-t)) / (2*sig*sig));
}
for (let i = 0; i < d.length; i += 4) {
let r = d[i], g = d[i+1], b = d[i+2];
const Y = (0.2126*r + 0.7152*g + 0.0722*b) | 0;
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
r += sR*ws + mR*wm + hR*wh;
g += sG*ws + mG*wm + hG*wh;
b += sB*ws + mB*wm + hB*wh;
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
d[i+1] = g < 0 ? 0 : g > 255 ? 255 : g;
d[i+2] = b < 0 ? 0 : b > 255 ? 255 : b;
}
octx.putImageData(img, 0, 0);
return out;
}
return out;
}
/**
* Apply a combined Levels + Color Balance pass to a layer in-place via
* its `layer.adjustments` field. Cached on `layer._adjCache` keyed by
* `cacheKey` so repeated composite passes don't re-run the math.
*
* Returns the cached output canvas.
*
* @param {{
* canvas: HTMLCanvasElement,
* adjustments: object,
* _adjCache?: HTMLCanvasElement,
* _adjCacheKey?: string,
* }} layer
* @param {string} cacheKey Stable signature of `layer.adjustments`.
*/
export function renderLayerPixelAdjustments(layer, cacheKey) {
const adj = layer.adjustments;
if (layer._adjCache && layer._adjCacheKey === cacheKey) return layer._adjCache;
if (!layer._adjCache) {
layer._adjCache = document.createElement('canvas');
}
const out = layer._adjCache;
out.width = layer.canvas.width;
out.height = layer.canvas.height;
const octx = out.getContext('2d');
octx.clearRect(0, 0, out.width, out.height);
octx.drawImage(layer.canvas, 0, 0);
const img = octx.getImageData(0, 0, out.width, out.height);
const d = img.data;
// Single 256-entry LUT for the Levels portion (applied per R/G/B
// channel identically — luma-style isn't right when colour balance
// follows, per-channel is fine here).
const l = adj.levels || { inBlack: 0, inWhite: 255, gamma: 1, outBlack: 0, outWhite: 255 };
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);
}
// Color Balance bell-curve weights (see applyAdjustment).
const cb = adj.colorBalance || { shadows: {r:0,g:0,b:0}, midtones: {r:0,g:0,b:0}, highlights: {r:0,g:0,b:0} };
const s = cb.shadows || {r:0,g:0,b:0};
const m = cb.midtones || {r:0,g:0,b:0};
const h = cb.highlights || {r:0,g:0,b:0};
const scale = 0.6;
const sR = s.r * scale, sG = s.g * scale, sB = s.b * scale;
const mR = m.r * scale, mG = m.g * scale, mB = m.b * scale;
const hR = h.r * scale, hG = h.g * scale, hB = h.b * scale;
const wS = new Float32Array(256);
const wM = new Float32Array(256);
const wH = new Float32Array(256);
for (let v = 0; v < 256; v++) {
const t = v / 255;
const dS = t, wsig = 0.25;
const dM = t - 0.5;
const dH = 1 - t;
wS[v] = Math.exp(-(dS * dS) / (2 * wsig * wsig));
wM[v] = Math.exp(-(dM * dM) / (2 * wsig * wsig));
wH[v] = Math.exp(-(dH * dH) / (2 * wsig * wsig));
}
for (let i = 0; i < d.length; i += 4) {
let r = lut[d[i]];
let g = lut[d[i + 1]];
let b = lut[d[i + 2]];
const Y = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
const ws = wS[Y], wm = wM[Y], wh = wH[Y];
r += sR * ws + mR * wm + hR * wh;
g += sG * ws + mG * wm + hG * wh;
b += sB * ws + mB * wm + hB * wh;
d[i] = r < 0 ? 0 : r > 255 ? 255 : r;
d[i + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
d[i + 2] = b < 0 ? 0 : b > 255 ? 255 : b;
}
octx.putImageData(img, 0, 0);
layer._adjCacheKey = cacheKey;
return out;
}
/**
* Walk the layer's `adjLayers` stack (skipping the one currently being
* edited, if any) plus an optional staged preview adjustment, producing
* a final canvas the composite step can paint. The result is memoised
* on `layer._adjFinal` keyed by a signature of all adjLayer params +
* staged + editing id, so repeated composite passes are O(1) when
* nothing has changed.
*
* If the stack is empty AND nothing is staged, returns the layer's own
* canvas unchanged (no allocation).
*
* @param {{
* canvas: HTMLCanvasElement,
* adjLayers?: Array<{id: string, type: string, params: object, visible: boolean, opacity: number}>,
* _stagedAdj?: {type: string, params: object} | null,
* _editingAdjId?: string | null,
* _adjFinal?: HTMLCanvasElement,
* _adjFinalKey?: string,
* }} layer
* @returns {HTMLCanvasElement}
*/
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) {
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;
let cur = layer.canvas;
const w = layer.canvas.width, h = layer.canvas.height;
for (const adj of stack) {
const adjOut = applyAdjustment(cur, adj);
if (adj.opacity >= 0.999) {
cur = adjOut;
} else {
const blend = document.createElement('canvas');
blend.width = w; blend.height = h;
const bctx = blend.getContext('2d');
bctx.drawImage(cur, 0, 0);
bctx.globalAlpha = adj.opacity;
bctx.drawImage(adjOut, 0, 0);
bctx.globalAlpha = 1;
cur = blend;
}
}
if (staged) {
cur = applyAdjustment(cur, staged);
}
layer._adjFinal = cur;
layer._adjFinalKey = sig;
return cur;
}

View File

@@ -0,0 +1,116 @@
/**
* Mask builders used by the AI Harmonize pipeline.
*
* - `layerUnionAlpha` — union of every non-base layer's alpha, as a
* binary (0/255) mask. Used as the substrate
* for both seam and body masks.
* - `seamMask` — feathered band along the alpha edges of all
* non-base layers. White = "blend here",
* black = "leave alone". Returned as base64
* PNG (so it can be POST'd to the diffusion
* endpoint as JSON).
* - `layerBodyMask` — feathered FULL shape of every non-base layer.
* White = "AI may redraw this pixel using the
* existing pixels as a starting point";
* black = "preserve exactly". Returned as base64.
*
* Each helper takes the visible layer list + the doc dimensions; no
* module state.
*
* @typedef {{ visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x: number, y: number} }} HarmLayer
*/
/**
* Build a binary alpha mask = the UNION of every non-base visible
* layer's pixels. Returns null when fewer than 2 visible layers exist
* or when the non-base layers are entirely transparent.
*
* @param {number} w / h Canvas dimensions in pixels.
* @param {HarmLayer[]} layers All layers in stack order; the
* first VISIBLE layer is treated as
* the base / background.
* @returns {HTMLCanvasElement|null}
*/
export function layerUnionAlpha(w, h, layers) {
const visible = layers.filter(l => l.visible);
if (visible.length < 2) return null;
const bgId = visible[0].id;
const alphaCanvas = document.createElement('canvas');
alphaCanvas.width = w; alphaCanvas.height = h;
const actx = alphaCanvas.getContext('2d');
let hasFg = false;
for (const layer of visible) {
if (layer.id === bgId) continue;
const off = layer.offset || { x: 0, y: 0 };
actx.drawImage(layer.canvas, off.x, off.y);
hasFg = true;
}
if (!hasFg) return null;
const src = actx.getImageData(0, 0, w, h);
const bin = document.createElement('canvas');
bin.width = w; bin.height = h;
const bctx = bin.getContext('2d');
const binImg = bctx.createImageData(w, h);
let any = false;
for (let i = 0; i < src.data.length; i += 4) {
const v = src.data[i + 3] > 0 ? 255 : 0;
if (v) any = true;
binImg.data[i] = binImg.data[i + 1] = binImg.data[i + 2] = v;
binImg.data[i + 3] = 255;
}
if (!any) return null;
bctx.putImageData(binImg, 0, 0);
return bin;
}
/**
* Build a feathered seam mask along the alpha edges of all non-base
* visible layers. Returns base64-encoded PNG (no `data:` prefix), or
* null if there's nothing to harmonize.
*/
export function seamMask(w, h, layers, featherPx = 12) {
const bin = layerUnionAlpha(w, h, layers);
if (!bin) return null;
const blur = document.createElement('canvas');
blur.width = w; blur.height = h;
const blctx = blur.getContext('2d');
blctx.filter = `blur(${featherPx}px)`;
blctx.drawImage(bin, 0, 0);
blctx.filter = 'none';
const blurred = blctx.getImageData(0, 0, w, h);
const mask = blctx.createImageData(w, h);
// Triangular weight peaked at mid-grey — picks out the alpha-edge band.
for (let i = 0; i < blurred.data.length; i += 4) {
const v = blurred.data[i];
const dist = Math.abs(v - 128);
const wt = Math.max(0, 255 - dist * 2);
mask.data[i] = mask.data[i + 1] = mask.data[i + 2] = wt;
mask.data[i + 3] = 255;
}
blctx.putImageData(mask, 0, 0);
const soft = document.createElement('canvas');
soft.width = w; soft.height = h;
const sctx = soft.getContext('2d');
sctx.filter = `blur(${Math.max(2, Math.floor(featherPx / 4))}px)`;
sctx.drawImage(blur, 0, 0);
sctx.filter = 'none';
return soft.toDataURL('image/png').split(',')[1];
}
/**
* Build a feathered FULL-shape mask of every non-base visible layer.
* Returns base64-encoded PNG, or null if there are no non-base layers.
*/
export function layerBodyMask(w, h, layers, featherPx = 12) {
const bin = layerUnionAlpha(w, h, layers);
if (!bin) return null;
const soft = document.createElement('canvas');
soft.width = w; soft.height = h;
const sctx = soft.getContext('2d');
sctx.filter = `blur(${featherPx}px)`;
sctx.drawImage(bin, 0, 0);
sctx.filter = 'none';
return soft.toDataURL('image/png').split(',')[1];
}

View File

@@ -0,0 +1,176 @@
/**
* History-panel subsystem — the floating frosted list of labeled
* undo/redo entries that hangs off the topbar History button.
*
* Same docking pattern as the FX adjustment popups: drag the head to
* reposition, click the minimise button to dock into the modalManager
* chip chain, click the chip to restore. Esc closes.
*
* @param {{
* undo: () => void,
* redo: () => void,
* }} deps
*
* @returns {{
* toggleHistoryPanel: () => void,
* refreshHistoryPanelIfOpen: () => void,
* jumpToHistory: (offset: number) => void,
* }}
*/
import { state } from './state.js';
import modalManager from '../modalManager.js';
import { HISTORY_ICON, relTime } from './layer-helpers.js';
import { historyPanelHTML } from './build/popups.js';
export function createHistoryPanel({ undo, redo }) {
function jumpToHistory(offset) {
if (offset === 0) return;
if (offset < 0) {
for (let i = 0; i < -offset; i++) undo();
} else {
for (let i = 0; i < offset; i++) redo();
}
}
function closeHistoryPanel() {
if (state.historyPanelEl) {
if (state.historyPanelEl._escHandler) {
document.removeEventListener('keydown', state.historyPanelEl._escHandler, true);
}
if (state.historyPanelEl._awayHandler) {
document.removeEventListener('pointerdown', state.historyPanelEl._awayHandler, true);
}
state.historyPanelEl.remove();
state.historyPanelEl = null;
}
}
function minimiseHistoryPanel() {
if (!state.historyPanelEl) return;
const panel = state.historyPanelEl;
const r = panel.getBoundingClientRect();
panel._stashLeft = r.left;
panel._stashTop = r.top;
panel.style.display = 'none';
state.historyPanelEl = null;
const modalId = panel._modalId || 'ge-history-panel-min';
panel._modalId = modalId;
modalManager.register(modalId, {
label: 'History',
icon: HISTORY_ICON,
restoreFn: () => {
panel.style.left = panel._stashLeft + 'px';
panel.style.top = panel._stashTop + 'px';
panel.style.display = '';
state.historyPanelEl = panel;
refreshHistoryPanelIfOpen();
},
closeFn: () => {
panel.remove();
modalManager.unregister(modalId);
},
});
modalManager.minimize(modalId);
}
function toggleHistoryPanel() {
if (state.historyPanelEl) { closeHistoryPanel(); return; }
const panel = document.createElement('div');
panel.id = 'ge-history-panel';
panel.className = 'ge-frosted';
panel.innerHTML = historyPanelHTML(HISTORY_ICON);
document.body.appendChild(panel);
state.historyPanelEl = panel;
const btn = document.getElementById('ge-history-btn');
if (btn) {
const r = btn.getBoundingClientRect();
panel.style.top = (r.bottom + 6) + 'px';
panel.style.left = Math.max(8, r.left) + 'px';
}
panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel);
// Click anywhere outside the panel (or trigger button) closes it.
setTimeout(() => {
const onAway = (ev) => {
if (!state.historyPanelEl) return;
if (state.historyPanelEl.contains(ev.target)) return;
if (btn && (ev.target === btn || btn.contains(ev.target))) return;
closeHistoryPanel();
document.removeEventListener('pointerdown', onAway, true);
};
document.addEventListener('pointerdown', onAway, true);
panel._awayHandler = onAway;
}, 0);
const head = panel.querySelector('[data-history-drag]');
head.addEventListener('pointerdown', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const startX = e.clientX, startY = e.clientY;
const r0 = panel.getBoundingClientRect();
head.setPointerCapture(e.pointerId);
head.style.cursor = 'grabbing';
const onMove = (ev) => {
const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX)));
const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY)));
panel.style.left = nx + 'px';
panel.style.top = ny + 'px';
};
const onUp = () => {
head.releasePointerCapture(e.pointerId);
head.style.cursor = '';
head.removeEventListener('pointermove', onMove);
head.removeEventListener('pointerup', onUp);
};
head.addEventListener('pointermove', onMove);
head.addEventListener('pointerup', onUp);
});
const onKey = (ev) => {
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
closeHistoryPanel();
}
};
document.addEventListener('keydown', onKey, true);
panel._escHandler = onKey;
refreshHistoryPanelIfOpen();
}
function refreshHistoryPanelIfOpen() {
if (!state.historyPanelEl) return;
const list = state.historyPanelEl.querySelector('#ge-history-list');
if (!list) return;
// Chronological order — oldest at top, latest at bottom. Past
// (undo) states first, then Current, then future (redo) states.
const rows = [];
for (let i = 0; i < state.undoStack.length; i++) {
const s = state.undoStack[i];
rows.push({ offset: -(state.undoStack.length - i), label: s._label || 'Edit', ts: s._ts });
}
rows.push({ offset: 0, label: 'Current', ts: Date.now(), current: true });
for (let i = state.redoStack.length - 1; i >= 0; i--) {
const s = state.redoStack[i];
rows.push({ offset: (state.redoStack.length - i), label: s._label || 'Edit', ts: s._ts, future: true });
}
list.innerHTML = rows.map(r => `
<button class="ge-history-row${r.current ? ' current' : ''}${r.future ? ' future' : ''}" data-offset="${r.offset}">
<span class="ge-history-row-dot"></span>
<span class="ge-history-row-label">${(r.label || '').replace(/[<>&]/g,'')}</span>
<span class="ge-history-row-time">${relTime(r.ts)}</span>
</button>
`).join('');
list.querySelectorAll('.ge-history-row').forEach(btn => {
btn.addEventListener('click', () => {
const off = parseInt(btn.dataset.offset, 10);
jumpToHistory(off);
});
});
// Scroll the current marker into view.
const cur = list.querySelector('.current');
if (cur) cur.scrollIntoView({ block: 'center' });
}
return { toggleHistoryPanel, refreshHistoryPanelIfOpen, jumpToHistory };
}

View File

@@ -0,0 +1,266 @@
/**
* Editor keyboard shortcuts — bound to `document` so shortcuts work
* without first clicking into the canvas. Gated by `state.editorOpen`
* so they don't leak into chat input when the editor is closed.
*
* Covers:
* ? toggle the shortcuts cheatsheet
* 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+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+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+V (handled by the paste event listener)
* Tool keys (V, B, E, L, …) → toolbar click
* [ / ] 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 {{
* toolbar: HTMLDivElement,
* toolKeyMap: Record<string, string>,
* composite: () => void,
* saveState: (label?: string) => void,
* undo: () => void,
* redo: () => void,
* toggleShortcuts: (show?: boolean) => void,
* confirmTransform: () => void,
* cancelTransform: () => void,
* startTransform: () => void,
* resizeCustomPrompt: () => void,
* addEmptyLayer: () => void,
* brushSizeSync: (source: HTMLInputElement | null) => void,
* invertSelection: () => boolean,
* wandDeleteSelection: () => void,
* wandCopyToNewLayer: () => void,
* lassoDeleteSelection: () => void,
* lassoCopyToLayer: () => void,
* lassoToMask: () => void,
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
* drawLassoOverlay: () => void,
* activeLayer: () => object | null,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
export function wireKeyboardShortcuts(deps) {
const {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer,
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
buildLassoMask, drawLassoOverlay,
activeLayer, uiModule,
} = deps;
document.addEventListener('keydown', (e) => {
if (!state.editorOpen) 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') {
e.preventDefault();
toggleShortcuts();
return;
}
if (e.key === 'Enter' && state.transformActive) {
e.preventDefault();
confirmTransform();
return;
}
if (e.key === 'Escape') return;
if (e.ctrlKey || e.metaKey) {
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();
}
}
// 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();
}
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
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
// don't break the match.
if (e.altKey && e.code === 'KeyI') {
if (invertSelection()) {
e.preventDefault();
e.stopPropagation();
}
}
// Ctrl+Alt+J — new empty layer.
if (e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
addEmptyLayer();
}
// 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) {
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();
}
}
// 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) {
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;
}
}
// Ctrl+Alt+A = select all canvas.
if (e.altKey && e.key === 'a' && state.imgWidth > 0 && state.imgHeight > 0) {
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;
composite();
drawLassoOverlay();
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
}
// Ctrl+V handled by the paste event listener.
if (e.key === 'v') { /* no-op here */ }
return;
}
// Tool shortcuts (only when not typing in an input).
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
if (toolBtn) toolBtn.click();
}
// Bracket keys for brush size — ±10% multiplier mirrors the
// exponential slider curve so each press feels the same at any
// size.
if (e.key === '[' || e.key === ']') {
const factor = e.key === '[' ? 0.9 : 1.1;
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(); }
}
});
}

View File

@@ -0,0 +1,131 @@
/**
* Pure helpers + constants for layers and adjustment sub-layers.
*
* Everything in this module is stateless — feed in a layer object and
* get back a value. The legacy gallery editor's module-level helpers
* re-export from here so existing call sites keep working unchanged.
*/
/** True if the layer has at least one FX/adjustment sub-layer. */
export function layerHasAdjustments(layer) {
return !!(layer && layer.adjLayers && layer.adjLayers.length > 0);
}
/**
* True if the layer carries a non-identity Levels OR Color-Balance
* adjustment that needs the per-pixel pass (vs the cheap CSS-filter
* path for plain B/C/H/S).
*/
export function layerNeedsPixelPass(layer) {
if (!layer || !layer.adjustments) return false;
const a = layer.adjustments;
if (a.levels && (a.levels.inBlack !== 0 || a.levels.inWhite !== 255 ||
a.levels.gamma !== 1 ||
a.levels.outBlack !== 0 || a.levels.outWhite !== 255)) return true;
if (a.colorBalance) {
for (const tone of ['shadows', 'midtones', 'highlights']) {
const v = a.colorBalance[tone];
if (v && (v.r || v.g || v.b)) return true;
}
}
return false;
}
/**
* Compact hash of a layer's Levels + Color-Balance values. Used to
* key the per-pixel adjustment cache so we can skip recomputing when
* nothing changed.
*/
export function adjustmentsKey(adj) {
const l = adj.levels || {};
const cb = adj.colorBalance || {};
const s = cb.shadows || {}, m = cb.midtones || {}, h = cb.highlights || {};
return [
l.inBlack|0, l.inWhite|0, l.gamma || 1, l.outBlack|0, l.outWhite|0,
s.r|0, s.g|0, s.b|0, m.r|0, m.g|0, m.b|0, h.r|0, h.g|0, h.b|0,
].join('|');
}
/** Identity params for each adjustment type. */
export function defaultAdjParams(type) {
switch (type) {
case 'brightness-contrast': return { brightness: 1, contrast: 1 };
case 'hue-saturation': return { hue: 0, saturation: 1 };
case 'levels': return { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 };
case 'color-balance': return {
shadows: { r: 0, g: 0, b: 0 },
midtones: { r: 0, g: 0, b: 0 },
highlights: { r: 0, g: 0, b: 0 },
};
}
return {};
}
/** Human-readable name for an adjustment type. */
export function adjLayerLabel(type) {
return {
'brightness-contrast': 'Brightness/Contrast',
'hue-saturation': 'Hue/Saturation',
'levels': 'Levels',
'color-balance': 'Color Balance',
}[type] || type;
}
/**
* Per-type SVG icon strings. Used in popup title bars, the minimised
* FX-dock chips, and the layer-panel sub-row name so the same glyph
* shows up everywhere a given adjustment type appears.
*/
export const ADJ_ICONS = {
'brightness-contrast': '<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="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none"/></svg>',
'hue-saturation': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="4"/><circle cx="15" cy="9.5" r="4"/><circle cx="15" cy="14.5" r="4"/></svg>',
'levels': '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="14" width="3" height="6" rx="0.5"/><rect x="8" y="9" width="3" height="11" rx="0.5"/><rect x="13" y="11" width="3" height="9" rx="0.5"/><rect x="18" y="6" width="3" height="14" rx="0.5"/></svg>',
'color-balance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12a9 9 0 0 1 9-9v18a9 9 0 0 1-9-9z" fill="currentColor" stroke="none"/></svg>',
};
/** SVG used in the topbar/history button glyphs. */
export const HISTORY_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 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg>';
/** Quick downsampled-alpha check: are there any opaque pixels on this canvas? */
export function isMaskCanvasEmpty(canvas) {
if (!canvas) return true;
try {
const w = canvas.width, h = canvas.height;
if (!w || !h) return true;
const sw = Math.min(200, w), sh = Math.min(200, h);
const tmp = document.createElement('canvas');
tmp.width = sw; tmp.height = sh;
tmp.getContext('2d').drawImage(canvas, 0, 0, sw, sh);
const d = tmp.getContext('2d').getImageData(0, 0, sw, sh).data;
for (let i = 3; i < d.length; i += 4) if (d[i] > 0) return false;
return true;
} catch { return false; }
}
/** Same as `isMaskCanvasEmpty` but accepts a layer wrapper. */
export function isLayerEmpty(layer) {
if (!layer || !layer.canvas) return true;
return isMaskCanvasEmpty(layer.canvas);
}
/**
* Compact "now / 30s / 12m / 4h" relative-time string. Used in the
* editor's history panel labels.
*/
export function relTime(ts) {
if (!ts) return '';
const dt = (Date.now() - ts) / 1000;
if (dt < 5) return 'now';
if (dt < 60) return Math.round(dt) + 's';
if (dt < 3600) return Math.round(dt / 60) + 'm';
return Math.round(dt / 3600) + 'h';
}

View File

@@ -0,0 +1,600 @@
/**
* Layer panel renderer — rebuilds the right-side layer list from
* `state.layers` every time it's called. The full row tree per layer:
*
* parent row
* [drag handle] [eye] [name] [opacity slider] [FX] [dup] [mask] [merge-down] [×]
* adjustment sub-rows (FX entries)
* [eye] [name+icon] [opacity slider] [merge] [×]
* mask sub-rows
* [eye] [name] [merge-up?] [×]
*
* Reads/writes shared `state` directly (layers, activeLayerId,
* layerOffsets, imgWidth, imgHeight, lassoPoints/lassoActive,
* wandMask, maskCanvas/maskCtx, nextLayerId). Function deps are
* orchestration callbacks still living in galleryEditor.js.
*
* Returns `{ render }` so the recursive self-call works via closure
* over `render` rather than module-state lookup.
*
* @param {{
* composite: () => void,
* saveState: (label?: string) => void,
* showLayerThumb: (rowEl: HTMLElement, layer: object) => void,
* hideLayerThumb: () => void,
* loadLayerAlphaAsSelection: (layer: object) => void,
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
* createLayer: (name: string, w: number, h: number) => object,
* lassoToMask: () => void,
* wandToMask: () => void,
* getActiveMaskLayer: () => object | null,
* syncFxPanelToActiveLayerIfPresent: () => void,
* dragSortModule: object | null,
* uiModule: object | null,
* }} deps
*/
import { state } from './state.js';
import {
layerHasAdjustments,
isLayerEmpty,
isMaskCanvasEmpty,
adjLayerLabel,
ADJ_ICONS,
} from './layer-helpers.js';
import { applyAdjustment } from './fx/pixel-pass.js';
import { mergeLayerDownAtIndex } from './wire-merge-buttons.js';
const EYE_OPEN = '<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>';
const EYE_OFF = '<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"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
const EYE_OPEN_SM = '<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="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>';
const EYE_OFF_SM = '<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="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 function createLayerPanelRenderer(deps) {
const {
composite, saveState, showLayerThumb, hideLayerThumb,
loadLayerAlphaAsSelection, openFxPopup, editAdjLayer,
createLayer, lassoToMask, wandToMask, getActiveMaskLayer,
syncFxPanelToActiveLayerIfPresent,
dragSortModule, uiModule,
} = deps;
function shouldIgnoreLayerTap() {
return Date.now() < (window.__geSuppressLayerTapUntil || 0);
}
function render() {
// FX panel mirrors the active layer's adjustments — re-sync on
// every layer event (activation, add, delete, etc).
try { syncFxPanelToActiveLayerIfPresent(); } catch {}
const list = document.getElementById('ge-layers-list');
if (!list) return;
// Mobile bottom-sheet peek height — header + N rows, capped so a
// 20-layer document doesn't get a peek that eats the canvas.
const panel = document.querySelector('.ge-right-panel');
if (panel) {
requestAnimationFrame(() => {
const header = panel.querySelector('.ge-layers-header');
const firstRow = list.querySelector('.ge-layer-item');
const headerH = header ? header.offsetHeight : 52;
const rowH = firstRow ? firstRow.offsetHeight : 36;
const allRows = list.querySelectorAll('.ge-layer-item').length;
const MAX_ROWS = 2;
const rows = Math.min(allRows, MAX_ROWS);
panel.style.setProperty('--peek-height', `${headerH + rows * rowH + 6}px`);
});
}
list.innerHTML = '';
// Render in reverse order (top layer first).
for (let i = state.layers.length - 1; i >= 0; i--) {
const layer = state.layers[i];
const item = document.createElement('div');
// Parent row is highlighted ONLY when it's actually the paint
// target — activated AND no mask sub-layer is currently active.
const parentIsPaintTarget = layer.id === state.activeLayerId &&
!(layer.masks && layer.activeMaskId && layer.masks.some(m => m.id === layer.activeMaskId));
item.className = 'ge-layer-item' +
(parentIsPaintTarget ? ' active' : '') +
(layer.id === state.activeLayerId && !parentIsPaintTarget ? ' active-parent' : '');
item.dataset.layerId = layer.id;
// Hover thumbnail.
item.addEventListener('mouseenter', () => showLayerThumb(item, layer));
item.addEventListener('mouseleave', () => hideLayerThumb());
item.addEventListener('click', (e) => {
if (shouldIgnoreLayerTap()) {
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+click → load layer transparency as wand selection.
if (e.shiftKey) {
e.preventDefault();
loadLayerAlphaAsSelection(layer);
return;
}
if (state.activeLayerId === layer.id) return;
state.activeLayerId = layer.id;
// Toggle the active class inline (avoid full re-render so the
// dblclick listener on the name element stays alive between
// clicks — a re-render destroys the element after the first
// click and the second lands on a different node).
document.querySelectorAll('.ge-layers-list .ge-layer-item').forEach(el => {
el.classList.toggle('active', el.dataset.layerId === state.activeLayerId);
});
});
// Drag handle — grip dots; dragSortModule.enable() below scopes
// drag-init to this handle so row body clicks still activate.
const handle = document.createElement('span');
handle.className = 'ge-layer-drag';
handle.title = 'Drag to reorder';
handle.innerHTML = '<svg width="8" height="14" viewBox="0 0 8 14" fill="currentColor"><circle cx="2" cy="2" r="1"/><circle cx="6" cy="2" r="1"/><circle cx="2" cy="7" r="1"/><circle cx="6" cy="7" r="1"/><circle cx="2" cy="12" r="1"/><circle cx="6" cy="12" r="1"/></svg>';
item.appendChild(handle);
const visBtn = document.createElement('button');
visBtn.className = 'ge-layer-vis' + (layer.visible ? ' visible' : '');
visBtn.innerHTML = layer.visible ? EYE_OPEN : EYE_OFF;
visBtn.title = layer.visible ? 'Hide layer' : 'Show layer';
visBtn.addEventListener('click', (e) => {
e.stopPropagation();
layer.visible = !layer.visible;
composite();
render();
});
const nameEl = document.createElement('span');
nameEl.className = 'ge-layer-name';
nameEl.textContent = layer.name + (isLayerEmpty(layer) ? ' (empty)' : '');
nameEl.addEventListener('dblclick', () => {
const input = document.createElement('input');
input.type = 'text';
input.value = layer.name;
input.className = 'ge-layer-name-input';
nameEl.replaceWith(input);
input.focus();
const save = () => { layer.name = input.value || layer.name; render(); };
input.addEventListener('blur', save);
input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') save(); });
});
const opSlider = document.createElement('input');
opSlider.type = 'range';
opSlider.min = '0';
opSlider.max = '100';
opSlider.value = String(Math.round(layer.opacity * 100));
opSlider.className = 'ge-layer-opacity';
opSlider.title = 'Opacity';
opSlider.addEventListener('input', (e) => {
e.stopPropagation();
layer.opacity = parseInt(e.target.value) / 100;
composite();
});
// Browser :active drops the moment the cursor leaves the slider
// hit-area in some browsers; a JS-managed `dragging` class
// survives the OS pointer-capture so the slider stays expanded
// for the whole drag.
opSlider.addEventListener('pointerdown', () => {
opSlider.classList.add('dragging');
const onUp = () => {
opSlider.classList.remove('dragging');
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointerup', onUp);
});
const controls = document.createElement('div');
controls.className = 'ge-layer-controls';
// FX (adjustments) — opens a floating popup bound to this layer.
const fxBtn = document.createElement('button');
fxBtn.className = 'ge-layer-btn ge-layer-fx-btn' + (layerHasAdjustments(layer) ? ' active' : '');
fxBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor"/></svg>';
fxBtn.title = 'Adjust layer (Brightness, Contrast, Saturation, Hue, Levels, Color Balance)';
fxBtn.style.touchAction = 'manipulation';
let lastFxPointerOpenAt = 0;
let fxOpenTimer = null;
const openLayerFx = (e, delay = 0) => {
e.preventDefault?.();
e.stopPropagation();
window.__geSuppressLayerTapUntil = 0;
if (fxOpenTimer) clearTimeout(fxOpenTimer);
fxOpenTimer = setTimeout(() => {
fxOpenTimer = null;
openFxPopup(layer, fxBtn);
}, delay);
};
fxBtn.addEventListener('pointerdown', (e) => {
e.stopPropagation();
});
fxBtn.addEventListener('pointerup', (e) => {
lastFxPointerOpenAt = Date.now();
const delay = e.pointerType === 'touch' || e.pointerType === 'pen' ? 120 : 0;
openLayerFx(e, delay);
});
fxBtn.addEventListener('click', (e) => {
if (Date.now() - lastFxPointerOpenAt < 500) {
e.preventDefault();
e.stopPropagation();
return;
}
openLayerFx(e);
});
controls.appendChild(fxBtn);
// Duplicate — clones pixels + offset + opacity + masks + adjLayers
// + visibility; inserts above the original; new copy becomes
// active.
const dupBtn = document.createElement('button');
dupBtn.className = 'ge-layer-btn';
dupBtn.title = 'Duplicate layer';
dupBtn.innerHTML = '<svg width="12" height="12" 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>';
dupBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Duplicate "${layer.name}"`);
const copy = createLayer(layer.name + ' copy', layer.canvas.width, layer.canvas.height);
copy.ctx.drawImage(layer.canvas, 0, 0);
copy.opacity = layer.opacity;
copy.visible = layer.visible;
const srcOff = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.layerOffsets.set(copy.id, { x: srcOff.x, y: srcOff.y });
if (Array.isArray(layer.masks) && layer.masks.length) {
copy.masks = layer.masks.map(m => {
const c = document.createElement('canvas');
c.width = m.canvas.width; c.height = m.canvas.height;
c.getContext('2d').drawImage(m.canvas, 0, 0);
return {
id: 'mask-' + (state.nextLayerId++),
name: m.name,
canvas: c,
ctx: c.getContext('2d'),
visible: m.visible !== false,
};
});
}
if (Array.isArray(layer.adjLayers) && layer.adjLayers.length) {
copy.adjLayers = layer.adjLayers.map(a => ({
id: 'adj-' + Math.random().toString(36).slice(2, 9),
type: a.type,
name: a.name,
visible: a.visible !== false,
opacity: a.opacity != null ? a.opacity : 1,
params: JSON.parse(JSON.stringify(a.params || {})),
}));
}
const idx = state.layers.findIndex(l => l.id === layer.id);
if (idx >= 0) state.layers.splice(idx + 1, 0, copy);
else state.layers.push(copy);
state.activeLayerId = copy.id;
composite();
render();
if (uiModule) uiModule.showToast('Layer duplicated');
});
controls.appendChild(dupBtn);
// Add-mask — if a lasso/wand selection is active, bake it into a
// mask sub-layer on this layer; otherwise create an empty mask
// for the user to paint with the Brush tool.
const hasLassoSelInitial = state.lassoPoints.length >= 3 && !state.lassoActive;
const hasWandSelInitial = !!state.wandMask;
const maskBtn = document.createElement('button');
maskBtn.className = 'ge-layer-btn ge-layer-mask-btn' +
((hasLassoSelInitial || hasWandSelInitial) ? ' from-selection' : '');
maskBtn.innerHTML = '<svg width="12" height="12" 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"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>';
maskBtn.title = (hasLassoSelInitial || hasWandSelInitial)
? 'Make mask from current selection'
: 'Add empty mask (paint with Brush)';
maskBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Activate this layer first so the new mask attaches here.
state.activeLayerId = layer.id;
// Re-check selection state AT CLICK TIME — captured vars may
// be stale if a selection was drawn after the panel paint.
const hasLassoSel = state.lassoPoints.length >= 3 && !state.lassoActive;
const hasWandSel = !!state.wandMask;
if (hasLassoSel) {
saveState(`Mask from lasso on "${layer.name}"`);
// Force a fresh mask sub-layer for this conversion so each
// selection becomes its own mask instead of merging into the
// previously active one.
layer.activeMaskId = null;
lassoToMask();
} else if (hasWandSel) {
saveState(`Mask from wand on "${layer.name}"`);
layer.activeMaskId = null;
wandToMask();
} else {
saveState(`Add mask to "${layer.name}"`);
const c = document.createElement('canvas');
c.width = state.imgWidth;
c.height = state.imgHeight;
if (!layer.masks) layer.masks = [];
const mask = {
id: 'mask-' + (state.nextLayerId++),
name: 'Mask ' + (layer.masks.length + 1),
canvas: c,
ctx: c.getContext('2d'),
visible: true,
};
layer.masks.push(mask);
layer.activeMaskId = mask.id;
state.maskCanvas = mask.canvas;
state.maskCtx = mask.ctx;
composite();
render();
}
});
controls.appendChild(maskBtn);
// Per-row Merge Down — bakes this layer into the one beneath.
// Hidden on the bottom layer in the visual stack (idx 0 forward).
if (i > 0) {
const mergeDownBtn = document.createElement('button');
mergeDownBtn.className = 'ge-layer-btn';
mergeDownBtn.title = 'Merge down into layer below';
mergeDownBtn.innerHTML = '<svg width="12" height="12" 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>';
mergeDownBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Merge "${layer.name}" down`);
mergeLayerDownAtIndex(i);
composite();
render();
uiModule.showToast('Layer merged down');
});
controls.appendChild(mergeDownBtn);
}
// Delete — shown for every layer except when this is the last
// remaining one. Base photo is deletable too; Ctrl+Z brings it
// back from history. Extra confirm for the base layer.
if (state.layers.length > 1) {
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer';
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (layer.isBase && uiModule?.styledConfirm) {
const ok = await uiModule.styledConfirm(
'Delete the original photo layer? Ctrl+Z brings it back.',
{ confirmText: 'Delete', cancelText: 'Cancel', danger: true }
);
if (!ok) return;
}
// Snapshot BEFORE removing so Ctrl+Z can bring it back.
saveState(`Delete layer "${layer.name}"`);
state.layers.splice(i, 1);
state.layerOffsets.delete(layer.id);
if (state.activeLayerId === layer.id) {
state.activeLayerId = state.layers[Math.min(i, state.layers.length - 1)].id;
}
composite();
render();
});
controls.appendChild(delBtn);
}
item.appendChild(visBtn);
item.appendChild(nameEl);
item.appendChild(opSlider);
item.appendChild(controls);
item.addEventListener('click', () => {
if (shouldIgnoreLayerTap()) return;
state.activeLayerId = layer.id;
// Clicking the PARENT row makes layer pixels the paint target
// (mask is no longer the target). Mask sub-rows stay in the
// panel; clicking one re-targets it.
layer.activeMaskId = null;
state.maskCanvas = null;
state.maskCtx = null;
render();
composite();
});
list.appendChild(item);
// Adjustment sub-layer rows, indented under the parent.
if (layer.adjLayers && layer.adjLayers.length) {
for (const adj of layer.adjLayers) {
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item';
sub.dataset.adjId = adj.id;
const sVis = document.createElement('button');
sVis.className = 'ge-layer-vis' + (adj.visible ? ' visible' : '');
sVis.innerHTML = adj.visible ? EYE_OPEN_SM : EYE_OFF_SM;
sVis.title = adj.visible ? 'Hide adjustment' : 'Show adjustment';
sVis.addEventListener('click', (e) => {
e.stopPropagation();
adj.visible = !adj.visible;
layer._adjFinalKey = null;
composite();
render();
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
sName.innerHTML = `<span class="ge-adj-sub-icon">${ADJ_ICONS[adj.type] || ''}</span><span>${(adj.name || adjLayerLabel(adj.type)).replace(/[<>&]/g,'')}</span>`;
const sOp = document.createElement('input');
sOp.type = 'range';
sOp.min = '0'; sOp.max = '100';
sOp.value = Math.round(adj.opacity * 100);
sOp.className = 'ge-layer-opacity';
sOp.title = 'Adjustment opacity';
sOp.addEventListener('input', () => {
adj.opacity = parseInt(sOp.value, 10) / 100;
layer._adjFinalKey = null;
composite();
});
const sControls = document.createElement('div');
sControls.className = 'ge-layer-controls';
const mergeBtn = document.createElement('button');
mergeBtn.className = 'ge-layer-btn';
mergeBtn.title = 'Merge into layer (bake)';
mergeBtn.innerHTML = '<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="M12 19V5M5 12l7-7 7 7"/></svg>';
mergeBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Bake just this adjustment into layer.canvas, then drop it.
saveState(`Merge ${adjLayerLabel(adj.type)}`);
const baked = applyAdjustment(layer.canvas, adj);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
layer.ctx.drawImage(baked, 0, 0);
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
layer._adjFinalKey = null;
composite();
render();
});
sControls.appendChild(mergeBtn);
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = 'Delete adjustment';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Delete ${adjLayerLabel(adj.type)}`);
layer.adjLayers = layer.adjLayers.filter(x => x.id !== adj.id);
layer._adjFinalKey = null;
composite();
render();
});
sControls.appendChild(delBtn);
sub.appendChild(sVis);
sub.appendChild(sName);
sub.appendChild(sOp);
sub.appendChild(sControls);
// Single-click on the sub-row (outside the inline controls)
// reopens the adj popup with this sub-layer's params staged.
sub.addEventListener('click', (e) => {
if (shouldIgnoreLayerTap()) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.target.closest('.ge-layer-vis, .ge-layer-opacity, .ge-layer-btn')) return;
if (!e.target.closest('.ge-adj-sub-name')) return;
e.stopPropagation();
editAdjLayer(layer, adj, sub);
});
list.appendChild(sub);
}
}
// Mask sub-layer rows.
if (layer.masks && layer.masks.length) {
for (let mi = 0; mi < layer.masks.length; mi++) {
const mk = layer.masks[mi];
const sub = document.createElement('div');
sub.className = 'ge-layer-item ge-adj-sub-item ge-mask-sub-item' +
(layer.activeMaskId === mk.id ? ' active' : '');
sub.dataset.maskId = mk.id;
const sVis = document.createElement('button');
sVis.className = 'ge-layer-vis' + (mk.visible ? ' visible' : '');
sVis.innerHTML = mk.visible ? EYE_OPEN_SM : EYE_OFF_SM;
sVis.title = mk.visible ? 'Hide mask' : 'Show mask';
sVis.addEventListener('click', (e) => {
e.stopPropagation();
mk.visible = !mk.visible;
composite();
render();
});
const sName = document.createElement('span');
sName.className = 'ge-layer-name ge-adj-sub-name';
const maskIcon = '<svg width="12" height="12" 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"/><path d="M3 12c4 0 4-4 8-4s4 4 8 4-4 4-8 4-4-4-8-4z" fill="currentColor"/></svg>';
const mkName = String(mk.name || 'Mask').replace(/[<>&]/g, '');
const mkEmpty = isMaskCanvasEmpty(mk.canvas) ? ' <span style="opacity:0.55;">(empty)</span>' : '';
sName.innerHTML = `<span class="ge-adj-sub-icon">${maskIcon}</span><span>${mkName}${mkEmpty}</span>`;
const sControls = document.createElement('div');
sControls.className = 'ge-layer-controls';
// Merge-up — combine this mask into the one above (lower mi).
if (mi > 0) {
const mergeBtn = document.createElement('button');
mergeBtn.className = 'ge-layer-btn';
mergeBtn.title = 'Merge into mask above';
mergeBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="6 11 12 5 18 11"/></svg>';
mergeBtn.addEventListener('click', (e) => {
e.stopPropagation();
const above = layer.masks[mi - 1];
if (!above) return;
saveState(`Merge mask "${mk.name}" into "${above.name}"`);
// Union of alpha — `source-over` already does max for
// fully opaque white masks; this also handles partial alpha.
above.ctx.save();
above.ctx.globalCompositeOperation = 'source-over';
above.ctx.drawImage(mk.canvas, 0, 0);
above.ctx.restore();
layer.masks = layer.masks.filter(x => x.id !== mk.id);
if (layer.activeMaskId === mk.id) layer.activeMaskId = above.id;
const a = getActiveMaskLayer();
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
else { state.maskCanvas = null; state.maskCtx = null; }
composite();
render();
});
sControls.appendChild(mergeBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'ge-layer-btn danger';
delBtn.textContent = '×';
delBtn.title = 'Delete mask';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveState(`Delete mask "${mk.name}"`);
layer.masks = layer.masks.filter(x => x.id !== mk.id);
if (layer.activeMaskId === mk.id) {
layer.activeMaskId = layer.masks[layer.masks.length - 1]?.id || null;
}
// Sync global mask plumbing.
const a = getActiveMaskLayer();
if (a) { state.maskCanvas = a.canvas; state.maskCtx = a.ctx; }
else { state.maskCanvas = null; state.maskCtx = null; }
composite();
render();
});
sControls.appendChild(delBtn);
sub.appendChild(sVis);
sub.appendChild(sName);
sub.appendChild(sControls);
sub.addEventListener('click', (e) => {
if (e.target.closest('.ge-layer-vis, .ge-layer-btn')) return;
e.stopPropagation();
// Activate this mask: paint/inpaint/generate target.
layer.activeMaskId = mk.id;
state.activeLayerId = layer.id;
state.maskCanvas = mk.canvas;
state.maskCtx = mk.ctx;
render();
composite();
});
list.appendChild(sub);
}
}
}
// Wire the shared dragSort module — limit drag-init to the grip
// handle so row body clicks still activate. Called every render
// because `enable()` cleans up the previous instance keyed on
// instanceKey.
if (dragSortModule) {
dragSortModule.enable('ge-layers-list', '.ge-layer-item', {
instanceKey: 'ge-layers',
handleSelector: '.ge-layer-drag',
onReorder: (orderedItems) => {
// DOM is top→bottom = reverse of array order, so the new
// array is the reverse of the DOM order.
const byId = new Map(state.layers.map(l => [l.id, l]));
const newLayers = orderedItems
.map(el => byId.get(el.dataset.layerId))
.filter(Boolean)
.reverse();
if (newLayers.length === state.layers.length) {
state.layers = newLayers;
saveState();
composite();
}
},
});
}
}
return { render };
}

View File

@@ -0,0 +1,91 @@
/**
* Mask-canvas helpers used by the inpaint pipeline.
*
* Pure utility functions — they take a canvas (or layer-shape) as
* input and return a fresh canvas, with no module-level state.
*/
/**
* Dilate (positive `px`) or erode (negative `px`) a binary alpha mask.
*
* Strategy: blur the source by `|px|`, then re-threshold the result.
* - Dilation keeps anything with non-trivial blurred alpha (low cutoff).
* - Erosion keeps only pixels that retained near-full alpha after blur.
*
* @param {HTMLCanvasElement} src Source mask canvas.
* @param {number} px Pixels to dilate (>0) or erode (<0). 0 = copy.
* @returns {HTMLCanvasElement} Fresh canvas with the same dimensions.
*/
export function dilateMask(src, px) {
const w = src.width, h = src.height;
const tmp = document.createElement('canvas');
tmp.width = w; tmp.height = h;
const ctx = tmp.getContext('2d');
if (px === 0) {
ctx.drawImage(src, 0, 0);
return tmp;
}
const dilate = px > 0;
const radius = Math.abs(px);
ctx.filter = `blur(${radius}px)`;
ctx.drawImage(src, 0, 0);
ctx.filter = 'none';
const img = ctx.getImageData(0, 0, w, h);
const threshold = dilate ? 8 : 247;
for (let i = 0; i < img.data.length; i += 4) {
const a = img.data[i + 3];
const keep = dilate ? a > threshold : a >= threshold;
if (keep) {
img.data[i] = img.data[i + 1] = img.data[i + 2] = 255;
img.data[i + 3] = 255;
} else {
img.data[i + 3] = 0;
}
}
ctx.putImageData(img, 0, 0);
return tmp;
}
/**
* Re-derive an inpaint-result layer's alpha from its cached AI image +
* the hard mask, applying a feather + optional dilate/erode of the
* boundary. Mutates `layer.canvas` in place via `layer.ctx`.
*
* The layer must carry an `inpaintSource = { ai, mask }` cache from the
* original inpaint call so we can re-shape the alpha cheaply (no
* second model call required).
*
* @param {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D,
* inpaintSource?: {ai: CanvasImageSource, mask: HTMLCanvasElement}}} layer
* @param {number} featherPx Gaussian blur radius applied to the mask alpha.
* @param {number} [edgeShiftPx] Dilate (+) or erode (-) the mask before blurring.
*/
export function applyInpaintFeather(layer, featherPx, edgeShiftPx = 0) {
if (!layer || !layer.inpaintSource) return;
const { ai, mask } = layer.inpaintSource;
const w = layer.canvas.width;
const h = layer.canvas.height;
// 1) Optional dilate/erode, then optional blur, into a fresh mask.
let shaped = mask;
if (edgeShiftPx !== 0) shaped = dilateMask(mask, edgeShiftPx);
const softMask = document.createElement('canvas');
softMask.width = w; softMask.height = h;
const smCtx = softMask.getContext('2d');
if (featherPx > 0) {
smCtx.filter = `blur(${featherPx}px)`;
smCtx.drawImage(shaped, 0, 0, w, h);
smCtx.filter = 'none';
} else {
smCtx.drawImage(shaped, 0, 0, w, h);
}
// 2) Draw the AI image fresh, then multiply alpha by the soft mask.
const ctx = layer.ctx;
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, w, h);
ctx.drawImage(ai, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(softMask, 0, 0);
ctx.restore();
}

View File

@@ -0,0 +1,149 @@
/**
* Shortcuts-cheatsheet popover — floating frosted-glass list of every
* editor keyboard shortcut, anchored above the topbar keyboard icon
* (drops below if there's no room above). Drag the header to move;
* Esc or click outside dismisses; position is persisted in
* localStorage so re-opening restores where the user left it.
*
* Public API: `toggleShortcuts(show?)` — true/false to force a state,
* undefined to toggle.
*
* @returns {{ toggleShortcuts: (show?: boolean) => void }}
*/
import { shortcutsPopupHTML } from './build/popups.js';
export function createShortcutsPopover() {
let pop = null;
let outside = null;
function ensurePopover() {
if (pop) return pop;
const el = document.createElement('div');
el.id = 'ge-shortcuts-popover';
el.style.cssText = [
'position:fixed', 'z-index:10000', 'display:none',
// Frosted-glass background: semi-transparent + heavy blur of
// what's behind. Layered with an inner translucent veil so
// light themes also read clearly without losing the see-through
// feel.
'background:color-mix(in srgb, var(--panel, #1a1a1a) 55%, transparent)',
'backdrop-filter:blur(18px) saturate(150%)',
'-webkit-backdrop-filter:blur(18px) saturate(150%)',
'color:var(--fg,#eee)',
'border:1px solid color-mix(in srgb, var(--fg, #eee) 18%, transparent)',
'border-radius:12px',
'box-shadow:0 14px 36px rgba(0,0,0,0.5), inset 0 1px 0 color-mix(in srgb, var(--fg, #fff) 8%, transparent)',
'padding:12px 14px', 'min-width:540px', 'max-width:min(720px,92vw)',
'font-size:12px', 'line-height:1.5',
].join(';');
el.innerHTML = shortcutsPopupHTML();
document.body.appendChild(el);
el.querySelector('#ge-shortcuts-close').addEventListener('click', () => toggleShortcuts(false));
// Drag by the header handle. Position survives across opens
// (localStorage).
const handle = el.querySelector('#ge-shortcuts-handle');
if (handle) {
let drag = null;
handle.addEventListener('pointerdown', (e) => {
if (e.target.closest('#ge-shortcuts-close')) return;
const r = el.getBoundingClientRect();
drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height };
handle.setPointerCapture(e.pointerId);
handle.style.cursor = 'grabbing';
// Mark as user-positioned so subsequent toggles don't re-anchor.
el.dataset.userPositioned = '1';
e.preventDefault();
});
handle.addEventListener('pointermove', (e) => {
if (!drag) return;
let left = e.clientX - drag.dx;
let top = e.clientY - drag.dy;
const m = 4;
left = Math.max(m, Math.min(left, window.innerWidth - drag.w - m));
top = Math.max(m, Math.min(top, window.innerHeight - drag.h - m));
el.style.left = left + 'px';
el.style.top = top + 'px';
});
const endDrag = () => {
if (!drag) return;
drag = null;
handle.style.cursor = 'grab';
try {
localStorage.setItem('ge-shortcuts-pos', JSON.stringify({
left: el.style.left, top: el.style.top,
}));
} catch {}
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
}
pop = el;
return pop;
}
function positionPopover(el, anchor) {
// Place ABOVE the anchor, horizontally centred but clamped to
// viewport. Falls back to BELOW if there's no room above.
el.style.display = 'block'; // need a layout pass for accurate size
const ar = anchor.getBoundingClientRect();
const pr = el.getBoundingClientRect();
const margin = 8;
let left = ar.left + (ar.width / 2) - (pr.width / 2);
let top = ar.top - pr.height - margin;
if (top < margin) top = ar.bottom + margin;
left = Math.max(margin, Math.min(left, window.innerWidth - pr.width - margin));
top = Math.max(margin, Math.min(top, window.innerHeight - pr.height - margin));
el.style.left = left + 'px';
el.style.top = top + 'px';
}
function toggleShortcuts(show) {
const el = ensurePopover();
const open = show === undefined ? el.style.display === 'none' : show;
if (open) {
// Restore the user's last-dragged position if any; otherwise
// anchor above the button.
let saved = null;
try { saved = JSON.parse(localStorage.getItem('ge-shortcuts-pos') || 'null'); } catch {}
if (saved && saved.left && saved.top) {
el.style.display = 'block';
el.style.left = saved.left;
el.style.top = saved.top;
// Re-clamp in case the viewport changed since the user dragged.
requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
const m = 4;
if (r.right > window.innerWidth) el.style.left = (window.innerWidth - r.width - m) + 'px';
if (r.bottom > window.innerHeight) el.style.top = (window.innerHeight - r.height - m) + 'px';
if (r.left < 0) el.style.left = m + 'px';
if (r.top < 0) el.style.top = m + 'px';
});
} else {
const anchor = document.getElementById('ge-shortcuts-btn');
if (anchor) positionPopover(el, anchor);
else el.style.display = 'block';
}
// Defer outside-click so the click that opened us doesn't close us.
outside = (e) => {
if (el.contains(e.target)) return;
if (e.target.closest('#ge-shortcuts-btn')) return;
toggleShortcuts(false);
};
setTimeout(() => document.addEventListener('mousedown', outside, true), 0);
} else {
el.style.display = 'none';
if (outside) {
document.removeEventListener('mousedown', outside, true);
outside = null;
}
}
}
/** True when the popover is currently visible. */
function isOpen() {
return !!(pop && pop.style.display && pop.style.display !== 'none');
}
return { toggleShortcuts, isOpen };
}

View File

@@ -0,0 +1,191 @@
/**
* Slider-UX wiring shared across the editor:
*
* 1. `is-using` class while a slider is being dragged (eraser-rows
* expand to a wider track when in use). Cleared 0.5s after
* pointerup so a quick click doesn't snap back instantly.
* 2. Floating value bubble above the thumb during drag.
* Desktop: only the layer-opacity slider gets a bubble (the
* eraser-row sliders already show a value chip on the right).
* Mobile: every slider in the editor gets a bubble.
* 3. Click the value chip to type a number directly — replaces
* the span with an inline input until blur/Enter.
*
* Wired ONCE on editor open; the listeners stay alive for the whole
* session via state.container delegation.
*
* @param {{
* registerDocClickAway: (handler: (e: Event) => void) => void,
* }} deps
*/
import { state } from './state.js';
export function wireSliderUx({ registerDocClickAway }) {
const container = state.container;
if (!container) return;
// ── Floating bubble ──
const sliderBubble = document.createElement('div');
sliderBubble.className = 'ge-slider-bubble';
sliderBubble.hidden = true;
let sliderBubbleSlider = null;
// Find the container row for any slider — works for ge-eraser-row
// sliders AND the layer-opacity slider on each layer item.
function bubbleRowFor(slider) {
return slider.closest('.ge-eraser-row, .ge-layer-item, .ge-control-row, .ge-adj-row');
}
function bubbleText(slider) {
const row = bubbleRowFor(slider);
// Pulled-out value chip (after the slider) wins; fall back to
// the various `<label> <span>` styles used across the editor.
const chip = row?.querySelector('.ge-slider-value')
|| row?.querySelector('label > span[id$="-label"]')
|| row?.querySelector('label > .ge-size-label')
|| row?.querySelector('.ge-adj-value');
if (chip) return chip.textContent;
if (slider.classList.contains('ge-layer-opacity')) {
return Math.round(parseFloat(slider.value)) + '%';
}
return slider.value;
}
function bubblePos(slider, cursorX) {
// Bubble is fixed-positioned on document.body so it escapes any
// overflow:hidden / overflow:auto on the row's ancestors. The
// bubble's X is CLAMPED to the slider's track so it can't follow
// a finger that drags way past either end.
const sliderRect = slider.getBoundingClientRect();
const minX = sliderRect.left + 8;
const maxX = sliderRect.right - 8;
const x = Math.max(minX, Math.min(maxX, cursorX));
sliderBubble.style.left = x + 'px';
sliderBubble.style.top = (sliderRect.top - 8) + 'px';
}
function showSliderBubble(slider, e) {
if (sliderBubble.parentElement !== document.body) document.body.appendChild(sliderBubble);
sliderBubble.textContent = bubbleText(slider);
bubblePos(slider, e ? e.clientX : slider.getBoundingClientRect().left + slider.offsetWidth / 2);
sliderBubble.hidden = false;
sliderBubble.classList.add('visible');
sliderBubbleSlider = slider;
}
function hideSliderBubble() {
sliderBubble.classList.remove('visible');
sliderBubble.hidden = true;
sliderBubbleSlider = null;
}
const slidingTimers = new WeakMap();
// Desktop: only the layer-opacity slider gets the bubble (eraser-
// rows have their own chip). Mobile: every slider gets one.
const isMobileSliders = window.matchMedia('(max-width: 820px)').matches;
const SLIDER_SEL = isMobileSliders
? '.ge-layer-opacity, .ge-eraser-row input[type="range"], .ge-control-row input[type="range"], .ge-adj-row input[type="range"]'
: '.ge-layer-opacity';
container.addEventListener('pointerdown', (e) => {
const slider = e.target.closest(SLIDER_SEL);
if (!slider) return;
const t = slidingTimers.get(slider);
if (t) { clearTimeout(t); slidingTimers.delete(slider); }
slider.classList.add('is-using');
showSliderBubble(slider, e);
// Compensate for the leftward-expanding eraser sliders so the
// thumb lands at the cursor's X on the new (wider) track. Layer-
// opacity doesn't shift left when it grows, so it uses the
// browser default.
if (slider.matches('.ge-eraser-row input[type="range"]')) {
const rect = slider.getBoundingClientRect();
const valFrac = Math.max(0, Math.min(1, 1 - (rect.right - e.clientX) / 140));
const min = parseFloat(slider.min) || 0;
const max = parseFloat(slider.max) || 100;
const step = parseFloat(slider.step) || 1;
const raw = min + valFrac * (max - min);
const stepped = Math.round(raw / step) * step;
requestAnimationFrame(() => {
slider.value = String(stepped);
slider.dispatchEvent(new Event('input', { bubbles: true }));
sliderBubble.textContent = bubbleText(slider);
});
} else {
requestAnimationFrame(() => {
sliderBubble.textContent = bubbleText(slider);
});
}
}, true);
document.addEventListener('pointermove', (e) => {
if (!sliderBubbleSlider) return;
bubblePos(sliderBubbleSlider, e.clientX);
sliderBubble.textContent = bubbleText(sliderBubbleSlider);
});
const scheduleSliderRelease = (slider) => {
if (!slider) return;
const old = slidingTimers.get(slider);
if (old) clearTimeout(old);
const t = setTimeout(() => {
slider.classList.remove('is-using');
slidingTimers.delete(slider);
}, 500);
slidingTimers.set(slider, t);
};
document.addEventListener('pointerup', () => {
container.querySelectorAll('input[type="range"].is-using').forEach(scheduleSliderRelease);
hideSliderBubble();
});
// ── Click value chip to type a number ──
// Replaces the chip with a tiny inline input until blur/Enter,
// then writes back to the slider and dispatches `input` so
// previews react. Matches the legacy chip AND the pulled-out
// `.ge-slider-value` chip so every slider row in the editor is
// click-to-type editable.
registerDocClickAway((e) => {
const chip = e.target.closest(
'.ge-eraser-row .ge-slider-value, ' +
'.ge-eraser-row label > span[id$="-label"], ' +
'.ge-eraser-row > span[id$="-label"], ' +
'.ge-adj-row .ge-adj-value'
);
if (!chip) return;
const row = chip.closest('.ge-eraser-row, .ge-adj-row');
const slider = row?.querySelector('input[type="range"]');
if (!slider) return;
e.preventDefault();
e.stopPropagation();
const numeric = (slider.value ?? '').toString();
const inp = document.createElement('input');
inp.type = 'text';
inp.value = numeric;
inp.className = 'ge-slider-edit';
chip.style.visibility = 'hidden';
row.appendChild(inp);
// Position the input over where the chip sits.
const crect = chip.getBoundingClientRect();
const rrect = row.getBoundingClientRect();
inp.style.left = (crect.left - rrect.left) + 'px';
inp.style.top = (crect.top - rrect.top - 1) + 'px';
inp.style.width = Math.max(40, crect.width + 8) + 'px';
inp.focus();
inp.select();
const commit = () => {
const v = parseFloat(inp.value);
if (!Number.isNaN(v)) {
const min = parseFloat(slider.min) || 0;
const max = parseFloat(slider.max) || 100;
const clamped = Math.max(min, Math.min(max, v));
slider.value = String(clamped);
slider.dispatchEvent(new Event('input', { bubbles: true }));
}
cleanup();
};
const cleanup = () => {
inp.remove();
chip.style.visibility = '';
};
inp.addEventListener('blur', commit);
inp.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
if (ev.key === 'Escape') { ev.preventDefault(); cleanup(); }
});
});
}

106
static/js/editor/snap.js Normal file
View File

@@ -0,0 +1,106 @@
/**
* Snap-while-dragging: when the move tool drags a layer near another
* layer's edge or the canvas centre/edges, gently lock the proposed
* (nx, ny) to the nearest target within SNAP_PX.
*
* The implementation is pure — it takes the layer being moved + the
* trial offset + a context describing zoom + the other layers, and
* returns the snapped position plus any guides to draw.
*
* The legacy gallery editor's `_computeSnap` is a one-line wrapper
* that builds the context from module state.
*
* @param {{canvas: HTMLCanvasElement, id: string}} layer
* The layer currently being moved.
* @param {number} nx / ny
* Trial offset (top-left) in canvas pixels, before snapping.
* @param {{
* zoom: number,
* canvasW: number,
* canvasH: number,
* otherLayers: Array<{visible: boolean, id: string, canvas: HTMLCanvasElement, offset: {x:number, y:number}}>,
* }} ctx
* @returns {{x: number, y: number, guides: Array}}
*/
export function computeSnap(layer, nx, ny, ctx) {
const SNAP_PX = 6 / Math.max(ctx.zoom, 0.0001);
const cw = ctx.canvasW, ch = ctx.canvasH;
const w = layer.canvas.width, h = layer.canvas.height;
const vTargets = [
{ x: 0, label: 'canvas-l' },
{ x: cw, label: 'canvas-r' },
{ x: cw / 2, label: 'canvas-cx' },
];
const hTargets = [
{ y: 0, label: 'canvas-t' },
{ y: ch, label: 'canvas-b' },
{ y: ch / 2, label: 'canvas-cy' },
];
for (const other of ctx.otherLayers) {
if (!other.visible || other.id === layer.id) continue;
const o = other.offset || { x: 0, y: 0 };
const ow = other.canvas.width, oh = other.canvas.height;
vTargets.push({ x: o.x, label: 'layer-l' });
vTargets.push({ x: o.x + ow, label: 'layer-r' });
vTargets.push({ x: o.x + ow / 2, label: 'layer-cx' });
hTargets.push({ y: o.y, label: 'layer-t' });
hTargets.push({ y: o.y + oh, label: 'layer-b' });
hTargets.push({ y: o.y + oh / 2, label: 'layer-cy' });
}
const myEdgesX = { l: nx, cx: nx + w / 2, r: nx + w };
const myEdgesY = { t: ny, cy: ny + h / 2, b: ny + h };
let bestX = null, bestDx = Infinity;
let bestY = null, bestDy = Infinity;
for (const [src, val] of Object.entries(myEdgesX)) {
for (const t of vTargets) {
const d = Math.abs(t.x - val);
if (d < SNAP_PX && d < bestDx) {
bestDx = d;
bestX = { snapTo: t.x, src, target: t };
}
}
}
for (const [src, val] of Object.entries(myEdgesY)) {
for (const t of hTargets) {
const d = Math.abs(t.y - val);
if (d < SNAP_PX && d < bestDy) {
bestDy = d;
bestY = { snapTo: t.y, src, target: t };
}
}
}
const guides = [];
let snappedX = nx, snappedY = ny;
if (bestX) {
if (bestX.src === 'l') snappedX = bestX.snapTo;
else if (bestX.src === 'cx') snappedX = bestX.snapTo - w / 2;
else snappedX = bestX.snapTo - w;
guides.push({ vertical: true, x: bestX.snapTo });
}
if (bestY) {
if (bestY.src === 't') snappedY = bestY.snapTo;
else if (bestY.src === 'cy') snappedY = bestY.snapTo - h / 2;
else snappedY = bestY.snapTo - h;
guides.push({ vertical: false, y: bestY.snapTo });
}
return { x: snappedX, y: snappedY, guides };
}
/**
* CSS cursor name for each transform-tool handle.
*
* @param {'tl'|'tr'|'bl'|'br'|'rot'|string} id
* @returns {string}
*/
export function cursorForHandle(id) {
switch (id) {
case 'tl': case 'br': return 'nwse-resize';
case 'tr': case 'bl': return 'nesw-resize';
case 'rot': return 'grab';
default: return 'default';
}
}

257
static/js/editor/state.js Normal file
View File

@@ -0,0 +1,257 @@
/**
* Editor state store — a single mutable object that the gallery editor
* and its tool modules read and write directly.
*
* Migration: galleryEditor.js used to own ~110 module-scope `let`
* declarations and capture them via closure. Tool modules can't import
* a `let` binding's mutations across module boundaries, so we move the
* state into a single exported OBJECT whose properties are freely
* mutated by anyone holding a reference. Read/write `state.transformW`
* exactly the way the old code wrote `_transformW`.
*
* Slices land here one tool at a time; this file grows as more state
* migrates out of galleryEditor.js. Defaults match the legacy
* module-scope initializers verbatim — every `state.foo = …` reset
* site in galleryEditor.js still works unchanged.
*/
export const state = {
// ── Transform tool ──
// Drag-resize / rotate session state. While `transformActive` is
// false every field below should be considered stale.
transformActive: false,
transformLayer: null,
transformOrigW: 0,
transformOrigH: 0,
// Which corner/edge handle the user is currently dragging. One of
// 'tl' | 'tr' | 'bl' | 'br' | 'rot' | null.
transformHandle: null,
// Which handle is currently under the cursor (no drag). Drives the
// hover cursor lookup; lives next to `transformHandle` because both
// come from `_getTransformHandle`.
hoveredHandle: null,
// Snapshot of the layer canvas + offset at transform start so Cancel
// can restore exactly without re-fetching from the layer.
transformOrigCanvas: null,
transformOrigOffset: null,
// In-progress dimensions / rotation / flips committed on Apply.
transformPendingW: 0,
transformPendingH: 0,
transformPendingRot: 0,
transformPendingFlipH: false,
transformPendingFlipV: false,
transformAspectLock: true,
// Floating Transform popup element + drag-start offsets.
transformPopup: null,
transformStartX: 0,
transformStartY: 0,
transformStartOffX: 0,
transformStartOffY: 0,
// Transform overlay canvas — separate canvas positioned over the
// main canvas with extra slack for handle rendering. Created by
// _buildEditor; the move/transform tools draw their handle layer
// onto its 2D context.
transformOverlay: null,
transformOverlayCtx: null,
// ── Magic Wand tool ──
// Binary selection mask + the layer it was sampled from. `wandMask`
// is a canvas the size of `wandLayer`'s pixels, white where selected,
// transparent elsewhere. `wandLastSeed` remembers the last click so
// tolerance retunes can re-run the flood-fill without re-prompting.
wandMask: null,
wandLayerId: null,
wandTolerance: 24,
wandMaskVisible: true,
wandMode: 'replace',
wandLiveRetune: false,
wandLastSeed: null,
// Cached layer pixel data (getImageData is O(pixels) — expensive for
// 4K layers; invalidated when the active layer changes).
wandSrcCache: null,
// ── Brush / Eraser / Clone tools ──
// Shared paint color (brush picks up the swatch; eraser and clone
// ignore color but reuse the same picker control).
color: '#e06c75',
// Brush diameter in canvas pixels. Persisted across tool switches;
// bumped to a mask-friendly default on first inpaint entry.
brushSize: 8,
// Per-tool stroke modifiers — opacity + flow + softness. Each tool
// owns its own row so users can dial them in independently.
brushOpacity: 100,
brushFlow: 100,
brushSoftness: 100,
eraserOpacity: 100,
eraserFlow: 100,
eraserSoftness: 100,
cloneOpacity: 100,
cloneFlow: 100,
cloneSoftness: 100,
// Clone-stamp source point (set via Alt-click or double-tap). Null
// means no source picked yet — clicking with the clone tool no-ops
// until a source is set.
cloneSourceX: null,
cloneSourceY: null,
// Stroke-start offsets so the source moves WITH the brush, keeping
// the source→destination offset constant across the stroke.
cloneStrokeStartX: null,
cloneStrokeStartY: null,
// Frozen snapshot of the source layer's pixels at stroke start so
// moving the source over previously-painted pixels samples the
// original, not the in-progress stamp ring.
cloneSourceSnapshot: null,
cloneSourceLayerId: null,
// Mobile: double-tap detection for "set source" since Alt-click
// isn't an option without a keyboard.
cloneLastTapTime: 0,
cloneLastTapX: 0,
cloneLastTapY: 0,
// ── Inpaint + mask ──
// Active mask canvas + its 2D context. Re-pointed to the active
// mask sub-layer whenever the user picks a different mask in the
// layer panel.
maskCanvas: null,
maskCtx: null,
maskVisible: true,
// Reused canvas for the union-of-masks tint pass (saves repeated
// allocation on every composite).
compositeMaskUnion: null,
// Visual tint applied to mask pixels in the composite — purely
// cosmetic; the AI model still sees a hard binary mask.
maskTintColor: 'rgba(255, 110, 110, 1)',
maskTintOpacity: 0.28,
// Inpaint-tool paint vs erase modes (Ctrl+Alt flips for a single
// stroke; UI buttons toggle the persistent setting).
inpaintEraseMode: false,
inpaintEraseStroke: false,
// First-entry guard: bump brush size to the mask-friendly default
// the first time the user opens inpaint per session.
inpaintBrushInitialised: false,
// Last successful inpaint result layer — drives the live edge
// feather / stroke sliders (those only apply to the most recent
// result).
lastInpaintLayerId: null,
// Captured handlers so we can detach them on close without leaking.
inpaintDismissHandlers: null,
// Background-remove tool state — pristine snapshot so the edge
// cleanup sliders can live-rebuild alpha without re-running rembg.
rembgLiveLayer: null,
rembgLiveSnap: null,
// Memoised "is rembg installed on the server?" probe.
rembgInstalledCache: null,
// ── Stroke drag state ──
// Generic in-progress-stroke flags shared by brush/eraser/clone/
// inpaint. `lastX/Y` are the last mouse position used to interpolate
// a continuous line through fast-moving cursor samples.
drawing: false,
lastX: 0,
lastY: 0,
// ── Move tool ──
moving: false,
moveStartX: 0,
moveStartY: 0,
// Layer offset at drag start so we can compute the new offset by
// (mouse - startMouse) + startOffset rather than accumulating delta.
moveLayerOffsetX: 0,
moveLayerOffsetY: 0,
// Snap guides drawn during a move-tool drag (Ctrl held). Each entry
// is a vertical / horizontal line in canvas space.
activeSnapGuides: null,
// ── Crop tool ──
cropping: false,
cropStart: null,
cropEnd: null,
cropRect: null,
cropAspectLock: null,
// True while the user drags the inside of an already-finished crop
// rect to reposition it.
cropMoving: false,
cropMoveStart: null,
// ── Lasso tool ──
// Freehand selection polygon in canvas pixels. Empty when no lasso
// is in progress or staged.
lassoPoints: [],
lassoActive: false,
// In-editor copy/paste — separate from the OS clipboard so we can
// round-trip layer alpha and metadata losslessly.
internalClipboard: null,
// ── Editor DOM refs ──
// Root container that openEditor mounts into.
container: null,
// Main image canvas + its 2D context. Re-created on every openEditor
// so the editor can reopen with fresh dimensions.
mainCanvas: null,
mainCtx: null,
// ── Document + layers ──
layers: [],
activeLayerId: null,
// Active tool ID — one of move/crop/transform/brush/eraser/clone/
// lasso/wand/inpaint/rembg/harmonize/sharpen/upscale/style.
tool: 'move',
// Display zoom (1 = 100%). pan{X,Y} translate the canvas inside the
// viewport.
zoom: 1,
panX: 0,
panY: 0,
// Document dimensions in canvas pixels.
imgWidth: 0,
imgHeight: 0,
// Gallery image id this editor session is editing, or null for
// blank-canvas drafts.
imageId: null,
// Original file extension so save-over-original re-encodes in the
// same format (JPEG vs PNG matters: JPEG cuts upload size 5-10× for
// camera photos over remote tunnels).
originalExt: 'png',
// True between openEditor / closeEditor — guards async callbacks
// that fire after the user closes the editor (don't draw onto a
// dead canvas, don't re-mount the spinner).
editorOpen: false,
// Document-level click-away handlers registered for the current
// session. Tracked so closeEditor can detach them all cleanly.
// Mutated in place (push / length = 0); the reference never changes.
editorDocClickHandlers: [],
// ── Undo / redo ──
undoStack: [],
redoStack: [],
// ── Layer offsets + id allocation ──
// Map<layerId, {x, y}> — kept in a Map so we can serialise it
// separately from the layer's own canvas. Mutated in place.
layerOffsets: new Map(),
nextLayerId: 1,
// ── Popup / panel handles ──
fxPopupEl: null,
fxPopupLayerId: null,
fxMenuEl: null,
adjPopupEl: null,
// rAF-throttled live preview while sliders are dragged in adj popups.
adjRafPending: false,
historyPanelEl: null,
// Custom brush-cursor overlay element (circle following the mouse).
cursorEl: null,
// Hover-preview thumbnail floating element (singleton, repositioned).
layerThumbEl: null,
// Loading-overlay element (whirlpool + label).
editorLoadingEl: null,
// ── Draft persistence ──
draftId: null,
draftName: '',
persistTimer: null,
// Current PUT/POST promise so concurrent saves can chain.
persistInFlight: null,
// True when an edit happened during an in-flight save — triggers a
// follow-up persist after the current one finishes.
persistDirty: false,
};

View File

@@ -0,0 +1,162 @@
/**
* Stroke pipeline — paints one segment (last-position → current
* position) onto the active layer (or its active mask sub-layer).
*
* `strokeTo` dispatches by tool:
* - clone → cloneStrokeTo (custom stamp-based paint loop)
* - brush → source-over with opacity × flow + softness blur
* - eraser → destination-out with opacity × flow + softness blur
* - inpaint → source-over (paint) or destination-out (erase) with
* full alpha on the mask canvas
*
* If the active parent has an active mask sub-layer, brush / eraser /
* inpaint target the mask canvas instead of the layer's pixel canvas.
*
* @param {{
* activeLayer: () => object | null,
* getActiveMaskLayer: () => object | null,
* composite: () => void,
* }} deps
*/
import { state } from './state.js';
export function createStrokePipeline({ activeLayer, getActiveMaskLayer, composite }) {
function cloneStrokeTo(x, y, layer) {
if (!state.cloneSourceSnapshot) return;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const dx = x - state.cloneStrokeStartX;
const dy = y - state.cloneStrokeStartY;
const srcX = state.cloneSourceX + dx;
const srcY = state.cloneSourceY + dy;
const ctx = layer.ctx;
const radius = Math.max(1, state.brushSize / 2);
// Walk last → current in roughly half-brush steps so stamps
// overlap into a continuous brush trail.
const lastSrcX = state.cloneSourceX + (state.lastX - state.cloneStrokeStartX);
const lastSrcY = state.cloneSourceY + (state.lastY - state.cloneStrokeStartY);
const dist = Math.hypot(x - state.lastX, y - state.lastY);
const step = Math.max(1, radius * 0.5);
const steps = Math.max(1, Math.ceil(dist / step));
const stampSize = Math.max(2, Math.ceil(radius * 2));
const stampRadius = stampSize / 2;
const stamp = document.createElement('canvas');
stamp.width = stampSize;
stamp.height = stampSize;
const stampCtx = stamp.getContext('2d');
const softness = Math.max(0, Math.min(1, state.cloneSoftness / 300));
const hardStop = stampRadius * (1 - softness);
ctx.save();
ctx.globalAlpha = (state.cloneOpacity / 100) * (state.cloneFlow / 100);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const px = state.lastX + (x - state.lastX) * t - off.x;
const py = state.lastY + (y - state.lastY) * t - off.y;
const sx = lastSrcX + (srcX - lastSrcX) * t;
const sy = lastSrcY + (srcY - lastSrcY) * t;
stampCtx.clearRect(0, 0, stampSize, stampSize);
stampCtx.globalCompositeOperation = 'source-over';
stampCtx.drawImage(
state.cloneSourceSnapshot,
sx - stampRadius, sy - stampRadius, stampSize, stampSize,
0, 0, stampSize, stampSize,
);
stampCtx.globalCompositeOperation = 'destination-in';
const mask = stampCtx.createRadialGradient(stampRadius, stampRadius, hardStop, stampRadius, stampRadius, stampRadius);
mask.addColorStop(0, 'rgba(0,0,0,1)');
mask.addColorStop(1, 'rgba(0,0,0,0)');
stampCtx.fillStyle = mask;
stampCtx.fillRect(0, 0, stampSize, stampSize);
ctx.drawImage(stamp, px - stampRadius, py - stampRadius);
}
ctx.restore();
state.lastX = x;
state.lastY = y;
composite();
}
function strokeTo(x, y) {
const layer = activeLayer();
if (!layer) return;
// Clone uses a stamp-based paint loop, not the line-stroke
// pipeline below.
if (state.tool === 'clone') return cloneStrokeTo(x, y, layer);
// If the active parent has an active mask sub-layer, brush /
// eraser / inpaint paint the mask canvas instead of the layer's
// pixel canvas. Brush adds to the mask, Eraser carves it away,
// Inpaint still works (its mask plumbing was already pointed at
// the same canvas).
const activeMask = getActiveMaskLayer();
const paintingMask = !!activeMask &&
(state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint');
const ctx = paintingMask
? activeMask.ctx
: (state.tool === 'inpaint' ? state.maskCtx : layer.ctx);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
ctx.save();
ctx.lineWidth = state.brushSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (state.tool === 'eraser') {
ctx.globalCompositeOperation = 'destination-out';
// Effective alpha = opacity × flow. Opacity = max strength a
// stroke can reach; flow = how much erases per pass.
ctx.globalAlpha = (state.eraserOpacity / 100) * (state.eraserFlow / 100);
ctx.strokeStyle = 'rgba(0,0,0,1)';
if (state.eraserSoftness > 0) {
const blurPx = (state.eraserSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
} else if (state.tool === 'brush') {
// Brush — state.color onto the layer (or white onto an active
// mask sub-layer). Mask painting forces full alpha so masks
// stay a clean binary by default (a sub-100% brush would
// silently paint partial-strength mask pixels).
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = paintingMask ? 'rgba(255,255,255,1)' : state.color;
if (paintingMask) {
ctx.globalAlpha = 1;
} else {
ctx.globalAlpha = (state.brushOpacity / 100) * (state.brushFlow / 100);
if (state.brushSoftness > 0) {
const blurPx = (state.brushSoftness / 100) * (state.brushSize / 2);
ctx.filter = `blur(${blurPx.toFixed(2)}px)`;
}
}
} else if (state.tool === 'inpaint') {
if (state.inpaintEraseStroke) {
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)';
} else {
ctx.globalCompositeOperation = 'source-over';
// Diffusion server expects white = inpaint area. The red
// overlay is rendered separately in composite() for the user.
ctx.strokeStyle = 'rgba(255,255,255,1)';
}
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = state.color;
}
// Mask canvases are always full-image (no per-layer offset), so
// painting onto a mask uses canvas-coord origin too — same as
// inpaint.
const onMaskOrInpaint = paintingMask || state.tool === 'inpaint';
const drawX = onMaskOrInpaint ? 0 : off.x;
const drawY = onMaskOrInpaint ? 0 : off.y;
ctx.beginPath();
ctx.moveTo(state.lastX - drawX, state.lastY - drawY);
ctx.lineTo(x - drawX, y - drawY);
ctx.stroke();
ctx.restore();
state.lastX = x;
state.lastY = y;
composite();
}
return { strokeTo, cloneStrokeTo };
}

View File

@@ -0,0 +1,64 @@
/**
* Per-tool stroke-modifier sliders (Opacity / Flow / Softness) for
* Eraser, Brush, and Clone. The three sections share identical UX:
*
* - Opacity slider: writes to state, updates label, fades the
* preview swatch opacity.
* - Flow slider: writes to state, updates label, fades the swatch
* opacity AND swaps its border style (dashed at low flow → dotted
* at high flow) so the user sees the "denseness" change.
* - Softness slider: writes to state, updates label, tweens the
* radial-gradient inner stop on the swatch so it visually fades
* from hard disk to soft falloff.
*
* The whole block was three near-identical 30-LOC copies before; now
* it's one helper that takes the tool's prefix + a state-field bag.
*
* Usage: just call wireStrokeToolSliders() — the DOM IDs are wired
* statically from #ge-{eraser,brush,clone}-{opacity,flow,softness}
* + their labels + preview swatches.
*/
import { state } from './state.js';
/** Wire the three sliders for one stroke tool. */
function wireToolSliders(prefix, fields) {
const opPrev = document.getElementById(`ge-${prefix}-preview-opacity`);
const flPrev = document.getElementById(`ge-${prefix}-preview-flow`);
const softPrev = document.getElementById(`ge-${prefix}-preview-softness`);
document.getElementById(`ge-${prefix}-opacity`)?.addEventListener('input', (e) => {
state[fields.opacity] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-opacity-label`).textContent = state[fields.opacity] + '%';
if (opPrev) opPrev.style.opacity = (state[fields.opacity] / 100).toFixed(2);
});
document.getElementById(`ge-${prefix}-flow`)?.addEventListener('input', (e) => {
state[fields.flow] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-flow-label`).textContent = state[fields.flow] + '%';
// Lower flow → fewer / sparser dots. Cycle dot densities by
// swapping the dashed/dotted border style and fading opacity.
if (flPrev) {
const denseness = Math.max(1, Math.round(state[fields.flow] / 20));
flPrev.style.borderStyle = denseness <= 2 ? 'dashed' : 'dotted';
flPrev.style.opacity = (0.3 + (state[fields.flow] / 100) * 0.6).toFixed(2);
}
});
document.getElementById(`ge-${prefix}-softness`)?.addEventListener('input', (e) => {
state[fields.softness] = parseInt(e.target.value);
document.getElementById(`ge-${prefix}-softness-label`).textContent = state[fields.softness] + '%';
// Preview tweens from a hard disk into a soft radial gradient as
// softness rises (the CSS already sets the radial gradient — we
// just tween the inner solid radius to communicate the falloff).
if (softPrev) {
const innerStop = Math.max(0, 60 - state[fields.softness] * 0.55);
softPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${innerStop}%, transparent 90%)`;
}
});
}
export function wireStrokeToolSliders() {
wireToolSliders('eraser', { opacity: 'eraserOpacity', flow: 'eraserFlow', softness: 'eraserSoftness' });
wireToolSliders('brush', { opacity: 'brushOpacity', flow: 'brushFlow', softness: 'brushSoftness' });
wireToolSliders('clone', { opacity: 'cloneOpacity', flow: 'cloneFlow', softness: 'cloneSoftness' });
}

View File

@@ -0,0 +1,81 @@
/**
* Clone tool — Alt-click (desktop) or double-tap (mobile) sets the
* sample source; a regular click+drag stamps from that source onto the
* active layer. The source point moves WITH the brush so the offset
* stays constant across the stroke.
*
* begin() handles the source-pick and stroke-start branches; the
* actual per-sample stamping continues through the shared stroke
* pipeline (`_strokeTo`) which knows about clone-mode internally.
*
* @param {{
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* strokeTo: (x: number, y: number) => void,
* showToast: (msg: string) => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function createCloneTool({ activeLayer, saveState, strokeTo, showToast }) {
return {
begin(e) {
const layer = activeLayer();
const coords = canvasCoords(e, state.mainCanvas);
// Mobile equivalent of Alt-click: double-tap in screen pixels.
// Wider tolerances (500 ms, 40 px) than desktop because finger
// taps drift more than mouse clicks.
const isTouchEvt = e.type && e.type.startsWith('touch');
let isDoubleTap = false;
if (isTouchEvt) {
const t = e.touches ? e.touches[0] : null;
const cx = t ? t.clientX : 0;
const cy = t ? t.clientY : 0;
const now = Date.now();
const dt = now - state.cloneLastTapTime;
const dx = cx - state.cloneLastTapX;
const dy = cy - state.cloneLastTapY;
if (dt < 500 && Math.hypot(dx, dy) < 40) {
isDoubleTap = true;
state.cloneLastTapTime = 0; // consume the pair
} else {
state.cloneLastTapTime = now;
state.cloneLastTapX = cx;
state.cloneLastTapY = cy;
}
}
if (e.altKey || isDoubleTap) {
state.cloneSourceX = coords.x;
state.cloneSourceY = coords.y;
state.cloneSourceLayerId = (layer && layer.id) || state.activeLayerId;
state.cloneSourceSnapshot = null; // captured at first stroke
showToast('Clone source set');
return;
}
if (state.cloneSourceX === null || state.cloneSourceY === null) {
showToast(isTouchEvt
? 'Double-tap first to set a clone source'
: 'Alt-click first to set a clone source');
return;
}
if (!layer || layer.locked) return;
saveState('Clone stroke');
// Snapshot the source layer's pixels at stroke-start so the
// brush samples clean source pixels even after it has painted
// over them. Otherwise we'd cascade-clone the same ring.
const srcLayer = state.layers.find(l => l.id === state.cloneSourceLayerId) || layer;
const snap = document.createElement('canvas');
snap.width = srcLayer.canvas.width;
snap.height = srcLayer.canvas.height;
snap.getContext('2d').drawImage(srcLayer.canvas, 0, 0);
state.cloneSourceSnapshot = snap;
state.cloneStrokeStartX = coords.x;
state.cloneStrokeStartY = coords.y;
state.drawing = true;
state.lastX = coords.x;
state.lastY = coords.y;
strokeTo(coords.x, coords.y);
},
};
}

View File

@@ -0,0 +1,137 @@
/**
* Crop tool — drag-rect selection that lets the user cut down the
* canvas to a smaller region. Supports Shift-lock aspect ratio and
* click-inside-rect to reposition an existing crop without redrawing.
*
* Owns its own begin/drag/end handlers and reads/writes shared state.
* The factory takes a small dependency bag for things still living in
* galleryEditor.js — `composite` redraws the canvas, `showCropApply`
* mounts the floating W×H + Apply panel after the user finishes
* dragging.
*
* @param {{
* composite: () => void,
* showCropApply: () => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { drawCheckerboard } from '../checkerboard.js';
export function createCropTool({ composite, showCropApply }) {
return {
begin(e) {
const coords = canvasCoords(e, state.mainCanvas);
// Click inside an existing crop rect → switch to move-mode so
// the user can reposition without redrawing.
if (state.cropRect &&
coords.x >= state.cropRect.x && coords.x <= state.cropRect.x + state.cropRect.w &&
coords.y >= state.cropRect.y && coords.y <= state.cropRect.y + state.cropRect.h) {
state.cropMoving = true;
state.cropMoveStart = { x: coords.x, y: coords.y, rx: state.cropRect.x, ry: state.cropRect.y };
return;
}
state.cropping = true;
state.cropStart = coords;
state.cropEnd = { ...state.cropStart };
state.cropRect = null;
state.cropAspectLock = null;
// Tear down the size panel while the user is drawing a new rect.
const old = state.container?.querySelector('.ge-crop-apply');
if (old) old.remove();
},
drag(e) {
// Move-mode: drag the existing rect around the canvas.
if (state.cropMoving && state.cropRect && state.cropMoveStart) {
e.preventDefault();
const c = canvasCoords(e, state.mainCanvas);
const dx = c.x - state.cropMoveStart.x;
const dy = c.y - state.cropMoveStart.y;
let nx = state.cropMoveStart.rx + dx;
let ny = state.cropMoveStart.ry + dy;
// Clamp to canvas bounds so the rect stays fully visible.
nx = Math.max(0, Math.min(nx, state.mainCanvas.width - state.cropRect.w));
ny = Math.max(0, Math.min(ny, state.mainCanvas.height - state.cropRect.h));
state.cropRect = { ...state.cropRect, x: nx, y: ny };
composite();
return;
}
if (!state.cropping) return;
e.preventDefault();
state.cropEnd = canvasCoords(e, state.mainCanvas);
// Shift-held = lock aspect ratio. First Shift press during the
// drag snapshots the current aspect; subsequent moves stay locked.
// Releasing Shift resets so the user can re-lock at a new ratio.
if (e.shiftKey) {
const rawDx = state.cropEnd.x - state.cropStart.x;
const rawDy = state.cropEnd.y - state.cropStart.y;
if (state.cropAspectLock == null) {
const rawW = Math.abs(rawDx) || 1;
const rawH = Math.abs(rawDy) || 1;
state.cropAspectLock = rawW / rawH;
}
const absDx = Math.abs(rawDx);
const absDy = Math.abs(rawDy);
// Whichever axis the user moved more (relative to the lock) is
// the driver; scale the other to preserve aspect.
let dx, dy;
if (absDx >= absDy * state.cropAspectLock) {
dx = rawDx;
dy = Math.sign(rawDy || 1) * (absDx / state.cropAspectLock);
} else {
dy = rawDy;
dx = Math.sign(rawDx || 1) * (absDy * state.cropAspectLock);
}
state.cropEnd = { x: state.cropStart.x + dx, y: state.cropStart.y + dy };
} else {
state.cropAspectLock = null;
}
composite();
// Draw crop overlay.
const x = Math.min(state.cropStart.x, state.cropEnd.x);
const y = Math.min(state.cropStart.y, state.cropEnd.y);
const w = Math.abs(state.cropEnd.x - state.cropStart.x);
const h = Math.abs(state.cropEnd.y - state.cropStart.y);
state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)';
state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
state.mainCtx.clearRect(x, y, w, h);
// Redraw layers inside the crop rect (dim everything outside).
state.mainCtx.save();
state.mainCtx.beginPath();
state.mainCtx.rect(x, y, w, h);
state.mainCtx.clip();
drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
for (const layer of state.layers) {
if (!layer.visible) continue;
state.mainCtx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.mainCtx.drawImage(layer.canvas, off.x, off.y);
}
state.mainCtx.globalAlpha = 1;
state.mainCtx.restore();
// Dashed border around the kept region.
state.mainCtx.strokeStyle = '#fff';
state.mainCtx.lineWidth = 1;
state.mainCtx.setLineDash([4, 4]);
state.mainCtx.strokeRect(x, y, w, h);
state.mainCtx.setLineDash([]);
state.cropRect = { x, y, w, h };
},
end() {
// Move-mode wrap-up: refresh the floating panel so Apply follows
// the rect to its new spot.
if (state.cropMoving) {
state.cropMoving = false;
state.cropMoveStart = null;
if (state.cropRect) showCropApply();
return;
}
state.cropping = false;
if (state.cropRect && state.cropRect.w > 5 && state.cropRect.h > 5) {
showCropApply();
}
},
};
}

View File

@@ -0,0 +1,72 @@
/**
* Iterative 4-connected flood fill on RGBA pixel data.
*
* Pure function — takes the source pixel array + seed + tolerance and
* returns a mask canvas with white where the fill landed. The legacy
* gallery editor's magic-wand tool delegates to this.
*
* @param {Uint8ClampedArray|Uint8Array} src RGBA bytes (length = w*h*4).
* @param {number} w Pixel width.
* @param {number} h Pixel height.
* @param {number} seedX Floored seed X.
* @param {number} seedY Floored seed Y.
* @param {number} tolerance Tolerance 0..100. Internally
* squared and scaled to RGB+A
* space (max ≈ 195k at 100).
* @returns {HTMLCanvasElement|null} A `w × h` mask canvas with
* white-opaque pixels for
* visited cells, or null if
* the seed is out of bounds.
*/
export function floodFillMask(src, w, h, seedX, seedY, tolerance) {
if (seedX < 0 || seedY < 0 || seedX >= w || seedY >= h) return null;
const seedIdx = (seedY * w + seedX) * 4;
const sr = src[seedIdx], sg = src[seedIdx + 1];
const sb = src[seedIdx + 2], sa = src[seedIdx + 3];
// 0..100 → squared RGB+A distance threshold. Max single-channel diff
// is 255, so sqrt(4 * 255²) ≈ 510; squared cap ≈ 195k at tol = 100.
const tol = Math.pow(tolerance * 4.42, 2);
const visited = new Uint8Array(w * h);
const stack = [seedX, seedY];
visited[seedY * w + seedX] = 1;
while (stack.length) {
const y = stack.pop();
const x = stack.pop();
const nbrs = [
[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1],
];
for (const [nx, ny] of nbrs) {
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const idx = ny * w + nx;
if (visited[idx]) continue;
const o = idx * 4;
const dr = src[o] - sr, dg = src[o + 1] - sg;
const db = src[o + 2] - sb, da = src[o + 3] - sa;
// RGB + alpha-aware so a click on a transparent pixel selects
// the transparent region cleanly.
if (dr * dr + dg * dg + db * db + da * da <= tol) {
visited[idx] = 1;
stack.push(nx, ny);
}
}
}
const mask = document.createElement('canvas');
mask.width = w;
mask.height = h;
const mCtx = mask.getContext('2d');
const mData = mCtx.createImageData(w, h);
for (let i = 0; i < w * h; i++) {
if (visited[i]) {
mData.data[i * 4] = 255;
mData.data[i * 4 + 1] = 255;
mData.data[i * 4 + 2] = 255;
mData.data[i * 4 + 3] = 255;
}
}
mCtx.putImageData(mData, 0, 0);
return mask;
}

View File

@@ -0,0 +1,171 @@
/**
* Lasso-tool pixel & path helpers.
*
* All functions take the lasso polygon `points` as an explicit
* argument so they can be tested in isolation. The legacy gallery
* editor calls them with its module-level `_lassoPoints` array.
*/
/**
* Shift each polygon vertex along the outward normal by `grow` pixels.
* Used by the lasso overlay (to draw the "feather" halo) and by
* `buildLassoMask` (to bake the grown polygon into the mask).
*
* @param {{x: number, y: number}[]} points Polygon vertices in draw order.
* @param {number} grow Positive = expand outward, negative = contract.
* @returns {{x: number, y: number}[]} New array (same length, original is not mutated).
*/
export function lassoOffsetPoints(points, grow) {
const n = points.length;
if (n < 3 || !grow) return points;
// Polygon winding (positive = CCW) — flip the normal so it points
// away from the interior regardless of draw direction.
let area = 0;
for (let i = 0; i < n; i++) {
const p = points[i], q = points[(i + 1) % n];
area += (q.x - p.x) * (q.y + p.y);
}
const sign = area > 0 ? 1 : -1;
const out = new Array(n);
for (let i = 0; i < n; i++) {
const a = points[(i - 1 + n) % n], b = points[i], c = points[(i + 1) % n];
const e1x = b.x - a.x, e1y = b.y - a.y;
const e2x = c.x - b.x, e2y = c.y - b.y;
const l1 = Math.hypot(e1x, e1y) || 1;
const l2 = Math.hypot(e2x, e2y) || 1;
// Perpendicular (dy, -dx); flip via `sign` for outward direction.
const n1x = (e1y / l1) * sign, n1y = (-e1x / l1) * sign;
const n2x = (e2y / l2) * sign, n2y = (-e2x / l2) * sign;
const nx = (n1x + n2x) / 2;
const ny = (n1y + n2y) / 2;
const nl = Math.hypot(nx, ny) || 1;
out[i] = { x: b.x + (nx / nl) * grow, y: b.y + (ny / nl) * grow };
}
return out;
}
/**
* Trace the lasso polygon on the given context (move-to + line-to,
* closed). Caller is responsible for `stroke()` / `fill()` choice.
*/
export function getLassoPath(ctx, points) {
if (!points || points.length < 1) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
}
/**
* Build a (optionally feathered, optionally grown) selection mask
* from a lasso polygon.
*
* @param {{x: number, y: number}[]} points Polygon vertices.
* @param {number} w / h Output canvas dimensions.
* @param {number} offX / offY Translate the polygon by (offX, offY) before rasterising.
* @param {number} feather Feather width in pixels. 0 = hard edge.
* @param {number} grow Positive = dilate the polygon, negative = erode.
* @returns {HTMLCanvasElement} A `w × h` canvas with alpha = selection strength.
*/
export function buildLassoMask(points, w, h, offX, offY, feather, grow) {
// Step 1: draw hard mask
const hard = document.createElement('canvas');
hard.width = w; hard.height = h;
const hCtx = hard.getContext('2d');
hCtx.beginPath();
hCtx.moveTo(points[0].x - offX, points[0].y - offY);
for (let i = 1; i < points.length; i++) {
hCtx.lineTo(points[i].x - offX, points[i].y - offY);
}
hCtx.closePath();
hCtx.fillStyle = '#fff';
hCtx.fill();
// Step 1b: grow / shrink — blur the hard mask, threshold low for
// grow and high for shrink. Same technique as the bg-remove edge
// tuner. RGB is left alone, alpha is replaced.
if (grow && grow !== 0) {
const blurC = document.createElement('canvas');
blurC.width = w; blurC.height = h;
const bctx = blurC.getContext('2d');
bctx.filter = `blur(${Math.abs(grow)}px)`;
bctx.drawImage(hard, 0, 0);
bctx.filter = 'none';
const blurred = bctx.getImageData(0, 0, w, h).data;
const hd = hCtx.getImageData(0, 0, w, h);
const out = hd.data;
const thr = grow > 0 ? 32 : 200;
for (let i = 0; i < out.length; i += 4) {
const a = blurred[i + 3] >= thr ? 255 : 0;
out[i] = a; out[i + 1] = a; out[i + 2] = a; out[i + 3] = a;
}
hCtx.putImageData(hd, 0, 0);
}
if (feather <= 0) return hard;
// Step 2: pixel data and distance-based feather.
const hardData = hCtx.getImageData(0, 0, w, h);
const d = hardData.data;
// Build inside/outside map.
const inside = new Uint8Array(w * h);
for (let i = 0; i < w * h; i++) {
inside[i] = d[i * 4] > 128 ? 1 : 0;
}
// Distance from edge (for pixels inside the selection, distance to nearest outside pixel).
const dist = new Float32Array(w * h);
dist.fill(feather + 1);
// Seed: edge pixels (inside pixels adjacent to outside pixels).
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (!inside[i]) { dist[i] = 0; continue; }
const hasOutside = (x > 0 && !inside[i-1]) || (x < w-1 && !inside[i+1]) ||
(y > 0 && !inside[(y-1)*w+x]) || (y < h-1 && !inside[(y+1)*w+x]);
if (hasOutside) dist[i] = 1;
}
}
// Two-pass chamfer distance transform.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (dist[i] === 0) continue;
if (x > 0) dist[i] = Math.min(dist[i], dist[i-1] + 1);
if (y > 0) dist[i] = Math.min(dist[i], dist[(y-1)*w+x] + 1);
}
}
for (let y = h-1; y >= 0; y--) {
for (let x = w-1; x >= 0; x--) {
const i = y * w + x;
if (dist[i] === 0) continue;
if (x < w-1) dist[i] = Math.min(dist[i], dist[i+1] + 1);
if (y < h-1) dist[i] = Math.min(dist[i], dist[(y+1)*w+x] + 1);
}
}
// Pixels near the edge get reduced alpha.
const result = document.createElement('canvas');
result.width = w; result.height = h;
const rCtx = result.getContext('2d');
const rData = rCtx.createImageData(w, h);
for (let i = 0; i < w * h; i++) {
if (!inside[i]) continue;
const edgeDist = dist[i];
const alpha = edgeDist >= feather ? 255 : Math.round((edgeDist / feather) * 255);
rData.data[i*4] = alpha;
rData.data[i*4+1] = alpha;
rData.data[i*4+2] = alpha;
rData.data[i*4+3] = 255;
}
rCtx.putImageData(rData, 0, 0);
return result;
}

View File

@@ -0,0 +1,65 @@
/**
* Lasso tool — freehand polygon selection. Mouse-down starts a fresh
* polygon; every move appends a point and redraws the dashed outline;
* mouse-up keeps the selection visible (the panel's action buttons
* read `state.lassoPoints` to act on it).
*
* Owns its own begin/drag/end handlers and reads/writes shared state.
*
* @param {{
* composite: () => void,
* drawLassoOverlay: () => void,
* syncToolClearIndicators: () => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function createLassoTool({ composite, drawLassoOverlay, syncToolClearIndicators }) {
return {
begin(e) {
state.lassoPoints = [];
state.lassoActive = true;
const coords = canvasCoords(e, state.mainCanvas);
state.lassoPoints.push(coords);
},
drag(e) {
if (!state.lassoActive) return;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
state.lassoPoints.push(coords);
// Live overlay: dashed white outline + translucent red fill.
composite();
if (state.lassoPoints.length > 1) {
state.mainCtx.beginPath();
state.mainCtx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
for (let i = 1; i < state.lassoPoints.length; i++) {
state.mainCtx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
}
state.mainCtx.closePath();
state.mainCtx.strokeStyle = '#fff';
state.mainCtx.lineWidth = 1 / state.zoom;
state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);
state.mainCtx.stroke();
state.mainCtx.setLineDash([]);
state.mainCtx.fillStyle = 'rgba(255, 80, 80, 0.15)';
state.mainCtx.fill();
}
},
end() {
state.lassoActive = false;
if (state.lassoPoints.length < 3) {
state.lassoPoints = [];
composite();
syncToolClearIndicators();
return;
}
// Keep the selection drawn — the panel's action buttons use it.
composite();
drawLassoOverlay();
syncToolClearIndicators();
},
};
}

View File

@@ -0,0 +1,79 @@
/**
* Move tool — drag a layer around the canvas, with optional snap-on-Ctrl
* to other layers' edges/centers and to canvas edges/center.
*
* Owns its own input handlers (begin/drag/end) and reads/writes the
* shared `state` store directly. The factory takes a small dependency
* bag for things that still live in galleryEditor.js — `activeLayer`,
* `saveState`, `composite` — so this module doesn't have to know about
* the orchestrator.
*
* @param {{
* activeLayer: () => {id: string, canvas: HTMLCanvasElement, locked?: boolean} | null,
* saveState: (label?: string) => void,
* composite: () => void,
* }} deps
* @returns {{ begin: (e: Event) => void, drag: (e: Event) => void, end: () => void }}
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
import { computeSnap as computeSnapImpl } from '../snap.js';
export function createMoveTool({ activeLayer, saveState, composite }) {
function computeSnap(layer, nx, ny) {
return computeSnapImpl(layer, nx, ny, {
zoom: state.zoom,
canvasW: state.imgWidth,
canvasH: state.imgHeight,
otherLayers: state.layers.map(l => ({
visible: l.visible,
id: l.id,
canvas: l.canvas,
offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
})),
});
}
return {
begin(e) {
const layer = activeLayer();
if (!layer || layer.locked) return;
saveState();
state.moving = true;
const coords = canvasCoords(e, state.mainCanvas);
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.moveStartX = coords.x;
state.moveStartY = coords.y;
state.moveLayerOffsetX = off.x;
state.moveLayerOffsetY = off.y;
},
drag(e) {
if (!state.moving) return;
e.preventDefault();
const layer = activeLayer();
if (!layer) return;
const coords = canvasCoords(e, state.mainCanvas);
const dx = coords.x - state.moveStartX;
const dy = coords.y - state.moveStartY;
let nx = state.moveLayerOffsetX + dx;
let ny = state.moveLayerOffsetY + dy;
// Ctrl held = snap to canvas edges/center and to every other
// visible layer's edges/center. Opt-in to avoid a "sticky" feel
// during normal drags.
if (e.ctrlKey || e.metaKey) {
const snapped = computeSnap(layer, nx, ny);
nx = snapped.x;
ny = snapped.y;
state.activeSnapGuides = snapped.guides;
} else {
state.activeSnapGuides = null;
}
state.layerOffsets.set(layer.id, { x: nx, y: ny });
composite();
},
end() {
state.moving = false;
state.activeSnapGuides = null;
},
};
}

View File

@@ -0,0 +1,123 @@
/**
* Shared stroke pipeline for brush / eraser / inpaint.
*
* Per-sample stamping happens in `_strokeTo` (still in galleryEditor.js
* because it touches a lot of pixel-pass internals). This module owns
* the begin / continue / end orchestration around it:
*
* - begin: capture the inpaint-erase flag for the stroke, ensure a
* mask sub-layer exists when inpaint runs against an empty
* layer, push an undo entry with a tool-specific label, then
* kick off the first stamp.
* - continue: forward the new cursor position to `_strokeTo`.
* - end: clear the drawing flag, composite, sync any tool indicators
* that reflect mask state.
*
* Clone has its own begin (see tools/clone.js) but reuses `continue`
* and `end` because once a clone stroke is in progress, the pipeline
* is identical.
*
* @param {{
* saveState: (label: string) => void,
* strokeTo: (x: number, y: number) => void,
* composite: () => void,
* getActiveMaskLayer: () => object | null,
* activeParentLayer: () => object | null,
* ensureActiveMaskLayer: () => object | null,
* createLayer: (name: string, w: number, h: number) => object,
* renderLayerPanel: () => void,
* syncToolClearIndicators: () => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
const STROKE_TOOLS = new Set(['brush', 'eraser', 'inpaint']);
function strokeLabel(tool) {
if (tool === 'brush') return 'Brush stroke';
if (tool === 'eraser') return 'Eraser stroke';
if (tool === 'inpaint') return state.inpaintEraseStroke ? 'Erase mask' : 'Paint mask';
return 'Stroke';
}
export function createStrokeTool({
saveState, strokeTo, composite,
getActiveMaskLayer, activeParentLayer, ensureActiveMaskLayer, createLayer,
renderLayerPanel, syncToolClearIndicators,
}) {
return {
/**
* Begin a stroke. Returns true if the dispatcher should consider
* the event handled (i.e. tool is one of brush/eraser/inpaint).
*/
tryBegin(e) {
if (!STROKE_TOOLS.has(state.tool)) return false;
// Capture the inpaint-erase flag for this stroke. Ctrl+Alt
// pressed at pointerdown flips the persistent toggle for one
// stroke only.
if (state.tool === 'inpaint') {
const flip = e && e.ctrlKey && e.altKey;
state.inpaintEraseStroke = flip ? !state.inpaintEraseMode : state.inpaintEraseMode;
// Make sure we're painting onto an existing mask sub-layer. If
// there's no parent layer at all, create one first so a totally
// empty canvas can accept an inpaint stroke.
if (!getActiveMaskLayer()) {
let parent = activeParentLayer();
if (!parent) {
parent = createLayer('Layer 1', state.imgWidth, state.imgHeight);
state.layers.push(parent);
state.activeLayerId = parent.id;
}
if (parent.masks && parent.masks.length) {
parent.activeMaskId = parent.masks[parent.masks.length - 1].id;
const m = getActiveMaskLayer();
if (m) {
state.maskCanvas = m.canvas;
state.maskCtx = m.ctx;
renderLayerPanel();
}
} else {
const mk = ensureActiveMaskLayer();
if (mk) {
state.maskCanvas = mk.canvas;
state.maskCtx = mk.ctx;
renderLayerPanel();
}
}
}
}
saveState(strokeLabel(state.tool));
state.drawing = true;
const coords = canvasCoords(e, state.mainCanvas);
state.lastX = coords.x;
state.lastY = coords.y;
strokeTo(coords.x, coords.y);
return true;
},
/**
* Forward an in-progress stroke. Returns true if a stroke is
* actually in progress (dispatcher should short-circuit).
*/
tryContinue(e) {
if (!state.drawing) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
strokeTo(coords.x, coords.y);
return true;
},
/**
* Wrap up an in-progress stroke. Returns true if there was one.
*/
tryEnd() {
if (!state.drawing) return false;
const wasDrawingInpaint = state.tool === 'inpaint';
state.drawing = false;
composite();
if (wasDrawingInpaint) syncToolClearIndicators();
return true;
},
};
}

View File

@@ -0,0 +1,174 @@
/**
* Transform-drag tool — handle drag interactions for the Transform
* tool (resize via corner/edge handles, rotation via the rot grip).
*
* The transform UI runs in TWO modes: the floating popup (W/H/rot
* numeric inputs, lives elsewhere) AND direct drag on the canvas
* handles. Both ultimately mutate `state.transformPendingW/H/Rot` and
* call `reapplyTransform()` to redraw. This module owns the drag
* branch.
*
* The dispatcher in galleryEditor.js calls `tryBegin/tryContinue/
* tryEnd` which return `true` when the event was for the transform
* tool and was handled (so the dispatcher can short-circuit).
*
* @param {{
* beginMove: (e: Event) => void,
* composite: () => void,
* drawTransformHandles: () => void,
* reapplyTransform: () => void,
* getTransformHandle: (x: number, y: number) => string | null,
* cursorForHandle: (id: string | null) => string,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function createTransformDragTool({
beginMove, composite, drawTransformHandles, reapplyTransform,
getTransformHandle, cursorForHandle,
}) {
return {
/**
* Called on pointerdown. Returns true if the transform tool handled
* the event (the dispatcher should NOT fall through to other tools).
*/
tryBegin(e) {
if (!state.transformActive) return false;
const coords = canvasCoords(e, state.mainCanvas);
state.transformHandle = getTransformHandle(coords.x, coords.y);
if (state.transformHandle) {
state.transformStartX = coords.x;
state.transformStartY = coords.y;
// Snapshot offset + size at drag-start so each frame computes
// "start + dx" (correct delta) rather than accumulating off the
// running offset, which was making top/left grabs drift.
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
state.transformStartOffX = off.x;
state.transformStartOffY = off.y;
state.transformOrigW = layer.canvas.width;
state.transformOrigH = layer.canvas.height;
return true;
}
// No corner hit — if click inside the layer's bounding box, act
// like Move so the user can drag the layer around without
// switching tools.
if (state.transformLayer) {
const off = state.layerOffsets.get(state.transformLayer.id) || { x: 0, y: 0 };
const w = state.transformLayer.canvas.width;
const h = state.transformLayer.canvas.height;
if (coords.x >= off.x && coords.x <= off.x + w &&
coords.y >= off.y && coords.y <= off.y + h) {
beginMove(e);
return true;
}
}
return false;
},
/**
* Called on pointermove. Returns true if handled.
*
* When transformActive but no handle is grabbed, updates the
* hover cursor + pulse. When a handle is grabbed, drives the
* resize / rotation pipeline.
*/
tryContinue(e) {
if (!state.transformActive) return false;
// No drag in progress — just hover-cursor + pulse.
if (!state.transformHandle && state.mainCanvas) {
const coords = canvasCoords(e, state.mainCanvas);
const hovered = getTransformHandle(coords.x, coords.y);
state.mainCanvas.style.cursor = hovered ? cursorForHandle(hovered) : 'default';
if (hovered !== state.hoveredHandle) {
state.hoveredHandle = hovered;
composite();
}
return false; // didn't fully consume the event
}
if (!state.transformHandle) return false;
e.preventDefault();
const coords = canvasCoords(e, state.mainCanvas);
// Rotation grip — angle measured from the layer's geometric
// centre to the cursor. Mirror into the popup if it's open.
if (state.transformHandle === 'rot') {
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const cx = off.x + layer.canvas.width / 2;
const cy = off.y + layer.canvas.height / 2;
const rad = Math.atan2(coords.y - cy, coords.x - cx) + Math.PI / 2;
let deg = Math.round((rad * 180) / Math.PI);
if (e.shiftKey) deg = Math.round(deg / 15) * 15; // 15° snap
while (deg > 180) deg -= 360;
while (deg <= -180) deg += 360;
state.transformPendingRot = deg;
reapplyTransform();
if (state.transformPopup) {
const rotIn = state.transformPopup.querySelector('#ge-transform-rot');
if (rotIn) rotIn.value = String(deg);
}
return true;
}
// Resize via corner / edge handle.
const dx = coords.x - state.transformStartX;
const dy = coords.y - state.transformStartY;
const layer = state.transformLayer;
let newW = layer.canvas.width;
let newH = layer.canvas.height;
if (state.transformHandle.includes('r')) newW = state.transformOrigW + dx;
if (state.transformHandle.includes('l')) newW = state.transformOrigW - dx;
if (state.transformHandle.includes('b')) newH = state.transformOrigH + dy;
if (state.transformHandle.includes('t')) newH = state.transformOrigH - dy;
// Shift = lock aspect ratio. Use whichever axis moved more
// (relative to the original) as the driver.
if (e.shiftKey && state.transformOrigW > 0 && state.transformOrigH > 0) {
const aspect = state.transformOrigW / state.transformOrigH;
const wDelta = Math.abs(newW - state.transformOrigW);
const hDelta = Math.abs(newH - state.transformOrigH);
if (wDelta >= hDelta) {
newH = Math.max(1, Math.round(newW / aspect));
} else {
newW = Math.max(1, Math.round(newH * aspect));
}
}
newW = Math.max(1, Math.round(newW));
newH = Math.max(1, Math.round(newH));
// Route through the popup-driven pipeline so popup + drag stay
// in sync. Anchor the opposite corner via transformOrigOffset so
// handles don't slide while the user drags.
state.transformPendingW = newW;
state.transformPendingH = newH;
const anchorOffX = state.transformStartOffX +
(state.transformHandle.includes('l') ? (state.transformOrigW - newW) : 0);
const anchorOffY = state.transformStartOffY +
(state.transformHandle.includes('t') ? (state.transformOrigH - newH) : 0);
state.transformOrigOffset = {
x: anchorOffX + newW / 2 - state.transformOrigW / 2,
y: anchorOffY + newH / 2 - state.transformOrigH / 2,
};
reapplyTransform();
// Mirror the new W/H into the popup if it's open.
if (state.transformPopup) {
const wIn = state.transformPopup.querySelector('#ge-transform-w');
const hIn = state.transformPopup.querySelector('#ge-transform-h');
if (wIn) wIn.value = String(state.transformPendingFlipH ? -newW : newW);
if (hIn) hIn.value = String(state.transformPendingFlipV ? -newH : newH);
}
return true;
},
/**
* Called on pointerup. Returns true if handled.
*/
tryEnd() {
if (!(state.transformActive && state.transformHandle)) return false;
state.transformHandle = null;
state.transformOrigW = state.transformLayer?.canvas.width || 0;
state.transformOrigH = state.transformLayer?.canvas.height || 0;
composite();
drawTransformHandles();
return true;
},
};
}

View File

@@ -0,0 +1,271 @@
/**
* Transform-tool handle rendering + hit-testing + overlay sync.
*
* Lives separately from `transform-drag.js` (which owns the drag
* STATE MACHINE) because these three helpers are pure geometry that
* happens to read shared state — they don't track in-progress drags,
* they just paint and hit-test.
*
* - `syncOverlay(margin)` positions the overlay canvas + sizes its
* bitmap based on the main canvas + zoom.
* - `drawHandles(margin)` draws the rotated bounding outline + 4
* corner handles + the rotation knob (with
* hover / active visual states).
* - `getHandleAt(x, y)` returns the handle id under (x, y), or
* null. Geometry MUST mirror `drawHandles`
* exactly or the user grabs phantom points.
*
* No event listeners attached here — the dispatcher in
* editor/tools/transform-drag.js calls `getHandleAt` and routes
* pointer events.
*/
import { state } from '../state.js';
/**
* Position the transform overlay canvas + size its backing bitmap.
* Margin is the image-space slack each side so handles can render
* outside the main canvas (matches _TRANSFORM_OVERLAY_MARGIN in
* galleryEditor.js — kept as a parameter so this module has no
* dependency on a magic number defined elsewhere).
*/
export function syncOverlay(margin) {
if (!state.transformOverlay || !state.mainCanvas) return;
if (!state.transformActive) {
state.transformOverlay.style.display = 'none';
return;
}
const W = state.mainCanvas.width + 2 * margin;
const H = state.mainCanvas.height + 2 * margin;
if (state.transformOverlay.width !== W) state.transformOverlay.width = W;
if (state.transformOverlay.height !== H) state.transformOverlay.height = H;
// Overlay must scale with state.zoom so its handles render at the
// SAME on-screen size as the main canvas content. Without this, the
// overlay renders at full bitmap size while main canvas shrinks
// (zoomed-out), making handles look massive.
state.transformOverlay.style.display = '';
state.transformOverlay.style.position = 'absolute';
state.transformOverlay.style.width = (W * state.zoom) + 'px';
state.transformOverlay.style.height = (H * state.zoom) + 'px';
state.transformOverlay.style.pointerEvents = 'none';
state.transformOverlay.style.zIndex = '5';
// Position the overlay at the main canvas's LAYOUT position
// (offsetLeft/Top — unaffected by CSS transforms), shifted up-left by
// the overlay's `margin` image-px of handle slack. Then SHARE the
// canvas's transform (the pan handler writes the same translate3d to
// both canvas + overlay), so pan moves them together. Reading the
// layout offset (not getBoundingClientRect, which includes the pan
// transform) is what avoids the double-pan "bounce".
state.transformOverlay.style.left = Math.round(state.mainCanvas.offsetLeft - margin * state.zoom) + 'px';
state.transformOverlay.style.top = Math.round(state.mainCanvas.offsetTop - margin * state.zoom) + 'px';
state.transformOverlay.style.transform = state.mainCanvas.style.transform || 'none';
}
/**
* Compute the on-screen position of the rotation knob given the
* layer's bbox center + rotation. The knob normally sits OUTSIDE the
* top edge of the rotated layer; if that would land beyond the canvas
* viewport, flip it INSIDE.
*
* Returned by `_knobPosition` and shared by drawHandles + getHandleAt
* so both compute the same point.
*/
function knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset) {
let rotInside = false;
const outsideR = baseInnerR + rotOffset;
const knobLocalX = cxh + Math.sin(rotRad) * outsideR;
const knobLocalY = cyh - Math.cos(rotRad) * outsideR;
// Primary check: anything drawn outside the main canvas's pixel
// buffer is invisible (canvas operations clip silently).
if (
knobLocalX < 0 || knobLocalY < 0 ||
knobLocalX > state.mainCanvas.width || knobLocalY > state.mainCanvas.height
) {
rotInside = true;
}
// Secondary check: even if the knob is inside the canvas bitmap, the
// viewport may have scrolled the canvas such that the knob falls
// outside the visible canvas-area window.
try {
const area = state.container && state.container.querySelector('.ge-canvas-area');
if (area && !rotInside) {
const aRect = area.getBoundingClientRect();
const mRect = state.mainCanvas.getBoundingClientRect();
const scaleX = mRect.width / state.mainCanvas.width;
const scaleY = mRect.height / state.mainCanvas.height;
const knobClientX = mRect.left + knobLocalX * scaleX;
const knobClientY = mRect.top + knobLocalY * scaleY;
if (knobClientY < aRect.top + 6) rotInside = true;
if (knobClientX < aRect.left + 6 || knobClientX > aRect.right - 6) rotInside = true;
}
} catch {}
const innerR = rotInside ? Math.max(4, baseInnerR - rotOffset) : baseInnerR;
const rotR = rotInside ? innerR : baseInnerR + rotOffset;
return {
rotInside,
innerR,
rotX: cxh + Math.sin(rotRad) * rotR,
rotY: cyh - Math.cos(rotRad) * rotR,
};
}
/**
* Draw the rotated bounding outline + 4 corner handles + the rotation
* knob into the overlay canvas. The overlay is translated by `margin`
* so image (0,0) maps to overlay (margin, margin).
*/
export function drawHandles(margin) {
if (!state.transformActive || !state.transformLayer) return;
syncOverlay(margin);
if (!state.transformOverlayCtx) return;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const ctx = state.transformOverlayCtx;
// Clear + shift drawing by margin so image (0,0) maps to overlay (M,M).
ctx.clearRect(0, 0, state.transformOverlay.width, state.transformOverlay.height);
ctx.save();
ctx.translate(margin, margin);
// Zoom-corrected handle size + stroke so they stay readable at any zoom.
const sz = 10 / state.zoom;
const stroke = 1.5 / state.zoom;
// Pre-rotation rectangle dims (what the user sees the layer as).
// Falls back to layer bbox before any popup values exist.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cxBox = off.x + w / 2;
const cyBox = off.y + h / 2;
const rotRadBox = ((state.transformPendingRot || 0) * Math.PI) / 180;
const cosBox = Math.cos(rotRadBox);
const sinBox = Math.sin(rotRadBox);
const rotPt = (dx, dy) => ({
x: cxBox + dx * cosBox - dy * sinBox,
y: cyBox + dx * sinBox + dy * cosBox,
});
const tl = rotPt(-preW / 2, -preH / 2);
const tr = rotPt( preW / 2, -preH / 2);
const br = rotPt( preW / 2, preH / 2);
const bl = rotPt(-preW / 2, preH / 2);
// Outline of the rotated rectangle — solid white inner line with a
// thin black halo for contrast on light AND dark backgrounds.
const drawRectOutline = () => {
ctx.beginPath();
ctx.moveTo(tl.x, tl.y);
ctx.lineTo(tr.x, tr.y);
ctx.lineTo(br.x, br.y);
ctx.lineTo(bl.x, bl.y);
ctx.closePath();
ctx.stroke();
};
ctx.lineWidth = 1 / state.zoom;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.45)';
ctx.setLineDash([6 / state.zoom, 4 / state.zoom]);
ctx.lineDashOffset = 1 / state.zoom;
drawRectOutline();
ctx.strokeStyle = '#fff';
ctx.lineDashOffset = 0;
drawRectOutline();
ctx.setLineDash([]);
// Corner handles + rotation knob anchored to the rotated layer's
// top-center (not bbox top), so the knob stays attached to the
// visible content as it spins.
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
// Tether line collapses to a point when knob is inside the layer.
const drawTether = !knob.rotInside;
const innerX = cxh + Math.sin(rotRad) * baseInnerR;
const innerY = cyh - Math.cos(rotRad) * baseInnerR;
const corners = [
{ x: tl.x, y: tl.y, id: 'tl' },
{ x: tr.x, y: tr.y, id: 'tr' },
{ x: br.x, y: br.y, id: 'br' },
{ x: bl.x, y: bl.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
if (drawTether) {
ctx.beginPath();
ctx.moveTo(innerX, innerY);
ctx.lineTo(knob.rotX, knob.rotY);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
ctx.lineWidth = 1 / state.zoom;
ctx.stroke();
}
for (const c of corners) {
const active = c.id === state.transformHandle;
const hovered = !active && c.id === state.hoveredHandle;
const radius = (active ? sz * 0.75 : hovered ? sz * 0.6 : sz / 2);
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fillStyle = active ? '#e06c75' : hovered ? '#ffd' : '#fff';
ctx.fill();
ctx.lineWidth = stroke;
ctx.strokeStyle = active ? '#fff' : 'rgba(0, 0, 0, 0.5)';
ctx.stroke();
if (hovered) {
// Subtle red ring around the hovered handle for visual feedback.
ctx.beginPath();
ctx.arc(c.x, c.y, radius + 2 / state.zoom, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(224, 108, 117, 0.7)';
ctx.lineWidth = stroke;
ctx.stroke();
}
}
ctx.restore();
}
/**
* Hit-test (x, y) against the transform handles. Returns the handle
* id ('tl' | 'tr' | 'br' | 'bl' | 'rot') or null.
*
* Geometry MUST mirror `drawHandles` exactly, otherwise the user
* grabs phantom points.
*/
export function getHandleAt(x, y) {
if (!state.transformLayer) return null;
const layer = state.transformLayer;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const w = layer.canvas.width;
const h = layer.canvas.height;
const threshold = 8 / state.zoom;
const rotOffset = 24 / state.zoom;
const cxh = off.x + w / 2;
const cyh = off.y + h / 2;
const rotRad = ((state.transformPendingRot || 0) * Math.PI) / 180;
const baseInnerR = (state.transformPendingH || h) / 2;
const knob = knobPosition(cxh, cyh, rotRad, baseInnerR, rotOffset);
// Rotate corners around centre — must match drawHandles.
const preW = state.transformPendingW || w;
const preH = state.transformPendingH || h;
const cosA = Math.cos(rotRad);
const sinA = Math.sin(rotRad);
const rotCorner = (dx, dy) => ({
x: cxh + dx * cosA - dy * sinA,
y: cyh + dx * sinA + dy * cosA,
});
const tlH = rotCorner(-preW / 2, -preH / 2);
const trH = rotCorner( preW / 2, -preH / 2);
const brH = rotCorner( preW / 2, preH / 2);
const blH = rotCorner(-preW / 2, preH / 2);
const handles = [
{ x: tlH.x, y: tlH.y, id: 'tl' },
{ x: trH.x, y: trH.y, id: 'tr' },
{ x: brH.x, y: brH.y, id: 'br' },
{ x: blH.x, y: blH.y, id: 'bl' },
{ x: knob.rotX, y: knob.rotY, id: 'rot' },
];
for (const c of handles) {
if (Math.abs(x - c.x) < threshold && Math.abs(y - c.y) < threshold) return c.id;
}
return null;
}

View File

@@ -0,0 +1,381 @@
/**
* Transform-tool session lifecycle + floating popup wiring.
*
* _startTransform snapshot the active layer + open popup
* _openTransformPopup build the W/H/rotation popup, wire inputs
* _wireTransformDrag header drag, mobile + desktop position handling
* _reapplyTransform live preview re-render from the snapshot
* _confirmTransform commit + clear session state
* _cancelTransform restore via undo() + clear session state
*
* Handle-drag interactions on the CANVAS (corner / rotation grip) live
* in `editor/tools/transform-drag.js` — those mutate the same staged
* `state.transformPending*` fields that the popup inputs do, so both
* surfaces stay in sync via `_reapplyTransform()`.
*
* @param {{
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* composite: () => void,
* fitZoom: () => void,
* drawTransformHandles: () => void,
* showCanvasLoading: (label: string) => void,
* hideCanvasLoading: () => void,
* undo: () => void,
* uiModule: object | null,
* }} deps
*
* @returns {{
* startTransform, openTransformPopup, closeTransformPopup,
* reapplyTransform, confirmTransform, cancelTransform,
* }}
*/
import { state } from '../state.js';
import {
transformPopupHTML,
attachSpinRepeat,
} from '../build/transform-popup.js';
export function createTransformSession({
activeLayer, saveState, composite, fitZoom, drawTransformHandles,
showCanvasLoading, hideCanvasLoading, undo, uiModule,
}) {
function startTransform() {
const layer = activeLayer();
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
if (state.transformActive) { cancelTransform(); return; } // toggle off
state.transformActive = true;
state.transformLayer = layer;
state.transformOrigW = layer.canvas.width;
state.transformOrigH = layer.canvas.height;
state.transformPendingW = state.transformOrigW;
state.transformPendingH = state.transformOrigH;
state.transformPendingRot = 0;
state.transformPendingFlipH = false;
state.transformPendingFlipV = false;
// Snapshot the layer so live preview can re-derive from the
// original pixels on every keystroke instead of stacking
// destructive edits.
state.transformOrigCanvas = document.createElement('canvas');
state.transformOrigCanvas.width = state.transformOrigW;
state.transformOrigCanvas.height = state.transformOrigH;
state.transformOrigCanvas.getContext('2d').drawImage(layer.canvas, 0, 0);
state.transformOrigOffset = { ...(state.layerOffsets.get(layer.id) || { x: 0, y: 0 }) };
saveState();
// Fit canvas to viewport so the corner handles are visible —
// without this, a layer larger than the viewport leaves the grab
// markers off-screen.
try { fitZoom(); } catch {}
composite();
drawTransformHandles();
openTransformPopup();
}
function closeTransformPopup() {
if (state.transformPopup) {
try { state.transformPopup.remove(); } catch {}
state.transformPopup = null;
}
}
// Floating Transform popup — horizontal layout, draggable via its
// header, anchored over the right panel (layers area) by default
// so it doesn't cover the canvas. Lets the user type exact W/H/Rot
// and flip via negative values.
function openTransformPopup() {
closeTransformPopup();
if (!state.container) return;
const pop = document.createElement('div');
pop.className = 'ge-transform-popup';
pop.innerHTML = transformPopupHTML();
state.container.appendChild(pop);
state.transformPopup = pop;
wireTransformDrag(pop);
const wInput = pop.querySelector('#ge-transform-w');
const hInput = pop.querySelector('#ge-transform-h');
const rotInput = pop.querySelector('#ge-transform-rot');
const aspectBtn = pop.querySelector('#ge-transform-aspect');
wInput.value = String(state.transformOrigW);
hInput.value = String(state.transformOrigH);
rotInput.value = '0';
aspectBtn.classList.toggle('active', state.transformAspectLock);
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
// Aspect-lock follower model: while the lock is engaged, ONE
// field is the "driver" and the other is read-only + dimmed.
// Driver = whichever field the user last typed in. Toggling the
// chain releases the follower.
let driver = null;
const applyAspectVisuals = () => {
if (!state.transformAspectLock || !driver) {
wInput.readOnly = false;
hInput.readOnly = false;
wInput.classList.remove('ge-transform-input-locked');
hInput.classList.remove('ge-transform-input-locked');
return;
}
const followerW = driver === 'h';
const followerH = driver === 'w';
wInput.readOnly = followerW;
hInput.readOnly = followerH;
wInput.classList.toggle('ge-transform-input-locked', followerW);
hInput.classList.toggle('ge-transform-input-locked', followerH);
};
const refresh = () => {
let w = parseInt(wInput.value, 10);
let h = parseInt(hInput.value, 10);
const rot = parseInt(rotInput.value, 10) || 0;
state.transformPendingFlipH = w < 0;
state.transformPendingFlipV = h < 0;
w = Math.abs(w || state.transformOrigW);
h = Math.abs(h || state.transformOrigH);
state.transformPendingW = Math.max(1, w);
state.transformPendingH = Math.max(1, h);
state.transformPendingRot = rot;
reapplyTransform();
};
wInput.addEventListener('input', () => {
if (state.transformAspectLock) {
driver = 'w';
const w = parseInt(wInput.value, 10);
if (!Number.isNaN(w) && state.transformOrigW > 0) {
const sign = (parseInt(hInput.value, 10) || 1) < 0 ? -1 : 1;
const newH = Math.round((Math.abs(w) / state.transformOrigW) * state.transformOrigH) * sign;
hInput.value = String(newH);
}
applyAspectVisuals();
}
refresh();
});
hInput.addEventListener('input', () => {
if (state.transformAspectLock) {
driver = 'h';
const h = parseInt(hInput.value, 10);
if (!Number.isNaN(h) && state.transformOrigH > 0) {
const sign = (parseInt(wInput.value, 10) || 1) < 0 ? -1 : 1;
const newW = Math.round((Math.abs(h) / state.transformOrigH) * state.transformOrigW) * sign;
wInput.value = String(newW);
}
applyAspectVisuals();
}
refresh();
});
rotInput.addEventListener('input', refresh);
aspectBtn.addEventListener('click', () => {
state.transformAspectLock = !state.transformAspectLock;
aspectBtn.classList.toggle('active', state.transformAspectLock);
aspectBtn.setAttribute('aria-pressed', state.transformAspectLock ? 'true' : 'false');
// Reset follower the moment the user breaks the lock so both
// fields go editable; re-engaging means "next type sets the driver".
driver = null;
applyAspectVisuals();
});
pop.querySelector('#ge-transform-apply').addEventListener('click', () => confirmTransform());
pop.querySelector('#ge-transform-cancel').addEventListener('click', () => cancelTransform());
pop.querySelector('#ge-transform-cancel-btn')?.addEventListener('click', () => cancelTransform());
// Minimise — collapses the body so only the header is visible.
pop.querySelector('#ge-transform-min')?.addEventListener('click', (e) => {
e.stopPropagation();
pop.classList.toggle('ge-transform-popup-minimised');
});
// Quick actions: flip W/H via sign so the reapply pipeline picks
// up the new orientation. Rotate-90 nudges rotation ±90°.
pop.querySelector('#ge-transform-flip-h')?.addEventListener('click', () => {
const wIn = pop.querySelector('#ge-transform-w');
const cur = parseInt(wIn.value, 10) || state.transformOrigW;
wIn.value = String(-cur);
wIn.dispatchEvent(new Event('input', { bubbles: true }));
});
pop.querySelector('#ge-transform-flip-v')?.addEventListener('click', () => {
const hIn = pop.querySelector('#ge-transform-h');
const cur = parseInt(hIn.value, 10) || state.transformOrigH;
hIn.value = String(-cur);
hIn.dispatchEvent(new Event('input', { bubbles: true }));
});
pop.querySelector('#ge-transform-rot-90')?.addEventListener('click', (e) => {
const rIn = pop.querySelector('#ge-transform-rot');
const cur = parseInt(rIn.value, 10) || 0;
const delta = e.shiftKey ? -90 : 90;
let next = cur + delta;
while (next > 180) next -= 360;
while (next <= -180) next += 360;
rIn.value = String(next);
// Big images: rotation pass blocks UI ~0.52 s. Show a spinner
// so the user sees something happen. rAF defers the heavy work
// past the current frame so the overlay paints first.
showCanvasLoading('Rotating…');
requestAnimationFrame(() => {
try { rIn.dispatchEvent(new Event('input', { bubbles: true })); }
finally { hideCanvasLoading(); }
});
});
attachSpinRepeat(pop);
}
// Header-drag for the Transform popup. Default position: over the
// right panel (layers area). Mobile pins via stylesheet so we use
// setProperty 'important' to override during drag.
function wireTransformDrag(pop) {
const isMobile = window.matchMedia('(max-width: 820px)').matches;
const defaultRight = 20;
const defaultTop = 60;
if (isMobile) {
pop.style.setProperty('position', 'fixed', 'important');
} else {
pop.style.position = 'absolute';
pop.style.right = defaultRight + 'px';
pop.style.top = defaultTop + 'px';
pop.style.left = 'auto';
}
const dragSource = pop.querySelector('[data-transform-drag]') || pop;
let dragging = false;
let startX = 0, startY = 0, originLeft = 0, originTop = 0;
const NON_DRAG = 'input,button,select,textarea,a,[contenteditable]';
const setPos = (x, y) => {
if (isMobile) {
pop.style.setProperty('left', x + 'px', 'important');
pop.style.setProperty('top', y + 'px', 'important');
pop.style.setProperty('right', 'auto', 'important');
pop.style.setProperty('bottom', 'auto', 'important');
pop.style.setProperty('width', 'auto', 'important');
pop.style.setProperty('max-width', 'calc(100vw - 16px)', 'important');
} else {
pop.style.left = x + 'px';
pop.style.top = y + 'px';
pop.style.right = 'auto';
}
};
const beginDrag = (clientX, clientY) => {
dragging = true;
const rect = pop.getBoundingClientRect();
if (isMobile) {
originLeft = rect.left;
originTop = rect.top;
} else {
const parentRect = state.container.getBoundingClientRect();
originLeft = rect.left - parentRect.left;
originTop = rect.top - parentRect.top;
}
startX = clientX;
startY = clientY;
setPos(originLeft, originTop);
pop.classList.add('ge-transform-popup-dragging');
document.body.style.userSelect = 'none';
};
const moveDrag = (clientX, clientY) => {
if (!dragging) return;
const dx = clientX - startX;
const dy = clientY - startY;
let nx = originLeft + dx;
let ny = originTop + dy;
if (isMobile) {
const rect = pop.getBoundingClientRect();
nx = Math.max(0, Math.min(window.innerWidth - rect.width, nx));
ny = Math.max(0, Math.min(window.innerHeight - rect.height, ny));
}
setPos(nx, ny);
};
const endDrag = () => {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
pop.classList.remove('ge-transform-popup-dragging');
};
dragSource.addEventListener('mousedown', (e) => {
if (e.target.closest(NON_DRAG)) return;
e.preventDefault();
beginDrag(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => moveDrag(e.clientX, e.clientY));
document.addEventListener('mouseup', endDrag);
dragSource.addEventListener('touchstart', (e) => {
if (e.target.closest(NON_DRAG)) return;
if (!e.touches || e.touches.length !== 1) return;
e.preventDefault();
beginDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchmove', (e) => {
if (!dragging) return;
if (!e.touches || e.touches.length !== 1) return;
e.preventDefault();
moveDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchend', endDrag);
document.addEventListener('touchcancel', endDrag);
}
// Re-derive the active layer's pixels from the original snapshot
// with the popup's current W/H/flip/rotation applied. Cheap —
// paints into an off-screen canvas of the final size.
function reapplyTransform() {
const layer = state.transformLayer;
if (!layer || !state.transformOrigCanvas) return;
const w = state.transformPendingW;
const h = state.transformPendingH;
const rotDeg = state.transformPendingRot;
const rotRad = (rotDeg * Math.PI) / 180;
const cos = Math.abs(Math.cos(rotRad));
const sin = Math.abs(Math.sin(rotRad));
// Bounding box of the rotated W×H — canvas grows so corners
// don't clip.
const finalW = Math.max(1, Math.round(w * cos + h * sin));
const finalH = Math.max(1, Math.round(w * sin + h * cos));
const tmp = document.createElement('canvas');
tmp.width = finalW; tmp.height = finalH;
const tCtx = tmp.getContext('2d');
tCtx.imageSmoothingEnabled = true;
tCtx.imageSmoothingQuality = 'high';
tCtx.save();
tCtx.translate(finalW / 2, finalH / 2);
if (rotDeg) tCtx.rotate(rotRad);
tCtx.scale(state.transformPendingFlipH ? -1 : 1, state.transformPendingFlipV ? -1 : 1);
tCtx.drawImage(state.transformOrigCanvas, -w / 2, -h / 2, w, h);
tCtx.restore();
layer.canvas.width = finalW;
layer.canvas.height = finalH;
layer.ctx.clearRect(0, 0, finalW, finalH);
layer.ctx.drawImage(tmp, 0, 0);
// Recenter the layer so the rotation pivot stays put visually.
const origCenterX = state.transformOrigOffset.x + state.transformOrigW / 2;
const origCenterY = state.transformOrigOffset.y + state.transformOrigH / 2;
state.layerOffsets.set(layer.id, {
x: Math.round(origCenterX - finalW / 2),
y: Math.round(origCenterY - finalH / 2),
});
composite();
drawTransformHandles();
}
function confirmTransform() {
closeTransformPopup();
state.transformOrigCanvas = null;
state.transformOrigOffset = null;
state.transformActive = false;
state.transformLayer = null;
state.transformHandle = null;
composite();
uiModule.showToast('Transform applied');
}
function cancelTransform() {
closeTransformPopup();
state.transformOrigCanvas = null;
state.transformOrigOffset = null;
if (state.transformLayer) undo(); // restore saved state
state.transformActive = false;
state.transformLayer = null;
state.transformHandle = null;
composite();
}
return {
startTransform, openTransformPopup, closeTransformPopup,
reapplyTransform, confirmTransform, cancelTransform,
};
}

View File

@@ -0,0 +1,46 @@
/**
* Magic-wand tool — single-click flood-fill selection on the active
* layer's pixels. Shift/Alt modifiers override the persistent mode
* toggle for the duration of the click (add / subtract).
*
* Clicking inside an existing selection with no modifier deselects.
*
* Wand is selection-only — it doesn't mutate the layer until the user
* invokes an action (Erase / Copy / etc.) from the panel. That's why
* it has just a `click` handler instead of begin/drag/end.
*
* @param {{
* activeLayer: () => object | null,
* saveState: () => void,
* composite: () => void,
* wandHits: (cx: number, cy: number) => boolean,
* runMagicWand: (cx: number, cy: number, mode: 'replace'|'add'|'subtract') => void,
* }} deps
*/
import { state } from '../state.js';
import { canvasCoords } from '../canvas-coords.js';
export function createWandTool({ activeLayer, saveState, composite, wandHits, runMagicWand }) {
return {
click(e) {
const layer = activeLayer();
if (!layer) return;
const coords = canvasCoords(e, state.mainCanvas);
// Persistent toggle sets the default mode; Shift forces add, Alt
// forces subtract regardless of the toggle (modifiers always win).
let mode = state.wandMode || 'replace';
if (e.shiftKey) mode = 'add';
else if (e.altKey) mode = 'subtract';
// Click INSIDE the existing selection with no modifier → deselect.
if (mode === 'replace' && wandHits(coords.x, coords.y)) {
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandLastSeed = null;
composite();
return;
}
runMagicWand(coords.x, coords.y, mode);
},
};
}

View File

@@ -0,0 +1,148 @@
/**
* Image-import wiring — covers all four entry points that drop an
* image as a new layer:
*
* #ge-import-topbar topbar "+ Import" button
* #ge-import-file File button in the Import section
* #ge-import-paste Clipboard button (uses async clipboard API)
* #ge-import-gallery Gallery picker — fetches /api/gallery/library
* and shows a thumbnail grid overlay
*
* Plus the shared `handleImportedImage(img)` sink — scales to canvas,
* centres, creates a new layer, switches to Move tool, hides the
* import section, refreshes the panel. Returned so the drag-and-drop
* + paste paths (wired in editor/clipboard-and-drop.js) can use the
* same sink.
*
* @param {{
* container: HTMLElement,
* saveState: (label?: string) => void,
* createLayer: (name, w, h) => object,
* composite: () => void,
* renderLayerPanel: () => void,
* uiModule: object,
* }} deps
*
* @returns {{ handleImportedImage: (img: HTMLImageElement) => void }}
*/
import { state } from './state.js';
export function wireImport({ container, saveState, createLayer, composite, renderLayerPanel, uiModule }) {
// Hidden <input type="file"> the topbar + File buttons both click.
const importFileInput = document.createElement('input');
importFileInput.type = 'file';
importFileInput.accept = 'image/*';
importFileInput.style.display = 'none';
container.appendChild(importFileInput);
function handleImportedImage(img) {
if (!state.editorOpen) return;
saveState('Import image');
// Scale down if larger than canvas.
let w = img.naturalWidth || img.width;
let h = img.naturalHeight || img.height;
if (w > state.imgWidth || h > state.imgHeight) {
const scale = Math.min(state.imgWidth / w, state.imgHeight / h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
const layer = createLayer('Imported', state.imgWidth, state.imgHeight);
// Centre on the canvas.
const ox = Math.round((state.imgWidth - w) / 2);
const oy = Math.round((state.imgHeight - h) / 2);
layer.ctx.drawImage(img, ox, oy, w, h);
state.layers.push(layer);
state.activeLayerId = layer.id;
// Switch to move tool so the imported layer is immediately
// repositionable.
state.tool = 'move';
const tb = container.querySelector('.ge-toolbar');
if (tb) tb.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === 'move'));
// Hide the import section now that the import is done.
const importSec = document.getElementById('ge-import-section');
if (importSec) importSec.style.display = 'none';
composite();
renderLayerPanel();
if (uiModule) uiModule.showToast('Image imported — drag to position');
}
importFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => handleImportedImage(img);
img.src = ev.target.result;
};
reader.readAsDataURL(file);
importFileInput.value = '';
});
document.getElementById('ge-import-topbar')?.addEventListener('click', () => importFileInput.click());
document.getElementById('ge-import-file')?.addEventListener('click', () => importFileInput.click());
document.getElementById('ge-import-paste')?.addEventListener('click', async () => {
try {
const clipItems = await navigator.clipboard.read();
let blob = null;
for (const item of clipItems) {
const imgType = item.types.find(t => t.startsWith('image/'));
if (imgType) { blob = await item.getType(imgType); break; }
}
if (!blob) { if (uiModule) uiModule.showToast('No image found in clipboard'); return; }
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { handleImportedImage(img); URL.revokeObjectURL(url); };
img.onerror = () => { URL.revokeObjectURL(url); if (uiModule) uiModule.showToast('Failed to load clipboard image'); };
img.src = url;
} catch (e) {
if (uiModule) uiModule.showToast('Clipboard access denied or no image available');
}
});
// Import from Gallery — fetch /api/gallery/library and show a
// thumbnail-grid picker overlay.
document.getElementById('ge-import-gallery')?.addEventListener('click', async () => {
try {
const res = await fetch('/api/gallery/library?limit=50', { credentials: 'same-origin' });
const data = await res.json();
const items = data.items || [];
if (!items.length) { if (uiModule) uiModule.showToast('No images in gallery'); return; }
// Picker overlay.
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:10001;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;';
const panel = document.createElement('div');
panel.style.cssText = 'background:var(--panel,#1e1e1e);border-radius:12px;padding:16px;max-width:500px;max-height:70vh;overflow-y:auto;width:90%;';
panel.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;"><span style="font-size:13px;font-weight:600;">Pick from Gallery</span><button id="ge-gallery-close" style="background:none;border:none;color:var(--fg);cursor:pointer;font-size:18px;">✕</button></div>';
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;';
for (const item of items) {
const thumb = document.createElement('img');
thumb.src = item.url;
thumb.style.cssText = 'width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:border-color 0.15s;';
thumb.addEventListener('mouseenter', () => { thumb.style.borderColor = 'var(--accent,#61afef)'; });
thumb.addEventListener('mouseleave', () => { thumb.style.borderColor = 'transparent'; });
thumb.addEventListener('click', () => {
overlay.remove();
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => handleImportedImage(img);
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load gallery image'); };
img.src = item.url;
});
grid.appendChild(thumb);
}
panel.appendChild(grid);
overlay.appendChild(panel);
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
panel.querySelector('#ge-gallery-close').addEventListener('click', () => overlay.remove());
} catch (e) {
if (uiModule) uiModule.showToast('Failed to load gallery: ' + e.message);
}
});
return { handleImportedImage };
}

View File

@@ -0,0 +1,164 @@
/**
* Inpaint panel controls — the non-AI side-panel UI for the inpaint
* tool (the AI Generate/Remove/Outpaint buttons live in
* editor/ai-inpaint.js).
*
* Pre-gen sliders (Feather + Strength swatch previews):
* #ge-strength-slider just-updates-the-label-and-swatch
*
* Post-gen live edge tuners — alpha-blur + dilate/erode on the most
* recent Inpaint Result layer, rAF-throttled so dragging stays
* smooth on big canvases:
* #ge-feather-slider calls applyInpaintFeather + composite
* #ge-edgestroke-slider same
*
* Mask controls:
* #ge-mask-vis toggle red-overlay visibility
* #ge-inpaint-invert invert the active mask sub-layer
* #ge-inpaint-clear wipe the active mask
* #ge-inpaint-mode-paint set persistent paint mode
* #ge-inpaint-mode-erase set persistent erase mode
*
* Mask tint pickers (wired to keep both visually in sync):
* .ge-inpaint-mask-color (inpaint section)
* #ge-topbar-mask-color (topbar swatch — HSV picker attached)
*
* @param {{
* composite: () => void,
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
* syncToolClearIndicators: () => void,
* attachColorPicker: (el: HTMLInputElement) => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
const EYE_OPEN_SM = '<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="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>';
const EYE_OFF_SM = '<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="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 function wireInpaintControls({
composite, applyInpaintFeather, syncToolClearIndicators,
attachColorPicker, uiModule,
}) {
// ── Feather + Strength preview swatches ──
const featherPrev = document.getElementById('ge-feather-preview');
const strengthPrev = document.getElementById('ge-strength-preview');
function syncFeatherPreview(v) {
if (!featherPrev) return;
const inner = Math.max(0, 50 - v * 1.25);
featherPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
}
function syncStrengthPreview(v) {
if (!strengthPrev) return;
strengthPrev.style.opacity = (v / 100).toFixed(2);
}
// ── Post-inpaint live edge tuner ──
// Alpha-blur (Feather) + dilate/erode (Edge Stroke) on the last
// Inpaint Result layer. rAF-throttled so dragging stays smooth.
let featherRafPending = false;
function scheduleInpaintEdgeRefresh() {
if (featherRafPending) return;
featherRafPending = true;
requestAnimationFrame(() => {
featherRafPending = false;
const layer = state.layers.find(l => l.id === state.lastInpaintLayerId);
if (!layer || !layer.inpaintSource) return;
const feather = parseInt(document.getElementById('ge-feather-slider')?.value || '0', 10);
const edge = parseInt(document.getElementById('ge-edgestroke-slider')?.value || '0', 10);
applyInpaintFeather(layer, feather, edge);
composite();
});
}
document.getElementById('ge-feather-slider')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
document.getElementById('ge-feather-label').textContent = v + 'px';
syncFeatherPreview(v);
scheduleInpaintEdgeRefresh();
});
document.getElementById('ge-edgestroke-slider')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
const label = document.getElementById('ge-edgestroke-label');
if (label) label.textContent = (v > 0 ? '+' : '') + v + 'px';
const prev = document.getElementById('ge-edgestroke-preview');
if (prev) {
// Visualise direction: dilate (+) → green, erode () → red.
const dir = v === 0 ? 'transparent' : (v > 0 ? 'rgba(120,200,120,0.5)' : 'rgba(200,120,120,0.5)');
prev.style.background = dir;
prev.style.opacity = Math.min(1, Math.abs(v) / 80).toFixed(2);
}
scheduleInpaintEdgeRefresh();
});
document.getElementById('ge-strength-slider')?.addEventListener('input', (e) => {
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
syncStrengthPreview(parseInt(e.target.value, 10));
});
syncFeatherPreview(0);
syncStrengthPreview(75);
// ── Mask vis / invert / clear ──
document.getElementById('ge-mask-vis')?.addEventListener('click', () => {
state.maskVisible = !state.maskVisible;
const btn = document.getElementById('ge-mask-vis');
if (!btn) { composite(); return; }
btn.innerHTML = `${state.maskVisible ? EYE_OPEN_SM : EYE_OFF_SM}<span id="ge-mask-vis-label">${state.maskVisible ? 'Hide' : 'Show'}</span>`;
btn.title = state.maskVisible ? 'Hide mask' : 'Show mask';
btn.classList.toggle('visible', state.maskVisible);
composite();
});
document.getElementById('ge-inpaint-invert')?.addEventListener('click', () => {
if (!state.maskCtx || !state.maskCanvas) return;
const imgData = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const alpha = d[i + 3];
if (alpha > 0) {
d[i] = 0; d[i+1] = 0; d[i+2] = 0; d[i+3] = 0;
} else {
d[i] = 255; d[i+1] = 255; d[i+2] = 255; d[i+3] = 255;
}
}
state.maskCtx.putImageData(imgData, 0, 0);
composite();
syncToolClearIndicators();
uiModule.showToast('Mask inverted');
});
document.getElementById('ge-inpaint-clear')?.addEventListener('click', () => {
if (state.maskCtx) { state.maskCtx.clearRect(0, 0, state.maskCanvas.width, state.maskCanvas.height); composite(); }
syncToolClearIndicators();
});
// ── Paint / Erase segmented toggle ──
function setInpaintMode(eraseMode) {
state.inpaintEraseMode = !!eraseMode;
const paintBtn = document.getElementById('ge-inpaint-mode-paint');
const eraseBtn = document.getElementById('ge-inpaint-mode-erase');
if (paintBtn) paintBtn.classList.toggle('active', !state.inpaintEraseMode);
if (eraseBtn) eraseBtn.classList.toggle('active', state.inpaintEraseMode);
}
document.getElementById('ge-inpaint-mode-paint')?.addEventListener('click', () => setInpaintMode(false));
document.getElementById('ge-inpaint-mode-erase')?.addEventListener('click', () => setInpaintMode(true));
// ── Mask color picker ──
// Updates state.maskTintColor live so the user can pick a colour
// that contrasts with their photo. Wire both the topbar picker AND
// the inpaint-section picker so changing one syncs the other.
function applyMaskTintFromHex(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
state.maskTintColor = `rgba(${r}, ${g}, ${b}, 1)`;
const inpaintPicker = document.querySelector('.ge-inpaint-mask-color');
const topbarPicker = document.getElementById('ge-topbar-mask-color');
if (inpaintPicker && inpaintPicker.value !== hex) inpaintPicker.value = hex;
if (topbarPicker && topbarPicker.value !== hex) topbarPicker.value = hex;
composite();
}
document.querySelector('.ge-inpaint-mask-color')?.addEventListener('input', (e) => applyMaskTintFromHex(e.target.value));
document.getElementById('ge-topbar-mask-color')?.addEventListener('input', (e) => applyMaskTintFromHex(e.target.value));
// Use the in-house HSV picker for the topbar swatch.
const topbarMaskColor = document.getElementById('ge-topbar-mask-color');
if (topbarMaskColor) {
try { attachColorPicker(topbarMaskColor); topbarMaskColor.value = topbarMaskColor.value; } catch {}
}
}

View File

@@ -0,0 +1,103 @@
/**
* Layer merge / flatten buttons in the layer-panel footer:
*
* #ge-flatten Flatten Copy — merge every visible layer into a
* new "Flattened" layer, keep originals.
* #ge-merge-all Merge All — flatten every VISIBLE layer into the
* lowest visible one. Hidden layers dropped. Base
* = lowest visible (not bottom of stack) so a
* hidden base can't absorb the visible stack into
* an invisible result.
* #ge-merge-down Merge active layer into the one beneath it.
*
* @param {{
* saveState: (label?: string) => void,
* createLayer: (name, w, h) => object,
* renderLayerPanel: () => void,
* composite: () => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
export function mergeLayerDownAtIndex(idx) {
if (idx < 1 || idx >= state.layers.length) return null;
const upper = state.layers[idx];
const lower = state.layers[idx - 1];
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
lower.ctx.save();
lower.ctx.globalAlpha = upper.opacity;
lower.ctx.drawImage(
upper.canvas,
upperOff.x - lowerOff.x,
upperOff.y - lowerOff.y,
);
lower.ctx.restore();
state.layers.splice(idx, 1);
state.layerOffsets.delete(upper.id);
state.activeLayerId = lower.id;
return lower;
}
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, uiModule }) {
// Flatten Copy.
document.getElementById('ge-flatten')?.addEventListener('click', () => {
if (state.layers.length < 2) return;
saveState('Flatten copy');
const merged = createLayer('Flattened', state.imgWidth, state.imgHeight);
const ctx = merged.ctx;
for (const l of state.layers) {
if (!l.visible) continue;
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
ctx.globalAlpha = l.opacity;
ctx.drawImage(l.canvas, off.x, off.y);
ctx.globalAlpha = 1;
}
state.layers.push(merged);
state.activeLayerId = merged.id;
renderLayerPanel();
composite();
uiModule.showToast('Flattened copy created');
});
// Merge All — drop hidden layers; base = lowest visible.
document.getElementById('ge-merge-all')?.addEventListener('click', () => {
const visibleLayers = state.layers.filter(l => l.visible);
if (visibleLayers.length < 2) {
if (uiModule) uiModule.showToast('Need at least two visible layers to merge');
return;
}
saveState('Merge all');
const base = visibleLayers[0];
const baseCtx = base.ctx;
for (let i = 1; i < visibleLayers.length; i++) {
const l = visibleLayers[i];
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
baseCtx.globalAlpha = l.opacity;
baseCtx.drawImage(l.canvas, off.x, off.y);
baseCtx.globalAlpha = 1;
}
// Free offset entries for the discarded layers; keep base.
for (const l of state.layers) {
if (l === base) continue;
state.layerOffsets.delete(l.id);
}
state.layers = [base];
state.activeLayerId = base.id;
renderLayerPanel();
composite();
uiModule.showToast('Visible layers merged');
});
// Merge Down.
document.getElementById('ge-merge-down')?.addEventListener('click', () => {
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
if (idx < 1) return; // can't merge the bottom layer
saveState('Merge down');
mergeLayerDownAtIndex(idx);
renderLayerPanel();
composite();
uiModule.showToast('Layer merged down');
});
}

View File

@@ -0,0 +1,170 @@
/**
* Lasso + Magic Wand panel controls — sliders, mode toggles, and the
* panel action buttons (Invert / Clear / Delete / Copy / To Mask /
* Bg Remove). The actual selection algorithms live in their tool
* modules (editor/tools/lasso.js, editor/tools/wand.js); this file
* just wires the side-panel UI to them.
*
* Lasso section:
* #ge-lasso-feather slider, updates label + preview, recomposites
* #ge-lasso-grow slider, updates label + recomposites
* #ge-lasso-invert → invertSelection
* #ge-lasso-delete → lassoDeleteSelection
* #ge-lasso-copy → lassoCopyToLayer
* #ge-lasso-mask → lassoToMask
*
* Wand section:
* #ge-wand-feather slider, updates label + recomposites
* #ge-wand-grow slider, updates label + recomposites
* #ge-wand-tolerance slider, updates future wand-click tolerance
* #ge-wand-live opt-in rAF-coalesced live retune while dragging
* .ge-wand-mode-btn segmented toggle (New / Add / Subtract)
* #ge-wand-vis toggle the translucent red overlay
* #ge-wand-clear / -invert / -delete / -copy / -mask / -rembg
*
* @param {{
* composite: () => void,
* invertSelection: () => boolean,
* lassoDeleteSelection: () => void,
* lassoCopyToLayer: () => void,
* lassoToMask: () => void,
* runMagicWand: (x: number, y: number, mode: string, opts?: object) => void,
* wandClear: () => void,
* wandDeleteSelection: () => void,
* wandCopyToNewLayer: () => void,
* wandToMask: () => void,
* buildSelectionHintMask: () => string | null,
* applyImageTool: (endpoint, payload, name, btn, opts?) => Promise<void>,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
const EYE_OPEN = '<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>';
const EYE_OFF = '<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"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
export function wireSelectionControls({
composite,
invertSelection,
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
runMagicWand,
wandClear, wandDeleteSelection, wandCopyToNewLayer, wandToMask,
buildSelectionHintMask, applyImageTool,
uiModule,
}) {
// ── Lasso section ──
const lassoFPrev = document.getElementById('ge-lasso-feather-preview');
function syncLassoFeather(v) {
if (!lassoFPrev) return;
const inner = Math.max(0, 50 - v * 1.0);
lassoFPrev.style.background = `radial-gradient(circle, var(--fg) 0%, var(--fg) ${inner}%, transparent 75%)`;
}
syncLassoFeather(0);
document.getElementById('ge-lasso-feather')?.addEventListener('input', (e) => {
document.getElementById('ge-lasso-feather-label').textContent = e.target.value + 'px';
syncLassoFeather(parseInt(e.target.value, 10));
composite();
});
document.getElementById('ge-lasso-grow')?.addEventListener('input', (e) => {
document.getElementById('ge-lasso-grow-label').textContent = e.target.value + 'px';
composite();
});
document.getElementById('ge-lasso-delete')?.addEventListener('click', () => {
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoDeleteSelection();
else if (state.wandMask) wandDeleteSelection();
});
document.getElementById('ge-lasso-copy')?.addEventListener('click', () => {
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoCopyToLayer();
else if (state.wandMask) wandCopyToNewLayer();
});
document.getElementById('ge-lasso-mask')?.addEventListener('click', () => {
if (state.lassoPoints.length >= 3 && !state.lassoActive) lassoToMask();
else if (state.wandMask) wandToMask();
});
document.getElementById('ge-lasso-invert')?.addEventListener('click', invertSelection);
// ── Wand section ──
document.getElementById('ge-wand-feather')?.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10) || 0;
document.getElementById('ge-wand-feather-label').textContent = v + 'px';
const prev = document.getElementById('ge-wand-feather-preview');
if (prev) prev.style.setProperty('--feather-blur', Math.min(v / 14, 8) + 'px');
composite();
});
document.getElementById('ge-wand-grow')?.addEventListener('input', (e) => {
document.getElementById('ge-wand-grow-label').textContent = e.target.value + 'px';
composite();
});
// Tolerance slider fires `input` rapidly — coalesce to one wand run
// per frame with rAF. Label updates synchronously so the number
// tracks the cursor even when the flood-fill runs at ~60fps.
let wandRetuneRaf = null;
const retuneWand = () => {
if (!state.wandLastSeed || !state.wandMask) return;
if (wandRetuneRaf) return;
wandRetuneRaf = requestAnimationFrame(() => {
wandRetuneRaf = null;
runMagicWand(state.wandLastSeed.x, state.wandLastSeed.y, 'replace', { retune: true });
});
};
const liveBtn = document.getElementById('ge-wand-live');
liveBtn?.addEventListener('click', () => {
state.wandLiveRetune = !state.wandLiveRetune;
liveBtn.classList.toggle('active', state.wandLiveRetune);
liveBtn.setAttribute('aria-pressed', state.wandLiveRetune ? 'true' : 'false');
if (state.wandLiveRetune) retuneWand();
});
document.getElementById('ge-wand-tolerance')?.addEventListener('input', (e) => {
state.wandTolerance = parseInt(e.target.value, 10);
const lbl = document.getElementById('ge-wand-tol-label');
if (lbl) lbl.textContent = state.wandTolerance;
const wp = document.getElementById('ge-wand-tol-preview');
if (wp) wp.style.opacity = (state.wandTolerance / 100).toFixed(2);
if (state.wandLiveRetune) retuneWand();
});
// Wand mode segmented toggle (New / Add / Subtract).
document.querySelectorAll('.ge-wand-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.wandMode;
if (!mode) return;
state.wandMode = mode;
document.querySelectorAll('.ge-wand-mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.wandMode === mode);
});
});
});
// Toggle the translucent red overlay for the wand selection.
document.getElementById('ge-wand-vis')?.addEventListener('click', () => {
state.wandMaskVisible = !state.wandMaskVisible;
const btn = document.getElementById('ge-wand-vis');
if (btn) {
btn.innerHTML = state.wandMaskVisible ? EYE_OPEN : EYE_OFF;
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
btn.classList.toggle('visible', state.wandMaskVisible);
}
composite();
});
document.getElementById('ge-wand-clear')?.addEventListener('click', wandClear);
document.getElementById('ge-wand-invert')?.addEventListener('click', invertSelection);
document.getElementById('ge-wand-delete')?.addEventListener('click', wandDeleteSelection);
document.getElementById('ge-wand-copy')?.addEventListener('click', wandCopyToNewLayer);
document.getElementById('ge-wand-mask')?.addEventListener('click', wandToMask);
// Selection-constrained Bg Remove — reuses the same path the toolbar
// Bg Remove button does. buildSelectionHintMask picks the active
// wand/lasso selection, so this just kicks off the existing flow.
document.getElementById('ge-wand-rembg')?.addEventListener('click', async () => {
const btn = document.getElementById('ge-wand-rembg');
const hint = buildSelectionHintMask();
if (!hint) { if (uiModule) uiModule.showToast('Click to make a wand selection first'); return; }
await applyImageTool('/api/image/remove-bg', { hint_mask: hint }, 'BG Removed', btn);
wandClear();
});
// Live tolerance preview (just opacity-tracking like sharpen).
const wandTolPrev = document.getElementById('ge-wand-tol-preview');
if (wandTolPrev) wandTolPrev.style.opacity = (state.wandTolerance / 100).toFixed(2);
}

View File

@@ -0,0 +1,174 @@
/**
* Topbar dropdown menus — Image, Filter, and Resize.
*
* Image menu (#ge-image-menu-btn → #ge-image-menu):
* resize, selection (edge feather/delete), fill, rotate 90/180,
* flip horizontal/vertical.
*
* Filter menu (#ge-filter-menu-btn → #ge-filter-menu):
* Blur sub-menu — Gaussian, Zoom.
*
* Resize menu (#ge-resize-menu-btn → #ge-resize-menu):
* preset W×H items (data-resize-w/-h) apply immediately;
* [data-resize-custom] opens a themed prompt for arbitrary sizes.
*
* Returns the resize helpers so the keyboard-shortcuts module can
* call them too (Ctrl+Shift+T opens the custom prompt).
*
* @param {{
* closeOtherTopbarMenus: (keepId: string) => void,
* registerDocClickAway: (handler: (e: Event) => void) => void,
* saveState: (label?: string) => void,
* composite: () => void,
* fitZoom: () => void,
* promptCanvasSize: (opts: object) => Promise<{w, h} | null>,
* doFillSelection: () => void,
* rotateAllLayers: (deg: number) => void,
* flipAllLayers: (axis: 'h' | 'v') => void,
* applyGaussianBlur: () => void,
* applyZoomBlur: () => void,
* uiModule: object,
* }} deps
*
* @returns {{
* applyResize: (newW: number, newH: number) => void,
* resizeCustomPrompt: () => Promise<void>,
* }}
*/
import { state } from './state.js';
export function wireTopbarMenus({
closeOtherTopbarMenus, registerDocClickAway,
saveState, composite, fitZoom,
promptCanvasSize, doFillSelection,
rotateAllLayers, flipAllLayers,
applyGaussianBlur, applyZoomBlur,
uiModule,
}) {
// ── Resize canvas ──
// Extracted so both the popup presets and the Ctrl+Shift+T shortcut
// can call it.
function applyResize(newW, newH) {
if (!newW || !newH || newW < 1 || newH < 1) {
uiModule.showToast('Invalid size');
return;
}
saveState('Resize canvas');
// Only resize the main canvas — layers keep their original size.
// Content outside the new bounds is clipped during composite, not
// destroyed.
if (state.maskCanvas) {
const tmpMask = document.createElement('canvas');
tmpMask.width = state.maskCanvas.width;
tmpMask.height = state.maskCanvas.height;
tmpMask.getContext('2d').drawImage(state.maskCanvas, 0, 0);
state.maskCanvas.width = newW;
state.maskCanvas.height = newH;
state.maskCtx.drawImage(tmpMask, 0, 0);
}
state.imgWidth = newW;
state.imgHeight = newH;
state.mainCanvas.width = newW;
state.mainCanvas.height = newH;
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
fitZoom();
composite();
uiModule.showToast(`Canvas resized to ${newW}×${newH}`);
}
async function resizeCustomPrompt() {
const result = await promptCanvasSize({
title: 'Canvas size',
okLabel: 'Apply',
initialW: state.imgWidth,
initialH: state.imgHeight,
});
if (!result) return;
applyResize(result.w, result.h);
}
// ── Image menu ──
{
const btn = document.getElementById('ge-image-menu-btn');
const menu = document.getElementById('ge-image-menu');
if (btn && menu) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
if (willOpen) closeOtherTopbarMenus('ge-image-menu');
menu.hidden = !menu.hidden;
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('[data-image-action]');
if (!item || item.disabled) return;
menu.hidden = true;
const action = item.dataset.imageAction;
if (action === 'resize') resizeCustomPrompt();
else if (action === 'selection') document.getElementById('ge-edge-menu-btn')?.click();
else if (action === 'fill') doFillSelection();
else if (action === 'rotate-90') rotateAllLayers(90);
else if (action === 'rotate-180') rotateAllLayers(180);
else if (action === 'flip-h') flipAllLayers('h');
else if (action === 'flip-v') flipAllLayers('v');
});
registerDocClickAway((e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
});
}
}
// ── Filter menu (Blur sub-menu — Gaussian / Zoom) ──
{
const btn = document.getElementById('ge-filter-menu-btn');
const menu = document.getElementById('ge-filter-menu');
if (btn && menu) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
if (willOpen) closeOtherTopbarMenus('ge-filter-menu');
menu.hidden = !menu.hidden;
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('[data-filter-action]');
if (!item) return;
menu.hidden = true;
const action = item.dataset.filterAction;
if (action === 'blur-gaussian') applyGaussianBlur();
else if (action === 'blur-zoom') applyZoomBlur();
});
registerDocClickAway((e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
});
}
}
// ── Resize popup (preset items + Custom… → resizeCustomPrompt) ──
{
const btn = document.getElementById('ge-resize-menu-btn');
const menu = document.getElementById('ge-resize-menu');
if (btn && menu) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
if (willOpen) closeOtherTopbarMenus('ge-resize-menu');
menu.hidden = !menu.hidden;
});
menu.querySelectorAll('[data-resize-w]').forEach(item => {
item.addEventListener('click', () => {
menu.hidden = true;
applyResize(parseInt(item.dataset.resizeW, 10), parseInt(item.dataset.resizeH, 10));
});
});
menu.querySelector('[data-resize-custom]')?.addEventListener('click', () => {
menu.hidden = true;
resizeCustomPrompt();
});
registerDocClickAway((e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
});
}
}
return { applyResize, resizeCustomPrompt };
}

View File

@@ -0,0 +1,49 @@
/**
* Topbar overflow handler — keeps lightweight labels updated and hides
* only low-priority AI model controls when the editor window gets narrow.
*
* Plus the small canvas-size display label updater (since it sits in
* the topbar too).
*
* Import and Canvas stay as real topbar buttons; there is intentionally
* no "More" overflow menu here.
*
* @param {{
* container: HTMLElement,
* registerDocClickAway: (handler: (e: Event) => void) => void,
* }} deps
*/
import { state } from './state.js';
export function wireTopbarOverflow({ container }) {
// Canvas-size badge updater (kept simple — it lives in the topbar).
const sizeLabel = document.getElementById('ge-canvas-size');
function updateSizeLabel() {
if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`;
}
updateSizeLabel();
const topbar = container.querySelector('.ge-topbar');
// The Gen control + its "Gen" label span — collapse as a group when
// narrow. The Inpaint model selector moved into the side panel.
const aiGroup = [
container.querySelector('#ge-ai-model'),
...container.querySelectorAll('.ge-topbar span[style*="font-size:9px"]'),
].filter(Boolean);
function syncOverflow() {
if (!topbar) return;
aiGroup.forEach(el => { el.style.display = ''; });
if (topbar.scrollWidth > topbar.clientWidth) {
// Hide AI group first — bulky and least essential at narrow widths.
aiGroup.forEach(el => { el.style.display = 'none'; });
}
}
if (topbar && window.ResizeObserver) {
const ro = new ResizeObserver(() => syncOverflow());
ro.observe(topbar);
}
// Initial pass after layout settles.
requestAnimationFrame(syncOverflow);
}

View File

@@ -0,0 +1,188 @@
/**
* Topbar wiring — undo/redo/history, Save dropdown, zoom buttons,
* Save/Export/Download/Project, Edge popup, and the cross-dropdown
* coordination (close-others + global outside-click).
*
* #ge-undo / #ge-redo / #ge-history-btn
* #ge-save-menu-btn + #ge-save-menu (Save / Save as / Download /
* Save project / Load project)
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-fit / #ge-zoom-100
* #ge-export-gallery / #ge-download
* #ge-save-project / #ge-load-project
* #ge-edge-menu-btn + #ge-edge-menu (Width input + Feather / Delete
* action buttons)
*
* Dropdown coordination: every menu hides any sibling menu when it
* opens (closeOtherTopbarMenus), and a global outside-click handler
* closes every open menu if the user clicks anywhere outside.
*
* @param {{
* undo: () => void,
* redo: () => void,
* toggleHistoryPanel: () => void,
* fitZoom: () => void,
* applyZoom: () => void,
* exportToGallery: () => void,
* downloadPNG: () => void,
* saveProject: () => void,
* loadProjectPrompt: () => void,
* activeLayer: () => object | null,
* saveState: (label?: string) => void,
* applyEdgeFeather: (layer: object, width: number, hardDelete: boolean) => void,
* composite: () => void,
* registerDocClickAway: (handler: (e: Event) => void) => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
const TOPBAR_MENU_IDS = ['ge-image-menu', 'ge-filter-menu', 'ge-resize-menu', 'ge-save-menu'];
const TOPBAR_TRIGGER_IDS = ['ge-image-menu-btn', 'ge-filter-menu-btn', 'ge-resize-menu-btn', 'ge-save-menu-btn'];
/**
* Close every topbar dropdown except an optional "keep open" one.
* Exported so the Image / Filter / Resize menus (wired elsewhere)
* can call it from their own open handlers.
*/
export function closeOtherTopbarMenus(keepId) {
for (const id of TOPBAR_MENU_IDS) {
if (id === keepId) continue;
const m = document.getElementById(id);
if (m && !m.hidden) m.hidden = true;
}
}
export function wireTopbar(deps) {
const {
undo, redo, toggleHistoryPanel,
fitZoom, applyZoom,
exportToGallery, downloadPNG, saveProject, loadProjectPrompt,
activeLayer, saveState, applyEdgeFeather, composite,
registerDocClickAway, uiModule,
} = deps;
// Undo / Redo / History.
document.getElementById('ge-undo')?.addEventListener('click', undo);
document.getElementById('ge-redo')?.addEventListener('click', redo);
document.getElementById('ge-history-btn')?.addEventListener('click', toggleHistoryPanel);
// Save dropdown — "Save ▾" toggles a small menu (Save / Save-as /
// Download / Save project / Load project). Inner items keep their
// original IDs so the standalone handlers below wire to them
// unchanged.
{
const saveBtn = document.getElementById('ge-save-menu-btn');
const saveMenu = document.getElementById('ge-save-menu');
if (saveBtn && saveMenu) {
const saveTopbar = saveBtn.closest('.ge-topbar');
// Reparent the menu to <body>. Without this, the menu inherits
// the gallery modal's containing block (the modal applies a
// `transform: scale(...)` for its enter animation — and any
// non-`none` transform on an ancestor makes that ancestor the
// containing block for `position: fixed` descendants, even after
// the animation lands on identity). The JS math below assumes
// viewport-relative coords, so without the reparent the menu
// ends up "way off" the button on desktop.
if (saveMenu.parentNode !== document.body) {
document.body.appendChild(saveMenu);
}
const setSaveMenuOpen = (open) => {
saveMenu.hidden = !open;
saveTopbar?.classList.toggle('ge-topbar-menu-open', !!open);
};
const positionSaveMenu = () => {
const r = saveBtn.getBoundingClientRect();
saveMenu.style.top = `${r.bottom + 2}px`;
saveMenu.style.right = `${Math.max(8, window.innerWidth - r.right)}px`;
saveMenu.style.left = 'auto';
};
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = saveMenu.hidden;
setSaveMenuOpen(willOpen);
if (willOpen) positionSaveMenu();
});
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
registerDocClickAway((e) => {
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
setSaveMenuOpen(false);
}
});
}
}
// Zoom buttons.
document.getElementById('ge-zoom-fit')?.addEventListener('click', fitZoom);
document.getElementById('ge-zoom-100')?.addEventListener('click', () => { state.zoom = 1; applyZoom(); });
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
// Export / Download / Project Save / Project Load.
document.getElementById('ge-export-gallery')?.addEventListener('click', exportToGallery);
document.getElementById('ge-download')?.addEventListener('click', downloadPNG);
document.getElementById('ge-save-project')?.addEventListener('click', saveProject);
document.getElementById('ge-load-project')?.addEventListener('click', loadProjectPrompt);
// Global outside-click — closes EVERY editor dropdown when the
// user clicks anywhere that isn't a menu or trigger button. Each
// menu has its own click-away handler too; this is a defence-in-
// depth net for cross-menu clicks / mobile touches that miss the
// individual handlers.
document.addEventListener('pointerdown', (e) => {
for (const id of TOPBAR_MENU_IDS.concat(TOPBAR_TRIGGER_IDS)) {
const el = document.getElementById(id);
if (el && el.contains(e.target)) return;
}
for (const id of TOPBAR_MENU_IDS) {
const m = document.getElementById(id);
if (m && !m.hidden) m.hidden = true;
}
});
// Edge popup — Width input + Feather / Delete action buttons.
function applyEdgeAction(hardDelete) {
const layer = activeLayer();
if (!layer || layer.locked) { uiModule.showToast('Select an unlocked layer'); return; }
const widthInput = document.getElementById('ge-edge-width');
const width = parseInt(widthInput?.value || '8');
if (isNaN(width) || width < 1) { uiModule.showToast('Invalid width'); return; }
saveState();
applyEdgeFeather(layer, width, hardDelete);
composite();
uiModule.showToast(hardDelete ? `Edges deleted ${width}px` : `Edges feathered ${width}px`);
}
{
const btn = document.getElementById('ge-edge-menu-btn');
const menu = document.getElementById('ge-edge-menu');
if (btn && menu) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = menu.hidden;
if (willOpen) closeOtherTopbarMenus('ge-edge-menu');
menu.hidden = !menu.hidden;
if (!menu.hidden) {
// Autofocus the width input so users can type immediately.
setTimeout(() => document.getElementById('ge-edge-width')?.select(), 0);
}
});
document.getElementById('ge-edge-feather')?.addEventListener('click', () => {
menu.hidden = true;
applyEdgeAction(false);
});
document.getElementById('ge-edge-delete')?.addEventListener('click', () => {
menu.hidden = true;
applyEdgeAction(true);
});
document.getElementById('ge-edge-width')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
menu.hidden = true;
applyEdgeAction(false);
}
});
registerDocClickAway((e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) menu.hidden = true;
});
}
}
}

1215
static/js/emailInbox.js Normal file

File diff suppressed because it is too large Load Diff

4659
static/js/emailLibrary.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
// static/js/emailLibrary/signatureFold.js
//
// Heuristics that turn raw HTML email bodies into folded structures —
// "Earlier reply" details collapsing the quoted history, and "Signature"
// details collapsing the trailing corporate disclaimer / boilerplate.
//
// All pure functions of HTML strings (and one DOM-mutating exception:
// `_harvestAttribution` peels nodes off a container). No module state,
// no fetch, no globals. The icons (`_SIG_ICON`, `_QUOTE_ICON`) live here
// since `_foldSummary` is the only caller and other modules pass them in
// via that helper.
import {
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_ORIG_RE,
_SIG_BLOAT_MIN_CHARS,
} from './utils.js';
// No leading icon on the signature fold — the user explicitly does not
// want a star/emoji-style glyph in this header.
export const _SIG_ICON = '';
export const _QUOTE_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
// HTML-escape used by `_extractQuoteMeta`. Inlined here (rather than
// imported from utils) so this module remains free of cross-file links.
function _esc(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// Looks like a signature / corporate disclaimer rather than a quoted email.
// Heuristic: scores known "this is a disclaimer" tells against
// "this is a real email" tells. 3+ disclaimer hits with ≤1 conversational
// hit → signature.
export function _looksLikeSignature(html) {
if (!html) return false;
const txt = String(html).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (!txt) return false;
let score = 0;
const SIG_TELLS = [
/\bregistered\s+in\b/i,
/\blimited\s+liability\s+partnership\b/i,
/\b(Pte\.?\s*Ltd|GmbH|S\.A\.|S\.A\.S|LLC|LLP|Inc\.?)\b/,
/\bintended\s+solely\s+for\b/i,
/\bconfidential(?:ity)?\s+(?:notice|information)\b/i,
/\b(?:disclaimer|please\s+(?:notify|delete))\b/i,
/\bunsubscribe\b/i,
/\bUEN\b\s*\w/i,
/\b\+\d[\d\s().-]{6,}\b/, // phone number
];
for (const re of SIG_TELLS) if (re.test(txt)) score++;
const PRIOR_TELLS = [
/\bHi\s+[A-Z][a-z]+\b/,
/\bDear\s+[A-Z][a-z]+\b/,
/\bRegards\b/i,
/\?\s*$/,
];
let priorScore = 0;
for (const re of PRIOR_TELLS) if (re.test(txt)) priorScore++;
return score >= 3 && priorScore <= 1;
}
// Look for an "On <date>, <addr> wrote:" line at the END of a fragment
// and remove it (returning the captured meta string, or null). Also
// handles Outlook-style "From: ... Sent: ... Subject: ..." blocks.
export function _harvestAttribution(container) {
const text = container.textContent || '';
const wroteLineRe = new RegExp(`${_TALON_WROTE}\\s*:\\s*$|${_TALON_WROTE}\\s*:\\s*<`, 'i');
const lastLines = text.trim().split('\n').slice(-3).join('\n');
if (!wroteLineRe.test(lastLines)) {
const outlookHeadRe = new RegExp(`${_TALON_FROM}\\s*:.*?${_TALON_SENT}\\s*:`, 'is');
if (!outlookHeadRe.test(text.split('\n').slice(-12).join('\n'))) {
if (!_TALON_ORIG_RE.test(text)) return null;
}
}
const trailing = [];
for (let i = container.childNodes.length - 1; i >= 0; i--) {
const node = container.childNodes[i];
const t = (node.textContent || '').trim();
if (!t) { trailing.unshift(node); continue; }
trailing.unshift(node);
if (trailing.map(n => n.textContent || '').join('\n').length > 600) break;
}
const meta = _extractQuoteMeta(trailing.map(n => n.outerHTML || n.textContent || '').join(''));
for (const n of trailing) {
try { container.removeChild(n); } catch {}
}
return meta || null;
}
export function _extractTurnMetaFromBlockquote(bq) {
const html = bq.innerHTML.slice(0, 2000);
const meta = _extractQuoteMeta(html);
return meta || null;
}
// "Earlier reply" / "Signature" summary header — caller supplies the
// label string + icon SVG. `meta`, when present, is split on " · " to
// promote the sender's name to the headline.
export function _foldSummary(label, iconSvg, meta) {
let primary = label;
let subMeta = meta || '';
if (meta) {
const idx = meta.indexOf(' · ');
if (idx > 0) {
primary = meta.slice(0, idx);
subMeta = meta.slice(idx + 3);
} else if (meta.length <= 80 && !/^\d/.test(meta)) {
primary = meta;
subMeta = '';
}
}
const metaSpan = subMeta
? `<span class="email-fold-summary-meta">${subMeta}</span>`
: '';
return (
'<summary class="email-fold-summary">'
+ iconSvg
+ `<span class="email-fold-summary-name">${primary}</span>`
+ metaSpan
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</summary>'
);
}
// Extract sender + date from a quoted email block. Tries Outlook-style
// "From: X · Sent: Y" header first, falls back to Gmail-style
// "On <date>, <addr> wrote:". Returns a display string like
// "Jane Doe · Mon, Apr 18, 2026 at 9:31 AM" or `''`.
export function _extractQuoteMeta(html) {
if (!html) return '';
const txt = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/\s+/g, ' ')
.slice(0, 1500);
const FROM = '(?:From|Från|Von|De|Da|От|Od|Van)';
const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane|Date)';
const STOP = `(?=\\s+(?:To|Cc|Bcc|Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|${SENT})\\s*:)`;
const fromMatch = txt.match(new RegExp(`${FROM}\\s*:\\s*(.+?)${STOP}`, 'i'));
const sentMatch = txt.match(new RegExp(`${SENT}\\s*:\\s*([^\\n]+?)(?=\\s+(?:To|Cc|Bcc|Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)\\s*:)`, 'i'));
let from = fromMatch ? fromMatch[1].trim() : '';
let date = sentMatch ? sentMatch[1].trim() : '';
if (!from && !date) {
const gmail = txt.match(/On\s+([^,]+?,[^,]+?\d{4}[^,]*),?\s+(.+?)\s+wrote\s*:/i);
if (gmail) { date = gmail[1].trim(); from = gmail[2].trim(); }
}
from = from.replace(/[<>]/g, '').replace(/\s+/g, ' ').trim();
date = date.replace(/\s+/g, ' ').trim();
if (from.length > 60) from = from.slice(0, 57) + '…';
if (date.length > 28) date = date.slice(0, 25) + '…';
if (from && date) return `${_esc(from)} · ${_esc(date)}`;
if (from) return _esc(from);
if (date) return _esc(date);
return '';
}
// Peel the first non-empty line off the signature tail. That line is
// usually the signer's name — keep it inline so "Kind regards, / Bob"
// reads naturally. Returns `{ preBloat, bloat }` — `bloat` is what
// should go into the fold; `preBloat` stays visible above it.
export function _peelSigNameLine(htmlAfterClosing) {
if (!htmlAfterClosing) return { preBloat: '', bloat: '' };
const breakRe = /<br\s*\/?>|<\/p>|<\/div>|\n/gi;
let cursor = 0;
let nameConsumed = false;
let mm;
while ((mm = breakRe.exec(htmlAfterClosing)) !== null) {
const seg = htmlAfterClosing.slice(cursor, mm.index)
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ').trim();
if (seg.length > 0) {
const looksBloat = /[@]|tel\.?:|mobile:|phone:|www\.|https?:\/\/|sent from|^\+?\d[\d \-().]{6,}$/i.test(seg);
if (looksBloat) {
return {
preBloat: htmlAfterClosing.slice(0, cursor),
bloat: htmlAfterClosing.slice(cursor),
};
}
if (!nameConsumed) {
nameConsumed = true;
const off = mm.index + mm[0].length;
return {
preBloat: htmlAfterClosing.slice(0, off),
bloat: htmlAfterClosing.slice(off),
};
}
}
cursor = mm.index + mm[0].length;
}
return { preBloat: htmlAfterClosing, bloat: '' };
}
export function _isBloatedSig(htmlFragment) {
if (!htmlFragment) return false;
const plain = htmlFragment
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
return plain.length >= _SIG_BLOAT_MIN_CHARS;
}
// Try folding using a per-sender cached signature (built by the
// `learn_sender_signatures` action). When the cached text is found near
// the end of `html`, slice there and wrap the tail in a details fold.
// Returns the wrapped HTML or null when the hint doesn't apply.
export function _tryFoldHintSig(html, hintSig) {
if (!html || !hintSig || typeof hintSig !== 'string') return null;
if (hintSig.length < 20) return null;
const lines = hintSig.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const closingsRe = /^(?:Best regards|Best wishes|Kind regards|Yours (?:truly|sincerely|faithfully)|Sincerely|Cheers|Thanks|Thank you|Regards|Warm regards|Many thanks|Take care)[,!.\s]*$/i;
const anchor = (lines.find(l => l.length >= 8 && !closingsRe.test(l)) || lines[0] || '').trim();
if (anchor.length < 8) return null;
const plain = [];
const map = [];
let i = 0;
while (i < html.length) {
if (html[i] === '<') {
if (/^<br\s*\/?\s*>/i.test(html.slice(i, i + 6))) {
plain.push('\n'); map.push(i);
const e = html.indexOf('>', i);
i = e + 1;
continue;
}
const e = html.indexOf('>', i);
if (e < 0) break;
i = e + 1;
continue;
}
if (html[i] === '&') {
const semi = html.indexOf(';', i);
if (semi > 0 && semi - i < 8) {
const ent = html.slice(i + 1, semi);
const dec = ({nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"})[ent];
if (dec !== undefined) {
plain.push(dec); map.push(i);
i = semi + 1;
continue;
}
}
}
plain.push(html[i]); map.push(i);
i++;
}
const plainStr = plain.join('');
const idx = plainStr.lastIndexOf(anchor);
if (idx < 0) return null;
const htmlStart = map[idx];
if (htmlStart == null) return null;
const before = html.slice(0, htmlStart);
const sigSection = html.slice(htmlStart);
if (!_isBloatedSig(sigSection)) return null;
return before + '<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON)
+ sigSection + '</details>';
}
// Top-level signature fold — runs through several detection strategies
// in priority order. Returns the original html unchanged when no
// strategy fires.
export function _foldSignature(html, hintSig) {
if (!html || typeof html !== 'string') return html;
if (html.length > 80000) return html;
if (hintSig) {
const wrapped = _tryFoldHintSig(html, hintSig);
if (wrapped !== null) return wrapped;
}
const wrap = (before, marker, rest) => {
if (!_isBloatedSig(rest)) return html;
return before + (marker || '') + '<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON) + rest + '</details>';
};
let m = html.match(/<div[^>]*class=["'][^"']*\bgmail_signature\b[^"']*["'][\s\S]*$/i);
if (m) return wrap(html.slice(0, html.length - m[0].length), '', m[0]);
m = html.match(/<div[^>]*data-smartmail=["']gmail_signature["'][\s\S]*$/i);
if (m) return wrap(html.slice(0, html.length - m[0].length), '', m[0]);
m = html.match(/<div[^>]*id=["'](?:Signature|signature|divRplyFwdMsg)["'][\s\S]*$/i);
if (m) return wrap(html.slice(0, html.length - m[0].length), '', m[0]);
m = html.match(/(<br>|\n)\s*--\s*(<br>|\n)([\s\S]*)$/i);
if (m) {
const idx = html.lastIndexOf(m[0]);
return wrap(html.slice(0, idx), m[1], m[3]);
}
const blockBoundary = '(?:<br\\s*/?>|<\\/p>|<\\/div>|<\\/li>|<p[^>]*>|<div[^>]*>|<span[^>]*>|\\n)';
const closings = '(?:Best regards|Best wishes|Kind regards|Yours truly|Yours sincerely|Yours faithfully|Best,|Best\\s|Cheers,|Cheers\\s|Thanks,|Thanks\\s|Thank you,|Regards,|Regards\\s|Sincerely[, ]|Warm regards|Many thanks|Talk soon|Take care)';
m = html.match(new RegExp(`(${blockBoundary})\\s*(${closings})([\\s\\S]+)$`, 'i'));
if (m) {
const idx = html.lastIndexOf(m[0]);
const boundary = m[1];
const closing = m[2];
const after = m[3];
const { preBloat, bloat } = _peelSigNameLine(after);
if (!_isBloatedSig(bloat)) return html;
return html.slice(0, idx) + boundary + closing + preBloat
+ '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
+ bloat + '</details>';
}
m = html.match(new RegExp(`(${blockBoundary})\\s*((?:Sent from my (?:iPhone|iPad|Android|Galaxy|Pixel|phone|mobile)|Get Outlook for (?:iOS|Android))[\\s\\S]*)$`, 'i'));
if (m) {
const idx = html.lastIndexOf(m[0]);
return wrap(html.slice(0, idx), m[1], m[2]);
}
m = html.match(new RegExp(`(${blockBoundary})\\s*((?:CONFIDENTIALITY NOTICE|DISCLAIMER|This e-?mail (?:is confidential|may contain confidential)|The information (?:contained )?in this e-?mail|This message and any attachments)[\\s\\S]*)$`, 'i'));
if (m) {
const idx = html.lastIndexOf(m[0]);
return wrap(html.slice(0, idx), m[1], m[2]);
}
return html;
}

View File

@@ -0,0 +1,34 @@
// static/js/emailLibrary/state.js
//
// Shared mutable state for the email-library popup. Keeping these on a
// single exported object lets sibling modules (utils, signatureFold,
// future render/menu/composer splits) read and write the same values
// without each one importing 19 `let` bindings — which ES modules
// don't allow from outside the defining module anyway.
//
// Writes look like `state._libOpen = true` everywhere; reads look like
// `state._libOpen`. The names match the originals so the refactor is a
// pure rename, not a semantic change.
export const state = {
_libOpen: false,
_libJustOpened: false,
_libEmails: [],
_libTotal: 0,
_libOffset: 0,
_libFolder: 'INBOX',
_libFolders: [],
_libAccountId: null, // null = backend default account
_libAccounts: [], // list of accounts for the chip strip
_libPendingExpandUid: null,
_libSearch: '',
_libFilter: 'all', // all, unread, unanswered
_libSort: 'recent', // recent, unread, favorites
_libHasAttachments: false,
_libLoading: false,
_docModule: null,
_onEmailClick: null,
_libEscHandler: null,
_selectMode: false,
_selectedUids: new Set(),
};

View File

@@ -0,0 +1,202 @@
// static/js/emailLibrary/utils.js
//
// Pure helpers extracted from emailLibrary.js. No DOM state, no fetch,
// no shared mutable references — safe to import anywhere.
// ── Talon-inspired multilingual quote-detection regexes ───────────
// Borrowed (loosely) from Mailgun's `talon` library. These are partial
// regex source strings — combined with surrounding patterns by callers.
// Multilingual on purpose: a typed "wrote:" line is locale-bound, and
// people forward / reply across language settings all the time.
export const _TALON_WROTE = '(?:wrote|écrit|escribió|scrisse|schrieb|skrev|schreef|napisał|написал|napsal|написа|έγραψε|katselivat|napisao|написав|napisała|napisali|hat geschrieben|kirjoitti|написала|escreveu|napisao|написа|написала)';
export const _TALON_FROM = '(?:From|Från|Von|De|Da|От|Od|Van|差出人|发件人|寄件人|Ut|Frá|Lähettäjä|Avsender|Pošiljatelj|Од|Від|Posiljatelj|Frå)';
export const _TALON_SENT = '(?:Sent|Skickat|Gesendet|Envoy[ée]|Inviato|Enviado|Verzonden|Отправлено|Wysłane|Date|送信日時|发送时间|寄件日期|Sendt|Lähetetty|Tarih|Datum|Data|Datum)';
export const _TALON_SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|件名|主题|主旨|Emne|Aihe|Onderwerp|Konu)';
export const _TALON_TO = '(?:To|Till|An|À|A|Voor|Para|Naar|Кому|Do|宛先|收件人|Emri|Komu)';
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d[']origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
// Minimum plain-text length of a "signature" before we bother folding it.
// Short closings ("Cheers, John") stay inline — folding them would add
// a click for two bytes of saving.
export const _SIG_BLOAT_MIN_CHARS = 200;
// HTML-escape a string by round-tripping through a detached div. Cheap
// and correct (handles all the entities that matter for innerHTML).
export function _esc(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const escaped = _esc(text);
// URLs: http(s)://... or www....
const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
// the email; final fallback to the input string.
export function _extractName(addr) {
const m = addr.match(/^"?([^"<]+?)"?\s*<([^>]+)>\s*$/);
if (m) return m[1].trim();
const localPart = addr.split('@')[0];
return localPart || addr;
}
// Parse the "Author <email> · Date" metadata string emitted by the
// server-side thread parser.
export function _parseTurnMeta(meta) {
if (!meta) return { author: '', email: '', date: '' };
const m = String(meta);
const eMatch = m.match(/<([^<>\s]+@[^<>\s]+)>/) ||
m.match(/\b([\w.+-]+@[\w.-]+\.[A-Za-z]{2,})\b/);
const email = eMatch ? eMatch[1].toLowerCase().trim() : '';
const parts = m.split(/\s+[·•]\s+/);
let author = '', date = '';
if (parts.length >= 2) {
author = parts[0].replace(/<[^>]+>/g, '').trim();
date = parts.slice(1).join(' · ').trim();
} else {
author = m.replace(/<[^>]+>/g, '').trim();
}
return { author, email, date };
}
// Short, locale-aware display string for a chat-bubble timestamp.
// Returns '' for invalid / empty input.
export function _formatBubbleDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (!d || isNaN(d.getTime())) return '';
try {
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
} catch (_) { return ''; }
}
// Format a raw "to" address string ("Foo <foo@x.com>, bar@y.com") into a
// short, readable list — display names when present, just the local part
// of the email otherwise, and ", +N" once there are more than 2 recipients.
export function _formatRecipients(raw) {
if (!raw) return '';
const addrs = String(raw).split(',').map(s => s.trim()).filter(Boolean);
if (!addrs.length) return '';
const friendly = addrs.map(a => {
const m = a.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
if (m && m[1].trim()) return m[1].trim();
const em = a.replace(/[<>]/g, '').trim();
return em.split('@')[0] || em;
});
if (friendly.length === 1) return friendly[0];
if (friendly.length === 2) return friendly.join(', ');
return friendly.slice(0, 2).join(', ') + ' +' + (friendly.length - 2);
}
// Deterministic per-sender colour. Same hashing as
// emailInbox.js#_senderColor so a sender's avatar / name colour matches
// across the list view and the bubble reader.
export function _senderColor(name) {
if (!name) return 'hsl(220, 55%, 65%)';
const key = String(name).toLowerCase();
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
}
const hue = ((hash % 360) + 360) % 360;
return `hsl(${hue}, 55%, 65%)`;
}
// 1- or 2-letter initials for an avatar bubble. Unicode-friendly.
export function _initials(s) {
if (!s) return '?';
const clean = String(s).replace(/<[^>]+>/g, '').replace(/[^\p{L}\s]/gu, ' ').trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (!parts.length) return '?';
const first = parts[0][0] || '';
const last = parts.length > 1 ? parts[parts.length - 1][0] : '';
return (first + last).toUpperCase();
}
// HTML sanitizer for rendering remote email bodies. Strips script/iframe/
// form/style/etc., kills `on*` handlers, blocks `javascript:`/`vbscript:`/
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
// position styles so the theme can take over, and wraps highlight-bearing
// inline tags in <mark> so they render legibly across themes.
export function _sanitizeHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll(
'script, iframe, object, embed, form, style, link, ' +
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
).forEach(el => el.remove());
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const isDangerousUrl = (val) => {
if (!val) return false;
const v = val.trim().toLowerCase();
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
};
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
'font-family', 'font', '-webkit-text-fill-color',
'position', 'z-index'];
const HIGHLIGHT_INLINE_TAGS = new Set(['SPAN', 'FONT', 'EM', 'B', 'I',
'STRONG', 'SMALL', 'U']);
const HAS_BG_COLOR = /background(?:-color)?\s*:\s*(?!\s*(?:transparent|none|inherit|initial)\b)[^;]+/i;
const _markedForHighlight = [];
doc.querySelectorAll('*').forEach(el => {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
el.removeAttribute(attr.name);
continue;
}
}
el.removeAttribute('color');
const bgcolor = el.getAttribute('bgcolor');
el.removeAttribute('bgcolor');
el.removeAttribute('face');
const style = el.getAttribute('style');
const hadHighlight =
HIGHLIGHT_INLINE_TAGS.has(el.tagName) &&
((style && HAS_BG_COLOR.test(style)) || (bgcolor && bgcolor !== 'transparent'));
if (hadHighlight) _markedForHighlight.push(el);
if (style) {
const kept = style.split(';').map(s => s.trim()).filter(decl => {
if (!decl) return false;
const lower = decl.toLowerCase();
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
const prop = decl.split(':', 1)[0].trim().toLowerCase();
return !STRIP_CSS_PROPS.includes(prop);
});
if (kept.length) el.setAttribute('style', kept.join('; '));
else el.removeAttribute('style');
}
if (el.tagName === 'A') {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
}
});
_markedForHighlight.forEach(el => {
if (el.tagName === 'MARK' || !el.firstChild) return;
const mark = doc.createElement('mark');
while (el.firstChild) mark.appendChild(el.firstChild);
el.appendChild(mark);
});
return doc.body.innerHTML;
}

311
static/js/emojiPicker.js Normal file
View File

@@ -0,0 +1,311 @@
/**
* emojiPicker.js — Monochrome icon picker (no colored emojis).
* Curated set of common icons as inline SVGs. The PICKER shows monochrome SVGs,
* and — crucially — every character it INSERTS is one with a real monochrome
* (text) presentation. On insert we append U+FE0E (VARIATION SELECTOR-15) so the
* glyph renders flat/text, not as a system color emoji — so the RECIPIENT of an
* email/message sees a non-colored symbol too, not just the sender. Pure-emoji
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
*/
// Each entry: [char, label, svgPath OR svg]
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
// Text variation selector — appended to chars that might render as color emoji,
// asks the browser to use text (monochrome) presentation if available.
const VS15 = '\uFE0E';
const EMOJI_GROUPS = [
{
name: 'Faces & Hearts',
// Only chars with a genuine monochrome (text) presentation. VS15 is appended
// on insert (see _insertEmoji) so they render flat for the recipient too.
// Pure-emoji faces (grin/cry/sunglasses/thumbs) have no text form, so they're
// omitted — there is no way to send them non-colored as plain text.
items: [
['☻', 'grin', I('<circle cx="12" cy="12" r="10"/><path d="M7 14 C 7 18, 17 18, 17 14 Z"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>')],
['♡', 'heart-outline', I('<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>')],
['★', 'star', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" fill="currentColor" stroke="none"/>')],
['☆', 'star-outline', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['✦', 'sparkle', I('<polygon points="12 2 14 10 22 12 14 14 12 22 10 14 2 12 10 10" fill="currentColor" stroke="none"/>')],
['☽', 'moon', I('<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>')],
],
},
{
name: 'Checks & Marks',
items: [
['✓', 'check', I('<polyline points="20 6 9 17 4 12"/>')],
['✗', 'cross', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['✘', 'cross-heavy', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['★', 'star-filled', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['☆', 'star-empty', I('<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>')],
['●', 'dot', I('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')],
['○', 'circle', I('<circle cx="12" cy="12" r="8"/>')],
['■', 'square-filled', I('<rect x="6" y="6" width="12" height="12" fill="currentColor" stroke="none"/>')],
['□', 'square-empty', I('<rect x="5" y="5" width="14" height="14"/>')],
['◆', 'diamond', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
['◇', 'diamond-empty', I('<polygon points="12 3 21 12 12 21 3 12"/>')],
['†', 'dagger', I('<line x1="12" y1="4" x2="12" y2="20"/><line x1="8" y1="8" x2="16" y2="8"/>')],
],
},
{
name: 'Arrows',
items: [
['→', 'arrow-right', I('<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>')],
['←', 'arrow-left', I('<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>')],
['↑', 'arrow-up', I('<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>')],
['↓', 'arrow-down', I('<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>')],
['⇒', 'arrow-r-dbl', I('<polyline points="10 5 17 12 10 19"/><polyline points="6 5 13 12 6 19"/>')],
['⇐', 'arrow-l-dbl', I('<polyline points="14 5 7 12 14 19"/><polyline points="18 5 11 12 18 19"/>')],
],
},
{
name: 'Math & Punctuation',
items: [
['±', 'plus-minus', I('<line x1="4" y1="10" x2="20" y2="10"/><line x1="12" y1="2" x2="12" y2="18"/><line x1="4" y1="20" x2="20" y2="20"/>')],
['×', 'multiply', I('<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>')],
['÷', 'divide', I('<circle cx="12" cy="6" r="1.5" fill="currentColor" stroke="none"/><line x1="5" y1="12" x2="19" y2="12"/><circle cx="12" cy="18" r="1.5" fill="currentColor" stroke="none"/>')],
['≈', 'approx', I('<path d="M4 9 C 6 6, 8 12, 10 9 S 14 6, 16 9 S 20 12, 22 9"/><path d="M4 15 C 6 12, 8 18, 10 15 S 14 12, 16 15 S 20 18, 22 15"/>')],
['≠', 'not-equal', I('<line x1="5" y1="9" x2="19" y2="9"/><line x1="5" y1="15" x2="19" y2="15"/><line x1="16" y1="5" x2="8" y2="19"/>')],
['≤', 'lte', I('<polyline points="17 5 7 11 17 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
['≥', 'gte', I('<polyline points="7 5 17 11 7 17"/><line x1="7" y1="20" x2="17" y2="20"/>')],
['∞', 'infinity', I('<path d="M18.178 8c5.096 0 5.096 8 0 8-5.095 0-7.133-8-12.739-8-4.585 0-4.585 8 0 8 5.606 0 7.644-8 12.739-8z"/>')],
['π', 'pi', I('<line x1="4" y1="8" x2="20" y2="8"/><line x1="9" y1="8" x2="9" y2="20"/><line x1="15" y1="8" x2="15" y2="20"/>')],
['Σ', 'sum', I('<polyline points="6 4 18 4 10 12 18 20 6 20"/>')],
['∆', 'delta', I('<polygon points="12 4 20 20 4 20"/>')],
['√', 'root', I('<polyline points="4 14 8 20 14 4 22 4"/>')],
['°', 'degree', I('<circle cx="12" cy="8" r="3"/>')],
['§', 'section', I('<path d="M14 6 a4 3 0 1 0 -4 4 q-3 0 -3 3 t3 3 q3 0 3 -3"/>')],
['¶', 'pilcrow', I('<path d="M16 4 H 9 a4 4 0 0 0 0 8 H 12 V 20"/><line x1="16" y1="4" x2="16" y2="20"/>')],
['•', 'bullet', I('<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>')],
['…', 'ellipsis', I('<circle cx="6" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="18" cy="12" r="1.5" fill="currentColor" stroke="none"/>')],
['—', 'em-dash', I('<line x1="4" y1="12" x2="20" y2="12"/>')],
['«', 'quote-l', I('<polyline points="12 5 6 12 12 19"/><polyline points="18 5 12 12 18 19"/>')],
['»', 'quote-r', I('<polyline points="6 5 12 12 6 19"/><polyline points="12 5 18 12 12 19"/>')],
['"', 'quote-dbl', I('<line x1="8" y1="5" x2="8" y2="11"/><line x1="11" y1="5" x2="11" y2="11"/><line x1="13" y1="5" x2="13" y2="11"/><line x1="16" y1="5" x2="16" y2="11"/>')],
],
},
{
name: 'Currency & Misc',
items: [
['€', 'euro', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">€</text>')],
['£', 'pound', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">£</text>')],
['¥', 'yen', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">¥</text>')],
['$', 'dollar', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">$</text>')],
['¢', 'cent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">¢</text>')],
['%', 'percent', I('<text x="12" y="16" font-size="16" text-anchor="middle" fill="currentColor" stroke="none">%</text>')],
['‰', 'per-mille', I('<text x="12" y="16" font-size="13" text-anchor="middle" fill="currentColor" stroke="none">‰</text>')],
['№', 'number', I('<text x="12" y="16" font-size="12" text-anchor="middle" fill="currentColor" stroke="none">№</text>')],
],
},
];
let _pickerEl = null;
let _pickerOpenedAt = 0;
let _targetEl = null;
let _closeOnOutsideClick = null;
let _closeOnEscape = null;
// For contenteditable targets we snapshot the caret/selection when the picker
// opens, since focusing the picker's search box collapses the live selection.
let _savedRange = null;
// `target` may be a textarea element id (string) or a resolver function that
// returns the live target element — the latter lets a caller switch between a
// textarea and a contenteditable (e.g. plain markdown vs. WYSIWYG email).
export function createEmojiButton(target) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-btn';
btn.title = 'Insert icon';
btn.innerHTML = '<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="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>';
// Don't steal focus from the editor on press — keeps the caret/selection so
// the emoji lands where the user was typing.
btn.addEventListener('mousedown', (e) => e.preventDefault());
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const el = (typeof target === 'function') ? target() : document.getElementById(target);
if (!el) return;
togglePicker(btn, el);
});
return btn;
}
function togglePicker(anchor, target) {
const now = Date.now();
if (_pickerEl) {
// Ignore the duplicate/ghost click mobile fires right after opening, which
// would otherwise re-toggle the picker shut the instant it appears.
if (now - _pickerOpenedAt < 400) return;
_closePicker();
return;
}
_targetEl = target;
_savedRange = null;
if (target.isContentEditable) {
const sel = window.getSelection();
if (sel && sel.rangeCount) {
const r = sel.getRangeAt(0);
if (target.contains(r.commonAncestorContainer)) _savedRange = r.cloneRange();
}
}
_pickerEl = _buildPicker();
_pickerOpenedAt = now;
document.body.appendChild(_pickerEl);
const rect = anchor.getBoundingClientRect();
_pickerEl.style.position = 'fixed';
_pickerEl.style.top = (rect.bottom + 4) + 'px';
_pickerEl.style.left = rect.left + 'px';
_pickerEl.style.zIndex = '10000';
requestAnimationFrame(() => {
const pr = _pickerEl.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) {
_pickerEl.style.left = Math.max(8, window.innerWidth - pr.width - 8) + 'px';
}
// Always open downward. If it would run past the bottom, cap its height so
// it scrolls internally instead of flipping up (which got cut off at top).
const avail = window.innerHeight - rect.bottom - 12;
if (pr.height > avail) {
_pickerEl.style.maxHeight = Math.max(160, avail) + 'px';
}
});
const close = (e) => {
// Ignore the ghost/duplicate click mobile fires right after opening.
if (e && e.type === 'click' && Date.now() - _pickerOpenedAt < 400) return;
if (_pickerEl && !_pickerEl.contains(e.target) && e.target !== anchor && !anchor.contains(e.target)) {
_closePicker();
}
};
_closeOnOutsideClick = close;
setTimeout(() => document.addEventListener('click', close, true), 10);
_closeOnEscape = (e) => {
if (e.key !== 'Escape' || !_pickerEl) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
_closePicker();
};
document.addEventListener('keydown', _closeOnEscape, true);
}
function _closePicker() {
if (_pickerEl) {
_pickerEl.remove();
_pickerEl = null;
}
if (_closeOnOutsideClick) {
document.removeEventListener('click', _closeOnOutsideClick, true);
_closeOnOutsideClick = null;
}
if (_closeOnEscape) {
document.removeEventListener('keydown', _closeOnEscape, true);
_closeOnEscape = null;
}
}
function _buildPicker() {
const el = document.createElement('div');
el.className = 'emoji-picker';
const search = document.createElement('input');
search.type = 'text';
search.placeholder = 'Search…';
search.className = 'emoji-picker-search';
el.appendChild(search);
const groupsContainer = document.createElement('div');
groupsContainer.className = 'emoji-picker-groups';
el.appendChild(groupsContainer);
function render(filter = '') {
groupsContainer.innerHTML = '';
const f = filter.toLowerCase();
for (const group of EMOJI_GROUPS) {
const filtered = f
? group.items.filter(item => item[1].toLowerCase().includes(f) || item[0].includes(filter))
: group.items;
if (filtered.length === 0) continue;
const groupDiv = document.createElement('div');
groupDiv.className = 'emoji-picker-group';
const header = document.createElement('div');
header.className = 'emoji-picker-group-name';
header.textContent = group.name;
groupDiv.appendChild(header);
const grid = document.createElement('div');
grid.className = 'emoji-picker-grid';
for (const item of filtered) {
const [char, label, svg] = item;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'emoji-picker-item';
btn.title = label;
btn.innerHTML = svg;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
_insertEmoji(char);
_closePicker();
});
grid.appendChild(btn);
}
groupDiv.appendChild(grid);
groupsContainer.appendChild(groupDiv);
}
}
render();
search.addEventListener('input', () => render(search.value.trim()));
setTimeout(() => search.focus(), 50);
return el;
}
function _insertEmoji(char) {
if (!_targetEl) return;
// Force monochrome (text) presentation for the recipient by appending the
// text variation selector U+FE0E. It only affects chars that *have* an emoji
// presentation (e.g. ♥ ▶ ❤ ↩ ☀); for plain ASCII it's pointless, so we skip
// those. This is why the inserted glyph is non-colored on the other end too,
// not just in our own (already-SVG) picker UI.
const cp = char.codePointAt(0);
const ins = cp >= 0x80 ? char + VS15 : char;
// Contenteditable (e.g. WYSIWYG email body) — insert at the saved caret.
if (_targetEl.isContentEditable) {
_targetEl.focus();
let range = _savedRange;
if (!range) {
range = document.createRange();
range.selectNodeContents(_targetEl);
range.collapse(false);
}
range.deleteContents();
const node = document.createTextNode(ins);
range.insertNode(node);
range.setStartAfter(node);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
_savedRange = range.cloneRange();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
const start = _targetEl.selectionStart || 0;
const end = _targetEl.selectionEnd || 0;
const before = _targetEl.value.substring(0, start);
const after = _targetEl.value.substring(end);
_targetEl.value = before + ins + after;
_targetEl.selectionStart = _targetEl.selectionEnd = start + ins.length;
_targetEl.focus();
_targetEl.dispatchEvent(new Event('input', { bubbles: true }));
}
export default { createEmojiButton };

284
static/js/fileHandler.js Normal file
View File

@@ -0,0 +1,284 @@
// static/js/fileHandler.js
/**
* File attachment and upload handling
*/
import uiModule from './ui.js';
import spinnerModule from './spinner.js';
let pendingFiles = [];
let uploaded = [];
// Holds the full meta (id/name/mime/size/width/height/…) from the most recent
// uploadPending() so callers can stamp width/height onto their attachment
// objects without changing uploadPending()'s return signature.
let _lastUploadedMeta = [];
let API_BASE = '';
let _uploadSpinners = [];
const _previewUrls = new WeakMap();
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
if (!url) {
url = URL.createObjectURL(f);
_previewUrls.set(f, url);
}
return url;
}
function _revokePreviewUrl(f) {
const url = _previewUrls.get(f);
if (url) {
try { URL.revokeObjectURL(url); } catch (_) {}
_previewUrls.delete(f);
}
}
/**
* Initialize with dependencies
*/
export function init(apiBase) {
API_BASE = apiBase;
}
/**
* Open file picker dialog
*/
export function openPicker() {
document.getElementById('file-input').click();
}
const MAX_VISIBLE = 3;
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
let _expanded = false;
/**
* Render the attachment strip with pending files.
* 1-3 files: show individual chips.
* 4+ files: collapse into a single "N files" badge (click to expand).
*/
export function renderAttachStrip() {
const strip = document.getElementById('attach-strip');
while (strip.firstChild) strip.removeChild(strip.firstChild);
if (pendingFiles.length === 0) {
_expanded = false;
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
return;
}
const total = pendingFiles.length;
const collapsed = total > MAX_VISIBLE && !_expanded;
if (collapsed) {
// Single compact badge: "5 files ×"
const badge = document.createElement('div');
badge.className = 'thumb thumb-collapsed';
const label = document.createElement('span');
label.textContent = total + ' file' + (total > 1 ? 's' : '');
label.className = 'thumb-collapsed-label';
badge.appendChild(label);
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
const canExpand = total <= MAX_EXPAND;
badge.style.cursor = canExpand ? 'pointer' : 'default';
badge.addEventListener('click', (e) => {
if (e.target.closest('.thumb-collapsed-x')) return;
if (!canExpand) return; // too many files — don't expand into chips
_expanded = true;
renderAttachStrip();
});
const x = document.createElement('button');
x.className = 'thumb-collapsed-x';
x.textContent = '\u00d7';
x.title = 'Remove all';
x.addEventListener('click', (e) => { e.stopPropagation(); clearPending(); });
badge.appendChild(x);
strip.appendChild(badge);
} else {
// Show individual chips
for (let idx = 0; idx < total; idx++) {
strip.appendChild(_createChip(pendingFiles[idx], idx));
}
}
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
function _createChip(f, idx) {
const chip = document.createElement('div');
chip.className = 'thumb';
const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
if (isImage) {
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = URL.createObjectURL(f);
img.alt = f.name || 'image';
chip.appendChild(img);
} else {
const span = document.createElement('span');
span.textContent = f.name || 'pasted-image';
chip.appendChild(span);
}
const x = document.createElement('button');
x.textContent = '\u00d7';
x.setAttribute('aria-label', 'Remove attachment');
x.addEventListener('click', (e) => { e.stopPropagation(); removePending(idx); });
chip.appendChild(x);
return chip;
}
/**
* Remove a pending file by index
*/
export function removePending(idx) {
_revokePreviewUrl(pendingFiles[idx]);
pendingFiles.splice(idx, 1);
renderAttachStrip();
}
/**
* Upload all pending files to server
*/
export async function uploadPending() {
if (pendingFiles.length === 0) return [];
// The message bubble is shown immediately, but the upload can take a moment —
// dim the chips and overlay a whirlpool so it's clear the files are still
// being sent (and aren't stuck). Cleared in the finally below.
const strip = document.getElementById('attach-strip');
if (strip) {
strip.classList.add('attach-uploading');
// Put a whirlpool ON each attachment chip (image/doc) so the spinner sits on
// the thing being uploaded, not floating over the whole strip.
strip.querySelectorAll('.thumb').forEach(chip => {
try {
const sp = spinnerModule.create('', 'clean', 'whirlpool');
const ov = document.createElement('span');
ov.className = 'thumb-upload-spinner';
ov.appendChild(sp.createElement());
chip.appendChild(ov);
sp.start();
_uploadSpinners.push(sp);
} catch (_) { /* spinner is best-effort */ }
});
}
const fd = new FormData();
pendingFiles.forEach(f => fd.append('files', f, f.name || 'paste.png'));
try {
const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
body: fd
});
const data = await res.json();
uploaded = (data.files || []);
pendingFiles = []; // clear only on success
// Stash the full meta (incl. width/height for images) on the module so
// callers that want it can grab it via getLastUploadedMeta(). Keep the
// returned shape as `ids` for backward-compatibility with existing call sites.
_lastUploadedMeta = uploaded;
return uploaded.map(x => x.id);
} finally {
_uploadSpinners.forEach(sp => { try { sp.stop && sp.stop(); } catch (_) {} });
_uploadSpinners = [];
if (strip) strip.classList.remove('attach-uploading');
// Re-render: empty on success (chips gone), or restored on error so the
// user can retry — and either way the spinners are removed.
renderAttachStrip();
}
}
const MAX_FILES = 10;
/**
* Add files to pending list (capped at MAX_FILES)
*/
export function addFiles(files) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
pendingFiles.push(f);
}
renderAttachStrip();
}
function _showToast(msg) {
if (window.showToast) { window.showToast(msg); return; }
// Fallback inline toast
let t = document.getElementById('_attach-toast');
if (!t) {
t = document.createElement('div');
t.id = '_attach-toast';
t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--red);color:var(--red);padding:6px 14px;border-radius:6px;font-size:13px;z-index:9999;opacity:0;transition:opacity .3s';
document.body.appendChild(t);
}
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2500);
}
/**
* Get pending files count
*/
export function getPendingCount() {
return pendingFiles.length;
}
/**
* Get raw pending File objects (for reading content before upload clears them)
*/
export function getPendingRaw() {
return [...pendingFiles];
}
/**
* Get pending file metadata (name, size, type) for display
*/
export function getPendingInfo() {
return pendingFiles.map(f => {
const isImage = f.type?.startsWith('image/') || /\.(png|jpg|jpeg|gif|webp|svg|bmp)$/i.test(f.name || '');
return {
name: f.name || 'pasted-image',
size: f.size || 0,
mime: f.type || '',
previewUrl: isImage ? _getPreviewUrl(f) : '',
};
});
}
/**
* Clear all pending files
*/
export function clearPending() {
pendingFiles.forEach(_revokePreviewUrl);
pendingFiles = [];
renderAttachStrip();
}
/** Full meta (incl. width/height for images) from the most recent uploadPending(). */
export function getLastUploadedMeta() {
return _lastUploadedMeta;
}
var escapeHtml = uiModule.esc;
const fileHandlerModule = {
init,
openPicker,
renderAttachStrip,
removePending,
uploadPending,
addFiles,
getPendingCount,
getPendingInfo,
getPendingRaw,
clearPending,
getLastUploadedMeta,
};
export default fileHandlerModule;

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