openvibers/openvibe.shared ov-float.js

ov-float.js

main 58.7 KB · 1093 lines · javascript

1/**
2 * ov-float.js (openvibe-shared 4.208.0; the handle, the size and the window 4.235.0): the move engine behind a picture-in-picture, a panel window or any box that
3 * floats over a page. It is Live's PiP move (public/js/stream-pip.js, 2026-11-11: "move it anywhere … mobile and
4 * desktop … more solid feeling and more responsive") lifted out whole, so every floating thing behaves the same:
5 *
6 * const f = OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange });
7 *
8 * - Docked in `frame` (an element) or floating over the page (position: fixed). It follows the finger or the mouse
9 * with a transform each animation frame and lands with a spring (FLIP); a flick carries on where it was thrown.
10 * - Dropped over its frame it docks; anywhere else it floats. Near an edge it snaps; thrown past a side it tucks into
11 * that side: it folds into a small handle at the edge (4.235.0; it used to leave a strip as tall as itself), the
12 * handle slides up and down that edge, a tap opens it where it was, and a pull brings it out under the finger.
13 * - Two fingers pinch it, the corner sizes it, a double tap toggles small and large, and with focus the arrows move it,
14 * + and − size it, T tucks, F pops it out of its frame (or back in) and Home puts it back where it started. It grows
15 * up to the whole width of the window: past what its frame holds it floats over the page and keeps growing.
16 * - Its place is kept as fractions of its frame: in this browser under `key`, and in the person's account when the
17 * page has GET/PATCH /api/me/layout (the `pip` map Live keeps). This browser's place wins; the account's fills a
18 * device that has none (a phone and a desktop each keep their own). `OVFloat.store` is the account plug:
19 * { get(key), put(key, value), load?() }; assign your own to keep it somewhere else.
20 * - `f.geom()`, `f.set(patch, { save })`, `f.tuck()`, `f.untuck()`, `f.toggleMode()`, `f.toggleSize()`, `f.reset()`,
21 * `f.paint()`, `f.settle()`, `f.save()`, `f.sizeTo(px)`, `f.relayout()`, `f.destroy()`. `OVFloat.layout()` lists every attached box,
22 * and each save dispatches `ov:float` ({ key, geom, el }).
23 *
24 * The CSS (injected once) is behaviour, not looks: positioning, the lift, the tuck's translate, the tab and the resize
25 * grip. A component adds its own colours, border, shadow and children. `aspect: null`, `size: false` and
26 * `resize: false` let a panel window keep its own height and width while it gets the move, the tuck and the place.
27 */
28(function (root) {
29 'use strict';
30 if (typeof document === 'undefined' || root.OVFloat) return;
31
32 const BASE = 16 / 9; // the shape `w` is measured for
33 const MIN_W = 0.08, MAX_W = 1; // up to its frame's whole width (4.235.0; was 0.60, a phone's PiP could not grow)
34 const MAX_H = 0.85; // docked: of the frame's height, whatever the shape
35 const FLOAT_MAX_H = 0.92; // floating: of the window's height
36 const MIN_PX = 150; // the smallest 16:9 width on a phone-sized frame
37 const SNAP = 0.04; // within this fraction of an edge, a drop snaps to it
38 const EDGE_PX = 12; // the margin it snaps to
39 const TAB_PX = 30; // the tucked handle's width (a finger's: 34), and what stays on screen in a drag
40 const TAB_H = 64; // the tucked handle's height (a finger's: 72)
41 const TUCK_MS = 340; // folding into the handle and opening out of it
42 const OPEN_CLIP = 'inset(-80px -80px -80px -80px round 0px)'; // nothing clipped, its shadow included
43 const DRAG_PX = 4; // a press that moves less is a tap
44 const BIG_W = 0.5; // a double tap's large size
45 const PROJECT_MS = 120; // how far a flick carries
46 const TAP_MS = 300;
47 const ACTIVE_MS = 3200;
48 const LAND_MS = 300;
49 const DEFAULT_IGNORE = 'a, button, input, textarea, select, [contenteditable], .ov-float-resize, [data-ov-float-resize], [data-ov-float-no-drag]';
50
51 const CSS = `:where(.ov-float) { position: absolute; box-sizing: border-box; touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent;
52 --ov-float-tab-w: ${TAB_PX}px; --ov-float-tab-h: ${TAB_H}px; }
53@media (pointer: coarse) { :where(.ov-float) { --ov-float-tab-w: 34px; --ov-float-tab-h: 72px; } }
54.ov-float.is-floating { position: fixed; z-index: 900; }
55.ov-float.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
56.ov-float.is-lifted, .ov-float.is-lifted * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
57.ov-float.is-dragging { cursor: grabbing; }
58.ov-float.is-tucked { cursor: pointer; }
59.ov-float.is-tucked-right { clip-path: inset(max(0px, calc(50% - var(--ov-float-tab-h) / 2)) 0px max(0px, calc(50% - var(--ov-float-tab-h) / 2)) calc(100% - var(--ov-float-tab-w)) round 14px 0px 0px 14px); }
60.ov-float.is-tucked-left { clip-path: inset(max(0px, calc(50% - var(--ov-float-tab-h) / 2)) calc(100% - var(--ov-float-tab-w)) max(0px, calc(50% - var(--ov-float-tab-h) / 2)) 0px round 0px 14px 14px 0px); }
61.ov-float.is-tucked .ov-float-tab { display: grid; animation: ovFloatTabIn ${TUCK_MS}ms ease-out; }
62@keyframes ovFloatTabIn { from { opacity: 0; } }
63.ov-float-tab { position: absolute; top: 50%; z-index: 4; width: var(--ov-float-tab-w); height: min(var(--ov-float-tab-h), 100%); translate: 0 -50%; display: none; place-items: center; padding: 0; border: 0; cursor: pointer; touch-action: none;
64 color: #fff; font: 600 18px/1 system-ui, sans-serif; background: rgba(10, 12, 18, .62); -webkit-backdrop-filter: blur(10px) saturate(1.2); backdrop-filter: blur(10px) saturate(1.2); }
65.ov-float.is-tucked-left .ov-float-tab { left: 0; border-radius: 0 14px 14px 0; box-shadow: inset -1px 0 0 rgba(255, 255, 255, .2); }
66.ov-float.is-tucked-right .ov-float-tab { right: 0; border-radius: 14px 0 0 14px; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .2); }
67.ov-float-tab-l, .ov-float-tab-r { display: none; }
68.ov-float.is-tucked-left .ov-float-tab-r, .ov-float.is-tucked-right .ov-float-tab-l { display: block; }
69.ov-float-resize { position: absolute; right: 0; bottom: 0; z-index: 3; width: 22px; height: 22px; cursor: nwse-resize; touch-action: none; opacity: 0;
70 background: linear-gradient(135deg, transparent 52%, rgba(255, 255, 255, .72) 52%, rgba(255, 255, 255, .72) 60%, transparent 60%, transparent 70%, rgba(255, 255, 255, .72) 70%, rgba(255, 255, 255, .72) 78%, transparent 78%); }
71.ov-float:hover .ov-float-resize, .ov-float:focus-within .ov-float-resize, .ov-float.is-resizing .ov-float-resize { opacity: 1; }
72@media (pointer: coarse) { .ov-float-resize { width: 30px; height: 30px; opacity: .85; } }
73html[data-ov-motion="off"] .ov-float { transition: none !important; }
74html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-resize { transition: none !important; animation: none !important; }
75@media (prefers-reduced-motion: reduce) {
76 html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
77}
78/* The floating action (4.239.0): its home holds the space; floating, it is fixed at the dock with a lift. */
79.ov-fab-home { position: relative; }
80.ov-fab-floating { position: fixed !important; z-index: 70; left: 0; right: 0; bottom: calc(var(--ov-fab-bottom, 12px) + env(safe-area-inset-bottom, 0px));
81 width: var(--ov-fab-w, auto); max-width: calc(100vw - 32px); margin: 0 auto !important; /* centred by margins: the glide owns transform */
82 filter: drop-shadow(0 14px 22px rgba(0, 0, 0, .45)) drop-shadow(0 2px 6px rgba(0, 0, 0, .3)); transition: bottom .25s cubic-bezier(.2, 1, .3, 1); }
83html[data-ov-motion=off] .ov-fab-floating { transition: none; }
84`;
85
86 const instances = new Set();
87 let uid = 0;
88
89 function clamp(v, lo, hi, dflt) {
90 const n = Number(v);
91 if (!Number.isFinite(n)) return dflt;
92 return Math.min(hi, Math.max(lo, n));
93 }
94
95 function viewport(inst) {
96 const de = document.documentElement;
97 // A floating element's percentage place is measured on the document's client box, not on innerWidth when a
98 // scrollbar takes 15 px of it. Live's PiP keeps the window size it always used; a free panel asks for `client`.
99 const client = inst && inst.options.viewport === 'client' && de;
100 const w = (client ? de.clientWidth : root.innerWidth) || 0;
101 const h = (client ? de.clientHeight : root.innerHeight) || 0;
102 return { left: 0, top: 0, width: w, height: h, right: w, bottom: h };
103 }
104
105 function rectOf(el) {
106 return el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : viewport();
107 }
108
109 /** The box an absolutely positioned child measures its percentages on: the frame's padding box, not its border. */
110 function contentRect(el) {
111 const r = rectOf(el);
112 if (!el || !el.clientWidth || !el.clientHeight) return r;
113 const left = r.left + (el.clientLeft || 0), top = r.top + (el.clientTop || 0);
114 return { left, top, width: el.clientWidth, height: el.clientHeight, right: left + el.clientWidth, bottom: top + el.clientHeight };
115 }
116
117 function reduced() {
118 const m = document.documentElement && document.documentElement.getAttribute('data-ov-motion');
119 if (m === 'off') return true;
120 try { return m !== 'on' && root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
121 }
122
123 function inFullscreen(inst) {
124 const fs = typeof document !== 'undefined' && document.fullscreenElement;
125 return !!(fs && inst && inst.frame && (fs === inst.frame || fs.contains(inst.frame)));
126 }
127
128 function frameRect(inst) {
129 if (inst.geom.mode === 'float' && !inFullscreen(inst)) return viewport(inst);
130 return inst.frame ? contentRect(inst.frame) : viewport(inst);
131 }
132
133 /* ── geometry (pure; the tests call these) ─────────────────────────────────────────────────────────────── */
134
135 /**
136 * The box as fractions of its frame: width, height and the place it may go, for the source's own shape.
137 * aspect: null keeps the element's own height (a panel window); `elHeight` is its measured height.
138 */
139 function boxOf(geom, rect, opts) {
140 const o = opts || {};
141 const a = o.aspect === null ? null : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE);
142 const minW = o.minW == null ? MIN_W : o.minW;
143 const maxW = o.maxW == null ? MAX_W : o.maxW;
144 const maxH = o.maxH == null ? MAX_H : o.maxH;
145 const rw = (rect && rect.width) || 0, rh = (rect && rect.height) || 0;
146 const rl = (rect && rect.left) || 0, rt = (rect && rect.top) || 0;
147 let w = clamp(geom && geom.w, minW, maxW, Math.min(0.25, maxW));
148 if (a == null) {
149 return { a: null, wf: w, hf: rh && o.elHeight ? o.elHeight / rh : 0, rw, rh, rl, rt };
150 }
151 // On a phone-sized frame a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
152 // (the saved size is unchanged, so a desktop shows it as saved).
153 const minPx = o.minPx == null ? MIN_PX : o.minPx;
154 if (rw && rw < (o.phoneFramePx == null ? 640 : o.phoneFramePx)) w = Math.max(w, Math.min(maxW, minPx / rw));
155 let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 box of width w
156 if (rw && rh && (wf * rw) / a > maxH * rh) wf = (maxH * rh * a) / rw;
157 wf = Math.min(wf, 1);
158 const hf = rw && rh ? (wf * rw) / a / rh : wf / a;
159 return { a, wf, hf, rw, rh, rl, rt };
160 }
161
162 /** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
163 function settleGeom(geom, box, opts) {
164 const o = opts || {};
165 const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
166 const snap = o.snap == null ? SNAP : o.snap;
167 const mx = box.rw ? edge / box.rw : 0, my = box.rh ? edge / box.rh : 0;
168 const maxX = Math.max(0, 1 - box.wf), maxY = Math.max(0, 1 - (box.hf || 0));
169 let x = clamp(geom.x, 0, maxX, 0), y = clamp(geom.y, 0, maxY, 0);
170 if (x < snap) x = Math.min(mx, maxX); else if (x > maxX - snap) x = Math.max(0, maxX - mx);
171 if (y < snap) y = Math.min(my, maxY); else if (y > maxY - snap) y = Math.max(0, maxY - my);
172 geom.x = x; geom.y = y;
173 return geom;
174 }
175
176 /** The last 90 ms of a drag: the speed it was let go at, and where a flick carries it. */
177 function flick(hist, from, opts) {
178 const o = opts || {};
179 const h = Array.isArray(hist) && hist.length ? hist : [{ x: 0, y: 0, t: 0 }];
180 const a = h[0], z = h[h.length - 1];
181 const dt = Math.max(16, (z.t || 0) - (a.t || 0));
182 const vx = ((z.x || 0) - (a.x || 0)) / dt, vy = ((z.y || 0) - (a.y || 0)) / dt;
183 const fast = Math.hypot(vx, vy) > (o.fastAt == null ? 0.6 : o.fastAt);
184 return { vx, vy, fast, left: from.left + (fast ? vx * PROJECT_MS : 0), top: from.top + (fast ? vy * PROJECT_MS : 0) };
185 }
186
187 /** Past a side of its frame by more than a third of itself (or thrown at it): tucked into that side. */
188 function tuckFor(point, from, frame, speed, opts) {
189 const o = opts || {};
190 const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
191 const out = from.width / 3;
192 if (point.left < frame.left - out || (speed.fast && speed.vx < -1.2 && point.left < frame.left + edge)) return 'left';
193 if (point.left + from.width > frame.right + out || (speed.fast && speed.vx > 1.2 && point.left + from.width > frame.right - edge)) return 'right';
194 return null;
195 }
196
197 function overFrame(cx, cy, frame) {
198 return !!frame && cx >= frame.left && cx <= frame.right && cy >= frame.top && cy <= frame.bottom;
199 }
200
201 /** Draw a geometry: the percentages, the shape, and the state classes the component styles. */
202 function paint(el, geom, box, opts) {
203 const o = opts || {};
204 const tuck = geom.tuck;
205 const floating = !!o.floating;
206 const classes = el.classList;
207 classes.toggle('is-floating', floating);
208 classes.toggle('is-tucked', !!tuck);
209 classes.toggle('is-tucked-left', tuck === 'left');
210 classes.toggle('is-tucked-right', tuck === 'right');
211 classes.toggle('is-portrait', !!box.a && box.a < 1);
212 // Shown inside its frame even when a turn of the phone or a smaller window would push it out; the saved place
213 // is not changed, so it comes back when there is room again. A tucked one sits at its side (CSS `translate`
214 // leaves only its tab on screen; `transform` stays free for the landing).
215 const left = tuck === 'left' ? 0 : tuck === 'right' ? Math.max(0, 1 - box.wf) : Math.min(Math.max(0, geom.x), Math.max(0, 1 - box.wf));
216 el.style.left = `${left * 100}%`;
217 el.style.top = `${Math.min(Math.max(0, geom.y), Math.max(0, 1 - (box.hf || 0))) * 100}%`;
218 if (o.size !== false) {
219 el.style.width = `${box.wf * 100}%`;
220 if (box.a) el.style.aspectRatio = String(box.a);
221 }
222 }
223
224 /* ── the account plug (GET/PATCH /api/me/layout, the shape Live's stream-pip.js keeps) ───────────────── */
225
226 function makeDefaultStore() {
227 const state = { data: null, loading: null, pending: {}, timer: 0, off: false };
228 function load() {
229 if (state.off || typeof fetch !== 'function') return Promise.resolve(null);
230 if (!state.loading) {
231 state.loading = fetch('/api/me/layout', { credentials: 'same-origin', headers: { accept: 'application/json' } })
232 .then((r) => {
233 if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) { state.off = true; return null; }
234 return r.ok ? r.json() : null;
235 })
236 .then((d) => { state.data = d && typeof d === 'object' ? d : {}; return state.data; })
237 .catch(() => null);
238 }
239 return state.loading;
240 }
241 function get(key) {
242 const pip = state.data && state.data.pip;
243 return pip && pip[key] ? pip[key] : null;
244 }
245 function put(key, value) {
246 if (state.off || typeof fetch !== 'function') return;
247 state.pending[key] = value;
248 if (state.data) { state.data.pip = state.data.pip || {}; state.data.pip[key] = value; }
249 clearTimeout(state.timer);
250 state.timer = setTimeout(() => {
251 const pip = state.pending; state.pending = {};
252 fetch('/api/me/layout', {
253 method: 'PATCH', credentials: 'same-origin',
254 headers: { 'content-type': 'application/json', accept: 'application/json' },
255 body: JSON.stringify({ pip }),
256 }).then((r) => { if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) state.off = true; })
257 .catch(() => { /* the next move tries again */ });
258 }, 1500);
259 }
260 return { load, get, put, _state: state };
261 }
262
263 function normalizeDefaults(raw, hasFrame) {
264 const d = raw && typeof raw === 'object' ? raw : {};
265 return {
266 x: clamp(d.x, 0, 1, 0.72), y: clamp(d.y, 0, 1, 0.70), w: clamp(d.w, MIN_W, MAX_W, 0.25),
267 mode: hasFrame ? (d.mode === 'float' ? 'float' : 'dock') : 'float',
268 tuck: d.tuck === 'left' || d.tuck === 'right' ? d.tuck : null,
269 muted: typeof d.muted === 'boolean' ? d.muted : undefined,
270 at: 0,
271 };
272 }
273
274 function normalize(raw, fallback, opts) {
275 const o = opts || {};
276 if (!raw || typeof raw !== 'object') return Object.assign({}, fallback);
277 const g = Object.assign({}, fallback);
278 g.x = clamp(raw.x, 0, 1, fallback.x);
279 g.y = clamp(raw.y, 0, 1, fallback.y);
280 g.w = clamp(raw.w, o.minW == null ? MIN_W : o.minW, o.maxW == null ? MAX_W : o.maxW, fallback.w);
281 g.mode = o.hasFrame === false ? 'float' : (raw.mode === 'float' ? 'float' : raw.mode === 'dock' ? 'dock' : fallback.mode);
282 g.tuck = raw.tuck === 'left' || raw.tuck === 'right' ? raw.tuck : null;
283 if (typeof raw.muted === 'boolean') g.muted = raw.muted;
284 g.at = Number(raw.at) || 0;
285 // Where a box tucked by hand opens again (this browser only).
286 if (g.tuck && raw.ux != null && Number.isFinite(Number(raw.ux))) g.ux = clamp(raw.ux, 0, 1, 0);
287 return g;
288 }
289
290 /** The place as an account keeps it (live.layout's fields): no browser-only extras. */
291 function placeOf(g) {
292 const out = { x: g.x, y: g.y, w: g.w, mode: g.mode, tuck: g.tuck || null, at: g.at || 0 };
293 if (typeof g.muted === 'boolean') out.muted = g.muted;
294 return out;
295 }
296
297 function readSaved(key, legacyKeys) {
298 const keys = [key].concat(Array.isArray(legacyKeys) ? legacyKeys : []);
299 for (const k of keys) {
300 try {
301 const raw = root.localStorage && root.localStorage.getItem(k);
302 if (!raw) continue;
303 const v = JSON.parse(raw);
304 if (v && typeof v === 'object') return v;
305 } catch { /* private mode, or a key from another shape */ }
306 }
307 return null;
308 }
309
310 /* ── the instance ──────────────────────────────────────────────────────────────────────────────────────── */
311
312 function box(inst) {
313 const floating = inst.geom.mode === 'float' && !inFullscreen(inst);
314 return boxOf(inst.geom, frameRect(inst), {
315 aspect: inst.aspect, minPx: inst.minPx, minW: inst.minW, maxW: inst.maxW,
316 maxH: floating ? FLOAT_MAX_H : MAX_H,
317 elHeight: inst.el.offsetHeight || 0,
318 });
319 }
320
321 /** The handle a tucked box folds into, in px (the component may set its own with --ov-float-tab-w/-h). */
322 function tabSize(inst) {
323 let w = 0, h = 0;
324 try {
325 const cs = root.getComputedStyle(inst.el);
326 w = parseFloat(cs.getPropertyValue('--ov-float-tab-w'));
327 h = parseFloat(cs.getPropertyValue('--ov-float-tab-h'));
328 } catch { /* no computed style here */ }
329 return { w: w > 0 ? w : TAB_PX, h: h > 0 ? h : TAB_H };
330 }
331
332 /**
333 * The tuck's own motion: the box folds into its handle at the side, or opens out of it, as a clip, so nothing inside
334 * is squeezed (the move to the side is the landing's spring, at the same time). The CSS class holds the folded shape.
335 */
336 function animateClip(inst, opening, side) {
337 const el = inst.el;
338 if (!side || reduced() || typeof el.animate !== 'function') return;
339 const w = el.offsetWidth, h = el.offsetHeight;
340 if (!w || !h) return;
341 const t = tabSize(inst);
342 const v = Math.max(0, (h - t.h) / 2), across = Math.max(0, w - t.w);
343 const folded = side === 'right'
344 ? `inset(${v}px 0px ${v}px ${across}px round 14px 0px 0px 14px)`
345 : `inset(${v}px ${across}px ${v}px 0px round 0px 14px 14px 0px)`;
346 try {
347 if (inst.clipAnim) inst.clipAnim.cancel();
348 inst.clipAnim = el.animate([{ clipPath: opening ? folded : OPEN_CLIP }, { clipPath: opening ? OPEN_CLIP : folded }],
349 { duration: TUCK_MS, easing: 'cubic-bezier(.2, .9, .25, 1)' });
350 } catch { /* a browser that cannot animate a clip simply shows the end */ }
351 }
352
353 /**
354 * Size it so the box is `px` wide on screen (for another shape, the width it has at that 16:9 area): inside a docked
355 * frame while it fits; past what the frame holds it floats over the page, where it is, and keeps growing up to the
356 * window's width. Answers true when it changed frames (the caller takes its pointers back).
357 */
358 function sizeTo(inst, px) {
359 const a = inst.aspect;
360 const k = a ? Math.sqrt(a / BASE) || 1 : 1;
361 let b = box(inst);
362 if (inst.geom.mode === 'dock' && inst.frame && !inFullscreen(inst) && b.rw && b.rh) {
363 const hpx = a ? px / a : (inst.el.offsetHeight || 0);
364 if (px > b.rw + 0.5 || (a && hpx > MAX_H * b.rh + 0.5)) {
365 const r = inst.el.getBoundingClientRect();
366 toMode(inst, 'float', { left: r.left, top: r.top, width: px, height: hpx });
367 placeIn(inst);
368 paintNow(inst);
369 return true;
370 }
371 }
372 b = box(inst);
373 if (!b.rw) return false;
374 inst.geom.w = clamp(px / b.rw / k, inst.minW, inst.maxW, inst.geom.w);
375 b = box(inst);
376 inst.geom.x = Math.min(inst.geom.x, Math.max(0, 1 - b.wf));
377 inst.geom.y = Math.min(inst.geom.y, Math.max(0, 1 - b.hf));
378 paintNow(inst);
379 return false;
380 }
381
382 function paintNow(inst) {
383 const b = box(inst);
384 paint(inst.el, inst.geom, b, {
385 floating: inst.geom.mode === 'float' && !inFullscreen(inst),
386 size: inst.options.size !== false,
387 });
388 return b;
389 }
390
391 function settleInst(inst) {
392 return settleGeom(inst.geom, box(inst));
393 }
394
395 function placeIn(inst) {
396 const fs = document.fullscreenElement;
397 let want;
398 if (inst.geom.mode === 'float' && !inFullscreen(inst)) want = document.body;
399 else if (inst.frame) want = fs && !fs.contains(inst.frame) ? fs : inst.frame;
400 else want = document.body;
401 if (want && inst.el.parentNode !== want) {
402 const v = inst.el.querySelector('video');
403 want.appendChild(inst.el);
404 // A media element moved in one task keeps playing; play() covers a browser that paused it.
405 if (v && v.paused) v.play().catch(() => {});
406 }
407 }
408
409 function save(inst) {
410 if (inst.destroyed) return;
411 // A drop clears the lifted inline left/top before it saves; draw the final place first so an onChange that
412 // reads the element's rect (a window's absolute place, for one) sees where it landed, not its static position.
413 if (!inst.el.classList.contains('is-lifted')) paintNow(inst);
414 inst.geom.at = Date.now();
415 // This browser keeps everything (where a tucked box opens again too); the account and listeners get the place.
416 try { if (root.localStorage) root.localStorage.setItem(inst.key, JSON.stringify(inst.geom)); } catch { /* private mode */ }
417 if (inst.store && typeof inst.store.put === 'function') {
418 try { inst.store.put(inst.accountKey, placeOf(inst.geom)); } catch { /* the page's own store */ }
419 }
420 if (typeof inst.options.onChange === 'function') {
421 try { inst.options.onChange(placeOf(inst.geom), inst.api); } catch (err) { console.error(err); }
422 }
423 try {
424 root.dispatchEvent(new root.CustomEvent('ov:float', { detail: { key: inst.key, geom: placeOf(inst.geom), el: inst.el } }));
425 } catch { /* an environment without CustomEvent */ }
426 }
427
428 /** Move it to its place with a spring from where it was (`from`: its rect on screen before). */
429 function land(inst, from) {
430 placeIn(inst);
431 const el = inst.el;
432 el.style.transition = 'none';
433 el.style.transform = '';
434 paintNow(inst);
435 if (!from || reduced()) { el.style.transition = ''; return; }
436 const to = el.getBoundingClientRect();
437 if (!to.width) { el.style.transition = ''; return; }
438 const dx = from.left - to.left, dy = from.top - to.top, s = from.width / to.width;
439 if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(s - 1) < 0.01) { el.style.transition = ''; return; }
440 el.style.transformOrigin = '0 0';
441 el.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${s})`;
442 void el.offsetWidth;
443 el.style.transition = `transform ${LAND_MS}ms var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1))`;
444 el.style.transform = '';
445 clearTimeout(inst.landTimer);
446 inst.landTimer = setTimeout(() => {
447 if (!inst.destroyed) { el.style.transition = ''; el.style.transformOrigin = ''; }
448 }, LAND_MS + 40);
449 }
450
451 /** Switch frames keeping its size and place on screen (`rect`: where it is now). */
452 function toMode(inst, mode, rect) {
453 if (!inst.frame) mode = 'float';
454 inst.geom.mode = mode;
455 const b0 = box(inst);
456 if (!b0.rw || !b0.rh) return;
457 const a = inst.aspect || BASE;
458 const k = Math.sqrt(a / BASE) || 1;
459 if (inst.options.size !== false) inst.geom.w = clamp(rect.width / b0.rw / k, inst.minW, inst.maxW, inst.geom.w);
460 const b = box(inst);
461 inst.geom.x = (rect.left - b.rl) / b.rw;
462 inst.geom.y = (rect.top - b.rt) / b.rh;
463 settleInst(inst);
464 }
465
466 function tuck(inst, side) {
467 if (side === null) { untuck(inst); return; }
468 const from = inst.el.getBoundingClientRect();
469 const b = box(inst);
470 // Tucked by hand (the button, T): it opens again where it was. Thrown at a side, it opens at that side.
471 if (!inst.geom.tuck) inst.geom.ux = inst.geom.x;
472 inst.geom.tuck = side || (inst.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right');
473 save(inst);
474 land(inst, from);
475 animateClip(inst, false, inst.geom.tuck);
476 }
477
478 function untuck(inst) {
479 const side = inst.geom.tuck;
480 if (!side) return;
481 const from = inst.el.getBoundingClientRect();
482 const b = box(inst);
483 const margin = b.rw ? EDGE_PX / b.rw : 0;
484 inst.geom.tuck = null;
485 inst.geom.x = inst.geom.ux != null ? inst.geom.ux : side === 'left' ? margin : Math.max(0, 1 - b.wf - margin);
486 delete inst.geom.ux;
487 inst.untuckedAt = performance.now();
488 settleInst(inst);
489 save(inst);
490 land(inst, from);
491 animateClip(inst, true, side);
492 }
493
494 function toggleMode(inst) {
495 if (!inst.frame) return false;
496 const from = inst.el.getBoundingClientRect();
497 toMode(inst, inst.geom.mode === 'float' ? 'dock' : 'float', from);
498 save(inst);
499 land(inst, from);
500 return true;
501 }
502
503 /** Small and large: large is half its frame, and on a phone the whole width of the screen (it floats to get it). */
504 function toggleSize(inst) {
505 const from = inst.el.getBoundingClientRect();
506 const vp = viewport(inst);
507 const b = box(inst);
508 const k = inst.aspect ? Math.sqrt(inst.aspect / BASE) || 1 : 1;
509 const bigW = inst.options.bigW == null ? BIG_W : inst.options.bigW;
510 const bigPx = vp.width && vp.width < 640 ? vp.width : bigW * k * b.rw;
511 delete inst.geom.ux;
512 inst.geom.tuck = null;
513 if (from.width < bigPx * 0.95) sizeTo(inst, bigPx);
514 else inst.geom.w = clamp(inst.defaults.w, inst.minW, inst.maxW, inst.defaults.w);
515 settleInst(inst);
516 save(inst);
517 land(inst, from);
518 }
519
520 function reset(inst) {
521 const from = inst.el.getBoundingClientRect();
522 const d = inst.defaults;
523 Object.assign(inst.geom, { x: d.x, y: d.y, w: d.w, mode: inst.frame ? d.mode : 'float', tuck: null });
524 delete inst.geom.ux;
525 settleInst(inst);
526 save(inst);
527 land(inst, from);
528 }
529
530 function relayout(inst) {
531 if (inst.destroyed || inst.el.classList.contains('is-lifted')) return;
532 placeIn(inst);
533 paintNow(inst);
534 }
535
536 function syncAccount(inst) {
537 const store = inst.store;
538 if (!store || typeof store.get !== 'function') return;
539 const apply = () => {
540 // This device's own place wins; the account's opens a device that has none where the person left it.
541 if (inst.destroyed || inst.touched || inst.hadLocal) return;
542 const remote = normalize(store.get(inst.accountKey), inst.defaults, { hasFrame: !!inst.frame, minW: inst.minW, maxW: inst.maxW });
543 if ((remote.at || 0) > (inst.geom.at || 0)) {
544 inst.geom = remote;
545 placeIn(inst);
546 paintNow(inst);
547 }
548 };
549 if (typeof store.load === 'function') {
550 let p;
551 try { p = store.load(); } catch { p = null; }
552 if (p && typeof p.then === 'function') p.then(apply).catch(() => { /* no account on this page */ });
553 else apply();
554 } else apply();
555 }
556
557 /* ── gestures ──────────────────────────────────────────────────────────────────────────────────────────── */
558
559 function wireMove(inst) {
560 const el = inst.el;
561 const handle = inst.options.handle && inst.options.handle.addEventListener ? inst.options.handle : el;
562 const ignore = inst.options.ignore || DEFAULT_IGNORE;
563 const hintClass = inst.options.hintClass || 'ov-float-dock-hint';
564 const pts = new Map(); // pointerId → { x, y }
565 let drag = null, pinch = null, raf = 0, lastTap = 0, activeTimer = 0;
566 const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
567 const hint = (on) => { if (inst.frame && inst.frame.classList) inst.frame.classList.toggle(hintClass, !!on); };
568 const paint = () => {
569 raf = 0;
570 if (!drag || !drag.lifted) return;
571 el.style.transform = `translate3d(${drag.dx}px, ${drag.dy}px, 0)`;
572 const c = drag.r;
573 const p = inst.frame ? contentRect(inst.frame) : null;
574 if (p) hint(overFrame(c.left + drag.dx + c.width / 2, c.top + drag.dy + c.height / 2, p));
575 };
576 const lift = () => {
577 drag.lifted = true;
578 // Over the page from the first move, at the same place on screen, so it can go anywhere.
579 const r = drag.r;
580 const layer = document.fullscreenElement || document.body;
581 if (el.parentNode !== layer) layer.appendChild(el);
582 // Moving the element in the DOM releases any pointer capture it had; take the pointers it is following back.
583 for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } }
584 el.classList.add('is-dragging', 'is-lifted');
585 el.style.transition = 'none';
586 el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.width}px`;
587 const v = el.querySelector('video');
588 if (v && v.paused) v.play().catch(() => {});
589 if (typeof inst.options.onLift === 'function') { try { inst.options.onLift(inst.api); } catch { /* */ } }
590 };
591 const showActive = () => {
592 el.classList.add('is-active');
593 clearTimeout(activeTimer);
594 activeTimer = setTimeout(() => el.classList.remove('is-active'), ACTIVE_MS);
595 };
596 const recapture = () => { for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* */ } } };
597 const onDown = (e) => {
598 if (e.button > 0) return;
599 // The tucked handle is a button that moves: a tap opens it, a drag slides it along its side or pulls it out.
600 if (e.target.closest && e.target.closest(ignore) && !e.target.closest('[data-ov-float-tab]')) return;
601 if (typeof inst.options.canDrag === 'function' && !inst.options.canDrag(e)) return;
602 inst.touched = true;
603 pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
604 try { el.setPointerCapture(e.pointerId); } catch { /* a pointer the browser no longer tracks */ }
605 e.preventDefault();
606 if (pts.size === 2) {
607 if (drag && drag.lifted) el.style.transform = '';
608 drag = null;
609 pinch = { d: gap(), px: el.getBoundingClientRect().width };
610 el.classList.add('is-resizing');
611 return;
612 }
613 if (pts.size > 2) return;
614 drag = {
615 sx: e.clientX, sy: e.clientY, dx: 0, dy: 0, r: el.getBoundingClientRect(), lifted: false,
616 tucked: inst.geom.tuck || null, slide: false,
617 hist: [{ x: e.clientX, y: e.clientY, t: performance.now() }],
618 };
619 };
620 const onMove = (e) => {
621 if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
622 if (pinch && pts.size === 2) {
623 // Up to the whole width of the window: past its frame it floats over the page and keeps growing.
624 if (sizeTo(inst, pinch.px * (gap() / pinch.d))) recapture();
625 return;
626 }
627 if (!drag) return;
628 const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
629 if (!drag.lifted && !drag.slide && Math.hypot(dx, dy) < DRAG_PX) return;
630 if (drag.tucked && !drag.lifted && (drag.slide || Math.abs(dy) > Math.abs(dx))) {
631 // A tucked handle dragged up or down slides along its side, the whole box inside its frame.
632 if (!drag.slide) { drag.slide = true; el.classList.add('is-dragging'); el.style.transition = 'none'; }
633 const f = frameRect(inst), r = drag.r;
634 drag.dy = Math.min(Math.max(dy, f.top - r.top), f.top + f.height - r.bottom);
635 if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (drag && drag.slide) el.style.transform = `translate3d(0, ${drag.dy}px, 0)`; });
636 return;
637 }
638 if (!drag.lifted) {
639 if (drag.tucked) {
640 // Pulled out of its side: it opens under the finger and follows it.
641 const side = inst.geom.tuck;
642 inst.geom.tuck = null;
643 delete inst.geom.ux;
644 el.classList.remove('is-tucked', 'is-tucked-left', 'is-tucked-right');
645 animateClip(inst, true, side);
646 }
647 lift();
648 }
649 // Anywhere on screen; most of it may go past a side (that is how it tucks), never past the top or bottom.
650 const vp = viewport(inst);
651 const r = drag.r, vw = vp.width, vh = vp.height, tab = inst.options.tabPx == null ? TAB_PX : inst.options.tabPx;
652 drag.dx = Math.min(Math.max(dx, -r.left - r.width + tab), vw - r.left - tab);
653 drag.dy = Math.min(Math.max(dy, -r.top), vh - r.top - r.height);
654 const now = performance.now();
655 drag.hist.push({ x: e.clientX, y: e.clientY, t: now });
656 while (drag.hist.length > 2 && now - drag.hist[0].t > 90) drag.hist.shift();
657 if (!raf) raf = requestAnimationFrame(paint);
658 };
659 const onUp = (e) => {
660 pts.delete(e.pointerId);
661 try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
662 if (pinch) {
663 if (pts.size < 2) {
664 pinch = null;
665 el.classList.remove('is-resizing');
666 // A pinch that began after the lift put the box over the page; put it back in its frame for its mode.
667 if (el.classList.contains('is-lifted')) {
668 el.classList.remove('is-dragging', 'is-lifted');
669 el.style.left = ''; el.style.top = ''; el.style.width = ''; el.style.transform = '';
670 placeIn(inst);
671 }
672 settleInst(inst);
673 paintNow(inst);
674 save(inst);
675 }
676 return;
677 }
678 if (!drag) return;
679 const d = drag; drag = null;
680 if (raf) { cancelAnimationFrame(raf); raf = 0; }
681 hint(false);
682 if (d.slide) {
683 // Slid along its side: it stays tucked there, at that height.
684 el.classList.remove('is-dragging');
685 el.style.transition = 'none';
686 el.style.transform = '';
687 const b = box(inst);
688 if (b.rh) inst.geom.y = clamp((d.r.top + d.dy - b.rt) / b.rh, 0, Math.max(0, 1 - b.hf), inst.geom.y);
689 paintNow(inst);
690 el.style.transition = '';
691 save(inst);
692 return;
693 }
694 if (!d.lifted) {
695 // A tap on a tucked box opens it; on an open one, the bar for a moment (a touch screen has no hover), and a
696 // second within 300 ms toggles the size.
697 if (inst.geom.tuck) { if (e.type === 'pointerup') untuck(inst); return; }
698 const now = performance.now();
699 if (inst.options.doubleTap !== false && now - lastTap < TAP_MS) { lastTap = 0; toggleSize(inst); }
700 else {
701 lastTap = now;
702 if (e.pointerType !== 'mouse' && inst.options.activeBar !== false) showActive();
703 }
704 return;
705 }
706 el.classList.remove('is-dragging', 'is-lifted');
707 // Where it was let go, and where a flick carries it (the last 90 ms, 120 ms on).
708 const fl = flick(d.hist, { left: d.r.left + d.dx, top: d.r.top + d.dy });
709 const from = { left: d.r.left + d.dx, top: d.r.top + d.dy, width: d.r.width, height: d.r.height };
710 const vp = viewport(inst);
711 const vhei = vp.height;
712 const top = Math.min(Math.max(0, fl.top), vhei - from.height);
713 const p = inst.frame ? contentRect(inst.frame) : null;
714 const fs = inFullscreen(inst);
715 const overPlayer = !!p && (fs || overFrame(fl.left + from.width / 2, top + from.height / 2, p));
716 const frame = overPlayer ? p : vp;
717 const tuckSide = tuckFor({ left: fl.left }, from, frame, fl);
718 el.style.transform = '';
719 el.style.left = ''; el.style.top = ''; el.style.width = '';
720 toMode(inst, overPlayer ? 'dock' : 'float', {
721 left: Math.min(Math.max(frame.left, fl.left), frame.right - from.width),
722 top,
723 width: from.width,
724 height: from.height,
725 });
726 inst.geom.tuck = tuckSide;
727 save(inst);
728 land(inst, from);
729 };
730 handle.addEventListener('pointerdown', onDown);
731 el.addEventListener('pointermove', onMove);
732 el.addEventListener('pointerup', onUp);
733 el.addEventListener('pointercancel', onUp);
734 inst.cleanups.push(() => {
735 handle.removeEventListener('pointerdown', onDown);
736 el.removeEventListener('pointermove', onMove);
737 el.removeEventListener('pointerup', onUp);
738 el.removeEventListener('pointercancel', onUp);
739 clearTimeout(activeTimer);
740 hint(false);
741 });
742 }
743
744 function wireKeys(inst) {
745 const el = inst.el;
746 const onKey = (e) => {
747 if (e.target !== el || e.altKey || e.ctrlKey || e.metaKey) return;
748 const step = e.shiftKey ? 0.1 : 0.02;
749 const k = e.key;
750 let moved = true;
751 if (k === 'ArrowLeft') inst.geom.x -= step;
752 else if (k === 'ArrowRight') inst.geom.x += step;
753 else if (k === 'ArrowUp') inst.geom.y -= step;
754 else if (k === 'ArrowDown') inst.geom.y += step;
755 else if (k === '+' || k === '=' || k === '-' || k === '_') {
756 const r = el.getBoundingClientRect();
757 sizeTo(inst, r.width * (k === '+' || k === '=' ? 1.12 : 1 / 1.12));
758 }
759 else if (k === 'Home') { e.preventDefault(); reset(inst); return; }
760 else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { e.preventDefault(); inst.geom.tuck ? untuck(inst) : tuck(inst); return; }
761 else if (k === 'f' || k === 'F') { e.preventDefault(); toggleMode(inst); return; }
762 else return;
763 e.preventDefault();
764 if (!moved) return;
765 const b = box(inst);
766 inst.geom.tuck = null;
767 delete inst.geom.ux;
768 inst.geom.x = Math.min(Math.max(0, inst.geom.x), Math.max(0, 1 - b.wf));
769 inst.geom.y = Math.min(Math.max(0, inst.geom.y), Math.max(0, 1 - b.hf));
770 paintNow(inst);
771 save(inst);
772 };
773 el.addEventListener('keydown', onKey);
774 inst.cleanups.push(() => el.removeEventListener('keydown', onKey));
775 }
776
777 function ensureTab(inst) {
778 let tab = inst.el.querySelector('[data-ov-float-tab]');
779 if (!tab) {
780 tab = document.createElement('button');
781 tab.type = 'button';
782 tab.className = 'ov-float-tab';
783 tab.dataset.ovFloatTab = '';
784 tab.setAttribute('aria-label', 'Bring it back');
785 tab.innerHTML = '<span class="ov-float-tab-l" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16"><path d="M16.1 3.6l-8.4 8.4l8.4 8.4"/></svg></span><span class="ov-float-tab-r" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16"><path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/></svg></span>';
786 inst.el.append(tab);
787 inst.ownedTab = tab;
788 }
789 // A pointer tap already opened it (the gesture's pointerup); the click is a key's, or a tap the gesture missed.
790 const onClick = (e) => {
791 e.preventDefault(); e.stopPropagation();
792 if (performance.now() - (inst.untuckedAt || 0) < 700) return;
793 untuck(inst);
794 };
795 tab.addEventListener('click', onClick);
796 inst.cleanups.push(() => tab.removeEventListener('click', onClick));
797 }
798
799 function ensureResize(inst) {
800 let handle = inst.el.querySelector('[data-ov-float-resize]');
801 if (!handle) {
802 handle = document.createElement('span');
803 handle.className = 'ov-float-resize';
804 handle.dataset.ovFloatResize = '';
805 handle.setAttribute('aria-hidden', 'true');
806 inst.el.append(handle);
807 inst.ownedResize = handle;
808 }
809 let start = null;
810 const onDown = (e) => {
811 if (e.button > 0) return;
812 start = { px: e.clientX, py: e.clientY, width: inst.el.getBoundingClientRect().width };
813 inst.touched = true;
814 inst.el.classList.add('is-resizing');
815 try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
816 e.preventDefault();
817 e.stopPropagation();
818 };
819 const onMove = (e) => {
820 if (!start || !start.width) return;
821 // The handle's diagonal pull is the box's own width; past its frame it floats and keeps growing.
822 const pull = inst.aspect ? ((e.clientX - start.px) + (e.clientY - start.py) * inst.aspect) / 2 : (e.clientX - start.px);
823 if (sizeTo(inst, Math.max(1, start.width + pull))) { try { handle.setPointerCapture(e.pointerId); } catch { /* */ } }
824 };
825 const end = (e) => {
826 if (!start) return;
827 start = null;
828 inst.el.classList.remove('is-resizing');
829 try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
830 settleInst(inst);
831 paintNow(inst);
832 save(inst);
833 };
834 handle.addEventListener('pointerdown', onDown);
835 handle.addEventListener('pointermove', onMove);
836 handle.addEventListener('pointerup', end);
837 handle.addEventListener('pointercancel', end);
838 inst.cleanups.push(() => {
839 handle.removeEventListener('pointerdown', onDown);
840 handle.removeEventListener('pointermove', onMove);
841 handle.removeEventListener('pointerup', end);
842 handle.removeEventListener('pointercancel', end);
843 });
844 }
845
846 function wireWindow(inst) {
847 // A turn of the phone, a keyboard, the address bar or a new window size: back inside, once a frame.
848 let queued = 0;
849 const onResize = () => {
850 if (queued) return;
851 queued = requestAnimationFrame(() => { queued = 0; relayout(inst); });
852 };
853 const vv = root.visualViewport;
854 root.addEventListener('resize', onResize);
855 root.addEventListener('orientationchange', onResize);
856 if (vv && vv.addEventListener) vv.addEventListener('resize', onResize);
857 document.addEventListener('fullscreenchange', onResize);
858 let ro = null;
859 if (typeof ResizeObserver === 'function') {
860 ro = new ResizeObserver(onResize);
861 if (inst.frame) ro.observe(inst.frame);
862 ro.observe(inst.el);
863 }
864 inst.cleanups.push(() => {
865 if (queued) cancelAnimationFrame(queued);
866 root.removeEventListener('resize', onResize);
867 root.removeEventListener('orientationchange', onResize);
868 if (vv && vv.removeEventListener) vv.removeEventListener('resize', onResize);
869 document.removeEventListener('fullscreenchange', onResize);
870 try { ro && ro.disconnect(); } catch { /* */ }
871 });
872 }
873
874 /* ── public API ───────────────────────────────────────────────────────────────────────────────────────── */
875
876 function attach(el, options) {
877 if (!el || typeof el !== 'object' || !el.classList) throw new TypeError('OVFloat.attach(el, options): el is required');
878 const o = options || {};
879 const frame = o.frame || null;
880 const key = String(o.key || `ov-float-${++uid}`);
881 const minW = clamp(o.minW == null ? MIN_W : o.minW, 0, 1, MIN_W);
882 const maxW = clamp(o.maxW == null ? MAX_W : o.maxW, minW, 1, MAX_W);
883 const defaults = normalizeDefaults(o.defaults, !!frame);
884 const inst = {
885 el, frame, key,
886 accountKey: o.accountKey == null ? key : String(o.accountKey),
887 options: o, defaults, minW, maxW,
888 aspect: o.aspect === null ? null : (typeof o.aspect === 'function' ? Number(o.aspect()) || BASE : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE)),
889 minPx: Number(o.minPx) > 0 ? Number(o.minPx) : MIN_PX,
890 store: o.store === null ? null : (o.store || root.OVFloat.store || null),
891 geom: null, touched: false, destroyed: false, cleanups: [], landTimer: 0, ownedTab: null, ownedResize: null, api: null,
892 };
893 const local = readSaved(key, o.legacyKeys);
894 inst.hadLocal = !!local;
895 inst.geom = normalize(local, defaults, { hasFrame: !!frame, minW, maxW });
896 injectStyles();
897 el.classList.add('ov-float');
898 if (o.tab !== false) ensureTab(inst);
899 if (o.resize !== false) ensureResize(inst);
900 wireMove(inst);
901 wireKeys(inst);
902 wireWindow(inst);
903 placeIn(inst);
904 paintNow(inst);
905
906 inst.api = {
907 el, key,
908 geom: () => Object.assign({}, inst.geom),
909 set(patch, opts) {
910 Object.assign(inst.geom, patch || {});
911 if (opts && opts.settle) settleInst(inst);
912 placeIn(inst);
913 paintNow(inst);
914 if (opts && opts.save) save(inst);
915 return Object.assign({}, inst.geom);
916 },
917 setAspect(a) {
918 inst.aspect = a === null ? null : (Number(a) > 0 ? Number(a) : BASE);
919 paintNow(inst);
920 },
921 metrics: () => box(inst),
922 sizeTo: (px) => sizeTo(inst, px),
923 paint: () => paintNow(inst),
924 settle: () => { settleInst(inst); paintNow(inst); return Object.assign({}, inst.geom); },
925 save: () => save(inst),
926 tuck: (side) => tuck(inst, side),
927 untuck: () => untuck(inst),
928 toggleTuck: () => (inst.geom.tuck ? untuck(inst) : tuck(inst)),
929 toggleMode: () => toggleMode(inst),
930 toggleSize: () => toggleSize(inst),
931 reset: () => reset(inst),
932 relayout: () => relayout(inst),
933 destroy: () => destroy(inst),
934 };
935 instances.add(inst);
936 syncAccount(inst);
937 return inst.api;
938 }
939
940 function destroy(inst) {
941 if (inst.destroyed) return;
942 inst.destroyed = true;
943 instances.delete(inst);
944 clearTimeout(inst.landTimer);
945 if (inst.clipAnim) { try { inst.clipAnim.cancel(); } catch { /* */ } }
946 for (const fn of inst.cleanups) { try { fn(); } catch { /* */ } }
947 inst.cleanups = [];
948 if (inst.ownedTab) { try { inst.ownedTab.remove(); } catch { /* */ } }
949 if (inst.ownedResize) { try { inst.ownedResize.remove(); } catch { /* */ } }
950 inst.el.classList.remove('ov-float', 'is-floating', 'is-lifted', 'is-dragging', 'is-tucked', 'is-tucked-left', 'is-tucked-right', 'is-resizing', 'is-active');
951 }
952
953 // ── The floating action (4.239.0; the owner, 11-13: "this button is buggy … it disappears when you scroll … it should
954 // just use a floating button that actually moves its position … it's one button, it's not jumping around, it's
955 // transitioning … aware of the page design"; "this camera button will flash in and out … a single button with smart
956 // positioning"). One element with a home in the page: while its home is on screen it sits there; when the home
957 // scrolls away, the same element glides to the dock (the bottom of the window, above the bars, the keyboard and a
958 // footer that has come up), and back when the home returns. Never a copy, never a blink: its home keeps the space,
959 // and the move is a transform (FLIP) the browser composites. Calm shortens it; Off places it at once.
960 //
961 // const a = OVFloat.action(el, { when, side, gap, maxWidth, stretch, above, avoid, onChange });
962 // a.floating · a.check() · a.destroy()
963 //
964 // when() whether it may float at all now (e.g. only on a phone); default always
965 // side 'both' (default): it floats while its home is above or below the window; 'below' only before the
966 // person reaches it; 'above' only after they pass it
967 // gap px between the dock and what is under it (12)
968 // maxWidth the floating width at most (560); stretch: true widens it to the window less the gutters
969 // above the bars it sits over (selector; default the app bar and any [data-ov-bottom-bar])
970 // avoid what it rises above when on screen (default the footer)
971 // Every change dispatches `ov:fab` ({ floating }) on the element; `data-ov-fab="floating|home"` styles it.
972 const fabs = new Set();
973 function action(el, o = {}) {
974 if (!el || !el.parentNode || el.__ovFab) return el && el.__ovFab;
975 injectStyles();
976 const home = document.createElement('div');
977 home.className = 'ov-fab-home';
978 el.parentNode.insertBefore(home, el);
979 home.appendChild(el);
980 el.dataset.ovFab = 'home';
981 let floating = false, seen = true, raf = 0, dead = false, anim = null;
982 const motion = () => document.documentElement.getAttribute('data-ov-motion') || 'auto';
983 const may = () => { try { return typeof o.when === 'function' ? !!o.when() : true; } catch { return false; } };
984 const gap = Number.isFinite(o.gap) ? o.gap : 12;
985 const vh = () => (root.visualViewport ? root.visualViewport.height + root.visualViewport.offsetTop : innerHeight);
986 // How high the dock must sit: over the fixed bars at the bottom, over the keyboard, over a footer on screen.
987 function floor() {
988 let lift = Math.max(0, innerHeight - vh());
989 const bars = o.above || '[data-ov-bottom-bar], .ovnav-appbar, .ov-appbar';
990 document.querySelectorAll(bars).forEach((b) => {
991 const r = b.getBoundingClientRect(); if (!r.height || r.bottom < innerHeight - 4) return;
992 const pos = getComputedStyle(b).position; if (pos !== 'fixed' && pos !== 'sticky') return;
993 lift = Math.max(lift, innerHeight - r.top);
994 });
995 document.querySelectorAll(o.avoid || '#ov-footer, footer.ov-footer').forEach((f) => {
996 const r = f.getBoundingClientRect(); if (r.height && r.top < innerHeight) lift = Math.max(lift, innerHeight - r.top);
997 });
998 return lift;
999 }
1000 function place() {
1001 if (!floating) return;
1002 const max = Number.isFinite(o.maxWidth) ? o.maxWidth : 560;
1003 const room = document.documentElement.clientWidth - 32;
1004 const w = Math.min(max, room, o.stretch ? room : Math.max(home.getBoundingClientRect().width, 1));
1005 el.style.setProperty('--ov-fab-w', `${Math.round(w)}px`);
1006 el.style.setProperty('--ov-fab-bottom', `${Math.round(floor() + gap)}px`);
1007 }
1008 function glide(from) {
1009 if (anim) { try { anim.cancel(); } catch { /* */ } anim = null; }
1010 const m = motion();
1011 if (m === 'off' || !from || !from.width || typeof el.animate !== 'function') return;
1012 const to = el.getBoundingClientRect();
1013 const dx = (from.left + from.width / 2) - (to.left + to.width / 2), dy = (from.top + from.height / 2) - (to.top + to.height / 2);
1014 if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
1015 // Over a long way (from far above or below the window) it rises from just past the edge instead.
1016 const far = Math.abs(dy) > innerHeight * 0.6;
1017 const start = far ? `translate(0, ${dy > 0 ? 40 : -40}px) scale(.96)` : `translate(${dx}px, ${dy}px)`;
1018 anim = el.animate([{ transform: start, opacity: far ? 0 : 1 }, { transform: 'none', opacity: 1 }],
1019 { duration: m === 'reduced' ? 180 : (far ? 360 : 460), easing: 'cubic-bezier(.2, 1, .3, 1)' });
1020 anim.onfinish = () => { anim = null; };
1021 }
1022 function set(next) {
1023 if (next === floating || dead) return;
1024 const from = el.getBoundingClientRect();
1025 if (next) home.style.minHeight = `${Math.round(from.height)}px`;
1026 floating = next;
1027 el.dataset.ovFab = next ? 'floating' : 'home';
1028 el.classList.toggle('ov-fab-floating', next);
1029 if (next) place(); else { el.style.removeProperty('--ov-fab-w'); el.style.removeProperty('--ov-fab-bottom'); }
1030 if (!next) home.style.minHeight = '';
1031 glide(from);
1032 try { el.dispatchEvent(new CustomEvent('ov:fab', { bubbles: true, detail: { floating: next } })); } catch { /* */ }
1033 if (typeof o.onChange === 'function') { try { o.onChange(next); } catch { /* the page's */ } }
1034 }
1035 function check() {
1036 raf = 0;
1037 if (dead) return;
1038 const r = home.getBoundingClientRect();
1039 const below = r.top >= vh() - floor() - 8, above = r.bottom <= 8;
1040 seen = !(below || above);
1041 const side = o.side || 'both';
1042 const want = may() && !seen && (side === 'both' || (side === 'below' && below) || (side === 'above' && above));
1043 set(want);
1044 place();
1045 }
1046 const later = () => { if (!raf) raf = requestAnimationFrame(check); };
1047 const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(later, { threshold: [0, 0.5, 1] }) : null;
1048 if (io) io.observe(home);
1049 addEventListener('scroll', later, { passive: true });
1050 addEventListener('resize', later);
1051 if (root.visualViewport) root.visualViewport.addEventListener('resize', later);
1052 const handle = {
1053 el, home,
1054 get floating() { return floating; },
1055 check: later,
1056 destroy() {
1057 dead = true; if (io) io.disconnect();
1058 removeEventListener('scroll', later); removeEventListener('resize', later);
1059 if (root.visualViewport) root.visualViewport.removeEventListener('resize', later);
1060 el.classList.remove('ov-fab-floating'); delete el.dataset.ovFab; el.__ovFab = null;
1061 if (home.parentNode) { home.parentNode.insertBefore(el, home); home.remove(); }
1062 fabs.delete(handle);
1063 },
1064 };
1065 el.__ovFab = handle;
1066 fabs.add(handle);
1067 later();
1068 return handle;
1069 }
1070
1071 function injectStyles() {
1072 if (document.getElementById('ov-float-styles')) return;
1073 const st = document.createElement('style');
1074 st.id = 'ov-float-styles';
1075 st.textContent = CSS;
1076 (document.head || document.documentElement).appendChild(st);
1077 }
1078
1079 const defaultStore = makeDefaultStore();
1080 root.OVFloat = {
1081 attach,
1082 action,
1083 actions: () => [...fabs],
1084 store: defaultStore,
1085 newStore: makeDefaultStore,
1086 layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
1087 _test: {
1088 boxOf, settle: settleGeom, flick, tuckFor, overFrame, paint, normalize,
1089 BASE, MAX_H, FLOAT_MAX_H, MIN_W, MAX_W, EDGE_PX, SNAP, PROJECT_MS, TAB_H, placeOf,
1090 },
1091 };
1092})(typeof window !== 'undefined' ? window : globalThis);
1093