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.