@nyaruka/temba-components 0.142.1 → 0.142.3

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.
Files changed (140) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/temba-components.js +953 -708
  3. package/dist/temba-components.js.map +1 -1
  4. package/out-tsc/src/Icons.js +1 -0
  5. package/out-tsc/src/Icons.js.map +1 -1
  6. package/out-tsc/src/flow/CanvasMenu.js +38 -38
  7. package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
  8. package/out-tsc/src/flow/CanvasNode.js +171 -17
  9. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  10. package/out-tsc/src/flow/Editor.js +491 -22
  11. package/out-tsc/src/flow/Editor.js.map +1 -1
  12. package/out-tsc/src/flow/NodeEditor.js +346 -10
  13. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  14. package/out-tsc/src/flow/NodeTypeSelector.js +2 -0
  15. package/out-tsc/src/flow/NodeTypeSelector.js.map +1 -1
  16. package/out-tsc/src/flow/Plumber.js +92 -28
  17. package/out-tsc/src/flow/Plumber.js.map +1 -1
  18. package/out-tsc/src/flow/StickyNote.js +63 -3
  19. package/out-tsc/src/flow/StickyNote.js.map +1 -1
  20. package/out-tsc/src/flow/actions/add_contact_urn.js +2 -6
  21. package/out-tsc/src/flow/actions/add_contact_urn.js.map +1 -1
  22. package/out-tsc/src/flow/actions/enter_flow.js +2 -2
  23. package/out-tsc/src/flow/actions/enter_flow.js.map +1 -1
  24. package/out-tsc/src/flow/actions/say_msg.js +2 -1
  25. package/out-tsc/src/flow/actions/say_msg.js.map +1 -1
  26. package/out-tsc/src/flow/actions/send_broadcast.js +2 -6
  27. package/out-tsc/src/flow/actions/send_broadcast.js.map +1 -1
  28. package/out-tsc/src/flow/actions/send_email.js +2 -6
  29. package/out-tsc/src/flow/actions/send_email.js.map +1 -1
  30. package/out-tsc/src/flow/actions/send_msg.js +55 -35
  31. package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
  32. package/out-tsc/src/flow/actions/set_contact_channel.js +2 -1
  33. package/out-tsc/src/flow/actions/set_contact_channel.js.map +1 -1
  34. package/out-tsc/src/flow/actions/set_contact_field.js +4 -5
  35. package/out-tsc/src/flow/actions/set_contact_field.js.map +1 -1
  36. package/out-tsc/src/flow/actions/set_contact_language.js +3 -3
  37. package/out-tsc/src/flow/actions/set_contact_language.js.map +1 -1
  38. package/out-tsc/src/flow/actions/set_contact_name.js +2 -1
  39. package/out-tsc/src/flow/actions/set_contact_name.js.map +1 -1
  40. package/out-tsc/src/flow/actions/set_contact_status.js +2 -1
  41. package/out-tsc/src/flow/actions/set_contact_status.js.map +1 -1
  42. package/out-tsc/src/flow/actions/set_run_result.js +3 -3
  43. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  44. package/out-tsc/src/flow/actions/start_session.js +2 -2
  45. package/out-tsc/src/flow/actions/start_session.js.map +1 -1
  46. package/out-tsc/src/flow/nodes/split_by_llm.js +4 -5
  47. package/out-tsc/src/flow/nodes/split_by_llm.js.map +1 -1
  48. package/out-tsc/src/flow/nodes/split_by_resthook.js +3 -8
  49. package/out-tsc/src/flow/nodes/split_by_resthook.js.map +1 -1
  50. package/out-tsc/src/flow/nodes/split_by_subflow.js +4 -2
  51. package/out-tsc/src/flow/nodes/split_by_subflow.js.map +1 -1
  52. package/out-tsc/src/flow/nodes/split_by_webhook.js +25 -33
  53. package/out-tsc/src/flow/nodes/split_by_webhook.js.map +1 -1
  54. package/out-tsc/src/flow/nodes/wait_for_response.js +1 -0
  55. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  56. package/out-tsc/src/flow/types.js.map +1 -1
  57. package/out-tsc/src/flow/utils.js +66 -0
  58. package/out-tsc/src/flow/utils.js.map +1 -1
  59. package/out-tsc/src/form/FieldRenderer.js +17 -2
  60. package/out-tsc/src/form/FieldRenderer.js.map +1 -1
  61. package/out-tsc/src/interfaces.js +1 -0
  62. package/out-tsc/src/interfaces.js.map +1 -1
  63. package/out-tsc/src/list/SortableList.js +104 -43
  64. package/out-tsc/src/list/SortableList.js.map +1 -1
  65. package/out-tsc/src/simulator/Simulator.js +6 -2
  66. package/out-tsc/src/simulator/Simulator.js.map +1 -1
  67. package/out-tsc/test/temba-canvas-menu.test.js +13 -9
  68. package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
  69. package/out-tsc/test/temba-flow-reflow.test.js.map +1 -1
  70. package/out-tsc/test/temba-node-editor.test.js +9 -10
  71. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  72. package/out-tsc/test/temba-node-type-selector.test.js +3 -3
  73. package/out-tsc/test/temba-node-type-selector.test.js.map +1 -1
  74. package/out-tsc/test/temba-simulator.test.js +2 -2
  75. package/out-tsc/test/temba-simulator.test.js.map +1 -1
  76. package/package.json +1 -1
  77. package/screenshots/truth/actions/enter_flow/render/basic-flow.png +0 -0
  78. package/screenshots/truth/actions/enter_flow/render/long-flow-name.png +0 -0
  79. package/screenshots/truth/actions/send_email/render/long-subject.png +0 -0
  80. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  81. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  82. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  83. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  84. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  85. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  86. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  87. package/screenshots/truth/actions/send_msg/render/long-quick-replies.png +0 -0
  88. package/screenshots/truth/actions/send_msg/render/multiline-text-with-replies.png +0 -0
  89. package/screenshots/truth/actions/send_msg/render/text-with-many-quick-replies.png +0 -0
  90. package/screenshots/truth/actions/send_msg/render/text-with-quick-replies.png +0 -0
  91. package/screenshots/truth/actions/start_session/render/contact-query.png +0 -0
  92. package/screenshots/truth/actions/start_session/render/contacts-only.png +0 -0
  93. package/screenshots/truth/actions/start_session/render/create-contact.png +0 -0
  94. package/screenshots/truth/actions/start_session/render/groups-and-contacts.png +0 -0
  95. package/screenshots/truth/actions/start_session/render/groups-only.png +0 -0
  96. package/screenshots/truth/actions/start_session/render/many-recipients.png +0 -0
  97. package/screenshots/truth/canvas-menu/open.png +0 -0
  98. package/screenshots/truth/node-type-selector/action-mode.png +0 -0
  99. package/screenshots/truth/node-type-selector/split-mode.png +0 -0
  100. package/screenshots/truth/nodes/split_by_llm/render/information-extraction.png +0 -0
  101. package/screenshots/truth/nodes/split_by_llm/render/sentiment-analysis.png +0 -0
  102. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  103. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  104. package/src/Icons.ts +1 -0
  105. package/src/flow/CanvasMenu.ts +50 -43
  106. package/src/flow/CanvasNode.ts +201 -17
  107. package/src/flow/Editor.ts +585 -25
  108. package/src/flow/NodeEditor.ts +373 -10
  109. package/src/flow/NodeTypeSelector.ts +2 -0
  110. package/src/flow/Plumber.ts +104 -37
  111. package/src/flow/StickyNote.ts +76 -4
  112. package/src/flow/actions/add_contact_urn.ts +5 -6
  113. package/src/flow/actions/enter_flow.ts +2 -2
  114. package/src/flow/actions/say_msg.ts +2 -1
  115. package/src/flow/actions/send_broadcast.ts +2 -6
  116. package/src/flow/actions/send_email.ts +2 -6
  117. package/src/flow/actions/send_msg.ts +59 -38
  118. package/src/flow/actions/set_contact_channel.ts +5 -1
  119. package/src/flow/actions/set_contact_field.ts +10 -5
  120. package/src/flow/actions/set_contact_language.ts +6 -3
  121. package/src/flow/actions/set_contact_name.ts +5 -1
  122. package/src/flow/actions/set_contact_status.ts +5 -1
  123. package/src/flow/actions/set_run_result.ts +6 -3
  124. package/src/flow/actions/start_session.ts +2 -2
  125. package/src/flow/nodes/split_by_llm.ts +5 -5
  126. package/src/flow/nodes/split_by_resthook.ts +3 -8
  127. package/src/flow/nodes/split_by_subflow.ts +4 -2
  128. package/src/flow/nodes/split_by_webhook.ts +26 -34
  129. package/src/flow/nodes/wait_for_response.ts +1 -0
  130. package/src/flow/types.ts +25 -2
  131. package/src/flow/utils.ts +79 -1
  132. package/src/form/FieldRenderer.ts +32 -3
  133. package/src/interfaces.ts +1 -0
  134. package/src/list/SortableList.ts +117 -47
  135. package/src/simulator/Simulator.ts +6 -2
  136. package/test/temba-canvas-menu.test.ts +13 -9
  137. package/test/temba-flow-reflow.test.ts +4 -2
  138. package/test/temba-node-editor.test.ts +9 -10
  139. package/test/temba-node-type-selector.test.ts +3 -3
  140. package/test/temba-simulator.test.ts +2 -2
@@ -2,6 +2,21 @@ import { isRightClick } from './utils';
2
2
 
3
3
  export type TargetFace = 'top' | 'left' | 'right';
4
4
 
5
+ /** Extract clientX/clientY from a MouseEvent or the first touch of a TouchEvent. */
6
+ function getClientCoords(e: MouseEvent | TouchEvent): {
7
+ clientX: number;
8
+ clientY: number;
9
+ } {
10
+ if ('touches' in e) {
11
+ const touch = e.touches[0] || (e as TouchEvent).changedTouches[0];
12
+ return { clientX: touch.clientX, clientY: touch.clientY };
13
+ }
14
+ return {
15
+ clientX: (e as MouseEvent).clientX,
16
+ clientY: (e as MouseEvent).clientY
17
+ };
18
+ }
19
+
5
20
  // Shared arrow/drag constants used by both Plumber and Editor
6
21
  export const ARROW_LENGTH = 13;
7
22
  export const ARROW_HALF_WIDTH = 6.5;
@@ -33,8 +48,8 @@ interface DragState {
33
48
  svgEl: SVGSVGElement;
34
49
  pathEl: SVGPathElement;
35
50
  arrowEl: SVGPolygonElement;
36
- onMove: (e: MouseEvent) => void;
37
- onUp: (e: MouseEvent) => void;
51
+ onMove: (e: MouseEvent | TouchEvent) => void;
52
+ onUp: (e: MouseEvent | TouchEvent) => void;
38
53
  }
39
54
 
40
55
  /**
@@ -242,56 +257,75 @@ export class Plumber {
242
257
  let pendingDrag: {
243
258
  startX: number;
244
259
  startY: number;
245
- onMove: (e: MouseEvent) => void;
246
- onUp: (e: MouseEvent) => void;
260
+ onMove: (e: MouseEvent | TouchEvent) => void;
261
+ onUp: (e: MouseEvent | TouchEvent) => void;
262
+ isTouch: boolean;
247
263
  } | null = null;
248
264
 
249
265
  const DRAG_THRESHOLD = 5;
250
266
 
251
- const onMouseDown = (e: MouseEvent) => {
252
- if (isRightClick(e)) return;
267
+ const beginPendingDrag = (e: MouseEvent | TouchEvent) => {
268
+ if ('button' in e && isRightClick(e)) return;
253
269
 
254
270
  // Don't start drag from exit if it already has a connection —
255
271
  // existing connections are picked up from the arrowhead instead
256
272
  if (this.connections.has(exitId)) return;
257
273
 
258
- const startX = e.clientX;
259
- const startY = e.clientY;
274
+ const isTouch = 'touches' in e;
275
+ if (isTouch) e.preventDefault();
276
+
277
+ const { clientX: startX, clientY: startY } = getClientCoords(e);
260
278
 
261
279
  const nodeEl = element.closest('temba-flow-node');
262
280
  const scope = nodeEl?.getAttribute('uuid') || '';
263
281
  const originalTargetId: string | null = null;
264
282
 
265
- const onMove = (me: MouseEvent) => {
266
- const dx = me.clientX - startX;
267
- const dy = me.clientY - startY;
283
+ const onMove = (me: MouseEvent | TouchEvent) => {
284
+ const { clientX, clientY } = getClientCoords(me);
285
+ const dx = clientX - startX;
286
+ const dy = clientY - startY;
268
287
  if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
269
288
  // Exceeded threshold — start actual drag
270
- document.removeEventListener('mousemove', onMove);
271
- document.removeEventListener('mouseup', onUp);
272
- pendingDrag = null;
289
+ removePendingListeners();
273
290
  this.startDrag(exitId, scope, originalTargetId, me);
274
291
  }
275
292
  };
276
293
 
277
294
  const onUp = () => {
278
- // Mouse released without dragging — let click handler fire
295
+ // Released without dragging — let click handler fire
296
+ removePendingListeners();
297
+ };
298
+
299
+ const removePendingListeners = () => {
279
300
  document.removeEventListener('mousemove', onMove);
280
301
  document.removeEventListener('mouseup', onUp);
302
+ document.removeEventListener('touchmove', onMove);
303
+ document.removeEventListener('touchend', onUp);
304
+ document.removeEventListener('touchcancel', onUp);
281
305
  pendingDrag = null;
282
306
  };
283
307
 
284
308
  document.addEventListener('mousemove', onMove);
285
309
  document.addEventListener('mouseup', onUp);
286
- pendingDrag = { startX, startY, onMove, onUp };
310
+ document.addEventListener('touchmove', onMove, { passive: false });
311
+ document.addEventListener('touchend', onUp);
312
+ document.addEventListener('touchcancel', onUp);
313
+ pendingDrag = { startX, startY, onMove, onUp, isTouch };
287
314
  };
288
315
 
289
- element.addEventListener('mousedown', onMouseDown);
316
+ element.addEventListener('mousedown', beginPendingDrag);
317
+ element.addEventListener('touchstart', beginPendingDrag, {
318
+ passive: false
319
+ });
290
320
  this.sources.set(exitId, () => {
291
- element.removeEventListener('mousedown', onMouseDown);
321
+ element.removeEventListener('mousedown', beginPendingDrag);
322
+ element.removeEventListener('touchstart', beginPendingDrag);
292
323
  if (pendingDrag) {
293
324
  document.removeEventListener('mousemove', pendingDrag.onMove);
294
325
  document.removeEventListener('mouseup', pendingDrag.onUp);
326
+ document.removeEventListener('touchmove', pendingDrag.onMove);
327
+ document.removeEventListener('touchend', pendingDrag.onUp);
328
+ document.removeEventListener('touchcancel', pendingDrag.onUp);
295
329
  pendingDrag = null;
296
330
  }
297
331
  });
@@ -714,19 +748,23 @@ export class Plumber {
714
748
 
715
749
  // Make arrowhead draggable for picking up existing connections
716
750
  const DRAG_THRESHOLD = 5;
717
- const onArrowMouseDown = (e: MouseEvent) => {
718
- if (isRightClick(e)) return;
751
+ const onArrowDown = (e: MouseEvent | TouchEvent) => {
752
+ if ('button' in e && isRightClick(e)) return;
719
753
  e.stopPropagation();
754
+ if ('touches' in e) e.preventDefault();
720
755
 
721
- const startX = e.clientX;
722
- const startY = e.clientY;
756
+ const { clientX: startX, clientY: startY } = getClientCoords(e);
723
757
 
724
- const onMove = (me: MouseEvent) => {
725
- const dx = me.clientX - startX;
726
- const dy = me.clientY - startY;
758
+ const onMove = (me: MouseEvent | TouchEvent) => {
759
+ const { clientX, clientY } = getClientCoords(me);
760
+ const dx = clientX - startX;
761
+ const dy = clientY - startY;
727
762
  if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
728
763
  document.removeEventListener('mousemove', onMove);
729
764
  document.removeEventListener('mouseup', onUp);
765
+ document.removeEventListener('touchmove', onMove);
766
+ document.removeEventListener('touchend', onUp);
767
+ document.removeEventListener('touchcancel', onUp);
730
768
  this.startDrag(exitId, scope, toId, me);
731
769
  }
732
770
  };
@@ -734,12 +772,21 @@ export class Plumber {
734
772
  const onUp = () => {
735
773
  document.removeEventListener('mousemove', onMove);
736
774
  document.removeEventListener('mouseup', onUp);
775
+ document.removeEventListener('touchmove', onMove);
776
+ document.removeEventListener('touchend', onUp);
777
+ document.removeEventListener('touchcancel', onUp);
737
778
  };
738
779
 
739
780
  document.addEventListener('mousemove', onMove);
740
781
  document.addEventListener('mouseup', onUp);
782
+ document.addEventListener('touchmove', onMove, { passive: false });
783
+ document.addEventListener('touchend', onUp);
784
+ document.addEventListener('touchcancel', onUp);
741
785
  };
742
- arrowEl.addEventListener('mousedown', onArrowMouseDown);
786
+ arrowEl.addEventListener('mousedown', onArrowDown);
787
+ arrowEl.addEventListener('touchstart', onArrowDown, { passive: false });
788
+ pathEl.addEventListener('mousedown', onArrowDown);
789
+ pathEl.addEventListener('touchstart', onArrowDown, { passive: false });
743
790
 
744
791
  // Mark the exit element as connected
745
792
  const exitEl = document.getElementById(exitId);
@@ -1087,7 +1134,9 @@ export class Plumber {
1087
1134
  const contactUuid = target.getAttribute('data-uuid');
1088
1135
  if (contactUuid) {
1089
1136
  this.editor.fireCustomEvent('temba-contact-clicked', {
1090
- uuid: contactUuid
1137
+ uuid: contactUuid,
1138
+ metaKey: e.metaKey,
1139
+ ctrlKey: e.ctrlKey
1091
1140
  });
1092
1141
  }
1093
1142
  }
@@ -1200,10 +1249,18 @@ export class Plumber {
1200
1249
  exitId: string,
1201
1250
  scope: string,
1202
1251
  originalTargetId: string | null,
1203
- e: MouseEvent
1252
+ e: MouseEvent | TouchEvent
1204
1253
  ) {
1205
- // Remove existing connection SVG for this exit (the connection is being dragged away)
1206
- this.removeConnectionSVG(exitId);
1254
+ // Hide (don't remove) the existing connection SVG while dragging.
1255
+ // On iOS Safari, removing the element that received the original
1256
+ // touchstart can trigger touchcancel, prematurely ending the drag.
1257
+ // We defer removal until the drag ends.
1258
+ const oldConn = this.connections.get(exitId);
1259
+ if (oldConn) {
1260
+ oldConn.svgEl.style.display = 'none';
1261
+ const overlay = this.overlays.get(exitId);
1262
+ if (overlay) overlay.style.display = 'none';
1263
+ }
1207
1264
 
1208
1265
  const { svgEl, pathEl, arrowEl } = this.createSVGElement();
1209
1266
  svgEl.classList.add('dragging');
@@ -1281,26 +1338,33 @@ export class Plumber {
1281
1338
  };
1282
1339
 
1283
1340
  // Initial path to cursor (convert viewport to canvas coordinates)
1284
- const cursorX = this.toCanvas(e.clientX - canvasRect.left);
1285
- const cursorY = this.toCanvas(e.clientY - canvasRect.top);
1341
+ const { clientX: initX, clientY: initY } = getClientCoords(e);
1342
+ const cursorX = this.toCanvas(initX - canvasRect.left);
1343
+ const cursorY = this.toCanvas(initY - canvasRect.top);
1286
1344
  updateDragPath(cursorX, cursorY);
1287
1345
 
1288
1346
  this.connectionDragging = true;
1289
1347
 
1290
- const onMove = (me: MouseEvent) => {
1348
+ const onMove = (me: MouseEvent | TouchEvent) => {
1349
+ if ('touches' in me) me.preventDefault();
1291
1350
  // Re-read canvasRect each move since scroll may have changed
1292
1351
  const rect = this.canvas.getBoundingClientRect();
1293
- const cx = this.toCanvas(me.clientX - rect.left);
1294
- const cy = this.toCanvas(me.clientY - rect.top);
1352
+ const { clientX, clientY } = getClientCoords(me);
1353
+ const cx = this.toCanvas(clientX - rect.left);
1354
+ const cy = this.toCanvas(clientY - rect.top);
1295
1355
  updateDragPath(cx, cy);
1296
1356
  };
1297
1357
 
1298
- const onUp = (_me: MouseEvent) => {
1358
+ const onUp = (_me: MouseEvent | TouchEvent) => {
1299
1359
  document.removeEventListener('mousemove', onMove);
1300
1360
  document.removeEventListener('mouseup', onUp);
1361
+ document.removeEventListener('touchmove', onMove);
1362
+ document.removeEventListener('touchend', onUp);
1363
+ document.removeEventListener('touchcancel', onUp);
1301
1364
 
1302
- // Remove the drag SVG
1365
+ // Remove the drag SVG and the hidden old connection SVG
1303
1366
  svgEl.remove();
1367
+ this.removeConnectionSVG(exitId);
1304
1368
  this.connectionDragging = false;
1305
1369
  this.dragState = null;
1306
1370
 
@@ -1315,6 +1379,9 @@ export class Plumber {
1315
1379
 
1316
1380
  document.addEventListener('mousemove', onMove);
1317
1381
  document.addEventListener('mouseup', onUp);
1382
+ document.addEventListener('touchmove', onMove, { passive: false });
1383
+ document.addEventListener('touchend', onUp);
1384
+ document.addEventListener('touchcancel', onUp);
1318
1385
 
1319
1386
  this.dragState = {
1320
1387
  sourceId: exitId,
@@ -21,6 +21,11 @@ export class StickyNote extends RapidElement {
21
21
  @property({ type: Boolean })
22
22
  private colorPickerExpanded = false;
23
23
 
24
+ // On touch devices, contenteditable starts false to prevent Apple Pencil
25
+ // Scribble from hijacking touches. It is set to true on explicit tap.
26
+ private isTouchDevice = navigator.maxTouchPoints > 0;
27
+ private editingField: HTMLElement | null = null;
28
+
24
29
  @fromStore(zustand, (state: AppState) => state.isTranslating)
25
30
  private isTranslating!: boolean;
26
31
 
@@ -289,6 +294,7 @@ export class StickyNote extends RapidElement {
289
294
  }
290
295
 
291
296
  private handleTitleBlur(event: FocusEvent): void {
297
+ this.handleContentBlurForTouch(event);
292
298
  const target = event.target as HTMLElement;
293
299
  const newTitle = target.textContent || '';
294
300
 
@@ -304,6 +310,7 @@ export class StickyNote extends RapidElement {
304
310
  }
305
311
 
306
312
  private handleBodyBlur(event: FocusEvent): void {
313
+ this.handleContentBlurForTouch(event);
307
314
  const target = event.target as HTMLElement;
308
315
  const newBody = target.innerText || '';
309
316
 
@@ -318,6 +325,46 @@ export class StickyNote extends RapidElement {
318
325
  this.requestUpdate();
319
326
  }
320
327
 
328
+ /* c8 ignore start -- touch-only handlers untestable in headless Chromium */
329
+ private handleDragHandleTouchStart(event: TouchEvent): void {
330
+ // Prevent Apple Pencil Scribble from activating on the adjacent
331
+ // contenteditable fields when touching/dragging the handle.
332
+ event.preventDefault();
333
+ }
334
+
335
+ /**
336
+ * On touch devices, contenteditable is off by default. A tap on the
337
+ * title or body enables it and focuses the element for editing.
338
+ */
339
+ private handleContentTap(event: TouchEvent): void {
340
+ if (!this.isTouchDevice) return;
341
+ const target = event.target as HTMLElement;
342
+ if (
343
+ !target.classList.contains('sticky-title') &&
344
+ !target.classList.contains('sticky-body')
345
+ )
346
+ return;
347
+
348
+ // Enable editing and focus
349
+ target.setAttribute('contenteditable', 'true');
350
+ this.editingField = target;
351
+ target.focus();
352
+ event.stopPropagation();
353
+ }
354
+
355
+ /**
356
+ * When a contenteditable field loses focus on a touch device,
357
+ * disable contenteditable again to prevent Scribble.
358
+ */
359
+ private handleContentBlurForTouch(event: FocusEvent): void {
360
+ const target = event.target as HTMLElement;
361
+ if (this.isTouchDevice && this.editingField === target) {
362
+ target.setAttribute('contenteditable', 'false');
363
+ this.editingField = null;
364
+ }
365
+ }
366
+ /* c8 ignore stop */
367
+
321
368
  private handleContentMouseDown(event: MouseEvent): void {
322
369
  // If this sticky note is selected, don't stop propagation
323
370
  // so that group dragging can work
@@ -356,11 +403,19 @@ export class StickyNote extends RapidElement {
356
403
  this.colorPickerExpanded = false;
357
404
  }
358
405
 
406
+ /* c8 ignore next 5 -- touch-only */
407
+ private handleColorPickerTap(event: TouchEvent): void {
408
+ event.stopPropagation();
409
+ event.preventDefault();
410
+ this.colorPickerExpanded = !this.colorPickerExpanded;
411
+ }
412
+
359
413
  private handleColorOptionClick(
360
- event: MouseEvent,
414
+ event: MouseEvent | TouchEvent,
361
415
  color: 'yellow' | 'blue' | 'pink' | 'green' | 'gray'
362
416
  ): void {
363
417
  event.stopPropagation();
418
+ event.preventDefault();
364
419
 
365
420
  if (this.data && color !== this.data.color) {
366
421
  getStore()
@@ -391,23 +446,29 @@ export class StickyNote extends RapidElement {
391
446
  data-uuid="${this.uuid}"
392
447
  >
393
448
  <div class="sticky-title-container">
394
- <temba-icon name="drag" class="drag-handle"></temba-icon>
449
+ <temba-icon
450
+ name="drag"
451
+ class="drag-handle"
452
+ @touchstart=${this.handleDragHandleTouchStart}
453
+ ></temba-icon>
395
454
  <div
396
455
  class="sticky-title"
397
- contenteditable="${!this.isTranslating}"
456
+ contenteditable="${!this.isTranslating && !this.isTouchDevice}"
398
457
  @blur="${this.handleTitleBlur}"
399
458
  @keydown="${this.handleTitleKeyDown}"
400
459
  @mousedown="${this.handleContentMouseDown}"
460
+ @touchend="${this.handleContentTap}"
401
461
  .textContent="${this.data.title}"
402
462
  ></div>
403
463
  </div>
404
464
  <div class="sticky-body-container">
405
465
  <div
406
466
  class="sticky-body"
407
- contenteditable="${!this.isTranslating}"
467
+ contenteditable="${!this.isTranslating && !this.isTouchDevice}"
408
468
  @blur="${this.handleBodyBlur}"
409
469
  @keydown="${this.handleBodyKeyDown}"
410
470
  @mousedown="${this.handleContentMouseDown}"
471
+ @touchend="${this.handleContentTap}"
411
472
  .textContent="${this.data.body}"
412
473
  ></div>
413
474
  ${!this.isTranslating
@@ -419,6 +480,7 @@ export class StickyNote extends RapidElement {
419
480
  class="color-picker"
420
481
  @mouseenter="${this.handleColorPickerMouseEnter}"
421
482
  @mouseleave="${this.handleColorPickerMouseLeave}"
483
+ @touchend="${this.handleColorPickerTap}"
422
484
  >
423
485
  <div
424
486
  class="color-options ${this.colorPickerExpanded
@@ -429,26 +491,36 @@ export class StickyNote extends RapidElement {
429
491
  class="color-option yellow"
430
492
  @click="${(e: MouseEvent) =>
431
493
  this.handleColorOptionClick(e, 'yellow')}"
494
+ @touchend="${(e: TouchEvent) =>
495
+ this.handleColorOptionClick(e, 'yellow')}"
432
496
  ></div>
433
497
  <div
434
498
  class="color-option blue"
435
499
  @click="${(e: MouseEvent) =>
436
500
  this.handleColorOptionClick(e, 'blue')}"
501
+ @touchend="${(e: TouchEvent) =>
502
+ this.handleColorOptionClick(e, 'blue')}"
437
503
  ></div>
438
504
  <div
439
505
  class="color-option pink"
440
506
  @click="${(e: MouseEvent) =>
441
507
  this.handleColorOptionClick(e, 'pink')}"
508
+ @touchend="${(e: TouchEvent) =>
509
+ this.handleColorOptionClick(e, 'pink')}"
442
510
  ></div>
443
511
  <div
444
512
  class="color-option green"
445
513
  @click="${(e: MouseEvent) =>
446
514
  this.handleColorOptionClick(e, 'green')}"
515
+ @touchend="${(e: TouchEvent) =>
516
+ this.handleColorOptionClick(e, 'green')}"
447
517
  ></div>
448
518
  <div
449
519
  class="color-option gray"
450
520
  @click="${(e: MouseEvent) =>
451
521
  this.handleColorOptionClick(e, 'gray')}"
522
+ @touchend="${(e: TouchEvent) =>
523
+ this.handleColorOptionClick(e, 'gray')}"
452
524
  ></div>
453
525
  </div>
454
526
  </div>
@@ -7,7 +7,7 @@ import {
7
7
  FlowTypes
8
8
  } from '../types';
9
9
  import { Node, AddContactUrn } from '../../store/flow-definition';
10
- import { SCHEMES } from '../utils';
10
+ import { SCHEMES, renderClamped } from '../utils';
11
11
 
12
12
  export const add_contact_urn: ActionConfig = {
13
13
  name: 'Add URN',
@@ -16,11 +16,10 @@ export const add_contact_urn: ActionConfig = {
16
16
  render: (_node: Node, action: AddContactUrn) => {
17
17
  const schemeObj = SCHEMES.find((s) => s.scheme === action.scheme);
18
18
  const friendlyScheme = schemeObj?.path || action.scheme;
19
- return html`<div
20
- style="word-wrap: break-word; overflow-wrap: break-word; hyphens: auto;"
21
- >
22
- Add ${friendlyScheme} <strong>${action.path}</strong>
23
- </div>`;
19
+ return renderClamped(
20
+ html`Add ${friendlyScheme} <strong>${action.path}</strong>`,
21
+ `Add ${friendlyScheme} ${action.path}`
22
+ );
24
23
  },
25
24
 
26
25
  toFormData: (action: AddContactUrn) => {
@@ -1,7 +1,7 @@
1
1
  import { html } from 'lit-html';
2
2
  import { ActionConfig, ACTION_GROUPS, FlowTypes } from '../types';
3
3
  import { Node, EnterFlow } from '../../store/flow-definition';
4
- import { renderNamedObjects } from '../utils';
4
+ import { renderFlowLinks } from '../utils';
5
5
 
6
6
  export const enter_flow: ActionConfig = {
7
7
  name: 'Enter a Flow',
@@ -9,7 +9,7 @@ export const enter_flow: ActionConfig = {
9
9
  hideFromActions: true,
10
10
  flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
11
11
  render: (_node: Node, action: EnterFlow) => {
12
- return html`${renderNamedObjects([action.flow], 'flow')}`;
12
+ return html`${renderFlowLinks([action.flow], 'flow')}`;
13
13
  },
14
14
  toFormData: (action: EnterFlow) => {
15
15
  return {
@@ -3,6 +3,7 @@ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
3
  import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
4
4
  import { Node, SayMsg } from '../../store/flow-definition';
5
5
  import { renderAudioPlayer } from './audio-player';
6
+ import { renderClamped } from '../utils';
6
7
 
7
8
  export const say_msg: ActionConfig = {
8
9
  name: 'Say Message',
@@ -11,7 +12,7 @@ export const say_msg: ActionConfig = {
11
12
  render: (_node: Node, action: SayMsg) => {
12
13
  const text = (action.text || '').replace(/\n/g, '<br>');
13
14
  return html`
14
- ${unsafeHTML(text)}
15
+ ${renderClamped(html`${unsafeHTML(text)}`, action.text || '')}
15
16
  ${action.audio_url
16
17
  ? html`<div style="margin-top: 0.5em;">
17
18
  ${renderAudioPlayer(action.audio_url)}
@@ -1,7 +1,7 @@
1
1
  import { html } from 'lit-html';
2
2
  import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
3
3
  import { Node, SendBroadcast } from '../../store/flow-definition';
4
- import { renderStringList } from '../utils';
4
+ import { renderStringList, renderClamped } from '../utils';
5
5
  import { Icon } from '../../Icons';
6
6
 
7
7
  export const send_broadcast: ActionConfig = {
@@ -17,11 +17,7 @@ export const send_broadcast: ActionConfig = {
17
17
  return html`<div>
18
18
  <div>${renderStringList(recipients, Icon.contacts)}</div>
19
19
  <div style="margin-top: 0.5em">
20
- <div
21
- style="word-wrap: break-word; overflow-wrap: break-word; hyphens: auto;"
22
- >
23
- ${action.text}
24
- </div>
20
+ ${renderClamped(action.text, action.text)}
25
21
  </div>
26
22
  </div>`;
27
23
  },
@@ -7,7 +7,7 @@ import {
7
7
  FlowTypes
8
8
  } from '../types';
9
9
  import { Node, SendEmail } from '../../store/flow-definition';
10
- import { renderStringList } from '../utils';
10
+ import { renderStringList, renderClamped } from '../utils';
11
11
  import { Icon } from '../../Icons';
12
12
 
13
13
  export const send_email: ActionConfig = {
@@ -18,11 +18,7 @@ export const send_email: ActionConfig = {
18
18
  return html`<div>
19
19
  <div>${renderStringList(action.addresses, Icon.email)}</div>
20
20
  <div style="margin-top: 0.5em">
21
- <div
22
- style="word-wrap: break-word; overflow-wrap: break-word; hyphens: auto;"
23
- >
24
- ${action.subject}
25
- </div>
21
+ ${renderClamped(action.subject, action.subject)}
26
22
  </div>
27
23
  </div>`;
28
24
  },