mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58773fe4ef |
@@ -1,7 +1,7 @@
|
||||
# Optional dependencies — install only if you use the corresponding feature.
|
||||
# Local OCR for screenshots, scans, labels, and coordinate-grounded text extraction.
|
||||
rapidocr==3.9.2
|
||||
onnxruntime>=1.20,<2
|
||||
onnxruntime>=1.30.0,<2
|
||||
# The app handles their absence gracefully (clear error message on first use).
|
||||
#
|
||||
# Note: chromadb-client + fastembed moved to requirements.txt — RAG, semantic
|
||||
@@ -46,7 +46,7 @@ PyMuPDF
|
||||
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||
# the dependency-age discussion in issue #485.
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.8
|
||||
|
||||
# Photoshop PSD opening / flattened previews / layer inspection.
|
||||
psd-tools
|
||||
|
||||
+4
-4
@@ -3,9 +3,9 @@ uvicorn
|
||||
python-multipart
|
||||
python-dotenv
|
||||
httpx
|
||||
httpcore>=1.0,<2.0
|
||||
pydantic>=2.13.4
|
||||
pydantic-settings>=2.14.1
|
||||
httpcore>=1.0.9,<2.0
|
||||
pydantic>=2.13.5
|
||||
pydantic-settings>=2.15.0
|
||||
SQLAlchemy
|
||||
pypdf
|
||||
pypdfium2
|
||||
@@ -46,7 +46,7 @@ bcrypt
|
||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
||||
# servers are migrated together.
|
||||
mcp<2
|
||||
mcp<3
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
croniter
|
||||
|
||||
@@ -130,30 +130,11 @@ body {
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
/* GohuFont is the default UI face across themes; keep the shipped font local
|
||||
so the default is available before custom-font discovery completes. */
|
||||
@font-face { font-family: 'GohuFont'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/custom/GohuFont.ttf') format('truetype'); }
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Self-hosted editor text fonts (SIL OFL 1.1). These are bundled so the text
|
||||
tool renders distinct, expected faces on every device — phones (Android/iOS)
|
||||
do not ship Arial/Verdana/Georgia/Times/Courier/Impact, which previously
|
||||
collapsed every font choice onto the same system fallback. */
|
||||
@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-Medium.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/Inter-SemiBold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Roboto'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Roboto'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/Roboto-Bold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Playfair Display'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Playfair Display'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/PlayfairDisplay-Bold.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Lobster'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/Lobster-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Bebas Neue'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/BebasNeue-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Roboto Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Roboto Mono'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/RobotoMono-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -704,7 +704,6 @@ textarea.memory-add-input {
|
||||
#serve-bulk-bar,
|
||||
#gallery-bulk-bar,
|
||||
#gallery-editor-drafts-bulk,
|
||||
#notes-bulk-bar,
|
||||
#memory-modal .memory-bulk-bar {
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
@@ -90,12 +90,6 @@
|
||||
.lib-tabs,
|
||||
.gallery-tabs,
|
||||
.preset-tabs {
|
||||
/* A flex-item tab strip defaults to min-width:auto, so it grows to its
|
||||
content width inside a narrow modal and the end tabs get clipped by the
|
||||
modal's overflow instead of scrolling. min-width:0 lets the strip shrink
|
||||
to the container so the overflow-x:auto below actually engages. */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
|
||||
+1
-61
@@ -18,13 +18,7 @@
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = [
|
||||
'#sidebar .list-item',
|
||||
'#user-bar-profile',
|
||||
'.sidebar-brand',
|
||||
'.export-dropdown-item',
|
||||
'.dropdown-item'
|
||||
].join(',');
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
|
||||
|
||||
// Native interactive descendants. If a row contains one of these we must
|
||||
// NOT give the row role="button" — a button inside a button is invalid
|
||||
@@ -61,31 +55,6 @@
|
||||
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
|
||||
}
|
||||
|
||||
// Settings form labels are written as <label class="settings-label">Text</label>
|
||||
// with the control as a sibling/descendant, but are not associated via for/id,
|
||||
// so screen readers cannot name the inputs. Associate each label with its
|
||||
// control when the mapping is unambiguous (a single form control per row).
|
||||
var settingsLabelSeq = 0;
|
||||
function associateSettingsLabels(root) {
|
||||
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
|
||||
if (label.hasAttribute('for')) return;
|
||||
var control = null;
|
||||
var sib = label.nextElementSibling;
|
||||
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
|
||||
control = sib;
|
||||
} else {
|
||||
var row = label.closest('.settings-row') || label.parentElement;
|
||||
if (row) {
|
||||
var controls = row.querySelectorAll('input,select,textarea');
|
||||
if (controls.length === 1) control = controls[0];
|
||||
}
|
||||
}
|
||||
if (!control || control.closest('label')) return;
|
||||
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
|
||||
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Modal dialogs -----------------------------------------------------
|
||||
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
|
||||
// them as ARIA dialogs lets screen readers announce them as dialogs and
|
||||
@@ -149,38 +118,9 @@
|
||||
el.click();
|
||||
});
|
||||
|
||||
// Disclosure buttons: keep aria-expanded in sync with their controlled
|
||||
// menu/dropdown, regardless of which module toggles its visibility.
|
||||
var DISCLOSURE_MAP = [
|
||||
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
|
||||
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
|
||||
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
|
||||
];
|
||||
|
||||
function syncDisclosures() {
|
||||
DISCLOSURE_MAP.forEach(function (d) {
|
||||
var btn = document.getElementById(d.button);
|
||||
if (!btn) return;
|
||||
var menu = document.getElementById(d.menu);
|
||||
var open = false;
|
||||
if (menu) {
|
||||
var cs = window.getComputedStyle(menu);
|
||||
open = cs.display !== 'none' && menu.offsetWidth > 0;
|
||||
}
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
enhanceAll(document);
|
||||
enhanceModals(document);
|
||||
associateSettingsLabels(document);
|
||||
|
||||
// Any click (the dropdown toggles, outside-click closes, etc.) may change
|
||||
// a disclosure's open state; re-sync on the next tick.
|
||||
document.addEventListener('click', function () {
|
||||
if (window.setTimeout) setTimeout(syncDisclosures, 0);
|
||||
});
|
||||
|
||||
// Sidebar content is re-rendered as the user navigates (session lists,
|
||||
// tool sub-rows, etc.). Watch for new rows and enhance them too.
|
||||
|
||||
@@ -499,7 +499,7 @@ function _eventSourceHtml(ev) {
|
||||
// calendar name). The calendar-initial badge alone is multi-calendar UI.
|
||||
if (ev.source_email_uid && ev.source_email_folder) {
|
||||
const href = `#email=${encodeURIComponent(ev.source_email_folder)}:${encodeURIComponent(ev.source_email_uid)}`;
|
||||
return `<a class="cal-event-source cal-event-source-email" href="${_e(href)}" title="Open source email" aria-label="Open source email"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><polyline points="3 7 12 13 21 7"></polyline></svg></a>`;
|
||||
return `<a class="cal-event-source cal-event-source-email" href="${_e(href)}" title="Open source email" aria-label="Open source email" onclick="event.stopPropagation();"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"></rect><polyline points="3 7 12 13 21 7"></polyline></svg></a>`;
|
||||
}
|
||||
if (_calendars.length <= 1) return '';
|
||||
const cal = _calendars.find(c => c.href === ev.calendar_href);
|
||||
@@ -764,7 +764,7 @@ function _getModal() {
|
||||
<div class="modal-content cal-modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>Calendar</h4>
|
||||
<button class="close-btn" id="cal-close" aria-label="Close">✖</button>
|
||||
<button class="close-btn" id="cal-close">✖</button>
|
||||
</div>
|
||||
<div class="modal-body" id="cal-body"></div>
|
||||
<button class="cal-side-nav cal-side-nav-prev" id="cal-side-prev" title="Previous" aria-label="Previous">‹</button>
|
||||
@@ -897,7 +897,7 @@ function _updateDaySearchResults() {
|
||||
// Re-wire click handlers on the newly-inserted event rows.
|
||||
dayDetail.querySelectorAll('.cal-event-item').forEach(it => {
|
||||
it.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(x => x.uid === it.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
});
|
||||
@@ -2230,7 +2230,7 @@ async function _renderAgenda() {
|
||||
_wireAll(body);
|
||||
_wireQuickDelete(body);
|
||||
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(e => e.uid === el.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -2295,7 +2295,7 @@ async function _renderSearch() {
|
||||
_wireAll(body);
|
||||
_wireQuickDelete(body);
|
||||
body.querySelectorAll('.cal-agenda-event').forEach(el => el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _allEvents[el.dataset.uid];
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -2984,7 +2984,7 @@ function _wireAll(body) {
|
||||
_render();
|
||||
}));
|
||||
body.querySelectorAll('.cal-event-item').forEach(it => it.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) return;
|
||||
if (e.target.closest('.cal-event-more, .cal-event-source')) return;
|
||||
const ev = _events.find(e => e.uid === it.dataset.uid);
|
||||
if (ev) _showEventForm(ev);
|
||||
}));
|
||||
@@ -4170,7 +4170,7 @@ function _locHTML(loc, description = '') {
|
||||
if (meetingMatch && !/https?:\/\/(?:teams\.microsoft\.com|(?:[a-z0-9-]+\.)?zoom\.us|meet\.google\.com|(?:[a-z0-9-]+\.)?webex\.com|meet\.jit\.si)\//i.test(String(loc))) {
|
||||
const meetingUrl = meetingMatch[0].replace(/[.,);\]]+$/, '');
|
||||
const safeMeetingUrl = _e(meetingUrl);
|
||||
return `<a class="cal-event-link" href="${safeMeetingUrl}" target="_blank" rel="noopener" title="Join meeting">${safeMeetingUrl}</a>`;
|
||||
return `<a href="${safeMeetingUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Join meeting">${safeMeetingUrl}</a>`;
|
||||
}
|
||||
const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
|
||||
if (urlRe.test(loc)) {
|
||||
@@ -4182,14 +4182,14 @@ function _locHTML(loc, description = '') {
|
||||
const url = match[0];
|
||||
html += _e(String(loc).slice(offset, match.index));
|
||||
const safe = _e(url);
|
||||
html += `<a class="cal-event-link" href="${safe}" target="_blank" rel="noopener">${safe}</a>`;
|
||||
html += `<a href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
|
||||
offset = match.index + url.length;
|
||||
}
|
||||
return (html + _e(String(loc).slice(offset))).replace(/\n/g, '<br>');
|
||||
}
|
||||
// No URL — link the whole thing to OpenStreetMap.
|
||||
const mapUrl = 'https://www.openstreetmap.org/search?query=' + encodeURIComponent(loc);
|
||||
return `<a class="cal-event-link" href="${mapUrl}" target="_blank" rel="noopener" title="Open in OpenStreetMap">${_e(loc)}</a>`;
|
||||
return `<a href="${mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap">${_e(loc)}</a>`;
|
||||
}
|
||||
|
||||
// ── Open / Close ──
|
||||
|
||||
+1
-69
@@ -44,66 +44,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
|
||||
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
|
||||
// URL of the photo attached to the current turn (chat uploads are served at
|
||||
// /api/upload/<id>). Used by the "Open in editor & apply" action.
|
||||
function _currentAttachedImageUrl() {
|
||||
try {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return null;
|
||||
// Use the newest user bubble that carries an image preview. Note
|
||||
// ':last-of-type' is wrong here (it selects the last sibling of the
|
||||
// element type, and the assistant reply follows the user bubble), so
|
||||
// walk the user bubbles from the end instead.
|
||||
const users = box.querySelectorAll('.msg-user');
|
||||
for (let i = users.length - 1; i >= 0; i--) {
|
||||
const img = users[i].querySelector('.attach-image-preview[data-file-id] img');
|
||||
if (img && img.getAttribute('src')) return img.getAttribute('src');
|
||||
}
|
||||
// Fall back to any attachment preview/card in the history (newest).
|
||||
const previews = box.querySelectorAll(
|
||||
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
|
||||
);
|
||||
if (previews.length) {
|
||||
const last = previews[previews.length - 1];
|
||||
const id = last.dataset ? last.dataset.fileId : null;
|
||||
if (id) return API_BASE + '/api/upload/' + id;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render the one-click "Open in editor & apply" button on a photo-edit reply
|
||||
// that had no open editor (photo_editor_action kind 'open_and_apply').
|
||||
function renderPhotoEditorApplyButton(container, action) {
|
||||
if (!container || !action || action.kind !== 'open_and_apply') return;
|
||||
if (container.querySelector('.chat-open-in-editor-btn')) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chat-open-in-editor-btn';
|
||||
btn.textContent = 'Open in editor & apply';
|
||||
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (typeof window.gePhotoEditorApply !== 'function') {
|
||||
await import('/static/js/galleryEditor.js');
|
||||
}
|
||||
await window.gePhotoEditorApply({
|
||||
imageId: null,
|
||||
imageUrl: _currentAttachedImageUrl(),
|
||||
prompt: action.prompt || '',
|
||||
displayName: 'Chat edit',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[chat] open_and_apply failed', err);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
|
||||
let API_BASE = '';
|
||||
let currentAbort = null;
|
||||
let isStreaming = false;
|
||||
@@ -4087,7 +4027,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
threadAbove.classList.add('has-bottom');
|
||||
}
|
||||
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
|
||||
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
|
||||
uiModule.scrollHistory();
|
||||
continue;
|
||||
@@ -6142,13 +6081,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
|
||||
// The server now reports "no active stream" as 200 {status:"idle"} (an
|
||||
// older server used 404); a body with status "streaming" means it is NOT
|
||||
// stale. Accept both so the watchdog keeps working across deploys.
|
||||
let _probe = null;
|
||||
try { _probe = await res.json(); } catch (_) { _probe = null; }
|
||||
const _noActiveStream = res.status === 404 || !_probe || _probe.status !== 'streaming';
|
||||
if (!_noActiveStream) return;
|
||||
if (res.status !== 404) return;
|
||||
|
||||
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
|
||||
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
|
||||
@@ -6510,7 +6443,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
gotDelta = true;
|
||||
try { spinner.destroy(); } catch (_) {}
|
||||
renderDelta(json);
|
||||
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
|
||||
replayRendering.settle();
|
||||
} else if (json.delta) {
|
||||
if (!replayRendering.accepts(json)) continue;
|
||||
|
||||
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
|
||||
* Main entry point — called when a Run button is clicked
|
||||
*/
|
||||
export function run(btn) {
|
||||
const code = btn.getAttribute('data-code');
|
||||
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
|
||||
|
||||
const pre = btn.closest('pre');
|
||||
if (!pre) return;
|
||||
const codeEl = pre.querySelector('code');
|
||||
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
|
||||
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
|
||||
|
||||
const panel = getOrCreatePanel(pre);
|
||||
if (!code || !code.trim()) {
|
||||
|
||||
@@ -96,21 +96,6 @@ function computeSuggestions() {
|
||||
];
|
||||
}
|
||||
|
||||
// ── Reusable helpers for hosts that surface swatches inline ──────────
|
||||
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
|
||||
export function getRecentColors() { return getRecents(); }
|
||||
export function recordRecentColor(hex) { addRecent(hex); }
|
||||
export function suggestColorsFor(hex) {
|
||||
const { h, s, v } = hexToHsv(hex || '#000000');
|
||||
return [
|
||||
{ hex: hsvToHex(h + 180, s, v), label: 'Complement' },
|
||||
{ hex: hsvToHex(h + 30, s, v), label: 'Analogous +30°' },
|
||||
{ hex: hsvToHex(h - 30, s, v), label: 'Analogous -30°' },
|
||||
{ hex: hsvToHex(h + 150, s, v), label: 'Split-complement' },
|
||||
{ hex: hsvToHex(h, s, clamp(v > 50 ? v - 30 : v + 30, 10, 95)), label: 'Tone shift' },
|
||||
];
|
||||
}
|
||||
|
||||
// ── Popover build ─────────────────────────────────────────────────────
|
||||
function buildPopover() {
|
||||
const p = document.createElement('div');
|
||||
|
||||
@@ -2083,15 +2083,15 @@ export function _expandModelRow(row, modelData) {
|
||||
const _HWFIT_ENGINE_GLYPHS = {
|
||||
'': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"></path><path d="M14 4l4 9 3-9"></path></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
|
||||
};
|
||||
|
||||
function _hwfitEngineGlyph(value) {
|
||||
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
|
||||
return _HWFIT_ENGINE_GLYPHS[value] ?? _HWFIT_ENGINE_GLYPHS[''];
|
||||
}
|
||||
|
||||
const _HWFIT_USECASE_GLYPHS = {
|
||||
|
||||
@@ -1167,12 +1167,12 @@ async function _fetchDependencies() {
|
||||
// icon (the name alone is fine for librosa, hf_transfer, etc.).
|
||||
const _DEP_GLYPHS = {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
|
||||
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
|
||||
|
||||
@@ -1498,10 +1498,9 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
|
||||
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
|
||||
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
|
||||
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
||||
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
||||
// Build save slots
|
||||
const _allPresets = _loadPresets();
|
||||
@@ -2211,11 +2210,11 @@ function _rerenderCachedModels() {
|
||||
// custom picker so the dropdown lists "[V] vLLM", "[⚡] SGLang", etc.
|
||||
const _BACKEND_GLYPHS = {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
sglang: '',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
ollama: '',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
|
||||
@@ -2235,7 +2234,7 @@ function _rerenderCachedModels() {
|
||||
const opt = _backendSource?.querySelector(`option[value="${CSS.escape(v)}"]`);
|
||||
const label = opt ? opt.textContent : v;
|
||||
if (_backendBtnLabel) _backendBtnLabel.textContent = label;
|
||||
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] || _BACKEND_GLYPHS.vllm;
|
||||
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] ?? _BACKEND_GLYPHS.vllm;
|
||||
}
|
||||
|
||||
function _renderBackendMenu() {
|
||||
@@ -2243,7 +2242,7 @@ function _rerenderCachedModels() {
|
||||
const items = Array.from(_backendSource.options).map(o => ({ value: o.value, label: o.textContent }));
|
||||
_backendMenu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="hwfit-backend-item" data-value="${it.value}" style="all:unset;display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border-radius:5px;font-size:12px;cursor:pointer;color:var(--fg);box-sizing:border-box;">
|
||||
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] || _BACKEND_GLYPHS.vllm}</span>
|
||||
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] ?? _BACKEND_GLYPHS.vllm}</span>
|
||||
<span class="hwfit-backend-item-label" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
+98
-130
@@ -1996,17 +1996,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
|
||||
// next click on a page consumes it. null means clicks do nothing.
|
||||
let _pdfDropMode = null;
|
||||
let _pdfAnnotationDragging = false;
|
||||
function _setPdfAnnotationDragging(dragging) {
|
||||
_pdfAnnotationDragging = dragging;
|
||||
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
|
||||
ref._refreshHandles?.();
|
||||
if (dragging) {
|
||||
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-doc last-used line spacing for text annotations. Once the user picks
|
||||
// 1.6 for one box, every text box dropped after that defaults to 1.6.
|
||||
const _pdfLastLineHeight = new Map(); // docId -> number
|
||||
@@ -2301,16 +2290,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// active (toolbar buttons set the mode). Without a mode, clicking does
|
||||
// nothing — keeps page interactions predictable so users don't get
|
||||
// surprise boxes from stray clicks.
|
||||
const isBlankPageTarget = target => target === pageWrap
|
||||
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
|
||||
let placementStartedOnPage = false;
|
||||
pageWrap.addEventListener('pointerdown', ev => {
|
||||
placementStartedOnPage = isBlankPageTarget(ev.target);
|
||||
}, true);
|
||||
pageWrap.addEventListener('click', (ev) => {
|
||||
const canPlace = placementStartedOnPage;
|
||||
placementStartedOnPage = false;
|
||||
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
|
||||
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
|
||||
if (!_pdfDropMode) return;
|
||||
const rect = pageWrap.getBoundingClientRect();
|
||||
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
@@ -2442,11 +2423,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
// Set handle visibility together; clicking/tapping the annotation itself
|
||||
// brings hidden controls back.
|
||||
let handlesRequested = _isTouch;
|
||||
const _setHandlesVisible = (show) => {
|
||||
if (!_pdfAnnotationDragging) handlesRequested = show;
|
||||
const dismissed = wrap.dataset.controlsDismissed === '1';
|
||||
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
|
||||
const v = (show && !dismissed) ? '' : 'none';
|
||||
del.style.display = v;
|
||||
grip.style.display = v;
|
||||
resize.style.display = v;
|
||||
@@ -2467,8 +2446,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
_setHandlesVisible(true);
|
||||
});
|
||||
|
||||
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
|
||||
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
|
||||
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
|
||||
|
||||
if (kind === 'check') {
|
||||
// Stamp checkmark — value is fixed, nothing to listen for.
|
||||
@@ -2590,12 +2568,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
ref._autoGrow = _autoGrow;
|
||||
}
|
||||
|
||||
del.addEventListener('pointerdown', ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
});
|
||||
del.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_pushPdfUndoSnapshot();
|
||||
_removeAnnotation(ref);
|
||||
@@ -2607,7 +2580,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
ev.stopPropagation();
|
||||
_pushPdfUndoSnapshot();
|
||||
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||
_setPdfAnnotationDragging(true);
|
||||
// Hide the × and resize handles while moving so they don't obscure the
|
||||
// box — easier to see exactly where it lands. Restored on release.
|
||||
del.style.display = 'none';
|
||||
@@ -2626,14 +2598,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const onUp = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
_setPdfAnnotationDragging(false);
|
||||
_setHandlesVisible(true);
|
||||
_schedulePdfPaneSave();
|
||||
};
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
document.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
|
||||
// Drag bottom-right corner to resize. Width/height stored as percentages.
|
||||
@@ -2642,7 +2611,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
ev.stopPropagation();
|
||||
_pushPdfUndoSnapshot();
|
||||
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
|
||||
_setPdfAnnotationDragging(true);
|
||||
// Hide the × and move handles while resizing — clean view of the box edge.
|
||||
del.style.display = 'none';
|
||||
grip.style.display = 'none';
|
||||
@@ -2660,14 +2628,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const onUp = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
_setPdfAnnotationDragging(false);
|
||||
_setHandlesVisible(true);
|
||||
_schedulePdfPaneSave();
|
||||
};
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
document.addEventListener('pointercancel', onUp);
|
||||
});
|
||||
|
||||
// Text options menu — opened from the floating … button so the spacing
|
||||
@@ -3451,9 +3416,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}).join('');
|
||||
|
||||
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
|
||||
const probe = document.createElement('div');
|
||||
const probe = document.createElement('template');
|
||||
probe.innerHTML = text;
|
||||
const plain = (probe.innerText || probe.textContent || '').trim();
|
||||
const plain = (probe.content.textContent || '').trim();
|
||||
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
|
||||
if (plainClean !== plain) return plainClean;
|
||||
}
|
||||
@@ -3565,9 +3530,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
function _emailHtmlToPlainText(html) {
|
||||
if (typeof document === 'undefined') return String(html || '');
|
||||
const d = document.createElement('div');
|
||||
const d = document.createElement('template');
|
||||
d.innerHTML = String(html || '');
|
||||
return d.innerText || d.textContent || '';
|
||||
return d.content.textContent || '';
|
||||
}
|
||||
|
||||
function _sanitizedRichTextHtml(rich) {
|
||||
@@ -5428,15 +5393,20 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
row.dataset.id = item.id || '';
|
||||
row.dataset.kind = kind;
|
||||
if (kind === 'gallery') {
|
||||
const src = item.url ? `${API_BASE}${item.url}` : '';
|
||||
row.innerHTML = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
|
||||
<span class="email-odysseus-attach-thumb"></span>
|
||||
<span class="email-odysseus-attach-main">
|
||||
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
|
||||
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
|
||||
</span>
|
||||
`;
|
||||
if (item.url) {
|
||||
const img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.src = `${API_BASE}${item.url}`;
|
||||
row.querySelector('.email-odysseus-attach-thumb').appendChild(img);
|
||||
}
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
|
||||
@@ -6765,10 +6735,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
.trim();
|
||||
};
|
||||
const splitCurrent = _splitEmailReplyQuote(currentBody);
|
||||
const ownBody = document.createElement('div');
|
||||
const ownBody = document.createElement('template');
|
||||
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);
|
||||
const ownText = (ownBody.content.textContent || '').trim();
|
||||
const isReplaceableDraft = (!ownText && !ownBody.content.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;
|
||||
@@ -7660,8 +7630,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
<button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button>
|
||||
<span style="flex:1"></span>
|
||||
<div class="email-send-split">
|
||||
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)" onpointerdown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onmousedown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onclick="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false" onpointerdown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)" onmousedown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<div id="doc-email-more-menu" class="email-more-menu" style="display:none">
|
||||
<div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div>
|
||||
<div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div>
|
||||
@@ -15095,20 +15065,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
return _docxReady;
|
||||
}
|
||||
|
||||
let _html2pdfReady = null;
|
||||
function ensureHtml2Pdf() {
|
||||
if (_html2pdfReady) return _html2pdfReady;
|
||||
if (window.html2pdf) return (_html2pdfReady = Promise.resolve());
|
||||
_html2pdfReady = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = '/static/lib/html2pdf.bundle.min.js';
|
||||
s.onload = resolve;
|
||||
s.onerror = () => reject(new Error('Failed to load PDF library'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _html2pdfReady;
|
||||
}
|
||||
|
||||
function _getExportBaseName() {
|
||||
const doc = docs.get(activeDocId);
|
||||
const title = (doc && doc.title) || 'document';
|
||||
@@ -15117,22 +15073,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
return safeName + ver;
|
||||
}
|
||||
|
||||
function _downloadDocumentBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
try {
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
// Mobile browsers may consume the URL after the click handler returns.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
}
|
||||
|
||||
function exportDocument() {
|
||||
if (!activeDocId) return;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
@@ -15153,7 +15093,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const ver = doc && doc.version ? `_v${doc.version}` : '';
|
||||
const mime = lang === 'csv' ? 'text/csv' : lang === 'json' ? 'application/json' : _isRichTextLang(lang) ? 'text/html' : 'text/plain';
|
||||
const blob = new Blob([textarea.value], { type: mime });
|
||||
_downloadDocumentBlob(blob, safeName + ver + ext);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = safeName + ver + ext;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
// "Import from device" — open a file picker, upload, and immediately open
|
||||
@@ -15259,7 +15203,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
|
||||
function showExportMenu(e, anchorRect) {
|
||||
if (e) e.stopPropagation();
|
||||
const anchor = e?.currentTarget || e?.target?.closest('button');
|
||||
// Remove existing menu if any (toggle off) — tear it down through its
|
||||
// registered dismiss so the outside-click listener + Escape-stack entry go.
|
||||
const existing = document.getElementById('doc-export-menu');
|
||||
@@ -15296,8 +15239,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
menu.style.top = (rect.bottom + 2) + 'px';
|
||||
menu.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
menu.style.left = 'auto';
|
||||
const pane = document.querySelector('.doc-editor-pane');
|
||||
menu.style.zIndex = String(Math.max(topPortalZ(), (pane && parseInt(getComputedStyle(pane).zIndex, 10) || 0) + 1));
|
||||
menu.style.zIndex = '9999';
|
||||
|
||||
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
||||
// Form-backed markdown doc → primary export is the filled PDF, not the
|
||||
@@ -15331,11 +15273,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// white paper preview; PDF is converted from its extracted text into an
|
||||
// editable DOCX in the browser.
|
||||
// PDF-backed documents already have a lossless filled-PDF export above.
|
||||
// Running the generic html2pdf path would rebuild the extracted text and
|
||||
// Running the generic browser print path would rebuild the extracted text and
|
||||
// destroy the original page layout, images, and form structure.
|
||||
if (!isForm) {
|
||||
if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {
|
||||
options.push({
|
||||
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF',
|
||||
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF',
|
||||
fn: _isDocxLang(lang) ? () => convertOriginalDocument('pdf') : exportAsPdf,
|
||||
});
|
||||
}
|
||||
@@ -15363,25 +15305,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
});
|
||||
|
||||
document.body.appendChild(menu);
|
||||
// Keep the footer menu inside the visible viewport, including with a keyboard open.
|
||||
const viewport = window.visualViewport;
|
||||
const left = (viewport?.offsetLeft || 0) + 8;
|
||||
const top = (viewport?.offsetTop || 0) + 8;
|
||||
const right = left + (viewport?.width || window.innerWidth) - 16;
|
||||
const bottom = top + (viewport?.height || window.innerHeight) - 16;
|
||||
menu.style.maxWidth = Math.max(0, right - left) + 'px';
|
||||
menu.style.minWidth = '0';
|
||||
menu.style.maxHeight = Math.max(0, bottom - top) + 'px';
|
||||
menu.style.overflowY = 'auto';
|
||||
// Flip above the anchor when there's no room below — the Export button now
|
||||
// lives in the bottom footer, so the menu would otherwise drop off-screen.
|
||||
const mh = menu.offsetHeight;
|
||||
const y = rect.bottom + 2 + mh <= bottom ? rect.bottom + 2 : rect.top - mh - 2;
|
||||
menu.style.top = Math.max(top, Math.min(y, bottom - mh)) + 'px';
|
||||
menu.style.left = Math.max(left, Math.min(rect.right - menu.offsetWidth, right - menu.offsetWidth)) + 'px';
|
||||
menu.style.right = 'auto';
|
||||
if (rect.bottom + mh > window.innerHeight - 8) {
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||
}
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual node removal.
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); },
|
||||
ev => !menu.contains(ev.target) && !anchor?.contains(ev.target));
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
}
|
||||
|
||||
function _richTextExportCss() {
|
||||
@@ -15441,7 +15374,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
const richStyles = _isRichTextLang(lang) ? `<style>${_richTextExportCss()}</style>` : '';
|
||||
const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>${_esc(title)}</title>${richStyles}</head><body style="max-width:800px;margin:40px auto;font-family:sans-serif;line-height:1.6;padding:0 20px;background:#fff;color:#000;">\n${body}\n</body></html>`;
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
_downloadDocumentBlob(blob, _getExportBaseName() + '.html');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = _getExportBaseName() + '.html';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
if (uiModule) uiModule.showToast('Exported as HTML');
|
||||
}
|
||||
|
||||
@@ -15503,15 +15440,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
if (!activeDocId) return;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (!textarea) return;
|
||||
try {
|
||||
await ensureHtml2Pdf();
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showError('Failed to load PDF library');
|
||||
if (typeof window.print !== 'function') {
|
||||
if (uiModule) uiModule.showError('Browser printing is unavailable.');
|
||||
return;
|
||||
}
|
||||
const lang = document.getElementById('doc-language-select')?.value || '';
|
||||
const text = textarea.value || '';
|
||||
// Render content as HTML for PDF
|
||||
// Render content as HTML for the browser print dialog.
|
||||
let html;
|
||||
if (_isRichTextLang(lang)) {
|
||||
html = text;
|
||||
@@ -15524,28 +15459,57 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
|
||||
text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
|
||||
container.innerHTML = html;
|
||||
if (_isRichTextLang(lang)) {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = _richTextExportCss();
|
||||
container.prepend(style);
|
||||
// Use a sandboxed same-origin frame: rich text can render, but scripts,
|
||||
// event handlers and embedded plugins cannot execute in the print view.
|
||||
document.getElementById('doc-browser-print-frame')?.remove();
|
||||
const frame = document.createElement('iframe');
|
||||
frame.id = 'doc-browser-print-frame';
|
||||
frame.title = 'Document print view';
|
||||
frame.setAttribute('sandbox', 'allow-same-origin allow-modals');
|
||||
frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;';
|
||||
document.body.appendChild(frame);
|
||||
try {
|
||||
const printDoc = frame.contentDocument;
|
||||
printDoc.open();
|
||||
printDoc.write('<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>');
|
||||
printDoc.close();
|
||||
printDoc.title = _getExportBaseName();
|
||||
const container = printDoc.createElement('div');
|
||||
container.style.cssText = 'padding:20px;font-family:sans-serif;font-size:12px;color:#000;background:#fff;line-height:1.6;';
|
||||
container.innerHTML = html;
|
||||
if (_isRichTextLang(lang)) {
|
||||
const style = printDoc.createElement('style');
|
||||
style.textContent = _richTextExportCss();
|
||||
container.prepend(style);
|
||||
}
|
||||
// Typeset deferred math before copying it into the print document.
|
||||
await markdownModule.renderMath(container);
|
||||
const styles = [];
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
|
||||
if (!link.href.includes('/katex/')) return;
|
||||
const copy = printDoc.createElement('link');
|
||||
copy.rel = 'stylesheet';
|
||||
copy.href = link.href;
|
||||
styles.push(new Promise((resolve, reject) => {
|
||||
copy.onload = resolve;
|
||||
copy.onerror = () => reject(new Error('Math styles could not be loaded'));
|
||||
}));
|
||||
printDoc.head.appendChild(copy);
|
||||
});
|
||||
const printStyle = printDoc.createElement('style');
|
||||
printStyle.textContent = '@page { margin: 10mm; } body { margin: 0; } img { max-width: 100%; } pre { white-space: pre-wrap; overflow-wrap: anywhere; }';
|
||||
printDoc.head.appendChild(printStyle);
|
||||
printDoc.body.appendChild(printDoc.importNode(container, true));
|
||||
await Promise.all(styles);
|
||||
await printDoc.fonts.ready;
|
||||
await Promise.all(Array.from(printDoc.images, img => img.decode().catch(() => {})));
|
||||
frame.contentWindow.addEventListener('afterprint', () => frame.remove(), { once: true });
|
||||
frame.contentWindow.focus();
|
||||
frame.contentWindow.print();
|
||||
} catch (error) {
|
||||
frame.remove();
|
||||
if (uiModule) uiModule.showError('Could not open browser print: ' + (error.message || error));
|
||||
}
|
||||
// This container is detached, so the document-scoped flush mdToHtml
|
||||
// schedules never sees it. Typeset the deferred math before html2pdf
|
||||
// rasterises, or the PDF gets raw formula source. renderMath() returns
|
||||
// immediately, without loading KaTeX, when there is nothing pending.
|
||||
await markdownModule.renderMath(container);
|
||||
const baseName = _getExportBaseName();
|
||||
window.html2pdf().set({
|
||||
margin: 10,
|
||||
filename: baseName + '.pdf',
|
||||
image: { type: 'jpeg', quality: 0.95 },
|
||||
html2canvas: { scale: 2 },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||
}).from(container).save();
|
||||
if (uiModule) uiModule.showToast('Exporting PDF...');
|
||||
}
|
||||
|
||||
function _docxHexColor(value) {
|
||||
@@ -15989,7 +15953,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
});
|
||||
const blob = await window.docx.Packer.toBlob(doc);
|
||||
const baseName = _getExportBaseName();
|
||||
_downloadDocumentBlob(blob, baseName + '.docx');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = baseName + '.docx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
if (uiModule) uiModule.showToast('Exported as DOCX');
|
||||
}
|
||||
|
||||
|
||||
@@ -66,4 +66,3 @@ export function parseMarkdownOutline(source) {
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
@@ -1302,11 +1302,8 @@ let _libSyncStatus = {
|
||||
source: '',
|
||||
warming: false,
|
||||
loading: false,
|
||||
stale: false,
|
||||
};
|
||||
let _libSyncTicker = null;
|
||||
let _libStaleRefetchTimer = null;
|
||||
let _libStaleRefetchCount = 0;
|
||||
|
||||
function _libSyncDateFrom(value) {
|
||||
if (!value) return null;
|
||||
@@ -1361,37 +1358,9 @@ function _setEmailSyncStatus(next = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'loading')) {
|
||||
_libSyncStatus.loading = Boolean(next.loading);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(next, 'stale')) {
|
||||
_libSyncStatus.stale = Boolean(next.stale);
|
||||
}
|
||||
_renderEmailSyncStatus();
|
||||
}
|
||||
|
||||
// A server-side stale (sync.stale) response means the SWR snapshot was served
|
||||
// while a background IMAP refresh is still running. Re-fetch in place after a
|
||||
// short delay (bounded) so the fresh rows land with no skeleton and no scroll
|
||||
// jump. We only keep retrying while the response is still marked stale.
|
||||
function _clearStaleEmailRefetch() {
|
||||
if (_libStaleRefetchTimer) {
|
||||
clearTimeout(_libStaleRefetchTimer);
|
||||
_libStaleRefetchTimer = null;
|
||||
}
|
||||
_libStaleRefetchCount = 0;
|
||||
}
|
||||
|
||||
function _scheduleStaleEmailRefetch() {
|
||||
if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return;
|
||||
const folderAtSchedule = state._libFolder;
|
||||
const accountAtSchedule = state._libAccountId || '';
|
||||
_libStaleRefetchCount++;
|
||||
_libStaleRefetchTimer = setTimeout(() => {
|
||||
_libStaleRefetchTimer = null;
|
||||
if (document.hidden) return;
|
||||
if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return;
|
||||
_loadEmails({ force: false, useCache: false, skipFastPath: true });
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
|
||||
return [
|
||||
accountId || '',
|
||||
@@ -2242,7 +2211,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<button class="close-btn" id="email-lib-close" aria-label="Close">\u2716</button>
|
||||
<button class="close-btn" id="email-lib-close">\u2716</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
|
||||
@@ -4825,7 +4794,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
|
||||
} catch (_) { _syncUnreadTabBadge(0); }
|
||||
}
|
||||
|
||||
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
|
||||
export async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
const seq = ++_libLoadSeq;
|
||||
state._libLoading = true;
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
@@ -4879,15 +4848,11 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
|
||||
}
|
||||
const stats = document.getElementById('email-lib-stats');
|
||||
if (stats) stats.textContent = `${state._libTotal} emails`;
|
||||
const _isStale = Boolean(sync.stale);
|
||||
_setEmailSyncStatus({
|
||||
updatedAt: sync.updated_at || '',
|
||||
source: sync.source || (cacheSource ? 'client_cache' : ''),
|
||||
loading: false,
|
||||
stale: _isStale,
|
||||
});
|
||||
if (_isStale) _scheduleStaleEmailRefetch();
|
||||
else _clearStaleEmailRefetch();
|
||||
if (cacheable && !cacheSource) {
|
||||
_libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync });
|
||||
}
|
||||
@@ -4923,7 +4888,7 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
|
||||
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
|
||||
if (!cached && cacheable && !force && !skipFastPath) {
|
||||
if (!cached && cacheable && !force) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||
try {
|
||||
|
||||
+7
-97
@@ -1278,7 +1278,6 @@ function _renderEditorLanding() {
|
||||
<p>Start a blank canvas, or open a photo from your gallery to edit it.</p>
|
||||
<div class="gallery-editor-landing-actions">
|
||||
<button class="gallery-select-btn" id="gallery-editor-new">New canvas...</button>
|
||||
<button class="gallery-select-btn" id="gallery-editor-import"><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" style="vertical-align:-1px;margin-right:4px;"><path d="M12 3v12"/><path d="M7 8l5-5 5 5"/><path d="M5 21h14a2 2 0 0 0 2-2v-4"/><path d="M3 15v4a2 2 0 0 0 2 2"/></svg>Import</button>
|
||||
<button class="gallery-select-btn" id="gallery-editor-pick"><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" style="vertical-align:-1px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Browse gallery</button>
|
||||
</div>
|
||||
<section class="gallery-editor-template-picker" aria-labelledby="gallery-editor-template-title">
|
||||
@@ -1329,28 +1328,6 @@ function _renderEditorLanding() {
|
||||
document.getElementById('gallery-editor-pick')?.addEventListener('click', () => {
|
||||
document.querySelector('#gallery-modal .gallery-tab[data-tab="images"]')?.click();
|
||||
});
|
||||
document.getElementById('gallery-editor-import')?.addEventListener('click', () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const name = (file.name || 'Imported image').replace(/\.[^.]+$/, '') || 'Imported image';
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
await openEditor(reader.result, null, null, name);
|
||||
} catch (e) {
|
||||
console.error('[gallery] image import failed', e);
|
||||
uiModule?.showError?.('Could not import that image');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => uiModule?.showError?.('Could not read that file');
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Wire the first-tile Upload affordance in the Photos grid. Opens the same
|
||||
@@ -1374,11 +1351,6 @@ function _wireUploadTile() {
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
tile.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
||||
e.preventDefault();
|
||||
tile.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Shimmer placeholder tiles shown while the FIRST page loads, so the grid
|
||||
@@ -1404,7 +1376,7 @@ function _renderGrid() {
|
||||
// tile in the Albums tab so the upload entry point is consistent across
|
||||
// both grids.
|
||||
const uploadTile = `
|
||||
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos" tabindex="0" role="button" aria-label="Upload photos or videos">
|
||||
<div class="gallery-card gallery-card-upload" id="gallery-upload-tile" title="Upload photos or videos">
|
||||
<div class="gallery-card-upload-inner">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<div class="gallery-card-upload-label">Upload</div>
|
||||
@@ -1443,10 +1415,10 @@ function _renderGrid() {
|
||||
_isVideoUrl(img.url) ? '<span class="gallery-card-badge">Video</span>' : '',
|
||||
].filter(Boolean).join('');
|
||||
html += `
|
||||
<div class="gallery-card" data-id="${_esc(img.id)}" tabindex="0" role="button" aria-label="${_esc(labelText)}">
|
||||
<div class="gallery-card" data-id="${_esc(img.id)}">
|
||||
<span class="gallery-select-dot" style="display:none;"></span>
|
||||
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite" aria-label="Favorite" aria-pressed="${favCls ? 'true' : 'false'}">♥</button>
|
||||
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download" aria-label="Download">
|
||||
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite">♥</button>
|
||||
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
${_isVideoUrl(img.url)
|
||||
@@ -1492,14 +1464,6 @@ function _renderGrid() {
|
||||
const img = _items.find(i => i.id === card.dataset.id);
|
||||
if (img) _openDetail(img);
|
||||
});
|
||||
card.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
|
||||
e.preventDefault();
|
||||
const selectBtn = document.getElementById('gallery-select-btn');
|
||||
if (selectBtn && selectBtn.classList.contains('active')) return;
|
||||
const img = _items.find(i => i.id === card.dataset.id);
|
||||
if (img) _openDetail(img);
|
||||
});
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
@@ -1611,7 +1575,7 @@ function _openDetail(img) {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="${img.favorite ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
</button>
|
||||
<div class="gallery-detail-menu-wrap">
|
||||
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions" aria-haspopup="menu" aria-expanded="false">
|
||||
<button class="gallery-detail-action gallery-detail-menu-btn" id="gallery-detail-menu-btn" title="Actions" aria-label="Photo actions">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
<div class="gallery-detail-menu dropdown" id="gallery-detail-menu" hidden>
|
||||
@@ -1840,7 +1804,7 @@ function _openDetail(img) {
|
||||
const menu = document.getElementById('gallery-detail-menu');
|
||||
if (menuBtn && menu) {
|
||||
// `.dropdown { display:none }` isn't tied to [hidden] — set inline display.
|
||||
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; menuBtn.setAttribute('aria-expanded', show ? 'true' : 'false'); };
|
||||
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; };
|
||||
_setMenu(false);
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -2271,7 +2235,7 @@ export function openGallery() {
|
||||
<div class="modal-content gallery-modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Gallery <span id="gallery-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px"></span></h4>
|
||||
<button class="modal-close" id="gallery-close" aria-label="Close">×</button>
|
||||
<button class="modal-close" id="gallery-close">×</button>
|
||||
</div>
|
||||
<div class="gallery-tabs">
|
||||
<button class="gallery-tab active" data-tab="images">
|
||||
@@ -2287,7 +2251,6 @@ export function openGallery() {
|
||||
<span class="gallery-tab-label">Edit</span>
|
||||
<span class="gallery-tab-close" id="gallery-editor-tab-close" title="Close edit" aria-label="Close edit">×</span>
|
||||
</button>
|
||||
<span class="gallery-editor-tabs-extra" id="gallery-editor-tabs-extra"></span>
|
||||
<button class="gallery-tab" data-tab="settings">
|
||||
<span class="gallery-tab-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<span class="gallery-tab-label">Settings</span>
|
||||
@@ -2355,13 +2318,6 @@ export function openGallery() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2>Image model</h2>
|
||||
<p class="memory-desc doclib-desc">Model used for AI edits and generated content in the editor (Quick Edit). Shared with the Quick Edit model picker.</p>
|
||||
<select id="gallery-image-model" style="margin-top:8px;width:100%;">
|
||||
<option value="">Loading…</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2602,52 +2558,6 @@ export function openGallery() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Image model (Settings tab) ──
|
||||
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
|
||||
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
|
||||
// so the two stay in sync.
|
||||
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||
if (imageModelSelect) {
|
||||
const _fillImageModels = async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
|
||||
const data = r.ok ? await r.json() : null;
|
||||
const models = (data && Array.isArray(data.models)) ? data.models : [];
|
||||
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
||||
imageModelSelect.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
def.textContent = 'Default (configured provider)';
|
||||
imageModelSelect.appendChild(def);
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = `${m.base_url || ''}::${m.id}`;
|
||||
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
|
||||
imageModelSelect.appendChild(opt);
|
||||
}
|
||||
imageModelSelect.value = saved;
|
||||
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
|
||||
} catch (e) {
|
||||
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
|
||||
}
|
||||
};
|
||||
imageModelSelect.addEventListener('change', () => {
|
||||
const val = imageModelSelect.value || '';
|
||||
try {
|
||||
if (val) localStorage.setItem(_GE_MODEL_KEY, val);
|
||||
else localStorage.removeItem(_GE_MODEL_KEY);
|
||||
} catch (e) {}
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
});
|
||||
_fillImageModels();
|
||||
}
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
|
||||
imageModelSelect.value = e.newValue || '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tag All Untagged ──
|
||||
let _tagCancelRequested = false;
|
||||
let _tagging = false;
|
||||
|
||||
+1
-7
@@ -582,19 +582,13 @@ export async function refreshModels(force = false, opts = {}) {
|
||||
noModels.className = 'models-empty-state';
|
||||
if (window._isAdmin) {
|
||||
noModels.innerHTML = '<span class="muted">No models found</span><br>'
|
||||
+ '<a href="#" id="models-open-admin" class="accent-link">Open Admin to add endpoints</a>'
|
||||
+ '<a href="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
|
||||
+ '<br><span class="muted-sm">Type /setup for Local models or API setup.</span>';
|
||||
} else {
|
||||
noModels.innerHTML = '<span class="muted">No models available</span><br>'
|
||||
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
|
||||
}
|
||||
box.appendChild(noModels);
|
||||
const openAdmin = noModels.querySelector('#models-open-admin');
|
||||
if (openAdmin) openAdmin.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('user-bar-admin');
|
||||
if (btn) btn.click();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
+4
-4
@@ -542,7 +542,7 @@ function _linkify(s) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
function _uid() { return Math.random().toString(36).slice(2, 10); }
|
||||
@@ -2335,7 +2335,7 @@ function _bindCardEvents(body) {
|
||||
if (_selectMode) {
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.note-card-cb, a')) return; // checkbox handles itself; links open without toggling
|
||||
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
@@ -3070,7 +3070,7 @@ function _buildForm(note = null) {
|
||||
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
|
||||
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
|
||||
</div>
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></div>` : ''}
|
||||
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button></div>` : ''}
|
||||
<div class="note-form-body">
|
||||
${type === 'note'
|
||||
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
|
||||
@@ -3651,7 +3651,7 @@ function _buildForm(note = null) {
|
||||
form.querySelector('.note-form-image-wrap')?.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'note-form-image-wrap';
|
||||
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</button>`;
|
||||
// Insert AFTER the whole header (a flex-row), not after the
|
||||
// title input itself — otherwise the image lands as a sibling
|
||||
// of the title inside the header and flex puts them side-by-side.
|
||||
|
||||
@@ -80,10 +80,7 @@ export function init(apiBase) {
|
||||
// sidebar — _reconnectActive only ran once at load before, so
|
||||
// agent-started jobs never appeared until a page reload.
|
||||
if (_activePollInterval) clearInterval(_activePollInterval);
|
||||
_activePollInterval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
_reconnectActive();
|
||||
}, 60000);
|
||||
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
|
||||
}
|
||||
|
||||
// Allow an immediate adopt when the chat stream signals a new research
|
||||
|
||||
@@ -103,9 +103,6 @@ export function initSidebarLayout(Storage, opts) {
|
||||
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
|
||||
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
|
||||
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
|
||||
hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden));
|
||||
const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn');
|
||||
if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden));
|
||||
}
|
||||
// Keep incognito button clear of hamburger
|
||||
const incogBtn = document.getElementById('incognito-btn');
|
||||
|
||||
@@ -465,7 +465,7 @@ export function pick(opts = {}) {
|
||||
if (!dataUrl) return '';
|
||||
return `
|
||||
<div class="sig-tile" data-id="${_esc(s.id)}">
|
||||
<img src="${_esc(dataUrl)}" alt=""/>
|
||||
<img src="${_esc(dataUrl)}"/>
|
||||
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
|
||||
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
|
||||
</div>
|
||||
|
||||
@@ -5336,29 +5336,6 @@ function _eggRender(html) {
|
||||
uiModule.scrollHistory();
|
||||
}
|
||||
|
||||
// Delegated handlers for egg commands that used inline onclick (blocked by
|
||||
// Firefox CSP script-src-attr). Dice re-roll + color copy.
|
||||
if (!window._eggDelegatedBound) {
|
||||
window._eggDelegatedBound = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
const dice = e.target.closest && e.target.closest('.egg-dice');
|
||||
if (dice) {
|
||||
const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000);
|
||||
dice.style.animation = 'none';
|
||||
void dice.offsetHeight;
|
||||
dice.textContent = Math.floor(Math.random() * sides) + 1;
|
||||
dice.style.animation = 'egg-shake 0.3s ease';
|
||||
return;
|
||||
}
|
||||
const swatch = e.target.closest && e.target.closest('.egg-color-copy');
|
||||
if (swatch) {
|
||||
navigator.clipboard.writeText(swatch.dataset.color || '');
|
||||
swatch.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => { swatch.style.transform = ''; }, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function _cmdFlip(args, ctx) {
|
||||
const isHeads = Math.random() < 0.5;
|
||||
const edge = Math.random() < 0.001;
|
||||
@@ -5396,7 +5373,7 @@ async function _cmdRoll(args, ctx) {
|
||||
const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
|
||||
const total = results.reduce((a, b) => a + b, 0);
|
||||
const dice = results.map((v, i) => {
|
||||
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${v}</div>`;
|
||||
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" title="d${sides}" onclick="this.style.animation='none';this.offsetHeight;var r=Math.floor(Math.random()*${sides})+1;this.textContent=r;this.style.animation='egg-shake 0.3s ease'">${v}</div>`;
|
||||
}).join('');
|
||||
const totalHtml = count > 1 ? `<div style="font-size:0.8em;opacity:0.5;margin-top:4px">${count}d${sides} = ${total}</div>` : '';
|
||||
_eggRender(`<div style="display:flex;flex-direction:column;align-items:center;gap:4px"><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center">${dice}</div>${totalHtml}</div>`);
|
||||
@@ -5713,7 +5690,7 @@ async function _cmdColor(args, ctx) {
|
||||
const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0');
|
||||
const c = hex.startsWith('#') ? hex : '#' + hex;
|
||||
_eggRender(`<div style="display:flex;align-items:center;gap:12px;animation:egg-fade 0.3s ease-out">
|
||||
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
|
||||
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText('${ctx.esc(c)}');this.style.transform='scale(0.9)';setTimeout(()=>this.style.transform='',150)"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:2px"><code style="font-size:1.1em">${ctx.esc(c)}</code>
|
||||
<span style="font-size:0.75em;opacity:0.4">click swatch to copy</span>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
|
||||
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
|
||||
let degraded = !ENABLED; // true once we fall back to full re-render
|
||||
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
|
||||
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
|
||||
|
||||
function start() {
|
||||
contentEl.textContent = '';
|
||||
@@ -81,24 +79,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
|
||||
}
|
||||
|
||||
// Coalesce the per-token tail re-render into at most one render per animation
|
||||
// frame. Fast providers emit many deltas per frame; the frozen-block pass is
|
||||
// cheap and stays synchronous, but the markdown re-parse + innerHTML write of
|
||||
// the still-growing tail only needs to happen once per frame for smooth output.
|
||||
function scheduleTailRender(tailText) {
|
||||
pendingTail = tailText;
|
||||
if (tailRaf !== null) return;
|
||||
tailRaf = requestAnimationFrame(() => {
|
||||
tailRaf = null;
|
||||
// The container may have been rewritten out from under us (chat.js writes
|
||||
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
|
||||
// let the next update() self-heal rather than appending stale tail nodes.
|
||||
if (tailMarker && tailMarker.parentNode === contentEl) {
|
||||
renderTail(pendingTail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Stream the body of an unterminated code fence by appending only the new
|
||||
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
|
||||
function appendOpenFence(tailText, fence) {
|
||||
@@ -191,7 +171,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
appendMode = null; // whatever was streaming is now frozen
|
||||
tailShownLen = 0;
|
||||
}
|
||||
scheduleTailRender(fullText.slice(committedLen));
|
||||
renderTail(fullText.slice(committedLen));
|
||||
} catch (err) {
|
||||
degraded = true;
|
||||
console.error('streamingRenderer: falling back to full render', err);
|
||||
@@ -207,7 +187,6 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
function finalize() {
|
||||
if (degraded) return;
|
||||
try {
|
||||
if (tailRaf !== null) { cancelAnimationFrame(tailRaf); tailRaf = null; }
|
||||
if (!started) start();
|
||||
clearTail();
|
||||
appendMode = null;
|
||||
|
||||
+1
-1
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {
|
||||
<div class="modal-header">
|
||||
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
|
||||
<span style="flex:1"></span>
|
||||
<button class="close-btn" id="tasks-close" aria-label="Close">✖</button>
|
||||
<button class="close-btn" id="tasks-close">✖</button>
|
||||
</div>
|
||||
<div class="memory-tabs tasks-tabs" role="tablist">
|
||||
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
|
||||
|
||||
@@ -31,33 +31,9 @@ function _targetEl(target) {
|
||||
return target && target.nodeType === 1 ? target : target?.parentElement || null;
|
||||
}
|
||||
|
||||
function _editorPanelVisible() {
|
||||
if (!document.body || !document.body.classList.contains('ge-editor-open')) return false;
|
||||
const sel = '.ge-adj-popup, .ge-fx-popup, .ge-fx-menu, .ge-transform-popup, .ge-filter-overlay, .ge-history-panel';
|
||||
return [...document.querySelectorAll(sel)].some((candidate) => {
|
||||
if (!document.contains(candidate)) return false;
|
||||
const style = getComputedStyle(candidate);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function _positionToastForOpenPanel(el) {
|
||||
if (!el) return;
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
el.classList.remove('toast-editor-bottom');
|
||||
// In the editor, a floating panel (Adjust/Develop, FX, Transform, filter
|
||||
// modal, History) is anchored to the top/right; drop the toast to the
|
||||
// bottom-centre so it can never cover a control (EVAL: "never over an
|
||||
// active panel"). On phones the CSS already anchors it at the bottom.
|
||||
if (_editorPanelVisible()) {
|
||||
el.style.left = '';
|
||||
el.style.right = '';
|
||||
el.style.maxWidth = '';
|
||||
el.classList.add('toast-editor-bottom');
|
||||
return;
|
||||
}
|
||||
const panel = [...document.querySelectorAll('.notes-pane')]
|
||||
.filter((candidate) => {
|
||||
if (!document.contains(candidate)) return false;
|
||||
@@ -382,14 +358,6 @@ export function showToast(msg, durationOrOpts) {
|
||||
} else if (typeof durationOrOpts === 'number') {
|
||||
duration = durationOrOpts;
|
||||
}
|
||||
// While the editor is open, cap every toast so it auto-dismisses within
|
||||
// ~2.5s and can never linger over the canvas or a panel (EVAL: "auto-dismiss
|
||||
// in 2.5s"). Longer informational durations (persist warnings etc.) are
|
||||
// still readable but do not pile up.
|
||||
if (typeof document !== 'undefined' && document.body
|
||||
&& document.body.classList.contains('ge-editor-open')) {
|
||||
duration = Math.min(duration, 2500);
|
||||
}
|
||||
if (aiReplyProgress) toastEl.classList.add('toast-ai-reply-progress');
|
||||
if (aiReplyResult) toastEl.classList.add('toast-ai-reply-result');
|
||||
if (toastClass) toastEl.classList.add(toastClass);
|
||||
|
||||
Vendored
-3
File diff suppressed because one or more lines are too long
Vendored
-8
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user