Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/flow/CanvasNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new event handlers handleActionDragExternal, handleActionDragInternal, and handleActionDragStop in CanvasNode.ts lack test coverage. According to the project's coding guidelines requiring 100% code coverage, tests should be added to verify these handlers correctly parse action IDs, extract action data, and propagate events to the editor.

Copilot generated this review using guidance from repository custom instructions.

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]);
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;
Expand Down Expand Up @@ -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)
Expand Down
183 changes: 182 additions & 1 deletion src/flow/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
});
}

private getPosition(uuid: string, type: 'node' | 'sticky'): FlowPosition {
Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of as any bypasses type safety. Consider defining a proper interface or type for the render function's first parameter to maintain type safety, or at minimum document why the type cast is necessary here.

Copilot uses AI. Check for mistakes.
</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) {
Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new methods handleActionDragExternal, handleActionDragInternal, handleActionDropExternal, calculateCanvasDropPosition, and renderCanvasDropPreview in Editor.ts lack test coverage. According to the project's coding guidelines requiring 100% code coverage, comprehensive tests should be added for this new drag-to-canvas functionality.

Copilot generated this review using guidance from repository custom instructions.

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>
Expand Down Expand Up @@ -1332,7 +1513,7 @@ export class Editor extends RapidElement {
></temba-sticky-note>`;
}
)}
${this.renderSelectionBox()}
${this.renderSelectionBox()} ${this.renderCanvasDropPreview()}
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ export enum CustomEventType {
OrderChanged = 'temba-order-changed',
DragStart = 'temba-drag-start',
DragStop = 'temba-drag-stop',
DragExternal = 'temba-drag-external',
DragInternal = 'temba-drag-internal',
Resized = 'temba-resized',
DetailsChanged = 'temba-details-changed',
Error = 'temba-error',
Expand Down
Loading
Loading