clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / render / keys.ts
keys.ts 14.9K
1// The half of the keyboard that is not a route.2//3// The application spells its shortcuts as hx-triggers, because every one of4// them moves discrete state and a route is where discrete state is moved5// (issue #5 §4.1, internal/app/templates/shell.tmpl). What is left over is6// this file, and the line between them is not arbitrary: nothing here writes7// a session, and nothing here would survive being posted.8//9// HELD, NOT SWITCHED. Z raises every name for as long as the key is down.10// There is no state to leave on by mistake and no control to find first,11// and there is nothing to tell the server: the moment the key comes up the12// map is back.13// FOCUS. ⌘K reaches the search field, Escape hands the keyboard back from14// the grid field to the map, and G hands it on to the grid's own field and15// takes it back again. Where the caret is is not a fact about the volume.16// WHICH PANE IS UP. The backquote flips chart and sphere, which is the same17// world seen from a different distance -- the same filters, the same18// selection, the same camera.19// ZOOM. +/- step the chart the way the buttons beside it do.20// AND SUPPRESSION. The webview's own menu offers Reload and Inspect21// Element, which belong to a browser rather than to a map.22//23// ONE LISTENER PER EVENT, ON THE WINDOW, IN THE CAPTURE PHASE, and all four24// of those words are load-bearing:25//26// One, because a listener per control is a listener re-added every time a27// swap carries its control across -- the bug that made one press of the28// globe toggle flip the panes four times.29// On the window, because the controls this reads (the grid field, the30// search field, the panel's fold button) are the application's and are31// replaced by swaps; every one of them is resolved at the moment a key is32// pressed rather than held from before.33// In the capture phase, because the grid field's Escape has to be able to34// stop the window's own ascend from hearing it, and a window listener that35// waits for the bubble is too late to stop anything.36//37// Everything is registered against one AbortController, so a viewport that38// leaves the page takes its keyboard with it.3940/**41 * What the seam can answer with, as the keyboard asks for it.42 *43 * The viewport implements this. It is an interface rather than the class44 * because the four things below are the whole of what a keystroke may reach45 * for in this lane, and a test can stand in for all of them.46 */47export interface KeyboardHost {48 /** Z, held: every name up while the key is down, the map back when it isn't. */49 holdLabels(down: boolean): void;50 /** The backquote's flip. Called only when the page offers a sphere. */51 flipPane(): void;52 /** One step of zoom, on whichever pane is up. */53 zoomBy(delta: number): void;54 /** Whether the sphere is up. The zoom keys are inert behind it. */55 readonly sphereUp: boolean;56}5758/**59 * Wire the seam's keyboard. Returns the way to take it off again.60 *61 * Calling this twice without calling the answer in between would double every62 * shortcut, which is why the viewport keeps the answer in a field and calls it63 * before it wires again.64 */65export function wireKeyboard(host: KeyboardHost): () => void {66 const stop = new AbortController();67 const { signal } = stop;6869 window.addEventListener("keydown", (event) => pressed(host, event, signal),70 { capture: true, signal });71 // No guard and no preventDefault on the way up. A key that went down inside72 // a text field still has to be allowed to come up: the alternative is labels73 // stuck on because the reader clicked into the search box mid-hold.74 window.addEventListener("keyup", (event) => {75 if (event.key.toLowerCase() === "z") host.holdLabels(false);76 }, { signal });77 // A window that loses focus with the key down never hears it come back up.78 window.addEventListener("blur", () => host.holdLabels(false), { signal });79 // Text fields keep their menu, where cut and paste are worth having.80 window.addEventListener("contextmenu", (event) => {81 if (editable(event.target)) return;82 event.preventDefault();83 }, { signal });84 // A crumb that has just been answered hands the keyboard back to the map.85 window.addEventListener("change", (event) => followCrumb(event, signal),86 { capture: true, signal });8788 return () => stop.abort();89}9091/**92 * The ladder, in the reference implementation's own order.93 *94 * Each rung either answers the key and returns or leaves it for the next one,95 * and the order is the meaning: the field before the guard, the guard before96 * everything a reader would rather type than trigger.97 */98function pressed(host: KeyboardHost, event: KeyboardEvent, signal: AbortSignal): void {99 // The grid field speaks for itself first. Everything it does not claim100 // stops here anyway -- it is an input, and the guard below would have101 // stopped it one line later.102 //103 // The field is compared only once it is known to be there. A page with no104 // grid navigator answers `null` for it, and a keystroke aimed at no element105 // in particular answers `null` too, so the unguarded comparison would hand106 // every window-level key to the field's own ladder.107 const field = find("#grid-input");108 if (field !== null && event.target === field) {109 inField(host, event);110 return;111 }112 if (editable(event.target)) return;113 // The map's own keys, heard while the map has the focus. They are checked114 // before the window's because that is where the reference had them -- on115 // the surface rather than on the window -- and a key the surface answers is116 // not offered to anything else.117 if (overMap(event.target) && onMap(host, event)) return;118 // The backquote flips between chart and globe and touches nothing else.119 // Only a map that declared itself a sphere answers it, and the key is120 // swallowed either way: a backquote is not a character this page wants.121 if (event.key === "`" && !event.metaKey && !event.ctrlKey && !event.altKey) {122 if (offersSphere()) {123 event.preventDefault();124 host.flipPane();125 }126 return;127 }128 // G is the application's key -- it posts the grid on and off -- and the129 // keyboard it hands on is this lane's. The keystroke is not answered here,130 // only noted: nothing is swallowed, nothing is moved, and the route the131 // shell declares against this very filter does the rest.132 if (event.key.toLowerCase() === "g" && !event.metaKey && !event.ctrlKey &&133 !event.altKey && !event.repeat) {134 followGrid(signal);135 }136 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {137 event.preventDefault();138 const search = find<HTMLInputElement>("#pin-search");139 search?.focus();140 search?.select();141 return;142 }143 // Z is last, and it is the one key that is answered whether or not it144 // changes anything: a held key repeats, and every repeat after the first145 // says what the first one already said.146 if (event.key.toLowerCase() === "z") {147 event.preventDefault();148 if (!event.repeat) host.holdLabels(true);149 }150}151152/**153 * Inside the grid field.154 *155 * Space is missing from this list on purpose. It flips the subgrid from in156 * here exactly as it does from the map, but flipping the subgrid is discrete157 * state, so it is a route the application declares against this very field158 * (shell.tmpl) rather than a handler here. The two keys below are not: one159 * moves which pane is up, and one moves the focus.160 */161function inField(host: KeyboardHost, event: KeyboardEvent): void {162 // No hash will ever spell a backquote, so the pane flip reaches in here too.163 if (event.key === "`") {164 event.preventDefault();165 if (offersSphere()) host.flipPane();166 return;167 }168 if (event.key !== "Escape") return;169 // THE stopPropagation IS THE POINT. Escape leaves the field before it170 // leaves the level: the first press hands the keyboard back to the map and171 // the window's ascend never hears it, and the next press -- with the map172 // focused now -- telescopes out as ever.173 event.preventDefault();174 event.stopPropagation();175 find<HTMLElement>("#map")?.focus({ preventScroll: true });176}177178/**179 * On the map surface.180 *181 * Escape is missing from this list for the same reason Space is missing from182 * the field's: closing the card is discrete state and the application posts183 * it, from a trigger scoped to this same surface. What is left is the zoom,184 * which is continuous and nobody's business but this lane's.185 *186 * The keys are answered whichever pane is up, and only the chart moves. On187 * the sphere the distance is the buttons' to change -- there is no zoom level188 * to step -- but swallowing the keystroke either way keeps a shortcut from189 * being a shortcut on one pane and a rejection tone on the other.190 */191function onMap(host: KeyboardHost, event: KeyboardEvent): boolean {192 if (event.key === "+" || event.key === "=") {193 if (!host.sphereUp) host.zoomBy(1);194 } else if (event.key === "-") {195 if (!host.sphereUp) host.zoomBy(-1);196 } else {197 return false;198 }199 event.preventDefault();200 return true;201}202203/**204 * Follow the G key's own answer, once.205 *206 * The reference hands the keyboard on when the grid opens and takes it back207 * when it closes: the field that takes a hash is focused and selected on the208 * way in -- so "gm6" arrives at m6, the rest of the word being typed landing209 * where a hash is typed -- and the map is focused on the way out, where the210 * shortcuts live.211 *212 * Both halves are focus, so both are this file's. Neither can be done from213 * the keydown: what they follow is the *answer* to the key's post, and until214 * the swap has settled the navigator on screen is still the one from before.215 * So the press arms a listener for one settle and asks the page which way it216 * went -- the navigator says whether it is up, and nothing here has to217 * remember what it was.218 *219 * THE PRESS FOLLOWS ITS OWN SWAP, NOT THE NEXT ONE TO GO PAST. An earlier220 * draft took the first settle of any kind, on the argument that a request221 * already in flight settling first only lands the keystroke a beat early. It222 * does not: the page settles a search, a camera report and a session island223 * without the navigator moving at all, and a press that spent itself on one of224 * those asked a navigator that had not been swapped yet, read it as still shut,225 * and put the keyboard back on the map -- so G opened the grid and left the226 * reader typing into whatever they were typing into before. It was a race, so227 * it was intermittent, which is the worst way for a focus bug to be wrong.228 *229 * The grid's answer is identifiable: every route the key posts renders the230 * navigator's own region (internal/app/session.go, the `grid` concern), so the231 * settle to follow is the one whose target is that region. Anything else is232 * left alone and the listener stays armed for the swap it was waiting for.233 *234 * ONE PRESS AT A TIME. A second press before the first was answered replaces235 * it rather than stacking a second listener, because two listeners would both236 * fire on the one swap and the second would be asking about a page the first237 * has already moved. The signal is the viewport's, so a seam that leaves the238 * page leaves no armed press behind.239 */240let following: (() => void) | null = null;241242function followGrid(signal: AbortSignal): void {243 following?.();244 const answered = (event: Event) => {245 const navigator = find<HTMLElement>("#atlas-grid-navigator");246 // A page that renders no navigator can never answer, so the press is247 // spent here rather than left armed: the keyboard belongs on the map,248 // which is where every shortcut that is not a route is heard.249 if (navigator === null) {250 stop();251 find<HTMLElement>("#map")?.focus({ preventScroll: true });252 return;253 }254 const target = event.target;255 // The region itself, or anything the swap left inside it. `contains`256 // answers false for anything that is not a node in it, including the257 // window an event with no element behind it names.258 if (target !== navigator && !navigator.contains(target as Node | null)) return;259 stop();260 if (!navigator.hidden) {261 const field = find<HTMLInputElement>("#grid-input");262 field?.focus({ preventScroll: true });263 field?.select();264 return;265 }266 // The grid went away. The keyboard goes back to the map with it.267 find<HTMLElement>("#map")?.focus({ preventScroll: true });268 };269 const stop = () => {270 window.removeEventListener("htmx:after:settle", answered);271 following = null;272 };273 following = stop;274 window.addEventListener("htmx:after:settle", answered, { signal });275 signal.addEventListener("abort", () => { following = null; }, { once: true });276}277278/**279 * Hand the keyboard back to the map once a crumb is answered.280 *281 * The volume, world and lens crumbs are selects, and a select keeps the282 * keyboard after it is used: the next typed letter walks its options -- a283 * "g" jumping the reader to a volume that starts with one -- instead of284 * speaking to the map the pick just changed. A change on a crumb arms a285 * one-shot follow, and the next settle is the page answering that pick.286 * One press at a time, exactly as followGrid: a second pick before the287 * first was answered replaces the follow rather than stacking one.288 */289let followingCrumb: (() => void) | null = null;290291function followCrumb(event: Event, signal: AbortSignal): void {292 const target = event.target;293 if (!(target instanceof HTMLSelectElement)) return;294 if (!/^(volume|world|lens)-select$/.test(target.id)) return;295 followingCrumb?.();296 const answered = () => {297 stop();298 find<HTMLElement>("#map")?.focus({ preventScroll: true });299 };300 const stop = () => {301 window.removeEventListener("htmx:after:settle", answered);302 followingCrumb = null;303 };304 followingCrumb = stop;305 window.addEventListener("htmx:after:settle", answered, { signal });306 signal.addEventListener("abort", () => { followingCrumb = null; }, { once: true });307}308309/** Whether the page is offering a sphere, asked of the control that says so. */310function offersSphere(): boolean {311 const toggle = find<HTMLElement>("#globe-toggle");312 return toggle !== null && !toggle.hidden;313}314315/** Whether a keystroke landed on the map surface. */316function overMap(target: EventTarget | null): boolean {317 const map = find("#map");318 if (!map) return false;319 return target === map || (target instanceof Element && map.contains(target));320}321322/**323 * Whether a keystroke is a reader typing.324 *325 * A select counts, and that is not an oversight: a select answers the space326 * bar and the arrow keys itself, and a shortcut that took them would make the327 * control unusable. Anything that is not an Element at all -- the window,328 * which is what a synthetic key from a headless driver is dispatched at -- is329 * nobody's typing.330 */331function editable(target: EventTarget | null): boolean {332 if (!(target instanceof Element)) return false;333 if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return true;334 return (target as HTMLElement).isContentEditable === true;335}336337/** The application's controls, resolved at the moment they are needed. */338function find<T extends Element>(selector: string): T | null {339 return document.querySelector<T>(selector);340}