openvibers/openvibe.shared ov-float.js
ov-float.js
| 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 | ; |
| 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; } } |
| 73 | html[data-ov-motion="off"] .ov-float { transition: none !important; } |
| 74 | html[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); } |
| 83 | html[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 |