').css({
- "position":"absolute",
- "bottom":$("#red-ui-workspace-footer").height(),
- "right":0,
- zIndex: 1
- }).appendTo("#red-ui-workspace").hide();
-
- navBox = d3.select(navContainer[0])
- .append("svg:svg")
- .attr("width", nav_width)
- .attr("height", nav_height)
- .attr("pointer-events", "all")
- .attr("id","red-ui-navigator-canvas")
-
- navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({
- fill:"none",
- stroke:"none",
- pointerEvents:"all"
- }).on("mousedown", function() {
- // Update these in case they have changed
- scaleFactor = RED.view.scale();
- chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
- dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor];
- var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
- var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
- navBorder.attr('x',newX).attr('y',newY);
- isDragging = true;
- $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
- $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
- }).on("mousemove", function() {
- if (!isDragging) { return }
- if (d3.event.buttons === 0) {
- isDragging = false;
- return;
- }
- var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
- var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
- navBorder.attr('x',newX).attr('y',newY);
- $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
- $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
- }).on("mouseup", function() {
- isDragging = false;
- })
-
- navBorder = navBox.append("rect").attr("class","red-ui-navigator-border")
-
- navVis = navBox.append("svg:g")
-
- RED.statusBar.add({
- id: "view-navigator",
- align: "right",
- element: $('')
- })
+RED.view.navigator = (function() {
+ var nav_scale = 50;
+ var nav_width = 8000/nav_scale;
+ var nav_height = 8000/nav_scale;
+ var navContainer;
+ var navBox;
+ var navBorder;
+ var navVis;
+ var scrollPos;
+ var scaleFactor;
+ var chartSize;
+ var dimensions;
+ var isDragging;
+ var isShowing = false;
+ var toggleTimeout;
+ var autoHideTimeout;
+ var isManuallyToggled = false;
+ var isTemporaryShow = false;
+ function refreshNodes() {
+ if (!isShowing) {
+ return;
+ }
+ var navNode = navVis.selectAll(".red-ui-navigator-node").data(RED.view.getActiveNodes(),function(d){return d.id});
+ navNode.exit().remove();
+ navNode.enter().insert("rect")
+ .attr('class','red-ui-navigator-node')
+ .attr("pointer-events", "none");
+ navNode.each(function(d) {
+ d3.select(this).attr("x",function(d) { return (d.x-d.w/2)/nav_scale })
+ .attr("y",function(d) { return (d.y-d.h/2)/nav_scale })
+ .attr("width",function(d) { return Math.max(9,d.w/nav_scale) })
+ .attr("height",function(d) { return Math.max(3,d.h/nav_scale) })
+ .attr("fill",function(d) { return RED.utils.getNodeColor(d.type,d._def);})
+ });
+ }
+ function onScroll() {
+ if (!isDragging) {
+ resizeNavBorder();
+ }
+ }
+ function resizeNavBorder() {
+ if (navBorder) {
+ scaleFactor = RED.view.scale();
+ chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
+ scrollPos = [$("#red-ui-workspace-chart").scrollLeft(),$("#red-ui-workspace-chart").scrollTop()];
+ // Convert scroll position (in scaled pixels) to workspace coordinates, then to minimap coordinates
+ // scrollPos is in scaled canvas pixels, divide by scaleFactor to get workspace coords
+ navBorder.attr('x',scrollPos[0]/scaleFactor/nav_scale)
+ .attr('y',scrollPos[1]/scaleFactor/nav_scale)
+ .attr('width',chartSize[0]/nav_scale/scaleFactor)
+ .attr('height',chartSize[1]/nav_scale/scaleFactor)
+ }
+ }
+ function show () {
+ if (!isShowing) {
+ isShowing = true;
+ clearTimeout(autoHideTimeout);
+ $("#red-ui-view-navigate").addClass("selected");
+ resizeNavBorder();
+ refreshNodes();
+ $("#red-ui-workspace-chart").on("scroll",onScroll);
+ navContainer.addClass('red-ui-navigator-container');
+ navContainer.show();
+ clearTimeout(toggleTimeout)
+ toggleTimeout = setTimeout(function() {
+ navContainer.addClass('red-ui-navigator-visible');
+ }, 10);
+ }
+ }
+ function hide () {
+ if (isShowing) {
+ isShowing = false;
+ isTemporaryShow = false;
+ isManuallyToggled = false;
+ clearTimeout(autoHideTimeout);
+ navContainer.removeClass('red-ui-navigator-visible');
+ clearTimeout(toggleTimeout)
+ toggleTimeout = setTimeout(function() {
+ navContainer.hide();
+ }, 300);
+ $("#red-ui-workspace-chart").off("scroll",onScroll);
+ $("#red-ui-view-navigate").removeClass("selected");
+ }
+ }
+ function toggle() {
+ if (!isShowing) {
+ isManuallyToggled = true;
+ show()
+ } else {
+ isManuallyToggled = false;
+ hide()
+ }
+ }
+ function setupAutoHide () {
+ clearTimeout(autoHideTimeout);
+ autoHideTimeout = setTimeout(function() {
+ hide()
+ }, 2000)
+ }
+ function showTemporary() {
+ if (!isManuallyToggled) {
+ isTemporaryShow = true
+ clearTimeout(autoHideTimeout);
+ show()
+ setupAutoHide()
+ }
+ }
+ return {
+ init: function() {
+ $(window).on("resize", resizeNavBorder);
+ RED.events.on("sidebar:resize",resizeNavBorder);
+ RED.actions.add("core:toggle-navigator",toggle);
+ navContainer = $('
').css({
+ "position":"absolute",
+ "bottom":$("#red-ui-workspace-footer").height(),
+ "right":0,
+ zIndex: 1
+ }).addClass('red-ui-navigator-container').appendTo("#red-ui-workspace").hide();
+ navBox = d3.select(navContainer[0])
+ .append("svg:svg")
+ .attr("width", nav_width)
+ .attr("height", nav_height)
+ .attr("pointer-events", "all")
+ .attr("id","red-ui-navigator-canvas")
+ navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({
+ fill:"none",
+ stroke:"none",
+ pointerEvents:"all"
+ }).on("mousedown", function() {
+ // Update these in case they have changed
+ scaleFactor = RED.view.scale();
+ chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()];
+ dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor];
+ var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
+ var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
+ navBorder.attr('x',newX).attr('y',newY);
+ isDragging = true;
+ $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
+ $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
+ }).on("mousemove", function() {
+ if (!isDragging) { return }
+ if (d3.event.buttons === 0) {
+ isDragging = false;
+ return;
+ }
+ var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]);
+ var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]);
+ navBorder.attr('x',newX).attr('y',newY);
+ $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor);
+ $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor);
+ }).on("mouseup", function() {
+ isDragging = false;
+ }).on("mouseenter", function () {
+ if (isTemporaryShow) {
+ // If user hovers over the minimap while it's temporarily shown, keep it shown
+ clearTimeout(autoHideTimeout);
+ }
+ }).on("mouseleave", function () {
+ if (isTemporaryShow) {
+ // Restart the auto-hide timer after mouse leaves the minimap
+ setupAutoHide()
+ }
+ })
+ navBorder = navBox.append("rect").attr("class","red-ui-navigator-border")
+ navVis = navBox.append("svg:g")
+ RED.statusBar.add({
+ id: "view-navigator",
+ align: "right",
+ element: $('')
+ })
$("#red-ui-view-navigate").on("click", function(evt) {
evt.preventDefault();
toggle();
})
RED.popover.tooltip($("#red-ui-view-navigate"),RED._('actions.toggle-navigator'),'core:toggle-navigator');
+
+ // Listen for canvas interactions to show minimap temporarily
+ // Only show on actual pan/zoom navigation, not selection changes
+ // RED.events.on("view:navigate", function() {
+ // showTemporary();
+ // });
+
+ // Show minimap briefly when workspace changes (includes initial load)
+ // RED.events.on("workspace:change", function(event) {
+ // // Only show if there's an active workspace with nodes
+ // if (event.workspace && RED.nodes.getWorkspaceOrder().length > 0) {
+ // // Small delay to ensure nodes are rendered
+ // setTimeout(function() {
+ // var activeNodes = RED.nodes.filterNodes({z: event.workspace});
+ // if (activeNodes.length > 0) {
+ // showTemporary();
+ // }
+ // }, 100);
+ // }
+ // });
},
refresh: refreshNodes,
resize: resizeNavBorder,
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js
new file mode 100644
index 000000000..cda28591c
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js
@@ -0,0 +1,257 @@
+/**
+ * Copyright JS Foundation and other contributors, http://js.foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ **/
+
+RED.view.zoomAnimator = (function() {
+
+ /**
+ * Easing function for smooth deceleration
+ * Creates natural-feeling animation curves
+ * @param {number} t - Progress from 0 to 1
+ * @returns {number} - Eased value from 0 to 1
+ */
+ function easeOut(t) {
+ // Cubic ease-out for smooth deceleration
+ return 1 - Math.pow(1 - t, 3);
+ }
+
+ /**
+ * Animate values using requestAnimationFrame with easing
+ * Based on Excalidraw's implementation for smooth zoom transitions
+ *
+ * @param {Object} options - Animation options
+ * @param {Object} options.fromValues - Starting values object
+ * @param {Object} options.toValues - Target values object
+ * @param {Function} options.onStep - Callback for each animation frame
+ * @param {number} [options.duration=250] - Animation duration in ms
+ * @param {Function} [options.interpolateValue] - Custom interpolation function
+ * @param {Function} [options.onStart] - Animation start callback
+ * @param {Function} [options.onEnd] - Animation end callback
+ * @param {Function} [options.onCancel] - Animation cancel callback
+ * @returns {Function} - Cancel function to stop animation
+ */
+ function easeToValuesRAF(options) {
+ const {
+ fromValues,
+ toValues,
+ onStep,
+ duration = 250,
+ interpolateValue,
+ onStart,
+ onEnd,
+ onCancel
+ } = options;
+
+ let startTime = null;
+ let animationId = null;
+ let cancelled = false;
+
+ function step(timestamp) {
+ if (cancelled) {
+ return;
+ }
+
+ if (!startTime) {
+ startTime = timestamp;
+ if (onStart) {
+ onStart();
+ }
+ }
+
+ const elapsed = timestamp - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+ const easedProgress = easeOut(progress);
+
+ const interpolatedValues = {};
+
+ for (const key in fromValues) {
+ if (fromValues.hasOwnProperty(key)) {
+ const from = fromValues[key];
+ const to = toValues[key];
+
+ if (interpolateValue && key === 'zoom') {
+ // Special interpolation for zoom to feel more natural
+ // Exponential interpolation preserves relative zoom feel
+ interpolatedValues[key] = from * Math.pow(to / from, easedProgress);
+ } else {
+ // Linear interpolation for other values
+ interpolatedValues[key] = from + (to - from) * easedProgress;
+ }
+ }
+ }
+
+ onStep(interpolatedValues);
+
+ if (progress < 1) {
+ animationId = requestAnimationFrame(step);
+ } else {
+ if (onEnd) {
+ onEnd();
+ }
+ }
+ }
+
+ animationId = requestAnimationFrame(step);
+
+ // Return cancel function
+ return function cancel() {
+ cancelled = true;
+ if (animationId) {
+ cancelAnimationFrame(animationId);
+ }
+ if (onCancel) {
+ onCancel();
+ }
+ };
+ }
+
+ /**
+ * Calculate smooth zoom delta with acceleration
+ * Provides consistent zoom speed regardless of input device
+ *
+ * @param {number} currentScale - Current zoom scale
+ * @param {number} delta - Input delta (wheel, gesture, etc)
+ * @param {boolean} isTrackpad - Whether input is from trackpad
+ * @returns {number} - Calculated zoom delta
+ */
+ function calculateZoomDelta(currentScale, delta, isTrackpad) {
+ // Normalize delta across different input devices
+ let normalizedDelta = delta;
+
+ if (isTrackpad) {
+ // Trackpad deltas are typically smaller and more frequent
+ normalizedDelta = delta * 0.005; // Reduced from 0.01 for gentler zoom
+ } else {
+ // Mouse wheel deltas are larger and less frequent
+ // Reduce zoom out speed more than zoom in
+ normalizedDelta = delta > 0 ? 0.06 : -0.08; // Reduced from 0.1, asymmetric for gentler zoom out
+ }
+
+ // Apply gentler acceleration based on current zoom level
+ // Less aggressive acceleration to prevent rapid zoom out
+ const acceleration = Math.max(0.7, Math.min(1.1, 1 / currentScale)); // Reduced from 0.5-1.2 to 0.7-1.1
+
+ return normalizedDelta * acceleration;
+ }
+
+ /**
+ * Gesture state management for consistent focal points
+ */
+ const gestureState = {
+ active: false,
+ initialFocalPoint: null, // Will store workspace coordinates
+ initialScale: 1,
+ currentScale: 1,
+ lastDistance: 0,
+ scrollPosAtStart: null, // Store initial scroll position
+ scaleFatorAtStart: 1 // Store initial scale factor
+ };
+
+ /**
+ * Start a zoom gesture with fixed focal point
+ * @param {Array} focalPoint - [x, y] coordinates of focal point in workspace
+ * @param {number} scale - Initial scale value
+ * @param {Array} scrollPos - Current scroll position [x, y]
+ * @param {number} currentScaleFactor - Current scale factor for coordinate conversion
+ */
+ function startGesture(focalPoint, scale, scrollPos, currentScaleFactor) {
+ gestureState.active = true;
+ // Store the focal point in workspace coordinates for stability
+ // This ensures the point remains fixed even if scroll changes due to canvas edge constraints
+ if (focalPoint && scrollPos && currentScaleFactor) {
+ gestureState.initialFocalPoint = [
+ (scrollPos[0] + focalPoint[0]) / currentScaleFactor,
+ (scrollPos[1] + focalPoint[1]) / currentScaleFactor
+ ];
+ gestureState.scrollPosAtStart = [...scrollPos];
+ gestureState.scaleFatorAtStart = currentScaleFactor;
+ } else {
+ gestureState.initialFocalPoint = focalPoint ? [...focalPoint] : null;
+ }
+ gestureState.initialScale = scale;
+ gestureState.currentScale = scale;
+ return gestureState;
+ }
+
+ /**
+ * Update gesture maintaining fixed focal point
+ * @param {number} newScale - New scale value
+ * @returns {Object} - Gesture state with fixed focal point
+ */
+ function updateGesture(newScale) {
+ if (!gestureState.active) {
+ return null;
+ }
+
+ gestureState.currentScale = newScale;
+
+ return {
+ scale: newScale,
+ focalPoint: gestureState.initialFocalPoint,
+ active: gestureState.active
+ };
+ }
+
+ /**
+ * End the current gesture
+ */
+ function endGesture() {
+ gestureState.active = false;
+ gestureState.initialFocalPoint = null;
+ gestureState.lastDistance = 0;
+ }
+
+ /**
+ * Check if a gesture is currently active
+ */
+ function isGestureActive() {
+ return gestureState.active;
+ }
+
+ /**
+ * Get the fixed focal point for the current gesture
+ * @param {Array} currentScrollPos - Current scroll position [x, y]
+ * @param {number} currentScaleFactor - Current scale factor
+ * @returns {Array} - Focal point in screen coordinates or null
+ */
+ function getGestureFocalPoint(currentScrollPos, currentScaleFactor) {
+ if (!gestureState.initialFocalPoint) {
+ return null;
+ }
+
+ // If we stored workspace coordinates, convert back to screen coordinates
+ if (gestureState.scrollPosAtStart && currentScrollPos && currentScaleFactor) {
+ // Convert workspace coordinates back to current screen coordinates
+ return [
+ gestureState.initialFocalPoint[0] * currentScaleFactor - currentScrollPos[0],
+ gestureState.initialFocalPoint[1] * currentScaleFactor - currentScrollPos[1]
+ ];
+ }
+
+ return gestureState.initialFocalPoint;
+ }
+
+ return {
+ easeOut: easeOut,
+ easeToValuesRAF: easeToValuesRAF,
+ calculateZoomDelta: calculateZoomDelta,
+ gestureState: gestureState,
+ startGesture: startGesture,
+ updateGesture: updateGesture,
+ endGesture: endGesture,
+ isGestureActive: isGestureActive,
+ getGestureFocalPoint: getGestureFocalPoint
+ };
+})();
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js
new file mode 100644
index 000000000..9b10afd82
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js
@@ -0,0 +1,21 @@
+/**
+ * Zoom configuration constants
+ */
+RED.view.zoomConstants = {
+ // Zoom limits
+ MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas
+ MAX_ZOOM: 2.0,
+
+ // Zoom step for keyboard/button controls
+ ZOOM_STEP: 0.2,
+
+ // Animation settings
+ DEFAULT_ZOOM_DURATION: 125, // ms, faster animation
+
+ // Gesture thresholds
+ PINCH_THRESHOLD: 10, // minimum pixel movement to trigger zoom
+
+ // Momentum and friction for smooth scrolling
+ FRICTION: 0.92,
+ BOUNCE_DAMPING: 0.6
+};
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
index c459de1ff..4aa9bc128 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js
@@ -38,11 +38,29 @@ RED.view = (function() {
node_height = 30,
dblClickInterval = 650;
+ var cancelInProgressAnimation = null; // For smooth zoom animation
+
var touchLongPressTimeout = 1000,
startTouchDistance = 0,
startTouchCenter = [],
moveTouchCenter = [],
- touchStartTime = 0;
+ touchStartTime = 0,
+ gesture = {};
+
+ var spacebarPressed = false;
+
+ // Momentum scrolling state
+ var scrollVelocity = { x: 0, y: 0 };
+ var lastScrollTime = 0;
+ var lastScrollPos = { x: 0, y: 0 };
+ var scrollAnimationId = null;
+ var momentumActive = false;
+
+ // Bounce effect parameters
+ var BOUNCE_DAMPING = 0.6;
+ var BOUNCE_TENSION = 0.3;
+ var MIN_VELOCITY = 0.5;
+ var FRICTION = 0.95;
var workspaceScrollPositions = {};
@@ -319,6 +337,24 @@ RED.view = (function() {
function init() {
chart = $("#red-ui-workspace-chart");
+
+ // Add invisible spacer div to ensure scrollable area matches canvas dimensions
+ // At minimum zoom with "cover" behavior, SVG may be smaller than viewport in one dimension
+ // This spacer forces the browser to calculate scrollWidth/Height based on full canvas size
+ // Browser's maxScroll = scrollWidth - viewport will then correctly show canvas edges
+ var scrollSpacer = $('
')
+ .css({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: space_width + 'px',
+ height: space_height + 'px',
+ pointerEvents: 'none',
+ visibility: 'hidden'
+ })
+ .attr('id', 'red-ui-workspace-scroll-spacer')
+ .appendTo(chart);
+
chart.on('contextmenu', function(evt) {
if (RED.view.DEBUG) {
console.warn("contextmenu", { mouse_mode, event: d3.event });
@@ -363,8 +399,9 @@ RED.view = (function() {
lasso.remove();
lasso = null;
}
- } else if (mouse_mode === RED.state.PANNING && d3.event.buttons !== 4) {
- resetMouseVars();
+ } else if (mouse_mode === RED.state.PANNING) {
+ // ensure the cursor is set to grab when re-entering the canvas while panning
+ outer.style('cursor', 'grabbing');
} else if (slicePath) {
if (d3.event.buttons !== 2) {
slicePath.remove();
@@ -381,11 +418,15 @@ RED.view = (function() {
if (RED.touch.radialMenu.active()) {
return;
}
+ // End gesture when touches end
+ RED.view.zoomAnimator.endGesture();
canvasMouseUp.call(this);
})
.on("touchcancel", function() {
if (RED.view.DEBUG) { console.warn("eventLayer.touchcancel", mouse_mode); }
d3.event.preventDefault();
+ // End gesture when touches are cancelled
+ RED.view.zoomAnimator.endGesture();
canvasMouseUp.call(this);
})
.on("touchstart", function() {
@@ -411,6 +452,20 @@ RED.view = (function() {
touch1["pageY"]+(a/2)
]
startTouchDistance = Math.sqrt((a*a)+(b*b));
+
+ // Store initial scale for ratio-based zoom calculation
+ gesture = {
+ initialScale: scaleFactor,
+ initialDistance: startTouchDistance,
+ mode: null // Will be determined on first significant move
+ };
+
+ // Start gesture with fixed focal point (store in workspace coordinates)
+ var focalPoint = [
+ (touch0["pageX"] + touch1["pageX"]) / 2 - offset.left,
+ (touch0["pageY"] + touch1["pageY"]) / 2 - offset.top
+ ];
+ RED.view.zoomAnimator.startGesture(focalPoint, scaleFactor, scrollPos, scaleFactor);
} else {
var obj = d3.select(document.body);
touch0 = d3.event.touches.item(0);
@@ -460,33 +515,93 @@ RED.view = (function() {
var offset = chart.offset();
var scrollPos = [chart.scrollLeft(),chart.scrollTop()];
var moveTouchDistance = Math.sqrt((a*a)+(b*b));
- var touchCenter = [
- touch1["pageX"]+(b/2),
- touch1["pageY"]+(a/2)
+
+ // Calculate center point of two fingers
+ var currentTouchCenter = [
+ (touch0["pageX"] + touch1["pageX"]) / 2,
+ (touch0["pageY"] + touch1["pageY"]) / 2
];
if (!isNaN(moveTouchDistance)) {
- oldScaleFactor = scaleFactor;
- scaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000)));
+ // Determine gesture mode on first significant movement
+ if (!gesture.mode) {
+ var distanceChange = Math.abs(moveTouchDistance - startTouchDistance);
+ var centerChange = moveTouchCenter ?
+ Math.sqrt(Math.pow(currentTouchCenter[0] - moveTouchCenter[0], 2) +
+ Math.pow(currentTouchCenter[1] - moveTouchCenter[1], 2)) : 0;
+
+ // Lock into zoom mode if distance changes significantly (>10px)
+ // Lock into pan mode if center moves significantly (>5px) without distance change
+ if (distanceChange > 10) {
+ gesture.mode = 'zoom';
+ } else if (centerChange > 5) {
+ gesture.mode = 'pan';
+ }
+ }
+
+ // Once mode is determined, stay in that mode for the entire gesture
+ if (gesture.mode === 'zoom') {
+ oldScaleFactor = scaleFactor;
+ // Use smooth ratio-based scaling for natural pinch-to-zoom
+ var zoomRatio = moveTouchDistance / startTouchDistance;
+ var minZoom = calculateMinZoom();
+ var newScaleFactor = Math.min(RED.view.zoomConstants.MAX_ZOOM,
+ Math.max(minZoom, gesture.initialScale * zoomRatio));
- var deltaTouchCenter = [ // Try to pan whilst zooming - not 100%
- startTouchCenter[0]*(scaleFactor-oldScaleFactor),//-(touchCenter[0]-moveTouchCenter[0]),
- startTouchCenter[1]*(scaleFactor-oldScaleFactor) //-(touchCenter[1]-moveTouchCenter[1])
- ];
+ // Use gesture state management to maintain fixed focal point
+ var gestureState = RED.view.zoomAnimator.updateGesture(newScaleFactor);
+
+ // Only call zoomView if scale is actually changing (not at limits)
+ if (Math.abs(scaleFactor - newScaleFactor) >= 0.001) {
+ // Get focal point converted back to current screen coordinates
+ var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()];
+ var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor);
+
+ if (focalPoint) {
+ // Use the fixed focal point from gesture start (converted from workspace coords)
+ zoomView(newScaleFactor, focalPoint);
+ } else {
+ // Fallback to current behavior if gesture not active
+ var touchCenter = [
+ touch1["pageX"]+(b/2),
+ touch1["pageY"]+(a/2)
+ ];
+ var pinchCenter = [
+ touchCenter[0] - offset.left,
+ touchCenter[1] - offset.top
+ ];
+ zoomView(newScaleFactor, pinchCenter);
+ }
+ }
+ } else if (gesture.mode === 'pan' || !gesture.mode) {
+ // Two-finger pan: allow immediate panning even if mode not determined
+ // Clear touchStartTime to prevent issues with next gesture
+ if (touchStartTime) {
+ clearTimeout(touchStartTime);
+ touchStartTime = null;
+ }
+ if (moveTouchCenter) {
+ var dx = currentTouchCenter[0] - moveTouchCenter[0];
+ var dy = currentTouchCenter[1] - moveTouchCenter[1];
+
+ // Pan the canvas
+ var currentScroll = [chart.scrollLeft(), chart.scrollTop()];
+ chart.scrollLeft(currentScroll[0] - dx);
+ chart.scrollTop(currentScroll[1] - dy);
+ RED.events.emit("view:navigate");
+ }
+ // Update the center for next move
+ moveTouchCenter = currentTouchCenter;
+ }
- startTouchDistance = moveTouchDistance;
- moveTouchCenter = touchCenter;
-
- chart.scrollLeft(scrollPos[0]+deltaTouchCenter[0]);
- chart.scrollTop(scrollPos[1]+deltaTouchCenter[1]);
- redraw();
+ // Don't update startTouchDistance - keep initial distance for ratio calculation
}
}
d3.event.preventDefault();
});
-
-
- const handleAltToggle = (event) => {
+
+ const handleChartKeyboardEvents = (event) => {
+ // Handle Alt toggle for pulling nodes out of groups
if (mouse_mode === RED.state.MOVING_ACTIVE && event.key === 'Alt' && groupAddParentGroup) {
RED.nodes.group(groupAddParentGroup).dirty = true
for (let n = 0; n