228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
(function defineSidebarController(root, factory) {
|
|
const treeApi = typeof module !== 'undefined' && module.exports
|
|
? require('./device-tree')
|
|
: root && root.AptDeviceTree;
|
|
const api = factory(treeApi);
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
if (root) root.AptSidebarController = api;
|
|
}(typeof window !== 'undefined' ? window : undefined, function sidebarFactory(treeApi) {
|
|
function validKey(key) {
|
|
return typeof key === 'string' && /^(site|group|camera):[^\s]+$/.test(key);
|
|
}
|
|
|
|
function copySet(value) {
|
|
return value instanceof Set ? new Set(value) : new Set();
|
|
}
|
|
|
|
function createInitialState(initial = {}) {
|
|
return {
|
|
expandedKeys: copySet(initial.expandedKeys),
|
|
activeKey: validKey(initial.activeKey) ? initial.activeKey : null,
|
|
selectedKey: validKey(initial.selectedKey) ? initial.selectedKey : null,
|
|
searchQuery: typeof initial.searchQuery === 'string' ? initial.searchQuery : '',
|
|
searchCameraKeys: initial.searchCameraKeys instanceof Set ? new Set(initial.searchCameraKeys) : null,
|
|
searchExpandedKeys: initial.searchExpandedKeys instanceof Set ? new Set(initial.searchExpandedKeys) : null,
|
|
searchGeneration: Number.isInteger(initial.searchGeneration) ? initial.searchGeneration : 0,
|
|
scrollTop: Number.isFinite(initial.scrollTop) ? Math.max(0, initial.scrollTop) : 0,
|
|
viewportHeight: Number.isFinite(initial.viewportHeight) ? Math.max(0, initial.viewportHeight) : 320,
|
|
};
|
|
}
|
|
|
|
function findRow(rows, key) {
|
|
if (!validKey(key) || !Array.isArray(rows)) return null;
|
|
return rows.find((row) => row && row.key === key) || null;
|
|
}
|
|
|
|
function expansionState(state) {
|
|
return state.searchQuery && state.searchExpandedKeys instanceof Set
|
|
? { field: 'searchExpandedKeys', keys: state.searchExpandedKeys }
|
|
: { field: 'expandedKeys', keys: state.expandedKeys };
|
|
}
|
|
|
|
function changeExpansion(state, row, force) {
|
|
if (!row || row.kind === 'camera') return state;
|
|
const current = expansionState(state);
|
|
const expandedKeys = copySet(current.keys);
|
|
const shouldExpand = force === undefined ? !expandedKeys.has(row.key) : force;
|
|
if (shouldExpand) expandedKeys.add(row.key);
|
|
else expandedKeys.delete(row.key);
|
|
return Object.assign({}, state, { [current.field]: expandedKeys, activeKey: row.key });
|
|
}
|
|
|
|
function activate(state, row) {
|
|
if (!row) return state;
|
|
if (row.kind === 'camera') return Object.assign({}, state, { activeKey: row.key, selectedKey: row.key });
|
|
return changeExpansion(state, row);
|
|
}
|
|
|
|
function moveActive(state, rows, targetIndex) {
|
|
if (!rows.length) return state;
|
|
const index = Math.max(0, Math.min(rows.length - 1, targetIndex));
|
|
return Object.assign({}, state, { activeKey: rows[index].key });
|
|
}
|
|
|
|
function reduceSidebar(state, action, rows = []) {
|
|
if (!state || !action || typeof action.type !== 'string') return state;
|
|
if (action.type === 'ACTIVATE' || action.type === 'CLICK') {
|
|
return activate(state, findRow(rows, action.rowKey));
|
|
}
|
|
if (action.type === 'SET_SELECTION') {
|
|
if (!validKey(action.rowKey) || !action.rowKey.startsWith('camera:') ||
|
|
(!action.allowHidden && !findRow(rows, action.rowKey))) return state;
|
|
return Object.assign({}, state, { selectedKey: action.rowKey, activeKey: action.rowKey });
|
|
}
|
|
if (action.type === 'SET_ACTIVE') {
|
|
const row = findRow(rows, action.rowKey);
|
|
return row ? Object.assign({}, state, { activeKey: row.key }) : state;
|
|
}
|
|
if (action.type === 'SET_VIEWPORT') {
|
|
const scrollTop = Number.isFinite(action.scrollTop) ? Math.max(0, action.scrollTop) : state.scrollTop;
|
|
const viewportHeight = Number.isFinite(action.viewportHeight) ? Math.max(0, action.viewportHeight) : state.viewportHeight;
|
|
if (scrollTop === state.scrollTop && viewportHeight === state.viewportHeight) return state;
|
|
return Object.assign({}, state, { scrollTop, viewportHeight });
|
|
}
|
|
if (action.type === 'SET_SEARCH_QUERY') {
|
|
const searchQuery = typeof action.query === 'string' ? action.query : '';
|
|
return Object.assign({}, state, {
|
|
searchQuery,
|
|
searchCameraKeys: searchQuery ? state.searchCameraKeys : null,
|
|
searchExpandedKeys: searchQuery ? state.searchExpandedKeys : null,
|
|
});
|
|
}
|
|
if (action.type === 'SET_SEARCH_RESULTS') {
|
|
if (!Number.isInteger(action.generation) || action.generation < state.searchGeneration) return state;
|
|
return Object.assign({}, state, {
|
|
searchQuery: typeof action.query === 'string' ? action.query : state.searchQuery,
|
|
searchCameraKeys: action.cameraKeys instanceof Set ? new Set(action.cameraKeys) : null,
|
|
searchExpandedKeys: action.expandedKeys instanceof Set ? new Set(action.expandedKeys) : null,
|
|
searchGeneration: action.generation,
|
|
});
|
|
}
|
|
if (action.type !== 'KEY' || typeof action.key !== 'string') return state;
|
|
|
|
if (action.key === 'Escape') {
|
|
if (!state.searchQuery && !state.searchCameraKeys) return state;
|
|
return Object.assign({}, state, { searchQuery: '', searchCameraKeys: null, searchExpandedKeys: null });
|
|
}
|
|
if (!rows.length) return state;
|
|
let index = rows.findIndex((row) => row.key === state.activeKey);
|
|
if (index < 0) {
|
|
if (action.key === 'ArrowDown' || action.key === 'Home') return moveActive(state, rows, 0);
|
|
if (action.key === 'ArrowUp' || action.key === 'End') return moveActive(state, rows, rows.length - 1);
|
|
index = 0;
|
|
}
|
|
const row = rows[index];
|
|
const expandedKeys = expansionState(state).keys;
|
|
switch (action.key) {
|
|
case 'ArrowDown': return moveActive(state, rows, index + 1);
|
|
case 'ArrowUp': return moveActive(state, rows, index - 1);
|
|
case 'Home': return moveActive(state, rows, 0);
|
|
case 'End': return moveActive(state, rows, rows.length - 1);
|
|
case 'ArrowRight': {
|
|
if (row.kind === 'camera') return state;
|
|
if (!expandedKeys.has(row.key)) return changeExpansion(state, row, true);
|
|
const child = rows[index + 1];
|
|
return child && child.parentKey === row.key ? Object.assign({}, state, { activeKey: child.key }) : state;
|
|
}
|
|
case 'ArrowLeft':
|
|
if (row.kind !== 'camera' && expandedKeys.has(row.key)) return changeExpansion(state, row, false);
|
|
return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state;
|
|
case 'Enter':
|
|
case ' ':
|
|
case 'Spacebar': return activate(state, row);
|
|
default: return state;
|
|
}
|
|
}
|
|
|
|
function createSidebarController(options = {}) {
|
|
if (!treeApi || typeof treeApi.flattenVisibleRows !== 'function' || typeof treeApi.calculateVirtualWindow !== 'function') {
|
|
throw new Error('AptDeviceTree must be loaded before AptSidebarController.');
|
|
}
|
|
if (!options.model || !options.view || typeof options.view.commit !== 'function') {
|
|
throw new TypeError('Sidebar controller requires a model and view.commit adapter.');
|
|
}
|
|
const model = options.model;
|
|
const view = options.view;
|
|
const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 30;
|
|
const overscan = Number.isInteger(options.overscan) ? options.overscan : 16;
|
|
const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize });
|
|
let state = createInitialState(options.initialState);
|
|
let lastSnapshot;
|
|
|
|
function effectiveExpandedKeys() {
|
|
return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys;
|
|
}
|
|
|
|
function commit() {
|
|
const rows = treeApi.flattenVisibleRows(model, {
|
|
expandedKeys: effectiveExpandedKeys(),
|
|
selectedKey: state.selectedKey,
|
|
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null,
|
|
});
|
|
const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
|
|
const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight);
|
|
if (state.scrollTop > maximumScrollTop) {
|
|
state = Object.assign({}, state, { scrollTop: maximumScrollTop });
|
|
}
|
|
if (activeIndex >= 0 && state.viewportHeight > 0) {
|
|
const activeTop = activeIndex * rowHeight;
|
|
const activeBottom = activeTop + rowHeight;
|
|
let scrollTop = state.scrollTop;
|
|
if (activeTop < scrollTop) scrollTop = activeTop;
|
|
else if (activeBottom > scrollTop + state.viewportHeight) scrollTop = activeBottom - state.viewportHeight;
|
|
if (scrollTop !== state.scrollTop) state = Object.assign({}, state, { scrollTop });
|
|
}
|
|
const window = treeApi.calculateVirtualWindow(rows, {
|
|
scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan,
|
|
});
|
|
lastSnapshot = Object.freeze({ state, rows, window });
|
|
view.commit(lastSnapshot);
|
|
return lastSnapshot;
|
|
}
|
|
|
|
function dispatch(action) {
|
|
const visibleRows = lastSnapshot ? lastSnapshot.rows : [];
|
|
let safeAction = action;
|
|
if (action && action.type === 'SET_SELECTION') {
|
|
if (!model.cameraByKey.has(action.rowKey)) return lastSnapshot;
|
|
safeAction = Object.assign({}, action, { allowHidden: true });
|
|
}
|
|
const next = reduceSidebar(state, safeAction, visibleRows);
|
|
if (next === state) return lastSnapshot;
|
|
state = next;
|
|
return commit();
|
|
}
|
|
|
|
async function search(query) {
|
|
const normalizedQuery = typeof query === 'string' ? query : '';
|
|
if (!normalizedQuery.trim()) {
|
|
searchRunner.cancel();
|
|
return dispatch({ type: 'SET_SEARCH_QUERY', query: '' });
|
|
}
|
|
state = reduceSidebar(state, { type: 'SET_SEARCH_QUERY', query: normalizedQuery }, lastSnapshot ? lastSnapshot.rows : []);
|
|
const result = await searchRunner.search(model, normalizedQuery, { expandedKeys: state.expandedKeys });
|
|
if (result.cancelled) return result;
|
|
dispatch({
|
|
type: 'SET_SEARCH_RESULTS', query: normalizedQuery, generation: result.generation,
|
|
cameraKeys: result.cameraKeys, expandedKeys: result.expandedKeys,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
commit();
|
|
return Object.freeze({
|
|
dispatch, search, refresh: commit, getState: () => state, getSnapshot: () => lastSnapshot,
|
|
destroy() { searchRunner.cancel(); },
|
|
});
|
|
}
|
|
|
|
return Object.freeze({
|
|
createInitialState,
|
|
reduceSidebar,
|
|
sidebarReducer: reduceSidebar,
|
|
createSidebarController,
|
|
});
|
|
}));
|