diff --git a/src/flow/CanvasNode.ts b/src/flow/CanvasNode.ts index 3a0b80834..91c6739ea 100644 --- a/src/flow/CanvasNode.ts +++ b/src/flow/CanvasNode.ts @@ -376,6 +376,9 @@ export class CanvasNode extends RapidElement { constructor() { super(); this.handleActionOrderChanged = this.handleActionOrderChanged.bind(this); + this.handleActionDragExternal = this.handleActionDragExternal.bind(this); + this.handleActionDragInternal = this.handleActionDragInternal.bind(this); + this.handleActionDragStop = this.handleActionDragStop.bind(this); } protected updated( @@ -646,6 +649,67 @@ export class CanvasNode extends RapidElement { .updateNode(this.node.uuid, { ...this.node, actions: newActions }); } + private handleActionDragExternal(event: CustomEvent) { + // stop propagation of the original event from SortableList + event.stopPropagation(); + + // get the action being dragged + const actionId = event.detail.id; + const splitId = actionId.split('-'); + if (splitId.length < 2 || isNaN(parseInt(splitId[1], 10))) { + // invalid format, do not proceed + return; + } + const actionIndex = parseInt(splitId[1], 10); + const action = this.node.actions[actionIndex]; + + // fire event to editor to show canvas drop preview + this.fireCustomEvent(CustomEventType.DragExternal, { + action, + nodeUuid: this.node.uuid, + actionIndex, + mouseX: event.detail.mouseX, + mouseY: event.detail.mouseY + }); + } + + private handleActionDragInternal(_event: CustomEvent) { + // stop propagation of the original event from SortableList + _event.stopPropagation(); + + // fire event to editor to hide canvas drop preview + this.fireCustomEvent(CustomEventType.DragInternal, {}); + } + + private handleActionDragStop(event: CustomEvent) { + const isExternal = event.detail.isExternal; + + if (isExternal) { + // stop propagation of the original event from SortableList + event.stopPropagation(); + + // get the action being dragged + const actionId = event.detail.id; + const split = actionId.split('-'); + if (split.length < 2 || isNaN(Number(split[1]))) { + // invalid actionId format, do not proceed + return; + } + const actionIndex = parseInt(split[1], 10); + const action = this.node.actions[actionIndex]; + + // fire event to editor to create new node + this.fireCustomEvent(CustomEventType.DragStop, { + action, + nodeUuid: this.node.uuid, + actionIndex, + isExternal: true, + mouseX: event.detail.mouseX, + mouseY: event.detail.mouseY + }); + } + } + private handleActionMouseDown(event: MouseEvent, action: Action): void { // Don't handle clicks on the remove button, drag handle, or when action is in removing state const target = event.target as HTMLElement; @@ -1051,7 +1115,11 @@ export class CanvasNode extends RapidElement { ? this.ui.type === 'execute_actions' ? html` ${this.node.actions.map((action, index) => this.renderAction(this.node, action, index) diff --git a/src/flow/Editor.ts b/src/flow/Editor.ts index 5c5416e8f..4bd40d403 100644 --- a/src/flow/Editor.ts +++ b/src/flow/Editor.ts @@ -15,6 +15,7 @@ import { repeat } from 'lit-html/directives/repeat.js'; import { CustomEventType } from '../interfaces'; import { generateUUID } from '../utils'; import { ACTION_CONFIG, NODE_CONFIG } from './config'; +import { ACTION_GROUP_METADATA } from './types'; import { Plumber } from './Plumber'; import { CanvasNode } from './CanvasNode'; @@ -59,6 +60,11 @@ export interface SelectionBox { const DRAG_THRESHOLD = 5; +// Offset for positioning dropped action node relative to mouse cursor +// Keep small to make drop location close to cursor position +const DROP_PREVIEW_OFFSET_X = 20; +const DROP_PREVIEW_OFFSET_Y = 20; + export class Editor extends RapidElement { // unfortunately, jsplumb requires that we be in light DOM createRenderRoot() { @@ -139,6 +145,14 @@ export class Editor extends RapidElement { @state() private pendingNodePosition: FlowPosition | null = null; + // Canvas drop state for dragging actions to canvas + @state() + private canvasDropPreview: { + action: Action; + nodeUuid: string; + actionIndex: number; + position: FlowPosition; + } | null = null; @state() private addActionToNodeUuid: string | null = null; @@ -467,6 +481,23 @@ export class Editor extends RapidElement { this.handleNodeTypeSelection(event); } }); + + // Listen for action drag events from nodes + this.addEventListener( + CustomEventType.DragExternal, + this.handleActionDragExternal.bind(this) + ); + + this.addEventListener( + CustomEventType.DragInternal, + this.handleActionDragInternal.bind(this) + ); + + this.addEventListener(CustomEventType.DragStop, (event: CustomEvent) => { + if (event.detail.isExternal) { + this.handleActionDropExternal(event); + } + }); } private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition { @@ -759,6 +790,50 @@ export class Editor extends RapidElement { >`; } + private renderCanvasDropPreview(): TemplateResult | string { + if (!this.canvasDropPreview) return ''; + + const { action, position } = this.canvasDropPreview; + const actionConfig = ACTION_CONFIG[action.type]; + + if (!actionConfig) return ''; + + return html`
+
+
+
+
+
+
${actionConfig.name}
+
+
+
+ ${actionConfig.render + ? actionConfig.render({ actions: [action] } as any, action) + : html`
${action.type}
`} +
+
+
+
+
+
+
+
+
+
`; + } + private handleMouseMove(event: MouseEvent): void { // Handle selection box drawing if (this.canvasMouseDown && !this.isMouseDown) { @@ -1331,6 +1406,110 @@ export class Editor extends RapidElement { this.closeNodeEditor(); } + private calculateCanvasDropPosition( + mouseX: number, + mouseY: number, + applyGridSnapping: boolean = true + ): FlowPosition { + // calculate the position on the canvas + const canvas = this.querySelector('#canvas'); + if (!canvas) return { left: 0, top: 0 }; + + const canvasRect = canvas.getBoundingClientRect(); + + // calculate position relative to canvas + // canvasRect gives us the canvas position in the viewport, which already accounts for scroll + // so we just need mouseX/Y - canvasRect.left/top to get position within canvas + const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X; + const top = mouseY - canvasRect.top - DROP_PREVIEW_OFFSET_Y; + + // Apply grid snapping only if requested (for final drop position) + if (applyGridSnapping) { + return { + left: snapToGrid(left), + top: snapToGrid(top) + }; + } + + return { left, top }; + } + + private handleActionDragExternal(event: CustomEvent): void { + const { action, nodeUuid, actionIndex, mouseX, mouseY } = event.detail; + + // Don't snap to grid for preview - let it follow cursor smoothly + const position = this.calculateCanvasDropPosition(mouseX, mouseY, false); + + this.canvasDropPreview = { + action, + nodeUuid, + actionIndex, + position + }; + + // Force re-render to update preview position + this.requestUpdate(); + } + + private handleActionDragInternal(_event: CustomEvent): void { + this.canvasDropPreview = null; + } + + private handleActionDropExternal(event: CustomEvent): void { + const { action, nodeUuid, actionIndex, mouseX, mouseY } = event.detail; + + // Snap to grid for the final drop position + const position = this.calculateCanvasDropPosition(mouseX, mouseY, true); + + // remove the action from the original node + const originalNode = this.definition.nodes.find((n) => n.uuid === nodeUuid); + if (!originalNode) return; + + const updatedActions = originalNode.actions.filter( + (_a, idx) => idx !== actionIndex + ); + + // if no actions remain, delete the node + if (updatedActions.length === 0) { + getStore()?.getState().removeNodes([nodeUuid]); + } else { + // update the node + const updatedNode = { ...originalNode, actions: updatedActions }; + getStore()?.getState().updateNode(nodeUuid, updatedNode); + } + + // create a new execute_actions node with the dropped action + const newNode: Node = { + uuid: generateUUID(), + actions: [action], + exits: [ + { + uuid: generateUUID(), + destination_uuid: null + } + ] + }; + + const newNodeUI: NodeUI = { + position, + type: 'execute_actions', + config: {} + }; + + // add the new node + getStore()?.getState().addNode(newNode, newNodeUI); + + // clear the preview + this.canvasDropPreview = null; + + // repaint connections + if (this.plumber) { + requestAnimationFrame(() => { + this.plumber.repaintEverything(); + }); + } + } + public render(): TemplateResult { // we have to embed our own style since we are in light DOM const style = html`