openvibers/openvibe.shared bottom.js
bottom.js
| 1 | /** |
| 2 | * openvibe-shared/bottom.js — the bottom of the screen, measured once for everything docked there. |
| 3 | * |
| 4 | * The owner, 2026-11-10 (plan 279): "That fixed bottom position is kind of buggy … as you scroll up and down, |
| 5 | * so let's make that nice and smooth and perfect across all sites." A phone's visible viewport is not its layout |
| 6 | * viewport: the browser's toolbar slides away, a keyboard opens, an orientation turns. A bar placed at bottom: 0 |
| 7 | * of the layout viewport is then somewhere behind the thing the person is looking at. This file measures the one |
| 8 | * number every bottom-docked part needs and writes it where CSS can use it: |
| 9 | * |
| 10 | * --ovb-lift px between the bottom of the layout viewport and the bottom of the visible (visual) viewport; |
| 11 | * the bar, the small player and the composer move up by it with `translate` |
| 12 | * (a paint, never layout). |
| 13 | * --ovb-vh the visible viewport's height in px (for a surface that must fit it), measured, never 100dvh. |
| 14 | * --ovb-bar the app bar's height while it is on screen, measured from the bar itself (0 when it is away). |
| 15 | * --ovb-mini the small player's height plus its 8 px gap, while it is the full-width bar on a phone. |
| 16 | * --ovb-base max(--ovb-bar, safe-area-inset-bottom): where the first docked part starts. |
| 17 | * --ovb-dock --ovb-base + --ovb-mini: how far anything above all of them sits from the bottom edge. |
| 18 | * |
| 19 | * One stacking order, written here only (the bottom of the screen, low to high): |
| 20 | * 1. the app bar --ovb-z-bar 9990 |
| 21 | * 2. the small player --ovb-z-mini 9991 |
| 22 | * Above them belong sheets (10060), the notification panel (99999), toasts and notices (2147483000+). |
| 23 | * |
| 24 | * navbar.js loads this before the bar, the Dock and the player draw. It is safe without it: every rule that uses |
| 25 | * these variables has its old value as the fallback, so a page whose bottom.js did not arrive keeps the bar it had. |
| 26 | * |
| 27 | * OpenVibeBottom.measure() measure now (a page that changed its own bottom calls it) |
| 28 | * OpenVibeBottom.lift() the current lift in px |
| 29 | * OpenVibeBottom.height() the visible viewport's height in px |
| 30 | */ |
| 31 | (function (root) { |
| 32 | ; |
| 33 | if (typeof document === 'undefined' || !document.documentElement || root.OpenVibeBottom) return; |
| 34 | const html = document.documentElement; |
| 35 | let raf = 0; |
| 36 | let observer = null; |
| 37 | let resize = null; |
| 38 | let watched = []; |
| 39 | |
| 40 | function styles() { |
| 41 | if (document.getElementById('ovb-styles')) return; |
| 42 | const st = document.createElement('style'); |
| 43 | st.id = 'ovb-styles'; |
| 44 | st.textContent = `:root { |
| 45 | --ovb-lift: 0px; |
| 46 | --ovb-bar: 0px; |
| 47 | --ovb-mini: 0px; |
| 48 | --ovb-base: max(var(--ovb-bar, 0px), env(safe-area-inset-bottom, 0px)); |
| 49 | --ovb-dock: calc(var(--ovb-base) + var(--ovb-mini, 0px)); |
| 50 | --ovb-z-bar: 9990; |
| 51 | --ovb-z-mini: 9991; |
| 52 | } |
| 53 | .ovnav-appbar, .ovmini { will-change: translate; }`; |
| 54 | (document.head || html).appendChild(st); |
| 55 | } |
| 56 | |
| 57 | const px = (n) => `${Math.round(n * 100) / 100}px`; |
| 58 | // A page's own composer docked at the bottom moves up with the toolbar like the bar does. |
| 59 | const COMPOSERS = 'form[data-ovt-composer], [data-ov-bottom-dock]'; |
| 60 | |
| 61 | /** The height of an element that is actually on screen (a bar hidden under the page is not). */ |
| 62 | function onScreen(el) { |
| 63 | if (!el) return 0; |
| 64 | const cs = getComputedStyle(el); |
| 65 | if (cs.display === 'none' || cs.visibility === 'hidden') return 0; |
| 66 | const r = el.getBoundingClientRect(); |
| 67 | if (!r.height || r.bottom <= 0 || r.top >= root.innerHeight) return 0; |
| 68 | return r.height; |
| 69 | } |
| 70 | |
| 71 | function measure() { |
| 72 | raf = 0; |
| 73 | const vv = root.visualViewport; |
| 74 | const vh = vv ? vv.height : root.innerHeight; |
| 75 | const top = vv ? vv.offsetTop : 0; |
| 76 | const lift = Math.max(0, root.innerHeight - (top + vh)); |
| 77 | html.style.setProperty('--ovb-lift', px(lift)); |
| 78 | // A visible height of 0 happens between two documents; keep the last real one. |
| 79 | if (vh > 0) html.style.setProperty('--ovb-vh', px(vh)); |
| 80 | const bar = onScreen(document.querySelector('.ovnav-appbar')); |
| 81 | html.style.setProperty('--ovb-bar', px(bar)); |
| 82 | let mini = 0; |
| 83 | try { |
| 84 | if (root.matchMedia && root.matchMedia('(max-width: 640px)').matches) { |
| 85 | const m = onScreen(document.querySelector('.ovmini:not(.is-leaving)')); |
| 86 | if (m) mini = m + 8; // its 8 px above the bar |
| 87 | } |
| 88 | } catch { /* no matchMedia: no full-width small player to stack */ } |
| 89 | html.style.setProperty('--ovb-mini', px(mini)); |
| 90 | } |
| 91 | |
| 92 | const later = () => { if (!raf) raf = requestAnimationFrame(measure); }; |
| 93 | |
| 94 | /** Watch the parts that are added to the page after this file, and their size changes. */ |
| 95 | function watch() { |
| 96 | const parts = [...document.querySelectorAll(`.ovnav-appbar, .ovmini, ${COMPOSERS}`)]; |
| 97 | if (resize) { resize.disconnect(); watched = []; } |
| 98 | else if (typeof ResizeObserver === 'function') resize = new ResizeObserver(later); |
| 99 | if (!resize) return; |
| 100 | for (const el of parts) { try { resize.observe(el); watched.push(el); } catch { /* */ } } |
| 101 | measure(); |
| 102 | } |
| 103 | |
| 104 | function start() { |
| 105 | styles(); |
| 106 | const vv = root.visualViewport; |
| 107 | if (vv && vv.addEventListener) { vv.addEventListener('resize', later); vv.addEventListener('scroll', later); } |
| 108 | root.addEventListener('resize', later); |
| 109 | root.addEventListener('orientationchange', later); |
| 110 | root.addEventListener('load', later); |
| 111 | document.addEventListener('visibilitychange', later); |
| 112 | if (typeof MutationObserver === 'function' && document.body) { |
| 113 | observer = new MutationObserver((records) => { |
| 114 | let parts = false; |
| 115 | for (const r of records) { |
| 116 | const sel = `.ovnav-appbar, .ovmini, ${COMPOSERS}`; |
| 117 | if (r.target && r.target.closest && r.target.closest(sel)) { parts = true; break; } |
| 118 | for (const n of [...r.addedNodes, ...r.removedNodes]) { |
| 119 | if (n.nodeType === 1 && (n.matches(sel) || n.querySelector(sel))) { parts = true; break; } |
| 120 | } |
| 121 | if (parts) break; |
| 122 | } |
| 123 | if (parts) watch(); |
| 124 | }); |
| 125 | observer.observe(document.body, { childList: true, subtree: true }); |
| 126 | } |
| 127 | watch(); |
| 128 | setTimeout(measure, 300); // fonts, a late app bar, a small player opened by the page |
| 129 | } |
| 130 | |
| 131 | root.OpenVibeBottom = { |
| 132 | measure, |
| 133 | refresh: later, |
| 134 | lift: () => { const n = parseFloat(html.style.getPropertyValue('--ovb-lift')); return Number.isFinite(n) ? n : 0; }, |
| 135 | height: () => { const vv = root.visualViewport; return vv ? vv.height : root.innerHeight; }, |
| 136 | _stop: () => { if (observer) observer.disconnect(); if (resize) resize.disconnect(); }, |
| 137 | }; |
| 138 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true }); |
| 139 | else start(); |
| 140 | if (typeof module === 'object' && module.exports) module.exports = root.OpenVibeBottom; |
| 141 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 142 |