Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent e5c99a5eee
commit 6ee6502010
2050 changed files with 538359 additions and 57745 deletions
+1065 -426
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+866 -461
View File
File diff suppressed because it is too large Load Diff
+215 -102
View File
@@ -1,116 +1,229 @@
# Module Organization Summary
# Frontend Module Organization Summary
## Purpose
This document describes what each JavaScript module is responsible for.
> **Scope:** This document describes the architecture of the Odysseus no-build
> frontend. The app is a collection of native ES6 modules loaded from
> `static/`. The authoritative source is the current `static/js/` tree and the
> top-level orchestrator `static/app.js`.
---
## Core Modules (in static/js/)
## 1. Top-level Application Orchestrator
### 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
### `static/app.js`
*Main application entry point.*
### 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`)
- Imports all feature modules.
- Exposes a few modules on `window` for legacy inter-module reachability
(`themeModule`, `sessionModule`, `uiModule`, `adminModule`, `cookbookModule`).
- Patches `fetch` so any `401` redirects the user to `/login`.
- Fetches the default chat configuration and handles deep-link route openers
(`/notes`, `/calendar`, `/email`, `/memory`, `/gallery`, `/cookbook`, `/library`, `/tasks`).
- Wires global event listeners: chat-history scrolling, popups, Escape handling,
drag-and-drop/paste attachment handling, transcription export, sidebar toggles,
rail/tool buttons, and session sorting.
- Loads auth status and applies per-user privilege restrictions.
### 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
### `static/index.html`
*SPA shell.* Loads `app.js` as a module, includes the theme-aware inline script,
and defines the DOM skeleton that the modules populate (chat history, composer,
sidebar, icon rail, modals).
---
## Main Application File
## 2. Core Foundation Modules
### **app.js**
- Application initialization
- Event listener setup
- Drag & drop handlers
- Keyboard shortcuts
- Module initialization
- Global configuration (API_BASE)
- Coordinates all modules together
These are imported first and used across most features.
| Module | Primary Exports | Responsibility |
|---|---|---|
| **`ui.js`** | `showToast`, `showError`, `el`, `copyToClipboard`, `scrollHistory`, `setAutoScroll`, `autoResize`, `debounce`, `esc` | Shared UI helpers, toast notifications, scroll behavior, element accessor, text escaping. |
| **`storage.js`** | `default` storage wrapper | LocalStorage helpers and toggle state persistence. |
| **`markdown.js`** | `mdToHtml`, `processWithThinking`, `squashOutsideCode`, `normalizeThinkingMarkup`, `extractThinkingBlocks`, `hasUnclosedThinkTag`, `startsWithReasoningPrefix` | Markdown→HTML, thinking/reasoning block parsing, code-block normalization. |
| **`spinner.js`** | `create`, `createWhirlpool` | Loading/spinner factories for streaming and tool cards. |
| **`keyboard-shortcuts.js`** | `initKeyboardShortcuts` | Global keyboard shortcut wiring. |
| **`sidebar-layout.js`** | `initSidebarLayout`, `syncRailSide` | Wide sidebar ↔ icon-rail layout behavior. |
| **`section-management.js`** | `initSectionCollapse`, `initSectionDrag` | Collapsible/draggable sidebar sections. |
| **`modalManager.js`** | side-effect import | Unified minimize/restore behavior for floating tool modals. |
| **`tileManager.js`** | side-effect import | Desktop window tiling and snap-to-edge behavior. |
| **`windowDrag.js`** | `makeWindowDraggable` | Drag support for floating panels. |
| **`modalSnap.js`**, **`toolWindowZOrder.js`**, **`windowResize.js`** | — | Modal snapping, z-index management, resize handles. |
---
## 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 -->
## 3. Chat Pipeline
The largest and most central subsystem. Chat submission → backend SSE → progressive rendering of text, tools, research, documents, and UI events.
| Module | Responsibility |
|---|---|
| **`chat.js`** | Main chat controller. Handles `handleChatSubmit`, stops/continues, builds `FormData`, posts to `/api/chat_stream`, reads the SSE stream, and dispatches each JSON event to the appropriate renderer. Tracks background streams, stalls, auto-recovery, and multi-round agent state. |
| **`chatStream.js`** | Helpers shared between streaming consumers: browser notifications, background-stream completion toasts, and `ui_control` event handling. |
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
| **`liveThinkingThrottle.js`** | Trailing-edge coalescer for the live thinking block in `chat.js`: one DOM commit per 100 ms carrying the latest reasoning text, with `flush`/`cancel` for terminal and session-switch paths. |
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
| **`assistant.js`** | Assistant/persona behaviors and message styling helpers. |
| **`tts-ai.js`** | AI text-to-speech manager, enqueueing, streaming TTS, and playback button injection. |
| **`voiceRecorder.js`** | Voice recording from the composer microphone. |
| **`fileHandler.js`** | Attachment picker, paste/drop handling, upload, attachment strip rendering, pending-file management. |
| **`codeRunner.js`** | Client-side execution affordances for code blocks returned by the model. |
---
## 4. Model, Endpoint, and Configuration Modules
| Module | Responsibility |
|---|---|
| **`models.js`** | Model discovery / scanning, local model port probing, provider management, model selection UI state. |
| **`modelPicker.js`** | Composer model-picker dropdown and endpoint selection. |
| **`modelSort.js`** | Sorting helpers for model lists. |
| **`model/matchKey.js`** | Model-to-key matching helper. |
| **`providers.js`** | Provider metadata and account-management helpers. |
| **`providerDeviceFlow.js`** | OAuth device-flow support for providers. |
| **`presets.js`** | Character/preset selection, custom preset saving, inject prefix/suffix handling. |
| **`search.js`** | Web-search settings, provider selection, API key management. |
| **`settings.js`** | Settings panel (models, search, appearance, users, MCP, RAG, embedding, tokens). |
| **`admin.js`** | Admin panel and privileged user/endpoint configuration. |
| **`theme.js`** | Theme presets, custom colors, fonts, backgrounds, live theme switching. |
---
## 5. Session, Sidebar, and Workspace
| Module | Responsibility |
|---|---|
| **`sessions.js`** | Chat session list loading, creation, switching, renaming, archiving, library modal, and direct-chat creation. Tracks current session, streaming/research indicators in the sidebar. |
| **`workspace.js`** | Workspace folder path management for shell/file tool confinement. |
| **`search-chat.js`** | In-chat history search. |
| **`skills.js`** | Client-side skill library UI (load, edit, delete, test, and audit status display). |
---
## 6. Knowledge, Memory, and RAG
| Module | Responsibility |
|---|---|
| **`memory.js`** | AI memory CRUD, search/filter UI, memory extraction, count badge. |
| **`rag.js`** | Personal document RAG: load documents, add directories/files, show included paths. |
| **`group.js`** | Group-chat UI and model orchestration. |
---
## 7. Document and Editor Subsystems
| Module | Responsibility |
|---|---|
| **`document.js`** | Tabbed document editor, AI edit suggestions, Markdown/HTML/CSV editing, document streaming (`streamDocOpen`/`streamDocDelta`), and panel state. |
| **`documentLibrary.js`** | Document library modal. |
| **`editor/`** | Gallery image editor canvas modules: layers, brush, inpaint, crop, filters, state, history panel, top-bar wiring, canvas coordinate helpers, and AI model runners for inpainting/background-removal. |
---
## 8. Research UI
| Module | Responsibility |
|---|---|
| **`research/panel.js`** | Research panel UI, job list, and controls. |
| **`research/jobs.js`** | Research job polling and status rendering. |
| **`researchSynapse.js`** | Animated research-progress visualization shown inside the chat bubble during a research run. |
---
## 9. Gallery, Email, Calendar, Tasks, and Notes
| Module | Responsibility |
|---|---|
| **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. |
| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. |
| **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. |
| **`tasks.js`** | Scheduled task/recurring LLM job UI. |
| **`notes.js`** | Notes and todo panel, reminders, pinboard. |
---
## 10. Cookbook (Model Serving)
| Module | Responsibility |
|---|---|
| **`cookbook.js`** | Cookbook main UI: hardware fitting, presets, action panels. |
| **`cookbook-hwfit.js`** / **`cookbook-diagnosis.js`** / **`cookbook-deps-recipes.js`** | Hardware-fit scoring, dependency diagnosis, recipe handling. |
| **`cookbookDownload.js`** / **`cookbookServe.js`** / **`cookbookRunning.js`** / **`cookbookSchedule.js`** / **`cookbookPorts.js`** / **`cookbookProgressSignal.js`** | Model download/serve flow, running job cards, scheduling, port detection, and progress computation. |
---
## 11. Compare and Utility Modules
| Module | Responsibility |
|---|---|
| **`compare/index.js`** (with `compare/state.js`, `compare/stream.js`, `compare/panes.js`, `compare/selector.js`, `compare/scoreboard.js`, `compare/probe.js`, `compare/vote.js`, `compare/icons.js`) | Model compare mode: parallel streams, panes, scoring, vote UI. |
| **`censor.js`** | Text/image censor overlay toggles. |
| **`a11y.js`** | Accessibility helpers. |
| **`platform.js`** | Platform detection (macOS/Windows/Linux) and keyboard-modifier helpers. |
| **`escMenuStack.js`** | Stack manager for dismissible popups. |
| **`dragSort.js`** | Drag-to-sort shared behavior. |
| **`tourHints.js`** / **`tourAutoplay.js`** | Onboarding tour helpers. |
| **`color/hex.js`**, **`colorPicker.js`**, **`langIcons.js`**, **`util/ordinal.js`** | Small utility modules for color, language icons, and formatting. |
---
## 12. Frontend Event Streaming Flow
```
User submits composer
└── chat.js::handleChatSubmit() builds FormData
├── fileHandler.uploadPending() for attachments
├── document.js saved (if a document panel is open)
└── POST /api/chat_stream
Server responds with SSE stream
└── chat.js reads chunks via res.body.getReader() + TextDecoder
├── Lines starting with "event:" set next-error state
└── Lines starting with "data:" carry JSON payloads
JSON events are dispatched by "type":
delta → streamingRenderer → markdown → live reply text
agent_prep → update spinner label
tool_start → finalize text bubble; create agent-thread node with wave animation
tool_progress → append/update live stdout/stderr tail
tool_output → mark node done/failed, render output, diffs, screenshots
agent_step → finalize tool thread; create new msg-continuation bubble
doc_stream_open → document.js opens a live document
doc_stream_delta → document.js appends content to that document
research_progress → researchSynapse visualization + spinner timer
research_sources → build sources box for research
research_done → reload session history to show the report
web_sources → build web-search sources box
model_info → update role header with requested/actual model
fallback → show fallback model toast + update role label
metrics → collect/display token/cost metrics
message_saved → store database id on the message element
budget_exceeded → show budget banner
rounds_exhausted → show Continue button for step-limit hits
teacher_takeover → insert escalation banner, reset round state
skill_saved → show skill-learned banner
```
Foreground vs background streams:
- If the user switches sessions while a stream is running, `chat.js` pauses DOM
updates and stores the state in `_backgroundStreams`. Completion is signaled
with a sidebar dot/notifications, and the history is reloaded when the user
returns.
---
## 13. What Changed from the Previous Summary
- The frontend is now exclusively ES6-module based; the old `<script>` tag load
order is no longer authoritative.
- `chat.js` is the streaming controller, but message rendering has been split
into `chatRenderer.js`, `streamingRenderer.js`, `chatStream.js`, and `researchSynapse.js`.
- New major subsystems added since the original summary: compare mode
(`compare/`), document editor streaming (`document.js`), research UI
(`research/`), model cookbook (`cookbook*.js`), group chat (`group.js`),
voice/TTS (`voiceRecorder.js`, `tts-ai.js`), skill UI (`skills.js`), and
slash autocomplete (`slashAutocomplete.js`).
- `sessions.js` now owns sidebar session state, streaming/research indicators,
and the library/archive modals.
+165
View File
@@ -0,0 +1,165 @@
// Accessibility enhancements for keyboard + screen-reader users.
//
// Several primary controls in Odysseus are authored as click-only <div>s
// (most notably the whole sidebar navigation: New Chat, Search, Brain,
// Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
// Tasks, Theme, plus the account row). <div>s are not in the tab order and
// are not announced as buttons, so keyboard and screen-reader users cannot
// reach or operate them.
//
// This module enhances those rows in place — making them focusable
// (tabindex=0), announcing them as buttons when it's safe to do so, and
// activating them with Enter / Space — without changing how they look or
// how they behave for mouse users. The visible focus ring already exists in
// style.css (`.list-item:focus-visible`); it simply never fired because the
// rows were never focusable.
(function () {
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
// (axe "nested-interactive") and confuses screen readers. Such rows still
// become focusable + Enter/Space-activatable, just without the role.
var NESTED_INTERACTIVE =
'a[href],button,input,select,textarea,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
function enhanceRow(el) {
if (!el || el.nodeType !== 1 || el.dataset.a11yEnhanced === '1') return;
var tag = el.tagName;
// Leave genuine native controls alone.
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
tag === 'SELECT' || tag === 'TEXTAREA') return;
el.dataset.a11yEnhanced = '1';
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
el.setAttribute('data-a11y-activatable', '1');
if (!el.querySelector(NESTED_INTERACTIVE) && !el.hasAttribute('role')) {
el.setAttribute('role', 'button');
}
// Guarantee an accessible name. Visible text normally supplies it; fall
// back to the title attribute for icon-only rows.
if (!el.getAttribute('aria-label') &&
!(el.textContent || '').trim() &&
el.getAttribute('title')) {
el.setAttribute('aria-label', el.getAttribute('title'));
}
}
function enhanceAll(root) {
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
// exempts their content from the "all content in landmarks" rule. We also
// normalize the modal title to heading level 2 (one below the page <h1>)
// so heading order stays valid no matter which tag the markup uses.
var titleSeq = 0;
// Each modal "kind" is a container selector plus where to find its title
// heading. Standard modals use .modal-content/.modal-header; the docked
// Notes pane uses its own markup.
var MODAL_KINDS = [
{
sel: '.modal-content',
heading: '.modal-header h1, .modal-header h2, .modal-header h3, ' +
'.modal-header h4, .modal-header h5, .modal-header h6'
},
{ sel: '.notes-pane', heading: '.notes-pane-title' }
];
var MODAL_SEL = MODAL_KINDS.map(function (k) { return k.sel; }).join(',');
function enhanceModal(mc, headingSel) {
if (!mc || mc.nodeType !== 1 || mc.dataset.a11yDialog === '1') return;
mc.dataset.a11yDialog = '1';
if (!mc.hasAttribute('role')) mc.setAttribute('role', 'dialog');
if (!mc.hasAttribute('aria-modal')) mc.setAttribute('aria-modal', 'true');
var heading = headingSel && mc.querySelector(headingSel);
if (heading) {
if (!heading.id) heading.id = 'a11y-modal-title-' + (++titleSeq);
if (!mc.hasAttribute('aria-labelledby')) {
mc.setAttribute('aria-labelledby', heading.id);
}
// Modal titles sit one level below the page <h1>; normalize so heading
// order stays valid regardless of the tag the markup happens to use.
if (!heading.hasAttribute('aria-level')) heading.setAttribute('aria-level', '2');
}
}
function enhanceModals(root) {
var scope = root || document;
MODAL_KINDS.forEach(function (k) {
scope.querySelectorAll(k.sel).forEach(function (mc) { enhanceModal(mc, k.heading); });
});
}
function headingSelFor(el) {
for (var i = 0; i < MODAL_KINDS.length; i++) {
if (el.matches(MODAL_KINDS[i].sel)) return MODAL_KINDS[i].heading;
}
return null;
}
// Delegated keyboard activation. We only act when the focused element is
// itself an enhanced row (keydown targets the focused element), so a press
// on a nested native button is left to the browser's own handling.
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var el = e.target;
if (!el || !el.matches || !el.matches('[data-a11y-activatable]')) return;
e.preventDefault(); // Space would otherwise scroll the page
el.click();
});
function init() {
enhanceAll(document);
enhanceModals(document);
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
var sidebar = document.getElementById('sidebar');
if (sidebar && 'MutationObserver' in window) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.matches && n.matches(ROW_SELECTOR)) enhanceRow(n);
if (n.querySelectorAll) enhanceAll(n);
}
}
}).observe(sidebar, { childList: true, subtree: true });
}
// Some modals (Notes, Tasks, …) are injected at runtime, usually as
// direct children of <body>. Catch those without paying for a deep
// subtree observer over the whole document.
if ('MutationObserver' in window) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.matches && n.matches(MODAL_SEL)) enhanceModal(n, headingSelFor(n));
if (n.querySelector && n.querySelector(MODAL_SEL)) enhanceModals(n);
}
}
}).observe(document.body, { childList: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+29
View File
@@ -0,0 +1,29 @@
/** Stable semantic ordering for item-level action menus. */
const COMMON_ACTION_ORDER = [
{ rank: 200, pattern: /^(rename|change name)\b/i },
{ rank: 650, pattern: /^select\b/i },
{ rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i },
{ rank: 500, pattern: /^(copy|clone|duplicate)\b/i },
{ rank: 550, pattern: /^(export|download)\b/i },
{ rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i },
{ rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i },
{ rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i },
{ rank: 1000, pattern: /^cancel\b/i },
];
// Canonical Select glyph for item-level menus. Toolbar toggles may use the
// smaller version, but dropdown rows should all use this exact SVG.
export const SELECT_MENU_ICON = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
export function actionMenuRank(item) {
if (Number.isFinite(item?.menuOrder)) return item.menuOrder;
const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim();
return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100;
}
export function orderActionMenuItems(items) {
return items
.map((item, index) => ({ item, index, rank: actionMenuRank(item) }))
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map(entry => entry.item);
}
+1854 -186
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
// static/js/appConfig.js
//
// One shared, invalidatable cache for the two config endpoints that every
// module wants at startup.
//
// Before this, /api/auth/settings was fetched independently by six modules and
// /api/tools by three, none of them aware of the others — 4 and 3 requests on a
// single cold load. Worse than the requests: each caller could observe a
// different snapshot of the same object, and chatRenderer.js is imported under
// three different ?v= query strings, so it is three separate module instances
// each issuing its own /api/tools fetch. Caching here fixes both, because the
// cache lives in one module every instance imports by the same specifier.
//
// URLs are bare paths on purpose. The callers that used `${API_BASE}/api/...`
// resolved to the identical URL — API_BASE is `window.location.origin`
// (app.js) — so nothing about the request changes for them.
//
// WRITERS MUST INVALIDATE. Anything that POSTs /api/auth/settings calls
// invalidateSettings(); anything that POSTs /api/tools calls invalidateTools()
// *and* invalidateSettings(), because that route persists `disabled_tools`
// into the same settings store (routes/model_routes.py). Miss one and the UI
// serves a stale settings object for the rest of the session, which is worse
// than the duplicate fetches this replaces.
//
// The resolved object is shared by reference, so treat it as read-only: copy
// before mutating (`{ ...await getSettings() }`).
// Written by login.html immediately before it redirects to '/', so the first
// load after a login can skip the request entirely. Consumed once per page
// load, by whichever module asks for settings first.
const PREFETCH_KEY = 'ody-prefetch-settings';
const _URLS = { settings: '/api/auth/settings', tools: '/api/tools' };
const _cache = { settings: null, tools: null };
function _readPrefetchedSettings() {
try {
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return null;
sessionStorage.removeItem(PREFETCH_KEY);
return JSON.parse(raw);
} catch (_) {
return null;
}
}
// A rejected promise must not stay in the slot. Plain `??=` memoisation would
// keep it, so one transient blip during boot would leave keybinds, TTS and the
// search provider on their defaults for the whole session with no retry. Clear
// the slot on failure — unless a later invalidate/refetch already replaced it —
// and rethrow, so every caller's existing .catch() still runs exactly as before.
function _get(key) {
if (_cache[key]) return _cache[key];
const pending = fetch(_URLS[key], { credentials: 'same-origin' })
.then(r => r.json())
.catch(err => {
if (_cache[key] === pending) _cache[key] = null;
throw err;
});
_cache[key] = pending;
return pending;
}
/** GET /api/auth/settings, once per page load (or once per invalidation). */
export function getSettings() {
if (!_cache.settings) {
const prefetched = _readPrefetchedSettings();
if (prefetched) _cache.settings = Promise.resolve(prefetched);
}
return _get('settings');
}
/** GET /api/tools, once per page load (or once per invalidation). */
export function getTools() {
return _get('tools');
}
/** Call after any write that can change settings. */
export function invalidateSettings() {
_cache.settings = null;
}
/** Call after any write that can change the tool enable/disable state. */
export function invalidateTools() {
_cache.tools = null;
}
+9 -9
View File
@@ -5,8 +5,9 @@
// singleton via /api/assistant/session and hands it to selectSession() so we
// reuse the full existing chat render path.
import uiModule from './ui.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import { selectSession } from './sessions.js';
import { sortModelIds } from './modelSort.js';
const API = '/api/assistant';
@@ -119,12 +120,12 @@ function _esc(s) {
// Tool groups for the tool selector UI
const TOOL_GROUPS = {
'Email': ['list_emails', 'read_email', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'],
'Email': ['list_emails', 'read_email', 'download_attachment', 'send_email', 'reply_to_email', 'archive_email', 'delete_email', 'mark_email_read'],
'Calendar & Notes': ['manage_calendar', 'manage_notes', 'manage_tasks'],
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
'Code': ['bash', 'python', 'write_file'],
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
'AI & Models': ['chat_with_model', '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'],
};
@@ -179,7 +180,7 @@ function _renderSettingsBody(body, data, tzList) {
<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>
<option value="">-- pick from persona --</option>
</select>
</span>
<textarea id="assistant-personality" rows="6" placeholder="Describe the assistant's personality, tone, and behavior...">${_esc(crew.personality || '')}</textarea>
@@ -250,9 +251,8 @@ function _renderSettingsBody(body, data, tzList) {
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 modelIds = (models.models || models || []).map(m => typeof m === 'string' ? m : (m.id || m.name || '')).filter(Boolean);
for (const mid of sortModelIds(modelIds)) {
const sel = mid === crew.model ? ' selected' : '';
mHTML += `<option value="${_esc(mid)}"${sel}>${_esc(mid.split('/').pop())}</option>`;
}
@@ -293,7 +293,7 @@ function _renderSettingsBody(body, data, tzList) {
allPresets.push(...presetsRaw);
}
const allTemplates = Array.isArray(templates) ? templates : [];
let opts = '<option value="">-- pick from character --</option>';
let opts = '<option value="">-- pick from persona --</option>';
if (allPresets.length) {
opts += '<optgroup label="Presets">';
for (const p of allPresets) {
@@ -304,7 +304,7 @@ function _renderSettingsBody(body, data, tzList) {
opts += '</optgroup>';
}
if (allTemplates.length) {
opts += '<optgroup label="Characters">';
opts += '<optgroup label="Personas">';
for (const t of allTemplates) {
if (!t.system_prompt && !t.personality) continue;
const name = t.character_name || t.name || 'Unnamed';
+117
View File
@@ -0,0 +1,117 @@
/** Reconcile server-delivered background replies without resetting chat DOM. */
import { createWhirlpool } from './spinner.js';
const researchSpinners = new WeakMap();
function stopResearchSpinner(node) {
researchSpinners.get(node)?.destroy();
researchSpinners.delete(node);
node.querySelector('[data-research-spinner]')?.replaceChildren();
}
export function deliveryMessages(jobs, existingIds) {
const seen = new Set(existingIds);
return jobs.flatMap(job => {
const message = job.status === 'delivered' && job.message;
const id = message?.metadata?._db_id;
if (!id || seen.has(id)) return [];
seen.add(id);
return [message];
});
}
export function researchCardState(job) {
if (job.outcome === 'error') return { tone: 'error', label: 'Research failed', detail: 'Open research for details.' };
if (job.outcome === 'no_sources') return { tone: 'error', label: 'No sources found', detail: 'This run did not return source-backed findings.' };
if (job.status === 'delivered') return { tone: 'done', label: 'Research finished', detail: `${job.source_count ?? '—'} sources · See the update in chat.` };
if (job.status === 'ready') return { tone: 'running', label: 'Preparing chat update', detail: 'The report is ready. You can keep chatting.' };
const p = job.progress || {};
const phase = { probing: 'Checking model', planning: 'Planning', searching: 'Searching', reading: 'Reading sources', analyzing: 'Analyzing findings', writing: 'Writing report' }[p.phase] || 'Starting research';
const round = p.round ? `Round ${p.round}${job.rounds ? `/${job.rounds}` : ''} · ` : '';
return { tone: 'running', label: phase, detail: `${round}${p.total_sources ?? 0} sources · You can keep chatting.` };
}
export function renderResearchCards(box, jobs) {
const visible = jobs.filter(j => j.tool === 'research' && /^[A-Za-z0-9_-]+$/.test(j.id) && j.status !== 'discarded');
let region = box.querySelector('[data-background-tools-status]');
if (!visible.length) {
if (region) for (const card of region.children) stopResearchSpinner(card);
region?.remove(); return;
}
if (!region) {
region = document.createElement('section');
region.dataset.backgroundToolsStatus = '';
region.className = 'background-tools-status agent-thread has-top';
region.setAttribute('aria-label', 'Chat research');
box.append(region);
}
const keep = new Set(visible.map(job => job.id));
for (const card of Array.from(region.children)) if (!keep.has(card.dataset.jobId)) {
stopResearchSpinner(card);
card.remove();
}
for (const job of visible) {
let card = Array.from(region.children).find(node => node.dataset.jobId === job.id);
if (!card) {
card = document.createElement('article');
card.dataset.jobId = job.id;
// Only constant markup; model-authored topics are assigned as text below.
card.innerHTML = '<div class="agent-thread-dot" aria-hidden="true"></div><button type="button" class="agent-thread-header" aria-expanded="false"><span class="agent-thread-icon" aria-hidden="true"></span><span class="agent-thread-tool">Research</span><span class="agent-thread-status" data-stage role="status"></span><span class="chat-research-background"><span>BG task</span><span data-research-spinner aria-hidden="true"></span></span><span class="agent-thread-chevron" aria-hidden="true"></span></button><div class="agent-thread-content"><div class="research-job-query"></div><div class="chat-research-detail"></div><a class="chat-research-open">Open research</a></div>';
const header = card.querySelector('.agent-thread-header');
const content = card.querySelector('.agent-thread-content');
content.id = `chat-research-details-${job.id}`;
header.setAttribute('aria-controls', content.id);
header.addEventListener('click', event => {
// Own this disclosure; do not also trigger the chat's delegated toggle.
event.stopPropagation();
header.setAttribute('aria-expanded', String(card.classList.toggle('open')));
});
region.append(card);
}
const state = researchCardState(job);
card.className = `agent-thread-node chat-research-card ${state.tone}${card.classList.contains('open') ? ' open' : ''}`;
const setText = (selector, text) => {
const node = card.querySelector(selector);
if (node.textContent !== text) node.textContent = text;
};
setText('.research-job-query', job.query || 'Research');
setText('[data-stage]', state.label);
setText('.agent-thread-icon', state.tone === 'error' ? '✗' : state.tone === 'done' ? '✓' : '·');
setText('.chat-research-detail', state.detail);
if (state.tone === 'running' && !researchSpinners.has(card)) {
const spinner = createWhirlpool(16);
card.querySelector('[data-research-spinner]').append(spinner.element);
researchSpinners.set(card, spinner);
} else if (state.tone !== 'running') stopResearchSpinner(card);
card.querySelector('a').href = `#research-${job.id}`;
}
}
export function startBackgroundToolJobs({ getSessionId, addMessage, base = '' }) {
let inFlight = false;
const busy = () => Boolean(document.querySelector('#chat-history .msg-ai.streaming'));
const tick = async () => {
const sid = getSessionId();
if (inFlight || !sid || document.hidden || window.__odysseusSessionReadyId !== sid) return;
inFlight = true;
try {
const response = await fetch(`${base}/api/research/chat-jobs/${encodeURIComponent(sid)}`, { credentials: 'same-origin' });
if (!response.ok) return;
const { jobs = [] } = await response.json();
if (getSessionId() !== sid || window.__odysseusSessionReadyId !== sid) return;
const box = document.querySelector('#chat-history');
if (!box) return;
renderResearchCards(box, jobs);
if (busy()) return;
const ids = Array.from(box.querySelectorAll('[data-db-id]'), node => node.dataset.dbId);
for (const message of deliveryMessages(jobs, ids)) {
addMessage(message.role, message.content, message.metadata?.model, message.metadata);
}
} catch { /* A transient poll error must not disrupt foreground chat. */ }
finally { inFlight = false; }
};
const timer = setInterval(tick, 3000);
const onVisible = () => { if (!document.hidden) void tick(); };
document.addEventListener('visibilitychange', onVisible);
void tick();
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); };
}
+1497 -253
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@
// `start()` kicks off the poll loop + permission request. Call once from
// the calendar's entry module.
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
const API_BASE = window.location.origin;
+57 -3
View File
@@ -3,7 +3,9 @@
// 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 WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export const WEEKDAYS_SUN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
@@ -63,17 +65,65 @@ export function _calBgImageUrl(c) {
return _isCalBgImage(c) ? c.slice(3) : '';
}
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
// add for `'` and let the value break out of the string (CodeQL
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
export function _cssUrlEscape(s) {
return String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '%22');
}
// 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 u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
}
return c || fallback || 'var(--accent)';
}
function _hexToRgb(c) {
if (typeof c !== 'string') return null;
const m = c.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return null;
const hex = m[1].length === 3
? m[1].split('').map(ch => ch + ch).join('')
: m[1];
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
function _relativeLuminance({ r, g, b }) {
return [r, g, b].map(v => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
}
function _contrastRatio(a, b) {
const light = Math.max(a, b);
const dark = Math.min(a, b);
return (light + 0.05) / (dark + 0.05);
}
export function _calReadableTextColor(bg) {
const rgb = _hexToRgb(bg);
if (!rgb) return 'var(--fg)';
const lum = _relativeLuminance(rgb);
const white = _contrastRatio(lum, 1);
const ink = _contrastRatio(lum, 0.006);
return ink >= white ? '#111820' : '#ffffff';
}
// ── date helpers ──
// `YYYY-MM-DD` string from a Date.
@@ -82,13 +132,17 @@ export function _ds(d) {
}
export function _addDays(dateStr, n) {
if (typeof dateStr !== 'string' || !dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
if (isNaN(d)) return '';
d.setDate(d.getDate() + n);
return _ds(d);
}
export function _shiftDT(iso, days) {
if (typeof iso !== 'string' || !iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
d.setDate(d.getDate() + days);
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
}
@@ -111,7 +165,7 @@ export function _tzOffset() {
// 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 (typeof isoStr !== 'string' || !isoStr) return '';
if (isoStr.length === 10) return isoStr;
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
const d = new Date(isoStr);
+7 -1
View File
@@ -8,7 +8,13 @@
let _enabled = true;
let _observer = null;
const PREF_KEY = 'odysseus-sensitive-blur';
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
export const _prefEnabled = () => {
try {
return localStorage.getItem(PREF_KEY) === 'on';
} catch (_) {
return false;
}
};
// Patterns that indicate sensitive data
const PATTERNS = [
+4249 -813
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/** Select and update the response holder for a route-provenance event. */
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
const target = event && event.round && roundHolder ? roundHolder : holder;
if (!target) return null;
target._requestedModel = (
event.requested_model
|| event.selected_model
|| target._requestedModel
|| defaultModel
);
target._actualModel = (
event.model
|| event.answered_by
|| target._actualModel
|| target._requestedModel
);
const hasEndpointRoute = Boolean(
event.requested_endpoint_id
|| event.selected_endpoint_id
|| event.endpoint_id
|| event.answered_by_endpoint_id
|| event.requested_endpoint_label
|| event.selected_endpoint_label
|| event.endpoint_label
|| event.answered_by_endpoint_label
|| target._requestedEndpointLabel
);
if (hasEndpointRoute) {
target._requestedEndpointId = (
event.requested_endpoint_id
|| event.selected_endpoint_id
|| target._requestedEndpointId
|| null
);
target._requestedEndpointLabel = (
event.requested_endpoint_label
|| event.selected_endpoint_label
|| target._requestedEndpointLabel
|| 'Selected route'
);
target._actualEndpointId = (
event.endpoint_id
|| event.answered_by_endpoint_id
|| target._actualEndpointId
|| target._requestedEndpointId
|| null
);
target._actualEndpointLabel = (
event.endpoint_label
|| event.answered_by_endpoint_label
|| target._actualEndpointLabel
|| target._requestedEndpointLabel
);
}
return target;
}
/** Copy the active route into the bubble created for the next Agent round. */
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
if (!target) return null;
const source = roundHolder || holder;
target._requestedModel = source?._requestedModel || defaultModel;
target._actualModel = source?._actualModel || target._requestedModel;
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
target._requestedEndpointId = source?._requestedEndpointId || null;
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
}
return target;
}
/** Apply final/metrics provenance to the active round, not the first bubble. */
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
const target = roundHolder || holder;
if (!target || !metrics) return target || null;
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
const roundModel = roundHolder && roundModels.length
? roundModels[roundModels.length - 1]
: null;
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
if (
metrics.requested_endpoint_label
|| metrics.endpoint_label
|| roundEndpointLabels.length
|| target._requestedEndpointLabel
) {
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
target._actualEndpointId = hasRoundEndpointId
? roundEndpointIds[roundEndpointIds.length - 1]
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
target._actualEndpointLabel = hasRoundEndpointLabel
? roundEndpointLabels[roundEndpointLabels.length - 1]
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
}
return target;
}
+1696 -196
View File
File diff suppressed because it is too large Load Diff
+95 -11
View File
@@ -2,11 +2,41 @@
// SSE event handlers extracted from chat.js handleChatSubmit
// Handles: ui_control events, background stream management
import uiModule from './ui.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import Storage from './storage.js';
import themeModule from './theme.js';
import themeModule from './theme.js?v=20260909effectspeed1';
import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260911removealignrightshortcut1';
// Tool approvals are control-plane submits for the current chat. chat.js
// deliberately leaves the composer untouched, then programmatically clicks the
// shared send button after it records the sealed approval id/decision. That
// button is polymorphic: with an empty composer it can mean New chat or Record
// voice instead of Send. Intercept only the programmatic approval click and
// route it through the form submit path, which already reaches chat.js directly.
document.addEventListener('odysseus:tool-approval', () => {
const sendButton = document.querySelector('.send-btn');
const chatForm = document.getElementById('chat-form');
if (!sendButton || !chatForm) return;
const interceptApprovalClick = (event) => {
// A real user click must retain the normal send/new-chat/STT behavior.
if (event.isTrusted) return;
sendButton.removeEventListener('click', interceptApprovalClick, true);
event.preventDefault();
event.stopImmediatePropagation();
if (chatForm.requestSubmit) chatForm.requestSubmit();
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
};
sendButton.addEventListener('click', interceptApprovalClick, true);
// Fail-safe cleanup if the approval continuation never reaches its deferred
// synthetic click (for example because the surrounding view is torn down).
setTimeout(() => {
sendButton.removeEventListener('click', interceptApprovalClick, true);
}, 60000);
}, true);
/**
* Handle a ui_control SSE event AI-driven UI manipulation.
@@ -99,6 +129,7 @@ export function handleUIControl(uiData) {
if (bg.effectColor && tm2.applyBgEffectColor) { tm2.applyBgEffectColor(bg.effectColor); opts.bgEffectColor = bg.effectColor; }
if (bg.effectIntensity != null && tm2.applyBgEffectIntensity) { tm2.applyBgEffectIntensity(bg.effectIntensity); opts.bgEffectIntensity = bg.effectIntensity; }
if (bg.effectSize != null && tm2.applyBgEffectSize) { tm2.applyBgEffectSize(bg.effectSize); opts.bgEffectSize = bg.effectSize; }
if (bg.effectSpeed != null && tm2.applyBgEffectSpeed) { tm2.applyBgEffectSpeed(bg.effectSpeed); opts.bgEffectSpeed = bg.effectSpeed; }
if (bg.frosted != null && tm2.applyFrostedGlass) { tm2.applyFrostedGlass(bg.frosted); opts.frosted = bg.frosted; }
}
if (tm2.saveCustomTheme) tm2.saveCustomTheme(name, colors2, Object.keys(opts).length ? opts : undefined);
@@ -131,7 +162,7 @@ export function handleUIControl(uiData) {
// the 12s active-poll.
var rsid = uiData.research_session_id || uiData.session_id;
if (rsid) {
import('./research/jobs.js').then(function(mod) {
import('./research/jobs.js?v=20260910researcherrorpersist1').then(function(mod) {
var fn = mod.adoptSession || (mod.default && mod.default.adoptSession);
if (fn) fn(rsid);
}).catch(function(){});
@@ -145,17 +176,24 @@ export function handleUIControl(uiData) {
} else if (uiEvent === 'open_panel' || uiData.ui_event === 'open_panel') {
var panel = uiData.panel;
if (panel === 'documents') {
import('./documentLibrary.js').then(function(mod) {
import('./documentLibrary.js?v=20260911librarybulkdelete1').then(function(mod) {
var fn = mod.openLibrary || (mod.default && mod.default.openLibrary);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'gallery') {
import('./gallery.js').then(function(mod) {
import('./gallery.js?v=20260910promptcopy1').then(function(mod) {
var fn = mod.openGallery || (mod.default && mod.default.openGallery);
if (fn) fn();
}).catch(function(){});
} else if (panel === 'calendar') {
import('./calendar.js?v=20260903weekscrollstable1').then(function(mod) {
var viewFn = mod.openCalendarView || (mod.default && mod.default.openCalendarView);
var fn = mod.openCalendar || (mod.default && mod.default.openCalendar);
if (viewFn && (uiData.view || uiData.target_date)) viewFn(uiData.view || 'month', uiData.target_date || '');
else if (fn) fn();
}).catch(function(){});
} else if (panel === 'email') {
import('./emailLibrary.js').then(function(mod) {
import('./emailLibrary.js?v=20260910replyactions1').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn();
}).catch(function(){});
@@ -170,22 +208,60 @@ export function handleUIControl(uiData) {
if (fn) fn();
}).catch(function(){});
} else if (panel === 'notes') {
import('./notes.js').then(function(mod) {
import('./notes.js?v=20260910drawmerge1').then(function(mod) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn();
}).catch(function(){});
} else if (panel === 'theme' || panel === 'themes') {
import('./theme.js?v=20260909effectspeed1').then(function(mod) {
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
var modal = document.getElementById('theme-modal');
if (modal && modal.classList.contains('hidden') && fn) fn();
else if (!modal && fn) fn();
else if (modal) modal.classList.remove('hidden');
}).catch(function(){
var btn = document.getElementById('tool-theme-btn') || document.getElementById('rail-theme');
if (btn) btn.click();
});
} else if (panel === 'memories' || panel === 'skills' || panel === 'settings') {
// These live in the sidebar / settings drawer — most just need
// an existing button click.
var ids = { memories: 'tool-memory-btn', skills: 'skills-btn', settings: 'open-settings-btn' };
var ids = { memories: 'tool-memory-btn', skills: 'tool-skills-btn', settings: 'open-settings-btn' };
var btn = document.getElementById(ids[panel]);
if (btn) btn.click();
if (panel === 'settings') {
import('./settings.js?v=20260909defaultmodelfix1').then(function(mod) {
var fn = mod.open || (mod.default && mod.default.open);
if (fn) fn();
else if (btn) btn.click();
}).catch(function(){ if (btn) btn.click(); });
} else if (btn) btn.click();
}
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
import('./emailInbox.js').then(function(mod) {
try {
var activeCtx = documentModule && documentModule.getActiveEmailComposerContext
? documentModule.getActiveEmailComposerContext()
: null;
var sameActiveDraft = activeCtx
&& String(activeCtx.sourceUid || '') === String(uiData.uid || '')
&& String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX');
var existingDocId = sameActiveDraft && activeCtx.docId
? activeCtx.docId
: (documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
: null);
if (existingDocId && documentModule.replaceEmailReplyBody) {
if (documentModule.loadDocument) documentModule.loadDocument(existingDocId);
documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true });
if (uiModule && uiModule.showToast) uiModule.showToast('Wrote reply into the open email');
return;
}
} catch (e) {
console.warn('open_email_reply existing draft update failed:', e);
}
import('./emailInbox.js?v=20260903emailsend2').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply');
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) {
console.warn('open_email_reply failed:', e);
});
@@ -222,6 +298,14 @@ export function notifyStreamComplete(sessionId, query) {
* Insert a clickable in-chat toast when a background stream finishes.
*/
export function insertStreamDoneToast(sessionId, query) {
if (
sessionModule
&& sessionModule.getCurrentSessionId
&& sessionModule.getCurrentSessionId() !== sessionId
) {
if (uiModule && uiModule.showToast) uiModule.showToast('Response ready in another chat', 4000);
return;
}
var box = document.getElementById('chat-history');
if (!box) return;
var sessions = sessionModule ? sessionModule.getSessions() : [];
+23
View File
@@ -0,0 +1,23 @@
/** Build a terminal stream error while preserving provider-supplied text. */
export function createTerminalStreamError(payload = {}) {
const rawError = payload.error;
const message = (
payload.text
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|| `Error ${payload.status || 'unknown'}`
);
const error = new Error(message);
error.name = 'TerminalStreamError';
error.terminalStreamError = true;
error.status = payload.status;
return error;
}
/** Only connection-class stream failures are safe to resubmit automatically. */
export function isRecoverableStreamError(error) {
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
if (error.name === 'TypeError') return true;
const message = (error.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
}
+151
View File
@@ -0,0 +1,151 @@
// Shared horizontal scrolling for filter/tag chip rows.
const CHIP_STRIP_SELECTOR = [
'.skills-summary-strip',
'#memory-category-filters',
'.memory-category-filters:has(> .memory-cat-chip)',
'.doclib-lang-chips',
'.doclib-chips',
'.notes-labels-bar',
'.tasks-activity-filters',
'.gallery-tag-chips',
'.gallery-album-chips',
'.gallery-ai-tags',
'.cal-filters',
].join(',');
function initChipStrip(strip) {
if (!strip || strip.dataset.chipScrollBound === '1') return;
const parent = strip.parentElement;
if (!parent) return;
strip.dataset.chipScrollBound = '1';
strip.classList.add('chip-scroll-strip');
const frame = document.createElement('div');
frame.className = 'doclib-chip-scroll-frame';
parent.insertBefore(frame, strip);
frame.appendChild(strip);
const makeArrow = (direction, label) => {
const button = document.createElement('button');
const glyph = document.createElement('span');
button.type = 'button';
button.className = `doclib-chip-scroll-arrow ${direction}`;
button.setAttribute('aria-label', label);
button.title = label;
glyph.className = 'doclib-chip-scroll-arrow-glyph';
glyph.textContent = direction === 'left' ? '\u2039' : '\u203a';
button.appendChild(glyph);
button.addEventListener('click', () => {
strip.scrollBy({ left: direction === 'left' ? -180 : 180, behavior: 'smooth' });
});
frame.appendChild(button);
return button;
};
const left = makeArrow('left', 'Show previous tags');
const right = makeArrow('right', 'Show more tags');
let pointerId = null;
let startX = 0;
let startScroll = 0;
let dragging = false;
let suppressClick = false;
const sync = () => {
const stripHidden = strip.hidden || getComputedStyle(strip).display === 'none';
frame.style.display = stripHidden ? 'none' : '';
if (stripHidden) {
frame.classList.remove('has-overflow');
left.hidden = true;
right.hidden = true;
return;
}
const max = Math.max(0, strip.scrollWidth - strip.clientWidth);
frame.classList.toggle('has-overflow', max > 1);
left.hidden = max <= 1 || strip.scrollLeft <= 1;
right.hidden = max <= 1 || strip.scrollLeft >= max - 1;
};
strip.addEventListener('scroll', sync, { passive: true });
strip.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
pointerId = event.pointerId;
startX = event.clientX;
startScroll = strip.scrollLeft;
dragging = false;
suppressClick = false;
});
strip.addEventListener('pointermove', (event) => {
if (pointerId !== event.pointerId) return;
const delta = event.clientX - startX;
if (!dragging && Math.abs(delta) <= 4) return;
if (!dragging) {
dragging = true;
suppressClick = true;
strip.classList.add('is-pointer-dragging');
strip.setPointerCapture?.(event.pointerId);
}
if (event.cancelable) event.preventDefault();
strip.scrollLeft = startScroll - delta;
});
const stopDragging = (event) => {
if (pointerId !== event.pointerId) return;
if (dragging && strip.hasPointerCapture?.(event.pointerId)) {
strip.releasePointerCapture(event.pointerId);
}
pointerId = null;
dragging = false;
strip.classList.remove('is-pointer-dragging');
sync();
};
strip.addEventListener('pointerup', stopDragging);
strip.addEventListener('pointercancel', stopDragging);
strip.addEventListener('click', (event) => {
if (!suppressClick) return;
event.preventDefault();
event.stopPropagation();
suppressClick = false;
}, true);
if (typeof ResizeObserver === 'function') new ResizeObserver(sync).observe(strip);
new MutationObserver(sync).observe(strip, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'hidden', 'style'],
});
requestAnimationFrame(sync);
}
function scanChipStrips(root = document) {
if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(CHIP_STRIP_SELECTOR)) initChipStrip(root);
root.querySelectorAll?.(CHIP_STRIP_SELECTOR).forEach(initChipStrip);
}
export function initChipScrollRows() {
if (window._chipScrollRowsBound) return;
window._chipScrollRowsBound = true;
['touchstart', 'touchmove'].forEach(type => {
document.addEventListener(type, event => {
if (event.target.closest?.('.chip-scroll-strip')) event.stopPropagation();
}, true);
});
const start = () => {
scanChipStrips();
new MutationObserver(records => {
for (const record of records) {
record.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) scanChipStrips(node);
});
}
}).observe(document.body, { childList: true, subtree: true });
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
else start();
}
initChipScrollRows();
+27 -8
View File
@@ -1,6 +1,6 @@
// static/js/codeRunner.js
import * as uiModule from './ui.js';
import * as uiModule from './ui.js?v=20260908weekhoverfix1';
/**
* In-browser code runner for Python (Pyodide), JavaScript, and HTML
@@ -33,6 +33,11 @@ function showLoading(panel, msg) {
panel.innerHTML = `<div class="code-runner-loading">${msg}</div>`;
}
function showEmpty(panel) {
panel.innerHTML = '<div class="code-runner-empty">Nothing to run. Add some code first.</div>';
panel.style.display = 'block';
}
/**
* Show output text in the panel
*/
@@ -48,7 +53,9 @@ function showOutput(panel, text, isError) {
const cbtn = document.createElement('button');
cbtn.type = 'button';
cbtn.className = 'code-runner-copy-inline';
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy';
cbtn.title = 'Copy output';
cbtn.setAttribute('aria-label', 'Copy output');
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
cbtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
@@ -67,14 +74,18 @@ function showOutput(panel, text, isError) {
if (!ok && navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
if (uiModule.showToast) uiModule.showToast('Copied');
cbtn.textContent = 'Copied!';
setTimeout(() => { cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>Copy'; }, 1500);
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
setTimeout(() => { cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; }, 1500);
}).catch(() => { if (uiModule.showToast) uiModule.showToast('Copy failed'); });
return;
}
if (uiModule.showToast) uiModule.showToast(ok ? 'Copied' : 'Copy failed');
const orig = cbtn.innerHTML;
cbtn.textContent = ok ? 'Copied!' : 'Copy failed';
if (ok) {
cbtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
} else {
cbtn.textContent = 'Copy failed';
}
setTimeout(() => { cbtn.innerHTML = orig; }, 1500);
});
// Button lives directly in the panel — no wrapping bar. The panel is
@@ -310,11 +321,15 @@ try {
*/
export async function runServer(code, panel, lang) {
showLoading(panel, 'Running on server...');
// Base64-encode the script so newlines survive the shell quoting intact.
// JSON.stringify turns \n into literal \\n which python3 -c sees as backslash-n;
// base64 avoids every quoting/escaping pitfall.
const b64 = btoa(unescape(encodeURIComponent(code)));
var command;
if (lang === 'python' || lang === 'py') {
command = 'python3 -c ' + JSON.stringify(code);
command = `python3 -c "import base64; exec(base64.b64decode('${b64}').decode('utf-8'))"`;
} else {
command = 'bash -c ' + JSON.stringify(code);
command = `python3 -c "import base64, subprocess, sys; sys.exit(subprocess.run(['bash','-c',base64.b64decode('${b64}').decode('utf-8')]).returncode)"`;
}
try {
var res = await fetch('/api/shell/exec', {
@@ -362,6 +377,7 @@ export function runHTML(code, panel) {
addCloseBtn(panel);
return;
}
try { win.opener = null; } catch (_) {}
win.document.open();
win.document.write(code);
win.document.close();
@@ -376,12 +392,15 @@ export function runHTML(code, panel) {
export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
if (!code) return;
const pre = btn.closest('pre');
if (!pre) return;
const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) {
showEmpty(panel);
return;
}
if (lang === 'bash' || lang === 'sh' || lang === 'shell' || lang === 'zsh') {
runServer(code, panel, 'bash');
+14
View File
@@ -0,0 +1,14 @@
// static/js/color/hex.js
//
// Parse a CSS hex color into {r, g, b}. Pure — no DOM — so it can be reused
// across modules and unit-tested under node.
// Accepts "#rgb", "#rrggbb" (with or without the leading '#'). Returns null
// for anything that isn't a valid 3- or 6-digit hex color.
export function hexToRgb(hex) {
let h = String(hex || '').trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
const n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
+95 -9
View File
@@ -4,6 +4,8 @@
// their .value stays the source of truth, and we dispatch 'input'
// events so existing listeners keep working.
import { topPortalZ } from './toolWindowZOrder.js';
const LS_RECENT = 'odysseus-recent-colors';
const MAX_RECENT = 12;
@@ -110,10 +112,13 @@ function buildPopover() {
<div class="cp-row">
<div class="cp-preview"></div>
<input type="text" class="cp-hex" maxlength="7" spellcheck="false" autocomplete="off">
<button class="cp-copy" title="Copy color" type="button" aria-label="Copy color">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<button class="cp-eyedropper" title="Eyedropper" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 22l4-4m0 0l3-3 5 5-3 3a2 2 0 01-2.8 0l-2.2-2.2a2 2 0 010-2.8z"/>
<path d="M14 8l3-3a3 3 0 014.2 4.2l-3 3-4.2-4.2z"/>
<path d="M12 2.5S5 10.1 5 14.5a7 7 0 0014 0C19 10.1 12 2.5 12 2.5Z"/>
<path d="M9 16.5c.6 1.2 1.6 1.9 3 2"/>
</svg>
</button>
</div>
@@ -177,6 +182,56 @@ function setFromHex(hex) {
_h = v.h; _s = v.s; _v = v.v;
}
function cssColorToHex(value) {
const match = String(value || '').match(/rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)(?:[, /]+\s*([\d.]+%?))?\s*\)/i);
if (!match) return null;
const alpha = match[4] == null ? 1 : (match[4].endsWith('%') ? parseFloat(match[4]) / 100 : parseFloat(match[4]));
if (!Number.isFinite(alpha) || alpha <= 0) return null;
return rgbToHex(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]));
}
function colorAtPoint(x, y) {
const candidates = document.elementsFromPoint(x, y)
.filter(el => el !== _popover && !_popover?.contains(el));
for (const el of candidates) {
const style = getComputedStyle(el);
for (const value of [style.backgroundColor, style.borderTopColor, style.color]) {
const hex = cssColorToHex(value);
if (hex) return hex;
}
}
return null;
}
function openFallbackEyedropper() {
return new Promise((resolve) => {
const hint = document.createElement('div');
hint.textContent = 'Click a visible color to sample · Esc to cancel';
hint.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:2147483647;padding:5px 9px;border:1px solid var(--border,#555);border-radius:5px;background:var(--bg,#222);color:var(--fg,#fff);font:12px sans-serif;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.35)';
document.body.appendChild(hint);
const finish = (value) => {
document.removeEventListener('click', onClick, true);
document.removeEventListener('keydown', onKey, true);
hint.remove();
resolve(value);
};
const onClick = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
finish(colorAtPoint(event.clientX, event.clientY));
};
const onKey = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
finish(null);
}
};
document.addEventListener('click', onClick, true);
document.addEventListener('keydown', onKey, true);
});
}
// ── Handlers ──────────────────────────────────────────────────────────
// Window-level pointer listeners — installed ONCE, not per-popover, so they
// don't leak when the popover is rebuilt on every open.
@@ -197,6 +252,7 @@ function wireHandlers(p) {
const sl = p.querySelector('.cp-sl');
const hue = p.querySelector('.cp-hue');
const hex = p.querySelector('.cp-hex');
const copy = p.querySelector('.cp-copy');
const eye = p.querySelector('.cp-eyedropper');
const onDown = (type) => (e) => {
@@ -221,6 +277,29 @@ function wireHandlers(p) {
if (e.key === 'Escape') { close(); }
});
copy.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const value = hsvToHex(_h, _s, _v);
try {
await navigator.clipboard.writeText(value);
} catch (_) {
hex.focus();
hex.select();
document.execCommand('copy');
hex.setSelectionRange(hex.value.length, hex.value.length);
}
copy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
copy.title = 'Copied';
copy.setAttribute('aria-label', 'Copied');
setTimeout(() => {
if (!copy.isConnected) return;
copy.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
copy.title = 'Copy color';
copy.setAttribute('aria-label', 'Copy color');
}, 900);
});
p.addEventListener('click', (e) => {
const sw = e.target.closest('.cp-swatch');
if (sw && sw.dataset.hex) {
@@ -230,8 +309,7 @@ function wireHandlers(p) {
}
});
if (window.EyeDropper) {
eye.addEventListener('click', async (ev) => {
eye.addEventListener('click', async (ev) => {
ev.stopPropagation();
// Suppress the outside-click close while the OS eyedropper is open.
// Without this, the user's pixel-pick fires a window click that
@@ -239,7 +317,9 @@ function wireHandlers(p) {
const wasOnOutside = _onOutside;
_detachOutsideHandlers();
try {
const r = await new window.EyeDropper().open();
const r = window.EyeDropper
? await new window.EyeDropper().open()
: { sRGBHex: await openFallbackEyedropper() };
if (r && r.sRGBHex) {
setFromHex(r.sRGBHex);
applyToInput(true);
@@ -258,10 +338,8 @@ function wireHandlers(p) {
});
}
});
} else {
eye.disabled = true;
eye.style.opacity = '0.3';
eye.title = 'Eyedropper not supported in this browser';
if (!window.EyeDropper) {
eye.title = 'Click a visible UI color to sample it';
}
}
@@ -291,6 +369,14 @@ function commitCurrent() {
// ── Open / close ──────────────────────────────────────────────────────
function position(p, anchor) {
p.style.zIndex = String(topPortalZ());
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) {
p.style.left = '50%';
p.style.top = '50%';
p.style.transform = 'translate(-50%, -50%)';
return;
}
p.style.transform = '';
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
let left = rect.left;
+14 -3
View File
@@ -5,7 +5,7 @@
export const EYE_OPEN = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
export const EYE_CLOSED = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
export const SAVE_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>';
export const CHAT_ICON = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
export const CHAT_ICON = '<svg class="section-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
export const ICON_COPY = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
export const ICON_REROLL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
export const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
@@ -29,7 +29,7 @@ export const VOTES_STORAGE_KEY = 'odysseus-compare-votes';
export const VOTES_MAX = 200;
export const POOL_STORAGE_KEY = 'odysseus-shuffle-pool-excluded';
// ── Evaluation prompt templates ──
// ── Comparison prompt templates ──
//
// Five high-signal prompts per category — each picked to differentiate models
// on a distinct capability. The Visual / SVG-render prompt in `chat` ends with
@@ -40,9 +40,12 @@ 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: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
{ sub: '★ Featured', label: 'Clock angle', answer: '100°', prompt: 'At exactly 7:20, what is the smaller angle between the hour hand and minute hand of an analog clock? Explain the movement of both hands precisely. End with only the angle, including the degree symbol, on its own line.' },
{ sub: '★ Featured', label: 'Painted cube', answer: '24', prompt: 'A 4×4×4 cube is painted on every outside face, then cut into 64 unit cubes. How many unit cubes have exactly two painted faces? Explain how you count them, then end with the final number on its own line.' },
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
{ sub: 'Visual explain', label: 'TCP handshake', prompt: 'Output one complete self-contained HTML file in a single ```html block with no prose outside it. Visually explain a TCP connection from client to server using a clear inline SVG sequence: SYN, SYN-ACK, ACK, data transfer, and FIN. Show packet direction, sequence/acknowledgment numbers, and a short plain-language annotation at every step. Use theme-aware CSS variables with a readable dark default, responsive layout, no external assets, and no JavaScript.' },
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
{ sub: 'Visual explain', label: 'Butterfly ASCII', prompt: 'Explain the butterfly lifecycle using ASCII art. Produce four separate frames in fenced code blocks, in order: egg, caterpillar, chrysalis, adult butterfly. Each frame must be drawn with monospace ASCII characters only and be visually recognizable as the creature/stage. Below each frame add one playful one-line caption (no longer than 15 words) describing what is happening at that stage.' },
],
@@ -57,8 +60,16 @@ export const EVAL_PROMPTS = {
{ sub: 'Web tasks', label: 'Multi-step', prompt: 'Search the web for the current population of the 3 largest cities in the world, then calculate what percentage of the world\'s total population lives in those cities.', toggles: ['web'] },
{ sub: 'Web tasks', label: 'Fact check', prompt: 'Fact-check these claims: 1) The Great Wall of China is visible from space. 2) Humans only use 10% of their brains. 3) Lightning never strikes the same place twice. Cite sources.', toggles: ['web'] },
{ sub: 'Web tasks', label: 'Compare prices', prompt: 'Find and compare the pricing, features, and limitations of the top 3 cloud GPU providers for machine learning training. Create a markdown comparison table.', toggles: ['web'] },
{ sub: 'Web tasks', label: 'Primary source', prompt: 'Find the latest official release notes for Python and summarize the most important developer-facing changes. Prefer the official Python documentation over blogs. Include source links.', toggles: ['web'] },
{ sub: 'Web tasks', label: 'JS-heavy page', prompt: 'Find a reliable source for the latest stable Chrome version, then verify it against at least one secondary source. Explain any mismatch clearly and cite both sources.', toggles: ['web'] },
{ sub: 'Research', label: 'Paper trail', prompt: 'Find two recent papers about long-context language models, extract each paper\'s claimed contribution, and compare the evidence quality in a short table. Cite the papers.', toggles: ['web'] },
{ sub: 'Research', label: 'Source quality', prompt: 'Research whether AI coding assistants improve developer productivity. Reject shallow listicles, use at least one empirical study, and state the strongest caveat.', toggles: ['web'] },
{ sub: 'Code tasks', label: 'Script + run', prompt: 'Write a Python script that generates a bar chart of the 5 most common programming languages in 2025 and save it as chart.png. Then run it.' },
{ sub: 'Code tasks', label: 'Debug + test', prompt: 'Create a minimal failing test for a function that should parse ISO dates, implement the parser, then run the test and report the result.' },
{ sub: 'Code tasks', label: 'Inspect files', prompt: 'Inspect the current project files, identify the main frontend entry point, and summarize the boot order with exact file names.' },
{ sub: 'Code tasks', label: 'CLI summarize', prompt: 'Use shell commands to find the 10 largest JavaScript files in this repo and explain which one looks most worth splitting first.' },
{ sub: 'Math', label: 'Proof + verify', prompt: 'Prove that the square root of 2 is irrational. Then write a Python program that approximates it using Newton\'s method to 50 decimal places and verify.' },
{ sub: 'Math', label: 'Monte Carlo', prompt: 'Estimate pi with a Monte Carlo simulation in Python, run it, then explain how sample size changes the error.' },
],
html: [
{ sub: 'Games', label: 'Snake', prompt: 'Output a complete HTML file (```html block) for a Snake game. ONLY use vanilla HTML, CSS, and JavaScript — no libraries, no Python, no imports, no external files. Canvas-based, neon green snake on dark grid, glowing food, score counter, speed increases, game over + restart. Skip any explanation, just output the code.' },
+278 -76
View File
@@ -17,28 +17,31 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
ICON_PARALLEL, ICON_SEQUENTIAL,
EYE_OPEN, EYE_CLOSED, SAVE_ICON, CHAT_ICON,
SEND_SVG, VOTES_STORAGE_KEY,
} from './icons.js';
} from './icons.js?v=20260908compareprompts1';
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260903compareprobe4';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260908panestatspopup2';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
_showModelSwapDropdown, _createAndAppendPane, _autoPreviewHtml,
mountMobilePaneTabs, syncShuffleButtonPlacement,
paneSettingsButtonHtml, togglePaneSettings,
registerPaneActions,
} from './panes.js';
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js';
import { showScoreboard } from './scoreboard.js';
} from './panes.js?v=20260908compareheader1';
import { handleVote, buildVoteBar, addFinishBadge, spawnConfetti, _saveVote, registerCompareActions } from './vote.js?v=20260828resendcaldrag1';
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
// ── External dependency imports ──
import Storage from '../storage.js';
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import sessionModule from '../sessions.js';
import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import presetsModule from '../presets.js';
import themeModule from '../theme.js?v=20260909effectspeed1';
import presetsModule from '../presets.js?v=20260908personaname1';
import markdownModule from '../markdown.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -73,6 +76,97 @@ function isCompareActive() {
return state.isActive;
}
function _compareModeLabel() {
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
}
function _paneModeBadgeHtml(paneIdx) {
const mode = String(state._compareMode || 'chat');
if (mode !== 'search') return '';
const label = 'Search';
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
'</span>';
}
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
const target = mode === 'agent' ? 'agent' : 'chat';
const toggleState = Storage.loadToggleState();
toggleState.mode = target;
Storage.saveToggleState(toggleState);
const agentBtn = document.getElementById('mode-agent-btn');
const chatBtn = document.getElementById('mode-chat-btn');
const modeToggle = agentBtn?.closest('.mode-toggle') || chatBtn?.closest('.mode-toggle') || document.querySelector('.mode-toggle');
if (agentBtn && chatBtn) {
agentBtn.classList.toggle('active', target === 'agent');
chatBtn.classList.toggle('active', target === 'chat');
agentBtn.setAttribute('aria-pressed', target === 'agent' ? 'true' : 'false');
chatBtn.setAttribute('aria-pressed', target === 'chat' ? 'true' : 'false');
}
if (modeToggle) {
modeToggle.classList.toggle('mode-chat', target === 'chat');
modeToggle.classList.toggle('mode-right', target === 'chat');
}
if (syncModeTools) {
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = target === 'agent' ? '' : 'none'; });
}
}
function _syncCompareModeFromToolbar(mode) {
if (!state.isActive) return;
state._compareMode = mode === 'agent' ? 'agent' : 'chat';
_setToolbarMode(state._compareMode, false);
const headerLabel = document.querySelector('.compare-header-label');
if (headerLabel) {
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
}
document.querySelectorAll('.compare-pane .pane-mode-badge').forEach((badge) => {
const template = document.createElement('template');
const paneIdx = Number(badge.closest('.compare-pane')?.dataset.pane || 0);
template.innerHTML = _paneModeBadgeHtml(paneIdx);
const replacement = template.content.firstElementChild;
if (replacement) badge.replaceWith(replacement);
else badge.remove();
});
const evalWrap = document.getElementById('cmp-eval-wrap');
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
}
function _showPaneStatsPopup(summary) {
document.querySelectorAll('.pane-stats-popup').forEach((el) => el._dismiss ? el._dismiss() : el.remove());
const values = String(summary.textContent || '').split(' · ').filter(Boolean);
if (!values.length) return;
const popup = document.createElement('div');
popup.className = 'pane-stats-popup';
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', 'Response statistics');
popup.innerHTML = '<div class="pane-stats-popup-title">Response stats</div>' + values.map((value) => {
let label = 'Value';
let display = value;
if (value.startsWith('TTFT ')) { label = 'First token'; display = value.slice(5); }
else if (value.endsWith(' tok')) { label = 'Output'; display = value.replace(/ tok$/, ' tokens'); }
else if (value.endsWith('/s')) label = 'Speed';
else if (value.endsWith('% ctx')) { label = 'Context'; display = value.replace(/ ctx$/, ''); }
else if (value.startsWith('$')) label = 'Cost';
return '<div class="pane-stats-popup-row"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(display) + '</strong></div>';
}).join('');
document.body.appendChild(popup);
const rect = summary.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - popupRect.width - 8)) + 'px';
popup.style.top = (rect.bottom + popupRect.height + 6 <= window.innerHeight
? rect.bottom + 6
: Math.max(8, rect.top - popupRect.height - 6)) + 'px';
summary.setAttribute('aria-expanded', 'true');
bindMenuDismiss(popup, () => {
popup.remove();
summary.setAttribute('aria-expanded', 'false');
}, (event) => !popup.contains(event.target) && event.target !== summary);
}
// ────────────────────────────────────────────────────────────────────────────
// ── closeCompare ──
// ────────────────────────────────────────────────────────────────────────────
@@ -92,7 +186,9 @@ async function toggleMode() {
deactivate(true);
return false;
}
if (state._openingSelector) return false;
state._openingSelector = true;
try {
const confirmed = await showModelSelector();
if (!confirmed) return false;
@@ -104,6 +200,8 @@ async function toggleMode() {
} catch (err) {
console.error('Compare toggleMode error:', err);
return false;
} finally {
state._openingSelector = false;
}
}
@@ -166,12 +264,7 @@ async function deactivate(teardown) {
});
// Restore agent/chat mode to what it was before compare
const _ts = Storage.loadToggleState();
_ts.mode = state._savedMode;
Storage.saveToggleState(_ts);
const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
_setToolbarMode(state._savedMode, true);
// Delete unsaved sessions, then reload
if (teardown) {
@@ -206,7 +299,9 @@ async function _buildCompareUI() {
for (let i = 0; i < n; i++) {
const m = state._selectedModels[i];
const fd = new FormData();
fd.append('name', '[CMP] ' + modelShorts[i]);
// Blind mode: name the session by its neutral slot so the sidebar /
// GET /api/sessions can't de-anonymize the comparison (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]));
fd.append('endpoint_url', m.endpoint || '');
fd.append('model', m.model || '');
if (m.endpointId) {
@@ -219,8 +314,12 @@ async function _buildCompareUI() {
sessionIds.push(data.id);
}
state._paneSessionIds = sessionIds;
state._paneGenerationSettings = sessionIds.map(() => ({
thinking_mode: '', temperature_override: null, max_tokens_override: null,
}));
} else {
state._paneSessionIds = [];
state._paneGenerationSettings = [];
}
state._paneMetrics = state._selectedModels.map(() => null);
state._abortControllers = state._selectedModels.map(() => null);
@@ -252,19 +351,30 @@ async function _buildCompareUI() {
if (el) state._savedIndicatorDisplay[id] = el.style.display;
});
// 5. Save current mode and lock to the right one for this compare type
// 5. Save current mode and seed the toolbar for this compare type.
const _toggleState = Storage.loadToggleState();
state._savedMode = _toggleState.mode || 'chat';
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
_toggleState.mode = _targetMode;
Storage.saveToggleState(_toggleState);
_setToolbarMode(_targetMode, false);
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
let _modeCleanup = null;
const _onCompareModeClick = (ev) => {
ev.stopPropagation();
ev.stopImmediatePropagation();
_syncCompareModeFromToolbar(ev.currentTarget === _ab ? 'agent' : 'chat');
};
if (_ab && _cb) {
_ab.classList.toggle('active', _targetMode === 'agent');
_cb.classList.toggle('active', _targetMode === 'chat');
_ab.addEventListener('click', _onCompareModeClick, true);
_cb.addEventListener('click', _onCompareModeClick, true);
_modeCleanup = document.createElement('span');
_modeCleanup.style.display = 'none';
_modeCleanup._cleanup = () => {
_ab.removeEventListener('click', _onCompareModeClick, true);
_cb.removeEventListener('click', _onCompareModeClick, true);
};
}
const _modeToggle = document.querySelector('.mode-toggle');
if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; }
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
// 6. Force tool toggles per compare mode
disableToolToggles();
@@ -283,6 +393,7 @@ async function _buildCompareUI() {
// 7. Hide existing chat container children (preserves event listeners)
const container = document.getElementById('chat-container');
state._compareElements = [];
if (_modeCleanup) state._compareElements.push(_modeCleanup);
Array.from(container.children).forEach(child => {
if (child.style.display === 'none') return;
child.dataset.cmpHidden = '1';
@@ -296,9 +407,9 @@ async function _buildCompareUI() {
headerBar.className = 'compare-header-bar';
headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
const headerLabel = document.createElement('span');
headerLabel.className = 'compare-header-label';
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
// Left side: the Compare tool icon (two side-by-side panes, matching the
// rail/sidebar icon) + the label. Other tool headers carry their icon; this
// one was missing it.
@@ -306,7 +417,7 @@ async function _buildCompareUI() {
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
const headerIcon = document.createElement('span');
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
headerIcon.innerHTML = '<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="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>';
headerLeft.appendChild(headerIcon);
headerLeft.appendChild(headerLabel);
headerBar.appendChild(headerLeft);
@@ -318,11 +429,10 @@ async function _buildCompareUI() {
const checkBtn = document.createElement('button');
checkBtn.id = 'compare-check-btn';
checkBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span style="font-size:11px;margin-left:3px;">Probe</span>';
checkBtn.innerHTML = '<svg class="compare-check-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><span class="compare-check-label">Probe</span>';
checkBtn.title = 'Probe unverified models with a small test request';
checkBtn.style.cssText = _btnCSS;
checkBtn.addEventListener('click', () => _checkUnprobed());
headerActions.appendChild(checkBtn);
// Check button is dynamic: only visible when at least one selected model
// hasn't been probed yet. Show right after add/change, hide after success.
@@ -336,6 +446,7 @@ async function _buildCompareUI() {
// (Scoreboard button moved into the vote bar, next to Tie — see vote.js.)
const exportWrap = document.createElement('div');
exportWrap.className = 'compare-export-wrap';
exportWrap.style.cssText = 'position:relative;display:inline-flex;';
const exportBtn = document.createElement('button');
exportBtn.id = 'compare-export-btn';
@@ -347,15 +458,41 @@ async function _buildCompareUI() {
_toggleExportMenu(exportBtn);
});
exportWrap.appendChild(exportBtn);
headerActions.appendChild(exportWrap);
const shuffleBtn = document.createElement('button');
shuffleBtn.id = 'compare-shuffle-btn';
shuffleBtn.innerHTML = ICON_DICE + '<span style="font-size:11px;margin-left:3px;">Shuffle</span>';
shuffleBtn.title = 'Shuffle pane positions';
shuffleBtn.style.cssText = _btnCSS;
shuffleBtn.addEventListener('click', () => shufflePanePositions());
headerActions.appendChild(shuffleBtn);
shuffleBtn.addEventListener('click', () => {
shufflePanePositions();
syncShuffleButtonPlacement(false);
});
const moreWrap = document.createElement('div');
moreWrap.className = 'compare-more-wrap';
const moreBtn = document.createElement('button');
moreBtn.className = 'compare-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'Compare actions';
moreBtn.setAttribute('aria-label', 'Compare actions');
moreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
const moreMenu = document.createElement('div');
moreMenu.className = 'compare-more-menu';
moreMenu.append(exportWrap, shuffleBtn, checkBtn);
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
moreMenu.classList.toggle('is-open');
moreBtn.setAttribute('aria-expanded', moreMenu.classList.contains('is-open') ? 'true' : 'false');
});
document.addEventListener('click', (e) => {
if (!moreWrap.contains(e.target)) {
moreMenu.classList.remove('is-open');
moreBtn.setAttribute('aria-expanded', 'false');
}
}, true);
moreWrap.append(moreBtn, moreMenu);
headerActions.appendChild(moreWrap);
const addBtn = document.createElement('button');
addBtn.id = 'compare-add-btn';
@@ -363,21 +500,24 @@ async function _buildCompareUI() {
addBtn.title = 'Add model pane';
addBtn.style.cssText = _btnCSS;
addBtn.addEventListener('click', () => _addPane(addBtn));
headerActions.appendChild(addBtn);
addBtn.className = 'compare-add-flap';
addBtn.setAttribute('aria-label', 'Add model pane');
const addMenuBtn = document.createElement('button');
addMenuBtn.id = 'compare-add-menu-btn';
addMenuBtn.type = 'button';
addMenuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>Add model</span>';
addMenuBtn.title = 'Add model pane';
addMenuBtn.addEventListener('click', () => _addPane(addMenuBtn));
moreMenu.append(addMenuBtn, shuffleBtn, checkBtn, exportWrap);
const closeBtn = document.createElement('button');
closeBtn.className = 'compare-close-btn';
closeBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
closeBtn.className = 'close-btn compare-close-btn';
closeBtn.innerHTML = '';
closeBtn.title = 'Close compare mode';
// Match Export/Score/Shuffle/Model styling so the X sits flush with
// the rest of the toolbar instead of being a 24×24 bordered square.
closeBtn.style.cssText = _btnCSS;
closeBtn.addEventListener('click', () => deactivate(true));
headerActions.appendChild(closeBtn);
// Move Export to the far left of the action cluster (per user preference).
headerActions.insertBefore(exportWrap, headerActions.firstChild);
headerBar.appendChild(headerActions);
container.appendChild(headerBar);
state._compareElements.push(headerBar);
@@ -396,16 +536,26 @@ async function _buildCompareUI() {
pane.dataset.pane = String(i);
pane.innerHTML =
'<div class="pane-header">' +
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#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">' +
'<div class="pane-header-row pane-header-primary">' +
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#x25BE;</span></button>' +
'<div class="pane-primary-actions">' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
paneSettingsButtonHtml(i) +
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button>' +
'</div>' +
'</div>' +
'<div class="pane-header-row pane-header-secondary">' +
'<div class="pane-stats">' + _paneModeBadgeHtml(i) +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span>' +
'<span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span>' +
'<span class="pane-finish-badge" id="cmp-badge-' + i + '"></span>' +
'</div>' +
'<div class="pane-actions">' +
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
'<button class="pane-action-btn" data-action="reroll" data-pane="' + i + '" title="Re-roll">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn" data-action="copy" data-pane="' + i + '" title="Copy">' + ICON_COPY + '</button>' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
@@ -419,6 +569,11 @@ async function _buildCompareUI() {
grid.appendChild(pane);
}
grid.addEventListener('click', (e) => {
const summary = e.target.closest('.pane-summary');
if (summary && summary.textContent.trim()) {
_showPaneStatsPopup(summary);
return;
}
const voteBtn = e.target.closest('.pane-vote-btn');
if (voteBtn) {
e.stopPropagation();
@@ -427,7 +582,7 @@ async function _buildCompareUI() {
handleVote(idx);
return;
}
const actionBtn = e.target.closest('.pane-action-btn');
const actionBtn = e.target.closest('[data-action]');
if (actionBtn) {
e.stopPropagation();
const action = actionBtn.dataset.action;
@@ -437,6 +592,7 @@ async function _buildCompareUI() {
else if (action === 'reroll') rerollPane(idx);
else if (action === 'expand') toggleExpandPane(idx, actionBtn);
else if (action === 'preview') togglePanePreview(idx);
else if (action === 'settings') togglePaneSettings(idx, actionBtn);
else if (action === 'close') _removePane(idx);
return;
}
@@ -447,7 +603,17 @@ async function _buildCompareUI() {
_showModelSwapDropdown(idx, titleBtn);
}
});
grid.addEventListener('keydown', (e) => {
const summary = e.target.closest('.pane-summary');
if (summary && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
_showPaneStatsPopup(summary);
}
});
container.appendChild(grid);
grid.appendChild(addBtn);
const mobileTabs = mountMobilePaneTabs(container, grid, (anchor) => _addPane(anchor));
state._compareElements.push(mobileTabs);
state._compareElements.push(grid);
// 10. Vote bar placeholder
@@ -469,8 +635,8 @@ async function _buildCompareUI() {
}
const msgTA = document.getElementById('message');
if (msgTA) {
msgTA.placeholder = 'Enter prompt for all models...';
requestAnimationFrame(() => msgTA.focus());
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
if (window.innerWidth > 768) requestAnimationFrame(() => msgTA.focus());
}
// Eval-prompts picker — sits inside the message box at top-right (where
@@ -885,8 +1051,7 @@ async function _executeCompare(message) {
let sharedSearchContext = null;
let sharedSearchSources = null;
const webChk = document.getElementById('web-toggle');
const toggleState = Storage.loadToggleState();
const isAgentMode = (toggleState.mode || 'chat') === 'agent';
const isAgentMode = state._compareMode === 'agent';
const webOn = webChk && webChk.checked;
// In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
if (webOn && !isAgentMode) {
@@ -954,11 +1119,16 @@ async function _executeCompare(message) {
console.error('Compare error:', err);
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
} finally {
state._streaming = false;
_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 = '';
// A pane may have started its own ask_user/approval continuation while the
// original all-pane Promise was settling. Keep Compare busy until every
// pane-owned controller is gone instead of exposing a second broadcast send.
const compareStillStreaming = state._abortControllers.some(Boolean);
state._streaming = compareStillStreaming;
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = compareStillStreaming;
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
});
}
}
@@ -1011,33 +1181,38 @@ function _buildComparisonMarkdown() {
}
let _exportMenuEl = null;
let _closeExportMenu = () => {};
function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect();
const m = document.createElement('div');
m.className = 'compare-export-menu';
m.style.cssText = 'position:fixed;z-index:10001;top:' + (r.bottom + 4) + 'px;left:' + r.left + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;display:flex;flex-direction:column;min-width:170px;';
const inCompareMenu = !!btn.closest('.compare-more-menu');
const menuLeft = inCompareMenu ? r.right + 6 : r.left;
const menuTop = inCompareMenu ? r.top : r.bottom + 4;
m.style.cssText = 'position:fixed;z-index:10001;top:' + menuTop + 'px;left:' + menuLeft + 'px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3),0 0 0 1px color-mix(in srgb,var(--fg) 5%,transparent);padding:6px;font-size:12px;display:flex;flex-direction:column;gap:2px;min-width:170px;backdrop-filter:blur(12px);';
const opts = [
{ label: 'Copy as Markdown', fn: () => _exportCopyMarkdown(btn) },
{ label: 'Download .md', fn: () => _exportDownloadMarkdown() },
{ label: 'Print / Save PDF', fn: () => _exportPrint() },
{ label: 'Copy as Markdown', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>', fn: () => _exportCopyMarkdown(btn) },
{ label: 'Download .md', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', fn: () => _exportDownloadMarkdown() },
{ label: 'Print / Save PDF', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>', fn: () => _exportPrint() },
];
for (const o of opts) {
const item = document.createElement('button');
item.type = 'button';
item.textContent = o.label;
item.style.cssText = 'background:none;border:none;color:var(--fg);text-align:left;padding:8px 12px;border-radius:6px;cursor:pointer;font:inherit;font-size:12px;';
item.addEventListener('mouseenter', () => { item.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
item.addEventListener('mouseleave', () => { item.style.background = 'none'; });
item.className = 'compare-export-item';
item.innerHTML = o.icon + '<span>' + o.label + '</span>';
item.style.cssText = 'background:none;border:1px solid transparent;color:var(--fg);text-align:left;padding:8px 10px;border-radius:6px;cursor:pointer;font:inherit;font-size:11px;line-height:1.3;';
item.addEventListener('click', () => { _closeExportMenu(); o.fn(); });
m.appendChild(item);
}
document.body.appendChild(m);
const mr = m.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) m.style.left = Math.max(8, r.left - mr.width - 6) + 'px';
if (mr.bottom > window.innerHeight - 8) m.style.top = Math.max(8, window.innerHeight - mr.height - 8) + 'px';
_exportMenuEl = m;
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
}
function _closeExportMenu() {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
_closeExportMenu = bindMenuDismiss(m, () => {
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
}, (ev) => !m.contains(ev.target));
}
async function _exportCopyMarkdown(_btn) {
@@ -1084,6 +1259,7 @@ function _exportPrint() {
// the system print dialog — user can pick "Save as PDF" from there.
const w = window.open('', '_blank');
if (!w) return;
try { w.opener = null; } catch (_) {}
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +
@@ -1179,10 +1355,10 @@ function _setupEvalPicker() {
btn.type = 'button';
btn.id = 'cmp-eval-btn';
btn.className = 'cmp-eval-btn';
btn.title = 'Insert an evaluation prompt';
btn.title = 'Insert a comparison prompt';
btn.innerHTML =
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>'
+ '<span class="cmp-eval-label">Eval prompts</span>'
+ '<span class="cmp-eval-label">Prompt sets</span>'
+ '<svg class="cmp-eval-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const menu = document.createElement('div');
@@ -1191,6 +1367,15 @@ function _setupEvalPicker() {
function _renderItems() {
const mode = state._compareMode || 'chat';
const label = btn.querySelector('.cmp-eval-label');
if (label) {
label.textContent = ({
agent: 'Agent prompts',
chat: 'Chat prompts',
search: 'Search prompts',
research: 'Research prompts'
}[mode] || 'Eval prompts');
}
// research/html aren't first-class compare types — fall back gracefully
const key = EVAL_PROMPTS[mode] ? mode
: (mode === 'research' ? 'search' : 'chat');
@@ -1226,8 +1411,9 @@ function _setupEvalPicker() {
const ta = document.getElementById('message');
if (ta) {
ta.value = decodeURIComponent(item.dataset.prompt);
ta.dataset.comparePromptPreset = 'true';
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.focus();
if (window.innerWidth > 768) ta.focus();
}
const ans = item.dataset.answer ? decodeURIComponent(item.dataset.answer) : '';
_showExpectedAnswer(ans);
@@ -1251,13 +1437,14 @@ function _setupEvalPicker() {
};
document.addEventListener('click', _onDocClick);
_renderItems();
wrap.appendChild(btn);
wrap.appendChild(menu);
wrap._renderItems = _renderItems;
inputTop.appendChild(wrap);
// Expected-answer chip placed above the chat-input-bar (outside it), so
// it floats over the compare grid right before the message box. Shows when
// a graded prompt is picked so the eval-runner can verify model output.
// Expected-answer chip placed above the input when a prompt includes a
// known answer, making side-by-side comparison easier.
const hintChip = document.createElement('div');
hintChip.className = 'cmp-eval-expected hidden';
hintChip.id = 'cmp-eval-expected';
@@ -1296,9 +1483,23 @@ function _setupEvalPicker() {
// pane ✓/✗ badges never appeared. The chip is only cleared via its
// own dismiss button (or when the user picks a new eval).
const ta = document.getElementById('message');
const clearPresetBtn = document.createElement('button');
clearPresetBtn.type = 'button';
clearPresetBtn.className = 'cmp-eval-clear';
clearPresetBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Clear prompt</span>';
clearPresetBtn.addEventListener('click', () => {
if (!ta) return;
ta.value = '';
delete ta.dataset.comparePromptPreset;
ta.dispatchEvent(new Event('input', { bubbles: true }));
_showExpectedAnswer('');
if (window.innerWidth > 768) ta.focus();
});
inputTop.appendChild(clearPresetBtn);
const _syncEvalVisibility = () => {
const hasText = ta && ta.value.trim().length > 0;
wrap.style.display = hasText ? 'none' : '';
clearPresetBtn.style.display = hasText && ta.dataset.comparePromptPreset === 'true' ? '' : 'none';
if (hasText) menu.classList.add('hidden');
};
if (ta) ta.addEventListener('input', _syncEvalVisibility);
@@ -1307,6 +1508,7 @@ function _setupEvalPicker() {
// Stash cleanup so cleanupResults() can detach the doc listener and
// restore the model-picker when compare deactivates.
wrap._cleanup = () => {
clearPresetBtn.remove();
document.removeEventListener('click', _onDocClick);
if (ta) ta.removeEventListener('input', _syncEvalVisibility);
if (modelWrap) modelWrap.style.display = prevModelDisplay || '';
@@ -1450,7 +1652,7 @@ async function showShufflePoolEditor() {
// ────────────────────────────────────────────────────────────────────────────
registerCompareActions({ stopAll, resetCompare });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
// ────────────────────────────────────────────────────────────────────────────
+4 -3
View File
@@ -1,7 +1,8 @@
// compare/models.js — model classification, fetching, display names, persistence
import Storage from '../storage.js';
import state from './state.js';
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import { sortModelObjects } from '../modelSort.js';
var escapeHtml = uiModule.esc;
@@ -84,9 +85,9 @@ async function fetchModels() {
});
});
}
state._fetchModelsCache = models;
state._fetchModelsCache = sortModelObjects(models);
state._fetchModelsCacheTime = now;
return models;
return state._fetchModelsCache;
}
// ── Shuffle pool persistence ──
+450 -78
View File
@@ -1,15 +1,16 @@
// compare/panes.js — pane lifecycle, actions, layout
import state from './state.js';
import { _persistSelections } from './models.js';
import { buildVoteBar } from './vote.js';
import { buildVoteBar } from './vote.js?v=20260828resendcaldrag1';
import {
ICON_REROLL, ICON_COPY, ICON_EXPAND, ICON_COLLAPSE, ICON_CLOSE,
ICON_PLAY, ICON_CODE, SEND_SVG,
} from './icons.js';
ICON_PLAY, ICON_CODE, SEND_SVG, ICON_DICE,
} from './icons.js?v=20260908compareprompts1';
import { _clearProbeWaves } from './probe.js';
import Storage from '../storage.js';
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import spinnerModule from '../spinner.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -32,6 +33,342 @@ function registerPaneActions({ setSendBtn, deactivate, streamToPane, renderSearc
/** Slot label: A/B/C in parallel mode, 1/2/3 in sequential. */
function _slotChar(i) { return state._parallel ? String.fromCharCode(65 + i) : String(i + 1); }
/** Keep Shuffle compact until a larger comparison makes it useful as a
* direct action. It returns to the kebab after being used. */
function syncShuffleButtonPlacement(showDirect = state._selectedModels.length > 2) {
const shuffleBtn = document.getElementById('compare-shuffle-btn');
const moreMenu = document.querySelector('.compare-more-menu');
const moreWrap = document.querySelector('.compare-more-wrap');
const headerActions = moreWrap?.parentElement;
if (!shuffleBtn || !moreMenu || !moreWrap || !headerActions) return;
if (showDirect) {
if (shuffleBtn.parentElement !== headerActions) headerActions.insertBefore(shuffleBtn, moreWrap);
} else if (shuffleBtn.parentElement !== moreMenu) {
moreMenu.appendChild(shuffleBtn);
}
}
function _paneModeBadgeHtml(paneIdx) {
const mode = String(state._compareMode || 'chat');
if (mode !== 'search') return '';
const label = ({ agent: 'Agent', search: 'Search', research: 'Research' }[mode] || 'Chat');
const detail = ({ agent: 'tools', search: 'web', research: 'sources' }[mode] || 'plain');
return '<span class="pane-mode-badge pane-mode-' + escapeHtml(mode) + '" title="' + escapeHtml(label + ' mode') + '">' +
'<span class="pane-mode-dot" aria-hidden="true"></span>' +
'<span class="pane-mode-label">' + escapeHtml(label) + '</span>' +
(mode === 'agent' ? '' : '<span class="pane-mode-detail">' + escapeHtml(detail) + '</span>') +
'</span>';
}
const PANE_SETTINGS_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>';
function paneSettingsButtonHtml(paneIdx) {
return '<button type="button" class="pane-action-btn pane-settings-btn" data-action="settings" data-pane="' + Number(paneIdx) + '" title="Inference settings" aria-label="Inference settings" aria-haspopup="dialog" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>';
}
function _paneModelRequiresThinking(paneIdx) {
const modelId = String(state._selectedModels[paneIdx]?.model || '').toLowerCase().split(':', 1)[0];
return modelId === 'x-ai/grok-4.5' || modelId === 'grok-4.5';
}
async function _savePaneGenerationSettings(paneIdx, change) {
const sid = state._paneSessionIds[paneIdx];
if (!sid) return false;
const current = state._paneGenerationSettings[paneIdx] || {};
const next = {
thinking_mode: current.thinking_mode || '',
temperature_override: current.temperature_override ?? null,
max_tokens_override: current.max_tokens_override ?? null,
...change,
};
try {
const res = await fetch(`${state.API_BASE}/api/session/${encodeURIComponent(sid)}/generation-settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(next),
});
if (!res.ok) throw new Error(await res.text());
state._paneGenerationSettings[paneIdx] = { ...next, ...await res.json() };
return true;
} catch (err) {
uiModule.showError(`Could not save pane settings: ${err.message || err}`);
return false;
}
}
function togglePaneSettings(paneIdx, anchorBtn) {
const existing = document.querySelector('.compare-pane-settings-popup');
if (existing) {
const same = existing.dataset.pane === String(paneIdx);
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (same) return;
}
const settings = state._paneGenerationSettings[paneIdx] || {
thinking_mode: '', temperature_override: null, max_tokens_override: null,
};
const popup = document.createElement('div');
popup.className = 'chat-context-popup compare-pane-settings-popup';
popup.dataset.pane = String(paneIdx);
popup.setAttribute('role', 'dialog');
popup.setAttribute('aria-label', 'Inference settings');
popup.innerHTML = '<div class="chat-context-popup-title">Inference settings</div>';
const thinkingRequired = _paneModelRequiresThinking(paneIdx);
const thinkingOn = thinkingRequired || settings.thinking_mode === 'on';
const thinkingRow = document.createElement('div');
thinkingRow.className = 'chat-context-toggle-row';
thinkingRow.innerHTML = '<div class="chat-context-toggle-copy"><span>Thinking</span><span class="chat-context-toggle-state">' + (thinkingRequired ? 'Required' : (thinkingOn ? 'On' : 'Off')) + '</span></div>';
const thinkingToggle = document.createElement('button');
thinkingToggle.type = 'button';
thinkingToggle.className = `chat-context-toggle${thinkingOn ? ' active' : ''}`;
thinkingToggle.setAttribute('role', 'switch');
thinkingToggle.setAttribute('aria-checked', thinkingOn ? 'true' : 'false');
thinkingToggle.disabled = thinkingRequired;
if (thinkingRequired) thinkingToggle.title = 'This model requires reasoning';
thinkingToggle.addEventListener('click', async () => {
if (thinkingRequired) return;
const next = !thinkingToggle.classList.contains('active');
thinkingToggle.disabled = true;
if (await _savePaneGenerationSettings(paneIdx, { thinking_mode: next ? 'on' : 'off' })) {
thinkingToggle.classList.toggle('active', next);
thinkingToggle.setAttribute('aria-checked', next ? 'true' : 'false');
thinkingRow.querySelector('.chat-context-toggle-state').textContent = next ? 'On' : 'Off';
}
thinkingToggle.disabled = false;
});
thinkingRow.appendChild(thinkingToggle);
popup.appendChild(thinkingRow);
const addSlider = (label, value, min, max, step, formatter, key, normalize) => {
const row = document.createElement('div');
row.className = 'chat-context-threshold-row';
row.innerHTML = '<div class="chat-context-threshold-top"><span>' + label + '</span><span>' + formatter(value) + '</span></div>';
const input = document.createElement('input');
Object.assign(input, { type: 'range', min: String(min), max: String(max), step: String(step), value: String(value), className: 'chat-context-threshold-slider preset-range' });
input.addEventListener('input', () => { row.querySelector('.chat-context-threshold-top span:last-child').textContent = formatter(Number(input.value)); });
input.addEventListener('change', async () => {
input.disabled = true;
await _savePaneGenerationSettings(paneIdx, { [key]: normalize(Number(input.value)) });
input.disabled = false;
});
row.appendChild(input);
popup.appendChild(row);
};
addSlider('Temperature', settings.temperature_override ?? 1, 0, 2, 0.1, value => Number(value).toFixed(1), 'temperature_override', value => value);
addSlider('Max tokens', settings.max_tokens_override ?? 8448, 256, 8448, 256, value => value > 8192 ? 'No limit' : Number(value).toLocaleString(), 'max_tokens_override', value => value > 8192 ? null : value);
document.body.appendChild(popup);
const rect = anchorBtn.getBoundingClientRect();
const margin = 8;
const popupRect = popup.getBoundingClientRect();
popup.style.left = Math.max(margin, Math.min(rect.left, window.innerWidth - popupRect.width - margin)) + 'px';
popup.style.top = Math.max(margin, Math.min(rect.bottom + 5, window.innerHeight - popupRect.height - margin)) + 'px';
anchorBtn.setAttribute('aria-expanded', 'true');
bindMenuDismiss(popup, () => {
popup.remove();
anchorBtn.setAttribute('aria-expanded', 'false');
}, event => !popup.contains(event.target) && event.target !== anchorBtn);
}
function _isMobileCompare() {
return window.matchMedia('(max-width: 768px)').matches;
}
function _mobilePaneLabel(index) {
if (state._blindMode) return 'Model ' + _slotChar(index);
return state._selectedModels[index]?.name || 'Model ' + (index + 1);
}
/** Keep the phone tab strip and its single visible pane in sync. */
function refreshMobilePaneTabs(preferredIndex = state._activeMobilePane) {
const tabs = document.querySelector('.compare-mobile-tabs');
const grid = document.querySelector('.compare-grid');
if (!tabs || !grid) return;
const panes = Array.from(grid.querySelectorAll(':scope > .compare-pane'));
if (!panes.length) {
tabs.replaceChildren();
return;
}
state._activeMobilePane = Math.max(0, Math.min(Number(preferredIndex) || 0, panes.length - 1));
const retained = new Set();
panes.forEach((pane, index) => {
const paneId = 'cmp-pane-' + index;
const tabId = 'cmp-mobile-tab-' + index;
pane.id = paneId;
pane.setAttribute('role', 'tabpanel');
pane.setAttribute('aria-labelledby', tabId);
let tab = tabs.querySelector(`[data-pane="${index}"]`);
if (!tab) {
tab = document.createElement('button');
tab.type = 'button';
tab.className = 'compare-mobile-tab';
tab.setAttribute('role', 'tab');
tab.innerHTML = '<span class="compare-mobile-tab-slot"></span><span class="compare-mobile-tab-label"></span><span class="compare-mobile-tab-state" aria-hidden="true"></span>';
}
retained.add(tab);
tab.id = tabId;
tab.dataset.pane = String(index);
tab.setAttribute('aria-controls', paneId);
tab.querySelector('.compare-mobile-tab-slot').textContent = _slotChar(index);
tab.querySelector('.compare-mobile-tab-label').textContent = _mobilePaneLabel(index);
tab.classList.toggle('is-streaming', pane.classList.contains('is-streaming'));
tab.classList.toggle('is-awaiting-input', pane.classList.contains('is-awaiting-input'));
tab.classList.toggle('is-done', pane.classList.contains('is-done'));
tab.classList.toggle('is-failed', pane.classList.contains('is-failed'));
tabs.appendChild(tab);
});
tabs.querySelectorAll('.compare-mobile-tab').forEach(tab => {
if (!retained.has(tab)) tab.remove();
});
let addButton = tabs.querySelector('.compare-mobile-add');
if (!addButton && typeof tabs._onAddPane === 'function') {
addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'compare-mobile-add';
addButton.title = 'Add model';
addButton.setAttribute('aria-label', 'Add model');
addButton.innerHTML = '<span aria-hidden="true">+</span><span>Add</span>';
addButton.addEventListener('click', (event) => {
event.stopPropagation();
tabs._onAddPane(addButton);
});
}
if (addButton) {
addButton.disabled = !!state._streaming;
addButton.style.display = panes.length >= 8 ? 'none' : '';
tabs.appendChild(addButton);
}
const mobile = _isMobileCompare();
panes.forEach((pane, index) => {
const active = index === state._activeMobilePane;
pane.classList.toggle('compare-pane-mobile-active', active);
if (mobile) pane.setAttribute('aria-hidden', active ? 'false' : 'true');
else pane.removeAttribute('aria-hidden');
});
tabs.querySelectorAll('.compare-mobile-tab').forEach((tab, index) => {
const active = index === state._activeMobilePane;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
tab.tabIndex = active ? 0 : -1;
});
}
function activateMobilePane(index, { focus = false } = {}) {
state._activeMobilePane = index;
refreshMobilePaneTabs(index);
const tab = document.querySelector(`.compare-mobile-tab[data-pane="${state._activeMobilePane}"]`);
if (tab && _isMobileCompare()) {
tab.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
if (focus) tab.focus();
}
}
/** Mount a horizontally scrollable tab row without unmounting inactive streams. */
function mountMobilePaneTabs(container, grid, onAddPane) {
const tabs = document.createElement('div');
tabs.className = 'compare-mobile-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('aria-label', 'Compared models');
tabs._onAddPane = onAddPane;
container.insertBefore(tabs, grid);
const onClick = (event) => {
const tab = event.target.closest('.compare-mobile-tab');
if (tab) activateMobilePane(Number(tab.dataset.pane));
};
const onKeyDown = (event) => {
if (!event.target.closest('.compare-mobile-tab')) return;
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const last = Math.max(0, state._selectedModels.length - 1);
let next = state._activeMobilePane;
if (event.key === 'ArrowLeft') next = Math.max(0, next - 1);
if (event.key === 'ArrowRight') next = Math.min(last, next + 1);
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = last;
activateMobilePane(next, { focus: true });
};
const onResize = () => refreshMobilePaneTabs();
tabs.addEventListener('click', onClick);
tabs.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', onResize);
// On phones, swipe across the response card to move to the adjacent model.
// Keep the gesture vertical-scroll friendly and don't steal touches from
// buttons, links, selects, or editable response content.
let swipeStart = null;
const onTouchStart = (event) => {
if (!_isMobileCompare() || event.touches.length !== 1) {
swipeStart = null;
return;
}
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('button, a, select, input, textarea, [contenteditable="true"]')) {
swipeStart = null;
return;
}
const touch = event.touches[0];
swipeStart = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
if (!swipeStart || !_isMobileCompare() || event.changedTouches.length !== 1) return;
const touch = event.changedTouches[0];
const dx = touch.clientX - swipeStart.x;
const dy = touch.clientY - swipeStart.y;
swipeStart = null;
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.25) return;
const last = Math.max(0, state._selectedModels.length - 1);
const next = dx < 0
? Math.min(last, state._activeMobilePane + 1)
: Math.max(0, state._activeMobilePane - 1);
if (next !== state._activeMobilePane) activateMobilePane(next);
};
grid.addEventListener('touchstart', onTouchStart, { passive: true });
grid.addEventListener('touchend', onTouchEnd, { passive: true });
const observer = new MutationObserver(() => refreshMobilePaneTabs());
observer.observe(grid, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['class', 'data-pane'],
});
tabs._cleanup = () => {
observer.disconnect();
window.removeEventListener('resize', onResize);
grid.removeEventListener('touchstart', onTouchStart);
grid.removeEventListener('touchend', onTouchEnd);
};
refreshMobilePaneTabs(0);
return tabs;
}
function _showShuffleNotice() {
const grid = document.querySelector('.compare-grid');
if (!grid) return;
grid.querySelector('.compare-shuffle-notice')?.remove();
const notice = document.createElement('div');
notice.className = 'compare-shuffle-notice';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.innerHTML = '<span class="compare-shuffle-notice-icon" aria-hidden="true">' + ICON_DICE + '</span><span>Shuffling</span>';
grid.appendChild(notice);
requestAnimationFrame(() => notice.classList.add('show'));
setTimeout(() => {
notice.classList.remove('show');
notice.addEventListener('transitionend', () => notice.remove(), { once: true });
setTimeout(() => notice.remove(), 220);
}, 1060);
}
// ── Stop / reroll ──
function stopAll() {
@@ -43,6 +380,9 @@ function stopAll() {
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
});
document.querySelectorAll('.compare-pane').forEach(pane => {
pane.classList.remove('is-streaming', 'is-awaiting-input');
});
}
function stopPane(paneIdx) {
@@ -54,6 +394,8 @@ function stopPane(paneIdx) {
// Hide stop button, show reroll
const pane = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (pane) {
pane.classList.remove('is-streaming', 'is-awaiting-input', 'is-done');
pane.classList.add('is-failed');
const stopBtn = pane.querySelector('.pane-stop-btn');
if (stopBtn) stopBtn.style.display = 'none';
pane.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
@@ -97,6 +439,8 @@ async function rerollPane(paneIdx, overrideTimeout) {
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const timer = document.getElementById('cmp-timer-' + paneIdx);
if (timer) timer.textContent = '';
const summary = document.getElementById('cmp-summary-' + paneIdx);
if (summary) { summary.textContent = ''; summary.title = ''; }
// Search mode: re-query the search provider
if (state._compareMode === 'search') {
@@ -282,10 +626,11 @@ async function _addPane(anchorBtn) {
// Toggle existing dropdown
const existing = document.querySelector('.add-pane-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const dropdown = document.createElement('div');
dropdown.className = 'add-pane-dropdown';
let closeMenu = () => dropdown.remove();
// Search input for large model lists
if (filtered.length >= 5) {
@@ -326,7 +671,7 @@ async function _addPane(anchorBtn) {
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
await _createAndAppendPane(m);
});
dropdown.appendChild(item);
@@ -337,6 +682,8 @@ async function _addPane(anchorBtn) {
// chat-container is wider than the viewport.
const btnRect = anchorBtn.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.right = 'auto';
dropdown.style.bottom = 'auto';
const vw = window.innerWidth;
const vh = window.innerHeight;
const margin = 8;
@@ -351,16 +698,18 @@ async function _addPane(anchorBtn) {
const ddRect = dropdown.getBoundingClientRect();
const ddW = ddRect.width;
const ddH = ddRect.height;
// Horizontal: align dropdown's right edge with the button's, then
// clamp so the dropdown stays within [margin, vw - margin].
// Align the dropdown's right edge with the button, then clamp so it stays
// within the viewport. This keeps the picker attached to the Add flap
// instead of opening at an unrelated edge of the compare surface.
let left = btnRect.right - ddW;
if (left + ddW > vw - margin) left = vw - margin - ddW;
if (left < margin) left = margin;
left = Math.max(margin, Math.min(left, vw - margin - ddW));
// Vertical: drop below the button if there's room, otherwise above.
const spaceBelow = vh - btnRect.bottom;
const spaceAbove = btnRect.top;
let top;
if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
if (anchorBtn.classList.contains('compare-add-flap')) {
top = Math.max(margin, Math.min(btnRect.bottom + 6, vh - margin - ddH));
} else if (spaceBelow >= ddH + margin || spaceBelow >= spaceAbove) {
top = Math.min(btnRect.bottom + 4, vh - margin - Math.min(ddH, vh - margin * 2));
} else {
top = Math.max(margin, btnRect.top - 4 - ddH);
@@ -371,15 +720,8 @@ async function _addPane(anchorBtn) {
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);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
/** Create a new pane for the given model and append it to the compare grid. */
async function _createAndAppendPane(m) {
@@ -387,7 +729,8 @@ async function _createAndAppendPane(m) {
// Create session
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : m.name));
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
@@ -401,6 +744,7 @@ async function _createAndAppendPane(m) {
// Update arrays
state._selectedModels.push({ model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name, endpointName: m.endpointName || '' });
state._paneSessionIds.push(data.id);
state._paneGenerationSettings.push({ thinking_mode: '', temperature_override: null, max_tokens_override: null });
state._paneMetrics.push(null);
state._abortControllers.push(null);
_persistSelections();
@@ -413,16 +757,17 @@ async function _createAndAppendPane(m) {
pane.dataset.pane = String(i);
pane.innerHTML =
'<div class="pane-header">' +
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#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>' +
'<div class="pane-header-row pane-header-primary"><button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#x25BE;</span></button><div class="pane-primary-actions">' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
'</div>' +
paneSettingsButtonHtml(i) +
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
'<div class="pane-actions">' +
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
'</div>' +
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
@@ -440,6 +785,14 @@ async function _createAndAppendPane(m) {
// Update grid columns
const n = state._selectedModels.length;
grid.dataset.cols = String(Math.min(n, 4));
syncShuffleButtonPlacement(n > 2);
refreshMobilePaneTabs(i);
if (_isMobileCompare()) {
activateMobilePane(i);
requestAnimationFrame(() => {
document.querySelector('.compare-mobile-add')?.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' });
});
}
// Update header label
const headerSpan = document.querySelector('.compare-active > div:first-child span');
@@ -452,19 +805,6 @@ async function _createAndAppendPane(m) {
// Rebuild vote bar
buildVoteBar(n);
// Prompt to shuffle in blind mode — tooltip bubble next to Shuffle button
if (state._blindMode && n > 2) {
const shuffleBtn = document.getElementById('compare-shuffle-btn');
if (shuffleBtn) {
const bubble = document.createElement('div');
bubble.style.cssText = 'position:absolute;top:100%;right:0;margin-top:6px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:5px 10px;font-size:11px;white-space:nowrap;z-index:10000;box-shadow:0 4px 12px rgba(0,0,0,0.25);pointer-events:none;opacity:0;transition:opacity 0.2s;';
bubble.textContent = 'Shuffle models?';
shuffleBtn.style.position = 'relative';
shuffleBtn.appendChild(bubble);
requestAnimationFrame(() => { bubble.style.opacity = '1'; });
setTimeout(() => { bubble.style.opacity = '0'; setTimeout(() => bubble.remove(), 200); }, 4000);
}
}
}
/** Remove a pane from the compare grid. If only 1 remains, exit compare mode. */
@@ -483,6 +823,7 @@ function _removePane(paneIdx) {
// Remove from arrays
state._selectedModels.splice(paneIdx, 1);
state._paneSessionIds.splice(paneIdx, 1);
state._paneGenerationSettings.splice(paneIdx, 1);
state._paneMetrics.splice(paneIdx, 1);
state._abortControllers.splice(paneIdx, 1);
_persistSelections();
@@ -499,6 +840,7 @@ function _removePane(paneIdx) {
grid.querySelectorAll('.compare-pane').forEach(p => p.remove());
const n = state._selectedModels.length;
syncShuffleButtonPlacement(n > 2);
for (let i = 0; i < n; i++) {
const label = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
const pane = document.createElement('div');
@@ -506,17 +848,17 @@ function _removePane(paneIdx) {
pane.dataset.pane = String(i);
pane.innerHTML =
'<div class="pane-header">' +
'<button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#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-header-row pane-header-primary"><button class="pane-title pane-title-btn" id="cmp-title-' + i + '" data-pane="' + i + '" type="button">' + escapeHtml(label) + ' <span class="pane-title-caret">&#x25BE;</span></button><div class="pane-primary-actions">' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
paneSettingsButtonHtml(i) +
'<button class="close-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane"></button></div></div>' +
'<div class="pane-header-row pane-header-secondary"><div class="pane-stats">' + _paneModeBadgeHtml(i) +
'<span class="pane-timer" id="cmp-timer-' + i + '"></span><span class="pane-summary" id="cmp-summary-' + i + '" role="button" tabindex="0" aria-label="Show response metrics"></span><span class="pane-finish-badge" id="cmp-badge-' + i + '"></span></div>' +
'<div class="pane-actions">' +
'<button class="pane-action-btn pane-stop-btn" data-action="stop" data-pane="' + i + '" title="Stop" style="display:none;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>' +
'<button class="pane-action-btn pane-preview-btn" data-action="preview" data-pane="' + i + '" id="cmp-preview-' + i + '" title="Run preview" style="display:none;">' + ICON_PLAY + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="reroll" data-pane="' + i + '" title="Re-roll" style="display:none;">' + ICON_REROLL + '</button>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button>' +
'<button class="pane-action-btn" data-action="expand" data-pane="' + i + '" title="Expand">' + ICON_EXPAND + '</button>' +
'<button class="pane-action-btn pane-close-btn" data-action="close" data-pane="' + i + '" title="Remove pane">' + ICON_CLOSE + '</button>' +
'</div>' +
'<button class="pane-action-btn pane-needs-response" data-action="copy" data-pane="' + i + '" title="Copy" style="display:none;">' + ICON_COPY + '</button></div></div>' +
'</div>' +
'<div class="chat-history" id="cmp-history-' + i + '"></div>' +
'<iframe class="compare-pane-iframe" id="cmp-iframe-' + i + '" sandbox="allow-scripts" style="display:none;"></iframe>' +
@@ -542,16 +884,20 @@ function _removePane(paneIdx) {
// Rebuild vote bar
buildVoteBar(n);
refreshMobilePaneTabs(Math.min(paneIdx, n - 1));
}
/** Show a dropdown under the pane title to swap the model for that pane. */
function _showModelSwapDropdown(paneIdx, titleBtn) {
// Don't allow swaps while streaming
if (state._streaming) return;
if (state._streaming) {
uiModule.showToast('Stop the response or wait for it to finish before changing models.');
return;
}
// Remove any existing dropdown
const existing = document.querySelector('.pane-model-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
@@ -559,6 +905,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
const dropdown = document.createElement('div');
dropdown.className = 'pane-model-dropdown';
let closeMenu = () => dropdown.remove();
filtered.forEach(m => {
const item = document.createElement('button');
@@ -573,34 +920,40 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
}
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
// Update the model for this pane and persist
state._selectedModels[paneIdx] = {
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
};
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
// Delete old session, create new one
const oldSid = state._paneSessionIds[paneIdx];
if (oldSid) {
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
}
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : 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');
}
let newSessionId = '';
try {
const res = await fetch(`${state.API_BASE}/api/session`, { method: 'POST', body: fd });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
state._paneSessionIds[paneIdx] = data.id;
newSessionId = data.id || '';
if (!newSessionId) throw new Error('Missing session id');
} catch (err) {
console.error('Failed to create session for swapped model:', err);
if (uiModule?.showError) uiModule.showError('Failed to swap compare model: ' + (err?.message || 'unknown'));
return;
}
const oldSid = state._paneSessionIds[paneIdx];
state._selectedModels[paneIdx] = {
model: m.id, endpoint: m.url, endpointId: m.endpointId, name: m.name,
};
state._paneSessionIds[paneIdx] = newSessionId;
await _savePaneGenerationSettings(paneIdx, {});
_persistSelections();
if (window._updateCheckBtnState) window._updateCheckBtnState();
if (oldSid) {
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
}
// Update title display
@@ -621,6 +974,9 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
if (previewBtn) { previewBtn.style.display = 'none'; previewBtn.classList.remove('active'); }
const badge = document.getElementById('cmp-badge-' + paneIdx);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const summary = document.getElementById('cmp-summary-' + paneIdx);
if (summary) { summary.textContent = ''; summary.title = ''; }
refreshMobilePaneTabs(paneIdx);
});
dropdown.appendChild(item);
});
@@ -653,15 +1009,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
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);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
// ── Shuffle / reset ──
@@ -672,6 +1021,7 @@ function shufflePanePositions() {
if (shuffleBtn) { const b = shuffleBtn.querySelector('div'); if (b) b.remove(); }
const n = state._selectedModels.length;
if (n < 2) return;
_showShuffleNotice();
// Fisher-Yates shuffle to get new order
const indices = Array.from({ length: n }, (_, i) => i);
@@ -683,6 +1033,7 @@ function shufflePanePositions() {
// Reorder internal state
const newModels = indices.map(i => state._selectedModels[i]);
const newSessionIds = indices.map(i => state._paneSessionIds[i]);
const newGenerationSettings = indices.map(i => state._paneGenerationSettings[i]);
const newMetrics = indices.map(i => state._paneMetrics[i]);
// Collect pane contents (HTML) before swapping
@@ -698,6 +1049,7 @@ function shufflePanePositions() {
// Apply shuffled state
state._selectedModels = newModels;
state._paneSessionIds = newSessionIds;
state._paneGenerationSettings = newGenerationSettings;
state._paneMetrics = newMetrics;
// Spin the shuffle button dice icon
@@ -764,7 +1116,10 @@ function shufflePanePositions() {
state._blindMode = true;
// Rebuild vote bar with new labels
setTimeout(() => buildVoteBar(n), 250);
setTimeout(() => {
buildVoteBar(n);
refreshMobilePaneTabs();
}, 250);
}
function resetCompare() {
@@ -773,6 +1128,13 @@ function resetCompare() {
// Clear last prompt so vote buttons are disabled until next prompt
state._lastPrompt = '';
state._expectedAnswer = '';
const expected = document.getElementById('cmp-eval-expected');
if (expected) {
expected.classList.add('hidden');
const value = expected.querySelector('.cmp-eval-expected-value');
if (value) value.textContent = '';
}
// Reset finish badges, titles, winner/loser state
state._finishOrder = 0;
@@ -781,12 +1143,16 @@ function resetCompare() {
for (let i = 0; i < n; i++) {
const badge = document.getElementById('cmp-badge-' + i);
if (badge) { badge.textContent = ''; badge.style.color = ''; }
const summary = document.getElementById('cmp-summary-' + i);
if (summary) { summary.textContent = ''; summary.title = ''; }
const titleEl = document.getElementById('cmp-title-' + i);
if (titleEl) {
const lbl = state._blindMode ? 'Model ' + _slotChar(i) : state._selectedModels[i].name;
titleEl.innerHTML = escapeHtml(lbl) + ' <span class="pane-title-caret">&#x25BE;</span>';
}
if (panes[i]) { panes[i].classList.remove('winner', 'loser'); }
if (panes[i]) {
panes[i].classList.remove('winner', 'loser', 'is-streaming', 'is-awaiting-input', 'is-done', 'is-failed');
}
// Clear all messages from pane history
const hist = document.getElementById('cmp-history-' + i);
@@ -823,4 +1189,10 @@ export {
_showModelSwapDropdown,
shufflePanePositions,
resetCompare,
mountMobilePaneTabs,
syncShuffleButtonPlacement,
activateMobilePane,
refreshMobilePaneTabs,
paneSettingsButtonHtml,
togglePaneSettings,
};
+2 -2
View File
@@ -1,7 +1,7 @@
// compare/probe.js — model probe/check system
import state from './state.js';
import { WAVE_FRAMES } from './icons.js';
import uiModule from '../ui.js';
import { WAVE_FRAMES } from './icons.js?v=20260908compareprompts1';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import spinnerModule from '../spinner.js';
function _clearProbeWaves() {
+9 -8
View File
@@ -1,9 +1,9 @@
// compare/scoreboard.js — vote history display
import Storage from '../storage.js';
import state from './state.js';
import { VOTES_STORAGE_KEY } from './icons.js';
import themeModule from '../theme.js';
import uiModule from '../ui.js';
import { VOTES_STORAGE_KEY } from './icons.js?v=20260908compareprompts1';
import themeModule from '../theme.js?v=20260909effectspeed1';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
const escapeHtml = uiModule.esc;
@@ -120,7 +120,7 @@ export function showScoreboard() {
if (sorted.length === 0) {
const empty = document.createElement('p');
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;';
empty.style.cssText = 'color:color-mix(in srgb, var(--fg) 50%, transparent);text-align:center;padding:24px 0;font-size:calc(1em - 1px);';
empty.textContent = 'No ' + activeMode + ' votes yet. Run a comparison and vote!';
wrap.appendChild(empty);
} else {
@@ -185,7 +185,7 @@ export function showScoreboard() {
clearBtn.addEventListener('click', () => {
// Inline confirmation
const confirmRow = document.createElement('div');
confirmRow.style.cssText = 'display:flex;gap:8px;justify-content:center;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
confirmRow.style.cssText = 'display:flex;gap:8px;align-items:center;margin-top:8px;padding:8px 12px;border:1px solid color-mix(in srgb, var(--red) 40%, var(--border));border-radius:6px;background:color-mix(in srgb, var(--red) 5%, transparent);';
const confirmLabel = document.createElement('span');
confirmLabel.style.cssText = 'font-size:12px;opacity:0.7;';
confirmLabel.textContent = 'Clear all vote history?';
@@ -202,9 +202,10 @@ export function showScoreboard() {
noBtn.className = 'cmp-btn-secondary';
noBtn.style.cssText = 'padding:4px 12px;border-radius:4px;font-size:12px;';
noBtn.addEventListener('click', () => confirmRow.remove());
confirmRow.appendChild(confirmLabel);
confirmRow.appendChild(yesBtn);
confirmRow.appendChild(noBtn);
const actions = document.createElement('span');
actions.style.cssText = 'display:inline-flex;align-items:center;gap:8px;margin-left:auto;';
actions.append(noBtn, yesBtn);
confirmRow.append(confirmLabel, actions);
// Replace button with confirmation
clearBtn.style.display = 'none';
clearBtn.parentElement.appendChild(confirmRow);
+134 -49
View File
@@ -2,12 +2,12 @@
import state from './state.js';
import Storage from '../storage.js';
import { fetchModels, _persistSelections, getExcludedModels } from './models.js';
import { showScoreboard } from './scoreboard.js';
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js';
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
import { EYE_OPEN, EYE_CLOSED, ICON_DICE, ICON_PARALLEL, ICON_SEQUENTIAL, SAVE_ICON, WAVE_FRAMES, CHAT_ICON } from './icons.js?v=20260908compareprompts1';
import { _clearProbeWaves } from './probe.js';
import uiModule from '../ui.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import themeModule from '../theme.js?v=20260909effectspeed1';
const escapeHtml = uiModule.esc;
@@ -75,7 +75,7 @@ async function showModelSelector() {
header.className = 'modal-header';
const title = document.createElement('h4');
title.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:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>Model Comparison';
title.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:6px"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>Model Comparison';
// Absorb the free space so the injected minimize (_) and close (✕) cluster
// together on the right instead of being spread apart by space-between.
title.style.marginRight = 'auto';
@@ -153,7 +153,7 @@ async function showModelSelector() {
uiModule.showToast('Mode: ' + (state._parallel ? 'Parallel' : 'Sequential'));
_updateModeLabel();
_setModeHint(state._parallel
? '<span style="color:#5b8def">Parallel</span>: all models answer at once, side by side.'
? '<span style="color:var(--accent, var(--red))">Parallel</span>: all models answer at once, side by side.'
: '<span style="color:#e0a050">Sequential</span>: models answer one at a time.');
});
toggleRow.appendChild(parallelBtn);
@@ -295,7 +295,7 @@ async function showModelSelector() {
const parts = [];
if (state._blindMode) parts.push('<span style="color:var(--color-blind-orange)">Blind</span>');
parts.push(state._parallel
? '<span style="color:#5b8def">Parallel</span>'
? '<span style="color:var(--accent, var(--red))">Parallel</span>'
: '<span style="color:#e0a050">Sequential</span>');
if (_shuffled) parts.push('<span style="color:var(--red)">Shuffle</span>');
if (state._saveOnClose) parts.push('<span style="color:var(--color-save-green)">Save</span>');
@@ -403,6 +403,7 @@ async function showModelSelector() {
// Validate saved selections against available models (done after models load)
let _needsValidation = selections.length > 0;
let addBtn = null;
let startBtn = null;
let _shuffled = false;
_updateModeLabel(); // initial readout (Blind + Parallel on by default)
@@ -419,6 +420,68 @@ async function showModelSelector() {
};
}
function _selectionKey(sel) {
if (!sel) return '';
const provider = sel.searchProvider || '';
return [sel.model || '', sel.endpointId || '', sel.endpoint || '', provider].join('|');
}
function _duplicateSelectionKeys() {
const counts = new Map();
selections.filter(Boolean).forEach(sel => {
const key = _selectionKey(sel);
if (!key) return;
counts.set(key, (counts.get(key) || 0) + 1);
});
return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key));
}
function _appendSelectionMeta(row, sel, duplicateKeys) {
if (!sel || _shuffled) return;
const key = _selectionKey(sel);
const meta = document.createElement('div');
meta.className = 'cmp-model-meta';
const bits = [];
if (duplicateKeys.has(key)) {
row.classList.add('cmp-model-row-duplicate');
bits.push('<span class="cmp-model-meta-warning">Duplicate selection</span>');
}
if (!bits.length) return;
meta.innerHTML = bits.join('');
row.appendChild(meta);
}
function _updateStartReadiness() {
if (!startBtn || !_modelsLoaded) return;
const duplicates = _duplicateSelectionKeys();
// Duplicate models are valid compare inputs. Keep the warning on the
// rows, but never block the Start flow because the user can choose to
// proceed through the probe fallback.
startBtn.disabled = false;
startBtn.style.opacity = '1';
startBtn.title = duplicates.size > 0 ? 'Duplicate selections will run as separate panes' : '';
}
function _expandModelSlot(slotIdx) {
const row = listContainer.querySelector(`.cmp-model-row[data-slot-index="${slotIdx}"]`);
if (!row) return;
row.classList.add('cmp-model-row-swap-target');
row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
const searchable = row.querySelector('.cmp-model-picker-input');
if (searchable) {
searchable.focus({ preventScroll: true });
return;
}
const select = row.querySelector('.cmp-model-primary-select');
if (!select) return;
select.focus({ preventScroll: true });
if (typeof select.showPicker === 'function') {
try { select.showPicker(); } catch (_) { /* focus still identifies the slot */ }
}
}
/** Build a searchable model picker (used when >5 models) */
function _buildSearchablePicker(modelList, currentSel, slotIdx, onSelect) {
const wrap = document.createElement('div');
@@ -427,7 +490,7 @@ async function showModelSelector() {
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Search models\u2026';
input.className = 'cmp-form-control';
input.className = 'cmp-form-control cmp-model-picker-input';
input.style.cssText = 'width:100%;box-sizing:border-box;';
// Mobile: suppress the on-screen keyboard so tapping the picker
// opens the dropdown but doesn't shove a keyboard up over the list.
@@ -594,6 +657,7 @@ async function showModelSelector() {
selections.forEach((sel, idx) => {
const row = document.createElement('div');
row.className = 'cmp-model-row';
row.dataset.slotIndex = String(idx);
if (_seqStepS) row.style.marginLeft = (idx * _seqStepS) + 'px';
// Left label: number/letter or blind eye icon
@@ -618,7 +682,7 @@ async function showModelSelector() {
row.appendChild(picker);
} else {
const modelSelect = document.createElement('select');
modelSelect.className = 'cmp-form-control';
modelSelect.className = 'cmp-form-control cmp-model-primary-select';
modelSelect.style.flex = '1';
chatModels.forEach(m => {
const opt = document.createElement('option');
@@ -647,26 +711,29 @@ async function showModelSelector() {
});
provSelect.addEventListener('change', () => {
try { selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
renderModelRows();
});
try { if (!selections[idx]) selections[idx] = JSON.parse(provSelect.value); } catch (e) {}
row.appendChild(provSelect);
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
// X remove button when >2 slots
if (selections.length > 2) {
// X remove button when more than one slot remains
if (selections.length > 1) {
const rmBtn = document.createElement('button');
rmBtn.type = 'button';
rmBtn.textContent = '\u00d7';
rmBtn.className = 'cmp-rm-btn';
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); state._searchSynthModels.splice(idx, 1); renderModelRows(); });
row.appendChild(rmBtn);
}
listContainer.appendChild(row);
});
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
return;
listContainer.appendChild(row);
});
if (addBtn) addBtn.style.display = selections.length >= 8 ? 'none' : '';
_updateStartReadiness();
return;
}
// ── Chat / Image / Agent / Research mode: show model dropdowns ──
@@ -705,6 +772,7 @@ async function showModelSelector() {
selections.forEach((sel, idx) => {
const row = document.createElement('div');
row.className = 'cmp-model-row';
row.dataset.slotIndex = String(idx);
if (_seqStep) row.style.marginLeft = (idx * _seqStep) + 'px';
// Left label: number/letter or blind eye icon
@@ -727,6 +795,7 @@ async function showModelSelector() {
const picker = _buildSearchablePicker(filtered, sel, idx, (chosen) => {
selections[idx] = chosen;
_remindShuffle();
renderModelRows();
});
if (!selections[idx]) {
const fallback = filtered[Math.min(idx, filtered.length - 1)];
@@ -735,7 +804,7 @@ async function showModelSelector() {
row.appendChild(picker);
} else {
const select = document.createElement('select');
select.className = 'cmp-form-control';
select.className = 'cmp-form-control cmp-model-primary-select';
select.style.flex = '1';
filtered.forEach((m, mi) => {
const opt = buildOption(m);
@@ -749,6 +818,7 @@ async function showModelSelector() {
select.addEventListener('change', () => {
try { selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model select parse failed:', e); }
_remindShuffle();
renderModelRows();
});
try { if (!selections[idx]) selections[idx] = JSON.parse(select.value); } catch (e) { console.warn('Compare model init parse failed:', e); }
row.appendChild(select);
@@ -767,19 +837,24 @@ async function showModelSelector() {
else if (!state._searchSynthModels[idx] && pi === 0) optEl.selected = true;
provSelect.appendChild(optEl);
});
provSelect.addEventListener('change', () => { state._searchSynthModels[idx] = provSelect.value; });
provSelect.addEventListener('change', () => {
state._searchSynthModels[idx] = provSelect.value;
renderModelRows();
});
if (!state._searchSynthModels[idx]) state._searchSynthModels[idx] = provSelect.value;
row.appendChild(provSelect);
}
// X remove button when >2 slots
if (selections.length > 2) {
_appendSelectionMeta(row, selections[idx], _duplicateSelectionKeys());
// X remove button when more than one slot remains
if (selections.length > 1) {
const rmBtn = document.createElement('button');
rmBtn.type = 'button';
rmBtn.textContent = '\u00d7';
rmBtn.className = 'cmp-rm-btn';
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--color-error)'; });
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--fg)'; });
rmBtn.addEventListener('mouseenter', () => { rmBtn.style.opacity = '1'; rmBtn.style.color = 'var(--accent, var(--red))'; });
rmBtn.addEventListener('mouseleave', () => { rmBtn.style.opacity = '0.3'; rmBtn.style.color = 'var(--accent, var(--red))'; });
rmBtn.addEventListener('click', () => { selections.splice(idx, 1); if (state._searchSynthModels.length > idx) state._searchSynthModels.splice(idx, 1); renderModelRows(); });
row.appendChild(rmBtn);
}
@@ -787,6 +862,7 @@ async function showModelSelector() {
listContainer.appendChild(row);
});
if (addBtn) addBtn.style.display = (selections.length >= 8) ? 'none' : '';
_updateStartReadiness();
}
// Default to 2 empty slots if no saved selections
@@ -795,7 +871,7 @@ async function showModelSelector() {
addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.style.cssText = 'display:none;align-items:center;gap:6px;background:none;border:1px dashed var(--border);color:var(--fg);border-radius:6px;cursor:pointer;padding:6px 12px;font-size:0.82em;opacity:0.6;transition:all 0.15s;margin-bottom:16px;width:100%;justify-content:center;';
addBtn.textContent = '+ Add Model';
addBtn.innerHTML = '<span style="color:var(--accent,var(--red));position:relative;left:-2px;">+</span><span>Add Model</span>';
addBtn.addEventListener('mouseenter', () => { addBtn.style.opacity = '1'; });
addBtn.addEventListener('mouseleave', () => { addBtn.style.opacity = '0.6'; });
addBtn.addEventListener('click', () => {
@@ -840,7 +916,7 @@ async function showModelSelector() {
// Scoreboard button
const scoreBtn = document.createElement('button');
scoreBtn.type = 'button';
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
scoreBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:0px;position:relative;top:2px;margin-right:4px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Scoreboard';
scoreBtn.style.cssText = 'margin-left:auto;padding:4px 10px;background:transparent;color:var(--fg);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.82em;opacity:0.7;position:relative;top:-5px;';
scoreBtn.addEventListener('mouseenter', () => { scoreBtn.style.opacity = '1'; });
scoreBtn.addEventListener('mouseleave', () => { scoreBtn.style.opacity = '0.7'; });
@@ -857,7 +933,7 @@ async function showModelSelector() {
footer.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;padding:14px 16px 10px;border-top:1px solid var(--border);';
// Cancel button removed — the overlay's X / outside-click / Esc all
// dismiss the popup, so the footer Cancel was redundant.
const startBtn = document.createElement('button');
startBtn = document.createElement('button');
startBtn.innerHTML = _CMP_START_LABEL;
startBtn.className = 'research-start-btn';
startBtn.disabled = true;
@@ -924,7 +1000,11 @@ async function showModelSelector() {
probeOverlay.className = 'compare-probe-overlay';
const probeCard = document.createElement('div');
probeCard.className = 'compare-probe-card';
probeCard.innerHTML = '<div class="compare-probe-title">Checking models...</div>';
probeCard.innerHTML = '<div class="compare-probe-title"><span class="compare-probe-title-label">Preround check:</span> <span class="compare-probe-title-status">Checking models...</span></div>';
const _setProbeTitle = (message) => {
const status = probeCard.querySelector('.compare-probe-title-status');
if (status) status.textContent = message;
};
let _probeSkipped = false;
const probeList = document.createElement('div');
probeList.className = 'compare-probe-list';
@@ -950,6 +1030,9 @@ async function showModelSelector() {
probeList.appendChild(row);
});
probeCard.appendChild(probeList);
const probeFeedback = document.createElement('div');
probeFeedback.className = 'compare-probe-feedback';
probeCard.appendChild(probeFeedback);
const skipBtn = document.createElement('button');
skipBtn.textContent = 'Skip';
skipBtn.className = 'cmp-btn-secondary';
@@ -1046,18 +1129,22 @@ async function showModelSelector() {
if (nameEl) nameEl.textContent = row._realName;
}
// Remove old detail/actions if retrying
const oldDetail = row.nextElementSibling;
if (oldDetail && oldDetail.classList.contains('compare-probe-detail')) oldDetail.remove();
// Error + actions below the row
const oldDetail = probeFeedback.querySelector(`[data-probe-detail="${idx}"]`);
if (oldDetail) oldDetail.remove();
// Keep diagnostic feedback below the complete model list.
const detail = document.createElement('div');
detail.className = 'compare-probe-detail';
detail.style.cssText = 'grid-column:1/-1;display:flex;align-items:flex-start;gap:6px;padding:4px 10px 6px;font-size:10px;opacity:0.6;background:color-mix(in srgb, var(--color-error, #f44) 5%, transparent);border-radius:4px;margin-top:-2px;';
detail.dataset.probeDetail = String(idx);
const detailIcon = document.createElement('span');
detailIcon.className = 'compare-probe-detail-icon';
const errSpan = document.createElement('span');
// Truncate long error messages
const errText = (result.error || 'Failed');
detailIcon.textContent = /insufficient balance/i.test(errText) ? '$' : '!';
detail.appendChild(detailIcon);
errSpan.textContent = errText.length > 80 ? errText.slice(0, 80) + '...' : errText;
errSpan.title = errText;
errSpan.style.cssText = 'flex:1;line-height:1.4;';
errSpan.className = 'compare-probe-detail-message';
detail.appendChild(errSpan);
// Track timeout for retry doubling
if (!row._probeTimeout) row._probeTimeout = 15000;
@@ -1065,7 +1152,7 @@ async function showModelSelector() {
const retryBtn = document.createElement('button');
retryBtn.className = 'compare-probe-action-btn';
const retryLabel = result.error === 'Timeout' ? `Retry ${Math.round(row._probeTimeout / 1000)}s` : 'Retry';
retryBtn.textContent = retryLabel;
retryBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66"/><polyline points="20 4 20 11 13 11"/></svg><span>' + escapeHtml(retryLabel) + '</span>';
retryBtn.addEventListener('click', async (e) => {
e.stopPropagation();
detail.remove();
@@ -1084,7 +1171,7 @@ async function showModelSelector() {
});
const swapBtn = document.createElement('button');
swapBtn.className = 'compare-probe-action-btn';
swapBtn.textContent = 'Swap';
swapBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/></svg><span>Swap</span>';
swapBtn.addEventListener('click', (e) => {
e.stopPropagation();
_clearProbeWaves();
@@ -1093,10 +1180,12 @@ async function showModelSelector() {
startBtn.disabled = false;
startBtn.innerHTML = _CMP_START_LABEL;
startBtn.style.opacity = '1';
renderModelRows();
_expandModelSlot(idx);
});
detail.appendChild(retryBtn);
detail.appendChild(swapBtn);
row.after(detail);
probeFeedback.appendChild(detail);
}
}
@@ -1186,8 +1275,7 @@ async function showModelSelector() {
: (state._searchSynthModels || []).map(p => typeof p === 'string' ? { id: p, label: p } : null).filter(Boolean);
if (providers.length > 0) {
const titleEl = probeOverlay.querySelector('.compare-probe-title');
titleEl.textContent = 'Checking search providers...';
_setProbeTitle('Checking search providers...');
// Add provider rows
const providerRows = [];
@@ -1195,7 +1283,7 @@ async function showModelSelector() {
const row = document.createElement('div');
row.className = 'compare-probe-row';
row.dataset.idx = 'p' + i;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
const waveEl = row.querySelector('.compare-probe-spinner');
const waveFrames = WAVE_FRAMES;
let wIdx = 0;
@@ -1250,7 +1338,7 @@ async function showModelSelector() {
// Don't hide the Skip button here — collapsing its space made the
// card shrink and the title + rows jump ("quick cut"). On success the
// whole overlay fades out a moment later, so just leave it in place.
probeOverlay.querySelector('.compare-probe-title').textContent = 'All ready!';
_setProbeTitle('All ready!');
setTimeout(() => {
probeOverlay.style.transition = 'opacity 0.3s ease';
probeOverlay.style.opacity = '0';
@@ -1264,21 +1352,18 @@ async function showModelSelector() {
probeList.querySelectorAll('.compare-probe-row.fail').forEach(row => {
failedNames.push(row.querySelector('.compare-probe-name').textContent);
});
const titleEl = probeOverlay.querySelector('.compare-probe-title');
titleEl.textContent = failedNames.length <= 2
_setProbeTitle(failedNames.length <= 2
? failedNames.join(' & ') + ' failed'
: `${failCount} models failed`;
: `${failCount} models failed`);
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;justify-content:center;margin-top:12px;';
btnRow.className = 'compare-probe-footer';
const goBackBtn = document.createElement('button');
goBackBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="15 18 9 12 15 6"/></svg>Go Back';
goBackBtn.className = 'cmp-btn-secondary';
goBackBtn.style.cssText = 'padding:5px 12px;font-size:12px;display:inline-flex;align-items:center;';
goBackBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg><span>Go Back</span>';
goBackBtn.className = 'cmp-btn-secondary compare-probe-footer-btn';
goBackBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); startBtn.disabled = false; startBtn.innerHTML = _CMP_START_LABEL; startBtn.style.opacity = '1'; });
const startAnywayBtn = document.createElement('button');
startAnywayBtn.textContent = 'Start Anyway';
startAnywayBtn.className = 'cmp-btn-primary';
startAnywayBtn.style.cssText = 'padding:5px 12px;font-size:12px;';
startAnywayBtn.innerHTML = _CMP_PLAY_ICON + '<span>Start Anyway</span>';
startAnywayBtn.className = 'cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway';
startAnywayBtn.addEventListener('click', () => { _clearProbeWaves(); probeOverlay.remove(); cleanup(true); });
btnRow.appendChild(goBackBtn);
btnRow.appendChild(startAnywayBtn);
+6
View File
@@ -2,6 +2,7 @@
const state = {
API_BASE: '',
isActive: false,
_openingSelector: false, // prevents duplicate compare modals on rapid re-clicks
_streaming: false,
_blindMode: true,
_saveOnClose: false,
@@ -13,6 +14,7 @@ const state = {
// (sequential mode otherwise always picks pane 1)
_selectedModels: [], // [{model, endpoint, endpointId, name}, ...]
_paneSessionIds: [], // session IDs for each pane
_paneGenerationSettings: [], // per-pane thinking / temperature / token overrides
_paneMetrics: [], // metrics per pane from last round
_abortControllers: [], // per-pane abort controllers
_sidebarWasHidden: false,
@@ -32,20 +34,24 @@ const state = {
_fetchModelsCacheTime: 0,
_expectedAnswer: '', // when an eval prompt with `answer` is picked,
// stream.js reads this and stamps ✓/✗ per pane
_activeMobilePane: 0, // visible pane in the phone tab/card layout
};
/** Reset transient state to defaults — useful for clean restarts. */
export function reset() {
state._openingSelector = false;
state._streaming = false;
state._finishOrder = 0;
state._paneElapsed = [];
state._abortControllers.forEach(c => { if (c) c.abort(); });
state._abortControllers = [];
state._paneSessionIds = [];
state._paneGenerationSettings = [];
state._paneMetrics = [];
state._compareElements = [];
state._hasVisibleResults = false;
state._lastPrompt = '';
state._activeMobilePane = 0;
state._cachedModels = [];
state._probed = new Set();
state._cachedProviders = null;
+366 -42
View File
@@ -1,24 +1,235 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost } from '../chatRenderer.js';
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260910streamlinks2';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
import presetsModule from '../presets.js';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import presetsModule from '../presets.js?v=20260908personaname1';
var escapeHtml = uiModule.esc;
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
function _safeHttpHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
let _setSendBtn = null;
/** Register external functions that live in compare.js. */
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
_rerollPane = rerollPane;
_autoPreviewHtml = autoPreviewHtml;
_setSendBtn = setSendBtn;
}
function _paneSessionIsCurrent(paneIdx, sessionId) {
return Boolean(
state.isActive
&& state._paneSessionIds[paneIdx] === sessionId
&& document.getElementById('cmp-history-' + paneIdx)
);
}
function _setCompareBusy(active) {
state._streaming = Boolean(active);
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = Boolean(active);
button.style.opacity = active ? '0.25' : '0.7';
button.style.pointerEvents = active ? 'none' : '';
});
}
function _syncCompareBusyFromPanes() {
_setCompareBusy((state._abortControllers || []).some(Boolean));
}
function _compareTimezoneHeaders() {
const headers = { 'X-Tz-Offset': String(-new Date().getTimezoneOffset()) };
try {
headers['X-Tz-Name'] = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch (_) {
headers['X-Tz-Name'] = '';
}
return headers;
}
function _compactNumber(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '';
if (Math.abs(num) >= 1000000) return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
if (Math.abs(num) >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return String(Math.round(num));
}
function _formatCost(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '';
if (num < 0.001) return '<$0.001';
return '$' + (num < 0.01 ? num.toFixed(4) : num.toFixed(3));
}
function _setPaneSummary(paneIdx, metrics, cost) {
const summary = document.getElementById('cmp-summary-' + paneIdx);
if (!summary) return;
if (!metrics) {
summary.textContent = '';
summary.title = '';
return;
}
const outputTokens = metrics.output_tokens;
const responseTime = metrics.response_time ?? metrics.total_time;
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
const numericOutput = Number(outputTokens);
const numericTime = Number(responseTime);
const numericTps = Number(explicitTps);
const derivedTps = Number.isFinite(numericTps)
? numericTps
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
? numericOutput / numericTime
: null;
const bits = [];
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) bits.push('TTFT ' + Number(ttft).toFixed(3) + 's');
if (outputTokens != null && outputTokens !== 'undefined') bits.push(_compactNumber(outputTokens) + ' tok');
if (derivedTps != null) bits.push((derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(1).replace(/\.0$/, '')) + '/s');
if (metrics.context_percent > 0) bits.push(metrics.context_percent + '% ctx');
if (cost !== null && cost !== undefined) bits.push(_formatCost(cost));
summary.textContent = bits.join(' · ');
summary.title = bits.length ? 'Response summary: ' + bits.join(', ') : '';
}
function _appendPaneMessage(hist, role, text) {
const message = document.createElement('div');
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
const roleEl = document.createElement('div');
roleEl.className = 'role';
roleEl.textContent = role === 'user' ? 'You' : 'AI';
const body = document.createElement('div');
body.className = 'body';
body.textContent = text || '';
message.appendChild(roleEl);
message.appendChild(body);
hist.appendChild(message);
return message;
}
function _createPaneContinuationMessage(hist) {
const message = _appendPaneMessage(hist, 'assistant', '');
const body = message.querySelector('.body');
if (spinnerModule) {
const spinner = spinnerModule.create('Continuing...', 'right');
body.appendChild(spinner.createElement());
spinner.start();
message._spinner = spinner;
}
return message;
}
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
const hist = document.getElementById('cmp-history-' + paneIdx);
const restored = _renderPaneAskUserCard(
paneIdx,
sessionId,
submission.payload || {},
hist,
null,
originController,
);
if (uiModule) {
uiModule.showError(
restored
? 'This pane is still streaming — choose again once it settles.'
: 'Compare pane is still streaming; the choice was not sent.',
);
}
return restored;
}
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
const startedAt = Date.now();
const resume = () => {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
const activeController = state._abortControllers[paneIdx];
if (activeController === originController) {
if (Date.now() - startedAt < 10000) {
setTimeout(resume, 25);
return;
}
// The originating stream never released the pane. The card was already
// removed when the choice was accepted, so put it back rather than
// swallowing a decision the user made.
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
return;
}
// A reroll/model replacement already owns this pane. Never send the stale
// choice into that replacement stream or session UI.
if (activeController) return;
const hist = document.getElementById('cmp-history-' + paneIdx);
if (!hist) return;
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
const isApproval = submission.kind === 'tool_approval';
const message = isApproval ? '' : String(submission.text || submission.label || '');
if (!isApproval) _appendPaneMessage(hist, 'user', message);
const aiMessage = _createPaneContinuationMessage(hist);
hist.scrollTop = hist.scrollHeight;
const resumeOptions = { skipBadge: true };
if (isApproval) {
resumeOptions.toolApproval = {
approval_id: String(submission.approval_id || ''),
decision: String(submission.decision || '').toLowerCase(),
};
}
_setCompareBusy(true);
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
.catch((error) => {
console.error('Compare pane continuation failed:', error);
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
})
.finally(_syncCompareBusyFromPanes);
};
setTimeout(resume, 0);
return true;
}
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
if (aiMsgEl && aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
const card = renderAskUserCard(payload, {
root: hist,
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
paneIdx,
sessionId,
originController,
submission,
),
});
if (card) {
card.dataset.comparePane = String(paneIdx);
card.dataset.compareSession = String(sessionId);
}
return card;
}
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
@@ -36,9 +247,12 @@ function _renderSearchResults(data) {
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';
const safeUrl = _safeHttpHref(r.url);
if (safeUrl) {
titleLink.href = safeUrl;
titleLink.target = '_blank';
titleLink.rel = 'noopener noreferrer';
}
titleLink.className = 'search-result-title';
titleLink.textContent = r.title || 'Untitled';
card.appendChild(titleLink);
@@ -143,6 +357,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
// Show stop button for this pane
const _paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneEl) {
_paneEl.classList.remove('is-done', 'is-failed', 'is-awaiting-input');
_paneEl.classList.add('is-streaming');
_setPaneSummary(paneIdx, null, null);
const _stopBtn = _paneEl.querySelector('.pane-stop-btn');
if (_stopBtn) _stopBtn.style.display = '';
}
@@ -151,6 +368,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
let metrics = null;
let timedOut = false;
let streamOk = false;
let awaitingChoice = 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
@@ -206,6 +424,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const fd = new FormData();
fd.append('message', message);
fd.append('session', sessionId);
if (opts.toolApproval) {
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
}
// Compare mode determines what tools/features are enabled
const isAgent = state._compareMode === 'agent';
@@ -243,7 +465,10 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
const response = await fetch(`${state.API_BASE}/api/chat_stream`, {
method: 'POST', body: fd, signal: ac.signal
method: 'POST',
body: fd,
headers: _compareTimezoneHeaders(),
signal: ac.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -309,6 +534,38 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// ── Pane-local question / approval selector ──
} else if (json.type === 'ask_user') {
awaitingChoice = true;
const paneEl = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (paneEl) paneEl.classList.add('is-awaiting-input');
_renderPaneAskUserCard(
paneIdx,
sessionId,
json.data || {},
hist,
aiMsgEl,
ac,
);
if (hist) hist.scrollTop = hist.scrollHeight;
// Deny ends as a tiny resolution-only stream, so replace the
// continuation spinner with an explicit pane-local result.
} else if (json.type === 'tool_approval_resolved') {
if (aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
let target = aiMsgEl._textEl;
if (!target) {
target = document.createElement('div');
target.className = 'compare-text-content';
aiBody.appendChild(target);
aiMsgEl._textEl = target;
}
target.textContent = accumulated;
// ── Tool start (bash, web search agent tool) ──
} else if (json.type === 'tool_start') {
// Finalize any accumulated text before the tool block
@@ -344,7 +601,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
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.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(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');
@@ -363,28 +620,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (json.image_url) {
// Stop image spinner and render generated image in pane
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
const safeImageUrl = safeDisplayImageSrc(json.image_url);
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);
if (!safeImageUrl) {
aiBody.textContent = '[Image unavailable]';
} else {
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = safeImageUrl;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
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: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
}
// 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; }
@@ -398,7 +660,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
currentToolBlock.className = 'agent-thread-node' + (ok ? '' : ' error');
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
// The chevron is drawn by CSS; leaving a literal ▶ here created
// two arrows in the completed tool rows.
currentToolBlock.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${escapeHtml(tLabel)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron"></span></div><div class="agent-thread-content">${cmdHtml}${outHtml}</div>`;
currentToolBlock.querySelector('.agent-thread-header').addEventListener('click', () => currentToolBlock.classList.toggle('open'));
currentToolBlock = null;
// Reset text element so next deltas create a fresh container
@@ -455,6 +719,12 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
finalTarget.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
}
// Preserve the client-side time-to-first-token measurement with the
// server metrics so it appears in the compare summary and footer.
if (metrics && _ttft > 0 && metrics.client_ttft == null) {
metrics.client_ttft = Number((_ttft / 1000).toFixed(3));
}
// ── Show play button if response contains HTML ──
if (_autoPreviewHtml) _autoPreviewHtml(paneIdx, accumulated);
@@ -533,23 +803,51 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
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';
const outputTokens = metrics.output_tokens;
const responseTime = metrics.response_time ?? metrics.total_time;
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
const numericOutput = Number(outputTokens);
const numericTime = Number(responseTime);
const numericTps = Number(explicitTps);
const derivedTps = Number.isFinite(numericTps)
? numericTps
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
? numericOutput / numericTime
: null;
const tpsLabel = derivedTps != null
? (derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(2).replace(/\.?0+$/, ''))
: null;
const parts = [];
if (Number.isFinite(Number(ttft)) && Number(ttft) > 0) {
parts.push('TTFT ' + Number(ttft).toFixed(3) + 's');
}
if (outputTokens != null && outputTokens !== 'undefined') {
parts.push(outputTokens + ' tokens');
}
if (tpsLabel != null) {
parts.push(tpsLabel + ' tok/s');
}
if (responseTime != null && responseTime !== 'undefined' && parts.length === 0) {
parts.push(responseTime + '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);
_setPaneSummary(paneIdx, metrics, _cost);
// Build the metrics span with optional cost and context
span.textContent = text;
span.textContent = parts.join(' | ');
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';
costSpan.textContent = (span.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';
ctx.textContent = (span.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);
@@ -557,6 +855,21 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
footer.appendChild(span);
aiMsgEl.appendChild(footer);
}
const footerMetrics = aiMsgEl?.querySelector('.msg-footer:last-child .response-metrics');
if (footerMetrics) {
const thinkingMode = state._paneGenerationSettings[paneIdx]?.thinking_mode;
if (thinkingMode === 'off') {
const thinkingState = document.createElement('span');
thinkingState.className = 'response-thinking-state';
thinkingState.textContent = (footerMetrics.textContent ? ' | ' : '') + 'Thinking off';
footerMetrics.appendChild(thinkingState);
}
footerMetrics.dataset.action = 'settings';
footerMetrics.dataset.pane = String(paneIdx);
footerMetrics.setAttribute('role', 'button');
footerMetrics.setAttribute('tabindex', '0');
footerMetrics.title = 'Response details and inference settings';
}
if (hist) hist.scrollTop = hist.scrollHeight;
} catch (error) {
@@ -599,19 +912,25 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
// TTFT removed from the header per user request — just show total time.
_timerEl.textContent = _formatMs(_totalMs);
}
state._abortControllers[paneIdx] = null;
if (state._abortControllers[paneIdx] === ac) {
state._abortControllers[paneIdx] = null;
}
// Hide stop button, show response action buttons
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneElFinal) {
_paneElFinal.classList.remove('is-streaming');
_paneElFinal.classList.toggle('is-awaiting-input', awaitingChoice);
_paneElFinal.classList.toggle('is-done', streamOk && !awaitingChoice);
_paneElFinal.classList.toggle('is-failed', !streamOk && !awaitingChoice);
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
if (accumulated.trim()) {
if (!awaitingChoice && accumulated.trim()) {
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
state._paneMetrics[paneIdx] = metrics;
state._paneElapsed[paneIdx] = _totalMs;
if (!opts.skipBadge) {
if (!opts.skipBadge && !awaitingChoice) {
if (streamOk) {
state._finishOrder++;
if (state._parallel) {
@@ -641,12 +960,14 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
if (streamOk && state._expectedAnswer) {
if (streamOk && !awaitingChoice && 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 = '');
if (paneEl && !awaitingChoice && accumulated.trim()) {
paneEl.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
}
@@ -686,9 +1007,12 @@ function _stampGradeBadge(paneIdx, response, expected) {
badge.className = 'pane-grade-badge ' + (pass ? 'pass' : 'fail');
badge.title = pass ? 'Response contains the expected answer' : 'Expected answer not found in response';
badge.textContent = pass ? '✓' : '✗';
// Insert just before the finish badge if present, else after the title
// The two-row pane header keeps result badges inside .pane-stats.
// Always insert relative to the finish badge's actual parent.
const finBadge = header.querySelector('.pane-finish-badge');
if (finBadge) header.insertBefore(badge, finBadge);
const stats = header.querySelector('.pane-stats');
if (finBadge?.parentNode) finBadge.parentNode.insertBefore(badge, finBadge);
else if (stats) stats.appendChild(badge);
else header.appendChild(badge);
}
+6 -6
View File
@@ -2,10 +2,10 @@
import Storage from '../storage.js';
import state from './state.js';
import { _modelDisplayNames } from './models.js';
import { getModelCost } from '../chatRenderer.js';
import uiModule from '../ui.js';
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js';
import { showScoreboard } from './scoreboard.js';
import { getModelCost } from '../chatRenderer.js?v=20260910streamlinks2';
import uiModule from '../ui.js?v=20260908weekhoverfix1';
import { VOTES_STORAGE_KEY, VOTES_MAX } from './icons.js?v=20260908compareprompts1';
import { showScoreboard } from './scoreboard.js?v=20260909voteconfirmalign1';
var escapeHtml = uiModule.esc;
@@ -74,7 +74,7 @@ function buildVoteBar(n) {
// before a prompt) since viewing the scoreboard is always allowed.
const scoreBtn = document.createElement('button');
scoreBtn.className = 'compare-vote-btn compare-score-btn';
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>Score';
scoreBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg><span class="compare-score-label">Score</span>';
scoreBtn.title = 'Scoreboard';
scoreBtn.addEventListener('click', () => showScoreboard());
bar.insertBefore(scoreBtn, tieBtn); // furthest left, before Tie
@@ -181,7 +181,7 @@ function handleVote(winnerIdx) {
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;
if (isWinner) html = '<span style="color:var(--green, #50fa7b);margin-right:4px;">&#x2605;</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--green, #50fa7b);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:0;">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;
+171
View File
@@ -0,0 +1,171 @@
/**
* ArrowUp on the composer recalls previous user messages from this chat.
*/
/**
* User bubbles in the active chat surface (#chat-history), newest first, using
* dataset.raw (same source as resend/regenerate in chat.js).
*
* @param {Document | Element} [root=document]
* @returns {string[]}
*/
export function getUserMessagesFromChatHistory(root = document) {
const chatBox =
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
? root
: (root.getElementById ? root.getElementById('chat-history') : null);
if (!chatBox) return [];
const users = chatBox.querySelectorAll('.msg-user');
const prompts = [];
for (let i = users.length - 1; i >= 0; i--) {
const msg = users[i];
const bodyEl = msg.querySelector('.body');
const text = msg.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
if (text) prompts.push(text);
}
return prompts;
}
/**
* Last user bubble in the active chat surface (#chat-history).
*
* @param {Document | Element} [root=document]
* @returns {string}
*/
export function getLastUserMessageFromChatHistory(root = document) {
return getUserMessagesFromChatHistory(root)[0] || '';
}
/**
* @param {HTMLTextAreaElement} composer
* @param {() => string|string[]} getUserMessages
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
* @returns {boolean} true when wired (or already wired)
*/
export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
if (!composer) return false;
if (composer._arrowUpRecallWired) return true;
composer._arrowUpRecallWired = true;
const { autoResize } = options;
let recallIndex = -1;
let applyingRecall = false;
let lastRecalledValue = '';
let recallHistory = [];
const readHistory = () => {
const value = getUserMessages?.();
if (Array.isArray(value)) return value.filter(Boolean);
return value ? [value] : [];
};
const norm = (value) => String(value || '').replace(/\r\n/g, '\n').trimEnd();
const debug = (...args) => {
try {
if (localStorage.getItem('odysseusArrowRecallDebug') === '1') {
console.debug('[arrow-recall]', ...args);
}
} catch (_) {}
};
composer.addEventListener('input', () => {
if (applyingRecall) return;
if (norm(composer.value) === norm(lastRecalledValue)) return;
recallIndex = -1;
lastRecalledValue = '';
recallHistory = [];
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
});
composer.addEventListener('keydown', (e) => {
// Prompt history: ArrowUp walks older, ArrowDown walks newer/back to blank.
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.isComposing) return;
if (typeof window !== 'undefined' && window._ghostAutocomplete?.isActive?.()) return;
const freshHistory = readHistory();
const history = freshHistory.length ? freshHistory : recallHistory;
if (!history.length) {
debug('skip:no-history', { value: composer.value });
return;
}
const rawCurrentValue = String(composer.value || '');
const currentValue = norm(rawCurrentValue);
const recalledValue = norm(lastRecalledValue);
let currentIndex = rawCurrentValue === ''
? -1
: history.findIndex((item) => norm(item) === currentValue);
if (currentIndex < 0 && currentValue && currentValue === recalledValue) {
currentIndex = recallIndex;
}
if (currentIndex < 0 && currentValue) {
const markedIndex = Number(composer.dataset?.odysseusRecallIndex);
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
currentIndex = markedIndex;
}
}
if (rawCurrentValue !== '' && currentIndex < 0) {
debug('skip:draft-in-progress', { value: composer.value });
return;
}
e.preventDefault();
e.stopPropagation?.();
e.stopImmediatePropagation?.();
if (e.key === 'ArrowDown') {
if (currentIndex < 0) return;
const nextIndex = currentIndex - 1;
if (nextIndex < 0) {
recallIndex = -1;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = '';
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
composer.value = '';
try { composer.selectionStart = composer.selectionEnd = 0; } catch (_) {}
if (autoResize) autoResize(composer);
debug('handled-down-clear', { historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
return;
}
const recalled = history[nextIndex];
recallIndex = nextIndex;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = recalled;
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
composer.value = recalled;
try { composer.selectionStart = composer.selectionEnd = recalled.length; } catch (_) {}
if (autoResize) autoResize(composer);
debug('handled-down', { nextIndex, recalled, historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
return;
}
// ArrowUp walks older prompts. An unmatched draft already returned above,
// so reaching here means the composer is empty or holds a recalled prompt
// — the caret-navigation case is never hijacked.
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
const recalled = history[nextIndex];
if (!recalled) {
debug('skip:no-recalled', { nextIndex, history });
return;
}
recallIndex = nextIndex;
recallHistory = history;
applyingRecall = true;
lastRecalledValue = recalled;
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
composer.value = recalled;
try {
composer.selectionStart = composer.selectionEnd = recalled.length;
} catch (_) {}
if (autoResize) autoResize(composer);
debug('handled', { nextIndex, recalled, historyLength: history.length });
setTimeout(() => { applyingRecall = false; }, 0);
}, true);
return true;
}
+189
View File
@@ -0,0 +1,189 @@
// Per-backend × per-model install recipes for the Dependencies tab.
//
// Each entry says: when you're about to serve `model` on `backend`, here's
// the exact shell sequence to make the venv + install the right packages.
// Entries are matched first-hit; put the more specific patterns ABOVE the
// generic fallback for that backend.
// Recipes carry two variants per entry:
// variants.pip → install into the configured venv via pip/uv
// variants.docker → pull the official container image
//
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
// (env_prefix handles activation for Run). The docker variant skips the
// activate line — `docker pull` doesn't need a venv.
const _RECIPES = [
// ── vllm ──────────────────────────────────────────────────────────────
// MiniMax M2/M2.7 — same as the generic vllm install/image for now;
// kept as its own entry so future model-specific patches land in one
// obvious place without touching the catch-all.
{
backend: 'vllm',
label: 'MiniMax M2 / M2.7',
match: (m) => /minimax[-_]?m\s?2(\.7)?/i.test(m || ''),
variants: {
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
},
},
// Generic vllm fallback.
{
backend: 'vllm',
label: 'Any vLLM model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
},
},
// ── sglang ────────────────────────────────────────────────────────────
{
backend: 'sglang',
label: 'Any SGLang model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U "sglang[all]" --torch-backend auto'] },
docker: { commands: ['docker pull lmsysorg/sglang:latest'] },
},
},
// ── MLX ───────────────────────────────────────────────────────────────
{
backend: 'mlx_lm',
label: 'Any MLX model',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U mlx-lm'] },
},
},
{
backend: 'mflux',
label: 'mflux-compatible MLX image models',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U mflux fastapi uvicorn python-multipart'] },
},
},
{
backend: 'boogu_image_mlx',
label: 'MLX image models (Boogu)',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow'] },
},
},
{
backend: 'mlx_vlm',
label: 'MLX image models (HiDream)',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm "transformers>=4.57.0,<6.0" huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer'] },
},
},
{
backend: 'mlx_lama_swift',
label: 'MLX image editing (LaMa / MI-GAN)',
match: () => true,
variants: {
pip: {
commands: [
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-inpaint',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-inpaint" "$HOME/.local/bin/odysseus-mlx-inpaint"',
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
],
},
},
},
{
backend: 'mlx_ddcolor_swift',
label: 'MLX image editing (DDColor)',
match: () => true,
variants: {
pip: {
commands: [
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-colorize',
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-colorize" "$HOME/.local/bin/odysseus-mlx-colorize"',
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
],
},
},
},
// ── Diffusers ────────────────────────────────────────────────────────
{
backend: 'diffusers',
label: 'Any Diffusers image model',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U "diffusers[torch]" torchvision accelerate scipy python-multipart'] },
},
},
{
backend: 'krea_diffusers',
label: 'Latest Diffusers from Git',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart'] },
},
},
{
backend: 'sam_mask',
label: 'SAM object mask tools',
match: () => true,
variants: {
pip: { commands: ['python -m pip install -U torch torchvision transformers accelerate pillow'] },
},
},
// ── llama.cpp ─────────────────────────────────────────────────────────
{
backend: 'llama_cpp',
label: 'Any GGUF model',
match: () => true,
variants: {
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
},
},
];
export const RECIPE_VARIANTS = ['pip', 'docker'];
export const RECIPE_DEFAULT_VARIANT = 'pip';
// Get the commands array for a recipe + variant. Falls back to pip when
// the requested variant isn't defined for the recipe.
export function recipeCommands(recipe, variant) {
if (!recipe) return [];
const v = (recipe.variants || {})[variant] || (recipe.variants || {}).pip;
return (v && v.commands) || [];
}
// Backends we surface a recipe panel for. Other rows in the Dependencies
// list keep the existing flat Install/Reinstall button without an expand
// affordance.
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'mflux', 'boogu_image_mlx', 'mlx_vlm', 'mlx_lama_swift', 'mlx_ddcolor_swift', 'diffusers', 'krea_diffusers', 'sam_mask', 'llama_cpp']);
// All recipe entries for a given backend, in catalog order. The first one
// is the model-specific match (when present); the last is always the
// generic fallback.
export function recipesForBackend(backend) {
return _RECIPES.filter((r) => r.backend === backend);
}
// Pick the best recipe for a backend + model id. Returns the catalog
// fallback when nothing more specific matches, or null if the backend
// isn't in the catalog at all.
export function pickRecipe(backend, modelId) {
const candidates = recipesForBackend(backend);
if (!candidates.length) return null;
for (const r of candidates) {
try { if (r.match(modelId)) return r; } catch (_) {}
}
return candidates[candidates.length - 1] || null;
}
+718 -110
View File
@@ -22,23 +22,280 @@ import {
// Plain specifier (no ?v=) — must match every other cookbook.js importer so the
// browser loads it once. See cookbook-hwfit.js.
} from './cookbook.js';
import uiModule from './ui.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
// Tiny HTML-escape — keeps the file standalone instead of leaning on a
// shared helper that may not be exported from this module's import surface.
function _diagEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _diagFetchWithTimeout(input, init = {}, timeoutMs = 75000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const parentSignal = init.signal;
const abortFromParent = () => controller.abort();
if (parentSignal) {
if (parentSignal.aborted) controller.abort();
else parentSignal.addEventListener('abort', abortFromParent, { once: true });
}
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
clearTimeout(timer);
parentSignal?.removeEventListener('abort', abortFromParent);
});
}
// Pick an icon for a diagnosis-action button based on the label. The icon
// renders on the LEFT of the button text. Keeps the strokes consistent
// across the set so they read as one family.
function _diagFixIcon(label) {
const l = String(label || '').toLowerCase();
const _svg = (path) => `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="cookbook-diag-btn-ico" aria-hidden="true">${path}</svg>`;
if (l.startsWith('retry') || l.includes('relaunch') || l.includes('restart')) {
// Circular-arrow refresh
return _svg('<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>');
}
if (l.startsWith('copy')) {
return _svg('<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"/>');
}
if (l.startsWith('edit')) {
return _svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
}
if (l.startsWith('open') || l.includes('dependencies')) {
return _svg('<path d="M14 3h7v7"/><path d="M21 3l-9 9"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>');
}
if (l.startsWith('install') || l.includes('upgrade')) {
return _svg('<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"/>');
}
if (l.startsWith('kill') || l.startsWith('stop')) {
return _svg('<rect x="6" y="6" width="12" height="12" rx="1"/>');
}
if (l.startsWith('switch') || l.includes('use ')) {
return _svg('<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"/>');
}
// Default: lightbulb (generic "suggestion")
return _svg('<path d="M9 21h6"/><path d="M12 17v4"/><path d="M12 3a6 6 0 0 0-4 10.5c1 1 1.5 2 1.5 3.5h5c0-1.5.5-2.5 1.5-3.5A6 6 0 0 0 12 3Z"/>');
}
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;
// Re-exported so callers (Launch-tab pre-flight) can deep-link into the
// Dependencies tab + auto-expand a specific backend's recipe panel and
// pre-select the model they were trying to launch.
export function openCookbookDependencies(pkgName = '', opts = {}) {
_openCookbookDependencies(pkgName, opts);
}
function _openCookbookDependencies(pkgName = '', opts = {}) {
const cookbook = window.cookbookModule;
if (cookbook && typeof cookbook.open === 'function') {
cookbook.open({ tab: 'Dependencies', dependencyModel: opts.model || '' });
} else {
document.getElementById('tool-cookbook-btn')?.click();
}
const wanted = String(pkgName || '').toLowerCase();
const tryHighlight = (attempt = 0) => {
const modal = document.getElementById('cookbook-modal');
const tab = modal?.querySelector('.cookbook-tab[data-backend="Dependencies"]');
if (tab && !tab.classList.contains('active')) tab.click();
const rows = [...document.querySelectorAll('#cookbook-deps-list [data-pkg-name]')];
if (!rows.length) {
if (attempt < 45) setTimeout(() => tryHighlight(attempt + 1), 100);
return;
}
if (!wanted) return;
const row = rows.find(r => {
const name = (r.dataset.pkgName || '').toLowerCase();
const pip = (r.dataset.depPip || '').toLowerCase();
return name === wanted || pip.includes(wanted) || wanted.includes(name);
});
if (row) {
row.scrollIntoView({ block: 'center' });
row.classList.add('cookbook-pkg-flash');
setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 1800);
// Pre-flight deep link: auto-expand the recipe panel + pre-select
// the model the user was trying to launch. The dropdown values are
// now full model ids (sourced from _cachedModelIds), so we match by
// exact value first, then fall back to a substring match.
if (opts.expandRecipe) {
const caret = row.querySelector('[data-dep-recipe-toggle]');
if (caret && caret.getAttribute('aria-expanded') !== 'true') caret.click();
if (opts.model) {
const sel = document.querySelector(`[data-dep-recipe-pick="${CSS.escape(opts.expandRecipe)}"]`);
if (sel) {
const wanted = String(opts.model);
let matched = false;
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === wanted) {
sel.value = wanted; matched = true; break;
}
}
if (!matched) {
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value && wanted.includes(sel.options[i].value)) {
sel.value = sel.options[i].value; matched = true; break;
}
}
}
if (matched) sel.dispatchEvent(new Event('change'));
}
}
}
}
};
tryHighlight();
}
function _openServeEditFromDiagnosis(panel, fields = null) {
const task = panel?.closest?.('.cookbook-task');
if (!task) return;
task.dispatchEvent(new CustomEvent('cookbook:edit-serve', { bubbles: true, detail: { fields } }));
}
function _openCpuServeEdit(panel) {
_openServeEditFromDiagnosis(panel, {
backend: 'llamacpp',
gpus: '',
tp: '1',
gpu_mem: '0.80',
_forceBackend: true,
});
}
function _taskForDiagnosisPanel(panel) {
const taskEl = panel?.closest?.('.cookbook-task');
const taskId = taskEl?.dataset?.taskId || '';
if (!taskId) return null;
return (_loadTasks() || []).find(t => t.sessionId === taskId) || null;
}
function _pythonFromServeCmd(cmd) {
const s = String(cmd || '');
const abs = s.match(/(?:^|\s)(\/[^\s]+\/bin\/python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
if (abs) return abs[1];
const rel = s.match(/(?:^|\s)(python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
return rel ? rel[1] : '';
}
function _pythonForDiagnosisPanel(panel) {
const task = _taskForDiagnosisPanel(panel);
const fromCmd = _pythonFromServeCmd(task?.payload?._cmd || '');
if (fromCmd) return fromCmd;
return (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
: 'python3';
}
function _sglangKernelRepairCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U --force-reinstall --no-cache-dir sglang-kernel`;
}
function _mlxLmInstallCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U mlx-lm`;
}
async function _repairSglangKernel(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Repairing sglang-kernel on the selected server...');
await _launchServeTask(
'repair-sglang-kernel',
'pip-update',
_sglangKernelRepairCommand(panel),
null,
task?.remoteHost || undefined,
task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null,
);
}
async function _installMlxLm(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Installing MLX LM on the selected server...');
await _launchServeTask(
'install-mlx-lm',
'pip-update',
_mlxLmInstallCommand(panel),
null,
task?.remoteHost || undefined,
_diagnosisTargetMeta(task),
);
}
function _diagnosisTargetMeta(task) {
return task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null;
}
function _gpuCleanupCommand() {
return `set -u
echo "[odysseus] Clearing GPU compute processes..."
if command -v nvidia-smi >/dev/null 2>&1; then
pids="$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | tr -d " " | grep -E "^[0-9]+$" | sort -u)"
if [ -z "$pids" ]; then
echo "[odysseus] No NVIDIA compute processes found."
exit 0
fi
echo "[odysseus] GPU PIDs: $pids"
ps -fp $pids 2>/dev/null || true
echo "[odysseus] Sending TERM..."
kill -TERM $pids || true
sleep 3
alive=""
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then alive="$alive $pid"; fi
done
if [ -n "$alive" ]; then
echo "[odysseus] Force killing remaining GPU PIDs:$alive"
kill -KILL $alive || true
fi
sleep 1
remaining="$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | sed "/^$/d" || true)"
if [ -n "$remaining" ]; then
echo "[odysseus] GPU processes still remain:"
echo "$remaining"
exit 2
fi
echo "[odysseus] GPU cleanup complete. No NVIDIA compute processes remain."
else
echo "[odysseus] nvidia-smi not found; falling back to common model-server process cleanup."
pkill -TERM -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
sleep 3
pkill -KILL -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
echo "[odysseus] Fallback cleanup complete."
fi`;
}
async function _clearGpuProcesses(panel) {
uiModule.showToast('Clearing GPU compute processes on the selected server...');
await _runQuickCmd(panel, _gpuCleanupCommand());
}
export const ERROR_PATTERNS = [
{
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
message: 'tmux is missing on this server.',
suggestion: 'Suggested action: open Dependencies and install tmux on the selected server.',
fixes: [
{ label: 'Open tmux dependency', action: () => _openCookbookDependencies('tmux') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y tmux') },
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -S --needed tmux') },
],
},
{
pattern: /Port \d+ is already serving|port is occupied by a different model|choose another port before launching/i,
message: 'Serve port is already occupied by another model.',
suggestion: 'Suggested action: stop the old server or choose a different port before relaunching.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy check command', action: () => _copyText('curl http://127.0.0.1:PORT/v1/models') },
],
},
{
pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i,
message: 'No GPU memory left for KV cache after loading model.',
@@ -57,6 +314,39 @@ export const ERROR_PATTERNS = [
{ label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') },
],
},
{
pattern: /Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static|Raise --mem-fraction-static above/i,
message: 'SGLang static memory fraction is too low for the loaded weights.',
suggestion: 'Suggested action: retry with --mem-fraction-static 0.80 so weights fit and KV cache can still allocate.',
fixes: [
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Retry mem 0.82', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.82') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /get_paged_mqa_logits_metadata|deepseek_v4_backend\.py|paged_mqa_metadata\.cuh:113.*CUDA error:\s*invalid argument/i,
message: 'SGLang DeepSeek-V4 attention metadata kernel failed on this GPU/runtime.',
suggestion: 'Suggested action: stop retrying graph/memory tweaks for this exact FP8 command. SGLangs RTX PRO 6000 recipe uses the original deepseek-ai/DeepSeek-V4-Flash checkpoint with --moe-runner-backend marlin, not the converted sgl-project FP8 checkpoint. Try that recipe/checkpoint, official SGLang container/nightly, or supported Hopper/Blackwell hardware.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /Capture cuda graph failed|cuda graph failed|paged_mqa_metadata|cuda-graph-backend-decode|cuda-graph-max-bs-decode|CUDA error:\s*invalid argument/i,
message: 'SGLang failed while capturing decode CUDA graphs.',
suggestion: 'Suggested action: disable SGLang decode CUDA graph for this launch. DeepSeek-V4 is reaching graph capture, but this kernel is failing on the target hardware.',
fixes: [
{ label: 'Disable decode graph', action: (panel) => _serveAutoRetryReplace(panel, '--cuda-graph-backend-decode', 'disabled') },
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
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.',
@@ -70,17 +360,24 @@ export const ERROR_PATTERNS = [
},
{
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.',
message: 'FP8 MoE quantization is incompatible with this tensor-parallel split.',
suggestion: 'Suggested action: retry with a lower tensor-parallel size, such as TP=4 or TP=2. If it still fails, use a non-FP8/GGUF version of the model.',
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);
}},
{ label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
{ label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /There is no module or parameter named ['"]lm_head\.input_scale['"]|lm_head\.input_scale|weight_scale_2/i,
message: 'vLLM cannot load this ModelOpt LM-head quantized checkpoint with the current runtime.',
suggestion: 'Suggested action: upgrade vLLM through the environment that provides this CLI (package manager, venv, Docker image, or source checkout), or choose a compatible checkpoint.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('vllm') },
{
label: 'Copy upgrade hint',
action: () => _copyText('Upgrade the vLLM environment that provides the selected vllm CLI, or use a compatible checkpoint. Do not assume Odysseus owns PATH/system/source/Docker installs.'),
},
],
},
{
@@ -157,11 +454,10 @@ export const ERROR_PATTERNS = [
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);
const repo = (gated && gated[1]) || (base && base[1]);
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else if (model && model[1]) window.open('https://huggingface.co/' + model[1].replace(/[.]$/, ''), '_blank');
}},
@@ -171,13 +467,21 @@ export const ERROR_PATTERNS = [
}},
],
},
{
pattern: /OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed/i,
message: 'This image model uses a custom Diffusers pipeline that your launch environment does not know yet.',
fixes: [
{ label: 'Update image dependencies', action: () => _openCookbookDependencies('diffusers') },
{ label: 'Copy diagnosis', action: (_panel, _text) => navigator.clipboard?.writeText(_text || '') },
],
},
{
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.',
message: 'Single-file model may need an explicit base config. Add --single-file-config <repo_or_path> if the checkpoint is missing components.',
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);
const repo = gated && gated[1];
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
}},
@@ -205,6 +509,18 @@ export const ERROR_PATTERNS = [
{ label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) },
],
},
{
pattern: /memory capacity is unbalanced|Some GPUs may be occupied by other processes|pre_model_load_memory=.*local_gpu_memory/i,
message: 'SGLang refused to start because free GPU memory is uneven across the selected tensor-parallel GPUs.',
suggestion: 'Suggested action: run Clear GPUs, then relaunch. If it still fails, choose only equally free GPUs or lower TP/context.',
fixes: [
{ label: 'Clear GPUs', action: (panel) => _clearGpuProcesses(panel) },
{ label: 'Copy clear command', action: () => _copyText(_gpuCleanupCommand()) },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Set TP to 1', action: (panel) => _setPanelField(panel, 'tp', '1') },
{ label: 'Lower context', action: (panel) => _setPanelField(panel, 'ctx', '32768') },
],
},
{
pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i,
message: 'Context length too large for available GPU memory.',
@@ -218,6 +534,7 @@ export const ERROR_PATTERNS = [
pattern: /vllm.*command not found|No module named vllm/i,
message: 'vLLM is not installed or not in PATH.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('vllm') },
{ label: 'Check environment is set', action: (panel) => {
const el = panel.querySelector('[data-field="env_type"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
@@ -225,12 +542,67 @@ export const ERROR_PATTERNS = [
],
},
{
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.',
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|(?:Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|Could not load any common_ops library|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native kernel/runtime is missing or mismatched on this server.',
suggestion: 'Suggested action: relaunch with Odysseus venv CUDA library path fix. If the venv does not contain the matching NVIDIA runtime libs, run Repair sglang-kernel.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Repair sglang-kernel', action: (panel) => _repairSglangKernel(panel) },
{ label: 'Copy repair command', action: (panel) => _copyText(_sglangKernelRepairCommand(panel)) },
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
{
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
message: 'SGLang is not installed or not in PATH.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
],
},
{
pattern: /No module named ['"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed/i,
message: 'MLX LM is not installed on this server.',
suggestion: 'Suggested action: install mlx-lm in the selected Python environment. MLX serving is intended for Apple Silicon Macs.',
fixes: [
{ label: 'Install MLX LM', action: (panel) => _installMlxLm(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
],
},
{
pattern: /mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['"]?mflux/i,
message: 'MLX image serving requires mflux on this Apple Silicon server.',
suggestion: 'Suggested action: install mflux in the selected Python environment. This is for MLX image generation, not text MLX-LM.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mflux') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mflux fastapi uvicorn') },
],
},
{
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
suggestion: 'Suggested action: relaunch from the cached local snapshot path. Odysseus now rewrites MLX repo-id launches to the newest local Hugging Face snapshot when it exists on the selected Mac.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
message: 'SGLang needs a visible GPU/accelerator on this server.',
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
fixes: [
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /flashinfer.*version.*does not match|flashinfer-cubin version/i,
message: 'FlashInfer version mismatch.',
@@ -241,8 +613,12 @@ export const ERROR_PATTERNS = [
},
{
pattern: /torch\.cuda\.is_available\(\).*False|No CUDA runtime/i,
message: 'CUDA not available in this environment.',
fixes: [],
message: 'vLLM needs a visible CUDA/ROCm GPU.',
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
fixes: [
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /Engine core initialization failed/i,
@@ -280,19 +656,27 @@ export const ERROR_PATTERNS = [
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);
{ label: 'Update vLLM on server', action: () => {
// Use the venv's python3 by absolute path when configured (SSH non-
// interactive sessions often pick user-site Python over the venv).
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm transformers`);
}},
],
},
{
pattern: /Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels\/layer/i,
message: 'Transformers/kernels package mismatch.',
fixes: [
{ label: 'Repair kernel package', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages "kernels<0.15"`);
}},
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
{
pattern: /ollama.*command not found/i,
message: 'Ollama is not installed on this server. Run: curl -fsSL https://ollama.com/install.sh | sh',
@@ -300,32 +684,82 @@ export const ERROR_PATTERNS = [
{ label: 'Copy install command', action: () => _copyText('curl -fsSL https://ollama.com/install.sh | sh') },
],
},
// System build deps must be checked BEFORE the llama-server catch-all:
// a `cmake: command not found` failure ALSO produces `llama-server:
// command not found` later in the script (the build aborts then the
// run line fails) — pattern order is first-match-wins, so without
// these specific entries the user gets the misleading "install
// llama-cpp-python[server]" suggestion when the actual blocker is a
// missing OS-package toolchain that pip can't ship.
{
pattern: /cmake: command not found|cmake.*not found.*Could not/i,
message: 'cmake is required to compile llama.cpp from source, but it is not installed on this server.',
suggestion: 'Suggested action: install cmake via the OS package manager — apt: cmake build-essential / pacman: cmake base-devel / dnf: cmake gcc-c++ make / brew: cmake. Cookbook can do this automatically on the next launch if your user has passwordless sudo for apt/pacman/dnf.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y cmake build-essential git') },
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -Sy --needed cmake base-devel git') },
{ label: 'Copy dnf install', action: () => _copyText('sudo dnf install -y cmake gcc gcc-c++ make git') },
],
},
{
pattern: /^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler/i,
message: 'A C/C++ compiler (build-essential / base-devel) is required to compile llama.cpp.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y build-essential') },
],
},
{
pattern: /^git: command not found/i,
message: 'git is required to clone the llama.cpp source tree.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y git') },
],
},
{
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: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
{ 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',
pattern: /Windows Error 0xc000001d|Illegal instruction|0xc000001d/i,
message: 'AVX2 Instruction Set Mismatch: the precompiled llama-cpp-python wheel requires CPU features (AVX2/FMA) that your processor or virtual machine lacks.',
suggestion: 'Suggested action: switch this serve config to Ollama (highly recommended, has dynamic CPU fallbacks), or choose a remote Linux GPU server.',
fixes: [
{ label: 'Copy install command', action: () => _copyText('pip install diffusers transformers accelerate') },
{ label: 'Switch to Ollama', action: (panel) => _openServeEditFromDiagnosis(panel, { backend: 'ollama' }) },
{ label: 'Choose remote server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /CUDA Toolkit not found|Unable to find cudart library|missing:\s*CUDA_CUDART/i,
message: 'llama.cpp found nvcc, but the CUDA runtime library is missing.',
suggestion: 'Suggested action: relaunch with the updated runner so llama.cpp builds CPU-only, or install a complete CUDA toolkit/runtime on this server for GPU llama.cpp.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
],
},
{
pattern: /No module named ['"]?torch|No module named ['"]?torchvision|No module named ['"]?diffusers|No module named ['"]?scipy|install scipy if you want to use beta sigmas|requires the Torchvision library|diffusers.*command not found/i,
message: 'Diffusion serving needs PyTorch, Torchvision, Diffusers, Accelerate, and SciPy. Install Diffusers image deps from Cookbook → Dependencies.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]" torchvision accelerate scipy python-multipart') },
],
},
{
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);
{ label: 'Update triton on server', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-triton', 'pip-update', `${_vp} -m pip install -U triton triton-kernels`);
}},
],
},
@@ -347,36 +781,104 @@ export const ERROR_PATTERNS = [
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);
{ label: 'Update vLLM on server', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm`);
}},
],
},
{
// 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.
// FlashInfer JIT-compiles attention kernels for the host GPU on first
// use. If the system /usr/bin/nvcc is older than CUDA 11.8 it can't
// target sm_89/sm_90 (Ada/Hopper), and the engine workers die before
// they can report a useful traceback. Two quick paths out: pick a
// non-flashinfer attention backend, or set CUDACXX to a newer nvcc
// (vLLM installs nvidia-cuda-nvcc into the venv — point at that).
pattern: /nvcc fatal\s+:\s+Unsupported gpu architecture 'compute_\d+'/i,
message: 'FlashInfer is JIT-compiling sampling kernels with an nvcc too old for this GPU (no sm_89 / sm_90 support — pre-CUDA 11.8). Changing the attention backend does not help — flashinfer JITs the SAMPLER too. The clean fix is to set VLLM_USE_FLASHINFER_SAMPLER=0 so vLLM uses its native sampler instead.',
suggestion: 'Suggested action: relaunch with VLLM_USE_FLASHINFER_SAMPLER=0 prepended. (Confirmed on the QuantTrio/Qwen3.5 model card as the canonical workaround.)',
fixes: [
{ label: 'Retry with VLLM_USE_FLASHINFER_SAMPLER=0', action: (panel) => _serveAutoRetryReplace(panel, '', 'VLLM_USE_FLASHINFER_SAMPLER=0 ', { prepend: true }) },
{ label: 'Uninstall flashinfer-python', action: () => {
// Hard fallback: vLLM 0.22 reaches into flashinfer for sampling kernels
// even with VLLM_USE_FLASHINFER_SAMPLER=0 in some configs. Removing
// the package forces it onto the native sampler.
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('uninstall-flashinfer', 'pip-update', `${_vp} -m pip uninstall flashinfer-python -y`);
}},
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
// vLLM <-> torch ABI mismatch: vLLM imports torch.library helpers
// (`infer_schema`, `register_fake`, etc.) that only exist on newer torch
// versions. When the installed torch is older, the import fails before
// any server code runs. Fix is to reinstall vllm (which pulls a matching
// torch) or upgrade torch directly.
pattern: /ImportError: cannot import name '[^']+' from 'torch(\.\w+)+'/i,
message: 'vLLM was built against a newer torch than what is installed. Reinstall vLLM so pip pulls a compatible torch (or upgrade torch directly).',
fixes: [
{ label: 'Reinstall vLLM (pulls matching torch)', action: () => {
// Absolute path to the venv's python3 — bare `python3` lands in the
// wrong site-packages over SSH when ~/.local/bin precedes the venv.
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('reinstall-vllm', 'pip-reinstall', `${_vp} -m pip install --force-reinstall vllm`);
}},
{ label: 'Upgrade torch only', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('upgrade-torch', 'pip-update', `${_vp} -m pip install -U torch`);
}},
],
},
{
// Dependency-install (pip) build failure — a required package failed to
// build its wheel (common when an old sdist's setup.py breaks on a newer
// Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
// problem, so it must never suggest killing vLLM.
match: (text) => {
const TAIL = text.slice(-6000);
// A serve script can run a fallback build and then start serving fine —
// don't flag a stale build error once the server is up.
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
},
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
fixes: [],
},
{
// vLLM-specific traceback: only offer the kill-processes recovery when the
// output is actually about vLLM. Tail-only + healthy-server suppression so
// a one-shot startup traceback doesn't stick on the panel forever while
// the server happily serves /v1/models.
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;
return /vllm/i.test(TAIL);
},
message: 'Python traceback detected — may be a handled error, check logs.',
message: 'A vLLM process hit a Python traceback and may be wedged.',
fixes: [
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
],
},
{
// Generic traceback (not vLLM, not a pip build): surface it without
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
match: (text) => {
const TAIL = text.slice(-4096);
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
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 — check the captured output below for the underlying error.',
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
fixes: [],
},
];
export function _diagnose(text) {
@@ -387,10 +889,32 @@ export function _diagnose(text) {
return null;
}
function _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText) {
const lines = ['## Odysseus Cookbook troubleshooting'];
if (task) {
lines.push(
'',
'### Task',
`- ID: ${task.sessionId || task.id || 'unknown'}`,
`- Type: ${task.type || 'unknown'}`,
`- Status: ${task.status || 'unknown'}`,
`- Model: ${task.payload?.repo_id || task.name || 'unknown'}`,
`- Host: ${task.remoteHost || 'local'}${task.sshPort ? `:${task.sshPort}` : ''}`,
);
}
lines.push('', '### Diagnosis', diagnosis?.message || '(none)');
if (suggestionText) lines.push('', '### Suggested action', suggestionText.replace(/^Suggested action:\s*/i, ''));
const cmd = task?.payload?._cmd || '';
if (cmd) lines.push('', '### Launch command', '```bash', cmd, '```');
if (sourceText) lines.push('', '### Captured output', '```text', String(sourceText).trim(), '```');
return lines.join('\n');
}
export function _showDiagnosis(panel, diagnosis, sourceText) {
if (panel._lastDiagMsg === diagnosis.message) return;
if (panel._diagDismissed === diagnosis.message) return; // stay dismissed until new error
const wasCollapsed = panel._lastDiagMsg === diagnosis.message && panel._diagCollapsed;
if (panel._diagDismissed === diagnosis.message) return;
panel._lastDiagMsg = diagnosis.message;
panel._diagCollapsed = !!wasCollapsed;
let diag = panel.querySelector('.cookbook-diagnosis');
if (!diag) {
@@ -402,53 +926,121 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
}
diag.classList.remove('hidden');
diag.innerHTML = '';
const taskEl = panel?.closest?.('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const fixes = [...(diagnosis.fixes || [])];
if (task?.type === 'serve' && task.payload?._cmd && !fixes.some(f => f.label === 'Edit serve')) {
fixes.push({ label: 'Edit serve', action: (p) => _openServeEditFromDiagnosis(p) });
}
const suggestionText = diagnosis.suggestion || (fixes.length
? `Suggested action: ${fixes[0].label}.`
: 'Suggested action: copy the error and adjust the serve settings.');
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
panel._diagCollapsed = false;
// Top-right toolbar: Copy bundle + × dismiss. Restored after user feedback
// — without them there's no way to quietly close a stale diagnosis or grab
// the full error+context for a forum/discord paste.
const toolbar = document.createElement('div');
toolbar.className = 'cookbook-diag-toolbar';
// Left side carries the diagnosis text (message + suggestion); buttons
// stay on the right. Was a separate body row below the toolbar, but
// the message reads more like "this is what the toolbar is for" when
// it sits inline with Copy / × Dismiss.
toolbar.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:-2px;';
const textWrap = document.createElement('div');
textWrap.style.cssText = 'flex:1;min-width:0;font-size:11px;line-height:1.35;';
const msg = document.createElement('div');
msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message;
header.appendChild(msg);
textWrap.appendChild(msg);
const suggestion = document.createElement('div');
suggestion.className = 'cookbook-diag-suggestion';
suggestion.textContent = suggestionText;
suggestion.style.cssText = 'opacity:0.75;margin-top:1px;';
textWrap.appendChild(suggestion);
toolbar.appendChild(textWrap);
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);
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'cookbook-diag-copy';
copyBtn.title = 'Copy diagnosis details';
copyBtn.setAttribute('aria-label', 'Copy diagnosis');
copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const bundle = _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText);
// Use the shared helper which falls back to execCommand('copy') on
// non-HTTPS origins (Tailscale IPs, LAN IPs, etc.) — navigator.clipboard
// is silently a no-op on those, which is why the button appeared dead
// for users on http://100.113.161.2:7011 over Tailscale/mobile.
const ok = await _copyText(bundle);
if (ok) {
copyBtn.classList.add('copied');
setTimeout(() => { if (copyBtn.isConnected) copyBtn.classList.remove('copied'); }, 1200);
}
});
diag.appendChild(header);
const dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.className = 'cookbook-diag-dismiss';
dismissBtn.title = 'Dismiss diagnosis';
dismissBtn.setAttribute('aria-label', 'Dismiss');
dismissBtn.textContent = '×';
dismissBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel._diagDismissed = diagnosis.message;
_clearDiagnosis(panel);
});
if (diagnosis.fixes && diagnosis.fixes.length) {
toolbar.appendChild(copyBtn);
toolbar.appendChild(dismissBtn);
diag.appendChild(toolbar);
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
if (!fix || !button || button.dataset.busy) return;
button.dataset.busy = '1';
const _orig = button.textContent;
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
button.textContent = '';
button.appendChild(wp.element);
const _lbl = document.createElement('span');
_lbl.textContent = busyLabel;
_lbl.style.verticalAlign = 'middle';
button.appendChild(_lbl);
try {
if (typeof onStart === 'function') onStart();
await fix.action(panel, sourceText);
} catch (err) {
console.error('[cookbook] diagnosis fix failed', err);
} finally {
if (button.isConnected) {
try { wp.destroy(); } catch {}
button.textContent = _orig;
delete button.dataset.busy;
}
if (typeof onDone === 'function') onDone();
}
};
if (fixes.length) {
// Always render fixes as inline buttons. The old "Actions ▾" dropdown
// (for >3 fixes) was broken — the menu wouldn't open in some panels and
// hid useful actions behind a non-working affordance. Inline buttons wrap
// naturally in `.cookbook-diag-fixes` (flex-wrap) so a long list reflows
// onto multiple rows instead of getting collapsed.
const row = document.createElement('div');
row.className = 'cookbook-diag-fixes';
for (const fix of diagnosis.fixes) {
for (const fix of 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; }
}
btn.type = 'button';
btn.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
btn.addEventListener('click', (e) => {
e.stopPropagation();
runFix(fix, btn);
});
row.appendChild(btn);
}
@@ -465,22 +1057,38 @@ export function _clearDiagnosis(panel) {
// ── Quick command ──
export async function _runQuickCmd(panel, cmd) {
const task = _taskForDiagnosisPanel(panel);
let fullCmd = cmd;
if (_envState.remoteHost) {
fullCmd = _sshCmd(_envState.remoteHost, cmd);
const host = task?.remoteHost || _envState.remoteHost || '';
const port = task?.sshPort || task?.payload?.ssh_port || _envState.sshPort || '';
if (host) {
fullCmd = _sshCmd(host, cmd, port);
}
const diag = panel.querySelector('.cookbook-diagnosis');
if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; }
if (diag) {
diag.classList.remove('hidden');
diag.innerHTML = '<div class="cookbook-diag-message">Running command...</div>';
}
try {
const res = await fetch('/api/shell/stream', {
const res = await _diagFetchWithTimeout('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
body: JSON.stringify({ command: fullCmd, timeout: 60 }),
});
if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`;
const data = await res.json().catch(() => ({}));
const out = [data.stdout, data.stderr].filter(Boolean).join('\n').trim();
const ok = res.ok && Number(data.exit_code ?? 1) === 0;
if (diag) {
diag.innerHTML = ''
+ `<div class="cookbook-diag-message">${ok ? 'Command completed.' : 'Command failed.'}</div>`
+ `<div class="cookbook-diag-suggestion" style="opacity:0.75;margin-top:1px;">Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}</div>`
+ (out ? `<pre class="cookbook-diag-output" style="margin:6px 0 0;white-space:pre-wrap;max-height:180px;overflow:auto;font-size:10px;line-height:1.35;">${_diagEsc(out)}</pre>` : '');
}
} catch (e) {
if (diag) diag.textContent = `Error: ${e.message}`;
if (diag) {
diag.innerHTML = `<div class="cookbook-diag-message">Command error.</div><div class="cookbook-diag-suggestion">${_diagEsc(e.message)}</div>`;
}
}
}
+1462 -134
View File
File diff suppressed because it is too large Load Diff
+2407 -339
View File
File diff suppressed because it is too large Load Diff
+199 -36
View File
@@ -4,7 +4,7 @@
// panel rendering, command building
// ============================================
import uiModule from './ui.js';
import uiModule from './ui.js?v=20260908weekhoverfix1';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
// Shared state/functions injected by init()
@@ -12,8 +12,10 @@ let _envState;
let _sshCmd;
let _getPort;
let _getPlatform;
let _serverByVal;
let _isWindows;
let _buildEnvPrefix;
let _psQuote;
let _buildServeCmd;
let _detectBackend;
let _detectToolParser;
@@ -30,6 +32,13 @@ let _saveTasks;
// Storage keys
const SERVE_STATE_KEY = 'cookbook-serve-state';
const _downloadStartsInFlight = new Set();
function _fetchDownloadControlWithTimeout(input, init = {}, timeoutMs = 20000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
}
// ── Panel field helpers ──
@@ -57,21 +66,87 @@ export function _setPanelCheckbox(panel, field, checked) {
// ── Command builder: download ──
function _firstGgufSource(model) {
const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
return sources.find(src => src && src.repo) || null;
}
function _looksLikeGgufRepo(model) {
const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
}
function _ggufDownloadSource(model, backend) {
if (backend !== 'llamacpp') return null;
const source = _firstGgufSource(model);
if (source) return source;
if (_looksLikeGgufRepo(model)) {
const repo = model?.quant_repo || model?.repo_id || model?.name;
if (repo) return { repo };
}
return null;
}
function _ggufIncludePattern(model, source) {
if (source?.file) return source.file;
if (model?.quant) return `*${model.quant}*`;
return '*.gguf';
}
function _ggufDisplayPartFromInclude(include) {
const clean = String(include || '').replace(/\*/g, '');
const parts = clean.split('/').filter(Boolean);
const file = parts[parts.length - 1] || clean;
const dir = parts.length > 1 ? parts[parts.length - 2] : '';
const quant = `${dir} ${file}`.match(/\b(?:UD-)?(?:IQ[1-8]_[A-Z0-9]+|Q[2-8]_K_[MLS]|Q[2-8]_[0-9A-Z]+|Q[2-8])\b/i);
if (quant) return quant[0].toUpperCase().replace(/^UD-/, '');
return file.replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/i, '');
}
function _downloadTaskName(shortName, payload) {
const include = payload?.include || '';
const part = include ? _ggufDisplayPartFromInclude(include) : '';
return part ? `${shortName} · ${part}` : shortName;
}
function _missingGgufMessage(model) {
const name = model?.name || 'this model';
if (/\bnvfp4\b/i.test(name)) {
return `${name} is an NVIDIA NVFP4 checkpoint, not a GGUF download. Pick the base model row with an Unsloth GGUF source, or paste the GGUF repo directly.`;
}
return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
}
function _bashQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
function _missingGgufCommand(model) {
const msg = _missingGgufMessage(model);
if (_isWindows()) {
return `Write-Error ${JSON.stringify(msg)}; exit 1`;
}
return `printf '%s\\n' ${_bashQuote(msg)} >&2; exit 1`;
}
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 "
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
cmd = _missingGgufCommand(model);
} else {
const repo = ggufSource?.repo || model.name;
const includePattern = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
const includeArg = includePattern ? `, allow_patterns=["${includePattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` : '';
// Reflect the server's download target in the preview (matches the real
// download path built server-side). '' = default HF cache.
const _dlDir = (_serverByVal?.(_envState.remoteServerKey || _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'
@@ -125,6 +200,7 @@ try:
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;
@@ -190,11 +266,7 @@ export function _wirePanelEvents(panel, model, backend) {
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);
}
_runModelDownload(panel, model, backend)
});
}
@@ -214,7 +286,7 @@ export function _wirePanelEvents(panel, model, backend) {
const outputText = panel.querySelector('.cookbook-output-pre')?.textContent || '';
const tmuxMatch = outputText.match(/Started tmux session: (cookbook-[a-f0-9]+)/);
if (tmuxMatch) {
fetch('/api/shell/exec', {
_fetchDownloadControlWithTimeout('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -402,10 +474,15 @@ export async function _runPanelCmd(panel, cmd, opts = {}) {
// ── 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;
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
uiModule.showToast(_missingGgufMessage(model));
return;
}
const repo = backend === 'ollama'
? (model.ollama || model.ollama_name || model.name)
: (ggufSource?.repo || model.quant_repo || model.name);
const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
_syncEnvFromPanel(panel);
@@ -415,41 +492,61 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// 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;
let selectedServer = null;
let selectedServerKey = '';
if (hostOverride !== undefined) {
host = hostOverride || '';
selectedServer = host ? (_serverByVal?.(host) || (_envState.servers || []).find(s => s.host === host) || null) : null;
selectedServerKey = selectedServer ? (typeof window.cookbookModule?._serverKey === 'function' ? window.cookbookModule._serverKey(selectedServer) : '') : '';
} 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).
// Dropdown values are profile keys now ('local' for local); stale host
// strings and numeric indices still resolve for backwards compatibility.
const _ssv = ssEl ? ssEl.value : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
selectedServer = _dsrv;
selectedServerKey = _ssv || '';
} else if (ssEl && ssEl.value === 'local') {
host = '';
} else {
host = _envState.remoteHost || '';
selectedServer = host ? ((_envState.servers || []).find(s => s.host === host) || _serverByVal?.(host) || null) : null;
}
}
const srv = _envState.servers.find(s => s.host === host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const srv = selectedServer || _serverByVal?.(host) || {};
let env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
if ((!env || env === 'none') && envPath) {
env = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(envPath) ? 'venv' : env;
}
const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows();
const payload = { repo_id: repo };
const payload = { repo_id: repo, backend };
if (include) payload.include = include;
// Large downloads are where hf_transfer most often dies near the end. Use the
// plain HuggingFace downloader up front for big model files; it is slower, but
// resumes cached partials more reliably.
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
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 (host) {
payload.remote_host = host;
if (selectedServerKey && selectedServerKey !== 'local') payload.remote_server_key = selectedServerKey;
if (srv.name) payload.remote_server_name = srv.name;
const _sp = srv.port || _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');
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + envPath;
}
@@ -462,41 +559,105 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
}
const shortName = (model.name || repo).split('/').pop();
const taskName = _downloadTaskName(shortName, payload);
const targetHost = host || 'local';
const tasks = _loadTasks();
const sameDownload = (t) => {
if (!t || t.type !== 'download') return false;
const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
};
const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
if (duplicate) {
_renderRunningTab();
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
return;
}
// Also catch zombie "done" tasks — the cookbook may have lost track of a
// download (server restart, stale state) while its tmux session is still
// alive on the host. Probe it; if alive, flip back to running + treat as
// duplicate so we don't kick off a second concurrent download writing to
// the same target dir.
const zombieCandidate = tasks.find(t => sameDownload(t)
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
&& t.sessionId && !String(t.sessionId).startsWith('queue-'));
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)
|| (_envState.servers || []).find(s => s.host === _zh) || {}).port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : '';
const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await _fetchDownloadControlWithTimeout('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
});
const _d = await _r.json();
if (_d.exit_code === 0) {
// tmux still alive → not actually done. Revive + tell the user.
const _fresh = _loadTasks();
const _ft = _fresh.find(t => t.sessionId === zombieCandidate.sessionId);
if (_ft) {
_ft.status = 'running';
_ft._selfHealed = true;
_saveTasks(_fresh);
}
_renderRunningTab();
uiModule.showToast(`${shortName} is still downloading (was marked finished after a restart — revived)`);
return;
}
} catch { /* probe failed — fall through and let the user launch */ }
}
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 });
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host, remoteServerKey: payload.remote_server_key || '', remoteServerName: payload.remote_server_name || '', sshPort: payload.ssh_port || '', platform: payload.platform || '' });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
return;
}
// The task list is persisted only after the POST returns. Guard the gap so
// rapid clicks (or two touch events) cannot start duplicate downloads before
// either request has registered its task.
const startKey = `${targetHost}\n${payload.repo_id}`;
if (_downloadStartsInFlight.has(startKey)) {
uiModule.showToast(`${shortName} download is already starting`);
return;
}
_downloadStartsInFlight.add(startKey);
try {
const res = await fetch('/api/model/download', {
const res = await _fetchDownloadControlWithTimeout('/api/model/download', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
uiModule.showToast('Download failed: HTTP ' + res.status);
// Errors carry actionable text (e.g. "tmux is required …"); keep them up
// long enough to read, matching the serve path's duration (issue #1355).
uiModule.showToast('Download failed: HTTP ' + res.status, 9000);
return;
}
const data = await res.json();
if (!data.ok) {
uiModule.showToast('Download failed: ' + (data.error || ''));
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
return;
}
_addTask(data.session_id, shortName, 'download', payload);
uiModule.showToast(`Downloading ${shortName}...`);
_addTask(data.session_id, taskName, 'download', payload);
uiModule.showToast(`Downloading ${taskName}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message);
uiModule.showToast('Download failed: ' + e.message, 9000);
} finally {
_downloadStartsInFlight.delete(startKey);
}
}
@@ -507,8 +668,10 @@ export function initDownload(shared) {
_sshCmd = shared._sshCmd;
_getPort = shared._getPort;
_getPlatform = shared._getPlatform;
_serverByVal = shared._serverByVal;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_psQuote = shared._psQuote;
_buildServeCmd = shared._buildServeCmd;
_detectBackend = shared._detectBackend;
_detectToolParser = shared._detectToolParser;
+19
View File
@@ -0,0 +1,19 @@
// Pure port helpers extracted so they're unit-testable without the
// browser-bound rest of cookbookRunning.js (issue #4507 follow-up).
// Read the port out of a serve launch command. Handles --port 8000,
// --port=8000, -p 8000, and -p=8000. Returns '' when none is present.
export function portOf(cmd) {
const s = cmd || '';
const m = s.match(/--port[=\s]+(\d+)/) || s.match(/(?:^|\s)-p[=\s]+(\d+)/);
return m ? m[1] : '';
}
// Lowest free port >= start that isn't in usedPorts (array or Set of
// numbers/strings). Returns a string to match the serve command format.
export function nextFreePort(usedPorts, start = 8000) {
const used = new Set([...usedPorts].map(p => parseInt(p, 10)));
let port = start;
while (used.has(port)) port++;
return String(port);
}
+29
View File
@@ -0,0 +1,29 @@
// static/js/cookbookProgressSignal.js
/**
* Liveness signal for a running cookbook download/install. The watchdog treats a
* task as stalled when this signal stays unchanged for too long, so it must move
* whenever the task is genuinely making progress.
*
* During a model DOWNLOAD the honest signal is the downloaded-byte counter
* ("1.81G" from "1.81G/2.49G"): it climbs while transferring and freezes when
* stuck and unlike a % bar or speed/ETA it doesn't keep animating on a frozen
* frame. That path is kept exactly as-is.
*
* But a dependency install (e.g. vllm) spends long stretches with NO byte
* counter pip dependency resolution and the native CUDA build/compile. A
* byte-only signal freezes there, so the watchdog falsely declares the install
* stale and restarts it mid-build, looping forever (#1568). When there's no byte
* counter, fall back to a fingerprint of the output tail: resolver/compile lines
* keep changing while the process is alive, and only a truly hung process leaves
* the tail frozen.
*
* Pure (string in, string out) so it's unit-testable; cookbookRunning.js pulls
* in browser-only modules and can't load under node.
*/
export function computeProgressSignal(bytes, dlAgg, lastPct, snapshot) {
if (bytes) return bytes;
const base = dlAgg != null ? String(dlAgg) : (lastPct || '0');
// No byte counter → use the output tail so a build/resolve phase that emits new
// lines counts as progress instead of a false stall (#1568).
return base + '|' + String(snapshot || '').slice(-300);
}
+2188 -379
View File
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
// Cookbook Schedule — opens a small inline form (styled with the app's
// existing .cookbook-* classes) that creates a ScheduledTask with
// action=cookbook_serve. Mounted from two places:
//
// 1. The ^ button next to Launch in a serve panel.
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
// programmatically clicks the ^ button so this module owns the
// single source of truth).
//
// Feedback uses uiModule.showToast() — the same toast the rest of the
// app uses for "Saved", "Favorited", etc. — so the success message
// doesn't introduce a parallel notification style.
//
// To remove: delete this file + the <script> tag in index.html + the
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
// registration line in app.py.
try { (function () {
function _safe(fn) {
return function () {
try { return fn.apply(this, arguments); }
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function fetchWithTimeout(input, init = {}, timeoutMs = 20000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const parentSignal = init.signal;
const abortFromParent = () => controller.abort();
if (parentSignal) {
if (parentSignal.aborted) controller.abort();
else parentSignal.addEventListener("abort", abortFromParent, { once: true });
}
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
clearTimeout(timer);
parentSignal?.removeEventListener("abort", abortFromParent);
});
}
// Cached handle to the ui.js showToast function. Bound lazily on
// first use because ui.js is an ES module — it's not on `window`
// unless something else has explicitly exposed it.
let _toastFn = null;
async function _getToast() {
if (_toastFn) return _toastFn;
try {
const m = await import("/static/js/ui.js?v=20260908weekhoverfix1");
_toastFn = m.default?.showToast || m.showToast || null;
} catch (_) { _toastFn = null; }
return _toastFn;
}
// Optional opts: {action, onAction, duration, leadingIcon}
async function toast(msg, opts) {
const fn = await _getToast();
if (fn) {
try { fn(msg, opts); return; } catch (_) {}
}
try { console.log("[toast]", msg); } catch (_) {}
}
// Cached handle to the tasks module so the success toast's "Open"
// action can jump straight to the new task in the Tasks tab.
let _tasksMod = null;
async function _getTasksMod() {
if (_tasksMod) return _tasksMod;
try { _tasksMod = await import("/static/js/tasks.js?v=20260901taskskilldensity1"); } catch (_) {}
return _tasksMod;
}
async function openTaskInTasksTab(taskId) {
const m = await _getTasksMod();
if (m && typeof m.openTasks === "function") {
try { m.openTasks(taskId); return; } catch (_) {}
}
// Last-resort fallback: click the sidebar Tasks button.
document.getElementById("tool-tasks-btn")?.click();
}
const DAYS = [
{ k: "MO", l: "Mon", idx: 0 },
{ k: "TU", l: "Tue", idx: 1 },
{ k: "WE", l: "Wed", idx: 2 },
{ k: "TH", l: "Thu", idx: 3 },
{ k: "FR", l: "Fri", idx: 4 },
{ k: "SA", l: "Sat", idx: 5 },
{ k: "SU", l: "Sun", idx: 6 },
];
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
// Resolve the model identity from the closest .memory-item card —
// that's the canonical container the cookbook serve UI uses, with
// the model repo on data-repo. We do NOT grab the title via
// textContent, because the title row also contains inline status
// pills ("running", "downloading") and an "HF ↗" link — pulling all
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
// preset lookup in action_cookbook_serve.
function readPanelConfig(arrowBtn) {
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
const panel = arrowBtn.closest(".hwfit-serve-panel");
const repo = item?.dataset?.repo
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|| "";
// Title = last segment of the repo (after the final /), which is
// exactly what the cookbook UI renders in the card title and what
// the preset registry uses as its short name. e.g.
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
// Falls back to data-modelName or the bare repo for ollama-style
// entries that don't have a slash.
let title = "";
if (repo) {
title = repo.includes("/") ? repo.split("/").pop() : repo;
}
if (!title) {
title = item?.dataset?.modelName || "model";
}
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
}
function buildFormHtml(cfg) {
return `
<div class="hwfit-schedule-form cookbook-panel">
<div class="hwfit-schedule-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
<span class="hwfit-schedule-title-spacer"></span>
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
<span class="admin-switch hwfit-schedule-mirror-switch">
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
<span class="admin-slider"></span>
</span>
</label>
</div>
<div class="hwfit-schedule-row hwfit-schedule-when-row">
<label class="hwfit-schedule-field">
<span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
</label>
<label class="hwfit-schedule-field">
<span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label>
<label class="hwfit-schedule-field hwfit-schedule-days-field">
<span>Days</span>
<div class="hwfit-sched-days">
${DAYS.map(d => `
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
`).join("")}
</div>
</label>
<div class="hwfit-schedule-actions-inline">
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span>Cancel</span>
</button>
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Save</span>
</button>
</div>
</div>
<div class="hwfit-sched-err"></div>
</div>`;
}
function openForm(arrowBtn) {
const cfg = readPanelConfig(arrowBtn);
const anchor = cfg.panel
|| cfg.item
|| arrowBtn.closest(".cookbook-saved-item")
|| arrowBtn.parentElement?.parentElement
|| arrowBtn.parentElement;
if (!anchor) {
toast("Couldn't find a panel to mount the schedule form");
return;
}
// Toggle.
const existing = anchor.querySelector(".hwfit-schedule-form");
if (existing) { existing.remove(); return; }
const tmp = document.createElement("div");
tmp.innerHTML = buildFormHtml(cfg);
const form = tmp.firstElementChild;
anchor.appendChild(form);
setTimeout(() => {
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
}, 50);
wireForm(form, cfg);
}
function wireForm(form, cfg) {
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
});
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
const startTime = form.querySelector(".hwfit-sched-start").value;
const endTime = form.querySelector(".hwfit-sched-end").value;
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
const errEl = form.querySelector(".hwfit-sched-err");
errEl.textContent = "";
errEl.classList.remove("is-visible");
function fail(msg) {
errEl.textContent = msg;
errEl.classList.add("is-visible");
}
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
return fail("Start and end must be HH:MM");
}
if (!days.length) {
return fail("Pick at least one day");
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
let dur = (eh * 60 + em) - (sh * 60 + sm);
if (dur <= 0) dur += 24 * 60;
// The backend stores scheduled_time as UTC. The user picks
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
// shows ~9 hours later instead of "in 5 min". Mirror what
// tasks.js does via its _localTimeToUtc helper.
const _localHHMMToUtc = (hhmm) => {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};
const startUtc = _localHHMMToUtc(startTime);
const [shUtc, smUtc] = startUtc.split(":").map(Number);
const allDays = days.length === 7;
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
const sched = {};
if (allDays) {
sched.schedule = "daily";
sched.scheduled_time = startUtc;
} else if (weekdaysOnly) {
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
} else if (days.length === 1) {
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
sched.schedule = "weekly";
sched.scheduled_time = startUtc;
sched.scheduled_day = dayIdx;
} else {
const dayNum = days.map(k => {
const i = DAYS.find(d => d.k === k).idx;
return i === 6 ? 0 : i + 1;
});
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
}
// Name: "Serve: <full model name>" — pulled from .memory-item-title
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
// not a placeholder like "model".
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
const payload = {
name: `Serve: ${fullName}`,
task_type: "action",
action: "cookbook_serve",
trigger_type: "schedule",
prompt: JSON.stringify({
preset: fullName,
repo_id: cfg.repo_id || "",
host: cfg.host || "",
end_after_min: dur,
}),
...sched,
};
const saveBtn = form.querySelector(".hwfit-sched-save");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
const r = await fetchWithTimeout("/api/tasks", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok || data.error) {
fail(data.error || data.detail || `HTTP ${r.status}`);
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
return;
}
if (mirrorToCalendar) {
// Mirror onto a dedicated "Cookbook" calendar so the user can
// toggle the whole set on/off as a unit in the calendar UI.
// Best-effort: if anything here fails, we still consider the
// task creation a success (the task itself works regardless).
try {
const calsRes = await fetchWithTimeout("/api/calendar/calendars", { credentials: "same-origin" });
const calsBody = calsRes.ok ? await calsRes.json() : {};
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
if (!cookbookCal) {
const mk = await fetchWithTimeout("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
method: "POST", credentials: "same-origin",
});
if (mk.ok) {
const mkData = await mk.json();
// The create endpoint returns {ok, id, name, color}; the
// list endpoint returns {href, name, color}. The two map
// 1:1 (href === id) so we synthesize the same shape.
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
}
}
// The `cookbook_task_id:` marker on its own line lets
// calendar.js's event-form code detect that this event was
// created from a Cookbook schedule and render an
// "Open task" button alongside the description, so the user
// can jump straight to the source task from the calendar UI.
const evBody = {
summary: payload.name,
dtstart: new Date().toISOString(),
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
all_day: false,
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
+ `cookbook_task_id: ${data.id || ""}`,
rrule: weekdaysOnly
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
color: "#3b82f6",
};
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
const evRes = await fetchWithTimeout("/api/calendar/events", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(evBody),
});
const evData = evRes.ok ? await evRes.json() : null;
// Stash the event uid + calendar href on the task's prompt
// JSON so the task-delete hook can cascade the calendar
// cleanup. PATCH the task with an updated prompt.
if (evData && (evData.uid || evData.id)) {
const eventUid = evData.uid || evData.id;
try {
const updatedPrompt = JSON.stringify({
...JSON.parse(payload.prompt),
cookbook_event_uid: eventUid,
cookbook_event_calendar: cookbookCal?.href || "",
});
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
// here silently failed (no such method on that route), so
// the task never got the cookbook_event_uid marker and the
// server-side delete-cascade had nothing to follow when the
// user later deleted the task.
await fetchWithTimeout(`/api/tasks/${encodeURIComponent(data.id)}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: updatedPrompt }),
});
} catch (_) {}
}
} catch (_) {}
}
form.remove();
const newTaskId = data.id || data.task_id || "";
toast(`Created task: Serve: ${fullName}`, {
leadingIcon: "check",
action: "Open",
duration: 5000,
onAction: () => openTaskInTasksTab(newTaskId),
});
} catch (e) {
fail(String(e));
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${e}`);
}
}));
}
document.addEventListener("click", _safe((e) => {
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
if (!arrow) return;
e.preventDefault();
e.stopPropagation();
openForm(arrow);
}));
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }
+3247 -308
View File
File diff suppressed because it is too large Load Diff
+8177 -568
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
// Pure Markdown outline parsing kept separate from the editor DOM so heading
// detection can be tested without mounting the full document workspace.
function cleanHeadingLabel(value) {
return String(value || '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/[*_~`]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export function parseMarkdownOutline(source) {
const text = String(source || '').replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const offsets = [];
let offset = 0;
for (const line of lines) {
offsets.push(offset);
offset += line.length + 1;
}
const entries = [];
let fence = null;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (fenceMatch) {
const marker = fenceMatch[1][0];
if (!fence) fence = marker;
else if (fence === marker) fence = null;
continue;
}
if (fence) continue;
const atx = line.match(/^ {0,3}(#{1,6})[\t ]+(.+?)\s*$/);
if (atx) {
const rawLabel = atx[2].replace(/[\t ]+#+[\t ]*$/, '');
const label = cleanHeadingLabel(rawLabel);
if (label) {
entries.push({
level: atx[1].length,
text: label,
start: offsets[index],
end: offsets[index] + line.length,
line: index,
});
}
continue;
}
const setext = line.match(/^ {0,3}(=+|-+)[\t ]*$/);
if (setext && index > 0) {
const previous = lines[index - 1];
const label = cleanHeadingLabel(previous.trim());
if (label && !/^ {0,3}(?:[-*_][\t ]*){3,}$/.test(previous)) {
entries.push({
level: setext[1][0] === '=' ? 1 : 2,
text: label,
start: offsets[index - 1],
end: offsets[index - 1] + previous.length,
line: index - 1,
});
}
}
}
return entries;
}
+55
View File
@@ -0,0 +1,55 @@
// Unicode-aware text statistics shared by source and rich-text documents.
let _wordSegmenter;
let _graphemeSegmenter;
function _segmenter(granularity) {
if (typeof Intl === 'undefined' || typeof Intl.Segmenter !== 'function') return null;
try {
if (granularity === 'word') {
_wordSegmenter ||= new Intl.Segmenter(undefined, { granularity: 'word' });
return _wordSegmenter;
}
_graphemeSegmenter ||= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
return _graphemeSegmenter;
} catch (_) {
return null;
}
}
export function countDocumentWords(value) {
const text = String(value ?? '');
if (!text.trim()) return 0;
const segmenter = _segmenter('word');
if (segmenter) {
let count = 0;
for (const segment of segmenter.segment(text)) {
if (segment.isWordLike) count += 1;
}
return count;
}
return (text.match(/[\p{L}\p{N}\p{M}]+(?:['][\p{L}\p{N}\p{M}]+)*/gu) || []).length;
}
export function countDocumentCharacters(value) {
const text = String(value ?? '');
const segmenter = _segmenter('grapheme');
if (segmenter) return Array.from(segmenter.segment(text)).length;
return Array.from(text).length;
}
export function getDocumentStats(value) {
const text = String(value ?? '').replace(/\r\n?/g, '\n');
const words = countDocumentWords(text);
const characters = countDocumentCharacters(text);
const charactersNoSpaces = countDocumentCharacters(text.replace(/\s/gu, ''));
const lineText = text.replace(/\n+$/u, '');
const lines = lineText ? lineText.split('\n').length : 0;
return {
words,
characters,
charactersNoSpaces,
lines,
readingMinutes: words ? Math.max(1, Math.ceil(words / 225)) : 0,
};
}
+66
View File
@@ -184,6 +184,7 @@ export function enable(containerId, itemSelector, options = {}) {
let _touchItem = null;
function onTouchStart(e) {
if (_pointerId !== null) return;
// Don't start on buttons/inputs.
if (e.target.closest('button, input, select, a')) return;
// Respect handleSelector on touch too — long-press anywhere was
@@ -240,7 +241,68 @@ export function enable(containerId, itemSelector, options = {}) {
}
}
// Modern mobile browsers expose touch as pointer events. Use pointer
// capture for pen input so a reorder continues even when the row becomes
// absolutely positioned or the pen leaves its original handle. Finger
// gestures stay on the touch path below because browsers may cancel a
// synthetic pointer stream while preserving touch events.
let _pointerId = null;
function onPointerDown(e) {
if (e.pointerType !== 'pen') return;
if (_touchTimer || draggedEl) return;
if (e.target.closest('button, input, select, a')) return;
if (config.handleSelector && !e.target.closest(config.handleSelector)) return;
const item = e.target.closest(itemSelector);
if (!item || !container.contains(item)) return;
if (config.excludeSelector && item.matches(config.excludeSelector)) return;
_pointerId = e.pointerId;
_touchItem = item;
_touchStartY = e.clientY;
try { container.setPointerCapture(_pointerId); } catch {}
_touchTimer = setTimeout(() => {
_touchTimer = null;
if (!_touchItem) return;
if (navigator.vibrate) navigator.vibrate(30);
startDrag(_touchStartY, _touchItem);
_touchItem.classList.add('touch-dragging');
}, 400);
}
function onPointerMove(e) {
if (e.pointerId !== _pointerId) return;
if (_touchTimer) {
if (Math.abs(e.clientY - _touchStartY) > 10) {
clearTimeout(_touchTimer);
_touchTimer = null;
_touchItem = null;
}
return;
}
if (draggedEl) {
e.preventDefault();
moveDrag(e.clientY);
}
}
function onPointerEnd(e) {
if (e.pointerId !== _pointerId) return;
if (_touchTimer) {
clearTimeout(_touchTimer);
_touchTimer = null;
_touchItem = null;
} else if (draggedEl) {
draggedEl.classList.remove('touch-dragging');
endDrag();
}
try { container.releasePointerCapture(_pointerId); } catch {}
_pointerId = null;
}
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('pointerdown', onPointerDown);
container.addEventListener('pointermove', onPointerMove, { passive: false });
container.addEventListener('pointerup', onPointerEnd);
container.addEventListener('pointercancel', onPointerEnd);
container.addEventListener('touchstart', onTouchStart, { passive: true });
container.addEventListener('touchmove', onTouchMove, { passive: false });
container.addEventListener('touchend', onTouchEnd);
@@ -249,6 +311,10 @@ export function enable(containerId, itemSelector, options = {}) {
const instance = {
cleanup: () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('pointerdown', onPointerDown);
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('pointerup', onPointerEnd);
container.removeEventListener('pointercancel', onPointerEnd);
container.removeEventListener('touchstart', onTouchStart);
container.removeEventListener('touchmove', onTouchMove);
container.removeEventListener('touchend', onTouchEnd);
+211
View File
@@ -0,0 +1,211 @@
/** First-class adjustment-layer rendering and retained metadata helpers. */
import { applyAdjustment } from './fx/pixel-pass.js';
import { defaultAdjParams } from './layer-helpers.js';
import { renderWithLayerMasks } from './composite-helpers.js';
const TYPES = new Set([
'brightness-contrast',
'exposure',
'white-balance',
'hue-saturation',
'vibrance',
'black-white',
'shadows-highlights',
'levels',
'curves',
'color-balance',
'selective-color',
'gradient-map',
]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
export function normalizeAdjustmentData(value) {
const type = TYPES.has(value?.type) ? value.type : 'levels';
const defaults = defaultAdjParams(type);
const params = value?.params && typeof value.params === 'object'
? { ...clone(defaults), ...clone(value.params) }
: clone(defaults);
if (type === 'levels') {
params.channels = {
...clone(defaults.channels),
...(params.channels || {}),
};
for (const name of Object.keys(defaults.channels)) {
params.channels[name] = { ...clone(defaults.channels[name]), ...(params.channels[name] || {}) };
}
}
if (type === 'curves') {
params.points = {
...clone(defaults.points),
...(params.points || {}),
};
}
if (type === 'selective-color') {
params.ranges = {
...clone(defaults.ranges),
...(params.ranges || {}),
};
for (const name of Object.keys(defaults.ranges)) {
params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) };
}
}
return { type, params };
}
export function adjustmentSpec(layer) {
if (layer?._stagedAdj) return normalizeAdjustmentData(layer._stagedAdj);
return normalizeAdjustmentData(layer?.adjustment);
}
function documentCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
return canvas;
}
/**
* Replace the already-rendered content under an adjustment layer with its
* adjusted equivalent. Opacity, blend mode, masks, clipping, and group scope
* are applied by drawing back into the current stack target.
*/
export function drawAdjustmentLayer(target, editorState, layer, clippingBase, renderLayer) {
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas) return false;
// During comparison the underlying stack is already on the target. Leaving
// it untouched shows the true before state without creating a history entry.
if (layer._adjCompare) return true;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
// Large live previews use a bounded working surface. The staged layer is
// marked only while its popup is open; committed rendering stays full-size
// so export and reopen remain pixel-accurate.
const maxPreviewEdge = 1400;
const previewScale = layer._adjPreview
? Math.min(1, maxPreviewEdge / Math.max(width, height))
: 1;
const sourceWidth = Math.max(1, Math.round(width * previewScale));
const sourceHeight = Math.max(1, Math.round(height * previewScale));
const source = documentCanvas(sourceWidth, sourceHeight);
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
let adjusted = applyAdjustment(source, adjustmentSpec(layer));
if (previewScale !== 1) {
const expanded = documentCanvas(width, height);
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
adjusted = expanded;
}
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
if (layer.clipped && clippingBase) {
const clipped = documentCanvas(width, height);
const clippedCtx = clipped.getContext('2d');
clippedCtx.drawImage(adjusted, 0, 0);
clippedCtx.globalCompositeOperation = 'destination-in';
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
clippedCtx.drawImage(renderLayer(clippingBase), offset.x, offset.y);
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true;
}
/** Async adjustment rendering for live previews and large documents. */
export async function drawAdjustmentLayerAsync(target, editorState, layer, clippingBase, renderLayer, shouldContinue = () => true) {
if (!layer?.visible || layer.kind !== 'adjustment' || !target?.canvas || !shouldContinue()) return false;
if (layer._adjCompare) return true;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
const maxPreviewEdge = 1400;
const previewScale = layer._adjPreview
? Math.min(1, maxPreviewEdge / Math.max(width, height))
: 1;
const sourceWidth = Math.max(1, Math.round(width * previewScale));
const sourceHeight = Math.max(1, Math.round(height * previewScale));
const source = documentCanvas(sourceWidth, sourceHeight);
source.getContext('2d').drawImage(target.canvas, 0, 0, sourceWidth, sourceHeight);
let adjusted = await renderAdjustmentAsync(source, adjustmentSpec(layer), shouldContinue);
if (!shouldContinue() || !adjusted) return false;
if (previewScale !== 1) {
if (!shouldContinue()) return false;
const expanded = documentCanvas(width, height);
expanded.getContext('2d').drawImage(adjusted, 0, 0, width, height);
adjusted = expanded;
}
if (!shouldContinue()) return false;
adjusted = renderWithLayerMasks(adjusted, layer, { x: 0, y: 0 });
if (!shouldContinue()) return false;
if (layer.clipped && clippingBase) {
const clipped = documentCanvas(width, height);
const clippedCtx = clipped.getContext('2d');
clippedCtx.drawImage(adjusted, 0, 0);
clippedCtx.globalCompositeOperation = 'destination-in';
const offset = editorState.layerOffsets.get(clippingBase.id) || { x: 0, y: 0 };
const baseOutput = await renderLayer(clippingBase);
if (!shouldContinue() || !baseOutput) return false;
clippedCtx.drawImage(baseOutput, offset.x, offset.y);
clippedCtx.globalCompositeOperation = 'source-over';
adjusted = clipped;
}
target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(adjusted, 0, 0);
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
return true;
}
async function renderAdjustmentAsync(source, adjustment, shouldContinue) {
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
return applyAdjustment(source, adjustment);
}
let sourceBitmap;
try {
sourceBitmap = await createImageBitmap(source);
} catch {
return applyAdjustment(source, adjustment);
}
if (!shouldContinue()) {
sourceBitmap.close?.();
return null;
}
let worker;
try {
worker = new Worker(new URL('./adjustments-worker.js', import.meta.url), { type: 'module' });
} catch {
return shouldContinue() ? applyAdjustment(source, adjustment) : null;
}
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
worker.terminate();
sourceBitmap.close?.();
resolve(result);
};
worker.onmessage = event => {
const { bitmap, error } = event.data || {};
if (error || !bitmap || !shouldContinue()) {
bitmap?.close?.();
finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
return;
}
const output = documentCanvas(source.width, source.height);
output.getContext('2d').drawImage(bitmap, 0, 0);
bitmap.close?.();
finish(output);
};
// A stale worker must not trigger a full-resolution fallback. The caller
// will discard it, and doing the work here makes slider scrubbing worse.
worker.onerror = () => finish(shouldContinue() ? applyAdjustment(source, adjustment) : null);
worker.postMessage({ source: sourceBitmap, adjustment }, [sourceBitmap]);
});
}
+26
View File
@@ -0,0 +1,26 @@
/* Worker adapter for the shared pure adjustment pixel pass. */
let applyAdjustment;
async function render(source, adjustment) {
if (!applyAdjustment) {
// pixel-pass only needs document.createElement('canvas'), so provide the
// smallest worker-local adapter instead of maintaining a second renderer.
self.document = { createElement: () => new OffscreenCanvas(source.width, source.height) };
({ applyAdjustment } = await import('./fx/pixel-pass.js'));
}
const input = new OffscreenCanvas(source.width, source.height);
input.getContext('2d').drawImage(source, 0, 0);
return applyAdjustment(input, adjustment);
}
self.onmessage = async event => {
try {
const { source, adjustment } = event.data;
const output = await render(source, adjustment);
const bitmap = output.transferToImageBitmap();
self.postMessage({ bitmap }, [bitmap]);
} catch (error) {
self.postMessage({ error: String(error?.message || error) });
}
};
+12 -13
View File
@@ -45,7 +45,7 @@ import { state } from './state.js';
export function wireInpaintButtons({
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
getSelectedAIEndpoint, ensureActiveMaskLayer,
saveState, createLayer, composite, renderLayerPanel,
saveState, createLayer, composite, flatten, renderLayerPanel,
spinnerModule, uiModule,
}) {
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
@@ -110,16 +110,7 @@ export function wireInpaintButtons({
} catch (_) { /* overlay is decorative */ }
try {
// Flatten current image.
const flatCanvas = document.createElement('canvas');
flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight;
const flatCtx = flatCanvas.getContext('2d');
for (const layer of state.layers) {
if (!layer.visible) continue;
flatCtx.globalAlpha = layer.opacity;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
flatCtx.drawImage(layer.canvas, off.x, off.y);
}
flatCtx.globalAlpha = 1;
const flatCanvas = flatten();
// Dilate the user's brush mask before sending to the model.
// The AI fills a small buffer zone around the brush, so the
// post-gen Edge feather slider has AI content to fade INTO
@@ -136,6 +127,10 @@ export function wireInpaintButtons({
const dilatedMask = dilateMask(mergedMask, padPx);
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
const baseSnap = document.createElement('canvas');
baseSnap.width = state.imgWidth;
baseSnap.height = state.imgHeight;
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
const res = await fetch('/api/image/inpaint', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -180,7 +175,7 @@ export function wireInpaintButtons({
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 };
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
// Apply initial alpha = hard mask (no feather, no edge shift).
applyInpaintFeather(resultLayer, 0, 0);
state.layers.push(resultLayer);
@@ -192,7 +187,9 @@ export function wireInpaintButtons({
// on each sub-row's eye icon.
for (const ly of state.layers) {
if (!ly.masks || !ly.masks.length) continue;
for (const mk of ly.masks) mk.visible = false;
for (const mk of ly.masks) {
if (mk.mode !== 'layer') mk.visible = false;
}
}
composite();
renderLayerPanel();
@@ -216,7 +213,9 @@ export function wireInpaintButtons({
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
const eSlider = document.getElementById('ge-edgestroke-slider');
const eLabel = document.getElementById('ge-edgestroke-label');
const autoRow = document.getElementById('ge-inpaint-automatch-row');
if (eRow) eRow.style.display = '';
if (autoRow) autoRow.style.display = '';
if (eSlider) {
eSlider.max = String(padPx);
eSlider.min = String(-padPx);
+2 -1
View File
@@ -25,6 +25,7 @@
* }} deps
*/
import { state } from './state.js';
import { sortModelIds } from '../modelSort.js';
// Heuristic classifier on a model id + endpoint name. A model can be:
// - gen: text-to-image generation
@@ -106,7 +107,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
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 models = hasListedModels ? sortModelIds(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
+11 -6
View File
@@ -36,6 +36,7 @@
* @returns {{ buildSelectionHintMask: () => string | null }}
*/
import { state } from './state.js';
import { selectionMaskToDocument } from './selection-mask.js';
export function wireRembgAndSharpen({
applyImageTool, openCookbookForDependency,
@@ -69,9 +70,9 @@ export function wireRembgAndSharpen({
// which to hide after a successful cutout.
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
// applyImageTool finishes after fetch but the new layer is added
// inside img.onload (one tick later). Poll for up to 60 frames
// (~1s) for the new layer to appear before we auto-hide.
// applyImageTool resolves after the returned image is decoded and the
// new layer is inserted. Keep a short poll for defensive compatibility
// with alternate runners that may still commit on the next frame.
let frames = 0;
while (state.layers.length <= before && frames < 60) {
await new Promise(r => requestAnimationFrame(r));
@@ -204,9 +205,13 @@ export function wireRembgAndSharpen({
const w = state.imgWidth, h = state.imgHeight;
if (state.wandMask && state.wandLayerId) {
const off = state.layerOffsets.get(state.wandLayerId) || { x: 0, y: 0 };
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(state.wandMask, off.x, off.y);
const c = selectionMaskToDocument(
state.wandMask,
state.wandMaskSpace || 'layer',
off,
w,
h,
);
return c.toDataURL('image/png').split(',')[1];
}
if (state.lassoPoints.length >= 3 && !state.lassoActive) {
+8 -5
View File
@@ -92,8 +92,13 @@ export function createApplyImageTool({
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)
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = () => reject(new Error('Failed to decode result image'));
img.src = 'data:image/png;base64,' + data.image;
});
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
{
saveState();
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
layer.ctx.drawImage(img, 0, 0);
@@ -102,9 +107,7 @@ export function createApplyImageTool({
composite();
renderLayerPanel();
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
};
img.onerror = () => { if (uiModule) uiModule.showToast('Failed to load result', 6000); };
img.src = 'data:image/png;base64,' + data.image;
}
} catch (e) {
// Detect known failure modes and surface an action-toast.
const msg = (e?.message || '').toLowerCase();
+48 -2
View File
@@ -25,6 +25,7 @@
* renderLayerPanel: () => void,
* spinnerModule: object,
* uiModule: object,
* openAdjustmentLayer: (type: string, anchor: HTMLElement) => void,
* }} deps
*
* @returns {{ addEmptyLayer: () => void }}
@@ -34,7 +35,7 @@ import { state } from './state.js';
export function wireAIToolsMisc({
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel,
spinnerModule, uiModule,
spinnerModule, uiModule, openAdjustmentLayer,
}) {
// ── Harmonize sliders — Color match + Seam fix ──
const harmColorPrev = document.getElementById('ge-harmonize-color-preview');
@@ -210,7 +211,52 @@ export function wireAIToolsMisc({
renderLayerPanel();
composite();
}
document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer);
const addButton = document.getElementById('ge-add-layer');
addButton?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
document.querySelector('.ge-add-layer-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'ge-add-layer-menu ge-frosted';
menu.innerHTML = `
<button type="button" data-layer-kind="raster"><span class="ge-add-layer-symbol">+</span><span>Pixel Layer</span></button>
<span class="ge-add-layer-divider"></span>
<button type="button" data-adjustment-type="levels"><span class="ge-add-layer-symbol"></span><span>Levels</span></button>
<button type="button" data-adjustment-type="curves"><span class="ge-add-layer-symbol"></span><span>Curves</span></button>
<button type="button" data-adjustment-type="exposure"><span class="ge-add-layer-symbol"></span><span>Exposure</span></button>
<button type="button" data-adjustment-type="white-balance"><span class="ge-add-layer-symbol"></span><span>White Balance</span></button>
<button type="button" data-adjustment-type="brightness-contrast"><span class="ge-add-layer-symbol"></span><span>Brightness / Contrast</span></button>
<button type="button" data-adjustment-type="hue-saturation"><span class="ge-add-layer-symbol"></span><span>Hue / Saturation</span></button>
<button type="button" data-adjustment-type="vibrance"><span class="ge-add-layer-symbol"></span><span>Vibrance</span></button>
<button type="button" data-adjustment-type="black-white"><span class="ge-add-layer-symbol"></span><span>Black &amp; White</span></button>
<button type="button" data-adjustment-type="shadows-highlights"><span class="ge-add-layer-symbol"></span><span>Shadows / Highlights</span></button>
<button type="button" data-adjustment-type="color-balance"><span class="ge-add-layer-symbol"></span><span>Color Balance</span></button>
<button type="button" data-adjustment-type="selective-color"><span class="ge-add-layer-symbol"></span><span>Selective Color</span></button>
<button type="button" data-adjustment-type="gradient-map"><span class="ge-add-layer-symbol"></span><span>Gradient Map</span></button>
`;
document.body.appendChild(menu);
const rect = addButton.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`;
menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`;
const close = () => {
menu.remove();
document.removeEventListener('pointerdown', away, true);
};
const away = pointerEvent => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close();
};
requestAnimationFrame(() => document.addEventListener('pointerdown', away, true));
menu.addEventListener('click', clickEvent => {
const button = clickEvent.target.closest('button');
if (!button) return;
clickEvent.preventDefault();
clickEvent.stopPropagation();
if (button.dataset.layerKind === 'raster') addEmptyLayer();
else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton);
close();
});
});
return { addEmptyLayer };
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Device-independent brush sampling. Rendering stays in stroke-pipeline.js;
* this module only turns irregular pointer input into evenly spaced samples.
*/
export function normalisePressure(value) {
const pressure = Number(value);
if (!Number.isFinite(pressure) || pressure <= 0) return 1;
return Math.max(0.01, Math.min(1, pressure));
}
export function pressureValue(base, pressure, enabled, minimum = 0.08) {
if (!enabled) return base;
return base * Math.max(minimum, normalisePressure(pressure));
}
export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit }) {
let emitted = null;
let filtered = null;
let remainder = 0;
const output = (sample) => {
emitted = { ...sample };
emit(emitted);
};
const begin = (sample) => {
emitted = null;
filtered = { ...sample, pressure: normalisePressure(sample.pressure) };
remainder = 0;
output(filtered);
};
const add = (sample) => {
const raw = { ...sample, pressure: normalisePressure(sample.pressure) };
if (!filtered || !emitted) return begin(raw);
const smoothing = Math.max(0, Math.min(0.95, Number(getSmoothing()) || 0));
const response = 1 - smoothing;
filtered = {
x: filtered.x + (raw.x - filtered.x) * response,
y: filtered.y + (raw.y - filtered.y) * response,
pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response,
};
const start = { ...emitted };
const dx = filtered.x - start.x;
const dy = filtered.y - start.y;
const distance = Math.hypot(dx, dy);
if (!distance) return;
const diameter = Math.max(1, Number(getDiameter()) || 1);
const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01));
let travelled = spacing - remainder;
while (travelled <= distance) {
const t = travelled / distance;
output({
x: start.x + dx * t,
y: start.y + dy * t,
pressure: start.pressure + (filtered.pressure - start.pressure) * t,
});
travelled += spacing;
}
remainder = Math.max(0, distance - (travelled - spacing));
};
const end = (sample) => {
if (sample && emitted) {
const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) };
if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample);
}
emitted = null;
filtered = null;
remainder = 0;
};
return { begin, add, end };
}
+47
View File
@@ -0,0 +1,47 @@
const STORAGE_KEY = 'odysseus.editor.brush-presets.v1';
const BUILT_INS = [
{ id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' },
{ id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' },
{ id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' },
];
export function loadBrushPresets(storage = globalThis.localStorage) {
let custom = [];
try {
const parsed = JSON.parse(storage?.getItem(STORAGE_KEY) || '[]');
if (Array.isArray(parsed)) custom = parsed.filter(item => item && typeof item.name === 'string');
} catch {}
return [...BUILT_INS.map(item => ({ ...item, builtIn: true })), ...custom];
}
export function saveCustomBrushPresets(presets, storage = globalThis.localStorage) {
const custom = (presets || []).filter(item => item && !item.builtIn);
storage?.setItem(STORAGE_KEY, JSON.stringify(custom));
}
export function captureBrushPreset(state, name) {
return {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
name: String(name || 'Brush').trim() || 'Brush',
size: state.brushSize,
opacity: state.brushOpacity,
flow: state.brushFlow,
softness: state.brushSoftness,
spacing: state.brushSpacing,
smoothing: state.brushSmoothing,
blendMode: state.brushBlendMode,
};
}
export function applyBrushPreset(state, preset) {
if (!preset) return;
state.brushSize = preset.size;
state.brushOpacity = preset.opacity;
state.brushFlow = preset.flow;
state.brushSoftness = preset.softness;
state.brushSpacing = preset.spacing;
state.brushSmoothing = preset.smoothing;
state.brushBlendMode = preset.blendMode || 'source-over';
}
+304 -2
View File
@@ -12,6 +12,15 @@
export function controlsHTML({ color, brushSize, wandTolerance }) {
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
return `
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
<div class="ge-layer-geometry-grid">
<label><span>X</span><input id="ge-layer-x" type="number" step="1" inputmode="numeric" /></label>
<label><span>Y</span><input id="ge-layer-y" type="number" step="1" inputmode="numeric" /></label>
<label><span>W</span><input id="ge-layer-width" type="number" readonly title="Edit width with Transform" /></label>
<label><span>H</span><input id="ge-layer-height" type="number" readonly title="Edit height with Transform" /></label>
</div>
</div>
<div id="ge-brush-controls">
<div class="ge-control-row" id="ge-color-row">
<label>Color</label>
@@ -20,9 +29,204 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<div class="ge-control-row">
<label>Size <span class="ge-size-label">${brushSize}px</span></label>
<input type="range" class="ge-size-slider" min="0" max="1000" value="${brushSliderValue}" />
</div>
<div class="ge-section-title">Stroke</div>
<div class="ge-control-row ge-eraser-row">
<label>Spacing <span id="ge-brush-spacing-label">15%</span></label>
<input type="range" id="ge-brush-spacing" min="1" max="100" value="15" />
</div>
<div class="ge-control-row ge-eraser-row">
<label>Smoothing <span id="ge-brush-smoothing-label">25%</span></label>
<input type="range" id="ge-brush-smoothing" min="0" max="95" value="25" />
</div>
<div class="ge-control-row">
<label for="ge-brush-blend">Blend</label>
<select id="ge-brush-blend">
<option value="source-over">Normal</option><option value="multiply">Multiply</option>
<option value="screen">Screen</option><option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option><option value="color">Color</option>
</select>
</div>
<div class="ge-control-row ge-brush-pressure-row">
<span class="ge-pressure-option" title="Pen pressure changes brush size"><span>Size</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-size" checked /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes opacity"><span>Opacity</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-opacity" /><span class="toggle-slider"></span></label></span>
<span class="ge-pressure-option" title="Pen pressure changes flow"><span>Flow</span><label class="toggle-switch"><input type="checkbox" id="ge-pressure-flow" checked /><span class="toggle-slider"></span></label></span>
</div>
<div class="ge-control-row ge-brush-preset-row">
<select id="ge-brush-preset" aria-label="Brush preset"></select>
<button type="button" class="ge-btn ge-btn-sm" id="ge-brush-preset-save" title="Save current brush preset">Save</button>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-brush-preset-delete" title="Delete selected preset" aria-label="Delete selected preset">×</button>
</div>
</div>
<div class="ge-gradient-section" id="ge-gradient-section" style="display:none;">
<div class="ge-section-title">Gradient</div>
<div class="ge-control-row"><label for="ge-gradient-type">Type</label><select id="ge-gradient-type"><option value="linear-gradient">Linear</option><option value="radial-gradient">Radial</option></select></div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-gradient-start" value="${color}" /></label>
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-gradient-end" value="#ffffff" /></label>
</div>
<div class="ge-control-row ge-gradient-mid-row">
<span class="ge-gradient-mid-toggle"><span>Midpoint</span><label class="toggle-switch"><input type="checkbox" id="ge-gradient-mid-enabled" /><span class="toggle-slider"></span></label></span>
<input type="color" class="ge-color-picker" id="ge-gradient-mid" value="#808080" title="Midpoint color" disabled />
<input type="range" id="ge-gradient-mid-position" min="1" max="99" value="50" title="Midpoint position" disabled />
<span id="ge-gradient-mid-position-label">50%</span>
</div>
<div class="ge-gradient-extra-stops" id="ge-gradient-extra-stops"></div>
<button type="button" class="ge-btn ge-btn-sm ge-gradient-add-stop" id="ge-gradient-add-stop">Add stop</button>
<div class="ge-control-row"><label for="ge-gradient-end-alpha">End opacity <span id="ge-gradient-end-alpha-label">100%</span></label><input type="range" id="ge-gradient-end-alpha" min="0" max="100" value="100" /></div>
<div class="ge-control-row"><label for="ge-gradient-opacity">Opacity <span id="ge-gradient-opacity-label">100%</span></label><input type="range" id="ge-gradient-opacity" min="0" max="100" value="100" /></div>
<p class="ge-section-hint">Drag across the active layer. Switch tools or press Esc to cancel.</p>
</div>
<div class="ge-eraser-section" id="ge-eyedropper-section" style="display:none;">
<div class="ge-section-title">Eyedropper</div>
<div class="ge-eyedropper-live" id="ge-eyedropper-live" aria-live="polite">
<canvas class="ge-eyedropper-loupe" id="ge-eyedropper-loupe" width="84" height="84" aria-hidden="true"></canvas>
<span class="ge-eyedropper-live-swatch" id="ge-eyedropper-live-swatch" aria-hidden="true"></span>
<span class="ge-eyedropper-live-values">
<code id="ge-eyedropper-live-value">Move over the canvas</code>
<span id="ge-eyedropper-live-rgb">RGB --</span>
<span id="ge-eyedropper-live-hsl">HSL --</span>
</span>
</div>
<div class="ge-control-row"><label for="ge-eyedropper-sample">Sample</label><select id="ge-eyedropper-sample"><option value="composite">All layers</option><option value="layer">Active layer</option></select></div>
</div>
<div class="ge-text-section" id="ge-text-section" style="display:none;">
<textarea id="ge-text-content" rows="3" placeholder="Type text..." aria-label="Text content"></textarea>
<div class="ge-text-grid">
<label class="ge-text-field ge-text-font-field"><span>Font</span>
<select id="ge-text-font">
<option value="Arial">Arial</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
<option value="system-ui">System</option>
</select>
</label>
<label class="ge-text-field ge-text-size-field"><span>Size</span>
<input id="ge-text-size" type="number" min="1" max="2000" value="48" />
</label>
</div>
<div class="ge-control-row ge-text-format-row">
<input type="color" class="ge-color-picker" id="ge-text-color" value="${color}" title="Text color" aria-label="Text color" />
<button type="button" class="ge-text-toggle" id="ge-text-bold" title="Bold" aria-pressed="false"><strong>B</strong></button>
<button type="button" class="ge-text-toggle" id="ge-text-italic" title="Italic" aria-pressed="false"><em>I</em></button>
<span class="ge-text-format-sep"></span>
<button type="button" class="ge-text-toggle active" data-text-align="left" title="Align left" aria-pressed="true">&#8676;</button>
<button type="button" class="ge-text-toggle" data-text-align="center" title="Align center" aria-pressed="false">&#8596;</button>
<button type="button" class="ge-text-toggle" data-text-align="right" title="Align right" aria-pressed="false">&#8677;</button>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Line</span>
<input id="ge-text-line-height" type="number" min="0.5" max="5" step="0.1" value="1.2" />
</label>
<label class="ge-text-field"><span>Spacing</span>
<input id="ge-text-letter-spacing" type="number" min="-100" max="500" step="0.5" value="0" />
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Frame</span>
<input id="ge-text-frame-width" type="number" min="1" max="10000" step="1" value="320" />
</label>
<label class="ge-text-field"><span>Height</span>
<input id="ge-text-frame-height" type="number" min="0" max="10000" step="1" value="0" />
</label>
<label class="ge-text-field"><span>Vertical</span>
<select id="ge-text-vertical-align"><option value="top">Top</option><option value="middle">Middle</option><option value="bottom">Bottom</option></select>
</label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Stroke</span>
<input id="ge-text-stroke-width" type="number" min="0" max="100" step="1" value="0" />
</label>
<input type="color" class="ge-color-picker" id="ge-text-stroke-color" value="#000000" title="Stroke color" aria-label="Stroke color" />
</div>
<label class="ge-text-auto-width-option"><span>Auto width</span><span class="toggle-switch"><input type="checkbox" id="ge-text-auto-width" /><span class="toggle-slider"></span></span></label>
<button type="button" class="ge-btn ge-btn-sm" id="ge-text-rasterize">Rasterize</button>
</div>
<div class="ge-shape-section" id="ge-shape-section" style="display:none;">
<div class="ge-section-title">Shape</div>
<div class="ge-shape-types" role="group" aria-label="Shape type">
<button type="button" class="ge-text-toggle active" data-shape-type="rectangle" title="Rectangle" aria-pressed="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="1"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="ellipse" title="Ellipse" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="line" title="Line" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="19" x2="20" y2="5"/></svg></button>
<button type="button" class="ge-text-toggle" data-shape-type="polygon" title="Polygon" aria-pressed="false"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3 9 7-3.5 11h-11L3 10Z"/></svg></button>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill</span><input type="color" class="ge-color-picker" id="ge-shape-fill" value="${color}" /></label>
<label class="ge-text-field"><span>Stroke</span><input type="color" class="ge-color-picker" id="ge-shape-stroke" value="#111111" /></label>
</div>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Fill type</span>
<select id="ge-shape-fill-type"><option value="solid">Solid</option><option value="linear-gradient">Linear gradient</option></select>
</label>
<label class="ge-text-field ge-shape-gradient-angle-field" hidden><span>Angle</span><input id="ge-shape-gradient-angle" type="number" min="-36000" max="36000" step="1" value="0" /></label>
</div>
<div class="ge-text-grid ge-shape-gradient-fields" hidden>
<label class="ge-text-field"><span>Start</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-start" value="#ffffff" /></label>
<label class="ge-text-field"><span>End</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-end" value="#000000" /></label>
<label class="ge-text-field"><span>Midpoint</span><input type="color" class="ge-color-picker" id="ge-shape-gradient-mid" value="#808080" /></label>
<label class="ge-text-field"><span>Position</span><input id="ge-shape-gradient-mid-position" type="number" min="1" max="99" step="1" value="50" /></label>
<label class="ge-text-field ge-shape-gradient-mid-enabled"><span>Use midpoint</span><input id="ge-shape-gradient-mid-enabled" type="checkbox" /></label>
</div>
<div class="ge-shape-gradient-extra-stops" id="ge-shape-gradient-extra-stops"></div>
<button type="button" class="ge-btn ge-btn-sm ge-shape-gradient-add-stop" id="ge-shape-gradient-add-stop">Add stop</button>
<div class="ge-text-grid">
<label class="ge-text-field"><span>Width</span><input id="ge-shape-stroke-width" type="number" min="0" max="500" step="1" value="2" /></label>
<label class="ge-text-field"><span>Radius</span><input id="ge-shape-radius" type="number" min="0" max="5000" step="1" value="0" /></label>
</div>
<label class="ge-text-field ge-shape-sides-field" hidden><span>Sides</span><input id="ge-shape-sides" type="number" min="3" max="24" step="1" value="5" /></label>
<button type="button" class="ge-btn ge-btn-sm" id="ge-shape-rasterize">Rasterize</button>
</div>
<div class="ge-marquee-section" id="ge-marquee-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;">
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn active" data-marquee-shape="rectangle" title="Rectangular marquee" aria-pressed="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14"/></svg>
</button>
<button type="button" class="ge-btn ge-btn-sm ge-marquee-shape-btn" data-marquee-shape="ellipse" title="Elliptical marquee" aria-pressed="false">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="7"/></svg>
</button>
<span style="flex:1"></span>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection"></button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"></button>
</div>
<div class="ge-marquee-constraint-row">
<label for="ge-marquee-constraint">Style</label>
<select id="ge-marquee-constraint" title="Marquee sizing style">
<option value="free">Free</option>
<option value="ratio">Fixed ratio</option>
<option value="size">Fixed size</option>
</select>
<div class="ge-marquee-dimensions" hidden>
<label>W <input type="number" id="ge-marquee-width" min="1" step="1" value="1" inputmode="decimal" /></label>
<label>H <input type="number" id="ge-marquee-height" min="1" step="1" value="1" inputmode="decimal" /></label>
<button type="button" class="ge-icon-btn" id="ge-marquee-swap" title="Swap width and height" aria-label="Swap width and height">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4"/><path d="M4 7h16"/><path d="m8 21-4-4 4-4"/><path d="M20 17H4"/></svg>
</button>
</div>
</div>
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-marquee-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-clear" title="Clear selection">Clear</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-invert" title="Invert selection">Invert</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-delete" title="Erase selected pixels">Erase</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-copy" title="Copy selection to a new layer">Copy Layer</button>
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-marquee-mask" title="Add selection to mask">To Mask</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
</div>
<div class="ge-lasso-section" id="ge-lasso-section" style="display:none;">
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next lasso combines with the current selection.">
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="New selection">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection"></button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"></button>
</div>
<div class="ge-control-row ge-eraser-row ge-sel-refine" id="ge-lasso-refine-feather" style="display:none;">
<span class="ge-eraser-preview" id="ge-lasso-feather-preview" aria-hidden="true"></span>
<label>Feather <span id="ge-lasso-feather-label">0px</span></label>
@@ -50,6 +254,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Draw a freehand selection. Esc to cancel.</p>
</div>
@@ -58,6 +263,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection on each click">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection (Shift)">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection (Alt)"> Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"></button>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-wand-tol-preview" aria-hidden="true"></span>
@@ -99,9 +305,41 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
To Mask
</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
</div>
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection 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">New</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection"> Subtract</button>
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="intersect" title="Intersect selection"></button>
</div>
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
<button class="ge-btn ge-btn-sm ge-btn-ai" id="ge-sam-find" style="height:28px;display:inline-flex;align-items:center;gap:5px;" title="Find object and create a SAM mask">
<span class="ge-btn-ai-mark" aria-hidden="true"></span>
Find
</button>
</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-sam-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-sam-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-sam-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>
<button type="button" class="ge-btn ge-btn-sm ge-btn-iconlabel ge-quick-mask-toggle" title="Edit selection as Quick Mask (Q)" aria-pressed="false">Quick Mask</button>
</div>
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
</div>
<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>
@@ -189,12 +427,29 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
<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 class="ge-control-row ge-actions" id="ge-inpaint-automatch-row" style="display:none;margin-top:6px;">
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-btn-ai" id="ge-inpaint-automatch" style="width:100%;justify-content:center;" title="Match the latest inpaint result to the surrounding colour and lighting using an adjustment layer.">
<span class="ge-btn-ai-mark" aria-hidden="true"></span>
Auto match color
</button>
</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-clone-source-status" id="ge-clone-source-status" aria-live="polite">
<span id="ge-clone-source-label">No source selected</span>
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-clone-source-clear" title="Clear sampled source" aria-label="Clear sampled source">×</button>
</div>
<div class="ge-control-row">
<label for="ge-clone-sample-mode">Sample</label>
<select id="ge-clone-sample-mode">
<option value="active-layer">Active layer</option>
<option value="composite">All visible layers</option>
</select>
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-clone-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-clone-opacity-label">100%</span></label>
@@ -212,7 +467,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
</div>
</div>
<div class="ge-eraser-section" id="ge-brush-section" style="display:none;">
<div class="ge-section-title">Brush</div>
<div class="ge-section-title"><span>Brush</span></div>
<div class="ge-control-row ge-eraser-row" id="ge-smudge-strength-row" style="display:none;">
<span class="ge-eraser-preview" id="ge-smudge-preview-strength" aria-hidden="true"></span>
<label>Strength <span id="ge-smudge-strength-label">65%</span></label>
<input type="range" id="ge-smudge-strength" min="5" max="100" value="65" title="How strongly Smudge carries sampled pixels into the stroke." />
</div>
<div class="ge-control-row ge-eraser-row">
<span class="ge-eraser-preview" id="ge-brush-preview-opacity" aria-hidden="true"></span>
<label>Opacity <span id="ge-brush-opacity-label">100%</span></label>
@@ -361,6 +621,48 @@ export function layerPanelHTML() {
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-flatten" title="Flatten copy (keeps originals)" aria-label="Flatten copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L4 6 L4 18 L12 22 L20 18 L20 6 Z"/><path d="M12 2 L12 22"/><path d="M4 6 L20 6"/><path d="M4 18 L20 18"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-select-all-layers" title="Select all layers" aria-label="Select all layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="13" height="13" rx="1"/><path d="M4 16H3V3h13v1"/></svg>
</button>
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-group-selected" title="Group selected layers" aria-label="Group selected layers">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v10H3z"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-add-layer" title="Add empty layer">+ Add</button>
</div><div class="ge-layers-list" id="ge-layers-list"></div>`;
</div>
<div class="ge-layer-blend-row">
<label for="ge-layer-blend">Blend</label>
<select id="ge-layer-blend" title="Active layer blend mode">
<option value="source-over">Normal</option>
<option value="multiply">Multiply</option>
<option value="screen">Screen</option>
<option value="overlay">Overlay</option>
<option value="soft-light">Soft Light</option>
<option value="hard-light">Hard Light</option>
<option value="darken">Darken</option>
<option value="lighten">Lighten</option>
<option value="color-dodge">Color Dodge</option>
<option value="color-burn">Color Burn</option>
<option value="difference">Difference</option>
<option value="exclusion">Exclusion</option>
<option value="hue">Hue</option>
<option value="saturation">Saturation</option>
<option value="color">Color</option>
<option value="luminosity">Luminosity</option>
</select>
</div>
<div class="ge-layer-selection-bar" id="ge-layer-selection-bar" hidden>
<span id="ge-layer-selection-count">2 layers</span>
<button class="ge-icon-btn" id="ge-selected-visibility" title="Toggle selected visibility" aria-label="Toggle selected visibility">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="ge-icon-btn" id="ge-selected-lock" title="Toggle selected lock" aria-label="Toggle selected lock">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
</button>
<button class="ge-icon-btn" id="ge-selected-align" title="Align or distribute selected layers" aria-label="Align or distribute selected layers">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5h16M7 12h10M4 19h16"/><path d="M12 3v18"/></svg>
</button>
<button class="ge-icon-btn danger" id="ge-selected-delete" title="Delete selected layers" aria-label="Delete selected layers">×</button>
</div>
<div class="ge-layers-list" id="ge-layers-list"></div>
<div class="ge-layer-tools" id="ge-layer-tools" aria-label="Selected layer tools"></div>`;
}
+28 -5
View File
@@ -22,14 +22,15 @@ export function shortcutsPopupHTML() {
<div class="ge-shortcuts-col">
<h5>Tools</h5>
<div><kbd>V</kbd> Move</div>
<div><kbd>T</kbd> Transform</div>
<div><kbd>H</kbd> Hand <span style="opacity:0.5">(hold Space temporarily)</span></div>
<div><kbd>Arrow</kbd> Move 1 px <span style="opacity:0.5">(Shift = 10 px)</span></div>
<div><kbd>T</kbd> Text</div>
<div><kbd>B</kbd> Brush</div>
<div><kbd>E</kbd> Eraser</div>
<div><kbd>K</kbd> Clone Stamp <span style="opacity:0.5">(Alt-click = set source)</span></div>
<div><kbd>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>
@@ -40,6 +41,7 @@ export function shortcutsPopupHTML() {
<div><kbd>Ctrl</kbd>+<kbd>S</kbd> Save</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd> Save to Gallery</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>J</kbd> New Layer</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>G</kbd> Clipping Mask</div>
<div><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>T</kbd> Canvas size</div>
</div>
@@ -89,8 +91,19 @@ export function historyPanelHTML(historyIcon) {
export function canvasSizePromptHTML() {
return `
<div class="modal-content ge-canvas-prompt">
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New canvas</h4></div>
<div class="modal-header"><h4 id="ge-canvas-prompt-title">New project</h4></div>
<div class="modal-body">
<div class="ge-canvas-prompt-section-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 3v18"></path></svg><span>Resolution templates</span></div>
<div class="ge-canvas-preset-grid" role="group" aria-label="Resolution templates">
<button type="button" class="ge-canvas-preset" data-canvas-preset="1024x1024"><svg class="ge-canvas-preset-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="1"></rect></svg><span>Square</span><strong>1024 × 1024</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1920x1080"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="1"></rect></svg><span>Landscape</span><strong>1920 × 1080</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1920"><svg class="ge-canvas-preset-icon" width="10" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="1"></rect></svg><span>Story</span><strong>1080 × 1920</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="1080x1350"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="5" y="3" width="14" height="18" rx="1"></rect></svg><span>Portrait</span><strong>1080 × 1350</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="2480x3508"><svg class="ge-canvas-preset-icon" width="11" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M6 2h9l3 3v17H6zM15 2v4h4"></path></svg><span>A4</span><strong>2480 × 3508</strong></button>
<button type="button" class="ge-canvas-preset" data-canvas-preset="3840x2160"><svg class="ge-canvas-preset-icon" width="14" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="1"></rect><path d="M8 22h8M12 20v2"></path></svg><span>4K</span><strong>3840 × 2160</strong></button>
</div>
<div class="ge-canvas-prompt-section-label ge-canvas-prompt-custom-label">Custom size</div>
<label class="ge-canvas-units-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16v16H4z"></path><path d="M8 4v16M16 4v16M4 8h16M4 16h16"></path></svg><span>Units</span></span><select id="ge-canvas-prompt-units"><option value="px">Pixels</option><option value="percent">Percent</option></select></label>
<div class="ge-canvas-prompt-row">
<label class="ge-canvas-prompt-field">
<span>Width</span>
@@ -102,9 +115,19 @@ export function canvasSizePromptHTML() {
<input type="text" id="ge-canvas-prompt-h" inputmode="numeric" value="1024">
</label>
</div>
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
<div class="ge-canvas-anchor-options">
<div class="ge-canvas-anchor-label">Anchor</div>
<div class="ge-canvas-anchor-grid" role="group" aria-label="Canvas anchor">
${[['0,0','Top left'],['0.5,0','Top'],['1,0','Top right'],['0,0.5','Left'],['0.5,0.5','Center'],['1,0.5','Right'],['0,1','Bottom left'],['0.5,1','Bottom'],['1,1','Bottom right']].map(([value, label], index) => `<button type="button" class="ge-canvas-anchor${index === 0 ? ' active' : ''}" data-canvas-anchor="${value}" title="${label}" aria-label="${label}"></button>`).join('')}
</div>
</div>
<div class="ge-canvas-resize-options">
<label class="ge-canvas-lock-option"><input type="checkbox" id="ge-canvas-prompt-lock" /> <span>Keep proportions</span></label>
<label class="ge-canvas-interpolation-field"><span class="ge-canvas-control-label"><svg class="ge-canvas-prompt-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18 9 13l3 3 8-8"></path><path d="M15 8h5v5"></path></svg><span>Interpolation</span></span><select id="ge-canvas-prompt-interpolation"><option value="high">Smooth</option><option value="medium">Balanced</option><option value="low">Crisp</option></select></label>
</div>
</div>
<div class="modal-footer">
<div class="modal-footer ge-canvas-prompt-footer">
<p class="ge-canvas-prompt-hint">Pixels, or type a ratio like 3x5 / 16:9 in either field.</p>
<button class="confirm-btn confirm-btn-secondary" id="ge-canvas-prompt-cancel">Cancel</button>
<button class="confirm-btn confirm-btn-primary" id="ge-canvas-prompt-ok">Create</button>
</div>
+9
View File
@@ -77,6 +77,10 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
// docked inside the right panel above the layers list.
if (window.innerWidth <= 700 && state.container) {
state.container.appendChild(controls);
// Start canvas-first on phones. The active tool button brings this
// sheet back and minimizes Layers, so the two bottom sheets never
// compete for the same pointer events on initial open.
controls.classList.add('dismissed');
}
// Move every slider-row's value chip out of its <label> and place
@@ -102,6 +106,9 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
{
const header = layerPanel.querySelector('.ge-layers-header');
if (header) {
const claimLayerSheet = () => {
if (window.innerWidth <= 700) controls.classList.add('dismissed');
};
let sy = 0, sx = 0, dragging = false, didSwipe = false;
header.addEventListener('touchstart', (e) => {
if (window.innerWidth > 700) return;
@@ -121,6 +128,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
// expanded → peek → minimized (swipe down)
if (Math.abs(dy) > 20 && Math.abs(dy) > dx) {
didSwipe = true;
claimLayerSheet();
const isExpanded = rightPanel.classList.contains('expanded');
const isMinimized = rightPanel.classList.contains('minimized');
if (dy < 0) {
@@ -143,6 +151,7 @@ export function buildRightPanel({ controlsHTML, layerPanelHTML }) {
if (window.innerWidth > 700) return;
if (e.target.closest('button')) return;
if (didSwipe) { didSwipe = false; return; }
claimLayerSheet();
// Click cycles between peek and expanded; minimized comes
// back to peek (so a tap on the handle always reveals at
// least the active layer row).
+16 -4
View File
@@ -9,7 +9,7 @@
* @param {{
* currentTool: string,
* onSelectTool: (toolId: string, btn: HTMLButtonElement, toolbar: HTMLDivElement) => void,
* onClearSelection: (which: 'lasso'|'wand') => void,
* onClearSelection: (which: 'marquee'|'lasso'|'wand') => void,
* }} ctx
* @returns {{ toolbar: HTMLDivElement, toolKeyMap: Record<string,string> }}
*/
@@ -18,15 +18,26 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
toolbar.className = 'ge-toolbar';
const tools = [
{ id: 'move', label: 'Move', icon: '✥', key: 'V' },
{ id: 'hand', label: 'Hand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-4 0v4"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 10V5a2 2 0 0 0-4 0v9"/><path d="M6 13.5 4.5 12A2.1 2.1 0 0 0 2 15l4.5 5A6 6 0 0 0 11 22h2a7 7 0 0 0 7-7v-4a2 2 0 0 0-4 0v1"/></svg>', key: 'H' },
{ id: 'crop', label: 'Crop', icon: '✂', key: 'C' },
{ id: 'transform', label: 'Transform', icon: '⤢', key: 'T' },
{ id: 'transform', label: 'Transform', icon: '⤢' },
{ id: 'text', label: 'Text', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>', key: 'T' },
{ id: 'shape', label: 'Shape', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="12" height="12" rx="1"/><circle cx="17" cy="15" r="4"/></svg>', key: 'U' },
{ 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: 'gradient', label: 'Gradient', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20 20 4"/><path d="M6 18 18 6" opacity=".45"/><path d="M8 16 16 8" opacity=".2"/></svg>', key: 'G' },
{ id: 'eraser', label: 'Eraser', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19.4 14.6 14.6 19.4a2 2 0 0 1-2.83 0L4.6 12.23a2 2 0 0 1 0-2.83l7.17-7.17a2 2 0 0 1 2.83 0l4.8 4.8a2 2 0 0 1 0 2.83Z"/><line x1="22" y1="21" x2="7" y2="21"/><line x1="14" y1="3" x2="9" y2="8"/></svg>', key: 'E' },
{ id: 'eyedropper', label: 'Eyedropper', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 3 2 2-9.5 9.5-3-3Z"/><path d="m8.5 11.5-5 5V21h4.5l5-5"/></svg>', key: 'I' },
{ id: 'smudge', label: 'Smudge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c2-3 4-5 6-6 2-1 4-3 4-6 0-2-1-4-3-4s-3 2-3 4v5"/><path d="M9 13c-2 0-4 1-5 3-.7 1.2-.1 3 1.4 3H18c1.7 0 3-1.3 3-3 0-1.1-.9-2-2-2h-4"/></svg>', key: 'Y' },
{ 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: 'heal', label: 'Healing', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 19 14-14"/><path d="M7 5h4M9 3v4M13 17h4M15 15v4"/></svg>', key: 'J' },
{ id: 'dodge', label: 'Dodge', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="10" cy="10" r="6"/><path d="m14.5 14.5 6 6"/></svg>', key: 'O' },
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>', key: 'D' },
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>', key: 'R' },
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
{ sep: true },
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
@@ -54,8 +65,9 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
// 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 + '">' +
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
const clearBadge = (t.id === 'marquee' || t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
'</span>'
: '';
+92 -9
View File
@@ -26,6 +26,10 @@ export function buildTopbar() {
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/><polyline points="12 7 12 12 16 14"/></svg></span>
<span class="ge-stacked-label">HISTORY</span>
</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-compare-btn" title="Show the document before editing" aria-label="Show the document before editing" aria-pressed="false">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"></path><path d="M5 7h14M5 17h14"></path></svg></span>
<span class="ge-stacked-label">BEFORE</span>
</button>
<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">
@@ -47,13 +51,36 @@ export function buildTopbar() {
<span class="ge-topbar-sep"></span>
</div>
<div class="ge-topbar-right">
<span class="ge-draft-status" id="ge-draft-status" role="status" aria-live="polite" title="Draft status">Not saved</span>
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
<div class="ge-view-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-view-menu-btn" title="Canvas view" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg><span class="ge-stacked-caret"></span></span>
<span class="ge-stacked-label">VIEW</span>
</button>
<div class="ge-view-menu dropdown" id="ge-view-menu" hidden>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="rulers"><span class="dropdown-icon ge-view-check"></span><span>Rulers</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="grid"><span class="dropdown-icon ge-view-check"></span><span>Grid</span></button>
<label class="ge-view-grid-size"><span>Grid size</span><input id="ge-grid-size" type="number" min="2" max="1000" step="1" value="16"><span>px</span></label>
<div class="dropdown-section-divider"></div>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap"><span class="dropdown-icon ge-view-check"></span><span>Snap</span></button>
<button class="dropdown-item-compact ge-view-toggle" role="menuitemcheckbox" data-view-action="snap-grid"><span class="dropdown-icon ge-view-check"></span><span>Snap to grid</span></button>
<button class="dropdown-item-compact" data-view-action="clear-guides"><span class="dropdown-icon">×</span><span>Clear guides</span></button>
</div>
</div>
<div class="ge-image-wrap">
<button class="ge-btn ge-btn-sm" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">Image </button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-image-menu-btn" title="Image actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg><span class="ge-stacked-caret"></span></span>
<span class="ge-stacked-label">IMAGE</span>
</button>
<div class="ge-image-menu dropdown" id="ge-image-menu" hidden>
<button class="dropdown-item-compact" data-image-action="resize">
<button class="dropdown-item-compact" data-image-action="canvas-size">
<span class="dropdown-icon"></span>
<span>Canvas</span>
<span>Canvas Size...</span>
</button>
<button class="dropdown-item-compact" data-image-action="image-size">
<span class="dropdown-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg></span>
<span>Image Size...</span>
</button>
<div class="ge-filter-submenu-label">Transform</div>
<button class="dropdown-item-compact" data-image-action="rotate-90">
@@ -74,9 +101,61 @@ export function buildTopbar() {
</button>
</div>
</div>
<div class="ge-selection-wrap">
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-selection-menu-btn" title="Selection actions" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg><span class="ge-stacked-caret"></span></span>
<span class="ge-stacked-label">SELECT</span>
</button>
<div class="ge-selection-menu dropdown" id="ge-selection-menu" hidden>
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+Alt+A</span></button>
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+Shift+D</span></button>
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+I</span></button>
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection</span></button>
<div class="dropdown-section-divider"></div>
<div class="ge-selection-save-row">
<input id="ge-selection-name" type="text" maxlength="100" placeholder="Selection name" aria-label="Saved selection name" />
<button type="button" class="ge-icon-btn" data-selection-action="save" title="Save current selection" aria-label="Save current selection">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</button>
</div>
<div class="ge-filter-submenu-label">Saved selections</div>
<div id="ge-saved-selection-list" class="ge-saved-selection-list"></div>
</div>
</div>
<div class="ge-filter-wrap">
<button class="ge-btn ge-btn-sm" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">Filter </button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-filter-menu-btn" title="Filters" aria-haspopup="true">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16M7 12h10M10 19h4"/></svg><span class="ge-stacked-caret"></span></span>
<span class="ge-stacked-label">FILTER</span>
</button>
<div class="ge-filter-menu dropdown" id="ge-filter-menu" hidden>
<div class="ge-filter-submenu-label">Retained effects</div>
<button class="dropdown-item-compact" data-filter-action="effect-blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
<span>Gaussian Blur (retained)</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-sharpen">
<span class="dropdown-icon" aria-hidden="true"></span>
<span>Sharpen (retained)</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-color-overlay">
<span class="dropdown-icon" aria-hidden="true"></span>
<span>Color Overlay (retained)</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-drop-shadow">
<span class="dropdown-icon" aria-hidden="true"></span>
<span>Drop Shadow (retained)</span>
</button>
<button class="dropdown-item-compact" data-filter-action="effect-stroke">
<span class="dropdown-icon" aria-hidden="true"></span>
<span>Stroke (retained)</span>
</button>
<div class="ge-filter-submenu-label">Presets</div>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-blur"><span>Soft Blur</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-crisp-detail"><span>Crisp Detail</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-soft-shadow"><span>Soft Shadow</span></button>
<button class="dropdown-item-compact" data-filter-action="effect-preset-white-outline"><span>White Outline</span></button>
<div class="ge-filter-submenu-label">Blur</div>
<button class="dropdown-item-compact" data-filter-action="blur-gaussian">
<span class="dropdown-icon ge-blur-icon ge-blur-gaussian" aria-hidden="true"></span>
@@ -92,10 +171,14 @@ export function buildTopbar() {
<button class="ge-btn ge-btn-sm" id="ge-shortcuts-btn" title="Keyboard shortcuts (?)" aria-label="Shortcuts">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:relative;top:2px;"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg>
</button>
<button class="ge-btn ge-btn-sm" id="ge-import-topbar" title="Import image as layer">+ Import</button>
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-import-topbar" title="Import image as layer">
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><polyline points="7 8 12 3 17 8"/><path d="M5 14v5h14v-5"/></svg></span>
<span class="ge-stacked-label">IMPORT</span>
</button>
<div class="ge-save-wrap">
<button class="ge-btn ge-btn-primary" id="ge-save-menu-btn" title="Save options" style="display:inline-flex;align-items:center;gap:4px;">Save
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7"><polyline points="6 9 12 15 18 9"/></svg>
<button class="ge-btn ge-btn-sm ge-btn-primary ge-stacked-btn" id="ge-save-menu-btn" title="Save options">
<span class="ge-stacked-glyph"><svg 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 class="ge-stacked-caret"></span></span>
<span class="ge-stacked-label">SAVE</span>
</button>
<div class="ge-save-menu dropdown" id="ge-save-menu" hidden>
<div class="dropdown-section-label">Image</div>
@@ -109,9 +192,9 @@ export function buildTopbar() {
<span>Save as copy</span>
<span class="dropdown-shortcut">Ctrl+Shift+S</span>
</button>
<button class="dropdown-item-compact" id="ge-download" title="Download PNG to your computer">
<button class="dropdown-item-compact" id="ge-download" title="Export an image to your computer">
<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span>Download PNG</span>
<span>Export image...</span>
</button>
<div class="dropdown-section-divider"></div>
<div class="dropdown-section-label">Project</div>
+32
View File
@@ -23,6 +23,23 @@ export function transformPopupHTML() {
</span>
</div>
<div class="ge-transform-popup-body">
<div class="ge-transform-field ge-transform-position-field">
<label for="ge-transform-x" title="Transform center X">X</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-x" step="1" aria-label="Transform center X" />
<span class="ge-transform-spin" data-spin-for="ge-transform-x">
<button type="button" data-spin="down" tabindex="-1" aria-label="Move left"></button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Move right">+</button>
</span>
</div>
<div class="ge-transform-field ge-transform-position-field">
<label for="ge-transform-y" title="Transform center Y">Y</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-y" step="1" aria-label="Transform center Y" />
<span class="ge-transform-spin" data-spin-for="ge-transform-y">
<button type="button" data-spin="down" tabindex="-1" aria-label="Move up"></button>
<button type="button" data-spin="up" tabindex="-1" aria-label="Move down">+</button>
</span>
</div>
<div class="ge-row-break ge-transform-position-break"></div>
<div class="ge-transform-field">
<label>W</label>
<input type="number" class="ge-transform-popup-input" id="ge-transform-w" step="1" />
@@ -48,9 +65,24 @@ export function transformPopupHTML() {
<button type="button" data-spin="up" tabindex="-1" aria-label="Rotate +1°">+</button>
</span>
</div>
<span class="ge-transform-quick" aria-label="Transform actions">
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-h" title="Flip horizontally" aria-label="Flip horizontally">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="m8 7-4 5 4 5"/><path d="m16 7 4 5-4 5"/></svg>
</button>
<button type="button" class="ge-transform-quick-btn" id="ge-transform-flip-v" title="Flip vertically" aria-label="Flip vertically">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18"/><path d="m7 8 5-4 5 4"/><path d="m7 16 5 4 5-4"/></svg>
</button>
<button type="button" class="ge-transform-quick-btn" id="ge-transform-rot-90" title="Rotate 90 degrees (Shift: counter-clockwise)" aria-label="Rotate 90 degrees">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg>
</button>
</span>
<button type="button" class="ge-btn ge-btn-sm" id="ge-transform-cancel-btn">Cancel</button>
<button type="button" class="ge-btn ge-btn-sm ge-btn-primary" id="ge-transform-apply">Apply</button>
</div>
<label class="ge-transform-preserve-field">
<input type="checkbox" id="ge-transform-preserve-source" />
<span>Preserve source pixels</span>
</label>
<p class="ge-transform-popup-hint">Type <strong>-</strong> before W / H to flip.</p>
`;
}
+2 -2
View File
@@ -12,8 +12,8 @@ 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;
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
const clientY = e.touches && e.touches.length ? e.touches[0].clientY : e.clientY;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
+133 -47
View File
@@ -14,13 +14,13 @@
* Touch:
* touchstart 1 finger beginDraw
* touchmove 1 finger continueDraw
* touchend / touchcancel endDraw
* touchend endDraw; touchcancel cancelDraw
* touchstart 2 fingers pinch-zoom + 2-finger pan
*
* Pan (any free space around the canvas):
* pointerdown / pointermove / pointerup on canvas-area, skipping
* the canvas + transform overlay + UI elements above them. Sets
* canvasArea.dataset.panX/Y + CSS transform on both canvases.
* Pan:
* Hand tool / held Space / middle mouse can drag directly over the
* image. Empty workspace remains draggable with any tool. The same
* path drives one-finger Hand-tool panning on touch screens.
*
* Exposes `canvasArea._resetPan()` so the zoom/fit reset can clear
* the pan offset.
@@ -30,19 +30,65 @@
* beginDraw: (e: Event) => void,
* continueDraw: (e: Event) => void,
* endDraw: (e?: Event) => void,
* cancelDraw?: () => void,
* updateBrushCursor: (e: Event) => void,
* syncZoomControls?: () => void,
* }} ctx
*/
import { state } from './state.js';
import {
applyCanvasPan,
isDirectPanIntent,
nextPanOffset,
syncPanCursor,
} from './canvas-navigation.js';
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, updateBrushCursor, syncZoomControls }) {
export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw, cancelDraw, updateBrushCursor, updateEyedropperPreview, syncZoomControls, onViewportChange }) {
let suppressMouseUntil = 0;
// Mouse — mousedown stays on the canvas; mousemove/up are bound to
// the WINDOW so a drag can continue (and end) past the canvas edge.
// Critical for the Resize tool where users overshoot.
state.mainCanvas.addEventListener('mousedown', beginDraw);
window.addEventListener('mousemove', continueDraw);
window.addEventListener('mouseup', endDraw);
state.mainCanvas.addEventListener('mousedown', (e) => {
if (Date.now() < suppressMouseUntil) return;
if (isDirectPanIntent(state.tool, state.spacePanActive, e.button)) return;
beginDraw(e);
});
window.addEventListener('mousemove', (e) => {
if (Date.now() < suppressMouseUntil) return;
continueDraw(e);
});
window.addEventListener('mouseup', (e) => {
if (Date.now() < suppressMouseUntil) return;
endDraw(e);
});
// Preserve pressure and browser-coalesced samples for pen input.
// Compatibility mouse events are briefly suppressed to avoid a
// duplicate stroke after pointerup.
let activePenId = null;
state.mainCanvas.addEventListener('pointerdown', (e) => {
if (e.pointerType !== 'pen') return;
suppressMouseUntil = Date.now() + 500;
activePenId = e.pointerId;
try { state.mainCanvas.setPointerCapture(activePenId); } catch {}
beginDraw(e);
e.preventDefault();
});
state.mainCanvas.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
continueDraw(e);
e.preventDefault();
});
const endPen = (e) => {
if (e.pointerType !== 'pen' || e.pointerId !== activePenId) return;
if (e.type === 'pointercancel') cancelDraw?.();
else endDraw(e);
try { state.mainCanvas.releasePointerCapture(activePenId); } catch {}
activePenId = null;
suppressMouseUntil = Date.now() + 500;
e.preventDefault();
};
state.mainCanvas.addEventListener('pointerup', endPen);
state.mainCanvas.addEventListener('pointercancel', endPen);
// Lasso can start OUTSIDE the canvas — fallback mousedown on the
// surrounding canvas-area so the user can begin a lasso path in
// the empty space around the image. Other tools stay canvas-only.
@@ -52,12 +98,21 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
beginDraw(e);
});
state.mainCanvas.addEventListener('mouseenter', (e) => {
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone'].includes(state.tool)) updateBrushCursor(e);
if (state.tool === 'eyedropper') updateEyedropperPreview?.(e);
if (['brush', 'eraser', 'inpaint', 'lasso', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(state.tool)) updateBrushCursor(e);
});
state.mainCanvas.addEventListener('mouseleave', () => {
// Only hide the brush-cursor overlay on leave — DO NOT end the
// drag, so the user can drag a resize handle past the canvas edge.
if (state.cursorEl) state.cursorEl.style.display = 'none';
if (state.tool === 'eyedropper') updateEyedropperPreview?.(null, true);
if (state.tool === 'transform' && state.hoveredHandle) {
state.hoveredHandle = null;
state.mainCanvas.style.cursor = 'default';
// The frame is drawn on a separate overlay, so clear its hover state
// explicitly when the pointer leaves the source canvas.
continueDraw?.({ clientX: -1, clientY: -1, target: null });
}
});
// Touch — single finger draws; two fingers pan + pinch-zoom.
@@ -74,13 +129,7 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const dy = t2.clientY - t1.clientY;
return { cx, cy, dist: Math.hypot(dx, dy) };
};
const applyCanvasOffset = (x, y) => {
canvasArea.dataset.panX = String(x);
canvasArea.dataset.panY = String(y);
const t = `translate3d(${x}px, ${y}px, 0)`;
state.mainCanvas.style.transform = t;
if (state.transformOverlay) state.transformOverlay.style.transform = t;
};
const applyCanvasOffset = (x, y) => applyCanvasPan(state, canvasArea, x, y);
state.mainCanvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length >= 2) {
@@ -91,13 +140,11 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
multiStartDist = info.dist;
multiStartZoom = state.zoom;
multiStartCenter = { x: info.cx, y: info.cy };
multiStartPan = {
x: parseFloat(canvasArea.dataset.panX || '0') || 0,
y: parseFloat(canvasArea.dataset.panY || '0') || 0,
};
multiStartPan = { x: state.panX || 0, y: state.panY || 0 };
return;
}
if (multiActive) return;
if (state.tool === 'hand') return;
beginDraw(e);
}, { passive: false });
state.mainCanvas.addEventListener('touchmove', (e) => {
@@ -113,10 +160,12 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
const label = state.container.querySelector('.ge-zoom-label');
if (label) label.textContent = Math.round(state.zoom * 100) + '%';
syncZoomControls?.();
onViewportChange?.();
}
const dx = info.cx - multiStartCenter.x;
const dy = info.cy - multiStartCenter.y;
applyCanvasOffset(multiStartPan.x + dx, multiStartPan.y + dy);
onViewportChange?.();
return;
}
if (multiActive) return;
@@ -131,67 +180,104 @@ export function wireCanvasEvents({ canvasArea, beginDraw, continueDraw, endDraw,
});
state.mainCanvas.addEventListener('touchcancel', () => {
multiActive = false;
endDraw();
cancelDraw?.();
});
// Press-and-drag in the empty space AROUND the canvas pans the
// canvas + overlay via CSS transform. Works even when the image
// fits the viewport (no scroll needed). Skips presses on the canvas
// itself (the canvas owns its own drawing input) or on UI elements
// above it.
// Direct pan gestures own the image as well as the surrounding area.
// With other tools only empty workspace pans, preserving normal edit input.
let panning = false;
let pid = null;
let transformPointerId = null;
let startX = 0, startY = 0;
const getOffset = () => {
const v = canvasArea.dataset.panX || '0';
const u = canvasArea.dataset.panY || '0';
return { x: parseFloat(v) || 0, y: parseFloat(u) || 0 };
};
let startPanX = 0, startPanY = 0;
const getOffset = () => ({ x: state.panX || 0, y: state.panY || 0 });
const applyOffset = (x, y) => {
canvasArea.dataset.panX = String(x);
canvasArea.dataset.panY = String(y);
const t = `translate3d(${x}px, ${y}px, 0)`;
state.mainCanvas.style.transform = t;
if (state.transformOverlay) state.transformOverlay.style.transform = t;
const result = applyCanvasPan(state, canvasArea, x, y);
onViewportChange?.();
return result;
};
canvasArea.addEventListener('pointerdown', (e) => {
if (state.tool === 'lasso') return;
if (e.target === state.mainCanvas || e.target === state.transformOverlay) return;
const directPan = isDirectPanIntent(state.tool, state.spacePanActive, e.button);
if (state.tool === 'lasso' && !directPan) return;
if (e.target.closest('button, input, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup, .ge-controls, .ge-right-panel, .ge-fx-menu')) return;
const onImage = e.target === state.mainCanvas || e.target === state.transformOverlay;
if (onImage && !directPan) return;
// During an active transform the corner/rotation handles render
// OUTSIDE the canvas (over the surrounding area), and the overlay is
// pointer-events:none — so a grab on an outside handle lands here.
// Route it to the transform tool (getHandleAt works in image space,
// even for points beyond the canvas) instead of panning the canvas.
if (state.transformActive) {
if (state.transformActive && !directPan) {
beginDraw(e);
// Only swallow the event (skip pan) if a handle was grabbed OR the
// layer-move fallback engaged; otherwise let the pan logic below
// run so empty space still pans while the transform tool is open.
if (state.transformHandle || state.moving) return;
if (state.transformHandle || state.moving) {
// Mouse drags already continue on window mousemove. Pen/touch drags
// that start on an outside-canvas handle need pointer capture so the
// session survives leaving the editor surface.
if (e.pointerType && e.pointerType !== 'mouse') {
transformPointerId = e.pointerId;
try { canvasArea.setPointerCapture(transformPointerId); } catch {}
e.preventDefault();
}
return;
}
}
const off = getOffset();
panning = true;
pid = e.pointerId;
startX = e.clientX - off.x;
startY = e.clientY - off.y;
startX = e.clientX;
startY = e.clientY;
startPanX = off.x;
startPanY = off.y;
try { canvasArea.setPointerCapture(pid); } catch {}
canvasArea.style.cursor = 'grabbing';
state.navigationPanning = true;
syncPanCursor(state, canvasArea, true);
e.preventDefault();
});
canvasArea.addEventListener('pointermove', (e) => {
if (transformPointerId !== null && e.pointerId === transformPointerId) {
continueDraw(e);
return;
}
if (!panning || e.pointerId !== pid) return;
applyOffset(e.clientX - startX, e.clientY - startY);
const next = nextPanOffset(
{ x: startX, y: startY },
{ x: startPanX, y: startPanY },
{ x: e.clientX, y: e.clientY },
);
applyOffset(next.x, next.y);
});
const endPan = () => {
const endPan = (e) => {
if (transformPointerId !== null && (!e || e.pointerId === transformPointerId)) {
const capturedId = transformPointerId;
transformPointerId = null;
if (e?.type === 'pointercancel') cancelDraw?.();
else endDraw(e);
try { canvasArea.releasePointerCapture(capturedId); } catch {}
}
if (!panning) return;
panning = false;
try { canvasArea.releasePointerCapture(pid); } catch {}
pid = null;
canvasArea.style.cursor = '';
state.navigationPanning = false;
syncPanCursor(state, canvasArea, false);
};
canvasArea.addEventListener('pointerup', endPan);
canvasArea.addEventListener('pointercancel', endPan);
// Reset offset whenever zoom/fit changes the canvas size.
canvasArea._resetPan = () => applyOffset(0, 0);
const navigation = {
resetPan: canvasArea._resetPan,
setTemporaryPan(active) {
state.spacePanActive = !!active;
syncPanCursor(state, canvasArea, panning);
},
updateCursor() {
syncPanCursor(state, canvasArea, panning);
},
};
navigation.updateCursor();
return navigation;
}
+31
View File
@@ -0,0 +1,31 @@
/** Canvas viewport navigation shared by mouse, pen, and touch input. */
export function isDirectPanIntent(tool, temporaryPan, button = 0) {
return tool === 'hand' || temporaryPan === true || button === 1;
}
export function nextPanOffset(startPointer, startPan, currentPointer) {
return {
x: startPan.x + currentPointer.x - startPointer.x,
y: startPan.y + currentPointer.y - startPointer.y,
};
}
export function applyCanvasPan(state, canvasArea, x, y) {
const panX = Number.isFinite(Number(x)) ? Number(x) : 0;
const panY = Number.isFinite(Number(y)) ? Number(y) : 0;
state.panX = panX;
state.panY = panY;
canvasArea.dataset.panX = String(panX);
canvasArea.dataset.panY = String(panY);
const transform = `translate3d(${panX}px, ${panY}px, 0)`;
if (state.mainCanvas) state.mainCanvas.style.transform = transform;
if (state.selectionOverlay) state.selectionOverlay.style.transform = transform;
if (state.transformOverlay) state.transformOverlay.style.transform = transform;
return { x: panX, y: panY };
}
export function syncPanCursor(state, canvasArea, panning = false) {
const ready = state.tool === 'hand' || state.spacePanActive === true;
canvasArea.classList.toggle('ge-pan-ready', ready && !panning);
canvasArea.classList.toggle('ge-panning', panning);
}
+10 -74
View File
@@ -14,11 +14,15 @@
* fitZoom: () => void,
* showCanvasLoading: (label: string) => void,
* hideCanvasLoading: () => void,
* renderLayerPanel: () => void,
* }} deps
*/
import { state } from './state.js';
import { flipDocument, rotateDocument } from './document-geometry.js';
export function createCanvasTransforms({ saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading }) {
export function createCanvasTransforms({
saveState, composite, fitZoom, showCanvasLoading, hideCanvasLoading, renderLayerPanel,
}) {
return {
/**
* Rotate the entire document by `deg` (90 / 180 / 270). 90 and 270
@@ -34,61 +38,14 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
if (!state.layers.length) return;
saveState(`Rotate ${deg}°`);
showCanvasLoading('Rotating…');
const oldW = state.imgWidth, oldH = state.imgHeight;
const swap = (deg === 90 || deg === 270);
const newW = swap ? oldH : oldW;
const newH = swap ? oldW : oldH;
const rad = (deg * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
requestAnimationFrame(() => {
try {
for (const layer of state.layers) {
const lw = layer.canvas.width, lh = layer.canvas.height;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
// Layer centre in old image coords.
const cx = off.x + lw / 2;
const cy = off.y + lh / 2;
// Rotate the centre around the old image centre and
// translate so the new image centre lands at (newW/2, newH/2).
const dx = cx - oldW / 2;
const dy = cy - oldH / 2;
const nx = dx * cos - dy * sin + newW / 2;
const ny = dx * sin + dy * cos + newH / 2;
// New per-layer dims: swap when 90/270.
const newLw = swap ? lh : lw;
const newLh = swap ? lw : lh;
const tmp = document.createElement('canvas');
tmp.width = newLw; tmp.height = newLh;
const tctx = tmp.getContext('2d');
tctx.translate(newLw / 2, newLh / 2);
tctx.rotate(rad);
tctx.drawImage(layer.canvas, -lw / 2, -lh / 2);
layer.canvas.width = newLw;
layer.canvas.height = newLh;
layer.ctx.drawImage(tmp, 0, 0);
// The adjustment-render caches are keyed only by the adjustment
// signature, which rotation doesn't change — so composite would draw
// the STALE pre-rotation cache (the "had to click twice" bug). Drop
// them so the next composite re-renders from the rotated canvas.
layer._adjCacheKey = null;
layer._adjFinalKey = null;
state.layerOffsets.set(layer.id, {
x: Math.round(nx - newLw / 2),
y: Math.round(ny - newLh / 2),
});
}
state.imgWidth = newW;
state.imgHeight = newH;
state.mainCanvas.width = newW;
state.mainCanvas.height = newH;
if (state.maskCanvas) {
state.maskCanvas.width = newW;
state.maskCanvas.height = newH;
}
const result = rotateDocument(state, deg);
const sizeLabel = document.getElementById('ge-canvas-size');
if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`;
if (sizeLabel && result) sizeLabel.textContent = `${result.width}×${result.height}`;
fitZoom();
composite();
renderLayerPanel?.();
} finally {
hideCanvasLoading();
}
@@ -103,30 +60,9 @@ export function createCanvasTransforms({ saveState, composite, fitZoom, showCanv
flipAll(axis) {
if (!state.layers.length) return;
saveState(axis === 'h' ? 'Flip horizontal' : 'Flip vertical');
for (const layer of state.layers) {
const lw = layer.canvas.width, lh = layer.canvas.height;
const tmp = document.createElement('canvas');
tmp.width = lw; tmp.height = lh;
const tctx = tmp.getContext('2d');
tctx.save();
if (axis === 'h') { tctx.translate(lw, 0); tctx.scale(-1, 1); }
else { tctx.translate(0, lh); tctx.scale(1, -1); }
tctx.drawImage(layer.canvas, 0, 0);
tctx.restore();
layer.ctx.clearRect(0, 0, lw, lh);
layer.ctx.drawImage(tmp, 0, 0);
// Invalidate the adjustment-render caches (keyed by adjustment sig only)
// so composite redraws from the flipped canvas, not a stale cache.
layer._adjCacheKey = null;
layer._adjFinalKey = null;
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
if (axis === 'h') {
state.layerOffsets.set(layer.id, { x: state.imgWidth - off.x - lw, y: off.y });
} else {
state.layerOffsets.set(layer.id, { x: off.x, y: state.imgHeight - off.y - lh });
}
}
flipDocument(state, axis);
composite();
renderLayerPanel?.();
},
};
}
+13 -3
View File
@@ -22,11 +22,12 @@
* createLayer: (name: string, w: number, h: number) => object,
* renderLayerPanel: () => void,
* composite: () => void,
* handleImportedImage: (img: HTMLImageElement) => void,
* handleImportedImage: (img: HTMLImageElement, sourceName?: string) => void,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { createPlacedData, renderPlacedLayer } from './placed-layer.js';
export function wireClipboardAndDrop({
container, saveState, createLayer, renderLayerPanel, composite,
@@ -40,7 +41,13 @@ export function wireClipboardAndDrop({
if (!state.editorOpen) return; // user closed mid-paste
saveState();
const layer = createLayer(label || 'Pasted', imgSource.width, imgSource.height);
layer.ctx.drawImage(imgSource, 0, 0);
// Selection clipboard data is already a complete layer-sized surface.
// Keep it source-backed with an identity matrix so future transforms do
// not repeatedly resample the pasted pixels.
layer.kind = 'placed';
layer.placed = createPlacedData(imgSource, [1, 0, 0, 1, 0, 0], label || 'Pasted');
const rendered = renderPlacedLayer(layer);
state.layerOffsets.set(layer.id, rendered.offset);
state.layers.push(layer);
state.activeLayerId = layer.id;
state.tool = 'move';
@@ -69,7 +76,10 @@ export function wireClipboardAndDrop({
const blob = item.getAsFile();
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { pasteAsLayer(img, 'Pasted'); URL.revokeObjectURL(url); };
// External clipboard images follow the same source-backed import path
// as files, gallery images, and drops. Internal selection clipboard
// content is handled above as an identity placed layer.
img.onload = () => { handleImportedImage(img, 'Pasted image'); URL.revokeObjectURL(url); };
img.src = url;
break;
}
+33
View File
@@ -72,6 +72,7 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
for (const ly of layers) {
if (!ly.masks || !ly.masks.length) continue;
for (const mk of ly.masks) {
if (mk.mode === 'layer') continue;
if (!mk.visible) continue;
if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
ctx.drawImage(mk.canvas, 0, 0);
@@ -81,3 +82,35 @@ export function buildMergedMaskCanvas(layers, imgW, imgH) {
ctx.globalCompositeOperation = 'source-over';
return anyMask ? out : null;
}
/** Apply visible true layer masks to an already-adjusted layer canvas. */
export function renderWithLayerMasks(source, layer, layerOffset = { x: 0, y: 0 }) {
const masks = (layer?.masks || []).filter(mask => mask.mode === 'layer' && mask.visible !== false);
if (!source || masks.length === 0) return source;
const out = document.createElement('canvas');
out.width = source.width;
out.height = source.height;
const ctx = out.getContext('2d');
ctx.drawImage(source, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
ctx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
ctx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
if (mask.space === 'document') {
ctx.drawImage(mask.canvas, -(layerOffset.x || 0), -(layerOffset.y || 0));
} else {
const offset = mask.offset && typeof mask.offset === 'object' ? mask.offset : { x: 0, y: 0 };
ctx.drawImage(mask.canvas, Number(offset.x) || 0, Number(offset.y) || 0);
}
}
ctx.globalAlpha = 1;
ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
return out;
}
@@ -0,0 +1,81 @@
/** Shared lifecycle guard for frame-style pointer interactions. */
export function createDirectManipulationSession({
name = 'interaction',
getContext = () => null,
isContextCurrent = () => true,
onCancel = () => {},
} = {}) {
let active = null;
let nextId = 1;
const pointerMatches = event => {
if (!active || active.pointerId == null || event?.pointerId == null) return true;
return active.pointerId === event.pointerId;
};
const releaseCapture = session => {
if (!session?.captureTarget || session.pointerId == null) return;
try { session.captureTarget.releasePointerCapture(session.pointerId); } catch {}
};
const contextIsCurrent = () => active && isContextCurrent(active.context, active.data);
function begin(event, data = {}, options = {}) {
if (active) cancel('restarted');
const pointerId = Number.isFinite(event?.pointerId) ? event.pointerId : null;
const captureTarget = options.captureTarget || null;
active = {
id: nextId++,
name,
pointerId,
captureTarget,
context: getContext(),
data,
};
if (captureTarget && pointerId != null) {
try { captureTarget.setPointerCapture(pointerId); } catch {}
}
return active;
}
function update(event, callback) {
if (!active || !pointerMatches(event)) return false;
if (!contextIsCurrent()) {
cancel('stale-context');
return false;
}
callback?.(active.data, active);
return true;
}
function commit(event, callback) {
if (!active || !pointerMatches(event)) return false;
if (!contextIsCurrent()) {
cancel('stale-context');
return false;
}
const completed = active;
active = null;
releaseCapture(completed);
callback?.(completed.data, completed);
return true;
}
function cancel(reason = 'cancelled') {
if (!active) return false;
const cancelled = active;
active = null;
releaseCapture(cancelled);
onCancel(cancelled.data, reason, cancelled);
return true;
}
return {
begin,
update,
commit,
cancel,
get active() { return active; },
};
}
+751
View File
@@ -0,0 +1,751 @@
/**
* Lossless, versioned editor document serialization.
*
* Draft autosave and downloaded project files must use this same boundary.
* Keeping a second hand-written layer shape is how masks and adjustments were
* previously dropped when a document was reopened.
*/
import { normalizeAdjustmentData } from './adjustment-layer.js';
import { normalizeEffect } from './effects.js';
import { normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js';
export const EDITOR_DOCUMENT_VERSION = 15;
export const EDITOR_PROJECT_MAX_BYTES = 256 * 1024 * 1024;
export const EDITOR_MAX_DIMENSION = 32768;
export const EDITOR_MAX_DOCUMENT_PIXELS = 100_000_000;
export const EDITOR_MAX_SURFACE_PIXELS = 300_000_000;
export const EDITOR_MAX_LAYERS = 256;
export const EDITOR_MAX_MASKS_PER_LAYER = 64;
export const EDITOR_MAX_GROUPS = 128;
export const EDITOR_MAX_SAVED_SELECTIONS = 64;
const MAX_SERIALIZED_GUIDES = 1000;
const SUPPORTED_BLEND_MODES = new Set([
'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light',
'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion',
'hue', 'saturation', 'color', 'luminosity',
]);
function normalizeGuideValues(values, fallback = []) {
const source = Array.isArray(values) ? values : fallback;
const normalized = [];
for (const raw of Array.isArray(source) ? source : []) {
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) continue;
if (!normalized.some(existing => Math.abs(existing - value) < 0.0001)) normalized.push(value);
if (normalized.length >= MAX_SERIALIZED_GUIDES) break;
}
return normalized.sort((a, b) => a - b);
}
export function normalizeEditorView(view, fallback = {}) {
const source = view && typeof view === 'object' ? view : {};
const fallbackGuides = fallback.guides && typeof fallback.guides === 'object'
? fallback.guides
: { vertical: [], horizontal: [] };
const guides = source.guides && typeof source.guides === 'object' ? source.guides : {};
const rawGridSize = Number(source.gridSize ?? fallback.gridSize ?? 16);
return {
rulersVisible: Boolean(source.rulersVisible ?? fallback.rulersVisible ?? true),
gridVisible: Boolean(source.gridVisible ?? fallback.gridVisible ?? false),
gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))),
snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false),
snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false),
guides: {
vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical),
horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal),
},
};
}
export function cloneDocumentValue(value, fallback) {
try {
return value == null ? fallback : JSON.parse(JSON.stringify(value));
} catch {
return fallback;
}
}
export class EditorDocumentError extends Error {
constructor(message, code = 'invalid-document') {
super(message);
this.name = 'EditorDocumentError';
this.code = code;
}
}
const MIGRATIONS = new Map([
[1, document => ({
...document,
v: 2,
layers: (document.layers || []).map(layer => ({
visible: true,
opacity: 1,
locked: false,
offset: { x: 0, y: 0 },
...layer,
dataUrl: layer?.dataUrl || layer?.dataURL || null,
})),
})],
[2, document => ({
...document,
v: 3,
layers: (document.layers || []).map(layer => ({
adjustments: {},
adjLayers: [],
activeMaskId: null,
masks: [],
...layer,
})),
})],
[3, document => ({
...document,
v: 4,
layers: (document.layers || []).map(layer => ({
kind: 'raster',
text: null,
...layer,
masks: (layer?.masks || []).map(mask => ({
mode: 'selection',
...mask,
space: mask?.space || (mask?.mode === 'layer' ? 'layer' : 'document'),
})),
})),
})],
[4, document => ({
...document,
v: 5,
view: normalizeEditorView(document.view),
})],
[5, document => ({
...document,
v: 6,
groups: [],
})],
[6, document => ({
...document,
v: 7,
layers: (document.layers || []).map(layer => ({ clipped: false, ...layer })),
})],
[7, document => ({
...document,
v: 8,
groups: (document.groups || []).map(group => ({ parentId: null, ...group })),
})],
[8, document => ({
...document,
v: 9,
groups: (document.groups || []).map(group => ({ masks: [], activeMaskId: null, ...group })),
})],
[9, document => ({
...document,
v: 10,
layers: (document.layers || []).map(layer => ({
locks: { pixels: false, transparency: false, position: false },
...layer,
})),
})],
[10, document => ({
...document,
v: 11,
savedSelections: [],
})],
[11, document => ({
...document,
v: 12,
layers: (document.layers || []).map(layer => ({
...layer,
masks: (layer.masks || []).map(mask => mask?.mode === 'layer' ? {
linked: true,
offset: { x: 0, y: 0 },
...mask,
} : mask),
})),
})],
[12, document => ({
...document,
v: 13,
layers: (document.layers || []).map(layer => ({ placed: null, ...layer })),
})],
[13, document => ({
...document,
v: 14,
layers: (document.layers || []).map(layer => ({ adjustment: null, ...layer })),
})],
[14, document => ({
...document,
v: 15,
layers: (document.layers || []).map(layer => ({ effects: [], ...layer })),
groups: (document.groups || []).map(group => ({ effects: [], ...group })),
})],
]);
function migrateEditorDocument(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new EditorDocumentError('Project root must be a JSON object.');
}
const storedVersion = raw.v == null ? 1 : Number(raw.v);
if (!Number.isInteger(storedVersion) || storedVersion < 1) {
throw new EditorDocumentError('Project has an invalid document version.', 'invalid-version');
}
if (storedVersion > EDITOR_DOCUMENT_VERSION) {
throw new EditorDocumentError(
`This project uses version ${storedVersion}; this editor supports up to version ${EDITOR_DOCUMENT_VERSION}.`,
'future-version',
);
}
let document = { ...raw };
while (document.v == null || document.v < EDITOR_DOCUMENT_VERSION) {
const version = document.v == null ? 1 : document.v;
const migrate = MIGRATIONS.get(version);
if (!migrate) throw new EditorDocumentError(`No migration is available for project version ${version}.`, 'missing-migration');
document = migrate(document);
}
return { document, storedVersion };
}
function positiveDimension(value, fallback = 0) {
const numeric = Math.round(Number(value ?? fallback));
return Number.isFinite(numeric) && numeric > 0 && numeric <= EDITOR_MAX_DIMENSION ? numeric : null;
}
function isEmbeddedPng(value) {
return typeof value === 'string' && value.startsWith('data:image/png;base64,');
}
function finiteCoordinate(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) && Math.abs(numeric) <= 1_000_000 ? numeric : fallback;
}
function normalizePlacedRecord(source) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
const sourceWidth = positiveDimension(source.sourceWidth);
const sourceHeight = positiveDimension(source.sourceHeight);
const matrix = Array.isArray(source.matrix) && source.matrix.length === 6
? source.matrix.map(Number)
: null;
if (!sourceWidth || !sourceHeight || !matrix?.every(Number.isFinite) || !isEmbeddedPng(source.sourceDataUrl)) {
return null;
}
if (matrix.some(value => Math.abs(value) > 1_000_000)) return null;
return {
sourceWidth,
sourceHeight,
sourceName: typeof source.sourceName === 'string'
? source.sourceName.slice(0, 200)
: 'Placed image',
sourceDataUrl: source.sourceDataUrl,
matrix,
};
}
/** Migrate and validate untrusted draft/project JSON before allocating canvases. */
export function prepareEditorDocument(raw) {
const { document: migrated, storedVersion } = migrateEditorDocument(raw);
const warnings = [];
const warn = message => {
if (warnings.length < 50) warnings.push(message);
};
const width = positiveDimension(migrated.imgWidth);
const height = positiveDimension(migrated.imgHeight);
if (!width || !height) {
throw new EditorDocumentError(`Project dimensions must be between 1 and ${EDITOR_MAX_DIMENSION} pixels.`, 'invalid-dimensions');
}
if (width * height > EDITOR_MAX_DOCUMENT_PIXELS) {
throw new EditorDocumentError('Project canvas exceeds the 100 megapixel safety limit.', 'pixel-budget');
}
if (!Array.isArray(migrated.layers)) {
throw new EditorDocumentError('Project is missing its layer list.', 'invalid-layers');
}
if (migrated.layers.length > EDITOR_MAX_LAYERS) {
throw new EditorDocumentError(`Project exceeds the ${EDITOR_MAX_LAYERS}-layer limit.`, 'layer-budget');
}
let surfacePixels = width * height;
let embeddedCharacters = 0;
let recoveredId = 1;
const usedIds = new Set();
const layers = [];
for (let index = 0; index < migrated.layers.length; index += 1) {
const source = migrated.layers[index];
const label = typeof source?.name === 'string' && source.name.trim()
? source.name.trim().slice(0, 200)
: `Layer ${index + 1}`;
if (!source || typeof source !== 'object' || Array.isArray(source)) {
warn(`${label} was skipped because its record is invalid.`);
continue;
}
const canvasW = positiveDimension(source.canvasW, width);
const canvasH = positiveDimension(source.canvasH, height);
if (!canvasW || !canvasH) {
warn(`${label} was skipped because its canvas dimensions are invalid.`);
continue;
}
const textIsValid = source.kind === 'text' && source.text && typeof source.text === 'object' && !Array.isArray(source.text);
const shapeIsValid = source.kind === 'shape' && source.shape && typeof source.shape === 'object' && !Array.isArray(source.shape);
const adjustmentIsValid = source.kind === 'adjustment' && source.adjustment && typeof source.adjustment === 'object' && !Array.isArray(source.adjustment);
const placed = source.kind === 'placed' ? normalizePlacedRecord(source.placed) : null;
const placedIsValid = source.kind === 'placed' && !!placed;
if (source.kind && source.kind !== 'raster' && source.kind !== 'text' && source.kind !== 'shape' && source.kind !== 'placed' && source.kind !== 'adjustment') {
warn(`${label} used unsupported layer type "${String(source.kind).slice(0, 40)}" and was recovered as raster pixels.`);
} else if (source.kind === 'text' && !textIsValid) {
warn(`${label} had invalid editable text metadata and was recovered as raster pixels.`);
} else if (source.kind === 'shape' && !shapeIsValid) {
warn(`${label} had invalid editable shape metadata and was recovered as raster pixels.`);
} else if (source.kind === 'adjustment' && !adjustmentIsValid) {
warn(`${label} had invalid adjustment metadata and was recovered as raster pixels.`);
} else if (source.kind === 'placed' && !placedIsValid) {
warn(`${label} had a missing or corrupt placed source and was recovered from its raster preview.`);
}
if (!isEmbeddedPng(source.dataUrl) && !textIsValid && !shapeIsValid && !placedIsValid && !adjustmentIsValid) {
warn(`${label} was skipped because its pixel data is missing or corrupt.`);
continue;
}
if (!isEmbeddedPng(source.dataUrl) && textIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its editable text data.`);
}
if (!isEmbeddedPng(source.dataUrl) && shapeIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its editable shape data.`);
}
if (!isEmbeddedPng(source.dataUrl) && placedIsValid) {
warn(`${label} had no valid preview pixels and will be rebuilt from its placed source.`);
}
embeddedCharacters += typeof source.dataUrl === 'string' ? source.dataUrl.length : 0;
surfacePixels += canvasW * canvasH;
if (placedIsValid) {
embeddedCharacters += placed.sourceDataUrl.length;
surfacePixels += placed.sourceWidth * placed.sourceHeight;
}
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `layer-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${label} received a replacement layer id.`);
}
usedIds.add(id);
const masks = [];
const maskRecords = Array.isArray(source.masks) ? source.masks : [];
if (maskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
warn(`${label} has more than ${EDITOR_MAX_MASKS_PER_LAYER} masks; extras were skipped.`);
}
for (let maskIndex = 0; maskIndex < Math.min(maskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
const mask = maskRecords[maskIndex];
const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Mask ${maskIndex + 1}`;
const maskW = positiveDimension(mask?.canvasW, width);
const maskH = positiveDimension(mask?.canvasH, height);
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
warn(`${label} / ${maskLabel} was skipped because its mask data is invalid.`);
continue;
}
embeddedCharacters += mask.dataUrl.length;
surfacePixels += maskW * maskH;
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
if (!maskId || usedIds.has(maskId)) {
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
warn(`${label} / ${maskLabel} received a replacement mask id.`);
}
usedIds.add(maskId);
const mode = mask.mode === 'layer' ? 'layer' : 'selection';
masks.push({
...mask,
id: maskId,
name: maskLabel,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode,
space: mask.space === 'layer' || mask.space === 'document'
? mask.space
: (mode === 'layer' ? 'layer' : 'document'),
linked: mode === 'layer' ? mask.linked !== false : true,
offset: mode === 'layer' ? {
x: finiteCoordinate(mask.offset?.x),
y: finiteCoordinate(mask.offset?.y),
} : { x: 0, y: 0 },
canvasW: maskW,
canvasH: maskH,
});
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const offset = source.offset && typeof source.offset === 'object' ? source.offset : {};
const opacity = Number(source.opacity);
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
if (source.blendMode && blendMode !== source.blendMode) {
warn(`${label} used unsupported blend mode "${String(source.blendMode).slice(0, 40)}" and was reset to Normal.`);
}
layers.push({
...source,
id,
name: label,
visible: source.visible !== false,
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
locked: !!source.locked,
locks: {
pixels: !!source.locks?.pixels,
transparency: !!source.locks?.transparency,
position: !!source.locks?.position,
},
clipped: !!source.clipped,
blendMode,
kind: textIsValid ? 'text' : (shapeIsValid ? 'shape' : (placedIsValid ? 'placed' : (adjustmentIsValid ? 'adjustment' : 'raster'))),
text: textIsValid ? source.text : null,
shape: shapeIsValid ? source.shape : null,
adjustment: adjustmentIsValid ? normalizeAdjustmentData(source.adjustment) : null,
effects: Array.isArray(source.effects) ? source.effects.map(normalizeEffect) : [],
placed: placedIsValid ? placed : null,
canvasW,
canvasH,
offset: { x: finiteCoordinate(offset.x), y: finiteCoordinate(offset.y) },
masks,
activeMaskId: masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
adjustments: source.adjustments && typeof source.adjustments === 'object' ? source.adjustments : {},
adjLayers: Array.isArray(source.adjLayers) ? source.adjLayers : [],
});
}
if (!layers.length) {
throw new EditorDocumentError('No recoverable layers were found in this project.', 'no-layers');
}
const activeLayerId = layers.some(layer => layer.id === migrated.activeLayerId)
? migrated.activeLayerId
: layers[layers.length - 1].id;
const groups = [];
const claimedLayerIds = new Set();
const validLayerIds = new Set(layers.map(layer => layer.id));
const groupRecords = Array.isArray(migrated.groups) ? migrated.groups : [];
if (groupRecords.length > EDITOR_MAX_GROUPS) {
warn(`Project has more than ${EDITOR_MAX_GROUPS} groups; extras were skipped.`);
}
for (let index = 0; index < Math.min(groupRecords.length, EDITOR_MAX_GROUPS); index += 1) {
const source = groupRecords[index];
if (!source || typeof source !== 'object' || Array.isArray(source)) {
warn(`Group ${index + 1} was skipped because its record is invalid.`);
continue;
}
const layerIds = [];
for (const id of Array.isArray(source.layerIds) ? source.layerIds : []) {
if (validLayerIds.has(id) && !claimedLayerIds.has(id)) {
claimedLayerIds.add(id);
layerIds.push(id);
}
}
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `group-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} received a replacement group id.`);
}
usedIds.add(id);
const groupMasks = [];
const groupMaskRecords = Array.isArray(source.masks) ? source.masks : [];
if (groupMaskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} has too many masks; extras were skipped.`);
}
for (let maskIndex = 0; maskIndex < Math.min(groupMaskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) {
const mask = groupMaskRecords[maskIndex];
const maskLabel = typeof mask?.name === 'string' && mask.name.trim()
? mask.name.trim().slice(0, 200)
: `Group Mask ${maskIndex + 1}`;
const maskW = positiveDimension(mask?.canvasW, width);
const maskH = positiveDimension(mask?.canvasH, height);
if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} was skipped because its mask data is invalid.`);
continue;
}
embeddedCharacters += mask.dataUrl.length;
surfacePixels += maskW * maskH;
let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : '';
if (!maskId || usedIds.has(maskId)) {
do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId));
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} received a replacement mask id.`);
}
usedIds.add(maskId);
groupMasks.push({
id: maskId,
name: maskLabel,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode: 'group',
space: 'document',
canvasW: maskW,
canvasH: maskH,
dataUrl: mask.dataUrl,
});
}
const opacity = Number(source.opacity);
const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over';
if (source.blendMode && blendMode !== source.blendMode) {
warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} used an unsupported blend mode and was reset to Normal.`);
}
groups.push({
id,
name: String(source.name || `Group ${index + 1}`).trim().slice(0, 200) || `Group ${index + 1}`,
layerIds,
parentId: typeof source.parentId === 'string' && source.parentId.trim()
? source.parentId.trim().slice(0, 100)
: null,
visible: source.visible !== false,
opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1,
blendMode,
locked: !!source.locked,
collapsed: !!source.collapsed,
...(Array.isArray(source.effects) && source.effects.length
? { effects: source.effects.map(serializeEffect) }
: {}),
masks: groupMasks,
activeMaskId: groupMasks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null,
});
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const savedSelections = [];
const selectionRecords = Array.isArray(migrated.savedSelections) ? migrated.savedSelections : [];
if (selectionRecords.length > EDITOR_MAX_SAVED_SELECTIONS) {
warn(`Project has more than ${EDITOR_MAX_SAVED_SELECTIONS} saved selections; extras were skipped.`);
}
for (let index = 0; index < Math.min(selectionRecords.length, EDITOR_MAX_SAVED_SELECTIONS); index += 1) {
const source = selectionRecords[index];
const name = typeof source?.name === 'string' && source.name.trim()
? source.name.trim().slice(0, 100)
: `Selection ${index + 1}`;
if (!source || typeof source !== 'object' || source.canvasW !== width || source.canvasH !== height || !isEmbeddedPng(source.dataUrl)) {
warn(`${name} was skipped because its saved selection data is invalid or has the wrong dimensions.`);
continue;
}
embeddedCharacters += source.dataUrl.length;
surfacePixels += width * height;
let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : '';
if (!id || usedIds.has(id)) {
do { id = `selection-recovered-${recoveredId++}`; } while (usedIds.has(id));
warn(`${name} received a replacement selection id.`);
}
usedIds.add(id);
savedSelections.push({ id, name, canvasW: width, canvasH: height, dataUrl: source.dataUrl });
}
if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) {
throw new EditorDocumentError('Project layers, masks, and saved selections exceed the safe in-memory pixel budget.', 'surface-budget');
}
if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) {
throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget');
}
const validGroupIds = new Set(groups.map(group => group.id));
const groupsById = new Map(groups.map(group => [group.id, group]));
for (const group of groups) {
if (group.parentId === group.id || (group.parentId && !validGroupIds.has(group.parentId))) {
warn(`${group.name} referenced a missing or invalid parent group and was moved to the top level.`);
group.parentId = null;
}
}
for (const group of groups) {
const visited = new Set([group.id]);
let cursor = group;
while (cursor.parentId) {
if (visited.has(cursor.parentId)) {
warn(`${group.name} contained a cyclic group relationship and was moved to the top level.`);
group.parentId = null;
break;
}
visited.add(cursor.parentId);
cursor = groupsById.get(cursor.parentId);
if (!cursor) break;
}
}
// A group may contain only child groups. Remove only empty leaves after the
// complete parent graph is available, then repeat for newly empty parents.
while (true) {
const parentIds = new Set(groups.map(group => group.parentId).filter(Boolean));
const emptyIndex = groups.findIndex(group => !group.layerIds.length && !parentIds.has(group.id));
if (emptyIndex < 0) break;
const [empty] = groups.splice(emptyIndex, 1);
warn(`${empty.name} was skipped because it has no recoverable layers or child groups.`);
}
const scopeByLayer = new Map();
for (const group of groups) {
for (const id of group.layerIds) scopeByLayer.set(id, group.id);
}
for (let index = 0; index < layers.length; index += 1) {
const layer = layers[index];
if (!layer.clipped) continue;
const previous = layers[index - 1];
const scope = scopeByLayer.get(layer.id) || null;
if (!previous || (scopeByLayer.get(previous.id) || null) !== scope) {
layer.clipped = false;
warn(`${layer.name} had no clipping base in its layer scope and was released.`);
}
}
if (storedVersion < EDITOR_DOCUMENT_VERSION) {
warnings.unshift(`Project upgraded from version ${storedVersion} to ${EDITOR_DOCUMENT_VERSION}.`);
}
return {
document: {
...migrated,
v: EDITOR_DOCUMENT_VERSION,
imgWidth: width,
imgHeight: height,
activeLayerId,
view: normalizeEditorView(migrated.view),
groups,
savedSelections,
layers,
},
warnings,
migratedFrom: storedVersion < EDITOR_DOCUMENT_VERSION ? storedVersion : null,
};
}
function canvasDataUrl(canvas) {
if (!canvas || typeof canvas.toDataURL !== 'function') return null;
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
function serializeEffect(effect) {
const normalized = normalizeEffect(effect);
if (!normalized.mask) return normalized;
return {
...normalized,
mask: {
id: normalized.mask.id,
name: normalized.mask.name,
visible: normalized.mask.visible !== false,
canvasW: normalized.mask.canvas?.width || normalized.mask.canvasW || 0,
canvasH: normalized.mask.canvas?.height || normalized.mask.canvasH || 0,
dataUrl: canvasDataUrl(normalized.mask.canvas),
},
};
}
export function serializeEditorDocument(state, extras = {}) {
return {
v: EDITOR_DOCUMENT_VERSION,
imageId: state.imageId || null,
imgWidth: state.imgWidth,
imgHeight: state.imgHeight,
activeLayerId: state.activeLayerId || null,
nextLayerId: state.nextLayerId,
view: normalizeEditorView(state),
...extras,
savedSelections: (state.savedSelections || []).slice(0, EDITOR_MAX_SAVED_SELECTIONS).map(selection => ({
id: selection.id,
name: String(selection.name || 'Selection').slice(0, 100),
canvasW: selection.canvas?.width || state.imgWidth || 0,
canvasH: selection.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(selection.canvas),
})),
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...(group.layerIds || [])],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: typeof group.opacity === 'number' ? group.opacity : 1,
blendMode: group.blendMode || 'source-over',
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
...(Array.isArray(group.effects) && group.effects.length
? { effects: group.effects.map(serializeEffect) }
: {}),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
mode: 'group',
space: 'document',
canvasW: mask.canvas?.width || state.imgWidth || 0,
canvasH: mask.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(mask.canvas),
})),
})),
layers: (state.layers || []).map(layer => ({
id: layer.id,
name: layer.name,
visible: layer.visible !== false,
opacity: typeof layer.opacity === 'number' ? layer.opacity : 1,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
isBase: !!layer.isBase,
blendMode: layer.blendMode || 'source-over',
kind: layer.kind || 'raster',
text: cloneDocumentValue(layer.text, null),
shape: cloneDocumentValue(layer.shape, null),
adjustment: cloneDocumentValue(layer.adjustment, null),
effects: Array.isArray(layer.effects) ? layer.effects.map(serializeEffect) : [],
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceWidth: layer.placed.sourceWidth || layer.placed.sourceCanvas.width,
sourceHeight: layer.placed.sourceHeight || layer.placed.sourceCanvas.height,
sourceName: String(layer.placed.sourceName || 'Placed image').slice(0, 200),
sourceDataUrl: canvasDataUrl(layer.placed.sourceCanvas),
matrix: Array.isArray(layer.placed.matrix) ? layer.placed.matrix.map(Number) : [1, 0, 0, 1, 0, 0],
} : null,
canvasW: layer.canvas?.width || 0,
canvasH: layer.canvas?.height || 0,
offset: { ...(state.layerOffsets?.get(layer.id) || { x: 0, y: 0 }) },
dataUrl: canvasDataUrl(layer.canvas),
adjustments: cloneDocumentValue(layer.adjustments, {}),
adjLayers: cloneDocumentValue(layer.adjLayers, []),
activeMaskId: layer.activeMaskId || null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: normalizeMaskDensity(mask.density),
feather: normalizeMaskFeather(mask.feather),
// Existing masks are AI/selection regions. A future true layer mask
// uses mode="layer" without changing old saved documents.
mode: mask.mode || 'selection',
space: mask.space || (mask.mode === 'layer' ? 'layer' : 'document'),
linked: mask.mode === 'layer' ? mask.linked !== false : true,
offset: mask.mode === 'layer'
? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y) }
: { x: 0, y: 0 },
canvasW: mask.canvas?.width || state.imgWidth || 0,
canvasH: mask.canvas?.height || state.imgHeight || 0,
dataUrl: canvasDataUrl(mask.canvas),
})),
})),
};
}
export function nextLayerIdFromDocument(data) {
const stored = Number(data?.nextLayerId);
if (Number.isInteger(stored) && stored > 0) return stored;
let max = 0;
for (const layer of data?.layers || []) {
const match = String(layer?.id || '').match(/(\d+)$/);
if (match) max = Math.max(max, Number(match[1]));
for (const mask of layer?.masks || []) {
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
}
}
for (const group of data?.groups || []) {
const match = String(group?.id || '').match(/(\d+)$/);
if (match) max = Math.max(max, Number(match[1]));
for (const mask of group?.masks || []) {
const maskMatch = String(mask?.id || '').match(/(\d+)$/);
if (maskMatch) max = Math.max(max, Number(maskMatch[1]));
}
}
return max + 1;
}
+575
View File
@@ -0,0 +1,575 @@
/**
* Document-wide geometry operations.
*
* Layer pixels live in layer coordinates, while AI/selection masks live in
* document coordinates. Keeping those spaces explicit here prevents crop and
* resize commands from updating the visible pixels while leaving masks and
* selections behind.
*/
import { flipTextLayer, rotateTextLayer, scaleTextLayer } from './text-layer.js';
import { flipShapeLayer, rotateShapeLayer, scaleShapeLayer } from './shape-layer.js';
import { renderPlacedLayer, transformPlacedData } from './placed-layer.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
function renderWindow(source, x, y, width, height) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, -x, -y);
return out;
}
function renderTranslated(source, width, height, x, y) {
const out = makeCanvas(width, height);
out.getContext('2d').drawImage(source, x, y);
return out;
}
function renderScaled(source, width, height, smoothingQuality = 'high') {
const out = makeCanvas(width, height);
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = smoothingQuality;
ctx.drawImage(source, 0, 0, out.width, out.height);
return out;
}
function renderRotated(source, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
const swap = normalized === 90 || normalized === 270;
const out = makeCanvas(swap ? source.height : source.width, swap ? source.width : source.height);
const ctx = out.getContext('2d');
ctx.translate(out.width / 2, out.height / 2);
ctx.rotate((normalized * Math.PI) / 180);
ctx.drawImage(source, -source.width / 2, -source.height / 2);
return out;
}
function renderFlipped(source, axis) {
const out = makeCanvas(source.width, source.height);
const ctx = out.getContext('2d');
ctx.save();
if (axis === 'h') {
ctx.translate(out.width, 0);
ctx.scale(-1, 1);
} else {
ctx.translate(0, out.height);
ctx.scale(1, -1);
}
ctx.drawImage(source, 0, 0);
ctx.restore();
return out;
}
function replaceCanvas(holder, rendered) {
holder.canvas.width = rendered.width;
holder.canvas.height = rendered.height;
holder.ctx = holder.canvas.getContext('2d');
holder.ctx.clearRect(0, 0, rendered.width, rendered.height);
holder.ctx.drawImage(rendered, 0, 0);
}
function replaceStandaloneCanvas(canvas, rendered) {
canvas.width = rendered.width;
canvas.height = rendered.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, rendered.width, rendered.height);
ctx.drawImage(rendered, 0, 0);
}
function invalidateLayerCaches(layer) {
layer._adjCache = null;
layer._adjCacheKey = null;
layer._adjFinal = null;
layer._adjFinalKey = null;
layer._stagedAdj = null;
layer._editingAdjId = null;
}
function syncActiveMask(editorState) {
const activeGroup = (editorState.layerGroups || []).find(group => group.id === editorState.activeGroupId);
const groupMask = activeGroup?.masks?.find(item => item.id === activeGroup.activeMaskId) || null;
if (groupMask) {
editorState.maskCanvas = groupMask.canvas;
editorState.maskCtx = groupMask.ctx;
return;
}
const active = editorState.layers.find(layer => layer.id === editorState.activeLayerId);
const mask = active?.masks?.find(item => item.id === active.activeMaskId) || null;
editorState.maskCanvas = mask?.canvas || null;
editorState.maskCtx = mask?.ctx || null;
}
function syncDocumentCanvas(editorState, width, height) {
editorState.imgWidth = width;
editorState.imgHeight = height;
if (editorState.mainCanvas) {
editorState.mainCanvas.width = width;
editorState.mainCanvas.height = height;
editorState.mainCtx = editorState.mainCanvas.getContext('2d');
}
editorState.documentCompositeCanvas = null;
editorState.compositeMaskUnion = null;
editorState.wandSrcCache = null;
syncActiveMask(editorState);
}
function normalizedGuideValues(values, limit, mapper = value => value) {
const output = [];
for (const raw of Array.isArray(values) ? values : []) {
const value = mapper(Number(raw));
if (!Number.isFinite(value) || value < 0 || value > limit) continue;
if (!output.some(existing => Math.abs(existing - value) < 0.0001)) output.push(value);
}
return output.sort((a, b) => a - b);
}
function transformGuides(editorState, vertical, horizontal) {
editorState.guides = {
vertical: normalizedGuideValues(vertical.values, vertical.limit, vertical.map),
horizontal: normalizedGuideValues(horizontal.values, horizontal.limit, horizontal.map),
};
}
function clipPolygonAxis(points, inside, intersect) {
if (!points.length) return [];
const output = [];
let previous = points[points.length - 1];
let previousInside = inside(previous);
for (const current of points) {
const currentInside = inside(current);
if (currentInside !== previousInside) output.push(intersect(previous, current));
if (currentInside) output.push(current);
previous = current;
previousInside = currentInside;
}
return output;
}
/** Clip a polygon to a document rectangle using Sutherland-Hodgman. */
export function clipPolygonToRect(points, width, height) {
let out = (points || []).map(point => ({ x: point.x, y: point.y }));
const xAt = (value) => (a, b) => {
const dx = b.x - a.x;
const t = dx === 0 ? 0 : (value - a.x) / dx;
return { x: value, y: a.y + (b.y - a.y) * t };
};
const yAt = (value) => (a, b) => {
const dy = b.y - a.y;
const t = dy === 0 ? 0 : (value - a.y) / dy;
return { x: a.x + (b.x - a.x) * t, y: value };
};
out = clipPolygonAxis(out, p => p.x >= 0, xAt(0));
out = clipPolygonAxis(out, p => p.x <= width, xAt(width));
out = clipPolygonAxis(out, p => p.y >= 0, yAt(0));
out = clipPolygonAxis(out, p => p.y <= height, yAt(height));
return out.length >= 3 ? out : [];
}
function normalizeCropRect(editorState, rect) {
const x = Math.max(0, Math.min(editorState.imgWidth - 1, Math.round(rect.x)));
const y = Math.max(0, Math.min(editorState.imgHeight - 1, Math.round(rect.y)));
const width = Math.max(1, Math.min(editorState.imgWidth - x, Math.round(rect.w)));
const height = Math.max(1, Math.min(editorState.imgHeight - y, Math.round(rect.h)));
return { x, y, width, height };
}
function forEachStoredSelection(editorState, callback) {
for (const selection of editorState.savedSelections || []) {
if (selection?.canvas) callback(selection.canvas);
}
if (editorState.lastSelection?.canvas) callback(editorState.lastSelection.canvas);
}
/** Crop layer pixels, masks, and transient selections as one document. */
export function cropDocument(editorState, rect) {
if (!editorState.imgWidth || !editorState.imgHeight) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const { x, y, width, height } = normalizeCropRect(editorState, rect);
const oldOffsets = new Map(editorState.layerOffsets);
for (const layer of editorState.layers) {
const offset = oldOffsets.get(layer.id) || { x: 0, y: 0 };
const retainedText = layer.kind === 'text' && layer.text;
const retainedShape = layer.kind === 'shape' && layer.shape;
let placedOffset = null;
if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [1, 0, 0, 1, -x, -y]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else if (!retainedText && !retainedShape) {
replaceCanvas(layer, renderWindow(layer.canvas, x - offset.x, y - offset.y, width, height));
}
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if ((retainedText || retainedShape || layer.kind === 'placed') && !documentSpace) continue;
const maskOffset = mask.offset || { x: 0, y: 0 };
const sourceX = documentSpace ? x : x - offset.x - (Number(maskOffset.x) || 0);
const sourceY = documentSpace ? y : y - offset.y - (Number(maskOffset.y) || 0);
replaceCanvas(mask, renderWindow(mask.canvas, sourceX, sourceY, width, height));
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) mask.offset = { x: 0, y: 0 };
}
editorState.layerOffsets.set(layer.id, placedOffset || ((retainedText || retainedShape)
? { x: offset.x - x, y: offset.y - y }
: { x: 0, y: 0 }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderWindow(mask.canvas, x, y, width, height));
}
if (editorState.wandMask) {
const sourceLayerOffset = oldOffsets.get(editorState.wandLayerId) || { x: 0, y: 0 };
const documentSpace = editorState.wandMaskSpace === 'document' || (
!editorState.wandMaskSpace && editorState.wandMask.width === oldWidth && editorState.wandMask.height === oldHeight
);
const sourceX = documentSpace ? x : x - sourceLayerOffset.x;
const sourceY = documentSpace ? y : y - sourceLayerOffset.y;
replaceStandaloneCanvas(
editorState.wandMask,
renderWindow(editorState.wandMask, sourceX, sourceY, width, height),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderWindow(canvas, x, y, width, height));
});
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x - x, y: editorState.wandLastSeed.y - y };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < width && seed.y < height ? seed : null;
}
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x - x, y: point.y - y })),
width,
height,
);
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: width, map: value => value - x },
{ values: guides.horizontal, limit: height, map: value => value - y },
);
editorState.cropRect = null;
editorState.cropStart = null;
editorState.cropEnd = null;
syncDocumentCanvas(editorState, width, height);
return { x, y, width, height };
}
/** Resize the actual image content, including every layer-space/document-space mask. */
export function resizeImageDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
if (!oldWidth || !oldHeight) return null;
const scaleX = newWidth / oldWidth;
const scaleY = newHeight / oldHeight;
const quality = options.smoothingQuality || 'high';
for (const layer of editorState.layers) {
const targetLayerWidth = Math.max(1, Math.round(layer.canvas.width * scaleX));
const targetLayerHeight = Math.max(1, Math.round(layer.canvas.height * scaleY));
let placedOffset = null;
if (layer.kind === 'text' && layer.text) scaleTextLayer(layer, scaleX, scaleY);
else if (layer.kind === 'shape' && layer.shape) scaleShapeLayer(layer, scaleX, scaleY);
else if (layer.kind === 'placed' && layer.placed) {
layer.placed = transformPlacedData(layer.placed, [scaleX, 0, 0, scaleY, 0, 0]);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderScaled(layer.canvas, targetLayerWidth, targetLayerHeight, quality));
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
const targetMaskWidth = documentSpace
? newWidth
: Math.max(1, Math.round(mask.canvas.width * scaleX));
const targetMaskHeight = documentSpace
? newHeight
: Math.max(1, Math.round(mask.canvas.height * scaleY));
replaceCanvas(
mask,
renderScaled(mask.canvas, targetMaskWidth, targetMaskHeight, quality),
);
mask.space = documentSpace ? 'document' : 'layer';
if (!documentSpace) {
mask.offset = {
x: Math.round((Number(mask.offset?.x) || 0) * scaleX),
y: Math.round((Number(mask.offset?.y) || 0) * scaleY),
};
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || {
x: Math.round(offset.x * scaleX),
y: Math.round(offset.y * scaleY),
});
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderScaled(mask.canvas, newWidth, newHeight, quality));
}
if (editorState.wandMask) {
replaceStandaloneCanvas(
editorState.wandMask,
renderScaled(
editorState.wandMask,
editorState.wandMaskSpace === 'document' ? newWidth : Math.max(1, Math.round(editorState.wandMask.width * scaleX)),
editorState.wandMaskSpace === 'document' ? newHeight : Math.max(1, Math.round(editorState.wandMask.height * scaleY)),
quality,
),
);
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderScaled(canvas, newWidth, newHeight, quality));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: editorState.wandLastSeed.x * scaleX,
y: editorState.wandLastSeed.y * scaleY,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: point.x * scaleX,
y: point.y * scaleY,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value * scaleX },
{ values: guides.horizontal, limit: newHeight, map: value => value * scaleY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, scaleX, scaleY };
}
/** Change document bounds without resampling layer pixels. Origin stays top-left. */
export function resizeCanvasDocument(editorState, width, height, options = {}) {
const newWidth = Math.max(1, Math.round(width));
const newHeight = Math.max(1, Math.round(height));
const anchorX = Math.max(0, Math.min(1, Number(options.anchorX) || 0));
const anchorY = Math.max(0, Math.min(1, Number(options.anchorY) || 0));
const shiftX = Math.round((newWidth - editorState.imgWidth) * anchorX);
const shiftY = Math.round((newHeight - editorState.imgHeight) * anchorY);
for (const layer of editorState.layers) {
for (const mask of layer.masks || []) {
const documentSpace = (mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'document';
if (!documentSpace) continue;
replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
mask.space = 'document';
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, {
x: (Number(offset.x) || 0) + shiftX,
y: (Number(offset.y) || 0) + shiftY,
});
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderTranslated(mask.canvas, newWidth, newHeight, shiftX, shiftY));
}
if (editorState.wandMask && editorState.wandMaskSpace === 'document') {
replaceStandaloneCanvas(editorState.wandMask, renderTranslated(editorState.wandMask, newWidth, newHeight, shiftX, shiftY));
}
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderTranslated(canvas, newWidth, newHeight, shiftX, shiftY));
});
editorState.lassoPoints = clipPolygonToRect(
(editorState.lassoPoints || []).map(point => ({ x: point.x + shiftX, y: point.y + shiftY })),
newWidth,
newHeight,
);
if (editorState.wandLastSeed) {
const seed = { ...editorState.wandLastSeed, x: editorState.wandLastSeed.x + shiftX, y: editorState.wandLastSeed.y + shiftY };
editorState.wandLastSeed = seed.x >= 0 && seed.y >= 0 && seed.x < newWidth && seed.y < newHeight ? seed : null;
}
const guides = editorState.guides || {};
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => value + shiftX },
{ values: guides.horizontal, limit: newHeight, map: value => value + shiftY },
);
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight };
}
function rotatedPoint(point, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - point.y, y: point.x };
if (normalized === 180) return { x: oldWidth - point.x, y: oldHeight - point.y };
if (normalized === 270) return { x: point.y, y: oldWidth - point.x };
return { x: point.x, y: point.y };
}
function rotatedOffset(offset, width, height, degrees, oldWidth, oldHeight) {
const normalized = ((degrees % 360) + 360) % 360;
if (normalized === 90) return { x: oldHeight - offset.y - height, y: offset.x };
if (normalized === 180) return { x: oldWidth - offset.x - width, y: oldHeight - offset.y - height };
if (normalized === 270) return { x: offset.y, y: oldWidth - offset.x - width };
return { x: offset.x, y: offset.y };
}
/** Rotate every layer, mask, offset, and active selection by a right angle. */
export function rotateDocument(editorState, degrees) {
const normalized = ((degrees % 360) + 360) % 360;
if (![90, 180, 270].includes(normalized)) return null;
const oldWidth = editorState.imgWidth;
const oldHeight = editorState.imgHeight;
const newWidth = normalized === 180 ? oldWidth : oldHeight;
const newHeight = normalized === 180 ? oldHeight : oldWidth;
for (const layer of editorState.layers) {
const oldLayerWidth = layer.canvas.width;
const oldLayerHeight = layer.canvas.height;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
let placedOffset = null;
if (layer.kind === 'text' && layer.text) rotateTextLayer(layer, normalized);
else if (layer.kind === 'shape' && layer.shape) rotateShapeLayer(layer, normalized);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = normalized === 90
? [0, 1, -1, 0, oldHeight, 0]
: normalized === 180
? [-1, 0, 0, -1, oldWidth, oldHeight]
: [0, -1, 1, 0, 0, oldWidth];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderRotated(layer.canvas, normalized));
for (const mask of layer.masks || []) {
const oldMaskWidth = mask.canvas.width;
const oldMaskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderRotated(mask.canvas, normalized));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
if (normalized === 90) {
mask.offset = { x: oldLayerHeight - oy - oldMaskHeight, y: ox };
} else if (normalized === 180) {
mask.offset = { x: oldLayerWidth - ox - oldMaskWidth, y: oldLayerHeight - oy - oldMaskHeight };
} else {
mask.offset = { x: oy, y: oldLayerWidth - ox - oldMaskWidth };
}
}
}
editorState.layerOffsets.set(layer.id, placedOffset ||
rotatedOffset(offset, oldLayerWidth, oldLayerHeight, normalized, oldWidth, oldHeight));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderRotated(mask.canvas, normalized));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderRotated(editorState.wandMask, normalized));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderRotated(canvas, normalized));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
...rotatedPoint(editorState.wandLastSeed, normalized, oldWidth, oldHeight),
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point =>
rotatedPoint(point, normalized, oldWidth, oldHeight));
const guides = editorState.guides || {};
if (normalized === 90) {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth, map: value => oldHeight - value },
{ values: guides.vertical, limit: newHeight },
);
} else if (normalized === 180) {
transformGuides(
editorState,
{ values: guides.vertical, limit: newWidth, map: value => oldWidth - value },
{ values: guides.horizontal, limit: newHeight, map: value => oldHeight - value },
);
} else {
transformGuides(
editorState,
{ values: guides.horizontal, limit: newWidth },
{ values: guides.vertical, limit: newHeight, map: value => oldWidth - value },
);
}
syncDocumentCanvas(editorState, newWidth, newHeight);
return { width: newWidth, height: newHeight, degrees: normalized };
}
/** Flip every layer, mask, offset, and active selection across the document. */
export function flipDocument(editorState, axis) {
if (axis !== 'h' && axis !== 'v') return null;
const width = editorState.imgWidth;
const height = editorState.imgHeight;
for (const layer of editorState.layers) {
const layerWidth = layer.canvas.width;
const layerHeight = layer.canvas.height;
let placedOffset = null;
if (layer.kind === 'text' && layer.text) flipTextLayer(layer, axis);
else if (layer.kind === 'shape' && layer.shape) flipShapeLayer(layer, axis);
else if (layer.kind === 'placed' && layer.placed) {
const matrix = axis === 'h'
? [-1, 0, 0, 1, width, 0]
: [1, 0, 0, -1, 0, height];
layer.placed = transformPlacedData(layer.placed, matrix);
placedOffset = renderPlacedLayer(layer)?.offset || null;
} else replaceCanvas(layer, renderFlipped(layer.canvas, axis));
for (const mask of layer.masks || []) {
const maskWidth = mask.canvas.width;
const maskHeight = mask.canvas.height;
const maskOffset = mask.offset || { x: 0, y: 0 };
replaceCanvas(mask, renderFlipped(mask.canvas, axis));
if ((mask.space || (mask.mode === 'layer' ? 'layer' : 'document')) === 'layer') {
const ox = Number(maskOffset.x) || 0;
const oy = Number(maskOffset.y) || 0;
mask.offset = axis === 'h'
? { x: layerWidth - ox - maskWidth, y: oy }
: { x: ox, y: layerHeight - oy - maskHeight };
}
}
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
editorState.layerOffsets.set(layer.id, placedOffset || (axis === 'h'
? { x: width - offset.x - layerWidth, y: offset.y }
: { x: offset.x, y: height - offset.y - layerHeight }));
invalidateLayerCaches(layer);
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) replaceCanvas(mask, renderFlipped(mask.canvas, axis));
}
if (editorState.wandMask) replaceStandaloneCanvas(editorState.wandMask, renderFlipped(editorState.wandMask, axis));
forEachStoredSelection(editorState, canvas => {
replaceStandaloneCanvas(canvas, renderFlipped(canvas, axis));
});
if (editorState.wandLastSeed) {
editorState.wandLastSeed = {
...editorState.wandLastSeed,
x: axis === 'h' ? width - editorState.wandLastSeed.x : editorState.wandLastSeed.x,
y: axis === 'v' ? height - editorState.wandLastSeed.y : editorState.wandLastSeed.y,
};
}
editorState.lassoPoints = (editorState.lassoPoints || []).map(point => ({
x: axis === 'h' ? width - point.x : point.x,
y: axis === 'v' ? height - point.y : point.y,
}));
const guides = editorState.guides || {};
transformGuides(
editorState,
{
values: guides.vertical,
limit: width,
map: axis === 'h' ? value => width - value : value => value,
},
{
values: guides.horizontal,
limit: height,
map: axis === 'v' ? value => height - value : value => value,
},
);
syncDocumentCanvas(editorState, width, height);
return { width, height, axis };
}
+118
View File
@@ -0,0 +1,118 @@
/* Worker implementation for retained-effect rasterization. */
function copyCanvas(source) {
const out = new OffscreenCanvas(source.width, source.height);
out.getContext('2d').drawImage(source, 0, 0);
return out;
}
function sharpenCanvas(source, amount) {
if (!amount) return copyCanvas(source);
const out = new OffscreenCanvas(source.width, source.height);
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
const dst = out.getContext('2d').createImageData(source.width, source.height);
const a = Math.max(0, Math.min(1, Number(amount) || 0));
const stride = source.width * 4;
for (let y = 0; y < source.height; y += 1) {
for (let x = 0; x < source.width; x += 1) {
const at = (y * source.width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
const center = src.data[at + channel] * (1 + 4 * a);
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
}
dst.data[at + 3] = src.data[at + 3];
}
}
out.getContext('2d').putImageData(dst, 0, 0);
return out;
}
function maskedContribution(effectCanvas, mask) {
if (!mask) return effectCanvas;
const out = new OffscreenCanvas(effectCanvas.width, effectCanvas.height);
const ctx = out.getContext('2d');
ctx.drawImage(effectCanvas, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(mask, 0, 0, out.width, out.height);
ctx.globalCompositeOperation = 'source-over';
return out;
}
function gradientColor(value, alpha = 1) {
const raw = String(value || '').replace(/^#/, '');
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
return `rgba(${channels.join(',')},${alpha})`;
}
function renderEffects(source, effects, masks) {
let current = copyCanvas(source);
for (let index = 0; index < effects.length; index += 1) {
const effect = effects[index];
if (effect.visible === false || effect.opacity <= 0) continue;
const next = new OffscreenCanvas(current.width, current.height);
const ctx = next.getContext('2d');
if (effect.type === 'gaussian-blur') {
ctx.filter = `blur(${effect.params.radius}px)`;
ctx.drawImage(current, 0, 0);
ctx.filter = 'none';
} else if (effect.type === 'sharpen') {
ctx.drawImage(sharpenCanvas(current, effect.params.amount), 0, 0);
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
ctx.globalCompositeOperation = effect.params.blendMode;
ctx.fillStyle = effect.params.color;
ctx.fillRect(0, 0, next.width, next.height);
} else if (effect.type === 'drop-shadow') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.blur;
ctx.shadowOffsetX = effect.params.x;
ctx.shadowOffsetY = effect.params.y;
ctx.drawImage(current, 0, 0);
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.width;
ctx.drawImage(current, 0, 0);
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
const p = effect.params;
const gradient = effect.type === 'radial-gradient'
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
for (const stop of p.stops || []) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
ctx.globalAlpha = p.opacity;
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, next.width, next.height);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.drawImage(current, 0, 0);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
const contribution = maskedContribution(next, masks[index]);
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
current = blended;
}
return current;
}
self.onmessage = event => {
try {
const { source, effects, masks } = event.data;
const output = renderEffects(source, effects || [], masks || []);
const bitmap = output.transferToImageBitmap();
self.postMessage({ bitmap }, [bitmap]);
} catch (error) {
self.postMessage({ error: String(error?.message || error) });
}
};
+323
View File
@@ -0,0 +1,323 @@
/** Retained, non-destructive layer effects. */
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
export const EFFECT_PRESETS = {
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
'crisp-detail': { type: 'sharpen', params: { amount: 0.35 } },
'soft-shadow': { type: 'drop-shadow', params: { color: '#000000', opacity: 0.3, blur: 8, x: 2, y: 3 } },
'white-outline': { type: 'stroke', params: { color: '#ffffff', opacity: 0.85, width: 2 } },
};
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeEffectMask(mask) {
if (!mask || typeof mask !== 'object' || Array.isArray(mask)) return null;
return {
id: String(mask.id || `effect-mask-${Math.random().toString(36).slice(2, 9)}`),
name: String(mask.name || 'Effect Mask'),
visible: mask.visible !== false,
canvas: mask.canvas || null,
ctx: mask.ctx || null,
canvasW: Number(mask.canvasW) || mask.canvas?.width || 0,
canvasH: Number(mask.canvasH) || mask.canvas?.height || 0,
dataUrl: typeof mask.dataUrl === 'string' ? mask.dataUrl : null,
};
}
export function defaultEffectParams(type) {
if (type === 'gaussian-blur') return { radius: 6 };
if (type === 'sharpen') return { amount: 0.5 };
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
if (type === 'drop-shadow') return { color: '#000000', opacity: 0.45, blur: 12, x: 4, y: 6 };
if (type === 'stroke') return { color: '#ffffff', opacity: 1, width: 3 };
if (type === 'linear-gradient' || type === 'radial-gradient') return {
x1: 0, y1: 0, x2: 1, y2: 0,
stops: [{ position: 0, color: '#e06c75', alpha: 1 }, { position: 100, color: '#ffffff', alpha: 1 }],
opacity: 1,
};
return {};
}
export function effectPreset(name) {
const preset = EFFECT_PRESETS[name];
return preset ? { type: preset.type, params: clone(preset.params) } : null;
}
export function normalizeEffect(effect) {
const type = EFFECT_TYPES.has(effect?.type) ? effect.type : 'gaussian-blur';
const defaults = defaultEffectParams(type);
const params = effect?.params && typeof effect.params === 'object'
? { ...defaults, ...clone(effect.params) }
: defaults;
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
if (type === 'color-overlay') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
params.blendMode = typeof params.blendMode === 'string' ? params.blendMode : defaults.blendMode;
}
if (type === 'drop-shadow') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
for (const key of ['opacity', 'blur', 'x', 'y']) params[key] = Number(params[key]) || 0;
params.opacity = Math.max(0, Math.min(1, params.opacity));
params.blur = Math.max(0, Math.min(200, params.blur));
params.x = Math.max(-200, Math.min(200, params.x));
params.y = Math.max(-200, Math.min(200, params.y));
}
if (type === 'stroke') {
params.color = /^#[0-9a-f]{6}$/i.test(params.color) ? params.color : defaults.color;
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
params.width = Math.max(0, Math.min(100, Number(params.width) || 0));
}
if (type === 'linear-gradient' || type === 'radial-gradient') {
for (const key of ['x1', 'y1', 'x2', 'y2']) {
const value = Number(params[key]);
params[key] = Number.isFinite(value) ? Math.max(-2_000_000, Math.min(2_000_000, value)) : defaults[key];
}
params.opacity = Math.max(0, Math.min(1, Number(params.opacity) || 0));
const stops = Array.isArray(params.stops) ? params.stops : defaults.stops;
params.stops = stops.map(stop => ({
position: Math.max(0, Math.min(100, Number(stop?.position) || 0)),
color: /^#[0-9a-f]{6}$/i.test(stop?.color) ? stop.color : '#000000',
alpha: Math.max(0, Math.min(1, Number(stop?.alpha ?? 1) || 0)),
})).sort((a, b) => a.position - b.position).slice(0, 12);
if (params.stops.length < 2) params.stops = clone(defaults.stops);
}
return {
id: String(effect?.id || `effect-${Math.random().toString(36).slice(2, 9)}`),
type,
name: String(effect?.name || effectLabel(type)),
visible: effect?.visible !== false,
opacity: Math.max(0, Math.min(1, Number(effect?.opacity ?? 1) || 0)),
mask: normalizeEffectMask(effect?.mask),
params,
};
}
export function effectLabel(type) {
return {
'gaussian-blur': 'Gaussian Blur',
sharpen: 'Sharpen',
'color-overlay': 'Color Overlay',
'drop-shadow': 'Drop Shadow',
'stroke': 'Stroke',
'linear-gradient': 'Gradient',
'radial-gradient': 'Radial Gradient',
}[type] || type;
}
function copyCanvas(source) {
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
canvas.getContext('2d').drawImage(source, 0, 0);
return canvas;
}
function gradientColor(value, alpha = 1) {
const raw = String(value || '').replace(/^#/, '');
if (!/^[0-9a-f]{6}$/i.test(raw)) return `rgba(0,0,0,${alpha})`;
const channels = [0, 2, 4].map(offset => parseInt(raw.slice(offset, offset + 2), 16));
return `rgba(${channels.join(',')},${alpha})`;
}
function renderGradient(ctx, effect, width, height) {
const p = effect.params;
const gradient = effect.type === 'radial-gradient'
? ctx.createRadialGradient(p.x1, p.y1, 0, p.x1, p.y1, Math.max(0.5, Math.hypot(p.x2 - p.x1, p.y2 - p.y1)))
: ctx.createLinearGradient(p.x1, p.y1, p.x2, p.y2);
for (const stop of p.stops) gradient.addColorStop(stop.position / 100, gradientColor(stop.color, stop.alpha));
ctx.globalAlpha = p.opacity;
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1;
}
function applyEffectMask(source, effected, mask) {
if (!mask?.canvas || !source || !effected) return effected;
const masked = document.createElement('canvas');
masked.width = effected.width;
masked.height = effected.height;
const mctx = masked.getContext('2d');
mctx.drawImage(effected, 0, 0);
mctx.globalCompositeOperation = 'destination-in';
mctx.drawImage(mask.canvas, 0, 0, masked.width, masked.height);
mctx.globalCompositeOperation = 'source-over';
return masked;
}
function sharpenCanvas(source, amount, shouldContinue = () => true) {
if (!amount) return copyCanvas(source);
const out = document.createElement('canvas');
out.width = source.width;
out.height = source.height;
const src = source.getContext('2d').getImageData(0, 0, source.width, source.height);
const dst = out.getContext('2d').createImageData(source.width, source.height);
const a = Math.max(0, Math.min(1, Number(amount) || 0));
const stride = source.width * 4;
for (let y = 0; y < source.height; y += 1) {
if ((y & 7) === 0 && !shouldContinue()) return null;
for (let x = 0; x < source.width; x += 1) {
const at = (y * source.width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
const center = src.data[at + channel] * (1 + 4 * a);
const left = src.data[y * stride + Math.max(0, x - 1) * 4 + channel];
const right = src.data[y * stride + Math.min(source.width - 1, x + 1) * 4 + channel];
const above = src.data[Math.max(0, y - 1) * stride + x * 4 + channel];
const below = src.data[Math.min(source.height - 1, y + 1) * stride + x * 4 + channel];
dst.data[at + channel] = Math.max(0, Math.min(255, center - a * (left + right + above + below)));
}
dst.data[at + 3] = src.data[at + 3];
}
}
out.getContext('2d').putImageData(dst, 0, 0);
return out;
}
/** Render effects in list order without mutating the source layer. */
export function renderEffects(source, effects = [], shouldContinue = () => true) {
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
let current = copyCanvas(source);
for (const raw of effects) {
if (!shouldContinue()) return current;
const effect = normalizeEffect(raw);
if (!effect.visible || effect.opacity <= 0) continue;
const next = document.createElement('canvas');
next.width = current.width;
next.height = current.height;
const ctx = next.getContext('2d');
if (effect.type === 'gaussian-blur') {
ctx.filter = `blur(${effect.params.radius}px)`;
ctx.drawImage(current, 0, 0);
ctx.filter = 'none';
} else if (effect.type === 'sharpen') {
const sharpened = sharpenCanvas(current, effect.params.amount, shouldContinue);
if (!sharpened) return current;
ctx.drawImage(sharpened, 0, 0);
} else if (effect.type === 'color-overlay') {
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = effect.params.opacity;
ctx.globalCompositeOperation = effect.params.blendMode;
ctx.fillStyle = effect.params.color;
ctx.fillRect(0, 0, next.width, next.height);
ctx.globalAlpha = 1;
} else if (effect.type === 'drop-shadow') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.blur;
ctx.shadowOffsetX = effect.params.x;
ctx.shadowOffsetY = effect.params.y;
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = 1;
} else if (effect.type === 'stroke') {
ctx.globalAlpha = effect.params.opacity;
ctx.shadowColor = effect.params.color;
ctx.shadowBlur = effect.params.width;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
ctx.drawImage(current, 0, 0);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.drawImage(current, 0, 0);
ctx.globalAlpha = 1;
} else if (effect.type === 'linear-gradient' || effect.type === 'radial-gradient') {
renderGradient(ctx, effect, next.width, next.height);
}
ctx.globalCompositeOperation = 'source-over';
const contribution = effect.mask?.canvas && effect.mask.visible !== false
? applyEffectMask(current, next, effect.mask)
: next;
const blended = copyCanvas(current);
const blendCtx = blended.getContext('2d');
blendCtx.globalAlpha = effect.opacity;
blendCtx.drawImage(contribution, 0, 0);
blendCtx.globalAlpha = 1;
current = blended;
}
return current;
}
/**
* Rasterize retained effects off the main thread when browser canvas workers
* are available. The synchronous renderer remains the compatibility fallback.
*/
export async function renderEffectsAsync(source, effects = [], shouldContinue = () => true) {
if (!source || !Array.isArray(effects) || effects.length === 0) return source;
if (typeof Worker === 'undefined' || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap !== 'function') {
return renderEffects(source, effects, shouldContinue);
}
if (!shouldContinue()) return source;
let sourceBitmap;
const maskBitmaps = [];
try {
sourceBitmap = await createImageBitmap(source);
for (const effect of effects) {
if (!shouldContinue()) {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return null;
}
maskBitmaps.push(effect?.mask?.canvas && effect.mask.visible !== false
? await createImageBitmap(effect.mask.canvas)
: null);
}
} catch {
sourceBitmap?.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
}
if (!shouldContinue()) {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return source;
}
let worker;
try {
worker = new Worker(new URL('./effects-worker.js', import.meta.url), { type: 'module' });
} catch {
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
return shouldContinue() ? renderEffects(source, effects, shouldContinue) : null;
}
const payloadEffects = effects.map(raw => {
const effect = normalizeEffect(raw);
return { ...effect, mask: null };
});
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
worker.terminate();
sourceBitmap.close?.();
for (const bitmap of maskBitmaps) bitmap?.close?.();
resolve(result);
};
worker.onmessage = event => {
const { bitmap, error } = event.data || {};
if (error || !bitmap || !shouldContinue()) {
bitmap?.close?.();
finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : source);
return;
}
const output = document.createElement('canvas');
output.width = source.width;
output.height = source.height;
output.getContext('2d').drawImage(bitmap, 0, 0);
bitmap.close?.();
finish(output);
};
// A stale worker must not trigger a full-resolution fallback while a newer
// preview is already queued.
worker.onerror = () => finish(shouldContinue() ? renderEffects(source, effects, shouldContinue) : null);
worker.postMessage({ source: sourceBitmap, effects: payloadEffects, masks: maskBitmaps }, [
sourceBitmap,
...maskBitmaps.filter(Boolean),
]);
});
}
export const EFFECT_TYPES_LIST = [...EFFECT_TYPES];
+221
View File
@@ -0,0 +1,221 @@
/** Controlled image export dialog and canvas encoding helpers. */
const FORMAT_META = {
png: { mime: 'image/png', extension: 'png', quality: undefined },
jpeg: { mime: 'image/jpeg', extension: 'jpg', quality: 0.9 },
webp: { mime: 'image/webp', extension: 'webp', quality: 0.9 },
};
function clampNumber(value, min, max, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
export function normalizeExportSettings(settings, sourceWidth, sourceHeight) {
const format = FORMAT_META[settings?.format] ? settings.format : 'png';
return {
format,
width: Math.round(clampNumber(settings?.width, 1, 16384, sourceWidth)),
height: Math.round(clampNumber(settings?.height, 1, 16384, sourceHeight)),
quality: clampNumber(settings?.quality, 0.01, 1, FORMAT_META[format].quality ?? 1),
transparency: format !== 'jpeg' && settings?.transparency !== false,
matte: /^#[0-9a-f]{6}$/i.test(settings?.matte || '') ? settings.matte : '#ffffff',
filename: String(settings?.filename || 'edited-image').trim() || 'edited-image',
};
}
export function prepareExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const canvas = document.createElement('canvas');
canvas.width = normalized.width;
canvas.height = normalized.height;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
if (!normalized.transparency) {
ctx.fillStyle = normalized.matte;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
return canvas;
}
export function encodeExportCanvas(sourceCanvas, settings) {
const normalized = normalizeExportSettings(settings, sourceCanvas.width, sourceCanvas.height);
const output = prepareExportCanvas(sourceCanvas, normalized);
const meta = FORMAT_META[normalized.format];
return new Promise((resolve, reject) => {
output.toBlob(
blob => blob ? resolve({ blob, settings: normalized, extension: meta.extension }) : reject(new Error('Image encoding failed')),
meta.mime,
meta.quality == null ? undefined : normalized.quality,
);
});
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return 'Estimating...';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function openExportDialog({
sourceCanvas,
defaultName = 'edited-image',
title = 'Export Image',
submitLabel = 'Export',
attachColorPicker = null,
returnFocus = null,
}) {
return new Promise(resolve => {
const previouslyFocused = returnFocus || document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'ge-export-overlay';
overlay.innerHTML = `
<form class="ge-export-dialog" role="dialog" aria-modal="true" aria-labelledby="ge-export-title">
<header class="ge-export-head">
<h2 id="ge-export-title">${String(title).replace(/[<>&]/g, '')}</h2>
<button type="button" class="ge-export-close" aria-label="Close">&times;</button>
</header>
<div class="ge-export-body">
<div class="ge-export-preview-wrap"><canvas class="ge-export-preview"></canvas></div>
<div class="ge-export-fields">
<label class="ge-export-field"><span>Filename</span><input id="ge-export-filename" type="text" value="${String(defaultName).replace(/[<>&"']/g, '')}" /></label>
<div class="ge-export-field"><span>Format</span><div class="ge-export-format" role="group" aria-label="Export format">
<button type="button" class="active" data-format="png">PNG</button>
<button type="button" data-format="jpeg">JPEG</button>
<button type="button" data-format="webp">WebP</button>
</div></div>
<div class="ge-export-dimensions">
<label class="ge-export-field"><span>Width</span><input id="ge-export-width" type="number" min="1" max="16384" value="${sourceCanvas.width}" /></label>
<button type="button" class="ge-export-link active" aria-label="Lock aspect ratio" title="Lock aspect ratio" aria-pressed="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button>
<label class="ge-export-field"><span>Height</span><input id="ge-export-height" type="number" min="1" max="16384" value="${sourceCanvas.height}" /></label>
</div>
<label class="ge-export-field ge-export-quality"><span>Quality <output>90%</output></span><input id="ge-export-quality" type="range" min="1" max="100" value="90" /></label>
<div class="ge-export-surface-row">
<label class="ge-export-toggle"><input id="ge-export-transparency" type="checkbox" checked /><span>Transparency</span></label>
<label class="ge-export-matte"><span>Matte</span><input id="ge-export-matte" class="ge-color-picker" type="color" value="#ffffff" /></label>
</div>
</div>
</div>
<footer class="ge-export-footer"><span class="ge-export-estimate">Estimating...</span><div><button type="button" class="ge-btn ge-btn-sm ge-export-cancel">Cancel</button><button type="submit" class="ge-btn ge-btn-primary">${String(submitLabel).replace(/[<>&]/g, '')}</button></div></footer>
</form>`;
document.body.appendChild(overlay);
const dialog = overlay.querySelector('.ge-export-dialog');
const preview = overlay.querySelector('.ge-export-preview');
const filename = overlay.querySelector('#ge-export-filename');
const widthInput = overlay.querySelector('#ge-export-width');
const heightInput = overlay.querySelector('#ge-export-height');
const qualityInput = overlay.querySelector('#ge-export-quality');
const qualityField = overlay.querySelector('.ge-export-quality');
const qualityOutput = qualityField.querySelector('output');
const transparency = overlay.querySelector('#ge-export-transparency');
const matte = overlay.querySelector('#ge-export-matte');
const matteField = overlay.querySelector('.ge-export-matte');
const estimate = overlay.querySelector('.ge-export-estimate');
const link = overlay.querySelector('.ge-export-link');
const ratio = sourceCanvas.width / sourceCanvas.height;
let format = 'png';
let ratioLocked = true;
let estimateTimer = null;
let estimateVersion = 0;
try { attachColorPicker?.(matte); } catch {}
const settings = () => normalizeExportSettings({
format,
width: widthInput.value,
height: heightInput.value,
quality: Number(qualityInput.value) / 100,
transparency: transparency.checked,
matte: matte.value,
filename: filename.value,
}, sourceCanvas.width, sourceCanvas.height);
const renderPreview = () => {
const current = settings();
const max = 420;
const scale = Math.min(max / current.width, max / current.height, 1);
preview.width = Math.max(1, Math.round(current.width * scale));
preview.height = Math.max(1, Math.round(current.height * scale));
const ctx = preview.getContext('2d');
// Settings can change without changing dimensions, so do not let the
// previous matte or alpha state bleed into the next preview.
ctx.clearRect(0, 0, preview.width, preview.height);
if (!current.transparency) {
ctx.fillStyle = current.matte;
ctx.fillRect(0, 0, preview.width, preview.height);
}
ctx.drawImage(sourceCanvas, 0, 0, preview.width, preview.height);
};
const update = () => {
const lossy = format !== 'png';
qualityField.hidden = !lossy;
transparency.disabled = format === 'jpeg';
if (format === 'jpeg') transparency.checked = false;
matteField.classList.toggle('disabled', transparency.checked && format !== 'jpeg');
qualityOutput.textContent = `${qualityInput.value}%`;
renderPreview();
estimate.textContent = 'Estimating...';
clearTimeout(estimateTimer);
const version = ++estimateVersion;
estimateTimer = setTimeout(async () => {
try {
const encoded = await encodeExportCanvas(sourceCanvas, settings());
if (version === estimateVersion) estimate.textContent = formatBytes(encoded.blob.size);
} catch {
if (version === estimateVersion) estimate.textContent = 'Estimate unavailable';
}
}, 180);
};
overlay.querySelectorAll('[data-format]').forEach(button => {
button.addEventListener('click', () => {
format = button.dataset.format;
overlay.querySelectorAll('[data-format]').forEach(candidate => {
candidate.classList.toggle('active', candidate.dataset.format === format);
});
update();
});
});
link.addEventListener('click', () => {
ratioLocked = !ratioLocked;
link.classList.toggle('active', ratioLocked);
link.setAttribute('aria-pressed', ratioLocked ? 'true' : 'false');
});
widthInput.addEventListener('input', () => {
if (ratioLocked) heightInput.value = String(Math.max(1, Math.round(Number(widthInput.value || 1) / ratio)));
update();
});
heightInput.addEventListener('input', () => {
if (ratioLocked) widthInput.value = String(Math.max(1, Math.round(Number(heightInput.value || 1) * ratio)));
update();
});
[qualityInput, transparency, matte].forEach(input => input.addEventListener('input', update));
const close = result => {
clearTimeout(estimateTimer);
document.removeEventListener('keydown', onKey, true);
overlay.remove();
if (previouslyFocused && previouslyFocused.isConnected && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus({ preventScroll: true });
}
resolve(result);
};
const onKey = event => {
if (event.key === 'Escape') { event.preventDefault(); close(null); }
};
document.addEventListener('keydown', onKey, true);
overlay.querySelector('.ge-export-close').addEventListener('click', () => close(null));
overlay.querySelector('.ge-export-cancel').addEventListener('click', () => close(null));
overlay.addEventListener('click', event => { if (event.target === overlay) close(null); });
dialog.addEventListener('submit', event => { event.preventDefault(); close(settings()); });
update();
filename.select();
});
}
+383 -21
View File
@@ -43,11 +43,13 @@ import modalManager from '../../modalManager.js';
import {
ADJ_ICONS,
adjLayerLabel,
adjustmentPresetOptions,
adjustmentPresetParams,
defaultAdjParams,
} from '../layer-helpers.js';
import { drawHistogram } from './histogram.js';
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel }) {
export function createAdjPopupSystem({ composite, saveState, renderLayerPanel, getAdjustmentSource = null }) {
function suppressLayerGhostTap() {
window.__geSuppressLayerTapUntil = Date.now() + 650;
}
@@ -104,6 +106,10 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
}
function openFxPopup(layer, anchorEl) {
if (layer?.kind === 'adjustment') {
openAdjPopup(layer, layer.adjustment?.type || 'levels', anchorEl, layer.adjustment);
return;
}
// Toggle off ONLY if a menu for this layer is genuinely on-screen.
// `state` is a shared singleton that survives editor close/reopen,
// so a stale `fxMenuEl` from a previous session (whose detached
@@ -135,9 +141,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
menu.style.pointerEvents = 'auto';
const items = [
{ type: 'brightness-contrast', label: 'Brightness / Contrast' },
{ type: 'exposure', label: 'Exposure' },
{ type: 'white-balance', label: 'White Balance' },
{ type: 'hue-saturation', label: 'Hue / Saturation' },
{ type: 'vibrance', label: 'Vibrance' },
{ type: 'black-white', label: 'Black & White' },
{ type: 'shadows-highlights', label: 'Shadows / Highlights' },
{ type: 'levels', label: 'Levels' },
{ type: 'curves', label: 'Curves' },
{ type: 'color-balance', label: 'Color Balance' },
{ type: 'selective-color', label: 'Selective Color' },
{ type: 'gradient-map', label: 'Gradient Map' },
];
menu.innerHTML = items.map(i =>
`<button class="ge-fx-menu-item" data-fx-type="${i.type}"><span class="ge-fx-menu-icon">${ADJ_ICONS[i.type] || ''}</span><span>${i.label}</span></button>`
@@ -257,7 +271,21 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
suppressLayerGhostTap();
const layer = state.adjPopupEl._layer;
if (layer) {
if (state.adjPreviewLayer === layer) state.adjPreviewLayer = null;
layer._adjPreview = false;
layer._adjCompare = false;
if (layer._stagedAdj) layer._stagedAdj = null;
if (layer._newAdjustmentLayer) {
const index = state.layers.findIndex(item => item.id === layer.id);
if (index >= 0) state.layers.splice(index, 1);
state.layerOffsets.delete(layer.id);
if (Number.isInteger(layer._newAdjustmentUndoLength) && state.undoStack.length > layer._newAdjustmentUndoLength) {
state.undoStack.length = layer._newAdjustmentUndoLength;
}
state.activeLayerId = state.layers[state.layers.length - 1]?.id || null;
layer._newAdjustmentLayer = false;
renderLayerPanel();
}
if (layer._editingAdjId) layer._editingAdjId = null;
layer._adjFinalKey = null;
composite();
@@ -277,11 +305,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
closeAdjPopup();
// Editing an existing sub-layer? Pre-load its params as the staged
// preview and mark the popup so Apply updates instead of appending.
const firstClass = layer?.kind === 'adjustment';
const editing = !!existingAdj;
const presetOptions = adjustmentPresetOptions(type);
const startParams = editing
? JSON.parse(JSON.stringify(existingAdj.params))
: defaultAdjParams(type);
layer._stagedAdj = { type, params: startParams };
layer._adjPreview = true;
state.adjPreviewLayer = layer;
if (editing) {
// Hide the existing sub-layer from the render stack so the
// staged preview shows correctly without doubling the effect.
@@ -303,8 +335,17 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
<button class="ge-adj-min" type="button" title="Minimise">&minus;</button>
</span>
</div>
<label class="ge-adj-preset-field">Preset
<select class="ge-adj-preset-select">
<option value="custom"${editing ? ' selected' : ''}>Custom</option>
${presetOptions.map(name => `<option value="${name}"${!editing && name === 'Default' ? ' selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<div class="ge-adj-body" data-adj-body></div>
<div class="ge-adj-foot">
<button class="ge-btn ge-btn-sm ge-adj-reset-btn" data-adj-action="reset">Reset</button>
<span class="ge-adj-foot-spacer"></span>
<button class="ge-btn ge-btn-sm ge-adj-compare-btn" data-adj-action="compare" aria-pressed="false">Compare</button>
<button class="ge-btn ge-btn-sm ge-adj-cancel-btn" data-adj-action="cancel">Cancel</button>
<button class="ge-btn ge-btn-sm ge-btn-primary ge-adj-apply-btn" data-adj-action="ok">Apply</button>
</div>
@@ -330,6 +371,15 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const body = pop.querySelector('[data-adj-body]');
buildAdjBody(layer, type, body, pop);
body.addEventListener('input', () => {
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'custom';
});
const popupRect = pop.getBoundingClientRect();
if (!window.matchMedia('(max-width: 820px)').matches) {
const clampedTop = Math.max(8, Math.min(Number.parseFloat(pop.style.top) || 8, window.innerHeight - popupRect.height - 8));
pop.style.top = `${clampedTop}px`;
}
pop.querySelector('.ge-adj-close')?.addEventListener('click', closeAdjPopup);
pop.querySelector('.ge-adj-min')?.addEventListener('click', () => minimiseAdjPopup(pop));
@@ -391,14 +441,52 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
e.stopPropagation();
closeAdjPopup();
});
pop.querySelector('[data-adj-action="reset"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._stagedAdj.params = defaultAdjParams(type);
const presetSelect = pop.querySelector('.ge-adj-preset-select');
if (presetSelect) presetSelect.value = 'Default';
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('.ge-adj-preset-select')?.addEventListener('change', (e) => {
e.preventDefault();
e.stopPropagation();
const preset = e.currentTarget.value;
layer._stagedAdj.params = adjustmentPresetParams(type, preset);
layer._adjFinalKey = null;
body.innerHTML = '';
buildAdjBody(layer, type, body, pop);
scheduleAdjRefresh(layer);
});
pop.querySelector('[data-adj-action="compare"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
layer._adjCompare = !layer._adjCompare;
e.currentTarget.setAttribute('aria-pressed', layer._adjCompare ? 'true' : 'false');
e.currentTarget.classList.toggle('is-active', layer._adjCompare);
layer._adjFinalKey = null;
composite();
});
pop.querySelector('[data-adj-action="ok"]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
suppressLayerGhostTap();
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
if (!layer._newAdjustmentLayer) {
saveState(editing ? `Edit ${adjLayerLabel(type)}` : `Add ${adjLayerLabel(type)}`);
}
const params = layer._stagedAdj.params;
layer._stagedAdj = null;
if (editing) {
layer._adjPreview = false;
layer._adjCompare = false;
if (firstClass) {
layer.adjustment = { type, params };
layer.name = layer.name || adjLayerLabel(type);
layer._newAdjustmentLayer = false;
layer._newAdjustmentUndoLength = null;
} else if (editing) {
const existing = (layer.adjLayers || []).find(a => a.id === existingAdj.id);
if (existing) existing.params = params;
layer._editingAdjId = null;
@@ -426,6 +514,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
state.adjRafPending = true;
requestAnimationFrame(() => {
state.adjRafPending = false;
if (state.adjPreviewLayer !== layer || !layer._stagedAdj) return;
layer._adjFinalKey = null;
composite();
});
@@ -433,6 +522,13 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
function buildAdjBody(layer, type, body, popEl) {
const p = layer._stagedAdj.params;
const levelValues = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
if (channel === 'rgb') return p;
if (!p.channels) p.channels = {};
if (!p.channels[channel]) p.channels[channel] = defaultAdjParams('levels').channels[channel];
return p.channels[channel];
};
const revertIcon = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
const sliderRow = (key, label, min, max, value, suffix) => `
<div class="ge-adj-row" data-adj-key="${key}">
@@ -449,14 +545,46 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
${sliderRow('brightness', 'Brightness', -100, 100, bSlider, '')}
${sliderRow('contrast', 'Contrast', -100, 100, cSlider, '')}
`;
} else if (type === 'exposure') {
body.innerHTML = `
${sliderRow('exposure', 'Exposure', -500, 500, Math.round(p.exposure * 100), ' EV')}
${sliderRow('offset', 'Offset', -50, 50, Math.round(p.offset * 100), '')}
${sliderRow('gamma', 'Gamma', 10, 300, Math.round(p.gamma * 100), 'γ')}
`;
} else if (type === 'white-balance') {
body.innerHTML = `
${sliderRow('temperature', 'Temperature', -100, 100, Math.round(p.temperature), '')}
${sliderRow('tint', 'Tint', -100, 100, Math.round(p.tint), '')}
`;
} else if (type === 'hue-saturation') {
const hSlider = Math.round(p.hue);
const sSlider = Math.round((p.saturation - 1) * 100);
body.innerHTML = `
${sliderRow('hue', 'Hue', -180, 180, hSlider, ' °')}
${sliderRow('saturation', 'Saturation', -100, 100, sSlider, '')}
${sliderRow('lightness', 'Lightness', -100, 100, Math.round(p.lightness || 0), '')}
`;
} else if (type === 'vibrance') {
body.innerHTML = `
${sliderRow('vibrance', 'Vibrance', -100, 100, Math.round(p.vibrance || 0), '')}
<p class="ge-adj-help">Boosts muted colors more than already-saturated colors.</p>
`;
} else if (type === 'black-white') {
body.innerHTML = `
${sliderRow('red', 'Red', 0, 100, Math.round(p.red ?? 30), '%')}
${sliderRow('green', 'Green', 0, 100, Math.round(p.green ?? 59), '%')}
${sliderRow('blue', 'Blue', 0, 100, Math.round(p.blue ?? 11), '%')}
${sliderRow('constant', 'Constant', -100, 100, Math.round(p.constant || 0), '')}
<p class="ge-adj-help">Mixes the source channels into a neutral grayscale result.</p>
`;
} else if (type === 'shadows-highlights') {
body.innerHTML = `
${sliderRow('shadows', 'Shadows', -100, 100, Math.round(p.shadows || 0), '')}
${sliderRow('highlights', 'Highlights', -100, 100, Math.round(p.highlights || 0), '')}
<p class="ge-adj-help">Recover detail in dark and bright tones without shifting midtones.</p>
`;
} else if (type === 'levels') {
const values = levelValues();
// Histogram canvas + sliders. Histogram is computed from the
// layer's pixel data (after any adjLayers below this one) so
// the user is matching levels against what they're really seeing.
@@ -464,6 +592,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
// vertical space; open by default on desktop.
const isMobile = window.matchMedia('(max-width: 820px)').matches;
body.innerHTML = `
<label class="ge-adj-channel-field">Channel
<select class="ge-adj-channel-select">
<option value="rgb"${(p.channel || 'rgb') === 'rgb' ? ' selected' : ''}>RGB</option>
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
<details class="ge-adj-hist-details"${isMobile ? '' : ' open'}>
<summary>Histogram</summary>
<div class="ge-adj-hist-wrap">
@@ -474,20 +610,60 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
<div class="ge-adj-hist-handle hist-h-white" data-handle="inWhite" title="Input white — drag"></div>
</div>
</div>
<div class="ge-adj-clip-status" aria-live="polite"></div>
</details>
${sliderRow('inBlack', 'Input black', 0, 254, p.inBlack, '')}
${sliderRow('inWhite', 'Input white', 1, 255, p.inWhite, '')}
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((p.gamma || 1) * 100), 'γ')}
${sliderRow('outBlack', 'Output black', 0, 255, p.outBlack, '')}
${sliderRow('outWhite', 'Output white', 0, 255, p.outWhite, '')}
${sliderRow('inBlack', 'Input black', 0, 254, values.inBlack, '')}
${sliderRow('inWhite', 'Input white', 1, 255, values.inWhite, '')}
${sliderRow('gamma', 'Gamma', 10, 990, Math.round((values.gamma || 1) * 100), 'γ')}
${sliderRow('outBlack', 'Output black', 0, 255, values.outBlack, '')}
${sliderRow('outWhite', 'Output white', 0, 255, values.outWhite, '')}
`;
const hist = body.querySelector('.ge-adj-histogram');
drawHistogram(hist, layer);
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const clipStatus = body.querySelector('.ge-adj-clip-status');
const renderHistogram = () => {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const stats = drawHistogram(hist, histogramLayer, channel);
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
};
renderHistogram();
wireHistogramHandles(body, layer, type);
// Redraw histogram when the user opens the disclosure (canvas
// dimensions are layout-dependent).
body.querySelector('.ge-adj-hist-details')?.addEventListener('toggle', (e) => {
if (e.target.open) drawHistogram(hist, layer);
if (e.target.open) renderHistogram();
});
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'curves') {
if (!p.points) p.points = defaultAdjParams('curves').points;
if (!p.channel) p.channel = 'rgb';
body.innerHTML = `
<label class="ge-adj-channel-field">Channel
<select class="ge-adj-channel-select">
<option value="rgb"${p.channel === 'rgb' ? ' selected' : ''}>RGB</option>
<option value="red"${p.channel === 'red' ? ' selected' : ''}>Red</option>
<option value="green"${p.channel === 'green' ? ' selected' : ''}>Green</option>
<option value="blue"${p.channel === 'blue' ? ' selected' : ''}>Blue</option>
</select>
</label>
<div class="ge-curves-wrap">
<canvas class="ge-curves-canvas" width="280" height="180" aria-label="Editable tone curve"></canvas>
</div>
<p class="ge-adj-help">Click the curve to add a point. Drag to adjust. Double-click a point to remove it.</p>
`;
const canvas = body.querySelector('.ge-curves-canvas');
wireCurveEditor(canvas, layer);
body.querySelector('.ge-adj-channel-select')?.addEventListener('change', event => {
p.channel = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'color-balance') {
// Color-tinted slider ends so the user sees what direction does what.
@@ -527,6 +703,49 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
body.innerHTML = '';
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'selective-color') {
if (!p.range) p.range = 'reds';
if (!p.ranges?.[p.range]) p.ranges = defaultAdjParams(type).ranges;
const values = p.ranges[p.range];
const ranges = ['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks'];
body.innerHTML = `
<label class="ge-adj-channel-field">Colors
<select class="ge-adj-selective-range">
${ranges.map(name => `<option value="${name}"${p.range === name ? ' selected' : ''}>${name[0].toUpperCase() + name.slice(1)}</option>`).join('')}
</select>
</label>
${sliderRow('cyan', 'Cyan', -100, 100, values.cyan, '')}
${sliderRow('magenta', 'Magenta', -100, 100, values.magenta, '')}
${sliderRow('yellow', 'Yellow', -100, 100, values.yellow, '')}
${sliderRow('black', 'Black', -100, 100, values.black, '')}
`;
body.querySelector('.ge-adj-selective-range')?.addEventListener('change', event => {
p.range = event.target.value;
buildAdjBody(layer, type, body, popEl);
});
} else if (type === 'gradient-map') {
body.innerHTML = `
<div class="ge-gradient-map-preview" style="--ge-gradient-start:${p.shadows};--ge-gradient-end:${p.highlights}"></div>
<div class="ge-gradient-color-row">
<label>Shadows <input type="color" data-gradient-key="shadows" value="${p.shadows}"></label>
<label>Highlights <input type="color" data-gradient-key="highlights" value="${p.highlights}"></label>
</div>
${sliderRow('midpoint', 'Midpoint', 1, 99, Math.round(p.midpoint), '%')}
<label class="ge-adj-toggle-row"><span>Reverse</span><input type="checkbox" data-gradient-reverse${p.reverse ? ' checked' : ''}><span class="ge-toggle-track"></span></label>
`;
body.querySelectorAll('[data-gradient-key]').forEach(input => {
input.addEventListener('input', () => {
p[input.dataset.gradientKey] = input.value;
const preview = body.querySelector('.ge-gradient-map-preview');
preview?.style.setProperty('--ge-gradient-start', p.shadows);
preview?.style.setProperty('--ge-gradient-end', p.highlights);
scheduleAdjRefresh(layer);
});
});
body.querySelector('[data-gradient-reverse]')?.addEventListener('change', event => {
p.reverse = event.target.checked;
scheduleAdjRefresh(layer);
});
}
// Wire all sliders.
body.querySelectorAll('input[type="range"]').forEach(sl => {
@@ -550,13 +769,18 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
function revertAdjKey(layer, type, key) {
const defaults = defaultAdjParams(type);
const p = layer._stagedAdj.params;
if (type === 'brightness-contrast' || type === 'hue-saturation') {
if (['brightness-contrast', 'exposure', 'white-balance', 'hue-saturation', 'vibrance', 'black-white', 'shadows-highlights', 'gradient-map'].includes(type)) {
p[key] = defaults[key];
} else if (type === 'levels') {
p[key] = defaults[key];
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
const defaultValues = channel === 'rgb' ? defaults : defaults.channels[channel];
values[key] = defaultValues[key];
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = defaults[tone][ch];
} else if (type === 'selective-color') {
p.ranges[p.range][key] = defaults.ranges[p.range][key];
}
layer._adjFinalKey = null;
composite();
@@ -573,21 +797,142 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
p[key] = 1 + raw / 100;
} else if (key === 'hue') {
p.hue = raw; display = raw + ' °';
} else if (key === 'lightness') {
p.lightness = raw;
}
} else if (type === 'exposure') {
if (key === 'exposure') { p.exposure = raw / 100; display = (raw / 100).toFixed(2) + ' EV'; }
else if (key === 'offset') { p.offset = raw / 100; display = (raw / 100).toFixed(2); }
else if (key === 'gamma') { p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ'; }
} else if (type === 'white-balance') {
p[key] = raw;
} else if (type === 'vibrance') {
p[key] = raw;
} else if (type === 'black-white') {
p[key] = raw;
} else if (type === 'shadows-highlights') {
p[key] = raw;
} else if (type === 'levels') {
const channel = ['red', 'green', 'blue'].includes(p.channel) ? p.channel : 'rgb';
const values = channel === 'rgb' ? p : p.channels[channel];
if (key === 'gamma') {
p.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
values.gamma = raw / 100; display = (raw / 100).toFixed(2) + 'γ';
} else {
p[key] = raw;
values[key] = raw;
}
} else if (type === 'color-balance') {
const [tone, ch] = key.split('-');
p[tone][ch] = raw;
} else if (type === 'selective-color') {
p.ranges[p.range][key] = raw;
} else if (type === 'gradient-map') {
p.midpoint = raw;
display = raw + '%';
}
if (valEl) valEl.textContent = display;
scheduleAdjRefresh(layer);
}
function wireCurveEditor(canvas, layer) {
if (!canvas) return;
const params = layer._stagedAdj.params;
const channelColor = { rgb: '#f1f5f9', red: '#ff6b78', green: '#57d68d', blue: '#69a7ff' };
const points = () => params.points[params.channel] || params.points.rgb;
const canvasPoint = event => {
const rect = canvas.getBoundingClientRect();
return {
x: Math.max(0, Math.min(255, ((event.clientX - rect.left) / rect.width) * 255)),
y: Math.max(0, Math.min(255, (1 - (event.clientY - rect.top) / rect.height) * 255)),
};
};
const draw = () => {
const source = layer.kind === 'adjustment' && getAdjustmentSource
? getAdjustmentSource(layer)
: layer.canvas;
drawHistogram(canvas, { canvas: source });
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.strokeStyle = 'rgba(255,255,255,.12)';
ctx.lineWidth = 1;
for (let index = 1; index < 4; index += 1) {
const x = Math.round(width * index / 4) + .5;
const y = Math.round(height * index / 4) + .5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
}
const list = points();
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath();
list.forEach((point, index) => {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
if (index) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.stroke();
for (const point of list) {
const x = point[0] / 255 * width;
const y = height - point[1] / 255 * height;
ctx.fillStyle = '#0b0d10';
ctx.strokeStyle = channelColor[params.channel] || channelColor.rgb;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
};
const nearestIndex = event => {
const point = canvasPoint(event);
let nearest = -1;
let distance = 14;
points().forEach((candidate, index) => {
const next = Math.hypot(candidate[0] - point.x, candidate[1] - point.y);
if (next < distance) { distance = next; nearest = index; }
});
return nearest;
};
canvas.addEventListener('pointerdown', event => {
event.preventDefault();
const list = points();
let index = nearestIndex(event);
if (index < 0) {
const point = canvasPoint(event);
list.push([Math.round(point.x), Math.round(point.y)]);
list.sort((a, b) => a[0] - b[0]);
index = list.findIndex(item => item[0] === Math.round(point.x) && item[1] === Math.round(point.y));
}
canvas.setPointerCapture(event.pointerId);
const move = moveEvent => {
const next = canvasPoint(moveEvent);
const current = list[index];
const endpoint = index === 0 || index === list.length - 1;
current[0] = endpoint
? (index === 0 ? 0 : 255)
: Math.round(Math.max(list[index - 1][0] + 1, Math.min(list[index + 1][0] - 1, next.x)));
current[1] = Math.round(next.y);
draw();
scheduleAdjRefresh(layer);
};
const up = () => {
canvas.releasePointerCapture(event.pointerId);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', up);
};
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', up);
draw();
scheduleAdjRefresh(layer);
});
canvas.addEventListener('dblclick', event => {
const index = nearestIndex(event);
const list = points();
if (index <= 0 || index >= list.length - 1) return;
list.splice(index, 1);
draw();
scheduleAdjRefresh(layer);
});
draw();
}
// Position the three histogram triangle handles by current staged
// values + wire pointer drags.
function wireHistogramHandles(bodyEl, layer, type) {
@@ -595,9 +940,14 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const canvas = bodyEl.querySelector('.ge-adj-histogram');
if (!wrap || !canvas) return;
const handles = bodyEl.querySelectorAll('.ge-adj-hist-handle');
const levelParams = () => {
const root = layer._stagedAdj.params;
const channel = ['red', 'green', 'blue'].includes(root.channel) ? root.channel : 'rgb';
return channel === 'rgb' ? root : root.channels[channel];
};
const placeHandles = () => {
const w = canvas.getBoundingClientRect().width;
const p = layer._stagedAdj.params;
const p = levelParams();
const xB = (p.inBlack / 255) * w;
const xW = (p.inWhite / 255) * w;
// Gamma handle sits at a fraction of the (xB..xW) span, mapped
@@ -623,7 +973,7 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
const onMove = (ev) => {
const x = Math.max(0, Math.min(rect.width, ev.clientX - rect.left));
const v = Math.round((x / rect.width) * 255);
const p = layer._stagedAdj.params;
const p = levelParams();
if (which === 'inBlack') {
p.inBlack = Math.min(p.inWhite - 1, v);
} else if (which === 'inWhite') {
@@ -642,14 +992,26 @@ export function createAdjPopupSystem({ composite, saveState, renderLayerPanel })
// Update visible slider rows + value labels.
const updateRow = (key, displayVal) => {
const sl = bodyEl.querySelector(`input[type="range"][data-key="${key}"]`);
if (sl) sl.value = String(key === 'gamma' ? Math.round(layer._stagedAdj.params.gamma * 100) : layer._stagedAdj.params[key]);
const values = levelParams();
if (sl) sl.value = String(key === 'gamma' ? Math.round(values.gamma * 100) : values[key]);
const val = sl?.parentElement.querySelector('.ge-adj-value');
if (val) val.textContent = displayVal;
};
if (which === 'inBlack') updateRow('inBlack', String(layer._stagedAdj.params.inBlack));
if (which === 'inWhite') updateRow('inWhite', String(layer._stagedAdj.params.inWhite));
if (which === 'gamma') updateRow('gamma', layer._stagedAdj.params.gamma.toFixed(2) + 'γ');
drawHistogram(canvas, layer);
const values = levelParams();
if (which === 'inBlack') updateRow('inBlack', String(values.inBlack));
if (which === 'inWhite') updateRow('inWhite', String(values.inWhite));
if (which === 'gamma') updateRow('gamma', values.gamma.toFixed(2) + 'γ');
const histogramLayer = layer.kind === 'adjustment' && getAdjustmentSource
? { canvas: getAdjustmentSource(layer), _stagedAdj: { params: values } }
: layer;
const channel = ['red', 'green', 'blue'].includes(layer._stagedAdj.params.channel)
? layer._stagedAdj.params.channel : 'rgb';
const stats = drawHistogram(canvas, histogramLayer, channel);
const clipStatus = bodyEl.querySelector('.ge-adj-clip-status');
if (clipStatus) {
const channelLabel = channel === 'rgb' ? 'RGB' : channel[0].toUpperCase() + channel.slice(1);
clipStatus.textContent = `${channelLabel} clipping: black ${stats.black.toFixed(1)}% · white ${stats.white.toFixed(1)}%`;
}
scheduleAdjRefresh(layer);
};
const onUp = () => {
+22 -5
View File
@@ -12,9 +12,11 @@
* canvas: HTMLCanvasElement,
* _stagedAdj?: {params?: {inBlack?: number, inWhite?: number}}
* }} layer Source layer.
* @param {'rgb'|'red'|'green'|'blue'} channel Channel to inspect. `rgb` uses luminance.
* @returns {{black: number, white: number, samples: number}}
*/
export function drawHistogram(canvas, layer) {
if (!canvas) return;
export function drawHistogram(canvas, layer, channel = 'rgb') {
if (!canvas) return { black: 0, white: 0, samples: 0 };
const w = canvas.width, h = canvas.height;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
@@ -33,11 +35,21 @@ export function drawHistogram(canvas, layer) {
const img = tctx.getImageData(0, 0, sampleW, sampleH).data;
const hist = new Uint32Array(256);
let samples = 0;
let black = 0;
let white = 0;
for (let i = 0; i < img.length; i += 4) {
if (img[i + 3] < 8) continue; // skip near-transparent
// Rec. 709 luminance — common choice for histograms in photo editors.
const Y = (0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2]) | 0;
hist[Math.min(255, Y)]++;
const value = channel === 'red' ? img[i]
: channel === 'green' ? img[i + 1]
: channel === 'blue' ? img[i + 2]
// Rec. 709 luminance — common choice for composite histograms.
: 0.2126 * img[i] + 0.7152 * img[i + 1] + 0.0722 * img[i + 2];
const bucket = Math.max(0, Math.min(255, value | 0));
hist[bucket]++;
samples++;
if (bucket === 0) black++;
if (bucket === 255) white++;
}
let peak = 1;
for (let i = 0; i < 256; i++) if (hist[i] > peak) peak = hist[i];
@@ -64,4 +76,9 @@ export function drawHistogram(canvas, layer) {
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillRect((p.inWhite / 256) * w, 0, 1, h);
}
return {
black: samples ? black / samples * 100 : 0,
white: samples ? white / samples * 100 : 0,
samples,
};
}
+263 -29
View File
@@ -1,5 +1,5 @@
/**
* Apply a Brightness/Contrast, Hue/Saturation, Levels, or Color Balance
* Apply a Brightness/Contrast, Black & White, Hue/Saturation, Levels, Curves, or Color Balance
* adjustment to a source canvas and return a fresh canvas with the
* result. Pure pixel math no DOM, no module state.
*
@@ -11,8 +11,111 @@
* { type: 'brightness-contrast', params: { brightness, contrast } }
* { type: 'hue-saturation', params: { hue, saturation } }
* { type: 'levels', params: { inBlack, inWhite, gamma, outBlack, outWhite } }
* { type: 'curves', params: { points: {rgb, red, green, blue} } }
* { type: 'color-balance', params: { shadows, midtones, highlights } }
*/
function clampByte(value) {
return value < 0 ? 0 : value > 255 ? 255 : Math.round(value);
}
function finiteOr(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function levelLut(values = {}) {
const inLow = Math.max(0, Math.min(254, finiteOr(values.inBlack, 0)));
const inHigh = Math.max(inLow + 1, Math.min(255, finiteOr(values.inWhite, 255)));
const gamma = Math.max(0.1, finiteOr(values.gamma, 1));
const outLow = Math.max(0, Math.min(255, finiteOr(values.outBlack, 0)));
const outHigh = Math.max(outLow, Math.min(255, finiteOr(values.outWhite, 255)));
const lut = new Uint8ClampedArray(256);
for (let value = 0; value < 256; value += 1) {
const normalized = Math.max(0, Math.min(1, (value - inLow) / (inHigh - inLow)));
lut[value] = clampByte(Math.pow(normalized, 1 / gamma) * (outHigh - outLow) + outLow);
}
return lut;
}
function normalizedCurvePoints(points) {
const clean = (Array.isArray(points) ? points : [])
.filter(point => Array.isArray(point) && point.length >= 2)
.map(point => [clampByte(Number(point[0]) || 0), clampByte(Number(point[1]) || 0)])
.sort((a, b) => a[0] - b[0]);
if (!clean.length || clean[0][0] !== 0) clean.unshift([0, clean[0]?.[1] ?? 0]);
if (clean[clean.length - 1][0] !== 255) clean.push([255, clean[clean.length - 1]?.[1] ?? 255]);
const unique = [];
for (const point of clean) {
if (unique.length && unique[unique.length - 1][0] === point[0]) unique[unique.length - 1] = point;
else unique.push(point);
}
return unique;
}
export function curveLut(points) {
const clean = normalizedCurvePoints(points);
const lut = new Uint8ClampedArray(256);
let segment = 0;
for (let value = 0; value < 256; value += 1) {
while (segment < clean.length - 2 && value > clean[segment + 1][0]) segment += 1;
const left = clean[segment];
const right = clean[Math.min(segment + 1, clean.length - 1)];
const span = Math.max(1, right[0] - left[0]);
const amount = Math.max(0, Math.min(1, (value - left[0]) / span));
lut[value] = clampByte(left[1] + (right[1] - left[1]) * amount);
}
return lut;
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const lightness = (max + min) / 2;
if (max === min) return [0, 0, lightness];
const delta = max - min;
const saturation = lightness > .5 ? delta / (2 - max - min) : delta / (max + min);
let hue;
if (max === r) hue = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
else if (max === g) hue = ((b - r) / delta + 2) / 6;
else hue = ((r - g) / delta + 4) / 6;
return [hue, saturation, lightness];
}
function hueChannel(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function hslToRgb(h, s, l) {
if (!s) return [l * 255, l * 255, l * 255];
const q = l < .5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [hueChannel(p, q, h + 1 / 3) * 255, hueChannel(p, q, h) * 255, hueChannel(p, q, h - 1 / 3) * 255];
}
function parseHexColor(value, fallback) {
const match = /^#?([0-9a-f]{6})$/i.exec(String(value || ''));
const hex = match ? match[1] : fallback;
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
}
function hueDistance(a, b) {
const distance = Math.abs(a - b);
return Math.min(distance, 1 - distance);
}
function selectiveRangeWeight(name, hue, saturation, lightness) {
if (name === 'blacks') return Math.max(0, Math.min(1, (0.55 - lightness) / 0.45));
if (name === 'neutrals') return Math.max(0, Math.min(1, (1 - saturation) * (1 - Math.abs(lightness - .5) * 1.5)));
const centers = { reds: 0, yellows: 1 / 6, greens: 1 / 3, cyans: .5, blues: 2 / 3, magentas: 5 / 6 };
const distance = hueDistance(hue, centers[name] ?? 0);
return Math.max(0, 1 - distance * 6) * saturation;
}
export function applyAdjustment(srcCanvas, adj) {
const w = srcCanvas.width, h = srcCanvas.height;
const out = document.createElement('canvas');
@@ -27,37 +130,129 @@ export function applyAdjustment(srcCanvas, adj) {
octx.filter = 'none';
return out;
}
if (adj.type === 'hue-saturation') {
const p = adj.params;
octx.filter = `saturate(${p.saturation}) hue-rotate(${p.hue}deg)`;
octx.drawImage(srcCanvas, 0, 0);
octx.filter = 'none';
return out;
}
// Levels + Color Balance need per-pixel math.
// The remaining adjustments need deterministic per-pixel math so preview,
// flattening, and reopened documents produce the same result.
octx.drawImage(srcCanvas, 0, 0);
const img = octx.getImageData(0, 0, w, h);
const d = img.data;
if (adj.type === 'exposure') {
const p = adj.params || {};
const multiplier = Math.pow(2, finiteOr(p.exposure, 0));
const offset = finiteOr(p.offset, 0);
const gamma = Math.max(.1, finiteOr(p.gamma, 1));
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i] / 255 * multiplier + offset)), 1 / gamma) * 255);
d[i + 1] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 1] / 255 * multiplier + offset)), 1 / gamma) * 255);
d[i + 2] = clampByte(Math.pow(Math.max(0, Math.min(1, d[i + 2] / 255 * multiplier + offset)), 1 / gamma) * 255);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'white-balance') {
const p = adj.params || {};
const temperature = finiteOr(p.temperature, 0) / 100;
const tint = finiteOr(p.tint, 0) / 100;
for (let i = 0; i < d.length; i += 4) {
d[i] = clampByte(d[i] * (1 + temperature * .28 + tint * .09));
d[i + 1] = clampByte(d[i + 1] * (1 - tint * .18));
d[i + 2] = clampByte(d[i + 2] * (1 - temperature * .28 + tint * .09));
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'hue-saturation') {
const p = adj.params || {};
const hueShift = finiteOr(p.hue, 0) / 360;
const saturationScale = Math.max(0, finiteOr(p.saturation, 1));
const lightnessShift = finiteOr(p.lightness, 0) / 100;
for (let i = 0; i < d.length; i += 4) {
let [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
hue = (hue + hueShift + 1) % 1;
saturation = Math.max(0, Math.min(1, saturation * saturationScale));
lightness = Math.max(0, Math.min(1, lightness + lightnessShift));
const rgb = hslToRgb(hue, saturation, lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'vibrance') {
const amount = Math.max(-1, Math.min(1, finiteOr(adj.params?.vibrance, 0) / 100));
for (let i = 0; i < d.length; i += 4) {
const [hue, saturation, lightness] = rgbToHsl(d[i], d[i + 1], d[i + 2]);
const adjustedSaturation = amount >= 0
? saturation + (1 - saturation) * amount
: saturation * (1 + amount);
const rgb = hslToRgb(hue, Math.max(0, Math.min(1, adjustedSaturation)), lightness);
d[i] = clampByte(rgb[0]); d[i + 1] = clampByte(rgb[1]); d[i + 2] = clampByte(rgb[2]);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'black-white') {
const p = adj.params || {};
const red = finiteOr(p.red, 30) / 100;
const green = finiteOr(p.green, 59) / 100;
const blue = finiteOr(p.blue, 11) / 100;
const constant = finiteOr(p.constant, 0) * 2.55;
for (let i = 0; i < d.length; i += 4) {
const gray = d[i] * red + d[i + 1] * green + d[i + 2] * blue + constant;
d[i] = clampByte(gray);
d[i + 1] = clampByte(gray);
d[i + 2] = clampByte(gray);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'shadows-highlights') {
const shadows = Math.max(-1, Math.min(1, finiteOr(adj.params?.shadows, 0) / 100));
const highlights = Math.max(-1, Math.min(1, finiteOr(adj.params?.highlights, 0) / 100));
const toneAdjust = (value, amount, weight) => amount >= 0
? value + (255 - value) * amount * weight
: value + value * amount * weight;
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const shadowWeight = (1 - luminance) ** 2;
const highlightWeight = luminance ** 2;
d[i] = clampByte(toneAdjust(toneAdjust(d[i], shadows, shadowWeight), highlights, highlightWeight));
d[i + 1] = clampByte(toneAdjust(toneAdjust(d[i + 1], shadows, shadowWeight), highlights, highlightWeight));
d[i + 2] = clampByte(toneAdjust(toneAdjust(d[i + 2], shadows, shadowWeight), highlights, highlightWeight));
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'levels') {
const l = adj.params;
const inLow = Math.max(0, Math.min(254, l.inBlack));
const inHigh = Math.max(inLow + 1, Math.min(255, l.inWhite));
const gamma = Math.max(0.1, l.gamma || 1);
const outLow = Math.max(0, Math.min(255, l.outBlack));
const outHigh = Math.max(outLow, Math.min(255, l.outWhite));
const inv = 1.0 / gamma;
const span = (outHigh - outLow);
const lut = new Uint8ClampedArray(256);
for (let v = 0; v < 256; v++) {
let t = (v - inLow) / (inHigh - inLow);
if (t < 0) t = 0; else if (t > 1) t = 1;
t = Math.pow(t, inv);
lut[v] = Math.round(t * span + outLow);
}
const master = levelLut(l);
const red = levelLut(l.channels?.red);
const green = levelLut(l.channels?.green);
const blue = levelLut(l.channels?.blue);
for (let i = 0; i < d.length; i += 4) {
d[i] = lut[d[i]]; d[i+1] = lut[d[i+1]]; d[i+2] = lut[d[i+2]];
d[i] = red[master[d[i]]];
d[i + 1] = green[master[d[i + 1]]];
d[i + 2] = blue[master[d[i + 2]]];
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'curves') {
const points = adj.params?.points || {};
const master = curveLut(points.rgb);
const red = curveLut(points.red);
const green = curveLut(points.green);
const blue = curveLut(points.blue);
for (let i = 0; i < d.length; i += 4) {
d[i] = red[master[d[i]]];
d[i + 1] = green[master[d[i + 1]]];
d[i + 2] = blue[master[d[i + 2]]];
}
octx.putImageData(img, 0, 0);
return out;
@@ -95,6 +290,43 @@ export function applyAdjustment(srcCanvas, adj) {
return out;
}
if (adj.type === 'selective-color') {
const ranges = adj.params?.ranges || {};
for (let i = 0; i < d.length; i += 4) {
let r = d[i], g = d[i + 1], b = d[i + 2];
const [hue, saturation, lightness] = rgbToHsl(r, g, b);
for (const [name, values] of Object.entries(ranges)) {
const weight = selectiveRangeWeight(name, hue, saturation, lightness) * .65;
if (weight <= 0) continue;
const black = finiteOr(values.black, 0) / 100;
r += (-finiteOr(values.cyan, 0) / 100 * 255 - black * r) * weight;
g += (-finiteOr(values.magenta, 0) / 100 * 255 - black * g) * weight;
b += (-finiteOr(values.yellow, 0) / 100 * 255 - black * b) * weight;
}
d[i] = clampByte(r); d[i + 1] = clampByte(g); d[i + 2] = clampByte(b);
}
octx.putImageData(img, 0, 0);
return out;
}
if (adj.type === 'gradient-map') {
const p = adj.params || {};
let shadows = parseHexColor(p.shadows, '000000');
let highlights = parseHexColor(p.highlights, 'ffffff');
if (p.reverse) [shadows, highlights] = [highlights, shadows];
const midpoint = Math.max(.01, Math.min(.99, finiteOr(p.midpoint, 50) / 100));
const exponent = Math.log(.5) / Math.log(midpoint);
for (let i = 0; i < d.length; i += 4) {
const luminance = (0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]) / 255;
const amount = Math.pow(luminance, exponent);
d[i] = clampByte(shadows[0] + (highlights[0] - shadows[0]) * amount);
d[i + 1] = clampByte(shadows[1] + (highlights[1] - shadows[1]) * amount);
d[i + 2] = clampByte(shadows[2] + (highlights[2] - shadows[2]) * amount);
}
octx.putImageData(img, 0, 0);
return out;
}
return out;
}
@@ -215,14 +447,16 @@ export function renderLayerWithAdjLayers(layer) {
const editingId = layer._editingAdjId || null;
const stack = (layer.adjLayers || []).filter(a => a.visible && a.id !== editingId);
const staged = layer._stagedAdj;
if (stack.length === 0 && !staged) {
if (stack.length === 0 && (!staged || layer._adjCompare)) {
layer._adjFinalKey = '';
return layer.canvas;
}
const sig = stack.map(a => `${a.id}:${a.visible?1:0}:${a.opacity}:${a.type}:${JSON.stringify(a.params)}`).join('|') +
(staged ? `|S:${staged.type}:${JSON.stringify(staged.params)}` : '') +
(editingId ? `|E:${editingId}` : '');
if (layer._adjFinal && layer._adjFinalKey === sig) return layer._adjFinal;
const compare = !!layer._adjCompare;
const fullSig = `${sig}|C:${compare ? 1 : 0}`;
if (layer._adjFinal && layer._adjFinalKey === fullSig) return layer._adjFinal;
let cur = layer.canvas;
const w = layer.canvas.width, h = layer.canvas.height;
for (const adj of stack) {
@@ -240,10 +474,10 @@ export function renderLayerWithAdjLayers(layer) {
cur = blend;
}
}
if (staged) {
if (staged && !compare) {
cur = applyAdjustment(cur, staged);
}
layer._adjFinal = cur;
layer._adjFinalKey = sig;
layer._adjFinalKey = fullSig;
return cur;
}
+63
View File
@@ -0,0 +1,63 @@
/** Shared normalization for editable linear-gradient stops. */
export const MAX_GRADIENT_STOPS = 12;
function finite(value, fallback, min, max) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
}
function color(value, fallback) {
const text = String(value || '').trim();
return text || fallback;
}
export function legacyGradientStops({
start = '#ffffff',
mid = '#808080',
midEnabled = false,
midPosition = 50,
end = '#000000',
} = {}) {
const stops = [{ position: 0, color: color(start, '#ffffff') }];
if (midEnabled) {
stops.push({
position: finite(midPosition, 50, 1, 99),
color: color(mid, '#808080'),
});
}
stops.push({ position: 100, color: color(end, '#000000') });
return stops;
}
export function normalizeGradientStops(value, legacy = {}) {
const source = Array.isArray(value) && value.length ? value : legacyGradientStops(legacy);
const stops = source.map((stop, index) => ({
position: finite(stop?.position, index === 0 ? 0 : 100, 0, 100),
color: color(stop?.color, index === 0 ? '#ffffff' : '#000000'),
}));
stops.sort((a, b) => a.position - b.position);
const unique = [];
for (const stop of stops) {
const previous = unique.at(-1);
if (previous && Math.abs(previous.position - stop.position) < 0.0001) {
unique[unique.length - 1] = stop;
} else {
unique.push(stop);
}
}
if (!unique.length || unique[0].position > 0) {
unique.unshift({ position: 0, color: unique[0]?.color || '#ffffff' });
} else {
unique[0].position = 0;
}
if (unique.at(-1).position < 100) {
unique.push({ position: 100, color: unique.at(-1)?.color || '#000000' });
} else {
unique.at(-1).position = 100;
}
const first = unique[0];
const last = unique.at(-1);
return [first, ...unique.slice(1, -1).slice(0, MAX_GRADIENT_STOPS - 2), last];
}
+1
View File
@@ -32,6 +32,7 @@
* @returns {HTMLCanvasElement|null}
*/
export function layerUnionAlpha(w, h, layers) {
if (!Array.isArray(layers)) return null;
const visible = layers.filter(l => l.visible);
if (visible.length < 2) return null;
const bgId = visible[0].id;
+39
View File
@@ -0,0 +1,39 @@
/** Adaptive bounds for raw ImageData history snapshots. */
export const MAX_HISTORY_ENTRIES = 30;
export const MAX_HISTORY_BYTES = 192 * 1024 * 1024;
function imageDataBytes(imageData) {
return Number(imageData?.data?.byteLength || imageData?.data?.length || 0);
}
export function snapshotByteSize(snapshot) {
if (!snapshot) return 0;
if (Number.isFinite(snapshot._bytes)) return snapshot._bytes;
let bytes = imageDataBytes(snapshot.wand?.imageData);
bytes += imageDataBytes(snapshot.lastSelection?.imageData);
for (const selection of snapshot.savedSelections || []) bytes += imageDataBytes(selection.imageData);
for (const group of snapshot.layerGroups || []) {
for (const mask of group.masks || []) bytes += imageDataBytes(mask.imageData);
}
for (const layer of snapshot.layers || []) {
bytes += imageDataBytes(layer.imageData);
bytes += imageDataBytes(layer.placed?.sourceImageData);
for (const mask of layer.masks || []) bytes += imageDataBytes(mask.imageData);
}
return bytes;
}
export function trimHistoryStack(
stack,
maxEntries = MAX_HISTORY_ENTRIES,
maxBytes = MAX_HISTORY_BYTES,
) {
while (stack.length > maxEntries) stack.shift();
let bytes = stack.reduce((total, snapshot) => total + snapshotByteSize(snapshot), 0);
// Keep the newest state even when one snapshot alone exceeds the budget.
while (stack.length > 1 && bytes > maxBytes) {
bytes -= snapshotByteSize(stack.shift());
}
return bytes;
}
+157 -26
View File
@@ -14,11 +14,14 @@
* Ctrl+Alt+T start free transform
* Ctrl+Alt+I invert wand / lasso selection
* Ctrl+Alt+J new empty layer
* Ctrl/Cmd+J duplicate the active layer
* Ctrl+Alt+G create/release clipping mask
* 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
* Hold Space temporarily pan without changing the active tool
* [ / ] shrink / grow brush size proportionally
* D, C, M (when lasso has 3+ points) delete / copy / convert mask
* Delete / Backspace (wand or lasso) delete pixels
@@ -33,6 +36,7 @@
* toggleShortcuts: (show?: boolean) => void,
* confirmTransform: () => void,
* cancelTransform: () => void,
* nudgeTransform: (dx: number, dy: number) => boolean,
* startTransform: () => void,
* resizeCustomPrompt: () => void,
* addEmptyLayer: () => void,
@@ -46,26 +50,83 @@
* buildLassoMask: (w: number, h: number, offX: number, offY: number, feather: number, grow: number) => HTMLCanvasElement,
* drawLassoOverlay: () => void,
* activeLayer: () => object | null,
* deleteSelectedLayers: () => boolean | Promise<boolean>,
* duplicateActiveLayer: () => boolean,
* uiModule: object,
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
import { createMarqueeMask, selectionMaskForLayer } from './selection-mask.js';
export function wireKeyboardShortcuts(deps) {
const {
toolbar, toolKeyMap,
composite, saveState, undo, redo,
toggleShortcuts, confirmTransform, cancelTransform, startTransform,
toggleShortcuts, confirmTransform, cancelTransform, startTransform, nudgeTransform,
resizeCustomPrompt, addEmptyLayer, brushSizeSync,
invertSelection,
wandDeleteSelection, wandCopyToNewLayer,
lassoDeleteSelection, lassoCopyToLayer, lassoToMask,
buildLassoMask, drawLassoOverlay,
activeLayer, uiModule,
activeLayer, deleteSelectedLayers, duplicateActiveLayer, uiModule,
setTemporaryPan,
nudgeActiveLayer, endLayerNudge,
toggleQuickMask, nudgeSelection,
deselectSelection,
} = deps;
const isTypingTarget = (target) => target && (
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
);
const releaseTemporaryPan = () => setTemporaryPan?.(false);
document.addEventListener('keyup', (e) => {
if (e.code === 'Space') releaseTemporaryPan();
if (e.key.startsWith('Arrow')) endLayerNudge?.();
});
window.addEventListener('blur', releaseTemporaryPan);
document.addEventListener('keydown', (e) => {
if (!state.editorOpen) return;
if (e.code === 'Space' && !isTypingTarget(e.target)) {
e.preventDefault();
setTemporaryPan?.(true);
return;
}
if (!isTypingTarget(e.target) && state.tool === 'marquee' && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeSelection?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && state.transformActive && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeTransform?.(...delta)) {
e.preventDefault();
return;
}
}
if (!isTypingTarget(e.target) && ['move', 'transform'].includes(state.tool) && e.key.startsWith('Arrow')) {
const amount = e.shiftKey ? 10 : 1;
const delta = {
ArrowLeft: [-amount, 0], ArrowRight: [amount, 0],
ArrowUp: [0, -amount], ArrowDown: [0, amount],
}[e.key];
if (delta && nudgeActiveLayer?.(...delta)) {
e.preventDefault();
return;
}
}
// `?` toggles the cheatsheet. Don't fire while typing in a text
// field — the user might be typing a prompt with a `?`.
if (e.key === '?' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
@@ -79,24 +140,18 @@ export function wireKeyboardShortcuts(deps) {
return;
}
if (e.key === 'Escape') return;
if (e.ctrlKey || e.metaKey) {
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
// only the chord block is skipped, so the layout-character handlers below
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
// AZERTY / QWERTZ.
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
// Ctrl+Shift+D = Deselect: clears the wand selection (and
// lasso if active) without affecting layers.
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
if (state.wandMask || state.lassoPoints.length) {
e.preventDefault();
if (state.wandMask) {
saveState();
state.wandMask = null;
state.wandLayerId = null;
state.wandLastSeed = null;
}
if (state.lassoPoints.length) {
state.lassoPoints = [];
state.lassoActive = false;
}
composite();
deselectSelection?.();
}
}
// Save shortcuts — match the hints shown in the Save dropdown.
@@ -121,6 +176,25 @@ export function wireKeyboardShortcuts(deps) {
e.stopPropagation();
addEmptyLayer();
}
// Ctrl/Cmd+J duplicates the active layer through the layer panel's
// existing implementation, which preserves masks and effects.
if (!e.altKey && e.code === 'KeyJ') {
e.preventDefault();
e.stopPropagation();
duplicateActiveLayer?.();
return;
}
// Ctrl+Alt+G — Photoshop-compatible clipping mask shortcut.
if (e.altKey && e.code === 'KeyG') {
const row = [...document.querySelectorAll('.ge-layer-item[data-layer-id]')]
.find(item => item.dataset.layerId === state.activeLayerId);
const button = row?.querySelector('.ge-layer-clip-btn');
if (button && !button.disabled) {
e.preventDefault();
e.stopPropagation();
button.click();
}
}
// Wand selection: Delete = erase pixels. Ctrl+X = cut to
// clipboard + new layer + erase. Ctrl+C = copy.
// (Legacy `&& !_wandActive` clause referenced an undeclared
@@ -135,7 +209,7 @@ export function wireKeyboardShortcuts(deps) {
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);
const src = activeLayer();
if (!src) return;
// Clip source by wand mask into a temp canvas.
const w = src.canvas.width, h = src.canvas.height;
@@ -144,7 +218,14 @@ export function wireKeyboardShortcuts(deps) {
const tCtx = tmp.getContext('2d');
tCtx.drawImage(src.canvas, 0, 0);
tCtx.globalCompositeOperation = 'destination-in';
tCtx.drawImage(state.wandMask, 0, 0);
const off = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
tCtx.drawImage(selectionMaskForLayer(
state.wandMask,
state.wandMaskSpace || 'layer',
off,
w,
h,
), 0, 0);
state.internalClipboard = tmp;
tmp.toBlob(blob => {
if (blob && navigator.clipboard?.write) {
@@ -154,9 +235,17 @@ export function wireKeyboardShortcuts(deps) {
}
}, 'image/png');
if (isCut) {
// Cut also moves the selection to a new layer + erases source.
wandCopyToNewLayer();
wandDeleteSelection();
// Cut is one user action: make one history checkpoint, then move
// the selected pixels and erase the source without nested saves.
saveState('Cut selection');
const cutLayer = wandCopyToNewLayer({ saveHistory: false, activate: false, announce: false });
wandDeleteSelection({ saveHistory: false, message: 'Selection cut' });
if (cutLayer) {
state.activeLayerId = cutLayer.id;
document.querySelectorAll('.ge-layer-item[data-layer-id]').forEach(row => {
row.classList.toggle('active', row.dataset.layerId === cutLayer.id);
});
}
}
return;
}
@@ -227,13 +316,19 @@ export function wireKeyboardShortcuts(deps) {
// 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;
saveState('Select all');
state.wandMask = createMarqueeMask(
state.imgWidth,
state.imgHeight,
{ x: 0, y: 0, w: state.imgWidth, h: state.imgHeight },
'rectangle',
);
state.wandLayerId = state.activeLayerId;
state.wandMaskSpace = 'document';
state.selectionSource = 'marquee';
state.wandLastSeed = null;
state.lassoPoints = [];
composite();
drawLassoOverlay();
uiModule.showToast('All selected — Ctrl+C to copy, Del to delete');
}
// Ctrl+V handled by the paste event listener.
@@ -241,7 +336,43 @@ export function wireKeyboardShortcuts(deps) {
return;
}
// Tool shortcuts (only when not typing in an input).
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (isTypingTarget(e.target)) return;
// Delete pixels for a selection, otherwise delete the selected layer(s).
// Clipboard shortcuts above retain ownership of Ctrl/Cmd+X and C.
if (e.key === 'Delete' || e.key === 'Backspace') {
if (state.wandMask) {
e.preventDefault();
wandDeleteSelection();
return;
}
if (state.lassoPoints.length >= 3) {
e.preventDefault();
lassoDeleteSelection();
return;
}
const layer = activeLayer?.();
const activeMask = layer?.activeMaskId &&
layer.masks?.some(mask => mask.id === layer.activeMaskId);
const group = state.activeGroupId &&
state.layerGroups?.find(item => item.id === state.activeGroupId);
const activeGroupMask = group?.activeMaskId &&
group.masks?.some(mask => mask.id === group.activeMaskId);
if (state.transformActive || state.cropping || state.cropMoving ||
state.marqueeActive || state.selectionMoving || state.lassoActive ||
activeMask || activeGroupMask || state.maskInspectMode) return;
if (deleteSelectedLayers) {
e.preventDefault();
deleteSelectedLayers();
return;
}
}
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === 'q') {
e.preventDefault();
toggleQuickMask?.();
return;
}
const toolId = toolKeyMap[e.key.toLowerCase()];
if (toolId) {
const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`);
+144
View File
@@ -0,0 +1,144 @@
/** Alpha clipping for flat layer stacks and isolated groups. */
function scopeByLayer(editorState) {
const scopes = new Map();
for (const group of editorState.layerGroups || []) {
for (const id of group.layerIds || []) scopes.set(id, group.id);
}
return scopes;
}
export function clippingBaseForLayer(editorState, layerId) {
const layers = editorState.layers || [];
const index = layers.findIndex(layer => layer.id === layerId);
if (index < 0 || !layers[index]?.clipped || index === 0) return null;
const scopes = scopeByLayer(editorState);
const scope = scopes.get(layerId) || null;
if ((scopes.get(layers[index - 1].id) || null) !== scope) return null;
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
const candidate = layers[cursor];
if ((scopes.get(candidate.id) || null) !== scope) return null;
if (candidate.kind === 'adjustment') return null;
if (!candidate.clipped) return candidate;
}
return null;
}
export function canToggleLayerClipping(editorState, layerId) {
const layer = (editorState.layers || []).find(item => item.id === layerId);
if (!layer) return false;
if (layer.clipped) return true;
layer.clipped = true;
const canClip = !!clippingBaseForLayer(editorState, layerId);
layer.clipped = false;
return canClip;
}
/** Clear clipping flags made invalid by delete, grouping, or reorder. */
export function normalizeLayerClipping(editorState) {
const cleared = [];
for (const layer of editorState.layers || []) {
layer.clipped = !!layer.clipped;
if (layer.clipped && !clippingBaseForLayer(editorState, layer.id)) {
layer.clipped = false;
cleared.push(layer.id);
}
}
return cleared;
}
function ensureScratchCanvas(editorState) {
const canvas = editorState.clippingCompositeCanvas || document.createElement('canvas');
editorState.clippingCompositeCanvas = canvas;
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
return canvas;
}
/** Draw one contiguous stack scope, bottom to top. */
export function drawLayerStack(target, editorState, layers, renderLayer, renderAdjustment = null) {
let base = null;
const drawNormal = layer => {
if (!layer.visible) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(renderLayer(layer), offset.x, offset.y);
};
for (const layer of layers) {
if (layer.kind === 'adjustment' && renderAdjustment) {
renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(layer), layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderLayer(base), baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}
/** Async counterpart used by the worker-backed live effect compositor. */
export async function drawLayerStackAsync(target, editorState, layers, renderLayer, renderAdjustment = null, shouldContinue = () => true) {
let base = null;
const drawNormal = async layer => {
if (!layer.visible || !shouldContinue()) return;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
const rendered = await renderLayer(layer);
if (!shouldContinue() || !rendered) return;
target.drawImage(rendered, offset.x, offset.y);
};
for (const layer of layers) {
if (!shouldContinue()) return;
if (layer.kind === 'adjustment' && renderAdjustment) {
await renderAdjustment(target, layer, layer.clipped ? base : null);
if (!layer.clipped) base = null;
continue;
}
if (!layer.clipped || !base) {
base = layer;
await drawNormal(layer);
continue;
}
if (!layer.visible || !base.visible) continue;
const scratch = ensureScratchCanvas(editorState);
const scratchCtx = scratch.getContext('2d');
scratchCtx.globalAlpha = 1;
scratchCtx.globalCompositeOperation = 'source-over';
scratchCtx.clearRect(0, 0, scratch.width, scratch.height);
const layerOffset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const renderedLayer = await renderLayer(layer);
const renderedBase = await renderLayer(base);
if (!shouldContinue() || !renderedLayer || !renderedBase) return;
scratchCtx.drawImage(renderedLayer, layerOffset.x, layerOffset.y);
scratchCtx.globalCompositeOperation = 'destination-in';
const baseOffset = editorState.layerOffsets.get(base.id) || { x: 0, y: 0 };
scratchCtx.drawImage(renderedBase, baseOffset.x, baseOffset.y);
scratchCtx.globalCompositeOperation = 'source-over';
target.globalAlpha = layer.opacity;
target.globalCompositeOperation = layer.blendMode || 'source-over';
target.drawImage(scratch, 0, 0);
}
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
}
+249
View File
@@ -0,0 +1,249 @@
/** Exact active-layer geometry used by controls, dragging, and key nudging. */
import { state } from './state.js';
import { selectedLayers, selectionLabel } from './layer-selection.js';
import { isLayerPositionLocked } from './layer-groups.js';
import { translatePlacedData } from './placed-layer.js';
const MAX_COORDINATE = 1000000;
export function normalizeLayerCoordinate(value, fallback = 0) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return Math.round(Number(fallback) || 0);
return Math.max(-MAX_COORDINATE, Math.min(MAX_COORDINATE, Math.round(parsed)));
}
export function readLayerGeometry(editorState, layer) {
if (!layer?.canvas) return null;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return {
x: normalizeLayerCoordinate(offset.x),
y: normalizeLayerCoordinate(offset.y),
width: Math.max(1, Math.round(layer.canvas.width || 1)),
height: Math.max(1, Math.round(layer.canvas.height || 1)),
};
}
export function setLayerPosition(editorState, layer, x, y) {
if (!layer) return null;
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = {
x: normalizeLayerCoordinate(x, current.x),
y: normalizeLayerCoordinate(y, current.y),
};
editorState.layerOffsets.set(layer.id, next);
return next;
}
export function nudgeLayerPosition(editorState, layer, dx, dy) {
const current = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
return setLayerPosition(
editorState,
layer,
current.x + normalizeLayerCoordinate(dx),
current.y + normalizeLayerCoordinate(dy),
);
}
function moveLayerBy(editorState, layer, dx, dy) {
if (!layer || isLayerPositionLocked(editorState, layer)) return false;
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const next = setLayerPosition(editorState, layer, offset.x + dx, offset.y + dy);
if (layer.kind === 'placed' && layer.placed) {
layer.placed = translatePlacedData(layer.placed, next.x - offset.x, next.y - offset.y);
}
for (const mask of layer.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - (next.x - offset.x),
y: (Number(mask.offset?.y) || 0) - (next.y - offset.y),
};
}
return next.x !== offset.x || next.y !== offset.y;
}
/** Move selected layers to a document edge/center without rasterizing them. */
export function alignSelectedLayers(editorState, layers, alignment) {
const targets = (layers || []).filter(layer => readLayerGeometry(editorState, layer));
if (targets.length < 2) return false;
let changed = false;
for (const layer of targets) {
const geometry = readLayerGeometry(editorState, layer);
let x = geometry.x;
let y = geometry.y;
if (alignment === 'left') x = 0;
else if (alignment === 'center') x = (editorState.imgWidth - geometry.width) / 2;
else if (alignment === 'right') x = editorState.imgWidth - geometry.width;
else if (alignment === 'top') y = 0;
else if (alignment === 'middle') y = (editorState.imgHeight - geometry.height) / 2;
else if (alignment === 'bottom') y = editorState.imgHeight - geometry.height;
changed = moveLayerBy(editorState, layer, x - geometry.x, y - geometry.y) || changed;
}
return changed;
}
/** Evenly distribute selected layer centers along one document axis. */
export function distributeSelectedLayers(editorState, layers, axis) {
const entries = (layers || []).map(layer => ({ layer, geometry: readLayerGeometry(editorState, layer) }))
.filter(entry => entry.geometry)
.sort((a, b) => a.geometry[axis === 'vertical' ? 'y' : 'x'] - b.geometry[axis === 'vertical' ? 'y' : 'x']);
if (entries.length < 3) return false;
const coordinate = axis === 'vertical' ? 'y' : 'x';
const size = axis === 'vertical' ? 'height' : 'width';
const first = entries[0].geometry[coordinate] + entries[0].geometry[size] / 2;
const lastEntry = entries[entries.length - 1];
const last = lastEntry.geometry[coordinate] + lastEntry.geometry[size] / 2;
const step = (last - first) / (entries.length - 1);
let changed = false;
entries.forEach((entry, index) => {
const target = first + step * index - entry.geometry[size] / 2;
const delta = target - entry.geometry[coordinate];
changed = moveLayerBy(editorState, entry.layer, axis === 'vertical' ? 0 : delta, axis === 'vertical' ? delta : 0) || changed;
});
return changed;
}
export function createLayerGeometryController({ activeLayer, saveState, composite }) {
let root = null;
let fieldHistorySaved = false;
let nudgeHistorySaved = false;
function shiftTransformFrame(dx, dy) {
if (!state.transformActive) return;
if (state.transformCenter) {
state.transformCenter = { x: state.transformCenter.x + dx, y: state.transformCenter.y + dy };
}
if (state.transformBounds) {
state.transformBounds = { ...state.transformBounds, x: state.transformBounds.x + dx, y: state.transformBounds.y + dy };
}
if (state.transformOrigOffset) {
state.transformOrigOffset.x += dx;
state.transformOrigOffset.y += dy;
}
}
const inputs = () => ({
x: root?.querySelector('#ge-layer-x') || null,
y: root?.querySelector('#ge-layer-y') || null,
width: root?.querySelector('#ge-layer-width') || null,
height: root?.querySelector('#ge-layer-height') || null,
name: root?.querySelector('#ge-layer-geometry-name') || null,
});
function sync() {
const layer = activeLayer();
const fields = inputs();
const geometry = readLayerGeometry(state, layer);
if (fields.name) fields.name.textContent = layer?.name || 'No layer selected';
for (const field of [fields.x, fields.y]) field && (field.disabled = !layer || isLayerPositionLocked(state, layer));
for (const field of [fields.width, fields.height]) field && (field.disabled = !layer);
if (!geometry) {
for (const field of [fields.x, fields.y, fields.width, fields.height]) {
if (field) field.value = '';
}
return null;
}
if (fields.x && document.activeElement !== fields.x) fields.x.value = String(geometry.x);
if (fields.y && document.activeElement !== fields.y) fields.y.value = String(geometry.y);
if (fields.width) fields.width.value = String(geometry.width);
if (fields.height) fields.height.value = String(geometry.height);
return geometry;
}
function moveTo(x, y, label = null) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const before = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const nextX = normalizeLayerCoordinate(x, before.x);
const nextY = normalizeLayerCoordinate(y, before.y);
if (before.x === nextX && before.y === nextY) {
sync();
return false;
}
if (label) saveState(label);
const dx = nextX - before.x;
const dy = nextY - before.y;
for (const selected of selectedLayers(state).filter(item => !isLayerPositionLocked(state, item))) {
const selectedOffset = state.layerOffsets.get(selected.id) || { x: 0, y: 0 };
setLayerPosition(state, selected, selectedOffset.x + dx, selectedOffset.y + dy);
if (selected.kind === 'placed' && selected.placed) {
selected.placed = translatePlacedData(selected.placed, dx, dy);
}
for (const mask of selected.masks || []) {
if (mask.mode !== 'layer' || mask.linked !== false) continue;
mask.offset = {
x: (Number(mask.offset?.x) || 0) - dx,
y: (Number(mask.offset?.y) || 0) - dy,
};
}
}
if (state.transformActive && state.transformLayer?.id === layer.id) shiftTransformFrame(dx, dy);
composite();
sync();
return true;
}
function nudge(dx, dy) {
const layer = activeLayer();
if (!layer || isLayerPositionLocked(state, layer)) return false;
const activeMask = (layer.masks || []).find(mask =>
mask.id === layer.activeMaskId && mask.mode === 'layer' && mask.linked === false);
if (activeMask) {
if (!nudgeHistorySaved) saveState(`Nudge mask "${activeMask.name || 'Layer Mask'}"`);
activeMask.offset = {
x: (Number(activeMask.offset?.x) || 0) + dx,
y: (Number(activeMask.offset?.y) || 0) + dy,
};
nudgeHistorySaved = true;
composite();
return true;
}
const current = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
const label = !state.transformActive && !nudgeHistorySaved
? `Nudge ${selectionLabel(state, `"${layer.name || 'layer'}"`)}`
: null;
const moved = moveTo(current.x + dx, current.y + dy, label);
if (moved) nudgeHistorySaved = true;
return moved;
}
function endNudge() {
nudgeHistorySaved = false;
}
function trackExternalMove(layer, before, next) {
if (state.transformActive && state.transformLayer?.id === layer?.id) {
shiftTransformFrame(next.x - before.x, next.y - before.y);
}
sync();
}
function wire(nextRoot) {
root = nextRoot;
const fields = inputs();
const applyFields = () => {
const layer = activeLayer();
const geometry = readLayerGeometry(state, layer);
if (!geometry || isLayerPositionLocked(state, layer)) return sync();
const nextX = normalizeLayerCoordinate(fields.x?.value, geometry.x);
const nextY = normalizeLayerCoordinate(fields.y?.value, geometry.y);
if (nextX === geometry.x && nextY === geometry.y) return;
const label = !state.transformActive && !fieldHistorySaved
? `Position "${layer.name || 'layer'}"`
: null;
if (moveTo(nextX, nextY, label)) fieldHistorySaved = true;
};
for (const field of [fields.x, fields.y]) {
if (!field) continue;
field.addEventListener('input', applyFields);
field.addEventListener('change', () => { applyFields(); fieldHistorySaved = false; });
field.addEventListener('blur', () => { fieldHistorySaved = false; sync(); });
field.addEventListener('keydown', (event) => {
if (event.key === 'Enter') field.blur();
if (event.key === 'Escape') { event.preventDefault(); field.blur(); sync(); }
});
}
sync();
}
return { wire, sync, moveTo, nudge, endNudge, trackExternalMove };
}
+510
View File
@@ -0,0 +1,510 @@
/** Parent-linked layer groups with recursively isolated compositing. */
import { drawLayerStack, drawLayerStackAsync } from './layer-clipping.js';
import { renderEffects, renderEffectsAsync } from './effects.js';
function groupMap(editorState) {
return new Map((editorState.layerGroups || []).map(group => [group.id, group]));
}
function breakParentCycles(groups) {
const byId = new Map(groups.map(group => [group.id, group]));
for (const group of groups) {
const visited = new Set([group.id]);
let cursor = group;
while (cursor.parentId) {
if (visited.has(cursor.parentId)) {
group.parentId = null;
break;
}
visited.add(cursor.parentId);
cursor = byId.get(cursor.parentId);
if (!cursor) break;
}
}
}
export function normalizeLayerGroups(editorState) {
const validLayerIds = new Set((editorState.layers || []).map(layer => layer.id));
const claimedLayers = new Set();
const usedGroupIds = new Set();
const normalized = [];
for (const source of editorState.layerGroups || []) {
if (!source || typeof source !== 'object') continue;
let id = String(source.id || `group-${normalized.length + 1}`);
while (usedGroupIds.has(id)) id = `${id}-${normalized.length + 1}`;
usedGroupIds.add(id);
const layerIds = [];
for (const layerId of source.layerIds || []) {
if (validLayerIds.has(layerId) && !claimedLayers.has(layerId)) {
claimedLayers.add(layerId);
layerIds.push(layerId);
}
}
source.id = id;
source.name = String(source.name || `Group ${normalized.length + 1}`);
source.layerIds = layerIds;
source.parentId = source.parentId == null ? null : String(source.parentId);
source.visible = source.visible !== false;
source.opacity = Number.isFinite(Number(source.opacity))
? Math.max(0, Math.min(1, Number(source.opacity)))
: 1;
source.blendMode = source.blendMode || 'source-over';
source.locked = !!source.locked;
source.collapsed = !!source.collapsed;
source.masks = Array.isArray(source.masks) ? source.masks.filter(mask => mask && typeof mask === 'object') : [];
source.effects = Array.isArray(source.effects) ? source.effects : [];
source.activeMaskId = source.masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null;
normalized.push(source);
}
const validGroupIds = new Set(normalized.map(group => group.id));
for (const group of normalized) {
if (group.parentId === group.id || !validGroupIds.has(group.parentId)) group.parentId = null;
}
breakParentCycles(normalized);
// Parent-only groups are valid. Remove only empty leaves, then repeat because
// their removal can make an ancestor empty as well.
let retained = normalized;
while (true) {
const parentIds = new Set(retained.map(group => group.parentId).filter(Boolean));
const next = retained.filter(group => group.layerIds.length || parentIds.has(group.id));
if (next.length === retained.length) break;
const nextIds = new Set(next.map(group => group.id));
for (const group of next) {
if (group.parentId && !nextIds.has(group.parentId)) group.parentId = null;
}
retained = next;
}
editorState.layerGroups = retained;
if (!retained.some(group => group.id === editorState.activeGroupId)) editorState.activeGroupId = null;
return retained;
}
export function groupForLayer(editorState, layerId) {
return normalizeLayerGroups(editorState).find(group => group.layerIds.includes(layerId)) || null;
}
export function groupAncestors(editorState, groupOrId, { includeSelf = false } = {}) {
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const group = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
const result = [];
let cursor = includeSelf ? group : byId.get(group?.parentId);
while (cursor && !result.some(item => item.id === cursor.id)) {
result.push(cursor);
cursor = byId.get(cursor.parentId);
}
return result;
}
export function groupDepth(editorState, groupOrId) {
return groupAncestors(editorState, groupOrId).length;
}
export function groupsForLayer(editorState, layerId) {
const direct = groupForLayer(editorState, layerId);
return direct ? [direct, ...groupAncestors(editorState, direct)] : [];
}
export function allLayerIdsInGroup(editorState, groupOrId) {
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const root = typeof groupOrId === 'string' ? byId.get(groupOrId) : groupOrId;
if (!root) return [];
const children = new Map();
for (const group of groups) {
if (!group.parentId) continue;
if (!children.has(group.parentId)) children.set(group.parentId, []);
children.get(group.parentId).push(group);
}
const ids = new Set();
const visit = group => {
for (const id of group.layerIds) ids.add(id);
for (const child of children.get(group.id) || []) visit(child);
};
visit(root);
return (editorState.layers || []).filter(layer => ids.has(layer.id)).map(layer => layer.id);
}
export function isLayerEffectivelyLocked(editorState, layer) {
if (!layer) return true;
return !!layer.locked || groupsForLayer(editorState, layer.id).some(group => group.locked);
}
export function normalizeLayerLocks(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return {
pixels: !!source.pixels,
transparency: !!source.transparency,
position: !!source.position,
};
}
export function layerHasAnyLock(layer) {
const locks = normalizeLayerLocks(layer?.locks);
return !!layer?.locked || locks.pixels || locks.transparency || locks.position;
}
export function isLayerPixelLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).pixels;
}
export function isLayerTransparencyLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).transparency;
}
export function isLayerPositionLocked(editorState, layer) {
return isLayerEffectivelyLocked(editorState, layer) || normalizeLayerLocks(layer?.locks).position;
}
export function createGroupFromSelection(editorState, name = null) {
const selected = new Set(editorState.selectedLayerIds || []);
const indexed = (editorState.layers || [])
.map((layer, index) => ({ layer, index }))
.filter(item => selected.has(item.layer.id));
if (indexed.length < 2) return null;
const groups = normalizeLayerGroups(editorState);
const descendantIds = new Map(groups.map(group => [group.id, allLayerIdsInGroup(editorState, group)]));
const fullySelected = new Set(groups
.filter(group => descendantIds.get(group.id).length && descendantIds.get(group.id).every(id => selected.has(id)))
.map(group => group.id));
const selectedGroups = groups.filter(group =>
fullySelected.has(group.id) && !groupAncestors(editorState, group).some(parent => fullySelected.has(parent.id))
);
const covered = new Set(selectedGroups.flatMap(group => descendantIds.get(group.id)));
const directLayerIds = indexed.map(item => item.layer.id).filter(id => !covered.has(id));
const componentParents = [
...selectedGroups.map(group => group.parentId || null),
...directLayerIds.map(id => groupForLayer(editorState, id)?.id || null),
];
const commonParentId = componentParents.length && componentParents.every(id => id === componentParents[0])
? componentParents[0]
: null;
const directSet = new Set(directLayerIds);
for (const group of groups) group.layerIds = group.layerIds.filter(id => !directSet.has(id));
const selectedIds = new Set(indexed.map(item => item.layer.id));
const topIndex = indexed[indexed.length - 1].index;
const remaining = editorState.layers.filter(layer => !selectedIds.has(layer.id));
const insertion = editorState.layers
.slice(0, topIndex + 1)
.filter(layer => !selectedIds.has(layer.id)).length;
const members = indexed.map(item => item.layer);
remaining.splice(insertion, 0, ...members);
editorState.layers = remaining;
const id = `group-${editorState.nextLayerId++}`;
const group = {
id,
name: name || `Group ${groups.length + 1}`,
layerIds: directLayerIds,
parentId: commonParentId,
visible: true,
opacity: 1,
blendMode: 'source-over',
locked: false,
collapsed: false,
masks: [],
activeMaskId: null,
effects: [],
};
for (const child of selectedGroups) child.parentId = id;
editorState.layerGroups.push(group);
normalizeLayerGroups(editorState);
editorState.activeGroupId = id;
editorState.selectedLayerIds = allLayerIdsInGroup(editorState, id);
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
editorState.selectionAnchorId = editorState.activeLayerId;
return groupMap(editorState).get(id) || group;
}
export function ungroupLayers(editorState, groupId) {
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
if (!group || group.masks?.length) return null;
const selectedIds = allLayerIdsInGroup(editorState, group);
const parent = group.parentId ? groupMap(editorState).get(group.parentId) : null;
if (parent) {
const direct = new Set([...parent.layerIds, ...group.layerIds]);
parent.layerIds = (editorState.layers || []).filter(layer => direct.has(layer.id)).map(layer => layer.id);
}
for (const child of editorState.layerGroups) {
if (child.parentId === group.id) child.parentId = group.parentId || null;
}
editorState.layerGroups = editorState.layerGroups.filter(item => item.id !== groupId);
if (editorState.activeGroupId === groupId) editorState.activeGroupId = group.parentId || null;
editorState.selectedLayerIds = selectedIds;
normalizeLayerGroups(editorState);
return group;
}
export function groupSiblingUnits(editorState, parentId = null) {
const groups = normalizeLayerGroups(editorState);
const parent = parentId ? groups.find(group => group.id === parentId) : null;
if (parentId && !parent) return [];
const layerOrder = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const units = [];
const directIds = parent
? parent.layerIds
: (editorState.layers || [])
.filter(layer => !groups.some(group => group.layerIds.includes(layer.id)))
.map(layer => layer.id);
for (const layerId of directIds) {
if (layerOrder.has(layerId)) units.push({ type: 'layer', id: layerId, layerIds: [layerId] });
}
for (const group of groups.filter(item => (item.parentId || null) === (parentId || null))) {
const layerIds = allLayerIdsInGroup(editorState, group);
if (layerIds.length) units.push({ type: 'group', id: group.id, layerIds });
}
return units.sort((a, b) => {
const aIndex = Math.min(...a.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
const bIndex = Math.min(...b.layerIds.map(id => layerOrder.get(id)).filter(Number.isInteger));
return aIndex - bIndex;
});
}
/** Move a complete group subtree among siblings without changing its parent. */
export function reorderGroupAmongSiblings(editorState, groupId, targetIndex) {
const group = normalizeLayerGroups(editorState).find(item => item.id === groupId);
if (!group) return null;
const parentId = group.parentId || null;
const units = groupSiblingUnits(editorState, parentId);
const currentIndex = units.findIndex(unit => unit.type === 'group' && unit.id === groupId);
if (currentIndex < 0) return null;
const nextIndex = Math.max(0, Math.min(units.length - 1, Math.round(Number(targetIndex))));
if (nextIndex === currentIndex) return { currentIndex, targetIndex: nextIndex, changed: false };
const [moving] = units.splice(currentIndex, 1);
units.splice(nextIndex, 0, moving);
const scopeIds = new Set(units.flatMap(unit => unit.layerIds));
const byId = new Map((editorState.layers || []).map(layer => [layer.id, layer]));
const reordered = units.flatMap(unit => unit.layerIds).map(id => byId.get(id)).filter(Boolean);
let cursor = 0;
editorState.layers = (editorState.layers || []).map(layer =>
scopeIds.has(layer.id) ? reordered[cursor++] : layer
);
return { currentIndex, targetIndex: nextIndex, changed: true, parentId };
}
function groupCanvas(editorState, groupId) {
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
let canvas = editorState.groupCompositeCanvases.get(groupId);
if (!canvas) {
canvas = document.createElement('canvas');
editorState.groupCompositeCanvases.set(groupId, canvas);
}
if (canvas.width !== editorState.imgWidth) canvas.width = editorState.imgWidth;
if (canvas.height !== editorState.imgHeight) canvas.height = editorState.imgHeight;
return canvas;
}
export function drawGroupedLayers(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
// A hidden or removed group must not keep advertising its previous pixels to
// the layer panel while the next document composite is being built.
editorState.groupCompositeCanvases.clear();
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const directGroupByLayer = new Map();
const children = new Map();
for (const group of groups) {
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
const parentId = group.parentId || null;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(group);
}
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const boundsMemo = new Map();
const groupBounds = group => {
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
const values = [
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
];
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
boundsMemo.set(group.id, result);
return result;
};
const renderScope = (target, parentId = null) => {
const nodes = [];
for (const layer of editorState.layers || []) {
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
}
}
for (const group of children.get(parentId) || []) {
const bounds = groupBounds(group);
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
}
nodes.sort((a, b) => a.index - b.index);
let layerRun = [];
const flushLayers = () => {
if (!layerRun.length) return;
drawLayerStack(target, editorState, layerRun, renderLayer, renderAdjustment);
layerRun = [];
};
for (const node of nodes) {
if (!shouldContinue()) return;
if (node.type === 'layer') {
layerRun.push(node.layer);
continue;
}
flushLayers();
const group = byId.get(node.group.id);
if (!group?.visible) continue;
const canvas = groupCanvas(editorState, group.id);
const groupCtx = canvas.getContext('2d');
groupCtx.clearRect(0, 0, canvas.width, canvas.height);
renderScope(groupCtx, group.id);
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
if (masks.length) {
groupCtx.globalAlpha = 1;
groupCtx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
}
}
groupCtx.globalAlpha = 1;
groupCtx.filter = 'none';
groupCtx.globalCompositeOperation = 'source-over';
const groupOutput = renderGroup
? renderGroup(canvas, group, shouldContinue)
: (group.effects?.length ? renderEffects(canvas, group.effects, shouldContinue) : canvas);
if (!shouldContinue()) return;
if (groupOutput?.width && groupOutput?.height) {
// Keep the exact final group output for the layer-panel thumbnail. A
// raw member fallback loses masks, blending, and group effects.
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
}
flushLayers();
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
};
renderScope(ctx);
const validIds = new Set(groups.map(group => group.id));
for (const id of editorState.groupCompositeCanvases?.keys?.() || []) {
if (!validIds.has(id)) editorState.groupCompositeCanvases.delete(id);
}
}
/**
* Async group compositor. It deliberately uses fresh group surfaces so an
* older generation cannot overwrite the cached surface used by a newer one.
*/
export async function drawGroupedLayersAsync(ctx, editorState, renderLayer, renderAdjustment = null, renderGroup = null, shouldContinue = () => true) {
ctx.clearRect(0, 0, editorState.imgWidth, editorState.imgHeight);
if (!(editorState.groupCompositeCanvases instanceof Map)) editorState.groupCompositeCanvases = new Map();
editorState.groupCompositeCanvases.clear();
const groups = normalizeLayerGroups(editorState);
const byId = new Map(groups.map(group => [group.id, group]));
const directGroupByLayer = new Map();
const children = new Map();
for (const group of groups) {
for (const id of group.layerIds) directGroupByLayer.set(id, group.id);
const parentId = group.parentId || null;
if (!children.has(parentId)) children.set(parentId, []);
children.get(parentId).push(group);
}
const indexes = new Map((editorState.layers || []).map((layer, index) => [layer.id, index]));
const boundsMemo = new Map();
const groupBounds = group => {
if (boundsMemo.has(group.id)) return boundsMemo.get(group.id);
const values = [
...group.layerIds.map(id => indexes.get(id)).filter(Number.isInteger),
...(children.get(group.id) || []).flatMap(child => groupBounds(child)),
];
const result = values.length ? [Math.min(...values), Math.max(...values)] : [];
boundsMemo.set(group.id, result);
return result;
};
const renderScope = async (target, parentId = null) => {
const nodes = [];
for (const layer of editorState.layers || []) {
if ((directGroupByLayer.get(layer.id) || null) === parentId) {
nodes.push({ type: 'layer', index: indexes.get(layer.id), layer });
}
}
for (const group of children.get(parentId) || []) {
const bounds = groupBounds(group);
if (bounds.length) nodes.push({ type: 'group', index: bounds[0], group });
}
nodes.sort((a, b) => a.index - b.index);
let layerRun = [];
const flushLayers = async () => {
if (!layerRun.length || !shouldContinue()) return;
const run = layerRun;
layerRun = [];
await drawLayerStackAsync(target, editorState, run, renderLayer, renderAdjustment, shouldContinue);
};
for (const node of nodes) {
if (!shouldContinue()) return;
if (node.type === 'layer') {
layerRun.push(node.layer);
continue;
}
await flushLayers();
if (!shouldContinue()) return;
const group = byId.get(node.group.id);
if (!group?.visible) continue;
const canvas = document.createElement('canvas');
canvas.width = editorState.imgWidth;
canvas.height = editorState.imgHeight;
const groupCtx = canvas.getContext('2d');
await renderScope(groupCtx, group.id);
if (!shouldContinue()) return;
const masks = (group.masks || []).filter(mask => mask.visible !== false && mask.canvas);
if (masks.length) {
groupCtx.globalAlpha = 1;
groupCtx.globalCompositeOperation = 'destination-in';
for (const mask of masks) {
groupCtx.globalAlpha = Number.isFinite(Number(mask.density))
? Math.max(0, Math.min(1, Number(mask.density)))
: 1;
const feather = Number.isFinite(Number(mask.feather))
? Math.max(0, Math.min(200, Number(mask.feather)))
: 0;
groupCtx.filter = feather > 0 ? `blur(${feather}px)` : 'none';
groupCtx.drawImage(mask.canvas, 0, 0, canvas.width, canvas.height);
}
}
groupCtx.globalAlpha = 1;
groupCtx.filter = 'none';
groupCtx.globalCompositeOperation = 'source-over';
const groupOutput = renderGroup
? await renderGroup(canvas, group, shouldContinue)
: (group.effects?.length ? await renderEffectsAsync(canvas, group.effects, shouldContinue) : canvas);
if (!shouldContinue() || !groupOutput) return;
if (groupOutput.width && groupOutput.height) {
editorState.groupCompositeCanvases.set(group.id, groupOutput);
}
target.globalAlpha = group.opacity;
target.globalCompositeOperation = group.blendMode || 'source-over';
target.drawImage(groupOutput, 0, 0);
}
await flushLayers();
target.globalAlpha = 1;
target.globalCompositeOperation = 'source-over';
};
await renderScope(ctx);
}
+119 -2
View File
@@ -53,25 +53,134 @@ export function adjustmentsKey(adj) {
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 'exposure': return { exposure: 0, offset: 0, gamma: 1 };
case 'white-balance': return { temperature: 0, tint: 0 };
case 'hue-saturation': return { hue: 0, saturation: 1, lightness: 0 };
case 'vibrance': return { vibrance: 0 };
case 'black-white': return { red: 30, green: 59, blue: 11, constant: 0 };
case 'shadows-highlights': return { shadows: 0, highlights: 0 };
case 'levels': return {
channel: 'rgb',
inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255,
channels: {
red: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
green: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
blue: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
},
};
case 'curves': return {
channel: 'rgb',
points: {
rgb: [[0, 0], [255, 255]],
red: [[0, 0], [255, 255]],
green: [[0, 0], [255, 255]],
blue: [[0, 0], [255, 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 },
};
case 'selective-color': return {
range: 'reds',
ranges: Object.fromEntries(
['reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'neutrals', 'blacks']
.map(name => [name, { cyan: 0, magenta: 0, yellow: 0, black: 0 }]),
),
};
case 'gradient-map': return {
shadows: '#000000',
highlights: '#ffffff',
midpoint: 50,
reverse: false,
};
}
return {};
}
/** Preset names for the retained adjustment popup. */
export function adjustmentPresetOptions(type) {
const options = {
'brightness-contrast': ['Default', 'High Contrast'],
'exposure': ['Default', 'Lift Exposure', 'Lower Exposure'],
'white-balance': ['Default', 'Warm', 'Cool'],
'hue-saturation': ['Default', 'Desaturate', 'Boost Color'],
'vibrance': ['Default', 'Muted', 'Color Pop'],
'black-white': ['Default', 'High Contrast'],
'shadows-highlights': ['Default', 'Lift Shadows', 'Recover Highlights'],
'levels': ['Default', 'Auto Contrast'],
'curves': ['Default', 'S-Curve'],
'color-balance': ['Default', 'Warm Highlights', 'Cool Shadows'],
'selective-color': ['Default', 'Deep Reds'],
'gradient-map': ['Default', 'Warm Tone', 'Cool Tone'],
};
return options[type] || ['Default'];
}
/** Return a fresh parameter object for a named adjustment preset. */
export function adjustmentPresetParams(type, preset) {
const params = defaultAdjParams(type);
switch (`${type}:${preset}`) {
case 'brightness-contrast:High Contrast':
return { ...params, contrast: 1.28 };
case 'exposure:Lift Exposure':
return { ...params, exposure: 0.45 };
case 'exposure:Lower Exposure':
return { ...params, exposure: -0.45 };
case 'white-balance:Warm':
return { ...params, temperature: 35 };
case 'white-balance:Cool':
return { ...params, temperature: -35 };
case 'hue-saturation:Desaturate':
return { ...params, saturation: 0.35 };
case 'hue-saturation:Boost Color':
return { ...params, saturation: 1.35 };
case 'vibrance:Muted':
return { ...params, vibrance: -30 };
case 'vibrance:Color Pop':
return { ...params, vibrance: 45 };
case 'black-white:High Contrast':
return { ...params, red: 38, green: 54, blue: 8, constant: 0 };
case 'shadows-highlights:Lift Shadows':
return { ...params, shadows: 38 };
case 'shadows-highlights:Recover Highlights':
return { ...params, highlights: -38 };
case 'levels:Auto Contrast':
return { ...params, inBlack: 12, inWhite: 243 };
case 'curves:S-Curve':
return { ...params, points: { ...params.points, rgb: [[0, 12], [76, 64], [178, 194], [255, 243]] } };
case 'color-balance:Warm Highlights':
return { ...params, highlights: { r: 16, g: 4, b: -14 } };
case 'color-balance:Cool Shadows':
return { ...params, shadows: { r: -12, g: 3, b: 16 } };
case 'selective-color:Deep Reds':
return { ...params, ranges: { ...params.ranges, reds: { ...params.ranges.reds, black: -18 } } };
case 'gradient-map:Warm Tone':
return { ...params, shadows: '#21151a', highlights: '#ffd59a' };
case 'gradient-map:Cool Tone':
return { ...params, shadows: '#101c36', highlights: '#d8f2ff' };
default:
return params;
}
}
/** Human-readable name for an adjustment type. */
export function adjLayerLabel(type) {
return {
'brightness-contrast': 'Brightness/Contrast',
'exposure': 'Exposure',
'white-balance': 'White Balance',
'hue-saturation': 'Hue/Saturation',
'vibrance': 'Vibrance',
'black-white': 'Black & White',
'shadows-highlights': 'Shadows/Highlights',
'levels': 'Levels',
'curves': 'Curves',
'color-balance': 'Color Balance',
'selective-color': 'Selective Color',
'gradient-map': 'Gradient Map',
}[type] || type;
}
@@ -83,9 +192,17 @@ export function adjLayerLabel(type) {
*/
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>',
'exposure': '<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="M13 2 4.5 13H11l-1 9 8.5-11H12z"/></svg>',
'white-balance': '<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 3v10"/><circle cx="12" cy="17" r="4"/><path d="M9 6h3"/></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>',
'vibrance': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 18 10 6l4 12 6-12"/><path d="M7 14h10"/></svg>',
'black-white': '<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>',
'shadows-highlights': '<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="8" cy="8" r="4"/><path d="M14 14h7M17.5 10.5v7"/><path d="M5 17h6"/></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>',
'curves': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19C7 19 8 5 13 5s4 8 7 8"/><path d="M4 4v16h16"/></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>',
'selective-color': '<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"/><circle cx="12" cy="12" r="3"/><path d="M12 3v6M21 12h-6M12 21v-6M3 12h6"/></svg>',
'gradient-map': '<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="6" width="18" height="12" rx="2"/><path d="M12 6v12"/></svg>',
};
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
/** Shared multi-layer selection semantics for panel and canvas tools. */
import { isLayerEffectivelyLocked } from './layer-groups.js';
function existingIds(editorState) {
return new Set((editorState.layers || []).map(layer => layer.id));
}
export function normalizeLayerSelection(editorState) {
const valid = existingIds(editorState);
const selected = [];
for (const id of editorState.selectedLayerIds || []) {
if (valid.has(id) && !selected.includes(id)) selected.push(id);
}
if (valid.has(editorState.activeLayerId) && !selected.includes(editorState.activeLayerId)) {
selected.length = 0;
selected.push(editorState.activeLayerId);
editorState.activeGroupId = null;
}
if (!selected.length && editorState.layers?.length) {
const fallback = editorState.layers[editorState.layers.length - 1];
editorState.activeLayerId = fallback.id;
selected.push(fallback.id);
editorState.activeGroupId = null;
}
editorState.selectedLayerIds = selected;
if (!valid.has(editorState.selectionAnchorId)) {
editorState.selectionAnchorId = editorState.activeLayerId || null;
}
return selected;
}
export function selectedLayers(editorState, { unlockedOnly = false } = {}) {
const ids = new Set(normalizeLayerSelection(editorState));
return (editorState.layers || []).filter(layer =>
ids.has(layer.id) && (!unlockedOnly || !isLayerEffectivelyLocked(editorState, layer))
);
}
export function selectOnlyLayer(editorState, layerId) {
if (!(editorState.layers || []).some(layer => layer.id === layerId)) return [];
editorState.activeLayerId = layerId;
editorState.selectionAnchorId = layerId;
editorState.selectedLayerIds = [layerId];
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function toggleLayerSelection(editorState, layerId) {
const valid = (editorState.layers || []).some(layer => layer.id === layerId);
if (!valid) return normalizeLayerSelection(editorState);
const selected = normalizeLayerSelection(editorState);
const index = selected.indexOf(layerId);
if (index >= 0 && selected.length > 1) {
selected.splice(index, 1);
if (editorState.activeLayerId === layerId) {
editorState.activeLayerId = selected[selected.length - 1];
}
} else if (index < 0) {
selected.push(layerId);
editorState.activeLayerId = layerId;
}
editorState.selectionAnchorId = layerId;
editorState.selectedLayerIds = selected;
editorState.activeGroupId = null;
return selected;
}
export function selectLayerRange(editorState, layerId) {
const layers = editorState.layers || [];
const target = layers.findIndex(layer => layer.id === layerId);
if (target < 0) return normalizeLayerSelection(editorState);
const anchorId = editorState.selectionAnchorId || editorState.activeLayerId || layerId;
const anchor = layers.findIndex(layer => layer.id === anchorId);
if (anchor < 0) return selectOnlyLayer(editorState, layerId);
const start = Math.min(anchor, target);
const end = Math.max(anchor, target);
editorState.selectedLayerIds = layers.slice(start, end + 1).map(layer => layer.id);
editorState.activeLayerId = layerId;
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function selectAllLayers(editorState) {
editorState.selectedLayerIds = (editorState.layers || []).map(layer => layer.id);
if (editorState.selectedLayerIds.length && !editorState.selectedLayerIds.includes(editorState.activeLayerId)) {
editorState.activeLayerId = editorState.selectedLayerIds[editorState.selectedLayerIds.length - 1];
}
editorState.selectionAnchorId = editorState.activeLayerId || null;
editorState.activeGroupId = null;
return editorState.selectedLayerIds;
}
export function selectionLabel(editorState, singular = 'layer') {
const count = normalizeLayerSelection(editorState).length;
return count === 1 ? singular : `${count} layers`;
}
+27
View File
@@ -46,6 +46,33 @@ export function dilateMask(src, px) {
return tmp;
}
/** Invert a mask's reveal amount while preserving partial opacity. */
export function invertMaskInPlace(canvas) {
if (!canvas?.width || !canvas?.height) return false;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let index = 0; index < image.data.length; index += 4) {
const alpha = 255 - image.data[index + 3];
image.data[index] = 255;
image.data[index + 1] = 255;
image.data[index + 2] = 255;
image.data[index + 3] = alpha;
}
ctx.putImageData(image, 0, 0);
return true;
}
export function normalizeMaskDensity(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : 1;
}
export function normalizeMaskFeather(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.max(0, Math.min(200, parsed)) : 0;
}
/**
* Re-derive an inpaint-result layer's alpha from its cached AI image +
+159
View File
@@ -0,0 +1,159 @@
/** Shared-bounds geometry for transforming one or many layers. */
export function selectionBounds(editorState, layers) {
if (!layers?.length) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const layer of layers) {
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
left = Math.min(left, offset.x);
top = Math.min(top, offset.y);
right = Math.max(right, offset.x + Math.max(1, layer.canvas?.width || 1));
bottom = Math.max(bottom, offset.y + Math.max(1, layer.canvas?.height || 1));
}
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
centerX: (left + right) / 2,
centerY: (top + bottom) / 2,
};
}
export function transformedLayerGeometry(snapshot, sourceBounds, target) {
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
const signedScaleX = target.flipH ? -scaleX : scaleX;
const signedScaleY = target.flipV ? -scaleY : scaleY;
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const absCos = Math.abs(cos);
const absSin = Math.abs(sin);
const sourceCenterX = snapshot.offset.x + snapshot.width / 2;
const sourceCenterY = snapshot.offset.y + snapshot.height / 2;
const relativeX = (sourceCenterX - sourceBounds.centerX) * signedScaleX;
const relativeY = (sourceCenterY - sourceBounds.centerY) * signedScaleY;
const centerX = target.centerX + relativeX * cos - relativeY * sin;
const centerY = target.centerY + relativeX * sin + relativeY * cos;
const scaledWidth = snapshot.width * scaleX;
const scaledHeight = snapshot.height * scaleY;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos));
return {
scaleX,
scaleY,
signedScaleX,
signedScaleY,
rotation: target.rotation,
radians,
width,
height,
centerX,
centerY,
offset: {
x: Math.round(centerX - width / 2),
y: Math.round(centerY - height / 2),
},
};
}
export function transformedSelectionBounds(sourceBounds, target) {
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, target.width * cos + target.height * sin);
const height = Math.max(1, target.width * sin + target.height * cos);
return {
x: target.centerX - width / 2,
y: target.centerY - height / 2,
width,
height,
centerX: target.centerX,
centerY: target.centerY,
};
}
function canvasPixels(canvas) {
const width = Number(canvas?.width) || 0;
const height = Number(canvas?.height) || 0;
return Math.max(0, width) * Math.max(0, height);
}
/**
* Build a transform plan without allocating output canvases. The project
* decoder and the live editor share the same limits so a transform cannot
* create a document that the editor would later refuse to reopen.
*/
export function planLayerTransform(editorState, snapshots, sourceBounds, target, limits) {
const maxDimension = Number(limits?.maxDimension) || 32768;
const maxSurfacePixels = Number(limits?.maxSurfacePixels) || 300_000_000;
const numericTarget = [
target?.width, target?.height, target?.rotation,
target?.centerX, target?.centerY,
].every(Number.isFinite);
if (!numericTarget || target.width < 1 || target.height < 1) {
return { ok: false, reason: 'Transform values must be finite positive numbers.' };
}
const planned = [];
const byLayerId = new Map();
for (const snapshot of snapshots || []) {
const geometry = transformedLayerGeometry(snapshot, sourceBounds, target);
if (![geometry.width, geometry.height, geometry.offset.x, geometry.offset.y].every(Number.isFinite)) {
return { ok: false, reason: 'Transform geometry is outside the supported numeric range.' };
}
if (geometry.width > maxDimension || geometry.height > maxDimension) {
return {
ok: false,
reason: `A transformed layer would exceed the ${maxDimension.toLocaleString()} px dimension limit.`,
};
}
const item = { snapshot, geometry };
planned.push(item);
byLayerId.set(snapshot.layer.id, item);
}
let surfacePixels = 0;
const addPixels = pixels => {
surfacePixels += pixels;
return surfacePixels <= maxSurfacePixels;
};
for (const layer of editorState.layers || []) {
const item = byLayerId.get(layer.id);
if (!addPixels(item ? item.geometry.width * item.geometry.height : canvasPixels(layer.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
if (layer.kind === 'placed' && layer.placed?.sourceCanvas && !addPixels(canvasPixels(layer.placed.sourceCanvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
for (const mask of layer.masks || []) {
const maskSnapshot = item?.snapshot.masks?.find(candidate => candidate.mask === mask);
const pixels = maskSnapshot?.linked
? item.geometry.width * item.geometry.height
: canvasPixels(mask.canvas);
if (!addPixels(pixels)) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
if (!addPixels(canvasPixels(mask.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const selection of editorState.savedSelections || []) {
if (!addPixels(canvasPixels(selection.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
if (editorState.wandMask && !addPixels(canvasPixels(editorState.wandMask))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
return { ok: true, surfacePixels, items: planned };
}

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