86 lines
3.6 KiB
JavaScript
86 lines
3.6 KiB
JavaScript
/**
|
|
* The way back to a task's newest step, over the message box (ui_ai_workbench.js puts it there): a
|
|
* round button, there while the user reads more than a screen above the timeline's foot or the AI's
|
|
* steps came in since they left it, with how many on its corner; a click takes them down. atFoot
|
|
* tells the workbench whether a redraw keeps the timeline at its foot.
|
|
*/
|
|
|
|
import { translate as t } from './locales.js';
|
|
import { icon } from './ui_ai_icons.js';
|
|
|
|
const NEAR_BOTTOM = 60;
|
|
const JUMP_MS = 240;
|
|
|
|
const below = scroller => scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
|
|
|
/** Whether `scroller` is scrolled to (about) its foot. */
|
|
export const atFoot = scroller => below(scroller) < NEAR_BOTTOM;
|
|
|
|
/** The button for the timeline in `scroller`; `arrived(count)` after a redraw that brought the AI's new steps. */
|
|
export function newestButton(scroller) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'anomalous-ai-newest';
|
|
button.hidden = true;
|
|
const count = document.createElement('span');
|
|
count.className = 'anomalous-ai-newest-count';
|
|
button.append(icon('chevronDown', 18), count);
|
|
let unseen = 0;
|
|
let cancelJump = () => {};
|
|
button.jumping = false;
|
|
const draw = () => {
|
|
if (atFoot(scroller)) unseen = 0;
|
|
button.hidden = !button.jumping && !unseen && below(scroller) < scroller.clientHeight;
|
|
const text = unseen > 99 ? '99+' : unseen ? String(unseen) : '';
|
|
if (count.textContent !== text) count.textContent = text;
|
|
const title = unseen ? t('aiNewSteps', { count: unseen }) : t('aiToNewest');
|
|
if (button.title !== title) {
|
|
button.title = title;
|
|
button.setAttribute('aria-label', title);
|
|
}
|
|
};
|
|
button.onclick = () => {
|
|
cancelJump();
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'instant' });
|
|
draw();
|
|
return;
|
|
}
|
|
const start = scroller.scrollTop;
|
|
const began = performance.now();
|
|
const inputs = new AbortController();
|
|
let frame;
|
|
button.jumping = true;
|
|
cancelJump = () => {
|
|
cancelAnimationFrame(frame);
|
|
inputs.abort();
|
|
button.jumping = false;
|
|
draw();
|
|
};
|
|
// Wheel, touch, or keyboard navigation immediately gives control back to the reader.
|
|
for (const name of ['wheel', 'touchstart', 'pointerdown', 'keydown']) {
|
|
scroller.addEventListener(name, cancelJump, { passive: true, signal: inputs.signal });
|
|
}
|
|
const tick = (now) => {
|
|
if (!button.isConnected) { cancelJump(); return; }
|
|
const progress = Math.min(1, (now - began) / JUMP_MS);
|
|
// Re-read the foot: pictures and incoming steps can change its position mid-jump.
|
|
const end = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
|
scroller.scrollTo({ top: start + (end - start) * (1 - (1 - progress) ** 3), behavior: 'instant' });
|
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
|
else cancelJump();
|
|
};
|
|
frame = requestAnimationFrame(tick);
|
|
};
|
|
const scrolled = () => {
|
|
if (!button.isConnected) scroller.removeEventListener('scroll', scrolled); // another task's view by now
|
|
else draw();
|
|
};
|
|
scroller.addEventListener('scroll', scrolled, { passive: true });
|
|
button.arrived = (steps) => {
|
|
if (!atFoot(scroller)) unseen += steps;
|
|
draw();
|
|
};
|
|
return button;
|
|
}
|