@nyaruka/temba-components 0.156.4 → 0.156.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.4",
3
+ "version": "0.156.5",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -5,7 +5,8 @@ import { ACTION_GROUP_METADATA, SPLIT_GROUP_METADATA } from './types';
5
5
  import { Action, Exit, Node, NodeUI, Router } from '../store/flow-definition';
6
6
  import { property } from 'lit/decorators.js';
7
7
  import { RapidElement } from '../RapidElement';
8
- import { getClasses } from '../utils';
8
+ import { generateUUID, getClasses } from '../utils';
9
+ import { SortableList } from '../list/SortableList';
9
10
  import { isRightClick, localizeAction, renderClamped } from './utils';
10
11
  import { Plumber } from './Plumber';
11
12
  import { getStore } from '../store/Store';
@@ -594,6 +595,8 @@ export class CanvasNode extends RapidElement {
594
595
  this.handleExternalActionDragLeave.bind(this);
595
596
  this.handleActionShowGhost = this.handleActionShowGhost.bind(this);
596
597
  this.handleActionHideGhost = this.handleActionHideGhost.bind(this);
598
+ this.handleActionShowOriginal = this.handleActionShowOriginal.bind(this);
599
+ this.handleActionHideOriginal = this.handleActionHideOriginal.bind(this);
597
600
  }
598
601
 
599
602
  connectedCallback() {
@@ -620,6 +623,14 @@ export class CanvasNode extends RapidElement {
620
623
  'action-hide-ghost',
621
624
  this.handleActionHideGhost as EventListener
622
625
  );
626
+ this.addEventListener(
627
+ 'action-show-original',
628
+ this.handleActionShowOriginal as EventListener
629
+ );
630
+ this.addEventListener(
631
+ 'action-hide-original',
632
+ this.handleActionHideOriginal as EventListener
633
+ );
623
634
 
624
635
  // Observe size changes to revalidate plumbing connections
625
636
  this.resizeObserver = new ResizeObserver(() => {
@@ -731,6 +742,14 @@ export class CanvasNode extends RapidElement {
731
742
  'action-hide-ghost',
732
743
  this.handleActionHideGhost as EventListener
733
744
  );
745
+ this.removeEventListener(
746
+ 'action-show-original',
747
+ this.handleActionShowOriginal as EventListener
748
+ );
749
+ this.removeEventListener(
750
+ 'action-hide-original',
751
+ this.handleActionHideOriginal as EventListener
752
+ );
734
753
 
735
754
  // Clear any pending exit removal timeouts
736
755
  this.exitRemovalTimeouts.forEach((timeoutId) => {
@@ -1551,11 +1570,32 @@ export class CanvasNode extends RapidElement {
1551
1570
  }
1552
1571
  }
1553
1572
 
1573
+ private handleActionShowOriginal(_event: CustomEvent): void {
1574
+ const sortableList = this.querySelector(
1575
+ 'temba-sortable-list'
1576
+ ) as SortableList;
1577
+ sortableList?.setOriginalVisible(true);
1578
+ this.showLastActionPlaceholder = false;
1579
+ this.requestUpdate();
1580
+ }
1581
+
1582
+ private handleActionHideOriginal(_event: CustomEvent): void {
1583
+ const sortableList = this.querySelector(
1584
+ 'temba-sortable-list'
1585
+ ) as SortableList;
1586
+ sortableList?.setOriginalVisible(false);
1587
+ // Restore the placeholder if this is the last action
1588
+ if (this.node.actions.length === 1) {
1589
+ this.showLastActionPlaceholder = true;
1590
+ }
1591
+ this.requestUpdate();
1592
+ }
1593
+
1554
1594
  private handleExternalActionDrop(event: CustomEvent): void {
1555
1595
  // Only handle if this is an execute_actions node
1556
1596
  if (this.ui.type !== 'execute_actions') return;
1557
1597
 
1558
- const { action, sourceNodeUuid, actionIndex } = event.detail;
1598
+ const { action, sourceNodeUuid, actionIndex, isCopy } = event.detail;
1559
1599
 
1560
1600
  // Don't accept drops from the same node
1561
1601
  if (sourceNodeUuid === this.node.uuid) return;
@@ -1581,30 +1621,52 @@ export class CanvasNode extends RapidElement {
1581
1621
 
1582
1622
  // IMPORTANT: Add the action to this node FIRST, before removing from source
1583
1623
  // This ensures we don't lose the action if the source node gets deleted
1624
+ const droppedAction = isCopy
1625
+ ? { ...action, uuid: generateUUID() }
1626
+ : action;
1584
1627
  const newActions = [...this.node.actions];
1585
- newActions.splice(dropIndex, 0, action);
1628
+ newActions.splice(dropIndex, 0, droppedAction);
1586
1629
 
1587
1630
  const updatedNode = { ...this.node, actions: newActions };
1588
1631
  getStore()?.getState().updateNode(this.node.uuid, updatedNode);
1589
1632
 
1590
- // Now remove the action from the source node
1591
- const updatedSourceActions = sourceNode.actions.filter(
1592
- (_a, idx) => idx !== actionIndex
1593
- );
1633
+ // Copy localizations from the original action to the new one
1634
+ if (isCopy) {
1635
+ const localization = flowDefinition.localization;
1636
+ if (localization) {
1637
+ for (const langCode of Object.keys(localization)) {
1638
+ const entry = localization[langCode]?.[action.uuid];
1639
+ if (entry) {
1640
+ store.getState().updateLocalization(
1641
+ langCode,
1642
+ droppedAction.uuid,
1643
+ JSON.parse(JSON.stringify(entry))
1644
+ );
1645
+ }
1646
+ }
1647
+ }
1648
+ }
1594
1649
 
1595
- // If source node has no actions left, remove it
1596
- if (updatedSourceActions.length === 0) {
1597
- // Fire event to Editor so it can clean up jsPlumb connections properly
1598
- this.fireCustomEvent(CustomEventType.NodeDeleted, {
1599
- uuid: sourceNodeUuid
1600
- });
1601
- } else {
1602
- // Update source node
1603
- const updatedSourceNode = {
1604
- ...sourceNode,
1605
- actions: updatedSourceActions
1606
- };
1607
- getStore()?.getState().updateNode(sourceNodeUuid, updatedSourceNode);
1650
+ if (!isCopy) {
1651
+ // Remove the action from the source node
1652
+ const updatedSourceActions = sourceNode.actions.filter(
1653
+ (_a, idx) => idx !== actionIndex
1654
+ );
1655
+
1656
+ // If source node has no actions left, remove it
1657
+ if (updatedSourceActions.length === 0) {
1658
+ // Fire event to Editor so it can clean up jsPlumb connections properly
1659
+ this.fireCustomEvent(CustomEventType.NodeDeleted, {
1660
+ uuid: sourceNodeUuid
1661
+ });
1662
+ } else {
1663
+ // Update source node
1664
+ const updatedSourceNode = {
1665
+ ...sourceNode,
1666
+ actions: updatedSourceActions
1667
+ };
1668
+ getStore()?.getState().updateNode(sourceNodeUuid, updatedSourceNode);
1669
+ }
1608
1670
  }
1609
1671
 
1610
1672
  // Request update and notify that this node's size changed
@@ -104,8 +104,9 @@ export interface SelectionBox {
104
104
  }
105
105
 
106
106
  const DRAG_THRESHOLD = 5;
107
- const AUTO_SCROLL_EDGE_ZONE = 100;
107
+ const AUTO_SCROLL_EDGE_ZONE = 150;
108
108
  const AUTO_SCROLL_MAX_SPEED = 15;
109
+ const AUTO_SCROLL_BEYOND_MULTIPLIER = 5;
109
110
 
110
111
  type TranslationType = 'property' | 'category';
111
112
 
@@ -499,6 +500,19 @@ export class Editor extends RapidElement {
499
500
  // Track previous target node to clear placeholder when moving between nodes
500
501
  private previousActionDragTargetNodeUuid: string | null = null;
501
502
 
503
+ // Track action external drag state for shift-copy support
504
+ private isActionExternalDrag = false;
505
+ private actionDragIsCopy = false;
506
+ private actionDragLastDetail: {
507
+ action: Action;
508
+ nodeUuid: string;
509
+ actionIndex: number;
510
+ mouseX: number;
511
+ mouseY: number;
512
+ actionHeight: number;
513
+ isLastAction: boolean;
514
+ } | null = null;
515
+
502
516
  // Connection placeholder state for dropping connections on empty canvas
503
517
  @state()
504
518
  private connectionPlaceholder: {
@@ -633,6 +647,7 @@ export class Editor extends RapidElement {
633
647
 
634
648
  #canvas.shift-held {
635
649
  --shift-held-cursor: copy;
650
+ cursor: copy;
636
651
  }
637
652
 
638
653
  #canvas.viewing-revision {
@@ -1415,6 +1430,11 @@ export class Editor extends RapidElement {
1415
1430
  }
1416
1431
 
1417
1432
  private makeConnection(info) {
1433
+ this.stopAutoScroll();
1434
+ this.autoScrollDeltaX = 0;
1435
+ this.autoScrollDeltaY = 0;
1436
+ this.lastPointerPos = null;
1437
+
1418
1438
  if (this.sourceId && this.targetId && this.isValidTarget) {
1419
1439
  // going to the same target, just put it back
1420
1440
  if (info.target.id === this.targetId) {
@@ -2311,7 +2331,7 @@ export class Editor extends RapidElement {
2311
2331
  if (event.key === 'Shift') {
2312
2332
  this.querySelector('#canvas')?.classList.add('shift-held');
2313
2333
 
2314
- // Toggle to copy mode mid-drag
2334
+ // Toggle to copy mode mid-drag (nodes)
2315
2335
  if (this.isDragging && !this.currentDragIsCopy) {
2316
2336
  this.hideDragHint();
2317
2337
  this.performShiftDragCopy();
@@ -2322,6 +2342,18 @@ export class Editor extends RapidElement {
2322
2342
  this.updateDragPositions();
2323
2343
  });
2324
2344
  }
2345
+
2346
+ // Toggle to copy mode mid-drag (actions)
2347
+ if (this.isActionExternalDrag && !this.actionDragIsCopy) {
2348
+ this.actionDragIsCopy = true;
2349
+ this.hideDragHint();
2350
+ this.showActionOriginal(true);
2351
+ // If this is a last-action drag, now show the canvas preview
2352
+ if (this.actionDragLastDetail?.isLastAction) {
2353
+ this.reprocessActionDrag();
2354
+ }
2355
+ this.requestUpdate();
2356
+ }
2325
2357
  }
2326
2358
 
2327
2359
  // Cmd/Ctrl+F opens flow search (unless a dialog is already open)
@@ -2357,11 +2389,23 @@ export class Editor extends RapidElement {
2357
2389
  if (event.key === 'Shift') {
2358
2390
  this.querySelector('#canvas')?.classList.remove('shift-held');
2359
2391
 
2360
- // Toggle back to move mode mid-drag
2392
+ // Toggle back to move mode mid-drag (nodes)
2361
2393
  if (this.isDragging && this.currentDragIsCopy) {
2362
2394
  this.revertShiftDragCopy();
2363
2395
  requestAnimationFrame(() => this.updateDragPositions());
2364
2396
  }
2397
+
2398
+ // Toggle back to move mode mid-drag (actions)
2399
+ if (this.isActionExternalDrag && this.actionDragIsCopy) {
2400
+ this.actionDragIsCopy = false;
2401
+ this.showDragHint();
2402
+ this.showActionOriginal(false);
2403
+ // If this is a last-action drag, hide the canvas preview again
2404
+ if (this.actionDragLastDetail?.isLastAction) {
2405
+ this.reprocessActionDrag();
2406
+ }
2407
+ this.requestUpdate();
2408
+ }
2365
2409
  }
2366
2410
  }
2367
2411
 
@@ -2373,6 +2417,16 @@ export class Editor extends RapidElement {
2373
2417
  this.revertShiftDragCopy();
2374
2418
  requestAnimationFrame(() => this.updateDragPositions());
2375
2419
  }
2420
+
2421
+ // Revert action copy mode on blur
2422
+ if (this.isActionExternalDrag && this.actionDragIsCopy) {
2423
+ this.actionDragIsCopy = false;
2424
+ this.showActionOriginal(false);
2425
+ if (this.actionDragLastDetail?.isLastAction) {
2426
+ this.reprocessActionDrag();
2427
+ }
2428
+ this.requestUpdate();
2429
+ }
2376
2430
  }
2377
2431
 
2378
2432
  // --- Flow settings cookie (LRU, max 50 flows) ---
@@ -3513,6 +3567,9 @@ export class Editor extends RapidElement {
3513
3567
  }
3514
3568
 
3515
3569
  if (this.plumber.connectionDragging) {
3570
+ this.lastPointerPos = { clientX: event.clientX, clientY: event.clientY };
3571
+ this.startAutoScroll();
3572
+
3516
3573
  const targetNode = document.querySelector('temba-flow-node:hover');
3517
3574
 
3518
3575
  // Clear previous target styles
@@ -3716,6 +3773,11 @@ export class Editor extends RapidElement {
3716
3773
 
3717
3774
  this.currentDragIsCopy = false;
3718
3775
 
3776
+ // The remove calls above set dirtyDate, but we're just reverting a
3777
+ // mid-drag copy — there's nothing to save yet. Clear it so no
3778
+ // revision is created while the drag is still in progress.
3779
+ getStore().getState().setDirtyDate(null);
3780
+
3719
3781
  // Restore drag context to originals
3720
3782
  this.currentDragItem = { ...this.originalDragItem };
3721
3783
  if (this.originalSelectedItems) {
@@ -3772,7 +3834,10 @@ export class Editor extends RapidElement {
3772
3834
  if (!editor) return;
3773
3835
 
3774
3836
  const tick = () => {
3775
- if (!this.isDragging || !this.lastPointerPos) {
3837
+ if (
3838
+ (!this.isDragging && !this.plumber?.connectionDragging) ||
3839
+ !this.lastPointerPos
3840
+ ) {
3776
3841
  this.autoScrollAnimationId = null;
3777
3842
  return;
3778
3843
  }
@@ -3784,32 +3849,56 @@ export class Editor extends RapidElement {
3784
3849
  let scrollDx = 0;
3785
3850
  let scrollDy = 0;
3786
3851
 
3787
- // Left edge
3852
+ // Left edge (including beyond)
3788
3853
  const distFromLeft = mouseX - editorRect.left;
3789
- if (distFromLeft >= 0 && distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
3790
- const ratio = 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE;
3791
- scrollDx = -(ratio * AUTO_SCROLL_MAX_SPEED);
3854
+ if (distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
3855
+ const beyond = distFromLeft < 0;
3856
+ const ratio = Math.min(
3857
+ 1,
3858
+ 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE
3859
+ );
3860
+ const speed =
3861
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
3862
+ scrollDx = -(ratio * speed);
3792
3863
  }
3793
3864
 
3794
- // Right edge
3865
+ // Right edge (including beyond)
3795
3866
  const distFromRight = editorRect.right - mouseX;
3796
- if (distFromRight >= 0 && distFromRight < AUTO_SCROLL_EDGE_ZONE) {
3797
- const ratio = 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE;
3798
- scrollDx = ratio * AUTO_SCROLL_MAX_SPEED;
3867
+ if (distFromRight < AUTO_SCROLL_EDGE_ZONE) {
3868
+ const beyond = distFromRight < 0;
3869
+ const ratio = Math.min(
3870
+ 1,
3871
+ 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE
3872
+ );
3873
+ const speed =
3874
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
3875
+ scrollDx = ratio * speed;
3799
3876
  }
3800
3877
 
3801
- // Top edge
3878
+ // Top edge (including beyond)
3802
3879
  const distFromTop = mouseY - editorRect.top;
3803
- if (distFromTop >= 0 && distFromTop < AUTO_SCROLL_EDGE_ZONE) {
3804
- const ratio = 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE;
3805
- scrollDy = -(ratio * AUTO_SCROLL_MAX_SPEED);
3880
+ if (distFromTop < AUTO_SCROLL_EDGE_ZONE) {
3881
+ const beyond = distFromTop < 0;
3882
+ const ratio = Math.min(
3883
+ 1,
3884
+ 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE
3885
+ );
3886
+ const speed =
3887
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
3888
+ scrollDy = -(ratio * speed);
3806
3889
  }
3807
3890
 
3808
- // Bottom edge
3891
+ // Bottom edge (including beyond)
3809
3892
  const distFromBottom = editorRect.bottom - mouseY;
3810
- if (distFromBottom >= 0 && distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
3811
- const ratio = 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE;
3812
- scrollDy = ratio * AUTO_SCROLL_MAX_SPEED;
3893
+ if (distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
3894
+ const beyond = distFromBottom < 0;
3895
+ const ratio = Math.min(
3896
+ 1,
3897
+ 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE
3898
+ );
3899
+ const speed =
3900
+ AUTO_SCROLL_MAX_SPEED * (beyond ? AUTO_SCROLL_BEYOND_MULTIPLIER : 1);
3901
+ scrollDy = ratio * speed;
3813
3902
  }
3814
3903
 
3815
3904
  if (scrollDx !== 0 || scrollDy !== 0) {
@@ -3823,7 +3912,7 @@ export class Editor extends RapidElement {
3823
3912
  (editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
3824
3913
  const neededHeight =
3825
3914
  (editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
3826
- getStore().getState().expandCanvas(neededWidth, neededHeight);
3915
+ getStore()?.getState()?.expandCanvas(neededWidth, neededHeight);
3827
3916
  }
3828
3917
 
3829
3918
  editor.scrollLeft += scrollDx;
@@ -4941,6 +5030,32 @@ export class Editor extends RapidElement {
4941
5030
  isLastAction = false
4942
5031
  } = event.detail;
4943
5032
 
5033
+ // Track action external drag state for shift-copy support
5034
+ const isFirstExternalEvent = !this.isActionExternalDrag;
5035
+ this.isActionExternalDrag = true;
5036
+ this.actionDragLastDetail = {
5037
+ action,
5038
+ nodeUuid,
5039
+ actionIndex,
5040
+ mouseX,
5041
+ mouseY,
5042
+ actionHeight,
5043
+ isLastAction
5044
+ };
5045
+
5046
+ // Initialize copy mode from current shift state (handles shift held before drag)
5047
+ if (isFirstExternalEvent) {
5048
+ const shiftHeld =
5049
+ this.querySelector('#canvas')?.classList.contains('shift-held') ??
5050
+ false;
5051
+ if (shiftHeld) {
5052
+ this.actionDragIsCopy = true;
5053
+ this.showActionOriginal(true);
5054
+ } else {
5055
+ this.showDragHint();
5056
+ }
5057
+ }
5058
+
4944
5059
  // Check if mouse is over another execute_actions node
4945
5060
  const targetNode = this.getNodeAtPosition(mouseX, mouseY);
4946
5061
 
@@ -5037,9 +5152,9 @@ export class Editor extends RapidElement {
5037
5152
  `temba-flow-node[data-node-uuid="${nodeUuid}"]`
5038
5153
  );
5039
5154
 
5040
- // Show canvas drop preview only if this is NOT the last action
5041
- // Last actions can only be dropped on other nodes, not on canvas
5042
- if (!isLastAction) {
5155
+ // Show canvas drop preview if this is not the last action,
5156
+ // or if shift-copy is active (copying allows canvas drop for last action too)
5157
+ if (!isLastAction || this.actionDragIsCopy) {
5043
5158
  // Hide ghost when showing canvas preview (for canvas drops)
5044
5159
  if (sourceElement) {
5045
5160
  sourceElement.dispatchEvent(
@@ -5061,7 +5176,7 @@ export class Editor extends RapidElement {
5061
5176
  actionHeight
5062
5177
  };
5063
5178
  } else {
5064
- // For last action, keep ghost visible (can't drop on canvas)
5179
+ // For last action without copy, keep ghost visible (can't drop on canvas)
5065
5180
  if (sourceElement) {
5066
5181
  sourceElement.dispatchEvent(
5067
5182
  new CustomEvent('action-show-ghost', {
@@ -5098,6 +5213,75 @@ export class Editor extends RapidElement {
5098
5213
 
5099
5214
  this.canvasDropPreview = null;
5100
5215
  this.actionDragTargetNodeUuid = null;
5216
+ this.isActionExternalDrag = false;
5217
+ this.actionDragIsCopy = false;
5218
+ this.hideDragHint();
5219
+ this.actionDragLastDetail = null;
5220
+ }
5221
+
5222
+ /** Show or hide the original action element in the source node. */
5223
+ private showActionOriginal(visible: boolean): void {
5224
+ if (!this.actionDragLastDetail) return;
5225
+ const sourceElement = this.querySelector(
5226
+ `temba-flow-node[data-node-uuid="${this.actionDragLastDetail.nodeUuid}"]`
5227
+ );
5228
+ if (sourceElement) {
5229
+ sourceElement.dispatchEvent(
5230
+ new CustomEvent(
5231
+ visible ? 'action-show-original' : 'action-hide-original',
5232
+ { detail: {}, bubbles: false }
5233
+ )
5234
+ );
5235
+ }
5236
+ }
5237
+
5238
+ /**
5239
+ * Reprocess the last action drag event with the current shift-copy state.
5240
+ * Used when shift is toggled mid-drag to show/hide the canvas preview
5241
+ * for last-action drags.
5242
+ */
5243
+ private reprocessActionDrag(): void {
5244
+ if (!this.actionDragLastDetail) return;
5245
+ const { action, nodeUuid, actionIndex, mouseX, mouseY, actionHeight } =
5246
+ this.actionDragLastDetail;
5247
+
5248
+ const sourceElement = this.querySelector(
5249
+ `temba-flow-node[data-node-uuid="${nodeUuid}"]`
5250
+ );
5251
+
5252
+ // Only relevant when not hovering a target node
5253
+ if (this.actionDragTargetNodeUuid) return;
5254
+
5255
+ if (this.actionDragIsCopy) {
5256
+ // Shift pressed: show canvas preview
5257
+ if (sourceElement) {
5258
+ sourceElement.dispatchEvent(
5259
+ new CustomEvent('action-hide-ghost', {
5260
+ detail: {},
5261
+ bubbles: false
5262
+ })
5263
+ );
5264
+ }
5265
+ const position = this.calculateCanvasDropPosition(mouseX, mouseY, false);
5266
+ this.canvasDropPreview = {
5267
+ action,
5268
+ nodeUuid,
5269
+ actionIndex,
5270
+ position,
5271
+ actionHeight
5272
+ };
5273
+ } else {
5274
+ // Shift released: hide canvas preview, show ghost
5275
+ if (sourceElement) {
5276
+ sourceElement.dispatchEvent(
5277
+ new CustomEvent('action-show-ghost', {
5278
+ detail: {},
5279
+ bubbles: false
5280
+ })
5281
+ );
5282
+ }
5283
+ this.canvasDropPreview = null;
5284
+ }
5101
5285
  }
5102
5286
 
5103
5287
  private handleActionDropExternal(event: CustomEvent): void {
@@ -5110,6 +5294,15 @@ export class Editor extends RapidElement {
5110
5294
  isLastAction = false
5111
5295
  } = event.detail;
5112
5296
 
5297
+ const isCopy = this.actionDragIsCopy;
5298
+
5299
+ // Reset action drag state
5300
+ this.isActionExternalDrag = false;
5301
+ this.actionDragIsCopy = false;
5302
+ this.actionDragLastDetail = null;
5303
+ this.previousActionDragTargetNodeUuid = null;
5304
+ this.hideDragHint();
5305
+
5113
5306
  // Check if we're dropping on an existing execute_actions node
5114
5307
  const targetNodeUuid = this.actionDragTargetNodeUuid;
5115
5308
 
@@ -5126,7 +5319,8 @@ export class Editor extends RapidElement {
5126
5319
  sourceNodeUuid: nodeUuid,
5127
5320
  actionIndex,
5128
5321
  mouseX,
5129
- mouseY
5322
+ mouseY,
5323
+ isCopy
5130
5324
  },
5131
5325
  bubbles: false
5132
5326
  })
@@ -5139,9 +5333,9 @@ export class Editor extends RapidElement {
5139
5333
  return;
5140
5334
  }
5141
5335
 
5142
- // If this is the last action and we're not dropping on another node, do nothing
5336
+ // If this is the last action and not copying, do nothing
5143
5337
  // Last actions can only be moved to other nodes, not dropped on canvas
5144
- if (isLastAction) {
5338
+ if (isLastAction && !isCopy) {
5145
5339
  this.canvasDropPreview = null;
5146
5340
  this.actionDragTargetNodeUuid = null;
5147
5341
  return;
@@ -5151,28 +5345,36 @@ export class Editor extends RapidElement {
5151
5345
  // Snap to grid for the final drop position
5152
5346
  const position = this.calculateCanvasDropPosition(mouseX, mouseY, true);
5153
5347
 
5154
- // remove the action from the original node
5155
- const originalNode = this.definition.nodes.find((n) => n.uuid === nodeUuid);
5156
- if (!originalNode) return;
5348
+ if (!isCopy) {
5349
+ // remove the action from the original node
5350
+ const originalNode = this.definition.nodes.find(
5351
+ (n) => n.uuid === nodeUuid
5352
+ );
5353
+ if (!originalNode) return;
5157
5354
 
5158
- const updatedActions = originalNode.actions.filter(
5159
- (_a, idx) => idx !== actionIndex
5160
- );
5355
+ const updatedActions = originalNode.actions.filter(
5356
+ (_a, idx) => idx !== actionIndex
5357
+ );
5161
5358
 
5162
- // if no actions remain, delete the node
5163
- if (updatedActions.length === 0) {
5164
- // Use deleteNodes to properly clean up Plumber connections before removing
5165
- this.deleteNodes([nodeUuid]);
5166
- } else {
5167
- // update the node
5168
- const updatedNode = { ...originalNode, actions: updatedActions };
5169
- getStore()?.getState().updateNode(nodeUuid, updatedNode);
5359
+ // if no actions remain, delete the node
5360
+ if (updatedActions.length === 0) {
5361
+ // Use deleteNodes to properly clean up Plumber connections before removing
5362
+ this.deleteNodes([nodeUuid]);
5363
+ } else {
5364
+ // update the node
5365
+ const updatedNode = { ...originalNode, actions: updatedActions };
5366
+ getStore()?.getState().updateNode(nodeUuid, updatedNode);
5367
+ }
5170
5368
  }
5171
5369
 
5172
5370
  // create a new execute_actions node with the dropped action
5371
+ // When copying, generate a fresh UUID so the clone doesn't share the original's
5372
+ const droppedAction = isCopy
5373
+ ? { ...action, uuid: generateUUID() }
5374
+ : action;
5173
5375
  const newNode: Node = {
5174
5376
  uuid: generateUUID(),
5175
- actions: [action],
5377
+ actions: [droppedAction],
5176
5378
  exits: [
5177
5379
  {
5178
5380
  uuid: generateUUID(),
@@ -5190,6 +5392,11 @@ export class Editor extends RapidElement {
5190
5392
  // add the new node
5191
5393
  getStore()?.getState().addNode(newNode, newNodeUI);
5192
5394
 
5395
+ // Copy localizations from the original action to the new one
5396
+ if (isCopy) {
5397
+ this.copyActionLocalizations(action.uuid, droppedAction.uuid);
5398
+ }
5399
+
5193
5400
  // clear the preview
5194
5401
  this.canvasDropPreview = null;
5195
5402
  this.actionDragTargetNodeUuid = null;
@@ -5200,6 +5407,27 @@ export class Editor extends RapidElement {
5200
5407
  });
5201
5408
  }
5202
5409
 
5410
+ /** Copy all localization entries from one action UUID to another. */
5411
+ private copyActionLocalizations(
5412
+ sourceUuid: string,
5413
+ targetUuid: string
5414
+ ): void {
5415
+ const localization = this.definition?.localization;
5416
+ if (!localization) return;
5417
+ const store = getStore()?.getState();
5418
+ if (!store) return;
5419
+ for (const langCode of Object.keys(localization)) {
5420
+ const entry = localization[langCode]?.[sourceUuid];
5421
+ if (entry) {
5422
+ store.updateLocalization(
5423
+ langCode,
5424
+ targetUuid,
5425
+ JSON.parse(JSON.stringify(entry))
5426
+ );
5427
+ }
5428
+ }
5429
+ }
5430
+
5203
5431
  private getLocalizationLanguages(): Array<{ code: string; name: string }> {
5204
5432
  if (!this.definition) {
5205
5433
  return [];
@@ -168,6 +168,7 @@ export class Select<T extends SelectOption> extends FieldElement {
168
168
  }
169
169
 
170
170
  .selected {
171
+ flex: 1;
171
172
  display: flex;
172
173
  flex-direction: row;
173
174
  align-items: stretch;
@@ -731,6 +731,12 @@ export class SortableList extends RapidElement {
731
731
  }
732
732
  /* c8 ignore stop */
733
733
 
734
+ /** Show or hide the original element that is being dragged. */
735
+ public setOriginalVisible(visible: boolean): void {
736
+ if (!this.downEle) return;
737
+ this.downEle.style.display = visible ? this.originalDownDisplay : 'none';
738
+ }
739
+
734
740
  public render(): TemplateResult {
735
741
  return html`
736
742
  <div