oxedyne/daimond/www/js/mobile.js
25.4 KiB, 8 runs
created by r2519314175:1399, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Daimond — the phone shell controller |
| 3 | ------------------------------------------------------------ |
| 4 | Two surfaces beyond the chat floor and the bottom bar: |
| 5 | |
| 6 | the DRAWER the rail (Diamonds / Chats / Admin), slid in from |
| 7 | the left. Opened by the hamburger, closed by a |
| 8 | tap on the scrim, a swipe, or picking a chat. |
| 9 | |
| 10 | the SHEET a "thing" (Web, Doc, Message, Compose, Tools) |
| 11 | raised over the chat at three detents. `half` |
| 12 | shows the thing and the tail of the conversation |
| 13 | together — the daimon beside the thing. An ask |
| 14 | pill at its foot forwards to the one composer, so |
| 15 | the thing can be talked over without leaving it. |
| 16 | |
| 17 | `daimond.js` routes here: a stage guest reaching mshow() opens |
| 18 | the sheet; the rail opens the drawer. This file exposes |
| 19 | window.DaimondSheet (open/close/onEngineHide) and |
| 20 | window.DaimondShell (openDrawer/closeDrawer). |
| 21 | ============================================================ */ |
| 22 | (function () { |
| 23 | 'use strict'; |
| 24 | |
| 25 | var mq = window.matchMedia('(max-width: 760px)'); |
| 26 | function isPhone() { return mq.matches; } |
| 27 | |
| 28 | // ── The drawer ───────────────────────────────────────────── |
| 29 | function openDrawer() { document.body.classList.add('drawer-open'); } |
| 30 | function closeDrawer() { document.body.classList.remove('drawer-open'); } |
| 31 | function toggleDrawer() { document.body.classList.toggle('drawer-open'); } |
| 32 | |
| 33 | // ── The footer: the chip row ─────────────────────────────── |
| 34 | // |
| 35 | // The bar carried four hard-wired destinations -- Chat, Email, Files, Agents |
| 36 | // -- and named four of the seventeen panels there are. It carries the chip |
| 37 | // row now, which is the row the desktop header carries, MOVED here rather |
| 38 | // than copied: `#panel-tags` is the one row the layout engine renders, and |
| 39 | // the one the gallery asks whether a panel is on. A second copy would be a |
| 40 | // second thing to keep in step with both, and the first to drift. |
| 41 | |
| 42 | /// Put the chip row where this width wants it: the footer on a phone, the |
| 43 | /// top bar otherwise. |
| 44 | function placeChips() { |
| 45 | var row = document.getElementById('panel-tags'); |
| 46 | var bar = document.getElementById('mnav'); |
| 47 | var acts = document.querySelector('.top-actions'); |
| 48 | if (!row || !bar || !acts) return; |
| 49 | var want = isPhone() ? bar : acts; |
| 50 | if (row.parentNode === want) return; |
| 51 | // FIRST in the top bar, which is where the markup has it: the icon buttons |
| 52 | // after it are what the row's left edge is measured against. |
| 53 | if (want === acts) acts.insertBefore(row, acts.firstChild); |
| 54 | else bar.appendChild(row); |
| 55 | bindScroll(row); |
| 56 | if (window.DaimondPanels) DaimondPanels.reflow(); |
| 57 | markHere(); |
| 58 | } |
| 59 | |
| 60 | /// Which panel the user is actually looking at, or '' if that is the chat |
| 61 | /// floor with nothing over it. |
| 62 | /// |
| 63 | /// Three surfaces can be the answer and the phone shows one of them at a |
| 64 | /// time: the drawer (the rail), the sheet (a guest), and the destination on |
| 65 | /// the floor. Exported because the chip row's renderer has to fill in the |
| 66 | /// same chip this marks, and two answers to "where am I" would show as two |
| 67 | /// chips filled at once. |
| 68 | function here() { |
| 69 | if (document.body.classList.contains('drawer-open')) return 'rail'; |
| 70 | if (guest) return guest; |
| 71 | return document.body.dataset.mpanel || ''; |
| 72 | } |
| 73 | |
| 74 | /// Fill in the chip for wherever we are, and scroll it into view. |
| 75 | /// |
| 76 | /// On a desktop a filled chip means "this panel is open", which is legible |
| 77 | /// because you can see the panel. On a phone one thing is on screen, so the |
| 78 | /// only useful meaning is "this is it" -- an `open` Email panel behind a |
| 79 | /// Terminal sheet is not where the user is. |
| 80 | var _here = null; |
| 81 | function markHere() { |
| 82 | if (!isPhone()) return; |
| 83 | var row = document.getElementById('panel-tags'); |
| 84 | if (!row) return; |
| 85 | var id = here(), on = null; |
| 86 | row.querySelectorAll('.ptag[data-panel]').forEach(function (c) { |
| 87 | var is = c.dataset.panel === id; |
| 88 | c.classList.toggle('on', is); |
| 89 | c.setAttribute('aria-pressed', is ? 'true' : 'false'); |
| 90 | if (is) on = c; |
| 91 | }); |
| 92 | // SCROLLED TO ONLY WHEN IT CHANGES. This runs on every attribute change to |
| 93 | // `body` -- and `class` alone carries `resizing`, `sheet-open` and |
| 94 | // `drawer-open` -- so a scroll on every call would drag the strip back to |
| 95 | // wherever the user already is each time anything at all happened, while |
| 96 | // their thumb was on it looking for something else. |
| 97 | if (on && id !== _here && on.scrollIntoView) { |
| 98 | try { on.scrollIntoView({ inline: 'nearest', block: 'nearest' }); } catch (e) { /* old engine */ } |
| 99 | } |
| 100 | _here = id; |
| 101 | markScroll(row); |
| 102 | } |
| 103 | |
| 104 | /// Say which way there is more of the row, so the fade at its ends is true. |
| 105 | function markScroll(row) { |
| 106 | var more = row.scrollWidth - row.clientWidth; |
| 107 | if (more <= 1) { row.removeAttribute('data-more'); return; } |
| 108 | var atStart = row.scrollLeft <= 1; |
| 109 | var atEnd = row.scrollLeft >= more - 1; |
| 110 | row.dataset.more = atStart ? 'end' : (atEnd ? 'start' : 'both'); |
| 111 | } |
| 112 | |
| 113 | function bindScroll(row) { |
| 114 | if (row._moreBound) return; |
| 115 | row._moreBound = true; |
| 116 | row.addEventListener('scroll', function () { markScroll(row); }, { passive: true }); |
| 117 | if (typeof ResizeObserver !== 'undefined') { |
| 118 | new ResizeObserver(function () { markScroll(row); }).observe(row); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// What a footer chip means: take me there. |
| 123 | /// |
| 124 | /// NOT the desktop row's toggle. A chip there is filled while its panel is |
| 125 | /// open and clicking it puts the panel away, which reads correctly because |
| 126 | /// you can see both states at once. Here the thing it would put away is the |
| 127 | /// whole screen -- and for the rail it is worse than that: `hide('rail')` |
| 128 | /// sets `display: none` on the element the drawer IS, and the hamburger only |
| 129 | /// toggles a class, so the drawer would never open again. See the capture |
| 130 | /// handler further down, which exists for the same trap on the rail's own |
| 131 | /// closer. |
| 132 | function goTo(id) { |
| 133 | if (guest && guest !== id) close(); // one thing up at a time |
| 134 | if (window.DaimondPanels) DaimondPanels.show(id); |
| 135 | markHere(); |
| 136 | } |
| 137 | |
| 138 | // ── The sheet ────────────────────────────────────────────── |
| 139 | // Guests that default to full (a thing you mostly read or write) |
| 140 | // versus half (a thing you glance at while talking to the daimon). |
| 141 | // A thing you mostly read or write, so it opens at full height rather than |
| 142 | // half. The terminal belongs here for the plainest reason: at the half detent |
| 143 | // it is eleven rows, and eleven rows is not a terminal. |
| 144 | var DEFAULT_FULL = { doc: 1, preview: 1, compose: 1, tools: 1, term: 1 }; |
| 145 | // Guests with nothing to "ask about" hide the ask pill. The trash is one: |
| 146 | // asking the daimon about a list of things you have deleted would offer to |
| 147 | // spend money on the one surface whose whole subject is undoing a mistake. |
| 148 | // Social is another, for the opposite reason: it is ALREADY a box you write |
| 149 | // in, and a second box under it that sends what you write to a model is two |
| 150 | // boxes with opposite meanings. |
| 151 | var NO_ASK = { compose: 1, tools: 1, trash: 1, social: 1 }; |
| 152 | |
| 153 | var sheetEl, bodyEl, grabEl, titleEl, askWrap, askInput, askSend; |
| 154 | var guest = null; // the panel id currently in the sheet, or null |
| 155 | var detent = 'half'; // full | half | peek |
| 156 | var closing = false; // re-entrancy guard against DaimondPanels.hide |
| 157 | // What had the keyboard when the sheet went up, so the sheet can give it |
| 158 | // back. Without this a dismissal dropped focus on the document body and the |
| 159 | // next Tab started again from the top of the app. |
| 160 | var opener = null; |
| 161 | |
| 162 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 163 | |
| 164 | function label(id) { |
| 165 | var el = document.getElementById('panel-' + id); |
| 166 | return (el && el.getAttribute('data-label')) || id; |
| 167 | } |
| 168 | |
| 169 | // The top bar (~50px) and the bottom bar bound the room the sheet may take. |
| 170 | var BAR_FALLBACK = 62, TOPBAR = 50, PEEK = 56; |
| 171 | |
| 172 | /// How tall the bottom bar actually is, home indicator included. |
| 173 | /// |
| 174 | /// MEASURED, not written down. This was the constant 58 while css/mobile.css |
| 175 | /// drew the bar from its own 58 and variables.css declared 54 -- three copies |
| 176 | /// of one number, and the sheet's foot is placed from one of them while its |
| 177 | /// height is worked out from another. A bar that changes (it did, on |
| 178 | /// 2026-08-28, when the four destinations became the chip row) then puts the |
| 179 | /// sheet's foot over it, which is what `dev/verify_sweep_mobile.mjs` measures |
| 180 | /// at every phone width and with a notch inset posed. The fallback is for a |
| 181 | /// call before the bar is laid out, and errs LARGE, which errs towards a |
| 182 | /// shorter sheet. |
| 183 | function barH() { |
| 184 | var b = document.getElementById('mnav'); |
| 185 | var h = b ? Math.round(b.getBoundingClientRect().height) : 0; |
| 186 | return h > 0 ? h : BAR_FALLBACK; |
| 187 | } |
| 188 | |
| 189 | /// The most a sheet may grow to: from just under the top bar to just above |
| 190 | /// the bottom bar. `full` stops a touch short so a sliver of chat stays. |
| 191 | function maxH() { return Math.max(PEEK, window.innerHeight - TOPBAR - barH()); } |
| 192 | |
| 193 | /// The HEIGHT of the sheet at each detent (it is anchored to the bottom, so |
| 194 | /// a taller sheet reveals more of the thing and less of the chat). |
| 195 | function detentH(name) { |
| 196 | if (name === 'peek') return PEEK; |
| 197 | if (name === 'full') return maxH(); |
| 198 | return Math.min(maxH(), Math.round(window.innerHeight * 0.52)); // half |
| 199 | } |
| 200 | |
| 201 | function applyH(px) { sheetEl.style.height = px + 'px'; } |
| 202 | |
| 203 | function snapTo(name) { |
| 204 | detent = name; |
| 205 | sheetEl.classList.remove('dragging'); |
| 206 | applyH(detentH(name)); // height is instant; only the slide animates |
| 207 | } |
| 208 | |
| 209 | /// Hide a guest's own title when the sheet has just said the same thing. |
| 210 | /// |
| 211 | /// A panel's `.ctitle` is its NAME while it is empty and a live title once it |
| 212 | /// holds something — a URL, a filename, a subject — so no CSS selector can |
| 213 | /// tell the two apart, and the stylesheet's attempt to name the offenders was |
| 214 | /// wrong: it claimed Tools was the only panel that repeated the sheet's label, |
| 215 | /// and rendering all nine showed Message over Message, Graph over Graph, and a |
| 216 | /// blank 44px band over Doc, whose title is empty and whose closer is hidden |
| 217 | /// on a phone. Compose ("New message") and Web (a live URL) are real subtitles |
| 218 | /// and are left alone. |
| 219 | /// |
| 220 | /// The test is what is on the screen, made when the guest is raised, so a |
| 221 | /// panel that later gains a real title keeps it. And a head is dropped only |
| 222 | /// when it says nothing new AND holds nothing but its own closer: the |
| 223 | /// Terminal's head repeats the label and carries Start and Stop, and a |
| 224 | /// terminal you cannot start is not a terminal. |
| 225 | function hideRedundantHead(el, sheetLabel) { |
| 226 | var head = el.querySelector('.chead, .railhead'); |
| 227 | if (!head) return; |
| 228 | var title = head.querySelector('.ctitle'); |
| 229 | var said = title ? (title.textContent || '').trim() : ''; |
| 230 | var same = !said || said.toLowerCase() === String(sheetLabel || '').trim().toLowerCase(); |
| 231 | var ctrls = head.querySelectorAll('button, a[href], input, select, [role="button"]'); |
| 232 | var kept = 0; |
| 233 | for (var i = 0; i < ctrls.length; i++) { |
| 234 | // The panel's own closer is what the sheet's own ✕ already is. |
| 235 | if (!ctrls[i].classList.contains('panel-close')) kept++; |
| 236 | } |
| 237 | head.classList.toggle('head-said-twice', same && kept === 0); |
| 238 | } |
| 239 | |
| 240 | /// Raise a guest. The element is MOVED into the sheet (the same idiom |
| 241 | /// the desktop layout engine uses to reorder panels); the desktop's |
| 242 | /// apply() skips reordering on a phone, so it stays put until closed. |
| 243 | function open(id) { |
| 244 | if (!sheetEl) return; |
| 245 | var el = document.getElementById('panel-' + id); |
| 246 | if (!el) return; |
| 247 | if (guest && guest !== id) stashBack(); // only one thing up at a time |
| 248 | // Captured before anything in the sheet takes the keyboard, and only on |
| 249 | // the FIRST raise: swapping one guest for another must not make the |
| 250 | // outgoing guest the thing focus goes home to. |
| 251 | if (!guest) opener = document.activeElement; |
| 252 | el.style.display = ''; // clear any inline none left by apply() |
| 253 | bodyEl.appendChild(el); |
| 254 | guest = id; |
| 255 | titleEl.textContent = label(id); |
| 256 | hideRedundantHead(el, label(id)); |
| 257 | document.body.classList.add('sheet-open'); |
| 258 | sheetEl.classList.add('open'); |
| 259 | if (NO_ASK[id]) askWrap.classList.add('hidden'); |
| 260 | else { |
| 261 | askWrap.classList.remove('hidden'); |
| 262 | askInput.placeholder = t('sheet.ask_about', { thing: label(id).toLowerCase() }); |
| 263 | } |
| 264 | // Size to the detent instantly (still slid off-screen), then add `.open` |
| 265 | // on the next frame so the transform slides it up into view. |
| 266 | snapTo(DEFAULT_FULL[id] ? 'full' : 'half'); |
| 267 | requestAnimationFrame(function () { sheetEl.classList.add('open'); }); |
| 268 | setTimeout(function () { sheetEl.classList.add('open'); }, 20); // headless-safe |
| 269 | } |
| 270 | |
| 271 | /// Put the guest element back where the desktop engine expects it, |
| 272 | /// hidden, so a later resize to desktop reseats it correctly. |
| 273 | function stashBack() { |
| 274 | var el = document.getElementById('panel-' + guest); |
| 275 | var stage = document.getElementById('stage'); |
| 276 | if (el && stage) { el.style.display = 'none'; stage.appendChild(el); } |
| 277 | } |
| 278 | |
| 279 | function teardown() { |
| 280 | stashBack(); |
| 281 | guest = null; |
| 282 | document.body.classList.remove('sheet-open'); |
| 283 | sheetEl.classList.remove('open'); // slides down (transform), then rests |
| 284 | applyH(0); |
| 285 | // Give the keyboard back to whatever raised the sheet, if it is still on |
| 286 | // screen — a panel that redrew underneath may have taken it with it. |
| 287 | if (opener && opener.focus && opener.getClientRects && opener.getClientRects().length) { |
| 288 | try { opener.focus(); } catch (e) { /* gone with the redraw */ } |
| 289 | } |
| 290 | opener = null; |
| 291 | } |
| 292 | |
| 293 | /// A user dismissal: tear the sheet down AND tell the engine the panel |
| 294 | /// is closed, so its state and (on desktop) its header tag stay honest. |
| 295 | function close() { |
| 296 | if (!guest) return; |
| 297 | var id = guest; |
| 298 | teardown(); |
| 299 | closing = true; |
| 300 | try { if (window.DaimondPanels) DaimondPanels.hide(id); } |
| 301 | finally { closing = false; } |
| 302 | } |
| 303 | |
| 304 | /// The engine closed a panel (e.g. its own close button was reached). |
| 305 | /// Mirror it in the sheet, unless we are the ones who asked for it. |
| 306 | function onEngineHide(id) { |
| 307 | if (closing) return; |
| 308 | if (guest === id) teardown(); |
| 309 | } |
| 310 | |
| 311 | // ── Dragging the grabber ─────────────────────────────────── |
| 312 | // Drag UP grows the sheet (reveal more of the thing); drag DOWN shrinks it, |
| 313 | // and past peek it dismisses. |
| 314 | // |
| 315 | // THE SHEET MOVES FROM THE GRABBER AND FROM NOWHERE ELSE. THE GUEST OWNS |
| 316 | // EVERY DRAG INSIDE ITSELF. |
| 317 | // |
| 318 | // That is a rule and not a description, because a guest can now be a |
| 319 | // SCROLLER: the watched live document puts a tall column of typeset pages in |
| 320 | // `.tl-scroll` inside this sheet, and a nested scroller under a draggable |
| 321 | // surface is where gestures fight. Whichever of the two loses becomes |
| 322 | // impossible — a sheet that takes the drag makes the book unreadable, and a |
| 323 | // scroller that takes it makes the sheet unmovable — so the line is drawn at |
| 324 | // the grabber, which is the one strip of the sheet a guest never occupies. |
| 325 | // |
| 326 | // Measured at 390x844, with a six-page document in the sheet at `full`: |
| 327 | // |
| 328 | // drag up from the middle of the pages document +333px, sheet unmoved |
| 329 | // drag down near the top, page scrolled document back to +58, sheet unmoved |
| 330 | // drag down at the very top nothing moves at all: not the |
| 331 | // scroller, not the sheet body, not |
| 332 | // the page behind it |
| 333 | // drag down on the grabber sheet 736 -> 439, and the reader |
| 334 | // stays on page 1 +36pt |
| 335 | // |
| 336 | // The third line is the one worth keeping: an overscroll cannot escape, |
| 337 | // because `responsive.css` pins `html, body { overscroll-behavior: none }` |
| 338 | // under 760px and `#msheet-body` is not itself a scroller. So a drag that |
| 339 | // runs off the end of the book does nothing, rather than dragging the sheet |
| 340 | // or pulling the page down to refresh — and a refresh is the one thing a live |
| 341 | // compiler does not survive. |
| 342 | // |
| 343 | // The obvious "improvement" — letting a drag anywhere in the sheet move it, |
| 344 | // which is what several phone sheets do — would take the pages away. Do not. |
| 345 | function bindGrab() { |
| 346 | var startY = 0, startH = 0, startDetent = 'half', dragging = false; |
| 347 | grabEl.addEventListener('pointerdown', function (e) { |
| 348 | dragging = true; |
| 349 | startY = e.clientY; |
| 350 | startH = sheetEl.getBoundingClientRect().height; |
| 351 | startDetent = detent; |
| 352 | sheetEl.classList.add('dragging'); |
| 353 | grabEl.setPointerCapture(e.pointerId); |
| 354 | }); |
| 355 | grabEl.addEventListener('pointermove', function (e) { |
| 356 | if (!dragging) return; |
| 357 | var h = Math.min(maxH(), Math.max(0, startH + (startY - e.clientY))); |
| 358 | applyH(h); |
| 359 | }); |
| 360 | grabEl.addEventListener('pointerup', function (e) { |
| 361 | if (!dragging) return; |
| 362 | dragging = false; |
| 363 | try { grabEl.releasePointerCapture(e.pointerId); } catch (x) {} |
| 364 | var h = Math.max(0, startH + (startY - e.clientY)); |
| 365 | // Dragged well below peek: dismiss. |
| 366 | if (h < PEEK - 24) { close(); return; } |
| 367 | // Otherwise snap to the nearest of full/half/peek by height. |
| 368 | var opts = [['full', detentH('full')], ['half', detentH('half')], ['peek', detentH('peek')]]; |
| 369 | var best = opts[0], bestD = Infinity; |
| 370 | opts.forEach(function (o) { |
| 371 | var d = Math.abs(o[1] - h); |
| 372 | if (d < bestD) { bestD = d; best = o; } |
| 373 | }); |
| 374 | snapTo(best[0]); |
| 375 | }); |
| 376 | // A POINTER STREAM CAN END IN `pointercancel` RATHER THAN `pointerup`, and |
| 377 | // on a phone it does: the browser claims a gesture mid-drag and takes the |
| 378 | // pointer with it. Nothing listened for that, so the drag never ended — |
| 379 | // `dragging` stayed true, `.dragging` stayed on the sheet, and the sheet was |
| 380 | // left stuck at whatever height the cancelled drag had reached. Worse, the |
| 381 | // `visualViewport` re-fit below skips a sheet that is `.dragging`, so from |
| 382 | // then on a keyboard coming up no longer re-fitted it either. Measured: a |
| 383 | // cancel at 299px left the sheet at 299px through a viewport change to 600. |
| 384 | // |
| 385 | // It goes back to the detent the drag STARTED at, not to the nearest one: a |
| 386 | // gesture the user did not finish decided nothing. |
| 387 | grabEl.addEventListener('pointercancel', function (e) { |
| 388 | if (!dragging) return; |
| 389 | dragging = false; |
| 390 | try { grabEl.releasePointerCapture(e.pointerId); } catch (x) {} |
| 391 | snapTo(startDetent); |
| 392 | }); |
| 393 | } |
| 394 | |
| 395 | // ── The ask pill: forward to the one composer ────────────── |
| 396 | function ask() { |
| 397 | var text = (askInput.value || '').trim(); |
| 398 | if (!text) return; |
| 399 | if (!(window.DaimondCore && DaimondCore.ask)) return; |
| 400 | DaimondCore.ask(text); |
| 401 | askInput.value = ''; |
| 402 | askInput.blur(); |
| 403 | // Park the thing so the answer, which lands on the chat floor |
| 404 | // behind the sheet, comes fully into view. The peek bar taps back. |
| 405 | snapTo('peek'); |
| 406 | } |
| 407 | |
| 408 | // ── Init ─────────────────────────────────────────────────── |
| 409 | function init() { |
| 410 | sheetEl = document.getElementById('msheet'); |
| 411 | bodyEl = document.getElementById('msheet-body'); |
| 412 | grabEl = document.getElementById('msheet-grab'); |
| 413 | titleEl = document.getElementById('msheet-title'); |
| 414 | askWrap = document.getElementById('msheet-ask'); |
| 415 | askInput = document.getElementById('msheet-ask-input'); |
| 416 | askSend = document.getElementById('msheet-ask-send'); |
| 417 | if (!sheetEl) return; |
| 418 | |
| 419 | bindGrab(); |
| 420 | document.getElementById('msheet-close').addEventListener('click', close); |
| 421 | askSend.addEventListener('click', ask); |
| 422 | askInput.addEventListener('keydown', function (e) { |
| 423 | if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(); } |
| 424 | }); |
| 425 | |
| 426 | // The chip row belongs to whichever bar this width uses. |
| 427 | placeChips(); |
| 428 | |
| 429 | // WHERE WE ARE IS WATCHED, NOT REPORTED. Six or more paths change it -- |
| 430 | // `mshow`, the sheet opening and closing, the drawer, a panel closing |
| 431 | // itself, a saved layout being restored -- and every one of them ends in |
| 432 | // one of these two attributes. Watching them covers the paths nobody |
| 433 | // remembered to call from, which is exactly how the old bar's four buttons |
| 434 | // came to be marked in two places and not in the others. |
| 435 | var watch = new MutationObserver(markHere); |
| 436 | watch.observe(document.body, { attributes: true, attributeFilter: ['data-mpanel', 'class'] }); |
| 437 | |
| 438 | // The hamburger and the scrim. |
| 439 | var burger = document.getElementById('drawer-btn'); |
| 440 | if (burger) burger.addEventListener('click', toggleDrawer); |
| 441 | var scrim = document.getElementById('scrim'); |
| 442 | if (scrim) scrim.addEventListener('click', closeDrawer); |
| 443 | |
| 444 | // Picking a chat or a Diamond is "go work on this" — the drawer's job |
| 445 | // is done, so it steps out of the way. |
| 446 | var rail = document.getElementById('panel-rail'); |
| 447 | if (rail) rail.addEventListener('click', function (e) { |
| 448 | if (e.target.closest('.session-box, .diamond-box')) closeDrawer(); |
| 449 | }); |
| 450 | |
| 451 | // The rail's own closer, which responsive.css leaves on screen here alone |
| 452 | // (see the note beside `.panel.rail .panel-close`). On a desktop it hides |
| 453 | // the rail panel; on a phone the rail IS the drawer, so it closes the |
| 454 | // drawer instead — a cross closes the ONE thing it sits on, and here that |
| 455 | // thing is the drawer. |
| 456 | // |
| 457 | // Caught in the CAPTURE phase and stopped, because the panel engine binds |
| 458 | // every `[data-close]` on the bubble: left to reach it, `hide('rail')` |
| 459 | // would set `display: none` on the drawer, and the hamburger — which only |
| 460 | // toggles `body.drawer-open` — would then open nothing at all. |
| 461 | if (rail) rail.addEventListener('click', function (e) { |
| 462 | var b = e.target.closest && e.target.closest('.panel-close'); |
| 463 | if (!b || !isPhone()) return; |
| 464 | e.stopPropagation(); |
| 465 | e.preventDefault(); |
| 466 | closeDrawer(); |
| 467 | var burger2 = document.getElementById('drawer-btn'); |
| 468 | // The keyboard goes back to what opened the drawer. |
| 469 | if (burger2) { try { burger2.focus(); } catch (x) { /* not on screen */ } } |
| 470 | }, true); |
| 471 | |
| 472 | // A left-edge swipe opens the drawer; a swipe on the open drawer's |
| 473 | // scrim is caught by the scrim tap. Gesture, always paired with the |
| 474 | // visible hamburger — never gesture-only. |
| 475 | bindEdgeSwipe(); |
| 476 | |
| 477 | // Escape, which neither of these two answered. They are the only surfaces |
| 478 | // in the app that cover it and had no key out at all — a phone shell run |
| 479 | // on a tablet with a keyboard, or on a desktop narrowed past 760px, left |
| 480 | // the keyboard with nothing to press. |
| 481 | // |
| 482 | // The innermost thing goes first, and a keystroke a dialog has already |
| 483 | // dealt with is left alone: the app's dialogs call `preventDefault` on the |
| 484 | // Escape they consume, so `defaultPrevented` is how one closed OVER the |
| 485 | // sheet avoids taking the sheet down with it. |
| 486 | document.addEventListener('keydown', function (e) { |
| 487 | if (e.key !== 'Escape' || e.defaultPrevented) return; |
| 488 | if (guest) { close(); return; } |
| 489 | if (document.body.classList.contains('drawer-open')) { |
| 490 | closeDrawer(); |
| 491 | var burger3 = document.getElementById('drawer-btn'); |
| 492 | if (burger3) { try { burger3.focus(); } catch (x) { /* not on screen */ } } |
| 493 | } |
| 494 | }); |
| 495 | |
| 496 | // Keep the sheet honest across a keyboard show/hide and rotation. |
| 497 | if (window.visualViewport) { |
| 498 | window.visualViewport.addEventListener('resize', function () { |
| 499 | if (guest && !sheetEl.classList.contains('dragging')) snapTo(detent); |
| 500 | }); |
| 501 | } |
| 502 | |
| 503 | // Crossing the phone boundary: fold the phone surfaces away when we |
| 504 | // grow to desktop, and let the engine reseat everything. Driven off |
| 505 | // `resize` (not only the media-query `change`, which some engines fire |
| 506 | // unreliably under a scripted viewport) so the desktop restore is sure. |
| 507 | if (mq.addEventListener) mq.addEventListener('change', scheduleMode); |
| 508 | else if (mq.addListener) mq.addListener(scheduleMode); |
| 509 | window.addEventListener('resize', scheduleMode); |
| 510 | } |
| 511 | |
| 512 | // Debounced, and gated on the live width rather than the media query — some |
| 513 | // engines flip matchMedia a beat after the resize event, and the reseat must |
| 514 | // not miss that beat and leave a guest stranded in the sheet. |
| 515 | var modeTimer = null; |
| 516 | function scheduleMode() { |
| 517 | if (modeTimer) clearTimeout(modeTimer); |
| 518 | modeTimer = setTimeout(onMode, 60); |
| 519 | } |
| 520 | function onMode() { |
| 521 | // Either way the chip row has to be in the bar this width uses, so this |
| 522 | // comes before the desktop-only restore below it. |
| 523 | placeChips(); |
| 524 | if (window.innerWidth <= 760) return; |
| 525 | closeDrawer(); |
| 526 | if (guest) teardown(); |
| 527 | if (window.DaimondPanels) DaimondPanels.reflow(); |
| 528 | } |
| 529 | |
| 530 | /// A drag that begins within 24px of the left edge opens the drawer. |
| 531 | function bindEdgeSwipe() { |
| 532 | var x0 = 0, y0 = 0, live = false; |
| 533 | document.addEventListener('touchstart', function (e) { |
| 534 | if (!isPhone() || document.body.classList.contains('drawer-open')) return; |
| 535 | var t = e.touches[0]; |
| 536 | if (t.clientX <= 24) { live = true; x0 = t.clientX; y0 = t.clientY; } |
| 537 | }, { passive: true }); |
| 538 | document.addEventListener('touchmove', function (e) { |
| 539 | if (!live) return; |
| 540 | var t = e.touches[0]; |
| 541 | if (t.clientX - x0 > 46 && Math.abs(t.clientY - y0) < 40) { openDrawer(); live = false; } |
| 542 | }, { passive: true }); |
| 543 | document.addEventListener('touchend', function () { live = false; }, { passive: true }); |
| 544 | } |
| 545 | |
| 546 | window.DaimondSheet = { |
| 547 | open: open, close: close, onEngineHide: onEngineHide, |
| 548 | isOpen: function () { return !!guest; }, |
| 549 | guest: function () { return guest; }, |
| 550 | }; |
| 551 | window.DaimondShell = { |
| 552 | openDrawer: openDrawer, closeDrawer: closeDrawer, toggleDrawer: toggleDrawer, |
| 553 | /// What a footer chip does, and where the chip row's renderer asks which |
| 554 | /// chip to fill in. See `goTo` and `here`. |
| 555 | goTo: goTo, here: here, markHere: markHere, |
| 556 | isPhone: isPhone, |
| 557 | }; |
| 558 | |
| 559 | if (document.readyState === 'loading') { |
| 560 | document.addEventListener('DOMContentLoaded', init); |
| 561 | } else { |
| 562 | init(); |
| 563 | } |
| 564 | })(); |