Implement smooth zoom functionality with pinch-to-zoom support

- Add smooth zoom animation with 125ms duration and easing curves
- Implement space+scroll zoom mode alongside existing Alt+scroll
- Fix pinch-to-zoom with proper ratio-based scaling and fixed focal point
- Add gesture state management for consistent zoom behavior
- Enhance spacebar handling to prevent scroll artifacts
- Fix zoom button layout (correct zoom in/out direction)
- Add zoom animation utilities (view-zoom-animator.js)
- Add zoom configuration constants (view-zoom-constants.js)
- Fix scale lock issues with improved tolerance handling
- Update Gruntfile to include new zoom modules in build

Features implemented:
- Smooth animated zoom transitions (125ms with ease-out)
- Space+scroll for zoom mode
- Fixed focal point during pinch gestures
- No scroll artifacts when pressing space
- Proper state management when cursor leaves canvas
- Natural acceleration/deceleration curves

Known issue: Trackpad pinch-to-zoom needs additional work on macOS
pull/5312/head
Dimitrie Hoekstra 2025-09-29 17:29:23 +02:00 committed by Nick O'Leary
parent bd51b0c153
commit eaf68815fd
No known key found for this signature in database
GPG Key ID: 4F2157149161A6C9
4 changed files with 411 additions and 23 deletions

View File

@ -173,6 +173,8 @@ module.exports = function(grunt) {
"packages/node_modules/@node-red/editor-client/src/js/ui/workspaces.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/statusBar.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view-annotations.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/view-tools.js",

View File

@ -0,0 +1,223 @@
/**
* 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) {
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.01;
} else {
// Mouse wheel deltas are larger and less frequent
normalizedDelta = delta > 0 ? 0.1 : -0.1;
}
// Apply acceleration based on current zoom level
// Zoom faster when zoomed out, slower when zoomed in
const acceleration = Math.max(0.5, Math.min(2, 1 / currentScale));
return normalizedDelta * acceleration;
}
/**
* Gesture state management for consistent focal points
*/
const gestureState = {
active: false,
initialFocalPoint: null,
initialScale: 1,
currentScale: 1,
lastDistance: 0
};
/**
* Start a zoom gesture with fixed focal point
* @param {Array} focalPoint - [x, y] coordinates of focal point
* @param {number} scale - Initial scale value
*/
function startGesture(focalPoint, scale) {
gestureState.active = true;
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
*/
function getGestureFocalPoint() {
return gestureState.initialFocalPoint;
}
return {
easeOut: easeOut,
easeToValuesRAF: easeToValuesRAF,
calculateZoomDelta: calculateZoomDelta,
gestureState: gestureState,
startGesture: startGesture,
updateGesture: updateGesture,
endGesture: endGesture,
isGestureActive: isGestureActive,
getGestureFocalPoint: getGestureFocalPoint
};
})();

View File

@ -0,0 +1,21 @@
/**
* Zoom configuration constants
*/
RED.view.zoomConstants = {
// Zoom limits
MIN_ZOOM: 0.3,
MAX_ZOOM: 2.0,
// Zoom step for keyboard/button controls
ZOOM_STEP: 0.1,
// 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
};

View File

@ -38,11 +38,14 @@ 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;
@ -395,11 +398,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() {
@ -425,6 +432,19 @@ 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
};
// Start gesture with fixed focal point (screen coordinates)
var focalPoint = [
(touch0["pageX"] + touch1["pageX"]) / 2 - offset.left,
(touch0["pageY"] + touch1["pageY"]) / 2 - offset.top
];
RED.view.zoomAnimator.startGesture(focalPoint, scaleFactor);
} else {
var obj = d3.select(document.body);
touch0 = d3.event.touches.item(0);
@ -474,26 +494,33 @@ 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)
];
if (!isNaN(moveTouchDistance)) {
oldScaleFactor = scaleFactor;
var newScaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000)));
// Use smooth ratio-based scaling for natural pinch-to-zoom
var zoomRatio = moveTouchDistance / startTouchDistance;
var newScaleFactor = Math.min(2, Math.max(0.3, gesture.initialScale * zoomRatio));
// Calculate pinch center relative to chart
var pinchCenter = [
touchCenter[0] - offset.left,
touchCenter[1] - offset.top
];
// Use gesture state management to maintain fixed focal point
var gestureState = RED.view.zoomAnimator.updateGesture(newScaleFactor);
if (gestureState && gestureState.focalPoint) {
// Use the fixed focal point from gesture start
zoomView(newScaleFactor, gestureState.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);
}
// Use zoomView with pinch center as focal point
zoomView(newScaleFactor, pinchCenter);
startTouchDistance = moveTouchDistance;
moveTouchCenter = touchCenter;
// Don't update startTouchDistance - keep initial distance for ratio calculation
}
}
d3.event.preventDefault();
@ -531,14 +558,35 @@ RED.view = (function() {
spacebarPressed = true;
// Prevent default space scrolling behavior
e.preventDefault();
e.stopPropagation();
} else if (e.type === "keyup" && spacebarPressed) {
spacebarPressed = false;
e.preventDefault();
e.stopPropagation();
}
}
}
// Window-level keyup listener to catch spacebar release when cursor is outside canvas
function handleWindowSpacebarUp(e) {
if ((e.keyCode === 32 || e.key === ' ') && spacebarPressed) {
spacebarPressed = false;
e.preventDefault();
e.stopPropagation();
}
}
document.addEventListener("keyup", handleSpacebarToggle)
document.addEventListener("keydown", handleSpacebarToggle)
// Additional window-level keyup listener to ensure spacebar state is cleared
// when cursor leaves canvas area while spacebar is held
window.addEventListener("keyup", handleWindowSpacebarUp)
// Reset spacebar state when window loses focus to prevent stuck state
window.addEventListener("blur", function() {
if (spacebarPressed) {
spacebarPressed = false;
}
})
// Workspace Background
eventLayer.append("svg:rect")
@ -633,11 +681,11 @@ RED.view = (function() {
'</span>')
})
$("#red-ui-view-zoom-out").on("click", zoomOut);
$("#red-ui-view-zoom-out").on("click", zoomIn);
RED.popover.tooltip($("#red-ui-view-zoom-out"),RED._('actions.zoom-out'),'core:zoom-out');
$("#red-ui-view-zoom-zero").on("click", zoomZero);
RED.popover.tooltip($("#red-ui-view-zoom-zero"),RED._('actions.zoom-reset'),'core:zoom-reset');
$("#red-ui-view-zoom-in").on("click", zoomIn);
$("#red-ui-view-zoom-in").on("click", zoomOut);
RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in');
chart.on("DOMMouseScroll mousewheel", function (evt) {
if ( evt.altKey || spacebarPressed ) {
@ -856,6 +904,10 @@ RED.view = (function() {
});
chart.on("blur", function() {
$("#red-ui-workspace-tabs").removeClass("red-ui-workspace-focussed");
// Reset spacebar state when chart loses focus to prevent stuck state
if (spacebarPressed) {
spacebarPressed = false;
}
});
RED.actions.add("core:copy-selection-to-internal-clipboard",copySelection);
@ -2208,6 +2260,11 @@ RED.view = (function() {
canvasMouseUp.call(this)
})
}
// Reset spacebar state when mouse leaves canvas to prevent interaction artifacts
if (spacebarPressed) {
// Note: We don't reset spacebarPressed here as user may still be holding spacebar
// The window-level keyup listener will handle the actual release
}
}
function canvasMouseUp() {
lastClickPosition = [d3.event.offsetX/scaleFactor,d3.event.offsetY/scaleFactor];
@ -2553,16 +2610,16 @@ RED.view = (function() {
}
function zoomIn(focalPoint) {
if (scaleFactor < 2) {
zoomView(scaleFactor+0.1, focalPoint);
if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) {
animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint);
}
}
function zoomOut(focalPoint) {
if (scaleFactor > 0.3) {
zoomView(scaleFactor-0.1, focalPoint);
if (scaleFactor > RED.view.zoomConstants.MIN_ZOOM) {
animatedZoomView(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, focalPoint);
}
}
function zoomZero() { zoomView(1); }
function zoomZero() { animatedZoomView(1); }
function searchFlows() { RED.actions.invoke("core:search", $(this).data("term")); }
function searchPrev() { RED.actions.invoke("core:search-previous"); }
function searchNext() { RED.actions.invoke("core:search-next"); }
@ -2605,6 +2662,91 @@ RED.view = (function() {
}
}
function animatedZoomView(targetFactor, focalPoint) {
// Cancel any in-progress animation
if (cancelInProgressAnimation) {
cancelInProgressAnimation();
cancelInProgressAnimation = null;
}
// Clamp target factor to valid range
targetFactor = Math.max(RED.view.zoomConstants.MIN_ZOOM,
Math.min(RED.view.zoomConstants.MAX_ZOOM, targetFactor));
// If we're already at the target, no need to animate
// Use a more tolerant threshold to account for floating-point precision
if (Math.abs(scaleFactor - targetFactor) < 0.01) {
return;
}
var startFactor = scaleFactor;
var screenSize = [chart.width(), chart.height()];
var scrollPos = [chart.scrollLeft(), chart.scrollTop()];
// Calculate the focal point in workspace coordinates (will remain constant)
var center;
if (focalPoint) {
// focalPoint is in screen coordinates, convert to workspace coordinates
center = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor];
} else {
// Default to viewport center
center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor, (scrollPos[1] + screenSize[1]/2)/scaleFactor];
}
// Start the animation
cancelInProgressAnimation = RED.view.zoomAnimator.easeToValuesRAF({
fromValues: {
zoom: startFactor
},
toValues: {
zoom: targetFactor
},
duration: RED.view.zoomConstants.DEFAULT_ZOOM_DURATION,
interpolateValue: true, // Use exponential interpolation for zoom
onStep: function(values) {
var currentFactor = values.zoom;
scaleFactor = currentFactor;
// Calculate new scroll position to maintain focal point
var currentScreenSize = [chart.width(), chart.height()];
var newScrollPos;
if (focalPoint) {
// Keep the focal point at the same screen position
newScrollPos = [
center[0] * scaleFactor - focalPoint[0],
center[1] * scaleFactor - focalPoint[1]
];
} else {
// Keep viewport center steady
newScrollPos = [
center[0] * scaleFactor - currentScreenSize[0]/2,
center[1] * scaleFactor - currentScreenSize[1]/2
];
}
chart.scrollLeft(newScrollPos[0]);
chart.scrollTop(newScrollPos[1]);
RED.view.navigator.resize();
redraw();
},
onEnd: function() {
cancelInProgressAnimation = null;
// Ensure scaleFactor is exactly the target to prevent precision issues
scaleFactor = targetFactor;
if (RED.settings.get("editor.view.view-store-zoom")) {
RED.settings.setLocal('zoom-level', targetFactor.toFixed(1));
}
},
onCancel: function() {
cancelInProgressAnimation = null;
// Ensure scaleFactor is set to current target on cancel
scaleFactor = targetFactor;
}
});
}
// Momentum scrolling functions
function startMomentumScroll() {
if (scrollAnimationId) {