openvibers/openvibe.shared ov-arrange-core.js
ov-arrange-core.js
| 1 | /* Generated by scripts/build-arrange-core.js from openvibe-sdk 0.160.0 (src/ui-layout.js). Do not edit: |
| 2 | * change the SDK, release it, bump the pin, and run the script. The SDK's file, unchanged but for the name of |
| 3 | * module.exports: |
| 4 | * ui-layout.js 4a079a94b487fd25 |
| 5 | */ |
| 6 | (function (root) { |
| 7 | ; |
| 8 | if (!root || root.OVArrangeCore) return; |
| 9 | const __ovArrangeCoreModule = { exports: {} }; |
| 10 | ; |
| 11 | /** |
| 12 | * ui.layout validation and editing (make your own controls design, 2026-11-11, pieces 2-3). |
| 13 | * |
| 14 | * One function is the authority for a person's arrangement of a surface: the server uses it before |
| 15 | * a layout is stored, the editor uses it before Save, and OpenVibe.AI uses it on every answer. It is |
| 16 | * plain code with no browser or Node dependency, so the same check runs everywhere. |
| 17 | * |
| 18 | * validate(catalog, layout) -> { ok, reasons[] } the catalog is ui.surface@1, the layout ui.layout@1 |
| 19 | * applyDefaults(catalog) -> a valid layout for the default (or named) device |
| 20 | * merge(layout, patch) -> a new layout, patched by part id |
| 21 | * |
| 22 | * The rules, in the words a person sees (the design note's "Can't do that: <reason>"): |
| 23 | * - required parts present, visible and at their minimum size; |
| 24 | * - anything pressed is at least 44 px, and a part above that gets its own minimum (Stop 64 px); |
| 25 | * - nothing covers a required part, and a catalog's never_covers rules hold; |
| 26 | * - every tint is a theme token the catalog lists, and every style and binding is one the part allows; |
| 27 | * - only parts the catalog has; |
| 28 | * - no URL, markup or code anywhere: a layout is data. |
| 29 | * |
| 30 | * The validator never changes a layout and never fills a missing value in: a caller that wants the |
| 31 | * default asks applyDefaults. Reasons are unique and in a stable order, so two runs and two callers |
| 32 | * agree on the refusal. |
| 33 | */ |
| 34 | |
| 35 | const DEVICE_PX = { phone: 44, tablet: 44, computer: 44 }; |
| 36 | const DEVICE_COLUMNS = { phone: 4, tablet: 8, computer: 12 }; |
| 37 | const PRESSED_PX = 44; |
| 38 | const MIN_OPACITY = 0.4; |
| 39 | const MAX_OPACITY = 1; |
| 40 | const URL_RE = /(?:https?:\/\/|www\.)/i; |
| 41 | const SCHEME_RE = /(?:^|[\s"'(])(?:javascript|data|vbscript|file):/i; |
| 42 | const CODE_RE = /(?:<\s*\/?\s*(?:script|iframe|img|svg|style|a|object|embed)\b|\bon[a-z]{3,}\s*=|\bfunction\s*\(|=>|\beval\s*\(|\brequire\s*\(|\bimport\s*\()/i; |
| 43 | |
| 44 | const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); |
| 45 | const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v))); |
| 46 | const nameOf = (part) => String((part && part.name) || (part && part.id) || 'that part'); |
| 47 | const unique = (list) => [...new Set(list)]; |
| 48 | |
| 49 | /** Every string (and object key) in a value, for the no-URL/no-code rule. */ |
| 50 | function stringsIn(value, out = []) { |
| 51 | if (typeof value === 'string') { out.push(value); return out; } |
| 52 | if (Array.isArray(value)) { for (const v of value) stringsIn(v, out); return out; } |
| 53 | if (!isObject(value)) return out; |
| 54 | for (const [k, v] of Object.entries(value)) { out.push(k); stringsIn(v, out); } |
| 55 | return out; |
| 56 | } |
| 57 | |
| 58 | /** A part's size in cells: grid place (w/h) or dock place (w/h); null when the place is unusable. */ |
| 59 | function sizeOf(place) { |
| 60 | if (!isObject(place)) return null; |
| 61 | if (place.dock !== undefined) { |
| 62 | return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null; |
| 63 | } |
| 64 | return Number.isInteger(place.w) && Number.isInteger(place.h) && place.w >= 1 && place.h >= 1 ? { w: place.w, h: place.h } : null; |
| 65 | } |
| 66 | |
| 67 | /** The rectangular cells a visible part occupies, or null for a dock (which covers along its edge). */ |
| 68 | function boxOf(part) { |
| 69 | const p = part && part.place; |
| 70 | if (!isObject(p) || p.dock !== undefined) return null; |
| 71 | if (![p.x, p.y, p.w, p.h].every(Number.isInteger)) return null; |
| 72 | if (p.x < 0 || p.y < 0 || p.w < 1 || p.h < 1) return null; |
| 73 | return { x: p.x, y: p.y, w: p.w, h: p.h }; |
| 74 | } |
| 75 | |
| 76 | function overlaps(a, b) { |
| 77 | return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; |
| 78 | } |
| 79 | |
| 80 | /** Two docked parts cover each other only when they claim the same edge and order. */ |
| 81 | function dockOverlaps(a, b) { |
| 82 | const pa = a && a.place; |
| 83 | const pb = b && b.place; |
| 84 | return isObject(pa) && isObject(pb) && pa.dock !== undefined && pb.dock !== undefined |
| 85 | && pa.dock === pb.dock && pa.order === pb.order; |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * Check one layout against its surface catalog. |
| 90 | * @returns {{ ok: boolean, reasons: string[] }} |
| 91 | */ |
| 92 | function validate(catalog, layout) { |
| 93 | const reasons = []; |
| 94 | const fail = (reason) => { if (!reasons.includes(reason)) reasons.push(reason); }; |
| 95 | if (!isObject(catalog) || !Array.isArray(catalog.parts)) { |
| 96 | return { ok: false, reasons: ['The surface catalog is missing.'] }; |
| 97 | } |
| 98 | if (!isObject(layout)) return { ok: false, reasons: ['A layout has to be an object.'] }; |
| 99 | |
| 100 | // A layout is data: never a URL, markup or code, anywhere in a name, a value or a key. |
| 101 | if (stringsIn(layout).some((s) => URL_RE.test(s) || SCHEME_RE.test(s) || CODE_RE.test(s))) { |
| 102 | fail("A layout is data only: it can't carry a URL or code."); |
| 103 | } |
| 104 | |
| 105 | if (layout.surface !== catalog.id) fail(`This layout is not for ${catalog.id || 'this surface'}.`); |
| 106 | if (layout.surface_version !== catalog.version) { |
| 107 | fail(`${catalog.name || 'This surface'} has changed since this layout was made; reset it to the default.`); |
| 108 | } |
| 109 | const device = layout.device; |
| 110 | const devices = Array.isArray(catalog.devices) ? catalog.devices : ['phone', 'tablet', 'computer']; |
| 111 | if (!devices.includes(device)) fail(`This surface doesn't come in ${device || 'that device'}.`); |
| 112 | |
| 113 | const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[device]) || DEVICE_COLUMNS[device] || 4; |
| 114 | const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[device]) || DEVICE_PX[device] || 44; |
| 115 | const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : []; |
| 116 | const byId = new Map(catalog.parts.filter((p) => p && p.id).map((p) => [p.id, p])); |
| 117 | const parts = Array.isArray(layout.parts) ? layout.parts : []; |
| 118 | const placed = new Map(); |
| 119 | |
| 120 | if (!Array.isArray(layout.parts) || layout.parts.length === 0) fail('A layout needs at least one part.'); |
| 121 | |
| 122 | for (const placedPart of parts) { |
| 123 | if (!isObject(placedPart) || typeof placedPart.id !== 'string') { fail('Every part in a layout needs an id.'); continue; } |
| 124 | const source = byId.get(placedPart.id); |
| 125 | if (!source) { fail(`${placedPart.id} is not a part of ${catalog.name || 'this surface'}.`); continue; } |
| 126 | if (placed.has(placedPart.id)) { fail(`${nameOf(source)} appears more than once.`); continue; } |
| 127 | placed.set(placedPart.id, placedPart); |
| 128 | |
| 129 | const name = nameOf(source); |
| 130 | if (source.required && placedPart.visible !== true) fail(`${name} has to stay visible.`); |
| 131 | |
| 132 | const place = placedPart.place; |
| 133 | if (!isObject(place)) { fail(`${name} needs a place on the surface.`); continue; } |
| 134 | const size = sizeOf(place); |
| 135 | if (!size) { fail(`${name} needs a size, in cells.`); continue; } |
| 136 | if (place.dock !== undefined) { |
| 137 | if (!['left', 'right', 'top', 'bottom'].includes(place.dock)) fail(`${name} can only dock to an edge.`); |
| 138 | if (!Number.isInteger(place.order) || place.order < 0) fail(`${name} needs a place in the dock order.`); |
| 139 | } else { |
| 140 | if (!Number.isInteger(place.x) || place.x < 0 || !Number.isInteger(place.y) || place.y < 0) fail(`${name} needs a place on the grid.`); |
| 141 | else if (place.x + place.w > columns) fail(`${name} runs past the edge of the ${device} grid.`); |
| 142 | } |
| 143 | const min = source.min || {}; |
| 144 | if (size.w < (min.w || 1) || size.h < (min.h || 1)) { |
| 145 | fail(`${name} has to be at least ${Math.max(min.w || 1, 1)} × ${Math.max(min.h || 1, 1)} cells.`); |
| 146 | } |
| 147 | const needPx = Math.max(source.pressed ? PRESSED_PX : 0, Number(min.px) || 0); |
| 148 | if (needPx && (size.w * cellPx < needPx || size.h * cellPx < needPx)) fail(`${name} has to be at least ${needPx} px.`); |
| 149 | |
| 150 | const style = isObject(placedPart.style) ? placedPart.style : {}; |
| 151 | const allowedStyles = isObject(source.styles) ? source.styles : {}; |
| 152 | if (typeof style.tint !== 'string' || !tokens.includes(style.tint)) { |
| 153 | fail(`${name}'s tint has to be one of the theme's tokens${tokens.length ? ` (${tokens.join(', ')})` : ''}.`); |
| 154 | } else if (Array.isArray(allowedStyles.tints) && !allowedStyles.tints.includes(style.tint)) { |
| 155 | fail(`${name} can only be tinted ${allowedStyles.tints.join(', ')}.`); |
| 156 | } |
| 157 | if (!['round', 'pill', 'square'].includes(style.shape)) fail(`${name} needs a shape: round, pill or square.`); |
| 158 | else if (Array.isArray(allowedStyles.shapes) && !allowedStyles.shapes.includes(style.shape)) { |
| 159 | fail(`${name} can only be ${allowedStyles.shapes.join(', ')}.`); |
| 160 | } |
| 161 | const low = Math.max(MIN_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.min) || MIN_OPACITY); |
| 162 | const high = Math.min(MAX_OPACITY, Number(allowedStyles.opacity && allowedStyles.opacity.max) || MAX_OPACITY); |
| 163 | if (typeof style.opacity !== 'number' || style.opacity < low || style.opacity > high) { |
| 164 | fail(`${name}'s opacity has to be between ${low} and ${high}.`); |
| 165 | } |
| 166 | if (style.label !== true && style.label !== false) fail(`${name} says whether its label is on.`); |
| 167 | else if (style.label && allowedStyles.label === false) fail(`${name} can't show a label.`); |
| 168 | |
| 169 | const bindings = isObject(placedPart.bindings) ? placedPart.bindings : {}; |
| 170 | const allowed = isObject(source.bindings) ? source.bindings : {}; |
| 171 | for (const kind of ['keys', 'gamepad']) { |
| 172 | const list = Array.isArray(bindings[kind]) ? bindings[kind] : []; |
| 173 | const ok = Array.isArray(allowed[kind]) ? allowed[kind] : []; |
| 174 | const bad = list.filter((b) => !ok.includes(b)); |
| 175 | if (bad.length) fail(`${name} can't be bound to ${bad.join(', ')}.`); |
| 176 | if (new Set(list).size !== list.length) fail(`${name} binds ${kind} more than once.`); |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | for (const source of catalog.parts) { |
| 181 | if (source && source.required && !placed.has(source.id)) fail(`${nameOf(source)} has to stay on the surface.`); |
| 182 | } |
| 183 | |
| 184 | // Nothing covers a required part. Only a visible part can cover anything, and a required part |
| 185 | // that is itself hidden is already refused above; its neighbours still do not cover it. |
| 186 | const visible = parts.filter((p) => p && p.visible === true && placed.has(p.id)); |
| 187 | for (const required of visible) { |
| 188 | const source = byId.get(required.id); |
| 189 | if (!source || !source.required) continue; |
| 190 | for (const other of visible) { |
| 191 | if (other === required || other.id === required.id) continue; |
| 192 | const covers = overlaps(boxOf(required) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(other) || { x: 0, y: 0, w: 0, h: 0 }) |
| 193 | || dockOverlaps(required, other); |
| 194 | if (covers) fail(`${nameOf(byId.get(other.id))} covers ${nameOf(source)}.`); |
| 195 | } |
| 196 | } |
| 197 | // A catalog's own never_covers rules, in either direction of the relation it states. |
| 198 | for (const [id, source] of byId) { |
| 199 | const own = placed.get(id); |
| 200 | if (!own || own.visible !== true) continue; |
| 201 | for (const targetId of Array.isArray(source.never_covers) ? source.never_covers : []) { |
| 202 | const target = placed.get(targetId); |
| 203 | if (!target || target.visible !== true) continue; |
| 204 | const targetSource = byId.get(targetId); |
| 205 | if (overlaps(boxOf(own) || { x: 0, y: 0, w: 0, h: 0 }, boxOf(target) || { x: 0, y: 0, w: 0, h: 0 }) || dockOverlaps(own, target)) { |
| 206 | fail(`${nameOf(source)} covers ${nameOf(targetSource || { id: targetId })}.`); |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | return { ok: reasons.length === 0, reasons }; |
| 212 | } |
| 213 | |
| 214 | /** The cells a part needs at the default size: its own minimum, and at least 44 px when pressed. */ |
| 215 | function cellsFor(part, cellPx) { |
| 216 | const min = part.min || {}; |
| 217 | const px = Math.max(part.pressed ? PRESSED_PX : 0, Number(min.px) || 0); |
| 218 | return { |
| 219 | w: Math.max(1, Number(min.w) || 1, px ? Math.ceil(px / cellPx) : 0), |
| 220 | h: Math.max(1, Number(min.h) || 1, px ? Math.ceil(px / cellPx) : 0), |
| 221 | }; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * The default arrangement of a surface: every catalog part, visible, at its minimum (or a size that |
| 226 | * meets the 44 px rule), required parts first, laid out left to right and then down the grid. |
| 227 | * @param {object} catalog ui.surface@1 |
| 228 | * @param {'phone'|'tablet'|'computer'} [device] defaults to the catalog's first device |
| 229 | */ |
| 230 | function applyDefaults(catalog, device) { |
| 231 | if (!isObject(catalog) || !Array.isArray(catalog.parts)) throw new TypeError('applyDefaults: a ui.surface@1 catalog is required'); |
| 232 | const chosen = (Array.isArray(catalog.devices) && catalog.devices.includes(device)) ? device : (catalog.devices || ['phone'])[0]; |
| 233 | const columns = (catalog.grid && catalog.grid.columns && catalog.grid.columns[chosen]) || DEVICE_COLUMNS[chosen] || 4; |
| 234 | const cellPx = (catalog.grid && catalog.grid.cell_px && catalog.grid.cell_px[chosen]) || DEVICE_PX[chosen] || 44; |
| 235 | const ordered = [...catalog.parts].sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0)); |
| 236 | let x = 0; let y = 0; let rowHeight = 0; |
| 237 | const parts = ordered.map((part) => { |
| 238 | const size = cellsFor(part, cellPx); |
| 239 | const w = Math.min(columns, size.w); |
| 240 | const h = size.h; |
| 241 | if (x + w > columns) { x = 0; y += rowHeight; rowHeight = 0; } |
| 242 | const place = { x, y, w, h }; |
| 243 | x += w; |
| 244 | rowHeight = Math.max(rowHeight, h); |
| 245 | const styles = isObject(part.styles) ? part.styles : {}; |
| 246 | const bindings = isObject(part.bindings) ? part.bindings : {}; |
| 247 | const tokens = Array.isArray(catalog.theme_tokens) ? catalog.theme_tokens : []; |
| 248 | const tints = Array.isArray(styles.tints) ? styles.tints : []; |
| 249 | return { |
| 250 | id: part.id, |
| 251 | visible: true, |
| 252 | place, |
| 253 | style: { |
| 254 | tint: tints.find((t) => tokens.includes(t)) || tints[0] || tokens[0] || 'surface', |
| 255 | shape: (Array.isArray(styles.shapes) && styles.shapes[0]) || 'square', |
| 256 | opacity: 1, |
| 257 | label: styles.label !== false, |
| 258 | }, |
| 259 | bindings: { |
| 260 | keys: Array.isArray(bindings.keys) ? [...bindings.keys] : [], |
| 261 | gamepad: Array.isArray(bindings.gamepad) ? [...bindings.gamepad] : [], |
| 262 | }, |
| 263 | }; |
| 264 | }); |
| 265 | return { |
| 266 | surface: catalog.id, |
| 267 | surface_version: catalog.version, |
| 268 | device: chosen, |
| 269 | name: 'Default', |
| 270 | author: { name: 'OpenVibe' }, |
| 271 | sharing: 'private', |
| 272 | parts, |
| 273 | }; |
| 274 | } |
| 275 | |
| 276 | function mergeInto(base, patch) { |
| 277 | if (!isObject(base)) return clone(patch); |
| 278 | if (!isObject(patch)) return clone(patch); |
| 279 | const out = clone(base); |
| 280 | for (const [key, value] of Object.entries(patch)) { |
| 281 | if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value); |
| 282 | else out[key] = clone(value); |
| 283 | } |
| 284 | return out; |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * A new layout with `patch` applied. `parts` are merged by id: a patch part changes only the fields it |
| 289 | * names, and a part the layout does not have is appended. Nothing is mutated, and the result is not |
| 290 | * validated (the caller validates before storing or showing it). |
| 291 | */ |
| 292 | function merge(layout, patch) { |
| 293 | if (!isObject(layout)) throw new TypeError('merge: a layout is required'); |
| 294 | if (!isObject(patch)) return clone(layout); |
| 295 | const out = clone(layout); |
| 296 | for (const [key, value] of Object.entries(patch)) { |
| 297 | if (key === 'parts' && Array.isArray(value)) { |
| 298 | const parts = Array.isArray(out.parts) ? out.parts.map(clone) : []; |
| 299 | const index = new Map(parts.map((p, i) => [p && p.id, i])); |
| 300 | for (const incoming of value) { |
| 301 | if (!isObject(incoming) || typeof incoming.id !== 'string') { parts.push(clone(incoming)); continue; } |
| 302 | if (index.has(incoming.id)) parts[index.get(incoming.id)] = mergeInto(parts[index.get(incoming.id)], incoming); |
| 303 | else { index.set(incoming.id, parts.length); parts.push(clone(incoming)); } |
| 304 | } |
| 305 | out.parts = parts; |
| 306 | } else if (isObject(value) && isObject(out[key])) out[key] = mergeInto(out[key], value); |
| 307 | else out[key] = clone(value); |
| 308 | } |
| 309 | return out; |
| 310 | } |
| 311 | |
| 312 | __ovArrangeCoreModule.exports = { validate, applyDefaults, merge, PRESSED_PX }; |
| 313 | |
| 314 | root.OVArrangeCore = __ovArrangeCoreModule.exports; |
| 315 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 316 |