PageSourceSearch

https://portal.miira.app/manage/control-library/model.js?v=20260925-control-library-pinned-1

js miira.app collected 2026-10-02 16:01:42 UTC 15,318 bytes, 355 lines download raw bytes

1/*
2 * Miira Control library — the builder's draft layout.
3 *
4 * Every edit the builder makes goes through one of these methods, and each one bumps `gen` and
5 * notifies subscribers. That is the whole dirty-tracking story: the builder autosaves when `gen`
6 * moves, and nothing listens for raw `input` events on the page (the Data Room's blanket dirty
7 * listener marks any stray control as an edit; this deliberately cannot).
8 *
9 * Shapes are those src/services/controlLibrary/layout.ts accepts; the server is still the gate.
10 * `payload()` sends null when the draft equals the default layout, so a project that was edited
11 * back to today's tabs stops storing a layout at all and keeps following future defaults.
12 *
13 * Dual-export shim: window.MiiraControlLibraryModel in the browser, CommonJS under Jest.
14 */
15(function (root, factory) {
16    var resolve =
17        typeof module !== 'undefined' && module.exports ? require('./resolve') : root.MiiraControlLibraryResolve;
18    var api = factory(resolve);
19    if (typeof module !== 'undefined' && module.exports) {
20        module.exports = api;
21    } else {
22        root.MiiraControlLibraryModel = api;
23    }
24})(typeof self !== 'undefined' ? self : this, function (R) {
25    'use strict';
26
27    /* Mirrors of the server caps (layout.ts). The server enforces them; these only stop the
28       builder offering an action the save would refuse. */
29    var MAX_TABS = 8;
30    var MAX_FOLDERS = 40;
31    var MAX_IDS = 500;
32    var MAX_LABEL = 32;
33
34    var RULE_TYPES = ['video', 'render', 'floor_plan', 'floorplate', 'brochure', 'pricelist', 'supporting_file'];
35
36    function clone(value) {
37        return JSON.parse(JSON.stringify(value));
38    }
39
40    function mintId(prefix, taken) {
41        var id;
42        do {
43            id = prefix + Math.random().toString(36).slice(2, 8);
44        } while (taken.has(id));
45        return id;
46    }
47
48    function allIds(layout) {
49        var ids = new Set();
50        layout.tabs.forEach(function (tab) {
51            ids.add(tab.id);
52            (tab.folders || []).forEach(function (f) { ids.add(f.id); });
53        });
54        return ids;
55    }
56
57    function cleanLabel(value, fallback) {
58        var label = String(value == null ? '' : value).replace(/\s+/g, ' ').trim().slice(0, MAX_LABEL);
59        return label || fallback;
60    }
61
62    /** Video and Floor Plans are always in the library: they can be hidden, never removed. */
63    function isPinnedTab(tab) {
64        return !!tab && tab.kind === 'preset' && (tab.preset === 'videos' || R.isFloorPlanTab(tab));
65    }
66
67    /** A layout saved before tabs were pinned may lack one; it comes back hidden, as it was. */
68    function withPinnedTabs(layout) {
69        var taken = allIds(layout);
70        function restore(preset, preferredId) {
71            var id = taken.has(preferredId) ? mintId('tab-', taken) : preferredId;
72            taken.add(id);
73            layout.tabs.push({ id: id, label: R.PRESET_LABELS[preset], kind: 'preset', preset: preset, hidden: true });
74        }
75        if (!layout.tabs.some(function (t) { return t.kind === 'preset' && t.preset === 'videos'; })) {
76            restore('videos', 'preset-videos');
77        }
78        if (!layout.tabs.some(R.isFloorPlanTab)) restore('floor_plan_levels', 'preset-floor_plans');
79        return layout;
80    }
81
82    function readLayout(stored) {
83        if (stored && typeof stored === 'object' && Array.isArray(stored.tabs)) {
84            return withPinnedTabs({ version: R.LAYOUT_VERSION, tabs: R.mergeFloorPlanTabs(clone(stored.tabs)) });
85        }
86        return R.defaultLayout();
87    }
88
89    /** Same content as the default, ignoring ids (a re-added preset gets a fresh one). */
90    function isDefaultLayout(layout) {
91        var def = R.defaultLayout().tabs;
92        if (layout.tabs.length !== def.length) return false;
93        return layout.tabs.every(function (tab, i) {
94            return (
95                tab.kind === 'preset' &&
96                tab.preset === def[i].preset &&
97                tab.label === def[i].label &&
98                !tab.hidden
99            );
100        });
101    }
102
103    /**
104     * @param storedLayout the project's saved layout, or null. A saved Floor Plans + Levels pair
105     *        reads as the one merged tab (resolve.js mergeFloorPlanTabs).
106     */
107    function createDraft(storedLayout) {
108        var layout = readLayout(storedLayout);
109        var listeners = [];
110        var draft = { gen: 0 };
111
112        function changed(detail) {
113            draft.gen += 1;
114            listeners.forEach(function (fn) { fn(detail || {}); });
115        }
116
117        function tabById(tabId) {
118            return layout.tabs.find(function (t) { return t.id === tabId; }) || null;
119        }
120
121        function folderById(tab, folderId) {
122            return tab && tab.folders ? tab.folders.find(function (f) { return f.id === folderId; }) || null : null;
123        }
124
125        /** The thing that owns a source: a list tab, or one folder of a folders tab. */
126        function sourceOwner(target) {
127            var tab = tabById(target && target.tabId);
128            if (!tab) return null;
129            if (target.folderId) return folderById(tab, target.folderId);
130            return tab.kind === 'list' ? tab : null;
131        }
132
133        function move(list, from, to) {
134            if (from < 0 || from >= list.length) return false;
135            var clamped = Math.max(0, Math.min(list.length - 1, to));
136            if (clamped === from) return false;
137            var item = list.splice(from, 1)[0];
138            list.splice(clamped, 0, item);
139            return true;
140        }
141
142        draft.subscribe = function (fn) {
143            listeners.push(fn);
144            return function () {
145                listeners = listeners.filter(function (l) { return l !== fn; });
146            };
147        };
148        draft.snapshot = function () { return clone(layout); };
149        draft.tab = function (tabId) { var t = tabById(tabId); return t ? clone(t) : null; };
150        draft.isDefault = function () { return isDefaultLayout(layout); };
151        /** Whether the app will show a Levels tab — the server only tags levels when it does. */
152        draft.showsLevels = function () {
153            return layout.tabs.some(function (t) { return t.kind === 'preset' && t.preset === 'floor_plan_levels' && !t.hidden; });
154        };
155        draft.payload = function () { return isDefaultLayout(layout) ? null : clone(layout); };
156        draft.canAddTab = function () { return layout.tabs.length < MAX_TABS; };
157        /** Presets that can still be added. The floor-plan tab is offered once, as Levels. */
158        draft.availablePresets = function () {
159            var used = new Set(layout.tabs.filter(function (t) { return t.kind === 'preset'; }).map(function (t) { return t.preset; }));
160            var hasFloorPlans = layout.tabs.some(R.isFloorPlanTab);
161            return R.PRESETS.filter(function (p) {
162                if (p === 'floor_plans') return false;
163                if (p === 'floor_plan_levels') return !hasFloorPlans;
164                return !used.has(p);
165            });
166        };
167        /** 'levels' or 'all' for the floor-plan tab, else null. */
168        draft.floorPlanView = function (tabId) {
169            var tab = tabById(tabId);
170            if (!R.isFloorPlanTab(tab)) return null;
171            return tab.preset === 'floor_plans' ? 'all' : 'levels';
172        };
173        /** The view switch: the tab keeps its id, name and slot, only its preset changes. */
174        draft.setFloorPlanView = function (tabId, view) {
175            var tab = tabById(tabId);
176            if (!R.isFloorPlanTab(tab)) return;
177            var next = view === 'all' ? 'floor_plans' : 'floor_plan_levels';
178            if (tab.preset === next) return;
179            tab.preset = next;
180            changed({ tabId: tabId });
181        };
182
183        draft.addPreset = function (preset) {
184            if (!draft.canAddTab() || draft.availablePresets().indexOf(preset) === -1) return null;
185            var id = mintId('tab-', allIds(layout));
186            layout.tabs.push({ id: id, label: R.PRESET_LABELS[preset], kind: 'preset', preset: preset });
187            changed({ tabId: id });
188            return id;
189        };
190        draft.addList = function () {
191            if (!draft.canAddTab()) return null;
192            var id = mintId('tab-', allIds(layout));
193            layout.tabs.push({ id: id, label: 'Collection', kind: 'list', source: { mode: 'manual', mediaIds: [] } });
194            changed({ tabId: id });
195            return id;
196        };
197        draft.addFolders = function () {
198            if (!draft.canAddTab()) return null;
199            var taken = allIds(layout);
200            var id = mintId('tab-', taken);
201            taken.add(id);
202            layout.tabs.push({
203                id: id,
204                label: 'Folders',
205                kind: 'folders',
206                folders: [{ id: mintId('folder-', taken), label: 'Folder', source: { mode: 'manual', mediaIds: [] } }],
207            });
208            changed({ tabId: id });
209            return id;
210        };
211        draft.isPinned = function (tabId) { return isPinnedTab(tabById(tabId)); };
212        draft.removeTab = function (tabId) {
213            var i = layout.tabs.findIndex(function (t) { return t.id === tabId; });
214            if (i === -1 || isPinnedTab(layout.tabs[i])) return false;
215            layout.tabs.splice(i, 1);
216            changed({ removedTabId: tabId });
217            return true;
218        };
219        draft.moveTab = function (from, to) {
220            if (move(layout.tabs, from, to)) changed({ reorder: 'tabs' });
221        };
222        draft.renameTab = function (tabId, label) {
223            var tab = tabById(tabId);
224            if (!tab) return;
225            var fallback = tab.kind === 'preset' ? R.PRESET_LABELS[tab.preset] : tab.label;
226            var next = cleanLabel(label, fallback);
227            if (next === tab.label) return;
228            tab.label = next;
229            changed({ tabId: tabId });
230        };
231        draft.setTabHidden = function (tabId, hidden) {
232            var tab = tabById(tabId);
233            if (!tab || !!tab.hidden === !!hidden) return;
234            if (hidden) tab.hidden = true;
235            else delete tab.hidden;
236            changed({ tabId: tabId });
237        };
238
239        draft.addFolder = function (tabId) {
240            var tab = tabById(tabId);
241            if (!tab || tab.kind !== 'folders' || tab.folders.length >= MAX_FOLDERS) return null;
242            var id = mintId('folder-', allIds(layout));
243            tab.folders.push({ id: id, label: 'Folder', source: { mode: 'manual', mediaIds: [] } });
244            changed({ tabId: tabId, folderId: id });
245            return id;
246        };
247        draft.removeFolder = function (tabId, folderId) {
248            var tab = tabById(tabId);
249            if (!tab || !tab.folders) return;
250            var i = tab.folders.findIndex(function (f) { return f.id === folderId; });
251            if (i === -1) return;
252            tab.folders.splice(i, 1);
253            changed({ tabId: tabId, removedFolderId: folderId });
254        };
255        draft.moveFolder = function (tabId, from, to) {
256            var tab = tabById(tabId);
257            if (tab && tab.folders && move(tab.folders, from, to)) changed({ tabId: tabId, reorder: 'folders' });
258        };
259        draft.renameFolder = function (tabId, folderId, label) {
260            var folder = folderById(tabById(tabId), folderId);
261            if (!folder) return;
262            var next = cleanLabel(label, folder.label);
263            if (next === folder.label) return;
264            folder.label = next;
265            changed({ tabId: tabId, folderId: folderId });
266        };
267        draft.setFolderCover = function (tabId, folderId, mediaId) {
268            var folder = folderById(tabById(tabId), folderId);
269            if (!folder) return;
270            var next = mediaId ? String(mediaId) : '';
271            if ((folder.coverMediaId || '') === next) return;
272            if (next) folder.coverMediaId = next;
273            else delete folder.coverMediaId;
274            changed({ tabId: tabId, folderId: folderId });
275        };
276
277        draft.source = function (target) {
278            var owner = sourceOwner(target);
279            return owner ? clone(owner.source) : null;
280        };
281        /** Switch Manual <-> Rules. Each mode starts empty rather than guessing from the other. */
282        draft.setSourceMode = function (target, mode) {
283            var owner = sourceOwner(target);
284            if (!owner || owner.source.mode === mode) return;
285            owner.source = mode === 'rules' ? { mode: 'rules', types: [] } : { mode: 'manual', mediaIds: [] };
286            changed(target);
287        };
288        draft.toggleManualItem = function (target, mediaId) {
289            var owner = sourceOwner(target);
290            if (!owner || owner.source.mode !== 'manual') return;
291            var ids = owner.source.mediaIds;
292            var id = String(mediaId);
293            var i = ids.indexOf(id);
294            if (i === -1) {
295                if (ids.length >= MAX_IDS) return;
296                ids.push(id);
297            } else {
298                ids.splice(i, 1);
299            }
300            changed(target);
301        };
302        draft.moveManualItem = function (target, from, to) {
303            var owner = sourceOwner(target);
304            if (owner && owner.source.mode === 'manual' && move(owner.source.mediaIds, from, to)) changed(target);
305        };
306        draft.toggleRuleType = function (target, type) {
307            var owner = sourceOwner(target);
308            if (!owner || owner.source.mode !== 'rules' || RULE_TYPES.indexOf(type) === -1) return;
309            var types = owner.source.types;
310            var i = types.indexOf(type);
311            if (i === -1) types.push(type);
312            else types.splice(i, 1);
313            changed(target);
314        };
315        draft.setRuleTitle = function (target, text) {
316            var owner = sourceOwner(target);
317            if (!owner || owner.source.mode !== 'rules') return;
318            var next = String(text == null ? '' : text).trim().slice(0, 60);
319            if ((owner.source.titleContains || '') === next) return;
320            if (next) owner.source.titleContains = next;
321            else delete owner.source.titleContains;
322            changed(target);
323        };
324        draft.setRuleSort = function (target, sort) {
325            var owner = sourceOwner(target);
326            if (!owner || owner.source.mode !== 'rules') return;
327            var next = sort === 'newest' ? 'newest' : 'title';
328            if ((owner.source.sort || 'title') === next) return;
329            if (next === 'newest') owner.source.sort = 'newest';
330            else delete owner.source.sort;
331            changed(target);
332        };
333
334        /** Replace the whole draft (a project switch, or a fresh read after a conflict). */
335        draft.reset = function (storedLayout) {
336            layout = readLayout(storedLayout);
337            changed({ reset: true });
338        };
339        draft.resetToDefault = function () {
340            layout = R.defaultLayout();
341            changed({ reset: true });
342        };
343
344        return draft;
345    }
346
347    return {
348        MAX_TABS: MAX_TABS,
349        MAX_FOLDERS: MAX_FOLDERS,
350        MAX_LABEL: MAX_LABEL,
351        RULE_TYPES: RULE_TYPES,
352        createDraft: createDraft,
353        isDefaultLayout: isDefaultLayout,
354    };
355});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.