diff --git a/packages/editor-ui/src/composables/useCanvasMouseSelect.ts b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts new file mode 100644 index 0000000000..51b1eac9d8 --- /dev/null +++ b/packages/editor-ui/src/composables/useCanvasMouseSelect.ts @@ -0,0 +1,234 @@ +import { INodeUi, XYPosition } from '@/Interface'; + +import useDeviceSupport from './useDeviceSupport'; +import { useUIStore } from '@/stores/ui'; +import { useWorkflowsStore } from '@/stores/workflows'; +import { + getMousePosition, + getRelativePosition, + HEADER_HEIGHT, + SIDEBAR_WIDTH, + SIDEBAR_WIDTH_EXPANDED, +} from '@/utils/nodeViewUtils'; +import { ref, watchEffect, onMounted, computed, onUnmounted } from 'vue'; +import { useCanvasStore } from '@/stores/canvas'; + +interface ExtendedHTMLSpanElement extends HTMLSpanElement { + x: number; + y: number; +} + +export default function useCanvasMouseSelect() { + const selectActive = ref(false); + const selectBox = ref(document.createElement('span') as ExtendedHTMLSpanElement); + + const { isTouchDevice, isCtrlKeyPressed } = useDeviceSupport(); + const uiStore = useUIStore(); + const canvasStore = useCanvasStore(); + const workflowsStore = useWorkflowsStore(); + + function _setSelectBoxStyle(styles: Record) { + Object.assign(selectBox.value.style, styles); + } + + function _showSelectBox(event: MouseEvent) { + const [x, y] = getMousePositionWithinNodeView(event); + selectBox.value = Object.assign(selectBox.value, { x, y }); + + _setSelectBoxStyle({ + left: selectBox.value.x + 'px', + top: selectBox.value.y + 'px', + visibility: 'visible', + }); + selectActive.value = true; + } + + function _updateSelectBox(event: MouseEvent) { + const selectionBox = _getSelectionBox(event); + + _setSelectBoxStyle({ + left: selectionBox.x + 'px', + top: selectionBox.y + 'px', + width: selectionBox.width + 'px', + height: selectionBox.height + 'px', + }); + } + + function _hideSelectBox() { + selectBox.value.x = 0; + selectBox.value.y = 0; + + _setSelectBoxStyle({ + visibility: 'hidden', + left: '0px', + top: '0px', + width: '0px', + height: '0px', + }); + selectActive.value = false; + } + + function _getSelectionBox(event: MouseEvent) { + const [x, y] = getMousePositionWithinNodeView(event); + return { + x: Math.min(x, selectBox.value.x), + y: Math.min(y, selectBox.value.y), + width: Math.abs(x - selectBox.value.x), + height: Math.abs(y - selectBox.value.y), + }; + } + + function _getNodesInSelection(event: MouseEvent): INodeUi[] { + const returnNodes: INodeUi[] = []; + const selectionBox = _getSelectionBox(event); + + // Go through all nodes and check if they are selected + workflowsStore.allNodes.forEach((node: INodeUi) => { + // TODO: Currently always uses the top left corner for checking. Should probably use the center instead + if ( + node.position[0] < selectionBox.x || + node.position[0] > selectionBox.x + selectionBox.width + ) { + return; + } + if ( + node.position[1] < selectionBox.y || + node.position[1] > selectionBox.y + selectionBox.height + ) { + return; + } + returnNodes.push(node); + }); + + return returnNodes; + } + + function _createSelectBox() { + selectBox.value.id = 'select-box'; + _setSelectBoxStyle({ + margin: '0px auto', + border: '2px dotted #FF0000', + // Positioned absolutely within #node-view. This is consistent with how nodes are positioned. + position: 'absolute', + zIndex: '100', + visibility: 'hidden', + }); + + selectBox.value.addEventListener('mouseup', mouseUpMouseSelect); + + const nodeViewEl = document.querySelector('#node-view') as HTMLDivElement; + nodeViewEl.appendChild(selectBox.value); + } + + function _mouseMoveSelect(e: MouseEvent) { + if (e.buttons === 0) { + // Mouse button is not pressed anymore so stop selection mode + // Happens normally when mouse leave the view pressed and then + // comes back unpressed. + mouseUpMouseSelect(e); + return; + } + + _updateSelectBox(e); + } + + function mouseUpMouseSelect(e: MouseEvent) { + if (selectActive.value === false) { + if (isTouchDevice === true && e.target instanceof HTMLElement) { + if (e.target && e.target.id.includes('node-view')) { + // Deselect all nodes + deselectAllNodes(); + } + } + // If it is not active return directly. + // Else normal node dragging will not work. + return; + } + document.removeEventListener('mousemove', _mouseMoveSelect); + + // Deselect all nodes + deselectAllNodes(); + + // Select the nodes which are in the selection box + const selectedNodes = _getNodesInSelection(e); + selectedNodes.forEach((node) => { + nodeSelected(node); + }); + + if (selectedNodes.length === 1) { + uiStore.lastSelectedNode = selectedNodes[0].name; + } + + _hideSelectBox(); + } + function mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) { + if (isCtrlKeyPressed(e) === true || moveButtonPressed) { + // We only care about it when the ctrl key is not pressed at the same time. + // So we exit when it is pressed. + return; + } + + if (uiStore.isActionActive('dragActive')) { + // If a node does currently get dragged we do not activate the selection + return; + } + _showSelectBox(e); + + // Leave like this. + // Do not add an anonymous function because then remove would not work anymore + document.addEventListener('mousemove', _mouseMoveSelect); + } + + function getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition { + const [mouseX, mouseY] = getMousePosition(event); + + const sidebarWidth = canvasStore.isDemo + ? 0 + : uiStore.sidebarMenuCollapsed + ? SIDEBAR_WIDTH + : SIDEBAR_WIDTH_EXPANDED; + const headerHeight = canvasStore.isDemo ? 0 : HEADER_HEIGHT; + + const relativeX = mouseX - sidebarWidth; + const relativeY = mouseY - headerHeight; + const nodeViewScale = canvasStore.nodeViewScale; + const nodeViewOffsetPosition = uiStore.nodeViewOffsetPosition; + + return getRelativePosition(relativeX, relativeY, nodeViewScale, nodeViewOffsetPosition); + } + + function nodeDeselected(node: INodeUi) { + uiStore.removeNodeFromSelection(node); + instance.value.removeFromDragSelection(instance.value.getManagedElement(node?.id)); + } + + function nodeSelected(node: INodeUi) { + uiStore.addSelectedNode(node); + instance.value.addToDragSelection(instance.value.getManagedElement(node?.id)); + } + + function deselectAllNodes() { + instance.value.clearDragSelection(); + uiStore.resetSelectedNodes(); + uiStore.lastSelectedNode = null; + uiStore.lastSelectedNodeOutputIndex = null; + + canvasStore.lastSelectedConnection = null; + canvasStore.newNodeInsertPosition = null; + } + + const instance = computed(() => canvasStore.jsPlumbInstance); + + onMounted(() => { + _createSelectBox(); + }); + + return { + getMousePositionWithinNodeView, + mouseUpMouseSelect, + mouseDownMouseSelect, + nodeDeselected, + nodeSelected, + deselectAllNodes, + }; +} diff --git a/packages/editor-ui/src/composables/useDeviceSupport.ts b/packages/editor-ui/src/composables/useDeviceSupport.ts new file mode 100644 index 0000000000..7c0be6fe66 --- /dev/null +++ b/packages/editor-ui/src/composables/useDeviceSupport.ts @@ -0,0 +1,37 @@ +import { ref } from 'vue'; + +interface DeviceSupportHelpers { + isTouchDevice: boolean; + isMacOs: boolean; + controlKeyCode: string; + isCtrlKeyPressed: (e: MouseEvent | KeyboardEvent) => boolean; +} + +export default function useDeviceSupportHelpers(): DeviceSupportHelpers { + const isTouchDevice = ref('ontouchstart' in window || navigator.maxTouchPoints > 0); + const userAgent = ref(navigator.userAgent.toLowerCase()); + const isMacOs = ref( + userAgent.value.includes('macintosh') || + userAgent.value.includes('ipad') || + userAgent.value.includes('iphone') || + userAgent.value.includes('ipod'), + ); + const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control'); + + function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean { + if (isTouchDevice.value === true) { + return true; + } + if (isMacOs.value) { + return (e as KeyboardEvent).metaKey; + } + return (e as KeyboardEvent).ctrlKey; + } + + return { + isTouchDevice: isTouchDevice.value, + isMacOs: isMacOs.value, + controlKeyCode: controlKeyCode.value, + isCtrlKeyPressed, + }; +} diff --git a/packages/editor-ui/src/mixins/mouseSelect.ts b/packages/editor-ui/src/mixins/mouseSelect.ts deleted file mode 100644 index c412a91b9f..0000000000 --- a/packages/editor-ui/src/mixins/mouseSelect.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { INodeUi, XYPosition } from '@/Interface'; - -import mixins from 'vue-typed-mixins'; - -import { deviceSupportHelpers } from '@/mixins/deviceSupportHelpers'; -import { VIEWS } from '@/constants'; -import { mapStores } from 'pinia'; -import { useUIStore } from '@/stores/ui'; -import { useWorkflowsStore } from '@/stores/workflows'; -import { - getMousePosition, - getRelativePosition, - HEADER_HEIGHT, - SIDEBAR_WIDTH, - SIDEBAR_WIDTH_EXPANDED, -} from '@/utils/nodeViewUtils'; - -export const mouseSelect = mixins(deviceSupportHelpers).extend({ - data() { - return { - selectActive: false, - selectBox: document.createElement('span'), - }; - }, - mounted() { - this.createSelectBox(); - }, - computed: { - ...mapStores(useUIStore, useWorkflowsStore), - isDemo(): boolean { - return this.$route.name === VIEWS.DEMO; - }, - }, - methods: { - createSelectBox() { - this.selectBox.id = 'select-box'; - this.selectBox.style.margin = '0px auto'; - this.selectBox.style.border = '2px dotted #FF0000'; - // Positioned absolutely within #node-view. This is consistent with how nodes are positioned. - this.selectBox.style.position = 'absolute'; - this.selectBox.style.zIndex = '100'; - this.selectBox.style.visibility = 'hidden'; - - this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect); - - const nodeViewEl = this.$el.querySelector('#node-view') as HTMLDivElement; - nodeViewEl.appendChild(this.selectBox); - }, - isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean { - if (this.isTouchDevice === true) { - return true; - } - if (this.isMacOs) { - return e.metaKey; - } - return e.ctrlKey; - }, - getMousePositionWithinNodeView(event: MouseEvent | TouchEvent): XYPosition { - const [x, y] = getMousePosition(event); - const sidebarOffset = this.isDemo - ? 0 - : this.uiStore.sidebarMenuCollapsed - ? SIDEBAR_WIDTH - : SIDEBAR_WIDTH_EXPANDED; - const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT; - // @ts-ignore - return getRelativePosition( - x - sidebarOffset, - y - headerOffset, - this.nodeViewScale, - this.uiStore.nodeViewOffsetPosition, - ); - }, - showSelectBox(event: MouseEvent) { - const [x, y] = this.getMousePositionWithinNodeView(event); - this.selectBox = Object.assign(this.selectBox, { x, y }); - - // @ts-ignore - this.selectBox.style.left = this.selectBox.x + 'px'; - // @ts-ignore - this.selectBox.style.top = this.selectBox.y + 'px'; - this.selectBox.style.visibility = 'visible'; - - this.selectActive = true; - }, - updateSelectBox(event: MouseEvent) { - const selectionBox = this.getSelectionBox(event); - this.selectBox.style.left = selectionBox.x + 'px'; - this.selectBox.style.top = selectionBox.y + 'px'; - - this.selectBox.style.width = selectionBox.width + 'px'; - this.selectBox.style.height = selectionBox.height + 'px'; - }, - hideSelectBox() { - this.selectBox.style.visibility = 'hidden'; - // @ts-ignore - this.selectBox.x = 0; - // @ts-ignore - this.selectBox.y = 0; - this.selectBox.style.left = '0px'; - this.selectBox.style.top = '0px'; - this.selectBox.style.width = '0px'; - this.selectBox.style.height = '0px'; - - this.selectActive = false; - }, - getSelectionBox(event: MouseEvent) { - const [x, y] = this.getMousePositionWithinNodeView(event); - return { - // @ts-ignore - x: Math.min(x, this.selectBox.x), - // @ts-ignore - y: Math.min(y, this.selectBox.y), - // @ts-ignore - width: Math.abs(x - this.selectBox.x), - // @ts-ignore - height: Math.abs(y - this.selectBox.y), - }; - }, - getNodesInSelection(event: MouseEvent): INodeUi[] { - const returnNodes: INodeUi[] = []; - const selectionBox = this.getSelectionBox(event); - - // Go through all nodes and check if they are selected - this.workflowsStore.allNodes.forEach((node: INodeUi) => { - // TODO: Currently always uses the top left corner for checking. Should probably use the center instead - if ( - node.position[0] < selectionBox.x || - node.position[0] > selectionBox.x + selectionBox.width - ) { - return; - } - if ( - node.position[1] < selectionBox.y || - node.position[1] > selectionBox.y + selectionBox.height - ) { - return; - } - returnNodes.push(node); - }); - - return returnNodes; - }, - mouseDownMouseSelect(e: MouseEvent, moveButtonPressed: boolean) { - if (this.isCtrlKeyPressed(e) === true || moveButtonPressed) { - // We only care about it when the ctrl key is not pressed at the same time. - // So we exit when it is pressed. - return; - } - - if (this.uiStore.isActionActive('dragActive')) { - // If a node does currently get dragged we do not activate the selection - return; - } - this.showSelectBox(e); - - // @ts-ignore // Leave like this. Do not add a anonymous function because then remove would not work anymore - this.$el.addEventListener('mousemove', this.mouseMoveSelect); - }, - mouseUpMouseSelect(e: MouseEvent) { - if (this.selectActive === false) { - if (this.isTouchDevice === true) { - // @ts-ignore - if (e.target && e.target.id.includes('node-view')) { - // Deselect all nodes - this.deselectAllNodes(); - } - } - // If it is not active return directly. - // Else normal node dragging will not work. - return; - } - - // @ts-ignore - this.$el.removeEventListener('mousemove', this.mouseMoveSelect); - - // Deselect all nodes - this.deselectAllNodes(); - - // Select the nodes which are in the selection box - const selectedNodes = this.getNodesInSelection(e); - selectedNodes.forEach((node) => { - this.nodeSelected(node); - }); - - if (selectedNodes.length === 1) { - this.uiStore.lastSelectedNode = selectedNodes[0].name; - } - - this.hideSelectBox(); - }, - mouseMoveSelect(e: MouseEvent) { - if (e.buttons === 0) { - // Mouse button is not pressed anymore so stop selection mode - // Happens normally when mouse leave the view pressed and then - // comes back unpressed. - this.mouseUpMouseSelect(e); - return; - } - - this.updateSelectBox(e); - }, - nodeDeselected(node: INodeUi) { - this.uiStore.removeNodeFromSelection(node); - // @ts-ignore - this.instance.removeFromDragSelection(this.instance.getManagedElement(node?.id)); - }, - nodeSelected(node: INodeUi) { - this.uiStore.addSelectedNode(node); - // @ts-ignore - this.instance.addToDragSelection(this.instance.getManagedElement(node?.id)); - }, - deselectAllNodes() { - // @ts-ignore - this.instance.clearDragSelection(); - this.uiStore.resetSelectedNodes(); - this.uiStore.lastSelectedNode = null; - this.uiStore.lastSelectedNodeOutputIndex = null; - // @ts-ignore - this.lastSelectedConnection = null; - // @ts-ignore - this.newNodeInsertPosition = null; - }, - }, -}); diff --git a/packages/editor-ui/src/stores/canvas.ts b/packages/editor-ui/src/stores/canvas.ts index 565dc24ef5..7fa8329cb7 100644 --- a/packages/editor-ui/src/stores/canvas.ts +++ b/packages/editor-ui/src/stores/canvas.ts @@ -18,7 +18,7 @@ import { newInstance } from '@jsplumb/browser-ui'; import { N8nPlusEndpointHandler } from '@/plugins/endpoints/N8nPlusEndpointType'; import * as N8nPlusEndpointRenderer from '@/plugins/endpoints/N8nPlusEndpointRenderer'; import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector'; -import { EndpointFactory, Connectors } from '@jsplumb/core'; +import { EndpointFactory, Connectors, Connection } from '@jsplumb/core'; import { MoveNodeCommand } from '@/models/history'; import { DEFAULT_PLACEHOLDER_TRIGGER_BUTTON, @@ -42,6 +42,8 @@ export const useCanvasStore = defineStore('canvas', () => { const jsPlumbInstanceRef = ref(); const isDragging = ref(false); + const lastSelectedConnection = ref(null); + const newNodeInsertPosition = ref(null); const nodes = computed(() => workflowStore.allNodes); const triggerNodes = computed(() => @@ -256,6 +258,9 @@ export const useCanvasStore = defineStore('canvas', () => { isDemo, nodeViewScale, canvasAddButtonPosition, + lastSelectedConnection, + newNodeInsertPosition, + jsPlumbInstance, setRecenteredCanvasAddButtonPosition, getNodesWithPlaceholderNode, setZoomLevel, @@ -265,6 +270,5 @@ export const useCanvasStore = defineStore('canvas', () => { zoomToFit, wheelScroll, initInstance, - jsPlumbInstance, }; }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 2c8f5b4328..f2adb4a70d 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -203,10 +203,10 @@ import { import { copyPaste } from '@/mixins/copyPaste'; import { externalHooks } from '@/mixins/externalHooks'; import { genericHelpers } from '@/mixins/genericHelpers'; -import { mouseSelect } from '@/mixins/mouseSelect'; import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow'; import { restApi } from '@/mixins/restApi'; import useGlobalLinkActions from '@/composables/useGlobalLinkActions'; +import useCanvasMouseSelect from '@/composables/useCanvasMouseSelect'; import { showMessage } from '@/mixins/showMessage'; import { titleChange } from '@/mixins/titleChange'; @@ -321,7 +321,6 @@ export default mixins( copyPaste, externalHooks, genericHelpers, - mouseSelect, moveNodeWorkflow, restApi, showMessage, @@ -342,10 +341,9 @@ export default mixins( CanvasControls, }, setup() { - const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions(); return { - registerCustomAction, - unregisterCustomAction, + ...useCanvasMouseSelect(), + ...useGlobalLinkActions(), }; }, errorCaptured: (err, vm, info) => { @@ -592,14 +590,12 @@ export default mixins( GRID_SIZE: NodeViewUtils.GRID_SIZE, STICKY_NODE_TYPE, createNodeActive: false, - lastSelectedConnection: null as null | Connection, lastClickPosition: [450, 450] as XYPosition, ctrlKeyPressed: false, moveCanvasKeyPressed: false, stopExecutionInProgress: false, blankRedirect: false, credentialsUpdated: false, - newNodeInsertPosition: null as XYPosition | null, pullConnActiveNodeName: null as string | null, pullConnActive: false, dropPrevented: false, @@ -1682,7 +1678,8 @@ export default mixins( // If adding more than one node, offset the X position mousePosition[0] - NodeViewUtils.NODE_SIZE / 2 + - NodeViewUtils.NODE_SIZE * (index * 2 + NodeViewUtils.GRID_SIZE), + NodeViewUtils.NODE_SIZE * index * 2 + + NodeViewUtils.GRID_SIZE, mousePosition[1] - NodeViewUtils.NODE_SIZE / 2, ] as XYPosition, dragAndDrop: true, @@ -1711,8 +1708,8 @@ export default mixins( this.nodeSelected(node); this.uiStore.lastSelectedNode = node.name; this.uiStore.lastSelectedNodeOutputIndex = null; - this.lastSelectedConnection = null; - this.newNodeInsertPosition = null; + this.canvasStore.lastSelectedConnection = null; + this.canvasStore.newNodeInsertPosition = null; if (setActive) { this.ndvStore.activeNodeName = node.name; @@ -1854,7 +1851,7 @@ export default mixins( options.position, ); } else if (lastSelectedNode) { - const lastSelectedConnection = this.lastSelectedConnection; + const lastSelectedConnection = this.canvasStore.lastSelectedConnection; if (lastSelectedConnection) { // set when injecting into a connection const [diffX] = NodeViewUtils.getConnectorLengths(lastSelectedConnection); @@ -1868,12 +1865,12 @@ export default mixins( } // set when pulling connections - if (this.newNodeInsertPosition) { + if (this.canvasStore.newNodeInsertPosition) { newNodeData.position = NodeViewUtils.getNewNodePosition(this.nodes, [ - this.newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE, - this.newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2, + this.canvasStore.newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE, + this.canvasStore.newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2, ]); - this.newNodeInsertPosition = null; + this.canvasStore.newNodeInsertPosition = null; } else { let yOffset = 0; @@ -2035,7 +2032,7 @@ export default mixins( return; } - const lastSelectedConnection = this.lastSelectedConnection; + const lastSelectedConnection = this.canvasStore.lastSelectedConnection; const lastSelectedNode = this.lastSelectedNode; const lastSelectedNodeOutputIndex = this.uiStore.lastSelectedNodeOutputIndex; @@ -2087,10 +2084,10 @@ export default mixins( this.uiStore.lastSelectedNode = sourceNode.name; this.uiStore.lastSelectedNodeOutputIndex = info.index; - this.newNodeInsertPosition = null; + this.canvasStore.newNodeInsertPosition = null; if (info.connection) { - this.lastSelectedConnection = info.connection; + this.canvasStore.lastSelectedConnection = info.connection; } this.onToggleNodeCreator({ @@ -2363,7 +2360,7 @@ export default mixins( try { this.pullConnActiveNodeName = null; this.pullConnActive = true; - this.newNodeInsertPosition = null; + this.canvasStore.newNodeInsertPosition = null; NodeViewUtils.resetConnection(connection); const nodes = [...document.querySelectorAll('.node-wrapper')]; @@ -2414,7 +2411,7 @@ export default mixins( const onMouseUp = (e: MouseEvent | TouchEvent) => { this.pullConnActive = false; - this.newNodeInsertPosition = this.getMousePositionWithinNodeView(e); + this.canvasStore.newNodeInsertPosition = this.getMousePositionWithinNodeView(e); NodeViewUtils.resetConnectionAfterPull(connection); window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp);