openvibers/openvibe.shared ov-window.js
ov-window.js
| 1 | /** |
| 2 | * ov-window.js (openvibe-shared 4.202.0): one window for every little interface. Asks 304 and 318, the owner: "a system |
| 3 | * that's used for all the windows [in] harmony … people can make their own or … custom … everything … feels connected". |
| 4 | * One frame (a head with the title and ×, a body, a foot) in three kinds, the same motion, the same keys: |
| 5 | * dialog: centred on a computer; a sheet from the bottom on a phone |
| 6 | * sheet: a sheet from the bottom on every screen |
| 7 | * panel: floats beside what opened it on a computer, draggable by its head (where you leave it is kept per id); |
| 8 | * a sheet on a phone |
| 9 | * It grows out of the button that opened it (the navbar's pop motion), closes with ×, Escape, a press outside (a dialog |
| 10 | * or a sheet; a panel stays until closed, and the page under it stays usable) or a swipe down on a phone, keeps the focus inside while open, and gives it back to that button. Windows stack: Escape closes the |
| 11 | * top one. The look follows the person's theme and their window style, <html data-ov-window="glass|solid|outline"> |
| 12 | * (OVWindow.setStyle), on every window at once. |
| 13 | * |
| 14 | * const w = OVWindow.open({ id, title, body: node | text, foot: node, kind: 'dialog', size: 'md', from: button, |
| 15 | * label, onClose, bare, width }); bare: the content draws its own head (the title is still |
| 16 | * read); width: px, for content that wants its own |
| 17 | * w.el, w.body, w.foot, w.setTitle(text), w.close() |
| 18 | * A panel can be resizable (4.204.0, the owner: a robot's controls "can be popped out … and resized"): { resizable: true, |
| 19 | * minWidth, minHeight, onResize({ width, height }) } adds a grip at its corner that a mouse, a finger or a pen drags and |
| 20 | * the arrow keys move (Shift for bigger steps, Enter for the first size); the size is kept with its place per id. |
| 21 | * A site's content goes in as nodes (or text, set as text): the window never parses markup it is given. |
| 22 | */ |
| 23 | (function (root) { |
| 24 | ; |
| 25 | if (typeof document === 'undefined' || root.OVWindow) return; |
| 26 | const HERE = document.currentScript && document.currentScript.src; |
| 27 | const WINDOW_REV = { 'ov-float.js': '1e087a0a11fd' }; |
| 28 | |
| 29 | const STYLES = ['glass', 'solid', 'outline']; |
| 30 | const CSS = `.ovw-scrim { position: fixed; inset: 0; z-index: var(--ovw-z, 10040); background: rgba(3, 7, 15, .5); animation: ovw-fade .2s ease-out both; } |
| 31 | .ovw-scrim.is-clear { background: transparent; } |
| 32 | .ovw { position: fixed; z-index: var(--ovw-z, 10040); box-sizing: border-box; display: flex; flex-direction: column; max-height: min(86vh, 760px); color: var(--text-primary, #e6edf7); |
| 33 | font: 500 15px/1.45 var(--font, Inter, system-ui, -apple-system, 'Segoe UI', sans-serif); border-radius: 20px; overflow: hidden; |
| 34 | background: var(--ovw-bg); border: 1px solid var(--ovw-line); box-shadow: var(--ovw-shadow); -webkit-backdrop-filter: var(--ovw-blur); backdrop-filter: var(--ovw-blur); |
| 35 | transform-origin: var(--ov-pop-x, 50%) var(--ov-pop-y, 0); animation: ovw-in .28s cubic-bezier(.2, 1.2, .3, 1) both; } |
| 36 | :root { --ovw-bg: color-mix(in srgb, var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))) 92%, transparent); --ovw-line: var(--border, rgba(255, 255, 255, .12)); |
| 37 | --ovw-shadow: 0 28px 70px rgba(0, 0, 0, .45), 0 2px 8px rgba(0, 0, 0, .2); --ovw-blur: blur(16px) saturate(1.3); } |
| 38 | html[data-ov-window="solid"] { --ovw-bg: var(--bg-elevated, var(--bg-secondary, var(--bg-primary, #111826))); --ovw-blur: none; } |
| 39 | html[data-ov-window="outline"] { --ovw-bg: var(--bg-primary, #0b1220); --ovw-blur: none; --ovw-line: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); --ovw-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 20%, transparent), 0 18px 40px rgba(0, 0, 0, .35); } |
| 40 | .ovw.is-out { animation: ovw-out .16s ease-in both; } |
| 41 | .ovw-h { flex: none; display: flex; align-items: center; gap: 10px; min-height: 56px; padding: 6px 8px 6px 18px; border-bottom: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); } |
| 42 | .ovw-t { flex: 1; min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 16.5px; font-weight: 700; letter-spacing: -.01em; } |
| 43 | .ovw-x { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 12px; background: none; color: var(--text-secondary, #a8b3c4); cursor: pointer; } |
| 44 | .ovw-x:hover, .ovw-x:focus-visible { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); color: var(--text-primary, #e6edf7); } |
| 45 | .ovw-x:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; } |
| 46 | .ovw-b { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px 18px 18px; } |
| 47 | .ovw-f { flex: none; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 18px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid color-mix(in srgb, var(--ovw-line) 70%, transparent); } |
| 48 | .ovw-f:empty { display: none; } |
| 49 | .ovw.is-dialog { left: 50%; top: 50%; width: min(var(--ovw-w), calc(100vw - 32px)); transform: translate(-50%, -50%); animation-name: ovw-in-c; } |
| 50 | .ovw.is-dialog.is-out { animation-name: ovw-out-c; } |
| 51 | .ovw.is-panel { width: min(var(--ovw-w), calc(100vw - 24px)); } |
| 52 | .ovw.is-panel .ovw-h { cursor: grab; touch-action: none; } |
| 53 | .ovw.is-panel.is-dragging { animation: none; transition: none; box-shadow: 0 34px 90px rgba(0, 0, 0, .55); } |
| 54 | .ovw.is-panel.is-dragging .ovw-h { cursor: grabbing; } |
| 55 | .ovw.is-sheet { left: 0; right: 0; bottom: 0; width: auto; max-height: 92vh; margin: 0 auto; max-width: 720px; border-radius: 22px 22px 0 0; border-bottom: 0; animation: ovw-up .32s cubic-bezier(.2, 1, .3, 1) both; } |
| 56 | .ovw.is-sheet.is-out { animation: ovw-down .2s ease-in both; } |
| 57 | .ovw.is-sheet .ovw-h { position: relative; padding-top: 16px; touch-action: none; } |
| 58 | .ovw.is-sheet .ovw-h::before { content: ""; position: absolute; left: 50%; top: 6px; width: 38px; height: 4px; margin-left: -19px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 28%, transparent); } |
| 59 | .ovw.is-sheet.is-dragging { animation: none; } |
| 60 | .ovw.is-resizable { max-height: none; } |
| 61 | .ovw-grip { position: absolute; right: 2px; bottom: 2px; z-index: 2; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 8px; background: none; cursor: nwse-resize; touch-action: none; color: var(--text-secondary, #a8b3c4); } |
| 62 | .ovw-grip::before { content: ""; position: absolute; right: 6px; bottom: 6px; width: 10px; height: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; border-radius: 0 0 3px 0; opacity: .55; } |
| 63 | .ovw-grip:hover::before, .ovw-grip:focus-visible::before, .ovw.is-resizing .ovw-grip::before { opacity: 1; color: var(--accent, #3b82f6); } |
| 64 | .ovw-grip:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; } |
| 65 | .ovw.is-resizing { animation: none; transition: none; user-select: none; } |
| 66 | .ovw { --ovw-w: 520px; } |
| 67 | .ovw.is-sm { --ovw-w: 380px; } |
| 68 | .ovw.is-lg { --ovw-w: 760px; } |
| 69 | .ovw.is-full { --ovw-w: 1100px; } |
| 70 | @keyframes ovw-in { from { opacity: 0; transform: translateY(calc(-6px * var(--ov-move, 1))) scale(.94); } } |
| 71 | @keyframes ovw-out { to { opacity: 0; transform: scale(.96); } } |
| 72 | @keyframes ovw-in-c { from { opacity: 0; transform: translate(-50%, calc(-50% + 10px * var(--ov-move, 1))) scale(.95); } } |
| 73 | @keyframes ovw-out-c { to { opacity: 0; transform: translate(-50%, -50%) scale(.97); } } |
| 74 | @keyframes ovw-up { from { transform: translateY(100%); } } |
| 75 | @keyframes ovw-down { to { transform: translateY(100%); } } |
| 76 | @keyframes ovw-fade { from { opacity: 0; } } |
| 77 | html[data-ov-motion=off] .ovw, html[data-ov-motion=off] .ovw-scrim { animation: none !important; } |
| 78 | html.ovw-locked { overflow: hidden; } |
| 79 | /* bare: the content carries its own head (a card's banner); the title is for screen readers, × floats on top, and |
| 80 | the top strip is still the handle (drag a panel, swipe a sheet). */ |
| 81 | .ovw.is-bare .ovw-h { position: absolute; left: 0; right: 0; top: 0; z-index: 3; min-height: 52px; padding: 6px; justify-content: flex-end; border: 0; background: none; } |
| 82 | .ovw.is-bare .ovw-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } |
| 83 | .ovw.is-bare .ovw-x { width: 36px; height: 36px; border-radius: 50%; color: #fff; background: rgba(4, 8, 16, .42); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } |
| 84 | .ovw.is-bare .ovw-x:hover, .ovw.is-bare .ovw-x:focus-visible { background: rgba(4, 8, 16, .62); color: #fff; } |
| 85 | .ovw.is-bare .ovw-b { padding: 0; } |
| 86 | .ovw.is-bare.is-sheet .ovw-h { padding-top: 14px; }`; |
| 87 | |
| 88 | const stack = []; |
| 89 | let uid = 0; |
| 90 | function styles() { if (document.getElementById('ovw-styles')) return; const st = document.createElement('style'); st.id = 'ovw-styles'; st.textContent = CSS; document.head.appendChild(st); } |
| 91 | |
| 92 | // The panel's move, tuck and place are OVFloat's (ov-float.js, loaded from beside this file the first time a panel |
| 93 | // opens). A page that already loaded it uses that. |
| 94 | let floatLoading = null; |
| 95 | const floatWait = []; |
| 96 | function withFloat(cb) { |
| 97 | if (root.OVFloat) { cb(root.OVFloat); return; } |
| 98 | if (!HERE || floatLoading === false) return; |
| 99 | if (floatLoading) { floatWait.push(cb); return; } |
| 100 | floatWait.push(cb); |
| 101 | const s = document.createElement('script'); |
| 102 | const rev = WINDOW_REV['ov-float.js']; |
| 103 | s.src = HERE.replace(/ov-window\.js(?:\?.*)?$/, 'ov-float.js') + (rev ? `?v=${rev}` : ''); |
| 104 | s.onload = () => { |
| 105 | floatLoading = null; |
| 106 | for (const fn of floatWait.splice(0)) { try { fn(root.OVFloat); } catch (e) { console.error(e); } } |
| 107 | }; |
| 108 | s.onerror = () => { floatLoading = false; floatWait.length = 0; }; |
| 109 | floatLoading = s; |
| 110 | (document.head || document.documentElement).appendChild(s); |
| 111 | } |
| 112 | const phone = () => !!(root.matchMedia && root.matchMedia('(max-width: 640px)').matches); |
| 113 | const focusables = (el) => [...el.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter((x) => x.offsetParent !== null || x === document.activeElement); |
| 114 | const place = { get(id) { try { return JSON.parse(root.localStorage.getItem(`ov:window:${id}`) || 'null'); } catch { return null; } }, set(id, v) { try { root.localStorage.setItem(`ov:window:${id}`, JSON.stringify(v)); } catch { /* private mode */ } } }; |
| 115 | function put(parent, v) { if (v == null) return; if (typeof v === 'string') parent.textContent = v; else parent.appendChild(v); } |
| 116 | |
| 117 | function open(o = {}) { |
| 118 | styles(); |
| 119 | const kind0 = ['dialog', 'sheet', 'panel'].includes(o.kind) ? o.kind : 'dialog'; |
| 120 | const kind = phone() ? 'sheet' : kind0; |
| 121 | const id = o.id || `ovw-${++uid}`; |
| 122 | const opener = o.from || document.activeElement; |
| 123 | const modal = o.modal !== false && kind !== 'panel'; |
| 124 | const z = 10040 + stack.length * 2; |
| 125 | const scrim = document.createElement('div'); |
| 126 | scrim.className = `ovw-scrim${modal ? '' : ' is-clear'}`; |
| 127 | scrim.style.setProperty('--ovw-z', String(z)); |
| 128 | const el = document.createElement('section'); |
| 129 | el.className = `ovw is-${kind} is-${['sm', 'md', 'lg', 'full'].includes(o.size) ? o.size : 'md'}${o.bare ? ' is-bare' : ''}`; |
| 130 | el.style.setProperty('--ovw-z', String(z + 1)); |
| 131 | if (Number(o.width) > 0) el.style.setProperty('--ovw-w', `${Math.round(Number(o.width))}px`); |
| 132 | el.setAttribute('role', 'dialog'); |
| 133 | if (modal) el.setAttribute('aria-modal', 'true'); |
| 134 | el.dataset.ovWindow = id; |
| 135 | const head = document.createElement('header'); head.className = 'ovw-h'; |
| 136 | const title = document.createElement('h2'); title.className = 'ovw-t'; title.id = `${id}-t`; title.textContent = o.title || ''; |
| 137 | const x = document.createElement('button'); x.type = 'button'; x.className = 'ovw-x'; x.setAttribute('aria-label', 'Close'); |
| 138 | x.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>'; |
| 139 | head.append(title, x); |
| 140 | const body = document.createElement('div'); body.className = 'ovw-b'; put(body, o.body); |
| 141 | const foot = document.createElement('footer'); foot.className = 'ovw-f'; put(foot, o.foot); |
| 142 | el.append(head, body, foot); |
| 143 | if (o.title) el.setAttribute('aria-labelledby', title.id); else el.setAttribute('aria-label', o.label || 'Window'); |
| 144 | if (modal) document.body.append(scrim, el); else document.body.append(el); |
| 145 | if (modal) document.documentElement.classList.add('ovw-locked'); |
| 146 | |
| 147 | // Where it opens: a panel beside its opener (or where it was left), a dialog centred, a sheet at the bottom. |
| 148 | let panelLeft = 0, panelTop = 0; |
| 149 | if (kind === 'panel') { |
| 150 | const saved = o.id ? place.get(o.id) : null; |
| 151 | const cs = document.documentElement; |
| 152 | const r = el.getBoundingClientRect(), vw = cs.clientWidth || root.innerWidth, vh = cs.clientHeight || root.innerHeight; |
| 153 | let left, top; |
| 154 | if (saved) { left = saved.x; top = saved.y; } |
| 155 | else if (opener && opener.getBoundingClientRect) { const f = opener.getBoundingClientRect(); left = f.left; top = f.bottom + 8; if (top + r.height > vh - 12) top = Math.max(12, f.top - r.height - 8); } |
| 156 | else { left = vw - r.width - 24; top = 72; } |
| 157 | left = Math.max(8, Math.min(left, vw - r.width - 8)); |
| 158 | top = Math.max(8, Math.min(top, vh - 60)); |
| 159 | panelLeft = left; panelTop = top; |
| 160 | el.style.left = `${left}px`; |
| 161 | el.style.top = `${top}px`; |
| 162 | } |
| 163 | if (opener && opener.getBoundingClientRect && kind !== 'sheet') { |
| 164 | const r = el.getBoundingClientRect(), f = opener.getBoundingClientRect(); |
| 165 | el.style.setProperty('--ov-pop-x', `${Math.round(f.left + f.width / 2 - r.left)}px`); |
| 166 | el.style.setProperty('--ov-pop-y', `${Math.round(f.top + f.height / 2 - r.top)}px`); |
| 167 | } |
| 168 | |
| 169 | let closed = false; |
| 170 | const w = { |
| 171 | id, el, body, foot, |
| 172 | setTitle(t) { title.textContent = t || ''; }, |
| 173 | close(why) { |
| 174 | if (closed) return; closed = true; |
| 175 | if (w.float) { try { w.float.destroy(); } catch { /* */ } w.float = null; } |
| 176 | const i = stack.indexOf(w); if (i >= 0) stack.splice(i, 1); |
| 177 | if (!stack.some((s) => s.modal)) document.documentElement.classList.remove('ovw-locked'); |
| 178 | const gone = () => { el.remove(); scrim.remove(); }; |
| 179 | if (document.documentElement.getAttribute('data-ov-motion') === 'off') gone(); |
| 180 | else { el.classList.add('is-out'); scrim.style.opacity = '0'; scrim.style.transition = 'opacity .16s'; let done = false; const end = () => { if (!done) { done = true; gone(); } }; el.addEventListener('animationend', end, { once: true }); setTimeout(end, 260); } |
| 181 | if (opener && opener.focus && document.contains(opener)) { try { opener.focus({ preventScroll: true }); } catch { /* */ } } |
| 182 | if (typeof o.onClose === 'function') { try { o.onClose(why || 'close'); } catch (e) { console.error(e); } } |
| 183 | }, |
| 184 | modal, |
| 185 | float: null, |
| 186 | }; |
| 187 | stack.push(w); |
| 188 | if (kind === 'panel') { |
| 189 | const panelId = o.id ? String(o.id) : ''; |
| 190 | withFloat((OVFloat) => { |
| 191 | if (closed) return; |
| 192 | w.float = OVFloat.attach(el, { |
| 193 | frame: null, key: panelId ? `ov:window:float:${panelId}` : undefined, |
| 194 | defaults: { |
| 195 | x: Math.max(0, Math.min(1, panelLeft / Math.max(1, (document.documentElement.clientWidth || root.innerWidth)))), |
| 196 | y: Math.max(0, Math.min(1, panelTop / Math.max(1, (document.documentElement.clientHeight || root.innerHeight)))), |
| 197 | mode: 'float', |
| 198 | }, |
| 199 | aspect: null, size: false, resize: false, doubleTap: false, activeBar: false, |
| 200 | viewport: 'client', handle: head, store: null, hintClass: 'ovw-dock-hint', |
| 201 | onChange(geom) { |
| 202 | if (!panelId) return; |
| 203 | // The fractions convert back to the same pixels without reading a rect mid-landing. (The old |
| 204 | // key stays as the absolute place older releases wrote, and for the size the grip keeps there.) |
| 205 | const vw = document.documentElement.clientWidth || root.innerWidth; |
| 206 | const vh = document.documentElement.clientHeight || root.innerHeight; |
| 207 | place.set(panelId, Object.assign(place.get(panelId) || {}, { x: Math.round(geom.x * vw), y: Math.round(geom.y * vh) })); |
| 208 | }, |
| 209 | }); |
| 210 | }); |
| 211 | } |
| 212 | x.addEventListener('click', () => w.close('x')); |
| 213 | if (modal) scrim.addEventListener('pointerdown', () => w.close('outside')); |
| 214 | el.addEventListener('keydown', (e) => { |
| 215 | if (e.key === 'Tab') { |
| 216 | const f = focusables(el); if (!f.length) return; |
| 217 | const first = f[0], last = f[f.length - 1]; |
| 218 | if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } |
| 219 | else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } |
| 220 | } |
| 221 | }); |
| 222 | |
| 223 | // A sheet goes down when swiped down on a phone; a panel's head is OVFloat's handle (with its tuck and place). |
| 224 | if (kind === 'sheet') { |
| 225 | head.addEventListener('pointerdown', (e) => { |
| 226 | if (e.target.closest('.ovw-x') || (e.button !== undefined && e.button !== 0)) return; |
| 227 | const r = el.getBoundingClientRect(), sx = e.clientX, sy = e.clientY; |
| 228 | let moved = false; |
| 229 | el.classList.add('is-dragging'); |
| 230 | try { head.setPointerCapture(e.pointerId); } catch { /* */ } |
| 231 | const move = (ev) => { |
| 232 | const dx = ev.clientX - sx, dy = ev.clientY - sy; |
| 233 | if (!moved && Math.hypot(dx, dy) < 4) return; |
| 234 | moved = true; |
| 235 | if (kind === 'panel') { |
| 236 | el.style.left = `${Math.max(8, Math.min(r.left + dx, root.innerWidth - r.width - 8))}px`; |
| 237 | el.style.top = `${Math.max(8, Math.min(r.top + dy, root.innerHeight - 56))}px`; |
| 238 | } else el.style.transform = `translateY(${Math.max(0, dy)}px)`; |
| 239 | }; |
| 240 | const up = (ev) => { |
| 241 | head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', up); head.removeEventListener('pointercancel', up); |
| 242 | el.classList.remove('is-dragging'); |
| 243 | if (kind === 'panel') { if (moved && o.id) { const b = el.getBoundingClientRect(); place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(b.left), y: Math.round(b.top) })); } return; } |
| 244 | const dy = ev.clientY - sy; |
| 245 | if (moved && dy > Math.min(140, r.height * 0.28)) w.close('swipe'); |
| 246 | else { el.style.transition = 'transform .2s cubic-bezier(.2, 1, .3, 1)'; el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); } |
| 247 | }; |
| 248 | head.addEventListener('pointermove', move); head.addEventListener('pointerup', up); head.addEventListener('pointercancel', up); |
| 249 | }); |
| 250 | } |
| 251 | |
| 252 | if (kind === 'panel' && o.resizable) resizable(el, o); |
| 253 | |
| 254 | const first = o.focus ? body.querySelector(o.focus) : focusables(body)[0]; |
| 255 | (first || x).focus({ preventScroll: true }); |
| 256 | return w; |
| 257 | } |
| 258 | |
| 259 | /** A panel's corner grip: pointer drag (mouse, finger, pen) and the arrow keys; the size is kept with its place. */ |
| 260 | function resizable(el, o) { |
| 261 | el.classList.add('is-resizable'); |
| 262 | const minW = Math.max(200, Number(o.minWidth) || 260), minH = Math.max(120, Number(o.minHeight) || 160); |
| 263 | const grip = document.createElement('button'); |
| 264 | grip.type = 'button'; grip.className = 'ovw-grip'; |
| 265 | grip.setAttribute('aria-label', 'Resize: drag, or use the arrow keys'); |
| 266 | el.append(grip); |
| 267 | const first = { w: el.offsetWidth, h: el.offsetHeight }; |
| 268 | const set = (w, h, save) => { |
| 269 | const r = el.getBoundingClientRect(); |
| 270 | const W = Math.round(Math.max(minW, Math.min(w, root.innerWidth - r.left - 8))); |
| 271 | const H = Math.round(Math.max(minH, Math.min(h, root.innerHeight - r.top - 8))); |
| 272 | el.style.width = `${W}px`; el.style.height = `${H}px`; |
| 273 | if (save && o.id) place.set(o.id, Object.assign(place.get(o.id) || {}, { x: Math.round(r.left), y: Math.round(r.top), w: W, h: H })); |
| 274 | if (typeof o.onResize === 'function') { try { o.onResize({ width: W, height: H }); } catch { /* the page's own */ } } |
| 275 | }; |
| 276 | const saved = o.id ? place.get(o.id) : null; |
| 277 | if (saved && saved.w && saved.h) set(saved.w, saved.h, false); |
| 278 | grip.addEventListener('pointerdown', (e) => { |
| 279 | if (e.button !== undefined && e.button !== 0) return; |
| 280 | e.preventDefault(); |
| 281 | const sx = e.clientX, sy = e.clientY, w0 = el.offsetWidth, h0 = el.offsetHeight; |
| 282 | el.classList.add('is-resizing'); |
| 283 | try { grip.setPointerCapture(e.pointerId); } catch { /* */ } |
| 284 | const move = (ev) => set(w0 + ev.clientX - sx, h0 + ev.clientY - sy, false); |
| 285 | const up = () => { |
| 286 | grip.removeEventListener('pointermove', move); grip.removeEventListener('pointerup', up); grip.removeEventListener('pointercancel', up); |
| 287 | el.classList.remove('is-resizing'); |
| 288 | set(el.offsetWidth, el.offsetHeight, true); |
| 289 | }; |
| 290 | grip.addEventListener('pointermove', move); grip.addEventListener('pointerup', up); grip.addEventListener('pointercancel', up); |
| 291 | }); |
| 292 | grip.addEventListener('keydown', (e) => { |
| 293 | const step = e.shiftKey ? 80 : 24; |
| 294 | const d = { ArrowRight: [step, 0], ArrowLeft: [-step, 0], ArrowDown: [0, step], ArrowUp: [0, -step] }[e.key]; |
| 295 | if (e.key === 'Enter' || e.key === 'Home') { e.preventDefault(); set(first.w, first.h, true); return; } |
| 296 | if (!d) return; |
| 297 | e.preventDefault(); |
| 298 | set(el.offsetWidth + d[0], el.offsetHeight + d[1], true); |
| 299 | }); |
| 300 | } |
| 301 | |
| 302 | document.addEventListener('keydown', (e) => { |
| 303 | if (e.key !== 'Escape' || !stack.length || e.defaultPrevented) return; |
| 304 | e.preventDefault(); |
| 305 | stack[stack.length - 1].close('escape'); |
| 306 | }); |
| 307 | |
| 308 | // The style is the person's display setting (theme-loader: saved, synced to their account, painted at boot as |
| 309 | // <html data-ov-window>); setStyle saves through it, or straight onto the page where the theme loader is absent. |
| 310 | function setStyle(name) { |
| 311 | const v = STYLES.includes(name) ? name : 'glass'; |
| 312 | const T = root.OpenVibeThemeLoader; |
| 313 | if (T && T.display && typeof T.display.set === 'function') { try { T.display.set({ window: v }); } catch { /* */ } } |
| 314 | if (v === 'glass') document.documentElement.removeAttribute('data-ov-window'); else document.documentElement.setAttribute('data-ov-window', v); |
| 315 | return v; |
| 316 | } |
| 317 | |
| 318 | root.OVWindow = { open, setStyle, styles: STYLES.slice(), top: () => stack[stack.length - 1] || null, closeAll: () => [...stack].reverse().forEach((w) => w.close('all')) }; |
| 319 | })(typeof window !== 'undefined' ? window : globalThis); |
| 320 |