@node-red/editor-client 4.1.1 → 5.0.0-beta.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/public/red/red.js CHANGED
@@ -878,7 +878,7 @@ var RED = (function() {
878
878
  RED.user.init();
879
879
  RED.notifications.init();
880
880
  RED.library.init();
881
- RED.palette.init();
881
+ RED.sidebar.init();
882
882
  RED.eventLog.init();
883
883
 
884
884
  if (RED.settings.get('externalModules.palette.allowInstall', true) !== false) {
@@ -887,7 +887,6 @@ var RED = (function() {
887
887
  console.log("Palette editor disabled");
888
888
  }
889
889
 
890
- RED.sidebar.init();
891
890
 
892
891
  if (RED.settings.theme("projects.enabled",false)) {
893
892
  RED.projects.init();
@@ -904,23 +903,34 @@ var RED = (function() {
904
903
  RED.diagnostics.init();
905
904
  RED.diff.init();
906
905
 
907
-
908
906
  RED.deploy.init(RED.settings.theme("deployButton",null));
907
+ RED.keyboard.init(() => {
908
+ buildMainMenu();
909
909
 
910
- RED.keyboard.init(buildMainMenu);
911
- RED.envVar.init();
910
+ // Register the core set of sidebar panels now the menu is ready to receive items
911
+ RED.palette.init();
912
+ RED.sidebar.info.init();
913
+ RED.sidebar.help.init();
914
+ RED.sidebar.config.init();
915
+ RED.sidebar.context.init();
916
+ // hide sidebar at start if screen rather narrow...
917
+ if ($("#red-ui-editor").width() < 600) { RED.menu.setSelected("menu-item-sidebar", false); }
912
918
 
913
- RED.nodes.init();
914
- RED.runtime.init()
919
+ RED.envVar.init();
915
920
 
916
- if (RED.settings.theme("multiplayer.enabled",false)) {
917
- RED.multiplayer.init()
918
- }
919
- RED.comms.connect();
921
+ RED.nodes.init();
922
+ RED.runtime.init()
920
923
 
921
- $("#red-ui-main-container").show();
924
+ if (RED.settings.theme("multiplayer.enabled",false)) {
925
+ RED.multiplayer.init()
926
+ }
927
+ RED.comms.connect();
922
928
 
923
- loadPluginList();
929
+ $("#red-ui-main-container").show();
930
+ RED.events.emit("sidebar:resize")
931
+
932
+ loadPluginList();
933
+ });
924
934
  }
925
935
 
926
936
 
@@ -929,13 +939,18 @@ var RED = (function() {
929
939
  var logo = $('<span class="red-ui-header-logo"></span>').appendTo(header);
930
940
  $('<ul class="red-ui-header-toolbar hide"></ul>').appendTo(header);
931
941
  $('<div id="red-ui-header-shade" class="hide"></div>').appendTo(header);
932
- $('<div id="red-ui-main-container" class="red-ui-sidebar-closed hide">'+
942
+ $('<div id="red-ui-main-container">'+
943
+ '<div id="red-ui-sidebar-left"></div>'+
933
944
  '<div id="red-ui-workspace"></div>'+
934
- '<div id="red-ui-editor-stack" tabindex="-1"></div>'+
935
- '<div id="red-ui-palette"></div>'+
936
945
  '<div id="red-ui-sidebar"></div>'+
937
- '<div id="red-ui-sidebar-separator"></div>'+
946
+ '<div id="red-ui-editor-stack" tabindex="-1"></div>'+
947
+ // '<div id="red-ui-palette"></div>'+
938
948
  '</div>').appendTo(options.target);
949
+
950
+ // Don't use the `hide` class on this container, as the show reverts it to block rather
951
+ // than the expected flex. So hide via jQuery as it'll track the show state internally.
952
+ options.target.find('#red-ui-main-container').hide()
953
+
939
954
  $('<div id="red-ui-editor-plugin-configs"></div>').appendTo(options.target);
940
955
  $('<div id="red-ui-editor-node-configs"></div>').appendTo(options.target);
941
956
  $('<div id="red-ui-full-shade" class="hide"></div>').appendTo(options.target);
@@ -2139,8 +2154,8 @@ RED.comms = (function() {
2139
2154
  subscribers[i](msg.topic,msg.data);
2140
2155
  } catch (error) {
2141
2156
  // need to decide what to do with this uncaught error
2142
- console.warn('Uncaught error from RED.comms.subscribe: ' + err.toString())
2143
- console.warn(err)
2157
+ console.warn('Uncaught error from RED.comms.subscribe: ' + error.toString())
2158
+ console.warn(error)
2144
2159
  }
2145
2160
  }
2146
2161
  }
@@ -9321,7 +9336,15 @@ RED.history = (function() {
9321
9336
  }
9322
9337
  }
9323
9338
  }
9324
-
9339
+ if (ev.node.type === 'subflow') {
9340
+ // Ensure ports get a refresh in case of a label change
9341
+ if (ev.changes.inputLabels) {
9342
+ ev.node.in.forEach(function(input) { input.dirty = true; });
9343
+ }
9344
+ if (ev.changes.outputLabels) {
9345
+ ev.node.out.forEach(function(output) { output.dirty = true; });
9346
+ }
9347
+ }
9325
9348
  ev.node.dirty = true;
9326
9349
  ev.node.changed = ev.changed;
9327
9350
 
@@ -9632,8 +9655,8 @@ RED.history = (function() {
9632
9655
  return {
9633
9656
  //TODO: this function is a placeholder until there is a 'save' event that can be listened to
9634
9657
  markAllDirty: function() {
9635
- for (var i=0;i<undoHistory.length;i++) {
9636
- undoHistory[i].dirty = true;
9658
+ for (const event of [...undoHistory, ...redoHistory]) {
9659
+ event.dirty = true;
9637
9660
  }
9638
9661
  },
9639
9662
  list: function() {
@@ -13195,7 +13218,7 @@ RED.menu = (function() {
13195
13218
  } else {
13196
13219
  for (var i=0;i<groupItems.length;i++) {
13197
13220
  var groupItem = groupItems[i];
13198
- var label = $(groupItem).find(".red-ui-menu-label").html();
13221
+ var label = $(groupItem).find(".red-ui-menu-label span").text();
13199
13222
  if (opt.label < label) {
13200
13223
  $(groupItem).before(item);
13201
13224
  break;
@@ -14861,7 +14884,6 @@ RED.tabs = (function() {
14861
14884
  ul.find("li.red-ui-tab.active .red-ui-tab-label").css({paddingLeft:""})
14862
14885
  }
14863
14886
  }
14864
-
14865
14887
  }
14866
14888
 
14867
14889
  ul.find("li.red-ui-tab a")
@@ -15361,7 +15383,8 @@ RED.tabs = (function() {
15361
15383
  pinnedButtons["__menu__"].appendTo(collapsedButtonsRow);
15362
15384
  updateTabWidths();
15363
15385
  }
15364
- }
15386
+ },
15387
+ container: wrapper
15365
15388
  }
15366
15389
  return tabAPI;
15367
15390
  }
@@ -17791,7 +17814,7 @@ RED.deploy = (function() {
17791
17814
  }
17792
17815
 
17793
17816
  function updateLockedState() {
17794
- if (RED.settings.user?.permissions === 'read') {
17817
+ if (!RED.user.hasPermission('flows.write')) {
17795
17818
  $(".red-ui-deploy-button-group").addClass("readOnly");
17796
17819
  $("#red-ui-header-button-deploy").addClass("disabled");
17797
17820
  } else {
@@ -17924,13 +17947,13 @@ RED.deploy = (function() {
17924
17947
  $("#red-ui-header-shade").show();
17925
17948
  $("#red-ui-editor-shade").show();
17926
17949
  $("#red-ui-palette-shade").show();
17927
- $("#red-ui-sidebar-shade").show();
17950
+ $(".red-ui-sidebar-shade").show();
17928
17951
  }
17929
17952
  function shadeHide() {
17930
17953
  $("#red-ui-header-shade").hide();
17931
17954
  $("#red-ui-editor-shade").hide();
17932
17955
  $("#red-ui-palette-shade").hide();
17933
- $("#red-ui-sidebar-shade").hide();
17956
+ $(".red-ui-sidebar-shade").hide();
17934
17957
  }
17935
17958
  function deployButtonSetBusy(){
17936
17959
  $(".red-ui-deploy-button-content").css('opacity',0);
@@ -19744,11 +19767,11 @@ RED.diagnostics = (function () {
19744
19767
  diffTable.finish();
19745
19768
  diffTable.list.show();
19746
19769
  },300);
19747
- $("#red-ui-sidebar-shade").show();
19770
+ $(".red-ui-sidebar-shade").show();
19748
19771
  },
19749
19772
  close: function() {
19750
19773
  diffVisible = false;
19751
- $("#red-ui-sidebar-shade").hide();
19774
+ $(".red-ui-sidebar-shade").hide();
19752
19775
 
19753
19776
  },
19754
19777
  show: function() {
@@ -22675,11 +22698,29 @@ RED.view = (function() {
22675
22698
  node_height = 30,
22676
22699
  dblClickInterval = 650;
22677
22700
 
22701
+ var cancelInProgressAnimation = null; // For smooth zoom animation
22702
+
22678
22703
  var touchLongPressTimeout = 1000,
22679
22704
  startTouchDistance = 0,
22680
22705
  startTouchCenter = [],
22681
22706
  moveTouchCenter = [],
22682
- touchStartTime = 0;
22707
+ touchStartTime = 0,
22708
+ gesture = {};
22709
+
22710
+ var spacebarPressed = false;
22711
+
22712
+ // Momentum scrolling state
22713
+ var scrollVelocity = { x: 0, y: 0 };
22714
+ var lastScrollTime = 0;
22715
+ var lastScrollPos = { x: 0, y: 0 };
22716
+ var scrollAnimationId = null;
22717
+ var momentumActive = false;
22718
+
22719
+ // Bounce effect parameters
22720
+ var BOUNCE_DAMPING = 0.6;
22721
+ var BOUNCE_TENSION = 0.3;
22722
+ var MIN_VELOCITY = 0.5;
22723
+ var FRICTION = 0.95;
22683
22724
 
22684
22725
  var workspaceScrollPositions = {};
22685
22726
 
@@ -22742,6 +22783,7 @@ RED.view = (function() {
22742
22783
  let suggestedLinks = [];
22743
22784
  let suggestedJunctions = [];
22744
22785
 
22786
+ let forceFullRedraw = false
22745
22787
  // Note: these are the permitted status colour aliases. The actual RGB values
22746
22788
  // are set in the CSS - flow.scss/colors.scss
22747
22789
  const status_colours = {
@@ -22955,6 +22997,24 @@ RED.view = (function() {
22955
22997
  function init() {
22956
22998
 
22957
22999
  chart = $("#red-ui-workspace-chart");
23000
+
23001
+ // Add invisible spacer div to ensure scrollable area matches canvas dimensions
23002
+ // At minimum zoom with "cover" behavior, SVG may be smaller than viewport in one dimension
23003
+ // This spacer forces the browser to calculate scrollWidth/Height based on full canvas size
23004
+ // Browser's maxScroll = scrollWidth - viewport will then correctly show canvas edges
23005
+ var scrollSpacer = $('<div>')
23006
+ .css({
23007
+ position: 'absolute',
23008
+ top: 0,
23009
+ left: 0,
23010
+ width: space_width + 'px',
23011
+ height: space_height + 'px',
23012
+ pointerEvents: 'none',
23013
+ visibility: 'hidden'
23014
+ })
23015
+ .attr('id', 'red-ui-workspace-scroll-spacer')
23016
+ .appendTo(chart);
23017
+
22958
23018
  chart.on('contextmenu', function(evt) {
22959
23019
  if (RED.view.DEBUG) {
22960
23020
  console.warn("contextmenu", { mouse_mode, event: d3.event });
@@ -22999,8 +23059,9 @@ RED.view = (function() {
22999
23059
  lasso.remove();
23000
23060
  lasso = null;
23001
23061
  }
23002
- } else if (mouse_mode === RED.state.PANNING && d3.event.buttons !== 4) {
23003
- resetMouseVars();
23062
+ } else if (mouse_mode === RED.state.PANNING) {
23063
+ // ensure the cursor is set to grab when re-entering the canvas while panning
23064
+ outer.style('cursor', 'grabbing');
23004
23065
  } else if (slicePath) {
23005
23066
  if (d3.event.buttons !== 2) {
23006
23067
  slicePath.remove();
@@ -23017,11 +23078,15 @@ RED.view = (function() {
23017
23078
  if (RED.touch.radialMenu.active()) {
23018
23079
  return;
23019
23080
  }
23081
+ // End gesture when touches end
23082
+ RED.view.zoomAnimator.endGesture();
23020
23083
  canvasMouseUp.call(this);
23021
23084
  })
23022
23085
  .on("touchcancel", function() {
23023
23086
  if (RED.view.DEBUG) { console.warn("eventLayer.touchcancel", mouse_mode); }
23024
23087
  d3.event.preventDefault();
23088
+ // End gesture when touches are cancelled
23089
+ RED.view.zoomAnimator.endGesture();
23025
23090
  canvasMouseUp.call(this);
23026
23091
  })
23027
23092
  .on("touchstart", function() {
@@ -23047,6 +23112,20 @@ RED.view = (function() {
23047
23112
  touch1["pageY"]+(a/2)
23048
23113
  ]
23049
23114
  startTouchDistance = Math.sqrt((a*a)+(b*b));
23115
+
23116
+ // Store initial scale for ratio-based zoom calculation
23117
+ gesture = {
23118
+ initialScale: scaleFactor,
23119
+ initialDistance: startTouchDistance,
23120
+ mode: null // Will be determined on first significant move
23121
+ };
23122
+
23123
+ // Start gesture with fixed focal point (store in workspace coordinates)
23124
+ var focalPoint = [
23125
+ (touch0["pageX"] + touch1["pageX"]) / 2 - offset.left,
23126
+ (touch0["pageY"] + touch1["pageY"]) / 2 - offset.top
23127
+ ];
23128
+ RED.view.zoomAnimator.startGesture(focalPoint, scaleFactor, scrollPos, scaleFactor);
23050
23129
  } else {
23051
23130
  var obj = d3.select(document.body);
23052
23131
  touch0 = d3.event.touches.item(0);
@@ -23096,33 +23175,93 @@ RED.view = (function() {
23096
23175
  var offset = chart.offset();
23097
23176
  var scrollPos = [chart.scrollLeft(),chart.scrollTop()];
23098
23177
  var moveTouchDistance = Math.sqrt((a*a)+(b*b));
23099
- var touchCenter = [
23100
- touch1["pageX"]+(b/2),
23101
- touch1["pageY"]+(a/2)
23178
+
23179
+ // Calculate center point of two fingers
23180
+ var currentTouchCenter = [
23181
+ (touch0["pageX"] + touch1["pageX"]) / 2,
23182
+ (touch0["pageY"] + touch1["pageY"]) / 2
23102
23183
  ];
23103
23184
 
23104
23185
  if (!isNaN(moveTouchDistance)) {
23105
- oldScaleFactor = scaleFactor;
23106
- scaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000)));
23107
-
23108
- var deltaTouchCenter = [ // Try to pan whilst zooming - not 100%
23109
- startTouchCenter[0]*(scaleFactor-oldScaleFactor),//-(touchCenter[0]-moveTouchCenter[0]),
23110
- startTouchCenter[1]*(scaleFactor-oldScaleFactor) //-(touchCenter[1]-moveTouchCenter[1])
23111
- ];
23112
-
23113
- startTouchDistance = moveTouchDistance;
23114
- moveTouchCenter = touchCenter;
23186
+ // Determine gesture mode on first significant movement
23187
+ if (!gesture.mode) {
23188
+ var distanceChange = Math.abs(moveTouchDistance - startTouchDistance);
23189
+ var centerChange = moveTouchCenter ?
23190
+ Math.sqrt(Math.pow(currentTouchCenter[0] - moveTouchCenter[0], 2) +
23191
+ Math.pow(currentTouchCenter[1] - moveTouchCenter[1], 2)) : 0;
23192
+
23193
+ // Lock into zoom mode if distance changes significantly (>10px)
23194
+ // Lock into pan mode if center moves significantly (>5px) without distance change
23195
+ if (distanceChange > 10) {
23196
+ gesture.mode = 'zoom';
23197
+ } else if (centerChange > 5) {
23198
+ gesture.mode = 'pan';
23199
+ }
23200
+ }
23201
+
23202
+ // Once mode is determined, stay in that mode for the entire gesture
23203
+ if (gesture.mode === 'zoom') {
23204
+ oldScaleFactor = scaleFactor;
23205
+ // Use smooth ratio-based scaling for natural pinch-to-zoom
23206
+ var zoomRatio = moveTouchDistance / startTouchDistance;
23207
+ var minZoom = calculateMinZoom();
23208
+ var newScaleFactor = Math.min(RED.view.zoomConstants.MAX_ZOOM,
23209
+ Math.max(minZoom, gesture.initialScale * zoomRatio));
23210
+
23211
+ // Use gesture state management to maintain fixed focal point
23212
+ var gestureState = RED.view.zoomAnimator.updateGesture(newScaleFactor);
23213
+
23214
+ // Only call zoomView if scale is actually changing (not at limits)
23215
+ if (Math.abs(scaleFactor - newScaleFactor) >= 0.001) {
23216
+ // Get focal point converted back to current screen coordinates
23217
+ var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()];
23218
+ var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor);
23219
+
23220
+ if (focalPoint) {
23221
+ // Use the fixed focal point from gesture start (converted from workspace coords)
23222
+ zoomView(newScaleFactor, focalPoint);
23223
+ } else {
23224
+ // Fallback to current behavior if gesture not active
23225
+ var touchCenter = [
23226
+ touch1["pageX"]+(b/2),
23227
+ touch1["pageY"]+(a/2)
23228
+ ];
23229
+ var pinchCenter = [
23230
+ touchCenter[0] - offset.left,
23231
+ touchCenter[1] - offset.top
23232
+ ];
23233
+ zoomView(newScaleFactor, pinchCenter);
23234
+ }
23235
+ }
23236
+ } else if (gesture.mode === 'pan' || !gesture.mode) {
23237
+ // Two-finger pan: allow immediate panning even if mode not determined
23238
+ // Clear touchStartTime to prevent issues with next gesture
23239
+ if (touchStartTime) {
23240
+ clearTimeout(touchStartTime);
23241
+ touchStartTime = null;
23242
+ }
23243
+ if (moveTouchCenter) {
23244
+ var dx = currentTouchCenter[0] - moveTouchCenter[0];
23245
+ var dy = currentTouchCenter[1] - moveTouchCenter[1];
23246
+
23247
+ // Pan the canvas
23248
+ var currentScroll = [chart.scrollLeft(), chart.scrollTop()];
23249
+ chart.scrollLeft(currentScroll[0] - dx);
23250
+ chart.scrollTop(currentScroll[1] - dy);
23251
+ RED.events.emit("view:navigate");
23252
+ }
23253
+ // Update the center for next move
23254
+ moveTouchCenter = currentTouchCenter;
23255
+ }
23115
23256
 
23116
- chart.scrollLeft(scrollPos[0]+deltaTouchCenter[0]);
23117
- chart.scrollTop(scrollPos[1]+deltaTouchCenter[1]);
23118
- redraw();
23257
+ // Don't update startTouchDistance - keep initial distance for ratio calculation
23119
23258
  }
23120
23259
  }
23121
23260
  d3.event.preventDefault();
23122
23261
  });
23123
-
23124
-
23125
- const handleAltToggle = (event) => {
23262
+
23263
+ const handleChartKeyboardEvents = (event) => {
23264
+ // Handle Alt toggle for pulling nodes out of groups
23126
23265
  if (mouse_mode === RED.state.MOVING_ACTIVE && event.key === 'Alt' && groupAddParentGroup) {
23127
23266
  RED.nodes.group(groupAddParentGroup).dirty = true
23128
23267
  for (let n = 0; n<movingSet.length(); n++) {
@@ -23141,10 +23280,67 @@ RED.view = (function() {
23141
23280
  }
23142
23281
  }
23143
23282
  RED.view.redraw()
23283
+ } else if (event.keyCode === 32 || event.key === ' ') {
23284
+ if (mouse_mode === RED.state.PANNING) {
23285
+ // Already in panning mode - just prevent the event default handler
23286
+ event.preventDefault()
23287
+ event.stopPropagation()
23288
+ if (event.type === 'keyup' && spacebarPressed) {
23289
+ spacebarPressed = false
23290
+ }
23291
+ } else if (mouse_mode === RED.state.DEFAULT) {
23292
+ // Handle spacebar for panning
23293
+ event.preventDefault();
23294
+ event.stopPropagation();
23295
+ if (event.type === "keydown" && !spacebarPressed) {
23296
+ spacebarPressed = true;
23297
+ // Change cursor to grab hand when spacebar is pressed
23298
+ outer.style('cursor', 'grab');
23299
+ } else if (event.type === "keyup" && spacebarPressed) {
23300
+ spacebarPressed = false;
23301
+ // Revert cursor when spacebar is released
23302
+ outer.style('cursor', '');
23303
+ }
23304
+ }
23144
23305
  }
23145
23306
  }
23146
- document.addEventListener("keyup", handleAltToggle)
23147
- document.addEventListener("keydown", handleAltToggle)
23307
+ chart.on("keydown", handleChartKeyboardEvents)
23308
+ chart.on("keyup", handleChartKeyboardEvents)
23309
+
23310
+ // // // Window-level keyup listener to catch spacebar release when cursor is outside canvas
23311
+ // // function handleWindowSpacebarUp(e) {
23312
+ // // if ((e.keyCode === 32 || e.key === ' ') && spacebarPressed) {
23313
+ // // spacebarPressed = false;
23314
+ // // // Revert cursor when spacebar is released outside canvas
23315
+ // // outer.style('cursor', '');
23316
+ // // e.preventDefault();
23317
+ // // e.stopPropagation();
23318
+ // // }
23319
+ // // }
23320
+ // // chart.on("keyup", handleSpacebarToggle)
23321
+ // // chart.on("keydown", handleSpacebarToggle)
23322
+ // // Additional window-level keyup listener to ensure spacebar state is cleared
23323
+ // // when cursor leaves canvas area while spacebar is held
23324
+ // window.addEventListener("keyup", handleWindowSpacebarUp)
23325
+
23326
+ // // Reset spacebar state when window loses focus to prevent stuck state
23327
+ // window.addEventListener("blur", function() {
23328
+ // if (spacebarPressed) {
23329
+ // spacebarPressed = false;
23330
+ // // Revert cursor when window loses focus
23331
+ // outer.style('cursor', '');
23332
+ // }
23333
+ // })
23334
+
23335
+ // Recalculate minimum zoom when window resizes
23336
+ $(window).on("resize.red-ui-view", function() {
23337
+ // Recalculate minimum zoom to ensure canvas fits in viewport
23338
+ var newMinZoom = calculateMinZoom();
23339
+ // If current zoom is below new minimum, adjust it
23340
+ if (scaleFactor < newMinZoom) {
23341
+ zoomView(newMinZoom);
23342
+ }
23343
+ })
23148
23344
 
23149
23345
  // Workspace Background
23150
23346
  eventLayer.append("svg:rect")
@@ -23236,22 +23432,194 @@ RED.view = (function() {
23236
23432
  '<button class="red-ui-footer-button" id="red-ui-view-zoom-out"><i class="fa fa-minus"></i></button>'+
23237
23433
  '<button class="red-ui-footer-button" id="red-ui-view-zoom-zero"><i class="fa fa-circle-o"></i></button>'+
23238
23434
  '<button class="red-ui-footer-button" id="red-ui-view-zoom-in"><i class="fa fa-plus"></i></button>'+
23435
+ '<button class="red-ui-footer-button" id="red-ui-view-zoom-fit"><i class="fa fa-compress"></i></button>'+
23239
23436
  '</span>')
23240
23437
  })
23241
23438
 
23242
- $("#red-ui-view-zoom-out").on("click", zoomOut);
23439
+ $("#red-ui-view-zoom-out").on("click", function() { zoomOut(); });
23243
23440
  RED.popover.tooltip($("#red-ui-view-zoom-out"),RED._('actions.zoom-out'),'core:zoom-out');
23244
23441
  $("#red-ui-view-zoom-zero").on("click", zoomZero);
23245
23442
  RED.popover.tooltip($("#red-ui-view-zoom-zero"),RED._('actions.zoom-reset'),'core:zoom-reset');
23246
- $("#red-ui-view-zoom-in").on("click", zoomIn);
23443
+ $("#red-ui-view-zoom-in").on("click", function() { zoomIn(); });
23247
23444
  RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in');
23248
- chart.on("DOMMouseScroll mousewheel", function (evt) {
23249
- if ( evt.altKey ) {
23445
+ $("#red-ui-view-zoom-fit").on("click", zoomToFitAll);
23446
+ RED.popover.tooltip($("#red-ui-view-zoom-fit"),RED._('actions.zoom-fit'),'core:zoom-fit');
23447
+ // Legacy mouse wheel handler - disabled in favor of modern wheel event
23448
+ // chart.on("DOMMouseScroll mousewheel", function (evt) {
23449
+ // if ( evt.altKey || spacebarPressed ) {
23450
+ // evt.preventDefault();
23451
+ // evt.stopPropagation();
23452
+ // // Get cursor position relative to the chart
23453
+ // var offset = chart.offset();
23454
+ // var cursorPos = [
23455
+ // evt.originalEvent.pageX - offset.left,
23456
+ // evt.originalEvent.pageY - offset.top
23457
+ // ];
23458
+ // var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta;
23459
+ // if (move <= 0) { zoomOut(cursorPos); }
23460
+ // else { zoomIn(cursorPos); }
23461
+ // }
23462
+ // });
23463
+
23464
+ // Modern wheel event handler for better trackpad support (pinch-to-zoom) and momentum
23465
+ var momentumTimer = null;
23466
+ var trackpadGestureTimer = null;
23467
+ var lastWheelEventTime = 0;
23468
+ var wheelEventContinuityThreshold = 100; // Events within 100ms are same gesture
23469
+ var gestureEndThreshold = 500; // 500ms+ gap means gesture ended
23470
+
23471
+ // Prevent browser zoom on non-canvas areas
23472
+ document.addEventListener("wheel", function(e) {
23473
+ if (e.ctrlKey && !e.target.closest('#red-ui-workspace-chart')) {
23474
+ e.preventDefault();
23475
+ }
23476
+ }, { passive: false });
23477
+
23478
+ chart.on("wheel", function(evt) {
23479
+ if (mouse_mode === RED.state.PANNING) {
23480
+ // Ignore wheel events while panning
23481
+ return;
23482
+ }
23483
+ // ctrlKey is set during pinch gestures on trackpads
23484
+ if (evt.ctrlKey || evt.altKey || spacebarPressed) {
23485
+ evt.preventDefault();
23486
+ evt.stopPropagation();
23487
+
23488
+ var currentTime = Date.now();
23489
+ var timeSinceLastEvent = currentTime - lastWheelEventTime;
23490
+
23491
+ // Get cursor position relative to the chart
23492
+ var offset = chart.offset();
23493
+ var cursorPos = [
23494
+ evt.originalEvent.pageX - offset.left,
23495
+ evt.originalEvent.pageY - offset.top
23496
+ ];
23497
+ var delta = evt.originalEvent.deltaY;
23498
+
23499
+ // For trackpad pinch (Ctrl+wheel), use smooth proportional zoom
23500
+ if (evt.ctrlKey && !evt.altKey && !spacebarPressed) {
23501
+ // Detect input device: trackpad has small deltas, mouse wheel has large deltas
23502
+ var isTrackpadInput = Math.abs(delta) < 50;
23503
+ // Invert delta: spreading fingers (negative deltaY) should zoom in
23504
+ var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, isTrackpadInput);
23505
+ var minZoom = calculateMinZoom();
23506
+ var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM,
23507
+ Math.max(minZoom, scaleFactor + scaleDelta));
23508
+
23509
+ // Session-based gesture tracking:
23510
+ // - If no active gesture OR gap > gestureEndThreshold, start new gesture
23511
+ // - If gap < wheelEventContinuityThreshold, continue current gesture
23512
+ // - If gap between continuity and end threshold, keep current gesture but don't update focal point
23513
+
23514
+ if (!RED.view.zoomAnimator.isGestureActive() || timeSinceLastEvent > gestureEndThreshold) {
23515
+ // Start new gesture session - store focal point in workspace coordinates
23516
+ var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
23517
+ RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor, scrollPos, scaleFactor);
23518
+ } else if (timeSinceLastEvent <= wheelEventContinuityThreshold) {
23519
+ // Events are continuous - this is the same gesture, focal point remains locked
23520
+ // No need to update focal point
23521
+ }
23522
+ // For gaps between continuity and end threshold, keep existing gesture state
23523
+
23524
+ // Update gesture with new scale, maintaining locked focal point
23525
+ RED.view.zoomAnimator.updateGesture(newScale);
23526
+ // Only call zoomView if scale is actually changing (not at limits)
23527
+ if (Math.abs(scaleFactor - newScale) >= 0.001) {
23528
+ // Get focal point converted back to current screen coordinates
23529
+ var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()];
23530
+ var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor);
23531
+ zoomView(newScale, focalPoint); // Direct call, no animation
23532
+ }
23533
+
23534
+ // Update last event time for continuity tracking
23535
+ lastWheelEventTime = currentTime;
23536
+
23537
+ // Reset gesture timeout - end gesture when no more events come in for gestureEndThreshold
23538
+ if (trackpadGestureTimer) {
23539
+ clearTimeout(trackpadGestureTimer);
23540
+ }
23541
+ trackpadGestureTimer = setTimeout(function() {
23542
+ RED.view.zoomAnimator.endGesture();
23543
+ trackpadGestureTimer = null;
23544
+ // Store zoom level when gesture completes
23545
+ if (RED.settings.get("editor.view.view-store-zoom")) {
23546
+ RED.settings.setLocal('zoom-level', scaleFactor.toFixed(1));
23547
+ }
23548
+ }, gestureEndThreshold); // Use 500ms timeout for gesture end detection
23549
+ } else {
23550
+ // Regular Alt+scroll or Space+scroll - use smooth zoom without animation
23551
+ // Detect input device: trackpad has small deltas, mouse wheel has large deltas
23552
+ var isTrackpadInput = Math.abs(delta) < 50;
23553
+ var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, isTrackpadInput);
23554
+ var minZoom = calculateMinZoom();
23555
+ var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM,
23556
+ Math.max(minZoom, scaleFactor + scaleDelta));
23557
+
23558
+ // Use gesture tracking for stable focal point like trackpad pinch
23559
+ if (!RED.view.zoomAnimator.isGestureActive() || timeSinceLastEvent > gestureEndThreshold) {
23560
+ // Start new gesture session - store focal point in workspace coordinates
23561
+ var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
23562
+ RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor, scrollPos, scaleFactor);
23563
+ } else if (timeSinceLastEvent <= wheelEventContinuityThreshold) {
23564
+ // Events are continuous - same gesture, focal point remains locked
23565
+ }
23566
+
23567
+ // Update gesture with new scale, maintaining locked focal point
23568
+ RED.view.zoomAnimator.updateGesture(newScale);
23569
+
23570
+ // Only zoom if scale is actually changing
23571
+ if (Math.abs(scaleFactor - newScale) >= 0.001) {
23572
+ // Get focal point converted back to current screen coordinates
23573
+ var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()];
23574
+ var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor);
23575
+ zoomView(newScale, focalPoint);
23576
+ }
23577
+
23578
+ // Update last event time for continuity tracking
23579
+ lastWheelEventTime = currentTime;
23580
+
23581
+ // Reset gesture timeout
23582
+ if (trackpadGestureTimer) {
23583
+ clearTimeout(trackpadGestureTimer);
23584
+ }
23585
+ trackpadGestureTimer = setTimeout(function() {
23586
+ RED.view.zoomAnimator.endGesture();
23587
+ trackpadGestureTimer = null;
23588
+ // Store zoom level when gesture completes
23589
+ if (RED.settings.get("editor.view.view-store-zoom")) {
23590
+ RED.settings.setLocal('zoom-level', scaleFactor.toFixed(1));
23591
+ }
23592
+ }, gestureEndThreshold);
23593
+ }
23594
+ } else {
23595
+ // Regular scroll - prevent default and manually handle both axes
23250
23596
  evt.preventDefault();
23251
23597
  evt.stopPropagation();
23252
- var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta;
23253
- if (move <= 0) { zoomOut(); }
23254
- else { zoomIn(); }
23598
+
23599
+ // Apply scroll deltas directly to both axes
23600
+ var deltaX = evt.originalEvent.deltaX;
23601
+ var deltaY = evt.originalEvent.deltaY;
23602
+
23603
+ chart.scrollLeft(chart.scrollLeft() + deltaX);
23604
+ chart.scrollTop(chart.scrollTop() + deltaY);
23605
+
23606
+ // Emit navigate event for minimap
23607
+ RED.events.emit("view:navigate");
23608
+
23609
+ // Track velocity and apply momentum
23610
+ handleScroll();
23611
+
23612
+ // Cancel previous momentum timer
23613
+ if (momentumTimer) {
23614
+ clearTimeout(momentumTimer);
23615
+ }
23616
+
23617
+ // Start momentum after scroll stops
23618
+ momentumTimer = setTimeout(function() {
23619
+ if (Math.abs(scrollVelocity.x) > MIN_VELOCITY || Math.abs(scrollVelocity.y) > MIN_VELOCITY) {
23620
+ startMomentumScroll();
23621
+ }
23622
+ }, 100);
23255
23623
  }
23256
23624
  });
23257
23625
 
@@ -23422,6 +23790,12 @@ RED.view = (function() {
23422
23790
  });
23423
23791
  chart.on("blur", function() {
23424
23792
  $("#red-ui-workspace-tabs").removeClass("red-ui-workspace-focussed");
23793
+ // Reset spacebar state when chart loses focus to prevent stuck state
23794
+ if (spacebarPressed) {
23795
+ spacebarPressed = false;
23796
+ // Revert cursor when chart loses focus
23797
+ outer.style('cursor', '');
23798
+ }
23425
23799
  });
23426
23800
 
23427
23801
  RED.actions.add("core:copy-selection-to-internal-clipboard",copySelection);
@@ -23486,6 +23860,7 @@ RED.view = (function() {
23486
23860
  RED.actions.add("core:zoom-in",zoomIn);
23487
23861
  RED.actions.add("core:zoom-out",zoomOut);
23488
23862
  RED.actions.add("core:zoom-reset",zoomZero);
23863
+ RED.actions.add("core:zoom-fit",zoomToFitAll);
23489
23864
  RED.actions.add("core:enable-selected-nodes", function() { setSelectedNodeState(false)});
23490
23865
  RED.actions.add("core:disable-selected-nodes", function() { setSelectedNodeState(true)});
23491
23866
 
@@ -23601,6 +23976,9 @@ RED.view = (function() {
23601
23976
  RED.settings.setLocal('scroll-positions', JSON.stringify(workspaceScrollPositions) )
23602
23977
  }
23603
23978
  chart.on("scroll", function() {
23979
+ // Track scroll velocity for momentum
23980
+ handleScroll();
23981
+
23604
23982
  if (RED.settings.get("editor.view.view-store-position")) {
23605
23983
  if (onScrollTimer) {
23606
23984
  clearTimeout(onScrollTimer)
@@ -23878,12 +24256,26 @@ RED.view = (function() {
23878
24256
  return;
23879
24257
  }
23880
24258
 
24259
+ // Spacebar + left click for panning
24260
+ if (spacebarPressed && d3.event.button === 0) {
24261
+ d3.event.preventDefault();
24262
+ d3.event.stopPropagation();
24263
+ mouse_mode = RED.state.PANNING;
24264
+ mouse_position = [d3.event.pageX,d3.event.pageY]
24265
+ scroll_position = [chart.scrollLeft(),chart.scrollTop()];
24266
+ // Change cursor to grabbing while actively panning
24267
+ outer.style('cursor', 'grabbing');
24268
+ return;
24269
+ }
24270
+
23881
24271
  if (d3.event.button === 1) {
23882
24272
  // Middle Click pan
23883
24273
  d3.event.preventDefault();
23884
24274
  mouse_mode = RED.state.PANNING;
23885
24275
  mouse_position = [d3.event.pageX,d3.event.pageY]
23886
24276
  scroll_position = [chart.scrollLeft(),chart.scrollTop()];
24277
+ // Change cursor to grabbing while actively panning
24278
+ outer.style('cursor', 'grabbing');
23887
24279
  return;
23888
24280
  }
23889
24281
  if (d3.event.button === 2) {
@@ -24415,6 +24807,30 @@ RED.view = (function() {
24415
24807
  redraw();
24416
24808
  }
24417
24809
 
24810
+ function startPanning () {
24811
+
24812
+ }
24813
+ window.addEventListener('mousemove', windowMouseMove)
24814
+ window.addEventListener('touchmove', windowMouseMove)
24815
+
24816
+ function windowMouseMove (event) {
24817
+ if (mouse_mode === RED.state.PANNING) {
24818
+ let pos = [event.pageX, event.pageY]
24819
+ if (event.touches) {
24820
+ let touch0 = event.touches.item(0)
24821
+ pos = [touch0.pageX, touch0.pageY]
24822
+ }
24823
+ const deltaPos = [
24824
+ mouse_position[0]-pos[0],
24825
+ mouse_position[1]-pos[1]
24826
+ ]
24827
+ chart.scrollLeft(scroll_position[0]+deltaPos[0])
24828
+ chart.scrollTop(scroll_position[1]+deltaPos[1])
24829
+ RED.events.emit("view:navigate");
24830
+ return
24831
+ }
24832
+ }
24833
+
24418
24834
  function canvasMouseMove() {
24419
24835
  var i;
24420
24836
  var node;
@@ -24429,18 +24845,8 @@ RED.view = (function() {
24429
24845
  //console.log(d3.mouse(this),container.offsetWidth,container.offsetHeight,container.scrollLeft,container.scrollTop);
24430
24846
 
24431
24847
  if (mouse_mode === RED.state.PANNING) {
24432
- var pos = [d3.event.pageX,d3.event.pageY];
24433
- if (d3.event.touches) {
24434
- var touch0 = d3.event.touches.item(0);
24435
- pos = [touch0.pageX, touch0.pageY];
24436
- }
24437
- var deltaPos = [
24438
- mouse_position[0]-pos[0],
24439
- mouse_position[1]-pos[1]
24440
- ];
24441
-
24442
- chart.scrollLeft(scroll_position[0]+deltaPos[0])
24443
- chart.scrollTop(scroll_position[1]+deltaPos[1])
24848
+ // A window-level handler is used for panning so the mouse can leave the confines of the chart
24849
+ // but continue panning
24444
24850
  return
24445
24851
  }
24446
24852
 
@@ -24784,6 +25190,12 @@ RED.view = (function() {
24784
25190
  }
24785
25191
  if (mouse_mode === RED.state.PANNING) {
24786
25192
  resetMouseVars();
25193
+ // Revert to grab cursor if spacebar still held, otherwise clear cursor
25194
+ if (spacebarPressed) {
25195
+ outer.style('cursor', 'grab');
25196
+ } else {
25197
+ outer.style('cursor', '');
25198
+ }
24787
25199
  return
24788
25200
  }
24789
25201
  if (mouse_mode === RED.state.SELECTING_NODE) {
@@ -25115,39 +25527,454 @@ RED.view = (function() {
25115
25527
 
25116
25528
  }
25117
25529
 
25118
- function zoomIn() {
25119
- if (scaleFactor < 2) {
25120
- zoomView(scaleFactor+0.1);
25530
+ function calculateMinZoom() {
25531
+ // Calculate the minimum zoom to ensure canvas always fills the viewport (no empty space)
25532
+ var viewportWidth = chart.width();
25533
+ var viewportHeight = chart.height();
25534
+
25535
+ // Canvas is 8000x8000, calculate zoom to cover viewport
25536
+ var zoomToFitWidth = viewportWidth / space_width;
25537
+ var zoomToFitHeight = viewportHeight / space_height;
25538
+
25539
+ // Use the LARGER zoom to ensure canvas covers entire viewport (no empty space visible)
25540
+ var calculatedMinZoom = Math.max(zoomToFitWidth, zoomToFitHeight);
25541
+
25542
+ // Return the larger of the calculated min or the configured min
25543
+ // This ensures canvas always fills the viewport
25544
+ return Math.max(calculatedMinZoom, RED.view.zoomConstants.MIN_ZOOM);
25545
+ }
25546
+
25547
+ // Track focal point for sequential button/hotkey zoom operations
25548
+ // Store in workspace coordinates so it remains valid after viewport shifts
25549
+ var buttonZoomWorkspaceCenter = null;
25550
+ var buttonZoomTimeout = null;
25551
+ var BUTTON_ZOOM_FOCAL_TIMEOUT = 1000; // ms - time to keep same focal point
25552
+
25553
+ function zoomIn(focalPoint) {
25554
+ if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) {
25555
+ var useFocalPoint = null;
25556
+
25557
+ // If focalPoint is explicitly provided (e.g., from wheel/pinch), use it directly
25558
+ if (focalPoint) {
25559
+ useFocalPoint = focalPoint;
25560
+ } else {
25561
+ // For button/hotkey zoom, maintain the same workspace center across sequential zooms
25562
+ if (!buttonZoomWorkspaceCenter) {
25563
+ // First button zoom - calculate and store workspace center
25564
+ var screenSize = [chart.width(), chart.height()];
25565
+ var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
25566
+ // Convert viewport center to workspace coordinates
25567
+ buttonZoomWorkspaceCenter = [
25568
+ (scrollPos[0] + screenSize[0]/2) / scaleFactor,
25569
+ (scrollPos[1] + screenSize[1]/2) / scaleFactor
25570
+ ];
25571
+ }
25572
+
25573
+ // ALWAYS use viewport center as focal point (fixed screen position)
25574
+ // The stored workspace center will be kept at this screen position
25575
+ var screenSize = [chart.width(), chart.height()];
25576
+ useFocalPoint = [screenSize[0]/2, screenSize[1]/2];
25577
+
25578
+ // Reset timeout
25579
+ clearTimeout(buttonZoomTimeout);
25580
+ buttonZoomTimeout = setTimeout(function() {
25581
+ buttonZoomWorkspaceCenter = null;
25582
+ }, BUTTON_ZOOM_FOCAL_TIMEOUT);
25583
+ }
25584
+
25585
+ animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, useFocalPoint, buttonZoomWorkspaceCenter);
25586
+ }
25587
+ }
25588
+ function zoomOut(focalPoint) {
25589
+ var minZoom = calculateMinZoom();
25590
+ if (scaleFactor > minZoom) {
25591
+ var useFocalPoint = null;
25592
+
25593
+ if (focalPoint) {
25594
+ useFocalPoint = focalPoint;
25595
+ } else {
25596
+ if (!buttonZoomWorkspaceCenter) {
25597
+ var screenSize = [chart.width(), chart.height()];
25598
+ var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
25599
+ buttonZoomWorkspaceCenter = [
25600
+ (scrollPos[0] + screenSize[0]/2) / scaleFactor,
25601
+ (scrollPos[1] + screenSize[1]/2) / scaleFactor
25602
+ ];
25603
+ }
25604
+
25605
+ // ALWAYS use viewport center as focal point (fixed screen position)
25606
+ var screenSize = [chart.width(), chart.height()];
25607
+ useFocalPoint = [screenSize[0]/2, screenSize[1]/2];
25608
+
25609
+ clearTimeout(buttonZoomTimeout);
25610
+ buttonZoomTimeout = setTimeout(function() {
25611
+ buttonZoomWorkspaceCenter = null;
25612
+ }, BUTTON_ZOOM_FOCAL_TIMEOUT);
25613
+ }
25614
+
25615
+ animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), useFocalPoint, buttonZoomWorkspaceCenter);
25121
25616
  }
25122
25617
  }
25123
- function zoomOut() {
25124
- if (scaleFactor > 0.3) {
25125
- zoomView(scaleFactor-0.1);
25618
+ function zoomZero() {
25619
+ // Reset button zoom focal point for zoom reset
25620
+ clearTimeout(buttonZoomTimeout);
25621
+ buttonZoomWorkspaceCenter = null;
25622
+ animatedZoomView(1);
25623
+ }
25624
+
25625
+ function zoomToFitAll() {
25626
+ // Refresh active nodes to ensure we have the latest
25627
+ updateActiveNodes();
25628
+
25629
+ // Get all nodes in active workspace
25630
+ if (!activeNodes || activeNodes.length === 0) {
25631
+ return; // No nodes to fit
25632
+ }
25633
+
25634
+ // Calculate bounding box of all nodes
25635
+ var minX = Infinity, minY = Infinity;
25636
+ var maxX = -Infinity, maxY = -Infinity;
25637
+
25638
+ activeNodes.forEach(function(node) {
25639
+ var nodeLeft = node.x - node.w / 2;
25640
+ var nodeRight = node.x + node.w / 2;
25641
+ var nodeTop = node.y - node.h / 2;
25642
+ var nodeBottom = node.y + node.h / 2;
25643
+
25644
+ minX = Math.min(minX, nodeLeft);
25645
+ maxX = Math.max(maxX, nodeRight);
25646
+ minY = Math.min(minY, nodeTop);
25647
+ maxY = Math.max(maxY, nodeBottom);
25648
+ });
25649
+
25650
+ // Add padding around nodes for visual breathing room
25651
+ var padding = 80;
25652
+ minX -= padding;
25653
+ minY -= padding;
25654
+ maxX += padding;
25655
+ maxY += padding;
25656
+
25657
+ // Calculate dimensions of bounding box
25658
+ var boundingWidth = maxX - minX;
25659
+ var boundingHeight = maxY - minY;
25660
+
25661
+ // Get viewport dimensions
25662
+ var viewportWidth = chart.width();
25663
+ var viewportHeight = chart.height();
25664
+
25665
+ // Calculate zoom level that fits bounding box in viewport
25666
+ var zoomX = viewportWidth / boundingWidth;
25667
+ var zoomY = viewportHeight / boundingHeight;
25668
+ var targetZoom = Math.min(zoomX, zoomY);
25669
+
25670
+ // Respect minimum and maximum zoom limits
25671
+ var minZoom = calculateMinZoom();
25672
+ targetZoom = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetZoom));
25673
+
25674
+ // Calculate center point of bounding box in workspace coordinates
25675
+ var centerX = (minX + maxX) / 2;
25676
+ var centerY = (minY + maxY) / 2;
25677
+
25678
+ // Reset button zoom focal point for zoom-to-fit
25679
+ clearTimeout(buttonZoomTimeout);
25680
+ buttonZoomWorkspaceCenter = null;
25681
+
25682
+ // Pass the bounding box center as workspace center
25683
+ // This ensures the nodes are centered in viewport after zoom
25684
+ var focalPoint = [viewportWidth / 2, viewportHeight / 2];
25685
+
25686
+ // If zoom level won't change significantly, animate just the pan
25687
+ if (Math.abs(scaleFactor - targetZoom) < 0.01) {
25688
+ var targetScrollLeft = centerX * scaleFactor - viewportWidth / 2;
25689
+ var targetScrollTop = centerY * scaleFactor - viewportHeight / 2;
25690
+
25691
+ // Calculate pan distance to determine duration (match zoom animation logic)
25692
+ var startScrollLeft = chart.scrollLeft();
25693
+ var startScrollTop = chart.scrollTop();
25694
+ var panDistance = Math.sqrt(
25695
+ Math.pow(targetScrollLeft - startScrollLeft, 2) +
25696
+ Math.pow(targetScrollTop - startScrollTop, 2)
25697
+ );
25698
+
25699
+ // Use similar duration calculation as zoom: scale with distance
25700
+ // Normalize by viewport diagonal for consistent feel
25701
+ var viewportDiagonal = Math.sqrt(viewportWidth * viewportWidth + viewportHeight * viewportHeight);
25702
+ var relativeDistance = panDistance / viewportDiagonal;
25703
+ // Duration scales with distance, matching zoom animation feel
25704
+ var duration = Math.max(200, Math.min(350, relativeDistance * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 4));
25705
+
25706
+ RED.view.zoomAnimator.easeToValuesRAF({
25707
+ fromValues: {
25708
+ scrollLeft: startScrollLeft,
25709
+ scrollTop: startScrollTop
25710
+ },
25711
+ toValues: {
25712
+ scrollLeft: targetScrollLeft,
25713
+ scrollTop: targetScrollTop
25714
+ },
25715
+ duration: duration,
25716
+ onStep: function(values) {
25717
+ chart.scrollLeft(values.scrollLeft);
25718
+ chart.scrollTop(values.scrollTop);
25719
+ },
25720
+ onStart: function() {
25721
+ RED.events.emit("view:navigate");
25722
+ }
25723
+ });
25724
+ } else {
25725
+ animatedZoomView(targetZoom, focalPoint, [centerX, centerY]);
25126
25726
  }
25127
25727
  }
25128
- function zoomZero() { zoomView(1); }
25728
+
25129
25729
  function searchFlows() { RED.actions.invoke("core:search", $(this).data("term")); }
25130
25730
  function searchPrev() { RED.actions.invoke("core:search-previous"); }
25131
25731
  function searchNext() { RED.actions.invoke("core:search-next"); }
25132
25732
 
25133
25733
 
25134
- function zoomView(factor) {
25734
+ function zoomView(factor, focalPoint) {
25735
+ // Early return if scale factor isn't actually changing
25736
+ // This prevents focal point shifts when at zoom limits
25737
+ if (Math.abs(scaleFactor - factor) < 0.001) {
25738
+ return;
25739
+ }
25740
+ // Make scale 1 'sticky'
25741
+ if (Math.abs(1.0 - factor) < 0.02) {
25742
+ factor = 1
25743
+ }
25744
+
25745
+ console.log(factor)
25135
25746
  var screenSize = [chart.width(),chart.height()];
25136
25747
  var scrollPos = [chart.scrollLeft(),chart.scrollTop()];
25137
- var center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor];
25748
+ var oldScaleFactor = scaleFactor;
25749
+
25750
+ // Calculate workspace coordinates of the point that should remain fixed
25751
+ var center;
25752
+ if (focalPoint) {
25753
+ // focalPoint is in screen coordinates, convert to workspace coordinates
25754
+ center = [(scrollPos[0] + focalPoint[0])/oldScaleFactor, (scrollPos[1] + focalPoint[1])/oldScaleFactor];
25755
+ } else {
25756
+ // Default to viewport center in workspace coordinates
25757
+ center = [(scrollPos[0] + screenSize[0]/2)/oldScaleFactor, (scrollPos[1] + screenSize[1]/2)/oldScaleFactor];
25758
+ }
25759
+
25138
25760
  scaleFactor = factor;
25139
- var newCenter = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor];
25140
- var delta = [(newCenter[0]-center[0])*scaleFactor,(newCenter[1]-center[1])*scaleFactor]
25141
- chart.scrollLeft(scrollPos[0]-delta[0]);
25142
- chart.scrollTop(scrollPos[1]-delta[1]);
25761
+
25762
+ // Calculate new scroll position to keep the center point at the same screen position
25763
+ if (focalPoint) {
25764
+ // Keep the focal point at the same screen position
25765
+ chart.scrollLeft(center[0] * scaleFactor - focalPoint[0]);
25766
+ chart.scrollTop(center[1] * scaleFactor - focalPoint[1]);
25767
+ } else {
25768
+ // Keep viewport center on the same workspace coordinates
25769
+ var newScrollLeft = center[0] * scaleFactor - screenSize[0]/2;
25770
+ var newScrollTop = center[1] * scaleFactor - screenSize[1]/2;
25771
+ chart.scrollLeft(newScrollLeft);
25772
+ chart.scrollTop(newScrollTop);
25773
+ }
25143
25774
 
25144
25775
  RED.view.navigator.resize();
25145
25776
  redraw();
25777
+ RED.events.emit("view:navigate");
25146
25778
  if (RED.settings.get("editor.view.view-store-zoom")) {
25147
25779
  RED.settings.setLocal('zoom-level', factor.toFixed(1))
25148
25780
  }
25149
25781
  }
25150
25782
 
25783
+ function animatedZoomView(targetFactor, focalPoint, workspaceCenter) {
25784
+ // Cancel any in-progress animation
25785
+ if (cancelInProgressAnimation) {
25786
+ cancelInProgressAnimation();
25787
+ cancelInProgressAnimation = null;
25788
+ }
25789
+
25790
+ // Calculate the actual minimum zoom to fit canvas
25791
+ var minZoom = calculateMinZoom();
25792
+
25793
+ // Clamp target factor to valid range
25794
+ targetFactor = Math.max(minZoom,
25795
+ Math.min(RED.view.zoomConstants.MAX_ZOOM, targetFactor));
25796
+
25797
+ // If we're already at the target, no need to animate
25798
+ // Use a more tolerant threshold to account for floating-point precision
25799
+ if (Math.abs(scaleFactor - targetFactor) < 0.01) {
25800
+ return;
25801
+ }
25802
+ // Make scale 1 'sticky'
25803
+ if (Math.abs(1.0 - targetFactor) < 0.02) {
25804
+ targetFactor = 1
25805
+ }
25806
+
25807
+
25808
+ var startFactor = scaleFactor;
25809
+ var screenSize = [chart.width(), chart.height()];
25810
+ var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
25811
+
25812
+ // Calculate the focal point in workspace coordinates (will remain constant)
25813
+ var center;
25814
+ if (workspaceCenter) {
25815
+ // Use the provided workspace center directly (for button zoom focal point locking)
25816
+ center = workspaceCenter;
25817
+ } else if (focalPoint) {
25818
+ // focalPoint is in screen coordinates, convert to workspace coordinates
25819
+ center = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor];
25820
+ } else {
25821
+ // Default to viewport center
25822
+ center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor, (scrollPos[1] + screenSize[1]/2)/scaleFactor];
25823
+ }
25824
+
25825
+ // Calculate duration based on relative zoom change to maintain consistent velocity
25826
+ // Use logarithmic scaling since zoom feels exponential to the user
25827
+ var zoomRatio = targetFactor / startFactor;
25828
+ var logChange = Math.abs(Math.log(zoomRatio));
25829
+ // Scale duration more aggressively: multiply by 2 for stronger effect
25830
+ // At extreme zoom levels, animation will be noticeably longer
25831
+ var duration = Math.max(200, Math.min(350, logChange / 0.693 * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 2));
25832
+
25833
+ // Start the animation
25834
+ cancelInProgressAnimation = RED.view.zoomAnimator.easeToValuesRAF({
25835
+ fromValues: {
25836
+ zoom: startFactor
25837
+ },
25838
+ toValues: {
25839
+ zoom: targetFactor
25840
+ },
25841
+ duration: duration,
25842
+ interpolateValue: true, // Use exponential interpolation for zoom
25843
+ onStep: function(values) {
25844
+ var currentFactor = values.zoom;
25845
+ scaleFactor = currentFactor;
25846
+
25847
+ // Calculate new scroll position to maintain focal point
25848
+ var currentScreenSize = [chart.width(), chart.height()];
25849
+ var newScrollPos;
25850
+
25851
+ if (focalPoint) {
25852
+ // Keep the focal point at the same screen position
25853
+ newScrollPos = [
25854
+ center[0] * scaleFactor - focalPoint[0],
25855
+ center[1] * scaleFactor - focalPoint[1]
25856
+ ];
25857
+ } else {
25858
+ // Keep viewport center steady
25859
+ newScrollPos = [
25860
+ center[0] * scaleFactor - currentScreenSize[0]/2,
25861
+ center[1] * scaleFactor - currentScreenSize[1]/2
25862
+ ];
25863
+ }
25864
+
25865
+ chart.scrollLeft(newScrollPos[0]);
25866
+ chart.scrollTop(newScrollPos[1]);
25867
+
25868
+ // During animation, only update the scale transform, not the full redraw
25869
+ // This is much more performant with many nodes
25870
+ eventLayer.attr("transform", "scale(" + scaleFactor + ")");
25871
+ outer.attr("width", space_width * scaleFactor).attr("height", space_height * scaleFactor);
25872
+ RED.view.navigator.resize();
25873
+ },
25874
+ onStart: function() {
25875
+ // Show minimap when zoom animation starts
25876
+ RED.events.emit("view:navigate");
25877
+ },
25878
+ onEnd: function() {
25879
+ cancelInProgressAnimation = null;
25880
+ // Ensure scaleFactor is exactly the target to prevent precision issues
25881
+ scaleFactor = targetFactor;
25882
+ // Full redraw at the end to ensure everything is correct
25883
+ redraw();
25884
+ if (RED.settings.get("editor.view.view-store-zoom")) {
25885
+ RED.settings.setLocal('zoom-level', targetFactor.toFixed(1));
25886
+ }
25887
+ },
25888
+ onCancel: function() {
25889
+ cancelInProgressAnimation = null;
25890
+ // Ensure scaleFactor is set to current target on cancel
25891
+ scaleFactor = targetFactor;
25892
+ }
25893
+ });
25894
+ }
25895
+
25896
+ // Momentum scrolling functions
25897
+ function startMomentumScroll() {
25898
+ if (scrollAnimationId) {
25899
+ cancelAnimationFrame(scrollAnimationId);
25900
+ }
25901
+ momentumActive = true;
25902
+ animateMomentumScroll();
25903
+ }
25904
+
25905
+ function animateMomentumScroll() {
25906
+ if (!momentumActive) {
25907
+ return;
25908
+ }
25909
+
25910
+ var scrollX = chart.scrollLeft();
25911
+ var scrollY = chart.scrollTop();
25912
+ var maxScrollX = chart[0].scrollWidth - chart.width();
25913
+ var maxScrollY = chart[0].scrollHeight - chart.height();
25914
+
25915
+ // Apply friction
25916
+ scrollVelocity.x *= FRICTION;
25917
+ scrollVelocity.y *= FRICTION;
25918
+
25919
+ // Check for edges and apply bounce
25920
+ var newScrollX = scrollX + scrollVelocity.x;
25921
+ var newScrollY = scrollY + scrollVelocity.y;
25922
+
25923
+ // Bounce effect at edges
25924
+ if (newScrollX < 0) {
25925
+ newScrollX = 0;
25926
+ scrollVelocity.x = -scrollVelocity.x * BOUNCE_DAMPING;
25927
+ } else if (newScrollX > maxScrollX) {
25928
+ newScrollX = maxScrollX;
25929
+ scrollVelocity.x = -scrollVelocity.x * BOUNCE_DAMPING;
25930
+ }
25931
+
25932
+ if (newScrollY < 0) {
25933
+ newScrollY = 0;
25934
+ scrollVelocity.y = -scrollVelocity.y * BOUNCE_DAMPING;
25935
+ } else if (newScrollY > maxScrollY) {
25936
+ newScrollY = maxScrollY;
25937
+ scrollVelocity.y = -scrollVelocity.y * BOUNCE_DAMPING;
25938
+ }
25939
+
25940
+ // Apply new scroll position
25941
+ chart.scrollLeft(newScrollX);
25942
+ chart.scrollTop(newScrollY);
25943
+
25944
+ // Stop if velocity is too small
25945
+ if (Math.abs(scrollVelocity.x) < MIN_VELOCITY && Math.abs(scrollVelocity.y) < MIN_VELOCITY) {
25946
+ momentumActive = false;
25947
+ scrollVelocity.x = 0;
25948
+ scrollVelocity.y = 0;
25949
+ } else {
25950
+ scrollAnimationId = requestAnimationFrame(animateMomentumScroll);
25951
+ }
25952
+ }
25953
+
25954
+ function handleScroll() {
25955
+ var now = Date.now();
25956
+ var scrollX = chart.scrollLeft();
25957
+ var scrollY = chart.scrollTop();
25958
+
25959
+ if (lastScrollTime) {
25960
+ var dt = now - lastScrollTime;
25961
+ if (dt > 0 && dt < 100) { // Only calculate velocity for recent scrolls
25962
+ scrollVelocity.x = (scrollX - lastScrollPos.x) / dt * 16; // Normalize to 60fps
25963
+ scrollVelocity.y = (scrollY - lastScrollPos.y) / dt * 16;
25964
+ }
25965
+ }
25966
+
25967
+ lastScrollTime = now;
25968
+ lastScrollPos.x = scrollX;
25969
+ lastScrollPos.y = scrollY;
25970
+
25971
+ // Cancel any ongoing momentum animation
25972
+ if (scrollAnimationId) {
25973
+ cancelAnimationFrame(scrollAnimationId);
25974
+ scrollAnimationId = null;
25975
+ }
25976
+ }
25977
+
25151
25978
  function selectNone() {
25152
25979
  if (mouse_mode === RED.state.MOVING || mouse_mode === RED.state.MOVING_ACTIVE) {
25153
25980
  return;
@@ -25852,6 +26679,10 @@ RED.view = (function() {
25852
26679
 
25853
26680
  function portMouseDown(d,portType,portIndex, evt) {
25854
26681
  if (RED.view.DEBUG) { console.warn("portMouseDown", mouse_mode,d,portType,portIndex); }
26682
+ if (spacebarPressed) {
26683
+ return
26684
+ }
26685
+ clearSuggestedFlow();
25855
26686
  RED.contextMenu.hide();
25856
26687
  evt = evt || d3.event;
25857
26688
  if (evt === 1) {
@@ -26275,11 +27106,14 @@ RED.view = (function() {
26275
27106
  return tooltip;
26276
27107
  }
26277
27108
 
26278
- function portMouseOver(port,d,portType,portIndex) {
27109
+ function portMouseOver(port,d,portType,portIndex, event) {
26279
27110
  if (mouse_mode === RED.state.SELECTING_NODE) {
26280
- d3.event.stopPropagation();
27111
+ (d3.event || event).stopPropagation();
26281
27112
  return;
26282
27113
  }
27114
+ if (spacebarPressed) {
27115
+ return
27116
+ }
26283
27117
  clearTimeout(portLabelHoverTimeout);
26284
27118
  var active = (mouse_mode!=RED.state.JOINING && mouse_mode != RED.state.QUICK_JOINING) || // Not currently joining - all ports active
26285
27119
  (
@@ -26316,9 +27150,9 @@ RED.view = (function() {
26316
27150
  }
26317
27151
  port.classed("red-ui-flow-port-hovered",active);
26318
27152
  }
26319
- function portMouseOut(port,d,portType,portIndex) {
27153
+ function portMouseOut(port,d,portType,portIndex, event) {
26320
27154
  if (mouse_mode === RED.state.SELECTING_NODE) {
26321
- d3.event.stopPropagation();
27155
+ (d3.event || event).stopPropagation();
26322
27156
  return;
26323
27157
  }
26324
27158
  clearTimeout(portLabelHoverTimeout);
@@ -26436,6 +27270,10 @@ RED.view = (function() {
26436
27270
  }
26437
27271
  function nodeMouseDown(d) {
26438
27272
  if (RED.view.DEBUG) { console.warn("nodeMouseDown", mouse_mode,d); }
27273
+ if (spacebarPressed) {
27274
+ return
27275
+ }
27276
+ clearSuggestedFlow()
26439
27277
  focusView();
26440
27278
  RED.contextMenu.hide();
26441
27279
  if (d3.event.button === 1) {
@@ -26613,6 +27451,9 @@ RED.view = (function() {
26613
27451
 
26614
27452
  function nodeMouseOver(d) {
26615
27453
  if (RED.view.DEBUG) { console.warn("nodeMouseOver", mouse_mode,d); }
27454
+ if (spacebarPressed) {
27455
+ return
27456
+ }
26616
27457
  if (mouse_mode === 0 || mouse_mode === RED.state.SELECTING_NODE) {
26617
27458
  if (mouse_mode === RED.state.SELECTING_NODE && selectNodesOptions && selectNodesOptions.filter) {
26618
27459
  if (selectNodesOptions.filter(d)) {
@@ -26786,6 +27627,9 @@ RED.view = (function() {
26786
27627
  if (RED.view.DEBUG) {
26787
27628
  console.warn("groupMouseDown", { mouse_mode, point: mouse, event: d3.event });
26788
27629
  }
27630
+ if (spacebarPressed) {
27631
+ return
27632
+ }
26789
27633
  RED.contextMenu.hide();
26790
27634
  focusView();
26791
27635
  if (d3.event.button === 1) {
@@ -27061,130 +27905,220 @@ RED.view = (function() {
27061
27905
  }
27062
27906
  }
27063
27907
 
27908
+ function buildSubflowPort (d) {
27909
+ const NODE_TYPE = d.direction === "in" ? PORT_TYPE_INPUT : PORT_TYPE_OUTPUT;
27910
+ // PORT_TYPE is the 'opposite' of NODE_TYPE
27911
+ const PORT_TYPE = NODE_TYPE === PORT_TYPE_INPUT ? PORT_TYPE_OUTPUT : PORT_TYPE_INPUT;
27912
+ var node = d3.select(this);
27913
+ var nodeContents = document.createDocumentFragment();
27914
+
27915
+ d.h = 40;
27916
+ d.resize = true;
27917
+ d.dirty = true;
27918
+
27919
+ var mainRect = document.createElementNS("http://www.w3.org/2000/svg","rect");
27920
+ mainRect.__data__ = d;
27921
+ mainRect.setAttribute("class", "red-ui-flow-subflow-port");
27922
+ mainRect.setAttribute("rx", 8);
27923
+ mainRect.setAttribute("ry", 8);
27924
+ mainRect.setAttribute("width", 40);
27925
+ mainRect.setAttribute("height", 40);
27926
+ node[0][0].__mainRect__ = mainRect;
27927
+ d3.select(mainRect)
27928
+ .on("mouseup",nodeMouseUp)
27929
+ .on("mousedown",nodeMouseDown)
27930
+ .on("touchstart",nodeTouchStart)
27931
+ .on("touchend",nodeTouchEnd)
27932
+ nodeContents.appendChild(mainRect);
27933
+
27934
+ const port_label_group = document.createElementNS("http://www.w3.org/2000/svg","g");
27935
+ port_label_group.setAttribute("x",0);
27936
+ port_label_group.setAttribute("y",0);
27937
+ node[0][0].__portLabelGroup__ = port_label_group;
27938
+
27939
+ const port_label = document.createElementNS("http://www.w3.org/2000/svg","text");
27940
+ port_label.setAttribute("class","red-ui-flow-port-label");
27941
+ port_label.style["font-size"] = "10px";
27942
+ port_label.textContent = NODE_TYPE === PORT_TYPE_INPUT? "input" : "output";
27943
+ port_label_group.appendChild(port_label);
27944
+ node[0][0].__portLabel__ = port_label;
27945
+
27946
+ if (NODE_TYPE === PORT_TYPE_OUTPUT) {
27947
+ const port_number = document.createElementNS("http://www.w3.org/2000/svg","text");
27948
+ port_number.setAttribute("class","red-ui-flow-port-label red-ui-flow-port-index");
27949
+ port_number.setAttribute("x",0);
27950
+ port_number.setAttribute("y",0);
27951
+ port_number.textContent = d.i+1;
27952
+ port_label_group.appendChild(port_number);
27953
+ node[0][0].__portNumber__ = port_number;
27954
+ }
27955
+
27956
+ const port_border = document.createElementNS("http://www.w3.org/2000/svg","path");
27957
+ port_border.setAttribute("d","M 40 1 l 0 38")
27958
+ port_border.setAttribute("class", "red-ui-flow-node-icon-shade-border")
27959
+ port_label_group.appendChild(port_border);
27960
+ node[0][0].__portBorder__ = port_border;
27961
+
27962
+ nodeContents.appendChild(port_label_group);
27963
+
27964
+ var text = document.createElementNS("http://www.w3.org/2000/svg","g");
27965
+ text.setAttribute("class","red-ui-flow-port-label");
27966
+ text.setAttribute("transform","translate(38,0)");
27967
+ text.setAttribute('style', 'fill : #888'); // hard coded here!
27968
+ node[0][0].__textGroup__ = text;
27969
+ nodeContents.append(text);
27970
+
27971
+ var portEl = document.createElementNS("http://www.w3.org/2000/svg","g");
27972
+ portEl.setAttribute('transform','translate(-5,15)')
27973
+
27974
+ var port = document.createElementNS("http://www.w3.org/2000/svg","rect");
27975
+ port.setAttribute("class","red-ui-flow-port");
27976
+ port.setAttribute("rx",3);
27977
+ port.setAttribute("ry",3);
27978
+ port.setAttribute("width",10);
27979
+ port.setAttribute("height",10);
27980
+ portEl.appendChild(port);
27981
+ port.__data__ = d;
27982
+
27983
+ d3.select(port)
27984
+ .on("mousedown", function(d,i){portMouseDown(d,PORT_TYPE,0);} )
27985
+ .on("touchstart", function(d,i){portMouseDown(d,PORT_TYPE,0);d3.event.preventDefault();} )
27986
+ .on("mouseup", function(d,i){portMouseUp(d,PORT_TYPE,0);})
27987
+ .on("touchend",function(d,i){portMouseUp(d,PORT_TYPE,0);d3.event.preventDefault();} )
27988
+ .on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE,0);})
27989
+ .on("mouseout",function(d){portMouseOut(d3.select(this),d,PORT_TYPE,0);});
27990
+
27991
+ node[0][0].__port__ = portEl
27992
+ nodeContents.appendChild(portEl);
27993
+ node[0][0].appendChild(nodeContents);
27994
+ }
27995
+ function updateSubflowPort (d) {
27996
+ if (d.dirty) {
27997
+ const port_height = 40;
27998
+ const NODE_TYPE = d.direction === "in" ? PORT_TYPE_INPUT : PORT_TYPE_OUTPUT;
27999
+ // PORT_TYPE is the 'opposite' of NODE_TYPE
28000
+ const PORT_TYPE = NODE_TYPE === PORT_TYPE_INPUT ? PORT_TYPE_OUTPUT : PORT_TYPE_INPUT;
28001
+
28002
+ var label = getPortLabel(activeSubflow, NODE_TYPE, d.i) || "";
28003
+ var hideLabel = (label.length < 1)
28004
+ var labelParts;
28005
+ if (d.resize || this.__hideLabel__ !== hideLabel || this.__label__ !== label) {
28006
+ labelParts = getLabelParts(label, "red-ui-flow-node-label");
28007
+ if (labelParts.lines.length !== this.__labelLineCount__ || this.__label__ !== label) {
28008
+ d.resize = true;
28009
+ }
28010
+ this.__label__ = label;
28011
+ this.__labelLineCount__ = labelParts.lines.length;
28012
+
28013
+ if (hideLabel) {
28014
+ d.h = Math.max(port_height,(d.outputs || 0) * 15);
28015
+ } else {
28016
+ d.h = Math.max(6+24*labelParts.lines.length,(d.outputs || 0) * 15, port_height);
28017
+ }
28018
+ this.__hideLabel__ = hideLabel;
28019
+ }
28020
+
28021
+ if (d.resize) {
28022
+ var ow = d.w;
28023
+ if (hideLabel) {
28024
+ d.w = port_height;
28025
+ } else {
28026
+ d.w = Math.max(port_height,20*(Math.ceil((labelParts.width+50+7)/20)) );
28027
+ }
28028
+ if (ow !== undefined) {
28029
+ d.x += (d.w-ow)/2;
28030
+ }
28031
+ d.resize = false;
28032
+ }
28033
+
28034
+ this.setAttribute("transform", "translate(" + (d.x-d.w/2) + "," + (d.y-d.h/2) + ")");
28035
+ // This might be the first redraw after a node has been click-dragged to start a move.
28036
+ // So its selected state might have changed since the last redraw.
28037
+ this.classList.toggle("red-ui-flow-node-selected", !!d.selected )
28038
+ if (mouse_mode != RED.state.MOVING_ACTIVE) {
28039
+ this.classList.toggle("red-ui-flow-node-disabled", d.d === true);
28040
+ this.__mainRect__.setAttribute("width", d.w)
28041
+ this.__mainRect__.setAttribute("height", d.h)
28042
+ this.__mainRect__.classList.toggle("red-ui-flow-node-highlighted",!!d.highlighted );
28043
+
28044
+ if (labelParts) {
28045
+ // The label has changed
28046
+ var sa = labelParts.lines;
28047
+ var sn = labelParts.lines.length;
28048
+ var textLines = this.__textGroup__.childNodes;
28049
+ while(textLines.length > sn) {
28050
+ textLines[textLines.length-1].remove();
28051
+ }
28052
+ for (var i=0; i<sn; i++) {
28053
+ if (i===textLines.length) {
28054
+ var line = document.createElementNS("http://www.w3.org/2000/svg","text");
28055
+ line.setAttribute("class","red-ui-flow-node-label-text");
28056
+ line.setAttribute("x",0);
28057
+ line.setAttribute("y",i*24);
28058
+ this.__textGroup__.appendChild(line);
28059
+ }
28060
+ textLines[i].textContent = sa[i];
28061
+ }
28062
+ }
28063
+
28064
+ var textClass = "red-ui-flow-node-label"+(hideLabel?" hide":"");
28065
+ this.__textGroup__.setAttribute("class", textClass);
28066
+ var yp = d.h / 2 - (this.__labelLineCount__ / 2) * 24 + 13;
28067
+
28068
+ // this.__textGroup__.classList.remove("red-ui-flow-node-label-right");
28069
+ this.__textGroup__.setAttribute("transform", "translate(48,"+yp+")");
28070
+
28071
+ this.__portBorder__.setAttribute("d","M 40 1 l 0 "+(hideLabel?0:(d.h - 2)));
28072
+ const portX = PORT_TYPE === PORT_TYPE_OUTPUT ? d.w - 5 : -5
28073
+ this.__port__.setAttribute("transform","translate("+portX+","+((d.h/2)-5)+")");
28074
+ if (NODE_TYPE === PORT_TYPE_OUTPUT) {
28075
+ this.__portLabel__.setAttribute("transform","translate(20,"+((d.h/2)-8)+")");
28076
+ this.__portNumber__.setAttribute("transform","translate(20,"+((d.h/2)+7)+")");
28077
+ this.__portNumber__.textContent = d.i+1;
28078
+ } else {
28079
+ this.__portLabel__.setAttribute("transform","translate(20,"+(d.h/2)+")");
28080
+ }
28081
+ }
28082
+ d.dirty = false;
28083
+ return true
28084
+ }
28085
+ return false
28086
+ }
28087
+
27064
28088
  function _redraw() {
27065
28089
  eventLayer.attr("transform","scale("+scaleFactor+")");
27066
28090
  outer.attr("width", space_width*scaleFactor).attr("height", space_height*scaleFactor);
27067
28091
 
27068
- // Don't bother redrawing nodes if we're drawing links
27069
- if (showAllLinkPorts !== -1 || mouse_mode != RED.state.JOINING) {
28092
+ // Update scroll spacer to match scaled canvas size
28093
+ // This ensures scrollable area = canvas area
28094
+ // Browser calculates maxScroll = scrollWidth - viewport, which correctly
28095
+ // allows scrolling to see the far edges of canvas without going beyond
28096
+ $('#red-ui-workspace-scroll-spacer').css({
28097
+ width: (space_width * scaleFactor) + 'px',
28098
+ height: (space_height * scaleFactor) + 'px'
28099
+ });
27070
28100
 
28101
+ // Don't bother redrawing nodes if we're drawing links
28102
+ if (forceFullRedraw || showAllLinkPorts !== -1 || mouse_mode != RED.state.JOINING) {
28103
+ forceFullRedraw = false
27071
28104
  var dirtyNodes = {};
27072
28105
 
27073
28106
  if (activeSubflow) {
27074
28107
  var subflowOutputs = nodeLayer.selectAll(".red-ui-flow-subflow-port-output").data(activeSubflow.out,function(d,i){ return d.id;});
27075
28108
  subflowOutputs.exit().remove();
27076
28109
  var outGroup = subflowOutputs.enter().insert("svg:g").attr("class","red-ui-flow-node red-ui-flow-subflow-port-output")
27077
- outGroup.each(function(d,i) {
27078
- var node = d3.select(this);
27079
- var nodeContents = document.createDocumentFragment();
27080
-
27081
- d.h = 40;
27082
- d.resize = true;
27083
- d.dirty = true;
27084
-
27085
- var mainRect = document.createElementNS("http://www.w3.org/2000/svg","rect");
27086
- mainRect.__data__ = d;
27087
- mainRect.setAttribute("class", "red-ui-flow-subflow-port");
27088
- mainRect.setAttribute("rx", 8);
27089
- mainRect.setAttribute("ry", 8);
27090
- mainRect.setAttribute("width", 40);
27091
- mainRect.setAttribute("height", 40);
27092
- node[0][0].__mainRect__ = mainRect;
27093
- d3.select(mainRect)
27094
- .on("mouseup",nodeMouseUp)
27095
- .on("mousedown",nodeMouseDown)
27096
- .on("touchstart",nodeTouchStart)
27097
- .on("touchend",nodeTouchEnd)
27098
- nodeContents.appendChild(mainRect);
27099
-
27100
- var output_groupEl = document.createElementNS("http://www.w3.org/2000/svg","g");
27101
- output_groupEl.setAttribute("x",0);
27102
- output_groupEl.setAttribute("y",0);
27103
- node[0][0].__outputLabelGroup__ = output_groupEl;
27104
-
27105
- var output_output = document.createElementNS("http://www.w3.org/2000/svg","text");
27106
- output_output.setAttribute("class","red-ui-flow-port-label");
27107
- output_output.style["font-size"] = "10px";
27108
- output_output.textContent = "output";
27109
- output_groupEl.appendChild(output_output);
27110
- node[0][0].__outputOutput__ = output_output;
27111
-
27112
- var output_number = document.createElementNS("http://www.w3.org/2000/svg","text");
27113
- output_number.setAttribute("class","red-ui-flow-port-label red-ui-flow-port-index");
27114
- output_number.setAttribute("x",0);
27115
- output_number.setAttribute("y",0);
27116
- output_number.textContent = d.i+1;
27117
- output_groupEl.appendChild(output_number);
27118
- node[0][0].__outputNumber__ = output_number;
27119
-
27120
- var output_border = document.createElementNS("http://www.w3.org/2000/svg","path");
27121
- output_border.setAttribute("d","M 40 1 l 0 38")
27122
- output_border.setAttribute("class", "red-ui-flow-node-icon-shade-border")
27123
- output_groupEl.appendChild(output_border);
27124
- node[0][0].__outputBorder__ = output_border;
27125
-
27126
- nodeContents.appendChild(output_groupEl);
27127
-
27128
- var text = document.createElementNS("http://www.w3.org/2000/svg","g");
27129
- text.setAttribute("class","red-ui-flow-port-label");
27130
- text.setAttribute("transform","translate(38,0)");
27131
- text.setAttribute('style', 'fill : #888'); // hard coded here!
27132
- node[0][0].__textGroup__ = text;
27133
- nodeContents.append(text);
27134
-
27135
- var portEl = document.createElementNS("http://www.w3.org/2000/svg","g");
27136
- portEl.setAttribute('transform','translate(-5,15)')
27137
-
27138
- var port = document.createElementNS("http://www.w3.org/2000/svg","rect");
27139
- port.setAttribute("class","red-ui-flow-port");
27140
- port.setAttribute("rx",3);
27141
- port.setAttribute("ry",3);
27142
- port.setAttribute("width",10);
27143
- port.setAttribute("height",10);
27144
- portEl.appendChild(port);
27145
- port.__data__ = d;
27146
-
27147
- d3.select(port)
27148
- .on("mousedown", function(d,i){portMouseDown(d,PORT_TYPE_INPUT,0);} )
27149
- .on("touchstart", function(d,i){portMouseDown(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();} )
27150
- .on("mouseup", function(d,i){portMouseUp(d,PORT_TYPE_INPUT,0);})
27151
- .on("touchend",function(d,i){portMouseUp(d,PORT_TYPE_INPUT,0);d3.event.preventDefault();} )
27152
- .on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE_INPUT,0);})
27153
- .on("mouseout",function(d){portMouseOut(d3.select(this),d,PORT_TYPE_INPUT,0);});
27154
-
27155
- node[0][0].__port__ = portEl
27156
- nodeContents.appendChild(portEl);
27157
- node[0][0].appendChild(nodeContents);
27158
- });
28110
+ outGroup.each(buildSubflowPort);
27159
28111
 
27160
28112
  var subflowInputs = nodeLayer.selectAll(".red-ui-flow-subflow-port-input").data(activeSubflow.in,function(d,i){ return d.id;});
27161
28113
  subflowInputs.exit().remove();
27162
28114
  var inGroup = subflowInputs.enter().insert("svg:g").attr("class","red-ui-flow-node red-ui-flow-subflow-port-input").attr("transform",function(d) { return "translate("+(d.x-20)+","+(d.y-20)+")"});
27163
- inGroup.each(function(d,i) {
27164
- d.w=40;
27165
- d.h=40;
27166
- });
27167
- inGroup.append("rect").attr("class","red-ui-flow-subflow-port").attr("rx",8).attr("ry",8).attr("width",40).attr("height",40)
27168
- // TODO: This is exactly the same set of handlers used for regular nodes - DRY
27169
- .on("mouseup",nodeMouseUp)
27170
- .on("mousedown",nodeMouseDown)
27171
- .on("touchstart",nodeTouchStart)
27172
- .on("touchend", nodeTouchEnd);
27173
-
27174
- inGroup.append("g").attr('transform','translate(35,15)').append("rect").attr("class","red-ui-flow-port").attr("rx",3).attr("ry",3).attr("width",10).attr("height",10)
27175
- .on("mousedown", function(d,i){portMouseDown(d,PORT_TYPE_OUTPUT,i);} )
27176
- .on("touchstart", function(d,i){portMouseDown(d,PORT_TYPE_OUTPUT,i);d3.event.preventDefault();} )
27177
- .on("mouseup", function(d,i){portMouseUp(d,PORT_TYPE_OUTPUT,i);})
27178
- .on("touchend",function(d,i){portMouseUp(d,PORT_TYPE_OUTPUT,i);d3.event.preventDefault();} )
27179
- .on("mouseover",function(d){portMouseOver(d3.select(this),d,PORT_TYPE_OUTPUT,0);})
27180
- .on("mouseout",function(d) {portMouseOut(d3.select(this),d,PORT_TYPE_OUTPUT,0);});
27181
-
27182
- inGroup.append("svg:text").attr("class","red-ui-flow-port-label").attr("x",18).attr("y",20).style("font-size","10px").text("input");
28115
+ inGroup.each(buildSubflowPort);
27183
28116
 
27184
28117
  var subflowStatus = nodeLayer.selectAll(".red-ui-flow-subflow-port-status").data(activeSubflow.status?[activeSubflow.status]:[],function(d,i){ return d.id;});
27185
28118
  subflowStatus.exit().remove();
27186
28119
 
27187
28120
  var statusGroup = subflowStatus.enter().insert("svg:g").attr("class","red-ui-flow-node red-ui-flow-subflow-port-status").attr("transform",function(d) { return "translate("+(d.x-20)+","+(d.y-20)+")"});
28121
+ // TODO: use buildSubflowPort/updateSubflowPort for status port
27188
28122
  statusGroup.each(function(d,i) {
27189
28123
  d.w=40;
27190
28124
  d.h=40;
@@ -27206,104 +28140,16 @@ RED.view = (function() {
27206
28140
 
27207
28141
  statusGroup.append("svg:text").attr("class","red-ui-flow-port-label").attr("x",22).attr("y",20).style("font-size","10px").text("status");
27208
28142
 
27209
- subflowOutputs.each(function(d,i) {
27210
- if (d.dirty) {
27211
-
27212
- var port_height = 40;
27213
-
27214
- var self = this;
27215
- var thisNode = d3.select(this);
27216
-
28143
+ subflowOutputs.each(function (d,i) {
28144
+ if (updateSubflowPort.call(this, d)) {
27217
28145
  dirtyNodes[d.id] = d;
27218
-
27219
- var label = getPortLabel(activeSubflow, PORT_TYPE_OUTPUT, d.i) || "";
27220
- var hideLabel = (label.length < 1)
27221
-
27222
- var labelParts;
27223
- if (d.resize || this.__hideLabel__ !== hideLabel || this.__label__ !== label) {
27224
- labelParts = getLabelParts(label, "red-ui-flow-node-label");
27225
- if (labelParts.lines.length !== this.__labelLineCount__ || this.__label__ !== label) {
27226
- d.resize = true;
27227
- }
27228
- this.__label__ = label;
27229
- this.__labelLineCount__ = labelParts.lines.length;
27230
-
27231
- if (hideLabel) {
27232
- d.h = Math.max(port_height,(d.outputs || 0) * 15);
27233
- } else {
27234
- d.h = Math.max(6+24*labelParts.lines.length,(d.outputs || 0) * 15, port_height);
27235
- }
27236
- this.__hideLabel__ = hideLabel;
27237
- }
27238
-
27239
- if (d.resize) {
27240
- var ow = d.w;
27241
- if (hideLabel) {
27242
- d.w = port_height;
27243
- } else {
27244
- d.w = Math.max(port_height,20*(Math.ceil((labelParts.width+50+7)/20)) );
27245
- }
27246
- if (ow !== undefined) {
27247
- d.x += (d.w-ow)/2;
27248
- }
27249
- d.resize = false;
27250
- }
27251
-
27252
- this.setAttribute("transform", "translate(" + (d.x-d.w/2) + "," + (d.y-d.h/2) + ")");
27253
- // This might be the first redraw after a node has been click-dragged to start a move.
27254
- // So its selected state might have changed since the last redraw.
27255
- this.classList.toggle("red-ui-flow-node-selected", !!d.selected )
27256
- if (mouse_mode != RED.state.MOVING_ACTIVE) {
27257
- this.classList.toggle("red-ui-flow-node-disabled", d.d === true);
27258
- this.__mainRect__.setAttribute("width", d.w)
27259
- this.__mainRect__.setAttribute("height", d.h)
27260
- this.__mainRect__.classList.toggle("red-ui-flow-node-highlighted",!!d.highlighted );
27261
-
27262
- if (labelParts) {
27263
- // The label has changed
27264
- var sa = labelParts.lines;
27265
- var sn = labelParts.lines.length;
27266
- var textLines = this.__textGroup__.childNodes;
27267
- while(textLines.length > sn) {
27268
- textLines[textLines.length-1].remove();
27269
- }
27270
- for (var i=0; i<sn; i++) {
27271
- if (i===textLines.length) {
27272
- var line = document.createElementNS("http://www.w3.org/2000/svg","text");
27273
- line.setAttribute("class","red-ui-flow-node-label-text");
27274
- line.setAttribute("x",0);
27275
- line.setAttribute("y",i*24);
27276
- this.__textGroup__.appendChild(line);
27277
- }
27278
- textLines[i].textContent = sa[i];
27279
- }
27280
- }
27281
-
27282
- var textClass = "red-ui-flow-node-label"+(hideLabel?" hide":"");
27283
- this.__textGroup__.setAttribute("class", textClass);
27284
- var yp = d.h / 2 - (this.__labelLineCount__ / 2) * 24 + 13;
27285
-
27286
- // this.__textGroup__.classList.remove("red-ui-flow-node-label-right");
27287
- this.__textGroup__.setAttribute("transform", "translate(48,"+yp+")");
27288
-
27289
- this.__outputBorder__.setAttribute("d","M 40 1 l 0 "+(hideLabel?0:(d.h - 2)));
27290
- this.__port__.setAttribute("transform","translate(-5,"+((d.h/2)-5)+")");
27291
- this.__outputOutput__.setAttribute("transform","translate(20,"+((d.h/2)-8)+")");
27292
- this.__outputNumber__.setAttribute("transform","translate(20,"+((d.h/2)+7)+")");
27293
- this.__outputNumber__.textContent = d.i+1;
27294
- }
27295
- d.dirty = false;
27296
28146
  }
27297
- });
27298
- subflowInputs.each(function(d,i) {
27299
- if (d.dirty) {
27300
- var input = d3.select(this);
27301
- input.classed("red-ui-flow-node-selected",function(d) { return d.selected; })
27302
- input.attr("transform", function(d) { return "translate(" + (d.x-d.w/2) + "," + (d.y-d.h/2) + ")"; });
28147
+ })
28148
+ subflowInputs.each(function (d,i) {
28149
+ if (updateSubflowPort.call(this, d)) {
27303
28150
  dirtyNodes[d.id] = d;
27304
- d.dirty = false;
27305
28151
  }
27306
- });
28152
+ })
27307
28153
  subflowStatus.each(function(d,i) {
27308
28154
  if (d.dirty) {
27309
28155
  var output = d3.select(this);
@@ -29357,7 +30203,6 @@ RED.view = (function() {
29357
30203
  refreshSuggestedFlow();
29358
30204
  } else { // Anything else; clear the suggestion
29359
30205
  clearSuggestedFlow();
29360
- RED.view.redraw(true);
29361
30206
  // manually push the event to the keyboard handler
29362
30207
  RED.keyboard.handle(evt)
29363
30208
  }
@@ -29366,7 +30211,6 @@ RED.view = (function() {
29366
30211
  if (suggestion.clickToApply) {
29367
30212
  $(window).on('mousedown.suggestedFlow', function (evnt) {
29368
30213
  clearSuggestedFlow();
29369
- RED.view.redraw(true);
29370
30214
  })
29371
30215
  }
29372
30216
  }
@@ -29387,12 +30231,16 @@ RED.view = (function() {
29387
30231
  }
29388
30232
 
29389
30233
  function clearSuggestedFlow () {
29390
- $(window).off('mousedown.suggestedFlow');
29391
- $(window).off('keydown.suggestedFlow')
29392
- RED.keyboard.enable()
29393
- currentSuggestion = null
29394
- suggestedNodes = []
29395
- suggestedLinks = []
30234
+ if (currentSuggestion) {
30235
+ $(window).off('mousedown.suggestedFlow');
30236
+ $(window).off('keydown.suggestedFlow')
30237
+ RED.keyboard.enable()
30238
+ currentSuggestion = null
30239
+ suggestedNodes = []
30240
+ suggestedLinks = []
30241
+ forceFullRedraw = true
30242
+ RED.view.redraw(true);
30243
+ }
29396
30244
  }
29397
30245
 
29398
30246
  function applySuggestedFlow () {
@@ -29610,7 +30458,7 @@ RED.view = (function() {
29610
30458
  selectNodes: function(options) {
29611
30459
  $("#red-ui-workspace-tabs-shade").show();
29612
30460
  $("#red-ui-palette-shade").show();
29613
- $("#red-ui-sidebar-shade").show();
30461
+ $(".red-ui-sidebar-shade").show();
29614
30462
  $("#red-ui-header-shade").show();
29615
30463
  $("#red-ui-workspace").addClass("red-ui-workspace-select-mode");
29616
30464
 
@@ -29632,7 +30480,7 @@ RED.view = (function() {
29632
30480
  clearSelection();
29633
30481
  $("#red-ui-workspace-tabs-shade").hide();
29634
30482
  $("#red-ui-palette-shade").hide();
29635
- $("#red-ui-sidebar-shade").hide();
30483
+ $(".red-ui-sidebar-shade").hide();
29636
30484
  $("#red-ui-header-shade").hide();
29637
30485
  $("#red-ui-workspace").removeClass("red-ui-workspace-select-mode");
29638
30486
  resetMouseVars();
@@ -29701,7 +30549,283 @@ RED.view = (function() {
29701
30549
  applySuggestedFlow
29702
30550
  };
29703
30551
  })();
29704
- ;RED.view.annotations = (function() {
30552
+ ;/**
30553
+ * Zoom configuration constants
30554
+ */
30555
+ RED.view.zoomConstants = {
30556
+ // Zoom limits
30557
+ MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas
30558
+ MAX_ZOOM: 2.0,
30559
+
30560
+ // Zoom step for keyboard/button controls
30561
+ ZOOM_STEP: 0.2,
30562
+
30563
+ // Animation settings
30564
+ DEFAULT_ZOOM_DURATION: 125, // ms, faster animation
30565
+
30566
+ // Gesture thresholds
30567
+ PINCH_THRESHOLD: 10, // minimum pixel movement to trigger zoom
30568
+
30569
+ // Momentum and friction for smooth scrolling
30570
+ FRICTION: 0.92,
30571
+ BOUNCE_DAMPING: 0.6
30572
+ };;/**
30573
+ * Copyright JS Foundation and other contributors, http://js.foundation
30574
+ *
30575
+ * Licensed under the Apache License, Version 2.0 (the "License");
30576
+ * you may not use this file except in compliance with the License.
30577
+ * You may obtain a copy of the License at
30578
+ *
30579
+ * http://www.apache.org/licenses/LICENSE-2.0
30580
+ *
30581
+ * Unless required by applicable law or agreed to in writing, software
30582
+ * distributed under the License is distributed on an "AS IS" BASIS,
30583
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
30584
+ * See the License for the specific language governing permissions and
30585
+ * limitations under the License.
30586
+ **/
30587
+
30588
+ RED.view.zoomAnimator = (function() {
30589
+
30590
+ /**
30591
+ * Easing function for smooth deceleration
30592
+ * Creates natural-feeling animation curves
30593
+ * @param {number} t - Progress from 0 to 1
30594
+ * @returns {number} - Eased value from 0 to 1
30595
+ */
30596
+ function easeOut(t) {
30597
+ // Cubic ease-out for smooth deceleration
30598
+ return 1 - Math.pow(1 - t, 3);
30599
+ }
30600
+
30601
+ /**
30602
+ * Animate values using requestAnimationFrame with easing
30603
+ * Based on Excalidraw's implementation for smooth zoom transitions
30604
+ *
30605
+ * @param {Object} options - Animation options
30606
+ * @param {Object} options.fromValues - Starting values object
30607
+ * @param {Object} options.toValues - Target values object
30608
+ * @param {Function} options.onStep - Callback for each animation frame
30609
+ * @param {number} [options.duration=250] - Animation duration in ms
30610
+ * @param {Function} [options.interpolateValue] - Custom interpolation function
30611
+ * @param {Function} [options.onStart] - Animation start callback
30612
+ * @param {Function} [options.onEnd] - Animation end callback
30613
+ * @param {Function} [options.onCancel] - Animation cancel callback
30614
+ * @returns {Function} - Cancel function to stop animation
30615
+ */
30616
+ function easeToValuesRAF(options) {
30617
+ const {
30618
+ fromValues,
30619
+ toValues,
30620
+ onStep,
30621
+ duration = 250,
30622
+ interpolateValue,
30623
+ onStart,
30624
+ onEnd,
30625
+ onCancel
30626
+ } = options;
30627
+
30628
+ let startTime = null;
30629
+ let animationId = null;
30630
+ let cancelled = false;
30631
+
30632
+ function step(timestamp) {
30633
+ if (cancelled) {
30634
+ return;
30635
+ }
30636
+
30637
+ if (!startTime) {
30638
+ startTime = timestamp;
30639
+ if (onStart) {
30640
+ onStart();
30641
+ }
30642
+ }
30643
+
30644
+ const elapsed = timestamp - startTime;
30645
+ const progress = Math.min(elapsed / duration, 1);
30646
+ const easedProgress = easeOut(progress);
30647
+
30648
+ const interpolatedValues = {};
30649
+
30650
+ for (const key in fromValues) {
30651
+ if (fromValues.hasOwnProperty(key)) {
30652
+ const from = fromValues[key];
30653
+ const to = toValues[key];
30654
+
30655
+ if (interpolateValue && key === 'zoom') {
30656
+ // Special interpolation for zoom to feel more natural
30657
+ // Exponential interpolation preserves relative zoom feel
30658
+ interpolatedValues[key] = from * Math.pow(to / from, easedProgress);
30659
+ } else {
30660
+ // Linear interpolation for other values
30661
+ interpolatedValues[key] = from + (to - from) * easedProgress;
30662
+ }
30663
+ }
30664
+ }
30665
+
30666
+ onStep(interpolatedValues);
30667
+
30668
+ if (progress < 1) {
30669
+ animationId = requestAnimationFrame(step);
30670
+ } else {
30671
+ if (onEnd) {
30672
+ onEnd();
30673
+ }
30674
+ }
30675
+ }
30676
+
30677
+ animationId = requestAnimationFrame(step);
30678
+
30679
+ // Return cancel function
30680
+ return function cancel() {
30681
+ cancelled = true;
30682
+ if (animationId) {
30683
+ cancelAnimationFrame(animationId);
30684
+ }
30685
+ if (onCancel) {
30686
+ onCancel();
30687
+ }
30688
+ };
30689
+ }
30690
+
30691
+ /**
30692
+ * Calculate smooth zoom delta with acceleration
30693
+ * Provides consistent zoom speed regardless of input device
30694
+ *
30695
+ * @param {number} currentScale - Current zoom scale
30696
+ * @param {number} delta - Input delta (wheel, gesture, etc)
30697
+ * @param {boolean} isTrackpad - Whether input is from trackpad
30698
+ * @returns {number} - Calculated zoom delta
30699
+ */
30700
+ function calculateZoomDelta(currentScale, delta, isTrackpad) {
30701
+ // Normalize delta across different input devices
30702
+ let normalizedDelta = delta;
30703
+
30704
+ if (isTrackpad) {
30705
+ // Trackpad deltas are typically smaller and more frequent
30706
+ normalizedDelta = delta * 0.005; // Reduced from 0.01 for gentler zoom
30707
+ } else {
30708
+ // Mouse wheel deltas are larger and less frequent
30709
+ // Reduce zoom out speed more than zoom in
30710
+ normalizedDelta = delta > 0 ? 0.06 : -0.08; // Reduced from 0.1, asymmetric for gentler zoom out
30711
+ }
30712
+
30713
+ // Apply gentler acceleration based on current zoom level
30714
+ // Less aggressive acceleration to prevent rapid zoom out
30715
+ const acceleration = Math.max(0.7, Math.min(1.1, 1 / currentScale)); // Reduced from 0.5-1.2 to 0.7-1.1
30716
+
30717
+ return normalizedDelta * acceleration;
30718
+ }
30719
+
30720
+ /**
30721
+ * Gesture state management for consistent focal points
30722
+ */
30723
+ const gestureState = {
30724
+ active: false,
30725
+ initialFocalPoint: null, // Will store workspace coordinates
30726
+ initialScale: 1,
30727
+ currentScale: 1,
30728
+ lastDistance: 0,
30729
+ scrollPosAtStart: null, // Store initial scroll position
30730
+ scaleFatorAtStart: 1 // Store initial scale factor
30731
+ };
30732
+
30733
+ /**
30734
+ * Start a zoom gesture with fixed focal point
30735
+ * @param {Array} focalPoint - [x, y] coordinates of focal point in workspace
30736
+ * @param {number} scale - Initial scale value
30737
+ * @param {Array} scrollPos - Current scroll position [x, y]
30738
+ * @param {number} currentScaleFactor - Current scale factor for coordinate conversion
30739
+ */
30740
+ function startGesture(focalPoint, scale, scrollPos, currentScaleFactor) {
30741
+ gestureState.active = true;
30742
+ // Store the focal point in workspace coordinates for stability
30743
+ // This ensures the point remains fixed even if scroll changes due to canvas edge constraints
30744
+ if (focalPoint && scrollPos && currentScaleFactor) {
30745
+ gestureState.initialFocalPoint = [
30746
+ (scrollPos[0] + focalPoint[0]) / currentScaleFactor,
30747
+ (scrollPos[1] + focalPoint[1]) / currentScaleFactor
30748
+ ];
30749
+ gestureState.scrollPosAtStart = [...scrollPos];
30750
+ gestureState.scaleFatorAtStart = currentScaleFactor;
30751
+ } else {
30752
+ gestureState.initialFocalPoint = focalPoint ? [...focalPoint] : null;
30753
+ }
30754
+ gestureState.initialScale = scale;
30755
+ gestureState.currentScale = scale;
30756
+ return gestureState;
30757
+ }
30758
+
30759
+ /**
30760
+ * Update gesture maintaining fixed focal point
30761
+ * @param {number} newScale - New scale value
30762
+ * @returns {Object} - Gesture state with fixed focal point
30763
+ */
30764
+ function updateGesture(newScale) {
30765
+ if (!gestureState.active) {
30766
+ return null;
30767
+ }
30768
+
30769
+ gestureState.currentScale = newScale;
30770
+
30771
+ return {
30772
+ scale: newScale,
30773
+ focalPoint: gestureState.initialFocalPoint,
30774
+ active: gestureState.active
30775
+ };
30776
+ }
30777
+
30778
+ /**
30779
+ * End the current gesture
30780
+ */
30781
+ function endGesture() {
30782
+ gestureState.active = false;
30783
+ gestureState.initialFocalPoint = null;
30784
+ gestureState.lastDistance = 0;
30785
+ }
30786
+
30787
+ /**
30788
+ * Check if a gesture is currently active
30789
+ */
30790
+ function isGestureActive() {
30791
+ return gestureState.active;
30792
+ }
30793
+
30794
+ /**
30795
+ * Get the fixed focal point for the current gesture
30796
+ * @param {Array} currentScrollPos - Current scroll position [x, y]
30797
+ * @param {number} currentScaleFactor - Current scale factor
30798
+ * @returns {Array} - Focal point in screen coordinates or null
30799
+ */
30800
+ function getGestureFocalPoint(currentScrollPos, currentScaleFactor) {
30801
+ if (!gestureState.initialFocalPoint) {
30802
+ return null;
30803
+ }
30804
+
30805
+ // If we stored workspace coordinates, convert back to screen coordinates
30806
+ if (gestureState.scrollPosAtStart && currentScrollPos && currentScaleFactor) {
30807
+ // Convert workspace coordinates back to current screen coordinates
30808
+ return [
30809
+ gestureState.initialFocalPoint[0] * currentScaleFactor - currentScrollPos[0],
30810
+ gestureState.initialFocalPoint[1] * currentScaleFactor - currentScrollPos[1]
30811
+ ];
30812
+ }
30813
+
30814
+ return gestureState.initialFocalPoint;
30815
+ }
30816
+
30817
+ return {
30818
+ easeOut: easeOut,
30819
+ easeToValuesRAF: easeToValuesRAF,
30820
+ calculateZoomDelta: calculateZoomDelta,
30821
+ gestureState: gestureState,
30822
+ startGesture: startGesture,
30823
+ updateGesture: updateGesture,
30824
+ endGesture: endGesture,
30825
+ isGestureActive: isGestureActive,
30826
+ getGestureFocalPoint: getGestureFocalPoint
30827
+ };
30828
+ })();;RED.view.annotations = (function() {
29705
30829
 
29706
30830
  var annotations = {};
29707
30831
 
@@ -29901,139 +31025,202 @@ RED.view = (function() {
29901
31025
  **/
29902
31026
 
29903
31027
 
29904
- RED.view.navigator = (function() {
29905
-
29906
- var nav_scale = 50;
29907
- var nav_width = 8000/nav_scale;
29908
- var nav_height = 8000/nav_scale;
29909
-
29910
- var navContainer;
29911
- var navBox;
29912
- var navBorder;
29913
- var navVis;
29914
- var scrollPos;
29915
- var scaleFactor;
29916
- var chartSize;
29917
- var dimensions;
29918
- var isDragging;
29919
- var isShowing = false;
29920
-
29921
- function refreshNodes() {
29922
- if (!isShowing) {
29923
- return;
29924
- }
29925
- var navNode = navVis.selectAll(".red-ui-navigator-node").data(RED.view.getActiveNodes(),function(d){return d.id});
29926
- navNode.exit().remove();
29927
- navNode.enter().insert("rect")
29928
- .attr('class','red-ui-navigator-node')
29929
- .attr("pointer-events", "none");
29930
- navNode.each(function(d) {
29931
- d3.select(this).attr("x",function(d) { return (d.x-d.w/2)/nav_scale })
29932
- .attr("y",function(d) { return (d.y-d.h/2)/nav_scale })
29933
- .attr("width",function(d) { return Math.max(9,d.w/nav_scale) })
29934
- .attr("height",function(d) { return Math.max(3,d.h/nav_scale) })
29935
- .attr("fill",function(d) { return RED.utils.getNodeColor(d.type,d._def);})
29936
- });
29937
- }
29938
- function onScroll() {
29939
- if (!isDragging) {
29940
- resizeNavBorder();
29941
- }
29942
- }
29943
- function resizeNavBorder() {
29944
- if (navBorder) {
29945
- scaleFactor = RED.view.scale();
29946
- chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
29947
- scrollPos = [$("#red-ui-workspace-chart").scrollLeft(),$("#red-ui-workspace-chart").scrollTop()];
29948
- navBorder.attr('x',scrollPos[0]/nav_scale)
29949
- .attr('y',scrollPos[1]/nav_scale)
29950
- .attr('width',chartSize[0]/nav_scale/scaleFactor)
29951
- .attr('height',chartSize[1]/nav_scale/scaleFactor)
29952
- }
29953
- }
29954
- function toggle() {
29955
- if (!isShowing) {
29956
- isShowing = true;
29957
- $("#red-ui-view-navigate").addClass("selected");
29958
- resizeNavBorder();
29959
- refreshNodes();
29960
- $("#red-ui-workspace-chart").on("scroll",onScroll);
29961
- navContainer.fadeIn(200);
29962
- } else {
29963
- isShowing = false;
29964
- navContainer.fadeOut(100);
29965
- $("#red-ui-workspace-chart").off("scroll",onScroll);
29966
- $("#red-ui-view-navigate").removeClass("selected");
29967
- }
29968
- }
29969
-
29970
- return {
29971
- init: function() {
29972
-
29973
- $(window).on("resize", resizeNavBorder);
29974
- RED.events.on("sidebar:resize",resizeNavBorder);
29975
- RED.actions.add("core:toggle-navigator",toggle);
29976
- var hideTimeout;
29977
-
29978
- navContainer = $('<div>').css({
29979
- "position":"absolute",
29980
- "bottom":$("#red-ui-workspace-footer").height(),
29981
- "right":0,
29982
- zIndex: 1
29983
- }).appendTo("#red-ui-workspace").hide();
29984
-
29985
- navBox = d3.select(navContainer[0])
29986
- .append("svg:svg")
29987
- .attr("width", nav_width)
29988
- .attr("height", nav_height)
29989
- .attr("pointer-events", "all")
29990
- .attr("id","red-ui-navigator-canvas")
29991
-
29992
- navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({
29993
- fill:"none",
29994
- stroke:"none",
29995
- pointerEvents:"all"
29996
- }).on("mousedown", function() {
29997
- // Update these in case they have changed
29998
- scaleFactor = RED.view.scale();
29999
- chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
30000
- dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor];
30001
- var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
30002
- var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
30003
- navBorder.attr('x',newX).attr('y',newY);
30004
- isDragging = true;
30005
- $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
30006
- $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
30007
- }).on("mousemove", function() {
30008
- if (!isDragging) { return }
30009
- if (d3.event.buttons === 0) {
30010
- isDragging = false;
30011
- return;
30012
- }
30013
- var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
30014
- var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
30015
- navBorder.attr('x',newX).attr('y',newY);
30016
- $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
30017
- $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
30018
- }).on("mouseup", function() {
30019
- isDragging = false;
30020
- })
30021
-
30022
- navBorder = navBox.append("rect").attr("class","red-ui-navigator-border")
30023
-
30024
- navVis = navBox.append("svg:g")
30025
-
30026
- RED.statusBar.add({
30027
- id: "view-navigator",
30028
- align: "right",
30029
- element: $('<button class="red-ui-footer-button-toggle single" id="red-ui-view-navigate"><i class="fa fa-map-o"></i></button>')
30030
- })
31028
+ RED.view.navigator = (function() {
31029
+ var nav_scale = 50;
31030
+ var nav_width = 8000/nav_scale;
31031
+ var nav_height = 8000/nav_scale;
31032
+ var navContainer;
31033
+ var navBox;
31034
+ var navBorder;
31035
+ var navVis;
31036
+ var scrollPos;
31037
+ var scaleFactor;
31038
+ var chartSize;
31039
+ var dimensions;
31040
+ var isDragging;
31041
+ var isShowing = false;
31042
+ var toggleTimeout;
31043
+ var autoHideTimeout;
31044
+ var isManuallyToggled = false;
31045
+ var isTemporaryShow = false;
31046
+ function refreshNodes() {
31047
+ if (!isShowing) {
31048
+ return;
31049
+ }
31050
+ var navNode = navVis.selectAll(".red-ui-navigator-node").data(RED.view.getActiveNodes(),function(d){return d.id});
31051
+ navNode.exit().remove();
31052
+ navNode.enter().insert("rect")
31053
+ .attr('class','red-ui-navigator-node')
31054
+ .attr("pointer-events", "none");
31055
+ navNode.each(function(d) {
31056
+ d3.select(this).attr("x",function(d) { return (d.x-d.w/2)/nav_scale })
31057
+ .attr("y",function(d) { return (d.y-d.h/2)/nav_scale })
31058
+ .attr("width",function(d) { return Math.max(9,d.w/nav_scale) })
31059
+ .attr("height",function(d) { return Math.max(3,d.h/nav_scale) })
31060
+ .attr("fill",function(d) { return RED.utils.getNodeColor(d.type,d._def);})
31061
+ });
31062
+ }
31063
+ function onScroll() {
31064
+ if (!isDragging) {
31065
+ resizeNavBorder();
31066
+ }
31067
+ }
31068
+ function resizeNavBorder() {
31069
+ if (navBorder) {
31070
+ scaleFactor = RED.view.scale();
31071
+ chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
31072
+ scrollPos = [$("#red-ui-workspace-chart").scrollLeft(),$("#red-ui-workspace-chart").scrollTop()];
31073
+ // Convert scroll position (in scaled pixels) to workspace coordinates, then to minimap coordinates
31074
+ // scrollPos is in scaled canvas pixels, divide by scaleFactor to get workspace coords
31075
+ navBorder.attr('x',scrollPos[0]/scaleFactor/nav_scale)
31076
+ .attr('y',scrollPos[1]/scaleFactor/nav_scale)
31077
+ .attr('width',chartSize[0]/nav_scale/scaleFactor)
31078
+ .attr('height',chartSize[1]/nav_scale/scaleFactor)
31079
+ }
31080
+ }
31081
+ function show () {
31082
+ if (!isShowing) {
31083
+ isShowing = true;
31084
+ clearTimeout(autoHideTimeout);
31085
+ $("#red-ui-view-navigate").addClass("selected");
31086
+ resizeNavBorder();
31087
+ refreshNodes();
31088
+ $("#red-ui-workspace-chart").on("scroll",onScroll);
31089
+ navContainer.addClass('red-ui-navigator-container');
31090
+ navContainer.show();
31091
+ clearTimeout(toggleTimeout)
31092
+ toggleTimeout = setTimeout(function() {
31093
+ navContainer.addClass('red-ui-navigator-visible');
31094
+ }, 10);
31095
+ }
31096
+ }
31097
+ function hide () {
31098
+ if (isShowing) {
31099
+ isShowing = false;
31100
+ isTemporaryShow = false;
31101
+ isManuallyToggled = false;
31102
+ clearTimeout(autoHideTimeout);
31103
+ navContainer.removeClass('red-ui-navigator-visible');
31104
+ clearTimeout(toggleTimeout)
31105
+ toggleTimeout = setTimeout(function() {
31106
+ navContainer.hide();
31107
+ }, 300);
31108
+ $("#red-ui-workspace-chart").off("scroll",onScroll);
31109
+ $("#red-ui-view-navigate").removeClass("selected");
31110
+ }
31111
+ }
31112
+ function toggle() {
31113
+ if (!isShowing) {
31114
+ isManuallyToggled = true;
31115
+ show()
31116
+ } else {
31117
+ isManuallyToggled = false;
31118
+ hide()
31119
+ }
31120
+ }
31121
+ function setupAutoHide () {
31122
+ clearTimeout(autoHideTimeout);
31123
+ autoHideTimeout = setTimeout(function() {
31124
+ hide()
31125
+ }, 2000)
31126
+ }
31127
+ function showTemporary() {
31128
+ if (!isManuallyToggled) {
31129
+ isTemporaryShow = true
31130
+ clearTimeout(autoHideTimeout);
31131
+ show()
31132
+ setupAutoHide()
31133
+ }
31134
+ }
31135
+ return {
31136
+ init: function() {
31137
+ $(window).on("resize", resizeNavBorder);
31138
+ RED.events.on("sidebar:resize",resizeNavBorder);
31139
+ RED.actions.add("core:toggle-navigator",toggle);
31140
+ navContainer = $('<div>').css({
31141
+ "position":"absolute",
31142
+ "bottom":$("#red-ui-workspace-footer").height(),
31143
+ "right":0,
31144
+ zIndex: 1
31145
+ }).addClass('red-ui-navigator-container').appendTo("#red-ui-workspace").hide();
31146
+ navBox = d3.select(navContainer[0])
31147
+ .append("svg:svg")
31148
+ .attr("width", nav_width)
31149
+ .attr("height", nav_height)
31150
+ .attr("pointer-events", "all")
31151
+ .attr("id","red-ui-navigator-canvas")
31152
+ navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({
31153
+ fill:"none",
31154
+ stroke:"none",
31155
+ pointerEvents:"all"
31156
+ }).on("mousedown", function() {
31157
+ // Update these in case they have changed
31158
+ scaleFactor = RED.view.scale();
31159
+ chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
31160
+ dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor];
31161
+ var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
31162
+ var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
31163
+ navBorder.attr('x',newX).attr('y',newY);
31164
+ isDragging = true;
31165
+ $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
31166
+ $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
31167
+ }).on("mousemove", function() {
31168
+ if (!isDragging) { return }
31169
+ if (d3.event.buttons === 0) {
31170
+ isDragging = false;
31171
+ return;
31172
+ }
31173
+ var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
31174
+ var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
31175
+ navBorder.attr('x',newX).attr('y',newY);
31176
+ $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
31177
+ $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
31178
+ }).on("mouseup", function() {
31179
+ isDragging = false;
31180
+ }).on("mouseenter", function () {
31181
+ if (isTemporaryShow) {
31182
+ // If user hovers over the minimap while it's temporarily shown, keep it shown
31183
+ clearTimeout(autoHideTimeout);
31184
+ }
31185
+ }).on("mouseleave", function () {
31186
+ if (isTemporaryShow) {
31187
+ // Restart the auto-hide timer after mouse leaves the minimap
31188
+ setupAutoHide()
31189
+ }
31190
+ })
31191
+ navBorder = navBox.append("rect").attr("class","red-ui-navigator-border")
31192
+ navVis = navBox.append("svg:g")
31193
+ RED.statusBar.add({
31194
+ id: "view-navigator",
31195
+ align: "right",
31196
+ element: $('<button class="red-ui-footer-button-toggle single" id="red-ui-view-navigate"><i class="fa fa-map-o"></i></button>')
31197
+ })
30031
31198
 
30032
31199
  $("#red-ui-view-navigate").on("click", function(evt) {
30033
31200
  evt.preventDefault();
30034
31201
  toggle();
30035
31202
  })
30036
31203
  RED.popover.tooltip($("#red-ui-view-navigate"),RED._('actions.toggle-navigator'),'core:toggle-navigator');
31204
+
31205
+ // Listen for canvas interactions to show minimap temporarily
31206
+ // Only show on actual pan/zoom navigation, not selection changes
31207
+ // RED.events.on("view:navigate", function() {
31208
+ // showTemporary();
31209
+ // });
31210
+
31211
+ // Show minimap briefly when workspace changes (includes initial load)
31212
+ // RED.events.on("workspace:change", function(event) {
31213
+ // // Only show if there's an active workspace with nodes
31214
+ // if (event.workspace && RED.nodes.getWorkspaceOrder().length > 0) {
31215
+ // // Small delay to ensure nodes are rendered
31216
+ // setTimeout(function() {
31217
+ // var activeNodes = RED.nodes.filterNodes({z: event.workspace});
31218
+ // if (activeNodes.length > 0) {
31219
+ // showTemporary();
31220
+ // }
31221
+ // }, 100);
31222
+ // }
31223
+ // });
30037
31224
  },
30038
31225
  refresh: refreshNodes,
30039
31226
  resize: resizeNavBorder,
@@ -31501,19 +32688,56 @@ RED.view.tools = (function() {
31501
32688
  * limitations under the License.
31502
32689
  **/
31503
32690
  RED.sidebar = (function() {
32691
+ const sidebars = {
32692
+ primary: {
32693
+ id: 'primary',
32694
+ direction: 'right',
32695
+ menuToggle: 'menu-item-sidebar',
32696
+ minimumWidth: 180,
32697
+ maximumWidth: 800,
32698
+ defaultWidth: 300
32699
+ },
32700
+ secondary: {
32701
+ id: 'secondary',
32702
+ direction: 'left',
32703
+ menuToggle: 'menu-item-palette',
32704
+ minimumWidth: 180,
32705
+ maximumWidth: 800,
32706
+ // Make LH side slightly narrower by default as its the palette that doesn't require a lot of width
32707
+ defaultWidth: 210
32708
+ }
32709
+ }
32710
+ const defaultSidebarConfiguration = {
32711
+ primary: ['info','debug','help','config','context'],
32712
+ secondary: ['palette']
32713
+ }
32714
+
32715
+ const knownTabs = {};
32716
+
32717
+ function exportSidebarState () {
32718
+ const state = {
32719
+ primary: [],
32720
+ secondary: []
32721
+ }
32722
+ sidebars.primary.tabBar.children('button').each(function() {
32723
+ const tabId = $(this).attr('data-tab-id');
32724
+ state.primary.push(tabId);
32725
+ })
32726
+ sidebars.secondary.tabBar.children('button').each(function() {
32727
+ const tabId = $(this).attr('data-tab-id');
32728
+ state.secondary.push(tabId);
32729
+ })
32730
+ RED.settings.set('editor.sidebar.state', state)
32731
+ }
31504
32732
 
31505
- //$('#sidebar').tabs();
31506
- var sidebar_tabs;
31507
- var knownTabs = {};
31508
32733
 
31509
32734
  // We store the current sidebar tab id in localStorage as 'last-sidebar-tab'
31510
32735
  // This is restored when the editor is reloaded.
31511
- // We use sidebar_tabs.onchange to update localStorage. However that will
32736
+ // We use sidebars.primary.tabs.onchange to update localStorage. However that will
31512
32737
  // also get triggered when the first tab gets added to the tabs - typically
31513
32738
  // the 'info' tab. So we use the following variable to store the retrieved
31514
32739
  // value from localStorage before we start adding the actual tabs
31515
- var lastSessionSelectedTab = null;
31516
-
32740
+ let lastSessionSelectedTabs = {}
31517
32741
 
31518
32742
  function addTab(title,content,closeable,visible) {
31519
32743
  var options;
@@ -31530,10 +32754,34 @@ RED.sidebar = (function() {
31530
32754
  } else if (typeof title === "object") {
31531
32755
  options = title;
31532
32756
  }
32757
+ options.target = options.target || 'primary';
32758
+ let targetTabButtonIndex = -1 // Append to end by default
31533
32759
 
32760
+ // Check the saved sidebar state to see if this tab should be added to the primary or secondary sidebar
32761
+ const savedState = RED.settings.get('editor.sidebar.state', defaultSidebarConfiguration)
32762
+ if (savedState) {
32763
+ let targetSidebar = null
32764
+ let sidebarState
32765
+ if (savedState.secondary.includes(options.id)) {
32766
+ options.target = 'secondary'
32767
+ sidebarState = savedState.secondary
32768
+ targetSidebar = sidebars.secondary
32769
+ } else if (savedState.primary.includes(options.id)) {
32770
+ options.target = 'primary'
32771
+ sidebarState = savedState.primary
32772
+ targetSidebar = sidebars.primary
32773
+ }
32774
+ if (targetSidebar) {
32775
+ // This tab was found in the saved sidebar state. Now find the target position for the tab button
32776
+ targetTabButtonIndex = sidebarState.indexOf(options.id)
32777
+ }
32778
+ }
32779
+
32780
+
32781
+ const targetSidebar = options.target === 'secondary' ? sidebars.secondary : sidebars.primary;
31534
32782
  delete options.closeable;
31535
32783
 
31536
- options.wrapper = $('<div>',{style:"height:100%"}).appendTo("#red-ui-sidebar-content")
32784
+ options.wrapper = $('<div>',{style:"height:100%"}).appendTo(targetSidebar.content)
31537
32785
  options.wrapper.append(options.content);
31538
32786
  options.wrapper.hide();
31539
32787
 
@@ -31542,11 +32790,12 @@ RED.sidebar = (function() {
31542
32790
  }
31543
32791
 
31544
32792
  if (options.toolbar) {
31545
- $("#red-ui-sidebar-footer").append(options.toolbar);
32793
+ targetSidebar.footer.append(options.toolbar);
31546
32794
  $(options.toolbar).hide();
31547
32795
  }
31548
32796
  var id = options.id;
31549
32797
 
32798
+ // console.log('menu', options.id, options.name)
31550
32799
  RED.menu.addItem("menu-item-view-menu",{
31551
32800
  id:"menu-item-view-menu-"+options.id,
31552
32801
  label:options.name,
@@ -31559,208 +32808,315 @@ RED.sidebar = (function() {
31559
32808
  options.iconClass = options.iconClass || "fa fa-square-o"
31560
32809
 
31561
32810
  knownTabs[options.id] = options;
32811
+ options.tabButton = $('<button></button>')
32812
+ // Insert the tab button at the correct index
32813
+ if (targetTabButtonIndex === -1 || targetTabButtonIndex >= targetSidebar.tabBar.children().length) {
32814
+ // Append to end
32815
+ options.tabButton = $('<button></button>').appendTo(targetSidebar.tabBar);
32816
+ } else {
32817
+ // Insert before the item at targetTabButtonIndex
32818
+ options.tabButton = $('<button></button>').insertBefore(targetSidebar.tabBar.children().eq(targetTabButtonIndex));
32819
+ }
32820
+ options.tabButton.attr('data-tab-id', options.id)
31562
32821
 
31563
- if (options.visible !== false) {
31564
- sidebar_tabs.addTab(knownTabs[options.id]);
32822
+ options.tabButtonTooltip = RED.popover.tooltip(options.tabButton, options.name, options.action);
32823
+ if (options.icon) {
32824
+ $('<i>',{class: 'red-ui-sidebar-tab-icon', style:"mask-image: url("+options.icon+"); -webkit-mask-image: url("+options.icon+");"}).appendTo(options.tabButton);
32825
+ } else if (options.iconClass) {
32826
+ $('<i>',{class:options.iconClass}).appendTo(options.tabButton);
32827
+ }
32828
+ options.tabButton.on('mouseup', function(evt) {
32829
+ if (draggingTabButton) {
32830
+ draggingTabButton = false
32831
+ return
32832
+ }
32833
+ const targetSidebar = options.target === 'secondary' ? sidebars.secondary : sidebars.primary;
32834
+ if (targetSidebar.activeTab === options.id && RED.menu.isSelected(targetSidebar.menuToggle)) {
32835
+ RED.menu.setSelected(targetSidebar.menuToggle, false);
32836
+ } else {
32837
+ RED.sidebar.show(options.id)
32838
+ }
32839
+ })
32840
+ if (targetSidebar.content.children().length === 1) {
32841
+ RED.sidebar.show(options.id)
31565
32842
  }
31566
32843
  }
31567
32844
 
31568
32845
  function removeTab(id) {
31569
- sidebar_tabs.removeTab(id);
31570
- $(knownTabs[id].wrapper).remove();
31571
- if (knownTabs[id].footer) {
31572
- knownTabs[id].footer.remove();
32846
+ if (knownTabs[id]) {
32847
+ const targetSidebar = knownTabs[id].target === 'secondary' ? sidebars.secondary : sidebars.primary;
32848
+ $(knownTabs[id].wrapper).remove();
32849
+ if (knownTabs[id].footer) {
32850
+ knownTabs[id].footer.remove();
32851
+ }
32852
+ targetSidebar.tabBar.find('button[data-tab-id="'+id+'"]').remove()
32853
+ RED.menu.removeItem("menu-item-view-menu-"+id);
32854
+ if (knownTabs[id].onremove) {
32855
+ knownTabs[id].onremove.call(knownTabs[id]);
32856
+ }
32857
+ delete knownTabs[id];
32858
+ const firstTab = targetSidebar.tabBar.find('button').first().attr('data-tab-id');
32859
+ if (firstTab) {
32860
+ RED.sidebar.show(firstTab);
32861
+ }
31573
32862
  }
31574
- delete knownTabs[id];
31575
- RED.menu.removeItem("menu-item-view-menu-"+id);
31576
32863
  }
31577
32864
 
31578
- var sidebarSeparator = {};
31579
- sidebarSeparator.dragging = false;
31580
-
31581
- function setupSidebarSeparator() {
31582
- $("#red-ui-sidebar-separator").draggable({
31583
- axis: "x",
31584
- start:function(event,ui) {
31585
- sidebarSeparator.closing = false;
31586
- sidebarSeparator.opening = false;
31587
- var winWidth = $("#red-ui-editor").width();
31588
- sidebarSeparator.start = ui.position.left;
31589
- sidebarSeparator.chartWidth = $("#red-ui-workspace").width();
31590
- sidebarSeparator.chartRight = winWidth-$("#red-ui-workspace").width()-$("#red-ui-workspace").offset().left-2;
31591
- sidebarSeparator.dragging = true;
31592
-
31593
- if (!RED.menu.isSelected("menu-item-sidebar")) {
31594
- sidebarSeparator.opening = true;
31595
- var newChartRight = 7;
31596
- $("#red-ui-sidebar").addClass("closing");
31597
- $("#red-ui-workspace").css("right",newChartRight);
31598
- $("#red-ui-editor-stack").css("right",newChartRight+1);
31599
- $("#red-ui-sidebar").width(0);
31600
- RED.menu.setSelected("menu-item-sidebar",true);
31601
- RED.events.emit("sidebar:resize");
31602
- }
31603
- sidebarSeparator.width = $("#red-ui-sidebar").width();
31604
- },
31605
- drag: function(event,ui) {
31606
- var d = ui.position.left-sidebarSeparator.start;
31607
- var newSidebarWidth = sidebarSeparator.width-d;
31608
- if (sidebarSeparator.opening) {
31609
- newSidebarWidth -= 3;
31610
- }
31611
-
31612
- if (newSidebarWidth > 150) {
31613
- if (sidebarSeparator.chartWidth+d < 200) {
31614
- ui.position.left = 200+sidebarSeparator.start-sidebarSeparator.chartWidth;
31615
- d = ui.position.left-sidebarSeparator.start;
31616
- newSidebarWidth = sidebarSeparator.width-d;
31617
- }
31618
- }
31619
-
31620
- if (newSidebarWidth < 150) {
31621
- if (!sidebarSeparator.closing) {
31622
- $("#red-ui-sidebar").addClass("closing");
31623
- sidebarSeparator.closing = true;
31624
- }
31625
- if (!sidebarSeparator.opening) {
31626
- newSidebarWidth = 150;
31627
- ui.position.left = sidebarSeparator.width-(150 - sidebarSeparator.start);
31628
- d = ui.position.left-sidebarSeparator.start;
31629
- }
31630
- } else if (newSidebarWidth > 150 && (sidebarSeparator.closing || sidebarSeparator.opening)) {
31631
- sidebarSeparator.closing = false;
31632
- $("#red-ui-sidebar").removeClass("closing");
32865
+ function moveTab(id, srcSidebar, targetSidebar) {
32866
+ const options = knownTabs[id];
32867
+ options.target = targetSidebar.id;
32868
+ $(options.wrapper).appendTo(targetSidebar.content);
32869
+ if (options.toolbar) {
32870
+ targetSidebar.footer.append(options.toolbar);
32871
+ }
32872
+ // Reset the tooltip so its left/right direction is recalculated
32873
+ options.tabButtonTooltip.delete()
32874
+ options.tabButtonTooltip = RED.popover.tooltip(options.tabButton, options.name, options.action);
32875
+
32876
+ if (targetSidebar.content.children().length === 1) {
32877
+ RED.sidebar.show(options.id)
32878
+ }
32879
+ if (srcSidebar.content.children().length === 0) {
32880
+ RED.menu.setSelected(srcSidebar.menuToggle, false);
32881
+ }
32882
+ }
32883
+
32884
+ let draggingTabButton = false
32885
+ function setupSidebarTabs(sidebar) {
32886
+ const tabBar = $('<div class="red-ui-sidebar-tab-bar"></div>').addClass('red-ui-sidebar-' + sidebar.direction);
32887
+ tabBar.attr('id', sidebar.container.attr('id') + '-tab-bar')
32888
+ tabBar.data('sidebar', sidebar.id)
32889
+ if (sidebar.direction === 'right') {
32890
+ tabBar.insertAfter(sidebar.container);
32891
+ } else if (sidebar.direction === 'left') {
32892
+ tabBar.insertBefore(sidebar.container);
32893
+ }
32894
+ tabBar.sortable({
32895
+ distance: 10,
32896
+ cancel: false,
32897
+ placeholder: "red-ui-sidebar-tab-bar-button-placeholder",
32898
+ connectWith: ".red-ui-sidebar-tab-bar",
32899
+ start: function(event, ui) {
32900
+ // Remove the tooltip so it doesn't display unexpectedly whilst dragging
32901
+ const tabId = ui.item.attr('data-tab-id');
32902
+ const options = knownTabs[tabId];
32903
+ options.tabButtonTooltip.delete()
32904
+ draggingTabButton = true
32905
+ tabBar.css('z-index','inherit')
32906
+ },
32907
+ stop: function(event, ui) {
32908
+ // Restore the tooltip
32909
+ const tabId = ui.item.attr('data-tab-id');
32910
+ const options = knownTabs[tabId];
32911
+ options.tabButtonTooltip.delete()
32912
+ options.tabButtonTooltip = RED.popover.tooltip(options.tabButton, options.name, options.action);
32913
+ // Save the sidebar state
32914
+ exportSidebarState()
32915
+ tabBar.css('z-index','')
32916
+ },
32917
+ receive: function(event, ui) {
32918
+ // Tab has been moved from one sidebar to another
32919
+ const src = sidebars[ui.sender.data('sidebar')]
32920
+ const dest = sidebars[$(this).data('sidebar')]
32921
+ const tabId = ui.item.attr('data-tab-id');
32922
+ moveTab(tabId, src, dest)
32923
+ if (ui.item.hasClass('selected')) {
32924
+ const firstTab = src.tabBar.find('button').first().attr('data-tab-id');
32925
+ if (firstTab) {
32926
+ RED.sidebar.show(firstTab);
31633
32927
  }
31634
-
31635
- var newChartRight = sidebarSeparator.chartRight-d;
31636
- $("#red-ui-workspace").css("right",newChartRight);
31637
- $("#red-ui-editor-stack").css("right",newChartRight+1);
31638
- $("#red-ui-sidebar").width(newSidebarWidth);
31639
-
31640
- sidebar_tabs.resize();
31641
- RED.events.emit("sidebar:resize");
31642
- },
31643
- stop:function(event,ui) {
31644
- sidebarSeparator.dragging = false;
31645
- if (sidebarSeparator.closing) {
31646
- $("#red-ui-sidebar").removeClass("closing");
31647
- RED.menu.setSelected("menu-item-sidebar",false);
31648
- if ($("#red-ui-sidebar").width() < 180) {
31649
- $("#red-ui-sidebar").width(180);
31650
- $("#red-ui-workspace").css("right",187);
31651
- $("#red-ui-editor-stack").css("right",188);
31652
- }
31653
- }
31654
- $("#red-ui-sidebar-separator").css("left","auto");
31655
- $("#red-ui-sidebar-separator").css("right",($("#red-ui-sidebar").width()+2)+"px");
32928
+ }
32929
+ RED.sidebar.show(tabId)
32930
+ }
32931
+ })
32932
+ // $(window).on("resize", function () {
32933
+ // const lastChild = tabBar.children().last();
32934
+ // if (lastChild.length > 0) {
32935
+ // const tabBarHeight = tabBar.height();
32936
+ // const lastChildBottom = lastChild.position().top + lastChild.outerHeight();
32937
+ // if (lastChildBottom > tabBarHeight) {
32938
+ // console.log('overflow')
32939
+ // }
32940
+ // }
32941
+ // })
32942
+ return tabBar
32943
+ }
32944
+ function setupSidebarSeparator(sidebar) {
32945
+ const separator = $('<div class="red-ui-sidebar-separator"></div>');
32946
+ separator.attr('id', sidebar.container.attr('id') + '-separator')
32947
+ $('<div class="red-ui-sidebar-shade hide"></div>').appendTo(separator);
32948
+ $('<div class="red-ui-sidebar-separator-handle"></div>').appendTo(separator);
32949
+ let scaleFactor = 1;
32950
+ if (sidebar.direction === 'right') {
32951
+ separator.insertBefore(sidebar.container);
32952
+ } else if (sidebar.direction === 'left') {
32953
+ scaleFactor = -1;
32954
+ separator.insertAfter(sidebar.container);
32955
+ }
32956
+ // Track sidebar state whilst dragging
32957
+ const sidebarSeparator = {}
32958
+ separator.draggable({
32959
+ axis: "x",
32960
+ start:function(event,ui) {
32961
+ sidebarSeparator.closing = false;
32962
+ sidebarSeparator.opening = false;
32963
+ // var winWidth = $("#red-ui-editor").width();
32964
+ sidebarSeparator.start = ui.position.left;
32965
+ sidebarSeparator.width = sidebar.container.width();
32966
+ sidebarSeparator.chartWidth = $("#red-ui-workspace").width();
32967
+ sidebarSeparator.dragging = true;
32968
+
32969
+ if (!RED.menu.isSelected(sidebar.menuToggle)) {
32970
+ sidebarSeparator.opening = true;
32971
+ sidebar.container.width(0);
32972
+ RED.menu.setSelected(sidebar.menuToggle,true);
31656
32973
  RED.events.emit("sidebar:resize");
31657
32974
  }
31658
- });
32975
+ sidebarSeparator.width = sidebar.container.width();
32976
+ },
32977
+ drag: function(event,ui) {
32978
+ var d = scaleFactor * (ui.position.left-sidebarSeparator.start);
31659
32979
 
31660
- var sidebarControls = $('<div class="red-ui-sidebar-control-right"><i class="fa fa-chevron-right"</div>').appendTo($("#red-ui-sidebar-separator"));
31661
- sidebarControls.on("click", function() {
31662
- sidebarControls.hide();
31663
- RED.menu.toggleSelected("menu-item-sidebar");
31664
- })
31665
- $("#red-ui-sidebar-separator").on("mouseenter", function() {
31666
- if (!sidebarSeparator.dragging) {
31667
- if (RED.menu.isSelected("menu-item-sidebar")) {
31668
- sidebarControls.find("i").addClass("fa-chevron-right").removeClass("fa-chevron-left");
32980
+ var newSidebarWidth = sidebarSeparator.width - d;
32981
+ if (newSidebarWidth > sidebar.maximumWidth) {
32982
+ newSidebarWidth = sidebar.maximumWidth;
32983
+ d = sidebarSeparator.width - sidebar.maximumWidth;
32984
+ ui.position.left = sidebarSeparator.start + scaleFactor * d;
32985
+ }
32986
+
32987
+ if (newSidebarWidth > sidebar.minimumWidth) {
32988
+ if (sidebarSeparator.chartWidth + d < 200) {
32989
+ // Chart is now too small, but we have room to resize the sidebar
32990
+ d += (200 - (sidebarSeparator.chartWidth + d));
32991
+ newSidebarWidth = sidebarSeparator.width - d;
32992
+ ui.position.left = sidebarSeparator.start + scaleFactor * d;
32993
+ }
32994
+ } else if (newSidebarWidth < sidebar.minimumWidth) {
32995
+ if (newSidebarWidth > 100) {
32996
+ newSidebarWidth = sidebar.minimumWidth
32997
+ sidebarSeparator.closing = false
32998
+ } else {
32999
+ newSidebarWidth = 0
33000
+ sidebarSeparator.closing = true
33001
+ }
31669
33002
  } else {
31670
- sidebarControls.find("i").removeClass("fa-chevron-right").addClass("fa-chevron-left");
33003
+ sidebarSeparator.closing = false
31671
33004
  }
31672
- sidebarControls.toggle("slide", { direction: "right" }, 200);
31673
- }
31674
- })
31675
- $("#red-ui-sidebar-separator").on("mouseleave", function() {
31676
- if (!sidebarSeparator.dragging) {
31677
- sidebarControls.stop(false,true);
31678
- sidebarControls.hide();
33005
+ sidebar.container.width(newSidebarWidth);
33006
+ ui.position.left -= scaleFactor * d
33007
+
33008
+ // sidebar.tabs.resize();
33009
+ RED.events.emit("sidebar:resize");
33010
+ },
33011
+ stop:function(event,ui) {
33012
+ sidebarSeparator.dragging = false;
33013
+ if (sidebarSeparator.closing) {
33014
+ sidebar.container.removeClass("closing");
33015
+ if (sidebar.menuToggle) {
33016
+ RED.menu.setSelected(sidebar.menuToggle,false);
33017
+ }
33018
+ sidebar.container.hide()
33019
+ sidebar.separator.hide()
33020
+ if (sidebar.container.width() < sidebar.minimumWidth) {
33021
+ sidebar.container.width(sidebar.defaultWidth);
33022
+ }
33023
+ }
33024
+ RED.events.emit("sidebar:resize");
31679
33025
  }
31680
33026
  });
33027
+ return separator
31681
33028
  }
31682
33029
 
31683
- function toggleSidebar(state) {
33030
+ function toggleSidebar(sidebar, state) {
31684
33031
  if (!state) {
31685
- $("#red-ui-main-container").addClass("red-ui-sidebar-closed");
33032
+ sidebar.container.hide()
33033
+ sidebar.separator.hide()
33034
+ sidebar.tabBar.find('button').removeClass('selected')
31686
33035
  } else {
31687
- $("#red-ui-main-container").removeClass("red-ui-sidebar-closed");
31688
- sidebar_tabs.resize();
33036
+ sidebar.container.show()
33037
+ sidebar.separator.show()
31689
33038
  }
31690
33039
  RED.events.emit("sidebar:resize");
31691
33040
  }
31692
33041
 
31693
33042
  function showSidebar(id, skipShowSidebar) {
31694
33043
  if (id === ":first") {
31695
- id = lastSessionSelectedTab || RED.settings.get("editor.sidebar.order",["info", "help", "version-control", "debug"])[0]
33044
+ // Show the last selected tab for each sidebar
33045
+ Object.keys(sidebars).forEach(function(sidebarKey) {
33046
+ const sidebar = sidebars[sidebarKey];
33047
+ let lastTabId = lastSessionSelectedTabs[sidebarKey];
33048
+ if (!lastTabId) {
33049
+ lastTabId = sidebar.tabBar.children('button').first().attr('data-tab-id');
33050
+ }
33051
+ showSidebar(lastTabId, true)
33052
+ })
33053
+ return
31696
33054
  }
31697
33055
  if (id) {
31698
- if (!containsTab(id) && knownTabs[id]) {
31699
- sidebar_tabs.addTab(knownTabs[id]);
31700
- }
31701
- sidebar_tabs.activateTab(id);
31702
- if (!skipShowSidebar && !RED.menu.isSelected("menu-item-sidebar")) {
31703
- RED.menu.setSelected("menu-item-sidebar",true);
33056
+ const tabOptions = knownTabs[id];
33057
+ if (tabOptions) {
33058
+ const targetSidebar = tabOptions.target === 'secondary' ? sidebars.secondary : sidebars.primary;
33059
+ targetSidebar.content.children().hide();
33060
+ targetSidebar.footer.children().hide();
33061
+ if (tabOptions.onchange) {
33062
+ tabOptions.onchange.call(tabOptions);
33063
+ }
33064
+ $(tabOptions.wrapper).show();
33065
+ if (tabOptions.toolbar) {
33066
+ $(tabOptions.toolbar).show();
33067
+ }
33068
+ RED.settings.setLocal("last-sidebar-tab-" + targetSidebar.id, tabOptions.id)
33069
+ targetSidebar.tabBar.find('button').removeClass('selected')
33070
+ targetSidebar.tabBar.find('button[data-tab-id="'+id+'"]').addClass('selected')
33071
+ targetSidebar.activeTab = id
33072
+
33073
+ if (!skipShowSidebar && !RED.menu.isSelected(targetSidebar.menuToggle)) {
33074
+ RED.menu.setSelected(targetSidebar.menuToggle,true);
33075
+ }
31704
33076
  }
31705
33077
  }
31706
33078
  }
31707
33079
 
31708
33080
  function containsTab(id) {
31709
- return sidebar_tabs.contains(id);
33081
+ return sidebars.primary.tabs.contains(id);
31710
33082
  }
31711
33083
 
31712
- function init () {
31713
- setupSidebarSeparator();
31714
- sidebar_tabs = RED.tabs.create({
31715
- element: $('<ul id="red-ui-sidebar-tabs"></ul>').appendTo("#red-ui-sidebar"),
31716
- onchange:function(tab) {
31717
- $("#red-ui-sidebar-content").children().hide();
31718
- $("#red-ui-sidebar-footer").children().hide();
31719
- if (tab.onchange) {
31720
- tab.onchange.call(tab);
31721
- }
31722
- $(tab.wrapper).show();
31723
- if (tab.toolbar) {
31724
- $(tab.toolbar).show();
31725
- }
31726
- RED.settings.setLocal("last-sidebar-tab", tab.id)
31727
- },
31728
- onremove: function(tab) {
31729
- $(tab.wrapper).hide();
31730
- if (tab.onremove) {
31731
- tab.onremove.call(tab);
31732
- }
31733
- },
31734
- // minimumActiveTabWidth: 70,
31735
- collapsible: true,
31736
- onreorder: function(order) {
31737
- RED.settings.set("editor.sidebar.order",order);
31738
- },
31739
- order: RED.settings.get("editor.sidebar.order",["info", "help", "version-control", "debug"])
31740
- // scrollable: true
31741
- });
33084
+ function setupSidebar(sidebar) {
33085
+ sidebar.container.addClass("red-ui-sidebar").addClass('red-ui-sidebar-' + sidebar.direction);
33086
+ sidebar.container.width(sidebar.defaultWidth);
33087
+ sidebar.separator = setupSidebarSeparator(sidebar);
33088
+ sidebar.tabBar = setupSidebarTabs(sidebar)
33089
+ sidebar.content = $('<div class="red-ui-sidebar-content"></div>').appendTo(sidebar.container);
33090
+ sidebar.footer = $('<div class="red-ui-sidebar-footer"></div>').appendTo(sidebar.container);
33091
+ sidebar.shade = $('<div class="red-ui-sidebar-shade hide"></div>').appendTo(sidebar.container);
31742
33092
 
31743
- $('<div id="red-ui-sidebar-content"></div>').appendTo("#red-ui-sidebar");
31744
- $('<div id="red-ui-sidebar-footer" class="red-ui-component-footer"></div>').appendTo("#red-ui-sidebar");
31745
- $('<div id="red-ui-sidebar-shade" class="hide"></div>').appendTo("#red-ui-sidebar");
33093
+ }
33094
+ function init () {
33095
+ sidebars.primary.container = $("#red-ui-sidebar");
33096
+ setupSidebar(sidebars.primary)
33097
+ sidebars.secondary.container = $("#red-ui-sidebar-left");
33098
+ setupSidebar(sidebars.secondary)
31746
33099
 
31747
33100
  RED.actions.add("core:toggle-sidebar",function(state){
31748
33101
  if (state === undefined) {
31749
- RED.menu.toggleSelected("menu-item-sidebar");
33102
+ RED.menu.toggleSelected(sidebars.primary.menuToggle);
31750
33103
  } else {
31751
- toggleSidebar(state);
33104
+ toggleSidebar(sidebars.primary, state);
33105
+ }
33106
+ });
33107
+ RED.actions.add("core:toggle-palette", function(state) {
33108
+ if (state === undefined) {
33109
+ RED.menu.toggleSelected(sidebars.secondary.menuToggle);
33110
+ } else {
33111
+ toggleSidebar(sidebars.secondary, state);
31752
33112
  }
31753
33113
  });
31754
- RED.popover.tooltip($("#red-ui-sidebar-separator").find(".red-ui-sidebar-control-right"),RED._("keyboard.toggleSidebar"),"core:toggle-sidebar");
31755
-
31756
- lastSessionSelectedTab = RED.settings.getLocal("last-sidebar-tab")
31757
33114
 
31758
- RED.sidebar.info.init();
31759
- RED.sidebar.help.init();
31760
- RED.sidebar.config.init();
31761
- RED.sidebar.context.init();
31762
- // hide info bar at start if screen rather narrow...
31763
- if ($("#red-ui-editor").width() < 600) { RED.menu.setSelected("menu-item-sidebar",false); }
33115
+ // Remember the last selected tab for each sidebar before
33116
+ // the tabs are readded causing the state to get updated
33117
+ Object.keys(sidebars).forEach(function(sidebarKey) {
33118
+ lastSessionSelectedTabs[sidebarKey] = RED.settings.getLocal("last-sidebar-tab-" + sidebarKey)
33119
+ })
31764
33120
  }
31765
33121
 
31766
33122
  return {
@@ -31808,7 +33164,6 @@ RED.palette = (function() {
31808
33164
  ];
31809
33165
 
31810
33166
  var categoryContainers = {};
31811
- var sidebarControls;
31812
33167
 
31813
33168
  let paletteState = { filter: "", collapsed: [] };
31814
33169
 
@@ -32085,6 +33440,7 @@ RED.palette = (function() {
32085
33440
  width: "300px",
32086
33441
  content: "hi",
32087
33442
  delay: { show: 750, hide: 50 }
33443
+ // direction: "left"
32088
33444
  });
32089
33445
 
32090
33446
  d.data('popover',popover);
@@ -32107,7 +33463,8 @@ RED.palette = (function() {
32107
33463
  revert: 'invalid',
32108
33464
  revertDuration: 200,
32109
33465
  containment:'#red-ui-main-container',
32110
- start: function() {
33466
+ start: function(e, ui) {
33467
+ ui.helper.css('z-index', 1000);
32111
33468
  dropEnabled = !(RED.nodes.workspace(RED.workspaces.active())?.locked);
32112
33469
  paletteWidth = $("#red-ui-palette").width();
32113
33470
  paletteTop = $("#red-ui-palette").parent().position().top + $("#red-ui-palette-container").position().top;
@@ -32133,7 +33490,9 @@ RED.palette = (function() {
32133
33490
  },
32134
33491
  drag: function(e,ui) {
32135
33492
  var paletteNode = getPaletteNode(nt);
32136
- ui.originalPosition.left = paletteNode.offset().left;
33493
+ console.log(ui.originalPosition.left, paletteNode.offset().left)
33494
+ // ui.originalPosition.left = paletteNode.offset().left;
33495
+ // console.log(paletteNode.offset())
32137
33496
  if (dropEnabled) {
32138
33497
  mouseX = ui.position.left - paletteWidth + (ui.helper.width()/2) + chart.scrollLeft();
32139
33498
  mouseY = ui.position.top - paletteTop + (ui.helper.height()/2) + chart.scrollTop() + 10;
@@ -32382,11 +33741,24 @@ RED.palette = (function() {
32382
33741
 
32383
33742
  function init() {
32384
33743
 
33744
+ const content = $('<div id="red-ui-palette" class="red-ui-sidebar-tab-content">')
33745
+ const toolbar = $('<div></div>');
33746
+ RED.sidebar.addTab({
33747
+ target: 'secondary',
33748
+ id: "palette",
33749
+ label: "Palette",
33750
+ name: "Palette",
33751
+ icon: "red/images/subflow_tab.svg",
33752
+ content,
33753
+ toolbar,
33754
+ pinned: true,
33755
+ enableOnEdit: true
33756
+ });
33757
+
32385
33758
  $('<img src="red/images/spin.svg" class="red-ui-palette-spinner hide"/>').appendTo("#red-ui-palette");
32386
33759
  $('<div id="red-ui-palette-search" class="red-ui-palette-search hide"><input type="text" data-i18n="[placeholder]palette.filter"></input></div>').appendTo("#red-ui-palette");
32387
33760
  $('<div id="red-ui-palette-container" class="red-ui-palette-scroll hide"></div>').appendTo("#red-ui-palette");
32388
- $('<div class="red-ui-component-footer"></div>').appendTo("#red-ui-palette");
32389
- $('<div id="red-ui-palette-shade" class="hide"></div>').appendTo("#red-ui-palette");
33761
+ // $('<div id="red-ui-palette-shade" class="hide"></div>').appendTo("#red-ui-palette");
32390
33762
 
32391
33763
  $("#red-ui-palette > .red-ui-palette-spinner").show();
32392
33764
 
@@ -32445,19 +33817,6 @@ RED.palette = (function() {
32445
33817
  }
32446
33818
  });
32447
33819
 
32448
- sidebarControls = $('<div class="red-ui-sidebar-control-left"><i class="fa fa-chevron-left"></i></div>').appendTo($("#red-ui-palette"));
32449
- RED.popover.tooltip(sidebarControls,RED._("keyboard.togglePalette"),"core:toggle-palette");
32450
-
32451
- sidebarControls.on("click", function() {
32452
- RED.menu.toggleSelected("menu-item-palette");
32453
- })
32454
- $("#red-ui-palette").on("mouseenter", function() {
32455
- sidebarControls.toggle("slide", { direction: "left" }, 200);
32456
- })
32457
- $("#red-ui-palette").on("mouseleave", function() {
32458
- sidebarControls.stop(false,true);
32459
- sidebarControls.hide();
32460
- })
32461
33820
  var userCategories = [];
32462
33821
  if (RED.settings.paletteCategories) {
32463
33822
  userCategories = RED.settings.paletteCategories;
@@ -32479,7 +33838,7 @@ RED.palette = (function() {
32479
33838
  }
32480
33839
  });
32481
33840
 
32482
- var paletteFooterButtons = $('<span class="button-group"></span>').appendTo("#red-ui-palette .red-ui-component-footer");
33841
+ var paletteFooterButtons = $('<span class="button-group"></span>').appendTo(toolbar);
32483
33842
  var paletteCollapseAll = $('<button type="button" class="red-ui-footer-button"><i class="fa fa-angle-double-up"></i></button>').appendTo(paletteFooterButtons);
32484
33843
  paletteCollapseAll.on("click", function(e) {
32485
33844
  e.preventDefault();
@@ -32502,13 +33861,7 @@ RED.palette = (function() {
32502
33861
  });
32503
33862
  RED.popover.tooltip(paletteExpandAll,RED._('palette.actions.expand-all'));
32504
33863
 
32505
- RED.actions.add("core:toggle-palette", function(state) {
32506
- if (state === undefined) {
32507
- RED.menu.toggleSelected("menu-item-palette");
32508
- } else {
32509
- togglePalette(state);
32510
- }
32511
- });
33864
+
32512
33865
 
32513
33866
  try {
32514
33867
  paletteState = JSON.parse(RED.settings.getLocal("palette-state") || '{"filter":"", "collapsed": []}');
@@ -32526,18 +33879,6 @@ RED.palette = (function() {
32526
33879
  }, 10000)
32527
33880
  }
32528
33881
 
32529
- function togglePalette(state) {
32530
- if (!state) {
32531
- $("#red-ui-main-container").addClass("red-ui-palette-closed");
32532
- sidebarControls.hide();
32533
- sidebarControls.find("i").addClass("fa-chevron-right").removeClass("fa-chevron-left");
32534
- } else {
32535
- $("#red-ui-main-container").removeClass("red-ui-palette-closed");
32536
- sidebarControls.find("i").removeClass("fa-chevron-right").addClass("fa-chevron-left");
32537
- }
32538
- setTimeout(function() { $(window).trigger("resize"); } ,200);
32539
- }
32540
-
32541
33882
  function getCategories() {
32542
33883
  var categories = [];
32543
33884
  $("#red-ui-palette-container .red-ui-palette-category").each(function(i,d) {
@@ -32685,9 +34026,10 @@ RED.sidebar.info = (function() {
32685
34026
 
32686
34027
  RED.sidebar.addTab({
32687
34028
  id: "info",
34029
+ // target: "secondary",
32688
34030
  label: RED._("sidebar.info.label"),
32689
34031
  name: RED._("sidebar.info.name"),
32690
- iconClass: "fa fa-info",
34032
+ icon: "red/images/explorer.svg",
32691
34033
  action:"core:show-info-tab",
32692
34034
  content: content,
32693
34035
  pinned: true,
@@ -32723,6 +34065,8 @@ RED.sidebar.info = (function() {
32723
34065
  tips.stop();
32724
34066
  }
32725
34067
 
34068
+ resizeStack();
34069
+
32726
34070
  }
32727
34071
 
32728
34072
  function show() {
@@ -40019,6 +41363,9 @@ RED.editor = (function() {
40019
41363
  changes.inputLabels = node.inputLabels;
40020
41364
  node.inputLabels = newValue;
40021
41365
  changed = true;
41366
+ if (node.type === "subflow") {
41367
+ node.in[0].dirty = true
41368
+ }
40022
41369
  }
40023
41370
  hasNonBlankLabel = false;
40024
41371
  newValue = new Array(node.outputs);
@@ -45668,6 +47015,8 @@ RED.eventLog = (function() {
45668
47015
  }
45669
47016
 
45670
47017
  function handleWindowResize() {
47018
+ let sidebarWidth = $("#red-ui-sidebar").is(":visible") ? $("#red-ui-sidebar").outerWidth() + $("#red-ui-sidebar-separator").outerWidth() : 0;
47019
+ $("#red-ui-editor-stack").css('right', sidebarWidth + $("#red-ui-sidebar-tab-bar").outerWidth() + 1);
45671
47020
  if (stack.length > 0) {
45672
47021
  var tray = stack[stack.length-1];
45673
47022
  if (tray.options.maximized || tray.width > $("#red-ui-editor-stack").position().left-8) {
@@ -48829,6 +50178,12 @@ RED.search = (function() {
48829
50178
  $('<div>',{class:"red-ui-search-result-node-type"}).text(node.type).appendTo(contentDiv);
48830
50179
  $('<div>',{class:"red-ui-search-result-node-id"}).text(node.id).appendTo(contentDiv);
48831
50180
 
50181
+ div.on("mouseover", function(evt) {
50182
+ if ( node.z == RED.workspaces.active() ) {
50183
+ RED.view.reveal(node.id)
50184
+ }
50185
+ });
50186
+
48832
50187
  div.on("click", function(evt) {
48833
50188
  evt.preventDefault();
48834
50189
  currentIndex = i;
@@ -48907,8 +50262,7 @@ RED.search = (function() {
48907
50262
  $("#red-ui-header-shade").show();
48908
50263
  $("#red-ui-editor-shade").show();
48909
50264
  $("#red-ui-palette-shade").show();
48910
- $("#red-ui-sidebar-shade").show();
48911
- $("#red-ui-sidebar-separator").hide();
50265
+ $(".red-ui-sidebar-shade").show();
48912
50266
 
48913
50267
  if (dialog === null) {
48914
50268
  createDialog();
@@ -48932,8 +50286,7 @@ RED.search = (function() {
48932
50286
  $("#red-ui-header-shade").hide();
48933
50287
  $("#red-ui-editor-shade").hide();
48934
50288
  $("#red-ui-palette-shade").hide();
48935
- $("#red-ui-sidebar-shade").hide();
48936
- $("#red-ui-sidebar-separator").show();
50289
+ $(".red-ui-sidebar-shade").hide();
48937
50290
  if (dialog !== null) {
48938
50291
  dialog.slideUp(200,function() {
48939
50292
  searchInput.searchBox('value','');
@@ -49033,7 +50386,7 @@ RED.search = (function() {
49033
50386
  $("#red-ui-header-shade").on('mousedown',hide);
49034
50387
  $("#red-ui-editor-shade").on('mousedown',hide);
49035
50388
  $("#red-ui-palette-shade").on('mousedown',hide);
49036
- $("#red-ui-sidebar-shade").on('mousedown',hide);
50389
+ $(".red-ui-sidebar-shade").on('mousedown',hide);
49037
50390
 
49038
50391
  $("#red-ui-view-searchtools-close").on("click", function close() {
49039
50392
  clearActiveSearch();
@@ -49295,6 +50648,11 @@ RED.search = (function() {
49295
50648
  { onselect: 'core:show-export-dialog', label: RED._("menu.label.export") }
49296
50649
  )
49297
50650
  }
50651
+ if (hasSelection && canEdit) {
50652
+ menuItems.push(
50653
+ { onselect: 'core:convert-to-subflow', label: RED._("menu.label.selectionToSubflow") }
50654
+ )
50655
+ }
49298
50656
  menuItems.push(
49299
50657
  { onselect: 'core:select-all-nodes', label: RED._("keyboard.selectAll") }
49300
50658
  )
@@ -49519,8 +50877,7 @@ RED.actionList = (function() {
49519
50877
  $("#red-ui-header-shade").show();
49520
50878
  $("#red-ui-editor-shade").show();
49521
50879
  $("#red-ui-palette-shade").show();
49522
- $("#red-ui-sidebar-shade").show();
49523
- $("#red-ui-sidebar-separator").hide();
50880
+ $(".red-ui-sidebar-shade").show();
49524
50881
  if (dialog === null) {
49525
50882
  createDialog();
49526
50883
  }
@@ -49554,8 +50911,7 @@ RED.actionList = (function() {
49554
50911
  $("#red-ui-header-shade").hide();
49555
50912
  $("#red-ui-editor-shade").hide();
49556
50913
  $("#red-ui-palette-shade").hide();
49557
- $("#red-ui-sidebar-shade").hide();
49558
- $("#red-ui-sidebar-separator").show();
50914
+ $(".red-ui-sidebar-shade").hide();
49559
50915
  if (dialog !== null) {
49560
50916
  dialog.slideUp(200,function() {
49561
50917
  searchInput.searchBox('value','');
@@ -49587,7 +50943,7 @@ RED.actionList = (function() {
49587
50943
  $("#red-ui-header-shade").on('mousedown',hide);
49588
50944
  $("#red-ui-editor-shade").on('mousedown',hide);
49589
50945
  $("#red-ui-palette-shade").on('mousedown',hide);
49590
- $("#red-ui-sidebar-shade").on('mousedown',hide);
50946
+ $(".red-ui-sidebar-shade").on('mousedown',hide);
49591
50947
  }
49592
50948
 
49593
50949
  return {
@@ -52409,7 +53765,7 @@ RED.userSettings = (function() {
52409
53765
  });
52410
53766
  settingsContent.i18n();
52411
53767
  settingsTabs.activateTab("red-ui-settings-tab-"+(initialTab||'view'))
52412
- $("#red-ui-sidebar-shade").show();
53768
+ $(".red-ui-sidebar-shade").show();
52413
53769
  },
52414
53770
  close: function() {
52415
53771
  settingsVisible = false;
@@ -52418,7 +53774,7 @@ RED.userSettings = (function() {
52418
53774
  pane.close();
52419
53775
  }
52420
53776
  });
52421
- $("#red-ui-sidebar-shade").hide();
53777
+ $(".red-ui-sidebar-shade").hide();
52422
53778
 
52423
53779
  },
52424
53780
  show: function() {}
@@ -55211,7 +56567,7 @@ RED.projects.settings = (function() {
55211
56567
  });
55212
56568
  settingsContent.i18n();
55213
56569
  settingsTabs.activateTab("red-ui-project-settings-tab-"+(initialTab||'main'))
55214
- $("#red-ui-sidebar-shade").show();
56570
+ $(".red-ui-sidebar-shade").show();
55215
56571
  },
55216
56572
  close: function() {
55217
56573
  settingsVisible = false;
@@ -55220,7 +56576,7 @@ RED.projects.settings = (function() {
55220
56576
  pane.close();
55221
56577
  }
55222
56578
  });
55223
- $("#red-ui-sidebar-shade").hide();
56579
+ $(".red-ui-sidebar-shade").hide();
55224
56580
 
55225
56581
  },
55226
56582
  show: function() {}
@@ -59234,10 +60590,15 @@ RED.touch.radialMenu = (function() {
59234
60590
 
59235
60591
  function listTour() {
59236
60592
  return [
60593
+ {
60594
+ id: "5_0",
60595
+ label: "5.0",
60596
+ path: "./tours/welcome.js"
60597
+ },
59237
60598
  {
59238
60599
  id: "4_1",
59239
60600
  label: "4.1",
59240
- path: "./tours/welcome.js"
60601
+ path: "./tours/4.1/welcome.js"
59241
60602
  },
59242
60603
  {
59243
60604
  id: "4_0",