Compare commits

...
Author SHA1 Message Date
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
26 changed files with 571 additions and 144 deletions
+19
View File
@@ -130,11 +130,30 @@ 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;
+1
View File
@@ -704,6 +704,7 @@ 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;
}
+6
View File
@@ -90,6 +90,12 @@
.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;
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].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
@@ -55,6 +61,31 @@
(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
@@ -118,9 +149,38 @@
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.
+9 -9
View File
@@ -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" 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>`;
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>`;
}
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">✖</button>
<button class="close-btn" id="cal-close" aria-label="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">&lsaquo;</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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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')) return;
if (e.target.closest('.cal-event-more, .cal-event-source, .cal-event-link')) 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 href="${safeMeetingUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Join meeting">${safeMeetingUrl}</a>`;
return `<a class="cal-event-link" href="${safeMeetingUrl}" target="_blank" rel="noopener" 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 href="${safe}" target="_blank" rel="noopener" onclick="event.stopPropagation();">${safe}</a>`;
html += `<a class="cal-event-link" href="${safe}" target="_blank" rel="noopener">${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 href="${mapUrl}" target="_blank" rel="noopener" onclick="event.stopPropagation();" title="Open in OpenStreetMap">${_e(loc)}</a>`;
return `<a class="cal-event-link" href="${mapUrl}" target="_blank" rel="noopener" title="Open in OpenStreetMap">${_e(loc)}</a>`;
}
// ── Open / Close ──
+69 -1
View File
@@ -44,6 +44,66 @@ 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;
@@ -4027,6 +4087,7 @@ 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;
@@ -6081,7 +6142,13 @@ import { invalidateSettings } from './appConfig.js';
cache: 'no-store',
});
if (!_getForegroundStreamState() || _backgroundStreams.has(sid)) return;
if (res.status !== 404) 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;
console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.');
if (active.abortCtrl && !active.abortCtrl.signal.aborted) {
@@ -6443,6 +6510,7 @@ 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;
+3 -3
View File
@@ -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()) {
+15
View File
@@ -96,6 +96,21 @@ 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');
+3 -3
View File
@@ -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: '',
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>',
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: '',
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>',
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 = {
+2 -2
View File
@@ -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: '',
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>',
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: '',
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;" />',
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>',
+7 -6
View File
@@ -1498,9 +1498,10 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
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 _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
// Build save slots
const _allPresets = _loadPresets();
@@ -2210,11 +2211,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: '',
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>',
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: '',
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>',
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>',
};
@@ -2234,7 +2235,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() {
@@ -2242,7 +2243,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('');
+130 -98
View File
@@ -1996,6 +1996,17 @@ 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
@@ -2290,8 +2301,16 @@ 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) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2423,9 +2442,11 @@ 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) ? '' : 'none';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2446,7 +2467,8 @@ 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 };
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) };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2568,7 +2590,12 @@ 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);
@@ -2580,6 +2607,7 @@ 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';
@@ -2598,11 +2626,14 @@ 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.
@@ -2611,6 +2642,7 @@ 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';
@@ -2628,11 +2660,14 @@ 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
@@ -3416,9 +3451,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}).join('');
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
const probe = document.createElement('template');
const probe = document.createElement('div');
probe.innerHTML = text;
const plain = (probe.content.textContent || '').trim();
const plain = (probe.innerText || probe.textContent || '').trim();
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
if (plainClean !== plain) return plainClean;
}
@@ -3530,9 +3565,9 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
function _emailHtmlToPlainText(html) {
if (typeof document === 'undefined') return String(html || '');
const d = document.createElement('template');
const d = document.createElement('div');
d.innerHTML = String(html || '');
return d.content.textContent || '';
return d.innerText || d.textContent || '';
}
function _sanitizedRichTextHtml(rich) {
@@ -5393,20 +5428,15 @@ 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"></span>
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</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>
@@ -6735,10 +6765,10 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
.trim();
};
const splitCurrent = _splitEmailReplyQuote(currentBody);
const ownBody = document.createElement('template');
const ownBody = document.createElement('div');
ownBody.innerHTML = String(splitCurrent.body || '');
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);
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;
@@ -7630,8 +7660,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)" 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>
<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>
<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>
@@ -15065,6 +15095,20 @@ 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';
@@ -15073,6 +15117,22 @@ 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');
@@ -15093,11 +15153,7 @@ 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 });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = safeName + ver + ext;
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, safeName + ver + ext);
}
// "Import from device" — open a file picker, upload, and immediately open
@@ -15203,6 +15259,7 @@ 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');
@@ -15239,7 +15296,8 @@ 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';
menu.style.zIndex = '9999';
const pane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (pane && parseInt(getComputedStyle(pane).zIndex, 10) || 0) + 1));
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15273,11 +15331,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 browser print path would rebuild the extracted text and
// Running the generic html2pdf path would rebuild the extracted text and
// destroy the original page layout, images, and form structure.
if (!isForm && (_isDocxLang(lang) || typeof window.print === 'function')) {
if (!isForm) {
options.push({
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print / save PDF',
label: _isDocxLang(lang) ? 'Convert to PDF' : 'Print as PDF',
fn: _isDocxLang(lang) ? () => convertOriginalDocument('pdf') : exportAsPdf,
});
}
@@ -15305,16 +15363,25 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
document.body.appendChild(menu);
// 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.
// 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';
const mh = menu.offsetHeight;
if (rect.bottom + mh > window.innerHeight - 8) {
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
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';
// 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(); });
const close = bindMenuDismiss(menu, () => { menu.remove(); },
ev => !menu.contains(ev.target) && !anchor?.contains(ev.target));
}
function _richTextExportCss() {
@@ -15374,11 +15441,7 @@ 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' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _getExportBaseName() + '.html';
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, _getExportBaseName() + '.html');
if (uiModule) uiModule.showToast('Exported as HTML');
}
@@ -15440,13 +15503,15 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
if (!activeDocId) return;
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
if (typeof window.print !== 'function') {
if (uiModule) uiModule.showError('Browser printing is unavailable.');
try {
await ensureHtml2Pdf();
} catch (e) {
if (uiModule) uiModule.showError('Failed to load PDF library');
return;
}
const lang = document.getElementById('doc-language-select')?.value || '';
const text = textarea.value || '';
// Render content as HTML for the browser print dialog.
// Render content as HTML for PDF
let html;
if (_isRichTextLang(lang)) {
html = text;
@@ -15459,57 +15524,28 @@ 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
}
// 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));
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);
}
// 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) {
@@ -15953,11 +15989,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
});
const blob = await window.docx.Packer.toBlob(doc);
const baseName = _getExportBaseName();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = baseName + '.docx';
a.click();
URL.revokeObjectURL(a.href);
_downloadDocumentBlob(blob, baseName + '.docx');
if (uiModule) uiModule.showToast('Exported as DOCX');
}
+1
View File
@@ -66,3 +66,4 @@ export function parseMarkdownOutline(source) {
}
return entries;
}
+38 -3
View File
@@ -1302,8 +1302,11 @@ 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;
@@ -1358,9 +1361,37 @@ 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 || '',
@@ -2211,7 +2242,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">\u2716</button>
<button class="close-btn" id="email-lib-close" aria-label="Close">\u2716</button>
</div>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
@@ -4794,7 +4825,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
} catch (_) { _syncUnreadTabBadge(0); }
}
export async function _loadEmails({ force = false, useCache = true } = {}) {
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -4848,11 +4879,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
}
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 });
}
@@ -4888,7 +4923,7 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
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) {
if (!cached && cacheable && !force && !skipFastPath) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
+97 -7
View File
@@ -1278,6 +1278,7 @@ 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">
@@ -1328,6 +1329,28 @@ 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
@@ -1351,6 +1374,11 @@ 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
@@ -1376,7 +1404,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">
<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-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>
@@ -1415,10 +1443,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)}">
<div class="gallery-card" data-id="${_esc(img.id)}" tabindex="0" role="button" aria-label="${_esc(labelText)}">
<span class="gallery-select-dot" style="display:none;"></span>
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite">&#9829;</button>
<button class="gallery-dl-btn" data-id="${_esc(img.id)}" data-url="${_esc(img.url)}" data-filename="${_esc(img.filename || '')}" title="Download">
<button class="gallery-fav-btn${favCls}" data-id="${_esc(img.id)}" title="Favorite" aria-label="Favorite" aria-pressed="${favCls ? 'true' : 'false'}">&#9829;</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">
<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)
@@ -1464,6 +1492,14 @@ 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
@@ -1575,7 +1611,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">
<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">
<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>
@@ -1804,7 +1840,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'; };
const _setMenu = (show) => { menu.hidden = !show; menu.style.display = show ? 'block' : 'none'; menuBtn.setAttribute('aria-expanded', show ? 'true' : 'false'); };
_setMenu(false);
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2235,7 +2271,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">&times;</button>
<button class="modal-close" id="gallery-close" aria-label="Close">&times;</button>
</div>
<div class="gallery-tabs">
<button class="gallery-tab active" data-tab="images">
@@ -2251,6 +2287,7 @@ 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>
@@ -2318,6 +2355,13 @@ 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>
@@ -2558,6 +2602,52 @@ 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;
+7 -1
View File
@@ -582,13 +582,19 @@ 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="#" onclick="document.getElementById(\'user-bar-admin\')?.click();return false;" class="accent-link">Open Admin to add endpoints</a>'
+ '<a href="#" id="models-open-admin" 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
View File
@@ -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" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${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')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // checkbox handles itself; links open without toggling
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)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></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">&times;</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" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</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.
+4 -1
View File
@@ -80,7 +80,10 @@ 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(() => { _reconnectActive(); }, 20000);
_activePollInterval = setInterval(() => {
if (document.hidden) return;
_reconnectActive();
}, 60000);
}
// Allow an immediate adopt when the chat stream signals a new research
+3
View File
@@ -103,6 +103,9 @@ 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');
+1 -1
View File
@@ -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)}"/>
<img src="${_esc(dataUrl)}" alt=""/>
<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>
+25 -2
View File
@@ -5336,6 +5336,29 @@ 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;
@@ -5373,7 +5396,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" 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>`;
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>`;
}).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>`);
@@ -5690,7 +5713,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" 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="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="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>
+22 -1
View File
@@ -34,6 +34,8 @@ 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 = '';
@@ -79,6 +81,24 @@ 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) {
@@ -171,7 +191,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
appendMode = null; // whatever was streaming is now frozen
tailShownLen = 0;
}
renderTail(fullText.slice(committedLen));
scheduleTailRender(fullText.slice(committedLen));
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
@@ -187,6 +207,7 @@ 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
View File
@@ -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">✖</button>
<button class="close-btn" id="tasks-close" aria-label="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">
+32
View File
@@ -31,9 +31,33 @@ 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;
@@ -358,6 +382,14 @@ 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);
File diff suppressed because one or more lines are too long
+8
View File
File diff suppressed because one or more lines are too long