/**
* 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
* floats over a page. It is Live's PiP move (public/js/stream-pip.js, 2026-11-11: "move it anywhere … mobile and
* desktop … more solid feeling and more responsive") lifted out whole, so every floating thing behaves the same:
*
* const f = OVFloat.attach(el, { frame, key, defaults, aspect, minPx, onChange });
*
* - Docked in `frame` (an element) or floating over the page (position: fixed). It follows the finger or the mouse
* with a transform each animation frame and lands with a spring (FLIP); a flick carries on where it was thrown.
* - Dropped over its frame it docks; anywhere else it floats. Near an edge it snaps; thrown past a side it tucks into
* 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
* handle slides up and down that edge, a tap opens it where it was, and a pull brings it out under the finger.
* - Two fingers pinch it, the corner sizes it, a double tap toggles small and large, and with focus the arrows move it,
* + 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
* up to the whole width of the window: past what its frame holds it floats over the page and keeps growing.
* - Its place is kept as fractions of its frame: in this browser under `key`, and in the person's account when the
* page has GET/PATCH /api/me/layout (the `pip` map Live keeps). This browser's place wins; the account's fills a
* device that has none (a phone and a desktop each keep their own). `OVFloat.store` is the account plug:
* { get(key), put(key, value), load?() }; assign your own to keep it somewhere else.
* - `f.geom()`, `f.set(patch, { save })`, `f.tuck()`, `f.untuck()`, `f.toggleMode()`, `f.toggleSize()`, `f.reset()`,
* `f.paint()`, `f.settle()`, `f.save()`, `f.sizeTo(px)`, `f.relayout()`, `f.destroy()`. `OVFloat.layout()` lists every attached box,
* and each save dispatches `ov:float` ({ key, geom, el }).
*
* The CSS (injected once) is behaviour, not looks: positioning, the lift, the tuck's translate, the tab and the resize
* grip. A component adds its own colours, border, shadow and children. `aspect: null`, `size: false` and
* `resize: false` let a panel window keep its own height and width while it gets the move, the tuck and the place.
*/
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OVFloat) return;
const BASE = 16 / 9; // the shape `w` is measured for
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)
const MAX_H = 0.85; // docked: of the frame's height, whatever the shape
const FLOAT_MAX_H = 0.92; // floating: of the window's height
const MIN_PX = 150; // the smallest 16:9 width on a phone-sized frame
const SNAP = 0.04; // within this fraction of an edge, a drop snaps to it
const EDGE_PX = 12; // the margin it snaps to
const TAB_PX = 30; // the tucked handle's width (a finger's: 34), and what stays on screen in a drag
const TAB_H = 64; // the tucked handle's height (a finger's: 72)
const TUCK_MS = 340; // folding into the handle and opening out of it
const OPEN_CLIP = 'inset(-80px -80px -80px -80px round 0px)'; // nothing clipped, its shadow included
const DRAG_PX = 4; // a press that moves less is a tap
const BIG_W = 0.5; // a double tap's large size
const PROJECT_MS = 120; // how far a flick carries
const TAP_MS = 300;
const ACTIVE_MS = 3200;
const LAND_MS = 300;
const DEFAULT_IGNORE = 'a, button, input, textarea, select, [contenteditable], .ov-float-resize, [data-ov-float-resize], [data-ov-float-no-drag]';
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;
--ov-float-tab-w: ${TAB_PX}px; --ov-float-tab-h: ${TAB_H}px; }
@media (pointer: coarse) { :where(.ov-float) { --ov-float-tab-w: 34px; --ov-float-tab-h: 72px; } }
.ov-float.is-floating { position: fixed; z-index: 900; }
.ov-float.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
.ov-float.is-lifted, .ov-float.is-lifted * { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
.ov-float.is-dragging { cursor: grabbing; }
.ov-float.is-tucked { cursor: pointer; }
.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); }
.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); }
.ov-float.is-tucked .ov-float-tab { display: grid; animation: ovFloatTabIn ${TUCK_MS}ms ease-out; }
@keyframes ovFloatTabIn { from { opacity: 0; } }
.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;
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); }
.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); }
.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); }
.ov-float-tab-l, .ov-float-tab-r { display: none; }
.ov-float.is-tucked-left .ov-float-tab-r, .ov-float.is-tucked-right .ov-float-tab-l { display: block; }
.ov-float-resize { position: absolute; right: 0; bottom: 0; z-index: 3; width: 22px; height: 22px; cursor: nwse-resize; touch-action: none; opacity: 0;
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%); }
.ov-float:hover .ov-float-resize, .ov-float:focus-within .ov-float-resize, .ov-float.is-resizing .ov-float-resize { opacity: 1; }
@media (pointer: coarse) { .ov-float-resize { width: 30px; height: 30px; opacity: .85; } }
html[data-ov-motion="off"] .ov-float { transition: none !important; }
html[data-ov-motion="off"] .ov-float-tab, html[data-ov-motion="off"] .ov-float-resize { transition: none !important; animation: none !important; }
@media (prefers-reduced-motion: reduce) {
html:not([data-ov-motion="on"]) .ov-float { transition: none !important; }
}
/* The floating action (4.239.0): its home holds the space; floating, it is fixed at the dock with a lift. */
.ov-fab-home { position: relative; }
.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));
width: var(--ov-fab-w, auto); max-width: calc(100vw - 32px); margin: 0 auto !important; /* centred by margins: the glide owns transform */
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); }
html[data-ov-motion=off] .ov-fab-floating { transition: none; }
`;
const instances = new Set();
let uid = 0;
function clamp(v, lo, hi, dflt) {
const n = Number(v);
if (!Number.isFinite(n)) return dflt;
return Math.min(hi, Math.max(lo, n));
}
function viewport(inst) {
const de = document.documentElement;
// A floating element's percentage place is measured on the document's client box, not on innerWidth when a
// scrollbar takes 15 px of it. Live's PiP keeps the window size it always used; a free panel asks for `client`.
const client = inst && inst.options.viewport === 'client' && de;
const w = (client ? de.clientWidth : root.innerWidth) || 0;
const h = (client ? de.clientHeight : root.innerHeight) || 0;
return { left: 0, top: 0, width: w, height: h, right: w, bottom: h };
}
function rectOf(el) {
return el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : viewport();
}
/** The box an absolutely positioned child measures its percentages on: the frame's padding box, not its border. */
function contentRect(el) {
const r = rectOf(el);
if (!el || !el.clientWidth || !el.clientHeight) return r;
const left = r.left + (el.clientLeft || 0), top = r.top + (el.clientTop || 0);
return { left, top, width: el.clientWidth, height: el.clientHeight, right: left + el.clientWidth, bottom: top + el.clientHeight };
}
function reduced() {
const m = document.documentElement && document.documentElement.getAttribute('data-ov-motion');
if (m === 'off') return true;
try { return m !== 'on' && root.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
}
function inFullscreen(inst) {
const fs = typeof document !== 'undefined' && document.fullscreenElement;
return !!(fs && inst && inst.frame && (fs === inst.frame || fs.contains(inst.frame)));
}
function frameRect(inst) {
if (inst.geom.mode === 'float' && !inFullscreen(inst)) return viewport(inst);
return inst.frame ? contentRect(inst.frame) : viewport(inst);
}
/* ── geometry (pure; the tests call these) ─────────────────────────────────────────────────────────────── */
/**
* The box as fractions of its frame: width, height and the place it may go, for the source's own shape.
* aspect: null keeps the element's own height (a panel window); `elHeight` is its measured height.
*/
function boxOf(geom, rect, opts) {
const o = opts || {};
const a = o.aspect === null ? null : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE);
const minW = o.minW == null ? MIN_W : o.minW;
const maxW = o.maxW == null ? MAX_W : o.maxW;
const maxH = o.maxH == null ? MAX_H : o.maxH;
const rw = (rect && rect.width) || 0, rh = (rect && rect.height) || 0;
const rl = (rect && rect.left) || 0, rt = (rect && rect.top) || 0;
let w = clamp(geom && geom.w, minW, maxW, Math.min(0.25, maxW));
if (a == null) {
return { a: null, wf: w, hf: rh && o.elHeight ? o.elHeight / rh : 0, rw, rh, rl, rt };
}
// On a phone-sized frame a quarter of the width is a postage stamp: never smaller than a 150 px wide 16:9 box
// (the saved size is unchanged, so a desktop shows it as saved).
const minPx = o.minPx == null ? MIN_PX : o.minPx;
if (rw && rw < (o.phoneFramePx == null ? 640 : o.phoneFramePx)) w = Math.max(w, Math.min(maxW, minPx / rw));
let wf = w * Math.sqrt(a / BASE); // the same area as a 16:9 box of width w
if (rw && rh && (wf * rw) / a > maxH * rh) wf = (maxH * rh * a) / rw;
wf = Math.min(wf, 1);
const hf = rw && rh ? (wf * rw) / a / rh : wf / a;
return { a, wf, hf, rw, rh, rl, rt };
}
/** After a drop: keep it inside, and snap it to an edge it was dropped close to. */
function settleGeom(geom, box, opts) {
const o = opts || {};
const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
const snap = o.snap == null ? SNAP : o.snap;
const mx = box.rw ? edge / box.rw : 0, my = box.rh ? edge / box.rh : 0;
const maxX = Math.max(0, 1 - box.wf), maxY = Math.max(0, 1 - (box.hf || 0));
let x = clamp(geom.x, 0, maxX, 0), y = clamp(geom.y, 0, maxY, 0);
if (x < snap) x = Math.min(mx, maxX); else if (x > maxX - snap) x = Math.max(0, maxX - mx);
if (y < snap) y = Math.min(my, maxY); else if (y > maxY - snap) y = Math.max(0, maxY - my);
geom.x = x; geom.y = y;
return geom;
}
/** The last 90 ms of a drag: the speed it was let go at, and where a flick carries it. */
function flick(hist, from, opts) {
const o = opts || {};
const h = Array.isArray(hist) && hist.length ? hist : [{ x: 0, y: 0, t: 0 }];
const a = h[0], z = h[h.length - 1];
const dt = Math.max(16, (z.t || 0) - (a.t || 0));
const vx = ((z.x || 0) - (a.x || 0)) / dt, vy = ((z.y || 0) - (a.y || 0)) / dt;
const fast = Math.hypot(vx, vy) > (o.fastAt == null ? 0.6 : o.fastAt);
return { vx, vy, fast, left: from.left + (fast ? vx * PROJECT_MS : 0), top: from.top + (fast ? vy * PROJECT_MS : 0) };
}
/** Past a side of its frame by more than a third of itself (or thrown at it): tucked into that side. */
function tuckFor(point, from, frame, speed, opts) {
const o = opts || {};
const edge = o.edgePx == null ? EDGE_PX : o.edgePx;
const out = from.width / 3;
if (point.left < frame.left - out || (speed.fast && speed.vx < -1.2 && point.left < frame.left + edge)) return 'left';
if (point.left + from.width > frame.right + out || (speed.fast && speed.vx > 1.2 && point.left + from.width > frame.right - edge)) return 'right';
return null;
}
function overFrame(cx, cy, frame) {
return !!frame && cx >= frame.left && cx <= frame.right && cy >= frame.top && cy <= frame.bottom;
}
/** Draw a geometry: the percentages, the shape, and the state classes the component styles. */
function paint(el, geom, box, opts) {
const o = opts || {};
const tuck = geom.tuck;
const floating = !!o.floating;
const classes = el.classList;
classes.toggle('is-floating', floating);
classes.toggle('is-tucked', !!tuck);
classes.toggle('is-tucked-left', tuck === 'left');
classes.toggle('is-tucked-right', tuck === 'right');
classes.toggle('is-portrait', !!box.a && box.a < 1);
// Shown inside its frame even when a turn of the phone or a smaller window would push it out; the saved place
// is not changed, so it comes back when there is room again. A tucked one sits at its side (CSS `translate`
// leaves only its tab on screen; `transform` stays free for the landing).
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));
el.style.left = `${left * 100}%`;
el.style.top = `${Math.min(Math.max(0, geom.y), Math.max(0, 1 - (box.hf || 0))) * 100}%`;
if (o.size !== false) {
el.style.width = `${box.wf * 100}%`;
if (box.a) el.style.aspectRatio = String(box.a);
}
}
/* ── the account plug (GET/PATCH /api/me/layout, the shape Live's stream-pip.js keeps) ───────────────── */
function makeDefaultStore() {
const state = { data: null, loading: null, pending: {}, timer: 0, off: false };
function load() {
if (state.off || typeof fetch !== 'function') return Promise.resolve(null);
if (!state.loading) {
state.loading = fetch('/api/me/layout', { credentials: 'same-origin', headers: { accept: 'application/json' } })
.then((r) => {
if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) { state.off = true; return null; }
return r.ok ? r.json() : null;
})
.then((d) => { state.data = d && typeof d === 'object' ? d : {}; return state.data; })
.catch(() => null);
}
return state.loading;
}
function get(key) {
const pip = state.data && state.data.pip;
return pip && pip[key] ? pip[key] : null;
}
function put(key, value) {
if (state.off || typeof fetch !== 'function') return;
state.pending[key] = value;
if (state.data) { state.data.pip = state.data.pip || {}; state.data.pip[key] = value; }
clearTimeout(state.timer);
state.timer = setTimeout(() => {
const pip = state.pending; state.pending = {};
fetch('/api/me/layout', {
method: 'PATCH', credentials: 'same-origin',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ pip }),
}).then((r) => { if (r.status === 400 || r.status === 401 || r.status === 403 || r.status === 404) state.off = true; })
.catch(() => { /* the next move tries again */ });
}, 1500);
}
return { load, get, put, _state: state };
}
function normalizeDefaults(raw, hasFrame) {
const d = raw && typeof raw === 'object' ? raw : {};
return {
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),
mode: hasFrame ? (d.mode === 'float' ? 'float' : 'dock') : 'float',
tuck: d.tuck === 'left' || d.tuck === 'right' ? d.tuck : null,
muted: typeof d.muted === 'boolean' ? d.muted : undefined,
at: 0,
};
}
function normalize(raw, fallback, opts) {
const o = opts || {};
if (!raw || typeof raw !== 'object') return Object.assign({}, fallback);
const g = Object.assign({}, fallback);
g.x = clamp(raw.x, 0, 1, fallback.x);
g.y = clamp(raw.y, 0, 1, fallback.y);
g.w = clamp(raw.w, o.minW == null ? MIN_W : o.minW, o.maxW == null ? MAX_W : o.maxW, fallback.w);
g.mode = o.hasFrame === false ? 'float' : (raw.mode === 'float' ? 'float' : raw.mode === 'dock' ? 'dock' : fallback.mode);
g.tuck = raw.tuck === 'left' || raw.tuck === 'right' ? raw.tuck : null;
if (typeof raw.muted === 'boolean') g.muted = raw.muted;
g.at = Number(raw.at) || 0;
// Where a box tucked by hand opens again (this browser only).
if (g.tuck && raw.ux != null && Number.isFinite(Number(raw.ux))) g.ux = clamp(raw.ux, 0, 1, 0);
return g;
}
/** The place as an account keeps it (live.layout's fields): no browser-only extras. */
function placeOf(g) {
const out = { x: g.x, y: g.y, w: g.w, mode: g.mode, tuck: g.tuck || null, at: g.at || 0 };
if (typeof g.muted === 'boolean') out.muted = g.muted;
return out;
}
function readSaved(key, legacyKeys) {
const keys = [key].concat(Array.isArray(legacyKeys) ? legacyKeys : []);
for (const k of keys) {
try {
const raw = root.localStorage && root.localStorage.getItem(k);
if (!raw) continue;
const v = JSON.parse(raw);
if (v && typeof v === 'object') return v;
} catch { /* private mode, or a key from another shape */ }
}
return null;
}
/* ── the instance ──────────────────────────────────────────────────────────────────────────────────────── */
function box(inst) {
const floating = inst.geom.mode === 'float' && !inFullscreen(inst);
return boxOf(inst.geom, frameRect(inst), {
aspect: inst.aspect, minPx: inst.minPx, minW: inst.minW, maxW: inst.maxW,
maxH: floating ? FLOAT_MAX_H : MAX_H,
elHeight: inst.el.offsetHeight || 0,
});
}
/** The handle a tucked box folds into, in px (the component may set its own with --ov-float-tab-w/-h). */
function tabSize(inst) {
let w = 0, h = 0;
try {
const cs = root.getComputedStyle(inst.el);
w = parseFloat(cs.getPropertyValue('--ov-float-tab-w'));
h = parseFloat(cs.getPropertyValue('--ov-float-tab-h'));
} catch { /* no computed style here */ }
return { w: w > 0 ? w : TAB_PX, h: h > 0 ? h : TAB_H };
}
/**
* 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
* is squeezed (the move to the side is the landing's spring, at the same time). The CSS class holds the folded shape.
*/
function animateClip(inst, opening, side) {
const el = inst.el;
if (!side || reduced() || typeof el.animate !== 'function') return;
const w = el.offsetWidth, h = el.offsetHeight;
if (!w || !h) return;
const t = tabSize(inst);
const v = Math.max(0, (h - t.h) / 2), across = Math.max(0, w - t.w);
const folded = side === 'right'
? `inset(${v}px 0px ${v}px ${across}px round 14px 0px 0px 14px)`
: `inset(${v}px ${across}px ${v}px 0px round 0px 14px 14px 0px)`;
try {
if (inst.clipAnim) inst.clipAnim.cancel();
inst.clipAnim = el.animate([{ clipPath: opening ? folded : OPEN_CLIP }, { clipPath: opening ? OPEN_CLIP : folded }],
{ duration: TUCK_MS, easing: 'cubic-bezier(.2, .9, .25, 1)' });
} catch { /* a browser that cannot animate a clip simply shows the end */ }
}
/**
* 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
* frame while it fits; past what the frame holds it floats over the page, where it is, and keeps growing up to the
* window's width. Answers true when it changed frames (the caller takes its pointers back).
*/
function sizeTo(inst, px) {
const a = inst.aspect;
const k = a ? Math.sqrt(a / BASE) || 1 : 1;
let b = box(inst);
if (inst.geom.mode === 'dock' && inst.frame && !inFullscreen(inst) && b.rw && b.rh) {
const hpx = a ? px / a : (inst.el.offsetHeight || 0);
if (px > b.rw + 0.5 || (a && hpx > MAX_H * b.rh + 0.5)) {
const r = inst.el.getBoundingClientRect();
toMode(inst, 'float', { left: r.left, top: r.top, width: px, height: hpx });
placeIn(inst);
paintNow(inst);
return true;
}
}
b = box(inst);
if (!b.rw) return false;
inst.geom.w = clamp(px / b.rw / k, inst.minW, inst.maxW, inst.geom.w);
b = box(inst);
inst.geom.x = Math.min(inst.geom.x, Math.max(0, 1 - b.wf));
inst.geom.y = Math.min(inst.geom.y, Math.max(0, 1 - b.hf));
paintNow(inst);
return false;
}
function paintNow(inst) {
const b = box(inst);
paint(inst.el, inst.geom, b, {
floating: inst.geom.mode === 'float' && !inFullscreen(inst),
size: inst.options.size !== false,
});
return b;
}
function settleInst(inst) {
return settleGeom(inst.geom, box(inst));
}
function placeIn(inst) {
const fs = document.fullscreenElement;
let want;
if (inst.geom.mode === 'float' && !inFullscreen(inst)) want = document.body;
else if (inst.frame) want = fs && !fs.contains(inst.frame) ? fs : inst.frame;
else want = document.body;
if (want && inst.el.parentNode !== want) {
const v = inst.el.querySelector('video');
want.appendChild(inst.el);
// A media element moved in one task keeps playing; play() covers a browser that paused it.
if (v && v.paused) v.play().catch(() => {});
}
}
function save(inst) {
if (inst.destroyed) return;
// A drop clears the lifted inline left/top before it saves; draw the final place first so an onChange that
// reads the element's rect (a window's absolute place, for one) sees where it landed, not its static position.
if (!inst.el.classList.contains('is-lifted')) paintNow(inst);
inst.geom.at = Date.now();
// This browser keeps everything (where a tucked box opens again too); the account and listeners get the place.
try { if (root.localStorage) root.localStorage.setItem(inst.key, JSON.stringify(inst.geom)); } catch { /* private mode */ }
if (inst.store && typeof inst.store.put === 'function') {
try { inst.store.put(inst.accountKey, placeOf(inst.geom)); } catch { /* the page's own store */ }
}
if (typeof inst.options.onChange === 'function') {
try { inst.options.onChange(placeOf(inst.geom), inst.api); } catch (err) { console.error(err); }
}
try {
root.dispatchEvent(new root.CustomEvent('ov:float', { detail: { key: inst.key, geom: placeOf(inst.geom), el: inst.el } }));
} catch { /* an environment without CustomEvent */ }
}
/** Move it to its place with a spring from where it was (`from`: its rect on screen before). */
function land(inst, from) {
placeIn(inst);
const el = inst.el;
el.style.transition = 'none';
el.style.transform = '';
paintNow(inst);
if (!from || reduced()) { el.style.transition = ''; return; }
const to = el.getBoundingClientRect();
if (!to.width) { el.style.transition = ''; return; }
const dx = from.left - to.left, dy = from.top - to.top, s = from.width / to.width;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1 && Math.abs(s - 1) < 0.01) { el.style.transition = ''; return; }
el.style.transformOrigin = '0 0';
el.style.transform = `translate3d(${dx}px, ${dy}px, 0) scale(${s})`;
void el.offsetWidth;
el.style.transition = `transform ${LAND_MS}ms var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1))`;
el.style.transform = '';
clearTimeout(inst.landTimer);
inst.landTimer = setTimeout(() => {
if (!inst.destroyed) { el.style.transition = ''; el.style.transformOrigin = ''; }
}, LAND_MS + 40);
}
/** Switch frames keeping its size and place on screen (`rect`: where it is now). */
function toMode(inst, mode, rect) {
if (!inst.frame) mode = 'float';
inst.geom.mode = mode;
const b0 = box(inst);
if (!b0.rw || !b0.rh) return;
const a = inst.aspect || BASE;
const k = Math.sqrt(a / BASE) || 1;
if (inst.options.size !== false) inst.geom.w = clamp(rect.width / b0.rw / k, inst.minW, inst.maxW, inst.geom.w);
const b = box(inst);
inst.geom.x = (rect.left - b.rl) / b.rw;
inst.geom.y = (rect.top - b.rt) / b.rh;
settleInst(inst);
}
function tuck(inst, side) {
if (side === null) { untuck(inst); return; }
const from = inst.el.getBoundingClientRect();
const b = box(inst);
// Tucked by hand (the button, T): it opens again where it was. Thrown at a side, it opens at that side.
if (!inst.geom.tuck) inst.geom.ux = inst.geom.x;
inst.geom.tuck = side || (inst.geom.x + b.wf / 2 < 0.5 ? 'left' : 'right');
save(inst);
land(inst, from);
animateClip(inst, false, inst.geom.tuck);
}
function untuck(inst) {
const side = inst.geom.tuck;
if (!side) return;
const from = inst.el.getBoundingClientRect();
const b = box(inst);
const margin = b.rw ? EDGE_PX / b.rw : 0;
inst.geom.tuck = null;
inst.geom.x = inst.geom.ux != null ? inst.geom.ux : side === 'left' ? margin : Math.max(0, 1 - b.wf - margin);
delete inst.geom.ux;
inst.untuckedAt = performance.now();
settleInst(inst);
save(inst);
land(inst, from);
animateClip(inst, true, side);
}
function toggleMode(inst) {
if (!inst.frame) return false;
const from = inst.el.getBoundingClientRect();
toMode(inst, inst.geom.mode === 'float' ? 'dock' : 'float', from);
save(inst);
land(inst, from);
return true;
}
/** Small and large: large is half its frame, and on a phone the whole width of the screen (it floats to get it). */
function toggleSize(inst) {
const from = inst.el.getBoundingClientRect();
const vp = viewport(inst);
const b = box(inst);
const k = inst.aspect ? Math.sqrt(inst.aspect / BASE) || 1 : 1;
const bigW = inst.options.bigW == null ? BIG_W : inst.options.bigW;
const bigPx = vp.width && vp.width < 640 ? vp.width : bigW * k * b.rw;
delete inst.geom.ux;
inst.geom.tuck = null;
if (from.width < bigPx * 0.95) sizeTo(inst, bigPx);
else inst.geom.w = clamp(inst.defaults.w, inst.minW, inst.maxW, inst.defaults.w);
settleInst(inst);
save(inst);
land(inst, from);
}
function reset(inst) {
const from = inst.el.getBoundingClientRect();
const d = inst.defaults;
Object.assign(inst.geom, { x: d.x, y: d.y, w: d.w, mode: inst.frame ? d.mode : 'float', tuck: null });
delete inst.geom.ux;
settleInst(inst);
save(inst);
land(inst, from);
}
function relayout(inst) {
if (inst.destroyed || inst.el.classList.contains('is-lifted')) return;
placeIn(inst);
paintNow(inst);
}
function syncAccount(inst) {
const store = inst.store;
if (!store || typeof store.get !== 'function') return;
const apply = () => {
// This device's own place wins; the account's opens a device that has none where the person left it.
if (inst.destroyed || inst.touched || inst.hadLocal) return;
const remote = normalize(store.get(inst.accountKey), inst.defaults, { hasFrame: !!inst.frame, minW: inst.minW, maxW: inst.maxW });
if ((remote.at || 0) > (inst.geom.at || 0)) {
inst.geom = remote;
placeIn(inst);
paintNow(inst);
}
};
if (typeof store.load === 'function') {
let p;
try { p = store.load(); } catch { p = null; }
if (p && typeof p.then === 'function') p.then(apply).catch(() => { /* no account on this page */ });
else apply();
} else apply();
}
/* ── gestures ──────────────────────────────────────────────────────────────────────────────────────────── */
function wireMove(inst) {
const el = inst.el;
const handle = inst.options.handle && inst.options.handle.addEventListener ? inst.options.handle : el;
const ignore = inst.options.ignore || DEFAULT_IGNORE;
const hintClass = inst.options.hintClass || 'ov-float-dock-hint';
const pts = new Map(); // pointerId → { x, y }
let drag = null, pinch = null, raf = 0, lastTap = 0, activeTimer = 0;
const gap = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y) || 1; };
const hint = (on) => { if (inst.frame && inst.frame.classList) inst.frame.classList.toggle(hintClass, !!on); };
const paint = () => {
raf = 0;
if (!drag || !drag.lifted) return;
el.style.transform = `translate3d(${drag.dx}px, ${drag.dy}px, 0)`;
const c = drag.r;
const p = inst.frame ? contentRect(inst.frame) : null;
if (p) hint(overFrame(c.left + drag.dx + c.width / 2, c.top + drag.dy + c.height / 2, p));
};
const lift = () => {
drag.lifted = true;
// Over the page from the first move, at the same place on screen, so it can go anywhere.
const r = drag.r;
const layer = document.fullscreenElement || document.body;
if (el.parentNode !== layer) layer.appendChild(el);
// Moving the element in the DOM releases any pointer capture it had; take the pointers it is following back.
for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } }
el.classList.add('is-dragging', 'is-lifted');
el.style.transition = 'none';
el.style.left = `${r.left}px`; el.style.top = `${r.top}px`; el.style.width = `${r.width}px`;
const v = el.querySelector('video');
if (v && v.paused) v.play().catch(() => {});
if (typeof inst.options.onLift === 'function') { try { inst.options.onLift(inst.api); } catch { /* */ } }
};
const showActive = () => {
el.classList.add('is-active');
clearTimeout(activeTimer);
activeTimer = setTimeout(() => el.classList.remove('is-active'), ACTIVE_MS);
};
const recapture = () => { for (const id of pts.keys()) { try { el.setPointerCapture(id); } catch { /* */ } } };
const onDown = (e) => {
if (e.button > 0) return;
// The tucked handle is a button that moves: a tap opens it, a drag slides it along its side or pulls it out.
if (e.target.closest && e.target.closest(ignore) && !e.target.closest('[data-ov-float-tab]')) return;
if (typeof inst.options.canDrag === 'function' && !inst.options.canDrag(e)) return;
inst.touched = true;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
try { el.setPointerCapture(e.pointerId); } catch { /* a pointer the browser no longer tracks */ }
e.preventDefault();
if (pts.size === 2) {
if (drag && drag.lifted) el.style.transform = '';
drag = null;
pinch = { d: gap(), px: el.getBoundingClientRect().width };
el.classList.add('is-resizing');
return;
}
if (pts.size > 2) return;
drag = {
sx: e.clientX, sy: e.clientY, dx: 0, dy: 0, r: el.getBoundingClientRect(), lifted: false,
tucked: inst.geom.tuck || null, slide: false,
hist: [{ x: e.clientX, y: e.clientY, t: performance.now() }],
};
};
const onMove = (e) => {
if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch && pts.size === 2) {
// Up to the whole width of the window: past its frame it floats over the page and keeps growing.
if (sizeTo(inst, pinch.px * (gap() / pinch.d))) recapture();
return;
}
if (!drag) return;
const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
if (!drag.lifted && !drag.slide && Math.hypot(dx, dy) < DRAG_PX) return;
if (drag.tucked && !drag.lifted && (drag.slide || Math.abs(dy) > Math.abs(dx))) {
// A tucked handle dragged up or down slides along its side, the whole box inside its frame.
if (!drag.slide) { drag.slide = true; el.classList.add('is-dragging'); el.style.transition = 'none'; }
const f = frameRect(inst), r = drag.r;
drag.dy = Math.min(Math.max(dy, f.top - r.top), f.top + f.height - r.bottom);
if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (drag && drag.slide) el.style.transform = `translate3d(0, ${drag.dy}px, 0)`; });
return;
}
if (!drag.lifted) {
if (drag.tucked) {
// Pulled out of its side: it opens under the finger and follows it.
const side = inst.geom.tuck;
inst.geom.tuck = null;
delete inst.geom.ux;
el.classList.remove('is-tucked', 'is-tucked-left', 'is-tucked-right');
animateClip(inst, true, side);
}
lift();
}
// Anywhere on screen; most of it may go past a side (that is how it tucks), never past the top or bottom.
const vp = viewport(inst);
const r = drag.r, vw = vp.width, vh = vp.height, tab = inst.options.tabPx == null ? TAB_PX : inst.options.tabPx;
drag.dx = Math.min(Math.max(dx, -r.left - r.width + tab), vw - r.left - tab);
drag.dy = Math.min(Math.max(dy, -r.top), vh - r.top - r.height);
const now = performance.now();
drag.hist.push({ x: e.clientX, y: e.clientY, t: now });
while (drag.hist.length > 2 && now - drag.hist[0].t > 90) drag.hist.shift();
if (!raf) raf = requestAnimationFrame(paint);
};
const onUp = (e) => {
pts.delete(e.pointerId);
try { el.releasePointerCapture(e.pointerId); } catch { /* */ }
if (pinch) {
if (pts.size < 2) {
pinch = null;
el.classList.remove('is-resizing');
// A pinch that began after the lift put the box over the page; put it back in its frame for its mode.
if (el.classList.contains('is-lifted')) {
el.classList.remove('is-dragging', 'is-lifted');
el.style.left = ''; el.style.top = ''; el.style.width = ''; el.style.transform = '';
placeIn(inst);
}
settleInst(inst);
paintNow(inst);
save(inst);
}
return;
}
if (!drag) return;
const d = drag; drag = null;
if (raf) { cancelAnimationFrame(raf); raf = 0; }
hint(false);
if (d.slide) {
// Slid along its side: it stays tucked there, at that height.
el.classList.remove('is-dragging');
el.style.transition = 'none';
el.style.transform = '';
const b = box(inst);
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);
paintNow(inst);
el.style.transition = '';
save(inst);
return;
}
if (!d.lifted) {
// 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
// second within 300 ms toggles the size.
if (inst.geom.tuck) { if (e.type === 'pointerup') untuck(inst); return; }
const now = performance.now();
if (inst.options.doubleTap !== false && now - lastTap < TAP_MS) { lastTap = 0; toggleSize(inst); }
else {
lastTap = now;
if (e.pointerType !== 'mouse' && inst.options.activeBar !== false) showActive();
}
return;
}
el.classList.remove('is-dragging', 'is-lifted');
// Where it was let go, and where a flick carries it (the last 90 ms, 120 ms on).
const fl = flick(d.hist, { left: d.r.left + d.dx, top: d.r.top + d.dy });
const from = { left: d.r.left + d.dx, top: d.r.top + d.dy, width: d.r.width, height: d.r.height };
const vp = viewport(inst);
const vhei = vp.height;
const top = Math.min(Math.max(0, fl.top), vhei - from.height);
const p = inst.frame ? contentRect(inst.frame) : null;
const fs = inFullscreen(inst);
const overPlayer = !!p && (fs || overFrame(fl.left + from.width / 2, top + from.height / 2, p));
const frame = overPlayer ? p : vp;
const tuckSide = tuckFor({ left: fl.left }, from, frame, fl);
el.style.transform = '';
el.style.left = ''; el.style.top = ''; el.style.width = '';
toMode(inst, overPlayer ? 'dock' : 'float', {
left: Math.min(Math.max(frame.left, fl.left), frame.right - from.width),
top,
width: from.width,
height: from.height,
});
inst.geom.tuck = tuckSide;
save(inst);
land(inst, from);
};
handle.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
inst.cleanups.push(() => {
handle.removeEventListener('pointerdown', onDown);
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
clearTimeout(activeTimer);
hint(false);
});
}
function wireKeys(inst) {
const el = inst.el;
const onKey = (e) => {
if (e.target !== el || e.altKey || e.ctrlKey || e.metaKey) return;
const step = e.shiftKey ? 0.1 : 0.02;
const k = e.key;
let moved = true;
if (k === 'ArrowLeft') inst.geom.x -= step;
else if (k === 'ArrowRight') inst.geom.x += step;
else if (k === 'ArrowUp') inst.geom.y -= step;
else if (k === 'ArrowDown') inst.geom.y += step;
else if (k === '+' || k === '=' || k === '-' || k === '_') {
const r = el.getBoundingClientRect();
sizeTo(inst, r.width * (k === '+' || k === '=' ? 1.12 : 1 / 1.12));
}
else if (k === 'Home') { e.preventDefault(); reset(inst); return; }
else if (k === 't' || k === 'T' || k === 'h' || k === 'H') { e.preventDefault(); inst.geom.tuck ? untuck(inst) : tuck(inst); return; }
else if (k === 'f' || k === 'F') { e.preventDefault(); toggleMode(inst); return; }
else return;
e.preventDefault();
if (!moved) return;
const b = box(inst);
inst.geom.tuck = null;
delete inst.geom.ux;
inst.geom.x = Math.min(Math.max(0, inst.geom.x), Math.max(0, 1 - b.wf));
inst.geom.y = Math.min(Math.max(0, inst.geom.y), Math.max(0, 1 - b.hf));
paintNow(inst);
save(inst);
};
el.addEventListener('keydown', onKey);
inst.cleanups.push(() => el.removeEventListener('keydown', onKey));
}
function ensureTab(inst) {
let tab = inst.el.querySelector('[data-ov-float-tab]');
if (!tab) {
tab = document.createElement('button');
tab.type = 'button';
tab.className = 'ov-float-tab';
tab.dataset.ovFloatTab = '';
tab.setAttribute('aria-label', 'Bring it back');
tab.innerHTML = '';
inst.el.append(tab);
inst.ownedTab = tab;
}
// A pointer tap already opened it (the gesture's pointerup); the click is a key's, or a tap the gesture missed.
const onClick = (e) => {
e.preventDefault(); e.stopPropagation();
if (performance.now() - (inst.untuckedAt || 0) < 700) return;
untuck(inst);
};
tab.addEventListener('click', onClick);
inst.cleanups.push(() => tab.removeEventListener('click', onClick));
}
function ensureResize(inst) {
let handle = inst.el.querySelector('[data-ov-float-resize]');
if (!handle) {
handle = document.createElement('span');
handle.className = 'ov-float-resize';
handle.dataset.ovFloatResize = '';
handle.setAttribute('aria-hidden', 'true');
inst.el.append(handle);
inst.ownedResize = handle;
}
let start = null;
const onDown = (e) => {
if (e.button > 0) return;
start = { px: e.clientX, py: e.clientY, width: inst.el.getBoundingClientRect().width };
inst.touched = true;
inst.el.classList.add('is-resizing');
try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
e.preventDefault();
e.stopPropagation();
};
const onMove = (e) => {
if (!start || !start.width) return;
// The handle's diagonal pull is the box's own width; past its frame it floats and keeps growing.
const pull = inst.aspect ? ((e.clientX - start.px) + (e.clientY - start.py) * inst.aspect) / 2 : (e.clientX - start.px);
if (sizeTo(inst, Math.max(1, start.width + pull))) { try { handle.setPointerCapture(e.pointerId); } catch { /* */ } }
};
const end = (e) => {
if (!start) return;
start = null;
inst.el.classList.remove('is-resizing');
try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
settleInst(inst);
paintNow(inst);
save(inst);
};
handle.addEventListener('pointerdown', onDown);
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', end);
handle.addEventListener('pointercancel', end);
inst.cleanups.push(() => {
handle.removeEventListener('pointerdown', onDown);
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', end);
handle.removeEventListener('pointercancel', end);
});
}
function wireWindow(inst) {
// A turn of the phone, a keyboard, the address bar or a new window size: back inside, once a frame.
let queued = 0;
const onResize = () => {
if (queued) return;
queued = requestAnimationFrame(() => { queued = 0; relayout(inst); });
};
const vv = root.visualViewport;
root.addEventListener('resize', onResize);
root.addEventListener('orientationchange', onResize);
if (vv && vv.addEventListener) vv.addEventListener('resize', onResize);
document.addEventListener('fullscreenchange', onResize);
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(onResize);
if (inst.frame) ro.observe(inst.frame);
ro.observe(inst.el);
}
inst.cleanups.push(() => {
if (queued) cancelAnimationFrame(queued);
root.removeEventListener('resize', onResize);
root.removeEventListener('orientationchange', onResize);
if (vv && vv.removeEventListener) vv.removeEventListener('resize', onResize);
document.removeEventListener('fullscreenchange', onResize);
try { ro && ro.disconnect(); } catch { /* */ }
});
}
/* ── public API ───────────────────────────────────────────────────────────────────────────────────────── */
function attach(el, options) {
if (!el || typeof el !== 'object' || !el.classList) throw new TypeError('OVFloat.attach(el, options): el is required');
const o = options || {};
const frame = o.frame || null;
const key = String(o.key || `ov-float-${++uid}`);
const minW = clamp(o.minW == null ? MIN_W : o.minW, 0, 1, MIN_W);
const maxW = clamp(o.maxW == null ? MAX_W : o.maxW, minW, 1, MAX_W);
const defaults = normalizeDefaults(o.defaults, !!frame);
const inst = {
el, frame, key,
accountKey: o.accountKey == null ? key : String(o.accountKey),
options: o, defaults, minW, maxW,
aspect: o.aspect === null ? null : (typeof o.aspect === 'function' ? Number(o.aspect()) || BASE : (Number(o.aspect) > 0 ? Number(o.aspect) : BASE)),
minPx: Number(o.minPx) > 0 ? Number(o.minPx) : MIN_PX,
store: o.store === null ? null : (o.store || root.OVFloat.store || null),
geom: null, touched: false, destroyed: false, cleanups: [], landTimer: 0, ownedTab: null, ownedResize: null, api: null,
};
const local = readSaved(key, o.legacyKeys);
inst.hadLocal = !!local;
inst.geom = normalize(local, defaults, { hasFrame: !!frame, minW, maxW });
injectStyles();
el.classList.add('ov-float');
if (o.tab !== false) ensureTab(inst);
if (o.resize !== false) ensureResize(inst);
wireMove(inst);
wireKeys(inst);
wireWindow(inst);
placeIn(inst);
paintNow(inst);
inst.api = {
el, key,
geom: () => Object.assign({}, inst.geom),
set(patch, opts) {
Object.assign(inst.geom, patch || {});
if (opts && opts.settle) settleInst(inst);
placeIn(inst);
paintNow(inst);
if (opts && opts.save) save(inst);
return Object.assign({}, inst.geom);
},
setAspect(a) {
inst.aspect = a === null ? null : (Number(a) > 0 ? Number(a) : BASE);
paintNow(inst);
},
metrics: () => box(inst),
sizeTo: (px) => sizeTo(inst, px),
paint: () => paintNow(inst),
settle: () => { settleInst(inst); paintNow(inst); return Object.assign({}, inst.geom); },
save: () => save(inst),
tuck: (side) => tuck(inst, side),
untuck: () => untuck(inst),
toggleTuck: () => (inst.geom.tuck ? untuck(inst) : tuck(inst)),
toggleMode: () => toggleMode(inst),
toggleSize: () => toggleSize(inst),
reset: () => reset(inst),
relayout: () => relayout(inst),
destroy: () => destroy(inst),
};
instances.add(inst);
syncAccount(inst);
return inst.api;
}
function destroy(inst) {
if (inst.destroyed) return;
inst.destroyed = true;
instances.delete(inst);
clearTimeout(inst.landTimer);
if (inst.clipAnim) { try { inst.clipAnim.cancel(); } catch { /* */ } }
for (const fn of inst.cleanups) { try { fn(); } catch { /* */ } }
inst.cleanups = [];
if (inst.ownedTab) { try { inst.ownedTab.remove(); } catch { /* */ } }
if (inst.ownedResize) { try { inst.ownedResize.remove(); } catch { /* */ } }
inst.el.classList.remove('ov-float', 'is-floating', 'is-lifted', 'is-dragging', 'is-tucked', 'is-tucked-left', 'is-tucked-right', 'is-resizing', 'is-active');
}
// ── The floating action (4.239.0; the owner, 11-13: "this button is buggy … it disappears when you scroll … it should
// just use a floating button that actually moves its position … it's one button, it's not jumping around, it's
// transitioning … aware of the page design"; "this camera button will flash in and out … a single button with smart
// positioning"). One element with a home in the page: while its home is on screen it sits there; when the home
// scrolls away, the same element glides to the dock (the bottom of the window, above the bars, the keyboard and a
// footer that has come up), and back when the home returns. Never a copy, never a blink: its home keeps the space,
// and the move is a transform (FLIP) the browser composites. Calm shortens it; Off places it at once.
//
// const a = OVFloat.action(el, { when, side, gap, maxWidth, stretch, above, avoid, onChange });
// a.floating · a.check() · a.destroy()
//
// when() whether it may float at all now (e.g. only on a phone); default always
// side 'both' (default): it floats while its home is above or below the window; 'below' only before the
// person reaches it; 'above' only after they pass it
// gap px between the dock and what is under it (12)
// maxWidth the floating width at most (560); stretch: true widens it to the window less the gutters
// above the bars it sits over (selector; default the app bar and any [data-ov-bottom-bar])
// avoid what it rises above when on screen (default the footer)
// Every change dispatches `ov:fab` ({ floating }) on the element; `data-ov-fab="floating|home"` styles it.
const fabs = new Set();
function action(el, o = {}) {
if (!el || !el.parentNode || el.__ovFab) return el && el.__ovFab;
injectStyles();
const home = document.createElement('div');
home.className = 'ov-fab-home';
el.parentNode.insertBefore(home, el);
home.appendChild(el);
el.dataset.ovFab = 'home';
let floating = false, seen = true, raf = 0, dead = false, anim = null;
const motion = () => document.documentElement.getAttribute('data-ov-motion') || 'auto';
const may = () => { try { return typeof o.when === 'function' ? !!o.when() : true; } catch { return false; } };
const gap = Number.isFinite(o.gap) ? o.gap : 12;
const vh = () => (root.visualViewport ? root.visualViewport.height + root.visualViewport.offsetTop : innerHeight);
// How high the dock must sit: over the fixed bars at the bottom, over the keyboard, over a footer on screen.
function floor() {
let lift = Math.max(0, innerHeight - vh());
const bars = o.above || '[data-ov-bottom-bar], .ovnav-appbar, .ov-appbar';
document.querySelectorAll(bars).forEach((b) => {
const r = b.getBoundingClientRect(); if (!r.height || r.bottom < innerHeight - 4) return;
const pos = getComputedStyle(b).position; if (pos !== 'fixed' && pos !== 'sticky') return;
lift = Math.max(lift, innerHeight - r.top);
});
document.querySelectorAll(o.avoid || '#ov-footer, footer.ov-footer').forEach((f) => {
const r = f.getBoundingClientRect(); if (r.height && r.top < innerHeight) lift = Math.max(lift, innerHeight - r.top);
});
return lift;
}
function place() {
if (!floating) return;
const max = Number.isFinite(o.maxWidth) ? o.maxWidth : 560;
const room = document.documentElement.clientWidth - 32;
const w = Math.min(max, room, o.stretch ? room : Math.max(home.getBoundingClientRect().width, 1));
el.style.setProperty('--ov-fab-w', `${Math.round(w)}px`);
el.style.setProperty('--ov-fab-bottom', `${Math.round(floor() + gap)}px`);
}
function glide(from) {
if (anim) { try { anim.cancel(); } catch { /* */ } anim = null; }
const m = motion();
if (m === 'off' || !from || !from.width || typeof el.animate !== 'function') return;
const to = el.getBoundingClientRect();
const dx = (from.left + from.width / 2) - (to.left + to.width / 2), dy = (from.top + from.height / 2) - (to.top + to.height / 2);
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
// Over a long way (from far above or below the window) it rises from just past the edge instead.
const far = Math.abs(dy) > innerHeight * 0.6;
const start = far ? `translate(0, ${dy > 0 ? 40 : -40}px) scale(.96)` : `translate(${dx}px, ${dy}px)`;
anim = el.animate([{ transform: start, opacity: far ? 0 : 1 }, { transform: 'none', opacity: 1 }],
{ duration: m === 'reduced' ? 180 : (far ? 360 : 460), easing: 'cubic-bezier(.2, 1, .3, 1)' });
anim.onfinish = () => { anim = null; };
}
function set(next) {
if (next === floating || dead) return;
const from = el.getBoundingClientRect();
if (next) home.style.minHeight = `${Math.round(from.height)}px`;
floating = next;
el.dataset.ovFab = next ? 'floating' : 'home';
el.classList.toggle('ov-fab-floating', next);
if (next) place(); else { el.style.removeProperty('--ov-fab-w'); el.style.removeProperty('--ov-fab-bottom'); }
if (!next) home.style.minHeight = '';
glide(from);
try { el.dispatchEvent(new CustomEvent('ov:fab', { bubbles: true, detail: { floating: next } })); } catch { /* */ }
if (typeof o.onChange === 'function') { try { o.onChange(next); } catch { /* the page's */ } }
}
function check() {
raf = 0;
if (dead) return;
const r = home.getBoundingClientRect();
const below = r.top >= vh() - floor() - 8, above = r.bottom <= 8;
seen = !(below || above);
const side = o.side || 'both';
const want = may() && !seen && (side === 'both' || (side === 'below' && below) || (side === 'above' && above));
set(want);
place();
}
const later = () => { if (!raf) raf = requestAnimationFrame(check); };
const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(later, { threshold: [0, 0.5, 1] }) : null;
if (io) io.observe(home);
addEventListener('scroll', later, { passive: true });
addEventListener('resize', later);
if (root.visualViewport) root.visualViewport.addEventListener('resize', later);
const handle = {
el, home,
get floating() { return floating; },
check: later,
destroy() {
dead = true; if (io) io.disconnect();
removeEventListener('scroll', later); removeEventListener('resize', later);
if (root.visualViewport) root.visualViewport.removeEventListener('resize', later);
el.classList.remove('ov-fab-floating'); delete el.dataset.ovFab; el.__ovFab = null;
if (home.parentNode) { home.parentNode.insertBefore(el, home); home.remove(); }
fabs.delete(handle);
},
};
el.__ovFab = handle;
fabs.add(handle);
later();
return handle;
}
function injectStyles() {
if (document.getElementById('ov-float-styles')) return;
const st = document.createElement('style');
st.id = 'ov-float-styles';
st.textContent = CSS;
(document.head || document.documentElement).appendChild(st);
}
const defaultStore = makeDefaultStore();
root.OVFloat = {
attach,
action,
actions: () => [...fabs],
store: defaultStore,
newStore: makeDefaultStore,
layout: () => [...instances].map((i) => ({ key: i.key, geom: Object.assign({}, i.geom), el: i.el })),
_test: {
boxOf, settle: settleGeom, flick, tuckFor, overFrame, paint, normalize,
BASE, MAX_H, FLOAT_MAX_H, MIN_W, MAX_W, EDGE_PX, SNAP, PROJECT_MS, TAB_H, placeOf,
},
};
})(typeof window !== 'undefined' ? window : globalThis);