From eaf68815fd6199da165c36f6b362eaa3f647f409 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 17:29:23 +0200 Subject: [PATCH] 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 --- Gruntfile.js | 2 + .../src/js/ui/view-zoom-animator.js | 223 ++++++++++++++++++ .../src/js/ui/view-zoom-constants.js | 21 ++ .../@node-red/editor-client/src/js/ui/view.js | 188 +++++++++++++-- 4 files changed, 411 insertions(+), 23 deletions(-) create mode 100644 packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js create mode 100644 packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js diff --git a/Gruntfile.js b/Gruntfile.js index 73b03f6ea..701686c35 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -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", 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..1a3238a62 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js @@ -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 + }; +})(); \ 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..e983b473e --- /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.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 +}; \ 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 cd2df75d7..b22a871eb 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,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() { '') }) - $("#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) {