openvibers/openvibe.shared kit-web.js
kit-web.js
| 1 | /** |
| 2 | * openvibe-shared/kit-web.js — the OpenVibe Kit's behaviour in the page, on its own: what a server-rendered site needs |
| 3 | * (kit.js's builders already made its markup on the server). About 5 KB; it runs by itself when it loads and again for |
| 4 | * markup added later through OpenVibeKitWeb.enhance(container) (kit.js's OpenVibeKit.enhance() calls it too). |
| 5 | * |
| 6 | * copy buttons [data-ovk-copy="#id"] are shown where the clipboard works and copy the target's text |
| 7 | * menus details.ovk-more: one open at a time; a click elsewhere or Escape closes them |
| 8 | * the head html.ovk-scrolled once the page scrolls (the sticky head's shadow) |
| 9 | * the composer textarea[data-ovk-enter]: Enter sends (Shift+Enter, or a phone's own Enter, is a new line); it grows |
| 10 | * confirms form[data-ovk-confirm]: asked first (OpenVibeUI.confirm when the page has it) |
| 11 | * sliders input[type=range][data-ovk-unit] say their value |
| 12 | * sheets [data-ovk-sheet="#id"] opens dialog.ovk-sheet; the backdrop, Close and Escape close it |
| 13 | * tabs a sideways-scrolling tab strip keeps the open tab in view |
| 14 | * rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end |
| 15 | * share [data-ovk-share]: the network's Share sheet (ov-share.js, beside this file); without it, the system |
| 16 | * share sheet, else the link copied (the menu item says "Link copied") |
| 17 | * sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape |
| 18 | * and a page move close it (document-level, so boost-swapped pages need no wiring) |
| 19 | * card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never |
| 20 | * under reduced motion; the file loads only then) |
| 21 | * hold .ovk-btn[data-hold="1200"]: press and hold to confirm (the fill runs; letting go early cancels; Enter or |
| 22 | * Space held works the same); the click goes through only after the hold |
| 23 | * busy(button, work) shows the work on the button: busy (the words stay, the wait beside them, aria-busy), then a |
| 24 | * check for a moment, or a shake and red when it failed; work is a promise or a function returning one |
| 25 | * |
| 26 | * Idempotent and safe on a page that uses none of it. docs/kit.md describes the pieces. |
| 27 | */ |
| 28 | (function (root) { |
| 29 | ; |
| 30 | // ov-share.js beside this file (its query, so a release's copy goes with this one's), for [data-ovk-share]. |
| 31 | const SHARE_SRC = (() => { try { const me = document.currentScript; return me && me.src ? me.src.replace(/kit-web\.js(\?[^#]*)?.*$/, 'ov-share.js$1') : ''; } catch (_) { return ''; } })(); |
| 32 | const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>'; |
| 33 | |
| 34 | /** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */ |
| 35 | function setSidebar(sb, on) { |
| 36 | if (!sb) return; |
| 37 | sb.classList.toggle('is-open', on); |
| 38 | // The page behind a phone's drawer stays put (11-09, the owner: a swipe on the drawer "scrolls the actual page"). |
| 39 | document.documentElement.classList.toggle('ovk-drawer-open', !!on); |
| 40 | const wrap = sb.closest('.ovk-with-sidebar') || document; |
| 41 | const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]'); |
| 42 | if (scrim) scrim.hidden = !on; |
| 43 | Array.prototype.forEach.call(document.querySelectorAll('[data-ovk-sidebar-open]'), (b) => { if (!b.getAttribute('aria-controls') || b.getAttribute('aria-controls') === sb.id) b.setAttribute('aria-expanded', on ? 'true' : 'false'); }); |
| 44 | if (on) { const first = sb.querySelector('.ovk-sidebar-link.is-here, .ovk-sidebar-action, a, input'); if (first && first.focus) first.focus({ preventScroll: true }); } |
| 45 | } |
| 46 | |
| 47 | function enhance(rootEl) { |
| 48 | if (typeof document === 'undefined') return; |
| 49 | const scope = rootEl || document; |
| 50 | const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []); |
| 51 | if (scope.querySelector && scope.querySelector('dialog.ovk-sheet, details.ovk-more') && !root.OVSurfaces && !document.getElementById('ov-surfaces-loader')) { |
| 52 | const sc = document.createElement('script'); sc.id = 'ov-surfaces-loader'; sc.src = SHARE_SRC ? SHARE_SRC.replace('ov-share.js', 'surfaces.js') : '/shared/surfaces.js'; sc.async = true; document.head.appendChild(sc); |
| 53 | } |
| 54 | // Marks an element as wired. The mark has its own name (…Wired): `data-ovk-copy="#id"` is dataset.ovkCopy, so a mark |
| 55 | // under that same name was already set by the markup, and copy, sheets, confirms and titled shares never wired. |
| 56 | const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; }; |
| 57 | // Copy buttons: shown only where the clipboard works; the icon turns into a check for a moment. |
| 58 | all('[data-ovk-copy]').forEach((b) => { |
| 59 | if (!once(b, 'ovkCopy') || !navigator.clipboard) return; |
| 60 | b.hidden = false; |
| 61 | b.addEventListener('click', () => { |
| 62 | const t = document.querySelector(b.getAttribute('data-ovk-copy')); |
| 63 | if (!t) return; |
| 64 | navigator.clipboard.writeText(t.value != null && t.tagName !== 'PRE' ? t.value : t.textContent).then(() => { |
| 65 | const was = b.innerHTML; b.innerHTML = CHECK; b.classList.add('is-done'); |
| 66 | setTimeout(() => { b.innerHTML = was; b.classList.remove('is-done'); }, 1400); |
| 67 | }, () => {}); |
| 68 | }); |
| 69 | }); |
| 70 | // Menus: one open at a time; a click elsewhere or Escape closes them. |
| 71 | all('details.ovk-more').forEach((d) => { |
| 72 | if (!once(d, 'ovkMore')) return; |
| 73 | d.addEventListener('toggle', () => { |
| 74 | if (d.open) all('details.ovk-more[open]').forEach((o) => { if (o !== d) o.open = false; }); |
| 75 | // The layer (surfaces.js 4.235.0): the menu is anchored to its button, in the top layer, placed on the screen, |
| 76 | // flipped above it when there is no room below, everywhere (a phone's clipped live layout, a rail, a sheet). |
| 77 | if (root.OVSurfaces) { |
| 78 | if (!d.__ovSurface) d.__ovSurface = root.OVSurfaces.popover({ el: d, trigger: d.querySelector('summary'), pop: d.querySelector('.ovk-pop'), close: () => { d.open = false; } }); |
| 79 | if (d.open) d.__ovSurface.open(); else d.__ovSurface.close(); |
| 80 | if (typeof root.OVSurfaces.anchor === 'function') return; |
| 81 | } |
| 82 | // Until surfaces.js has loaded: inside a rail (a sideways scroller clips anything that hangs out of it, and its edge fade is a mask that hides |
| 83 | // even a fixed menu outside its box: Community's paste cards, 11-11) the menu opens in the top layer, placed on |
| 84 | // the screen. Without the Popover API it stays fixed, as before. |
| 85 | const pop = d.querySelector('.ovk-pop'); |
| 86 | if (!pop || !d.closest('.ovk-rail-track, [data-ovk-scroll]')) return; |
| 87 | const topLayer = typeof pop.showPopover === 'function'; |
| 88 | pop.classList.toggle('is-fixed', d.open); |
| 89 | if (!d.open) { |
| 90 | if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* already closed */ } pop.removeAttribute('popover'); } |
| 91 | pop.style.left = pop.style.top = ''; |
| 92 | return; |
| 93 | } |
| 94 | if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } } |
| 95 | const r = d.getBoundingClientRect(); |
| 96 | const w = pop.offsetWidth, h = pop.offsetHeight; |
| 97 | pop.style.left = `${Math.max(8, Math.min(r.right - w, innerWidth - w - 8))}px`; |
| 98 | pop.style.top = `${r.bottom + 4 + h > innerHeight - 8 ? Math.max(8, r.top - h - 4) : r.bottom + 4}px`; |
| 99 | const shut = () => { d.open = false; }; |
| 100 | const track = d.closest('.ovk-rail-track'); |
| 101 | track.addEventListener('scroll', shut, { once: true, passive: true }); |
| 102 | window.addEventListener('scroll', shut, { once: true, passive: true }); |
| 103 | }); |
| 104 | }); |
| 105 | if (!document.documentElement.dataset.ovkDoc) { |
| 106 | document.documentElement.dataset.ovkDoc = '1'; |
| 107 | document.addEventListener('pointerdown', (e) => { |
| 108 | Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { if (!d.contains(e.target)) d.open = false; }); |
| 109 | }, true); |
| 110 | document.addEventListener('keydown', (e) => { |
| 111 | if (e.key !== 'Escape') return; |
| 112 | Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); }); |
| 113 | Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)); |
| 114 | }); |
| 115 | // The left sidebar on a phone (kit.sidebar): its button opens it; the scrim, its close button, Escape and a page |
| 116 | // move close it. Handled on the document, so a page boost swapped in works without being wired again. |
| 117 | document.addEventListener('click', (e) => { |
| 118 | const t = e.target && e.target.closest ? e.target : null; |
| 119 | if (!t) return; |
| 120 | const open = t.closest('[data-ovk-sidebar-open]'); |
| 121 | if (open) { setSidebar(document.getElementById(open.getAttribute('aria-controls') || '') || document.querySelector('[data-ovk-sidebar]'), true); return; } |
| 122 | if (t.closest('[data-ovk-sidebar-close], [data-ovk-sidebar-scrim]')) Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)); |
| 123 | }); |
| 124 | document.addEventListener('ov:boost:before', () => Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false))); |
| 125 | // The head casts a shadow once the page has scrolled under it. |
| 126 | let ticking = false; |
| 127 | const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); }; |
| 128 | window.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(mark); } }, { passive: true }); |
| 129 | mark(); |
| 130 | } |
| 131 | // The composer: Enter sends (Shift+Enter, or a phone's own Enter key, is a new line); the box grows with the text. |
| 132 | const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches; |
| 133 | all('textarea[data-ovk-enter]').forEach((ta) => { |
| 134 | if (!once(ta, 'ovkEnter')) return; |
| 135 | const grow = () => { if (CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; ta.style.height = 'auto'; ta.style.height = `${Math.min(ta.scrollHeight, Math.round(innerHeight * 0.4))}px`; }; |
| 136 | ta.addEventListener('input', grow); |
| 137 | ta.addEventListener('keydown', (e) => { |
| 138 | if (e.key !== 'Enter' || e.shiftKey || e.isComposing || coarse) return; |
| 139 | e.preventDefault(); |
| 140 | const f = ta.form; if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); } |
| 141 | }); |
| 142 | }); |
| 143 | // A form that asks first (delete, leave): the network's confirm dialog when the page has it. |
| 144 | all('form[data-ovk-confirm]').forEach((f) => { |
| 145 | if (!once(f, 'ovkConfirm')) return; |
| 146 | f.addEventListener('submit', (e) => { |
| 147 | if (f.dataset.ovkConfirmed) { delete f.dataset.ovkConfirmed; return; } |
| 148 | e.preventDefault(); |
| 149 | const msg = f.getAttribute('data-ovk-confirm'); |
| 150 | const ask = window.OpenVibeUI && window.OpenVibeUI.confirm ? window.OpenVibeUI.confirm({ title: msg, danger: true }) : Promise.resolve(window.confirm(msg)); |
| 151 | ask.then((ok) => { if (ok) { f.dataset.ovkConfirmed = '1'; if (f.requestSubmit) f.requestSubmit(e.submitter || undefined); else f.submit(); } }); |
| 152 | }); |
| 153 | }); |
| 154 | // Sliders say their value. |
| 155 | all('input[type=range][data-ovk-unit]').forEach((r) => { |
| 156 | if (!once(r, 'ovkRange')) return; |
| 157 | const u = r.getAttribute('data-ovk-unit'); |
| 158 | r.addEventListener('input', () => r.setAttribute('aria-valuetext', `${r.value}${u === '%' ? ' percent' : ` ${u}`}`)); |
| 159 | }); |
| 160 | // Sheets: a button opens its dialog; the backdrop, Close and Escape close it. |
| 161 | all('[data-ovk-sheet]').forEach((b) => { |
| 162 | if (!once(b, 'ovkSheet')) return; |
| 163 | b.addEventListener('click', () => { |
| 164 | const d = document.querySelector(b.getAttribute('data-ovk-sheet')); |
| 165 | if (d && d.showModal && !d.open) d.showModal(); |
| 166 | }); |
| 167 | }); |
| 168 | all('dialog.ovk-sheet').forEach((d) => { |
| 169 | if (!once(d, 'ovkDialog')) return; |
| 170 | d.addEventListener('click', (e) => { if (e.target === d || (e.target.closest && e.target.closest('[data-ovk-close]'))) d.close(); }); |
| 171 | }); |
| 172 | // Tabs that scroll sideways keep the open one in view. |
| 173 | // Only the strip moves, sideways: scrollIntoView would also scroll the page to a strip below the fold. |
| 174 | all('.ovk-tabs [aria-current="page"]').forEach((a) => { |
| 175 | if (!once(a, 'ovkTab')) return; |
| 176 | const strip = a.closest('.ovk-tabs'); |
| 177 | if (!strip || strip.scrollWidth <= strip.clientWidth) return; |
| 178 | const ar = a.getBoundingClientRect(), sr = strip.getBoundingClientRect(); |
| 179 | strip.scrollLeft += (ar.left + ar.width / 2) - (sr.left + sr.width / 2); |
| 180 | }); |
| 181 | // Rails: ‹ › move a page of cards where there is a pointer to click them; each hides at its end. |
| 182 | const fine = typeof matchMedia === 'function' && matchMedia('(hover: hover) and (pointer: fine)').matches; |
| 183 | // Motion (4.183.0): only Off keeps a strip still. Calm glides, nudges half as far and drifts at half the speed |
| 184 | // (<html data-ov-motion>, theme-loader.js; a page without the loader asks the device). |
| 185 | const motionAttr = () => document.documentElement.getAttribute('data-ov-motion'); |
| 186 | const still = () => motionAttr() === 'off'; |
| 187 | const gentle = () => { const m = motionAttr(); return m === 'reduced' || (!m && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ? 0.5 : 1; }; |
| 188 | all('.ovk-rail').forEach((r) => { |
| 189 | if (!once(r, 'ovkRail')) return; |
| 190 | const track = r.querySelector('.ovk-rail-track'); |
| 191 | const prev = r.querySelector('.ovk-rail-prev'); |
| 192 | const next = r.querySelector('.ovk-rail-next'); |
| 193 | if (!track || !prev || !next || !fine) return; |
| 194 | const update = () => { |
| 195 | const max = track.scrollWidth - track.clientWidth; |
| 196 | prev.hidden = track.scrollLeft <= 4; |
| 197 | next.hidden = track.scrollLeft >= max - 4; |
| 198 | }; |
| 199 | const go = (dir) => track.scrollBy({ left: dir * Math.max(track.clientWidth * 0.85, 200), behavior: still() ? 'auto' : 'smooth' }); |
| 200 | prev.addEventListener('click', () => go(-1)); |
| 201 | next.addEventListener('click', () => go(1)); |
| 202 | track.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true }); |
| 203 | if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(track); else window.addEventListener('resize', update); |
| 204 | update(); |
| 205 | }); |
| 206 | // Every sideways strip says it scrolls (11-09, the owner: "it's not very apparent you can horizontal scroll… a little |
| 207 | // bounce… or auto scroll until you tap"): a kit rail's track, or any element marked data-ovk-scroll. Its edges fade |
| 208 | // where more is hidden (is-more-start / is-more-end); on a touch screen it nudges once when it first comes into view; |
| 209 | // data-ovk-scroll="drift" drifts slowly while in view (back and forth) and stops for good at the first touch, |
| 210 | // wheel or keyboard focus. Reduced motion: fades only. |
| 211 | all('.ovk-rail-track, [data-ovk-scroll]').forEach((track) => { |
| 212 | if (!once(track, 'ovkScroll')) return; |
| 213 | // A thin position bar under the strip (a thumb as wide as the share in view), shown while there is more. |
| 214 | let bar = null, thumb = null; |
| 215 | if (track.getAttribute('data-ovk-scroll-bar') !== 'off') { |
| 216 | bar = document.createElement('span'); bar.className = 'ovk-scroll-bar'; bar.setAttribute('aria-hidden', 'true'); bar.hidden = true; |
| 217 | thumb = document.createElement('i'); bar.appendChild(thumb); |
| 218 | track.insertAdjacentElement('afterend', bar); |
| 219 | } |
| 220 | const edges = () => { |
| 221 | const max = track.scrollWidth - track.clientWidth; |
| 222 | track.classList.toggle('is-more-start', max > 4 && track.scrollLeft > 4); |
| 223 | track.classList.toggle('is-more-end', max > 4 && track.scrollLeft < max - 4); |
| 224 | if (bar) { |
| 225 | bar.hidden = max <= 4; |
| 226 | if (max > 4) { const share = track.clientWidth / track.scrollWidth; thumb.style.width = `${Math.max(14, share * 100)}%`; thumb.style.transform = `translateX(${(track.scrollLeft / max) * ((1 / Math.max(share, 0.14)) - 1) * 100}%)`; } |
| 227 | } |
| 228 | }; |
| 229 | track.addEventListener('scroll', () => requestAnimationFrame(edges), { passive: true }); |
| 230 | if (typeof ResizeObserver === 'function') new ResizeObserver(edges).observe(track); |
| 231 | edges(); |
| 232 | if (still() || typeof IntersectionObserver !== 'function') return; |
| 233 | const drift = track.getAttribute('data-ovk-scroll') === 'drift'; |
| 234 | let touched = false, inView = false, raf = 0, dir = 1, last = 0, pos = -1; |
| 235 | const stop = () => { touched = true; cancelAnimationFrame(raf); raf = 0; }; |
| 236 | ['pointerdown', 'wheel', 'focusin', 'touchstart'].forEach((ev) => track.addEventListener(ev, stop, { passive: true, once: true })); |
| 237 | const step = (t) => { |
| 238 | raf = 0; |
| 239 | if (touched || !inView || document.hidden) return; |
| 240 | const dt = last ? Math.min(64, t - last) : 16; last = t; |
| 241 | const max = track.scrollWidth - track.clientWidth; |
| 242 | if (max <= 4) return; |
| 243 | // A float of its own: a browser rounds scrollLeft, and under a pixel a frame would never move. |
| 244 | if (pos < 0 || Math.abs(pos - track.scrollLeft) > 2) pos = track.scrollLeft; |
| 245 | pos = Math.max(0, Math.min(max, pos + dir * dt * 0.028 * gentle())); |
| 246 | track.scrollLeft = pos; |
| 247 | if (pos >= max - 1) dir = -1; else if (pos <= 0) dir = 1; |
| 248 | raf = requestAnimationFrame(step); |
| 249 | }; |
| 250 | let nudged = false; |
| 251 | new IntersectionObserver((entries) => { |
| 252 | for (const e of entries) { |
| 253 | inView = e.isIntersecting && e.intersectionRatio >= 0.6; |
| 254 | if (!inView || touched) continue; |
| 255 | const max = track.scrollWidth - track.clientWidth; |
| 256 | if (max <= 4) continue; |
| 257 | if (drift) { last = 0; if (!raf) raf = requestAnimationFrame(step); continue; } |
| 258 | // One nudge, phones only (a computer has the ‹ › buttons): 48 px along and back (24 under Calm). |
| 259 | if (!nudged && !fine && track.scrollLeft < 4) { |
| 260 | nudged = true; |
| 261 | setTimeout(() => { if (touched) return; const far = 48 * gentle(); track.scrollBy({ left: far, behavior: 'smooth' }); setTimeout(() => { if (!touched) track.scrollBy({ left: -far, behavior: 'smooth' }); }, 420); }, 350); |
| 262 | } |
| 263 | } |
| 264 | }, { threshold: [0, 0.6] }).observe(track); |
| 265 | }); |
| 266 | // Share: the system sheet where there is one, else the link copied, said in the item itself. |
| 267 | all('[data-ovk-share]').forEach((a) => { |
| 268 | if (!once(a, 'ovkShare')) return; |
| 269 | a.addEventListener('click', (e) => { |
| 270 | e.preventDefault(); |
| 271 | const link = new URL(a.getAttribute('href'), location.href).href; |
| 272 | const title = a.getAttribute('data-ovk-share') || document.title; |
| 273 | const done = () => { const d = a.closest('details'); if (d) d.open = false; }; |
| 274 | // The network's one Share sheet (ov-share.js, 4.114.0) when it can load; the device's own sheet, or the |
| 275 | // link copied, when it cannot. |
| 276 | const sheet = () => { done(); root.OVShare.open(Object.assign(root.OVShare.fromButton(a), { url: link, title })); }; |
| 277 | if (root.OVShare) { sheet(); return; } |
| 278 | if (SHARE_SRC && !a.dataset.ovkShareTried) { |
| 279 | a.dataset.ovkShareTried = '1'; |
| 280 | if (!document.querySelector('link[href*="ov-share.css"]')) { const ln = document.createElement('link'); ln.rel = 'stylesheet'; ln.href = SHARE_SRC.replace('ov-share.js', 'ov-share.css'); document.head.appendChild(ln); } |
| 281 | const sc = document.createElement('script'); sc.src = SHARE_SRC; sc.async = true; |
| 282 | sc.onload = () => { if (root.OVShare) sheet(); else a.click(); }; |
| 283 | sc.onerror = () => a.click(); |
| 284 | document.head.appendChild(sc); |
| 285 | return; |
| 286 | } |
| 287 | if (navigator.share) { navigator.share({ title, url: link }).then(done, () => {}); return; } |
| 288 | if (!navigator.clipboard) { location.href = link; return; } |
| 289 | navigator.clipboard.writeText(link).then(() => { |
| 290 | const span = a.querySelector('span'); |
| 291 | const was = span ? span.textContent : ''; |
| 292 | if (span) span.textContent = 'Link copied'; |
| 293 | setTimeout(() => { if (span) span.textContent = was; done(); }, 1200); |
| 294 | }, () => {}); |
| 295 | }); |
| 296 | }); |
| 297 | // Card previews: a muted loop while hovered or focused, on computers only, never under reduced motion. |
| 298 | if (fine) all('.ovk-card').forEach((c) => { |
| 299 | const v = c.querySelector('video.ovk-card-video[data-src]'); |
| 300 | if (!v || !once(c, 'ovkPeek')) return; |
| 301 | const on = () => { if (still()) return; if (!v.src) v.src = v.getAttribute('data-src'); c.classList.add('is-playing'); const p = v.play(); if (p && p.catch) p.catch(() => {}); }; |
| 302 | const off = () => { c.classList.remove('is-playing'); try { v.pause(); } catch (e) { /* not started */ } }; |
| 303 | c.addEventListener('mouseenter', on); c.addEventListener('mouseleave', off); |
| 304 | c.addEventListener('focusin', on); c.addEventListener('focusout', off); |
| 305 | }); |
| 306 | } |
| 307 | |
| 308 | |
| 309 | /** The work a button started, shown on the button. Resolves with the work's value; rethrows its error. */ |
| 310 | async function busy(btn, work) { |
| 311 | if (!btn) return typeof work === 'function' ? work() : work; |
| 312 | const wait = document.createElement('span'); |
| 313 | wait.className = 'ovk-btn-wait'; wait.setAttribute('aria-hidden', 'true'); wait.innerHTML = '<i></i><i></i><i></i>'; |
| 314 | const wasDisabled = btn.disabled; |
| 315 | btn.dataset.state = 'busy'; btn.setAttribute('aria-busy', 'true'); btn.appendChild(wait); |
| 316 | if ('disabled' in btn) btn.disabled = true; |
| 317 | const settle = (state) => { |
| 318 | wait.remove(); btn.removeAttribute('aria-busy'); |
| 319 | if ('disabled' in btn) btn.disabled = wasDisabled; |
| 320 | btn.dataset.state = state; |
| 321 | setTimeout(() => { if (btn.dataset.state === state) delete btn.dataset.state; }, state === 'done' ? 1200 : 1600); |
| 322 | }; |
| 323 | try { |
| 324 | const out = await (typeof work === 'function' ? work() : work); |
| 325 | settle('done'); |
| 326 | return out; |
| 327 | } catch (e) { settle('error'); throw e; } |
| 328 | } |
| 329 | |
| 330 | // Hold to confirm: document-level, so buttons added later need no wiring. |
| 331 | function bindHold() { |
| 332 | if (typeof document === 'undefined' || root.__ovkHoldBound) return; |
| 333 | root.__ovkHoldBound = true; |
| 334 | let held = null; |
| 335 | const stop = (fire) => { |
| 336 | if (!held) return; |
| 337 | const { btn, raf, ok } = held; held = null; |
| 338 | cancelAnimationFrame(raf); btn.classList.remove('is-holding'); btn.style.removeProperty('--ovk-hold'); |
| 339 | if (fire && ok) { btn.dataset.ovkHeld = '1'; btn.click(); } |
| 340 | }; |
| 341 | const start = (btn) => { |
| 342 | const ms = Math.max(400, Math.min(4000, Number(btn.getAttribute('data-hold')) || 1200)); |
| 343 | const t0 = performance.now(); |
| 344 | held = { btn, raf: 0, ok: false }; |
| 345 | btn.classList.add('is-holding'); |
| 346 | const tick = (t) => { |
| 347 | if (!held || held.btn !== btn) return; |
| 348 | const f = Math.min(1, (t - t0) / ms); |
| 349 | btn.style.setProperty('--ovk-hold', `${Math.round(f * 100)}%`); |
| 350 | if (f >= 1) { held.ok = true; stop(true); return; } |
| 351 | held.raf = requestAnimationFrame(tick); |
| 352 | }; |
| 353 | held.raf = requestAnimationFrame(tick); |
| 354 | }; |
| 355 | document.addEventListener('pointerdown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !b.disabled && e.button === 0) start(b); }); |
| 356 | document.addEventListener('pointerup', () => stop(false)); |
| 357 | document.addEventListener('pointercancel', () => stop(false)); |
| 358 | document.addEventListener('keydown', (e) => { const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); if (b && !e.repeat && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); start(b); } }); |
| 359 | document.addEventListener('keyup', (e) => { if (e.key === 'Enter' || e.key === ' ') stop(false); }); |
| 360 | // A plain click on a hold button does nothing until the hold has run. |
| 361 | document.addEventListener('click', (e) => { |
| 362 | const b = e.target.closest && e.target.closest('.ovk-btn[data-hold]'); |
| 363 | if (!b) return; |
| 364 | if (b.dataset.ovkHeld === '1') { delete b.dataset.ovkHeld; return; } |
| 365 | e.preventDefault(); e.stopImmediatePropagation(); |
| 366 | }, true); |
| 367 | } |
| 368 | bindHold(); |
| 369 | |
| 370 | const api = { enhance, busy }; |
| 371 | root.OpenVibeKitWeb = api; |
| 372 | if (typeof module === 'object' && module.exports) { module.exports = api; return; } |
| 373 | if (typeof document !== 'undefined') { |
| 374 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document)); |
| 375 | else enhance(document); |
| 376 | // A boosted page swap (boost.js) brings new markup without a load: enhance it too (enhance is idempotent). |
| 377 | document.addEventListener('ov:boost:load', () => enhance(document)); |
| 378 | } |
| 379 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 380 |