Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
8.4 kB · 126 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127// One tooltip for the whole screen. Hover anything with a data-tip (or a title, which is// taken over so the browser's slow native tip never shows) and it says what that thing is.// The first line of a tip is its name; the rest explains it.import { forScreen } from './keys';import { screen } from './telemetry';type Spot = { left: number; top: number; right: number; bottom: number; width: number };let place: ((text: string, r: Spot) => void) | null = null, drop: (() => void) | null = null, follow: (() => void) | null = null;let spotText: string | null = null, spotTimer = 0;/** A tip for something drawn on the canvas, which has no element to hover: show `text` by the * screen box `r` after the usual pause (or `now`, for a finger that has already held still), or * hide it with null. Calling again with the same text keeps the tip that is showing. */export function tipAt(text: string | null, r?: Spot, now = false) { if (text === spotText) return; clearTimeout(spotTimer); spotText = text; if (!text || !r) { drop?.(); return; } if (now) place?.(text, r); else spotTimer = window.setTimeout(() => { if (spotText === text) place?.(text, r); }, 250);}/** Once a frame: a tip that is showing keeps up with its thing when the words change under the mouse. */export const tipTick = () => follow?.();/** A button whose face is only a glyph (×, ↻, ▸) has no name a screen reader can say once its title has * become a tip. Give it the tip's first line as its name, unless it has one already. */export function nameGlyph(e: HTMLElement, tip = e.dataset.tip ?? e.title) { if (!tip || e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby') || !e.matches('button, a, [role="button"]')) return; const face = (e.textContent ?? '').trim(); if (face.length <= 3 && !/[\p{L}\p{N}]/u.test(face)) e.setAttribute('aria-label', tip.split('\n')[0]);}/** How long a finger holds a button before its tip shows in place of what it does. */export const HOLD = 450;/** What a tap presses, rather than reads: its tip waits for a hold. */const PRESS = 'button:not(.locked), a, input, select, textarea, [data-act], [data-need], [data-site], [data-sort], [data-node]';const phone = matchMedia('(max-width: 700px)');/** How far a finger may move and still tap: further, and it was a swipe. */export const SLOP = 12;let lastTouch = -1e9;/** A tap on a panel's buttons. A mouse acts at once: on pointerdown (so a panel's frequent redraw can't swallow * the click), or on click with `mouse` 'click'. A finger acts when it lifts, on what it went down on, and only if * it did not move (a swipe scrolls the panel) or hold (a hold shows the tip). The keyboard's click (Enter or * Space on a button) acts too. */export function taps(el: HTMLElement, act: (t: HTMLElement, e: Event) => void, mouse: 'down' | 'click' = 'down') { let down: { id: number; t: HTMLElement; x: number; y: number; at: number } | null = null; el.addEventListener('pointerdown', e => { if (e.pointerType === 'touch') { lastTouch = performance.now(); down = { id: e.pointerId, t: e.target as HTMLElement, x: e.clientX, y: e.clientY, at: lastTouch }; return; } if (mouse === 'down') act(e.target as HTMLElement, e); }); window.addEventListener('pointerup', e => { const d = down; if (!d || d.id !== e.pointerId) return; down = null; lastTouch = performance.now(); if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > SLOP || performance.now() - d.at > HOLD) return; const t = d.t.isConnected ? d.t : document.elementFromPoint(e.clientX, e.clientY); // the panel redrew under the finger: the copy in the same spot is the same thing if (t instanceof HTMLElement && el.contains(t)) act(t, e); }); window.addEventListener('pointercancel', e => { if (down?.id === e.pointerId) down = null; }); // the panel scrolled el.addEventListener('click', e => { if (e.detail === 0 || (mouse === 'click' && performance.now() - lastTouch > 800)) act(e.target as HTMLElement, e); // not the click a finger's tap makes after it });}export function attachTips() { const tip = document.createElement('div'); tip.className = 'tip'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); document.body.append(tip); let at: HTMLElement | null = null, timer = 0, said = '';
const target = (el: EventTarget | null) => { const e = el instanceof Element ? el.closest<HTMLElement>('[data-tip], [title]') : null; if (!e) return null; if (e.title) { nameGlyph(e, e.title); e.dataset.tip = e.title; e.removeAttribute('title'); } return e.dataset.tip ? e : null; }; const hide = () => { clearTimeout(timer); clearTimeout(spotTimer); spotText = null; tip.hidden = true; at = null; }; const show = (e: HTMLElement) => { said = e.dataset.tip!; showAt(said, e.getBoundingClientRect(), e); }; const showAt = (text: string, r: Spot, el?: HTMLElement) => { text = forScreen(text); // a touch screen's words: Tap, and no key hints (index.html's tips too) const [head, ...rest] = text.split('\n'); screen('tip', head); tip.innerHTML = rest.length ? `<b></b><span></span>` : `<span></span>`; if (rest.length) { tip.querySelector('b')!.textContent = head; tip.querySelector('span')!.textContent = rest.join('\n'); } else tip.querySelector('span')!.textContent = head; tip.hidden = false; // below the thing if there's room, else above; always on screen const w = tip.offsetWidth, h = tip.offsetHeight, pad = 8; const x = Math.max(pad, Math.min(innerWidth - w - pad, r.left + r.width / 2 - w / 2)); let y = r.bottom + 6 + h < innerHeight - pad ? r.bottom + 6 : Math.max(pad, r.top - 6 - h); // a phone's card is a sheet: a tip for something on it goes above the sheet, so it covers neither the thing // held nor the card's head. With no room there, under the thing; else over it, but never over the head const sheet = el && phone.matches ? el.closest<HTMLElement>('#card') : null; if (sheet) { const top = sheet.getBoundingClientRect().top, head = sheet.querySelector('.head')?.getBoundingClientRect().bottom ?? top; y = top - 6 - h >= pad ? top - 6 - h : r.bottom + 6 + h <= innerHeight - pad ? r.bottom + 6 : Math.max(head + 4, r.top - 6 - h); } tip.style.left = `${x}px`; tip.style.top = `${y}px`; };
place = showAt; drop = () => { if (!at) tip.hidden = true; }; document.addEventListener('pointerover', ev => { if (ev.pointerType === 'touch') return; const e = target(ev.target); if (e === at) return; // a panel redrew and put a new copy of the thing under the mouse: it's the same thing, so the tip stays if (e && at && !at.isConnected) { at = e; if (!tip.hidden) show(e); return; } hide(); if (!e) return; at = e; timer = window.setTimeout(() => { if (at?.isConnected) show(at); }, 250); }); follow = () => { if (at?.isConnected && !tip.hidden && at.dataset.tip && at.dataset.tip !== said) show(at); }; // touch has no hover: tap a readout (a chip, a note, a drawn row, a button that cannot be pressed) to see its tip, and tap // it again or anywhere else to close. A button keeps its tap for what it does: hold it to see its tip instead. That // goes for a button inside a row with a tip of its own too (a Needs-you row's Show): a tap there never shows the row's tip. let held = 0, x0 = 0, y0 = 0; const letGo = () => clearTimeout(held); document.addEventListener('pointerdown', ev => { const e = ev.pointerType === 'touch' ? target(ev.target) : null, again = e && at && e.dataset.tip === at.dataset.tip; hide(); letGo(); if (!e || again) return; if (e.closest(PRESS) || (ev.target as Element).closest?.(PRESS)) { x0 = ev.clientX; y0 = ev.clientY; held = window.setTimeout(() => { if (e.isConnected) { at = e; show(e); } }, HOLD); return; } at = e; show(e); }, true); document.addEventListener('pointerup', letGo, true); document.addEventListener('pointercancel', letGo, true); document.addEventListener('pointermove', e => { if (Math.hypot(e.clientX - x0, e.clientY - y0) > 12) letGo(); }, true); document.addEventListener('contextmenu', e => { if (at && (e.target as Element)?.closest?.('[data-act]')) e.preventDefault(); }); // a held button shows its tip, not the phone's menu document.addEventListener('keydown', hide, true); document.addEventListener('wheel', hide, { passive: true });}