feat: on a computer the assistant's ball and chat can be dragged, the chat opening beside the ball; on a phone a smaller ball further from the edges; the send button the field's height

This commit is contained in:
redtea committed 2026-10-15 22:29:55 +03:00
1 parent ed77c8e0c1
commit 3044e650d3
2 files changed
+82 -3

No files matched your search

+68 -1
View File
@@ -43,6 +43,7 @@
const phone = () => matchMedia('(max-width: 640px)').matches; const phone = () => matchMedia('(max-width: 640px)').matches;
const isOpen = () => box.classList.contains('on'); const isOpen = () => box.classList.contains('on');
function show() { function show() {
placeBox();
box.classList.add('on'); box.classList.add('on');
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href); if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
where(); where();
@@ -57,7 +58,73 @@
function setOn(on) { fab.hidden = !on; if (!on) hide(); } function setOn(on) { fab.hidden = !on; if (!on) hide(); }
fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {}); fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(d => setOn(!!d.on)).catch(() => {});
document.addEventListener('lpra:ai', ev => setOn(ev.detail)); document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => (isOpen() ? hide() : show()); fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
// ---------- on a computer: the ball and the chat move ----------
// The ball goes where it is dragged and stays there (kept in this
// browser, as distances from the right and bottom edges); the chat opens
// beside it, toward the room, and moves by its title.
const desk = !matchMedia('(pointer: coarse)').matches;
let moved = false;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function placeFab(right, bottom) {
right = clamp(right, 8, innerWidth - fab.offsetWidth - 8);
bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8);
fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px';
return {right, bottom};
}
function placeBox() {
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
let x = toLeft ? f.right - w : f.left;
let y = up ? f.top - gap - h : f.bottom + gap;
// No room above or below: beside the ball.
if (y < 8 || y + h > innerHeight - 8) { y = clamp(f.top + f.height - h, 8, innerHeight - h - 8); x = toLeft ? f.left - gap - w : f.right + gap; }
box.style.right = box.style.bottom = 'auto';
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left');
}
if (desk) {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ }
const drag = (el, ev, onMove, onEnd) => {
if (ev.button !== 0) return;
const x0 = ev.clientX, y0 = ev.clientY;
let on = false;
const mv = e => {
if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return;
on = true;
onMove(e.clientX - x0, e.clientY - y0);
};
const up = () => {
removeEventListener('pointermove', mv); removeEventListener('pointerup', up);
document.body.classList.remove('ai-dragging');
if (on) onEnd();
};
addEventListener('pointermove', mv); addEventListener('pointerup', up);
document.body.classList.add('ai-dragging');
};
fab.addEventListener('pointerdown', ev => {
const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom;
let pos = {right: r0, bottom: b0};
drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => {
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
});
});
q('.ai-head').addEventListener('pointerdown', ev => {
if (ev.target.closest('button')) return;
const r = box.getBoundingClientRect();
drag(box, ev, (dx, dy) => {
box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px';
box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px';
}, () => {});
});
addEventListener('resize', () => {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ }
if (isOpen()) placeBox();
});
}
q('.ai-close').onclick = hide; q('.ai-close').onclick = hide;
q('.ai-clear').onclick = () => { q('.ai-clear').onclick = () => {
if (ac) ac.abort(); if (ac) ac.abort();
+14 -2
View File
@@ -820,11 +820,11 @@ dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; ov
.ai-form label { display:grid; gap:4px; font-size:13px; color:var(--muted); } .ai-form label { display:grid; gap:4px; font-size:13px; color:var(--muted); }
/* The assistant: a round button, and the chat over the page's corner. */ /* The assistant: a round button, and the chat over the page's corner. */
.ai-fab { position:fixed; right:18px; bottom:18px; z-index:40; width:52px; height:52px; padding:0; border-radius:50%; .ai-fab { position:fixed; right:28px; bottom:28px; z-index:40; width:52px; height:52px; padding:0; border-radius:50%;
display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); } display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.ai-fab[hidden] { display:none; } .ai-fab[hidden] { display:none; }
body.touch.term-active .ai-fab { bottom:104px; } body.touch.term-active .ai-fab { bottom:104px; }
.ai-box { position:fixed; right:18px; bottom:82px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px)); .ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:360px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px; display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px;
box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right; box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right;
opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96); opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96);
@@ -876,3 +876,15 @@ dialog.uidlg { max-width:420px; width:92vw; }
.ai-think { margin:0 0 6px; font-size:12.5px; color:var(--muted); } .ai-think { margin:0 0 6px; font-size:12.5px; color:var(--muted); }
.ai-think summary { cursor:pointer; } .ai-think summary { cursor:pointer; }
.ai-think > div { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:200px; overflow:auto; } .ai-think > div { margin-top:4px; padding-left:10px; border-left:2px solid var(--border); max-height:200px; overflow:auto; }
/* The chat's title moves it on a computer. */
body:not(.touch) .ai-head { cursor:grab; user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; }
body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
/* Send: the field's height, square. */
.ai-form-in textarea, .ai-send { box-sizing:border-box; }
.ai-send { width:40px; height:40px; align-self:flex-end; }
@media (max-width: 640px) {
.ai-fab { width:46px; height:46px; right:22px; bottom:24px; }
body.touch.term-active .ai-fab { bottom:110px; }
.ai-form-in textarea { font-size:16px; }
}