openvibers/openvibe.shared bottom.js

bottom.js

main 7.1 KB · 142 lines · javascript

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 'use strict';
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