openvibers/openvibe.shared ov-arrange-core.js

ov-arrange-core.js

main 16.4 KB · 316 lines · javascript

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 'use strict';
8 if (!root || root.OVArrangeCore) return;
9 const __ovArrangeCoreModule = { exports: {} };
10'use strict';
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
35const DEVICE_PX = { phone: 44, tablet: 44, computer: 44 };
36const DEVICE_COLUMNS = { phone: 4, tablet: 8, computer: 12 };
37const PRESSED_PX = 44;
38const MIN_OPACITY = 0.4;
39const MAX_OPACITY = 1;
40const URL_RE = /(?:https?:\/\/|www\.)/i;
41const SCHEME_RE = /(?:^|[\s"'(])(?:javascript|data|vbscript|file):/i;
42const 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
44const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
45const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
46const nameOf = (part) => String((part && part.name) || (part && part.id) || 'that part');
47const unique = (list) => [...new Set(list)];
48
49/** Every string (and object key) in a value, for the no-URL/no-code rule. */
50function 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. */
59function 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). */
68function 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
76function 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. */
81function 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 */
92function 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. */
215function 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 */
230function 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
276function 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 */
292function 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