iModel.js前端统一选择一致性机制


iModel.js前端统一选择一致性机制

背景

iModel.js为了保持前端页面选择数据的一致性以及实现应用控件与数据的解耦,并且实现用户自定义控件以及事件的可扩展性,而使用了一套自定义机制,以下简单梳理以下其机制与原理。

准备工作

需要了解React的高阶组件的概念,高阶组件实际上是经过一个包装函数返回的组件,这类函数接收React组件处理传入的组件,然后返回一个新的组件。

iModel.js高阶组件

Imodel.js应用程序前端页面可能会同时显示多个数据展示控件,为了保持这些控件的统一选择性,并解耦数据与控件的交互复杂性,从而使用了以下这些高阶组件。

 

 

高阶组件

描述

viewWithUnifiedSelection

视图相关高阶组件。

tableWithUnifiedSelection

表格相关高阶组件。

WithUnifiedSelection

 

propertyGridWithUnifiedSelection

属性表格高阶组件。

useUnifiedSelectionTreeEventHandler  

树形控件高阶组件。

 

先从IModelConnection.SelectionSet介绍

IModelConnection.SelectionSet包含一组当前选中的Element,其中选中的Element在视窗内以可自定义的hilite效果显示。

SelectionTool是用于选择用户感兴趣的一组元素的工具,其也是imodel.js系统定义的唯一有机会向IModelConnection.SelectionSet通过Add方法添加所选择元素的地方(用户通过显式调用或自定义工具除外)。

其关键源码如下所示:

  public updateSelection(elementId: Id64Arg, process: SelectionProcessing): boolean {
    let returnValue = false;
    switch (process) {
      case SelectionProcessing.AddElementToSelection:
        returnValue = this.iModel.selectionSet.add(elementId);
        break;
      case SelectionProcessing.RemoveElementFromSelection:
        returnValue = this.iModel.selectionSet.remove(elementId);
        break;
      case SelectionProcessing.InvertElementInSelection: // (if element is in selection remove it else add it.)
        returnValue = this.iModel.selectionSet.invert(elementId);
        break;
      case SelectionProcessing.ReplaceSelectionWithElement:
        this.iModel.selectionSet.replace(elementId);
        returnValue = true;
        break;
      default:
        return false;
    }

IModelConnection.SelectionSet类维护着一个重要的事件广播onChanged,每当IModelConnection.SelectionSet中的元素集发生变化的时候,会触发该广播向所有已经注册的监听者发送消息以执行其具体相应。因此,用户通过向该广播onChanged中注册自己的事件监听者,可以实现相关的需求。

在iModel.js中,IModelConnection.SelectionSet中的onChanged已经预注册了以下三个监听者:

EditManipulator中已经注册。

SyncUiEventDispatcher中已经注册,一些工具栏项可能需要监听此事件以刷新其状态。关键代码如下所示:

ToolSelectionSyncHandler在构造的时候注册,目的是以同步更新SelectionManager中所维护的统一选择数据。

关键代码如下所示:

 

export class ToolSelectionSyncHandler implements IDisposable {

  private _selectionSourceName = "Tool";
  private _logicalSelection: SelectionManager;
  private _imodel: IModelConnection;
  private _imodelToolSelectionListenerDisposeFunc: () => void;
  private _asyncsTracker = new AsyncTasksTracker();
  public isSuspended?: boolean;

  public constructor(imodel: IModelConnection, logicalSelection: SelectionManager) {
    this._imodel = imodel;
    this._logicalSelection = logicalSelection;
    this._imodelToolSelectionListenerDisposeFunc = imodel.selectionSet.onChanged.addListener(this.onToolSelectionChanged);
  }
  
  
  // tslint:disable-next-line:naming-convention
  private onToolSelectionChanged = async (ev: SelectionSetEvent): Promise<void> => {
    // 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: Id64Arg;
    switch (ev.type) {
      case SelectionSetEventType.Add:
        ids = ev.added;
        break;
      case SelectionSetEventType.Replace:
        ids = ev.set.elements;
        break;
      default:
        ids = ev.removed;
        break;
    }

    const scopeId = getScopeId(this._logicalSelection.scopes.activeScope);

    // 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, scopeId);

    // we know what to do immediately on `clear` events
    if (SelectionSetEventType.Clear === ev.type) {
      await changer.clear(selectionLevel);
      return;
    }

    const parsedIds = parseIds(ids);
    await using(this._asyncsTracker.trackAsyncTask(), async (_r) => {
      switch (ev.type) {
        case SelectionSetEventType.Add:
          await changer.add(parsedIds.transient, parsedIds.persistent, selectionLevel);
          break;
        case SelectionSetEventType.Replace:
          await changer.replace(parsedIds.transient, parsedIds.persistent, selectionLevel);
          break;
        case SelectionSetEventType.Remove:
          await changer.remove(parsedIds.transient, parsedIds.persistent, selectionLevel);
          break;
      }
    });
  }
}

整个事件流程如下所示:

 

SelectionManager

SelectionManager是一个存储整个选择的选择管理器,在iModel.js中其是一个单例,由Presentation管理。其包含丰富的增删改查API,并支持可配置的scope,rule,level等谓词。

以tableWithUnifiedSelection为例

tableWithUnifiedSelection是一个高阶组件,其所显示的数据支持以rule的方式呈现,并受统一选择数据所控制。

tableWithUnifiedSelection维护着一个SelectionHandler(一个处理选择变化并帮助改变内部选择状态的类)。

例如,当在Table控件上选择一行数据的时候,会触发Table控件的onRowsSelected,从dataProvider处获取InstanceKey列表,所维护的SelectionHandler实例会通过SelectionHandler.addToSelection将InstanceKey列表以及若干可选谓词一起委托给SelectionManager.addToSelection从而将数据存储至SelectionManager中,并触发selectionChange进行广播,告诉所有已经注册的监听者,以执行需要的相应,其中SelectionManager.addToSelection关键代码如下所示:

  /**
   * 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]($docs/learning/presentation/Unified-Selection/Terminology#selection-level))
   * @param rulesetId ID of the ruleset in case the selection was changed from a rules-driven control
   */
  public addToSelection(source: string, imodel: IModelConnection, keys: Keys, level: number = 0, rulesetId?: string): void {
    const evt: SelectionChangeEventArgs = {
      source,
      level,
      imodel,
      changeType: SelectionChangeType.Add,
      keys: new KeySet(keys),
      timestamp: new Date(),
      rulesetId,
    };
    this.handleEvent(evt);
  }
  
    private handleEvent(evt: SelectionChangeEventArgs): void {
    const container = this.getContainer(evt.imodel);
    const selectedItemsSet = container.getSelection(evt.level);
    const guidBefore = selectedItemsSet.guid;
    switch (evt.changeType) {
      case SelectionChangeType.Add:
        selectedItemsSet.add(evt.keys);
        break;
      case SelectionChangeType.Remove:
        selectedItemsSet.delete(evt.keys);
        break;
      case SelectionChangeType.Replace:
        if (selectedItemsSet.size !== evt.keys.size || !selectedItemsSet.hasAll(evt.keys)) {
          // note: the above check is only needed to avoid changing
          // guid of the keyset if we're replacing keyset with the same keys
          selectedItemsSet.clear().add(evt.keys);
        }
        break;
      case SelectionChangeType.Clear:
        selectedItemsSet.clear();
        break;
    }

    if (selectedItemsSet.guid === guidBefore)
      return;

    container.clear(evt.level + 1);
    this.selectionChange.raiseEvent(evt, this);
  }

iModel.js中的SelectionManager. selectionChange广播已经注册了以下监听者:

1.SyncUiEventDispatcher初始化的时候进行注册监听者,一检测当前选择的Element的数目等。关键代码如下所示:

    // listen for changes from presentation rules selection manager (this is done once an iModelConnection is available to ensure Presentation.selection is valid)
    SyncUiEventDispatcher._unregisterListenerFunc = Presentation.selection.selectionChange.addListener((args: SelectionChangeEventArgs, provider: ISelectionProvider) => {
      // istanbul ignore if
      if (args.level !== 0) {
        // don't need to handle sub-selections
        return;
      }
      const selection = provider.getSelection(args.imodel, args.level);
      const numSelected = getInstancesCount(selection);
      UiFramework.dispatchActionToStore(SessionStateActionId.SetNumItemsSelected, numSelected);
    });

2.SelectionHandler构造的时候,会进行注册,其目的是在使用高阶组件的时候,具有一个控件方面的可选回调接口。其关键代码如下所示:

  constructor(props: SelectionHandlerProps) {
    this._inSelect = false;
    this.manager = props.manager;
    this._disposables = new DisposableList();
    this.name = props.name;
    this.rulesetId = props.rulesetId;
    this.imodel = props.imodel;
    this.onSelect = props.onSelect;
    this._disposables.add(this.manager.selectionChange.addListener(this.onSelectionChanged));
  }

3.当然,用户也可以显式使用selection.selectionChange.addListener注册自己的监听者。

整个事件流程如下所示:

 

 

高阶组件withStatusFieldProps

withStatusFieldProps高级组件应用于状态栏,例如显示当前选中元素的个数的显示,就需要使用,关键代码如下所示:

const SelectionInfo = withStatusFieldProps(SelectionInfoField);

其中应用SelectionInfo状态栏项之后,效果如下所示:

那么该状态栏项是如何获取当前所选元素的个数呢?

iModel.js的ui-framework基于react和redux,其维护着一个SessionState,其中SessionState定义代码如下所示:

而SelectionInfoField组件是经过redux的高级组件connect生成之后的组件,关键代码如下所示:

/**
 * Status Field React component. This component is designed to be specified in a status bar definition.
 * It is used to display the number of selected items based on the Presentation Rules Selection Manager.
*/
class SelectionInfoFieldComponent extends React.Component<SelectionInfoFieldProps> {

  constructor(props: SelectionInfoFieldProps) {
    super(props);
  }

  public render(): React.ReactNode {
    return (
      <FooterIndicator
        className={classnames("uifw-statusFields-selectionInfo", this.props.className)}
        style={this.props.style}
        isInFooterMode={this.props.isInFooterMode}
      >
        {<Icon iconSpec={"icon-cursor"} />}
        {this.props.selectionCount.toString()}
      </FooterIndicator>
    );
  }
}

/** Function used by Redux to map state data in Redux store to props that are used to render this component. */
function mapStateToProps(state: any) {
  const frameworkState = state[UiFramework.frameworkStateKey];  // since app sets up key, don't hard-code name
  /* istanbul ignore next */
  if (!frameworkState)
    return undefined;

  return { selectionCount: frameworkState.sessionState.numItemsSelected };
}

// we declare the variable and export that rather than using export default.
/**
 * SelectionInfo Status Field React component. This component is designed to be specified in a status bar definition.
 * It is used to display the number of selected items based on the Presentation Rules Selection Manager.
 * This React component is Redux connected.
 * @public
 */ // tslint:disable-next-line:variable-name
export const SelectionInfoField = connect(mapStateToProps)(SelectionInfoFieldComponent);

从前面内容得知,我们已经在Presentation.selection.selectionChange广播中注册此监听者,关键代码如下所示:

    // listen for changes from presentation rules selection manager (this is done once an iModelConnection is available to ensure Presentation.selection is valid)
    SyncUiEventDispatcher._unregisterListenerFunc = Presentation.selection.selectionChange.addListener((args: SelectionChangeEventArgs, provider: ISelectionProvider) => {
      // istanbul ignore if
      if (args.level !== 0) {
        // don't need to handle sub-selections
        return;
      }
      const selection = provider.getSelection(args.imodel, args.level);
      const numSelected = getInstancesCount(selection);
      UiFramework.dispatchActionToStore(SessionStateActionId.SetNumItemsSelected, numSelected);
    });

因此当统一所选择的元素有变化的时候,就会通过广播触发此监听者,从而调用

UiFramework.dispatchActionToStore(SessionStateActionId.SetNumItemsSelected, numSelected);

然后分发状态到redux的Reducer操作,从而更新显示选中当前元素数目的状态栏项的内容,关键代码如下所示:

export function SessionStateReducer(state: SessionState = initialState, action: SessionStateActionsUnion): DeepReadonly<SessionState> {
  switch (action.type) {
    case SessionStateActionId.SetNumItemsSelected: {
      // istanbul ignore else
      if (undefined !== action.payload)
        return { ...state, numItemsSelected: action.payload };
      else
        return { ...state, numItemsSelected: 0 };
    }
    case SessionStateActionId.SetAvailableSelectionScopes: {
      const payloadArray: PresentationSelectionScope[] = [];
      action.payload.forEach((scope) => payloadArray.push(scope));
      // istanbul ignore else
      if (undefined !== action.payload)
        return { ...state, availableSelectionScopes: payloadArray };
      else
        return { ...state, availableSelectionScopes: [defaultSelectionScope] };
    }
    case SessionStateActionId.SetSelectionScope: {
      // istanbul ignore else
      if (undefined !== action.payload)
        return { ...state, activeSelectionScope: action.payload };
      else
        return { ...state, activeSelectionScope: defaultSelectionScope.id };
    }
    case SessionStateActionId.SetActiveIModelId: {
      // istanbul ignore else
      if (undefined !== action.payload)
        return { ...state, iModelId: action.payload };
      else
        return { ...state, iModelId: "" };
    }
    case SessionStateActionId.SetDefaultIModelViewportControlId: {
      return { ...state, defaultIModelViewportControlId: action.payload };
    }
    case SessionStateActionId.SetDefaultViewId: {
      return { ...state, defaultViewId: action.payload };
    }
    case SessionStateActionId.SetDefaultViewState: {
      return { ...state, defaultViewState: action.payload };
    }
    case SessionStateActionId.SetIModelConnection: {
      return { ...state, iModelConnection: action.payload };
    }
    case SessionStateActionId.SetUserInfo: {
      return { ...state, userInfo: action.payload };
    }
    case SessionStateActionId.UpdateCursorMenu: {
      return { ...state, cursorMenuData: action.payload };
    }
  }

  return state;
}

以上简单梳理了imodel.js中统一选择数据的机制,通过熟悉该机制,用户可以更好的实现自己的需求。