UNPKG

@itwin/presentation-frontend

Version:

Frontend of iModel.js Presentation library

741 lines • 30.5 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ /* eslint-disable @typescript-eslint/no-deprecated */ /** @packageDocumentation * @module UnifiedSelection */ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value; }; var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); }; })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }); import { defer, EMPTY, mergeMap, of, Subject, takeUntil, tap } from "rxjs"; import { Id64 } from "@itwin/core-bentley"; import { IModelConnection, SelectionSetEventType } from "@itwin/core-frontend"; import { KeySet, NodeKey } from "@itwin/presentation-common"; import { AsyncTasksTracker } from "@itwin/presentation-common/internal"; import { createStorage, TRANSIENT_ELEMENT_CLASSNAME, } from "@itwin/unified-selection"; import { Presentation } from "../Presentation.js"; import { HiliteSetProvider } from "./HiliteSetProvider.js"; import { SelectionChangeEvent, SelectionChangeType } from "./SelectionChangeEvent.js"; import { createSelectionScopeProps } from "./SelectionScopesManager.js"; /** * The selection manager which stores the overall selection. * @public * @deprecated in 5.0 - will not be removed until after 2026-06-13. Use `SelectionStorage` from [@itwin/unified-selection](https://github.com/iTwin/presentation/blob/master/packages/unified-selection/README.md) package instead. */ export class SelectionManager { _imodelKeyFactory; _imodelToolSelectionSyncHandlers = new Map(); _hiliteSetProviders = new Map(); _ownsStorage; _knownIModels = new Set(); _currentSelection = new CurrentSelectionStorage(); _selectionChanges = new Subject(); _selectionEventsSubscription; _listeners = []; /** * Underlying selection storage used by this selection manager. Ideally, consumers should use * the storage directly instead of using this manager to manipulate selection. */ selectionStorage; /** An event which gets broadcasted on selection changes */ selectionChange; /** Manager for [selection scopes]($docs/presentation/unified-selection/index#selection-scopes) */ scopes; /** * Creates an instance of SelectionManager. */ constructor(props) { this.selectionChange = new SelectionChangeEvent(); this.scopes = props.scopes; this.selectionStorage = props.selectionStorage ?? createStorage(); this._imodelKeyFactory = props.imodelKeyFactory ?? ((imodel) => (imodel.key.length ? imodel.key : imodel.name)); this._ownsStorage = props.selectionStorage === undefined; this.selectionStorage.selectionChangeEvent.addListener((args) => this._selectionChanges.next(args)); this._selectionEventsSubscription = this.streamSelectionEvents(); this._listeners.push(IModelConnection.onOpen.addListener((imodel) => { this._knownIModels.add(imodel); })); this._listeners.push(IModelConnection.onClose.addListener((imodel) => { this.onConnectionClose(imodel); })); } [Symbol.dispose]() { this._selectionEventsSubscription.unsubscribe(); this._listeners.forEach((dispose) => dispose()); } /** @deprecated in 5.0 - will not be removed until after 2026-06-13. Use [Symbol.dispose] instead. */ /* c8 ignore next 3 */ dispose() { this[Symbol.dispose](); } onConnectionClose(imodel) { const imodelKey = this._imodelKeyFactory(imodel); this._hiliteSetProviders.delete(imodel); this._knownIModels.delete(imodel); this._currentSelection.clear(imodelKey); if (this._ownsStorage) { this.clearSelection("Connection Close Event", imodel); this.selectionStorage.clearStorage({ imodelKey }); } } /** * 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[Symbol.dispose](); } } } } /** * Temporarily suspends tool selection synchronization until the returned `Disposable` * is disposed. */ suspendIModelToolSelectionSync(imodel) { const registration = this._imodelToolSelectionSyncHandlers.get(imodel); if (!registration) { const noop = () => { }; return { [Symbol.dispose]: noop, dispose: noop }; } const wasSuspended = registration.handler.isSuspended; registration.handler.isSuspended = true; const doDispose = () => (registration.handler.isSuspended = wasSuspended); return { [Symbol.dispose]: doDispose, dispose: doDispose }; } /** Get the selection levels currently stored in this manager for the specified imodel */ getSelectionLevels(imodel) { const imodelKey = this._imodelKeyFactory(imodel); return this.selectionStorage.getSelectionLevels({ imodelKey }); } /** * 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) { const imodelKey = this._imodelKeyFactory(imodel); return this._currentSelection.getSelection(imodelKey, level); } handleEvent(evt) { const imodelKey = this._imodelKeyFactory(evt.imodel); this._knownIModels.add(evt.imodel); switch (evt.changeType) { case SelectionChangeType.Add: this.selectionStorage.addToSelection({ imodelKey, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeType.Remove: this.selectionStorage.removeFromSelection({ imodelKey, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeType.Replace: this.selectionStorage.replaceSelection({ imodelKey, source: evt.source, level: evt.level, selectables: keysToSelectable(evt.imodel, evt.keys), }); break; case SelectionChangeType.Clear: this.selectionStorage.clearSelection({ imodelKey, 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: SelectionChangeType.Add, keys: new 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: SelectionChangeType.Remove, keys: new 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: SelectionChangeType.Replace, keys: new 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: SelectionChangeType.Clear, keys: new 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.create({ imodel }); this._hiliteSetProviders.set(imodel, provider); } return provider; } streamSelectionEvents() { return this._selectionChanges .pipe(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(mergeMap(({ level, changedSelection }) => { const imodel = findIModel(this._knownIModels, this._imodelKeyFactory, args.imodelKey); /* c8 ignore next 3 */ if (!imodel) { return EMPTY; } return of({ imodel, keys: changedSelection, level, source: args.source, timestamp: args.timestamp, changeType: getChangeType(args.changeType), }); })); })) .subscribe({ next: (args) => { this.selectionChange.raiseEvent(args, this); }, }); } } function findIModel(set, imodelKeyFactory, key) { for (const imodel of set) { if (imodelKeyFactory(imodel) === key) { return imodel; } } return undefined; } /** @internal */ export class ToolSelectionSyncHandler { _selectionSourceName = "Tool"; _logicalSelection; _imodel; _imodelToolSelectionListenerDisposeFunc; _asyncsTracker = new AsyncTasksTracker(); isSuspended; constructor(imodel, logicalSelection) { this._imodel = imodel; this._logicalSelection = logicalSelection; this._imodelToolSelectionListenerDisposeFunc = imodel.selectionSet.onChanged.addListener(this.onToolSelectionChanged); } [Symbol.dispose]() { this._imodelToolSelectionListenerDisposeFunc(); } /** note: used only it tests */ get pendingAsyncs() { return this._asyncsTracker.pendingAsyncs; } onToolSelectionChanged = async (ev) => { const env_1 = { stack: [], error: void 0, hasError: false }; try { // 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 SelectionSetEventType.Add: ids = ev.additions; break; case SelectionSetEventType.Replace: ids = ev.set.active; break; default: ids = ev.removals; 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, createSelectionScopeProps(this._logicalSelection.scopes.activeScope)); const _r = __addDisposableResource(env_1, this._asyncsTracker.trackAsyncTask(), false); switch (ev.type) { case SelectionSetEventType.Add: await changer.add(ids, selectionLevel); break; case SelectionSetEventType.Replace: await changer.replace(ids, selectionLevel); break; case SelectionSetEventType.Remove: await changer.remove(ids, selectionLevel); break; case SelectionSetEventType.Clear: await changer.clear(selectionLevel); break; } } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } }; } const parseElementIds = (ids) => { let allPersistent = true; let allTransient = true; for (const id of Id64.iterable(ids)) { if (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 Id64.iterable(ids)) { if (Id64.isTransient(id)) { transientElementIds.push(id); } else { persistentElementIds.push(id); } } return { persistent: persistentElementIds, transient: transientElementIds }; }; function addKeys(target, className, ids) { for (const id of Id64.iterable(ids)) { target.add({ className, id }); } } class ScopedSelectionChanger { name; imodel; manager; scope; 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(ids, level) { const keys = await this.#computeSelection(ids); this.manager.addToSelection(this.name, this.imodel, keys, level); } async remove(ids, level) { const keys = await this.#computeSelection(ids); this.manager.removeFromSelection(this.name, this.imodel, keys, level); } async replace(ids, level) { const keys = await this.#computeSelection(ids); this.manager.replaceSelection(this.name, this.imodel, keys, level); } async #computeSelection(ids) { let keys = new KeySet(); if (ids.elements) { const { persistent, transient } = parseElementIds(ids.elements); keys = await this.manager.scopes.computeSelection(this.imodel, persistent, this.scope); addKeys(keys, TRANSIENT_ELEMENT_CLASSNAME, transient); } if (ids.models) { addKeys(keys, "BisCore.Model", ids.models); } if (ids.subcategories) { addKeys(keys, "BisCore.SubCategory", ids.subcategories); } return keys; } } /** Stores current selection in `KeySet` format per iModel. */ class CurrentSelectionStorage { _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 { _currentSelection = new Map(); getSelection(level) { let entry = this._currentSelection.get(level); if (!entry) { entry = { value: new 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); /* c8 ignore next 3 */ 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 KeySet(), ongoingComputationDisposers: new Set([disposer]) }); return; } entry.ongoingComputationDisposers.add(disposer); } setSelection(level, keys, disposer) { const currEntry = this._currentSelection.get(level); if (currEntry) { currEntry.ongoingComputationDisposers.delete(disposer); } this._currentSelection.set(level, { value: keys, ongoingComputationDisposers: currEntry?.ongoingComputationDisposers ?? /* c8 ignore next */ new Set(), }); } computeSelection(level, currSelectables, changedSelectables) { this.clearSelections(level); const prevComputationsDisposers = [...(this._currentSelection.get(level)?.ongoingComputationDisposers ?? [])]; const currDisposer = new Subject(); this.addDisposer(level, currDisposer); return defer(async () => { const convertedSelectables = []; const [current, changed] = await Promise.all([ selectablesToKeys(currSelectables, convertedSelectables), selectablesToKeys(changedSelectables, convertedSelectables), ]); const currentSelection = new KeySet([...current.keys, ...current.selectableKeys.flatMap((selectable) => selectable.keys)]); const changedSelection = new KeySet([...changed.keys, ...changed.selectableKeys.flatMap((selectable) => selectable.keys)]); return { level, currentSelection, changedSelection, }; }).pipe(takeUntil(currDisposer), 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 (NodeKey.isInstancesNodeKey(nodeKey)) { for (const key of nodeKey.instanceKeys) { yield key; } return; } const content = await Presentation.presentation.getContentInstanceKeys({ imodel, keys: new 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 SelectionChangeType.Add; case "remove": return SelectionChangeType.Remove; case "replace": return SelectionChangeType.Replace; case "clear": return SelectionChangeType.Clear; } } //# sourceMappingURL=SelectionManager.js.map