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:
1 parent
ed77c8e0c1
commit
3044e650d3
2 files changed
+82
-3
No files matched your search
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in new issue
Block a user