@nyaruka/temba-components 0.141.0 → 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 +22 -0
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +859 -656
- 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 +665 -67
- 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/src/live/ContactChat.js +10 -1
- package/out-tsc/src/live/ContactChat.js.map +1 -1
- package/out-tsc/src/version.js +9 -0
- package/out-tsc/src/version.js.map +1 -0
- 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-contact-chat.test.js +12 -0
- package/out-tsc/test/temba-contact-chat.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 +164 -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/rollup.components.mjs +7 -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 +769 -76
- 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/src/live/ContactChat.ts +10 -1
- package/src/store/flow-definition.d.ts +1 -0
- package/src/version.ts +10 -0
- 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-contact-chat.test.ts +17 -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 +211 -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/web-dev-server.config.mjs +5 -1
- package/web-test-runner.config.mjs +4 -1
- 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
|
@@ -26,14 +26,18 @@ import {
|
|
|
26
26
|
getClasses,
|
|
27
27
|
WebResponse
|
|
28
28
|
} from '../utils';
|
|
29
|
+
import { TEMBA_COMPONENTS_VERSION } from '../version';
|
|
29
30
|
import {
|
|
30
31
|
formatIssueMessage,
|
|
31
32
|
getNodeBounds,
|
|
32
33
|
calculateReflowPositions,
|
|
34
|
+
isRightClick,
|
|
33
35
|
NodeBounds,
|
|
34
36
|
snapToGrid
|
|
35
37
|
} from './utils';
|
|
36
38
|
import { ACTION_CONFIG, NODE_CONFIG } from './config';
|
|
39
|
+
import { calculateLayeredLayout, placeStickyNotes } from './reflow';
|
|
40
|
+
import { FloatingTab } from '../display/FloatingTab';
|
|
37
41
|
|
|
38
42
|
interface Revision {
|
|
39
43
|
id: number;
|
|
@@ -64,6 +68,7 @@ import { Dialog } from '../layout/Dialog';
|
|
|
64
68
|
import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
|
|
65
69
|
import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
|
|
66
70
|
import { FloatingWindow } from '../layout/FloatingWindow';
|
|
71
|
+
import { Icon } from '../Icons';
|
|
67
72
|
|
|
68
73
|
export function findNodeForExit(
|
|
69
74
|
definition: FlowDefinition,
|
|
@@ -95,6 +100,8 @@ export interface SelectionBox {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
const DRAG_THRESHOLD = 5;
|
|
103
|
+
const AUTO_SCROLL_EDGE_ZONE = 100;
|
|
104
|
+
const AUTO_SCROLL_MAX_SPEED = 15;
|
|
98
105
|
|
|
99
106
|
type TranslationType = 'property' | 'category';
|
|
100
107
|
|
|
@@ -125,6 +132,10 @@ interface LocalizationUpdate {
|
|
|
125
132
|
|
|
126
133
|
const AUTO_TRANSLATE_MODELS_ENDPOINT = '/api/internal/llms.json';
|
|
127
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
|
+
|
|
128
139
|
// Offset for positioning dropped action node relative to mouse cursor
|
|
129
140
|
// Keep small to make drop location close to cursor position
|
|
130
141
|
const DROP_PREVIEW_OFFSET_X = 20;
|
|
@@ -199,6 +210,12 @@ export class Editor extends RapidElement {
|
|
|
199
210
|
private currentDragItem: DraggableItem | null = null;
|
|
200
211
|
private startPos = { left: 0, top: 0 };
|
|
201
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
|
+
|
|
202
219
|
// Selection state
|
|
203
220
|
@state()
|
|
204
221
|
private selectedItems: Set<string> = new Set();
|
|
@@ -272,6 +289,28 @@ export class Editor extends RapidElement {
|
|
|
272
289
|
@state()
|
|
273
290
|
private saveError: string | null = null;
|
|
274
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
|
+
|
|
275
314
|
private preRevertState: {
|
|
276
315
|
definition: FlowDefinition;
|
|
277
316
|
dirtyDate: Date | null;
|
|
@@ -370,6 +409,7 @@ export class Editor extends RapidElement {
|
|
|
370
409
|
private boundGlobalMouseDown = this.handleGlobalMouseDown.bind(this);
|
|
371
410
|
private boundKeyDown = this.handleKeyDown.bind(this);
|
|
372
411
|
private boundCanvasContextMenu = this.handleCanvasContextMenu.bind(this);
|
|
412
|
+
private boundWheel = this.handleWheel.bind(this);
|
|
373
413
|
|
|
374
414
|
static get styles() {
|
|
375
415
|
return css`
|
|
@@ -403,6 +443,7 @@ export class Editor extends RapidElement {
|
|
|
403
443
|
width: 100%;
|
|
404
444
|
display: flex;
|
|
405
445
|
padding-top: 20px;
|
|
446
|
+
transform-origin: 0 0;
|
|
406
447
|
}
|
|
407
448
|
|
|
408
449
|
#canvas {
|
|
@@ -912,7 +953,7 @@ export class Editor extends RapidElement {
|
|
|
912
953
|
.save-indicator {
|
|
913
954
|
position: absolute;
|
|
914
955
|
top: 8px;
|
|
915
|
-
right:
|
|
956
|
+
right: 240px;
|
|
916
957
|
padding: 6px 10px;
|
|
917
958
|
z-index: 10000;
|
|
918
959
|
pointer-events: none;
|
|
@@ -923,6 +964,135 @@ export class Editor extends RapidElement {
|
|
|
923
964
|
.save-indicator.visible {
|
|
924
965
|
opacity: 1;
|
|
925
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
|
+
}
|
|
926
1096
|
`;
|
|
927
1097
|
}
|
|
928
1098
|
|
|
@@ -936,6 +1106,7 @@ export class Editor extends RapidElement {
|
|
|
936
1106
|
super.firstUpdated(changes);
|
|
937
1107
|
this.plumber = new Plumber(this.querySelector('#canvas'), this);
|
|
938
1108
|
this.setupGlobalEventListeners();
|
|
1109
|
+
this.updateZoomControlPositioning();
|
|
939
1110
|
if (changes.has('flow')) {
|
|
940
1111
|
getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
|
|
941
1112
|
this.fetchRevisions();
|
|
@@ -1001,10 +1172,10 @@ export class Editor extends RapidElement {
|
|
|
1001
1172
|
const canvas = this.querySelector('#canvas');
|
|
1002
1173
|
if (canvas) {
|
|
1003
1174
|
const canvasRect = canvas.getBoundingClientRect();
|
|
1004
|
-
const menuX = canvasRect.left + snappedPosition.left - 40;
|
|
1175
|
+
const menuX = canvasRect.left + snappedPosition.left * this.zoom - 40;
|
|
1005
1176
|
const menuY = isDragUp
|
|
1006
|
-
? canvasRect.top + snappedPosition.top + 74 // just below placeholder bottom
|
|
1007
|
-
: 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
|
|
1008
1179
|
|
|
1009
1180
|
const canvasMenu = this.querySelector(
|
|
1010
1181
|
'temba-canvas-menu'
|
|
@@ -1103,8 +1274,20 @@ export class Editor extends RapidElement {
|
|
|
1103
1274
|
|
|
1104
1275
|
if (changes.has('dirtyDate')) {
|
|
1105
1276
|
if (this.dirtyDate) {
|
|
1106
|
-
this.
|
|
1107
|
-
|
|
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
|
+
}
|
|
1108
1291
|
}
|
|
1109
1292
|
}
|
|
1110
1293
|
|
|
@@ -1144,6 +1327,12 @@ export class Editor extends RapidElement {
|
|
|
1144
1327
|
}
|
|
1145
1328
|
|
|
1146
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
|
+
|
|
1147
1336
|
const now = new Date();
|
|
1148
1337
|
const timeSinceLastChange = now.getTime() - this.dirtyDate.getTime();
|
|
1149
1338
|
|
|
@@ -1156,8 +1345,20 @@ export class Editor extends RapidElement {
|
|
|
1156
1345
|
}, SAVE_QUIET_TIME);
|
|
1157
1346
|
}
|
|
1158
1347
|
|
|
1348
|
+
private definitionForSave(definition: FlowDefinition): FlowDefinition {
|
|
1349
|
+
return {
|
|
1350
|
+
...definition,
|
|
1351
|
+
_ui: {
|
|
1352
|
+
...definition._ui,
|
|
1353
|
+
editor: TEMBA_COMPONENTS_VERSION
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1159
1358
|
private saveChanges(definitionOverride?: FlowDefinition): Promise<void> {
|
|
1160
|
-
const definition =
|
|
1359
|
+
const definition = this.definitionForSave(
|
|
1360
|
+
definitionOverride || this.definition
|
|
1361
|
+
);
|
|
1161
1362
|
this.isSaving = true;
|
|
1162
1363
|
|
|
1163
1364
|
return getStore()
|
|
@@ -1291,6 +1492,7 @@ export class Editor extends RapidElement {
|
|
|
1291
1492
|
|
|
1292
1493
|
disconnectedCallback(): void {
|
|
1293
1494
|
super.disconnectedCallback();
|
|
1495
|
+
this.stopAutoScroll();
|
|
1294
1496
|
if (this.saveTimer !== null) {
|
|
1295
1497
|
clearTimeout(this.saveTimer);
|
|
1296
1498
|
this.saveTimer = null;
|
|
@@ -1299,6 +1501,7 @@ export class Editor extends RapidElement {
|
|
|
1299
1501
|
clearTimeout(this.activityTimer);
|
|
1300
1502
|
this.activityTimer = null;
|
|
1301
1503
|
}
|
|
1504
|
+
this.clearReflowAutoSaveTimer();
|
|
1302
1505
|
document.removeEventListener('mousemove', this.boundMouseMove);
|
|
1303
1506
|
document.removeEventListener('mouseup', this.boundMouseUp);
|
|
1304
1507
|
document.removeEventListener('mousedown', this.boundGlobalMouseDown);
|
|
@@ -1309,6 +1512,11 @@ export class Editor extends RapidElement {
|
|
|
1309
1512
|
canvas.removeEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1310
1513
|
}
|
|
1311
1514
|
|
|
1515
|
+
const editor = this.querySelector('#editor');
|
|
1516
|
+
if (editor) {
|
|
1517
|
+
editor.removeEventListener('wheel', this.boundWheel);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1312
1520
|
// Clear all flow-specific data from the store so stale data
|
|
1313
1521
|
// isn't briefly visible when a different flow is opened.
|
|
1314
1522
|
zustand.getState().clearFlowData();
|
|
@@ -1325,6 +1533,11 @@ export class Editor extends RapidElement {
|
|
|
1325
1533
|
canvas.addEventListener('contextmenu', this.boundCanvasContextMenu);
|
|
1326
1534
|
}
|
|
1327
1535
|
|
|
1536
|
+
const editor = this.querySelector('#editor');
|
|
1537
|
+
if (editor) {
|
|
1538
|
+
editor.addEventListener('wheel', this.boundWheel, { passive: false });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1328
1541
|
// Listen for action edit requests from flow nodes
|
|
1329
1542
|
this.addEventListener(
|
|
1330
1543
|
CustomEventType.ActionEditRequested,
|
|
@@ -1396,8 +1609,7 @@ export class Editor extends RapidElement {
|
|
|
1396
1609
|
}
|
|
1397
1610
|
|
|
1398
1611
|
private handleMouseDown(event: MouseEvent): void {
|
|
1399
|
-
|
|
1400
|
-
if (event.button !== 0) return;
|
|
1612
|
+
if (isRightClick(event)) return;
|
|
1401
1613
|
|
|
1402
1614
|
if (this.isReadOnly()) return;
|
|
1403
1615
|
this.blurActiveContentEditable();
|
|
@@ -1441,8 +1653,7 @@ export class Editor extends RapidElement {
|
|
|
1441
1653
|
}
|
|
1442
1654
|
|
|
1443
1655
|
private handleGlobalMouseDown(event: MouseEvent): void {
|
|
1444
|
-
|
|
1445
|
-
if (event.button !== 0) return;
|
|
1656
|
+
if (isRightClick(event)) return;
|
|
1446
1657
|
|
|
1447
1658
|
// Check if the click is within our canvas
|
|
1448
1659
|
const canvasRect = this.querySelector('#grid')?.getBoundingClientRect();
|
|
@@ -1500,8 +1711,8 @@ export class Editor extends RapidElement {
|
|
|
1500
1711
|
// Clear current selection
|
|
1501
1712
|
this.selectedItems.clear();
|
|
1502
1713
|
|
|
1503
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1504
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
1714
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
1715
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1505
1716
|
|
|
1506
1717
|
this.selectionBox = {
|
|
1507
1718
|
startX: relativeX,
|
|
@@ -1527,6 +1738,154 @@ export class Editor extends RapidElement {
|
|
|
1527
1738
|
}
|
|
1528
1739
|
}
|
|
1529
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
|
+
|
|
1530
1889
|
private showDeleteConfirmation(): void {
|
|
1531
1890
|
const itemCount = this.selectedItems.size;
|
|
1532
1891
|
const itemType = itemCount === 1 ? 'item' : 'items';
|
|
@@ -1556,6 +1915,158 @@ export class Editor extends RapidElement {
|
|
|
1556
1915
|
});
|
|
1557
1916
|
}
|
|
1558
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
|
+
|
|
1559
2070
|
private deleteNodes(uuids: string[]): void {
|
|
1560
2071
|
// Remove nodes from the definition - CanvasNode will handle plumber cleanup
|
|
1561
2072
|
if (uuids.length > 0) {
|
|
@@ -1585,8 +2096,8 @@ export class Editor extends RapidElement {
|
|
|
1585
2096
|
const canvasRect = this.querySelector('#canvas')?.getBoundingClientRect();
|
|
1586
2097
|
if (!canvasRect) return;
|
|
1587
2098
|
|
|
1588
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1589
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
2099
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
2100
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1590
2101
|
|
|
1591
2102
|
this.selectionBox = {
|
|
1592
2103
|
...this.selectionBox,
|
|
@@ -1613,19 +2124,20 @@ export class Editor extends RapidElement {
|
|
|
1613
2124
|
|
|
1614
2125
|
// Check nodes
|
|
1615
2126
|
this.definition?.nodes.forEach((node) => {
|
|
1616
|
-
const nodeElement = this.querySelector(
|
|
2127
|
+
const nodeElement = this.querySelector(
|
|
2128
|
+
`[id="${node.uuid}"]`
|
|
2129
|
+
) as HTMLElement;
|
|
1617
2130
|
if (nodeElement) {
|
|
1618
2131
|
const position = this.definition._ui?.nodes[node.uuid]?.position;
|
|
1619
2132
|
if (position) {
|
|
1620
|
-
const rect = nodeElement.getBoundingClientRect();
|
|
1621
2133
|
const canvasRect =
|
|
1622
2134
|
this.querySelector('#canvas')?.getBoundingClientRect();
|
|
1623
2135
|
|
|
1624
2136
|
if (canvasRect) {
|
|
1625
2137
|
const nodeLeft = position.left;
|
|
1626
2138
|
const nodeTop = position.top;
|
|
1627
|
-
const nodeRight = nodeLeft +
|
|
1628
|
-
const nodeBottom = nodeTop +
|
|
2139
|
+
const nodeRight = nodeLeft + nodeElement.offsetWidth;
|
|
2140
|
+
const nodeBottom = nodeTop + nodeElement.offsetHeight;
|
|
1629
2141
|
|
|
1630
2142
|
// Check if selection box intersects with node
|
|
1631
2143
|
if (
|
|
@@ -1900,8 +2412,8 @@ export class Editor extends RapidElement {
|
|
|
1900
2412
|
const canvas = this.querySelector('#canvas');
|
|
1901
2413
|
if (canvas) {
|
|
1902
2414
|
const canvasRect = canvas.getBoundingClientRect();
|
|
1903
|
-
const relativeX = event.clientX - canvasRect.left;
|
|
1904
|
-
const relativeY = event.clientY - canvasRect.top;
|
|
2415
|
+
const relativeX = (event.clientX - canvasRect.left) / this.zoom;
|
|
2416
|
+
const relativeY = (event.clientY - canvasRect.top) / this.zoom;
|
|
1905
2417
|
|
|
1906
2418
|
const placeholderWidth = 200;
|
|
1907
2419
|
const placeholderHeight = 64;
|
|
@@ -1943,46 +2455,147 @@ export class Editor extends RapidElement {
|
|
|
1943
2455
|
// Handle item dragging
|
|
1944
2456
|
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
1945
2457
|
|
|
1946
|
-
|
|
1947
|
-
|
|
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;
|
|
1948
2462
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
1949
2463
|
|
|
1950
2464
|
// Only start dragging if we've moved beyond the threshold
|
|
1951
2465
|
if (!this.isDragging && distance > DRAG_THRESHOLD) {
|
|
1952
2466
|
this.isDragging = true;
|
|
2467
|
+
this.startAutoScroll();
|
|
1953
2468
|
}
|
|
1954
2469
|
|
|
1955
2470
|
// If we're actually dragging, update positions
|
|
1956
2471
|
if (this.isDragging) {
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
this.selectedItems.size > 1
|
|
1961
|
-
? Array.from(this.selectedItems)
|
|
1962
|
-
: [this.currentDragItem.uuid];
|
|
2472
|
+
this.updateDragPositions();
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
1963
2475
|
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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');
|
|
1982
2507
|
}
|
|
1983
|
-
}
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
this.plumber.revalidate(itemsToMove);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
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
|
+
}
|
|
1984
2546
|
|
|
1985
|
-
|
|
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;
|
|
1986
2599
|
}
|
|
1987
2600
|
}
|
|
1988
2601
|
|
|
@@ -2005,10 +2618,17 @@ export class Editor extends RapidElement {
|
|
|
2005
2618
|
// Handle item drag completion
|
|
2006
2619
|
if (!this.isMouseDown || !this.currentDragItem) return;
|
|
2007
2620
|
|
|
2621
|
+
this.stopAutoScroll();
|
|
2622
|
+
|
|
2008
2623
|
// If we were actually dragging, handle the drag end
|
|
2009
2624
|
if (this.isDragging) {
|
|
2010
|
-
|
|
2011
|
-
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;
|
|
2012
2632
|
|
|
2013
2633
|
// Determine what items were moved
|
|
2014
2634
|
const itemsToMove =
|
|
@@ -2077,6 +2697,9 @@ export class Editor extends RapidElement {
|
|
|
2077
2697
|
this.isMouseDown = false;
|
|
2078
2698
|
this.currentDragItem = null;
|
|
2079
2699
|
this.canvasMouseDown = false;
|
|
2700
|
+
this.autoScrollDeltaX = 0;
|
|
2701
|
+
this.autoScrollDeltaY = 0;
|
|
2702
|
+
this.lastMouseEvent = null;
|
|
2080
2703
|
}
|
|
2081
2704
|
|
|
2082
2705
|
private updateCanvasSize(): void {
|
|
@@ -2093,11 +2716,19 @@ export class Editor extends RapidElement {
|
|
|
2093
2716
|
this.definition.nodes.forEach((node) => {
|
|
2094
2717
|
const ui = this.definition._ui.nodes[node.uuid];
|
|
2095
2718
|
if (ui && ui.position) {
|
|
2096
|
-
const nodeElement = this.querySelector(
|
|
2719
|
+
const nodeElement = this.querySelector(
|
|
2720
|
+
`[id="${node.uuid}"]`
|
|
2721
|
+
) as HTMLElement;
|
|
2097
2722
|
if (nodeElement) {
|
|
2098
|
-
|
|
2099
|
-
maxWidth = Math.max(
|
|
2100
|
-
|
|
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
|
+
);
|
|
2101
2732
|
}
|
|
2102
2733
|
}
|
|
2103
2734
|
});
|
|
@@ -2153,8 +2784,8 @@ export class Editor extends RapidElement {
|
|
|
2153
2784
|
}
|
|
2154
2785
|
|
|
2155
2786
|
const canvasRect = canvas.getBoundingClientRect();
|
|
2156
|
-
const relativeX = event.clientX - canvasRect.left - 10;
|
|
2157
|
-
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;
|
|
2158
2789
|
|
|
2159
2790
|
// Snap position to grid
|
|
2160
2791
|
const snappedLeft = snapToGrid(relativeX);
|
|
@@ -2163,10 +2794,17 @@ export class Editor extends RapidElement {
|
|
|
2163
2794
|
// Show the canvas menu at the mouse position (use viewport coordinates)
|
|
2164
2795
|
const canvasMenu = this.querySelector('temba-canvas-menu') as CanvasMenu;
|
|
2165
2796
|
if (canvasMenu) {
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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
|
+
);
|
|
2170
2808
|
}
|
|
2171
2809
|
}
|
|
2172
2810
|
|
|
@@ -2196,6 +2834,11 @@ export class Editor extends RapidElement {
|
|
|
2196
2834
|
const selection = event.detail as CanvasMenuSelection;
|
|
2197
2835
|
const store = getStore();
|
|
2198
2836
|
|
|
2837
|
+
if (selection.action === 'reflow') {
|
|
2838
|
+
this.performReflow();
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2199
2842
|
if (selection.action === 'sticky') {
|
|
2200
2843
|
// Create new sticky note
|
|
2201
2844
|
store.getState().createStickyNote({
|
|
@@ -2604,8 +3247,9 @@ export class Editor extends RapidElement {
|
|
|
2604
3247
|
}
|
|
2605
3248
|
|
|
2606
3249
|
// Check for collisions and reflow in case node size changed
|
|
3250
|
+
const nodeUuid = updatedNode.uuid;
|
|
2607
3251
|
requestAnimationFrame(() => {
|
|
2608
|
-
this.checkCollisionsAndReflow([
|
|
3252
|
+
this.checkCollisionsAndReflow([nodeUuid]);
|
|
2609
3253
|
});
|
|
2610
3254
|
}
|
|
2611
3255
|
}
|
|
@@ -2648,11 +3292,9 @@ export class Editor extends RapidElement {
|
|
|
2648
3292
|
|
|
2649
3293
|
const canvasRect = canvas.getBoundingClientRect();
|
|
2650
3294
|
|
|
2651
|
-
//
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
const left = mouseX - canvasRect.left - DROP_PREVIEW_OFFSET_X;
|
|
2655
|
-
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;
|
|
2656
3298
|
|
|
2657
3299
|
// Apply grid snapping only if requested (for final drop position)
|
|
2658
3300
|
if (applyGridSnapping) {
|
|
@@ -3973,14 +4615,15 @@ export class Editor extends RapidElement {
|
|
|
3973
4615
|
const editorCenterX = editorRect.width / 2;
|
|
3974
4616
|
const editorCenterY = editorRect.height / 2;
|
|
3975
4617
|
|
|
3976
|
-
//
|
|
3977
|
-
const
|
|
3978
|
-
const
|
|
3979
|
-
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;
|
|
3980
4621
|
|
|
3981
|
-
// Calculate the scroll position needed to center the node
|
|
3982
|
-
|
|
3983
|
-
|
|
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;
|
|
3984
4627
|
|
|
3985
4628
|
// Smooth scroll the editor container to the target position
|
|
3986
4629
|
editor.scrollTo({
|
|
@@ -4030,8 +4673,9 @@ export class Editor extends RapidElement {
|
|
|
4030
4673
|
<div
|
|
4031
4674
|
id="grid"
|
|
4032
4675
|
class="${this.viewingRevision ? 'viewing-revision' : ''}"
|
|
4033
|
-
style="min-width
|
|
4034
|
-
|
|
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})"
|
|
4035
4679
|
>
|
|
4036
4680
|
<div
|
|
4037
4681
|
id="canvas"
|
|
@@ -4114,6 +4758,55 @@ export class Editor extends RapidElement {
|
|
|
4114
4758
|
<div class="save-indicator ${this.isSaving ? 'visible' : ''}">
|
|
4115
4759
|
<temba-loading units="3" size="8"></temba-loading>
|
|
4116
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
|
+
: ''}
|
|
4117
4810
|
</div>
|
|
4118
4811
|
|
|
4119
4812
|
${this.editingNode || this.editingAction
|