diff --git a/packages/base/src/panelview/components/layers.tsx b/packages/base/src/panelview/components/layers.tsx index d0b870a80..b0c02f089 100644 --- a/packages/base/src/panelview/components/layers.tsx +++ b/packages/base/src/panelview/components/layers.tsx @@ -1,7 +1,9 @@ import { IJGISLayersGroup, IJGISLayersTree, - IJupyterGISModel + IJupyterGISClientState, + IJupyterGISModel, + ISelection } from '@jupytergis/schema'; import { Button, @@ -9,10 +11,10 @@ import { ReactWidget, caretDownIcon } from '@jupyterlab/ui-components'; -import { ISignal, Signal } from '@lumino/signaling'; import { Panel } from '@lumino/widgets'; import React, { useEffect, useState } from 'react'; import { nonVisibilityIcon, rasterIcon, visibilityIcon } from '../../icons'; +import { IControlPanelModel } from '../../types'; const LAYERS_PANEL_CLASS = 'jp-gis-layerPanel'; const LAYERS_GROUP_CLASS = 'jp-gis-layersGroup'; @@ -31,7 +33,7 @@ export namespace LayersPanel { * Options of the layers panel widget. */ export interface IOptions { - model: IJupyterGISModel | undefined; + model: IControlPanelModel; } } @@ -48,28 +50,12 @@ export class LayersPanel extends Panel { ReactWidget.create( ) ); } - /** - * Set the GIS model associated to the widget. - */ - set model(value: IJupyterGISModel | undefined) { - this._model = value; - this._modelChanged.emit(value); - } - - /** - * A signal emitting when the GIS model changed. - */ - get modelChanged(): ISignal { - return this._modelChanged; - } - /** * Function to call when a layer is selected from a component of the panel. * @@ -77,21 +63,24 @@ export class LayersPanel extends Panel { */ private _onSelect = (layer?: string) => { if (this._model) { - this._model.currentLayer = layer ?? null; + const selection: { [key: string]: ISelection } = {}; + if (layer) { + selection[layer] = { + type: 'layer' + }; + } + this._model?.jGISModel?.syncSelected(selection, this.id); } }; - private _model: IJupyterGISModel | undefined; - private _modelChanged = new Signal( - this - ); + private _model: IControlPanelModel | undefined; } /** * Properties of the layers body component. */ -interface IBodyProps extends LayersPanel.IOptions { - modelChanged: ISignal; +interface IBodyProps { + model: IControlPanelModel; onSelect: (layer?: string) => void; } @@ -99,7 +88,9 @@ interface IBodyProps extends LayersPanel.IOptions { * The body component of the panel. */ function LayersBodyComponent(props: IBodyProps): JSX.Element { - const [model, setModel] = useState(props.model); + const [model, setModel] = useState( + props.model?.jGISModel + ); const [layersTree, setLayersTree] = useState( model?.getLayersTree() || [] ); @@ -118,31 +109,35 @@ function LayersBodyComponent(props: IBodyProps): JSX.Element { const updateLayers = () => { setLayersTree(model?.getLayersTree() || []); }; - model?.sharedModel.layersChanged.connect(updateLayers); - model?.sharedModel.layersTreeChanged.connect(updateLayers); + model?.sharedModel?.layersChanged.connect(updateLayers); + model?.sharedModel?.layersTreeChanged.connect(updateLayers); return () => { - model?.sharedModel.layersChanged.disconnect(updateLayers); - model?.sharedModel.layersTreeChanged.disconnect(updateLayers); + model?.sharedModel?.layersChanged.disconnect(updateLayers); + model?.sharedModel?.layersTreeChanged.disconnect(updateLayers); }; }, [model]); /** * Update the model when it changes. */ - props.modelChanged.connect((_, model) => { - setModel(model); - setLayersTree(model?.getLayersTree() || []); + props.model?.documentChanged.connect((_, widget) => { + setModel(widget?.context.model); + setLayersTree(widget?.context.model?.getLayersTree() || []); }); return (
{layersTree.map(layer => typeof layer === 'string' ? ( - + ) : ( @@ -155,7 +150,8 @@ function LayersBodyComponent(props: IBodyProps): JSX.Element { /** * Properties of the layers group component. */ -interface ILayersGroupProps extends LayersPanel.IOptions { +interface ILayersGroupProps { + gisModel: IJupyterGISModel | undefined; group: IJGISLayersGroup | undefined; onClick: (item?: string) => void; } @@ -164,7 +160,7 @@ interface ILayersGroupProps extends LayersPanel.IOptions { * The component to handle group of layers. */ function LayersGroupComponent(props: ILayersGroupProps): JSX.Element { - const { group, model } = props; + const { group, gisModel } = props; if (group === undefined) { return <>; } @@ -189,13 +185,13 @@ function LayersGroupComponent(props: ILayersGroupProps): JSX.Element { {layers.map(layer => typeof layer === 'string' ? ( ) : ( @@ -210,22 +206,32 @@ function LayersGroupComponent(props: ILayersGroupProps): JSX.Element { /** * Properties of the layer component. */ -interface ILayerProps extends LayersPanel.IOptions { +interface ILayerProps { + gisModel: IJupyterGISModel | undefined; layerId: string; onClick: (item?: string) => void; } +function isSelected(layerId: string, model: IJupyterGISModel | undefined) { + return ( + (model?.localState?.selected?.value && + Object.keys(model?.localState?.selected?.value).includes(layerId)) || + false + ); +} + /** * The component to display a single layer. */ function LayerComponent(props: ILayerProps): JSX.Element { - const { layerId, model } = props; - const layer = model?.getLayer(layerId); + const { layerId, gisModel } = props; + const layer = gisModel?.getLayer(layerId); if (layer === undefined) { return <>; } const [selected, setSelected] = useState( - model?.currentLayer === layerId + // TODO Support multi-selection as `model?.jGISModel?.localState?.selected.value` does + isSelected(layerId, gisModel) ); const name = layer.name; @@ -233,22 +239,26 @@ function LayerComponent(props: ILayerProps): JSX.Element { * Listen to the changes on the current layer. */ useEffect(() => { - const isSelected = () => { - setSelected(model?.currentLayer === layerId); + const onClientSharedStateChanged = ( + sender: IJupyterGISModel, + clients: Map + ) => { + // TODO Support follow mode and remoteUser state + setSelected(isSelected(layerId, gisModel)); }; - model?.currentLayerChanged.connect(isSelected); + gisModel?.clientStateChanged.connect(onClientSharedStateChanged); return () => { - model?.currentLayerChanged.disconnect(isSelected); + gisModel?.clientStateChanged.disconnect(onClientSharedStateChanged); }; - }, [model]); + }, [gisModel]); /** * Toggle layer visibility. */ const toggleVisibility = () => { layer.visible = !layer.visible; - model?.sharedModel.updateLayer(layerId, layer); + gisModel?.sharedModel?.updateLayer(layerId, layer); }; return ( diff --git a/packages/base/src/panelview/leftpanel.tsx b/packages/base/src/panelview/leftpanel.tsx index 1958af714..891e8c923 100644 --- a/packages/base/src/panelview/leftpanel.tsx +++ b/packages/base/src/panelview/leftpanel.tsx @@ -16,13 +16,12 @@ export class LeftPanelWidget extends SidePanel { // const datasources = new DataSourceList({ controlPanelModel: this._model }); // this.addWidget(datasources); - const layersTree = new LayersPanel({ model: this._model.jGISModel }); + const layersTree = new LayersPanel({ model: this._model }); layersTree.title.caption = 'Layer tree'; layersTree.title.label = 'Layers'; this.addWidget(layersTree); options.tracker.currentChanged.connect((_, changed) => { - layersTree.model = this._model.jGISModel; if (changed) { header.title.label = changed.context.localPath; } else { diff --git a/packages/base/src/panelview/objectproperties.tsx b/packages/base/src/panelview/objectproperties.tsx index 18d52512b..74a6cbfdb 100644 --- a/packages/base/src/panelview/objectproperties.tsx +++ b/packages/base/src/panelview/objectproperties.tsx @@ -1,7 +1,9 @@ import { IDict, IJGISFormSchemaRegistry, + IJGISLayer, IJGISLayerDocChange, + IJGISSource, IJupyterGISClientState, IJupyterGISDoc, IJupyterGISModel, @@ -38,8 +40,12 @@ interface IStates { jGISOption?: IDict; filePath?: string; selectedObjectData?: IDict; + selectedObjectSourceData?: IDict; selectedObject?: string; + selectedObjectType?: 'layer' | 'source'; + selectedObjectSource?: string; schema?: IDict; + sourceSchema?: IDict; clientId: number | null; // ID of the yjs client id: string; // ID of the component, it is used to identify which component //is the source of awareness updates. @@ -59,6 +65,7 @@ class ObjectPropertiesReact extends React.Component { clientId: null, id: uuid() }; + this._formSchema = props.formSchemaRegistry.getSchemas(); this.props.cpModel.jGISModel?.sharedLayersChanged.connect( this._sharedJGISModelChanged @@ -84,8 +91,11 @@ class ObjectPropertiesReact extends React.Component { jGISOption: undefined, filePath: undefined, selectedObjectData: undefined, + selectedObjectSourceData: undefined, selectedObject: undefined, - schema: undefined + selectedObjectSource: undefined, + schema: undefined, + sourceSchema: undefined }); } }); @@ -136,19 +146,7 @@ class ObjectPropertiesReact extends React.Component { ): void => { this.setState(old => { if (old.selectedObject) { - const selectedObject = - this.props.cpModel.jGISModel?.sharedModel.getObject( - old.selectedObject - ); - if (selectedObject) { - const selectedObjectData = selectedObject.parameters; - return { - ...old, - selectedObjectData - }; - } else { - return old; - } + return this.getStateForSelection(old, old.selectedObject); } else { return old; } @@ -161,6 +159,7 @@ class ObjectPropertiesReact extends React.Component { ): void => { const remoteUser = this.props.cpModel.jGISModel?.localState?.remoteUser; let newState: IJupyterGISClientState | undefined; + const clientId = this.state.clientId; if (remoteUser) { newState = clients.get(remoteUser); @@ -177,6 +176,7 @@ class ObjectPropertiesReact extends React.Component { ); } } else { + const localState = clientId ? clients.get(clientId) : null; if (this._lastSelectedPropFieldId) { removeStyleFromProperty( `${this.state.filePath}::panel`, @@ -186,27 +186,173 @@ class ObjectPropertiesReact extends React.Component { this._lastSelectedPropFieldId = undefined; } + if ( + localState && + localState.selected?.emitter && + localState.selected.emitter !== this.state.id && + localState.selected?.value + ) { + newState = localState; + } + } + if (newState) { + const selection = newState.selected.value; + const selectedObjectIds = Object.keys(selection || {}); + // Only show object properties if ONE object is selected + if (selection === undefined || selectedObjectIds.length !== 1) { + this.setState(old => ({ + ...old, + selectedObject: undefined, + selectedObjectSource: undefined, + selectedObjectData: undefined, + selectedObjectSourceData: undefined, + schema: undefined, + sourceSchema: undefined + })); + return; + } + + const selectedObject = selectedObjectIds[0]; + if (selectedObject !== this.state.selectedObject) { + this.setState(old => { + return this.getStateForSelection(old, selectedObject); + }); + } } }; + private getStateForSelection(old: IStates, selectedObject: string): IStates { + let selectedObj: IJGISLayer | IJGISSource | undefined; + // This will be the layer source in case where the selected object is a layer + let selectedObjSource: IJGISSource | undefined; + + selectedObj = this.props.cpModel.jGISModel?.getLayer(selectedObject); + + if (!selectedObj) { + selectedObj = this.props.cpModel.jGISModel?.getSource(selectedObject); + } + + if (selectedObj && selectedObj.parameters?.source) { + selectedObjSource = this.props.cpModel.jGISModel?.getSource( + selectedObj!.parameters?.source + ); + } + + if (!selectedObj) { + return { + ...old, + selectedObject: undefined, + selectedObjectSource: undefined, + selectedObjectData: undefined, + selectedObjectSourceData: undefined, + schema: undefined, + sourceSchema: undefined + }; + } + + let schema: IDict | undefined; + let selectedObjectSourceId: string | undefined; + const selectedObjectData = selectedObj.parameters; + if (selectedObj.type) { + schema = this._formSchema.get(selectedObj.type); + + // Generate dropdown for layer source entry + if ( + schema && + schema.properties.source && + selectedObjectData && + selectedObjectData.source + ) { + const sourceNames: string[] = []; + for (const sourceId of Object.keys( + this.props.cpModel.jGISModel?.getSources() || {} + )) { + const source = this.props.cpModel.jGISModel?.getSource(sourceId); + if (source) { + sourceNames.push(source.name); + } + } + selectedObjectSourceId = selectedObjectData.source; + selectedObjectData.source = this.props.cpModel.jGISModel?.getSource( + selectedObjectData.source + )?.name; + schema.properties.source.enum = sourceNames; + } + } + + let sourceSchema: IDict | undefined; + let selectedObjectSourceData: IDict | undefined; + if (selectedObjSource) { + sourceSchema = this._formSchema.get(selectedObjSource.type); + selectedObjectSourceData = selectedObjSource.parameters; + } + + return { + ...old, + selectedObjectData, + selectedObject, + selectedObjectType: selectedObjSource === undefined ? 'source' : 'layer', + selectedObjectSource: selectedObjectSourceId, + schema, + selectedObjectSourceData, + sourceSchema + }; + } + render(): React.ReactNode { return this.state.schema && this.state.selectedObjectData ? ( - { - this.syncObjectProperties(this.state.selectedObject, properties); - }} - syncSelectedField={this.syncSelectedField} - /> +
+

Layer Properties

+ { + if (properties.source) { + const sources = this.props.cpModel.jGISModel?.getSources(); + if (!sources) { + throw Error('Unreachable'); + } + + for (const source of Object.keys(sources)) { + if (sources[source].name === properties.source) { + properties.source = source; + break; + } + } + } + + this.syncObjectProperties(this.state.selectedObject, properties); + }} + syncSelectedField={this.syncSelectedField} + /> + {this.state.selectedObjectSourceData && this.state.sourceSchema && ( + <> +

Source Properties

+ { + this.syncObjectProperties( + this.state.selectedObjectSource, + properties + ); + }} + syncSelectedField={this.syncSelectedField} + /> + + )} +
) : (
); } private _lastSelectedPropFieldId?: string; + private _formSchema: Map; } export namespace ObjectProperties { diff --git a/packages/schema/src/doc.ts b/packages/schema/src/doc.ts index 8a48d919e..e078e2c25 100644 --- a/packages/schema/src/doc.ts +++ b/packages/schema/src/doc.ts @@ -202,11 +202,7 @@ export class JupyterGISDoc } updateSource(id: string, value: any): void { - const obj = this._getSourceAsYMap(id); - if (!obj) { - return; - } - this.transact(() => obj.set(id, value)); + this.transact(() => this._sources.set(id, value)); } getOption(key: keyof IJGISOptions): IDict | undefined { diff --git a/packages/schema/src/interfaces.ts b/packages/schema/src/interfaces.ts index 382e06088..529dcc3f3 100644 --- a/packages/schema/src/interfaces.ts +++ b/packages/schema/src/interfaces.ts @@ -45,7 +45,13 @@ export interface IJGISSourceDocChange { }>; } +export interface ISelection { + type: 'layer' | 'source'; + parent?: string; +} + export interface IJupyterGISClientState { + selected: { value?: { [key: string]: ISelection }; emitter?: string | null }; selectedPropField?: { id: string | null; value: any; @@ -109,7 +115,6 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel { isDisposed: boolean; sharedModel: IJupyterGISDoc; localState: IJupyterGISClientState | null; - currentLayer: string | null; themeChanged: Signal< IJupyterGISModel, @@ -119,7 +124,6 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel { IJupyterGISModel, Map >; - currentLayerChanged: ISignal; sharedOptionsChanged: ISignal; sharedLayersChanged: ISignal; sharedLayersTreeChanged: ISignal; @@ -132,11 +136,12 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel { getSource(id: string): IJGISSource | undefined; getLayersTree(): IJGISLayersTree; + syncSelected(value: { [key: string]: ISelection }, emitter?: string): void; syncSelectedPropField(data: { id: string | null; value: any; parentType: 'panel' | 'dialog'; - }); + }): void; setUserToFollow(userId?: number): void; syncFormData(form: any): void; diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 998bf3d25..6ef443cf5 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -22,6 +22,7 @@ import { IJupyterGISClientState, IJupyterGISDoc, IJupyterGISModel, + ISelection, IUserData } from './interfaces'; import jgisSchema from './schema/jgis.json'; @@ -47,17 +48,6 @@ export class JupyterGISModel implements IJupyterGISModel { readonly collaborative = true; - /** - * Getter and setter for the current selected layer. - */ - get currentLayer(): string | null { - return this._currentLayer; - } - set currentLayer(layer: string | null) { - this._currentLayer = layer; - this._currentLayerChanged.emit(layer); - } - get sharedModel(): IJupyterGISDoc { return this._sharedModel; } @@ -141,10 +131,6 @@ export class JupyterGISModel implements IJupyterGISModel { return this.sharedModel.sourcesChanged; } - get currentLayerChanged(): ISignal { - return this._currentLayerChanged; - } - get disposed(): ISignal { return this._disposed; } @@ -227,6 +213,13 @@ export class JupyterGISModel implements IJupyterGISModel { return this.sharedModel.getSource(id); } + syncSelected(value: { [key: string]: ISelection }, emitter?: string): void { + this.sharedModel.awareness.setLocalStateField('selected', { + value, + emitter: emitter + }); + } + syncSelectedPropField(data: { id: string | null; value: any; @@ -287,9 +280,6 @@ export class JupyterGISModel implements IJupyterGISModel { Map >(this); - private _currentLayer: string | null = null; - private _currentLayerChanged = new Signal(this); - static worker: Worker; }