-
Notifications
You must be signed in to change notification settings - Fork 7
Enable dragging actions from execute_actions nodes to canvas to create new nodes #732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
651466f
32ffdb7
1177826
c304aa6
0cd1acd
76934a9
83ebd66
3786f2c
214976c
492605a
9c89df6
ceec7d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -346,6 +346,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( | ||
|
|
@@ -616,6 +619,57 @@ 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 actionIndex = parseInt(actionId.split('-')[1]); | ||
| 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 | ||
| }); | ||
| } | ||
|
Comment on lines
652
to
674
|
||
|
|
||
| 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 actionIndex = parseInt(actionId.split('-')[1]); | ||
ericnewcomer marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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; | ||
|
|
@@ -1012,6 +1066,9 @@ export class CanvasNode extends RapidElement { | |
| ? html`<temba-sortable-list | ||
| dragHandle="drag-handle" | ||
| @temba-order-changed="${this.handleActionOrderChanged}" | ||
| @temba-drag-external="${this.handleActionDragExternal}" | ||
| @temba-drag-internal="${this.handleActionDragInternal}" | ||
| @temba-drag-stop="${this.handleActionDragStop}" | ||
| > | ||
| ${this.node.actions.map((action, index) => | ||
| this.renderAction(this.node, action, index) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,15 @@ 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; | ||
|
|
||
| private canvasMouseDown = false; | ||
|
|
||
| // Bound event handlers to maintain proper 'this' context | ||
|
|
@@ -458,6 +473,24 @@ 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) => { | ||
| const customEvent = event as CustomEvent; | ||
| if (customEvent.detail.isExternal) { | ||
| this.handleActionDropExternal(customEvent); | ||
ericnewcomer marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| }); | ||
| } | ||
|
|
||
| private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition { | ||
|
|
@@ -750,6 +783,50 @@ export class Editor extends RapidElement { | |
| ></div>`; | ||
| } | ||
|
|
||
| private renderCanvasDropPreview(): TemplateResult | string { | ||
| if (!this.canvasDropPreview) return ''; | ||
|
|
||
| const { action, position } = this.canvasDropPreview; | ||
| const actionConfig = ACTION_CONFIG[action.type]; | ||
|
|
||
| if (!actionConfig) return ''; | ||
|
|
||
| return html`<div | ||
| class="canvas-drop-preview" | ||
| style="position: absolute; left: ${position.left}px; top: ${position.top}px; opacity: 0.6; pointer-events: none; z-index: 10000;" | ||
| > | ||
| <div | ||
| class="node execute-actions" | ||
| style="outline: 3px dashed var(--color-primary, #3b82f6); outline-offset: 2px; border-radius: var(--curvature);" | ||
| > | ||
| <div class="action sortable ${action.type}"> | ||
| <div class="action-content"> | ||
| <div | ||
| class="cn-title" | ||
| style="background: ${actionConfig.group | ||
| ? ACTION_GROUP_METADATA[actionConfig.group]?.color | ||
| : '#aaaaaa'}" | ||
| > | ||
| <div class="title-spacer"></div> | ||
| <div class="name">${actionConfig.name}</div> | ||
| <div class="title-spacer"></div> | ||
| </div> | ||
| <div class="body"> | ||
| ${actionConfig.render | ||
| ? actionConfig.render({ actions: [action] } as any, action) | ||
| : html`<pre>${action.type}</pre>`} | ||
|
Comment on lines
+822
to
+824
|
||
| </div> | ||
| </div> | ||
| </div> | ||
| <div class="action-exits"> | ||
| <div class="exit-wrapper"> | ||
| <div class="exit"></div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div>`; | ||
| } | ||
|
|
||
| private handleMouseMove(event: MouseEvent): void { | ||
| // Handle selection box drawing | ||
| if (this.canvasMouseDown && !this.isMouseDown) { | ||
|
|
@@ -1259,6 +1336,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(); | ||
| } | ||
|
Comment on lines
1437
to
1452
|
||
|
|
||
| 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`<style> | ||
|
|
@@ -1332,7 +1513,7 @@ export class Editor extends RapidElement { | |
| ></temba-sticky-note>`; | ||
| } | ||
| )} | ||
| ${this.renderSelectionBox()} | ||
| ${this.renderSelectionBox()} ${this.renderCanvasDropPreview()} | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.