diff --git a/packages/jupytercad-extension/src/commands.ts b/packages/jupytercad-extension/src/commands.ts index 1f52e2bd..04173009 100644 --- a/packages/jupytercad-extension/src/commands.ts +++ b/packages/jupytercad-extension/src/commands.ts @@ -130,10 +130,11 @@ const OPERATORS = { shape: 'Part::Cut', default: (model: IJupyterCadModel) => { const objects = model.getAllObject(); + const selected = model.localState?.selected.value || []; return { Name: newName('Cut', model), - Base: objects[0].name ?? '', - Tool: objects[1].name ?? '', + Base: selected.length > 0 ? selected[0] : objects[0].name ?? '', + Tool: selected.length > 1 ? selected[1] : objects[1].name ?? '', Refine: false, Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; @@ -178,9 +179,10 @@ const OPERATORS = { shape: 'Part::Extrusion', default: (model: IJupyterCadModel) => { const objects = model.getAllObject(); + const selected = model.localState?.selected.value || []; return { Name: newName('Extrusion', model), - Base: [objects[0].name ?? ''], + Base: [selected.length > 0 ? selected[0] : objects[0].name ?? ''], Dir: [0, 0, 1], LengthFwd: 10, LengthRev: 0, @@ -218,9 +220,13 @@ const OPERATORS = { shape: 'Part::MultiFuse', default: (model: IJupyterCadModel) => { const objects = model.getAllObject(); + const selected = model.localState?.selected.value || []; return { Name: newName('Union', model), - Shapes: [objects[0].name ?? '', objects[1].name ?? ''], + Shapes: [ + selected.length > 0 ? selected[0] : objects[0].name ?? '', + selected.length > 1 ? selected[1] : objects[1].name ?? '' + ], Refine: false, Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; @@ -255,9 +261,13 @@ const OPERATORS = { shape: 'Part::MultiCommon', default: (model: IJupyterCadModel) => { const objects = model.getAllObject(); + const selected = model.localState?.selected.value || []; return { Name: newName('Intersection', model), - Shapes: [objects[0].name ?? '', objects[1].name ?? ''], + Shapes: [ + selected.length > 0 ? selected[0] : objects[0].name ?? '', + selected.length > 1 ? selected[1] : objects[1].name ?? '' + ], Refine: false, Placement: { Position: [0, 0, 0], Axis: [0, 0, 1], Angle: 0 } }; diff --git a/packages/jupytercad-extension/src/mainview.tsx b/packages/jupytercad-extension/src/mainview.tsx index 5a409fae..0f1d8bbf 100644 --- a/packages/jupytercad-extension/src/mainview.tsx +++ b/packages/jupytercad-extension/src/mainview.tsx @@ -488,43 +488,37 @@ export class MainView extends React.Component { private _onClick(e: MouseEvent) { const selection = this._pick(); - - const guidata = this._model.sharedModel.getOption('guidata'); + const selectedMeshesNames = new Set( + this._selectedMeshes.map(sel => sel.name) + ); if (selection) { - // Deselect old selection - if (this._selectedMesh) { - let originalColor = DEFAULT_MESH_COLOR; - if ( - guidata && - guidata[this._selectedMesh.name] && - guidata[this._selectedMesh.name]['color'] - ) { - const rgba = guidata[this._selectedMesh.name]['color'] as number[]; - originalColor = new THREE.Color(rgba[0], rgba[1], rgba[2]); - } - - this._selectedMesh.material.color = originalColor; + // TODO Support selecting edges? + let selectionName = ''; + if (selection.mesh.name.startsWith('edge')) { + selectionName = (selection.mesh.parent as BasicMesh).name; + } else { + selectionName = selection.mesh.name; } - // Set new selection - if (selection.mesh === this._selectedMesh) { - this._selectedMesh = null; - } else { - // TODO Support selecting edges? - if (selection.mesh.name.startsWith('edge')) { - this._selectedMesh = selection.mesh.parent as BasicMesh; + if (e.ctrlKey) { + if (selectedMeshesNames.has(selectionName)) { + selectedMeshesNames.delete(selectionName); } else { - this._selectedMesh = selection.mesh; + selectedMeshesNames.add(selectionName); } - } - - if (this._selectedMesh) { - this._selectedMesh.material.color = SELECTED_MESH_COLOR; - this._model.syncSelectedObject(this._selectedMesh.name, this.state.id); } else { - this._model.syncSelectedObject(undefined, this.state.id); + const alreadySelected = selectedMeshesNames.has(selectionName); + selectedMeshesNames.clear(); + + if (!alreadySelected) { + selectedMeshesNames.add(selectionName); + } } + + const names = Array.from(selectedMeshesNames); + this._updateSelected(names); + this._model.syncSelectedObject(names, this.state.id); } } @@ -538,6 +532,9 @@ export class MainView extends React.Component { const guidata = this._model.sharedModel.getOption('guidata'); + const selectedNames = this._selectedMeshes.map(sel => sel.name); + this._selectedMeshes = []; + this._boundingGroup = new THREE.Box3(); this._meshGroup = new THREE.Group(); @@ -619,8 +616,8 @@ export class MainView extends React.Component { this._boundingGroup.expandByObject(mesh); } - if (this._selectedMesh?.name === objName) { - this._selectedMesh = mesh; + if (selectedNames.includes(objName)) { + this._selectedMeshes.push(mesh); mesh.material.color = SELECTED_MESH_COLOR; } @@ -726,37 +723,36 @@ export class MainView extends React.Component { return new THREE.Mesh(this._pointerGeometry, material); } - private _selectObject(name: string): void { - if (name === this._selectedMesh?.name) { - return; - } - - const selected = this._meshGroup?.getObjectByName(name); + private _updateSelected(names: string[]) { + // Reset original color for old selection + for (const selectedMesh of this._selectedMeshes) { + let originalColor = DEFAULT_MESH_COLOR; + const guidata = this._model.sharedModel.getOption('guidata'); + if ( + guidata && + guidata[selectedMesh.name] && + guidata[selectedMesh.name]['color'] + ) { + const rgba = guidata[selectedMesh.name]['color'] as number[]; + originalColor = new THREE.Color(rgba[0], rgba[1], rgba[2]); + } - if (selected) { - this._selectedMesh = selected as BasicMesh; - this._selectedMesh.material.color = SELECTED_MESH_COLOR; + selectedMesh.material.color = originalColor; } - } - private _deselectObject(): void { - if (!this._selectedMesh) { - return; - } + // Set new selection + this._selectedMeshes = []; + for (const name of names) { + const selected = this._meshGroup?.getObjectByName(name) as + | BasicMesh + | undefined; + if (!selected) { + continue; + } - let originalColor = DEFAULT_MESH_COLOR; - const guidata = this._model.sharedModel.getOption('guidata'); - if ( - guidata && - guidata[this._selectedMesh.name] && - guidata[this._selectedMesh.name]['color'] - ) { - const rgba = guidata[this._selectedMesh.name]['color'] as number[]; - originalColor = new THREE.Color(rgba[0], rgba[1], rgba[2]); + this._selectedMeshes.push(selected); + selected.material.color = SELECTED_MESH_COLOR; } - - this._selectedMesh.material.color = originalColor; - this._selectedMesh = null; } private _onSharedMetadataChanged = ( @@ -804,12 +800,8 @@ export class MainView extends React.Component { } // Sync selected - if (remoteState.selected.value !== this._selectedMesh?.name) { - this._deselectObject(); - - if (remoteState.selected.value) { - this._selectObject(remoteState.selected.value); - } + if (Array.isArray(remoteState.selected.value)) { + this._updateSelected(remoteState.selected.value); } // Sync camera @@ -840,12 +832,8 @@ export class MainView extends React.Component { // Sync local selection if needed const localState = this._model.localState; - if (localState?.selected?.value !== this._selectedMesh?.name) { - this._deselectObject(); - - if (localState?.selected?.value) { - this._selectObject(localState.selected.value); - } + if (localState?.selected && Array.isArray(localState.selected.value)) { + this._updateSelected(localState.selected.value); } } @@ -1151,7 +1139,7 @@ export class MainView extends React.Component { position: THREE.Vector3 | undefined, parent: string | undefined ) => void; - private _selectedMesh: BasicMesh | null = null; + private _selectedMeshes: BasicMesh[] = []; private _meshGroup: THREE.Group | null = null; // The list of ThreeJS meshes diff --git a/packages/jupytercad-extension/src/model.ts b/packages/jupytercad-extension/src/model.ts index 890cf089..fc4eb07d 100644 --- a/packages/jupytercad-extension/src/model.ts +++ b/packages/jupytercad-extension/src/model.ts @@ -196,7 +196,7 @@ export class JupyterCadModel implements IJupyterCadModel { }); } - syncSelectedObject(name?: string, emitter?: string): void { + syncSelectedObject(name: string[], emitter?: string): void { this.sharedModel.awareness.setLocalStateField('selected', { value: name, emitter: emitter diff --git a/packages/jupytercad-extension/src/panelview/objecttree.tsx b/packages/jupytercad-extension/src/panelview/objecttree.tsx index 1798e9e5..1178c1c7 100644 --- a/packages/jupytercad-extension/src/panelview/objecttree.tsx +++ b/packages/jupytercad-extension/src/panelview/objecttree.tsx @@ -9,7 +9,12 @@ import { closeIcon } from '@jupyterlab/ui-components'; import { Panel } from '@lumino/widgets'; -import { ReactTree, ThemeSettings, TreeNodeList } from '@naisutech/react-tree'; +import { + ReactTree, + ThemeSettings, + TreeNodeId, + TreeNodeList +} from '@naisutech/react-tree'; import visibilitySvg from '../../style/icon/visibility.svg'; import visibilityOffSvg from '../../style/icon/visibilityOff.svg'; @@ -80,7 +85,7 @@ interface IStates { jcadObject?: IJCadModel; options?: JSONObject; lightTheme: boolean; - selectedNode: string | null; + selectedNodes: string[]; 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. @@ -102,7 +107,7 @@ class ObjectTreeReact extends React.Component { filePath: this.props.cpModel.filePath, jcadObject: this.props.cpModel.jcadModel?.getAllObject(), lightTheme, - selectedNode: null, + selectedNodes: [], clientId: null, id: uuid(), openNodes: [] @@ -213,26 +218,28 @@ class ObjectTreeReact extends React.Component { return; } - let selectedNode: string | null = null; + let selectedNodes: string[] = []; if (localState.remoteUser) { // We are in following mode. // Sync selections from a remote user const remoteState = clients.get(localState.remoteUser); if (remoteState?.selected?.value) { - selectedNode = remoteState?.selected?.value; + selectedNodes = remoteState.selected.value; } - } else if (localState.selected.value) { - selectedNode = localState.selected.value; + } else if (localState.selected?.value) { + selectedNodes = localState.selected.value; } const openNodes = [...this.state.openNodes]; - if (selectedNode && openNodes.indexOf(selectedNode) === -1) { - openNodes.push(selectedNode); + for (const selectedNode of selectedNodes) { + if (selectedNode && openNodes.indexOf(selectedNode) === -1) { + openNodes.push(selectedNode); + } } - this.setState(old => ({ ...old, openNodes, selectedNode })); + this.setState(old => ({ ...old, openNodes, selectedNodes })); }; private _onClientSharedOptionsChanged = ( @@ -243,50 +250,52 @@ class ObjectTreeReact extends React.Component { }; render(): React.ReactNode { - const { selectedNode, openNodes, options } = this.state; + const { selectedNodes, openNodes, options } = this.state; const data = this.stateToTree(); - let selectedNodes: (number | string)[] = []; - if (selectedNode) { + const selectedNodeIds: TreeNodeId[] = []; + for (const selectedNode of selectedNodes) { const parentNode = data.filter(node => node.id === selectedNode); if (parentNode.length > 0 && parentNode[0].items.length > 0) { - selectedNodes = [parentNode[0].items[0].id]; + selectedNodeIds.push(parentNode[0].items[0].id); } } return (
{ - if (id && id.length > 0) { - let name = id[0] as string; - - if (name.includes('#')) { - name = name.split('#')[0]; - this.props.cpModel.jcadModel?.syncSelectedObject( - name, - this.state.id - ); - return; - } + if (id === selectedNodeIds) { + return; + } - const openNodes = [...this.state.openNodes]; - const index = openNodes.indexOf(name); + console.log('toggle selected', id); - if (index !== -1) { - openNodes.splice(index, 1); - } else { - openNodes.push(name); + if (id && id.length > 0) { + const names: string[] = []; + for (const subid of id) { + const name = subid as string; + + if (name.includes('#')) { + names.push(name.split('#')[0]); + } else { + names.push(name); + } } - this.setState(old => ({ ...old, openNodes })); + + this.props.cpModel.jcadModel?.syncSelectedObject( + names, + this.state.id + ); } else { - this.props.cpModel.jcadModel?.syncSelectedObject(undefined); + this.props.cpModel.jcadModel?.syncSelectedObject([]); } }} RenderNode={opts => { @@ -359,9 +368,7 @@ class ObjectTreeReact extends React.Component { this.props.cpModel.jcadModel?.sharedModel.removeObjectByName( objectId ); - this.props.cpModel.jcadModel?.syncSelectedObject( - undefined - ); + this.props.cpModel.jcadModel?.syncSelectedObject([]); }} icon={closeIcon} /> diff --git a/packages/jupytercad-extension/src/types.ts b/packages/jupytercad-extension/src/types.ts index e394dafe..e2323155 100644 --- a/packages/jupytercad-extension/src/types.ts +++ b/packages/jupytercad-extension/src/types.ts @@ -180,7 +180,7 @@ export interface IJupyterCadDoc extends YDocument { export interface IJupyterCadClientState { pointer: { value?: Pointer; emitter?: string | null }; camera: { value?: Camera; emitter?: string | null }; - selected: { value?: string; emitter?: string | null }; + selected: { value?: string[]; emitter?: string | null }; selectedPropField?: { id: string | null; value: any; @@ -215,7 +215,7 @@ export interface IJupyterCadModel extends DocumentRegistry.IModel { syncPointer(position: Pointer | undefined, emitter?: string): void; syncCamera(camera: Camera | undefined, emitter?: string): void; - syncSelectedObject(name: string | undefined, emitter?: string): void; + syncSelectedObject(name: string[], emitter?: string): void; syncSelectedPropField(data: { id: string | null; value: any; diff --git a/ui-tests/tests/ui.spec.ts b/ui-tests/tests/ui.spec.ts index 8ccb7aa7..c89e547f 100644 --- a/ui-tests/tests/ui.spec.ts +++ b/ui-tests/tests/ui.spec.ts @@ -245,5 +245,70 @@ test.describe('UI Test', () => { }); } }); + + test(`Should be able to do multi selection`, async ({ page }) => { + await page.goto(); + + const fileName = 'example3.FCStd'; + const fullPath = `examples/${fileName}`; + await page.notebook.openByPath(fullPath); + await page.notebook.activate(fullPath); + await page.locator('div.jpcad-Spinner').waitFor({ state: 'hidden' }); + + // Create a cone + const btn = await page.locator( + "button.jp-ToolbarButtonComponent[data-command='jupytercad:newCone']" + ); + await btn.click(); + await page.getByLabel('Radius1').click(); + await page.getByLabel('Radius1').fill('15'); + await page.getByLabel('Radius2').click(); + await page.getByLabel('Radius2').fill('5'); + await page.getByLabel('Height').click(); + await page.getByLabel('Height').fill('20'); + await page + .locator('div.jp-Dialog-buttonLabel', { + hasText: 'Submit' + }) + .click(); + + // Select cone + await page + .locator('[data-test-id="react-tree-root"]') + .getByText('Cone 1') + .click(); + + // Select other shape with ctrl key pressed + await page.keyboard.down('Control'); + await page + .locator('[data-test-id="react-tree-root"]') + .getByText('Cut') + .click(); + + let main = await page.$('#jp-main-split-panel'); + if (main) { + expect(await main.screenshot()).toMatchSnapshot({ + name: `MultiSelect-${fileName}.png` + }); + } + + // Apply a cut operator from the selection + const cutbtn = await page.locator( + "button.jp-ToolbarButtonComponent[data-command='jupytercad:cut']" + ); + await cutbtn.click(); + await page + .locator('div.jp-Dialog-buttonLabel', { + hasText: 'Submit' + }) + .click(); + + main = await page.$('#jp-main-split-panel'); + if (main) { + expect(await main.screenshot()).toMatchSnapshot({ + name: `MultiSelect-Cut-${fileName}.png` + }); + } + }); }); }); diff --git a/ui-tests/tests/ui.spec.ts-snapshots/MultiSelect-example3-FCStd-linux.png b/ui-tests/tests/ui.spec.ts-snapshots/MultiSelect-example3-FCStd-linux.png new file mode 100644 index 00000000..a8a6e090 Binary files /dev/null and b/ui-tests/tests/ui.spec.ts-snapshots/MultiSelect-example3-FCStd-linux.png differ