UNPKG

@itwin/presentation-frontend

Version:

Frontend of iModel.js Presentation library

652 lines • 27.8 kB
"use strict"; /*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /** @packageDocumentation * @module UnifiedSelection */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ToolSelectionSyncHandler = exports.SelectionManager = void 0; const rxjs_1 = require("rxjs"); const core_bentley_1 = require("@itwin/core-bentley"); const core_frontend_1 = require("@itwin/core-frontend"); const presentation_common_1 = require("@itwin/presentation-common"); const unified_selection_1 = require("@itwin/unified-selection"); const Presentation_1 = require("../Presentation"); const HiliteSetProvider_1 = require("./HiliteSetProvider"); const SelectionChangeEvent_1 = require("./SelectionChangeEvent"); const SelectionScopesManager_1 = require("./SelectionScopesManager"); /** * The selection manager which stores the overall selection. * @public */ class SelectionManager { /** * Creates an instance of SelectionManager. */ constructor(props) { this._imodelToolSelectionSyncHandlers = new Map(); this._hiliteSetProviders = new Map(); this._knownIModels = new Set(); this._currentSelection = new CurrentSelectionStorage(); this._selectionChanges = new rxjs_1.Subject(); this._listeners = []; this.selectionChange = new SelectionChangeEvent_1.SelectionChangeEvent(); this.scopes = props.scopes; this._selectionStorage = props.selectionStorage ?? (0, unified_selection_1.createStorage)(); this._ownsStorage = props.selectionStorage === undefined; this._selectionStorage.selectionChangeEvent.addListener((args) => this._selectionChanges.next(args)); this._selectionEventsSubscription = this.streamSelectionEvents(); this._listeners.push(core_frontend_1.IModelConnection.onOpen.addListener((imodel) => { this._knownIModels.add(imodel); })); this._listeners.push(core_frontend_1.IModelConnection.onClose.addListener((imodel) => { this.onConnectionClose(imodel); })); } dispose() { this._selectionEventsSubscription.unsubscribe(); this._listeners.forEach((dispose) => dispose()); } onConnectionClose(imodel) { this._hiliteSetProviders.delete(imodel); this._knownIModels.delete(imodel); this._currentSelection.clear(imodel.key); if (this._ownsStorage) { this.clearSelection("Connection Close Event", imodel); this._selectionStorage.clearStorage({ iModelKey: imodel.key }); } } /** @internal */ // istanbul ignore next getToolSelectionSyncHandler(imodel) { return this._imodelToolSelectionSyncHandlers.get(imodel)?.handler; } /** * Request the manager to sync with imodel's tool selection (see `IModelConnection.selectionSet`). */ setSyncWithIModelToolSelection(imodel, sync = true) { const registration = this._imodelToolSelectionSyncHandlers.get(imodel); if (sync) { if (!registration || registration.requestorsCount === 0) { this._imodelToolSelectionSyncHandlers.set(imodel, { requestorsCount: 1, handler: new ToolSelectionSyncHandler(imodel, this) }); } else { this._imodelToolSelectionSyncHandlers.set(imodel, { ...registration, requestorsCount: registration.requestorsCount + 1 }); } } else { if (registration && registration.requestorsCount > 0) { const requestorsCount = registration.requestorsCount - 1; if (requestorsCount > 0) { this._imodelToolSelectionSyncHandlers.set(imodel, { ...registration, requestorsCount }); } else { this._imodelToolSelectionSyncHandlers.delete(imodel); registration.handler.dispose(); } } } } /** * Temporarily suspends tool selection synchronization until the returned `IDisposable` * is disposed. */ suspendIModelToolSelectionSync(imodel) { const registration = this._imodelToolSelectionSyncHandlers.get(imodel); if (!registration) { return { dispose: () => { } }; } const wasSuspended = registration.handler.isSuspended; registration.handler.isSuspended = true; return { dispose: () => (registration.handler.isSuspended = wasSuspended) }; } /** Get the selection levels currently stored in this manager for the specified imodel */ getSelectionLevels(imodel) { return this._selectionStorage.getSelectionLevels({ iModelKey: imodel.key }); } /** * Get the selection currently stored in this manager * * @note Calling immediately after `add*`|`replace*`|`remove*`|`clear*` method call does not guarantee * that returned `KeySet` will include latest changes. Listen for `selectionChange` event to get the * latest selection after changes. */ getSelection(imodel, level = 0) { return this._currentSelection.getSelection(imodel.key, level); } handleEvent(evt) { this._knownIModels.add(evt.imodel); switch (evt.changeType) { case SelectionChangeEvent_1.SelectionChangeType.Add: this._selectionStorage.addToSelection({ iModelKey: evt.imodel.key, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeEvent_1.SelectionChangeType.Remove: this._selectionStorage.removeFromSelection({ iModelKey: evt.imodel.key, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeEvent_1.SelectionChangeType.Replace: this._selectionStorage.replaceSelection({ iModelKey: evt.imodel.key, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeEvent_1.SelectionChangeType.Clear: this._selectionStorage.clearSelection({ iModelKey: evt.imodel.key, source: evt.source, level: evt.level }); break; } } /** * Add keys to the selection * @param source Name of the selection source * @param imodel iModel associated with the selection * @param keys Keys to add * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ addToSelection(source, imodel, keys, level = 0, rulesetId) { const evt = { source, level, imodel, changeType: SelectionChangeEvent_1.SelectionChangeType.Add, keys: new presentation_common_1.KeySet(keys), timestamp: new Date(), rulesetId, }; this.handleEvent(evt); } /** * Remove keys from current selection * @param source Name of the selection source * @param imodel iModel associated with the selection * @param keys Keys to remove * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ removeFromSelection(source, imodel, keys, level = 0, rulesetId) { const evt = { source, level, imodel, changeType: SelectionChangeEvent_1.SelectionChangeType.Remove, keys: new presentation_common_1.KeySet(keys), timestamp: new Date(), rulesetId, }; this.handleEvent(evt); } /** * Replace current selection * @param source Name of the selection source * @param imodel iModel associated with the selection * @param keys Keys to add * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ replaceSelection(source, imodel, keys, level = 0, rulesetId) { const evt = { source, level, imodel, changeType: SelectionChangeEvent_1.SelectionChangeType.Replace, keys: new presentation_common_1.KeySet(keys), timestamp: new Date(), rulesetId, }; this.handleEvent(evt); } /** * Clear current selection * @param source Name of the selection source * @param imodel iModel associated with the selection * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ clearSelection(source, imodel, level = 0, rulesetId) { const evt = { source, level, imodel, changeType: SelectionChangeEvent_1.SelectionChangeType.Clear, keys: new presentation_common_1.KeySet(), timestamp: new Date(), rulesetId, }; this.handleEvent(evt); } /** * Add keys to selection after applying [selection scope]($docs/presentation/unified-selection/index#selection-scopes) on them. * @param source Name of the selection source * @param imodel iModel associated with the selection * @param ids Element IDs to add * @param scope Selection scope to apply * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ async addToSelectionWithScope(source, imodel, ids, scope, level = 0, rulesetId) { const scopedKeys = await this.scopes.computeSelection(imodel, ids, scope); this.addToSelection(source, imodel, scopedKeys, level, rulesetId); } /** * Remove keys from current selection after applying [selection scope]($docs/presentation/unified-selection/index#selection-scopes) on them. * @param source Name of the selection source * @param imodel iModel associated with the selection * @param ids Element IDs to remove * @param scope Selection scope to apply * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ async removeFromSelectionWithScope(source, imodel, ids, scope, level = 0, rulesetId) { const scopedKeys = await this.scopes.computeSelection(imodel, ids, scope); this.removeFromSelection(source, imodel, scopedKeys, level, rulesetId); } /** * Replace current selection with keys after applying [selection scope]($docs/presentation/unified-selection/index#selection-scopes) on them. * @param source Name of the selection source * @param imodel iModel associated with the selection * @param ids Element IDs to replace with * @param scope Selection scope to apply * @param level Selection level (see [selection levels documentation section]($docs/presentation/unified-selection/index#selection-levels)) * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control */ async replaceSelectionWithScope(source, imodel, ids, scope, level = 0, rulesetId) { const scopedKeys = await this.scopes.computeSelection(imodel, ids, scope); this.replaceSelection(source, imodel, scopedKeys, level, rulesetId); } /** * Get the current hilite set for the specified imodel * @public */ async getHiliteSet(imodel) { return this.getHiliteSetProvider(imodel).getHiliteSet(this.getSelection(imodel)); } /** * Get the current hilite set iterator for the specified imodel. * @public */ getHiliteSetIterator(imodel) { return this.getHiliteSetProvider(imodel).getHiliteSetIterator(this.getSelection(imodel)); } getHiliteSetProvider(imodel) { let provider = this._hiliteSetProviders.get(imodel); if (!provider) { provider = HiliteSetProvider_1.HiliteSetProvider.create({ imodel }); this._hiliteSetProviders.set(imodel, provider); } return provider; } streamSelectionEvents() { return this._selectionChanges .pipe((0, rxjs_1.mergeMap)((args) => { const currentSelectables = this._selectionStorage.getSelection({ iModelKey: args.imodelKey, level: args.level }); return this._currentSelection.computeSelection(args.imodelKey, args.level, currentSelectables, args.selectables).pipe((0, rxjs_1.mergeMap)(({ level, changedSelection }) => { const imodel = findIModel(this._knownIModels, args.imodelKey); // istanbul ignore if if (!imodel) { return rxjs_1.EMPTY; } return (0, rxjs_1.of)({ imodel, keys: changedSelection, level, source: args.source, timestamp: args.timestamp, changeType: getChangeType(args.changeType), }); })); })) .subscribe({ next: (args) => { this.selectionChange.raiseEvent(args, this); }, }); } } exports.SelectionManager = SelectionManager; function findIModel(set, key) { for (const imodel of set) { if (imodel.key === key) { return imodel; } } return undefined; } /** @internal */ class ToolSelectionSyncHandler { constructor(imodel, logicalSelection) { this._selectionSourceName = "Tool"; this._asyncsTracker = new presentation_common_1.AsyncTasksTracker(); this.onToolSelectionChanged = async (ev) => { // ignore selection change event if the handler is suspended if (this.isSuspended) { return; } // this component only cares about its own imodel const imodel = ev.set.iModel; if (imodel !== this._imodel) { return; } // determine the level of selection changes // wip: may want to allow selecting at different levels? const selectionLevel = 0; let ids; switch (ev.type) { case core_frontend_1.SelectionSetEventType.Add: ids = ev.added; break; case core_frontend_1.SelectionSetEventType.Replace: ids = ev.set.elements; break; default: ids = ev.removed; break; } // we're always using scoped selection changer even if the scope is set to "element" - that // makes sure we're adding to selection keys with concrete classes and not "BisCore:Element", which // we can't because otherwise our keys compare fails (presentation components load data with // concrete classes) const changer = new ScopedSelectionChanger(this._selectionSourceName, this._imodel, this._logicalSelection, (0, SelectionScopesManager_1.createSelectionScopeProps)(this._logicalSelection.scopes.activeScope)); // we know what to do immediately on `clear` events if (core_frontend_1.SelectionSetEventType.Clear === ev.type) { await changer.clear(selectionLevel); return; } const parsedIds = parseIds(ids); await (0, core_bentley_1.using)(this._asyncsTracker.trackAsyncTask(), async (_r) => { switch (ev.type) { case core_frontend_1.SelectionSetEventType.Add: await changer.add(parsedIds.transient, parsedIds.persistent, selectionLevel); break; case core_frontend_1.SelectionSetEventType.Replace: await changer.replace(parsedIds.transient, parsedIds.persistent, selectionLevel); break; case core_frontend_1.SelectionSetEventType.Remove: await changer.remove(parsedIds.transient, parsedIds.persistent, selectionLevel); break; } }); }; this._imodel = imodel; this._logicalSelection = logicalSelection; this._imodelToolSelectionListenerDisposeFunc = imodel.selectionSet.onChanged.addListener(this.onToolSelectionChanged); } dispose() { this._imodelToolSelectionListenerDisposeFunc(); } /** note: used only it tests */ get pendingAsyncs() { return this._asyncsTracker.pendingAsyncs; } } exports.ToolSelectionSyncHandler = ToolSelectionSyncHandler; const parseIds = (ids) => { let allPersistent = true; let allTransient = true; for (const id of core_bentley_1.Id64.iterable(ids)) { if (core_bentley_1.Id64.isTransient(id)) { allPersistent = false; } else { allTransient = false; } if (!allPersistent && !allTransient) { break; } } // avoid making a copy if ids are only persistent or only transient if (allPersistent) { return { persistent: ids, transient: [] }; } else if (allTransient) { return { persistent: [], transient: ids }; } // if `ids` contain mixed ids, we have to copy.. use Array instead of // a Set for performance const persistentElementIds = []; const transientElementIds = []; for (const id of core_bentley_1.Id64.iterable(ids)) { if (core_bentley_1.Id64.isTransient(id)) { transientElementIds.push(id); } else { persistentElementIds.push(id); } } return { persistent: persistentElementIds, transient: transientElementIds }; }; function addTransientKeys(transientIds, keys) { for (const id of core_bentley_1.Id64.iterable(transientIds)) { keys.add({ className: unified_selection_1.TRANSIENT_ELEMENT_CLASSNAME, id }); } } /** @internal */ class ScopedSelectionChanger { constructor(name, imodel, manager, scope) { this.name = name; this.imodel = imodel; this.manager = manager; this.scope = scope; } async clear(level) { this.manager.clearSelection(this.name, this.imodel, level); } async add(transientIds, persistentIds, level) { const keys = await this.manager.scopes.computeSelection(this.imodel, persistentIds, this.scope); addTransientKeys(transientIds, keys); this.manager.addToSelection(this.name, this.imodel, keys, level); } async remove(transientIds, persistentIds, level) { const keys = await this.manager.scopes.computeSelection(this.imodel, persistentIds, this.scope); addTransientKeys(transientIds, keys); this.manager.removeFromSelection(this.name, this.imodel, keys, level); } async replace(transientIds, persistentIds, level) { const keys = await this.manager.scopes.computeSelection(this.imodel, persistentIds, this.scope); addTransientKeys(transientIds, keys); this.manager.replaceSelection(this.name, this.imodel, keys, level); } } /** Stores current selection in `KeySet` format per iModel. */ class CurrentSelectionStorage { constructor() { this._currentSelection = new Map(); } getCurrentSelectionStorage(imodelKey) { let storage = this._currentSelection.get(imodelKey); if (!storage) { storage = new IModelSelectionStorage(); this._currentSelection.set(imodelKey, storage); } return storage; } getSelection(imodelKey, level) { return this.getCurrentSelectionStorage(imodelKey).getSelection(level); } clear(imodelKey) { this._currentSelection.delete(imodelKey); } computeSelection(imodelKey, level, currSelectables, changedSelectables) { return this.getCurrentSelectionStorage(imodelKey).computeSelection(level, currSelectables, changedSelectables); } } /** * Computes and stores current selection in `KeySet` format. * It always stores result of latest resolved call to `computeSelection`. */ class IModelSelectionStorage { constructor() { this._currentSelection = new Map(); } getSelection(level) { let entry = this._currentSelection.get(level); if (!entry) { entry = { value: new presentation_common_1.KeySet(), ongoingComputationDisposers: new Set() }; this._currentSelection.set(level, entry); } return entry.value; } clearSelections(level) { const clearedLevels = []; for (const [storedLevel] of this._currentSelection.entries()) { if (storedLevel > level) { clearedLevels.push(storedLevel); } } clearedLevels.forEach((storedLevel) => { const entry = this._currentSelection.get(storedLevel); // istanbul ignore if if (!entry) { return; } for (const disposer of entry.ongoingComputationDisposers) { disposer.next(); } this._currentSelection.delete(storedLevel); }); } addDisposer(level, disposer) { const entry = this._currentSelection.get(level); if (!entry) { this._currentSelection.set(level, { value: new presentation_common_1.KeySet(), ongoingComputationDisposers: new Set([disposer]) }); return; } entry.ongoingComputationDisposers.add(disposer); } setSelection(level, keys, disposer) { const currEntry = this._currentSelection.get(level); // istanbul ignore else if (currEntry) { currEntry.ongoingComputationDisposers.delete(disposer); } this._currentSelection.set(level, { value: keys, ongoingComputationDisposers: currEntry?.ongoingComputationDisposers ?? /* istanbul ignore next */ new Set(), }); } computeSelection(level, currSelectables, changedSelectables) { this.clearSelections(level); const prevComputationsDisposers = [...(this._currentSelection.get(level)?.ongoingComputationDisposers ?? [])]; const currDisposer = new rxjs_1.Subject(); this.addDisposer(level, currDisposer); return (0, rxjs_1.defer)(async () => { const convertedSelectables = []; const [current, changed] = await Promise.all([ selectablesToKeys(currSelectables, convertedSelectables), selectablesToKeys(changedSelectables, convertedSelectables), ]); const currentSelection = new presentation_common_1.KeySet([...current.keys, ...current.selectableKeys.flatMap((selectable) => selectable.keys)]); const changedSelection = new presentation_common_1.KeySet([...changed.keys, ...changed.selectableKeys.flatMap((selectable) => selectable.keys)]); return { level, currentSelection, changedSelection, }; }).pipe((0, rxjs_1.takeUntil)(currDisposer), (0, rxjs_1.tap)({ next: (val) => { prevComputationsDisposers.forEach((disposer) => disposer.next()); this.setSelection(val.level, val.currentSelection, currDisposer); }, })); } } function keysToSelectable(imodel, keys) { const selectables = []; keys.forEach((key) => { if ("id" in key) { selectables.push(key); return; } const customSelectable = { identifier: key.pathFromRoot.join("/"), data: key, loadInstanceKeys: () => createInstanceKeysIterator(imodel, key), }; selectables.push(customSelectable); }); return selectables; } async function selectablesToKeys(selectables, convertedList) { const keys = []; const selectableKeys = []; for (const [className, ids] of selectables.instanceKeys) { for (const id of ids) { keys.push({ id, className }); } } for (const [_, selectable] of selectables.custom) { if (isNodeKey(selectable.data)) { selectableKeys.push({ identifier: selectable.identifier, keys: [selectable.data] }); continue; } const converted = convertedList.find((con) => con.identifier === selectable.identifier); if (converted) { selectableKeys.push(converted); continue; } const newConverted = { identifier: selectable.identifier, keys: [] }; convertedList.push(newConverted); for await (const instanceKey of selectable.loadInstanceKeys()) { newConverted.keys.push(instanceKey); } selectableKeys.push(newConverted); } return { keys, selectableKeys }; } async function* createInstanceKeysIterator(imodel, nodeKey) { if (presentation_common_1.NodeKey.isInstancesNodeKey(nodeKey)) { for (const key of nodeKey.instanceKeys) { yield key; } return; } const content = await Presentation_1.Presentation.presentation.getContentInstanceKeys({ imodel, keys: new presentation_common_1.KeySet([nodeKey]), rulesetOrId: { id: "grouped-instances", rules: [ { ruleType: "Content", specifications: [ { specType: "SelectedNodeInstances", }, ], }, ], }, }); for await (const key of content.items()) { yield key; } } function isNodeKey(data) { const key = data; return key.pathFromRoot !== undefined && key.type !== undefined; } function getChangeType(type) { switch (type) { case "add": return SelectionChangeEvent_1.SelectionChangeType.Add; case "remove": return SelectionChangeEvent_1.SelectionChangeType.Remove; case "replace": return SelectionChangeEvent_1.SelectionChangeType.Replace; case "clear": return SelectionChangeEvent_1.SelectionChangeType.Clear; } } //# sourceMappingURL=SelectionManager.js.map