openvibers/openvibe.shared ov-arrange.js
ov-arrange.js
| 1 | /** |
| 2 | * openvibe-shared/ov-arrange.js — the Arrange editor (make your own controls, stage 2, 2026-11-11). |
| 3 | * |
| 4 | * A product publishes a ui.surface@1 catalog and renders one element per part with data-ov-part="<id>". |
| 5 | * A person's ui.layout@1 is applied to those real parts: a place on the surface's grid, or a dock edge for |
| 6 | * an overlay; visible; a style (a theme token, a shape, an opacity and the label); and bindings. This file |
| 7 | * is loaded only when someone presses Arrange (a product loads it then, by the shared serve URL, and this |
| 8 | * file loads its own sibling ov-arrange-core.js — openvibe-sdk/ui-layout for the browser — by content hash). |
| 9 | * |
| 10 | * OVArrange.open({ |
| 11 | * surface, catalog, layout, device: 'phone'|'tablet'|'computer', |
| 12 | * validate(catalog, layout) -> { ok, reasons[] }, // optional: defaults to the sdk core |
| 13 | * defaults(catalog) -> layout, // optional: defaults to the sdk core |
| 14 | * onSave(layout) -> Promise<{ ok?, reasons? }>, // PUT the person's layout (Network) |
| 15 | * onShare(layout) -> Promise<{ url?, reasons? }>, // save, then a share link |
| 16 | * onReset() -> Promise<{ ok?, reasons? }>, // clear the stored layout (Network's reset) |
| 17 | * onAsk(words, request) -> Promise<ui.layout-suggestion@1>, |
| 18 | * onClose(), confirm() -> boolean, core |
| 19 | * }) -> { close, apply, layout, destroy } |
| 20 | * |
| 21 | * Edit mode: drag a part to move it, drag its corner to resize, arrow keys to move and Shift with the |
| 22 | * arrows to resize (for a docked part: the arrows choose the edge, up/down its order). A part's sheet |
| 23 | * (long press on a phone, the Style button, or Enter on a part) has visible, size, style and bindings. |
| 24 | * Every move and resize is announced, and a refused move says why in plain words (Stop has to stay |
| 25 | * visible; it is too small). Undo, Reset to default, Save and Share are in the bar, with Ask OpenVibe: |
| 26 | * a suggestion is previewed on the real parts and kept only with Keep. Motion Off changes nothing; |
| 27 | * Calm plays the short transition. Plain kit markup (ovk-*): the look is the page's. |
| 28 | */ |
| 29 | (function (root) { |
| 30 | ; |
| 31 | if (!root || root.OVArrange || typeof document === 'undefined') return; |
| 32 | |
| 33 | const VERSION = '1.0.0'; |
| 34 | const MOTION = () => { |
| 35 | const m = document.documentElement.getAttribute('data-ov-motion'); |
| 36 | if (m === 'off') return 'off'; |
| 37 | return m === 'reduced' || (!m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'calm' : 'full'; |
| 38 | }; |
| 39 | |
| 40 | const CSS = ` |
| 41 | .ov-arrange-surface { position: relative; display: grid; grid-template-columns: repeat(var(--ov-cols, 12), minmax(0, 1fr)); grid-auto-rows: minmax(var(--ov-cell, 64px), auto); gap: var(--ov-gap, .75rem); } |
| 42 | .ov-arrange-surface > [data-ov-part] { min-width: 0; min-height: 0; opacity: var(--ov-part-opacity, 1); } |
| 43 | .ov-arrange-surface > [data-ov-part][data-ov-label="false"] .ov-arrange-label { display: none; } |
| 44 | .ov-arrange-editing [data-ov-part] { cursor: grab; outline: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); outline-offset: 2px; touch-action: none; transition: box-shadow var(--ov-arrange-dur, .2s) ease, outline-color var(--ov-arrange-dur, .2s) ease; } |
| 45 | .ov-arrange-editing [data-ov-part]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; } |
| 46 | .ov-arrange-editing [data-ov-part][data-ov-selected] { outline: 2px solid var(--accent, #3b82f6); outline-offset: 3px; } |
| 47 | .ov-arrange-editing [data-ov-part] :is(button, input, select, textarea, a, [role="button"]) { pointer-events: none; } |
| 48 | .ov-arrange-editing [data-ov-part][data-ov-dragging] { cursor: grabbing; opacity: .8; } |
| 49 | .ov-arrange-editing [data-ov-part][data-ov-refused] { outline-color: var(--danger, #e5484d); } |
| 50 | .ov-arrange-sel { position: fixed; z-index: 2147482990; pointer-events: none; border: 2px solid var(--accent, #3b82f6); border-radius: 10px; box-shadow: 0 0 0 9999px color-mix(in srgb, #000 14%, transparent); } |
| 51 | .ov-arrange-handle, .ov-arrange-style { position: fixed; z-index: 2147482991; pointer-events: auto; width: 34px; height: 34px; min-width: 34px; min-height: 34px; padding: 0; display: grid; place-items: center; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.2)); background: var(--raised, #1b2230); color: var(--text, #eef); cursor: nwse-resize; } |
| 52 | .ov-arrange-handle { touch-action: none; } |
| 53 | .ov-arrange-style { cursor: pointer; font-size: 15px; } |
| 54 | .ov-arrange-handle::before { content: ""; width: 12px; height: 12px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translate(-2px, -2px); } |
| 55 | .ov-arrange-bar { position: fixed; z-index: 2147483000; left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(56rem, calc(100vw - 24px)); display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--line, rgba(255,255,255,.16)); border-radius: 16px; background: var(--surface, #151b26); color: var(--text, #eef); box-shadow: 0 18px 50px -16px rgba(0,0,0,.7); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } |
| 56 | .ov-arrange-bar form { display: flex; gap: 8px; min-width: 0; } |
| 57 | .ov-arrange-bar input { flex: 1; min-width: 0; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--bg, #0d1117); color: var(--text, #eef); font: inherit; } |
| 58 | .ov-arrange-actions { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } |
| 59 | .ov-arrange-actions .ovk-btn, .ov-arrange-bar form .ovk-btn { min-height: 38px; } |
| 60 | .ov-arrange-status { margin: 0; min-height: 1.2em; color: var(--muted, #9aa6b6); font-size: 12.5px; } |
| 61 | .ov-arrange-status[data-tone="bad"] { color: var(--danger, #e5484d); } |
| 62 | .ov-arrange-status[data-tone="ok"] { color: var(--ok, #3fb950); } |
| 63 | .ov-arrange-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); } |
| 64 | .ov-arrange-preview p { margin: 0; flex: 1 1 14rem; } |
| 65 | .ov-arrange-sheet { position: fixed; z-index: 2147483001; inset: auto 0 0 0; max-height: min(82vh, 42rem); overflow: auto; padding: 14px; background: var(--surface, #151b26); color: var(--text, #eef); border-top: 1px solid var(--line, rgba(255,255,255,.18)); border-radius: 16px 16px 0 0; box-shadow: 0 -20px 50px -20px rgba(0,0,0,.7); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: grid; gap: 12px; } |
| 66 | @media (min-width: 44rem) { .ov-arrange-sheet { left: 50%; right: auto; bottom: 50%; transform: translate(-50%, 50%); width: min(30rem, calc(100vw - 32px)); border-radius: 16px; border: 1px solid var(--line, rgba(255,255,255,.18)); } } |
| 67 | .ov-arrange-sheet h2 { margin: 0; font-size: 15px; } |
| 68 | .ov-arrange-sheet .ov-arrange-row { display: grid; gap: 5px; } |
| 69 | .ov-arrange-sheet .ov-arrange-row > span { font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted, #9aa6b6); } |
| 70 | .ov-arrange-sheet .ov-arrange-opts { display: flex; flex-wrap: wrap; gap: 6px; } |
| 71 | .ov-arrange-sheet .ov-arrange-opts label { display: inline-flex; align-items: center; gap: 5px; min-height: 36px; padding: 0 10px; border: 1px solid var(--line, rgba(255,255,255,.18)); border-radius: 9px; cursor: pointer; } |
| 72 | .ov-arrange-sheet .ov-arrange-opts input { margin: 0; } |
| 73 | .ov-arrange-sheet .ov-arrange-close { justify-self: end; } |
| 74 | .ov-arrange-sheet input[type="text"] { min-height: 38px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--bg, #0d1117); color: var(--text, #eef); font: inherit; } |
| 75 | .ov-arrange-share { display: grid; gap: 8px; padding: 10px; border-radius: 10px; background: var(--bg, #0d1117); } |
| 76 | .ov-arrange-share input { width: 100%; min-height: 38px; padding: 0 10px; border-radius: 9px; border: 1px solid var(--line, rgba(255,255,255,.18)); background: var(--surface, #151b26); color: var(--text, #eef); font: inherit; } |
| 77 | html[data-ov-motion="off"] .ov-arrange-surface [data-ov-part], html[data-ov-motion="off"] .ov-arrange-sel, html[data-ov-motion="off"] .ov-arrange-bar { transition: none !important; } |
| 78 | html[data-ov-motion="reduced"] { --ov-arrange-dur: .12s; } |
| 79 | `; |
| 80 | |
| 81 | const clone = (v) => (v == null ? v : JSON.parse(JSON.stringify(v))); |
| 82 | const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); |
| 83 | const esc = (s) => String(s == null ? '' : s); |
| 84 | const partName = (catalog, id) => { |
| 85 | const p = (catalog.parts || []).find((x) => x.id === id); |
| 86 | return p ? p.name : id; |
| 87 | }; |
| 88 | const catalogPart = (catalog, id) => (catalog.parts || []).find((x) => x.id === id) || null; |
| 89 | const entryOf = (layout, id) => ((layout && layout.parts) || []).find((p) => p.id === id) || null; |
| 90 | const docked = (place) => !!(place && place.dock); |
| 91 | |
| 92 | function styleOnce(doc) { |
| 93 | if (doc.getElementById('ov-arrange-styles')) return; |
| 94 | const st = doc.createElement('style'); |
| 95 | st.id = 'ov-arrange-styles'; |
| 96 | st.textContent = CSS; |
| 97 | (doc.head || doc.documentElement).appendChild(st); |
| 98 | } |
| 99 | |
| 100 | // ── The SDK's rules, as a browser sibling (scripts/build-arrange-core.js) ───────────────────────── |
| 101 | const ARRANGE_CORE_REV = { 'ov-arrange-core.js': 'cbf71f9cebb2' }; |
| 102 | let coreLoading = null; |
| 103 | function loadCore(opts) { |
| 104 | if (opts && opts.core) return Promise.resolve(opts.core); |
| 105 | if (root.OVArrangeCore) return Promise.resolve(root.OVArrangeCore); |
| 106 | if (coreLoading) return coreLoading; |
| 107 | if (!root.document || !document.currentScript) return Promise.resolve(null); |
| 108 | const src = document.currentScript.src || ''; |
| 109 | if (!src) return Promise.resolve(null); |
| 110 | const url = src.replace(/[^/]*$/, `ov-arrange-core.js${ARRANGE_CORE_REV['ov-arrange-core.js'] ? `?v=${ARRANGE_CORE_REV['ov-arrange-core.js']}` : ''}`); |
| 111 | coreLoading = new Promise((resolve) => { |
| 112 | const sc = document.createElement('script'); |
| 113 | sc.src = url; sc.async = true; |
| 114 | sc.onload = () => resolve(root.OVArrangeCore || null); |
| 115 | sc.onerror = () => resolve(null); |
| 116 | document.head.appendChild(sc); |
| 117 | }); |
| 118 | return coreLoading; |
| 119 | } |
| 120 | |
| 121 | // The rules when the SDK core is not there (a product without the sibling): the same checks the contract |
| 122 | // lists, in the same plain words. The SDK remains the one authority when it is loaded. |
| 123 | function fallbackValidate(catalog, layout) { |
| 124 | const reasons = []; |
| 125 | const parts = catalog && catalog.parts || []; |
| 126 | const byId = new Map(parts.map((p) => [p.id, p])); |
| 127 | if (!isObj(layout)) return { ok: false, reasons: ['That is not a layout.'] }; |
| 128 | if (layout.surface !== catalog.id) reasons.push(`This layout is for ${esc(layout.surface)}, not ${esc(catalog.id)}.`); |
| 129 | if (layout.surface_version !== catalog.version) reasons.push('This layout was made for another version of these controls.'); |
| 130 | if (!(catalog.devices || []).includes(layout.device)) reasons.push(`These controls do not work on a ${esc(layout.device)}.`); |
| 131 | if (!Array.isArray(layout.parts) || !layout.parts.length) reasons.push('A layout needs at least one part.'); |
| 132 | const seen = new Set(); |
| 133 | const entries = Array.isArray(layout.parts) ? layout.parts : []; |
| 134 | for (const e of entries) { |
| 135 | if (!isObj(e) || typeof e.id !== 'string') { reasons.push('A layout has a part without an id.'); continue; } |
| 136 | if (seen.has(e.id)) reasons.push(`${partName(catalog, e.id)} is in the layout twice.`); |
| 137 | seen.add(e.id); |
| 138 | if (!byId.has(e.id)) { reasons.push(`These controls have no part called ${esc(e.id)}.`); continue; } |
| 139 | // No links or code anywhere in the data. |
| 140 | for (const [k, v] of Object.entries(e)) { |
| 141 | if (typeof v === 'string' && /(https?:\/\/|\/\/|javascript:|data:|<[a-z])/i.test(v)) reasons.push('A layout only carries data: no links or code.'); |
| 142 | if (k === 'style' && isObj(v)) for (const sv of Object.values(v)) if (typeof sv === 'string' && /(https?:\/\/|\/\/|javascript:|data:|<[a-z])/i.test(sv)) reasons.push('A layout only carries data: no links or code.'); |
| 143 | } |
| 144 | } |
| 145 | for (const p of parts) { |
| 146 | const e = entries.find((x) => isObj(x) && x.id === p.id); |
| 147 | if (p.required && !e) { reasons.push(`${p.name} has to be there.`); continue; } |
| 148 | if (!e) continue; |
| 149 | if (p.required && e.visible === false) { reasons.push(`${p.name} has to stay visible.`); continue; } |
| 150 | if (!isObj(e.place)) { reasons.push(`${p.name} has no place.`); continue; } |
| 151 | if (!docked(e.place)) { |
| 152 | const { x, y, w, h } = e.place; |
| 153 | const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[layout.device]) || 12; |
| 154 | if (![x, y, w, h].every((n) => Number.isInteger(n))) { reasons.push(`${p.name} needs a place on the grid.`); continue; } |
| 155 | if (x < 0 || y < 0 || w < 1 || h < 1 || x + w > cols) reasons.push(`${p.name} does not fit on this grid.`); |
| 156 | if (w < p.min.w || h < p.min.h) reasons.push(`${p.name} is too small: it needs ${p.min.w} by ${p.min.h} cells.`); |
| 157 | const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[layout.device]) || 44; |
| 158 | if (p.min.px && (w * cell < p.min.px || h * cell < p.min.px)) reasons.push(`${p.name} has to be at least ${p.min.px} px.`); |
| 159 | if (p.pressed && (w * cell < 44 || h * cell < 44)) reasons.push(`${p.name} is pressed, so it has to be at least 44 px.`); |
| 160 | } else { |
| 161 | if (!['left', 'right', 'top', 'bottom'].includes(e.place.dock)) reasons.push(`${p.name} needs an edge to dock to.`); |
| 162 | if (!Number.isInteger(e.place.order) || e.place.order < 0) reasons.push(`${p.name} needs an order on its edge.`); |
| 163 | if (!Number.isInteger(e.place.w) || !Number.isInteger(e.place.h)) reasons.push(`${p.name} needs a size, in cells.`); |
| 164 | else { |
| 165 | if (e.place.w < p.min.w || e.place.h < p.min.h) reasons.push(`${p.name} is too small: it needs ${p.min.w} by ${p.min.h} cells.`); |
| 166 | const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[layout.device]) || 44; |
| 167 | if (p.min.px && (e.place.w * cell < p.min.px || e.place.h * cell < p.min.px)) reasons.push(`${p.name} has to be at least ${p.min.px} px.`); |
| 168 | if (p.pressed && (e.place.w * cell < 44 || e.place.h * cell < 44)) reasons.push(`${p.name} is pressed, so it has to be at least 44 px.`); |
| 169 | } |
| 170 | } |
| 171 | const st = e.style || {}; |
| 172 | if (!(catalog.theme_tokens || []).includes(st.tint)) reasons.push(`${p.name} uses a colour this theme does not have.`); |
| 173 | if (!((p.styles && p.styles.tints) || []).includes(st.tint)) reasons.push(`${partName(catalog, p.id)} cannot use that colour.`); |
| 174 | if (!((p.styles && p.styles.shapes) || []).includes(st.shape)) reasons.push(`${p.name} cannot be that shape.`); |
| 175 | if (typeof st.opacity === 'number' && (st.opacity < 0.4 || st.opacity > 1)) reasons.push(`${p.name} has to stay at least 40% visible.`); |
| 176 | const b = e.bindings || {}; |
| 177 | for (const k of (b.keys || [])) if (!((p.bindings && p.bindings.keys) || []).includes(k)) reasons.push(`${p.name} cannot use the key ${esc(k)}.`); |
| 178 | for (const g of (b.gamepad || [])) if (!((p.bindings && p.bindings.gamepad) || []).includes(g)) reasons.push(`${p.name} cannot use the gamepad control ${esc(g)}.`); |
| 179 | } |
| 180 | // Nothing covers a required part, and never_covers. |
| 181 | const placed = entries.filter((e) => isObj(e.place) && !docked(e.place)); |
| 182 | const dockedEntries = entries.filter((e) => isObj(e.place) && docked(e.place)); |
| 183 | for (const a of dockedEntries) { |
| 184 | if (!catalogPart(catalog, a.id)) continue; |
| 185 | for (const b of dockedEntries) { |
| 186 | if (a === b || !catalogPart(catalog, b.id)) continue; |
| 187 | if (a.place.dock === b.place.dock && a.place.order === b.place.order) { |
| 188 | const ap = catalogPart(catalog, a.id), bp = catalogPart(catalog, b.id); |
| 189 | if (bp.required) reasons.push(`${partName(catalog, a.id)} covers ${bp.name}.`); |
| 190 | if (((ap.never_covers) || []).includes(b.id)) reasons.push(`${partName(catalog, a.id)} must never cover ${bp.name}.`); |
| 191 | } |
| 192 | } |
| 193 | } |
| 194 | for (const a of placed) { |
| 195 | if (!catalogPart(catalog, a.id)) continue; |
| 196 | for (const b of placed) { |
| 197 | if (a === b || !catalogPart(catalog, b.id)) continue; |
| 198 | const covers = a.place.x < b.place.x + b.place.w && a.place.x + a.place.w > b.place.x && a.place.y < b.place.y + b.place.h && a.place.y + a.place.h > b.place.y; |
| 199 | if (!covers) continue; |
| 200 | const bp = catalogPart(catalog, b.id); |
| 201 | if (bp.required) reasons.push(`${partName(catalog, a.id)} covers ${bp.name}.`); |
| 202 | if (((catalogPart(catalog, a.id).never_covers) || []).includes(b.id)) reasons.push(`${partName(catalog, a.id)} must never cover ${bp.name}.`); |
| 203 | } |
| 204 | } |
| 205 | return { ok: reasons.length === 0, reasons: [...new Set(reasons)] }; |
| 206 | } |
| 207 | |
| 208 | // ── Defaults: one visible part after another on the grid; docked parts by their catalog order ───── |
| 209 | function spanFor(p, device) { |
| 210 | const cols = (p && p._cols) || 4; |
| 211 | if (device === 'computer') return { w: Math.min(4, cols), h: 2 }; |
| 212 | if (device === 'tablet') return { w: Math.min(4, cols), h: 2 }; |
| 213 | return { w: cols, h: 2 }; |
| 214 | } |
| 215 | function fallbackDefaults(catalog, device) { |
| 216 | const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12; |
| 217 | const overlay = (catalog.id || '').split('.').pop() === 'overlay'; |
| 218 | const parts = (catalog.parts || []).map((p) => { |
| 219 | const st = { tint: (p.styles && p.styles.tints && p.styles.tints[0]) || (catalog.theme_tokens || ['surface'])[0], shape: (p.styles && p.styles.shapes && p.styles.shapes[0]) || 'square', opacity: 1, label: true }; |
| 220 | const b = { keys: (p.bindings && p.bindings.keys || []).slice(0, 0), gamepad: [] }; |
| 221 | if (overlay) { |
| 222 | const dock = p.id === 'pill' || p.id === 'stick' ? 'left' : p.id === 'slider' ? 'bottom' : 'right'; |
| 223 | return { id: p.id, visible: true, place: { dock, order: 0, w: Math.max(p.min.w, p.id === 'stick' ? 2 : 1), h: Math.max(p.min.h, p.id === 'stick' ? 2 : 1) }, style: st, bindings: b }; |
| 224 | } |
| 225 | return { id: p.id, visible: true, place: { x: 0, y: 0, w: Math.min(spanFor({ ...p, _cols: cols }, device).w, cols), h: Math.max(p.min.h, 2) }, style: st, bindings: b }; |
| 226 | }); |
| 227 | if (!overlay) { |
| 228 | // Flow them: full-width rows on a phone, two or three columns on larger surfaces. |
| 229 | let y = 0; |
| 230 | for (const p of parts) { |
| 231 | const c = catalogPart(catalog, p.id); |
| 232 | const w = Math.min(cols, Math.max(c.min.w, device === 'phone' ? cols : Math.min(cols, device === 'tablet' ? 4 : 4))); |
| 233 | const h = Math.max(c.min.h, c.kind === 'video' ? 3 : c.kind === 'readout' ? 1 : 2); |
| 234 | if (p.place.x + w > cols) { p.place.x = 0; y += 1; } |
| 235 | p.place = { x: p.place.x, y, w, h }; |
| 236 | p.place.x += w; |
| 237 | if (p.place.x >= cols) { p.place.x = 0; y += h; } |
| 238 | } |
| 239 | } |
| 240 | return { surface: catalog.id, surface_version: catalog.version, device, name: 'My layout', author: { name: 'Me' }, sharing: 'private', parts }; |
| 241 | } |
| 242 | |
| 243 | function fitPlace(place, catalog, device, part) { |
| 244 | if (!place) return null; |
| 245 | const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12; |
| 246 | if (docked(place)) return { dock: place.dock, order: Math.max(0, Math.min(63, Number(place.order) || 0)), w: Math.max(1, Math.min(24, Math.round(place.w) || part.min.w)), h: Math.max(1, Math.min(24, Math.round(place.h) || part.min.h)) }; |
| 247 | const w = Math.max(1, Math.min(24, Math.round(place.w) || part.min.w)); |
| 248 | const h = Math.max(1, Math.min(24, Math.round(place.h) || part.min.h)); |
| 249 | return { x: Math.max(0, Math.min(Math.max(0, cols - w), Math.round(place.x) || 0)), y: Math.max(0, Math.min(100, Math.round(place.y) || 0)), w, h }; |
| 250 | } |
| 251 | |
| 252 | // ── The editor ────────────────────────────────────────────────────────────────────────────────── |
| 253 | function open(opts) { |
| 254 | const surface = opts && opts.surface; |
| 255 | if (!surface || !isObj(opts.catalog)) throw new Error('OVArrange.open: a surface element and its ui.surface@1 catalog are needed'); |
| 256 | const catalog = opts.catalog; |
| 257 | const doc = surface.ownerDocument || document; |
| 258 | const device = ['phone', 'tablet', 'computer'].includes(opts.device) ? opts.device : 'computer'; |
| 259 | styleOnce(doc); |
| 260 | const cols = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || 12; |
| 261 | const cell = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[device]) || 64; |
| 262 | const confirmFn = typeof opts.confirm === 'function' ? opts.confirm : (msg) => !root.confirm || root.confirm(msg); |
| 263 | const announce = (text) => { if (live) live.textContent = text; if (opts.announce) try { opts.announce(text); } catch (_) { /* */ } }; |
| 264 | |
| 265 | let core = opts.core || root.OVArrangeCore || null; |
| 266 | loadCore(opts).then((c) => { if (c) core = c; }).catch(() => { /* the fallback rules stay */ }); |
| 267 | const validate = (lay) => { |
| 268 | const fn = (opts.validate || (core && core.validate) || fallbackValidate); |
| 269 | try { |
| 270 | const v = fn(catalog, lay, device); |
| 271 | return v && typeof v.ok === 'boolean' ? { ok: v.ok, reasons: Array.isArray(v.reasons) ? v.reasons : [] } : { ok: false, reasons: ['That layout could not be checked.'] }; |
| 272 | } catch (e) { return { ok: false, reasons: [String(e && e.message || e)] }; } |
| 273 | }; |
| 274 | const defaults = () => { |
| 275 | const fn = opts.defaults || (core && core.applyDefaults); |
| 276 | try { const d = fn ? fn(catalog, device) : null; if (isObj(d)) return d; } catch (_) { /* fall through */ } |
| 277 | return fallbackDefaults(catalog, device); |
| 278 | }; |
| 279 | const normal = (lay) => { |
| 280 | const d = defaults(); |
| 281 | const out = clone(d); |
| 282 | const base = isObj(lay) ? lay : opts.layout; |
| 283 | out.surface = catalog.id; out.surface_version = catalog.version; out.device = device; |
| 284 | if (isObj(base)) { |
| 285 | out.name = base.name || out.name; out.sharing = base.sharing || out.sharing; out.author = base.author || out.author; |
| 286 | out.parts = (out.parts || []).map((p) => { |
| 287 | const got = entryOf(base, p.id); |
| 288 | return got ? { ...p, ...clone(got) } : p; |
| 289 | }); |
| 290 | } |
| 291 | out.parts = (out.parts || []).map((p) => { |
| 292 | const cp = catalogPart(catalog, p.id) || { min: { w: 1, h: 1 }, styles: {} }; |
| 293 | return { ...p, visible: cp.required ? true : p.visible !== false, place: fitPlace(p.place, catalog, device, cp) || { x: 0, y: 0, w: cp.min.w, h: cp.min.h }, style: { tint: (p.style && p.style.tint) || (cp.styles.tints || catalog.theme_tokens || ['surface'])[0], shape: (p.style && p.style.shape) || (cp.styles.shapes || ['square'])[0], opacity: p.style && typeof p.style.opacity === 'number' ? p.style.opacity : 1, label: p.style && typeof p.style.label === 'boolean' ? p.style.label : (cp.styles && cp.styles.label) !== false }, bindings: { keys: (p.bindings && p.bindings.keys) || [], gamepad: (p.bindings && p.bindings.gamepad) || [] } }; |
| 294 | }); |
| 295 | return out; |
| 296 | }; |
| 297 | |
| 298 | const initial = normal(opts.layout); |
| 299 | let working = clone(initial); |
| 300 | let undoStack = []; |
| 301 | let preview = null; // { layout, summary, reasons, words } |
| 302 | let selected = null; |
| 303 | let dirty = false; |
| 304 | let saving = false; |
| 305 | let closed = false; |
| 306 | let drag = null; |
| 307 | |
| 308 | const host = doc.createElement('div'); |
| 309 | host.className = 'ov-arrange-bar'; |
| 310 | host.setAttribute('role', 'toolbar'); |
| 311 | host.setAttribute('aria-label', 'Arrange the controls'); |
| 312 | host.innerHTML = `<form data-ask><label class="sr-only" for="ov-arrange-words">Ask OpenVibe</label><input id="ov-arrange-words" type="text" maxlength="500" autocomplete="off" placeholder="Describe how you want it…" aria-label="Ask OpenVibe"><button type="submit" class="ovk-btn ovk-btn--primary">Ask OpenVibe</button></form> |
| 313 | <div class="ov-arrange-actions"><button type="button" class="ovk-btn ovk-btn--quiet" data-undo>Undo</button><button type="button" class="ovk-btn ovk-btn--quiet" data-reset>Reset to default</button><button type="button" class="ovk-btn ovk-btn--quiet" data-share>Share</button><button type="button" class="ovk-btn ovk-btn--primary" data-save>Save</button><button type="button" class="ovk-btn ovk-btn--ghost" data-done>Done</button></div> |
| 314 | <p class="ov-arrange-status" data-status role="status" aria-live="polite"></p>`; |
| 315 | doc.body.appendChild(host); |
| 316 | const statusEl = host.querySelector('[data-status]'); |
| 317 | const setStatus = (text, tone) => { statusEl.textContent = text || ''; if (tone) statusEl.dataset.tone = tone; else delete statusEl.dataset.tone; if (text) announce(text); }; |
| 318 | const live = doc.createElement('div'); |
| 319 | live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); |
| 320 | live.className = 'sr-only'; |
| 321 | doc.body.appendChild(live); |
| 322 | |
| 323 | const sel = doc.createElement('div'); sel.className = 'ov-arrange-sel'; sel.hidden = true; doc.body.appendChild(sel); |
| 324 | const handle = doc.createElement('button'); handle.type = 'button'; handle.className = 'ov-arrange-handle'; handle.setAttribute('aria-label', 'Resize'); handle.title = 'Resize'; handle.hidden = true; doc.body.appendChild(handle); |
| 325 | const styleBtn = doc.createElement('button'); styleBtn.type = 'button'; styleBtn.className = 'ov-arrange-style'; styleBtn.textContent = 'Aa'; styleBtn.setAttribute('aria-label', 'Style this part'); styleBtn.title = 'Style this part'; styleBtn.hidden = true; doc.body.appendChild(styleBtn); |
| 326 | |
| 327 | const partEls = () => [...surface.querySelectorAll('[data-ov-part]')].filter((el) => catalogPart(catalog, el.dataset.ovPart)); |
| 328 | const originals = new Map(); |
| 329 | const innerTab = []; |
| 330 | for (const el of partEls()) originals.set(el, { style: el.getAttribute('style'), hidden: el.hidden, tabindex: el.getAttribute('tabindex'), role: el.getAttribute('role'), label: el.getAttribute('aria-label'), cls: el.className }); |
| 331 | const surfaceStyle = surface.getAttribute('style'); |
| 332 | const surfaceClass = surface.className; |
| 333 | const surfaceCustom = surface.getAttribute('data-ov-custom'); |
| 334 | const elOf = (id) => partEls().find((el) => el.dataset.ovPart === id) || null; |
| 335 | const entry = (id) => entryOf(working, id); |
| 336 | |
| 337 | function snapshot() { return clone(working); } |
| 338 | function pushUndo() { undoStack.push(snapshot()); if (undoStack.length > 40) undoStack.shift(); } |
| 339 | function markDirty() { dirty = true; paintActions(); } |
| 340 | |
| 341 | function flatten(el) { |
| 342 | for (let a = el.parentElement; a && a !== surface; a = a.parentElement) a.setAttribute('data-ov-flatten', 'true'); |
| 343 | } |
| 344 | |
| 345 | function paintPlace(el, e) { |
| 346 | const p = e.place || {}; |
| 347 | el.style.gridColumn = ''; el.style.gridRow = ''; el.style.order = ''; |
| 348 | delete el.dataset.ovDock; delete el.dataset.ovOrder; |
| 349 | if (docked(p)) { |
| 350 | el.dataset.ovDock = p.dock; |
| 351 | el.dataset.ovOrder = String(p.order || 0); |
| 352 | el.style.order = String(p.order || 0); |
| 353 | } else { |
| 354 | el.style.gridColumn = `${p.x + 1} / span ${p.w}`; |
| 355 | el.style.gridRow = `${p.y + 1} / span ${p.h}`; |
| 356 | } |
| 357 | if (el.dataset.ovRoleHidden !== 'true') el.hidden = e.visible === false; |
| 358 | el.style.setProperty('--ov-part-opacity', String(e.style && typeof e.style.opacity === 'number' ? e.style.opacity : 1)); |
| 359 | el.dataset.ovTint = (e.style && e.style.tint) || ''; |
| 360 | el.dataset.ovShape = (e.style && e.style.shape) || ''; |
| 361 | el.dataset.ovLabel = e.style && e.style.label === false ? 'false' : 'true'; |
| 362 | el.dataset.ovKeys = ((e.bindings && e.bindings.keys) || []).join(' '); |
| 363 | el.dataset.ovGamepad = ((e.bindings && e.bindings.gamepad) || []).join(' '); |
| 364 | el.classList.toggle('ov-arrange-part', true); |
| 365 | } |
| 366 | |
| 367 | function apply(lay, { focus = true } = {}) { |
| 368 | surface.classList.add('ov-arrange-surface'); |
| 369 | surface.setAttribute('data-ov-custom', 'true'); |
| 370 | surface.style.setProperty('--ov-cols', String(cols)); |
| 371 | surface.style.setProperty('--ov-cell', `${cell}px`); |
| 372 | surface.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; |
| 373 | surface.style.gridAutoRows = `minmax(${cell}px, auto)`; |
| 374 | const dockMode = (catalog.id || '').split('.').pop() === 'overlay'; |
| 375 | if (dockMode) { surface.style.display = surface.style.display || 'block'; surface.style.position = 'relative'; } |
| 376 | for (const el of partEls()) { |
| 377 | flatten(el); |
| 378 | for (const inner of el.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')) { |
| 379 | if (!innerTab.some((x) => x.el === inner)) innerTab.push({ el: inner, tabindex: inner.getAttribute('tabindex') }); |
| 380 | if (inner !== el) inner.setAttribute('tabindex', '-1'); |
| 381 | } |
| 382 | } |
| 383 | for (const p of (catalog.parts || [])) { |
| 384 | const el = elOf(p.id), e = entryOf(lay, p.id); |
| 385 | if (!el || !e) continue; |
| 386 | paintPlace(el, e); |
| 387 | if (focus && !dockMode) { |
| 388 | el.tabIndex = 0; |
| 389 | el.setAttribute('role', 'group'); |
| 390 | el.setAttribute('aria-label', `${p.name}${p.required ? ', required' : ''}. Arrow keys move it, Shift with the arrows resizes it, Enter opens its style.`); |
| 391 | } |
| 392 | } |
| 393 | positionSelection(); |
| 394 | } |
| 395 | |
| 396 | function selectedEl() { return selected ? elOf(selected) : null; } |
| 397 | function positionSelection() { |
| 398 | const el = selectedEl(); |
| 399 | if (!el || el.hidden || closed) { sel.hidden = true; handle.hidden = true; styleBtn.hidden = true; return; } |
| 400 | const r = el.getBoundingClientRect(); |
| 401 | sel.hidden = false; sel.style.left = `${r.left - 3}px`; sel.style.top = `${r.top - 3}px`; sel.style.width = `${r.width + 6}px`; sel.style.height = `${r.height + 6}px`; |
| 402 | handle.hidden = false; handle.style.left = `${r.right - 20}px`; handle.style.top = `${r.bottom - 20}px`; |
| 403 | styleBtn.hidden = false; styleBtn.style.left = `${r.left + 6}px`; styleBtn.style.top = `${r.top + 6}px`; |
| 404 | } |
| 405 | function select(id) { |
| 406 | if (selected && elOf(selected)) elOf(selected).removeAttribute('data-ov-selected'); |
| 407 | selected = id; |
| 408 | if (id && elOf(id)) elOf(id).setAttribute('data-ov-selected', 'true'); |
| 409 | positionSelection(); |
| 410 | } |
| 411 | |
| 412 | function labelOf(id) { return partName(catalog, id); } |
| 413 | function where(d, place) { |
| 414 | if (docked(place)) return `on the ${place.dock} edge, number ${Number(place.order) + 1}`; |
| 415 | return `column ${place.x + 1}, row ${place.y + 1}, ${place.w} by ${place.h} cells`; |
| 416 | } |
| 417 | |
| 418 | function paintActions() { |
| 419 | const u = host.querySelector('[data-undo]'); if (u) u.disabled = !undoStack.length; |
| 420 | const s = host.querySelector('[data-save]'); if (s) s.disabled = saving || !dirty; |
| 421 | const sh = host.querySelector('[data-share]'); if (sh) sh.disabled = saving; |
| 422 | } |
| 423 | |
| 424 | function refuse(reasons) { |
| 425 | setStatus((reasons && reasons[0]) ? `Can't do that: ${reasons[0]}` : "Can't do that.", 'bad'); |
| 426 | const el = selectedEl(); if (el) el.setAttribute('data-ov-refused', 'true'); |
| 427 | } |
| 428 | |
| 429 | function commit(candidate, { announceText, push = true } = {}) { |
| 430 | const v = validate(candidate); |
| 431 | if (!v.ok) { refuse(v.reasons); return false; } |
| 432 | if (push) pushUndo(); |
| 433 | working = candidate; |
| 434 | const el = selectedEl(); if (el) el.removeAttribute('data-ov-refused'); |
| 435 | apply(working); |
| 436 | markDirty(); |
| 437 | if (announceText) { const id = selected; setStatus(announceText, 'ok'); if (id) announce(`${labelOf(id)} is now ${where(device, entry(id).place)}.`); } |
| 438 | return true; |
| 439 | } |
| 440 | |
| 441 | function moveBy(id, dx, dy) { |
| 442 | const e = clone(entry(id)); if (!e) return; |
| 443 | const p = catalogPart(catalog, id) || { min: { w: 1, h: 1 } }; |
| 444 | if (docked(e.place)) { |
| 445 | if (dx) e.place.dock = dx < 0 ? 'left' : 'right'; |
| 446 | else if (dy) e.place.dock = dy < 0 ? 'top' : 'bottom'; |
| 447 | } else { |
| 448 | e.place.x = Math.max(0, Math.min(cols - e.place.w, e.place.x + dx)); |
| 449 | e.place.y = Math.max(0, e.place.y + dy); |
| 450 | if (e.place.x === entry(id).place.x && e.place.y === entry(id).place.y) return; |
| 451 | } |
| 452 | const cand = snapshot(); |
| 453 | const at = cand.parts.findIndex((x) => x.id === id); |
| 454 | cand.parts[at] = e; |
| 455 | commit(cand, { announceText: `${labelOf(id)} moved to ${where(device, e.place)}.` }); |
| 456 | } |
| 457 | |
| 458 | function resizeBy(id, dw, dh) { |
| 459 | const e = clone(entry(id)); if (!e) return; |
| 460 | const p = catalogPart(catalog, id) || { min: { w: 1, h: 1 } }; |
| 461 | if (docked(e.place)) { |
| 462 | e.place.w = Math.max(1, Math.min(24, e.place.w + dw)); |
| 463 | e.place.h = Math.max(1, Math.min(24, e.place.h + dh)); |
| 464 | } else { |
| 465 | e.place.w = Math.max(1, Math.min(cols - e.place.x, e.place.w + dw)); |
| 466 | e.place.h = Math.max(1, e.place.h + dh); |
| 467 | } |
| 468 | if (e.place.w === entry(id).place.w && e.place.h === entry(id).place.h) return; |
| 469 | const cand = snapshot(); |
| 470 | const at = cand.parts.findIndex((x) => x.id === id); |
| 471 | cand.parts[at] = e; |
| 472 | commit(cand, { announceText: `${labelOf(id)} is now ${where(device, e.place)}.` }); |
| 473 | } |
| 474 | |
| 475 | // Pointer drag: move, then the corner handle for resize. A refused cell keeps the last one that passed. |
| 476 | function cellAt(clientX, clientY) { |
| 477 | const r = surface.getBoundingClientRect(); |
| 478 | const col = Math.max(0, Math.min(cols - 1, Math.floor((clientX - r.left) / (r.width / cols)))); |
| 479 | const rows = (getComputedStyle(surface).gridTemplateRows || '').split(/\s+/).map(parseFloat).filter((n) => n > 0); |
| 480 | let y = 0, top = r.top; |
| 481 | for (let i = 0; i < rows.length; i++) { if (clientY < top + rows[i]) { y = i; break; } top += rows[i]; y = i + 1; } |
| 482 | return { x: col, y }; |
| 483 | } |
| 484 | function startDrag(e, id, mode) { |
| 485 | if (e.button != null && e.button !== 0) return; |
| 486 | const el = elOf(id); if (!el) return; |
| 487 | if (preview) { setStatus('Keep or discard the suggestion first.'); return; } |
| 488 | e.preventDefault(); |
| 489 | select(id); |
| 490 | const p = clone(entry(id).place); |
| 491 | drag = { id, mode, startX: e.clientX, startY: e.clientY, place: p, before: snapshot(), pushed: false, moved: false }; |
| 492 | el.setAttribute('data-ov-dragging', 'true'); |
| 493 | try { (mode === 'resize' ? handle : el).setPointerCapture(e.pointerId); } catch (_) { /* */ } |
| 494 | onPointerMove = (ev) => { |
| 495 | if (!drag) return; |
| 496 | if (Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) < 3 && !drag.moved) return; |
| 497 | drag.moved = true; |
| 498 | const cand = snapshot(); |
| 499 | const at = cand.parts.findIndex((x) => x.id === drag.id); |
| 500 | if (docked(drag.place)) { |
| 501 | if (drag.mode === 'resize') { |
| 502 | const colW = surface.getBoundingClientRect().width / cols; |
| 503 | const w = Math.max(1, Math.round((ev.clientX - drag.startX) / colW) + drag.place.w); |
| 504 | const h = Math.max(1, Math.round((ev.clientY - drag.startY) / cell) + drag.place.h); |
| 505 | if (w === cand.parts[at].place.w && h === cand.parts[at].place.h) return; |
| 506 | cand.parts[at].place.w = w; cand.parts[at].place.h = h; |
| 507 | } else { |
| 508 | const r = surface.getBoundingClientRect(); |
| 509 | const dx = (ev.clientX - r.left) / Math.max(1, r.width); |
| 510 | const dy = (ev.clientY - r.top) / Math.max(1, r.height); |
| 511 | const dock = Math.abs(dx - 0.5) > Math.abs(dy - 0.5) ? (dx < 0.5 ? 'left' : 'right') : (dy < 0.5 ? 'top' : 'bottom'); |
| 512 | const along = dock === 'left' || dock === 'right' ? dy : dx; |
| 513 | const order = Math.max(0, Math.min(63, Math.floor(along * 8))); |
| 514 | if (dock === cand.parts[at].place.dock && order === cand.parts[at].place.order) return; |
| 515 | cand.parts[at].place.dock = dock; cand.parts[at].place.order = order; |
| 516 | } |
| 517 | const v = validate(cand); |
| 518 | if (!v.ok) { refuse(v.reasons); return; } |
| 519 | if (!drag.pushed) { undoStack.push(drag.before); drag.pushed = true; } |
| 520 | working = cand; apply(working); markDirty(); |
| 521 | return; |
| 522 | } |
| 523 | const c = cellAt(ev.clientX, ev.clientY); |
| 524 | if (drag.mode === 'resize') { |
| 525 | const w = Math.max(1, c.x - drag.place.x + 1); |
| 526 | const h = Math.max(1, c.y - drag.place.y + 1); |
| 527 | if (w === cand.parts[at].place.w && h === cand.parts[at].place.h) return; |
| 528 | cand.parts[at].place.w = w; cand.parts[at].place.h = h; |
| 529 | } else { |
| 530 | const colW = surface.getBoundingClientRect().width / cols; |
| 531 | const dx = Math.round((ev.clientX - drag.startX) / colW); |
| 532 | const x = Math.max(0, Math.min(cols - drag.place.w, drag.place.x + dx)); |
| 533 | const y = Math.max(0, drag.place.y + (c.y - cellAt(drag.startX, drag.startY).y)); |
| 534 | if (x === cand.parts[at].place.x && y === cand.parts[at].place.y) return; |
| 535 | cand.parts[at].place.x = x; cand.parts[at].place.y = y; |
| 536 | } |
| 537 | const v = validate(cand); |
| 538 | if (!v.ok) { refuse(v.reasons); return; } |
| 539 | if (!drag.pushed) { undoStack.push(drag.before); drag.pushed = true; } |
| 540 | working = cand; apply(working); markDirty(); |
| 541 | }; |
| 542 | onPointerUp = () => { |
| 543 | if (!drag) return; |
| 544 | const done = drag; drag = null; |
| 545 | const el2 = elOf(done.id); |
| 546 | if (el2) { el2.removeAttribute('data-ov-dragging'); el2.removeAttribute('data-ov-refused'); } |
| 547 | if (done.moved) { |
| 548 | const place = entry(done.id).place; |
| 549 | setStatus(`${labelOf(done.id)} is now ${where(device, place)}.`, 'ok'); |
| 550 | announce(`${labelOf(done.id)} is now ${where(device, place)}.`); |
| 551 | } |
| 552 | positionSelection(); |
| 553 | }; |
| 554 | onPointerCancel = onPointerUp; |
| 555 | } |
| 556 | let onPointerMove = null, onPointerUp = null, onPointerCancel = null; |
| 557 | const pointerMove = (e) => { if (onPointerMove) onPointerMove(e); }; |
| 558 | const pointerUp = (e) => { if (onPointerUp) { const f = onPointerUp; onPointerMove = null; onPointerUp = null; onPointerCancel = null; f(e); } }; |
| 559 | doc.addEventListener('pointermove', pointerMove, { passive: true }); |
| 560 | doc.addEventListener('pointerup', pointerUp); |
| 561 | doc.addEventListener('pointercancel', pointerUp); |
| 562 | |
| 563 | // Long press (a phone) opens the sheet; a short press selects and focuses. |
| 564 | let pressTimer = null; |
| 565 | function onPartDown(e) { |
| 566 | const el = e.target.closest && e.target.closest('[data-ov-part]'); |
| 567 | if (!el || !surface.contains(el)) return; |
| 568 | if (e.target.closest('.ov-arrange-handle, .ov-arrange-style')) return; |
| 569 | const id = el.dataset.ovPart; |
| 570 | select(id); |
| 571 | clearTimeout(pressTimer); |
| 572 | pressTimer = setTimeout(() => { |
| 573 | if (drag && drag.moved) return; |
| 574 | if (drag) { |
| 575 | // A long press is the part's sheet, not a drag: let go of the gesture before opening it. |
| 576 | const held = elOf(drag.id); drag = null; |
| 577 | onPointerMove = null; onPointerUp = null; onPointerCancel = null; |
| 578 | if (held) held.removeAttribute('data-ov-dragging'); |
| 579 | } |
| 580 | openSheet(id); |
| 581 | }, 520); |
| 582 | startDrag(e, id, 'move'); |
| 583 | if (e.pointerType === 'touch') try { el.setPointerCapture(e.pointerId); } catch (_) { /* */ } |
| 584 | } |
| 585 | surface.addEventListener('pointerdown', onPartDown); |
| 586 | const cancelPress = () => clearTimeout(pressTimer); |
| 587 | surface.addEventListener('pointerup', cancelPress); |
| 588 | surface.addEventListener('pointercancel', cancelPress); |
| 589 | |
| 590 | // Keyboard: arrows move, Shift+arrows resize, Enter opens the sheet. |
| 591 | function onKey(e) { |
| 592 | const el = e.target.closest && e.target.closest('[data-ov-part]'); |
| 593 | if (!el) return; |
| 594 | const id = el.dataset.ovPart; |
| 595 | if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSheet(id); return; } |
| 596 | if (!/^Arrow(Up|Down|Left|Right)$/.test(e.key)) return; |
| 597 | e.preventDefault(); |
| 598 | const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; |
| 599 | const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0; |
| 600 | if (e.shiftKey) resizeBy(id, dx, dy); else moveBy(id, dx, dy); |
| 601 | } |
| 602 | surface.addEventListener('keydown', onKey); |
| 603 | |
| 604 | handle.addEventListener('pointerdown', (e) => { if (selected) startDrag(e, selected, 'resize'); }); |
| 605 | styleBtn.addEventListener('click', () => { if (selected) openSheet(selected); }); |
| 606 | |
| 607 | // ── The part's sheet ───────────────────────────────────────────────────────────────────── |
| 608 | let sheet = null; |
| 609 | function closeSheet() { if (sheet) { sheet.remove(); sheet = null; select(selected); } } |
| 610 | function openSheet(id) { |
| 611 | closeSheet(); |
| 612 | const p = catalogPart(catalog, id), e = entry(id); |
| 613 | if (!p || !e) return; |
| 614 | select(id); |
| 615 | sheet = doc.createElement('section'); |
| 616 | sheet.className = 'ov-arrange-sheet'; |
| 617 | sheet.setAttribute('role', 'dialog'); |
| 618 | sheet.setAttribute('aria-modal', 'false'); |
| 619 | sheet.setAttribute('aria-label', `${p.name}`); |
| 620 | const tints = (p.styles && p.styles.tints) || catalog.theme_tokens || ['surface']; |
| 621 | const shapes = (p.styles && p.styles.shapes) || ['square']; |
| 622 | const wp = clone(e.place); |
| 623 | sheet.innerHTML = `<button type="button" class="ovk-btn ovk-btn--quiet ov-arrange-close" data-close>Close</button> |
| 624 | <h2>${esc(p.name)}</h2> |
| 625 | ${p.required ? '<p>The Stop has to stay visible and at its size.</p>' : ''} |
| 626 | <label class="ov-arrange-row"><input type="checkbox" data-visible${e.visible === false ? '' : ' checked'}${p.required ? ' disabled' : ''}> <span>Visible</span></label> |
| 627 | <div class="ov-arrange-row"><span>Size</span><div class="ov-arrange-opts"> |
| 628 | ${(p.sizes || []).filter((s) => s.w >= p.min.w && s.h >= p.min.h).map((s) => `<label><input type="radio" name="ov-arrange-size" value="${s.w}x${s.h}"${docked(e.place) ? ' disabled' : (e.place.w === s.w && e.place.h === s.h ? ' checked' : '')}>${s.w} × ${s.h}</label>`).join('')} |
| 629 | <label>Width <input type="number" min="${p.min.w}" max="24" value="${e.place.w}" data-w style="width:4.5rem"></label> |
| 630 | <label>Height <input type="number" min="${p.min.h}" max="24" value="${e.place.h}" data-h style="width:4.5rem"></label> |
| 631 | </div></div> |
| 632 | <div class="ov-arrange-row"><span>Colour</span><div class="ov-arrange-opts">${(catalog.theme_tokens || tints).filter((t) => tints.includes(t)).map((t) => `<label><input type="radio" name="ov-arrange-tint" value="${esc(t)}"${e.style.tint === t ? ' checked' : ''}>${esc(t)}</label>`).join('')}</div></div> |
| 633 | <div class="ov-arrange-row"><span>Shape</span><div class="ov-arrange-opts">${shapes.map((s) => `<label><input type="radio" name="ov-arrange-shape" value="${esc(s)}"${e.style.shape === s ? ' checked' : ''}>${esc(s)}</label>`).join('')}</div></div> |
| 634 | <div class="ov-arrange-row"><span>Opacity</span><input type="range" min="0.4" max="1" step="0.05" value="${e.style.opacity}" data-opacity></div> |
| 635 | <label class="ov-arrange-row"><input type="checkbox" data-label${e.style.label === false ? '' : ' checked'}> <span>Show its label</span></label> |
| 636 | <div class="ov-arrange-row"><span>Keys</span><input type="text" data-keys value="${esc(((e.bindings && e.bindings.keys) || []).join(' '))}" placeholder="${esc(((p.bindings && p.bindings.keys) || []).join(' '))}" aria-label="Keys"></div> |
| 637 | <div class="ov-arrange-row"><span>Gamepad</span><input type="text" data-gamepad value="${esc(((e.bindings && e.bindings.gamepad) || []).join(' '))}" placeholder="${esc(((p.bindings && p.bindings.gamepad) || []).join(' '))}" aria-label="Gamepad controls"></div> |
| 638 | <p class="ov-arrange-status" data-sheet-status role="status" aria-live="polite"></p>`; |
| 639 | doc.body.appendChild(sheet); |
| 640 | const sheetStatus = sheet.querySelector('[data-sheet-status]'); |
| 641 | const setSheetStatus = (t, tone) => { sheetStatus.textContent = t; sheetStatus.dataset.tone = tone || ''; announce(t); }; |
| 642 | function change(fn) { |
| 643 | const cand = snapshot(); |
| 644 | const at = cand.parts.findIndex((x) => x.id === id); |
| 645 | fn(cand.parts[at], p); |
| 646 | const v = validate(cand); |
| 647 | if (!v.ok) { setSheetStatus(`Can't do that: ${v.reasons[0]}`, 'bad'); return false; } |
| 648 | pushUndo(); working = cand; apply(working); markDirty(); setSheetStatus('', ''); return true; |
| 649 | } |
| 650 | sheet.querySelector('[data-close]').addEventListener('click', closeSheet); |
| 651 | sheet.querySelector('[data-visible]').addEventListener('change', (ev) => { |
| 652 | const on = ev.target.checked; |
| 653 | const ok = change((x) => { x.visible = on; }); |
| 654 | if (!ok) ev.target.checked = !on; |
| 655 | else announce(`${p.name} is ${on ? 'shown' : 'hidden'}.`); |
| 656 | }); |
| 657 | sheet.querySelectorAll('input[name="ov-arrange-size"]').forEach((r) => r.addEventListener('change', () => { |
| 658 | const [w, h] = r.value.split('x').map(Number); |
| 659 | if (change((x) => { x.place.w = w; x.place.h = h; })) announce(`${p.name} is now ${w} by ${h} cells.`); |
| 660 | })); |
| 661 | const wIn = sheet.querySelector('[data-w]'), hIn = sheet.querySelector('[data-h]'); |
| 662 | for (const [inp, key] of [[wIn, 'w'], [hIn, 'h']]) inp.addEventListener('change', () => { |
| 663 | const n = Number(inp.value); |
| 664 | if (!Number.isInteger(n) || n < 1) { setSheetStatus(`Can't do that: ${p.name} needs at least ${key === 'w' ? p.min.w : p.min.h} cells.`, 'bad'); return; } |
| 665 | change((x) => { x.place[key] = n; }); |
| 666 | }); |
| 667 | sheet.querySelectorAll('input[name="ov-arrange-tint"]').forEach((r) => r.addEventListener('change', () => { if (change((x) => { x.style.tint = r.value; })) announce(`${p.name} is ${r.value}.`); })); |
| 668 | sheet.querySelectorAll('input[name="ov-arrange-shape"]').forEach((r) => r.addEventListener('change', () => { if (change((x) => { x.style.shape = r.value; })) announce(`${p.name} is ${r.value}.`); })); |
| 669 | const op = sheet.querySelector('[data-opacity]'); |
| 670 | op.addEventListener('change', () => { if (change((x) => { x.style.opacity = Number(op.value); })) announce(`${p.name} is ${Math.round(op.value * 100)}% visible.`); }); |
| 671 | sheet.querySelector('[data-label]').addEventListener('change', (ev) => { const on = ev.target.checked; if (change((x) => { x.style.label = on; })) announce(`${p.name}'s label is ${on ? 'shown' : 'hidden'}.`); }); |
| 672 | const keys = sheet.querySelector('[data-keys]'), pad = sheet.querySelector('[data-gamepad]'); |
| 673 | const parse = (v) => String(v || '').trim().split(/\s+/).filter(Boolean); |
| 674 | keys.addEventListener('change', () => { const v = parse(keys.value); if (change((x) => { x.bindings.keys = v; })) announce(`${p.name}'s keys: ${v.join(' ') || 'none'}.`); }); |
| 675 | pad.addEventListener('change', () => { const v = parse(pad.value); if (change((x) => { x.bindings.gamepad = v; })) announce(`${p.name}'s gamepad: ${v.join(' ') || 'none'}.`); }); |
| 676 | const first = sheet.querySelector('[data-close]'); if (first) first.focus(); |
| 677 | } |
| 678 | function onDocKey(e) { |
| 679 | if (e.key === 'Escape') { if (sheet) closeSheet(); else if (preview) discardPreview(); } |
| 680 | } |
| 681 | doc.addEventListener('keydown', onDocKey); |
| 682 | |
| 683 | // ── Ask OpenVibe, previewed ─────────────────────────────────────────────────────────────── |
| 684 | const ask = host.querySelector('form[data-ask]'); |
| 685 | const words = host.querySelector('#ov-arrange-words'); |
| 686 | let previewBar = null; |
| 687 | function showPreview(sug, wordText) { |
| 688 | preview = { layout: normal(sug.layout), summary: sug.summary || '', reasons: sug.reasons || [], words: wordText, model: sug.receipt && sug.receipt.model ? String(sug.receipt.model) : '' }; |
| 689 | apply(preview.layout); |
| 690 | if (previewBar) previewBar.remove(); |
| 691 | previewBar = doc.createElement('div'); |
| 692 | previewBar.className = 'ov-arrange-preview'; |
| 693 | previewBar.setAttribute('role', 'group'); |
| 694 | previewBar.setAttribute('aria-label', 'OpenVibe\'s suggestion'); |
| 695 | previewBar.innerHTML = `<p><b>OpenVibe suggests:</b> ${esc(preview.summary)}${preview.model ? ` <span class="ov-arrange-by">(${esc(preview.model)})</span>` : ''}</p><button type="button" class="ovk-btn ovk-btn--primary" data-keep>Keep</button><button type="button" class="ovk-btn ovk-btn--quiet" data-discard>Discard</button>`; |
| 696 | host.querySelector('.ov-arrange-actions').before(previewBar); |
| 697 | previewBar.querySelector('[data-keep]').addEventListener('click', keepPreview); |
| 698 | previewBar.querySelector('[data-discard]').addEventListener('click', discardPreview); |
| 699 | setStatus(preview.summary || 'Previewing OpenVibe\'s suggestion.', 'ok'); |
| 700 | announce(`OpenVibe suggests: ${preview.summary}. Keep or discard.`); |
| 701 | } |
| 702 | function keepPreview() { |
| 703 | if (!preview) return; |
| 704 | pushUndo(); working = preview.layout; preview = null; |
| 705 | if (previewBar) { previewBar.remove(); previewBar = null; } |
| 706 | apply(working); markDirty(); setStatus('Kept. Save it to keep it for next time.', 'ok'); |
| 707 | announce('Kept the suggestion. Save it to keep it.'); |
| 708 | } |
| 709 | function discardPreview() { |
| 710 | if (!preview) return; |
| 711 | preview = null; |
| 712 | if (previewBar) { previewBar.remove(); previewBar = null; } |
| 713 | apply(working); setStatus('Discarded the suggestion.'); |
| 714 | announce('Discarded the suggestion.'); |
| 715 | } |
| 716 | ask.addEventListener('submit', async (e) => { |
| 717 | e.preventDefault(); |
| 718 | const text = words.value.trim(); |
| 719 | if (!text) return; |
| 720 | if (typeof opts.onAsk !== 'function') { setStatus('Ask OpenVibe needs a signed-in person.', 'bad'); return; } |
| 721 | if (preview) discardPreview(); |
| 722 | setStatus('Asking OpenVibe…'); |
| 723 | ask.querySelector('button').disabled = true; |
| 724 | try { |
| 725 | const request = { catalog, layout: clone(working), words: text, device }; |
| 726 | const sug = await opts.onAsk(text, request); |
| 727 | if (!sug) { setStatus("OpenVibe couldn't answer. Try again.", 'bad'); return; } |
| 728 | if (sug.ok === false || !isObj(sug.layout)) { |
| 729 | const why = (sug.reasons && sug.reasons[0]) || sug.summary || "OpenVibe couldn't do that."; |
| 730 | setStatus(`Can't do that: ${why}`, 'bad'); return; |
| 731 | } |
| 732 | const v = validate(sug.layout); |
| 733 | if (!v.ok) { setStatus(`Can't do that: ${v.reasons[0]}`, 'bad'); return; } |
| 734 | showPreview(sug, text); |
| 735 | } catch (err) { |
| 736 | setStatus(`OpenVibe could not answer: ${String(err && err.message || err)}`, 'bad'); |
| 737 | } finally { ask.querySelector('button').disabled = false; } |
| 738 | }); |
| 739 | |
| 740 | // ── Save, Share, Reset, Undo, Done ─────────────────────────────────────────────────────── |
| 741 | host.querySelector('[data-undo]').addEventListener('click', () => { |
| 742 | if (!undoStack.length) return; |
| 743 | working = undoStack.pop(); preview = null; |
| 744 | if (previewBar) { previewBar.remove(); previewBar = null; } |
| 745 | apply(working); markDirty(); setStatus('Undone.'); announce('Undone.'); |
| 746 | }); |
| 747 | host.querySelector('[data-reset]').addEventListener('click', async () => { |
| 748 | preview = null; |
| 749 | if (previewBar) { previewBar.remove(); previewBar = null; } |
| 750 | pushUndo(); working = defaults(); apply(working); markDirty(); |
| 751 | if (typeof opts.onReset !== 'function') { setStatus('Reset to the default. Save it to keep it.', 'ok'); announce('Reset to the default.'); return; } |
| 752 | setStatus('Resetting to the default…'); |
| 753 | try { |
| 754 | const r = await opts.onReset({ surface: catalog.id, device }); |
| 755 | if (r && r.ok === false) { setStatus(`Couldn't reset: ${(r.reasons && r.reasons[0]) || 'try again.'}`, 'bad'); return; } |
| 756 | dirty = false; paintActions(); |
| 757 | setStatus('Reset to the default.', 'ok'); announce('Reset to the default. It comes back next time.'); |
| 758 | } catch (err) { setStatus(`Couldn't reset: ${String(err && err.message || err)}`, 'bad'); } |
| 759 | }); |
| 760 | host.querySelector('[data-save]').addEventListener('click', async () => { |
| 761 | if (saving) return; |
| 762 | const v = validate(working); |
| 763 | if (!v.ok) { refuse(v.reasons); return; } |
| 764 | if (typeof opts.onSave !== 'function') { setStatus('Sign in to save your layout.', 'bad'); return; } |
| 765 | saving = true; paintActions(); setStatus('Saving…'); |
| 766 | try { |
| 767 | const r = await opts.onSave(clone(working), { surface: catalog.id, device }); |
| 768 | if (r && r.ok === false) { setStatus(`Couldn't save: ${(r.reasons && r.reasons[0]) || r.message || 'try again.'}`, 'bad'); return; } |
| 769 | dirty = false; setStatus('Saved. It follows you to your other devices.', 'ok'); |
| 770 | announce('Saved.'); |
| 771 | } catch (err) { setStatus(`Couldn't save: ${String(err && err.message || err)}`, 'bad'); } |
| 772 | finally { saving = false; paintActions(); } |
| 773 | }); |
| 774 | host.querySelector('[data-share]').addEventListener('click', async () => { |
| 775 | if (typeof opts.onShare !== 'function') { setStatus('Sign in to share a layout.', 'bad'); return; } |
| 776 | const v = validate(working); |
| 777 | if (!v.ok) { refuse(v.reasons); return; } |
| 778 | setStatus('Making a share link…'); |
| 779 | try { |
| 780 | const r = await opts.onShare(clone(working), { surface: catalog.id, device }); |
| 781 | if (!r || !r.url) { setStatus(`Couldn't share: ${(r && r.reasons && r.reasons[0]) || 'try again.'}`, 'bad'); return; } |
| 782 | shareBox(r.url); |
| 783 | setStatus('Shared. Anyone with the link can use this arrangement.', 'ok'); |
| 784 | } catch (err) { setStatus(`Couldn't share: ${String(err && err.message || err)}`, 'bad'); } |
| 785 | }); |
| 786 | function shareBox(url) { |
| 787 | const box = doc.createElement('div'); |
| 788 | box.className = 'ov-arrange-share'; |
| 789 | box.innerHTML = `<label>Share this arrangement<input type="text" readonly value="${esc(url)}" aria-label="Share link"></label><button type="button" class="ovk-btn ovk-btn--quiet" data-copy>Copy link</button>`; |
| 790 | host.querySelector('.ov-arrange-status').before(box); |
| 791 | const input = box.querySelector('input'); |
| 792 | box.querySelector('[data-copy]').addEventListener('click', async () => { |
| 793 | input.select(); |
| 794 | try { await navigator.clipboard.writeText(url); setStatus('Link copied.', 'ok'); } |
| 795 | catch (_) { setStatus('Select the link and copy it.', 'ok'); } |
| 796 | }); |
| 797 | } |
| 798 | function close({ force = false } = {}) { |
| 799 | if (closed) return; |
| 800 | if (dirty && !force && !confirmFn('Leave without saving your arrangement?')) return; |
| 801 | closed = true; |
| 802 | if (previewBar) previewBar.remove(); |
| 803 | if (sheet) sheet.remove(); |
| 804 | host.remove(); live.remove(); sel.remove(); handle.remove(); styleBtn.remove(); |
| 805 | doc.removeEventListener('pointermove', pointerMove); |
| 806 | doc.removeEventListener('pointerup', pointerUp); |
| 807 | doc.removeEventListener('pointercancel', pointerUp); |
| 808 | doc.removeEventListener('keydown', onDocKey); |
| 809 | root.removeEventListener('resize', positionSelection); |
| 810 | doc.removeEventListener('scroll', positionSelection, true); |
| 811 | surface.removeEventListener('pointerdown', onPartDown); |
| 812 | surface.removeEventListener('keydown', onKey); |
| 813 | if (surfaceStyle == null) surface.removeAttribute('style'); else surface.setAttribute('style', surfaceStyle); |
| 814 | surface.className = surfaceClass; |
| 815 | if (surfaceCustom == null) surface.removeAttribute('data-ov-custom'); else surface.setAttribute('data-ov-custom', surfaceCustom); |
| 816 | for (const [el, o] of originals) { |
| 817 | for (const a of ['data-ov-selected', 'data-ov-dragging', 'data-ov-refused', 'data-ov-flatten', 'data-ov-tint', 'data-ov-shape', 'data-ov-label', 'data-ov-keys', 'data-ov-gamepad', 'data-ov-dock', 'data-ov-order']) el.removeAttribute(a); |
| 818 | if (o.style == null) el.removeAttribute('style'); else el.setAttribute('style', o.style); |
| 819 | el.hidden = o.hidden; |
| 820 | if (o.tabindex == null) el.removeAttribute('tabindex'); else el.setAttribute('tabindex', o.tabindex); |
| 821 | if (o.role == null) el.removeAttribute('role'); else el.setAttribute('role', o.role); |
| 822 | if (o.label == null) el.removeAttribute('aria-label'); else el.setAttribute('aria-label', o.label); |
| 823 | el.className = o.cls; |
| 824 | } |
| 825 | for (const x of innerTab) { if (x.tabindex == null) x.el.removeAttribute('tabindex'); else x.el.setAttribute('tabindex', x.tabindex); } |
| 826 | for (const a of surface.querySelectorAll('[data-ov-flatten]')) a.removeAttribute('data-ov-flatten'); |
| 827 | if (typeof opts.onApply === 'function') try { opts.onApply(clone(working)); } catch (_) { /* */ } |
| 828 | if (typeof opts.onClose === 'function') try { opts.onClose(clone(working), { dirty }); } catch (_) { /* */ } |
| 829 | } |
| 830 | host.querySelector('[data-done]').addEventListener('click', () => close()); |
| 831 | |
| 832 | surface.classList.add('ov-arrange-editing'); |
| 833 | apply(working); |
| 834 | paintActions(); |
| 835 | positionSelection(); |
| 836 | root.addEventListener('resize', positionSelection, { passive: true }); |
| 837 | doc.addEventListener('scroll', positionSelection, { passive: true, capture: true }); |
| 838 | setStatus('Arrange: drag a part, or use the arrow keys. Shift with the arrows resizes.'); |
| 839 | return { close, apply: (lay) => { working = normal(lay); apply(working); }, layout: () => clone(working), destroy: () => close({ force: true }), get core() { return core; } }; |
| 840 | } |
| 841 | |
| 842 | root.OVArrange = { open, validate: fallbackValidate, defaults: fallbackDefaults, _fallback: { validate: fallbackValidate, defaults: fallbackDefaults }, VERSION }; |
| 843 | loadCore().then((c) => { if (c) { fallbackValidate.sdk = c; root.OVArrange.core = c; } }).catch(() => { /* the fallback rules stay */ }); |
| 844 | })(typeof window !== 'undefined' ? window : globalThis); |
| 845 |