mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 03:12:21 +02:00
Preserve preview harness, editor, email and task improvements
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
This commit is contained in:
@@ -3459,6 +3459,11 @@ function initializeEventListeners() {
|
||||
if (sidebarNewChatBtn) {
|
||||
sidebarNewChatBtn.addEventListener('click', async (e) => {
|
||||
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
if (window.innerWidth < 768) {
|
||||
el('sidebar')?.classList.add('hidden');
|
||||
el('sidebar-backdrop')?.classList.remove('visible');
|
||||
window.syncRailSide?.();
|
||||
}
|
||||
await _handleNewChatAction();
|
||||
});
|
||||
}
|
||||
|
||||
+10
-8
@@ -1195,14 +1195,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-input-right">
|
||||
<!-- Reasoning effort dropdown for models supporting reasoning levels -->
|
||||
<div class="reasoning-effort-wrap" id="reasoning-effort-wrap" style="display:none;">
|
||||
<button type="button" class="reasoning-effort-btn" id="reasoning-effort-btn" title="Reasoning effort" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="reasoning-effort-prefix">Effort: </span><span class="reasoning-effort-current" id="reasoning-effort-current">Default</span>
|
||||
<svg class="reasoning-effort-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="reasoning-effort-menu hidden" id="reasoning-effort-menu" role="listbox"></div>
|
||||
</div>
|
||||
<!-- Agent / Chat mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn" aria-pressed="true">Agent</button>
|
||||
@@ -1219,6 +1211,16 @@
|
||||
<input type="checkbox" id="plan-toggle" style="display:none;">
|
||||
</div>
|
||||
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
|
||||
<!-- Retain the bound control while the Chat Context popup is closed. -->
|
||||
<div id="reasoning-effort-home" hidden>
|
||||
<div class="reasoning-effort-wrap" id="reasoning-effort-wrap" style="display:none;">
|
||||
<button type="button" class="reasoning-effort-btn" id="reasoning-effort-btn" title="Reasoning effort" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="reasoning-effort-prefix">Reasoning effort</span><span class="reasoning-effort-current" id="reasoning-effort-current">Default</span>
|
||||
<svg class="reasoning-effort-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div class="reasoning-effort-menu hidden" id="reasoning-effort-menu" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Character (custom preset) modal -->
|
||||
<div id="custom-preset-modal" class="modal hidden">
|
||||
|
||||
+28
-4
@@ -79,7 +79,7 @@ async function loadUsers() {
|
||||
|
||||
// Header: name + badges + delete
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;';
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;cursor:pointer;padding:4px 0;';
|
||||
const initial = u.username.charAt(0).toUpperCase();
|
||||
header.innerHTML = `
|
||||
<div class="admin-user-info">
|
||||
@@ -89,9 +89,10 @@ async function loadUsers() {
|
||||
${u.is_admin ? '<span class="admin-badge" style="margin-left:6px;">ADMIN</span>' : '<span style="font-size:10px;opacity:0.4;display:block;">Click to manage privileges</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<button class="admin-btn-sm" data-adm-toggle-admin="${esc(u.username)}" data-make-admin="${u.is_admin ? '0' : '1'}" style="font-size:11px;">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>
|
||||
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
|
||||
${u.is_admin ? '' : '<button class="admin-btn-sm" data-adm-reset-password style="font-size:11px;">Change password</button>'}
|
||||
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
|
||||
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
|
||||
</div>
|
||||
@@ -149,7 +150,7 @@ async function loadUsers() {
|
||||
// Toggle panel visibility + rotate chevron + load models
|
||||
let _modelsLoaded = false;
|
||||
header.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
|
||||
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin], [data-adm-reset-password]')) return;
|
||||
privPanel.classList.toggle('hidden');
|
||||
const chevron = header.querySelector('.admin-user-chevron');
|
||||
if (chevron) {
|
||||
@@ -186,6 +187,29 @@ async function loadUsers() {
|
||||
});
|
||||
}
|
||||
|
||||
const passwordBtn = row.querySelector('[data-adm-reset-password]');
|
||||
passwordBtn?.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const options = { title: 'Change password', inputType: 'password', maxLength: 72 };
|
||||
const password = await uiModule.styledPrompt(`New password for "${u.username}". Their existing sessions will be signed out.`, options);
|
||||
if (password === null) return;
|
||||
const confirmation = await uiModule.styledPrompt('Confirm the new password', options);
|
||||
if (confirmation === null) return;
|
||||
if (password !== confirmation) { uiModule.showError('Passwords do not match'); return; }
|
||||
passwordBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/users/${encodeURIComponent(u.username)}/password`, {
|
||||
method: 'PUT', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_password: password }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { uiModule.showError(data.detail || 'Failed to change password'); return; }
|
||||
uiModule.showToast('Password changed. Existing sessions signed out.');
|
||||
} catch { uiModule.showError('Failed to change password'); }
|
||||
finally { passwordBtn.disabled = false; }
|
||||
});
|
||||
|
||||
// Rename button
|
||||
const renameBtn = row.querySelector('[data-adm-rename-user]');
|
||||
if (renameBtn) {
|
||||
@@ -1042,7 +1066,7 @@ async function loadEndpoints() {
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
||||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;align-items:center;">
|
||||
${_endpointSelectMode ? `<label class="adm-model-row" title="Select ${esc(ep.name)}" style="display:inline-flex;align-items:center;justify-content:center;margin:0 5px 0 0;cursor:pointer;flex-shrink:0;">
|
||||
${_endpointSelectMode ? `<label class="adm-model-row adm-endpoint-select-label" title="Select ${esc(ep.name)}">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-adm-ep-select="${ep.id}" aria-label="Select ${esc(ep.name)}" ${_selectedEndpointIds.has(String(ep.id)) ? 'checked' : ''}>
|
||||
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
|
||||
</label>` : ''}
|
||||
|
||||
+143
-30
@@ -63,6 +63,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
let _contextHeaderAnchorEl = null;
|
||||
let _contextHeaderPopupCleanup = null;
|
||||
let _pendingToolApproval = null;
|
||||
let _lastPrivateBrowserUrl = '';
|
||||
function _isPrivateBrowserTool(tool) {
|
||||
@@ -330,6 +331,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
|
||||
function _closeContextHeaderPopup() {
|
||||
_contextHeaderPopupCleanup?.();
|
||||
_contextHeaderPopupCleanup = null;
|
||||
const effort = document.getElementById('reasoning-effort-wrap');
|
||||
const effortHome = document.getElementById('reasoning-effort-home');
|
||||
if (effort && effortHome) {
|
||||
effort.querySelector('#reasoning-effort-menu')?.classList.add('hidden');
|
||||
effort.querySelector('#reasoning-effort-btn')?.setAttribute('aria-expanded', 'false');
|
||||
effortHome.appendChild(effort);
|
||||
}
|
||||
document.querySelectorAll('.chat-context-popup').forEach(el => el.remove());
|
||||
const pill = document.getElementById('chat-context-pill');
|
||||
if (pill) pill.classList.remove('open');
|
||||
@@ -488,8 +498,11 @@ import { invalidateSettings } from './appConfig.js';
|
||||
thinkingToggle.type = 'button';
|
||||
thinkingToggle.className = `chat-context-toggle${thinkingOn ? ' active' : ''}`;
|
||||
thinkingToggle.setAttribute('role', 'switch');
|
||||
thinkingToggle.setAttribute('aria-label', 'Thinking for this chat');
|
||||
thinkingToggle.setAttribute('aria-checked', thinkingOn ? 'true' : 'false');
|
||||
thinkingToggle.addEventListener('click', async () => {
|
||||
thinkingToggle.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
thinkingToggle.disabled = true;
|
||||
const next = !thinkingToggle.classList.contains('active');
|
||||
if (await _saveChatGenerationSettings({ thinking_mode: next ? 'on' : 'off' })) {
|
||||
thinkingToggle.classList.toggle('active', next);
|
||||
@@ -497,11 +510,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
thinkingRow.querySelector('.chat-context-toggle-state').textContent = next ? 'On' : 'Off';
|
||||
uiModule.showToast(`Thinking ${next ? 'on' : 'off'} for this chat`);
|
||||
}
|
||||
thinkingToggle.disabled = false;
|
||||
});
|
||||
thinkingRow.appendChild(thinkingToggle);
|
||||
popup.appendChild(thinkingRow);
|
||||
}
|
||||
|
||||
const effort = document.getElementById('reasoning-effort-wrap');
|
||||
if (effort) popup.appendChild(effort);
|
||||
|
||||
const addGenerationSlider = (label, value, min, max, step, formatter, key) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-context-threshold-row';
|
||||
@@ -596,21 +613,23 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_contextHeaderAnchorEl?.classList?.add('open');
|
||||
_positionContextHeaderPopup(popup, _contextHeaderAnchorEl);
|
||||
setTimeout(() => {
|
||||
if (!popup.isConnected) return;
|
||||
const closeOnEscape = (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', closeOnEscape, true);
|
||||
_closeContextHeaderPopup();
|
||||
};
|
||||
document.addEventListener('keydown', closeOnEscape, true);
|
||||
const close = (ev) => {
|
||||
if (popup.contains(ev.target) || pill.contains(ev.target) || _contextHeaderAnchorEl?.contains?.(ev.target)) return;
|
||||
document.removeEventListener('pointerdown', close, true);
|
||||
document.removeEventListener('keydown', closeOnEscape, true);
|
||||
_closeContextHeaderPopup();
|
||||
};
|
||||
document.addEventListener('pointerdown', close, true);
|
||||
_contextHeaderPopupCleanup = () => {
|
||||
document.removeEventListener('pointerdown', close, true);
|
||||
document.removeEventListener('keydown', closeOnEscape, true);
|
||||
};
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -743,6 +762,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const res = await fetch(`/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());
|
||||
_contextHeaderData = { ..._contextHeaderData, ...await res.json() };
|
||||
const session = _liveSessionModule()?.getSessions?.().find(item => item.id === sid);
|
||||
if (session) Object.assign(session, {
|
||||
thinking_mode: _contextHeaderData.thinking_mode,
|
||||
temperature_override: _contextHeaderData.temperature_override,
|
||||
max_tokens_override: _contextHeaderData.max_tokens_override,
|
||||
});
|
||||
return true;
|
||||
} catch (err) { uiModule.showError(`Could not save chat settings: ${err.message || err}`); return false; }
|
||||
}
|
||||
@@ -2138,6 +2163,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let holder = null;
|
||||
let finalMeta = null;
|
||||
let _canonicalTerminalSaved = false;
|
||||
let _streamSawDone = false;
|
||||
let spinner = null;
|
||||
let timedOut = false;
|
||||
let processingProbeTimer = null;
|
||||
@@ -2163,7 +2189,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let firstTokenWaitTimers = [];
|
||||
let _ttftDisplayTimer = null;
|
||||
let _clientTtftSeconds = null;
|
||||
let _editorProgress = null;
|
||||
const _ttftLabel = (elapsed) => {
|
||||
if (_editorProgress) {
|
||||
const proposed = Number(_editorProgress.proposed) || 0;
|
||||
return `${proposed > 0 ? `${proposed} proposed` : 'Reviewing'} · ${elapsed.toFixed(1)}s`;
|
||||
}
|
||||
if (elapsed >= 120) return `Still waiting for first token · ${elapsed.toFixed(1)}s`;
|
||||
if (elapsed >= 60) return `Model pre-filling context · ${elapsed.toFixed(1)}s`;
|
||||
if (elapsed >= 20) return `Waiting for first token · ${elapsed.toFixed(1)}s`;
|
||||
@@ -2172,7 +2203,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const _startTtftDisplay = () => {
|
||||
if (_ttftDisplayTimer) clearInterval(_ttftDisplayTimer);
|
||||
const update = () => {
|
||||
if (!spinner || !spinner.element || _clientTtftSeconds != null) return;
|
||||
if (!spinner || !spinner.element || (_clientTtftSeconds != null && !_editorProgress)) return;
|
||||
spinner.updateMessage(_ttftLabel((performance.now() - _ttftStartedAt) / 1000));
|
||||
};
|
||||
update();
|
||||
@@ -2817,6 +2848,39 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let roundReplyText = null; // Reply-only text after a thinking transition
|
||||
let currentToolBubble = null; // Current tool execution bubble
|
||||
let lastToolThread = null; // Visible tool timeline for tool-only turns
|
||||
let finishEditorButton = null;
|
||||
const clearFinishEditorButton = () => {
|
||||
finishEditorButton?.remove();
|
||||
finishEditorButton = null;
|
||||
};
|
||||
const offerFinishEditorTurn = () => {
|
||||
if (finishEditorButton || !lastToolThread || !_streamRunIds.get(streamSessionId)) return;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'continue-btn resume-btn agent-finish-editor';
|
||||
button.textContent = 'Finish with these';
|
||||
button.title = 'Stop generating after the completed document work; keep the saved edits and suggestions';
|
||||
button.addEventListener('click', async () => {
|
||||
const runId = _streamRunIds.get(streamSessionId);
|
||||
if (!runId) return;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Finishing…';
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/chat/finish/${encodeURIComponent(streamSessionId)}`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'X-Odysseus-Run-Id': runId },
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.accepted) throw new Error('The run has already finished or changed');
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Finish with these';
|
||||
uiModule?.showError?.(error.message || 'Could not finish this run');
|
||||
}
|
||||
});
|
||||
lastToolThread.appendChild(button);
|
||||
finishEditorButton = button;
|
||||
};
|
||||
let roundFinalized = false; // Whether current round's text is finalized
|
||||
let terminalFinalResponseRendered = false; // final_response already rendered the canonical bubble
|
||||
let roundFinalization = null; // Terminal owner/result for the current round
|
||||
@@ -3218,20 +3282,23 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_showThinkingSpinner(label);
|
||||
}
|
||||
|
||||
// Auto-show thinking spinner after text stops streaming
|
||||
let _textPauseTimer = null;
|
||||
function _scheduleThinkingSpinner() {
|
||||
if (_textPauseTimer) clearTimeout(_textPauseTimer);
|
||||
_textPauseTimer = setTimeout(() => {
|
||||
// Show waiting feedback only between tool rounds, never infer thinking
|
||||
// from a text pause (which also occurs while a finished reply is saved).
|
||||
let _toolPauseTimer = null;
|
||||
function _scheduleToolWaitSpinner() {
|
||||
if (_toolPauseTimer) clearTimeout(_toolPauseTimer);
|
||||
_toolPauseTimer = setTimeout(() => {
|
||||
const active = _activeStreams.get(streamSessionId);
|
||||
const isVisible = !(sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== streamSessionId);
|
||||
if (active && isVisible && !_thinkingSpinnerEl) {
|
||||
_toolPauseTimer = null;
|
||||
if (active?.abortCtrl === abortCtrl && !abortCtrl?.signal?.aborted
|
||||
&& !_streamSawDone && isVisible && !_thinkingSpinnerEl) {
|
||||
_showThinkingSpinner(_thinkingLabel());
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
_cancelThinkingTimer = () => {
|
||||
if (_textPauseTimer) { clearTimeout(_textPauseTimer); _textPauseTimer = null; }
|
||||
if (_toolPauseTimer) { clearTimeout(_toolPauseTimer); _toolPauseTimer = null; }
|
||||
};
|
||||
|
||||
// Document streaming state (text-fence detection)
|
||||
@@ -3455,9 +3522,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_liveThinkTokenCount = 0;
|
||||
_liveThinkToggle = null;
|
||||
_liveThinkDomId = null;
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
|
||||
_scheduleThinkingSpinner();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3579,6 +3644,13 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|| /^Latest emails? with attachments\b/i.test(s);
|
||||
}
|
||||
|
||||
// Keep processing visible through whitespace/control-only deltas. Swap
|
||||
// it out only after replacement content is in the DOM, in the same paint.
|
||||
function _finishProcessingWhenVisible(content) {
|
||||
if (content && (content.textContent.trim() || content.querySelector('img, svg, canvas'))
|
||||
&& spinner && spinner.element) spinner.destroy();
|
||||
}
|
||||
|
||||
// Direct render helper for streaming text
|
||||
_renderStream = ({ knownNormal = false, displayText = null, replyText = null } = {}) => {
|
||||
if (
|
||||
@@ -3606,6 +3678,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
hljs: window.hljs,
|
||||
}));
|
||||
renderer.update(visiblePersonaText);
|
||||
_finishProcessingWhenVisible(contentEl);
|
||||
uiModule.scrollHistory();
|
||||
return;
|
||||
}
|
||||
@@ -3663,6 +3736,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
hljs: window.hljs,
|
||||
}));
|
||||
r.update(replyTrimmed);
|
||||
_finishProcessingWhenVisible(liveReply);
|
||||
}
|
||||
// Reply empty or not — preserve thinking bar, don't fall through to full re-render
|
||||
uiModule.scrollHistory();
|
||||
@@ -3686,6 +3760,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
contentEl.innerHTML =
|
||||
'<div class="thinking-section"><div class="thinking-header"><div class="thinking-header-left">Thinking' +
|
||||
(lines > 1 ? ` (${lines} lines)` : '') + '</div></div></div>';
|
||||
_finishProcessingWhenVisible(contentEl);
|
||||
// The stream renderer self-heals when it next sees this overwritten
|
||||
// container (streamingRenderer.js), so no explicit reset is needed here.
|
||||
uiModule.scrollHistory();
|
||||
@@ -3699,6 +3774,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// See streamingRenderer.js / streamingSegmenter.js.
|
||||
if (_docFenceOpened && !dt.trim()) {
|
||||
_showDocumentWritingStatus(contentEl);
|
||||
_finishProcessingWhenVisible(contentEl);
|
||||
uiModule.scrollHistory();
|
||||
return;
|
||||
}
|
||||
@@ -3708,11 +3784,11 @@ import { invalidateSettings } from './appConfig.js';
|
||||
hljs: window.hljs,
|
||||
}));
|
||||
renderer.update(dt);
|
||||
_finishProcessingWhenVisible(contentEl);
|
||||
uiModule.scrollHistory();
|
||||
};
|
||||
|
||||
let _nextIsError = false;
|
||||
let _streamSawDone = false;
|
||||
let _streamTerminalError = null;
|
||||
let _firstVisibleOutputSeen = false;
|
||||
const markFirstVisibleOutput = () => {
|
||||
@@ -3723,6 +3799,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
clearFirstTokenWaitTimers();
|
||||
};
|
||||
|
||||
streamReadLoop:
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
_touchStreamActivity(streamSessionId);
|
||||
@@ -3770,6 +3847,11 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
if (data === '[DONE]') {
|
||||
_streamSawDone = true;
|
||||
// DONE completes the protocol even if the HTTP connection stays
|
||||
// open for server cleanup. Do not wait for another network read.
|
||||
void reader.cancel().catch(() => {});
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
const _completedStreamState = _activeStreams.get(streamSessionId);
|
||||
const _completedWhileAway = document.visibilityState !== 'visible' || !!_completedStreamState?.wasAway;
|
||||
_closeOpenThinkingMarkup(_isBg);
|
||||
@@ -3799,7 +3881,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
// Don't do foreground final render — the checkBackgroundStream poll
|
||||
// will detect 'completed' and reload history cleanly
|
||||
break;
|
||||
break streamReadLoop;
|
||||
}
|
||||
if (_completedWhileAway && sessionModule && sessionModule.markStreamComplete) {
|
||||
sessionModule.markStreamComplete(streamSessionId, { force: true });
|
||||
@@ -3844,11 +3926,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle';
|
||||
}
|
||||
// Normal foreground completion — metrics will be displayed in the final render block below
|
||||
break;
|
||||
break streamReadLoop;
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (['stable', 'complete', 'error', 'agent_terminal', 'chat_terminal'].includes(json.type)) _settleTurnRendering();
|
||||
if (['stable', 'complete', 'error', 'agent_terminal', 'chat_terminal'].includes(json.type)) {
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
_settleTurnRendering();
|
||||
}
|
||||
if (
|
||||
(typeof json.delta === 'string' && json.delta.length > 0)
|
||||
|| (json.type === 'final_response' && String(json.content || json.delta || '').length > 0)
|
||||
@@ -3863,7 +3949,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
break;
|
||||
}
|
||||
if (json.delta || json.type === 'final_response' || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
if (json.delta || json.type === 'final_response' || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'editor_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
@@ -3882,7 +3968,9 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (json.type === 'agent_prep') {
|
||||
if (!_isBg) {
|
||||
_cancelThinkingTimer();
|
||||
_replaceThinkingSpinner('Preparing agent');
|
||||
// The existing processing row already owns this wait. Do
|
||||
// not create a second status bubble beside it.
|
||||
if (!spinner?.element) _replaceThinkingSpinner('Preparing agent');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -3895,6 +3983,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
continue;
|
||||
}
|
||||
if (json.type === 'final_response') {
|
||||
_editorProgress = null;
|
||||
clearFinishEditorButton();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('odysseus:agent-final-response', { detail: json }));
|
||||
} catch (_) {}
|
||||
@@ -4019,7 +4109,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (isThinking) {
|
||||
_queueLiveThinking(roundText);
|
||||
} else {
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
if (roundReplyText !== null) {
|
||||
roundReplyText += _delta;
|
||||
const replyDisplayText = _replyDisplayProjector.append(_delta, roundReplyText);
|
||||
@@ -4027,7 +4116,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
} else {
|
||||
_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
|
||||
}
|
||||
_scheduleThinkingSpinner();
|
||||
if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
|
||||
}
|
||||
continue;
|
||||
@@ -4141,8 +4229,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
} else if (!hasUnclosedThink && isThinking) {
|
||||
_finishLiveThinkingTransition();
|
||||
} else {
|
||||
// Normal streaming
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
// Normal streaming: retain processing until the renderer has visible content.
|
||||
if (roundReplyText !== null) {
|
||||
roundReplyText += _delta;
|
||||
const replyDisplayText = _replyDisplayProjector.append(_delta, roundReplyText);
|
||||
@@ -4150,7 +4237,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
} else {
|
||||
_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
|
||||
}
|
||||
_scheduleThinkingSpinner();
|
||||
// Feed streaming TTS with accumulated text
|
||||
if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
|
||||
}
|
||||
@@ -4571,6 +4657,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
if (holder && json.id) holder.dataset.dbId = json.id;
|
||||
|
||||
} else if (json.type === 'tool_start') {
|
||||
_editorProgress = null;
|
||||
// A tool call is the model's first completed output for this
|
||||
// round, even though it is rendered as a structured card
|
||||
// rather than prose. Stop the initial TTFT ticker here so
|
||||
@@ -4674,6 +4761,11 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}, 50);
|
||||
uiModule.scrollHistory();
|
||||
|
||||
} else if (json.type === 'editor_progress') {
|
||||
if (_isBg) continue;
|
||||
_editorProgress = json;
|
||||
if (spinner?.element) _startTtftDisplay();
|
||||
|
||||
} else if (json.type === 'tool_progress') {
|
||||
// Long-running subprocess (bash, python) is still in
|
||||
// flight — refresh the running tool card with the
|
||||
@@ -4874,7 +4966,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
// Schedule a thinking spinner between tool rounds (short delay so
|
||||
// agent_step in the same SSE chunk can cancel it before it shows)
|
||||
_scheduleThinkingSpinner();
|
||||
_scheduleToolWaitSpinner();
|
||||
uiModule.scrollHistory();
|
||||
|
||||
} else if (json.type === 'doc_stream_open') {
|
||||
@@ -4905,15 +4997,25 @@ import { invalidateSettings } from './appConfig.js';
|
||||
} else if (json.type === 'doc_update') {
|
||||
// doc_update means the server already saved the doc to DB.
|
||||
if (_isBg) continue;
|
||||
_editorProgress = null;
|
||||
if (documentModule) {
|
||||
documentModule.handleDocUpdate(json);
|
||||
}
|
||||
offerFinishEditorTurn();
|
||||
|
||||
} else if (json.type === 'doc_suggestions') {
|
||||
if (_isBg) continue;
|
||||
_editorProgress = null;
|
||||
if (documentModule && documentModule.handleDocSuggestions) {
|
||||
documentModule.handleDocSuggestions(json);
|
||||
}
|
||||
offerFinishEditorTurn();
|
||||
|
||||
} else if (json.type === 'email_open') {
|
||||
if (_isBg) continue;
|
||||
import('./emailLibrary.js?v=20260915trashmove2').then(mod =>
|
||||
mod.openEmailFromTool(json, () => sessionModule.getCurrentSessionId() === streamSessionId)
|
||||
).catch(err => uiModule.showToast(`Could not open email: ${err.message}`));
|
||||
|
||||
} else if (json.type === 'ui_control') {
|
||||
if (_isBg) continue;
|
||||
@@ -4938,7 +5040,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
} else if (json.type === 'agent_step') {
|
||||
if (!_turnRendering.accepts(json)) continue;
|
||||
if (!startsContinuationRound(json)) {
|
||||
if (spinner && spinner.element) spinner.updateMessage('Generating response');
|
||||
// Keep the current label stable; the TTFT ticker owns it
|
||||
// until visible output arrives.
|
||||
continue;
|
||||
}
|
||||
_closeOpenThinkingMarkup(_isBg);
|
||||
@@ -5333,9 +5436,9 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
|
||||
const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
|
||||
if (!footerTarget.querySelector('.msg-footer')) {
|
||||
footerTarget.appendChild(createMsgFooter(footerTarget));
|
||||
footerTarget.appendChild(createMsgFooter(footerTarget, { animate: true }));
|
||||
}
|
||||
if (_generatedImagesForTurn.length && !_isBg) {
|
||||
if (_generatedImagesForTurn.length && !_isBgFinal) {
|
||||
_generatedImagesForTurn.forEach(imgData => _appendGeneratedImageBubble(imgData, streamSessionId));
|
||||
}
|
||||
// Add "View Report" link for completed research
|
||||
@@ -5704,6 +5807,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_editorProgress = null;
|
||||
document.querySelectorAll('.agent-finish-editor').forEach(button => button.remove());
|
||||
_settleTurnRendering();
|
||||
_cancelLiveThinkingWork();
|
||||
clearResponseTimeout();
|
||||
@@ -5718,6 +5823,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// reader session id, research marker, UI — to the replacement.
|
||||
const _ownsStreamState =
|
||||
_streamGenerations.get(streamSessionId) === streamGeneration;
|
||||
if (_ownsStreamState && sessionModule.getCurrentSessionId() === streamSessionId) {
|
||||
documentModule?.streamDocFinalize?.();
|
||||
}
|
||||
if (_ownsStreamState && _streamSawDone) {
|
||||
sessionModule.markStreamComplete?.(streamSessionId);
|
||||
} else if (_ownsStreamState && abortCtrl?._reason === 'user-stop') {
|
||||
sessionModule.clearStreaming?.(streamSessionId);
|
||||
}
|
||||
const _finallyRegistered = _activeStreams.get(streamSessionId);
|
||||
if (!_finallyRegistered || _finallyRegistered.abortCtrl === abortCtrl) {
|
||||
_activeStreams.delete(streamSessionId);
|
||||
@@ -6435,7 +6548,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
progress.textContent = String(json.tail || json.message || '');
|
||||
}
|
||||
} else if (json.type === 'agent_step') {
|
||||
if (replayRendering.accepts(json)) startReplayRound();
|
||||
if (replayRendering.accepts(json) && startsContinuationRound(json)) startReplayRound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-12
@@ -3,6 +3,7 @@
|
||||
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import markdownModule from './markdown.js';
|
||||
import { generatedImageResult } from './generatedImageResult.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
@@ -2255,9 +2256,10 @@ function _trackAction(id) {
|
||||
/**
|
||||
* Create a footer row for an AI message with timestamp and action buttons.
|
||||
*/
|
||||
export function createMsgFooter(msgElement) {
|
||||
export function createMsgFooter(msgElement, { animate = false } = {}) {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'msg-footer';
|
||||
if (animate) footer.classList.add('msg-footer-enter');
|
||||
|
||||
const actions = document.createElement('span');
|
||||
actions.className = 'msg-actions';
|
||||
@@ -2587,7 +2589,6 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const outputTokens = metrics.output_tokens || 0;
|
||||
const tps = metrics.tokens_per_second;
|
||||
const ttft = metrics.client_ttft ?? metrics.time_to_first_token;
|
||||
const injectedTokens = metrics.injected_tokens;
|
||||
const isReal = metrics.usage_source === 'real';
|
||||
const ctxPct = metrics.context_percent;
|
||||
const model = metrics.model || 'Unknown';
|
||||
@@ -2643,7 +2644,8 @@ export function displayMetrics(messageElement, metrics) {
|
||||
const toolCalls = metrics.tool_calls;
|
||||
const prepBreakdown = metrics.agent_prep_breakdown || null;
|
||||
const prepDetails = prepBreakdown
|
||||
? Object.entries(prepBreakdown).map(([k, v]) => `${k}: ${v}s`).join('<br>')
|
||||
? Object.entries(prepBreakdown).map(([k, v]) =>
|
||||
`<div class="ctx-stat-row"><span class="ctx-label">${uiModule.esc(k.replaceAll('_', ' '))}</span><span class="ctx-stat-value">${Number(v).toFixed(3)}s</span></div>`).join('')
|
||||
: '';
|
||||
|
||||
// Session total cost
|
||||
@@ -2660,7 +2662,6 @@ export function displayMetrics(messageElement, metrics) {
|
||||
<div class="ctx-stat-section">
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Model</span><span class="ctx-stat-value">${model.split('/').pop()}</span></div>
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Input</span><span class="ctx-stat-value">${inputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</span></div>
|
||||
${injectedTokens != null ? `<div class="ctx-stat-row"><span class="ctx-label">Injected</span><span class="ctx-stat-value">${Number(injectedTokens).toLocaleString()} tokens</span></div>` : ''}
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Output</span><span class="ctx-stat-value">${outputTokens.toLocaleString()} tokens${isReal ? '' : '~'}</span></div>
|
||||
<div class="ctx-stat-row"><span class="ctx-label">Total</span><span class="ctx-stat-value">${totalTok.toLocaleString()} tokens</span></div>
|
||||
</div>
|
||||
@@ -2672,22 +2673,32 @@ export function displayMetrics(messageElement, metrics) {
|
||||
${visibleTtft != null ? `<div class="ctx-stat-row"><span class="ctx-label">TTFT</span><span class="ctx-stat-value">${Number(visibleTtft).toFixed(3)}s</span></div>` : ''}
|
||||
</div>
|
||||
<div class="ctx-stat-section">
|
||||
${schemaCount != null ? `<div class="ctx-stat-row"><span class="ctx-label">Tool schemas</span><span class="ctx-stat-value">${Number(schemaCount).toLocaleString()}</span></div>` : ''}
|
||||
${schemaCount != null ? `<div class="ctx-stat-row ctx-tool-schemas" tabindex="0"><span class="ctx-label">Tool schemas</span><span class="ctx-stat-value">${Number(schemaCount).toLocaleString()}</span></div>` : ''}
|
||||
${agentRounds != null ? `<div class="ctx-stat-row"><span class="ctx-label">Agent rounds</span><span class="ctx-stat-value">${Number(agentRounds).toLocaleString()}</span></div>` : ''}
|
||||
${toolCalls != null ? `<div class="ctx-stat-row"><span class="ctx-label">Tool calls</span><span class="ctx-stat-value">${Number(toolCalls).toLocaleString()}</span></div>` : ''}
|
||||
${costRows}
|
||||
${sessionCostStr}
|
||||
</div>
|
||||
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
|
||||
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
|
||||
${prepDetails}
|
||||
</div>` : ''}
|
||||
${prepDetails ? `<div class="ctx-stat-section">${prepDetails}</div>` : ''}
|
||||
${ctxPct !== undefined && ctxPct > 0 ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);">
|
||||
<span class="ctx-label">Context</span> <span style="color:${ctxColor};font-weight:600;">${ctxPct}%</span> used
|
||||
</div>` : ''}
|
||||
${isReal ? '' : '<div style="margin-top:4px;font-size:0.8em;opacity:0.4;">~ estimated token count</div>'}
|
||||
`;
|
||||
|
||||
const schemaRow = popup.querySelector('.ctx-tool-schemas');
|
||||
if (schemaRow) {
|
||||
const names = Array.isArray(metrics.tool_schema_names)
|
||||
? metrics.tool_schema_names.filter(name => typeof name === 'string' && name.trim())
|
||||
: [];
|
||||
const details = names.length
|
||||
? `Available tools:\n${names.join('\n')}`
|
||||
: Number(schemaCount) === 0 ? 'No tools offered.' : 'Tool names were not recorded for this response.';
|
||||
schemaRow.title = details;
|
||||
schemaRow.setAttribute('aria-label', `Tool schemas: ${schemaCount}. ${details}`);
|
||||
schemaRow.style.cursor = 'help';
|
||||
}
|
||||
|
||||
const rect = metricsContainer.getBoundingClientRect();
|
||||
popup.style.left = rect.left + 'px';
|
||||
popup.style.visibility = 'hidden';
|
||||
@@ -2851,7 +2862,9 @@ export function displayMetrics(messageElement, metrics) {
|
||||
|
||||
let footer = messageElement.querySelector('.msg-footer');
|
||||
if (!footer) {
|
||||
footer = createMsgFooter(messageElement);
|
||||
footer = createMsgFooter(messageElement, {
|
||||
animate: messageElement.classList?.contains('streaming'),
|
||||
});
|
||||
if (messageElement.classList?.contains('agent-thread')) {
|
||||
footer.classList.add('agent-thread-footer');
|
||||
}
|
||||
@@ -3388,8 +3401,9 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
lastWrap = threadWrap;
|
||||
|
||||
for (const ev of roundTools) {
|
||||
if (ev.image_url) {
|
||||
box.appendChild(buildImageBubble(ev.image_url, ev.image_prompt, ev.image_model, ev.image_size, ev.image_quality, ev.image_id));
|
||||
const image = generatedImageResult(ev);
|
||||
if (image) {
|
||||
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
-123
@@ -9,6 +9,7 @@
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import sessionModule from './sessions.js';
|
||||
import emojiPicker from './emojiPicker.js';
|
||||
import { readEmailReplyResponse } from './emailReplyStream.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
|
||||
import { langIcon } from './langIcons.js?v=20260831richtexttools91';
|
||||
@@ -3579,10 +3580,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
catch (_) { return _emailPlainTextToHtml(raw); }
|
||||
}
|
||||
|
||||
function _richTextContentToPlain(content) {
|
||||
return _normalizeRichStatsText(_emailHtmlToPlainText(_richTextContentToHtml(content)));
|
||||
}
|
||||
|
||||
function _emailQuoteMarkerMatch(text) {
|
||||
const raw = String(text || '');
|
||||
return raw.match(/(?:<p[^>]*>\s*)?-{5,}\s*Previous message\s*-{5,}(?:\s*<\/p>)?/i)
|
||||
@@ -4191,7 +4188,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
_normalizeRichInlineCode(rich);
|
||||
_syncEmailRichbody(rich);
|
||||
_scheduleEmailRichbodySave();
|
||||
if (_selections.length) clearSelection();
|
||||
if (_selections.length) clearSelection({ preserveCaret: true });
|
||||
const findBar = document.getElementById('doc-find-bar');
|
||||
const findInput = document.getElementById('doc-find-input');
|
||||
if (findBar?.style.display !== 'none' && findInput?.value) {
|
||||
@@ -6700,6 +6697,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateEmailReply(opts = {}) {
|
||||
return _aiReply(opts);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
const { mode = 'auto', noteHint = '', contextKey = '' } = (opts || {});
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
|
||||
@@ -6729,8 +6730,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
.trim();
|
||||
};
|
||||
const splitCurrent = _splitEmailReplyQuote(currentBody);
|
||||
const ownText = String(splitCurrent.body || '').trim();
|
||||
const isReplaceableDraft = !ownText || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
|
||||
const ownBody = document.createElement('div');
|
||||
ownBody.innerHTML = String(splitCurrent.body || '');
|
||||
const ownText = (ownBody.textContent || '').trim();
|
||||
const isReplaceableDraft = (!ownText && !ownBody.querySelector('img,video,audio,iframe,table')) || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
|
||||
if (!isReplaceableDraft) {
|
||||
if (uiModule) uiModule.showToast('Reply already has text');
|
||||
return;
|
||||
@@ -6741,14 +6744,28 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// the user typed while it was in flight.
|
||||
const generationId = ++_emailAiReplyGeneration;
|
||||
const generationDocId = activeDocId;
|
||||
const generationBody = currentBody;
|
||||
let generationBody = currentBody;
|
||||
const generationRich = _emailRichbodyActive();
|
||||
const generationRichHtml = generationRich?.innerHTML || '';
|
||||
const richDraftSnapshot = () => {
|
||||
if (!generationRich) return '';
|
||||
const clone = generationRich.cloneNode(true);
|
||||
// Focusing an empty reply inserts a caret slot, not a user edit.
|
||||
clone.querySelectorAll('.email-reply-edit-slot').forEach(slot => {
|
||||
if (!slot.textContent.trim() && !slot.querySelector('img,video,audio,iframe,table')) slot.remove();
|
||||
});
|
||||
return clone.innerHTML;
|
||||
};
|
||||
let generationRichHtml = richDraftSnapshot();
|
||||
let manuallyEdited = false;
|
||||
const markEdited = () => { manuallyEdited = true; };
|
||||
textarea.addEventListener('input', markEdited);
|
||||
generationRich?.addEventListener('input', markEdited);
|
||||
const draftStillUnchanged = () => (
|
||||
generationId === _emailAiReplyGeneration &&
|
||||
!manuallyEdited &&
|
||||
activeDocId === generationDocId &&
|
||||
textarea.value === generationBody &&
|
||||
(!generationRich || generationRich.innerHTML === generationRichHtml)
|
||||
(!generationRich || richDraftSnapshot() === generationRichHtml)
|
||||
);
|
||||
|
||||
// Use the current chat model
|
||||
@@ -6771,7 +6788,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Empty-compose path: if there's no original body, send a placeholder
|
||||
// so the backend's "no body" guard doesn't fail. The user_hint carries
|
||||
// the user's compose intent; the model uses To/Subject + that hint.
|
||||
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
|
||||
const bodyForApi = opts.originalBody || splitCurrent.quote || currentBody || (noteHint ? '(no prior email -- compose from the user instructions)' : '');
|
||||
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -6779,17 +6796,25 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
to: to,
|
||||
subject: subject,
|
||||
original_body: bodyForApi,
|
||||
stream: true,
|
||||
model: currentModel,
|
||||
session_id: currentSessionId,
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
account_id: sourceAccountId,
|
||||
fast: mode === 'ai-reply-fast',
|
||||
fast: mode !== 'ai-reply-full',
|
||||
user_hint: noteHint || '',
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const data = await readEmailReplyResponse(res, text => {
|
||||
if (!draftStillUnchanged()) return false;
|
||||
const quote = splitCurrent.quote || '';
|
||||
_setEmailBodyText(textarea, text + (quote ? `\n\n${quote}` : ''));
|
||||
generationBody = textarea.value;
|
||||
generationRichHtml = richDraftSnapshot();
|
||||
return true;
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `AI reply service returned HTTP ${res.status}`);
|
||||
}
|
||||
@@ -6807,9 +6832,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
|
||||
const quote = splitCurrent.quote || '';
|
||||
const newBody = cleanReply + (quote ? `\n\n${quote}` : '');
|
||||
// Insert in one guarded operation. Streaming here used to write a
|
||||
// frame at a time, which could erase newly typed text after the user
|
||||
// had started editing the draft.
|
||||
// Reconcile the final body only while this generation still owns the draft.
|
||||
if (!draftStillUnchanged()) {
|
||||
if (uiModule) uiModule.showToast('AI reply ready, but draft was edited', { aiReplyResult: true });
|
||||
return;
|
||||
@@ -6817,7 +6840,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
_setEmailBodyText(textarea, newBody);
|
||||
_clearDocAiReplyContext(contextKey || _docAiReplyContextKey());
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`, { aiReplyResult: true });
|
||||
return true;
|
||||
} else {
|
||||
if (draftStillUnchanged()) _setEmailBodyText(textarea, currentBody);
|
||||
const rawMsg = data.error || 'Failed to generate reply';
|
||||
const msg = /empty response/i.test(rawMsg)
|
||||
? 'AI reply failed: AI returned empty response.'
|
||||
@@ -6825,8 +6850,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
if (uiModule) uiModule.showError(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
if (draftStillUnchanged()) _setEmailBodyText(textarea, currentBody);
|
||||
if (uiModule) uiModule.showError(`AI reply failed: ${e?.message || 'Unable to reach the AI reply service'}`);
|
||||
} finally {
|
||||
textarea.removeEventListener('input', markEdited);
|
||||
generationRich?.removeEventListener('input', markEdited);
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
|
||||
}
|
||||
}
|
||||
@@ -8712,7 +8740,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
if (ta && pre) {
|
||||
ta.addEventListener('input', () => {
|
||||
// Typing invalidates any pinned selection highlight
|
||||
if (_selections.length) clearSelection();
|
||||
if (_selections.length) clearSelection({ preserveCaret: true });
|
||||
// Auto-create a document if user types/pastes with no active doc.
|
||||
// Skip while a createDocument POST is in flight — otherwise typing
|
||||
// during the round-trip spawns a duplicate untitled doc.
|
||||
@@ -11183,9 +11211,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
];
|
||||
|
||||
const _AI_WRITING_ACTIONS = Object.freeze({
|
||||
proofread: 'Proofread the open document for spelling and grammar. Create inline suggestions only; do not apply changes.',
|
||||
proofread: 'Fix spelling and grammar in the open document. Apply corrections directly using targeted edits. Preserve the meaning, voice, and formatting; do not rewrite for style. Keep already-correct sentences unchanged. Use each affected paragraph as an exact unique FIND anchor and change only the spelling or grammar errors in its replacement. Check every paragraph, including repeated errors. Do not just list corrections in chat.',
|
||||
improve: 'Review the open document and create inline suggestions for clarity, wording, structure, and readability. Do not apply changes.',
|
||||
concise: 'Review the open document and suggest concise wording improvements. Create inline suggestions only; do not apply changes.',
|
||||
concise: 'Make the open document more concise by proposing one concrete, shorter replacement per affected paragraph. Each REPLACE must actually shorten the prose, not merely correct spelling or grammar. Each FIND must quote the entire original paragraph exactly, including any HTML tags. Keep distinct paragraphs separate and preserve their facts and meaning. Create inline suggestions only; do not apply changes.',
|
||||
style: 'Rewrite the open document to match my configured Writing Style setting. Preserve the meaning and create inline suggestions only; do not apply changes.',
|
||||
sources: 'Check factual claims in the open document using web research. For each claim, verify it, suggest a source link/citation when valid, or clearly mark it as unverified when you cannot find reliable evidence. Create inline suggestions only; do not apply changes.',
|
||||
});
|
||||
@@ -11330,8 +11358,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
? `\n\nUse this configured writing style as the source of truth:\n---\n${configuredStyle.slice(0, 8000)}\n---`
|
||||
: '';
|
||||
const scopedPrompt = selectedText
|
||||
? `${prompt}${styleContext}\n\nImportant scope: work only on this selected passage and do not suggest changes elsewhere in the document. Use the exact matching text from the active document as the FIND target even if the editor stores formatting markup around it. Selected passage:\n---\n${selectedText.slice(0, 12000)}\n---`
|
||||
: `${prompt}${styleContext}\n\nThere is no text selection, so review the whole open document.`;
|
||||
? `${prompt}${styleContext}\n\nImportant scope: work only on this selected passage and do not change or suggest changes elsewhere in the document. Use the exact matching text from the active document as the FIND target even if the editor stores formatting markup around it. Selected passage:\n---\n${selectedText.slice(0, 12000)}\n---`
|
||||
: `${prompt}${styleContext}\n\nThere is no text selection, so work on the whole open document.`;
|
||||
// Desktop reveals the chat beside the document before dispatching the
|
||||
// writing request. On mobile the document is already a fixed sheet;
|
||||
// toggling its fullscreen class here reflows the sheet as the prompt is
|
||||
@@ -12038,9 +12066,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// populates it. Do not call switchToDoc synchronously here: it saves the
|
||||
// previously active doc and can re-enter the email draft path while a reply
|
||||
// document is still being injected.
|
||||
requestAnimationFrame(() => {
|
||||
return new Promise(resolve => requestAnimationFrame(() => {
|
||||
if (docs.has(doc.id)) switchToDoc(doc.id);
|
||||
});
|
||||
resolve();
|
||||
}));
|
||||
}
|
||||
|
||||
export async function replaceEmailReplyBody(docId, replyText, { force = false } = {}) {
|
||||
@@ -12570,6 +12599,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// hljs has no 'svg' grammar — highlight it as xml (the dropdown value stays
|
||||
// 'svg' so the preview/run routing still treats it as renderable markup).
|
||||
const _hlLang = lang === 'svg' ? 'xml' : lang;
|
||||
const syntaxEnabled = !!(window.hljs?.getLanguage(_hlLang || '')
|
||||
&& !['markdown', 'text', 'plaintext', 'email', 'richtext'].includes(lang));
|
||||
document.getElementById('doc-editor-wrap')?.classList.toggle('doc-code-syntax', syntaxEnabled);
|
||||
textarea.wrap = syntaxEnabled ? 'off' : 'soft';
|
||||
codeEl.className = _hlLang ? `language-${_hlLang}` : '';
|
||||
if (window.hljs && _hlLang) {
|
||||
codeEl.removeAttribute('data-highlighted');
|
||||
@@ -13318,7 +13351,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
/** Clear all selections, badge, and highlights */
|
||||
function clearSelection() {
|
||||
function clearSelection({ preserveCaret = false } = {}) {
|
||||
_selections = [];
|
||||
try { CSS.highlights?.delete(_richSelectionHighlightName); } catch (_) {}
|
||||
document.querySelectorAll('.doc-selection-rich-clear').forEach(el => el.remove());
|
||||
@@ -13328,7 +13361,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// after the badge's X has cleared the actual AI-edit context.
|
||||
const rich = _emailRichbodyActive();
|
||||
const browserSelection = window.getSelection?.();
|
||||
if (rich && browserSelection?.rangeCount
|
||||
// Input already placed the caret after the edit. Clearing pinned AI
|
||||
// context must not discard that live insertion point.
|
||||
if (!preserveCaret && rich && browserSelection?.rangeCount
|
||||
&& (rich.contains(browserSelection.anchorNode) || rich.contains(browserSelection.focusNode))) {
|
||||
browserSelection.removeAllRanges();
|
||||
}
|
||||
@@ -13545,12 +13580,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
const hadPending = _activeSuggestions.length > 0;
|
||||
const existingIds = new Set(_activeSuggestions.map(s => s.id));
|
||||
const existingFinds = new Set(_activeSuggestions.map(s => s.find));
|
||||
|
||||
// Append new suggestions, skipping any IDs already in the queue so a
|
||||
// re-sent batch doesn't duplicate.
|
||||
let added = 0;
|
||||
for (const sugg of data.suggestions) {
|
||||
if (existingIds.has(sugg.id)) continue;
|
||||
if (existingIds.has(sugg.id) || existingFinds.has(sugg.find)) continue;
|
||||
_activeSuggestions.push({
|
||||
id: sugg.id,
|
||||
find: sugg.find,
|
||||
@@ -13558,6 +13594,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
reason: sugg.reason,
|
||||
cardEl: null,
|
||||
});
|
||||
existingFinds.add(sugg.find);
|
||||
added++;
|
||||
}
|
||||
_suggestionTotal = (_suggestionTotal || 0) + added;
|
||||
@@ -13622,6 +13659,19 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
// Position card next to the highlighted text
|
||||
function _positionCard(card) {
|
||||
card.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(pane).zIndex, 10) || 0) + 1));
|
||||
if (window.innerWidth <= 768) {
|
||||
const viewport = window.visualViewport;
|
||||
const top = viewport?.offsetTop || 0;
|
||||
const height = viewport?.height || window.innerHeight;
|
||||
card.style.position = 'fixed';
|
||||
card.style.left = ((viewport?.offsetLeft || 0) + 8) + 'px';
|
||||
card.style.right = 'auto';
|
||||
card.style.width = Math.max(0, (viewport?.width || window.innerWidth) - 16) + 'px';
|
||||
card.style.maxHeight = Math.max(0, height - 16) + 'px';
|
||||
card.style.top = Math.max(top + 8, top + height - card.offsetHeight - 8) + 'px';
|
||||
return;
|
||||
}
|
||||
if (!textarea) return;
|
||||
const text = textarea.value;
|
||||
const idx = text.indexOf(sugg.find);
|
||||
@@ -13670,8 +13720,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
<div class="doc-suggestion-reason">${_esc(sugg.reason)}</div>
|
||||
<div class="doc-suggestion-actions">
|
||||
<button class="doc-suggestion-accept">Accept</button>
|
||||
<button class="doc-suggestion-accept-all" title="Apply all ${remaining} pending suggestions" aria-label="Accept all ${remaining} pending suggestions">Accept All</button>
|
||||
<button class="doc-suggestion-dismiss">Skip</button>
|
||||
${remaining > 1 ? '<button class="doc-suggestion-accept-all"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg><span>Accept All</span></button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -13696,7 +13746,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
_showCurrentSuggestion();
|
||||
});
|
||||
card.querySelector('.doc-suggestion-accept').addEventListener('click', () => {
|
||||
_applySuggestion(sugg);
|
||||
if (!_applySuggestions([sugg]).length) return;
|
||||
_activeSuggestions.shift();
|
||||
_animateNext();
|
||||
});
|
||||
@@ -13707,8 +13757,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const acceptAllBtn = card.querySelector('.doc-suggestion-accept-all');
|
||||
if (acceptAllBtn) {
|
||||
acceptAllBtn.addEventListener('click', () => {
|
||||
for (const s of _activeSuggestions) _applySuggestion(s);
|
||||
_activeSuggestions = [];
|
||||
const applied = new Set(_applySuggestions(_activeSuggestions));
|
||||
if (!applied.size) return;
|
||||
_activeSuggestions = _activeSuggestions.filter(s => !applied.has(s.id));
|
||||
_animateNext();
|
||||
});
|
||||
}
|
||||
@@ -13723,10 +13774,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const _reposition = () => { if (card.isConnected) _positionCard(card); };
|
||||
if (textarea) textarea.addEventListener('scroll', _reposition);
|
||||
window.addEventListener('resize', _reposition);
|
||||
window.visualViewport?.addEventListener('resize', _reposition);
|
||||
window.visualViewport?.addEventListener('scroll', _reposition);
|
||||
// Store cleanup refs on the card
|
||||
card._cleanup = () => {
|
||||
if (textarea) textarea.removeEventListener('scroll', _reposition);
|
||||
window.removeEventListener('resize', _reposition);
|
||||
window.visualViewport?.removeEventListener('resize', _reposition);
|
||||
window.visualViewport?.removeEventListener('scroll', _reposition);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14142,7 +14197,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
|
||||
/** Exit diff mode and apply resolved changes */
|
||||
function exitDiffMode(discard) {
|
||||
function exitDiffMode(discard, { persist = true } = {}) {
|
||||
if (!_diffModeActive) return;
|
||||
_diffModeActive = false;
|
||||
const acceptedAnyDiffChunk = !discard && _diffChunks.some(chunk => chunk && chunk.resolved && chunk.accepted);
|
||||
@@ -14208,7 +14263,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
syncHighlighting();
|
||||
updateLineNumbers(textarea ? textarea.value : '');
|
||||
saveDocument({ silent: true });
|
||||
if (persist) saveDocument({ silent: true });
|
||||
if (acceptedAnyDiffChunk) {
|
||||
const lang = ((docs.get(activeDocId)?.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
|
||||
if (lang === 'markdown') {
|
||||
@@ -14227,14 +14282,41 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const _origHandleDocSuggestions = handleDocSuggestions;
|
||||
// (total is set inside handleDocSuggestions before _showCurrentSuggestion)
|
||||
|
||||
/** Apply a single suggestion edit without removing from queue */
|
||||
function _applySuggestion(sugg) {
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (textarea && sugg.find && textarea.value.includes(sugg.find)) {
|
||||
textarea.value = textarea.value.replace(sugg.find, sugg.replace);
|
||||
syncHighlighting();
|
||||
saveDocument({ silent: true });
|
||||
/** Apply exact suggestion matches to the active editor surface, then save once. */
|
||||
function _applySuggestions(suggestions) {
|
||||
if (!activeDocId || !docs.has(activeDocId)) return [];
|
||||
// The textarea is only a hidden mirror for rich-text and email documents.
|
||||
// Capture the visible editor first, then replace in the document source.
|
||||
saveCurrentToMap();
|
||||
const doc = docs.get(activeDocId);
|
||||
let content = doc.content || '';
|
||||
const applied = [];
|
||||
for (const sugg of suggestions) {
|
||||
if (!sugg.find || content.split(sugg.find).length !== 2) continue;
|
||||
content = content.replace(sugg.find, sugg.replace || '');
|
||||
applied.push(sugg.id);
|
||||
}
|
||||
if (!applied.length) {
|
||||
uiModule?.showError?.('Suggestion no longer matches the document. Refresh suggestions to review it.');
|
||||
return [];
|
||||
}
|
||||
doc.content = content;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (_isRichTextLang(doc.language)) {
|
||||
_showRichTextEditor(doc);
|
||||
} else if (doc.language === 'email') {
|
||||
_showEmailFields(doc, { applyLocalDraft: false, forceHeaderFields: true });
|
||||
} else if (textarea) {
|
||||
textarea.value = content;
|
||||
syncHighlighting();
|
||||
_refreshMarkdownPreviewIfVisible(doc.id, content);
|
||||
if (_htmlPreviewActive && _isRenderLang(doc.language)) {
|
||||
const iframe = document.getElementById('doc-html-preview');
|
||||
if (iframe) iframe.srcdoc = _themedRenderSrcdoc(content, doc.language);
|
||||
}
|
||||
}
|
||||
saveDocument({ silent: true });
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Animate transition to next suggestion */
|
||||
@@ -16620,6 +16702,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
_syncDocIndicator();
|
||||
|
||||
if (!isOpen) openPanel();
|
||||
// A previous SVG preview must not hide the next document's live code.
|
||||
exitHtmlPreview();
|
||||
|
||||
// Force doc button visible
|
||||
const toggleBtn = document.getElementById('overflow-doc-btn');
|
||||
@@ -16664,71 +16748,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
/** Simulate streaming effect for doc edits */
|
||||
let _editAnimFrame = null;
|
||||
let _richEditDiffTimer = null;
|
||||
|
||||
/** Show AI changes over the visible rich editor without exposing stored HTML. */
|
||||
function _animateRichTextEdit(oldContent, newContent, updatedDoc) {
|
||||
_showRichTextEditor(updatedDoc);
|
||||
const rich = _emailRichbodyActive();
|
||||
if (!rich) return;
|
||||
|
||||
const oldText = _richTextContentToPlain(oldContent);
|
||||
const newText = _richTextContentToPlain(newContent);
|
||||
const diff = lineDiff(oldText, newText);
|
||||
if (!diff || !diff.some(line => line.type !== 'same')) return;
|
||||
|
||||
clearTimeout(_richEditDiffTimer);
|
||||
document.querySelectorAll('.doc-rich-diff-overlay').forEach(node => node.remove());
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'doc-diff-overlay doc-rich-diff-overlay';
|
||||
overlay.setAttribute('aria-label', 'Document changes');
|
||||
const deleted = diff.filter(line => line.type === 'del').length;
|
||||
const added = diff.filter(line => line.type === 'add').length;
|
||||
const stats = document.createElement('div');
|
||||
stats.className = 'doc-diff-stats';
|
||||
stats.innerHTML = `<span class="diff-stat-del">−${deleted}</span><span class="diff-stat-add">+${added}</span>`;
|
||||
overlay.appendChild(stats);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'doc-diff-content';
|
||||
let skipped = 0;
|
||||
diff.forEach((line, index) => {
|
||||
const nearChange = line.type !== 'same'
|
||||
|| diff.slice(Math.max(0, index - 2), index + 3).some(item => item.type !== 'same');
|
||||
if (!nearChange) { skipped++; return; }
|
||||
if (skipped) {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'doc-diff-sep';
|
||||
separator.textContent = `⋯ ${skipped} unchanged`;
|
||||
content.appendChild(separator);
|
||||
skipped = 0;
|
||||
}
|
||||
const row = document.createElement('div');
|
||||
row.className = `doc-diff-line ${line.type}`;
|
||||
row.textContent = line.type === 'del'
|
||||
? `− ${line.text || '\u00a0'}`
|
||||
: line.type === 'add'
|
||||
? `+ ${line.text || '\u00a0'}`
|
||||
: (line.text || '\u00a0');
|
||||
content.appendChild(row);
|
||||
});
|
||||
overlay.appendChild(content);
|
||||
|
||||
const pane = rich.closest('.doc-editor-pane') || rich.parentElement;
|
||||
if (!pane) return;
|
||||
overlay.style.top = `${rich.offsetTop}px`;
|
||||
overlay.style.right = `${Math.max(0, pane.clientWidth - rich.offsetLeft - rich.offsetWidth)}px`;
|
||||
overlay.style.bottom = `${Math.max(0, pane.clientHeight - rich.offsetTop - rich.offsetHeight)}px`;
|
||||
overlay.style.left = `${rich.offsetLeft}px`;
|
||||
pane.appendChild(overlay);
|
||||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
||||
_richEditDiffTimer = setTimeout(() => {
|
||||
overlay.classList.remove('visible');
|
||||
overlay.classList.add('fading');
|
||||
setTimeout(() => overlay.remove(), 400);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function _animateDocEdit(textarea, newContent) {
|
||||
if (_editAnimFrame) cancelAnimationFrame(_editAnimFrame);
|
||||
@@ -16846,6 +16865,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
* Returns the old _streamDocId so handleDocUpdate can migrate temp→real. */
|
||||
export function streamDocFinalize() {
|
||||
const oldId = _streamDocId;
|
||||
if (!oldId) return null;
|
||||
const finishingDoc = oldId ? docs.get(oldId) : null;
|
||||
if (oldId === activeDocId && (finishingDoc?.language || '').toLowerCase() === 'email') {
|
||||
const fields = _parseEmailHeader(finishingDoc.content || '');
|
||||
@@ -16914,7 +16934,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// the previously-active doc here, so exitDiffMode(true) restores and saves
|
||||
// THAT doc before we reassign activeDocId below — mirroring switchToDoc()
|
||||
// and enterDiffMode().
|
||||
if (_diffModeActive) exitDiffMode(true);
|
||||
if (_diffModeActive) exitDiffMode(true, { persist: data.doc_id !== activeDocId });
|
||||
let docId = data.doc_id;
|
||||
let newContent = data.content || '';
|
||||
|
||||
@@ -17075,27 +17095,20 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const isRichTextUpdate = _isRichTextLang(docLang);
|
||||
const markdownPreviewWasVisible = _isMarkdownPreviewVisible();
|
||||
|
||||
// Animate content update for edits; apply directly for creates/streaming
|
||||
// The server has already saved doc_update. Show its current content now;
|
||||
// a temporary diff or typing animation makes a completed edit look pending.
|
||||
const isEdit = !isEmailUpdate && !isRichTextUpdate && isExistingDoc && oldContent && oldContent !== newContent && !streamingId;
|
||||
const updatedDocForRichText = isRichTextUpdate ? docs.get(docId) : null;
|
||||
if (isRichTextUpdate && updatedDocForRichText) {
|
||||
_animateRichTextEdit(oldContent, newContent, updatedDocForRichText);
|
||||
_showRichTextEditor(updatedDocForRichText);
|
||||
} else if (isEdit && textarea) {
|
||||
// Count changed lines to decide between animation and diff mode
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
let changedLines = 0;
|
||||
const maxLen = Math.max(oldLines.length, newLines.length);
|
||||
for (let li = 0; li < maxLen; li++) {
|
||||
if (oldLines[li] !== newLines[li]) changedLines++;
|
||||
}
|
||||
if (changedLines >= DIFF_MODE_THRESHOLD) {
|
||||
if (markdownPreviewWasVisible) _setMarkdownPreviewActive(false, { remember: false });
|
||||
enterDiffMode(oldContent, newContent);
|
||||
} else if (markdownPreviewWasVisible && _refreshMarkdownPreviewIfVisible(docId, newContent)) {
|
||||
// Preview is the visible surface, so refresh it instead of animating a hidden editor.
|
||||
// This event reports an already-saved edit. Do not turn it into a
|
||||
// pending accept/reject diff that can later restore the old content.
|
||||
if (markdownPreviewWasVisible && _refreshMarkdownPreviewIfVisible(docId, newContent)) {
|
||||
// Keep the visible preview synchronized with the saved document.
|
||||
} else {
|
||||
_animateDocEdit(textarea, newContent);
|
||||
textarea.value = newContent;
|
||||
syncHighlighting();
|
||||
}
|
||||
} else {
|
||||
if (isEmailUpdate) {
|
||||
@@ -17115,7 +17128,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
// Flash the editor wrap to indicate content was updated
|
||||
const wrap = document.getElementById('doc-editor-wrap');
|
||||
if (wrap && !isEdit) {
|
||||
if (wrap) {
|
||||
wrap.classList.remove('doc-updated-flash');
|
||||
void wrap.offsetWidth; // force reflow
|
||||
wrap.classList.add('doc-updated-flash');
|
||||
@@ -17142,7 +17155,18 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
if (mdToolbar) mdToolbar.style.display = '';
|
||||
// Auto-show table view for CSV after streaming
|
||||
const finalLangLower = (finalLang || '').toLowerCase();
|
||||
if (finalLangLower === 'csv') {
|
||||
if (finalLangLower === 'svg') {
|
||||
requestAnimationFrame(() => {
|
||||
if (activeDocId !== docId || !isOpen) return;
|
||||
// Idempotent: doc_update and the tool-output fallback may both arrive.
|
||||
// SVG uses the existing sandboxed preview, never the server code runner.
|
||||
if (!_htmlPreviewActive) toggleHtmlPreview();
|
||||
else {
|
||||
const iframe = document.getElementById('doc-html-preview');
|
||||
if (iframe) iframe.srcdoc = _themedRenderSrcdoc(docs.get(docId)?.content || '', 'svg');
|
||||
}
|
||||
});
|
||||
} else if (finalLangLower === 'csv') {
|
||||
requestAnimationFrame(() => {
|
||||
const csvPreview = document.getElementById('doc-csv-preview');
|
||||
if (csvPreview && csvPreview.style.display === 'none') toggleCsvPreview();
|
||||
@@ -17536,6 +17560,7 @@ const documentModule = {
|
||||
replaceEmailReplyBody,
|
||||
ensureEmailDraftEnvelope,
|
||||
openEmailDraft,
|
||||
generateEmailReply,
|
||||
ensurePaneMounted: _ensureDocPaneMounted,
|
||||
loadSessionDocs,
|
||||
ensureDocPanel,
|
||||
|
||||
@@ -41,27 +41,52 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
buildMergedMaskCanvas, dilateMask, applyInpaintFeather,
|
||||
getSelectedAIEndpoint, ensureActiveMaskLayer,
|
||||
saveState, createLayer, composite, flatten, renderLayerPanel,
|
||||
saveState, createLayer, composite, flatten, renderLayerPanel, renderLayer,
|
||||
spinnerModule, uiModule,
|
||||
}) {
|
||||
// Shared inpaint runner — used by Generate, Remove, and Outpaint.
|
||||
async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) {
|
||||
// Pre-check: build the union mask the AI will receive and verify
|
||||
// at least one pixel is painted.
|
||||
const preMerged = buildMergedMaskCanvas();
|
||||
if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
const pmCtx = preMerged.getContext('2d');
|
||||
const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data;
|
||||
let hasMask = false;
|
||||
for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } }
|
||||
if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; }
|
||||
let preMerged = buildMergedMaskCanvas();
|
||||
const hasPixels = canvas => canvas && canvas.getContext('2d')
|
||||
.getImageData(0, 0, canvas.width, canvas.height).data.some((value, index) => index % 4 === 3 && value > 0);
|
||||
let layerSource = null;
|
||||
if (!hasPixels(preMerged)) {
|
||||
const layer = state.layers.find(item => item.id === state.activeLayerId);
|
||||
if (!layer || layer.kind === 'adjustment') {
|
||||
uiModule?.showToast('Select an image layer to inpaint');
|
||||
return;
|
||||
}
|
||||
const rendered = renderLayer?.(layer) || layer.canvas;
|
||||
const offset = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
layerSource = document.createElement('canvas');
|
||||
layerSource.width = state.imgWidth;
|
||||
layerSource.height = state.imgHeight;
|
||||
layerSource.getContext('2d').drawImage(rendered, offset.x, offset.y);
|
||||
preMerged = document.createElement('canvas');
|
||||
preMerged.width = state.imgWidth;
|
||||
preMerged.height = state.imgHeight;
|
||||
const ctx = preMerged.getContext('2d');
|
||||
ctx.drawImage(layerSource, 0, 0);
|
||||
// Preserve transparent cutouts; empty layers use their full bounds.
|
||||
if (hasPixels(preMerged)) ctx.globalCompositeOperation = 'source-in';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(offset.x, offset.y, rendered.width, rendered.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
if (!hasPixels(preMerged)) {
|
||||
uiModule?.showToast('The selected layer is outside the canvas');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const btn = document.getElementById(btnId);
|
||||
const btnLabel = labelId ? document.getElementById(labelId) : null;
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Inpaint cancelled'));
|
||||
if (btnLabel) btnLabel.textContent = busyLabel;
|
||||
let runWp = null;
|
||||
try {
|
||||
@@ -110,7 +135,7 @@ export function wireInpaintButtons({
|
||||
} catch (_) { /* overlay is decorative */ }
|
||||
try {
|
||||
// Flatten current image.
|
||||
const flatCanvas = flatten();
|
||||
const flatCanvas = layerSource || flatten();
|
||||
// Dilate the user's brush mask before sending to the model.
|
||||
// The AI fills a small buffer zone around the brush, so the
|
||||
// post-gen Edge feather slider has AI content to fade INTO
|
||||
@@ -123,7 +148,7 @@ export function wireInpaintButtons({
|
||||
// This way, if the user built up the inpaint region across
|
||||
// multiple masks, the final generation sees the combined
|
||||
// region instead of just the currently-active mask.
|
||||
const mergedMask = buildMergedMaskCanvas() || state.maskCanvas;
|
||||
const mergedMask = preMerged;
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
@@ -132,6 +157,7 @@ export function wireInpaintButtons({
|
||||
baseSnap.height = state.imgHeight;
|
||||
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify((() => {
|
||||
@@ -152,8 +178,9 @@ export function wireInpaintButtons({
|
||||
// unfeathered (AI image + hard mask) on the layer so the live
|
||||
// Feather slider can re-derive the alpha on each input event
|
||||
// without re-running the model.
|
||||
const resultImg = new Image();
|
||||
resultImg.onload = () => {
|
||||
const resultImg = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
@@ -172,9 +199,9 @@ export function wireInpaintButtons({
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
const maskSnap = document.createElement('canvas');
|
||||
maskSnap.width = state.maskCanvas.width;
|
||||
maskSnap.height = state.maskCanvas.height;
|
||||
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
maskSnap.width = mergedMask.width;
|
||||
maskSnap.height = mergedMask.height;
|
||||
maskSnap.getContext('2d').drawImage(mergedMask, 0, 0);
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
@@ -227,15 +254,11 @@ export function wireInpaintButtons({
|
||||
console.error('[inpaint] render error', renderErr);
|
||||
if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000);
|
||||
}
|
||||
};
|
||||
resultImg.onerror = (e) => {
|
||||
console.error('[inpaint] base64 decode failed', e);
|
||||
if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000);
|
||||
};
|
||||
resultImg.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
|
||||
if (!operation.signal.aborted && uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000);
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
if (btnLabel) btnLabel.textContent = idleLabel;
|
||||
if (runWp) { try { runWp.destroy(); } catch (_) {} }
|
||||
@@ -263,7 +286,9 @@ export function wireInpaintButtons({
|
||||
// SDXL inpaint pipelines literally try to draw the prompt, so we
|
||||
// send a generic surroundings-matching prompt and crank strength.
|
||||
document.getElementById('ge-inpaint-remove').addEventListener('click', async () => {
|
||||
const sel = getSelectedAIEndpoint('inpaint');
|
||||
let sel;
|
||||
try { sel = getSelectedAIEndpoint('inpaint'); }
|
||||
catch (error) { uiModule?.showToast(error.message); return; }
|
||||
const ep = (sel.endpoint || '').toLowerCase();
|
||||
const isOpenAI = ep.includes('api.openai.com');
|
||||
let prompt, strength;
|
||||
|
||||
@@ -27,11 +27,18 @@
|
||||
import { state } from './state.js';
|
||||
import { sortModelIds } from '../modelSort.js';
|
||||
|
||||
export function resolveInpaintModel(select) {
|
||||
const options = Array.from(select?.options || []).filter(option =>
|
||||
!option.disabled && option.value && option.value !== '__serve_cookbook__');
|
||||
const explicit = options.find(option => option.value === select.value);
|
||||
return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null;
|
||||
}
|
||||
|
||||
// Heuristic classifier on a model id + endpoint name. A model can be:
|
||||
// - gen: text-to-image generation
|
||||
// - inpaint: image+mask edit (inpaint / img2img)
|
||||
// Some models do only one (e.g. dall-e-3 = gen-only, no edits API).
|
||||
function modelCaps(modelId, endpointName, endpointType) {
|
||||
export function modelCaps(modelId, endpointName, endpointType) {
|
||||
const id = (modelId || '').toLowerCase();
|
||||
const name = (endpointName || '').toLowerCase();
|
||||
const type = (endpointType || '').toLowerCase();
|
||||
@@ -41,7 +48,9 @@ function modelCaps(modelId, endpointName, endpointType) {
|
||||
// OpenAI image family.
|
||||
if (/dall-e-3/.test(id)) return { gen: true, inpaint: false };
|
||||
if (/dall-e-2/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/gpt-image/.test(id)) return { gen: true, inpaint: true };
|
||||
if (/(?:^|\/)(?:gpt-image[^/]*|gpt-[^/]*-image[^/]*|gemini-[^/]*image[^/]*|qwen-image[^/]*)$/.test(id)) {
|
||||
return { gen: true, inpaint: true };
|
||||
}
|
||||
// Diffusion families — most generic SD/SDXL/Flux base models
|
||||
// support both via diffusers.
|
||||
if (/(?:^|[/\-_])(?:sd-?xl|sdxl|sd3|sd-|stable[\s-]*diffusion|flux|playground|pixart|kandinsky)/i.test(id)) {
|
||||
@@ -95,13 +104,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
const prevGenValue = aiGenSelect?.value || '';
|
||||
const prevInpaintValue = aiInpaintSelect?.value || '';
|
||||
const res = await fetch(`${apiBase}/api/model-endpoints`);
|
||||
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
|
||||
const endpoints = await res.json();
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
let firstInpaint = null;
|
||||
let selectedGen = null;
|
||||
let selectedInpaint = null;
|
||||
for (const ep of endpoints) {
|
||||
@@ -137,10 +146,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
opt.disabled = !epUsable;
|
||||
aiInpaintSelect.appendChild(opt);
|
||||
if (epUsable && selectBaseUrl && ep.base_url === selectBaseUrl && !selectedInpaint) selectedInpaint = value;
|
||||
// Prefer dedicated inpaint/edit models for default selection.
|
||||
if (epUsable && !firstInpaint && (!modelId || /inpaint|edit|fill|gpt-image/i.test(modelId) || /inpaint|edit|fill/i.test(ep.name || ''))) {
|
||||
firstInpaint = value;
|
||||
}
|
||||
}
|
||||
// Per-tool selectors get every img2img-capable entry. Both
|
||||
// caps.inpaint AND caps.gen models work for harmonize /
|
||||
@@ -165,7 +170,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
if (aiInpaintSelect) {
|
||||
if (selectedInpaint) aiInpaintSelect.value = selectedInpaint;
|
||||
else if (hasValue(aiInpaintSelect, prevInpaintValue)) aiInpaintSelect.value = prevInpaintValue;
|
||||
else if (firstInpaint) aiInpaintSelect.value = firstInpaint;
|
||||
const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' });
|
||||
aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)';
|
||||
}
|
||||
// Append the "Serve a model in Cookbook…" sentinel at the
|
||||
// bottom of every model dropdown.
|
||||
@@ -221,7 +227,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
|
||||
// Fetch failed — still give the user the affordance to set up
|
||||
// a model. Otherwise the dropdown shows only "Auto" with no
|
||||
// hint about what to do next.
|
||||
const fallback = '<option value="">Auto</option><option value="" disabled>──────────</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
const fallback = '<option value="">Auto</option><option disabled>Could not load image models</option><option value="__serve_cookbook__">+ Serve a model in Cookbook…</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = fallback;
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
|
||||
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Capture repeat clicks before tool handlers can start another request.
|
||||
export function beginAIOperation(button, onCancel) {
|
||||
const controller = new AbortController();
|
||||
const title = button.title;
|
||||
button.disabled = false;
|
||||
button.title = 'Cancel running action';
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
const cancel = event => {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort();
|
||||
onCancel?.();
|
||||
}
|
||||
};
|
||||
button.addEventListener('click', cancel, true);
|
||||
return {
|
||||
signal: controller.signal,
|
||||
finish() {
|
||||
button.removeEventListener('click', cancel, true);
|
||||
button.title = title;
|
||||
button.removeAttribute('aria-busy');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeAIImage(base64, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const cleanup = () => {
|
||||
image.onload = image.onerror = null;
|
||||
signal.removeEventListener('abort', abort);
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(new DOMException('Cancelled', 'AbortError'));
|
||||
};
|
||||
if (signal.aborted) { abort(); return; }
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.onload = () => { cleanup(); resolve(image); };
|
||||
image.onerror = () => { cleanup(); reject(new Error('Failed to decode result image')); };
|
||||
image.src = base64.startsWith('data:') ? base64 : 'data:image/png;base64,' + base64;
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,7 @@
|
||||
* @returns {(endpoint: string, extraPayload: object, layerName: string, btn: HTMLButtonElement, opts?: { busyLabel?: string }) => Promise<void>}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
const KNOWN_DEPS = ['realesrgan', 'rembg'];
|
||||
|
||||
@@ -45,7 +46,7 @@ export function createApplyImageTool({
|
||||
return async function applyImageTool(endpoint, extraPayload, layerName, btn, opts) {
|
||||
const origHTML = btn.innerHTML;
|
||||
const origWidth = btn.offsetWidth; // lock width so the button doesn't jump
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Cancelled'));
|
||||
btn.classList.add('ge-btn-processing');
|
||||
btn.style.minWidth = origWidth + 'px';
|
||||
// Swap label for a "<verbing>…" text + whirlpool while the
|
||||
@@ -67,18 +68,19 @@ export function createApplyImageTool({
|
||||
// Tool-specific model picker — pulled from the per-tool select
|
||||
// (harmonize/style) if available, otherwise the global
|
||||
// fallback. Derived from the endpoint URL.
|
||||
if (!extraPayload._endpoint) {
|
||||
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
|
||||
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
|
||||
const sel = getSelectedAIEndpoint(type);
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
try {
|
||||
if (!extraPayload._endpoint) {
|
||||
const m = /\/api\/image\/([\w-]+)/.exec(endpoint || '');
|
||||
const type = m ? m[1].replace('upscale-ai', 'upscale').replace('remove-bg', 'rembg') : null;
|
||||
const sel = getSelectedAIEndpoint(type);
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
const flatCanvas = flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -91,12 +93,8 @@ export function createApplyImageTool({
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
if (!data.image) throw new Error('No image returned');
|
||||
const img = new Image();
|
||||
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;
|
||||
});
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
{
|
||||
saveState();
|
||||
@@ -109,6 +107,7 @@ export function createApplyImageTool({
|
||||
if (uiModule) uiModule.showToast(layerName + ' complete', 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
if (operation.signal.aborted) return;
|
||||
// Detect known failure modes and surface an action-toast.
|
||||
const msg = (e?.message || '').toLowerCase();
|
||||
const needsImg2Img = (
|
||||
@@ -140,6 +139,7 @@ export function createApplyImageTool({
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('ge-btn-processing');
|
||||
try { btnSpinner?.destroy(); } catch {}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
* @returns {{ addEmptyLayer: () => void }}
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireAIToolsMisc({
|
||||
apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool,
|
||||
@@ -104,7 +105,7 @@ export function wireAIToolsMisc({
|
||||
document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('ge-upscale-ai');
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Upscale cancelled'));
|
||||
let upWp = null;
|
||||
try {
|
||||
upWp = spinnerModule.createWhirlpool(14);
|
||||
@@ -119,6 +120,7 @@ export function wireAIToolsMisc({
|
||||
const flat = flatten();
|
||||
const imageB64 = flat.toDataURL('image/png').split(',')[1];
|
||||
const res = await fetch('/api/image/upscale-local', {
|
||||
signal: operation.signal,
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image: imageB64, scale: 2 }),
|
||||
@@ -126,8 +128,9 @@ export function wireAIToolsMisc({
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const newW = img.width, newH = img.height;
|
||||
@@ -144,17 +147,18 @@ export function wireAIToolsMisc({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast(`AI upscaled to ${newW}×${newH}`);
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('AI upscale failed: ' + e.message);
|
||||
if (!operation.signal.aborted) uiModule.showToast('AI upscale failed: ' + e.message);
|
||||
} finally {
|
||||
operation.finish();
|
||||
try { upWp?.destroy(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHTML;
|
||||
}
|
||||
try { upWp?.destroy(); } catch (_) {}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHTML;
|
||||
});
|
||||
|
||||
// ── Style transfer ──
|
||||
@@ -166,7 +170,9 @@ export function wireAIToolsMisc({
|
||||
const prompt = document.getElementById('ge-style-prompt').value.trim();
|
||||
if (!prompt) { uiModule.showToast('Enter a style prompt'); return; }
|
||||
const strength = parseInt(document.getElementById('ge-style-strength').value) / 100;
|
||||
btn.disabled = true; btn.textContent = 'Applying...';
|
||||
const originalHTML = btn.innerHTML;
|
||||
const operation = beginAIOperation(btn, () => uiModule?.showToast('Style transfer cancelled'));
|
||||
btn.textContent = 'Applying...';
|
||||
try {
|
||||
const flat = flatten();
|
||||
const blob = await new Promise(r => flat.toBlob(r, 'image/png'));
|
||||
@@ -174,12 +180,13 @@ export function wireAIToolsMisc({
|
||||
fd.append('image', blob, 'style.png');
|
||||
fd.append('prompt', prompt);
|
||||
fd.append('strength', String(strength));
|
||||
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd, signal: operation.signal });
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
const data = await res.json();
|
||||
if (data.image) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
{
|
||||
if (!state.editorOpen) return;
|
||||
saveState();
|
||||
const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight);
|
||||
@@ -189,15 +196,17 @@ export function wireAIToolsMisc({
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule.showToast('Style applied');
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + data.image;
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'No image returned');
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showToast('Style transfer failed: ' + e.message);
|
||||
if (!operation.signal.aborted) uiModule.showToast('Style transfer failed: ' + e.message);
|
||||
} finally {
|
||||
operation.finish();
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Apply Style';
|
||||
});
|
||||
|
||||
// ── Add empty layer (used by the layer-panel header button + the
|
||||
|
||||
Vendored
+15
-3
@@ -12,6 +12,13 @@
|
||||
export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000);
|
||||
return `
|
||||
<div id="ge-pen-section" style="display:none;">
|
||||
<div class="ge-section-title">Pen Selection</div>
|
||||
<div class="ge-control-row ge-actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-commit" title="Close path and make selection (Enter)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> Make selection</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm" id="ge-pen-cancel" title="Cancel path (Escape)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m18 6-12 12M6 6l12 12"/></svg> Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-geometry-section" id="ge-layer-geometry-section">
|
||||
<div class="ge-section-title"><span>Position</span><span id="ge-layer-geometry-name"></span></div>
|
||||
<div class="ge-layer-geometry-grid">
|
||||
@@ -238,7 +245,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<input type="range" id="ge-lasso-grow" min="-40" max="40" value="0" title="Expand (+) or contract (−) the selection before baking." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-lasso-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
@@ -289,7 +296,7 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+I)">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-wand-invert" title="Invert selection (Ctrl+Alt+Shift+I)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
Invert
|
||||
</button>
|
||||
@@ -612,6 +619,10 @@ export function layerPanelHTML() {
|
||||
return `<div class="ge-layers-header">
|
||||
<span class="ge-layers-grab"></span>
|
||||
<span class="ge-layers-title">Layers</span>
|
||||
<div class="ge-layer-header-tools" role="group" aria-label="Layer actions">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-icon-btn" id="ge-add-layer" title="New layer" aria-label="New layer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-icon-btn" id="ge-merge-down" title="Merge down" aria-label="Merge down">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6 13 12 19 18 13"/></svg>
|
||||
</button>
|
||||
@@ -627,7 +638,8 @@ export function layerPanelHTML() {
|
||||
<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>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-layer-fill" title="Fill selected layer or selection with foreground color (Alt+Backspace)">Fill</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-layer-blend-row">
|
||||
<label for="ge-layer-blend">Blend</label>
|
||||
|
||||
@@ -38,6 +38,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
{ id: 'burn', label: 'Burn', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 22c4 0 7-3 7-7 0-5-4-8-7-13-3 5-7 8-7 13 0 4 3 7 7 7Z"/><path d="M9 16c1.5 1 4.5 1 6 0"/></svg>' },
|
||||
{ id: 'marquee', label: 'Marquee', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 3"/></svg>' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡' },
|
||||
{ id: 'pen', label: 'Pen selection', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19 7-7 3 3-7 7-3-3zM18 13l-1.5-7.5L2 2l3.5 14.5L13 18M2 2l7.5 7.5"/><circle cx="10" cy="10" r="2"/></svg>' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
@@ -60,6 +61,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
btn.className = 'ge-tool-btn' + (t.id === currentTool ? ' active' : '');
|
||||
btn.dataset.tool = t.id;
|
||||
btn.title = t.label + (t.key ? ` (${t.key})` : '');
|
||||
if (t.id === 'pen') btn.title += ' - Click anchors; drag for curves; close path or press Enter';
|
||||
// Heavy 4-point AI star marker for AI-backed tools — sits just to
|
||||
// the left of the icon so the user can spot AI vs local tools at a
|
||||
// glance now that the "AI Tools" separator is gone.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Build the editor's top bar (undo/redo/history, zoom group, Image
|
||||
* Build the editor's top bar (undo/redo/history, Image
|
||||
* menu, Filter menu, Selection-edge menu, Shortcuts, Import, Save).
|
||||
*
|
||||
* Pure DOM — no module state, no event listeners. All wiring is done
|
||||
@@ -30,28 +30,8 @@ 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="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">−</button>
|
||||
<span class="ge-zoom-stack">
|
||||
<span class="ge-zoom-glyph">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</span>
|
||||
<span class="ge-zoom-label">100%</span>
|
||||
</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in">+</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-fit" title="Fit to view" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg></span>
|
||||
<span class="ge-stacked-label">FIT</span>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Actual size" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph">1:1</span>
|
||||
<span class="ge-stacked-label">SCALE</span>
|
||||
</button>
|
||||
<span class="ge-topbar-sep"></span>
|
||||
</div>
|
||||
<div class="ge-topbar-right">
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-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>
|
||||
@@ -73,7 +53,7 @@ export function buildTopbar() {
|
||||
<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="fill"><span>Fill selection / mask</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="fill" title="Fill with foreground color (Alt+Backspace)"><span>Fill</span></button>
|
||||
<button class="dropdown-item-compact" data-image-action="canvas-size">
|
||||
<span class="dropdown-icon">⤢</span>
|
||||
<span>Canvas Size...</span>
|
||||
@@ -110,7 +90,7 @@ export function buildTopbar() {
|
||||
<button class="dropdown-item-compact" data-selection-action="all"><span>Select All</span><span class="dropdown-shortcut">Ctrl+A</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="deselect"><span>Deselect</span><span class="dropdown-shortcut">Ctrl+D</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="reselect"><span>Reselect</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="invert"><span>Invert</span><span class="dropdown-shortcut">Ctrl+Alt+Shift+I</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="transform"><span>Transform Selection</span></button>
|
||||
<button class="dropdown-item-compact" data-selection-action="refine"><span>Refine Selection…</span></button>
|
||||
<div class="dropdown-section-divider"></div>
|
||||
@@ -215,3 +195,22 @@ export function buildTopbar() {
|
||||
`;
|
||||
return topBar;
|
||||
}
|
||||
|
||||
export function buildZoomFooter() {
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'ge-editor-footer';
|
||||
footer.innerHTML = `
|
||||
<span class="ge-canvas-size" id="ge-canvas-size" title="Canvas size" hidden></span>
|
||||
<div class="ge-footer-zoom" role="group" aria-label="Canvas zoom">
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
|
||||
<span class="ge-zoom-stack" aria-live="off">
|
||||
<span class="ge-zoom-glyph"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<span class="ge-zoom-label">100%</span>
|
||||
</span>
|
||||
<button class="ge-btn ge-btn-sm" id="ge-zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
|
||||
<button class="ge-btn ge-btn-sm ge-stacked-btn" id="ge-zoom-100" title="Switch to actual size" aria-label="Fit view; switch to actual size" aria-pressed="false">
|
||||
<span class="ge-stacked-glyph">1:1</span><span class="ge-stacked-label">FIT</span>
|
||||
</button>
|
||||
</div>`;
|
||||
return footer;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ export function wireClipboardAndDrop({
|
||||
const { signal } = clipboardBindings;
|
||||
// ── Paste ──
|
||||
window.addEventListener('paste', (e) => {
|
||||
if (!state.editorOpen || state.container !== container || e.defaultPrevented) return;
|
||||
if (!state.editorOpen || state.container !== container) return;
|
||||
// Editable fields keep native paste. The Clipboard button focuses the
|
||||
// canvas container before asking for a keyboard paste when read() is
|
||||
// unavailable on an insecure origin.
|
||||
if (e.target?.isContentEditable || e.target?.closest?.('input, textarea, select, [role="dialog"]')) return;
|
||||
|
||||
function pasteAsLayer(imgSource, label, offset = { x: 0, y: 0 }) {
|
||||
@@ -65,30 +68,26 @@ export function wireClipboardAndDrop({
|
||||
uiModule.showToast('Pasted as new layer');
|
||||
}
|
||||
|
||||
// Check internal clipboard first (from Ctrl+C lasso/wand).
|
||||
if (state.internalClipboard) {
|
||||
const imageItem = Array.from(e.clipboardData?.items || []).find(item => item.type.startsWith('image/'));
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to system clipboard.
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
const blob = item.getAsFile();
|
||||
const blob = imageItem.getAsFile();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
// External clipboard images follow the same source-backed import path
|
||||
// as files, gallery images, and drops. Internal selection clipboard
|
||||
// content is handled above as an identity placed layer.
|
||||
img.onload = () => { handleImportedImage(img, 'Pasted image'); URL.revokeObjectURL(url); };
|
||||
img.onerror = () => { URL.revokeObjectURL(url); uiModule?.showToast('Failed to load clipboard image'); };
|
||||
img.src = url;
|
||||
break;
|
||||
return;
|
||||
}
|
||||
if (state.internalClipboard && !e.defaultPrevented && !e.clipboardData?.types?.includes?.('text/plain')) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
pasteAsLayer(state.internalClipboard, 'Pasted Selection', state.internalClipboardOffset || { x: 0, y: 0 });
|
||||
}
|
||||
}, { capture: true, signal });
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Worker implementation for retained-effect rasterization. */
|
||||
import { LAYER_STYLES, renderLayerStyle, renderDropShadow } from './layer-styles.js';
|
||||
|
||||
function copyCanvas(source) {
|
||||
const out = new OffscreenCanvas(source.width, source.height);
|
||||
@@ -56,7 +57,9 @@ function renderEffects(source, effects, masks) {
|
||||
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') {
|
||||
if (LAYER_STYLES[effect.type]) {
|
||||
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
|
||||
} else if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
@@ -69,12 +72,7 @@ function renderEffects(source, effects, masks) {
|
||||
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);
|
||||
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
@@ -98,6 +96,7 @@ function renderEffects(source, effects, masks) {
|
||||
const contribution = maskedContribution(next, masks[index]);
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
Vendored
+18
-8
@@ -1,6 +1,16 @@
|
||||
/** Retained, non-destructive layer effects. */
|
||||
import { LAYER_STYLES, styleParams, renderLayerStyle, renderDropShadow } from './layer-styles.js';
|
||||
|
||||
export function effectsWithPreview(owner) {
|
||||
const effects = owner.effects || [];
|
||||
const preview = owner._effectPreview;
|
||||
if (!preview) return effects;
|
||||
const index = preview.id ? effects.findIndex(effect => effect.id === preview.id) : -1;
|
||||
return index < 0 ? [...effects, preview] : effects.map((effect, i) => i === index ? preview : effect);
|
||||
}
|
||||
|
||||
const EFFECT_TYPES = new Set(['gaussian-blur', 'sharpen', 'color-overlay', 'drop-shadow', 'stroke', 'linear-gradient', 'radial-gradient']);
|
||||
for (const type of Object.keys(LAYER_STYLES)) EFFECT_TYPES.add(type);
|
||||
|
||||
export const EFFECT_PRESETS = {
|
||||
'soft-blur': { type: 'gaussian-blur', params: { radius: 4 } },
|
||||
@@ -28,6 +38,7 @@ function normalizeEffectMask(mask) {
|
||||
}
|
||||
|
||||
export function defaultEffectParams(type) {
|
||||
if (LAYER_STYLES[type]) return styleParams(type);
|
||||
if (type === 'gaussian-blur') return { radius: 6 };
|
||||
if (type === 'sharpen') return { amount: 0.5 };
|
||||
if (type === 'color-overlay') return { color: '#ffffff', opacity: 0.2, blendMode: 'source-atop' };
|
||||
@@ -52,6 +63,7 @@ export function normalizeEffect(effect) {
|
||||
const params = effect?.params && typeof effect.params === 'object'
|
||||
? { ...defaults, ...clone(effect.params) }
|
||||
: defaults;
|
||||
if (LAYER_STYLES[type]) Object.assign(params, styleParams(type, params));
|
||||
if (type === 'gaussian-blur') params.radius = Math.max(0, Math.min(200, Number(params.radius) || 0));
|
||||
if (type === 'sharpen') params.amount = Math.max(0, Math.min(1, Number(params.amount) || 0));
|
||||
if (type === 'color-overlay') {
|
||||
@@ -98,6 +110,7 @@ export function normalizeEffect(effect) {
|
||||
}
|
||||
|
||||
export function effectLabel(type) {
|
||||
if (LAYER_STYLES[type]) return LAYER_STYLES[type].label;
|
||||
return {
|
||||
'gaussian-blur': 'Gaussian Blur',
|
||||
sharpen: 'Sharpen',
|
||||
@@ -189,7 +202,9 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
next.width = current.width;
|
||||
next.height = current.height;
|
||||
const ctx = next.getContext('2d');
|
||||
if (effect.type === 'gaussian-blur') {
|
||||
if (LAYER_STYLES[effect.type]) {
|
||||
ctx.drawImage(renderLayerStyle(current, effect.type, effect.params), 0, 0);
|
||||
} else if (effect.type === 'gaussian-blur') {
|
||||
ctx.filter = `blur(${effect.params.radius}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
ctx.filter = 'none';
|
||||
@@ -205,13 +220,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
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;
|
||||
ctx.drawImage(renderDropShadow(current, effect.params), 0, 0);
|
||||
} else if (effect.type === 'stroke') {
|
||||
ctx.globalAlpha = effect.params.opacity;
|
||||
ctx.shadowColor = effect.params.color;
|
||||
@@ -232,6 +241,7 @@ export function renderEffects(source, effects = [], shouldContinue = () => true)
|
||||
: next;
|
||||
const blended = copyCanvas(current);
|
||||
const blendCtx = blended.getContext('2d');
|
||||
if (effect.type === 'drop-shadow') blendCtx.globalCompositeOperation = 'destination-over';
|
||||
blendCtx.globalAlpha = effect.opacity;
|
||||
blendCtx.drawImage(contribution, 0, 0);
|
||||
blendCtx.globalAlpha = 1;
|
||||
|
||||
@@ -7,6 +7,53 @@ function imageDataBytes(imageData) {
|
||||
return Number(imageData?.data?.byteLength || imageData?.data?.length || 0);
|
||||
}
|
||||
|
||||
function pixelSlots(snapshot) {
|
||||
const slots = [];
|
||||
const add = (object, key = 'imageData') => { if (object?.[key]) slots.push([object, key]); };
|
||||
add(snapshot?.wand); add(snapshot?.lastSelection);
|
||||
for (const item of snapshot?.savedSelections || []) add(item);
|
||||
for (const group of snapshot?.layerGroups || []) {
|
||||
for (const mask of group.masks || []) add(mask);
|
||||
for (const effect of group.effects || []) add(effect.mask);
|
||||
}
|
||||
for (const layer of snapshot?.layers || []) {
|
||||
add(layer); add(layer.placed, 'sourceImageData');
|
||||
for (const mask of layer.masks || []) add(mask);
|
||||
for (const effect of layer.effects || []) add(effect.mask);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function shareSnapshotPixels(snapshot, previous) {
|
||||
if (!previous) return snapshot;
|
||||
const candidates = pixelSlots(previous).map(([object, key]) => object[key]);
|
||||
for (const [object, key] of pixelSlots(snapshot)) {
|
||||
const image = object[key];
|
||||
const match = candidates.find(other => {
|
||||
if (image === other) return true;
|
||||
if (image.width !== other.width || image.height !== other.height || image.data.length !== other.data.length) return false;
|
||||
for (let i = 0; i < image.data.length; i++) if (image.data[i] !== other.data[i]) return false;
|
||||
return true;
|
||||
});
|
||||
if (match) object[key] = match;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function historyByteSize(stack) {
|
||||
const buffers = new Set();
|
||||
let bytes = 0;
|
||||
for (const snapshot of stack) {
|
||||
const slots = pixelSlots(snapshot);
|
||||
if (!slots.length) { bytes += snapshotByteSize(snapshot); continue; }
|
||||
for (const [object, key] of slots) {
|
||||
const image = object[key], buffer = image.data.buffer || image.data;
|
||||
if (!buffers.has(buffer)) { buffers.add(buffer); bytes += buffer.byteLength || imageDataBytes(image); }
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function snapshotByteSize(snapshot) {
|
||||
if (!snapshot) return 0;
|
||||
if (Number.isFinite(snapshot._bytes)) return snapshot._bytes;
|
||||
@@ -30,10 +77,11 @@ export function trimHistoryStack(
|
||||
maxBytes = MAX_HISTORY_BYTES,
|
||||
) {
|
||||
while (stack.length > maxEntries) stack.shift();
|
||||
let bytes = stack.reduce((total, snapshot) => total + snapshotByteSize(snapshot), 0);
|
||||
let bytes = historyByteSize(stack);
|
||||
// Keep the newest state even when one snapshot alone exceeds the budget.
|
||||
while (stack.length > 1 && bytes > maxBytes) {
|
||||
bytes -= snapshotByteSize(stack.shift());
|
||||
stack.shift();
|
||||
bytes = historyByteSize(stack);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Ctrl+S save (Shift = save as / export to gallery)
|
||||
* Ctrl+Shift+T open resize popup
|
||||
* Ctrl+Alt+T start free transform
|
||||
* Ctrl+Alt+I invert wand / lasso selection
|
||||
* Ctrl+Alt+Shift+I invert selection (Ctrl+Alt+I also works)
|
||||
* Ctrl+Alt+J new empty layer
|
||||
* Ctrl/Cmd+J copy selected pixels to a layer, or duplicate the layer
|
||||
* Ctrl+Alt+G create/release clipping mask
|
||||
@@ -74,7 +74,7 @@ export function wireKeyboardShortcuts(deps) {
|
||||
setTemporaryPan,
|
||||
nudgeActiveLayer, endLayerNudge,
|
||||
toggleQuickMask, nudgeSelection,
|
||||
deselectSelection,
|
||||
deselectSelection, fillSelection,
|
||||
} = deps;
|
||||
|
||||
const isTypingTarget = (target) => target && (
|
||||
@@ -94,6 +94,12 @@ export function wireKeyboardShortcuts(deps) {
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
// Fields and text layers own native editing, including undo and clipboard.
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.repeat) fillSelection?.();
|
||||
return;
|
||||
}
|
||||
if (e.code === 'Space' && !isTypingTarget(e.target)) {
|
||||
e.preventDefault();
|
||||
setTemporaryPan?.(true);
|
||||
@@ -170,7 +176,7 @@ export function wireKeyboardShortcuts(deps) {
|
||||
}
|
||||
if (e.shiftKey && e.key === 'T') { e.preventDefault(); e.stopPropagation(); resizeCustomPrompt(); return; }
|
||||
if (e.altKey && e.code === 'KeyT') { e.preventDefault(); e.stopPropagation(); startTransform(); return; }
|
||||
// Ctrl+Alt+I — invert current selection. Uses e.code so
|
||||
// Ctrl+Alt+Shift+I (or the previous Ctrl+Alt+I) inverts the selection. Uses e.code so
|
||||
// Alt-modified key values (e.g. `ˆ` on Mac with Option+I)
|
||||
// don't break the match.
|
||||
if (e.altKey && e.code === 'KeyI') {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const {
|
||||
composite, saveState, showLayerThumb, hideLayerThumb,
|
||||
loadLayerAlphaAsSelection, loadMaskAsSelection, getDocumentSelection,
|
||||
openFxPopup, editAdjLayer, editRetainedEffect, addEffectMask, rasterizeEffects,
|
||||
openFxPopup, openLayerStyles, editAdjLayer, editRetainedEffect, addEffectMask, rasterizeEffects,
|
||||
createLayer, renderLayer, replacePlacedLayer, rasterizePlacedLayer,
|
||||
lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
syncFxPanelToActiveLayerIfPresent, onSelectLayer,
|
||||
@@ -227,15 +227,39 @@ export function createLayerPanelRenderer(deps) {
|
||||
const summary = document.createElement('summary');
|
||||
summary.title = `${label} properties`;
|
||||
summary.setAttribute('aria-label', summary.title);
|
||||
summary.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h16"></path><circle cx="9" cy="6" r="2"></circle><circle cx="15" cy="12" r="2"></circle><circle cx="11" cy="18" r="2"></circle></svg>';
|
||||
summary.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"><path d="M3 8h4m4 0h10M3 16h10m4 0h4M7 5v6m10 2v6"/></svg>';
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'ge-mask-properties-panel';
|
||||
details.addEventListener('toggle', () => {
|
||||
if (!details.open) return;
|
||||
panel.setAttribute('popover', 'manual');
|
||||
let positionFrame = 0;
|
||||
const positionPanel = () => {
|
||||
if (!details.open || !details.isConnected) {
|
||||
if (panel.matches(':popover-open')) panel.hidePopover();
|
||||
return;
|
||||
}
|
||||
const viewport = window.visualViewport;
|
||||
const left = viewport?.offsetLeft || 0;
|
||||
const top = viewport?.offsetTop || 0;
|
||||
const width = viewport?.width || window.innerWidth;
|
||||
const height = viewport?.height || window.innerHeight;
|
||||
panel.style.maxWidth = `${Math.max(0, width - 12)}px`;
|
||||
panel.style.maxHeight = `${Math.max(0, height - 12)}px`;
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const width = 190;
|
||||
panel.style.left = `${Math.max(6, Math.min(window.innerWidth - width - 6, rect.right - width))}px`;
|
||||
panel.style.top = `${Math.min(window.innerHeight - 150, rect.bottom + 4)}px`;
|
||||
const bounds = panel.getBoundingClientRect();
|
||||
const y = rect.bottom + 4 + bounds.height <= top + height - 6
|
||||
? rect.bottom + 4 : rect.top - bounds.height - 4;
|
||||
panel.style.left = `${Math.max(left + 6, Math.min(left + width - bounds.width - 6, rect.right - bounds.width))}px`;
|
||||
panel.style.top = `${Math.max(top + 6, Math.min(top + height - bounds.height - 6, y))}px`;
|
||||
positionFrame = requestAnimationFrame(positionPanel);
|
||||
};
|
||||
details.addEventListener('toggle', () => {
|
||||
cancelAnimationFrame(positionFrame);
|
||||
if (!details.open) {
|
||||
if (panel.matches(':popover-open')) panel.hidePopover();
|
||||
return;
|
||||
}
|
||||
panel.showPopover();
|
||||
positionPanel();
|
||||
});
|
||||
const makeRange = (className, title, min, max, step, value, update, historyLabel) => {
|
||||
const row = document.createElement('label');
|
||||
@@ -1034,12 +1058,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const list = document.getElementById('ge-layers-list');
|
||||
if (!list) return;
|
||||
const sharedTools = document.getElementById('ge-layer-tools');
|
||||
if (sharedTools) {
|
||||
// Controls are rebuilt with each row, so clear the previous active
|
||||
// row's cluster before the list is rendered again.
|
||||
sharedTools.innerHTML = '';
|
||||
sharedTools.hidden = true;
|
||||
}
|
||||
if (sharedTools) sharedTools.hidden = true;
|
||||
const blendSelect = document.getElementById('ge-layer-blend');
|
||||
normalizeLayerSelection(state);
|
||||
wireSelectionActions();
|
||||
@@ -1148,6 +1167,10 @@ export function createLayerPanelRenderer(deps) {
|
||||
loadLayerAlphaAsSelection(layer);
|
||||
return;
|
||||
}
|
||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey &&
|
||||
state.activeLayerId === layer.id && !state.activeGroupId &&
|
||||
!layer.activeMaskId && state.selectedLayerIds.length === 1 &&
|
||||
state.selectedLayerIds[0] === layer.id) return;
|
||||
if (e.shiftKey) selectLayerRange(state, layer.id);
|
||||
else if (e.ctrlKey || e.metaKey) toggleLayerSelection(state, layer.id);
|
||||
else selectOnlyLayer(state, layer.id);
|
||||
@@ -1157,6 +1180,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
state.maskCanvas = null;
|
||||
state.maskCtx = null;
|
||||
syncSelectionUi();
|
||||
document.querySelectorAll('.ge-layer-group-row.active').forEach(row => row.classList.remove('active'));
|
||||
onSelectLayer?.(active);
|
||||
composite();
|
||||
// Preserve the name element across the first click of a double-click
|
||||
@@ -1195,6 +1219,21 @@ export function createLayerPanelRenderer(deps) {
|
||||
// from the raster canvas. Use the normal renderer when available so
|
||||
// their layer previews match what the document actually displays.
|
||||
const thumb = createInlineThumbnail(() => renderLayer?.(layer) || layer.canvas, `${layer.name} preview`);
|
||||
thumb.title = `${layer.name} preview (Ctrl-click to select pixels)`;
|
||||
thumb.addEventListener('click', (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || layer.kind === 'adjustment') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectOnlyLayer(state, layer.id);
|
||||
layer.activeMaskId = null;
|
||||
state.maskInspectMode = false;
|
||||
state.maskCanvas = null;
|
||||
state.maskCtx = null;
|
||||
syncSelectionUi();
|
||||
onSelectLayer?.(layer);
|
||||
loadLayerAlphaAsSelection(layer);
|
||||
render();
|
||||
});
|
||||
item.appendChild(thumb);
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
@@ -1203,7 +1242,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const marker = document.createElement('span');
|
||||
marker.className = 'ge-layer-placed-marker';
|
||||
marker.title = 'Placed image';
|
||||
marker.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>';
|
||||
marker.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="position:relative;top:2px"><rect x="3" y="3" width="18" height="18" rx="1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>';
|
||||
nameEl.appendChild(marker);
|
||||
}
|
||||
if (layer.kind === 'text' || layer.kind === 'shape') {
|
||||
@@ -1230,7 +1269,6 @@ export function createLayerPanelRenderer(deps) {
|
||||
nameEl.appendChild(document.createTextNode(layer.name + (layer.kind !== 'adjustment' && isLayerEmpty(layer) ? ' (empty)' : '')));
|
||||
// Keep the name node stable across the first click of a double-click
|
||||
// so inline renaming can receive the browser's dblclick event.
|
||||
nameEl.addEventListener('click', event => event.stopPropagation());
|
||||
nameEl.addEventListener('dblclick', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1373,6 +1411,18 @@ export function createLayerPanelRenderer(deps) {
|
||||
openLayerFx(e);
|
||||
});
|
||||
controls.appendChild(fxBtn);
|
||||
const stylesButton = document.createElement('button');
|
||||
stylesButton.type = 'button';
|
||||
stylesButton.className = 'ge-layer-btn ge-layer-style-btn';
|
||||
stylesButton.textContent = 'fx';
|
||||
stylesButton.title = 'Layer styles';
|
||||
stylesButton.setAttribute('aria-label', 'Layer styles');
|
||||
stylesButton.setAttribute('aria-haspopup', 'menu');
|
||||
stylesButton.setAttribute('aria-expanded', 'false');
|
||||
stylesButton.addEventListener('click', event => {
|
||||
event.stopPropagation(); openLayerStyles?.(layer, stylesButton);
|
||||
});
|
||||
if (layer.kind !== 'adjustment') controls.appendChild(stylesButton);
|
||||
|
||||
// Duplicate — clones pixels + offset + opacity + masks + adjLayers
|
||||
// + visibility; inserts above the original; new copy becomes
|
||||
@@ -1618,8 +1668,9 @@ export function createLayerPanelRenderer(deps) {
|
||||
if (state.layers.length > 1) {
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'ge-layer-btn danger';
|
||||
delBtn.textContent = '×';
|
||||
delBtn.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"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
||||
delBtn.title = layer.isBase ? 'Delete original layer (Ctrl+Z to undo)' : 'Delete layer';
|
||||
delBtn.setAttribute('aria-label', delBtn.title);
|
||||
delBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await deleteLayers(rowTargets(layer));
|
||||
@@ -2045,22 +2096,6 @@ export function createLayerPanelRenderer(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep one action strip for the active layer/group. The buttons retain
|
||||
// their row-specific closures, but the UI no longer repeats them on
|
||||
// every layer row.
|
||||
if (sharedTools) {
|
||||
const activeRow = selectedGroup
|
||||
? [...list.children].find(row => row.dataset.groupId === selectedGroup.id)
|
||||
: selectedLayer
|
||||
? [...list.children].find(row => row.dataset.layerId === selectedLayer.id)
|
||||
: null;
|
||||
const controls = activeRow?.querySelector(':scope > .ge-layer-controls');
|
||||
if (controls) {
|
||||
sharedTools.appendChild(controls);
|
||||
sharedTools.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the shared dragSort module — limit drag-init to the grip
|
||||
// handle so row body clicks still activate. Called every render
|
||||
// because `enable()` cleans up the previous instance keyed on
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LAYER_STYLES } from './layer-styles.js';
|
||||
|
||||
const items = [
|
||||
['bevel-emboss', 'Bevel & Emboss'], ['stroke', 'Stroke'],
|
||||
['inner-shadow', 'Inner Shadow'], ['inner-glow', 'Inner Glow'], ['satin', 'Satin'],
|
||||
['color-overlay', 'Color Overlay'], ['gradient-overlay', 'Gradient Overlay'],
|
||||
['pattern-overlay', 'Pattern Overlay'], ['outer-glow', 'Outer Glow'], ['drop-shadow', 'Drop Shadow'],
|
||||
];
|
||||
let menu = null, activeAnchor = null;
|
||||
export function openLayerStyleMenu(anchor, onSelect) {
|
||||
const wasOpen = activeAnchor === anchor;
|
||||
menu?.remove(); menu = null;
|
||||
activeAnchor?.setAttribute('aria-expanded', 'false'); activeAnchor = null;
|
||||
if (wasOpen) return;
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'dropdown session-dropdown-menu ge-layer-style-menu';
|
||||
popup.setAttribute('popover', 'auto'); popup.setAttribute('role', 'menu');
|
||||
popup.setAttribute('aria-label', 'Layer styles');
|
||||
for (const [type, label] of items) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'dropdown-item-compact';
|
||||
button.setAttribute('role', 'menuitem'); button.textContent = LAYER_STYLES[type]?.label || label;
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation(); popup.hidePopover(); onSelect(type);
|
||||
});
|
||||
popup.append(button);
|
||||
}
|
||||
popup.addEventListener('toggle', () => {
|
||||
if (!popup.matches(':popover-open')) {
|
||||
if (menu === popup) { menu = null; activeAnchor = null; }
|
||||
anchor.setAttribute('aria-expanded', 'false'); popup.remove();
|
||||
}
|
||||
});
|
||||
document.body.append(popup); menu = popup; activeAnchor = anchor;
|
||||
popup.showPopover(); anchor.setAttribute('aria-expanded', 'true');
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
popup.style.maxHeight = `${Math.max(40, Math.max(rect.top - 10, innerHeight - rect.bottom - 10))}px`;
|
||||
const bounds = popup.getBoundingClientRect();
|
||||
popup.style.left = `${Math.max(6, Math.min(innerWidth - bounds.width - 6, rect.right - bounds.width))}px`;
|
||||
const top = rect.bottom + bounds.height + 10 <= innerHeight ? rect.bottom + 4 : rect.top - bounds.height - 4;
|
||||
popup.style.top = `${Math.max(6, Math.min(innerHeight - bounds.height - 6, top))}px`;
|
||||
popup.querySelector('button')?.focus();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Layer-style definitions and canvas rendering shared with the effects worker. */
|
||||
const color = (key, label, value) => ({ key, label, type: 'color', value });
|
||||
const number = (key, label, value, min, max, suffix = 'px') => ({ key, label, value, min, max, step: 1, suffix });
|
||||
const opacity = () => number('opacity', 'Opacity', 65, 0, 100, '%');
|
||||
const size = () => number('size', 'Size', 8, 0, 100);
|
||||
const angle = () => number('angle', 'Angle', 120, -180, 180, 'deg');
|
||||
export const LAYER_STYLES = {
|
||||
'bevel-emboss': { label: 'Bevel & Emboss', controls: [size(), angle(), number('depth', 'Depth', 100, 0, 300, '%'), color('highlight', 'Highlight', '#ffffff'), color('color', 'Shadow', '#000000'), opacity()] },
|
||||
'inner-shadow': { label: 'Inner Shadow', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 6, 0, 100)] },
|
||||
'inner-glow': { label: 'Inner Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||
satin: { label: 'Satin', controls: [color('color', 'Color', '#000000'), opacity(), size(), angle(), number('distance', 'Distance', 12, 0, 100)] },
|
||||
'gradient-overlay': { label: 'Gradient Overlay', controls: [color('color', 'Start', '#e06c75'), color('endColor', 'End', '#ffffff'), opacity(), angle(), number('scale', 'Scale', 100, 10, 300, '%')] },
|
||||
'pattern-overlay': { label: 'Pattern Overlay', controls: [color('color', 'Color', '#ffffff'), color('endColor', 'Background', '#222222'), opacity(), number('scale', 'Tile size', 16, 2, 128)] },
|
||||
'outer-glow': { label: 'Outer Glow', controls: [color('color', 'Color', '#ffffff'), opacity(), size()] },
|
||||
};
|
||||
|
||||
export function styleParams(type, values = {}) {
|
||||
return Object.fromEntries(LAYER_STYLES[type].controls.map(control => {
|
||||
const raw = values[control.key] ?? control.value;
|
||||
const value = control.type === 'color'
|
||||
? (/^#[0-9a-f]{6}$/i.test(raw) ? raw : control.value)
|
||||
: Math.max(control.min, Math.min(control.max, Number.isFinite(Number(raw)) ? Number(raw) : control.value));
|
||||
return [control.key, value];
|
||||
}));
|
||||
}
|
||||
|
||||
function surface(width, height) {
|
||||
if (typeof document === 'undefined') return new OffscreenCanvas(width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width; canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function renderDropShadow(source, params) {
|
||||
// Tint alpha first so RGB content cannot leak into the shadow. Draw only
|
||||
// this silhouette; compositing the source again thickens translucent edges.
|
||||
const silhouette = surface(source.width, source.height);
|
||||
const sc = silhouette.getContext('2d');
|
||||
sc.drawImage(source, 0, 0);
|
||||
sc.globalCompositeOperation = 'source-in';
|
||||
sc.fillStyle = params.color;
|
||||
sc.fillRect(0, 0, source.width, source.height);
|
||||
const shadow = surface(source.width, source.height), ctx = shadow.getContext('2d');
|
||||
ctx.globalAlpha = params.opacity;
|
||||
ctx.filter = `blur(${params.blur / 2}px)`;
|
||||
ctx.drawImage(silhouette, params.x, params.y);
|
||||
return shadow;
|
||||
}
|
||||
|
||||
// Produce only the effect's contribution; the caller composites it over source.
|
||||
export function renderLayerStyle(source, type, values) {
|
||||
const p = styleParams(type, values), w = source.width, h = source.height;
|
||||
const out = surface(w, h), ctx = out.getContext('2d');
|
||||
const rad = (p.angle || 0) * Math.PI / 180;
|
||||
const dx = Math.cos(rad) * (p.distance || 0), dy = -Math.sin(rad) * (p.distance || 0);
|
||||
const clip = () => {
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.drawImage(source, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
};
|
||||
if (type === 'gradient-overlay' || type === 'pattern-overlay') {
|
||||
if (type === 'gradient-overlay') {
|
||||
const length = (Math.abs(Math.cos(rad)) * w + Math.abs(Math.sin(rad)) * h) * p.scale / 200;
|
||||
const gradient = ctx.createLinearGradient(w / 2 - Math.cos(rad) * length, h / 2 + Math.sin(rad) * length, w / 2 + Math.cos(rad) * length, h / 2 - Math.sin(rad) * length);
|
||||
gradient.addColorStop(0, p.color); gradient.addColorStop(1, p.endColor);
|
||||
ctx.fillStyle = gradient;
|
||||
} else {
|
||||
const tile = surface(p.scale * 2, p.scale * 2), tc = tile.getContext('2d');
|
||||
tc.fillStyle = p.endColor; tc.fillRect(0, 0, tile.width, tile.height);
|
||||
tc.fillStyle = p.color; tc.fillRect(0, 0, p.scale, p.scale); tc.fillRect(p.scale, p.scale, p.scale, p.scale);
|
||||
ctx.fillStyle = ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
ctx.fillRect(0, 0, w, h); clip();
|
||||
} else if (type === 'outer-glow') {
|
||||
ctx.filter = `blur(${p.size}px)`;
|
||||
ctx.drawImage(source, 0, 0); ctx.filter = 'none';
|
||||
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'destination-out'; ctx.drawImage(source, 0, 0);
|
||||
} else if (type === 'inner-shadow' || type === 'inner-glow') {
|
||||
// Pad the inverse alpha so edges touching the canvas still cast inward.
|
||||
const pad = Math.ceil(p.size * 3 + (p.distance || 0) + 2);
|
||||
const inverse = surface(w + pad * 2, h + pad * 2), ic = inverse.getContext('2d');
|
||||
ic.fillStyle = p.color; ic.fillRect(0, 0, inverse.width, inverse.height);
|
||||
ic.globalCompositeOperation = 'destination-out'; ic.drawImage(source, pad, pad);
|
||||
ctx.filter = `blur(${p.size}px)`;
|
||||
ctx.drawImage(inverse, -pad + dx, -pad + dy); ctx.filter = 'none'; clip();
|
||||
} else if (type === 'satin') {
|
||||
const a = surface(w, h), ac = a.getContext('2d');
|
||||
ac.filter = `blur(${p.size}px)`; ac.drawImage(source, dx, dy);
|
||||
ac.globalCompositeOperation = 'xor'; ac.drawImage(source, -dx, -dy); ac.filter = 'none';
|
||||
ctx.drawImage(a, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-in'; ctx.fillStyle = p.color; ctx.fillRect(0, 0, w, h); clip();
|
||||
} else if (type === 'bevel-emboss') {
|
||||
const blurred = surface(w, h), bc = blurred.getContext('2d');
|
||||
bc.filter = `blur(${Math.max(0.5, p.size / 2)}px)`; bc.drawImage(source, 0, 0);
|
||||
const alpha = bc.getImageData(0, 0, w, h).data;
|
||||
const original = source.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
const pixels = ctx.createImageData(w, h), step = Math.max(1, Math.round(p.size / 2));
|
||||
const sample = (x, y) => x < 0 || x >= w || y < 0 || y >= h ? 0 : alpha[(y * w + x) * 4 + 3] / 255;
|
||||
const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
|
||||
const light = rgb(p.highlight), dark = rgb(p.color);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const at = (y * w + x) * 4;
|
||||
const slope = ((sample(x + step, y) - sample(x - step, y)) * Math.cos(rad)
|
||||
- (sample(x, y + step) - sample(x, y - step)) * Math.sin(rad)) * p.depth / 100;
|
||||
pixels.data.set(slope > 0 ? light : dark, at);
|
||||
pixels.data[at + 3] = Math.min(1, Math.abs(slope)) * original[at + 3];
|
||||
}
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
const faded = surface(w, h), fc = faded.getContext('2d');
|
||||
fc.globalAlpha = p.opacity / 100; fc.drawImage(out, 0, 0);
|
||||
return faded;
|
||||
}
|
||||
@@ -3,5 +3,5 @@ export const TOOL_SHORTCUTS = Object.freeze({
|
||||
move: 'V', hand: 'H', crop: 'C', text: 'T', shape: 'U',
|
||||
brush: 'B', gradient: 'G', eraser: 'E', eyedropper: 'I',
|
||||
smudge: 'Y', clone: 'S', heal: 'J', dodge: 'O',
|
||||
marquee: 'M', lasso: 'L', wand: 'W',
|
||||
marquee: 'M', lasso: 'L', wand: 'W', pen: 'P',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { state } from '../state.js';
|
||||
import { canvasCoords } from '../canvas-coords.js';
|
||||
import { selectionModeForEvent } from '../selection-modifiers.js';
|
||||
|
||||
export function createPenSelectionTool({ activeLayer, saveState, commitSelectionMask, composite, syncSelectionUi }) {
|
||||
let points = [], dragging = false, owner = null, canvas = null, mode = 'replace';
|
||||
const current = () => canvas === state.mainCanvas && state.tool === 'pen' && owner === activeLayer();
|
||||
function cancel() {
|
||||
const hadPath = points.length > 0;
|
||||
points = []; dragging = false; owner = canvas = null;
|
||||
if (hadPath) composite();
|
||||
return hadPath;
|
||||
}
|
||||
function trace(ctx, closed) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0].x, points[0].y);
|
||||
for (let i = 1; i < points.length + (closed ? 1 : 0); i++) {
|
||||
const a = points[i - 1], b = points[i % points.length];
|
||||
ctx.bezierCurveTo(a.x + a.dx, a.y + a.dy, b.x - b.dx, b.y - b.dy, b.x, b.y);
|
||||
}
|
||||
if (closed) ctx.closePath();
|
||||
}
|
||||
function commit() {
|
||||
if (!current() || points.length < 3) return false;
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = state.imgWidth; mask.height = state.imgHeight;
|
||||
const ctx = mask.getContext('2d');
|
||||
trace(ctx, true);
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
saveState('Pen selection');
|
||||
commitSelectionMask(mask, owner, mode, 'pen', { x: 0, y: 0 });
|
||||
state.wandMaskVisible = true;
|
||||
state.wandLastSeed = null;
|
||||
state.lassoPoints = []; state.lassoActive = false;
|
||||
cancel(); syncSelectionUi();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
cancel, commit,
|
||||
begin(event) {
|
||||
if (points.length && !current()) cancel();
|
||||
if (!activeLayer()) return;
|
||||
event.preventDefault();
|
||||
const p = canvasCoords(event, state.mainCanvas);
|
||||
if (points.length >= 3 && Math.hypot(p.x - points[0].x, p.y - points[0].y) * state.zoom < 9) {
|
||||
commit(); return;
|
||||
}
|
||||
if (!points.length) {
|
||||
owner = activeLayer(); canvas = state.mainCanvas;
|
||||
mode = selectionModeForEvent(event, state.wandMode || 'replace');
|
||||
}
|
||||
points.push({ ...p, dx: 0, dy: 0 }); dragging = true;
|
||||
composite();
|
||||
},
|
||||
drag(event) {
|
||||
if (!dragging || !current()) return;
|
||||
event.preventDefault();
|
||||
const p = canvasCoords(event, state.mainCanvas), anchor = points.at(-1);
|
||||
anchor.dx = p.x - anchor.x; anchor.dy = p.y - anchor.y;
|
||||
composite();
|
||||
},
|
||||
end() { dragging = false; },
|
||||
key(event) {
|
||||
if (!points.length || !current()) return false;
|
||||
if (event.key === 'Escape') cancel();
|
||||
else if (event.key === 'Enter') commit();
|
||||
else if (event.key === 'Backspace' || event.key === 'Delete' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z')) {
|
||||
points.pop(); dragging = false; composite();
|
||||
} else return false;
|
||||
event.preventDefault(); event.stopImmediatePropagation(); return true;
|
||||
},
|
||||
draw() {
|
||||
if (!points.length || !current()) return;
|
||||
const ctx = state.mainCtx, unit = 1 / state.zoom;
|
||||
ctx.save();
|
||||
trace(ctx, false);
|
||||
ctx.strokeStyle = '#000'; ctx.lineWidth = 3 * unit; ctx.stroke();
|
||||
ctx.strokeStyle = '#fff'; ctx.lineWidth = unit; ctx.stroke();
|
||||
for (const p of points) {
|
||||
if (p.dx || p.dy) {
|
||||
ctx.beginPath(); ctx.moveTo(p.x - p.dx, p.y - p.dy); ctx.lineTo(p.x + p.dx, p.y + p.dy); ctx.stroke();
|
||||
for (const sign of [-1, 1]) {
|
||||
ctx.beginPath(); ctx.arc(p.x + sign * p.dx, p.y + sign * p.dy, 2.5 * unit, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff'; ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
|
||||
ctx.strokeStyle = '#000'; ctx.strokeRect(p.x - 3 * unit, p.y - 3 * unit, 6 * unit, 6 * unit);
|
||||
ctx.strokeStyle = '#fff';
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -99,6 +99,14 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
document.getElementById('ge-import-file')?.addEventListener('click', () => importFileInput.click());
|
||||
|
||||
document.getElementById('ge-import-paste')?.addEventListener('click', async () => {
|
||||
const pasteFromKeyboard = () => {
|
||||
document.getElementById('gallery-editor-container')?.focus();
|
||||
uiModule?.showToast('Press Ctrl+V to paste the image');
|
||||
};
|
||||
if (!navigator.clipboard?.read) {
|
||||
pasteFromKeyboard();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipItems = await navigator.clipboard.read();
|
||||
let blob = null;
|
||||
@@ -113,7 +121,7 @@ export function wireImport({ container, saveState, createLayer, composite, rende
|
||||
img.onerror = () => { URL.revokeObjectURL(url); if (uiModule) uiModule.showToast('Failed to load clipboard image'); };
|
||||
img.src = url;
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showToast('Clipboard access denied or no image available');
|
||||
pasteFromKeyboard();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* #ge-compare-btn
|
||||
* #ge-save-menu-btn + #ge-save-menu (Save / Save as / Download /
|
||||
* Save project / Load project)
|
||||
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-fit / #ge-zoom-100
|
||||
* #ge-zoom-out / #ge-zoom-in / #ge-zoom-100 (Fit / 1:1 toggle)
|
||||
* #ge-export-gallery / #ge-download
|
||||
* #ge-save-project / #ge-load-project
|
||||
* #ge-edge-menu-btn + #ge-edge-menu (Width input + Feather / Delete
|
||||
@@ -122,8 +122,11 @@ export function wireTopbar(deps) {
|
||||
}
|
||||
|
||||
// Zoom buttons.
|
||||
document.getElementById('ge-zoom-fit')?.addEventListener('click', fitZoom);
|
||||
document.getElementById('ge-zoom-100')?.addEventListener('click', () => { state.zoom = 1; applyZoom(); });
|
||||
document.getElementById('ge-zoom-100')?.addEventListener('click', () => {
|
||||
if (state.zoomViewMode === 'scale' ||
|
||||
(Math.abs(state.zoom - 1) < 0.001 && state.zoomViewMode !== 'fit')) fitZoom();
|
||||
else { state.zoom = 1; state.zoomViewMode = 'scale'; applyZoom(); }
|
||||
});
|
||||
document.getElementById('ge-zoom-in')?.addEventListener('click', () => { state.zoom = Math.min(5, state.zoom * 1.25); applyZoom(); });
|
||||
document.getElementById('ge-zoom-out')?.addEventListener('click', () => { state.zoom = Math.max(0.1, state.zoom / 1.25); applyZoom(); });
|
||||
|
||||
|
||||
@@ -847,6 +847,11 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
await _docModule.openEmailDraft(data);
|
||||
return;
|
||||
}
|
||||
if (wantsAiReply && _docModule?.generateEmailReply) {
|
||||
const opened = await _openEmail(em, itemEl, data, 'reply-all', '', '', mailboxContext);
|
||||
if (!opened) return false;
|
||||
return await _docModule.generateEmailReply({ mode: 'ai-reply-fast', noteHint, originalBody: data.body }) === true;
|
||||
}
|
||||
if (wantsAiReply) {
|
||||
const activeReplyAccount = data.account_id || em.account_id || accountAtStart;
|
||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||
@@ -1128,7 +1133,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
// connection (or when caching is interfering). loadDocument's
|
||||
// GET path can still be used as a fallback.
|
||||
if (_docModule.injectFreshDoc) {
|
||||
_docModule.injectFreshDoc(doc);
|
||||
await _docModule.injectFreshDoc(doc);
|
||||
if (!isCurrentOpen()) return;
|
||||
} else {
|
||||
await _docModule.loadDocument(doc.id);
|
||||
if (!isCurrentOpen()) return;
|
||||
|
||||
@@ -4118,6 +4118,20 @@ export function initEmailLibrary(config) {
|
||||
|
||||
export function isOpen() { return state._libOpen; }
|
||||
|
||||
export async function openEmailFromTool(target, isCurrent = () => true) {
|
||||
const response = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Could not load email accounts');
|
||||
const data = await response.json();
|
||||
const key = String(target.account || '').trim().toLowerCase();
|
||||
const matches = (data.accounts || []).filter(account =>
|
||||
[account.id, account.from_address, account.imap_user, account.name]
|
||||
.some(value => value && String(value).trim().toLowerCase() === key));
|
||||
if (matches.length !== 1) throw new Error('Could not uniquely identify the email account');
|
||||
if (!/^\d+$/.test(String(target.uid || ''))) throw new Error('Invalid email message ID');
|
||||
if (!isCurrent()) return;
|
||||
openEmailLibrary({ uid: String(target.uid), folder: target.folder || 'INBOX', account_id: matches[0].id });
|
||||
}
|
||||
|
||||
export function openEmailLibrary(opts = {}) {
|
||||
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
||||
// one optional request if it has already started. Generation checks make a
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export async function readEmailReplyResponse(response, onReply) {
|
||||
if (!response.ok) throw new Error(`AI reply service returned HTTP ${response.status}`);
|
||||
if (!response.headers.get('content-type')?.includes('text/event-stream')) return response.json();
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
let boundary;
|
||||
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
for (const line of frame.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === 'reply' && onReply(event.text) === false) {
|
||||
throw new Error('Draft was edited or closed; AI insertion stopped');
|
||||
}
|
||||
if (event.type === 'result') return event;
|
||||
}
|
||||
}
|
||||
if (done) throw new Error('AI reply connection ended before completion');
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+111
-37
@@ -40,6 +40,7 @@ import {
|
||||
} from './editor/layer-helpers.js';
|
||||
import {
|
||||
renderEffects as _renderEffects,
|
||||
effectsWithPreview as _effectsWithPreview,
|
||||
renderEffectsAsync as _renderEffectsAsync,
|
||||
normalizeEffect as _normalizeEffect,
|
||||
effectLabel as _effectLabel,
|
||||
@@ -72,7 +73,7 @@ import {
|
||||
normalizeAdjustmentData as _normalizeAdjustmentData,
|
||||
} from './editor/adjustment-layer.js';
|
||||
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js?v=20260830editor2';
|
||||
import { buildTopbar as _buildTopbar } from './editor/build/topbar.js';
|
||||
import { buildTopbar as _buildTopbar, buildZoomFooter as _buildZoomFooter } from './editor/build/topbar.js';
|
||||
import {
|
||||
controlsHTML as _controlsHTML,
|
||||
layerPanelHTML as _layerPanelHTML,
|
||||
@@ -98,6 +99,7 @@ import {
|
||||
import {
|
||||
snapshotByteSize as _snapshotByteSize,
|
||||
trimHistoryStack as _trimHistoryStack,
|
||||
shareSnapshotPixels as _shareSnapshotPixels,
|
||||
} from './editor/history-budget.js';
|
||||
import { cropDocument as _cropDocument } from './editor/document-geometry.js';
|
||||
import { encodeExportCanvas as _encodeExportCanvas, openExportDialog as _openExportDialog } from './editor/export-dialog.js';
|
||||
@@ -134,6 +136,9 @@ import {
|
||||
import { normalizeLayerClipping as _normalizeLayerClipping } from './editor/layer-clipping.js';
|
||||
import { createCropTool } from './editor/tools/crop.js';
|
||||
import { createLassoTool } from './editor/tools/lasso.js';
|
||||
import { createPenSelectionTool } from './editor/tools/pen-selection.js';
|
||||
import { LAYER_STYLES, styleParams } from './editor/layer-styles.js';
|
||||
import { openLayerStyleMenu } from './editor/layer-style-menu.js';
|
||||
import { createMarqueeTool } from './editor/tools/marquee.js';
|
||||
import { createWandTool } from './editor/tools/wand.js';
|
||||
import {
|
||||
@@ -170,7 +175,7 @@ import { wireSliderUx } from './editor/slider-ux.js';
|
||||
import { createShortcutsPopover } from './editor/shortcuts-popover.js';
|
||||
import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js';
|
||||
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||
import { wireAIModelSelectors } from './editor/ai-models.js';
|
||||
import { wireAIModelSelectors, resolveInpaintModel } from './editor/ai-models.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
import { wireRembgAndSharpen } from './editor/ai-rembg.js';
|
||||
@@ -257,6 +262,7 @@ if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
return;
|
||||
}
|
||||
if (e.target?.closest?.('#styled-confirm-overlay')) return;
|
||||
if (state.editorOpen && state.tool === 'pen' && !e.target?.closest?.('input, textarea, select, [contenteditable="true"]') && _penTool.key(e)) return;
|
||||
const isSamCancel = !!_samAbortController
|
||||
&& (e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'c'));
|
||||
if (isSamCancel) {
|
||||
@@ -426,7 +432,9 @@ function _registerDocClickAway(handler) {
|
||||
function _getSelectedAIEndpoint(type) {
|
||||
let raw = '';
|
||||
if (type === 'inpaint') {
|
||||
raw = document.getElementById('ge-ai-inpaint')?.value || '';
|
||||
const option = resolveInpaintModel(document.getElementById('ge-ai-inpaint'));
|
||||
if (!option) throw new Error('No available inpaint model. Select or connect an image-editing endpoint.');
|
||||
raw = option.value;
|
||||
} else if (type) {
|
||||
// Per-tool dropdowns (harmonize/upscale/style). Each lives in its
|
||||
// own section's panel and is marked with data-ge-tool-model="<name>".
|
||||
@@ -1198,10 +1206,7 @@ function _flipAllLayers(axis) { return _canvasTransforms.flipAll(axis); }
|
||||
|
||||
function _renderLayerOutput(layer, shouldContinue = () => true) {
|
||||
return _renderWithLayerMasks(
|
||||
_renderEffects(_renderLayerWithAdjLayers(layer), [
|
||||
...(layer.effects || []),
|
||||
...(layer._effectPreview ? [layer._effectPreview] : []),
|
||||
], shouldContinue),
|
||||
_renderEffects(_renderLayerWithAdjLayers(layer), _effectsWithPreview(layer), shouldContinue),
|
||||
layer,
|
||||
state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
|
||||
);
|
||||
@@ -1218,19 +1223,13 @@ function _drawDocumentLayers(ctx, shouldContinue = () => true) {
|
||||
state,
|
||||
renderLayer,
|
||||
(target, layer, base) => _drawAdjustmentLayer(target, state, layer, base, renderLayer),
|
||||
(base, group, renderGroupContinue) => _renderEffects(base, [
|
||||
...(group.effects || []),
|
||||
...(group._effectPreview ? [group._effectPreview] : []),
|
||||
], renderGroupContinue),
|
||||
(base, group, renderGroupContinue) => _renderEffects(base, _effectsWithPreview(group), renderGroupContinue),
|
||||
shouldContinue,
|
||||
);
|
||||
}
|
||||
|
||||
async function _renderLayerOutputAsync(layer, shouldContinue = () => true) {
|
||||
const rendered = await _renderEffectsAsync(_renderLayerWithAdjLayers(layer), [
|
||||
...(layer.effects || []),
|
||||
...(layer._effectPreview ? [layer._effectPreview] : []),
|
||||
], shouldContinue);
|
||||
const rendered = await _renderEffectsAsync(_renderLayerWithAdjLayers(layer), _effectsWithPreview(layer), shouldContinue);
|
||||
return _renderWithLayerMasks(rendered, layer, state.layerOffsets.get(layer.id) || { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
@@ -1248,10 +1247,7 @@ async function _drawDocumentLayersAsync(ctx, shouldContinue = () => true) {
|
||||
item => _renderLayerOutputAsync(item, shouldContinue),
|
||||
shouldContinue,
|
||||
),
|
||||
(base, group, renderGroupContinue) => _renderEffectsAsync(base, [
|
||||
...(group.effects || []),
|
||||
...(group._effectPreview ? [group._effectPreview] : []),
|
||||
], renderGroupContinue),
|
||||
(base, group, renderGroupContinue) => _renderEffectsAsync(base, _effectsWithPreview(group), renderGroupContinue),
|
||||
shouldContinue,
|
||||
);
|
||||
}
|
||||
@@ -1596,6 +1592,7 @@ function _finishComposite(render, documentCanvas) {
|
||||
if (state.transformActive) _drawTransformHandles();
|
||||
else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
|
||||
if (state.marqueeActive && state.marqueeRect) _drawMarqueeOverlay();
|
||||
_penTool.draw();
|
||||
if (state.cropRect && !state.cropping) _drawCropOverlay();
|
||||
if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
|
||||
_precisionGuides?.drawDocumentOverlay(state.mainCtx);
|
||||
@@ -1904,7 +1901,7 @@ function _saveState(label) {
|
||||
// isolated: a history-snapshot failure must degrade (lose one undo
|
||||
// step) rather than kill the user's action.
|
||||
try {
|
||||
const snap = _snapshotState();
|
||||
const snap = _shareSnapshotPixels(_snapshotState(), state.undoStack.at(-1));
|
||||
snap._label = label || 'Edit';
|
||||
snap._ts = Date.now();
|
||||
state.undoStack.push(snap);
|
||||
@@ -2788,6 +2785,7 @@ function undo() {
|
||||
if (state.undoStack.length === 0) return;
|
||||
const target = state.undoStack.pop();
|
||||
const cur = _snapshotState();
|
||||
_shareSnapshotPixels(cur, target);
|
||||
cur._label = target._label || 'Edit';
|
||||
cur._ts = Date.now();
|
||||
state.redoStack.push(cur);
|
||||
@@ -2801,6 +2799,7 @@ function redo() {
|
||||
if (state.redoStack.length === 0) return;
|
||||
const target = state.redoStack.pop();
|
||||
const cur = _snapshotState();
|
||||
_shareSnapshotPixels(cur, target);
|
||||
cur._label = target._label || 'Edit';
|
||||
cur._ts = Date.now();
|
||||
state.undoStack.push(cur);
|
||||
@@ -2898,6 +2897,7 @@ function _beginDraw(e) {
|
||||
if (state.tool === 'eyedropper') return _eyedropperTool.pick(e);
|
||||
if (state.tool === 'gradient') return _gradientTool.begin(e);
|
||||
if (state.tool === 'marquee') return _marqueeTool.begin(e);
|
||||
if (state.tool === 'pen') return _penTool.begin(e);
|
||||
// Inpaint can create its own layer + mask on the fly, so skip the
|
||||
// "no active layer → bail" gate for it specifically.
|
||||
const activeMask = _getActiveMaskLayer();
|
||||
@@ -2922,6 +2922,7 @@ function _beginDraw(e) {
|
||||
}
|
||||
|
||||
function _continueDraw(e) {
|
||||
if (state.tool === 'pen') return _penTool.drag(e);
|
||||
// _continueDraw is now bound to the window so drags can extend past
|
||||
// the canvas. The brush-cursor overlay should only follow the cursor
|
||||
// when it's actually over the canvas, otherwise hide it.
|
||||
@@ -2953,6 +2954,7 @@ function _continueDraw(e) {
|
||||
}
|
||||
|
||||
function _endDraw(e) {
|
||||
if (state.tool === 'pen') return _penTool.end();
|
||||
// Transform-tool drag end — handler in editor/tools/transform-drag.js.
|
||||
if (_transformDragTool.tryEnd(e)) return;
|
||||
if (_shapeDraft) return _endShape(e);
|
||||
@@ -3724,6 +3726,11 @@ const _beginLasso = _lassoTool.begin;
|
||||
const _continueLasso = _lassoTool.drag;
|
||||
const _endLasso = _lassoTool.end;
|
||||
const _cancelLasso = _lassoTool.cancel;
|
||||
const _penTool = createPenSelectionTool({
|
||||
activeLayer, saveState: _saveState, composite,
|
||||
commitSelectionMask: _commitSelectionMask,
|
||||
syncSelectionUi: _syncToolClearIndicators,
|
||||
});
|
||||
|
||||
function _drawMarqueeOverlay() {
|
||||
const rect = state.marqueeRect;
|
||||
@@ -4326,13 +4333,14 @@ function _hideLayerThumb() {
|
||||
if (state.layerThumbEl) state.layerThumbEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Shift+click on a layer row → use that layer's opaque pixels as a
|
||||
// Ctrl-click on a layer thumbnail uses that layer's opaque pixels as a
|
||||
// wand-style selection. Lifts pixel alpha > 0 into the wand mask so the
|
||||
// user can immediately Bg-Remove / Erase / Copy through the layer.
|
||||
function _loadLayerAlphaAsSelection(layer) {
|
||||
if (!layer || !layer.canvas) return;
|
||||
const w = layer.canvas.width, h = layer.canvas.height;
|
||||
const src = layer.ctx.getImageData(0, 0, w, h).data;
|
||||
const rendered = _renderLayerOutput(layer) || layer.canvas;
|
||||
const w = rendered.width, h = rendered.height;
|
||||
const src = rendered.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = w; mask.height = h;
|
||||
const mctx = mask.getContext('2d');
|
||||
@@ -4348,8 +4356,7 @@ function _loadLayerAlphaAsSelection(layer) {
|
||||
mctx.putImageData(mdata, 0, 0);
|
||||
_saveState();
|
||||
_commitSelectionMask(mask, layer, 'replace', 'wand');
|
||||
state.wandLastSeed = null;
|
||||
composite();
|
||||
_activateSelectionCanvas(state.wandMask, 'wand');
|
||||
if (uiModule) uiModule.showToast('Layer pixels selected');
|
||||
}
|
||||
|
||||
@@ -5038,6 +5045,7 @@ async function _addRetainedEffect(type, presetName = null) {
|
||||
uiModule?.showToast?.('Select an image layer or group first');
|
||||
return;
|
||||
}
|
||||
if (LAYER_STYLES[type]) return _configureLayerStyle(layer, type);
|
||||
const initial = type === 'color-overlay'
|
||||
? [
|
||||
{ key: 'color', label: 'Color', type: 'color', value: '#ffffff' },
|
||||
@@ -5106,6 +5114,28 @@ async function _addRetainedEffect(type, presetName = null) {
|
||||
uiModule?.showToast?.(`${type === 'color-overlay' ? 'Color Overlay' : 'Drop Shadow'} added`);
|
||||
}
|
||||
|
||||
async function _configureLayerStyle(layer, type, existing = null) {
|
||||
if (_isLayerEffectivelyLocked(state, layer)) { uiModule?.showToast('Unlock the layer first'); return; }
|
||||
const session = state.editorSessionToken;
|
||||
const initial = styleParams(type, existing?.params);
|
||||
const controls = LAYER_STYLES[type].controls.map(control => ({ ...control, value: initial[control.key] }));
|
||||
const result = await _filterSliderPrompt(LAYER_STYLES[type].label, controls, values => {
|
||||
layer._effectPreview = { ...existing, type, visible: true, opacity: existing?.opacity ?? 1, params: styleParams(type, values) };
|
||||
composite();
|
||||
});
|
||||
layer._effectPreview = null;
|
||||
composite();
|
||||
if (!result || session !== state.editorSessionToken || !state.editorOpen ||
|
||||
!(state.layers.includes(layer) || state.layerGroups?.includes(layer))) return;
|
||||
_saveState(`${existing ? 'Edit' : 'Add'} ${LAYER_STYLES[type].label}`);
|
||||
if (existing) existing.params = styleParams(type, result);
|
||||
else {
|
||||
layer.effects ||= [];
|
||||
layer.effects.push(_normalizeEffect({ type, params: styleParams(type, result) }));
|
||||
}
|
||||
_schedulePersist(); _renderLayerPanel(); composite();
|
||||
}
|
||||
|
||||
async function _editRetainedGradient(layer, effect) {
|
||||
const params = effect.params || {};
|
||||
const stops = Array.isArray(params.stops) && params.stops.length >= 2
|
||||
@@ -5159,6 +5189,7 @@ async function _editRetainedGradient(layer, effect) {
|
||||
async function _editRetainedEffect(layer, effect) {
|
||||
if (!layer || !effect) return;
|
||||
const type = effect.type;
|
||||
if (LAYER_STYLES[type]) return _configureLayerStyle(layer, type, effect);
|
||||
if (type === 'linear-gradient' || type === 'radial-gradient') return _editRetainedGradient(layer, effect);
|
||||
const p = effect.params || {};
|
||||
const initial = type === 'gaussian-blur'
|
||||
@@ -5316,6 +5347,7 @@ function _fitZoom() {
|
||||
const fit = _getFitZoom();
|
||||
if (!fit) return;
|
||||
state.zoom = fit;
|
||||
state.zoomViewMode = 'fit';
|
||||
_applyZoom({ resetPan: true });
|
||||
}
|
||||
|
||||
@@ -5410,26 +5442,40 @@ function _nudgeSelectionBoundary(dx, dy) {
|
||||
}
|
||||
|
||||
function _syncZoomControls() {
|
||||
const fitBtn = document.getElementById('ge-zoom-fit');
|
||||
const actualBtn = document.getElementById('ge-zoom-100');
|
||||
const fit = _getFitZoom();
|
||||
const isFit = fit !== null && Math.abs(state.zoom - fit) < 0.001;
|
||||
const isActual = !isFit && Math.abs(state.zoom - 1) < 0.001;
|
||||
if (fitBtn) {
|
||||
fitBtn.classList.toggle('active', isFit);
|
||||
fitBtn.setAttribute('aria-pressed', isFit ? 'true' : 'false');
|
||||
}
|
||||
const isFit = fit !== null && Math.abs(state.zoom - fit) < 0.001 &&
|
||||
(fit < 0.999 || state.zoomViewMode !== 'scale');
|
||||
if (actualBtn) {
|
||||
actualBtn.classList.toggle('active', isActual);
|
||||
const isActual = !isFit && Math.abs(state.zoom - 1) < 0.001;
|
||||
actualBtn.classList.toggle('active', isFit || isActual);
|
||||
actualBtn.setAttribute('aria-pressed', isActual ? 'true' : 'false');
|
||||
actualBtn.title = isActual ? 'Switch to fit view' : 'Switch to actual size';
|
||||
actualBtn.setAttribute('aria-label', isActual ? 'Actual size; switch to fit view' : 'Fit view; switch to actual size');
|
||||
const label = actualBtn.querySelector('.ge-stacked-label');
|
||||
if (label) label.textContent = isActual ? 'SCALE' : 'FIT';
|
||||
const glyph = actualBtn.querySelector('.ge-stacked-glyph');
|
||||
if (glyph) glyph.innerHTML = isActual ? '1:1' : '<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="4 14 4 20 10 20"/><polyline points="20 10 20 4 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
let _inpaintPanelPosition = null;
|
||||
|
||||
function _applyInpaintPanelPosition(panel, position) {
|
||||
const left = Math.max(8, Math.min(window.innerWidth - panel.offsetWidth - 8, position.left));
|
||||
const top = Math.max(8, Math.min(window.innerHeight - panel.offsetHeight - 8, position.top));
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.top = `${top}px`;
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
function _positionInpaintPanel(anchorBtn) {
|
||||
const panel = document.getElementById('ge-inpaint-section');
|
||||
if (!panel || window.innerWidth <= 820) return;
|
||||
if (panel.dataset.userMoved === '1') {
|
||||
if (_inpaintPanelPosition) {
|
||||
panel.classList.add('ge-inpaint-popover');
|
||||
panel.dataset.userMoved = '1';
|
||||
_inpaintPanelPosition = _applyInpaintPanelPosition(panel, _inpaintPanelPosition);
|
||||
return;
|
||||
}
|
||||
panel.classList.add('ge-inpaint-popover');
|
||||
@@ -5448,6 +5494,7 @@ function _positionInpaintPanel(anchorBtn) {
|
||||
const r = anchorBtn?.getBoundingClientRect?.();
|
||||
if (!r) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel.isConnected || _inpaintPanelPosition || panel.dataset.userMoved === '1') return;
|
||||
const panelW = panel.offsetWidth || 320;
|
||||
const panelH = panel.offsetHeight || 520;
|
||||
const left = Math.min(window.innerWidth - panelW - 12, Math.max(12, r.right + 10));
|
||||
@@ -5458,6 +5505,7 @@ function _positionInpaintPanel(anchorBtn) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel.isConnected || _inpaintPanelPosition || panel.dataset.userMoved === '1') return;
|
||||
const refRect = ref.getBoundingClientRect();
|
||||
const panelW = panel.offsetWidth || 320;
|
||||
const panelH = panel.offsetHeight || 520;
|
||||
@@ -5489,13 +5537,17 @@ function _wireInpaintPopoverWindow() {
|
||||
const head = panel.querySelector('[data-inpaint-drag]');
|
||||
if (!head) return;
|
||||
head.addEventListener('pointerdown', (e) => {
|
||||
if (window.innerWidth <= 820 || e.target.closest('button')) return;
|
||||
if (window.innerWidth <= 820 || e.button !== 0 || e.target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
panel.classList.add('ge-inpaint-popover');
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const r0 = panel.getBoundingClientRect();
|
||||
// Claim the position immediately so a pending auto-placement cannot
|
||||
// overwrite this drag, and retain it when the editor rebuilds its DOM.
|
||||
panel.dataset.userMoved = '1';
|
||||
_inpaintPanelPosition = { left: r0.left, top: r0.top };
|
||||
head.setPointerCapture(e.pointerId);
|
||||
head.style.cursor = 'grabbing';
|
||||
const onMove = (ev) => {
|
||||
@@ -5506,15 +5558,20 @@ function _wireInpaintPopoverWindow() {
|
||||
panel.dataset.userMoved = '1';
|
||||
panel.style.left = `${nx}px`;
|
||||
panel.style.top = `${ny}px`;
|
||||
_inpaintPanelPosition = { left: nx, top: ny };
|
||||
};
|
||||
const onUp = () => {
|
||||
try { head.releasePointerCapture(e.pointerId); } catch {}
|
||||
head.style.cursor = '';
|
||||
head.removeEventListener('pointermove', onMove);
|
||||
head.removeEventListener('pointerup', onUp);
|
||||
head.removeEventListener('pointercancel', onUp);
|
||||
head.removeEventListener('lostpointercapture', onUp);
|
||||
};
|
||||
head.addEventListener('pointermove', onMove);
|
||||
head.addEventListener('pointerup', onUp);
|
||||
head.addEventListener('pointercancel', onUp);
|
||||
head.addEventListener('lostpointercapture', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5545,6 +5602,7 @@ function _buildEditor(container) {
|
||||
},
|
||||
onSelectTool: (toolId, _btn, toolbarEl) => {
|
||||
if (state.tool !== toolId) {
|
||||
_penTool.cancel();
|
||||
// Finish a paint gesture before its tool identity changes.
|
||||
if (state.drawing) _strokeTool.tryEnd();
|
||||
_cropTool.cancel('tool-switch');
|
||||
@@ -5572,7 +5630,7 @@ function _buildEditor(container) {
|
||||
// panel auto-minimises the layers sheet so the controls aren't
|
||||
// covered. Swiping the layers handle back up restores it.
|
||||
const isMobile = window.innerWidth <= 820;
|
||||
const hasToolControls = ['move', 'transform', 'brush', 'gradient', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'eyedropper', 'text', 'shape', 'marquee', 'lasso', 'wand', 'inpaint', 'sam'].includes(toolId);
|
||||
const hasToolControls = ['move', 'transform', 'brush', 'gradient', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'eyedropper', 'text', 'shape', 'marquee', 'lasso', 'pen', 'wand', 'inpaint', 'sam'].includes(toolId);
|
||||
const controlsVisible = controls && !controls.classList.contains('dismissed');
|
||||
if (isMobile && hasToolControls && controlsVisible) {
|
||||
const rp = document.querySelector('.ge-right-panel');
|
||||
@@ -5590,6 +5648,8 @@ function _buildEditor(container) {
|
||||
const needsBrush = ['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(toolId);
|
||||
if (brushControls) brushControls.style.display = needsBrush ? '' : 'none';
|
||||
const textSection = document.getElementById('ge-text-section');
|
||||
const penSection = document.getElementById('ge-pen-section');
|
||||
if (penSection) penSection.style.display = toolId === 'pen' ? '' : 'none';
|
||||
if (textSection) textSection.style.display = toolId === 'text' ? '' : 'none';
|
||||
if (toolId === 'text') _syncTextControls();
|
||||
const shapeSection = document.getElementById('ge-shape-section');
|
||||
@@ -5845,6 +5905,7 @@ function _buildEditor(container) {
|
||||
continueDraw: _continueDraw,
|
||||
endDraw: _endDraw,
|
||||
cancelDraw: () => {
|
||||
if (state.tool === 'pen') { _penTool.cancel(); return; }
|
||||
if (state.transformActive) _cancelTransform();
|
||||
else if (state.drawing) _strokeTool.cancel();
|
||||
else if (state.gradientActive) _gradientTool.cancel();
|
||||
@@ -5872,6 +5933,7 @@ function _buildEditor(container) {
|
||||
});
|
||||
editorBody.appendChild(rightPanel);
|
||||
container.appendChild(editorBody);
|
||||
container.appendChild(_buildZoomFooter());
|
||||
_wireInpaintPopoverWindow();
|
||||
|
||||
// Slider UX (expand-while-using, floating bubble, click-to-type) —
|
||||
@@ -6134,7 +6196,7 @@ function _buildEditor(container) {
|
||||
sctx.fillStyle = '#fff';
|
||||
sctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
_saveState('Fill selection');
|
||||
_saveState(selection ? 'Fill selection' : 'Fill layer');
|
||||
sctx.globalCompositeOperation = 'source-in';
|
||||
sctx.fillStyle = state.color;
|
||||
sctx.fillRect(0, 0, w, h);
|
||||
@@ -6146,7 +6208,11 @@ function _buildEditor(container) {
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Filled');
|
||||
_schedulePersist();
|
||||
}
|
||||
document.getElementById('ge-layer-fill')?.addEventListener('click', () => _doFillSelection());
|
||||
document.getElementById('ge-pen-commit')?.addEventListener('click', () => _penTool.commit());
|
||||
document.getElementById('ge-pen-cancel')?.addEventListener('click', () => _penTool.cancel());
|
||||
|
||||
// AI model selectors (Gen, Inpaint, per-tool) — full
|
||||
// implementation in editor/ai-models.js.
|
||||
@@ -6266,6 +6332,7 @@ function _buildEditor(container) {
|
||||
// AI inpaint (Generate / Remove / Outpaint) — full implementation
|
||||
// in editor/ai-inpaint.js.
|
||||
wireInpaintButtons({
|
||||
renderLayer: (layer) => _renderLayerOutput(layer),
|
||||
buildMergedMaskCanvas: () => _buildMergedMaskCanvas(),
|
||||
dilateMask: _dilateMask,
|
||||
applyInpaintFeather: _applyInpaintFeather,
|
||||
@@ -6465,6 +6532,7 @@ function _buildEditor(container) {
|
||||
// Keyboard shortcuts — full implementation in
|
||||
// editor/keyboard-shortcuts.js.
|
||||
wireKeyboardShortcuts({
|
||||
fillSelection: _doFillSelection,
|
||||
copyPixelsToClipboard: _copyPixelsToClipboard,
|
||||
toolbar, toolKeyMap: _toolKeyMap,
|
||||
composite, saveState: _saveState, undo, redo,
|
||||
@@ -6565,6 +6633,11 @@ function _rasterizePlacedLayerCommand(layer) {
|
||||
// Wrap to the legacy name so the dozens of `_renderLayerPanel()` call
|
||||
// sites scattered across the file keep working unchanged.
|
||||
const _layerPanelRenderer = createLayerPanelRenderer({
|
||||
openLayerStyles: (layer, anchor) => openLayerStyleMenu(anchor, type => {
|
||||
state.activeGroupId = null;
|
||||
state.activeLayerId = layer.id;
|
||||
void _addRetainedEffect(type);
|
||||
}),
|
||||
composite,
|
||||
saveState: _saveState,
|
||||
showLayerThumb: (row, layer) => _showLayerThumb(row, layer),
|
||||
@@ -7507,6 +7580,7 @@ function _setEditTabLabel(name) {
|
||||
}
|
||||
|
||||
export function closeEditor(options = {}) {
|
||||
_penTool.cancel();
|
||||
const force = options === true || options.force === true;
|
||||
const editorMounted = _galleryEditMounted();
|
||||
if ((state.editorOpen || editorMounted) && !force && !window.__galleryAllowCloseEditor) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Older saved turns kept image fields only inside the tool's JSON output.
|
||||
export function generatedImageResult(event) {
|
||||
if (!event || event.error || (event.exit_code != null && event.exit_code !== 0)) return null;
|
||||
if (event.image_url) return event;
|
||||
if (!['generate_image', 'edit_image'].includes(event.tool) || typeof event.output !== 'string') return null;
|
||||
try {
|
||||
const result = JSON.parse(event.output);
|
||||
if (result.error || !result.image_url || typeof result.image_url !== 'string') return null;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,28 @@ function linkifyRawCookbookLists(src) {
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
// Older Ajax skill inventories were saved with plain slugs. Make those
|
||||
// existing chat rows open the same Skills panel as current linked results.
|
||||
function linkifyPlainSkillLists(src) {
|
||||
let inSkills = false;
|
||||
return String(src || '').split('\n').map(line => {
|
||||
if (/^Skills \(\d+\):\s*$|^Skill matches \(\d+\):\s*$/.test(line)) {
|
||||
inSkills = true;
|
||||
return line;
|
||||
}
|
||||
if (!inSkills) return line;
|
||||
if (/^\*\*(?:Published|Drafts)\*\*\s*$/.test(line) || !line.trim()) return line;
|
||||
if (/^#{1,6}\s|^[A-Za-z][^\n]*:\s*$/.test(line)) {
|
||||
inSkills = false;
|
||||
return line;
|
||||
}
|
||||
if (/\]\(#skill-/.test(line)) return line;
|
||||
const row = line.match(/^(\s*-\s+)(?:\*\*)?([A-Za-z0-9][A-Za-z0-9._-]*)(?:\*\*)?(?=\s|$)(.*)$/);
|
||||
if (!row || row[2] === '...and') return line;
|
||||
return `${row[1]}[${row[2]}](#skill-${encodeURIComponent(row[2])})${row[3]}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function flattenLegacyNoteMoreDetails(src) {
|
||||
return String(src || '').replace(
|
||||
/<details>\s*<summary>\s*(\.\.\.and\s+\d+\s+more\s+notes?)\s*<\/summary>[\s\S]*?<\/details>/gi,
|
||||
@@ -945,6 +967,13 @@ export function mdToHtml(src, opts) {
|
||||
s = linkifyRawEmailReadBlocks(s);
|
||||
s = linkifyPlainEmailUidLines(s);
|
||||
s = linkifyRawCookbookLists(s);
|
||||
s = linkifyPlainSkillLists(s);
|
||||
// Older saved memory summaries used a plain-text instruction. Keep those
|
||||
// actionable too, using the existing Memory panel anchor handler.
|
||||
s = s.replace(
|
||||
/(^|\n)(\.\.\.and \d+ more saved memories\. )Open Memory to browse all\./g,
|
||||
'$1$2[Open Memory to browse all](#memory).',
|
||||
);
|
||||
|
||||
// Convert markdown images before links so  does not become
|
||||
// literal "!" plus a normal link.
|
||||
|
||||
+21
-19
@@ -1,7 +1,7 @@
|
||||
// Model Picker — chatbox model selector dropdown
|
||||
// Extracted from sessions.js
|
||||
|
||||
import { providerLogo } from './providers.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import uiModule from './ui.js?v=20260916largetoolscroll1';
|
||||
import settingsModule from './settings.js?v=20260912writingstyle3';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
@@ -252,8 +252,7 @@ function _initModelPickerDropdown() {
|
||||
|
||||
// Local endpoint health — only probed for LOCAL endpoints, since
|
||||
// cloud APIs are essentially always up. Cached briefly on the
|
||||
// server side too (8s TTL). Picker opens do not probe; the refresh button
|
||||
// is the explicit network/probe action.
|
||||
// server side too (8s TTL).
|
||||
let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}}
|
||||
let _localProbeFetchedAt = 0;
|
||||
const _LOCAL_PROBE_TTL_MS = 5000;
|
||||
@@ -354,12 +353,12 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
|
||||
async function _refreshPickerModels({ force = false, showLoading = false } = {}) {
|
||||
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return;
|
||||
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return false;
|
||||
const seq = ++_pickerLoadSeq;
|
||||
_pickerLoading = true;
|
||||
if (showLoading) _renderLoading(force ? 'Refreshing models…' : 'Loading models…');
|
||||
try {
|
||||
await window.modelsModule.refreshModels(force);
|
||||
await window.modelsModule.refreshModels(force, { waitForRefresh: force });
|
||||
await _refreshLocalProbe();
|
||||
} finally {
|
||||
if (seq === _pickerLoadSeq) {
|
||||
@@ -367,6 +366,7 @@ function _initModelPickerDropdown() {
|
||||
listEl.classList.remove('is-loading');
|
||||
}
|
||||
}
|
||||
return seq === _pickerLoadSeq;
|
||||
}
|
||||
|
||||
// ── Provider display names and grouping ──
|
||||
@@ -810,13 +810,8 @@ async function _pick(m) {
|
||||
_renderLoading('Loading models…');
|
||||
}
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
// Force the cheap /api/models cache refresh when the picker opens.
|
||||
// This does not wait on provider probes; the backend returns cached
|
||||
// inventory and starts refresh work separately. Without this, models
|
||||
// enabled in Added Models can be absent from the chatbox picker until
|
||||
// the tab's frontend cache ages out.
|
||||
_refreshPickerModels({ force: hasCache, showLoading: !hasCache }).then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
_refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => {
|
||||
if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -974,13 +969,20 @@ export function updateModelPicker() {
|
||||
const normalizeRouteUrl = url => String(url || '').replace(/\/chat\/completions\/?$/, '').replace(/\/$/, '');
|
||||
const selectedEndpoint = candidates.find(item => selectedId ? item.endpoint_id === selectedId
|
||||
: normalizeRouteUrl(item.url) === normalizeRouteUrl(selectedUrl));
|
||||
const routeName = s?.endpoint_name || selectedEndpoint?.endpoint_name;
|
||||
if (routeName) {
|
||||
displayName = `${displayName} · ${routeName}`;
|
||||
}
|
||||
// The header indicator clips long names with ellipsis; show the full model
|
||||
// identifier on hover (#1982). No tooltip on the "Select model" placeholder.
|
||||
label.title = modelId ? displayName : '';
|
||||
const routeName = s?.endpoint_name || selectedEndpoint?.endpoint_name || '';
|
||||
const endpointUrl = selectedUrl || selectedEndpoint?.url || '';
|
||||
const detectedProvider = selectedEndpoint?.category === 'local'
|
||||
? 'Local' : providerLabel(endpointUrl || routeName);
|
||||
let addressLabel = !routeName;
|
||||
try {
|
||||
const endpoint = new URL(endpointUrl);
|
||||
addressLabel = addressLabel || [endpoint.host, endpoint.hostname, endpointUrl].includes(routeName);
|
||||
} catch (_) {}
|
||||
const provider = addressLabel ? detectedProvider : routeName;
|
||||
if (modelId && provider) displayName = `${provider} · ${displayName}`;
|
||||
// Keep route details and the full model ID available without putting raw
|
||||
// local addresses in the compact picker label.
|
||||
label.title = modelId ? [...new Set([modelId, routeName, endpointUrl].filter(Boolean))].join(' · ') : '';
|
||||
const logo = modelId ? providerLogo(modelId) : null;
|
||||
if (logo) {
|
||||
label.innerHTML = '<span class="model-picker-logo">' + logo + '</span> ';
|
||||
|
||||
+3
-1
@@ -199,7 +199,9 @@ export async function refreshModels(force = false, opts = {}) {
|
||||
// ages out. (Bug repro: serve a model, picker is empty for ~30s
|
||||
// even though the endpoint is in the DB and online.)
|
||||
const _seq = ++_fetchSeq;
|
||||
const _url = `${API_BASE}/api/models` + (force ? '?refresh=true' : '?background=false');
|
||||
const _url = `${API_BASE}/api/models` + (force
|
||||
? `?refresh=true${opts.waitForRefresh ? '&wait_refresh=true' : ''}`
|
||||
: '?background=false');
|
||||
_fetchInflight = fetch(_url, { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
+64
-175
@@ -12,6 +12,7 @@ import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { bindTourHintDismiss } from './tourHintDismiss.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -87,7 +88,7 @@ function _showNotesFirstOpenHint(pane) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('notes-first-open-hint')?.remove();
|
||||
dismissOrRemove(document.getElementById('notes-first-open-hint'));
|
||||
const hint = document.createElement('div');
|
||||
hint.id = 'notes-first-open-hint';
|
||||
hint.className = 'tour-hint';
|
||||
@@ -103,19 +104,16 @@ function _showNotesFirstOpenHint(pane) {
|
||||
hint.style.top = Math.max(12, r.top + 58) + 'px';
|
||||
hint.style.left = Math.min(window.innerWidth - hw - 12, Math.max(12, r.left + 18)) + 'px';
|
||||
};
|
||||
const close = () => {
|
||||
window.removeEventListener('resize', place);
|
||||
hint.classList.add('tour-hint-out');
|
||||
setTimeout(() => hint.remove(), 180);
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
place();
|
||||
hint.classList.add('tour-hint-in');
|
||||
});
|
||||
window.addEventListener('resize', place);
|
||||
hint.querySelector('.tour-hint-dismiss')?.addEventListener('click', close);
|
||||
setTimeout(close, 6500);
|
||||
bindTourHintDismiss(hint, {
|
||||
timeout: 6500,
|
||||
fade: 180,
|
||||
onDismiss: () => window.removeEventListener('resize', place),
|
||||
});
|
||||
}
|
||||
|
||||
function _notesFullscreenSafeRect() {
|
||||
@@ -1917,20 +1915,10 @@ function _renderNotes() {
|
||||
for (let i = 0; i < note.items.length; i++) {
|
||||
const item = note.items[i];
|
||||
const doneClass = item.done ? ' done' : '';
|
||||
const agentStatus = (item.agent_status || '').toLowerCase();
|
||||
const agentDoneClass = agentStatus === 'stream_complete' ? ' is-agent-stream-complete' : '';
|
||||
const agentTitle = agentStatus === 'stream_complete'
|
||||
? 'Agent stream finished for this todo'
|
||||
: (agentStatus === 'running' ? 'Agent is working on this todo' : 'Solve this todo with the agent');
|
||||
const agentSessionAttr = item.agent_session_id ? ` data-session-id="${_attrEsc(item.agent_session_id)}"` : '';
|
||||
const agentMenuTitle = item.agent_session_title || `Agent: ${(item.text || '').slice(0, 40)}`;
|
||||
const indent = Math.min(item.indent || 0, 3);
|
||||
contentHtml += `<div class="note-checkbox${doneClass}" data-note-id="${note.id}" data-idx="${i}" style="padding-left:${indent * 16}px">
|
||||
<span class="note-check-dot" title="Mark done"></span>
|
||||
<span class="note-check-text">${_linkify(item.text)}</span>
|
||||
<button class="note-checkbox-agent${agentDoneClass}" data-note-id="${_attrEsc(note.id)}" data-idx="${i}"${agentSessionAttr} data-agent-title="${_attrEsc(agentMenuTitle)}" title="${_attrEsc(agentTitle)}">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
|
||||
</button>
|
||||
<button class="note-checkbox-edit" data-note-id="${note.id}" data-idx="${i}" title="Edit item">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
@@ -2333,7 +2321,7 @@ function _bindCardEvents(body) {
|
||||
}
|
||||
}, { passive: false });
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, input')) return;
|
||||
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return;
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(el.closest('.note-card'));
|
||||
});
|
||||
@@ -2359,7 +2347,7 @@ function _bindCardEvents(body) {
|
||||
// title / content preview triggered edit, so padding + empty gutters were
|
||||
// dead zones that felt broken on mobile.
|
||||
if (_isNotesMobileMode() && !_selectMode) {
|
||||
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
||||
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest(_INTERACTIVE)) return;
|
||||
@@ -2726,18 +2714,6 @@ function _bindCardEvents(body) {
|
||||
});
|
||||
});
|
||||
|
||||
// Per-item agent solve (hover button next to the X). Scoped to one todo
|
||||
// item — uses the note title as context if present, but only the single
|
||||
// item's text as the work. Mirrors the per-note _agentSolveNote pattern.
|
||||
body.querySelectorAll('.note-checkbox-agent').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (_selectMode) return;
|
||||
_openTodoAgentMenu(btn);
|
||||
});
|
||||
});
|
||||
|
||||
// Quick-add new checklist item (hover input at bottom of todo cards)
|
||||
body.querySelectorAll('.note-cl-quickadd-input').forEach(input => {
|
||||
input.addEventListener('click', (e) => e.stopPropagation());
|
||||
@@ -2968,10 +2944,9 @@ function _bindCardEvents(body) {
|
||||
|
||||
// ── Draft autosave ──────────────────────────────────────────────────
|
||||
// While a note is open in the editor, its form is snapshotted to
|
||||
// localStorage on every change (debounced). If the connection drops, the
|
||||
// localStorage on every change. If the connection drops, the
|
||||
// tab closes, or the page reloads before Save is hit, reopening that note
|
||||
// restores the unsaved text. Drafts are cleared on an explicit Save or
|
||||
// Cancel. Survives offline because it never touches the network.
|
||||
// restores the unsaved text. Clear only after confirmed persistence.
|
||||
const _DRAFT_PREFIX = 'odysseus-note-draft-';
|
||||
function _draftKey(id) { return _DRAFT_PREFIX + (id || '__new__'); }
|
||||
function _loadDraft(id) {
|
||||
@@ -2980,7 +2955,7 @@ function _loadDraft(id) {
|
||||
function _clearDraft(id) { try { localStorage.removeItem(_draftKey(id)); } catch {} }
|
||||
function _collectFormDraft(form) {
|
||||
if (!form) return null;
|
||||
const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || 'note';
|
||||
const type = form.querySelector('.note-form-type-pill.active')?.dataset.type || form.dataset.noteType || 'note';
|
||||
const d = {
|
||||
_ts: Date.now(),
|
||||
note_type: type,
|
||||
@@ -3004,15 +2979,50 @@ function _isDraftEmpty(d) {
|
||||
}
|
||||
function _wireDraftAutosave(form, id) {
|
||||
let t = null;
|
||||
let pending = Promise.resolve();
|
||||
let stopped = false;
|
||||
let warned = false;
|
||||
const save = () => {
|
||||
const d = _collectFormDraft(form);
|
||||
if (_isDraftEmpty(d)) { _clearDraft(id); return; }
|
||||
try { localStorage.setItem(_draftKey(id), JSON.stringify(d)); } catch {}
|
||||
// An empty edit can be intentional: don't restore stale server text over it.
|
||||
try { localStorage.setItem(_draftKey(id), JSON.stringify(d)); }
|
||||
catch {
|
||||
if (!warned) uiModule.showError('Draft backup unavailable. Keep this note open until saved.');
|
||||
warned = true;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
const persist = (d) => {
|
||||
if (!id || id === '__new__' || d.note_type === 'draw') return pending;
|
||||
const { _ts, ...payload } = d;
|
||||
payload.label = [...new Set(payload.label.split(/\s+/).map(s => s.replace(/^#+/, '')).filter(Boolean))].join(' ') || null;
|
||||
pending = pending.then(async () => {
|
||||
try {
|
||||
await _patchNote(id, payload);
|
||||
form._autosaveFailed = false;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
if (note) Object.assign(note, payload);
|
||||
if (localStorage.getItem(_draftKey(id)) === JSON.stringify(d)) _clearDraft(id);
|
||||
} catch {
|
||||
if (!form._autosaveFailed) uiModule.showError('Note not synced. Your edits are kept on this device; retry Save.');
|
||||
form._autosaveFailed = true;
|
||||
}
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
form._flushDraft = () => { clearTimeout(t); return save(); };
|
||||
form._stopDraftAutosave = () => { stopped = true; clearTimeout(t); return pending; };
|
||||
const sched = () => {
|
||||
const d = save();
|
||||
if (stopped) return;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => persist(d), 500);
|
||||
};
|
||||
form._flushDraft = () => { clearTimeout(t); save(); };
|
||||
const sched = () => { clearTimeout(t); t = setTimeout(save, 600); };
|
||||
form.addEventListener('input', sched);
|
||||
form.addEventListener('change', sched);
|
||||
form.addEventListener('click', (event) => {
|
||||
if (event.target.closest('.note-cl-dot, .note-cl-rm')) sched();
|
||||
});
|
||||
}
|
||||
|
||||
// Commit whatever in-place editor is open (called when the panel closes
|
||||
@@ -3028,7 +3038,7 @@ function _commitOpenInPlaceEditor() {
|
||||
// Merge a stored draft over a note so _buildForm renders the unsaved edits.
|
||||
function _applyDraftToNote(note, id) {
|
||||
const d = _loadDraft(id);
|
||||
if (_isDraftEmpty(d)) return { note, restored: false };
|
||||
if (!d) return { note, restored: false };
|
||||
const merged = { ...(note || {}) };
|
||||
['note_type', 'color', 'title', 'label', 'due_date', 'repeat', 'content', 'items'].forEach(k => {
|
||||
if (d[k] !== undefined) merged[k] = d[k];
|
||||
@@ -3040,12 +3050,13 @@ function _applyDraftToNote(note, id) {
|
||||
|
||||
function _buildForm(note = null) {
|
||||
const isEdit = note && note.id;
|
||||
const type = note?.note_type || 'note';
|
||||
const type = note?.note_type === 'checklist' ? 'todo' : (note?.note_type || 'note');
|
||||
const color = note?.color || '';
|
||||
const items = note?.items || [{ id: _uid(), text: '', done: false }];
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'note-form';
|
||||
form.dataset.noteType = type;
|
||||
form.dataset.noteColor = color || '';
|
||||
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
|
||||
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
|
||||
@@ -3103,8 +3114,8 @@ function _buildForm(note = null) {
|
||||
</button>
|
||||
` : ''}
|
||||
<span class="note-form-actions-spacer"></span>
|
||||
<button class="note-form-cancel note-form-text-btn note-form-collapsible" title="Cancel">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="nft-label">Cancel</span>
|
||||
<button class="note-form-cancel note-form-text-btn note-form-collapsible" title="Close (keep changes)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg><span class="nft-label">Close</span>
|
||||
</button>
|
||||
<button class="note-form-save note-form-text-btn" title="${isEdit ? 'Update' : 'Save'}">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><span class="nft-label">${isEdit ? 'Update' : 'Save'}</span>
|
||||
@@ -3751,6 +3762,9 @@ function _buildForm(note = null) {
|
||||
}
|
||||
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
|
||||
try {
|
||||
form._flushDraft?.();
|
||||
await form._stopDraftAutosave?.();
|
||||
form._flushDraft?.();
|
||||
const title = form.querySelector('.note-form-title').value.trim();
|
||||
// Normalize tag input: split on whitespace, strip leading #s, dedupe,
|
||||
// re-join with single spaces. Empty → null.
|
||||
@@ -3814,7 +3828,8 @@ function _buildForm(note = null) {
|
||||
}
|
||||
// Optimistic update — update local state first, render, then save in background
|
||||
_editingId = null;
|
||||
_clearDraft(isEdit ? note.id : '__new__'); // saved → discard the draft
|
||||
const draftId = isEdit ? note.id : '__new__';
|
||||
const submittedDraft = JSON.stringify(_loadDraft(draftId));
|
||||
if (isEdit) {
|
||||
const idx = _notes.findIndex(n => n.id === note.id);
|
||||
if (idx >= 0) _notes[idx] = { ..._notes[idx], ...payload };
|
||||
@@ -3824,6 +3839,7 @@ function _buildForm(note = null) {
|
||||
_renderNotes();
|
||||
// Background save
|
||||
_saveNote(payload).then(saved => {
|
||||
if (JSON.stringify(_loadDraft(draftId)) === submittedDraft) _clearDraft(draftId);
|
||||
if (!isEdit && saved && saved.id) {
|
||||
// Replace temp ID with real one from server. AND re-render — the
|
||||
// existing card's `data-note-id="tmp_xxx"` is stale after Object.assign
|
||||
@@ -3884,7 +3900,7 @@ function _buildForm(note = null) {
|
||||
}
|
||||
|
||||
// Cancel
|
||||
form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); });
|
||||
form.querySelector('.note-form-cancel').addEventListener('click', () => { form._flushDraft?.(); _editingId = null; _renderNotes(); });
|
||||
|
||||
// Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions.
|
||||
form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
|
||||
@@ -4616,54 +4632,6 @@ function _openNoteCornerMenu(btn) {
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||
}
|
||||
|
||||
function _positionNoteMenu(menu, btn, width = 196) {
|
||||
document.body.appendChild(menu);
|
||||
const r = btn.getBoundingClientRect();
|
||||
let left = Math.min(r.right - width, window.innerWidth - width - 8);
|
||||
left = Math.max(8, left);
|
||||
const mh = menu.offsetHeight || 112;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
}
|
||||
|
||||
function _openTodoAgentMenu(btn) {
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
||||
const noteId = btn.dataset.noteId;
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
const sid = btn.dataset.sessionId || '';
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'note-corner-menu-dropdown note-agent-item-menu';
|
||||
menu.innerHTML = `
|
||||
${sid ? `<button type="button" class="ncm-item" data-act="open">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14L21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
||||
<span>Open</span>
|
||||
</button>` : ''}
|
||||
<button type="button" class="ncm-item" data-act="run">
|
||||
<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 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
|
||||
<span>${sid ? 'Run again' : 'Run Agent'}</span>
|
||||
</button>`;
|
||||
_positionNoteMenu(menu, btn);
|
||||
const openBtn = menu.querySelector('[data-act="open"]');
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
const _sm = window.sessionModule;
|
||||
if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); }
|
||||
});
|
||||
}
|
||||
menu.querySelector('[data-act="run"]').addEventListener('click', () => {
|
||||
menu.remove();
|
||||
_agentSolveTodoItem(noteId, idx);
|
||||
});
|
||||
}
|
||||
|
||||
// Build the prompt the agent gets from a note: title + body, plus any
|
||||
// not-yet-done checklist items.
|
||||
function _noteToAgentPrompt(note) {
|
||||
@@ -4736,86 +4704,6 @@ async function _agentSolveNote(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-item version of _agentSolveNote. Scoped to a single checklist item;
|
||||
// the note title (if any) is included as context, but only this one item's
|
||||
// text is the work the agent is asked to do. agent_session_id is set on the
|
||||
// PARENT note (latest-wins) so the Agent tag still surfaces the most recent
|
||||
// run from this note — same UX as a per-note solve.
|
||||
async function _agentSolveTodoItem(noteId, idx) {
|
||||
const note = _notes.find(n => n.id === noteId);
|
||||
if (!note || !Array.isArray(note.items)) return;
|
||||
const item = note.items[idx];
|
||||
const itemText = (item && (item.text || '').trim()) || '';
|
||||
if (!itemText) {
|
||||
uiModule.showToast('Nothing to solve — item is empty');
|
||||
return;
|
||||
}
|
||||
const titleCtx = (note.title || '').trim();
|
||||
const prompt = titleCtx
|
||||
? `Context (from note "${titleCtx}").\n\nHelp me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.`
|
||||
: `Help me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.`;
|
||||
try {
|
||||
const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json();
|
||||
if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; }
|
||||
|
||||
const label = itemText.slice(0, 40);
|
||||
const csFd = new FormData();
|
||||
csFd.append('name', 'Agent: ' + label);
|
||||
csFd.append('endpoint_url', dc.endpoint_url);
|
||||
csFd.append('model', dc.model);
|
||||
if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id);
|
||||
csFd.append('skip_validation', 'true');
|
||||
const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd });
|
||||
if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; }
|
||||
const sess = await csRes.json();
|
||||
const sid = sess.id;
|
||||
const sessionTitle = 'Agent: ' + label;
|
||||
|
||||
const n = _notes.find(x => x.id === noteId);
|
||||
if (n) {
|
||||
n.agent_session_id = sid;
|
||||
if (Array.isArray(n.items) && n.items[idx]) {
|
||||
n.items[idx].agent_session_id = sid;
|
||||
n.items[idx].agent_session_title = sessionTitle;
|
||||
n.items[idx].agent_status = 'running';
|
||||
n.items[idx].agent_stream_completed_at = '';
|
||||
}
|
||||
}
|
||||
_renderNotes();
|
||||
_patchNote(noteId, { items: n && Array.isArray(n.items) ? n.items : note.items, agent_session_id: sid }).catch(() => {});
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', prompt);
|
||||
fd.append('session', sid);
|
||||
fd.append('mode', 'agent');
|
||||
fd.append('disabled_tools', JSON.stringify(['manage_notes']));
|
||||
fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
while (true) { const { done } = await reader.read(); if (done) break; }
|
||||
if (window.sessionModule && window.sessionModule.markStreamComplete) {
|
||||
try { window.sessionModule.markStreamComplete(sid); } catch {}
|
||||
}
|
||||
const doneNote = _notes.find(x => x.id === noteId);
|
||||
if (doneNote && Array.isArray(doneNote.items) && doneNote.items[idx]) {
|
||||
doneNote.agent_session_id = sid;
|
||||
doneNote.items[idx].agent_session_id = sid;
|
||||
doneNote.items[idx].agent_session_title = sessionTitle;
|
||||
doneNote.items[idx].agent_status = 'stream_complete';
|
||||
doneNote.items[idx].agent_stream_completed_at = new Date().toISOString();
|
||||
_renderNotes();
|
||||
_patchNote(noteId, { items: doneNote.items, agent_session_id: sid }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
uiModule.showToast('Agent working on this item — tap the Agent tag when ready');
|
||||
} catch (e) {
|
||||
uiModule.showError('Agent failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function _copyNote(noteId, btnEl) {
|
||||
const note = _notes.find(n => n.id === noteId);
|
||||
if (!note) return false;
|
||||
@@ -5129,6 +5017,7 @@ function _openMobileFullscreenEdit(id, fromCard) {
|
||||
function _closeMobileFullscreenEdit(opts = {}) {
|
||||
if (!_mobileFsOverlay) return;
|
||||
const overlay = _mobileFsOverlay;
|
||||
overlay.querySelector('.note-form')?._flushDraft?.();
|
||||
_mobileFsOverlay = null;
|
||||
// If the form has a Save button, click it on close so edits aren't lost
|
||||
// when the user uses the back arrow instead of an explicit Save.
|
||||
|
||||
@@ -153,6 +153,8 @@ export function providerLabel(endpointUrl) {
|
||||
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||
return "Local";
|
||||
}
|
||||
// Shared-address space used by local overlay networks such as Tailscale.
|
||||
if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host)) return "Local";
|
||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||
if (re.test(host)) return label;
|
||||
}
|
||||
|
||||
+63
-64
@@ -324,7 +324,7 @@ async function _cleanupIncognitoSessions() {
|
||||
const keep = ids.filter(sid => sid === currentSessionId);
|
||||
sessionStorage.setItem(_INCOGNITO_SESSIONS_KEY, JSON.stringify(keep));
|
||||
await Promise.all(toDelete.map(sid =>
|
||||
fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' }).catch(() => {})
|
||||
fetch(_sessionDeletionUrl(sid), { method: 'DELETE' }).catch(() => {})
|
||||
));
|
||||
}
|
||||
|
||||
@@ -344,29 +344,6 @@ function _persistCompletedSessions() {
|
||||
try { localStorage.setItem(_COMPLETED_SESSIONS_KEY, JSON.stringify([..._completedSessions])); } catch (_) {}
|
||||
}
|
||||
|
||||
function _renderSessionRunState(state, isRunning) {
|
||||
if (!state) return;
|
||||
if (state._whirlpool) {
|
||||
state._whirlpool.destroy();
|
||||
state._whirlpool = null;
|
||||
}
|
||||
state.replaceChildren();
|
||||
state.classList.toggle('is-working', isRunning);
|
||||
state.classList.toggle('is-done', !isRunning);
|
||||
if (isRunning) {
|
||||
const whirlpool = spinnerModule.createWhirlpool(12);
|
||||
whirlpool.element.classList.add('session-run-whirlpool');
|
||||
state.appendChild(whirlpool.element);
|
||||
state._whirlpool = whirlpool;
|
||||
state.title = 'Agent is working';
|
||||
state.setAttribute('aria-label', 'Agent is working');
|
||||
} else {
|
||||
state.textContent = '✓';
|
||||
state.title = 'Agent finished while you were away';
|
||||
state.setAttribute('aria-label', 'Agent finished while you were away');
|
||||
}
|
||||
}
|
||||
|
||||
// Session list keyboard navigation state
|
||||
let _sessionListFocused = false;
|
||||
|
||||
@@ -1103,7 +1080,7 @@ function createSessionItem(s) {
|
||||
return;
|
||||
}
|
||||
dropdown.style.display = 'none';
|
||||
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
|
||||
if (!await _confirmSessionDeletion([s.id])) {
|
||||
_forceSidebarOpen();
|
||||
return;
|
||||
}
|
||||
@@ -1131,7 +1108,7 @@ function createSessionItem(s) {
|
||||
}
|
||||
// Await API deletion, then reload the authoritative list from the server
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' });
|
||||
} catch (e) { /* network error — session may still exist server-side */ }
|
||||
await loadSessions();
|
||||
});
|
||||
@@ -1193,13 +1170,6 @@ function createSessionItem(s) {
|
||||
}
|
||||
}
|
||||
|
||||
if (_isProcessing || _isDone) {
|
||||
const state = document.createElement('span');
|
||||
state.className = 'session-run-state ' + (_isProcessing ? 'is-working' : 'is-done');
|
||||
_renderSessionRunState(state, _isProcessing);
|
||||
div.appendChild(state);
|
||||
}
|
||||
|
||||
div.appendChild(menuBtn);
|
||||
dropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -1481,10 +1451,10 @@ function _renderSessionListImpl() {
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const count = folders[folderName].length;
|
||||
if (!await uiModule.styledConfirm(`Delete folder "${folderName}" and all ${count} session(s) inside it?`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await _confirmSessionDeletion(folders[folderName].map(s => s.id), `Delete folder "${folderName}" and all ${count} chats inside it?`)) return;
|
||||
for (const s of folders[folderName]) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' });
|
||||
_deselectCurrentSession(s.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete session:', s.id, err);
|
||||
@@ -1593,10 +1563,10 @@ function _renderSessionListImpl() {
|
||||
deleteBtn.title = 'Delete all unsorted sessions';
|
||||
deleteBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!await uiModule.styledConfirm(`Delete all ${unfiled.length} unsorted session(s)?`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await _confirmSessionDeletion(unfiled.map(s => s.id), `Delete all ${unfiled.length} unsorted chats?`)) return;
|
||||
for (const s of unfiled) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' });
|
||||
_deselectCurrentSession(s.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete session:', s.id, err);
|
||||
@@ -1872,11 +1842,11 @@ function _initBulkSelect() {
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (_selectedIds.size === 0) return;
|
||||
const count = _selectedIds.size;
|
||||
if (!await uiModule.styledConfirm(`Delete ${count} session(s)? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await _confirmSessionDeletion([..._selectedIds], `Delete ${count} chats? This cannot be undone.`)) return;
|
||||
const deletedIds = [];
|
||||
for (const sid of _selectedIds) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
const res = await fetch(_sessionDeletionUrl(sid), { method: 'DELETE' });
|
||||
if (res.ok) deletedIds.push(sid);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -2667,6 +2637,44 @@ export function setCurrentSessionId(id) {
|
||||
}
|
||||
}
|
||||
|
||||
const _sessionImageDeletionChoices = new Map();
|
||||
|
||||
async function _confirmSessionDeletion(ids, message = 'Delete this chat?') {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
uniqueIds.forEach(id => _sessionImageDeletionChoices.delete(String(id)));
|
||||
let counts;
|
||||
try {
|
||||
counts = await Promise.all(uniqueIds.map(async id => {
|
||||
const response = await fetch(`${API_BASE}/api/session/${encodeURIComponent(id)}/deletion-info`);
|
||||
if (!response.ok) throw new Error('Unable to check attached images');
|
||||
const info = await response.json();
|
||||
if (!Number.isInteger(info.image_count) || info.image_count < 0) throw new Error('Invalid image count');
|
||||
return info.image_count;
|
||||
}));
|
||||
} catch (_) {
|
||||
uiModule.showError('Could not check this chat’s images. Nothing was deleted. Please try again.');
|
||||
return false;
|
||||
}
|
||||
const hasImages = counts.some(count => count > 0);
|
||||
const answer = await uiModule.styledConfirm(
|
||||
hasImages
|
||||
? `${message} ${uniqueIds.length === 1 ? 'This chat has' : 'These chats have'} images in Gallery. Delete those images as well? Keeping them also keeps them in their albums.`
|
||||
: message,
|
||||
hasImages
|
||||
? { title: 'Delete chat', confirmText: 'Delete chat only', alternateText: 'Delete chat and images', danger: true }
|
||||
: { confirmText: 'Delete', danger: true },
|
||||
);
|
||||
if (!answer) return false;
|
||||
uniqueIds.forEach(id => _sessionImageDeletionChoices.set(String(id), answer === 'alternate'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function _sessionDeletionUrl(id) {
|
||||
const deleteImages = _sessionImageDeletionChoices.get(String(id)) === true;
|
||||
_sessionImageDeletionChoices.delete(String(id));
|
||||
return `${API_BASE}/api/session/${encodeURIComponent(id)}?delete_images=${deleteImages}`;
|
||||
}
|
||||
|
||||
export async function deleteCurrentSessionFromTopMenu() {
|
||||
const sid = currentSessionId;
|
||||
if (!sid) {
|
||||
@@ -2678,7 +2686,7 @@ export async function deleteCurrentSessionFromTopMenu() {
|
||||
uiModule.showToast('Unfavorite before deleting');
|
||||
return false;
|
||||
}
|
||||
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
|
||||
if (!await _confirmSessionDeletion([sid])) {
|
||||
return false;
|
||||
}
|
||||
if (window.chatModule && window.chatModule.abortCurrentRequest) {
|
||||
@@ -2692,7 +2700,7 @@ export async function deleteCurrentSessionFromTopMenu() {
|
||||
if (pm.removePersistentChat) pm.removePersistentChat(sid);
|
||||
} catch (e) {}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
const res = await fetch(_sessionDeletionUrl(sid), { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
uiModule.showToast('Session deleted');
|
||||
} catch (e) {
|
||||
@@ -2732,11 +2740,11 @@ async function _onSessionListKeydown(e) {
|
||||
uiModule.showToast('Unfavorite before deleting');
|
||||
return;
|
||||
}
|
||||
const ok = await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true });
|
||||
const ok = await _confirmSessionDeletion([s.id]);
|
||||
if (!ok) return;
|
||||
_sessionListFocused = true;
|
||||
(async () => {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' });
|
||||
_deselectCurrentSession(s.id);
|
||||
await loadSessions();
|
||||
})();
|
||||
@@ -2811,19 +2819,7 @@ function _updateResearchDots() {
|
||||
|
||||
if (listItem) {
|
||||
let state = listItem.querySelector('.session-run-state');
|
||||
if (isRunning || isCompleted) {
|
||||
if (!state) {
|
||||
state = document.createElement('span');
|
||||
state.className = 'session-run-state';
|
||||
const menu = listItem.querySelector('.session-menu-btn');
|
||||
listItem.insertBefore(state, menu || null);
|
||||
}
|
||||
const alreadyRunning = state.classList.contains('is-working') && !!state._whirlpool;
|
||||
const alreadyDone = state.classList.contains('is-done') && state.textContent.trim() === '✓';
|
||||
if ((isRunning && !alreadyRunning) || (isCompleted && !alreadyDone)) {
|
||||
_renderSessionRunState(state, isRunning);
|
||||
}
|
||||
} else if (state) {
|
||||
if (state) {
|
||||
if (state._whirlpool) state._whirlpool.destroy();
|
||||
state.remove();
|
||||
}
|
||||
@@ -3244,9 +3240,9 @@ async function _arcRestore(sid) {
|
||||
}
|
||||
|
||||
async function _arcDelete(sid) {
|
||||
if (!await window.styledConfirm('Delete this session permanently?', { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await _confirmSessionDeletion([sid], 'Delete this chat permanently?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
const res = await fetch(_sessionDeletionUrl(sid), { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
await _animateSessionRowsRemoving([sid], '#archive-grid .archive-row[data-session-id]');
|
||||
_arcRemove(sid);
|
||||
@@ -3279,12 +3275,12 @@ async function _arcBulkRestore() {
|
||||
async function _arcBulkDelete() {
|
||||
const ids = [..._arc.selected];
|
||||
if (!ids.length) return;
|
||||
const ok = await uiModule.styledConfirm(`Delete ${ids.length} session${ids.length > 1 ? 's' : ''} permanently?`, { confirmText: 'Delete', danger: true });
|
||||
const ok = await _confirmSessionDeletion(ids, `Delete ${ids.length} chats permanently?`);
|
||||
if (!ok) return;
|
||||
const deletedIds = [];
|
||||
for (const sid of ids) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
const res = await fetch(_sessionDeletionUrl(sid), { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
deletedIds.push(sid);
|
||||
_arcRemove(sid);
|
||||
@@ -3593,9 +3589,12 @@ export function openLibrary(defaultTab) {
|
||||
|
||||
// Bulk delete
|
||||
document.getElementById('lib-bulk-delete').addEventListener('click', async () => {
|
||||
if (!await uiModule.styledConfirm(`Delete ${_lib.selected.size} items?`, { confirmText: 'Delete', danger: true })) return;
|
||||
const confirmed = (_lib.tab === 'chats' || _lib.tab === 'archive')
|
||||
? await _confirmSessionDeletion([..._lib.selected], `Delete ${_lib.selected.size} chats?`)
|
||||
: await uiModule.styledConfirm(`Delete ${_lib.selected.size} items?`, { confirmText: 'Delete', danger: true });
|
||||
if (!confirmed) return;
|
||||
if (_lib.tab === 'chats' || _lib.tab === 'archive') {
|
||||
for (const sid of _lib.selected) await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
for (const sid of _lib.selected) await fetch(_sessionDeletionUrl(sid), { method: 'DELETE' });
|
||||
} else if (_lib.tab === 'documents') {
|
||||
for (const did of _lib.selected) await fetch(`${API_BASE}/api/document/${did}`, { method: 'DELETE' });
|
||||
} else if (_lib.tab === 'research') {
|
||||
@@ -3660,7 +3659,7 @@ function _renderLibChats(grid) {
|
||||
_showDropdown(e.currentTarget, [
|
||||
{ label: 'Open', action: () => { closeLibrary(); selectSession(s.id); } },
|
||||
{ label: 'Archive', action: async () => { await fetch(`${API_BASE}/api/session/${s.id}/archive`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); await loadSessions(); _renderLibGrid(); } },
|
||||
{ label: 'Delete', action: async () => { if (!await uiModule.styledConfirm('Delete?', { confirmText: 'Delete', danger: true })) return; await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); await loadSessions(); _renderLibGrid(); }, danger: true },
|
||||
{ label: 'Delete', action: async () => { if (!await _confirmSessionDeletion([s.id])) return; await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' }); await loadSessions(); _renderLibGrid(); }, danger: true },
|
||||
]);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
@@ -3690,7 +3689,7 @@ async function _renderLibArchive(grid) {
|
||||
e.stopPropagation();
|
||||
_showDropdown(e.currentTarget, [
|
||||
{ label: 'Restore', action: async () => { await fetch(`${API_BASE}/api/session/${s.id}/restore`, { method: 'POST' }); await loadSessions(); _renderLibGrid(); } },
|
||||
{ label: 'Delete', action: async () => { if (!await uiModule.styledConfirm('Delete?', { confirmText: 'Delete', danger: true })) return; await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' }); _renderLibGrid(); }, danger: true },
|
||||
{ label: 'Delete', action: async () => { if (!await _confirmSessionDeletion([s.id])) return; await fetch(_sessionDeletionUrl(s.id), { method: 'DELETE' }); _renderLibGrid(); }, danger: true },
|
||||
]);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
|
||||
+26
-16
@@ -23,6 +23,7 @@ import {
|
||||
hideSettingsModal,
|
||||
} from './settings/lifecycle.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { modelCaps } from './editor/ai-models.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -47,12 +48,21 @@ let _authPolicy = { password_min_length: 8 };
|
||||
*/
|
||||
async function _postSettings(body) {
|
||||
try {
|
||||
return await fetch('/api/auth/settings', {
|
||||
const response = await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.status === 403
|
||||
? 'Admin access is required to change these settings.'
|
||||
: 'Failed to save');
|
||||
error.status = response.status;
|
||||
if (response.status === 403) uiModule.showError(error.message);
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
invalidateSettings();
|
||||
}
|
||||
@@ -432,7 +442,7 @@ async function initDefaultChat() {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
_registerAiEndpointRefresh(function(endpoints) {
|
||||
@@ -489,7 +499,7 @@ async function initUtilityModel() {
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 1500);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
||||
@@ -582,7 +592,7 @@ async function initTeacherModel() {
|
||||
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
|
||||
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
if (enabledToggle) {
|
||||
@@ -613,9 +623,9 @@ async function initImageSettings() {
|
||||
const endpoints = await endpointsRes.json();
|
||||
const imageModels = new Set();
|
||||
(Array.isArray(endpoints) ? endpoints : []).forEach(endpoint => {
|
||||
if (!endpoint.is_enabled || !endpoint.online || String(endpoint.model_type || '').toLowerCase() !== 'image') return;
|
||||
if (!endpoint.is_enabled) return;
|
||||
(Array.isArray(endpoint.models) ? endpoint.models : []).forEach(modelId => {
|
||||
if (modelId) imageModels.add(String(modelId));
|
||||
if (modelId && modelCaps(modelId, '', endpoint.model_type).gen) imageModels.add(String(modelId));
|
||||
});
|
||||
});
|
||||
sortModelIds(Array.from(imageModels)).forEach(mid => {
|
||||
@@ -646,7 +656,7 @@ async function initImageSettings() {
|
||||
const res = await _postSettings({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
modelSel.addEventListener('change', saveSettings);
|
||||
qualSel.addEventListener('change', saveSettings);
|
||||
@@ -719,7 +729,7 @@ async function initVisionSettings() {
|
||||
try {
|
||||
await _postSettings({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value });
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
vlSel.addEventListener('change', saveSettings);
|
||||
if (enabledToggle) enabledToggle.addEventListener('change', function() { syncVisionDisabled(); saveSettings(); });
|
||||
@@ -801,7 +811,7 @@ async function initTtsSettings() {
|
||||
await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
|
||||
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
|
||||
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
|
||||
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
} catch (e) { ttsMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
async function saveAndClearCache() {
|
||||
@@ -964,7 +974,7 @@ async function initSttSettings() {
|
||||
// Notify voiceRecorder of effective provider and update send button icon
|
||||
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
} catch (e) { sttMsg.textContent = 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
} catch (e) { sttMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; sttMsg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); });
|
||||
@@ -1119,7 +1129,7 @@ async function initSearchSettings() {
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(refreshStatus, 2000);
|
||||
if (searchModule && searchModule.refresh) searchModule.refresh();
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
provSel.addEventListener('change', function() { updateVisibility(); saveSearch(); _syncSearchPicker(); });
|
||||
@@ -1270,7 +1280,7 @@ async function initSearchSettings() {
|
||||
await _postSettings({ search_fallback_chain: chain });
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(refreshStatus, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
_renderFallbackChain();
|
||||
}
|
||||
_renderFallbackChain();
|
||||
@@ -1423,7 +1433,7 @@ async function initResearchSettings() {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(showStatus, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
epSel.addEventListener('change', async function() {
|
||||
@@ -1487,7 +1497,7 @@ async function initResearchSearchSettings() {
|
||||
await _postSettings({ research_search_provider: searchSel.value });
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
setTimeout(function() { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
searchSel.addEventListener('change', function() { updateSearchLogo(); saveResearchSearch(); });
|
||||
@@ -1531,7 +1541,7 @@ async function initAgentSettings() {
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
@@ -2217,7 +2227,7 @@ async function initDocumentWritingStyle() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (msg) msg.textContent = '✓ Saved';
|
||||
} catch (_) {
|
||||
if (msg) msg.textContent = 'Failed to save';
|
||||
if (msg) msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save';
|
||||
}
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { registerEscapeLayer } from './escMenuStack.js';
|
||||
|
||||
// Give every tutorial hint the same button, timeout, and Escape lifecycle.
|
||||
// The global Escape arbiter closes one layer before its underlying window.
|
||||
export function bindTourHintDismiss(hint, { timeout, fade = 280, onDismiss } = {}) {
|
||||
let closed = false;
|
||||
let timer;
|
||||
const button = hint.querySelector('.tour-hint-dismiss');
|
||||
const unregister = registerEscapeLayer(close, () => hint.isConnected && !closed);
|
||||
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearTimeout(timer);
|
||||
unregister();
|
||||
button?.removeEventListener('click', close);
|
||||
hint.classList.add('tour-hint-out');
|
||||
setTimeout(() => hint.remove(), fade);
|
||||
onDismiss?.();
|
||||
}
|
||||
|
||||
button?.addEventListener('click', close);
|
||||
if (timeout > 0) timer = setTimeout(close, timeout);
|
||||
hint._dismiss = close;
|
||||
return close;
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// fullscreened by dragging the title bar. Shown once globally — once the
|
||||
// user has dismissed it (or it auto-hides), it never returns.
|
||||
|
||||
import { bindTourHintDismiss } from './tourHintDismiss.js';
|
||||
|
||||
const HINT_SEEN_KEY = 'odysseus-hint-drag-to-snap-seen';
|
||||
|
||||
// Allow-list of modals where the snap/fullscreen hint makes sense.
|
||||
@@ -114,14 +116,7 @@ function _show(modal) {
|
||||
pop.classList.add('tour-hint-in');
|
||||
});
|
||||
|
||||
const dismiss = () => {
|
||||
pop.classList.add('tour-hint-out');
|
||||
setTimeout(() => pop.remove(), 280);
|
||||
_markSeen();
|
||||
};
|
||||
pop.querySelector('.tour-hint-dismiss').addEventListener('click', dismiss);
|
||||
// Auto-dismiss after 14s so it doesn't linger forever.
|
||||
setTimeout(() => { if (pop.isConnected) dismiss(); }, 14000);
|
||||
bindTourHintDismiss(pop, { timeout: 14000, onDismiss: _markSeen });
|
||||
}
|
||||
|
||||
function _watchModals() {
|
||||
|
||||
@@ -7,11 +7,12 @@ const EMPTY = '["",[],[]]';
|
||||
const normalize = text => text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
export function startsContinuationRound(event) {
|
||||
if (!event || event.type !== 'agent_step') return false;
|
||||
if (!event || event.type !== 'agent_step' || event.stage) return false;
|
||||
const round = Number(event.round);
|
||||
// The initial response bubble already owns round 1. Replacing it before the
|
||||
// first token causes a visible flash and discards its in-flight UI state.
|
||||
return !Number.isFinite(round) || round > 1;
|
||||
// Only an explicit later round needs a new bubble. Status-only steps (such
|
||||
// as task classification) and round 1 belong to the processing bubble;
|
||||
// replacing it replays its entrance animation just before the first token.
|
||||
return Number.isInteger(round) && round > 1;
|
||||
}
|
||||
|
||||
function signature(root, live) {
|
||||
|
||||
+5
-1
@@ -803,6 +803,7 @@ export function styledPrompt(message, {
|
||||
confirmText = 'Save',
|
||||
cancelText = 'Cancel',
|
||||
maxLength = 80,
|
||||
inputType = 'text',
|
||||
} = {}) {
|
||||
return new Promise(resolve => {
|
||||
let overlay = document.getElementById('styled-prompt-overlay');
|
||||
@@ -835,6 +836,8 @@ export function styledPrompt(message, {
|
||||
msgEl.textContent = message || '';
|
||||
msgEl.style.display = message ? '' : 'none';
|
||||
input.value = defaultValue || '';
|
||||
input.type = inputType === 'password' ? 'password' : 'text';
|
||||
input.autocomplete = inputType === 'password' ? 'new-password' : 'off';
|
||||
input.placeholder = placeholder || '';
|
||||
input.maxLength = maxLength;
|
||||
okBtn.textContent = confirmText;
|
||||
@@ -846,6 +849,7 @@ export function styledPrompt(message, {
|
||||
overlay.style.display = '';
|
||||
|
||||
function cleanup(result) {
|
||||
input.value = '';
|
||||
overlay.classList.add('hidden');
|
||||
overlay.style.display = 'none';
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
@@ -856,7 +860,7 @@ export function styledPrompt(message, {
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup((input.value || '').trim()); }
|
||||
function onOk() { cleanup(inputType === 'password' ? input.value : (input.value || '').trim()); }
|
||||
function onCancel() { cleanup(null); }
|
||||
function onBackdrop(e) { if (e.target === overlay) cleanup(null); }
|
||||
function onKey(e) {
|
||||
|
||||
+209
-69
@@ -1587,9 +1587,9 @@ body.bg-pattern-ascii-fireflies {
|
||||
/* Completed runs keep the provider slot but turn it into a compact
|
||||
success check, so the logo does not simply appear to vanish. */
|
||||
width: 14px; height: 14px;
|
||||
background: var(--color-success, #4caf50);
|
||||
border-color: var(--color-success, #4caf50);
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--accent, var(--red));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1598,12 +1598,11 @@ body.bg-pattern-ascii-fireflies {
|
||||
done state so the solid notif dot doesn't collide with the SVG behind it. */
|
||||
.session-star.notify svg { display: none; }
|
||||
.session-star.notify::after {
|
||||
content: '\2713';
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
transform: translateY(-0.5px);
|
||||
content: '';
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: currentColor;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolyline points='20 6 9 17 4 12' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
@keyframes research-done-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
@@ -2788,7 +2787,7 @@ body.bg-pattern-ascii-fireflies {
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Reasoning effort pill / dropdown in composer */
|
||||
/* Reasoning effort dropdown in Chat Context */
|
||||
.reasoning-effort-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -2862,11 +2861,23 @@ body.bg-pattern-ascii-fireflies {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@container chatbar (max-width: 480px) {
|
||||
.reasoning-effort-prefix { display: none; }
|
||||
.chat-context-popup .reasoning-effort-wrap {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.reasoning-effort-prefix { display: none; }
|
||||
.chat-context-popup .reasoning-effort-btn {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
}
|
||||
.chat-context-popup .reasoning-effort-prefix {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.chat-context-popup .reasoning-effort-menu {
|
||||
top: calc(100% + 4px);
|
||||
bottom: auto;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.input-icon-btn {
|
||||
background: none;
|
||||
@@ -4174,6 +4185,16 @@ body.bg-pattern-ascii-fireflies {
|
||||
font-size:0.75rem; color:var(--color-muted-alt);
|
||||
margin:0; opacity:1;
|
||||
}
|
||||
.msg-footer-enter {
|
||||
animation: msg-footer-enter 180ms ease-out both;
|
||||
}
|
||||
@keyframes msg-footer-enter {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg-footer-enter { animation: none; }
|
||||
}
|
||||
.msg-footer .response-metrics {
|
||||
font-size:0.75rem; color:var(--color-muted-alt);
|
||||
transition: color .15s;
|
||||
@@ -4572,6 +4593,14 @@ body.bg-pattern-ascii-fireflies {
|
||||
.continue-btn.resume-btn:hover {
|
||||
border:none;
|
||||
}
|
||||
.agent-finish-editor {
|
||||
margin: 4px 0 6px 16px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.agent-finish-editor:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.continue-btn.resume-btn .resume-btn-icon {
|
||||
display:block;
|
||||
width:16px;
|
||||
@@ -4621,11 +4650,11 @@ body.bg-pattern-ascii-fireflies {
|
||||
background:var(--panel);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
padding:11px 13px 12px;
|
||||
padding:9px 12px 10px;
|
||||
font-size:0.78rem;
|
||||
color:var(--fg);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,0.4);
|
||||
min-width:250px;
|
||||
min-width:280px;
|
||||
max-width:min(360px, calc(100vw - 16px));
|
||||
line-height:1.45;
|
||||
}
|
||||
@@ -4657,19 +4686,19 @@ body.bg-pattern-ascii-fireflies {
|
||||
.ctx-stat-section {
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:4px;
|
||||
gap:2px;
|
||||
}
|
||||
.ctx-stat-section + .ctx-stat-section {
|
||||
margin-top:9px;
|
||||
padding-top:8px;
|
||||
margin-top:6px;
|
||||
padding-top:6px;
|
||||
border-top:1px solid color-mix(in srgb, var(--border) 45%, transparent);
|
||||
}
|
||||
.ctx-stat-row {
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0, 1fr) auto;
|
||||
grid-template-columns:minmax(82px, 1fr) minmax(0, 2fr);
|
||||
align-items:baseline;
|
||||
gap:14px;
|
||||
min-height:18px;
|
||||
gap:10px;
|
||||
min-height:16px;
|
||||
}
|
||||
.ctx-stat-row .ctx-label {
|
||||
white-space:nowrap;
|
||||
@@ -4679,6 +4708,8 @@ body.bg-pattern-ascii-fireflies {
|
||||
text-align:right;
|
||||
font-variant-numeric:tabular-nums;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.edit-btn {
|
||||
background:none; border:none;
|
||||
@@ -6611,6 +6642,10 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
|
||||
.confirm-btn-secondary:hover { background:var(--border); }
|
||||
#styled-confirm-ok {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
#styled-confirm-cancel .styled-confirm-cancel-label {
|
||||
position:relative;
|
||||
top:2px;
|
||||
@@ -16849,7 +16884,8 @@ mark.doc-find-mark.current {
|
||||
.diff-chunk-btn-reject:hover { border-color: #f2cc60; background: color-mix(in srgb, #f2cc60 15%, transparent); }
|
||||
|
||||
.doc-suggestion-accept-all {
|
||||
flex: 1;
|
||||
flex: 1.3;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -16858,15 +16894,16 @@ mark.doc-find-mark.current {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
color: var(--bg);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.doc-suggestion-accept-all:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 85%, var(--bg));
|
||||
}
|
||||
.doc-suggestion-reason {
|
||||
opacity: 0.6;
|
||||
@@ -16931,10 +16968,11 @@ mark.doc-find-mark.current {
|
||||
/* Mobile: suggestion card overlays top of editor (no room on side) */
|
||||
@media (max-width: 768px) {
|
||||
.doc-suggestion-card {
|
||||
right: 8px;
|
||||
box-sizing: border-box;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
top: 8px !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.doc-suggestion-card::before { display: none; }
|
||||
}
|
||||
@@ -17963,6 +18001,15 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.adm-endpoint-select-label {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin-right: 5px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm-model-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -31014,11 +31061,50 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.ge-editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
padding: 3px 10px;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.ge-editor-footer .ge-canvas-size {
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--fg) 65%, transparent);
|
||||
}
|
||||
.ge-footer-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ge-footer-zoom > .ge-btn { min-width: 28px; min-height: 26px; }
|
||||
.ge-footer-zoom .ge-zoom-stack { flex-direction: row; gap: 5px; min-width: 56px; }
|
||||
.ge-footer-zoom .ge-zoom-label { top: 0; left: 0; font-size: 11px; letter-spacing: 0; opacity: 0.8; }
|
||||
.ge-footer-zoom .ge-zoom-glyph { left: 0; }
|
||||
.ge-footer-zoom .ge-stacked-btn { min-width: 48px; }
|
||||
.gallery-editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
}
|
||||
.gallery-editor-container input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
.gallery-editor-container input[type="number"]::-webkit-inner-spin-button,
|
||||
.gallery-editor-container input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Toolbar (left) */
|
||||
@keyframes ge-layer-action-flash {
|
||||
@@ -34673,6 +34759,7 @@ button .spinner-whirlpool {
|
||||
}
|
||||
.ge-layers-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
@@ -34684,6 +34771,14 @@ button .spinner-whirlpool {
|
||||
position: relative;
|
||||
}
|
||||
.ge-layers-title { flex: 1; }
|
||||
.ge-layers-header #ge-add-layer { flex: 0 0 auto; white-space: nowrap; }
|
||||
.ge-layer-header-tools {
|
||||
display: flex;
|
||||
flex: 0 0 100%;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ge-layers-grab { display: none; } /* mobile only */
|
||||
.ge-layers-merge-wrap { position: relative; display: inline-flex; }
|
||||
.ge-layers-merge-wrap #ge-layers-merge-btn { position: relative; top: 0; }
|
||||
@@ -34756,9 +34851,10 @@ button .spinner-whirlpool {
|
||||
/* Cap the list so a huge layer stack scrolls within its own box
|
||||
rather than pushing the whole panel arbitrarily tall. */
|
||||
max-height: 320px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
.ge-layer-item {
|
||||
display: flex;
|
||||
@@ -34830,9 +34926,12 @@ button .spinner-whirlpool {
|
||||
/* Row-hover tint removed — kept the layer row's background stable so
|
||||
it doesn't shift visually as the user reaches for the opacity slider. */
|
||||
.ge-layer-item.active {
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
border-left-color: var(--red);
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 52%, var(--bg-secondary, var(--bg)));
|
||||
border-left-color: var(--accent, var(--red));
|
||||
box-shadow: inset 3px 0 0 var(--accent, var(--red)), inset 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 80%, transparent);
|
||||
}
|
||||
.ge-layer-item.active > .ge-layer-name,
|
||||
.ge-layer-group-row.active > .ge-layer-group-name { font-weight: 700; }
|
||||
/* "Active parent" — the layer is selected, but a mask sub-layer below
|
||||
it is the actual paint target. Soft hint so the user can see which
|
||||
parent owns the active mask without competing with the mask's
|
||||
@@ -34842,7 +34941,7 @@ button .spinner-whirlpool {
|
||||
border-left-color: color-mix(in srgb, var(--fg) 30%, transparent);
|
||||
}
|
||||
.ge-layer-item.selected:not(.active):not(.active-parent) {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 7%, transparent);
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 22%, var(--bg));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
|
||||
}
|
||||
.ge-layer-group-row {
|
||||
@@ -34915,8 +35014,8 @@ button .spinner-whirlpool {
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--bg) 65%, transparent);
|
||||
}
|
||||
.ge-layer-group-row.active {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, var(--bg-secondary));
|
||||
box-shadow: inset 2px 0 0 var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 38%, var(--bg-secondary));
|
||||
box-shadow: inset 2px 0 0 var(--accent, var(--red)), inset 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 50%, transparent);
|
||||
}
|
||||
.ge-layer-group-toggle {
|
||||
width: 18px;
|
||||
@@ -35112,28 +35211,40 @@ button .spinner-whirlpool {
|
||||
}
|
||||
.ge-mask-properties {
|
||||
position: relative;
|
||||
flex: 0 0 30px;
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
.ge-mask-properties > summary {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
border: 1px solid transparent;
|
||||
opacity: 0.4;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.ge-mask-properties > summary::-webkit-details-marker { display: none; }
|
||||
.ge-mask-properties > summary > svg { width: 12px; height: 12px; flex: none; }
|
||||
.ge-mask-properties > summary:hover,
|
||||
.ge-mask-properties[open] > summary {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ge-mask-properties[open] > summary { color: var(--accent, var(--red)); opacity: 0.95; }
|
||||
.ge-mask-properties-panel {
|
||||
position: fixed;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
bottom: auto;
|
||||
overflow: auto;
|
||||
color: var(--fg);
|
||||
z-index: 9999;
|
||||
right: auto;
|
||||
top: 0;
|
||||
@@ -35146,6 +35257,7 @@ button .spinner-whirlpool {
|
||||
background: var(--bg-secondary, var(--bg));
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.28);
|
||||
}
|
||||
.ge-mask-properties-panel:not(:popover-open) { display: none; }
|
||||
.ge-mask-sub-item:has(.ge-mask-properties[open]) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
@@ -35174,6 +35286,19 @@ button .spinner-whirlpool {
|
||||
.ge-layers-list > .ge-layer-group-row > .ge-layer-controls {
|
||||
display: none;
|
||||
}
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active-parent > .ge-layer-controls,
|
||||
.ge-layers-list > .ge-layer-group-row.active > .ge-layer-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
.ge-layer-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -35286,6 +35411,27 @@ button .spinner-whirlpool {
|
||||
.ge-layer-btn:hover { opacity: 0.8; }
|
||||
.ge-layer-btn.active { color: var(--accent, var(--red)); opacity: 0.95; }
|
||||
.ge-layer-btn:disabled { cursor: default; opacity: 0.16; }
|
||||
.ge-layer-style-btn { font-style: italic; font-weight: 700; font-size: 13px; }
|
||||
.ge-layer-style-menu {
|
||||
animation: none;
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
min-width: 170px;
|
||||
max-width: calc(100vw - 12px);
|
||||
max-height: calc(100dvh - 12px);
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel, var(--bg));
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px #0005;
|
||||
}
|
||||
.ge-layer-style-menu:not(:popover-open) { display: none; }
|
||||
.ge-layer-style-menu:popover-open { display: block; }
|
||||
.ge-layer-style-menu > button { width: 100%; border: 0; background: none; font: inherit; text-align: left; }
|
||||
.ge-layer-btn.danger:hover { color: var(--red); opacity: 1; }
|
||||
|
||||
.ge-layer-lock-menu {
|
||||
@@ -38103,6 +38249,28 @@ body.doc-find-active mark.doc-find-mark.current {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Code uses non-wrapping, identical lines in both layers. Prose keeps the
|
||||
single-layer caret fix above. Colors come from the current theme's --hl-*.
|
||||
Find mode retains its own overlay and visible textarea text. */
|
||||
.doc-code-syntax .doc-editor-textarea,
|
||||
.doc-code-syntax .doc-editor-highlight,
|
||||
.doc-code-syntax .doc-editor-highlight code {
|
||||
white-space: pre !important;
|
||||
overflow-wrap: normal !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
.doc-code-syntax .doc-editor-highlight code {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
body:not(.doc-find-active) .doc-code-syntax:not(.diff-mode) .doc-editor-highlight {
|
||||
display: block !important;
|
||||
}
|
||||
body:not(.doc-find-active) .doc-code-syntax:not(.diff-mode) .doc-editor-textarea {
|
||||
color: transparent !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Emoji picker (monochrome icon picker) */
|
||||
.emoji-picker-btn {
|
||||
background: none; border: none; color: var(--fg); opacity: 0.35;
|
||||
@@ -42149,34 +42317,6 @@ body.notes-drag-mode .note-card-pin svg {
|
||||
.note-corner-menu-dropdown .ncm-item:hover {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
.note-checkbox-agent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
margin: 0 1px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent, var(--red));
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.note-checkbox:hover .note-checkbox-agent { opacity: 0.55; }
|
||||
.note-checkbox-agent:hover {
|
||||
background: transparent;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.note-checkbox-agent.is-agent-stream-complete {
|
||||
color: #50fa7b;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.note-checkbox-agent svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
/* Same tint that .doclib-card uses so a default (uncolored) note
|
||||
|
||||
Reference in New Issue
Block a user