@nyaruka/temba-components 0.141.1 → 0.142.0
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/CHANGELOG.md +15 -0
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +849 -655
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/Icons.js +3 -1
- package/out-tsc/src/Icons.js.map +1 -1
- package/out-tsc/src/display/Button.js +2 -2
- package/out-tsc/src/display/Button.js.map +1 -1
- package/out-tsc/src/display/FloatingTab.js.map +1 -1
- package/out-tsc/src/flow/CanvasMenu.js +24 -1
- package/out-tsc/src/flow/CanvasMenu.js.map +1 -1
- package/out-tsc/src/flow/CanvasNode.js +7 -2
- package/out-tsc/src/flow/CanvasNode.js.map +1 -1
- package/out-tsc/src/flow/Editor.js +654 -66
- package/out-tsc/src/flow/Editor.js.map +1 -1
- package/out-tsc/src/flow/NodeEditor.js +8 -5
- package/out-tsc/src/flow/NodeEditor.js.map +1 -1
- package/out-tsc/src/flow/Plumber.js +40 -28
- package/out-tsc/src/flow/Plumber.js.map +1 -1
- package/out-tsc/src/flow/actions/send_msg.js +2 -1
- package/out-tsc/src/flow/actions/send_msg.js.map +1 -1
- package/out-tsc/src/flow/nodes/wait_for_response.js +1 -1
- package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
- package/out-tsc/src/flow/reflow.js +393 -0
- package/out-tsc/src/flow/reflow.js.map +1 -0
- package/out-tsc/src/flow/types.js.map +1 -1
- package/out-tsc/src/flow/utils.js +18 -3
- package/out-tsc/src/flow/utils.js.map +1 -1
- package/out-tsc/src/form/Compose.js +5 -0
- package/out-tsc/src/form/Compose.js.map +1 -1
- package/out-tsc/src/form/FieldRenderer.js +1 -3
- package/out-tsc/src/form/FieldRenderer.js.map +1 -1
- package/out-tsc/src/layout/Dialog.js +2 -0
- package/out-tsc/src/layout/Dialog.js.map +1 -1
- package/out-tsc/src/list/SortableList.js +39 -19
- package/out-tsc/src/list/SortableList.js.map +1 -1
- package/out-tsc/test/temba-canvas-menu.test.js +44 -0
- package/out-tsc/test/temba-canvas-menu.test.js.map +1 -1
- package/out-tsc/test/temba-flow-collision.test.js +25 -0
- package/out-tsc/test/temba-flow-collision.test.js.map +1 -1
- package/out-tsc/test/temba-flow-editor-zoom.test.js +491 -0
- package/out-tsc/test/temba-flow-editor-zoom.test.js.map +1 -0
- package/out-tsc/test/temba-flow-editor.test.js +145 -1
- package/out-tsc/test/temba-flow-editor.test.js.map +1 -1
- package/out-tsc/test/temba-flow-node-drag.test.js +123 -0
- package/out-tsc/test/temba-flow-node-drag.test.js.map +1 -1
- package/out-tsc/test/temba-flow-plumber.test.js +31 -0
- package/out-tsc/test/temba-flow-plumber.test.js.map +1 -1
- package/out-tsc/test/temba-flow-reflow.test.js +472 -0
- package/out-tsc/test/temba-flow-reflow.test.js.map +1 -0
- package/out-tsc/test/temba-sortable-list.test.js +93 -0
- package/out-tsc/test/temba-sortable-list.test.js.map +1 -1
- package/package.json +1 -1
- package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
- package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/expression-facebook.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/expression-phone.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/facebook-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/instagram-handle.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/line-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/phone-number.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/telegram-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/viber-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/wechat-id.png +0 -0
- package/screenshots/truth/actions/add_contact_urn/editor/whatsapp.png +0 -0
- package/screenshots/truth/actions/enter_flow/editor/basic-flow.png +0 -0
- package/screenshots/truth/actions/enter_flow/editor/long-flow-name.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
- package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/say_msg/editor/text-with-audio-url.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/contacts-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/groups-only.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/many-groups.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/multiline-text.png +0 -0
- package/screenshots/truth/actions/send_email/editor/empty-body.png +0 -0
- package/screenshots/truth/actions/send_email/editor/empty-subject.png +0 -0
- package/screenshots/truth/actions/send_email/editor/long-subject.png +0 -0
- package/screenshots/truth/actions/send_email/editor/multiline-body.png +0 -0
- package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
- package/screenshots/truth/actions/send_email/editor/simple-email.png +0 -0
- package/screenshots/truth/actions/send_email/editor/with-expressions.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
- package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
- package/screenshots/truth/actions/set_contact_channel/editor/sms-channel.png +0 -0
- package/screenshots/truth/actions/set_contact_channel/editor/whatsapp-channel.png +0 -0
- package/screenshots/truth/actions/set_contact_field/editor/clear-value.png +0 -0
- package/screenshots/truth/actions/set_contact_field/editor/set-value.png +0 -0
- package/screenshots/truth/actions/set_contact_language/editor/english.png +0 -0
- package/screenshots/truth/actions/set_contact_language/editor/french.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/active.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/archived.png +0 -0
- package/screenshots/truth/actions/set_contact_status/editor/blocked.png +0 -0
- package/screenshots/truth/actions/set_run_result/editor/expression-value.png +0 -0
- package/screenshots/truth/actions/set_run_result/editor/with-category.png +0 -0
- package/screenshots/truth/actions/start_session/editor/contact-query.png +0 -0
- package/screenshots/truth/actions/start_session/editor/contacts-only.png +0 -0
- package/screenshots/truth/actions/start_session/editor/create-contact.png +0 -0
- package/screenshots/truth/actions/start_session/editor/groups-and-contacts.png +0 -0
- package/screenshots/truth/actions/start_session/editor/groups-only.png +0 -0
- package/screenshots/truth/actions/start_session/editor/many-recipients.png +0 -0
- package/screenshots/truth/list/fields-dragging.png +0 -0
- package/screenshots/truth/list/sortable-dragging.png +0 -0
- package/screenshots/truth/modax/simple.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/information-extraction.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/sentiment-analysis.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/summarization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm/editor/translation-task.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
- package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
- package/screenshots/truth/nodes/wait_for_dial/editor/dial-with-limits.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_digits/editor/digits-with-rules.png +0 -0
- package/screenshots/truth/nodes/wait_for_menu/editor/menu-with-digits.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
- package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
- package/src/Icons.ts +3 -1
- package/src/display/Button.ts +2 -2
- package/src/display/FloatingTab.ts +1 -1
- package/src/flow/CanvasMenu.ts +28 -3
- package/src/flow/CanvasNode.ts +7 -2
- package/src/flow/Editor.ts +755 -75
- package/src/flow/NodeEditor.ts +8 -4
- package/src/flow/Plumber.ts +65 -35
- package/src/flow/actions/send_msg.ts +2 -1
- package/src/flow/nodes/wait_for_response.ts +1 -1
- package/src/flow/reflow.ts +534 -0
- package/src/flow/types.ts +1 -0
- package/src/flow/utils.ts +19 -3
- package/src/form/Compose.ts +5 -0
- package/src/form/FieldRenderer.ts +1 -3
- package/src/layout/Dialog.ts +2 -0
- package/src/list/SortableList.ts +40 -19
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/expand-06.svg +1 -0
- package/static/svg/work/used/expand-06.svg +3 -0
- package/test/temba-canvas-menu.test.ts +55 -0
- package/test/temba-flow-collision.test.ts +31 -0
- package/test/temba-flow-editor-zoom.test.ts +583 -0
- package/test/temba-flow-editor.test.ts +187 -1
- package/test/temba-flow-node-drag.test.ts +171 -0
- package/test/temba-flow-plumber.test.ts +38 -0
- package/test/temba-flow-reflow.test.ts +703 -0
- package/test/temba-sortable-list.test.ts +120 -0
- package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
- package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
- package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
- package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
- package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
- package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
- package/screenshots/truth/actions/send_broadcast/editor/with-attachments.png +0 -0
- package/screenshots/truth/actions/send_broadcast/render/with-attachments.png +0 -0
- package/screenshots/truth/compose/attachments-with-failures.png +0 -0
- package/screenshots/truth/compose/attachments-with-files-and-failures.png +0 -0
- package/screenshots/truth/contacts/tickets-assignment.png +0 -0
- package/screenshots/truth/contacts/tickets.png +0 -0
- package/screenshots/truth/flow/editor-basic.png +0 -0
- package/screenshots/truth/formfield/markdown-errors.png +0 -0
- package/screenshots/truth/formfield/no-errors.png +0 -0
- package/screenshots/truth/formfield/plain-text-errors.png +0 -0
- package/screenshots/truth/formfield/widget-only-markdown-errors.png +0 -0
- package/screenshots/truth/omnibox/selected.png +0 -0
- package/screenshots/truth/select/enabled-multi-selection.png +0 -0
- package/screenshots/truth/select/endpoint-initial-value-updated.png +0 -0
- package/screenshots/truth/select/endpoint-initial-value.png +0 -0
- package/screenshots/truth/select/initial-value.png +0 -0
- package/screenshots/truth/select/multi-reorder-final.png +0 -0
- package/screenshots/truth/select/multi-reorder-initial.png +0 -0
- package/screenshots/truth/select/selected-multi-test.png +0 -0
- package/screenshots/truth/select/value-initial.png +0 -0
- package/screenshots/truth/wait-for-response/rules-editor.png +0 -0
- package/screenshots/truth/wait-for-response/timeout-editor-unchecked.png +0 -0
- package/screenshots/truth/wait-for-response/timeout-editor.png +0 -0
- package/screenshots/truth/webchat/connecting-state.png +0 -0
package/src/flow/Editor.ts
CHANGED
|
@@ -31,10 +31,13 @@ import {
|
|
|
31
31
|
formatIssueMessage,
|
|
32
32
|
getNodeBounds,
|
|
33
33
|
calculateReflowPositions,
|
|
34
|
+
isRightClick,
|
|
34
35
|
NodeBounds,
|
|
35
36
|
snapToGrid
|
|
36
37
|
} from './utils';
|
|
37
38
|
import { ACTION_CONFIG, NODE_CONFIG } from './config';
|
|
39
|
+
import { calculateLayeredLayout, placeStickyNotes } from './reflow';
|
|
40
|
+
import { FloatingTab } from '../display/FloatingTab';
|
|
38
41
|
|
|
39
42
|
interface Revision {
|
|
40
43
|
id: number;
|
|
@@ -65,6 +68,7 @@ import { Dialog } from '../layout/Dialog';
|
|
|
65
68
|
import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
|
|
66
69
|
import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
|
|
67
70
|
import { FloatingWindow } from '../layout/FloatingWindow';
|
|
71
|
+
import { Icon } from '../Icons';
|
|
68
72
|
|
|
69
73
|
export function findNodeForExit(
|
|
70
74
|
definition: FlowDefinition,
|
|
@@ -96,6 +100,8 @@ export interface SelectionBox {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
const DRAG_THRESHOLD = 5;
|
|
103
|
+
const AUTO_SCROLL_EDGE_ZONE = 100;
|
|
104
|
+
const AUTO_SCROLL_MAX_SPEED = 15;
|
|
99
105
|
|
|
100
106
|
type TranslationType = 'property' | 'category';
|
|
101
107
|
|
|
@@ -126,6 +132,10 @@ interface LocalizationUpdate {
|
|
|
126
132
|
|
|
127
133
|
const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
|
|
128
134
|
|
|
135
|
+
// How long the reflow auto-save countdown runs (in ms).
|
|
136
|
+
// Used in both the CSS animation and the JS setTimeout.
|
|
137
|
+
const REFLOW_AUTO_SAVE_DELAY = 5000;
|
|
138
|
+
|
|
129
139
|
// Offset for positioning dropped action node relative to mouse cursor
|
|
130
140
|
// Keep small to make drop location close to cursor position
|
|
131
141
|
const DROP_PREVIEW_OFFSET_X = 20;
|
|
@@ -200,6 +210,12 @@ export class Editor extends RapidElement {
|
|
|
200
210
|
private currentDragItem: DraggableItem | null = null;
|
|
201
211
|
private startPos = { left: 0, top: 0 };
|
|
202
212
|
|
|
213
|
+
// Auto-scroll state
|
|
214
|
+
private autoScrollAnimationId: number | null = null;
|
|
215
|
+
private autoScrollDeltaX = 0;
|
|
216
|
+
private autoScrollDeltaY = 0;
|
|
217
|
+
private lastMouseEvent: MouseEvent | null = null;
|
|
218
|
+
|
|
203
219
|
// Selection state
|
|
204
220
|
@state()
|
|
205
221
|
private selectedItems: Set<string> = new Set();
|
|
@@ -273,6 +289,28 @@ export class Editor extends RapidElement {
|
|
|
273
289
|
@state()
|
|
274
290
|
private saveError: string | null = null;
|
|
275
291
|
|
|
292
|
+
@state()
|
|
293
|
+
private zoom = 1.0;
|
|
294
|
+
|
|
295
|
+
@state()
|
|
296
|
+
private zoomFitted = false;
|
|
297
|
+
|
|
298
|
+
@state()
|
|
299
|
+
private reflowPending = false;
|
|
300
|
+
|
|
301
|
+
@state()
|
|
302
|
+
private reflowUnsaved = false;
|
|
303
|
+
|
|
304
|
+
private savedReflowPositions: Record<string, FlowPosition> | null = null;
|
|
305
|
+
private reflowAutoSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
306
|
+
|
|
307
|
+
private clearReflowAutoSaveTimer(): void {
|
|
308
|
+
if (this.reflowAutoSaveTimer !== null) {
|
|
309
|
+
clearTimeout(this.reflowAutoSaveTimer);
|
|
310
|
+
this.reflowAutoSaveTimer = null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
276
314
|
private preRevertState: {
|
|
277
315
|
definition: FlowDefinition;
|
|
278
316
|
dirtyDate: Date | null;
|
|
@@ -371,6 +409,7 @@ export class Editor extends RapidElement {
|
|
|
371
409
|
private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
|
|
372
410
|
private boundKeyDown = this.handleKeyDown.bind(this);
|
|
373
411
|
private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
|
|
412
|
+
private boundWheel = this.handleWheel.bind(this);
|
|
374
413
|
|
|
375
414
|
static get styles() {
|
|
376
415
|
return css`
|
|
@@ -404,6 +443,7 @@ export class Editor extends RapidElement {
|
|
|
404
443
|
width: 100%;
|
|
405
444
|
display: flex;
|
|
406
445
|
padding-top: 20px;
|
|
446
|
+
transform-origin: 0 0;
|
|
407
447
|
}
|
|
408
448
|
|
|
409
449
|
#canvas {
|
|
@@ -913,7 +953,7 @@ export class Editor extends RapidElement {
|
|
|
913
953
|
.save-indicator {
|
|
914
954
|
position: absolute;
|
|
915
955
|
top: 8px;
|
|
916
|
-
right:
|
|
956
|
+
right: 240px;
|
|
917
957
|
padding: 6px 10px;
|
|
918
958
|
z-index: 10000;
|
|
919
959
|
pointer-events: none;
|
|
@@ -924,6 +964,135 @@ export class Editor extends RapidElement {
|
|
|
924
964
|
.save-indicator.visible {
|
|
925
965
|
opacity: 1;
|
|
926
966
|
}
|
|
967
|
+
|
|
968
|
+
.zoom-controls {
|
|
969
|
+
position: absolute;
|
|
970
|
+
top: 8px;
|
|
971
|
+
right: 16px;
|
|
972
|
+
z-index: 4999;
|
|
973
|
+
display: flex;
|
|
974
|
+
align-items: center;
|
|
975
|
+
gap: 2px;
|
|
976
|
+
background: white;
|
|
977
|
+
border-radius: var(--curvature);
|
|
978
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
979
|
+
padding: 4px;
|
|
980
|
+
user-select: none;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.zoom-controls button {
|
|
984
|
+
width: 28px;
|
|
985
|
+
height: 28px;
|
|
986
|
+
border: none;
|
|
987
|
+
background: transparent;
|
|
988
|
+
border-radius: var(--curvature);
|
|
989
|
+
cursor: pointer;
|
|
990
|
+
display: flex;
|
|
991
|
+
align-items: center;
|
|
992
|
+
justify-content: center;
|
|
993
|
+
padding: 0;
|
|
994
|
+
color: #555;
|
|
995
|
+
font-size: 16px;
|
|
996
|
+
line-height: 1;
|
|
997
|
+
outline: none;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.zoom-controls button:hover {
|
|
1001
|
+
background: rgba(0, 0, 0, 0.06);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.zoom-controls button:disabled {
|
|
1005
|
+
opacity: 0.3;
|
|
1006
|
+
cursor: default;
|
|
1007
|
+
background: transparent;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
.zoom-controls .zoom-level {
|
|
1011
|
+
font-size: 12px;
|
|
1012
|
+
min-width: 40px;
|
|
1013
|
+
text-align: center;
|
|
1014
|
+
color: #555;
|
|
1015
|
+
font-weight: 500;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.zoom-controls .zoom-divider {
|
|
1019
|
+
width: 1px;
|
|
1020
|
+
height: 16px;
|
|
1021
|
+
background: #e0e0e0;
|
|
1022
|
+
margin: 0 2px;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
.reflow-card {
|
|
1026
|
+
position: absolute;
|
|
1027
|
+
top: 16px;
|
|
1028
|
+
left: 50%;
|
|
1029
|
+
transform: translateX(-50%);
|
|
1030
|
+
z-index: 10000;
|
|
1031
|
+
background: rgba(0, 0, 0, 0.65);
|
|
1032
|
+
backdrop-filter: blur(8px);
|
|
1033
|
+
border-radius: 10px;
|
|
1034
|
+
padding: 12px 16px 8px;
|
|
1035
|
+
display: flex;
|
|
1036
|
+
flex-direction: column;
|
|
1037
|
+
gap: 8px;
|
|
1038
|
+
color: white;
|
|
1039
|
+
font-size: 13px;
|
|
1040
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.reflow-card .reflow-top {
|
|
1044
|
+
display: flex;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
gap: 10px;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.reflow-card .reflow-label {
|
|
1050
|
+
white-space: nowrap;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.reflow-card button {
|
|
1054
|
+
border: none;
|
|
1055
|
+
border-radius: 6px;
|
|
1056
|
+
padding: 6px 14px;
|
|
1057
|
+
font-size: 13px;
|
|
1058
|
+
font-weight: 500;
|
|
1059
|
+
cursor: pointer;
|
|
1060
|
+
white-space: nowrap;
|
|
1061
|
+
transition: opacity 0.15s ease;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.reflow-card button:hover {
|
|
1065
|
+
opacity: 0.85;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.reflow-card .reflow-discard {
|
|
1069
|
+
background: rgba(255, 255, 255, 0.2);
|
|
1070
|
+
color: white;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.reflow-meter {
|
|
1074
|
+
height: 3px;
|
|
1075
|
+
border-radius: 2px;
|
|
1076
|
+
background: rgba(255, 255, 255, 0.15);
|
|
1077
|
+
overflow: hidden;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.reflow-meter-fill {
|
|
1081
|
+
height: 100%;
|
|
1082
|
+
background: rgba(255, 255, 255, 0.5);
|
|
1083
|
+
border-radius: 2px;
|
|
1084
|
+
animation: reflow-countdown ${unsafeCSS(REFLOW_AUTO_SAVE_DELAY / 1000)}s
|
|
1085
|
+
linear forwards;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
@keyframes reflow-countdown {
|
|
1089
|
+
from {
|
|
1090
|
+
width: 100%;
|
|
1091
|
+
}
|
|
1092
|
+
to {
|
|
1093
|
+
width: 0%;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
927
1096
|
`;
|
|
928
1097
|
}
|
|
929
1098
|
|
|
@@ -937,6 +1106,7 @@ export class Editor extends RapidElement {
|
|
|
937
1106
|
super.firstUpdated(changes);
|
|
938
1107
|
this.plumber = new Plumber(this.querySelector('#canvas'), this);
|
|
939
1108
|
this.setupGlobalEventListeners();
|
|
1109
|
+
this.updateZoomControlPositioning();
|
|
940
1110
|
if (changes.has('flow')) {
|
|
941
1111
|
getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
|
|
942
1112
|
this.fetchRevisions();
|
|
@@ -1002,10 +1172,10 @@ export class Editor extends RapidElement {
|
|
|
1002
1172
|
const canvas = this.querySelector('#canvas');
|
|
1003
1173
|
if (canvas) {
|
|
1004
1174
|
const canvasRect = canvas.getBoundingClientRect();
|
|
1005
|
-
const menuX = canvasRect.left + snappedPosition.left - 40;
|
|
1175
|
+
const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
|
|
1006
1176
|
const menuY = isDragUp
|
|
1007
|
-
? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
|
|
1008
|
-
: canvasRect.top + snappedPosition.top + 80; // just below placeholder
|
|
1177
|
+
? canvasRect.top + snappedPosition.top * this.zoom + 74 // just below placeholder bottom
|
|
1178
|
+
: canvasRect.top + snappedPosition.top * this.zoom + 80; // just below placeholder
|
|
1009
1179
|
|
|
1010
1180
|
const canvasMenu = this.querySelector(
|
|
1011
1181
|
'temba-canvas-menu'
|
|
@@ -1104,8 +1274,20 @@ export class Editor extends RapidElement {
|
|
|
1104
1274
|
|
|
1105
1275
|
if (changes.has('dirtyDate')) {
|
|
1106
1276
|
if (this.dirtyDate) {
|
|
1107
|
-
this.
|
|
1108
|
-
|
|
1277
|
+
if (this.reflowPending) {
|
|
1278
|
+
// This dirtyDate is from the reflow itself — suppress save
|
|
1279
|
+
this.reflowPending = false;
|
|
1280
|
+
} else {
|
|
1281
|
+
// Normal change — if reflow card was showing, it goes away
|
|
1282
|
+
// because these changes will be included in the save
|
|
1283
|
+
if (this.reflowUnsaved) {
|
|
1284
|
+
this.reflowUnsaved = false;
|
|
1285
|
+
this.savedReflowPositions = null;
|
|
1286
|
+
this.clearReflowAutoSaveTimer();
|
|
1287
|
+
}
|
|
1288
|
+
this.isSaving = true;
|
|
1289
|
+
this.debouncedSave();
|
|
1290
|
+
}
|
|
1109
1291
|
}
|
|
1110
1292
|
}
|
|
1111
1293
|
|
|
@@ -1145,6 +1327,12 @@ export class Editor extends RapidElement {
|
|
|
1145
1327
|
}
|
|
1146
1328
|
|
|
1147
1329
|
this.saveTimer = window.setTimeout(() => {
|
|
1330
|
+
// Don't auto-save while a reflow preview is pending user confirmation
|
|
1331
|
+
if (this.reflowUnsaved) {
|
|
1332
|
+
this.saveTimer = null;
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1148
1336
|
const now = new Date();
|
|
1149
1337
|
const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
|
|
1150
1338
|
|
|
@@ -1304,6 +1492,7 @@ export class Editor extends RapidElement {
|
|
|
1304
1492
|
|
|
1305
1493
|
disconnectedCallback(): void {
|
|
1306
1494
|
super.disconnectedCallback();
|
|
1495
|
+
this.stopAutoScroll();
|
|
1307
1496
|
if (this.saveTimer !== null) {
|
|
1308
1497
|
clearTimeout(this.saveTimer);
|
|
1309
1498
|
this.saveTimer = null;
|
|
@@ -1312,6 +1501,7 @@ export class Editor extends RapidElement {
|
|
|
1312
1501
|
clearTimeout(this.activityTimer);
|
|
1313
1502
|
this.activityTimer = null;
|
|
1314
1503
|
}
|
|
1504
|
+
this.clearReflowAutoSaveTimer();
|
|
1315
1505
|
document.removeEventListener('mousemove', this.boundMouseMove);
|
|
1316
1506
|
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
1317
1507
|
document.removeEventListener('mousedown', this.boundGlobalMouseDown);
|
|
@@ -1322,6 +1512,11 @@ export class Editor extends RapidElement {
|
|
|
1322
1512
|
canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1323
1513
|
}
|
|
1324
1514
|
|
|
1515
|
+
const editor = this.querySelector('#editor');
|
|
1516
|
+
if (editor) {
|
|
1517
|
+
editor.removeEventListener('wheel', this.boundWheel);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1325
1520
|
// Clear all flow-specific data from the store so stale data
|
|
1326
1521
|
// isn't briefly visible when a different flow is opened.
|
|
1327
1522
|
zustand.getState().clearFlowData();
|
|
@@ -1338,6 +1533,11 @@ export class Editor extends RapidElement {
|
|
|
1338
1533
|
canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1339
1534
|
}
|
|
1340
1535
|
|
|
1536
|
+
const editor = this.querySelector('#editor');
|
|
1537
|
+
if (editor) {
|
|
1538
|
+
editor.addEventListener('wheel', this.boundWheel, { passive: false });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1341
1541
|
// Listen for action edit requests from flow nodes
|
|
1342
1542
|
this.addEventListener(
|
|
1343
1543
|
CustomEventType.ActionEditRequested,
|
|
@@ -1409,8 +1609,7 @@ export class Editor extends RapidElement {
|
|
|
1409
1609
|
}
|
|
1410
1610
|
|
|
1411
1611
|
private handleMouseDown(event: MouseEvent): void {
|
|
1412
|
-
|
|
1413
|
-
if (event.button !== 0) return;
|
|
1612
|
+
if (isRightClick(event)) return;
|
|
1414
1613
|
|
|
1415
1614
|
if (this.isReadOnly()) return;
|
|
1416
1615
|
this.blurActiveContentEditable();
|
|
@@ -1454,8 +1653,7 @@ export class Editor extends RapidElement {
|
|
|
1454
1653
|
}
|
|
1455
1654
|
|
|
1456
1655
|
private handleGlobalMouseDown(event: MouseEvent): void {
|
|
1457
|
-
|
|
1458
|
-
if (event.button !== 0) return;
|
|
1656
|
+
if (isRightClick(event)) return;
|
|
1459
1657
|
|
|
1460
1658
|
// Check if the click is within our canvas
|
|
1461
1659
|
const canvasRect = this.querySelector('#grid')?.getBoundingClientRect();
|
|
@@ -1513,8 +1711,8 @@ export class Editor extends RapidElement {
|
|
|
1513
1711
|
// Clear current selection
|
|
1514
1712
|
this.selectedItems.clear();
|
|
1515
1713
|
|
|
1516
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1517
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
1714
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
1715
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1518
1716
|
|
|
1519
1717
|
this.selectionBox = {
|
|
1520
1718
|
startX: relativeX,
|
|
@@ -1540,6 +1738,154 @@ export class Editor extends RapidElement {
|
|
|
1540
1738
|
}
|
|
1541
1739
|
}
|
|
1542
1740
|
|
|
1741
|
+
// --- Zoom ---
|
|
1742
|
+
|
|
1743
|
+
private setZoom(
|
|
1744
|
+
newZoom: number,
|
|
1745
|
+
center?: { clientX: number; clientY: number }
|
|
1746
|
+
): void {
|
|
1747
|
+
const clamped = Math.max(
|
|
1748
|
+
0.1,
|
|
1749
|
+
Math.min(1.0, Math.round(newZoom * 100) / 100)
|
|
1750
|
+
);
|
|
1751
|
+
if (clamped === this.zoom) return;
|
|
1752
|
+
|
|
1753
|
+
const editor = this.querySelector('#editor') as HTMLElement;
|
|
1754
|
+
const oldZoom = this.zoom;
|
|
1755
|
+
this.zoom = clamped;
|
|
1756
|
+
this.plumber.zoom = clamped;
|
|
1757
|
+
this.zoomFitted = false;
|
|
1758
|
+
|
|
1759
|
+
if (editor && center) {
|
|
1760
|
+
const editorRect = editor.getBoundingClientRect();
|
|
1761
|
+
const ox = center.clientX - editorRect.left;
|
|
1762
|
+
const oy = center.clientY - editorRect.top;
|
|
1763
|
+
// Canvas point under cursor at old zoom
|
|
1764
|
+
const cx = (editor.scrollLeft + ox) / oldZoom;
|
|
1765
|
+
const cy = (editor.scrollTop + oy) / oldZoom;
|
|
1766
|
+
|
|
1767
|
+
requestAnimationFrame(() => {
|
|
1768
|
+
editor.scrollLeft = cx * clamped - ox;
|
|
1769
|
+
editor.scrollTop = cy * clamped - oy;
|
|
1770
|
+
this.plumber.repaintEverything();
|
|
1771
|
+
});
|
|
1772
|
+
} else {
|
|
1773
|
+
requestAnimationFrame(() => this.plumber.repaintEverything());
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
private zoomIn(): void {
|
|
1778
|
+
this.setZoom(this.zoom + 0.05);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
private zoomOut(): void {
|
|
1782
|
+
this.setZoom(this.zoom - 0.05);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
private zoomToFit(): void {
|
|
1786
|
+
if (!this.definition || this.definition.nodes.length === 0) return;
|
|
1787
|
+
|
|
1788
|
+
const editor = this.querySelector('#editor') as HTMLElement;
|
|
1789
|
+
if (!editor) return;
|
|
1790
|
+
|
|
1791
|
+
// Calculate bounding box of all content in canvas coordinates
|
|
1792
|
+
let minX = Infinity;
|
|
1793
|
+
let minY = Infinity;
|
|
1794
|
+
let maxX = -Infinity;
|
|
1795
|
+
let maxY = -Infinity;
|
|
1796
|
+
|
|
1797
|
+
this.definition.nodes.forEach((node) => {
|
|
1798
|
+
const ui = this.definition._ui?.nodes[node.uuid];
|
|
1799
|
+
if (!ui?.position) return;
|
|
1800
|
+
const el = this.querySelector(`[id="${node.uuid}"]`) as HTMLElement;
|
|
1801
|
+
if (!el) return;
|
|
1802
|
+
const w = el.offsetWidth;
|
|
1803
|
+
const h = el.offsetHeight;
|
|
1804
|
+
minX = Math.min(minX, ui.position.left);
|
|
1805
|
+
minY = Math.min(minY, ui.position.top);
|
|
1806
|
+
maxX = Math.max(maxX, ui.position.left + w);
|
|
1807
|
+
maxY = Math.max(maxY, ui.position.top + h);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
const stickies = this.definition._ui?.stickies || {};
|
|
1811
|
+
Object.entries(stickies).forEach(([uuid, sticky]) => {
|
|
1812
|
+
if (!sticky.position) return;
|
|
1813
|
+
const el = this.querySelector(
|
|
1814
|
+
`temba-sticky-note[uuid="${uuid}"]`
|
|
1815
|
+
) as HTMLElement;
|
|
1816
|
+
if (!el) return;
|
|
1817
|
+
const w = el.offsetWidth;
|
|
1818
|
+
const h = el.offsetHeight;
|
|
1819
|
+
minX = Math.min(minX, sticky.position.left);
|
|
1820
|
+
minY = Math.min(minY, sticky.position.top);
|
|
1821
|
+
maxX = Math.max(maxX, sticky.position.left + w);
|
|
1822
|
+
maxY = Math.max(maxY, sticky.position.top + h);
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
if (minX === Infinity) return;
|
|
1826
|
+
|
|
1827
|
+
const contentWidth = maxX - minX;
|
|
1828
|
+
const contentHeight = maxY - minY;
|
|
1829
|
+
const padding = 40;
|
|
1830
|
+
|
|
1831
|
+
const availWidth = editor.clientWidth - padding * 2;
|
|
1832
|
+
const availHeight = editor.clientHeight - padding * 2;
|
|
1833
|
+
|
|
1834
|
+
const scaleX = availWidth / contentWidth;
|
|
1835
|
+
const scaleY = availHeight / contentHeight;
|
|
1836
|
+
let fitZoom = Math.min(scaleX, scaleY, 1.0);
|
|
1837
|
+
fitZoom = Math.max(fitZoom, 0.1);
|
|
1838
|
+
fitZoom = Math.round(fitZoom * 20) / 20; // round to nearest 0.05
|
|
1839
|
+
|
|
1840
|
+
this.zoom = fitZoom;
|
|
1841
|
+
this.plumber.zoom = fitZoom;
|
|
1842
|
+
this.zoomFitted = true;
|
|
1843
|
+
|
|
1844
|
+
// Center of content in canvas coordinates, plus grid/canvas margin offset
|
|
1845
|
+
const centerX = (minX + maxX) / 2 + 40;
|
|
1846
|
+
const centerY = (minY + maxY) / 2 + 40;
|
|
1847
|
+
|
|
1848
|
+
requestAnimationFrame(() => {
|
|
1849
|
+
editor.scrollLeft = centerX * fitZoom - editor.clientWidth / 2;
|
|
1850
|
+
editor.scrollTop = centerY * fitZoom - editor.clientHeight / 2;
|
|
1851
|
+
this.plumber.repaintEverything();
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
private zoomToFull(): void {
|
|
1856
|
+
this.setZoom(1.0);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/** Adjust zoom control right offset and floating tab positions */
|
|
1860
|
+
private updateZoomControlPositioning(): void {
|
|
1861
|
+
requestAnimationFrame(() => {
|
|
1862
|
+
const editor = this.querySelector('#editor') as HTMLElement;
|
|
1863
|
+
const zoomControls = this.querySelector('.zoom-controls') as HTMLElement;
|
|
1864
|
+
if (editor && zoomControls) {
|
|
1865
|
+
// Match right spacing to the top spacing (8px) by accounting for
|
|
1866
|
+
// the scrollbar width
|
|
1867
|
+
const scrollbarWidth = editor.offsetWidth - editor.clientWidth;
|
|
1868
|
+
zoomControls.style.right = `${8 + scrollbarWidth}px`;
|
|
1869
|
+
}
|
|
1870
|
+
if (zoomControls) {
|
|
1871
|
+
const rect = zoomControls.getBoundingClientRect();
|
|
1872
|
+
FloatingTab.START_TOP = rect.bottom + 8;
|
|
1873
|
+
FloatingTab.updateAllPositions();
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
private handleWheel(event: WheelEvent): void {
|
|
1879
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
1880
|
+
event.preventDefault();
|
|
1881
|
+
|
|
1882
|
+
const delta = event.deltaY > 0 ? -0.05 : 0.05;
|
|
1883
|
+
this.setZoom(this.zoom + delta, {
|
|
1884
|
+
clientX: event.clientX,
|
|
1885
|
+
clientY: event.clientY
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1543
1889
|
private showDeleteConfirmation(): void {
|
|
1544
1890
|
const itemCount = this.selectedItems.size;
|
|
1545
1891
|
const itemType = itemCount === 1 ? 'item' : 'items';
|
|
@@ -1569,6 +1915,158 @@ export class Editor extends RapidElement {
|
|
|
1569
1915
|
});
|
|
1570
1916
|
}
|
|
1571
1917
|
|
|
1918
|
+
private performReflow(): void {
|
|
1919
|
+
if (!this.definition || this.definition.nodes.length === 0) return;
|
|
1920
|
+
|
|
1921
|
+
// Save current positions for discard (nodes + stickies)
|
|
1922
|
+
const savedPositions: Record<string, FlowPosition> = {};
|
|
1923
|
+
for (const node of this.definition.nodes) {
|
|
1924
|
+
const ui = this.definition._ui?.nodes[node.uuid];
|
|
1925
|
+
if (ui?.position) {
|
|
1926
|
+
savedPositions[node.uuid] = { ...ui.position };
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
const stickies = this.definition._ui?.stickies || {};
|
|
1930
|
+
for (const [uuid, sticky] of Object.entries(stickies)) {
|
|
1931
|
+
if (sticky.position) {
|
|
1932
|
+
savedPositions[uuid] = { ...sticky.position };
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
this.savedReflowPositions = savedPositions;
|
|
1936
|
+
|
|
1937
|
+
// Save old node positions before reflow for sticky proximity calculation
|
|
1938
|
+
const oldNodePositions: Record<string, FlowPosition> = {};
|
|
1939
|
+
for (const node of this.definition.nodes) {
|
|
1940
|
+
const ui = this.definition._ui?.nodes[node.uuid];
|
|
1941
|
+
if (ui?.position) {
|
|
1942
|
+
oldNodePositions[node.uuid] = { ...ui.position };
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// Identify start node (first in sorted array)
|
|
1947
|
+
const startNodeUuid = this.definition.nodes[0].uuid;
|
|
1948
|
+
|
|
1949
|
+
// Gather node sizes from DOM
|
|
1950
|
+
const nodeSizes = new Map<string, { width: number; height: number }>();
|
|
1951
|
+
const getNodeSize = (uuid: string): { width: number; height: number } => {
|
|
1952
|
+
const element = this.querySelector(`[id="${uuid}"]`) as HTMLElement;
|
|
1953
|
+
if (element) {
|
|
1954
|
+
const size = {
|
|
1955
|
+
width: element.offsetWidth,
|
|
1956
|
+
height: element.offsetHeight
|
|
1957
|
+
};
|
|
1958
|
+
nodeSizes.set(uuid, size);
|
|
1959
|
+
return size;
|
|
1960
|
+
}
|
|
1961
|
+
const fallback = { width: 200, height: 100 };
|
|
1962
|
+
nodeSizes.set(uuid, fallback);
|
|
1963
|
+
return fallback;
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
// Compute new layout
|
|
1967
|
+
const newPositions = calculateLayeredLayout(
|
|
1968
|
+
this.definition.nodes,
|
|
1969
|
+
this.definition._ui.nodes,
|
|
1970
|
+
startNodeUuid,
|
|
1971
|
+
getNodeSize
|
|
1972
|
+
);
|
|
1973
|
+
|
|
1974
|
+
// Place sticky notes next to their closest nodes
|
|
1975
|
+
if (Object.keys(stickies).length > 0) {
|
|
1976
|
+
const stickySizes = new Map<string, { width: number; height: number }>();
|
|
1977
|
+
for (const uuid of Object.keys(stickies)) {
|
|
1978
|
+
const el = this.querySelector(
|
|
1979
|
+
`temba-sticky-note[uuid="${uuid}"]`
|
|
1980
|
+
) as HTMLElement;
|
|
1981
|
+
if (el) {
|
|
1982
|
+
stickySizes.set(uuid, {
|
|
1983
|
+
width: el.offsetWidth,
|
|
1984
|
+
height: el.offsetHeight
|
|
1985
|
+
});
|
|
1986
|
+
} else {
|
|
1987
|
+
stickySizes.set(uuid, { width: 182, height: 100 });
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const stickyPositions = placeStickyNotes(
|
|
1992
|
+
stickies,
|
|
1993
|
+
oldNodePositions,
|
|
1994
|
+
newPositions,
|
|
1995
|
+
nodeSizes,
|
|
1996
|
+
stickySizes,
|
|
1997
|
+
startNodeUuid
|
|
1998
|
+
);
|
|
1999
|
+
|
|
2000
|
+
// Merge sticky positions into newPositions
|
|
2001
|
+
Object.assign(newPositions, stickyPositions);
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Cancel any in-flight save timer so it doesn't persist the reflowed
|
|
2005
|
+
// layout before the user has a chance to Save or Discard.
|
|
2006
|
+
if (this.saveTimer !== null) {
|
|
2007
|
+
clearTimeout(this.saveTimer);
|
|
2008
|
+
this.saveTimer = null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// Suppress the auto-save from this updateCanvasPositions call
|
|
2012
|
+
this.reflowPending = true;
|
|
2013
|
+
this.reflowUnsaved = true;
|
|
2014
|
+
|
|
2015
|
+
// Apply new positions
|
|
2016
|
+
getStore().getState().updateCanvasPositions(newPositions);
|
|
2017
|
+
|
|
2018
|
+
// Update canvas size and repaint connections
|
|
2019
|
+
this.updateCanvasSize();
|
|
2020
|
+
requestAnimationFrame(() => {
|
|
2021
|
+
this.plumber.repaintEverything();
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
// Scroll to top-left so the start node is visible
|
|
2025
|
+
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2026
|
+
if (editor) {
|
|
2027
|
+
editor.scrollTo({ left: 0, top: 0, behavior: 'smooth' });
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Start auto-save countdown (duration shared with CSS animation)
|
|
2031
|
+
this.clearReflowAutoSaveTimer();
|
|
2032
|
+
this.reflowAutoSaveTimer = setTimeout(() => {
|
|
2033
|
+
this.reflowAutoSaveTimer = null;
|
|
2034
|
+
if (this.reflowUnsaved) {
|
|
2035
|
+
this.reflowUnsaved = false;
|
|
2036
|
+
this.savedReflowPositions = null;
|
|
2037
|
+
this.saveChanges();
|
|
2038
|
+
}
|
|
2039
|
+
}, REFLOW_AUTO_SAVE_DELAY);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
private handleReflowDiscard(): void {
|
|
2043
|
+
this.reflowUnsaved = false;
|
|
2044
|
+
this.clearReflowAutoSaveTimer();
|
|
2045
|
+
|
|
2046
|
+
if (this.savedReflowPositions) {
|
|
2047
|
+
// Cancel any pending save timer before reverting
|
|
2048
|
+
if (this.saveTimer !== null) {
|
|
2049
|
+
clearTimeout(this.saveTimer);
|
|
2050
|
+
this.saveTimer = null;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Suppress the auto-save from reverting positions
|
|
2054
|
+
this.reflowPending = true;
|
|
2055
|
+
getStore().getState().updateCanvasPositions(this.savedReflowPositions);
|
|
2056
|
+
this.savedReflowPositions = null;
|
|
2057
|
+
|
|
2058
|
+
// Clear dirty state since we reverted to the saved version
|
|
2059
|
+
setTimeout(() => {
|
|
2060
|
+
getStore().getState().setDirtyDate(null);
|
|
2061
|
+
this.isSaving = false;
|
|
2062
|
+
}, 0);
|
|
2063
|
+
|
|
2064
|
+
requestAnimationFrame(() => {
|
|
2065
|
+
this.plumber.repaintEverything();
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
1572
2070
|
private deleteNodes(uuids: string[]): void {
|
|
1573
2071
|
// Remove nodes from the definition - CanvasNode will handle plumber cleanup
|
|
1574
2072
|
if (uuids.length > 0) {
|
|
@@ -1598,8 +2096,8 @@ export class Editor extends RapidElement {
|
|
|
1598
2096
|
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
1599
2097
|
if (!canvasRect) return;
|
|
1600
2098
|
|
|
1601
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1602
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
2099
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
2100
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1603
2101
|
|
|
1604
2102
|
this.selectionBox = {
|
|
1605
2103
|
...this.selectionBox,
|
|
@@ -1626,19 +2124,20 @@ export class Editor extends RapidElement {
|
|
|
1626
2124
|
|
|
1627
2125
|
// Check nodes
|
|
1628
2126
|
this.definition?.nodes.forEach((node) => {
|
|
1629
|
-
const nodeElement = this.querySelector(
|
|
2127
|
+
const nodeElement = this.querySelector(
|
|
2128
|
+
`[id="${node.uuid}"]`
|
|
2129
|
+
) as HTMLElement;
|
|
1630
2130
|
if (nodeElement) {
|
|
1631
2131
|
const position = this.definition._ui?.nodes[node.uuid]?.position;
|
|
1632
2132
|
if (position) {
|
|
1633
|
-
const rect = nodeElement.getBoundingClientRect();
|
|
1634
2133
|
const canvasRect =
|
|
1635
2134
|
this.querySelector('#canvas')?.getBoundingClientRect();
|
|
1636
2135
|
|
|
1637
2136
|
if (canvasRect) {
|
|
1638
2137
|
const nodeLeft = position.left;
|
|
1639
2138
|
const nodeTop = position.top;
|
|
1640
|
-
const nodeRight = nodeLeft +
|
|
1641
|
-
const nodeBottom = nodeTop +
|
|
2139
|
+
const nodeRight = nodeLeft + nodeElement.offsetWidth;
|
|
2140
|
+
const nodeBottom = nodeTop + nodeElement.offsetHeight;
|
|
1642
2141
|
|
|
1643
2142
|
// Check if selection box intersects with node
|
|
1644
2143
|
if (
|
|
@@ -1913,8 +2412,8 @@ export class Editor extends RapidElement {
|
|
|
1913
2412
|
const canvas = this.querySelector('#canvas');
|
|
1914
2413
|
if (canvas) {
|
|
1915
2414
|
const canvasRect = canvas.getBoundingClientRect();
|
|
1916
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1917
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
2415
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
2416
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1918
2417
|
|
|
1919
2418
|
const placeholderWidth = 200;
|
|
1920
2419
|
const placeholderHeight = 64;
|
|
@@ -1956,46 +2455,147 @@ export class Editor extends RapidElement {
|
|
|
1956
2455
|
// Handle item dragging
|
|
1957
2456
|
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
1958
2457
|
|
|
1959
|
-
|
|
1960
|
-
|
|
2458
|
+
this.lastMouseEvent = event;
|
|
2459
|
+
|
|
2460
|
+
const deltaX = event.clientX - this.dragStartPos.x + this.autoScrollDeltaX;
|
|
2461
|
+
const deltaY = event.clientY - this.dragStartPos.y + this.autoScrollDeltaY;
|
|
1961
2462
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
1962
2463
|
|
|
1963
2464
|
// Only start dragging if we've moved beyond the threshold
|
|
1964
2465
|
if (!this.isDragging && distance > DRAG_THRESHOLD) {
|
|
1965
2466
|
this.isDragging = true;
|
|
2467
|
+
this.startAutoScroll();
|
|
1966
2468
|
}
|
|
1967
2469
|
|
|
1968
2470
|
// If we're actually dragging, update positions
|
|
1969
2471
|
if (this.isDragging) {
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
this.selectedItems.size > 1
|
|
1974
|
-
? Array.from(this.selectedItems)
|
|
1975
|
-
: [this.currentDragItem.uuid];
|
|
2472
|
+
this.updateDragPositions();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
1976
2475
|
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2476
|
+
private updateDragPositions(): void {
|
|
2477
|
+
if (!this.currentDragItem || !this.lastMouseEvent) return;
|
|
2478
|
+
|
|
2479
|
+
// Convert screen + scroll delta to canvas delta
|
|
2480
|
+
const deltaX =
|
|
2481
|
+
(this.lastMouseEvent.clientX -
|
|
2482
|
+
this.dragStartPos.x +
|
|
2483
|
+
this.autoScrollDeltaX) /
|
|
2484
|
+
this.zoom;
|
|
2485
|
+
const deltaY =
|
|
2486
|
+
(this.lastMouseEvent.clientY -
|
|
2487
|
+
this.dragStartPos.y +
|
|
2488
|
+
this.autoScrollDeltaY) /
|
|
2489
|
+
this.zoom;
|
|
2490
|
+
|
|
2491
|
+
const itemsToMove =
|
|
2492
|
+
this.selectedItems.has(this.currentDragItem.uuid) &&
|
|
2493
|
+
this.selectedItems.size > 1
|
|
2494
|
+
? Array.from(this.selectedItems)
|
|
2495
|
+
: [this.currentDragItem.uuid];
|
|
2496
|
+
|
|
2497
|
+
itemsToMove.forEach((uuid) => {
|
|
2498
|
+
const element = this.querySelector(`[uuid="${uuid}"]`) as HTMLElement;
|
|
2499
|
+
if (element) {
|
|
2500
|
+
const type = element.tagName === 'TEMBA-FLOW-NODE' ? 'node' : 'sticky';
|
|
2501
|
+
const position = this.getPosition(uuid, type);
|
|
2502
|
+
|
|
2503
|
+
if (position) {
|
|
2504
|
+
element.style.left = `${position.left + deltaX}px`;
|
|
2505
|
+
element.style.top = `${position.top + deltaY}px`;
|
|
2506
|
+
element.classList.add('dragging');
|
|
1995
2507
|
}
|
|
1996
|
-
}
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
this.plumber.revalidate(itemsToMove);
|
|
2512
|
+
}
|
|
1997
2513
|
|
|
1998
|
-
|
|
2514
|
+
private startAutoScroll(): void {
|
|
2515
|
+
if (this.autoScrollAnimationId !== null) return;
|
|
2516
|
+
|
|
2517
|
+
const editor = this.querySelector('#editor') as HTMLElement;
|
|
2518
|
+
if (!editor) return;
|
|
2519
|
+
|
|
2520
|
+
const tick = () => {
|
|
2521
|
+
if (!this.isDragging || !this.lastMouseEvent) {
|
|
2522
|
+
this.autoScrollAnimationId = null;
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
const editorRect = editor.getBoundingClientRect();
|
|
2527
|
+
const mouseX = this.lastMouseEvent.clientX;
|
|
2528
|
+
const mouseY = this.lastMouseEvent.clientY;
|
|
2529
|
+
|
|
2530
|
+
let scrollDx = 0;
|
|
2531
|
+
let scrollDy = 0;
|
|
2532
|
+
|
|
2533
|
+
// Left edge
|
|
2534
|
+
const distFromLeft = mouseX - editorRect.left;
|
|
2535
|
+
if (distFromLeft >= 0 && distFromLeft < AUTO_SCROLL_EDGE_ZONE) {
|
|
2536
|
+
const ratio = 1 - distFromLeft / AUTO_SCROLL_EDGE_ZONE;
|
|
2537
|
+
scrollDx = -(ratio * AUTO_SCROLL_MAX_SPEED);
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// Right edge
|
|
2541
|
+
const distFromRight = editorRect.right - mouseX;
|
|
2542
|
+
if (distFromRight >= 0 && distFromRight < AUTO_SCROLL_EDGE_ZONE) {
|
|
2543
|
+
const ratio = 1 - distFromRight / AUTO_SCROLL_EDGE_ZONE;
|
|
2544
|
+
scrollDx = ratio * AUTO_SCROLL_MAX_SPEED;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Top edge
|
|
2548
|
+
const distFromTop = mouseY - editorRect.top;
|
|
2549
|
+
if (distFromTop >= 0 && distFromTop < AUTO_SCROLL_EDGE_ZONE) {
|
|
2550
|
+
const ratio = 1 - distFromTop / AUTO_SCROLL_EDGE_ZONE;
|
|
2551
|
+
scrollDy = -(ratio * AUTO_SCROLL_MAX_SPEED);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// Bottom edge
|
|
2555
|
+
const distFromBottom = editorRect.bottom - mouseY;
|
|
2556
|
+
if (distFromBottom >= 0 && distFromBottom < AUTO_SCROLL_EDGE_ZONE) {
|
|
2557
|
+
const ratio = 1 - distFromBottom / AUTO_SCROLL_EDGE_ZONE;
|
|
2558
|
+
scrollDy = ratio * AUTO_SCROLL_MAX_SPEED;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
if (scrollDx !== 0 || scrollDy !== 0) {
|
|
2562
|
+
const beforeScrollLeft = editor.scrollLeft;
|
|
2563
|
+
const beforeScrollTop = editor.scrollTop;
|
|
2564
|
+
|
|
2565
|
+
// Expand canvas if scrolling toward bottom/right edges
|
|
2566
|
+
// Convert from scroll space to canvas space for expandCanvas
|
|
2567
|
+
if (scrollDx > 0 || scrollDy > 0) {
|
|
2568
|
+
const neededWidth =
|
|
2569
|
+
(editor.scrollLeft + editor.clientWidth + scrollDx) / this.zoom;
|
|
2570
|
+
const neededHeight =
|
|
2571
|
+
(editor.scrollTop + editor.clientHeight + scrollDy) / this.zoom;
|
|
2572
|
+
getStore().getState().expandCanvas(neededWidth, neededHeight);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
editor.scrollLeft += scrollDx;
|
|
2576
|
+
editor.scrollTop += scrollDy;
|
|
2577
|
+
|
|
2578
|
+
// Track actual scroll delta (browser clamps at boundaries)
|
|
2579
|
+
const actualDx = editor.scrollLeft - beforeScrollLeft;
|
|
2580
|
+
const actualDy = editor.scrollTop - beforeScrollTop;
|
|
2581
|
+
this.autoScrollDeltaX += actualDx;
|
|
2582
|
+
this.autoScrollDeltaY += actualDy;
|
|
2583
|
+
|
|
2584
|
+
if (actualDx !== 0 || actualDy !== 0) {
|
|
2585
|
+
this.updateDragPositions();
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
this.autoScrollAnimationId = requestAnimationFrame(tick);
|
|
2590
|
+
};
|
|
2591
|
+
|
|
2592
|
+
this.autoScrollAnimationId = requestAnimationFrame(tick);
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
private stopAutoScroll(): void {
|
|
2596
|
+
if (this.autoScrollAnimationId !== null) {
|
|
2597
|
+
cancelAnimationFrame(this.autoScrollAnimationId);
|
|
2598
|
+
this.autoScrollAnimationId = null;
|
|
1999
2599
|
}
|
|
2000
2600
|
}
|
|
2001
2601
|
|
|
@@ -2018,10 +2618,17 @@ export class Editor extends RapidElement {
|
|
|
2018
2618
|
// Handle item drag completion
|
|
2019
2619
|
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
2020
2620
|
|
|
2621
|
+
this.stopAutoScroll();
|
|
2622
|
+
|
|
2021
2623
|
// If we were actually dragging, handle the drag end
|
|
2022
2624
|
if (this.isDragging) {
|
|
2023
|
-
|
|
2024
|
-
const
|
|
2625
|
+
// Convert screen + scroll delta to canvas delta
|
|
2626
|
+
const deltaX =
|
|
2627
|
+
(event.clientX - this.dragStartPos.x + this.autoScrollDeltaX) /
|
|
2628
|
+
this.zoom;
|
|
2629
|
+
const deltaY =
|
|
2630
|
+
(event.clientY - this.dragStartPos.y + this.autoScrollDeltaY) /
|
|
2631
|
+
this.zoom;
|
|
2025
2632
|
|
|
2026
2633
|
// Determine what items were moved
|
|
2027
2634
|
const itemsToMove =
|
|
@@ -2090,6 +2697,9 @@ export class Editor extends RapidElement {
|
|
|
2090
2697
|
this.isMouseDown = false;
|
|
2091
2698
|
this.currentDragItem = null;
|
|
2092
2699
|
this.canvasMouseDown = false;
|
|
2700
|
+
this.autoScrollDeltaX = 0;
|
|
2701
|
+
this.autoScrollDeltaY = 0;
|
|
2702
|
+
this.lastMouseEvent = null;
|
|
2093
2703
|
}
|
|
2094
2704
|
|
|
2095
2705
|
private updateCanvasSize(): void {
|
|
@@ -2106,11 +2716,19 @@ export class Editor extends RapidElement {
|
|
|
2106
2716
|
this.definition.nodes.forEach((node) => {
|
|
2107
2717
|
const ui = this.definition._ui.nodes[node.uuid];
|
|
2108
2718
|
if (ui && ui.position) {
|
|
2109
|
-
const nodeElement = this.querySelector(
|
|
2719
|
+
const nodeElement = this.querySelector(
|
|
2720
|
+
`[id="${node.uuid}"]`
|
|
2721
|
+
) as HTMLElement;
|
|
2110
2722
|
if (nodeElement) {
|
|
2111
|
-
|
|
2112
|
-
maxWidth = Math.max(
|
|
2113
|
-
|
|
2723
|
+
// Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
|
|
2724
|
+
maxWidth = Math.max(
|
|
2725
|
+
maxWidth,
|
|
2726
|
+
ui.position.left + nodeElement.offsetWidth
|
|
2727
|
+
);
|
|
2728
|
+
maxHeight = Math.max(
|
|
2729
|
+
maxHeight,
|
|
2730
|
+
ui.position.top + nodeElement.offsetHeight
|
|
2731
|
+
);
|
|
2114
2732
|
}
|
|
2115
2733
|
}
|
|
2116
2734
|
});
|
|
@@ -2166,8 +2784,8 @@ export class Editor extends RapidElement {
|
|
|
2166
2784
|
}
|
|
2167
2785
|
|
|
2168
2786
|
const canvasRect = canvas.getBoundingClientRect();
|
|
2169
|
-
const relativeX = event.clientX - canvasRect.left - 10;
|
|
2170
|
-
const relativeY = event.clientY - canvasRect.top - 10;
|
|
2787
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom - 10;
|
|
2788
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom - 10;
|
|
2171
2789
|
|
|
2172
2790
|
// Snap position to grid
|
|
2173
2791
|
const snappedLeft = snapToGrid(relativeX);
|
|
@@ -2176,10 +2794,17 @@ export class Editor extends RapidElement {
|
|
|
2176
2794
|
// Show the canvas menu at the mouse position (use viewport coordinates)
|
|
2177
2795
|
const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
|
|
2178
2796
|
if (canvasMenu) {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2797
|
+
const hasNodes = this.definition && this.definition.nodes.length > 0;
|
|
2798
|
+
canvasMenu.show(
|
|
2799
|
+
event.clientX,
|
|
2800
|
+
event.clientY,
|
|
2801
|
+
{
|
|
2802
|
+
x: snappedLeft,
|
|
2803
|
+
y: snappedTop
|
|
2804
|
+
},
|
|
2805
|
+
true,
|
|
2806
|
+
hasNodes
|
|
2807
|
+
);
|
|
2183
2808
|
}
|
|
2184
2809
|
}
|
|
2185
2810
|
|
|
@@ -2209,6 +2834,11 @@ export class Editor extends RapidElement {
|
|
|
2209
2834
|
const selection = event.detail as CanvasMenuSelection;
|
|
2210
2835
|
const store = getStore();
|
|
2211
2836
|
|
|
2837
|
+
if (selection.action === 'reflow') {
|
|
2838
|
+
this.performReflow();
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2212
2842
|
if (selection.action === 'sticky') {
|
|
2213
2843
|
// Create new sticky note
|
|
2214
2844
|
store.getState().createStickyNote({
|
|
@@ -2617,8 +3247,9 @@ export class Editor extends RapidElement {
|
|
|
2617
3247
|
}
|
|
2618
3248
|
|
|
2619
3249
|
// Check for collisions and reflow in case node size changed
|
|
3250
|
+
const nodeUuid = updatedNode.uuid;
|
|
2620
3251
|
requestAnimationFrame(() => {
|
|
2621
|
-
this.checkCollisionsAndReflow([
|
|
3252
|
+
this.checkCollisionsAndReflow([nodeUuid]);
|
|
2622
3253
|
});
|
|
2623
3254
|
}
|
|
2624
3255
|
}
|
|
@@ -2661,11 +3292,9 @@ export class Editor extends RapidElement {
|
|
|
2661
3292
|
|
|
2662
3293
|
const canvasRect = canvas.getBoundingClientRect();
|
|
2663
3294
|
|
|
2664
|
-
//
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
|
|
2668
|
-
const top = mouseY - canvasRect.top - DROP_PREVIEW_OFFSET_Y;
|
|
3295
|
+
// Convert viewport coordinates to canvas coordinates, accounting for zoom
|
|
3296
|
+
const left = (mouseX - canvasRect.left) / this.zoom - DROP_PREVIEW_OFFSET_X;
|
|
3297
|
+
const top = (mouseY - canvasRect.top) / this.zoom - DROP_PREVIEW_OFFSET_Y;
|
|
2669
3298
|
|
|
2670
3299
|
// Apply grid snapping only if requested (for final drop position)
|
|
2671
3300
|
if (applyGridSnapping) {
|
|
@@ -3986,14 +4615,15 @@ export class Editor extends RapidElement {
|
|
|
3986
4615
|
const editorCenterX = editorRect.width / 2;
|
|
3987
4616
|
const editorCenterY = editorRect.height / 2;
|
|
3988
4617
|
|
|
3989
|
-
//
|
|
3990
|
-
const
|
|
3991
|
-
const
|
|
3992
|
-
const nodeCenterY = nodeElement.offsetTop + nodeRect.height / 2;
|
|
4618
|
+
// Use offsetWidth/offsetHeight (unaffected by ancestor transforms)
|
|
4619
|
+
const nodeCenterX = nodeElement.offsetLeft + nodeElement.offsetWidth / 2;
|
|
4620
|
+
const nodeCenterY = nodeElement.offsetTop + nodeElement.offsetHeight / 2;
|
|
3993
4621
|
|
|
3994
|
-
// Calculate the scroll position needed to center the node
|
|
3995
|
-
|
|
3996
|
-
|
|
4622
|
+
// Calculate the scroll position needed to center the node.
|
|
4623
|
+
// Multiply by zoom because scroll operates in visual (transformed) space
|
|
4624
|
+
// while offsetLeft/offsetTop are in layout space.
|
|
4625
|
+
const targetScrollX = nodeCenterX * this.zoom - editorCenterX;
|
|
4626
|
+
const targetScrollY = nodeCenterY * this.zoom - editorCenterY;
|
|
3997
4627
|
|
|
3998
4628
|
// Smooth scroll the editor container to the target position
|
|
3999
4629
|
editor.scrollTo({
|
|
@@ -4043,8 +4673,9 @@ export class Editor extends RapidElement {
|
|
|
4043
4673
|
<div
|
|
4044
4674
|
id="grid"
|
|
4045
4675
|
class="${this.viewingRevision ? 'viewing-revision' : ''}"
|
|
4046
|
-
style="min-width
|
|
4047
|
-
|
|
4676
|
+
style="min-width:${100 / this.zoom}%;min-height:${100 /
|
|
4677
|
+
this.zoom}%;width:${this.canvasSize.width}px; height:${this
|
|
4678
|
+
.canvasSize.height}px;transform:scale(${this.zoom})"
|
|
4048
4679
|
>
|
|
4049
4680
|
<div
|
|
4050
4681
|
id="canvas"
|
|
@@ -4127,6 +4758,55 @@ export class Editor extends RapidElement {
|
|
|
4127
4758
|
<div class="save-indicator ${this.isSaving ? 'visible' : ''}">
|
|
4128
4759
|
<temba-loading units="3" size="8"></temba-loading>
|
|
4129
4760
|
</div>
|
|
4761
|
+
<div class="zoom-controls">
|
|
4762
|
+
<button
|
|
4763
|
+
@click=${this.zoomToFit}
|
|
4764
|
+
?disabled=${this.zoomFitted}
|
|
4765
|
+
title="Zoom to fit"
|
|
4766
|
+
>
|
|
4767
|
+
<temba-icon name=${Icon.zoom_fit} size="1"></temba-icon>
|
|
4768
|
+
</button>
|
|
4769
|
+
<div class="zoom-divider"></div>
|
|
4770
|
+
<button
|
|
4771
|
+
@click=${this.zoomOut}
|
|
4772
|
+
?disabled=${this.zoom <= 0.1}
|
|
4773
|
+
title="Zoom out"
|
|
4774
|
+
>
|
|
4775
|
+
−
|
|
4776
|
+
</button>
|
|
4777
|
+
<span class="zoom-level">${Math.round(this.zoom * 100)}%</span>
|
|
4778
|
+
<button
|
|
4779
|
+
@click=${this.zoomIn}
|
|
4780
|
+
?disabled=${this.zoom >= 1.0}
|
|
4781
|
+
title="Zoom in"
|
|
4782
|
+
>
|
|
4783
|
+
+
|
|
4784
|
+
</button>
|
|
4785
|
+
<div class="zoom-divider"></div>
|
|
4786
|
+
<button
|
|
4787
|
+
@click=${this.zoomToFull}
|
|
4788
|
+
?disabled=${this.zoom >= 1.0}
|
|
4789
|
+
title="Zoom to 100%"
|
|
4790
|
+
>
|
|
4791
|
+
<temba-icon name=${Icon.zoom_in} size="1"></temba-icon>
|
|
4792
|
+
</button>
|
|
4793
|
+
</div>
|
|
4794
|
+
${this.reflowUnsaved
|
|
4795
|
+
? html`<div class="reflow-card">
|
|
4796
|
+
<div class="reflow-top">
|
|
4797
|
+
<span class="reflow-label">Unsaved layout changes</span>
|
|
4798
|
+
<button
|
|
4799
|
+
class="reflow-discard"
|
|
4800
|
+
@click=${this.handleReflowDiscard}
|
|
4801
|
+
>
|
|
4802
|
+
Discard
|
|
4803
|
+
</button>
|
|
4804
|
+
</div>
|
|
4805
|
+
<div class="reflow-meter">
|
|
4806
|
+
<div class="reflow-meter-fill"></div>
|
|
4807
|
+
</div>
|
|
4808
|
+
</div>`
|
|
4809
|
+
: ''}
|
|
4130
4810
|
</div>
|
|
4131
4811
|
|
|
4132
4812
|
${this.editingNode || this.editingAction
|