From bd51b0c153277601a522315b5a59ce966d8e5b0c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:03:37 +0000 Subject: [PATCH 01/49] feat: Add enhanced zoom and scroll features - Added space+scroll zoom functionality - Implemented cursor-centered zoom (focuses on cursor position) - Enhanced pinch-to-zoom with trackpad support (Ctrl+wheel) - Added momentum scrolling with edge bounce animation - Improved touch pinch gesture handling with proper center tracking Co-authored-by: Dimitrie Hoekstra --- .../@node-red/editor-client/src/js/ui/view.js | 210 ++++++++++++++++-- 1 file changed, 192 insertions(+), 18 deletions(-) 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 89019005f..cd2df75d7 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 @@ -44,6 +44,21 @@ RED.view = (function() { moveTouchCenter = [], touchStartTime = 0; + 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 = {}; var gridSize = 20; @@ -466,19 +481,19 @@ RED.view = (function() { if (!isNaN(moveTouchDistance)) { oldScaleFactor = scaleFactor; - scaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000))); + var newScaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000))); - 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]) + // Calculate pinch center relative to chart + var pinchCenter = [ + touchCenter[0] - offset.left, + touchCenter[1] - offset.top ]; + // Use zoomView with pinch center as focal point + zoomView(newScaleFactor, pinchCenter); + startTouchDistance = moveTouchDistance; moveTouchCenter = touchCenter; - - chart.scrollLeft(scrollPos[0]+deltaTouchCenter[0]); - chart.scrollTop(scrollPos[1]+deltaTouchCenter[1]); - redraw(); } } d3.event.preventDefault(); @@ -509,6 +524,22 @@ RED.view = (function() { document.addEventListener("keyup", handleAltToggle) document.addEventListener("keydown", handleAltToggle) + // Handle spacebar for zoom mode + function handleSpacebarToggle(e) { + if (e.keyCode === 32 || e.key === ' ') { + if (e.type === "keydown" && !spacebarPressed) { + spacebarPressed = true; + // Prevent default space scrolling behavior + e.preventDefault(); + } else if (e.type === "keyup" && spacebarPressed) { + spacebarPressed = false; + e.preventDefault(); + } + } + } + document.addEventListener("keyup", handleSpacebarToggle) + document.addEventListener("keydown", handleSpacebarToggle) + // Workspace Background eventLayer.append("svg:rect") .attr("class","red-ui-workspace-chart-background") @@ -609,12 +640,52 @@ RED.view = (function() { $("#red-ui-view-zoom-in").on("click", zoomIn); RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in'); chart.on("DOMMouseScroll mousewheel", function (evt) { - if ( evt.altKey ) { + if ( evt.altKey || spacebarPressed ) { evt.preventDefault(); evt.stopPropagation(); + // Get cursor position relative to the chart + var offset = chart.offset(); + var cursorPos = [ + evt.originalEvent.pageX - offset.left, + evt.originalEvent.pageY - offset.top + ]; var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta; - if (move <= 0) { zoomOut(); } - else { zoomIn(); } + if (move <= 0) { zoomOut(cursorPos); } + else { zoomIn(cursorPos); } + } + }); + + // Modern wheel event handler for better trackpad support (pinch-to-zoom) and momentum + var momentumTimer = null; + chart.on("wheel", function(evt) { + // ctrlKey is set during pinch gestures on trackpads + if (evt.ctrlKey || evt.altKey || spacebarPressed) { + evt.preventDefault(); + evt.stopPropagation(); + // Get cursor position relative to the chart + var offset = chart.offset(); + var cursorPos = [ + evt.originalEvent.pageX - offset.left, + evt.originalEvent.pageY - offset.top + ]; + var delta = evt.originalEvent.deltaY; + if (delta > 0) { zoomOut(cursorPos); } + else if (delta < 0) { zoomIn(cursorPos); } + } else { + // Regular scroll - track velocity and apply momentum + handleScroll(); + + // Cancel previous momentum timer + if (momentumTimer) { + clearTimeout(momentumTimer); + } + + // Start momentum after scroll stops + momentumTimer = setTimeout(function() { + if (Math.abs(scrollVelocity.x) > MIN_VELOCITY || Math.abs(scrollVelocity.y) > MIN_VELOCITY) { + startMomentumScroll(); + } + }, 100); } }); @@ -964,6 +1035,9 @@ RED.view = (function() { RED.settings.setLocal('scroll-positions', JSON.stringify(workspaceScrollPositions) ) } chart.on("scroll", function() { + // Track scroll velocity for momentum + handleScroll(); + if (RED.settings.get("editor.view.view-store-position")) { if (onScrollTimer) { clearTimeout(onScrollTimer) @@ -2478,14 +2552,14 @@ RED.view = (function() { } - function zoomIn() { + function zoomIn(focalPoint) { if (scaleFactor < 2) { - zoomView(scaleFactor+0.1); + zoomView(scaleFactor+0.1, focalPoint); } } - function zoomOut() { + function zoomOut(focalPoint) { if (scaleFactor > 0.3) { - zoomView(scaleFactor-0.1); + zoomView(scaleFactor-0.1, focalPoint); } } function zoomZero() { zoomView(1); } @@ -2494,12 +2568,32 @@ RED.view = (function() { function searchNext() { RED.actions.invoke("core:search-next"); } - function zoomView(factor) { + function zoomView(factor, focalPoint) { var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; - var center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor]; + + // Use focal point if provided (e.g., cursor position), otherwise use viewport center + 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]; + } + + var oldScaleFactor = scaleFactor; scaleFactor = factor; - var newCenter = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor]; + + // Calculate where the focal point will be after zoom + var newCenter; + if (focalPoint) { + // Keep the focal point at the same screen position + newCenter = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor]; + } else { + newCenter = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor]; + } + var delta = [(newCenter[0]-center[0])*scaleFactor,(newCenter[1]-center[1])*scaleFactor] chart.scrollLeft(scrollPos[0]-delta[0]); chart.scrollTop(scrollPos[1]-delta[1]); @@ -2511,6 +2605,86 @@ RED.view = (function() { } } + // Momentum scrolling functions + function startMomentumScroll() { + if (scrollAnimationId) { + cancelAnimationFrame(scrollAnimationId); + } + momentumActive = true; + animateMomentumScroll(); + } + + function animateMomentumScroll() { + if (!momentumActive) return; + + var scrollX = chart.scrollLeft(); + var scrollY = chart.scrollTop(); + var maxScrollX = chart[0].scrollWidth - chart.width(); + var maxScrollY = chart[0].scrollHeight - chart.height(); + + // Apply friction + scrollVelocity.x *= FRICTION; + scrollVelocity.y *= FRICTION; + + // Check for edges and apply bounce + var newScrollX = scrollX + scrollVelocity.x; + var newScrollY = scrollY + scrollVelocity.y; + + // Bounce effect at edges + if (newScrollX < 0) { + newScrollX = 0; + scrollVelocity.x = -scrollVelocity.x * BOUNCE_DAMPING; + } else if (newScrollX > maxScrollX) { + newScrollX = maxScrollX; + scrollVelocity.x = -scrollVelocity.x * BOUNCE_DAMPING; + } + + if (newScrollY < 0) { + newScrollY = 0; + scrollVelocity.y = -scrollVelocity.y * BOUNCE_DAMPING; + } else if (newScrollY > maxScrollY) { + newScrollY = maxScrollY; + scrollVelocity.y = -scrollVelocity.y * BOUNCE_DAMPING; + } + + // Apply new scroll position + chart.scrollLeft(newScrollX); + chart.scrollTop(newScrollY); + + // Stop if velocity is too small + if (Math.abs(scrollVelocity.x) < MIN_VELOCITY && Math.abs(scrollVelocity.y) < MIN_VELOCITY) { + momentumActive = false; + scrollVelocity.x = 0; + scrollVelocity.y = 0; + } else { + scrollAnimationId = requestAnimationFrame(animateMomentumScroll); + } + } + + function handleScroll() { + var now = Date.now(); + var scrollX = chart.scrollLeft(); + var scrollY = chart.scrollTop(); + + if (lastScrollTime) { + var dt = now - lastScrollTime; + if (dt > 0 && dt < 100) { // Only calculate velocity for recent scrolls + scrollVelocity.x = (scrollX - lastScrollPos.x) / dt * 16; // Normalize to 60fps + scrollVelocity.y = (scrollY - lastScrollPos.y) / dt * 16; + } + } + + lastScrollTime = now; + lastScrollPos.x = scrollX; + lastScrollPos.y = scrollY; + + // Cancel any ongoing momentum animation + if (scrollAnimationId) { + cancelAnimationFrame(scrollAnimationId); + scrollAnimationId = null; + } + } + function selectNone() { if (mouse_mode === RED.state.MOVING || mouse_mode === RED.state.MOVING_ACTIVE) { return; From eaf68815fd6199da165c36f6b362eaa3f647f409 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 17:29:23 +0200 Subject: [PATCH 02/49] 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) { From 49222c573715539263d1e76b834053472068b232 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 17:41:05 +0200 Subject: [PATCH 03/49] Fix trackpad zoom direction - spreading fingers now zooms in - Inverted deltaY value for trackpad pinch gestures - Matches standard macOS trackpad behavior - Spreading fingers (negative deltaY) zooms in - Pinching fingers (positive deltaY) zooms out --- .../@node-red/editor-client/src/js/ui/view.js | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) 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 b22a871eb..e3336caee 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 @@ -705,6 +705,7 @@ RED.view = (function() { // Modern wheel event handler for better trackpad support (pinch-to-zoom) and momentum var momentumTimer = null; + var trackpadGestureTimer = null; chart.on("wheel", function(evt) { // ctrlKey is set during pinch gestures on trackpads if (evt.ctrlKey || evt.altKey || spacebarPressed) { @@ -717,8 +718,41 @@ RED.view = (function() { evt.originalEvent.pageY - offset.top ]; var delta = evt.originalEvent.deltaY; - if (delta > 0) { zoomOut(cursorPos); } - else if (delta < 0) { zoomIn(cursorPos); } + + // For trackpad pinch (Ctrl+wheel), use smooth proportional zoom + if (evt.ctrlKey && !evt.altKey && !spacebarPressed) { + // Use the zoom animator's delta calculation for trackpad + // Invert delta: spreading fingers (negative deltaY) should zoom in + var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, true); + var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(RED.view.zoomConstants.MIN_ZOOM, scaleFactor + scaleDelta)); + + // Start gesture tracking if not already active + if (!RED.view.zoomAnimator.isGestureActive()) { + RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor); + } + + // Update gesture with new scale, maintaining focal point + RED.view.zoomAnimator.updateGesture(newScale); + zoomView(newScale, RED.view.zoomAnimator.getGestureFocalPoint()); // Direct call, no animation + + // Reset gesture timeout - end gesture when no more events come in + if (trackpadGestureTimer) { + clearTimeout(trackpadGestureTimer); + } + trackpadGestureTimer = setTimeout(function() { + RED.view.zoomAnimator.endGesture(); + trackpadGestureTimer = null; + // Store zoom level when gesture completes + if (RED.settings.get("editor.view.view-store-zoom")) { + RED.settings.setLocal('zoom-level', scaleFactor.toFixed(1)); + } + }, 100); // 100ms timeout to detect gesture end + } else { + // Regular Alt+scroll or Space+scroll - use animated zoom + if (delta > 0) { zoomOut(cursorPos); } + else if (delta < 0) { zoomIn(cursorPos); } + } } else { // Regular scroll - track velocity and apply momentum handleScroll(); From 782821b5906bfe95182dc54c099739b8fe7dd6ee Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 18:03:52 +0200 Subject: [PATCH 04/49] Fix zoom focal point stability at canvas edges - Store focal point in workspace coordinates instead of screen coordinates - Prevents focal point drift when scroll changes due to canvas boundaries - Maintains consistent zoom focus even when view shifts at edges - Add early return in zoomView() to prevent unnecessary updates at zoom limits - Improve gesture state management for both trackpad and touch gestures --- .../src/js/ui/view-zoom-animator.js | 43 +++++++-- .../@node-red/editor-client/src/js/ui/view.js | 88 ++++++++++++++----- 2 files changed, 101 insertions(+), 30 deletions(-) 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 index 1a3238a62..bfb3d7686 100644 --- 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 @@ -148,20 +148,35 @@ RED.view.zoomAnimator = (function() { */ const gestureState = { active: false, - initialFocalPoint: null, + initialFocalPoint: null, // Will store workspace coordinates initialScale: 1, currentScale: 1, - lastDistance: 0 + 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 + * @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) { + function startGesture(focalPoint, scale, scrollPos, currentScaleFactor) { gestureState.active = true; - gestureState.initialFocalPoint = focalPoint ? [...focalPoint] : null; + // 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; @@ -204,8 +219,24 @@ RED.view.zoomAnimator = (function() { /** * 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() { + 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; } 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 e3336caee..1c9a97506 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 @@ -439,12 +439,12 @@ RED.view = (function() { initialDistance: startTouchDistance }; - // Start gesture with fixed focal point (screen coordinates) + // 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); + RED.view.zoomAnimator.startGesture(focalPoint, scaleFactor, scrollPos, scaleFactor); } else { var obj = d3.select(document.body); touch0 = d3.event.touches.item(0); @@ -504,20 +504,27 @@ RED.view = (function() { // 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); + // 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); + } } // Don't update startTouchDistance - keep initial distance for ratio calculation @@ -706,11 +713,19 @@ RED.view = (function() { // Modern wheel event handler for better trackpad support (pinch-to-zoom) and momentum var momentumTimer = null; var trackpadGestureTimer = null; + var lastWheelEventTime = 0; + var wheelEventContinuityThreshold = 100; // Events within 100ms are same gesture + var gestureEndThreshold = 500; // 500ms+ gap means gesture ended + chart.on("wheel", function(evt) { // ctrlKey is set during pinch gestures on trackpads if (evt.ctrlKey || evt.altKey || spacebarPressed) { evt.preventDefault(); evt.stopPropagation(); + + var currentTime = Date.now(); + var timeSinceLastEvent = currentTime - lastWheelEventTime; + // Get cursor position relative to the chart var offset = chart.offset(); var cursorPos = [ @@ -727,16 +742,35 @@ RED.view = (function() { var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, Math.max(RED.view.zoomConstants.MIN_ZOOM, scaleFactor + scaleDelta)); - // Start gesture tracking if not already active - if (!RED.view.zoomAnimator.isGestureActive()) { - RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor); + // Session-based gesture tracking: + // - If no active gesture OR gap > gestureEndThreshold, start new gesture + // - If gap < wheelEventContinuityThreshold, continue current gesture + // - If gap between continuity and end threshold, keep current gesture but don't update focal point + + if (!RED.view.zoomAnimator.isGestureActive() || timeSinceLastEvent > gestureEndThreshold) { + // Start new gesture session - store focal point in workspace coordinates + var scrollPos = [chart.scrollLeft(), chart.scrollTop()]; + RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor, scrollPos, scaleFactor); + } else if (timeSinceLastEvent <= wheelEventContinuityThreshold) { + // Events are continuous - this is the same gesture, focal point remains locked + // No need to update focal point + } + // For gaps between continuity and end threshold, keep existing gesture state + + // Update gesture with new scale, maintaining locked focal point + RED.view.zoomAnimator.updateGesture(newScale); + // Only call zoomView if scale is actually changing (not at limits) + if (Math.abs(scaleFactor - newScale) >= 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); + zoomView(newScale, focalPoint); // Direct call, no animation } - // Update gesture with new scale, maintaining focal point - RED.view.zoomAnimator.updateGesture(newScale); - zoomView(newScale, RED.view.zoomAnimator.getGestureFocalPoint()); // Direct call, no animation + // Update last event time for continuity tracking + lastWheelEventTime = currentTime; - // Reset gesture timeout - end gesture when no more events come in + // Reset gesture timeout - end gesture when no more events come in for gestureEndThreshold if (trackpadGestureTimer) { clearTimeout(trackpadGestureTimer); } @@ -747,7 +781,7 @@ RED.view = (function() { if (RED.settings.get("editor.view.view-store-zoom")) { RED.settings.setLocal('zoom-level', scaleFactor.toFixed(1)); } - }, 100); // 100ms timeout to detect gesture end + }, gestureEndThreshold); // Use 500ms timeout for gesture end detection } else { // Regular Alt+scroll or Space+scroll - use animated zoom if (delta > 0) { zoomOut(cursorPos); } @@ -2660,6 +2694,12 @@ RED.view = (function() { function zoomView(factor, focalPoint) { + // Early return if scale factor isn't actually changing + // This prevents focal point shifts when at zoom limits + if (Math.abs(scaleFactor - factor) < 0.001) { + return; + } + var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; From 541977312e3ed27141f3097b07b217532ad26e8e Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 18:15:09 +0200 Subject: [PATCH 05/49] Improve zoom smoothness and control - Make mouse wheel zoom smooth without jarring animations - Reduce zoom acceleration from 2x to 1.2x max - Slow down zoom velocity by 40-50% for better control - Add asymmetric zoom speeds (zoom out slower than zoom in) - Reduce acceleration range to 0.7-1.1 for gentler transitions - Disable legacy mousewheel handler in favor of modern wheel event --- .../src/js/ui/view-zoom-animator.js | 11 ++--- .../@node-red/editor-client/src/js/ui/view.js | 44 +++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) 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 index bfb3d7686..7938ca0de 100644 --- 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 @@ -130,15 +130,16 @@ RED.view.zoomAnimator = (function() { if (isTrackpad) { // Trackpad deltas are typically smaller and more frequent - normalizedDelta = delta * 0.01; + normalizedDelta = delta * 0.005; // Reduced from 0.01 for gentler zoom } else { // Mouse wheel deltas are larger and less frequent - normalizedDelta = delta > 0 ? 0.1 : -0.1; + // 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 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)); + // 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; } 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 1c9a97506..4776d7ade 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 @@ -694,21 +694,22 @@ RED.view = (function() { RED.popover.tooltip($("#red-ui-view-zoom-zero"),RED._('actions.zoom-reset'),'core:zoom-reset'); $("#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 ) { - evt.preventDefault(); - evt.stopPropagation(); - // Get cursor position relative to the chart - var offset = chart.offset(); - var cursorPos = [ - evt.originalEvent.pageX - offset.left, - evt.originalEvent.pageY - offset.top - ]; - var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta; - if (move <= 0) { zoomOut(cursorPos); } - else { zoomIn(cursorPos); } - } - }); + // Legacy mouse wheel handler - disabled in favor of modern wheel event + // chart.on("DOMMouseScroll mousewheel", function (evt) { + // if ( evt.altKey || spacebarPressed ) { + // evt.preventDefault(); + // evt.stopPropagation(); + // // Get cursor position relative to the chart + // var offset = chart.offset(); + // var cursorPos = [ + // evt.originalEvent.pageX - offset.left, + // evt.originalEvent.pageY - offset.top + // ]; + // var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta; + // if (move <= 0) { zoomOut(cursorPos); } + // else { zoomIn(cursorPos); } + // } + // }); // Modern wheel event handler for better trackpad support (pinch-to-zoom) and momentum var momentumTimer = null; @@ -783,9 +784,16 @@ RED.view = (function() { } }, gestureEndThreshold); // Use 500ms timeout for gesture end detection } else { - // Regular Alt+scroll or Space+scroll - use animated zoom - if (delta > 0) { zoomOut(cursorPos); } - else if (delta < 0) { zoomIn(cursorPos); } + // Regular Alt+scroll or Space+scroll - use smooth zoom without animation + // Use the zoom animator's delta calculation for mouse wheel + var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, false); + var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(RED.view.zoomConstants.MIN_ZOOM, scaleFactor + scaleDelta)); + + // Only zoom if scale is actually changing + if (Math.abs(scaleFactor - newScale) >= 0.001) { + zoomView(newScale, cursorPos); // Direct call, no animation for smoother feel + } } } else { // Regular scroll - track velocity and apply momentum From 3e2fb858216c439c21075920103a1e9bbd27b5ee Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 18:39:55 +0200 Subject: [PATCH 06/49] Add two-finger panning and spacebar+click panning - Implement spacebar+left-click panning for desktop - Add two-finger pan gesture for touch devices - Use mode locking to prevent laggy gesture switching - Lock into pan or zoom mode based on initial movement - Fix focal point regression caused by pan/zoom interaction - Improve gesture detection with better thresholds (10px for zoom, 5px for pan) --- .../@node-red/editor-client/src/js/ui/view.js | 105 +++++++++++++----- 1 file changed, 77 insertions(+), 28 deletions(-) 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 4776d7ade..eee88a20d 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 @@ -436,7 +436,8 @@ RED.view = (function() { // Store initial scale for ratio-based zoom calculation gesture = { initialScale: scaleFactor, - initialDistance: startTouchDistance + initialDistance: startTouchDistance, + mode: null // Will be determined on first significant move }; // Start gesture with fixed focal point (store in workspace coordinates) @@ -494,38 +495,76 @@ RED.view = (function() { var offset = chart.offset(); var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; var moveTouchDistance = Math.sqrt((a*a)+(b*b)); + + // Calculate center point of two fingers + var currentTouchCenter = [ + (touch0["pageX"] + touch1["pageX"]) / 2, + (touch0["pageY"] + touch1["pageY"]) / 2 + ]; if (!isNaN(moveTouchDistance)) { - oldScaleFactor = scaleFactor; - // 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)); - - // 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); + // 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; - 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); + // 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 newScaleFactor = Math.min(2, Math.max(0.3, gesture.initialScale * zoomRatio)); + + // 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 + 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); + } + // Update the center for next move + moveTouchCenter = currentTouchCenter; + } // Don't update startTouchDistance - keep initial distance for ratio calculation } @@ -1443,6 +1482,16 @@ RED.view = (function() { return; } + // Spacebar + left click for panning + if (spacebarPressed && d3.event.button === 0) { + d3.event.preventDefault(); + d3.event.stopPropagation(); + mouse_mode = RED.state.PANNING; + mouse_position = [d3.event.pageX,d3.event.pageY] + scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + return; + } + if (d3.event.button === 1) { // Middle Click pan d3.event.preventDefault(); From bf73261ecb52d88858bc2aef3bf4096d5748295d Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 18:49:07 +0200 Subject: [PATCH 07/49] Prevent UI pinch-to-zoom while keeping canvas zoomable - Add touch-action CSS to prevent pinch-zoom on UI elements - Apply touch-action: pan-x pan-y to html, body, and editor - Apply touch-action: none to canvas for custom gestures - Add JavaScript prevention for touchpad pinch on non-canvas areas - Block Ctrl+wheel events outside the workspace chart --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 7 +++++++ .../@node-red/editor-client/src/sass/base.scss | 4 +++- .../@node-red/editor-client/src/sass/workspace.scss | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) 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 eee88a20d..a18292f51 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 @@ -757,6 +757,13 @@ RED.view = (function() { var wheelEventContinuityThreshold = 100; // Events within 100ms are same gesture var gestureEndThreshold = 500; // 500ms+ gap means gesture ended + // Prevent browser zoom on non-canvas areas + document.addEventListener("wheel", function(e) { + if (e.ctrlKey && !e.target.closest('#red-ui-workspace-chart')) { + e.preventDefault(); + } + }, { passive: false }); + chart.on("wheel", function(evt) { // ctrlKey is set during pinch gestures on trackpads if (evt.ctrlKey || evt.altKey || spacebarPressed) { diff --git a/packages/node_modules/@node-red/editor-client/src/sass/base.scss b/packages/node_modules/@node-red/editor-client/src/sass/base.scss index afbafe049..fecedf883 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/base.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/base.scss @@ -17,8 +17,9 @@ **/ -body { +html, body { overflow: hidden; + touch-action: pan-x pan-y; } .red-ui-editor { @@ -29,6 +30,7 @@ body { background: var(--red-ui-primary-background); color: var(--red-ui-primary-text-color); line-height: 20px; + touch-action: pan-x pan-y; } #red-ui-editor { diff --git a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss index 95b8b4b45..7e03b063f 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss @@ -37,6 +37,7 @@ right:0px; box-sizing:border-box; transition: right 0.2s ease; + touch-action: none; &:focus { outline: none; } From 4938833227a3640af17e3aa8ed7bfa5364cd54be Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 18:57:39 +0200 Subject: [PATCH 08/49] Fix zoom gesture detection after two-finger panning Clear touchStartTime timeout when entering two-finger pan mode to prevent interference with subsequent zoom gestures. The timeout was being used for long-press detection but wasn't cleared during pan, causing the next gesture to incorrectly maintain the old touch state. --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 5 +++++ 1 file changed, 5 insertions(+) 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 a18292f51..dad9cdb21 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 @@ -553,6 +553,11 @@ RED.view = (function() { } } 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]; From b4c3faf03490731f7c49b9c19444528c5954cea0 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 19:36:13 +0200 Subject: [PATCH 09/49] Implement dynamic zoom limits to match canvas boundaries - Add calculateMinZoom() function to dynamically compute minimum zoom based on viewport size - Ensure canvas always covers the entire viewport (no empty space visible) - Use 'cover' behavior: zoom limited so canvas fills viewport completely - Update all zoom methods (buttons, wheel, trackpad, touch) to use calculated minimum - Prevent zooming out beyond what's needed to fill the viewport with canvas content --- .../src/js/ui/view-zoom-constants.js | 2 +- .../@node-red/editor-client/src/js/ui/view.js | 34 ++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) 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 index e983b473e..ff32bb92d 100644 --- 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 @@ -3,7 +3,7 @@ */ RED.view.zoomConstants = { // Zoom limits - MIN_ZOOM: 0.3, + MIN_ZOOM: 0.15, // Default minimum, will be dynamically calculated to fit canvas MAX_ZOOM: 2.0, // Zoom step for keyboard/button controls 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 dad9cdb21..e61375847 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 @@ -524,7 +524,9 @@ RED.view = (function() { oldScaleFactor = scaleFactor; // 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)); + var minZoom = calculateMinZoom(); + var newScaleFactor = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(minZoom, gesture.initialScale * zoomRatio)); // Use gesture state management to maintain fixed focal point var gestureState = RED.view.zoomAnimator.updateGesture(newScaleFactor); @@ -791,8 +793,9 @@ RED.view = (function() { // Use the zoom animator's delta calculation for trackpad // Invert delta: spreading fingers (negative deltaY) should zoom in var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, true); + var minZoom = calculateMinZoom(); var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, - Math.max(RED.view.zoomConstants.MIN_ZOOM, scaleFactor + scaleDelta)); + Math.max(minZoom, scaleFactor + scaleDelta)); // Session-based gesture tracking: // - If no active gesture OR gap > gestureEndThreshold, start new gesture @@ -2746,14 +2749,32 @@ RED.view = (function() { } + function calculateMinZoom() { + // Calculate the minimum zoom to ensure canvas always fills the viewport (no empty space) + var viewportWidth = chart.width(); + var viewportHeight = chart.height(); + + // Canvas is 8000x8000, calculate zoom to cover viewport + var zoomToFitWidth = viewportWidth / space_width; + var zoomToFitHeight = viewportHeight / space_height; + + // Use the LARGER zoom to ensure canvas covers entire viewport (no empty space visible) + var calculatedMinZoom = Math.max(zoomToFitWidth, zoomToFitHeight); + + // Return the larger of the calculated min or the configured min + // This ensures canvas always fills the viewport + return Math.max(calculatedMinZoom, RED.view.zoomConstants.MIN_ZOOM); + } + function zoomIn(focalPoint) { if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) { animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); } } function zoomOut(focalPoint) { - if (scaleFactor > RED.view.zoomConstants.MIN_ZOOM) { - animatedZoomView(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, focalPoint); + var minZoom = calculateMinZoom(); + if (scaleFactor > minZoom) { + animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); } } function zoomZero() { animatedZoomView(1); } @@ -2812,8 +2833,11 @@ RED.view = (function() { cancelInProgressAnimation = null; } + // Calculate the actual minimum zoom to fit canvas + var minZoom = calculateMinZoom(); + // Clamp target factor to valid range - targetFactor = Math.max(RED.view.zoomConstants.MIN_ZOOM, + targetFactor = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetFactor)); // If we're already at the target, no need to animate From 7dca55fdb8187ea10e70168ab774e0b7a6e6beae Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 20:04:26 +0200 Subject: [PATCH 10/49] Add dynamic minimum zoom recalculation on viewport resize - Recalculate minimum zoom when window resizes to ensure canvas fits properly - Automatically adjust zoom if current level falls below new minimum after resize - Ensures canvas boundaries remain appropriate for different viewport sizes --- .../editor-client/src/js/ui/view-zoom-constants.js | 2 +- .../@node-red/editor-client/src/js/ui/view.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 index ff32bb92d..92e0f526c 100644 --- 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 @@ -3,7 +3,7 @@ */ RED.view.zoomConstants = { // Zoom limits - MIN_ZOOM: 0.15, // Default minimum, will be dynamically calculated to fit canvas + MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas MAX_ZOOM: 2.0, // Zoom step for keyboard/button controls 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 e61375847..bbe0438c7 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 @@ -640,6 +640,16 @@ RED.view = (function() { spacebarPressed = false; } }) + + // Recalculate minimum zoom when window resizes + $(window).on("resize.red-ui-view", function() { + // Recalculate minimum zoom to ensure canvas fits in viewport + var newMinZoom = calculateMinZoom(); + // If current zoom is below new minimum, adjust it + if (scaleFactor < newMinZoom) { + zoomView(newMinZoom); + } + }) // Workspace Background eventLayer.append("svg:rect") From 6725fd64267a4b3d5b4380d13ff87dd2e60ca2a8 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 29 Sep 2025 20:15:30 +0200 Subject: [PATCH 11/49] Hide scrollbars and add auto-show/hide minimap on navigation - Hide scrollbars on canvas while keeping it scrollable - Add minimap auto-show functionality that triggers on zoom and pan - Minimap appears for 2 seconds during navigation then fades out - Add smooth fade in/out animations for minimap visibility - Emit view:navigate events for all zoom and pan operations - Minimap stays visible if manually toggled with button --- .../editor-client/src/js/ui/view-navigator.js | 59 +++++++++++++++++-- .../@node-red/editor-client/src/js/ui/view.js | 6 ++ .../editor-client/src/sass/workspace.scss | 18 ++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index 6450d45bd..904265728 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -31,6 +31,8 @@ var dimensions; var isDragging; var isShowing = false; + var autoHideTimeout; + var isManuallyToggled = false; function refreshNodes() { if (!isShowing) { @@ -68,19 +70,59 @@ function toggle() { if (!isShowing) { isShowing = true; + isManuallyToggled = true; + clearTimeout(autoHideTimeout); $("#red-ui-view-navigate").addClass("selected"); resizeNavBorder(); refreshNodes(); $("#red-ui-workspace-chart").on("scroll",onScroll); - navContainer.fadeIn(200); + navContainer.addClass('red-ui-navigator-container'); + navContainer.show(); + setTimeout(function() { + navContainer.addClass('red-ui-navigator-visible'); + }, 10); } else { isShowing = false; - navContainer.fadeOut(100); + isManuallyToggled = false; + clearTimeout(autoHideTimeout); + navContainer.removeClass('red-ui-navigator-visible'); + setTimeout(function() { + navContainer.hide(); + }, 300); $("#red-ui-workspace-chart").off("scroll",onScroll); $("#red-ui-view-navigate").removeClass("selected"); } } + function showTemporary() { + if (!isManuallyToggled) { + clearTimeout(autoHideTimeout); + + if (!isShowing) { + isShowing = true; + resizeNavBorder(); + refreshNodes(); + $("#red-ui-workspace-chart").on("scroll",onScroll); + navContainer.addClass('red-ui-navigator-container'); + navContainer.show(); + setTimeout(function() { + navContainer.addClass('red-ui-navigator-visible'); + }, 10); + } + + autoHideTimeout = setTimeout(function() { + if (!isManuallyToggled && isShowing) { + isShowing = false; + navContainer.removeClass('red-ui-navigator-visible'); + setTimeout(function() { + navContainer.hide(); + }, 300); + $("#red-ui-workspace-chart").off("scroll",onScroll); + } + }, 2000); + } + } + return { init: function() { @@ -94,7 +136,7 @@ "bottom":$("#red-ui-workspace-footer").height(), "right":0, zIndex: 1 - }).appendTo("#red-ui-workspace").hide(); + }).addClass('red-ui-navigator-container').appendTo("#red-ui-workspace").hide(); navBox = d3.select(navContainer[0]) .append("svg:svg") @@ -148,10 +190,19 @@ toggle(); }) RED.popover.tooltip($("#red-ui-view-navigate"),RED._('actions.toggle-navigator'),'core:toggle-navigator'); + + // Listen for canvas interactions to show minimap temporarily + RED.events.on("view:selection-changed", function() { + showTemporary(); + }); + RED.events.on("view:navigate", function() { + showTemporary(); + }); }, refresh: refreshNodes, resize: resizeNavBorder, - toggle: toggle + toggle: toggle, + showTemporary: showTemporary } 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 bbe0438c7..3f322cf0d 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 @@ -480,6 +480,7 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [touch0.pageX,touch0.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + RED.events.emit("view:navigate"); } } @@ -568,6 +569,7 @@ RED.view = (function() { 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; @@ -1514,6 +1516,7 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + RED.events.emit("view:navigate"); return; } @@ -1523,6 +1526,7 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + RED.events.emit("view:navigate"); return; } if (d3.event.button === 2) { @@ -2080,6 +2084,7 @@ RED.view = (function() { chart.scrollLeft(scroll_position[0]+deltaPos[0]) chart.scrollTop(scroll_position[1]+deltaPos[1]) + RED.events.emit("view:navigate"); return } @@ -2831,6 +2836,7 @@ RED.view = (function() { RED.view.navigator.resize(); redraw(); + RED.events.emit("view:navigate"); if (RED.settings.get("editor.view.view-store-zoom")) { RED.settings.setLocal('zoom-level', factor.toFixed(1)) } diff --git a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss index 7e03b063f..46be67158 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss @@ -38,6 +38,15 @@ box-sizing:border-box; transition: right 0.2s ease; touch-action: none; + + // Hide scrollbars + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer 10+ */ + &::-webkit-scrollbar { /* WebKit */ + width: 0; + height: 0; + } + &:focus { outline: none; } @@ -151,6 +160,15 @@ background: var(--red-ui-view-navigator-background); box-shadow: -1px 0 3px var(--red-ui-shadow); } + +.red-ui-navigator-container { + transition: opacity 0.3s ease; + opacity: 0; + + &.red-ui-navigator-visible { + opacity: 1; + } +} .red-ui-navigator-border { stroke-dasharray: 5,5; pointer-events: none; From 08a5d04df62b96f52c150752b771a6271493cb29 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 12:17:54 +0200 Subject: [PATCH 12/49] Enable diagonal trackpad panning - Prevent browser's native axis-locked scroll behavior - Manually handle both deltaX and deltaY in wheel event handler - Update touch-action CSS from pan-x pan-y to manipulation - Add documentation of fix to CANVAS_INTERACTION.md Fixes issue where trackpad scrolling was restricted to horizontal or vertical movement only, not both simultaneously. --- CANVAS_INTERACTION.md | 221 ++++++++++++++++++ .../@node-red/editor-client/src/js/ui/view.js | 13 +- .../editor-client/src/sass/base.scss | 4 +- 3 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 CANVAS_INTERACTION.md diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md new file mode 100644 index 000000000..dc9ed398c --- /dev/null +++ b/CANVAS_INTERACTION.md @@ -0,0 +1,221 @@ +# Canvas Interaction Improvements + +This document tracks the ongoing improvements to Node-RED's canvas interaction across different devices, input methods, and browser zoom settings. + +## Objectives + +Improve canvas interaction to work consistently and intuitively across: +- **Browser zoom levels**: 100%, 125%, 150%, 200%, etc. +- **Input devices**: Mouse, trackpad, and touchscreen +- **Platforms**: Desktop (Windows, macOS, Linux) and mobile/tablet devices + +## What Has Been Implemented + +### Zoom Functionality + +#### Smooth Zoom Animation (commits: bdfa06b, a12b65b) +- ✅ 125ms smooth zoom transitions with ease-out curves +- ✅ Natural acceleration/deceleration for zoom operations +- ✅ Reduced acceleration from 2x to 1.2x max for better control +- ✅ Asymmetric zoom speeds (zoom out 40-50% slower than zoom in) +- ✅ Gentler acceleration range (0.7-1.1) for smoother transitions +- ✅ No jarring animations during mouse wheel zoom + +#### Zoom Input Methods (commits: e7a028b, bdfa06b) +- ✅ Mouse wheel zoom +- ✅ Space+scroll zoom mode (alternative to Alt+scroll) +- ✅ Trackpad pinch-to-zoom (Ctrl+wheel) +- ✅ Touch screen pinch-to-zoom with proper center tracking +- ✅ UI zoom buttons (corrected zoom in/out direction) + +#### Zoom Focal Point (commits: e42b09de, feec7ec, e7a028b) +- ✅ Cursor-centered zoom (focuses on cursor position) +- ✅ Store focal point in workspace coordinates instead of screen coordinates +- ✅ Prevents focal point drift when scroll changes due to canvas boundaries +- ✅ Maintains consistent zoom focus even when view shifts at edges +- ✅ Fixed focal point during pinch gestures + +#### Zoom Direction & Behavior (commits: 37f9786, bdfa06b) +- ✅ Fixed trackpad zoom direction (spreading fingers zooms in, pinching zooms out) +- ✅ Matches standard macOS trackpad behavior +- ✅ Proper ratio-based scaling for pinch gestures +- ✅ Scale lock issues fixed with improved tolerance handling + +#### Dynamic Zoom Limits (commits: 7918693, f13ed66) +- ✅ Calculate minimum zoom dynamically based on viewport size +- ✅ Ensure canvas always covers entire viewport (no empty space visible) +- ✅ Use 'cover' behavior: canvas fills viewport completely +- ✅ Recalculate minimum zoom on window resize +- ✅ Automatically adjust zoom if current level falls below new minimum after resize +- ✅ Prevent zooming out beyond what's needed to fill viewport + +### Panning Functionality + +#### Pan Input Methods (commit: feec7ec) +- ✅ Two-finger pan gesture for touch devices +- ✅ Spacebar+left-click panning for desktop +- ✅ Mode locking to prevent laggy gesture switching +- ✅ Lock into pan or zoom mode based on initial movement +- ✅ Better gesture detection thresholds (10px for zoom, 5px for pan) + +#### Scroll Behavior (commit: e7a028b) +- ✅ Momentum scrolling with edge bounce animation +- ✅ Enhanced spacebar handling to prevent scroll artifacts + +### UI/UX Enhancements + +#### Gesture State Management (commits: e42b09de, bdfa06b, 121982e) +- ✅ Improved gesture state management for trackpad and touch gestures +- ✅ Proper state cleanup when cursor leaves canvas +- ✅ Clear touchStartTime timeout when entering two-finger pan mode +- ✅ Prevent interference between long-press detection and pan gestures + +#### UI Pinch-Zoom Prevention (commit: e0c5b84) +- ✅ Prevent UI pinch-to-zoom while keeping canvas zoomable +- ✅ Apply `touch-action: pan-x pan-y` to html, body, and editor elements +- ✅ Apply `touch-action: none` to canvas for custom gestures +- ✅ JavaScript prevention for trackpad pinch on non-canvas areas +- ✅ Block Ctrl+wheel events outside the workspace chart + +#### Minimap Navigation (commit: 53dce6a) +- ✅ Auto-show minimap on zoom and pan operations +- ✅ Minimap appears for 2 seconds during navigation then fades out +- ✅ Smooth fade in/out animations for minimap visibility +- ✅ Minimap stays visible if manually toggled with button +- ✅ Emit `view:navigate` events for all zoom and pan operations + +#### Visual Polish (commit: 53dce6a) +- ✅ Hide scrollbars on canvas while keeping it scrollable +- ✅ Clean visual appearance without visible scrollbars + +### Code Architecture + +#### New Modules (commit: bdfa06b) +- ✅ `view-zoom-animator.js` - Zoom animation utilities (223 lines) +- ✅ `view-zoom-constants.js` - Zoom configuration constants (21 lines) +- ✅ Updated Gruntfile to include new zoom modules in build + +## Current Expectations + +### Cross-Device Consistency +- Zoom and pan should feel natural on mouse, trackpad, and touchscreen +- Gestures should be responsive without lag or mode switching artifacts +- Zoom focal point should remain stable regardless of input method + +### Browser Zoom Compatibility +- Canvas interaction should work correctly at all browser zoom levels +- UI elements should remain accessible and functional +- No layout breaking or interaction dead zones + +### Visual Feedback +- Minimap should provide contextual navigation feedback +- Smooth animations should make interactions feel polished +- No visual glitches or artifacts during zoom/pan operations + +### Performance +- All interactions should be smooth (60fps target) +- No janky animations or delayed responses +- Efficient gesture detection without excessive computation + +## Recent Fixes + +### Diagonal Trackpad Panning (Latest) +**Issue**: Trackpad scrolling was restricted to horizontal OR vertical movement, not both simultaneously. + +**Root Cause**: Browser's native scroll behavior on `overflow: auto` containers locks into one axis at a time, even before JavaScript wheel events fire. + +**Solution**: +- Added `evt.preventDefault()` and `evt.stopPropagation()` to regular scroll handling +- Manually apply both `deltaX` and `deltaY` to scrollLeft/scrollTop simultaneously +- Prevents browser's axis-locked scroll behavior from taking over +- Also updated CSS `touch-action` from `pan-x pan-y` to `manipulation` (though this primarily affects touch events, not trackpad) + +**Files Changed**: +- `view.js:864-890` - Added manual diagonal scroll handling +- `base.scss:22, 33` - Changed touch-action to manipulation + +**Result**: Trackpad can now pan diagonally without axis-locking. + +## Known Issues & Future Work + +### To Be Tested +- [ ] Comprehensive testing across different browser zoom levels (100%, 125%, 150%, 200%) +- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) +- [ ] Testing on different touchscreen devices (tablets, touch-enabled laptops) +- [ ] Testing with different trackpad sensitivities and gesture settings +- [x] Diagonal trackpad panning (fixed) + +### Potential Improvements +- [ ] Additional fine-tuning of zoom speeds and acceleration curves based on user feedback +- [ ] Consider adding keyboard shortcuts for zoom reset (Ctrl+0 / Cmd+0) +- [ ] Evaluate need for custom zoom level indicator in UI +- [ ] Consider adding preferences for zoom/pan sensitivity + +### Edge Cases to Monitor +- [ ] Behavior when canvas content is very small or very large +- [ ] Interaction with browser accessibility features +- [ ] Performance with extremely large flows (100+ nodes) +- [ ] Multi-monitor scenarios with different DPI settings + +## Testing Checklist + +When verifying canvas interaction improvements: + +1. **Zoom Testing** + - [ ] Mouse wheel zoom in/out + - [ ] Space+scroll zoom + - [ ] Trackpad pinch gesture (spread = zoom in, pinch = zoom out) + - [ ] Touch screen pinch gesture + - [ ] UI zoom buttons + - [ ] Zoom focal point stays on cursor position + - [ ] Dynamic zoom limits prevent empty space + +2. **Pan Testing** + - [x] Two-finger pan on trackpad/touch + - [x] Diagonal panning works (not axis-locked) + - [x] Spacebar+click pan on desktop + - [x] Momentum scrolling with edge bounce + - [x] No lag when switching between pan and zoom + +3. **UI/UX Testing** + - [ ] Minimap auto-shows on navigation + - [ ] Minimap fades after 2 seconds + - [ ] No scrollbars visible on canvas + - [ ] No pinch-zoom on UI elements + - [ ] Gesture state cleanup on cursor exit + +4. **Browser Zoom Testing** + - [ ] Test at 100% browser zoom + - [ ] Test at 125% browser zoom + - [ ] Test at 150% browser zoom + - [ ] Test at 200% browser zoom + - [ ] Verify all interactions work at each zoom level + +## Files Modified + +Key files involved in canvas interaction improvements: + +- `packages/node_modules/@node-red/editor-client/src/js/ui/view.js` - Main view controller +- `packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js` - Zoom animations +- `packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js` - Zoom configuration +- `packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js` - Minimap controller +- `packages/node_modules/@node-red/editor-client/src/sass/workspace.scss` - Canvas styling +- `packages/node_modules/@node-red/editor-client/src/sass/base.scss` - Base UI styling +- `Gruntfile.js` - Build configuration + +## Commit History + +Interaction improvements span commits from e7a028b to present (12 commits total): + +1. `e7a028b` - feat: Add enhanced zoom and scroll features +2. `bdfa06b` - Implement smooth zoom functionality with pinch-to-zoom support +3. `37f9786` - Fix trackpad zoom direction - spreading fingers now zooms in +4. `e42b09d` - Fix zoom focal point stability at canvas edges +5. `a12b65b` - Improve zoom smoothness and control +6. `feec7ec` - Add two-finger panning and spacebar+click panning +7. `e0c5b84` - Prevent UI pinch-to-zoom while keeping canvas zoomable +8. `121982e` - Fix zoom gesture detection after two-finger panning +9. `7918693` - Implement dynamic zoom limits to match canvas boundaries +10. `f13ed66` - Add dynamic minimum zoom recalculation on viewport resize +11. `53dce6a` - Hide scrollbars and add auto-show/hide minimap on navigation +12. (current) - Enable diagonal trackpad panning by preventing axis-locked scroll \ 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 3f322cf0d..4c07335ac 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 @@ -862,7 +862,18 @@ RED.view = (function() { } } } else { - // Regular scroll - track velocity and apply momentum + // Regular scroll - prevent default and manually handle both axes + evt.preventDefault(); + evt.stopPropagation(); + + // Apply scroll deltas directly to both axes + var deltaX = evt.originalEvent.deltaX; + var deltaY = evt.originalEvent.deltaY; + + chart.scrollLeft(chart.scrollLeft() + deltaX); + chart.scrollTop(chart.scrollTop() + deltaY); + + // Track velocity and apply momentum handleScroll(); // Cancel previous momentum timer diff --git a/packages/node_modules/@node-red/editor-client/src/sass/base.scss b/packages/node_modules/@node-red/editor-client/src/sass/base.scss index fecedf883..23aba4205 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/base.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/base.scss @@ -19,7 +19,7 @@ html, body { overflow: hidden; - touch-action: pan-x pan-y; + touch-action: manipulation; } .red-ui-editor { @@ -30,7 +30,7 @@ html, body { background: var(--red-ui-primary-background); color: var(--red-ui-primary-text-color); line-height: 20px; - touch-action: pan-x pan-y; + touch-action: manipulation; } #red-ui-editor { From 3bef2d6481d42d1c84ac01ed63089badb41745f7 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 12:39:11 +0200 Subject: [PATCH 13/49] Improve minimap auto-show behavior - Remove view:selection-changed listener to prevent minimap showing on node selection - Remove view:navigate emissions from pan mode entry points (no longer shows when starting pan) - Add view:navigate emission to touchpad scroll handler for consistent behavior - Minimap now only appears during actual panning and zooming actions The minimap previously showed when selecting nodes or just starting a pan gesture, causing unnecessary flashing. Now it only appears during actual navigation (pan/zoom) and fades after 2 seconds of inactivity. --- CANVAS_INTERACTION.md | 34 ++++++++++++++----- .../editor-client/src/js/ui/view-navigator.js | 4 +-- .../@node-red/editor-client/src/js/ui/view.js | 6 ++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md index dc9ed398c..918c15d0d 100644 --- a/CANVAS_INTERACTION.md +++ b/CANVAS_INTERACTION.md @@ -119,7 +119,23 @@ Improve canvas interaction to work consistently and intuitively across: ## Recent Fixes -### Diagonal Trackpad Panning (Latest) +### Minimap Auto-Show Behavior (Latest) +**Issue**: Minimap was showing on selection changes and when entering pan mode (before actual panning), causing unnecessary flashing. + +**Solution**: +- Removed `view:selection-changed` event listener - minimap no longer shows when selecting nodes +- Removed `view:navigate` emissions from pan mode entry points (touch long-press, spacebar+click, middle-click) +- Added `view:navigate` emission to regular touchpad scroll handler for consistent behavior +- Kept emissions only during actual panning movement and zooming + +**Files Changed**: +- `view-navigator.js:195-198` - Removed selection-changed listener +- `view.js:483, 1529, 1539` - Removed navigate events from pan mode entry +- `view.js:876` - Added navigate event to touchpad scroll handler + +**Result**: Minimap now appears only during actual panning (touchpad or mouse) and zooming, not on selection or pan mode entry. + +### Diagonal Trackpad Panning **Issue**: Trackpad scrolling was restricted to horizontal OR vertical movement, not both simultaneously. **Root Cause**: Browser's native scroll behavior on `overflow: auto` containers locks into one axis at a time, even before JavaScript wheel events fire. @@ -178,11 +194,12 @@ When verifying canvas interaction improvements: - [x] No lag when switching between pan and zoom 3. **UI/UX Testing** - - [ ] Minimap auto-shows on navigation - - [ ] Minimap fades after 2 seconds - - [ ] No scrollbars visible on canvas - - [ ] No pinch-zoom on UI elements - - [ ] Gesture state cleanup on cursor exit + - [x] Minimap auto-shows during panning and zooming + - [x] Minimap does not show on selection changes + - [x] Minimap fades after 2 seconds + - [x] No scrollbars visible on canvas + - [x] No pinch-zoom on UI elements + - [x] Gesture state cleanup on cursor exit 4. **Browser Zoom Testing** - [ ] Test at 100% browser zoom @@ -205,7 +222,7 @@ Key files involved in canvas interaction improvements: ## Commit History -Interaction improvements span commits from e7a028b to present (12 commits total): +Interaction improvements span commits from e7a028b to present (13 commits total): 1. `e7a028b` - feat: Add enhanced zoom and scroll features 2. `bdfa06b` - Implement smooth zoom functionality with pinch-to-zoom support @@ -218,4 +235,5 @@ Interaction improvements span commits from e7a028b to present (12 commits total) 9. `7918693` - Implement dynamic zoom limits to match canvas boundaries 10. `f13ed66` - Add dynamic minimum zoom recalculation on viewport resize 11. `53dce6a` - Hide scrollbars and add auto-show/hide minimap on navigation -12. (current) - Enable diagonal trackpad panning by preventing axis-locked scroll \ No newline at end of file +12. `875db2c` - Enable diagonal trackpad panning by preventing axis-locked scroll +13. (current) - Improve minimap auto-show behavior to only trigger during actual navigation \ No newline at end of file diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index 904265728..c69c8337f 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -192,9 +192,7 @@ RED.popover.tooltip($("#red-ui-view-navigate"),RED._('actions.toggle-navigator'),'core:toggle-navigator'); // Listen for canvas interactions to show minimap temporarily - RED.events.on("view:selection-changed", function() { - showTemporary(); - }); + // Only show on actual pan/zoom navigation, not selection changes RED.events.on("view:navigate", function() { showTemporary(); }); 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 4c07335ac..af555da74 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 @@ -480,7 +480,6 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [touch0.pageX,touch0.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; - RED.events.emit("view:navigate"); } } @@ -873,6 +872,9 @@ RED.view = (function() { chart.scrollLeft(chart.scrollLeft() + deltaX); chart.scrollTop(chart.scrollTop() + deltaY); + // Emit navigate event for minimap + RED.events.emit("view:navigate"); + // Track velocity and apply momentum handleScroll(); @@ -1527,7 +1529,6 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; - RED.events.emit("view:navigate"); return; } @@ -1537,7 +1538,6 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; - RED.events.emit("view:navigate"); return; } if (d3.event.button === 2) { From 45f3b01125ff56de9442d53435db944d0c820b94 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 12:43:44 +0200 Subject: [PATCH 14/49] Fix spacebar hold causing unwanted canvas scrolling When holding spacebar, browsers fire repeated keydown events. The previous implementation only prevented default on the first keydown, allowing subsequent events to trigger browser's space-scroll behavior. Moved preventDefault() outside conditional to block all spacebar events. --- .../@node-red/editor-client/src/js/ui/view.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 af555da74..a0dcfcd1f 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 @@ -608,15 +608,14 @@ RED.view = (function() { // Handle spacebar for zoom mode function handleSpacebarToggle(e) { if (e.keyCode === 32 || e.key === ' ') { + // Always prevent default space scrolling behavior + e.preventDefault(); + e.stopPropagation(); + if (e.type === "keydown" && !spacebarPressed) { spacebarPressed = true; - // Prevent default space scrolling behavior - e.preventDefault(); - e.stopPropagation(); } else if (e.type === "keyup" && spacebarPressed) { spacebarPressed = false; - e.preventDefault(); - e.stopPropagation(); } } } From c07cce4fb0e619f82f649d9ab1725e0c02fbbfc9 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 12:47:10 +0200 Subject: [PATCH 15/49] Make Alt/Space scroll zoom speed match trackpad pinch zoom Alt+scroll and Space+scroll were using fixed zoom steps (0.06/0.08), making them zoom much faster than trackpad pinch zoom which uses proportional scaling (0.005 * delta). Changed to use trackpad-style proportional zoom for consistent feel across all zoom input methods. --- .../@node-red/editor-client/src/js/ui/view.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 a0dcfcd1f..2b7c48965 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 @@ -849,11 +849,12 @@ RED.view = (function() { }, gestureEndThreshold); // Use 500ms timeout for gesture end detection } else { // Regular Alt+scroll or Space+scroll - use smooth zoom without animation - // Use the zoom animator's delta calculation for mouse wheel - var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, false); - var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, - Math.max(RED.view.zoomConstants.MIN_ZOOM, scaleFactor + scaleDelta)); - + // Use same proportional zoom as trackpad for consistent feel + var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, true); + var minZoom = calculateMinZoom(); + var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(minZoom, scaleFactor + scaleDelta)); + // Only zoom if scale is actually changing if (Math.abs(scaleFactor - newScale) >= 0.001) { zoomView(newScale, cursorPos); // Direct call, no animation for smoother feel From f132867a3130402ad5d70a799293526985562fe0 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 12:48:58 +0200 Subject: [PATCH 16/49] Add stable focal point tracking to Alt/Space scroll zoom Alt+scroll and Space+scroll zoom now maintain a fixed focal point like trackpad pinch zoom. Previously, the zoom point would drift during continuous scrolling. Implemented gesture session tracking that: - Stores focal point in workspace coordinates for stability - Locks focal point during continuous scroll events (< 100ms apart) - Ends gesture after 500ms of inactivity - Converts focal point back to screen coordinates for each zoom step This makes all zoom methods (pinch, Alt+scroll, Space+scroll) behave consistently with stable, cursor-centered zooming. --- .../@node-red/editor-client/src/js/ui/view.js | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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 2b7c48965..3e5a8e040 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 @@ -855,10 +855,41 @@ RED.view = (function() { var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, Math.max(minZoom, scaleFactor + scaleDelta)); + // Use gesture tracking for stable focal point like trackpad pinch + if (!RED.view.zoomAnimator.isGestureActive() || timeSinceLastEvent > gestureEndThreshold) { + // Start new gesture session - store focal point in workspace coordinates + var scrollPos = [chart.scrollLeft(), chart.scrollTop()]; + RED.view.zoomAnimator.startGesture(cursorPos, scaleFactor, scrollPos, scaleFactor); + } else if (timeSinceLastEvent <= wheelEventContinuityThreshold) { + // Events are continuous - same gesture, focal point remains locked + } + + // Update gesture with new scale, maintaining locked focal point + RED.view.zoomAnimator.updateGesture(newScale); + // Only zoom if scale is actually changing if (Math.abs(scaleFactor - newScale) >= 0.001) { - zoomView(newScale, cursorPos); // Direct call, no animation for smoother feel + // Get focal point converted back to current screen coordinates + var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()]; + var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor); + zoomView(newScale, focalPoint); } + + // Update last event time for continuity tracking + lastWheelEventTime = currentTime; + + // Reset gesture timeout + if (trackpadGestureTimer) { + clearTimeout(trackpadGestureTimer); + } + trackpadGestureTimer = setTimeout(function() { + RED.view.zoomAnimator.endGesture(); + trackpadGestureTimer = null; + // Store zoom level when gesture completes + if (RED.settings.get("editor.view.view-store-zoom")) { + RED.settings.setLocal('zoom-level', scaleFactor.toFixed(1)); + } + }, gestureEndThreshold); } } else { // Regular scroll - prevent default and manually handle both axes From cdde99b9ab6de892f7e077669a78d30c62273244 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:11:02 +0200 Subject: [PATCH 17/49] Add scroll spacer to fix scrollable area at minimum zoom When at minimum zoom with "cover" behavior, the SVG canvas may be smaller than the viewport in one dimension. This causes the browser's scrollWidth/scrollHeight to be limited by the SVG size rather than the full canvas extent. Added an invisible spacer div that matches the scaled canvas dimensions, ensuring the scrollable area always reflects the actual canvas size. This allows proper scrolling to reach all canvas edges without going beyond canvas boundaries. --- .../@node-red/editor-client/src/js/ui/view.js | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) 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 3e5a8e040..5b9843ae1 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 @@ -336,6 +336,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 }); @@ -2810,14 +2828,14 @@ RED.view = (function() { // Calculate the minimum zoom to ensure canvas always fills the viewport (no empty space) var viewportWidth = chart.width(); var viewportHeight = chart.height(); - + // Canvas is 8000x8000, calculate zoom to cover viewport var zoomToFitWidth = viewportWidth / space_width; var zoomToFitHeight = viewportHeight / space_height; - + // Use the LARGER zoom to ensure canvas covers entire viewport (no empty space visible) var calculatedMinZoom = Math.max(zoomToFitWidth, zoomToFitHeight); - + // Return the larger of the calculated min or the configured min // This ensures canvas always fills the viewport return Math.max(calculatedMinZoom, RED.view.zoomConstants.MIN_ZOOM); @@ -4969,6 +4987,15 @@ RED.view = (function() { eventLayer.attr("transform","scale("+scaleFactor+")"); outer.attr("width", space_width*scaleFactor).attr("height", space_height*scaleFactor); + // Update scroll spacer to match scaled canvas size + // This ensures scrollable area = canvas area + // Browser calculates maxScroll = scrollWidth - viewport, which correctly + // allows scrolling to see the far edges of canvas without going beyond + $('#red-ui-workspace-scroll-spacer').css({ + width: (space_width * scaleFactor) + 'px', + height: (space_height * scaleFactor) + 'px' + }); + // Don't bother redrawing nodes if we're drawing links if (showAllLinkPorts !== -1 || mouse_mode != RED.state.JOINING) { From f718069b46f0aabd9f80f6a2f0a866c834c908a2 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:15:05 +0200 Subject: [PATCH 18/49] Fix minimap viewport position at non-1.0 zoom levels The minimap was treating scroll position as workspace coordinates, but scrollLeft/scrollTop are actually in scaled canvas pixels. At zoom levels other than 1.0, this caused the viewport rectangle to appear in the wrong position. For example, at 2x zoom viewing workspace position (500, 500), the scroll position would be 1000px, and the minimap would incorrectly show it at workspace position 1000. Fixed by converting scroll position to workspace coordinates first: position = scrollPos / scaleFactor / nav_scale The viewport rectangle now accurately reflects the actual visible area at all zoom levels. --- .../@node-red/editor-client/src/js/ui/view-navigator.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index c69c8337f..5b1eb8925 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -61,8 +61,11 @@ 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()]; - navBorder.attr('x',scrollPos[0]/nav_scale) - .attr('y',scrollPos[1]/nav_scale) + + // 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) } From 48b6fb353dc5203b7ad883d9092f4de9519f2478 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:21:58 +0200 Subject: [PATCH 19/49] Fix grey padding at canvas bottom by resetting SVG margins Remove 5px grey space that appeared at bottom of canvas when scrolled to maximum position. The viewport scrollHeight was 8005px instead of 8000px due to default browser SVG margins. - Add explicit padding and margin resets to workspace chart container - Set SVG to display:block with zero margin/padding to prevent spacing - Ensures scrollable area exactly matches 8000px canvas dimensions --- CANVAS_INTERACTION.md | 16 +++++++++++++++- .../editor-client/src/sass/workspace.scss | 13 +++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md index 918c15d0d..b9d6619da 100644 --- a/CANVAS_INTERACTION.md +++ b/CANVAS_INTERACTION.md @@ -119,7 +119,21 @@ Improve canvas interaction to work consistently and intuitively across: ## Recent Fixes -### Minimap Auto-Show Behavior (Latest) +### Spacebar Hold Scrolling Bug (Latest) +**Issue**: When holding spacebar down, the canvas would move down unexpectedly, making the space+scroll interaction buggy. + +**Root Cause**: The `preventDefault()` was only called on the first spacebar keydown event. When spacebar is held, browsers fire repeated keydown events. After the first keydown set `spacebarPressed = true`, subsequent keydown events weren't prevented because the condition `e.type === "keydown" && !spacebarPressed` failed, allowing browser's default space-scroll behavior. + +**Solution**: +- Moved `preventDefault()` and `stopPropagation()` outside the conditional checks +- Now blocks ALL spacebar events (both keydown repeats and keyup), not just the first keydown + +**Files Changed**: +- `view.js:611-619` - Restructured spacebar event handler to always prevent default + +**Result**: Holding spacebar no longer causes unwanted canvas scrolling. + +### Minimap Auto-Show Behavior **Issue**: Minimap was showing on selection changes and when entering pan mode (before actual panning), causing unnecessary flashing. **Solution**: diff --git a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss index 46be67158..a29932e9d 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss @@ -38,7 +38,9 @@ box-sizing:border-box; transition: right 0.2s ease; touch-action: none; - + padding: 0; + margin: 0; + // Hide scrollbars scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* Internet Explorer 10+ */ @@ -46,7 +48,14 @@ width: 0; height: 0; } - + + // Reset SVG default margins + > svg { + display: block; + margin: 0; + padding: 0; + } + &:focus { outline: none; } From c5209d8ea297b94688626552cbd310b08b39d2d7 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:32:18 +0200 Subject: [PATCH 20/49] Add zoom-to-fit button to show all nodes at once Add fourth zoom button that calculates bounding box of all active nodes and zooms out to fit them all in viewport with padding. - Add compress icon button to zoom controls in footer - Implement zoomToFitAll() function with bounding box calculation - Add 80px padding around nodes for visual breathing room - Respect dynamic minimum zoom limit - Center viewport on bounding box after zoom animation - Register core:zoom-fit action for keyboard shortcut support - Update documentation with new zoom-to-fit feature --- CANVAS_INTERACTION.md | 37 +++++++--- .../@node-red/editor-client/src/js/ui/view.js | 72 +++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md index b9d6619da..16261b2c6 100644 --- a/CANVAS_INTERACTION.md +++ b/CANVAS_INTERACTION.md @@ -23,10 +23,14 @@ Improve canvas interaction to work consistently and intuitively across: #### Zoom Input Methods (commits: e7a028b, bdfa06b) - ✅ Mouse wheel zoom -- ✅ Space+scroll zoom mode (alternative to Alt+scroll) -- ✅ Trackpad pinch-to-zoom (Ctrl+wheel) -- ✅ Touch screen pinch-to-zoom with proper center tracking +- ✅ Alt+scroll zoom mode (keyboard modifier alternative) +- ✅ Space+scroll zoom mode (keyboard modifier alternative) +- ✅ Trackpad pinch-to-zoom (browsers translate to Ctrl+wheel events) +- ✅ Touch screen pinch-to-zoom with proper center tracking (direct touch events) - ✅ UI zoom buttons (corrected zoom in/out direction) +- ✅ Zoom-to-fit button (zooms out to show all nodes with padding, respects minimum zoom) + +**Note**: Ctrl+wheel is used for trackpad pinch gestures on desktop. Browsers automatically translate two-finger pinch gestures on trackpads into Ctrl+wheel events. This is separate from touchscreen pinch-to-zoom, which uses direct touch events (touchstart/touchmove/touchend). #### Zoom Focal Point (commits: e42b09de, feec7ec, e7a028b) - ✅ Cursor-centered zoom (focuses on cursor position) @@ -119,7 +123,22 @@ Improve canvas interaction to work consistently and intuitively across: ## Recent Fixes -### Spacebar Hold Scrolling Bug (Latest) +### Grey Padding at Canvas Bottom (Latest) +**Issue**: When scrolled to the bottom of the canvas, 5 pixels of grey space appeared below the grid, allowing users to scroll slightly beyond the canvas boundary. + +**Root Cause**: Default browser margins on SVG elements caused the viewport's `scrollHeight` to be 8005px instead of 8000px, creating extra scrollable area beyond the canvas. + +**Solution**: +- Added explicit `padding: 0` and `margin: 0` to `#red-ui-workspace-chart` container +- Added `display: block`, `margin: 0`, and `padding: 0` to SVG element via `#red-ui-workspace-chart > svg` selector +- The `display: block` prevents inline element spacing issues + +**Files Changed**: +- `workspace.scss:41-42, 52-57` - Added margin/padding resets for container and SVG + +**Result**: Canvas now has exact 8000px scrollable area with no grey padding visible at bottom. + +### Spacebar Hold Scrolling Bug **Issue**: When holding spacebar down, the canvas would move down unexpectedly, making the space+scroll interaction buggy. **Root Cause**: The `preventDefault()` was only called on the first spacebar keydown event. When spacebar is held, browsers fire repeated keydown events. After the first keydown set `spacebarPressed = true`, subsequent keydown events weren't prevented because the condition `e.type === "keydown" && !spacebarPressed` failed, allowing browser's default space-scroll behavior. @@ -193,10 +212,12 @@ When verifying canvas interaction improvements: 1. **Zoom Testing** - [ ] Mouse wheel zoom in/out - - [ ] Space+scroll zoom - - [ ] Trackpad pinch gesture (spread = zoom in, pinch = zoom out) - - [ ] Touch screen pinch gesture - - [ ] UI zoom buttons + - [ ] Alt+scroll zoom (keyboard modifier) + - [ ] Space+scroll zoom (keyboard modifier) + - [ ] Trackpad pinch gesture (spread = zoom in, pinch = zoom out, generates Ctrl+wheel) + - [ ] Touch screen pinch gesture (direct touch events) + - [ ] UI zoom buttons (zoom in, zoom out, reset) + - [ ] Zoom-to-fit button (shows all nodes with padding, respects min zoom) - [ ] Zoom focal point stays on cursor position - [ ] Dynamic zoom limits prevent empty space 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 5b9843ae1..312193a1e 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 @@ -759,6 +759,7 @@ RED.view = (function() { ''+ ''+ ''+ + ''+ '') }) @@ -768,6 +769,8 @@ RED.view = (function() { RED.popover.tooltip($("#red-ui-view-zoom-zero"),RED._('actions.zoom-reset'),'core:zoom-reset'); $("#red-ui-view-zoom-in").on("click", zoomOut); RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in'); + $("#red-ui-view-zoom-fit").on("click", zoomToFitAll); + RED.popover.tooltip($("#red-ui-view-zoom-fit"),RED._('actions.zoom-fit'),'core:zoom-fit'); // Legacy mouse wheel handler - disabled in favor of modern wheel event // chart.on("DOMMouseScroll mousewheel", function (evt) { // if ( evt.altKey || spacebarPressed ) { @@ -1176,6 +1179,7 @@ RED.view = (function() { RED.actions.add("core:zoom-in",zoomIn); RED.actions.add("core:zoom-out",zoomOut); RED.actions.add("core:zoom-reset",zoomZero); + RED.actions.add("core:zoom-fit",zoomToFitAll); RED.actions.add("core:enable-selected-nodes", function() { setSelectedNodeState(false)}); RED.actions.add("core:disable-selected-nodes", function() { setSelectedNodeState(true)}); @@ -2853,6 +2857,74 @@ RED.view = (function() { } } function zoomZero() { animatedZoomView(1); } + + function zoomToFitAll() { + // Get all nodes in active workspace + if (!activeNodes || activeNodes.length === 0) { + return; // No nodes to fit + } + + // Calculate bounding box of all nodes + var minX = Infinity, minY = Infinity; + var maxX = -Infinity, maxY = -Infinity; + + activeNodes.forEach(function(node) { + var nodeLeft = node.x - node.w / 2; + var nodeRight = node.x + node.w / 2; + var nodeTop = node.y - node.h / 2; + var nodeBottom = node.y + node.h / 2; + + minX = Math.min(minX, nodeLeft); + maxX = Math.max(maxX, nodeRight); + minY = Math.min(minY, nodeTop); + maxY = Math.max(maxY, nodeBottom); + }); + + // Add padding around nodes for visual breathing room + var padding = 80; + minX -= padding; + minY -= padding; + maxX += padding; + maxY += padding; + + // Calculate dimensions of bounding box + var boundingWidth = maxX - minX; + var boundingHeight = maxY - minY; + + // Get viewport dimensions + var viewportWidth = chart.width(); + var viewportHeight = chart.height(); + + // Calculate zoom level that fits bounding box in viewport + var zoomX = viewportWidth / boundingWidth; + var zoomY = viewportHeight / boundingHeight; + var targetZoom = Math.min(zoomX, zoomY); + + // Respect minimum and maximum zoom limits + var minZoom = calculateMinZoom(); + targetZoom = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetZoom)); + + // Calculate center point of bounding box as focal point + var centerX = (minX + maxX) / 2; + var centerY = (minY + maxY) / 2; + + // Convert to screen coordinates for focal point + // We want to center the bounding box in the viewport + var focalPoint = [viewportWidth / 2, viewportHeight / 2]; + + // First zoom to target level + animatedZoomView(targetZoom, focalPoint); + + // Then scroll to center the bounding box + // Wait for zoom animation to complete before scrolling + setTimeout(function() { + var scrollLeft = centerX * targetZoom - viewportWidth / 2; + var scrollTop = centerY * targetZoom - viewportHeight / 2; + chart.scrollLeft(scrollLeft); + chart.scrollTop(scrollTop); + }, RED.view.zoomConstants.DEFAULT_ZOOM_DURATION + 50); + } + 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"); } From e2a6a1b52d553201d04ab38f4730c69a9c6f69ec Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:33:37 +0200 Subject: [PATCH 21/49] Fix zoom button handlers - zoom in/out were reversed --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 312193a1e..607a67681 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 @@ -763,11 +763,11 @@ RED.view = (function() { '') }) - $("#red-ui-view-zoom-out").on("click", zoomIn); + $("#red-ui-view-zoom-out").on("click", zoomOut); 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", zoomOut); + $("#red-ui-view-zoom-in").on("click", zoomIn); RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in'); $("#red-ui-view-zoom-fit").on("click", zoomToFitAll); RED.popover.tooltip($("#red-ui-view-zoom-fit"),RED._('actions.zoom-fit'),'core:zoom-fit'); From 269cab2e9c16c4e0b12dbbf6cfd5fe4b947bb2f4 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:35:09 +0200 Subject: [PATCH 22/49] Move zoom-to-fit button between reset and zoom-in --- packages/node_modules/@node-red/editor-client/src/js/ui/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 607a67681..8161f5f9a 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 @@ -758,8 +758,8 @@ RED.view = (function() { element: $(''+ ''+ ''+ - ''+ ''+ + ''+ '') }) From f6decfd58991645885efe12b72e399ee6e07fa7c Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:36:34 +0200 Subject: [PATCH 23/49] Revert "Move zoom-to-fit button between reset and zoom-in" This reverts commit e46cfc9479b2db429d4f73c981e1886b6c0cbbca. --- packages/node_modules/@node-red/editor-client/src/js/ui/view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8161f5f9a..607a67681 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 @@ -758,8 +758,8 @@ RED.view = (function() { element: $(''+ ''+ ''+ - ''+ ''+ + ''+ '') }) From f74beb6a924f39d01f7b1b497bdfaf42429b5c98 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:39:52 +0200 Subject: [PATCH 24/49] Add Ctrl+1/Cmd+1 keyboard shortcut for zoom-to-fit --- .../node_modules/@node-red/editor-client/src/js/keymap.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/keymap.json b/packages/node_modules/@node-red/editor-client/src/js/keymap.json index 4cf28d227..697205f3a 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/keymap.json +++ b/packages/node_modules/@node-red/editor-client/src/js/keymap.json @@ -91,7 +91,8 @@ "alt-shift-w": "core:show-last-hidden-flow", "ctrl-+": "core:zoom-in", "ctrl--": "core:zoom-out", - "ctrl-0": "core:zoom-reset" + "ctrl-0": "core:zoom-reset", + "ctrl-1": "core:zoom-fit" }, "red-ui-editor-stack": { "ctrl-enter": "core:confirm-edit-tray", From 8286ec81316894e81613e549ff561a21ebcff2f5 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:43:36 +0200 Subject: [PATCH 25/49] Remove animation from zoom buttons for instant, smooth zooming Replace animatedZoomView() with direct zoomView() calls for zoom buttons and keyboard shortcuts to eliminate jagged animation caused by redraw() being called on every frame. - Change zoomIn/zoomOut/zoomZero to use instant zoom like trackpad - Single redraw per zoom step instead of 8-10 redraws during animation - Makes all zoom methods (buttons, keyboard, trackpad) feel consistent - Keep animatedZoomView() only for zoomToFitAll() where animation helps Fixes stuttering when zooming with buttons or Ctrl+/-/0 shortcuts. --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 607a67681..e71993663 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 @@ -2847,16 +2847,16 @@ RED.view = (function() { function zoomIn(focalPoint) { if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) { - animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); + zoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); } } function zoomOut(focalPoint) { var minZoom = calculateMinZoom(); if (scaleFactor > minZoom) { - animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); + zoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); } } - function zoomZero() { animatedZoomView(1); } + function zoomZero() { zoomView(1); } function zoomToFitAll() { // Get all nodes in active workspace From a1854806afd728f7103844994b774828bae7dac5 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:45:40 +0200 Subject: [PATCH 26/49] Fix viewport drift when using zoom buttons without focal point --- .../@node-red/editor-client/src/js/ui/view.js | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) 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 e71993663..ac7992f0a 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 @@ -2940,32 +2940,30 @@ RED.view = (function() { var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; - // Use focal point if provided (e.g., cursor position), otherwise use viewport center + // Calculate workspace coordinates of the point that should remain fixed 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]; + // Default to viewport center in workspace coordinates + center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor, (scrollPos[1] + screenSize[1]/2)/scaleFactor]; } var oldScaleFactor = scaleFactor; scaleFactor = factor; - // Calculate where the focal point will be after zoom - var newCenter; + // Calculate new scroll position to keep the center point at the same screen position if (focalPoint) { // Keep the focal point at the same screen position - newCenter = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor]; + chart.scrollLeft(center[0] * scaleFactor - focalPoint[0]); + chart.scrollTop(center[1] * scaleFactor - focalPoint[1]); } else { - newCenter = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor]; + // Keep viewport center on the same workspace coordinates + chart.scrollLeft(center[0] * scaleFactor - screenSize[0]/2); + chart.scrollTop(center[1] * scaleFactor - screenSize[1]/2); } - var delta = [(newCenter[0]-center[0])*scaleFactor,(newCenter[1]-center[1])*scaleFactor] - chart.scrollLeft(scrollPos[0]-delta[0]); - chart.scrollTop(scrollPos[1]-delta[1]); - RED.view.navigator.resize(); redraw(); RED.events.emit("view:navigate"); From 324ca525160f81660c6892803d5f5a04a4f7e1a1 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 13:46:44 +0200 Subject: [PATCH 27/49] Fix zoom center calculation to use oldScaleFactor consistently --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ac7992f0a..6589522a0 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 @@ -2939,18 +2939,18 @@ RED.view = (function() { var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; + var oldScaleFactor = scaleFactor; // Calculate workspace coordinates of the point that should remain fixed var center; if (focalPoint) { // focalPoint is in screen coordinates, convert to workspace coordinates - center = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor]; + center = [(scrollPos[0] + focalPoint[0])/oldScaleFactor, (scrollPos[1] + focalPoint[1])/oldScaleFactor]; } else { // Default to viewport center in workspace coordinates - center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor, (scrollPos[1] + screenSize[1]/2)/scaleFactor]; + center = [(scrollPos[0] + screenSize[0]/2)/oldScaleFactor, (scrollPos[1] + screenSize[1]/2)/oldScaleFactor]; } - var oldScaleFactor = scaleFactor; scaleFactor = factor; // Calculate new scroll position to keep the center point at the same screen position From 95b750060f03c970a41da4b03e3627647a3742d4 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:12:51 +0200 Subject: [PATCH 28/49] Fix zoom button animation and improve performance - Fixed viewport jump to 0,0 by preventing click event from being passed as focal point - Added smooth animation to zoom buttons and keyboard shortcuts (animatedZoomView) - Doubled zoom step from 0.1 to 0.2 for faster zooming - Optimized animation performance by only updating transforms during animation frames - Fixed undefined variable issue (vis/gridScale -> eventLayer/outer) - Full redraw only happens once at end of animation, eliminating jarring experience --- .../src/js/ui/view-zoom-constants.js | 2 +- .../@node-red/editor-client/src/js/ui/view.js | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) 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 index 92e0f526c..9b10afd82 100644 --- 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 @@ -7,7 +7,7 @@ RED.view.zoomConstants = { MAX_ZOOM: 2.0, // Zoom step for keyboard/button controls - ZOOM_STEP: 0.1, + ZOOM_STEP: 0.2, // Animation settings DEFAULT_ZOOM_DURATION: 125, // ms, faster animation 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 6589522a0..89ed5a35c 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 @@ -763,11 +763,11 @@ RED.view = (function() { '') }) - $("#red-ui-view-zoom-out").on("click", zoomOut); + $("#red-ui-view-zoom-out").on("click", function() { zoomOut(); }); 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", function() { zoomIn(); }); RED.popover.tooltip($("#red-ui-view-zoom-in"),RED._('actions.zoom-in'),'core:zoom-in'); $("#red-ui-view-zoom-fit").on("click", zoomToFitAll); RED.popover.tooltip($("#red-ui-view-zoom-fit"),RED._('actions.zoom-fit'),'core:zoom-fit'); @@ -2847,16 +2847,16 @@ RED.view = (function() { function zoomIn(focalPoint) { if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) { - zoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); + animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); } } function zoomOut(focalPoint) { var minZoom = calculateMinZoom(); if (scaleFactor > minZoom) { - zoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); + animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); } } - function zoomZero() { zoomView(1); } + function zoomZero() { animatedZoomView(1); } function zoomToFitAll() { // Get all nodes in active workspace @@ -2931,12 +2931,14 @@ RED.view = (function() { function zoomView(factor, focalPoint) { + console.log('=== ZOOM VIEW CALLED ===', 'factor:', factor, 'focalPoint:', focalPoint); // Early return if scale factor isn't actually changing // This prevents focal point shifts when at zoom limits if (Math.abs(scaleFactor - factor) < 0.001) { + console.log('Zoom view SKIPPED - already at target zoom'); return; } - + var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; var oldScaleFactor = scaleFactor; @@ -2960,8 +2962,10 @@ RED.view = (function() { chart.scrollTop(center[1] * scaleFactor - focalPoint[1]); } else { // Keep viewport center on the same workspace coordinates - chart.scrollLeft(center[0] * scaleFactor - screenSize[0]/2); - chart.scrollTop(center[1] * scaleFactor - screenSize[1]/2); + var newScrollLeft = center[0] * scaleFactor - screenSize[0]/2; + var newScrollTop = center[1] * scaleFactor - screenSize[1]/2; + chart.scrollLeft(newScrollLeft); + chart.scrollTop(newScrollTop); } RED.view.navigator.resize(); @@ -3041,13 +3045,18 @@ RED.view = (function() { chart.scrollLeft(newScrollPos[0]); chart.scrollTop(newScrollPos[1]); + // During animation, only update the scale transform, not the full redraw + // This is much more performant with many nodes + eventLayer.attr("transform", "scale(" + scaleFactor + ")"); + outer.attr("width", space_width * scaleFactor).attr("height", space_height * scaleFactor); RED.view.navigator.resize(); - redraw(); }, onEnd: function() { cancelInProgressAnimation = null; // Ensure scaleFactor is exactly the target to prevent precision issues scaleFactor = targetFactor; + // Full redraw at the end to ensure everything is correct + redraw(); if (RED.settings.get("editor.view.view-store-zoom")) { RED.settings.setLocal('zoom-level', targetFactor.toFixed(1)); } From 79918f0187646868841ad37b3582f00e5ac4ec2b Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:19:31 +0200 Subject: [PATCH 29/49] Add focal point locking for sequential button/hotkey zooms - Store workspace center on first button/hotkey zoom operation - Maintain same focal point for sequential zooms within 1 second timeout - Pass workspace center directly to animatedZoomView to prevent recalculation - Focal point always at viewport center with consistent workspace point - Works correctly at canvas edges where viewport may shift - Does not interfere with wheel/pinch zoom which provide explicit focal points --- .../@node-red/editor-client/src/js/ui/view.js | 78 +++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) 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 89ed5a35c..d120a094b 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 @@ -2845,18 +2845,83 @@ RED.view = (function() { return Math.max(calculatedMinZoom, RED.view.zoomConstants.MIN_ZOOM); } + // Track focal point for sequential button/hotkey zoom operations + // Store in workspace coordinates so it remains valid after viewport shifts + var buttonZoomWorkspaceCenter = null; + var buttonZoomTimeout = null; + var BUTTON_ZOOM_FOCAL_TIMEOUT = 1000; // ms - time to keep same focal point + function zoomIn(focalPoint) { if (scaleFactor < RED.view.zoomConstants.MAX_ZOOM) { - animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, focalPoint); + var useFocalPoint = null; + + // If focalPoint is explicitly provided (e.g., from wheel/pinch), use it directly + if (focalPoint) { + useFocalPoint = focalPoint; + } else { + // For button/hotkey zoom, maintain the same workspace center across sequential zooms + if (!buttonZoomWorkspaceCenter) { + // First button zoom - calculate and store workspace center + var screenSize = [chart.width(), chart.height()]; + var scrollPos = [chart.scrollLeft(), chart.scrollTop()]; + // Convert viewport center to workspace coordinates + buttonZoomWorkspaceCenter = [ + (scrollPos[0] + screenSize[0]/2) / scaleFactor, + (scrollPos[1] + screenSize[1]/2) / scaleFactor + ]; + } + + // ALWAYS use viewport center as focal point (fixed screen position) + // The stored workspace center will be kept at this screen position + var screenSize = [chart.width(), chart.height()]; + useFocalPoint = [screenSize[0]/2, screenSize[1]/2]; + + // Reset timeout + clearTimeout(buttonZoomTimeout); + buttonZoomTimeout = setTimeout(function() { + buttonZoomWorkspaceCenter = null; + }, BUTTON_ZOOM_FOCAL_TIMEOUT); + } + + animatedZoomView(scaleFactor + RED.view.zoomConstants.ZOOM_STEP, useFocalPoint, buttonZoomWorkspaceCenter); } } function zoomOut(focalPoint) { var minZoom = calculateMinZoom(); if (scaleFactor > minZoom) { - animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), focalPoint); + var useFocalPoint = null; + + if (focalPoint) { + useFocalPoint = focalPoint; + } else { + if (!buttonZoomWorkspaceCenter) { + var screenSize = [chart.width(), chart.height()]; + var scrollPos = [chart.scrollLeft(), chart.scrollTop()]; + buttonZoomWorkspaceCenter = [ + (scrollPos[0] + screenSize[0]/2) / scaleFactor, + (scrollPos[1] + screenSize[1]/2) / scaleFactor + ]; + } + + // ALWAYS use viewport center as focal point (fixed screen position) + var screenSize = [chart.width(), chart.height()]; + useFocalPoint = [screenSize[0]/2, screenSize[1]/2]; + + clearTimeout(buttonZoomTimeout); + buttonZoomTimeout = setTimeout(function() { + buttonZoomWorkspaceCenter = null; + }, BUTTON_ZOOM_FOCAL_TIMEOUT); + } + + animatedZoomView(Math.max(scaleFactor - RED.view.zoomConstants.ZOOM_STEP, minZoom), useFocalPoint, buttonZoomWorkspaceCenter); } } - function zoomZero() { animatedZoomView(1); } + function zoomZero() { + // Reset button zoom focal point for zoom reset + clearTimeout(buttonZoomTimeout); + buttonZoomWorkspaceCenter = null; + animatedZoomView(1); + } function zoomToFitAll() { // Get all nodes in active workspace @@ -2976,7 +3041,7 @@ RED.view = (function() { } } - function animatedZoomView(targetFactor, focalPoint) { + function animatedZoomView(targetFactor, focalPoint, workspaceCenter) { // Cancel any in-progress animation if (cancelInProgressAnimation) { cancelInProgressAnimation(); @@ -3002,7 +3067,10 @@ RED.view = (function() { // Calculate the focal point in workspace coordinates (will remain constant) var center; - if (focalPoint) { + if (workspaceCenter) { + // Use the provided workspace center directly (for button zoom focal point locking) + center = workspaceCenter; + } else if (focalPoint) { // focalPoint is in screen coordinates, convert to workspace coordinates center = [(scrollPos[0] + focalPoint[0])/scaleFactor, (scrollPos[1] + focalPoint[1])/scaleFactor]; } else { From 47026ec744c1fb1cf8f79f59f3d4d4a80654d64c Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:21:52 +0200 Subject: [PATCH 30/49] Add minimap auto-show for zoom button/hotkey interactions - Emit view:navigate event in animatedZoomView onStart callback - Minimap now appears when using zoom buttons (in/out/reset/fit) - Minimap now appears when using zoom hotkeys (Ctrl+/-/0/1) - Auto-hides after 2 seconds as expected - Applies to all animated zoom operations consistently --- .../node_modules/@node-red/editor-client/src/js/ui/view.js | 4 ++++ 1 file changed, 4 insertions(+) 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 d120a094b..51ad86828 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 @@ -3119,6 +3119,10 @@ RED.view = (function() { outer.attr("width", space_width * scaleFactor).attr("height", space_height * scaleFactor); RED.view.navigator.resize(); }, + onStart: function() { + // Show minimap when zoom animation starts + RED.events.emit("view:navigate"); + }, onEnd: function() { cancelInProgressAnimation = null; // Ensure scaleFactor is exactly the target to prevent precision issues From 775d6181c91f85aa18fe568e0ed03bafdec1e3ea Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:26:53 +0200 Subject: [PATCH 31/49] Add grab/grabbing cursor for spacebar pan mode - Show grab cursor (open hand) when spacebar is pressed - Show grabbing cursor (closed hand) when actively panning with spacebar+drag - Revert to grab cursor on mouse release if spacebar still held - Clear cursor when spacebar is released - Apply cursor to SVG element (outer) where mouse events occur - Handle edge cases: window blur, canvas blur, spacebar release outside canvas --- .../@node-red/editor-client/src/js/ui/view.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 51ad86828..3a2d7baca 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 @@ -632,8 +632,12 @@ RED.view = (function() { if (e.type === "keydown" && !spacebarPressed) { spacebarPressed = true; + // Change cursor to grab hand when spacebar is pressed + outer.style('cursor', 'grab'); } else if (e.type === "keyup" && spacebarPressed) { spacebarPressed = false; + // Revert cursor when spacebar is released + outer.style('cursor', ''); } } } @@ -642,6 +646,8 @@ RED.view = (function() { function handleWindowSpacebarUp(e) { if ((e.keyCode === 32 || e.key === ' ') && spacebarPressed) { spacebarPressed = false; + // Revert cursor when spacebar is released outside canvas + outer.style('cursor', ''); e.preventDefault(); e.stopPropagation(); } @@ -656,6 +662,8 @@ RED.view = (function() { window.addEventListener("blur", function() { if (spacebarPressed) { spacebarPressed = false; + // Revert cursor when window loses focus + outer.style('cursor', ''); } }) @@ -1114,6 +1122,8 @@ RED.view = (function() { // Reset spacebar state when chart loses focus to prevent stuck state if (spacebarPressed) { spacebarPressed = false; + // Revert cursor when chart loses focus + outer.style('cursor', ''); } }); @@ -1582,6 +1592,8 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + // Change cursor to grabbing while actively panning + outer.style('cursor', 'grabbing'); return; } @@ -2497,6 +2509,12 @@ RED.view = (function() { } if (mouse_mode === RED.state.PANNING) { resetMouseVars(); + // Revert to grab cursor if spacebar still held, otherwise clear cursor + if (spacebarPressed) { + outer.style('cursor', 'grab'); + } else { + outer.style('cursor', ''); + } return } if (mouse_mode === RED.state.SELECTING_NODE) { From f22915e1b978b68d7096673f1c948081edca104d Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:29:16 +0200 Subject: [PATCH 32/49] Add grabbing cursor for middle-click pan mode --- packages/node_modules/@node-red/editor-client/src/js/ui/view.js | 2 ++ 1 file changed, 2 insertions(+) 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 3a2d7baca..464e0f923 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 @@ -1603,6 +1603,8 @@ RED.view = (function() { mouse_mode = RED.state.PANNING; mouse_position = [d3.event.pageX,d3.event.pageY] scroll_position = [chart.scrollLeft(),chart.scrollTop()]; + // Change cursor to grabbing while actively panning + outer.style('cursor', 'grabbing'); return; } if (d3.event.button === 2) { From 37a4440a5a9893440b8c7d5a680f958c4406fab7 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:31:24 +0200 Subject: [PATCH 33/49] Make zoom animation duration relative to maintain consistent velocity --- .../@node-red/editor-client/src/js/ui/view.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 464e0f923..0957ae749 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 @@ -3098,6 +3098,14 @@ RED.view = (function() { center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor, (scrollPos[1] + screenSize[1]/2)/scaleFactor]; } + // Calculate duration based on relative zoom change to maintain consistent velocity + // Use logarithmic scaling since zoom feels exponential to the user + var zoomRatio = targetFactor / startFactor; + var logChange = Math.abs(Math.log(zoomRatio)); + // Scale duration more aggressively: multiply by 2 for stronger effect + // At extreme zoom levels, animation will be noticeably longer + var duration = Math.max(100, Math.min(350, logChange / 0.693 * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 2)); + // Start the animation cancelInProgressAnimation = RED.view.zoomAnimator.easeToValuesRAF({ fromValues: { @@ -3106,7 +3114,7 @@ RED.view = (function() { toValues: { zoom: targetFactor }, - duration: RED.view.zoomConstants.DEFAULT_ZOOM_DURATION, + duration: duration, interpolateValue: true, // Use exponential interpolation for zoom onStep: function(values) { var currentFactor = values.zoom; From 34d356230b9a8a5f61a652c140012303949c7cd7 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:42:07 +0200 Subject: [PATCH 34/49] Set maximum zoom level to 1.0 --- .../@node-red/editor-client/src/js/ui/view-zoom-constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 9b10afd82..3f789ced2 100644 --- 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 @@ -4,7 +4,7 @@ RED.view.zoomConstants = { // Zoom limits MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas - MAX_ZOOM: 2.0, + MAX_ZOOM: 1.0, // Zoom step for keyboard/button controls ZOOM_STEP: 0.2, From f831df70cefb02a78d489d2a8f87848818f75ead Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:43:54 +0200 Subject: [PATCH 35/49] Fix zoom-to-fit to properly center nodes in viewport --- .../@node-red/editor-client/src/js/ui/view.js | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) 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 0957ae749..09ca0a98d 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 @@ -2989,25 +2989,18 @@ RED.view = (function() { var minZoom = calculateMinZoom(); targetZoom = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetZoom)); - // Calculate center point of bounding box as focal point + // Calculate center point of bounding box in workspace coordinates var centerX = (minX + maxX) / 2; var centerY = (minY + maxY) / 2; - // Convert to screen coordinates for focal point - // We want to center the bounding box in the viewport + // Reset button zoom focal point for zoom-to-fit + clearTimeout(buttonZoomTimeout); + buttonZoomWorkspaceCenter = null; + + // Pass the bounding box center as workspace center + // This ensures the nodes are centered in viewport after zoom var focalPoint = [viewportWidth / 2, viewportHeight / 2]; - - // First zoom to target level - animatedZoomView(targetZoom, focalPoint); - - // Then scroll to center the bounding box - // Wait for zoom animation to complete before scrolling - setTimeout(function() { - var scrollLeft = centerX * targetZoom - viewportWidth / 2; - var scrollTop = centerY * targetZoom - viewportHeight / 2; - chart.scrollLeft(scrollLeft); - chart.scrollTop(scrollTop); - }, RED.view.zoomConstants.DEFAULT_ZOOM_DURATION + 50); + animatedZoomView(targetZoom, focalPoint, [centerX, centerY]); } function searchFlows() { RED.actions.invoke("core:search", $(this).data("term")); } From 79581bf51f772285f0df557b5e942cbaf1cf288a Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 14:45:45 +0200 Subject: [PATCH 36/49] Refresh active nodes before zoom-to-fit to work immediately --- .../@node-red/editor-client/src/js/ui/view.js | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) 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 09ca0a98d..0a63b7dfe 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 @@ -2944,11 +2944,17 @@ RED.view = (function() { } function zoomToFitAll() { + // Refresh active nodes to ensure we have the latest + updateActiveNodes(); + // Get all nodes in active workspace if (!activeNodes || activeNodes.length === 0) { + console.log("zoomToFitAll: No active nodes found. activeNodes:", activeNodes); return; // No nodes to fit } + console.log("zoomToFitAll: Found", activeNodes.length, "nodes"); + // Calculate bounding box of all nodes var minX = Infinity, minY = Infinity; var maxX = -Infinity, maxY = -Infinity; @@ -2985,14 +2991,20 @@ RED.view = (function() { var zoomY = viewportHeight / boundingHeight; var targetZoom = Math.min(zoomX, zoomY); + console.log("zoomToFitAll: targetZoom calculated:", targetZoom, "bounds:", {minX, minY, maxX, maxY}, "viewport:", {viewportWidth, viewportHeight}); + // Respect minimum and maximum zoom limits var minZoom = calculateMinZoom(); targetZoom = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetZoom)); + console.log("zoomToFitAll: clamped targetZoom:", targetZoom, "current scaleFactor:", scaleFactor); + // Calculate center point of bounding box in workspace coordinates var centerX = (minX + maxX) / 2; var centerY = (minY + maxY) / 2; + console.log("zoomToFitAll: center point:", centerX, centerY); + // Reset button zoom focal point for zoom-to-fit clearTimeout(buttonZoomTimeout); buttonZoomWorkspaceCenter = null; @@ -3000,7 +3012,49 @@ RED.view = (function() { // Pass the bounding box center as workspace center // This ensures the nodes are centered in viewport after zoom var focalPoint = [viewportWidth / 2, viewportHeight / 2]; - animatedZoomView(targetZoom, focalPoint, [centerX, centerY]); + + // If zoom level won't change significantly, animate just the pan + if (Math.abs(scaleFactor - targetZoom) < 0.01) { + console.log("zoomToFitAll: zoom unchanged, animating pan to center"); + var targetScrollLeft = centerX * scaleFactor - viewportWidth / 2; + var targetScrollTop = centerY * scaleFactor - viewportHeight / 2; + + // Calculate pan distance to determine duration (match zoom animation logic) + var startScrollLeft = chart.scrollLeft(); + var startScrollTop = chart.scrollTop(); + var panDistance = Math.sqrt( + Math.pow(targetScrollLeft - startScrollLeft, 2) + + Math.pow(targetScrollTop - startScrollTop, 2) + ); + + // Use similar duration calculation as zoom: scale with distance + // Normalize by viewport diagonal for consistent feel + var viewportDiagonal = Math.sqrt(viewportWidth * viewportWidth + viewportHeight * viewportHeight); + var relativeDistance = panDistance / viewportDiagonal; + // Duration scales with distance, matching zoom animation feel + var duration = Math.max(200, Math.min(350, relativeDistance * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 4)); + + RED.view.zoomAnimator.easeToValuesRAF({ + fromValues: { + scrollLeft: startScrollLeft, + scrollTop: startScrollTop + }, + toValues: { + scrollLeft: targetScrollLeft, + scrollTop: targetScrollTop + }, + duration: duration, + onStep: function(values) { + chart.scrollLeft(values.scrollLeft); + chart.scrollTop(values.scrollTop); + }, + onStart: function() { + RED.events.emit("view:navigate"); + } + }); + } else { + animatedZoomView(targetZoom, focalPoint, [centerX, centerY]); + } } function searchFlows() { RED.actions.invoke("core:search", $(this).data("term")); } @@ -3097,7 +3151,7 @@ RED.view = (function() { var logChange = Math.abs(Math.log(zoomRatio)); // Scale duration more aggressively: multiply by 2 for stronger effect // At extreme zoom levels, animation will be noticeably longer - var duration = Math.max(100, Math.min(350, logChange / 0.693 * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 2)); + var duration = Math.max(200, Math.min(350, logChange / 0.693 * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 2)); // Start the animation cancelInProgressAnimation = RED.view.zoomAnimator.easeToValuesRAF({ From d308bc77638a822013d3291bce5de8369e169fd2 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 15:02:32 +0200 Subject: [PATCH 37/49] Remove debug console logs and update documentation --- CANVAS_INTERACTION.md | 90 +++++++++++++++++-- .../@node-red/editor-client/src/js/ui/view.js | 10 --- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md index 16261b2c6..831d96856 100644 --- a/CANVAS_INTERACTION.md +++ b/CANVAS_INTERACTION.md @@ -11,6 +11,49 @@ Improve canvas interaction to work consistently and intuitively across: ## What Has Been Implemented +### Zoom Button & Hotkey Enhancements (Session 2) + +#### Zoom-to-Fit Feature (commits: 6f164a8, 788d0a3, ed71cf9, 732c828) +- ✅ New zoom-to-fit button in footer toolbar +- ✅ Shows all nodes with padding, properly centered in viewport +- ✅ Keyboard shortcut: Ctrl+1 / Cmd+1 for zoom-to-fit +- ✅ Respects maximum zoom level (won't zoom in beyond 1.0) +- ✅ Works immediately on page load (refreshes active nodes first) +- ✅ Smooth animated pan when zoom doesn't need to change +- ✅ Pan animation duration matches zoom animation (200-350ms) + +#### Smooth Zoom Button Animation (commits: 935ff62, f07907e) +- ✅ Smooth 200-350ms zoom transitions for button/hotkey zoom +- ✅ Performance optimized: only updates transforms during animation +- ✅ No viewport jumping or drift +- ✅ Focal point locking for sequential zooms (1 second timeout) +- ✅ Maintains viewport center across multiple rapid button presses + +#### Dynamic Zoom Animation Duration (commit: 594c0d6) +- ✅ Animation duration scales with zoom distance (logarithmic) +- ✅ Consistent perceived velocity across all zoom levels +- ✅ Range: 200-350ms (faster for small changes, slower for large) +- ✅ Pan-only animations also scale with distance traveled +- ✅ Reference: doubling/halving zoom takes ~250ms + +#### Visual Feedback Enhancements (commits: 6261213, ce5a031, 0247a91) +- ✅ Grab cursor (open hand) when spacebar pressed +- ✅ Grabbing cursor (closed hand) during active pan +- ✅ Works for spacebar+click and middle-click pan modes +- ✅ Minimap auto-shows during zoom button/hotkey operations +- ✅ Minimap auto-shows during zoom-to-fit and zoom reset + +#### Zoom Limits (commit: 45c2a79) +- ✅ Maximum zoom level set to 1.0 (100%, no zooming in beyond 1:1) +- ✅ All zoom methods respect this limit +- ✅ Zoom-to-fit properly clamps to max zoom + +#### Bug Fixes (commits: be1da36, 7fdff0c, 6e49e96, e3de29d) +- ✅ Fixed grey padding at canvas bottom (SVG margin reset) +- ✅ Fixed zoom button direction (were reversed) +- ✅ Fixed viewport drift without focal point +- ✅ Fixed zoom center calculation consistency + ### Zoom Functionality #### Smooth Zoom Animation (commits: bdfa06b, a12b65b) @@ -216,25 +259,37 @@ When verifying canvas interaction improvements: - [ ] Space+scroll zoom (keyboard modifier) - [ ] Trackpad pinch gesture (spread = zoom in, pinch = zoom out, generates Ctrl+wheel) - [ ] Touch screen pinch gesture (direct touch events) - - [ ] UI zoom buttons (zoom in, zoom out, reset) - - [ ] Zoom-to-fit button (shows all nodes with padding, respects min zoom) - - [ ] Zoom focal point stays on cursor position - - [ ] Dynamic zoom limits prevent empty space + - [x] UI zoom buttons (zoom in, zoom out, reset) - smooth animated, focal point locking + - [x] Zoom-to-fit button (shows all nodes with padding, respects max zoom of 1.0) + - [x] Zoom-to-fit hotkey (Ctrl+1 / Cmd+1) + - [x] Zoom hotkeys (Ctrl+Plus, Ctrl+Minus, Ctrl+0) + - [x] Zoom focal point stays on viewport center for button/hotkey zooms + - [x] Dynamic zoom limits prevent empty space + - [x] Maximum zoom capped at 1.0 (100%) + - [x] Animation duration scales with zoom distance (200-350ms) + - [x] Sequential zooms maintain same focal point (1 second timeout) + - [x] Zoom-to-fit works immediately after page load + - [x] Pan animation when zoom-to-fit doesn't need zoom change 2. **Pan Testing** - [x] Two-finger pan on trackpad/touch - [x] Diagonal panning works (not axis-locked) - [x] Spacebar+click pan on desktop + - [x] Middle-click pan on desktop - [x] Momentum scrolling with edge bounce - [x] No lag when switching between pan and zoom 3. **UI/UX Testing** - [x] Minimap auto-shows during panning and zooming + - [x] Minimap auto-shows during zoom button/hotkey/zoom-to-fit - [x] Minimap does not show on selection changes - [x] Minimap fades after 2 seconds - [x] No scrollbars visible on canvas - [x] No pinch-zoom on UI elements - [x] Gesture state cleanup on cursor exit + - [x] Grab cursor (open hand) shows when spacebar held + - [x] Grabbing cursor (closed hand) shows during active pan (spacebar or middle-click) + - [x] No grey padding visible at canvas bottom 4. **Browser Zoom Testing** - [ ] Test at 100% browser zoom @@ -257,8 +312,9 @@ Key files involved in canvas interaction improvements: ## Commit History -Interaction improvements span commits from e7a028b to present (13 commits total): +Interaction improvements (20 commits total on claude/issue-44-20250925-0754): +### Session 1: Previous commits (commits 1-13) 1. `e7a028b` - feat: Add enhanced zoom and scroll features 2. `bdfa06b` - Implement smooth zoom functionality with pinch-to-zoom support 3. `37f9786` - Fix trackpad zoom direction - spreading fingers now zooms in @@ -271,4 +327,26 @@ Interaction improvements span commits from e7a028b to present (13 commits total) 10. `f13ed66` - Add dynamic minimum zoom recalculation on viewport resize 11. `53dce6a` - Hide scrollbars and add auto-show/hide minimap on navigation 12. `875db2c` - Enable diagonal trackpad panning by preventing axis-locked scroll -13. (current) - Improve minimap auto-show behavior to only trigger during actual navigation \ No newline at end of file +13. (previous) - Improve minimap auto-show behavior to only trigger during actual navigation + +### Session 2: Zoom button/hotkey improvements (commits 14-20) +14. `ad00ca23e` - Add scroll spacer to fix scrollable area at minimum zoom +15. `48f0f3be` - Fix minimap viewport position at non-1.0 zoom levels +16. `be1da360` - Fix grey padding at canvas bottom by resetting SVG margins +17. `6f164a8a` - Add zoom-to-fit button to show all nodes at once +18. `7fdff0ca` - Fix zoom button handlers - zoom in/out were reversed +19. `e46cfc94` - Move zoom-to-fit button between reset and zoom-in +20. `95304e26` - Revert "Move zoom-to-fit button between reset and zoom-in" +21. `788d0a38` - Add Ctrl+1/Cmd+1 keyboard shortcut for zoom-to-fit +22. `5c090786` - Remove animation from zoom buttons for instant, smooth zooming +23. `6e49e962` - Fix viewport drift when using zoom buttons without focal point +24. `e3de29d8` - Fix zoom center calculation to use oldScaleFactor consistently +25. `935ff622` - Fix zoom button animation and improve performance +26. `f07907e1` - Add focal point locking for sequential button/hotkey zooms +27. `0247a910` - Add minimap auto-show for zoom button/hotkey interactions +28. `62612139` - Add grab/grabbing cursor for spacebar pan mode +29. `ce5a0313` - Add grabbing cursor for middle-click pan mode +30. `594c0d66` - Make zoom animation duration relative to maintain consistent velocity +31. `45c2a798` - Set maximum zoom level to 1.0 +32. `ed71cf91` - Fix zoom-to-fit to properly center nodes in viewport +33. `732c8283` - Refresh active nodes before zoom-to-fit to work immediately (includes pan animation matching zoom duration, 200-350ms range) \ 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 0a63b7dfe..7e4836a41 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 @@ -2949,12 +2949,9 @@ RED.view = (function() { // Get all nodes in active workspace if (!activeNodes || activeNodes.length === 0) { - console.log("zoomToFitAll: No active nodes found. activeNodes:", activeNodes); return; // No nodes to fit } - console.log("zoomToFitAll: Found", activeNodes.length, "nodes"); - // Calculate bounding box of all nodes var minX = Infinity, minY = Infinity; var maxX = -Infinity, maxY = -Infinity; @@ -2991,20 +2988,14 @@ RED.view = (function() { var zoomY = viewportHeight / boundingHeight; var targetZoom = Math.min(zoomX, zoomY); - console.log("zoomToFitAll: targetZoom calculated:", targetZoom, "bounds:", {minX, minY, maxX, maxY}, "viewport:", {viewportWidth, viewportHeight}); - // Respect minimum and maximum zoom limits var minZoom = calculateMinZoom(); targetZoom = Math.max(minZoom, Math.min(RED.view.zoomConstants.MAX_ZOOM, targetZoom)); - console.log("zoomToFitAll: clamped targetZoom:", targetZoom, "current scaleFactor:", scaleFactor); - // Calculate center point of bounding box in workspace coordinates var centerX = (minX + maxX) / 2; var centerY = (minY + maxY) / 2; - console.log("zoomToFitAll: center point:", centerX, centerY); - // Reset button zoom focal point for zoom-to-fit clearTimeout(buttonZoomTimeout); buttonZoomWorkspaceCenter = null; @@ -3015,7 +3006,6 @@ RED.view = (function() { // If zoom level won't change significantly, animate just the pan if (Math.abs(scaleFactor - targetZoom) < 0.01) { - console.log("zoomToFitAll: zoom unchanged, animating pan to center"); var targetScrollLeft = centerX * scaleFactor - viewportWidth / 2; var targetScrollTop = centerY * scaleFactor - viewportHeight / 2; From 250f7ab89772adf707a25e85497d6c029935d363 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 15:24:34 +0200 Subject: [PATCH 38/49] Add minimap auto-show on flow startup and workspace changes Shows minimap briefly when: - Flow initially loads (if nodes exist) - Switching between workspace tabs - Continuing to show during zoom/pan navigation Implementation: - Listen to workspace:change events - Check for active workspace with nodes before showing - Use 100ms delay to ensure nodes are rendered - Reuse existing showTemporary() for consistent behavior --- .../editor-client/src/js/ui/view-navigator.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index 5b1eb8925..61af52e50 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -199,6 +199,20 @@ 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, From be3430c032af8099a57dfd47dc6a06ba26c6c2ed Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 15:26:33 +0200 Subject: [PATCH 39/49] Update documentation with minimap startup feature --- CANVAS_INTERACTION.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md index 831d96856..63da03e5d 100644 --- a/CANVAS_INTERACTION.md +++ b/CANVAS_INTERACTION.md @@ -124,12 +124,14 @@ Improve canvas interaction to work consistently and intuitively across: - ✅ JavaScript prevention for trackpad pinch on non-canvas areas - ✅ Block Ctrl+wheel events outside the workspace chart -#### Minimap Navigation (commit: 53dce6a) +#### Minimap Navigation (commits: 53dce6a, 5e056a4) - ✅ Auto-show minimap on zoom and pan operations +- ✅ Auto-show minimap on flow startup and workspace changes - ✅ Minimap appears for 2 seconds during navigation then fades out - ✅ Smooth fade in/out animations for minimap visibility - ✅ Minimap stays visible if manually toggled with button - ✅ Emit `view:navigate` events for all zoom and pan operations +- ✅ Check for active nodes before showing on workspace change #### Visual Polish (commit: 53dce6a) - ✅ Hide scrollbars on canvas while keeping it scrollable @@ -280,6 +282,7 @@ When verifying canvas interaction improvements: - [x] No lag when switching between pan and zoom 3. **UI/UX Testing** + - [x] Minimap auto-shows on flow startup and workspace changes - [x] Minimap auto-shows during panning and zooming - [x] Minimap auto-shows during zoom button/hotkey/zoom-to-fit - [x] Minimap does not show on selection changes @@ -349,4 +352,5 @@ Interaction improvements (20 commits total on claude/issue-44-20250925-0754): 30. `594c0d66` - Make zoom animation duration relative to maintain consistent velocity 31. `45c2a798` - Set maximum zoom level to 1.0 32. `ed71cf91` - Fix zoom-to-fit to properly center nodes in viewport -33. `732c8283` - Refresh active nodes before zoom-to-fit to work immediately (includes pan animation matching zoom duration, 200-350ms range) \ No newline at end of file +33. `732c8283` - Refresh active nodes before zoom-to-fit to work immediately (includes pan animation matching zoom duration, 200-350ms range) +34. `5e056a4d` - Add minimap auto-show on flow startup and workspace changes \ No newline at end of file From 1ff980d190c2fdb1eb3d1b4d52b64addc53f000e Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 30 Sep 2025 22:30:26 +0200 Subject: [PATCH 40/49] Increase maximum zoom level to 2.0 --- .../@node-red/editor-client/src/js/ui/view-zoom-constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3f789ced2..9b10afd82 100644 --- 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 @@ -4,7 +4,7 @@ RED.view.zoomConstants = { // Zoom limits MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas - MAX_ZOOM: 1.0, + MAX_ZOOM: 2.0, // Zoom step for keyboard/button controls ZOOM_STEP: 0.2, From 1638fa927bbed10fadb61748ceab7b5ee92c21ad Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Wed, 1 Oct 2025 13:55:58 +0200 Subject: [PATCH 41/49] Fix mouse wheel zoom sensitivity by detecting input device type Mouse wheel events were being treated as trackpad input, causing excessive zoom jumps. Added delta magnitude detection (threshold: 50) to distinguish between mouse wheel (large deltas) and trackpad (small deltas), applying appropriate zoom calculation for each device. --- .../@node-red/editor-client/src/js/ui/view.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 7e4836a41..5ac2bdb76 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 @@ -829,9 +829,10 @@ RED.view = (function() { // For trackpad pinch (Ctrl+wheel), use smooth proportional zoom if (evt.ctrlKey && !evt.altKey && !spacebarPressed) { - // Use the zoom animator's delta calculation for trackpad + // Detect input device: trackpad has small deltas, mouse wheel has large deltas + var isTrackpadInput = Math.abs(delta) < 50; // Invert delta: spreading fingers (negative deltaY) should zoom in - var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, true); + var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, isTrackpadInput); var minZoom = calculateMinZoom(); var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, Math.max(minZoom, scaleFactor + scaleDelta)); @@ -878,8 +879,9 @@ RED.view = (function() { }, gestureEndThreshold); // Use 500ms timeout for gesture end detection } else { // Regular Alt+scroll or Space+scroll - use smooth zoom without animation - // Use same proportional zoom as trackpad for consistent feel - var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, true); + // Detect input device: trackpad has small deltas, mouse wheel has large deltas + var isTrackpadInput = Math.abs(delta) < 50; + var scaleDelta = RED.view.zoomAnimator.calculateZoomDelta(scaleFactor, -delta, isTrackpadInput); var minZoom = calculateMinZoom(); var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, Math.max(minZoom, scaleFactor + scaleDelta)); From a01c920c74ed0a5e3973d77ee5cb1b21d1b74d42 Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Wed, 1 Oct 2025 13:56:47 +0200 Subject: [PATCH 42/49] Remove CANVAS_INTERACTION.md documentation file --- CANVAS_INTERACTION.md | 356 ------------------------------------------ 1 file changed, 356 deletions(-) delete mode 100644 CANVAS_INTERACTION.md diff --git a/CANVAS_INTERACTION.md b/CANVAS_INTERACTION.md deleted file mode 100644 index 63da03e5d..000000000 --- a/CANVAS_INTERACTION.md +++ /dev/null @@ -1,356 +0,0 @@ -# Canvas Interaction Improvements - -This document tracks the ongoing improvements to Node-RED's canvas interaction across different devices, input methods, and browser zoom settings. - -## Objectives - -Improve canvas interaction to work consistently and intuitively across: -- **Browser zoom levels**: 100%, 125%, 150%, 200%, etc. -- **Input devices**: Mouse, trackpad, and touchscreen -- **Platforms**: Desktop (Windows, macOS, Linux) and mobile/tablet devices - -## What Has Been Implemented - -### Zoom Button & Hotkey Enhancements (Session 2) - -#### Zoom-to-Fit Feature (commits: 6f164a8, 788d0a3, ed71cf9, 732c828) -- ✅ New zoom-to-fit button in footer toolbar -- ✅ Shows all nodes with padding, properly centered in viewport -- ✅ Keyboard shortcut: Ctrl+1 / Cmd+1 for zoom-to-fit -- ✅ Respects maximum zoom level (won't zoom in beyond 1.0) -- ✅ Works immediately on page load (refreshes active nodes first) -- ✅ Smooth animated pan when zoom doesn't need to change -- ✅ Pan animation duration matches zoom animation (200-350ms) - -#### Smooth Zoom Button Animation (commits: 935ff62, f07907e) -- ✅ Smooth 200-350ms zoom transitions for button/hotkey zoom -- ✅ Performance optimized: only updates transforms during animation -- ✅ No viewport jumping or drift -- ✅ Focal point locking for sequential zooms (1 second timeout) -- ✅ Maintains viewport center across multiple rapid button presses - -#### Dynamic Zoom Animation Duration (commit: 594c0d6) -- ✅ Animation duration scales with zoom distance (logarithmic) -- ✅ Consistent perceived velocity across all zoom levels -- ✅ Range: 200-350ms (faster for small changes, slower for large) -- ✅ Pan-only animations also scale with distance traveled -- ✅ Reference: doubling/halving zoom takes ~250ms - -#### Visual Feedback Enhancements (commits: 6261213, ce5a031, 0247a91) -- ✅ Grab cursor (open hand) when spacebar pressed -- ✅ Grabbing cursor (closed hand) during active pan -- ✅ Works for spacebar+click and middle-click pan modes -- ✅ Minimap auto-shows during zoom button/hotkey operations -- ✅ Minimap auto-shows during zoom-to-fit and zoom reset - -#### Zoom Limits (commit: 45c2a79) -- ✅ Maximum zoom level set to 1.0 (100%, no zooming in beyond 1:1) -- ✅ All zoom methods respect this limit -- ✅ Zoom-to-fit properly clamps to max zoom - -#### Bug Fixes (commits: be1da36, 7fdff0c, 6e49e96, e3de29d) -- ✅ Fixed grey padding at canvas bottom (SVG margin reset) -- ✅ Fixed zoom button direction (were reversed) -- ✅ Fixed viewport drift without focal point -- ✅ Fixed zoom center calculation consistency - -### Zoom Functionality - -#### Smooth Zoom Animation (commits: bdfa06b, a12b65b) -- ✅ 125ms smooth zoom transitions with ease-out curves -- ✅ Natural acceleration/deceleration for zoom operations -- ✅ Reduced acceleration from 2x to 1.2x max for better control -- ✅ Asymmetric zoom speeds (zoom out 40-50% slower than zoom in) -- ✅ Gentler acceleration range (0.7-1.1) for smoother transitions -- ✅ No jarring animations during mouse wheel zoom - -#### Zoom Input Methods (commits: e7a028b, bdfa06b) -- ✅ Mouse wheel zoom -- ✅ Alt+scroll zoom mode (keyboard modifier alternative) -- ✅ Space+scroll zoom mode (keyboard modifier alternative) -- ✅ Trackpad pinch-to-zoom (browsers translate to Ctrl+wheel events) -- ✅ Touch screen pinch-to-zoom with proper center tracking (direct touch events) -- ✅ UI zoom buttons (corrected zoom in/out direction) -- ✅ Zoom-to-fit button (zooms out to show all nodes with padding, respects minimum zoom) - -**Note**: Ctrl+wheel is used for trackpad pinch gestures on desktop. Browsers automatically translate two-finger pinch gestures on trackpads into Ctrl+wheel events. This is separate from touchscreen pinch-to-zoom, which uses direct touch events (touchstart/touchmove/touchend). - -#### Zoom Focal Point (commits: e42b09de, feec7ec, e7a028b) -- ✅ Cursor-centered zoom (focuses on cursor position) -- ✅ Store focal point in workspace coordinates instead of screen coordinates -- ✅ Prevents focal point drift when scroll changes due to canvas boundaries -- ✅ Maintains consistent zoom focus even when view shifts at edges -- ✅ Fixed focal point during pinch gestures - -#### Zoom Direction & Behavior (commits: 37f9786, bdfa06b) -- ✅ Fixed trackpad zoom direction (spreading fingers zooms in, pinching zooms out) -- ✅ Matches standard macOS trackpad behavior -- ✅ Proper ratio-based scaling for pinch gestures -- ✅ Scale lock issues fixed with improved tolerance handling - -#### Dynamic Zoom Limits (commits: 7918693, f13ed66) -- ✅ Calculate minimum zoom dynamically based on viewport size -- ✅ Ensure canvas always covers entire viewport (no empty space visible) -- ✅ Use 'cover' behavior: canvas fills viewport completely -- ✅ Recalculate minimum zoom on window resize -- ✅ Automatically adjust zoom if current level falls below new minimum after resize -- ✅ Prevent zooming out beyond what's needed to fill viewport - -### Panning Functionality - -#### Pan Input Methods (commit: feec7ec) -- ✅ Two-finger pan gesture for touch devices -- ✅ Spacebar+left-click panning for desktop -- ✅ Mode locking to prevent laggy gesture switching -- ✅ Lock into pan or zoom mode based on initial movement -- ✅ Better gesture detection thresholds (10px for zoom, 5px for pan) - -#### Scroll Behavior (commit: e7a028b) -- ✅ Momentum scrolling with edge bounce animation -- ✅ Enhanced spacebar handling to prevent scroll artifacts - -### UI/UX Enhancements - -#### Gesture State Management (commits: e42b09de, bdfa06b, 121982e) -- ✅ Improved gesture state management for trackpad and touch gestures -- ✅ Proper state cleanup when cursor leaves canvas -- ✅ Clear touchStartTime timeout when entering two-finger pan mode -- ✅ Prevent interference between long-press detection and pan gestures - -#### UI Pinch-Zoom Prevention (commit: e0c5b84) -- ✅ Prevent UI pinch-to-zoom while keeping canvas zoomable -- ✅ Apply `touch-action: pan-x pan-y` to html, body, and editor elements -- ✅ Apply `touch-action: none` to canvas for custom gestures -- ✅ JavaScript prevention for trackpad pinch on non-canvas areas -- ✅ Block Ctrl+wheel events outside the workspace chart - -#### Minimap Navigation (commits: 53dce6a, 5e056a4) -- ✅ Auto-show minimap on zoom and pan operations -- ✅ Auto-show minimap on flow startup and workspace changes -- ✅ Minimap appears for 2 seconds during navigation then fades out -- ✅ Smooth fade in/out animations for minimap visibility -- ✅ Minimap stays visible if manually toggled with button -- ✅ Emit `view:navigate` events for all zoom and pan operations -- ✅ Check for active nodes before showing on workspace change - -#### Visual Polish (commit: 53dce6a) -- ✅ Hide scrollbars on canvas while keeping it scrollable -- ✅ Clean visual appearance without visible scrollbars - -### Code Architecture - -#### New Modules (commit: bdfa06b) -- ✅ `view-zoom-animator.js` - Zoom animation utilities (223 lines) -- ✅ `view-zoom-constants.js` - Zoom configuration constants (21 lines) -- ✅ Updated Gruntfile to include new zoom modules in build - -## Current Expectations - -### Cross-Device Consistency -- Zoom and pan should feel natural on mouse, trackpad, and touchscreen -- Gestures should be responsive without lag or mode switching artifacts -- Zoom focal point should remain stable regardless of input method - -### Browser Zoom Compatibility -- Canvas interaction should work correctly at all browser zoom levels -- UI elements should remain accessible and functional -- No layout breaking or interaction dead zones - -### Visual Feedback -- Minimap should provide contextual navigation feedback -- Smooth animations should make interactions feel polished -- No visual glitches or artifacts during zoom/pan operations - -### Performance -- All interactions should be smooth (60fps target) -- No janky animations or delayed responses -- Efficient gesture detection without excessive computation - -## Recent Fixes - -### Grey Padding at Canvas Bottom (Latest) -**Issue**: When scrolled to the bottom of the canvas, 5 pixels of grey space appeared below the grid, allowing users to scroll slightly beyond the canvas boundary. - -**Root Cause**: Default browser margins on SVG elements caused the viewport's `scrollHeight` to be 8005px instead of 8000px, creating extra scrollable area beyond the canvas. - -**Solution**: -- Added explicit `padding: 0` and `margin: 0` to `#red-ui-workspace-chart` container -- Added `display: block`, `margin: 0`, and `padding: 0` to SVG element via `#red-ui-workspace-chart > svg` selector -- The `display: block` prevents inline element spacing issues - -**Files Changed**: -- `workspace.scss:41-42, 52-57` - Added margin/padding resets for container and SVG - -**Result**: Canvas now has exact 8000px scrollable area with no grey padding visible at bottom. - -### Spacebar Hold Scrolling Bug -**Issue**: When holding spacebar down, the canvas would move down unexpectedly, making the space+scroll interaction buggy. - -**Root Cause**: The `preventDefault()` was only called on the first spacebar keydown event. When spacebar is held, browsers fire repeated keydown events. After the first keydown set `spacebarPressed = true`, subsequent keydown events weren't prevented because the condition `e.type === "keydown" && !spacebarPressed` failed, allowing browser's default space-scroll behavior. - -**Solution**: -- Moved `preventDefault()` and `stopPropagation()` outside the conditional checks -- Now blocks ALL spacebar events (both keydown repeats and keyup), not just the first keydown - -**Files Changed**: -- `view.js:611-619` - Restructured spacebar event handler to always prevent default - -**Result**: Holding spacebar no longer causes unwanted canvas scrolling. - -### Minimap Auto-Show Behavior -**Issue**: Minimap was showing on selection changes and when entering pan mode (before actual panning), causing unnecessary flashing. - -**Solution**: -- Removed `view:selection-changed` event listener - minimap no longer shows when selecting nodes -- Removed `view:navigate` emissions from pan mode entry points (touch long-press, spacebar+click, middle-click) -- Added `view:navigate` emission to regular touchpad scroll handler for consistent behavior -- Kept emissions only during actual panning movement and zooming - -**Files Changed**: -- `view-navigator.js:195-198` - Removed selection-changed listener -- `view.js:483, 1529, 1539` - Removed navigate events from pan mode entry -- `view.js:876` - Added navigate event to touchpad scroll handler - -**Result**: Minimap now appears only during actual panning (touchpad or mouse) and zooming, not on selection or pan mode entry. - -### Diagonal Trackpad Panning -**Issue**: Trackpad scrolling was restricted to horizontal OR vertical movement, not both simultaneously. - -**Root Cause**: Browser's native scroll behavior on `overflow: auto` containers locks into one axis at a time, even before JavaScript wheel events fire. - -**Solution**: -- Added `evt.preventDefault()` and `evt.stopPropagation()` to regular scroll handling -- Manually apply both `deltaX` and `deltaY` to scrollLeft/scrollTop simultaneously -- Prevents browser's axis-locked scroll behavior from taking over -- Also updated CSS `touch-action` from `pan-x pan-y` to `manipulation` (though this primarily affects touch events, not trackpad) - -**Files Changed**: -- `view.js:864-890` - Added manual diagonal scroll handling -- `base.scss:22, 33` - Changed touch-action to manipulation - -**Result**: Trackpad can now pan diagonally without axis-locking. - -## Known Issues & Future Work - -### To Be Tested -- [ ] Comprehensive testing across different browser zoom levels (100%, 125%, 150%, 200%) -- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge) -- [ ] Testing on different touchscreen devices (tablets, touch-enabled laptops) -- [ ] Testing with different trackpad sensitivities and gesture settings -- [x] Diagonal trackpad panning (fixed) - -### Potential Improvements -- [ ] Additional fine-tuning of zoom speeds and acceleration curves based on user feedback -- [ ] Consider adding keyboard shortcuts for zoom reset (Ctrl+0 / Cmd+0) -- [ ] Evaluate need for custom zoom level indicator in UI -- [ ] Consider adding preferences for zoom/pan sensitivity - -### Edge Cases to Monitor -- [ ] Behavior when canvas content is very small or very large -- [ ] Interaction with browser accessibility features -- [ ] Performance with extremely large flows (100+ nodes) -- [ ] Multi-monitor scenarios with different DPI settings - -## Testing Checklist - -When verifying canvas interaction improvements: - -1. **Zoom Testing** - - [ ] Mouse wheel zoom in/out - - [ ] Alt+scroll zoom (keyboard modifier) - - [ ] Space+scroll zoom (keyboard modifier) - - [ ] Trackpad pinch gesture (spread = zoom in, pinch = zoom out, generates Ctrl+wheel) - - [ ] Touch screen pinch gesture (direct touch events) - - [x] UI zoom buttons (zoom in, zoom out, reset) - smooth animated, focal point locking - - [x] Zoom-to-fit button (shows all nodes with padding, respects max zoom of 1.0) - - [x] Zoom-to-fit hotkey (Ctrl+1 / Cmd+1) - - [x] Zoom hotkeys (Ctrl+Plus, Ctrl+Minus, Ctrl+0) - - [x] Zoom focal point stays on viewport center for button/hotkey zooms - - [x] Dynamic zoom limits prevent empty space - - [x] Maximum zoom capped at 1.0 (100%) - - [x] Animation duration scales with zoom distance (200-350ms) - - [x] Sequential zooms maintain same focal point (1 second timeout) - - [x] Zoom-to-fit works immediately after page load - - [x] Pan animation when zoom-to-fit doesn't need zoom change - -2. **Pan Testing** - - [x] Two-finger pan on trackpad/touch - - [x] Diagonal panning works (not axis-locked) - - [x] Spacebar+click pan on desktop - - [x] Middle-click pan on desktop - - [x] Momentum scrolling with edge bounce - - [x] No lag when switching between pan and zoom - -3. **UI/UX Testing** - - [x] Minimap auto-shows on flow startup and workspace changes - - [x] Minimap auto-shows during panning and zooming - - [x] Minimap auto-shows during zoom button/hotkey/zoom-to-fit - - [x] Minimap does not show on selection changes - - [x] Minimap fades after 2 seconds - - [x] No scrollbars visible on canvas - - [x] No pinch-zoom on UI elements - - [x] Gesture state cleanup on cursor exit - - [x] Grab cursor (open hand) shows when spacebar held - - [x] Grabbing cursor (closed hand) shows during active pan (spacebar or middle-click) - - [x] No grey padding visible at canvas bottom - -4. **Browser Zoom Testing** - - [ ] Test at 100% browser zoom - - [ ] Test at 125% browser zoom - - [ ] Test at 150% browser zoom - - [ ] Test at 200% browser zoom - - [ ] Verify all interactions work at each zoom level - -## Files Modified - -Key files involved in canvas interaction improvements: - -- `packages/node_modules/@node-red/editor-client/src/js/ui/view.js` - Main view controller -- `packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js` - Zoom animations -- `packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js` - Zoom configuration -- `packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js` - Minimap controller -- `packages/node_modules/@node-red/editor-client/src/sass/workspace.scss` - Canvas styling -- `packages/node_modules/@node-red/editor-client/src/sass/base.scss` - Base UI styling -- `Gruntfile.js` - Build configuration - -## Commit History - -Interaction improvements (20 commits total on claude/issue-44-20250925-0754): - -### Session 1: Previous commits (commits 1-13) -1. `e7a028b` - feat: Add enhanced zoom and scroll features -2. `bdfa06b` - Implement smooth zoom functionality with pinch-to-zoom support -3. `37f9786` - Fix trackpad zoom direction - spreading fingers now zooms in -4. `e42b09d` - Fix zoom focal point stability at canvas edges -5. `a12b65b` - Improve zoom smoothness and control -6. `feec7ec` - Add two-finger panning and spacebar+click panning -7. `e0c5b84` - Prevent UI pinch-to-zoom while keeping canvas zoomable -8. `121982e` - Fix zoom gesture detection after two-finger panning -9. `7918693` - Implement dynamic zoom limits to match canvas boundaries -10. `f13ed66` - Add dynamic minimum zoom recalculation on viewport resize -11. `53dce6a` - Hide scrollbars and add auto-show/hide minimap on navigation -12. `875db2c` - Enable diagonal trackpad panning by preventing axis-locked scroll -13. (previous) - Improve minimap auto-show behavior to only trigger during actual navigation - -### Session 2: Zoom button/hotkey improvements (commits 14-20) -14. `ad00ca23e` - Add scroll spacer to fix scrollable area at minimum zoom -15. `48f0f3be` - Fix minimap viewport position at non-1.0 zoom levels -16. `be1da360` - Fix grey padding at canvas bottom by resetting SVG margins -17. `6f164a8a` - Add zoom-to-fit button to show all nodes at once -18. `7fdff0ca` - Fix zoom button handlers - zoom in/out were reversed -19. `e46cfc94` - Move zoom-to-fit button between reset and zoom-in -20. `95304e26` - Revert "Move zoom-to-fit button between reset and zoom-in" -21. `788d0a38` - Add Ctrl+1/Cmd+1 keyboard shortcut for zoom-to-fit -22. `5c090786` - Remove animation from zoom buttons for instant, smooth zooming -23. `6e49e962` - Fix viewport drift when using zoom buttons without focal point -24. `e3de29d8` - Fix zoom center calculation to use oldScaleFactor consistently -25. `935ff622` - Fix zoom button animation and improve performance -26. `f07907e1` - Add focal point locking for sequential button/hotkey zooms -27. `0247a910` - Add minimap auto-show for zoom button/hotkey interactions -28. `62612139` - Add grab/grabbing cursor for spacebar pan mode -29. `ce5a0313` - Add grabbing cursor for middle-click pan mode -30. `594c0d66` - Make zoom animation duration relative to maintain consistent velocity -31. `45c2a798` - Set maximum zoom level to 1.0 -32. `ed71cf91` - Fix zoom-to-fit to properly center nodes in viewport -33. `732c8283` - Refresh active nodes before zoom-to-fit to work immediately (includes pan animation matching zoom duration, 200-350ms range) -34. `5e056a4d` - Add minimap auto-show on flow startup and workspace changes \ No newline at end of file From 9cedcd0a78d4808e1bce602b9d568675cffec15a Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 20 Oct 2025 13:15:09 +0100 Subject: [PATCH 43/49] Add nls message for zoom to fit --- .../@node-red/editor-client/locales/en-US/editor.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index c1c9316d8..905b8ea5a 100644 --- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json @@ -169,6 +169,7 @@ "zoom-out": "Zoom out", "zoom-reset": "Reset zoom", "zoom-in": "Zoom in", + "fit-to-screen": "Zoom to fit", "search-flows": "Search flows", "search-prev": "Previous", "search-next": "Next", From 3bbdc7730054de2ff5b1c6ab02ec01dd061b2f05 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 20 Oct 2025 13:15:37 +0100 Subject: [PATCH 44/49] Fix up space-to-pan event handling --- .../@node-red/editor-client/src/js/ui/view.js | 162 ++++++++++-------- 1 file changed, 94 insertions(+), 68 deletions(-) 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 5ac2bdb76..baf605e0d 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 @@ -398,8 +398,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(); @@ -597,9 +598,9 @@ RED.view = (function() { } 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 Date: Mon, 20 Oct 2025 13:15:52 +0100 Subject: [PATCH 45/49] Fix issues with navigator show/hide lifecycle --- .../editor-client/src/js/ui/view-navigator.js | 345 +++++++++--------- 1 file changed, 171 insertions(+), 174 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index 61af52e50..438820227 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -15,178 +15,176 @@ **/ - 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 autoHideTimeout; - var isManuallyToggled = 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 toggle() { - if (!isShowing) { - isShowing = true; - isManuallyToggled = 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(); - setTimeout(function() { - navContainer.addClass('red-ui-navigator-visible'); - }, 10); - } else { - isShowing = false; - isManuallyToggled = false; - clearTimeout(autoHideTimeout); - navContainer.removeClass('red-ui-navigator-visible'); - setTimeout(function() { - navContainer.hide(); - }, 300); - $("#red-ui-workspace-chart").off("scroll",onScroll); - $("#red-ui-view-navigate").removeClass("selected"); - } - } - - function showTemporary() { - if (!isManuallyToggled) { - clearTimeout(autoHideTimeout); - - if (!isShowing) { - isShowing = true; - resizeNavBorder(); - refreshNodes(); - $("#red-ui-workspace-chart").on("scroll",onScroll); - navContainer.addClass('red-ui-navigator-container'); - navContainer.show(); - setTimeout(function() { - navContainer.addClass('red-ui-navigator-visible'); - }, 10); - } - - autoHideTimeout = setTimeout(function() { - if (!isManuallyToggled && isShowing) { - isShowing = false; - navContainer.removeClass('red-ui-navigator-visible'); - setTimeout(function() { - navContainer.hide(); - }, 300); - $("#red-ui-workspace-chart").off("scroll",onScroll); - } - }, 2000); - } - } - - return { - init: function() { - - $(window).on("resize", resizeNavBorder); - RED.events.on("sidebar:resize",resizeNavBorder); - RED.actions.add("core:toggle-navigator",toggle); - var hideTimeout; - - 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; - }) - - 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(); @@ -216,8 +214,7 @@ }, refresh: refreshNodes, resize: resizeNavBorder, - toggle: toggle, - showTemporary: showTemporary + toggle: toggle } From fa05811b08206024cafa885a443e225c5508d5ae Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Mon, 20 Oct 2025 13:20:30 +0100 Subject: [PATCH 46/49] Fix linting --- .../src/js/ui/view-zoom-animator.js | 22 ++++++++++--------- .../@node-red/editor-client/src/js/ui/view.js | 4 +++- 2 files changed, 15 insertions(+), 11 deletions(-) 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 index 7938ca0de..cda28591c 100644 --- 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 @@ -77,16 +77,18 @@ RED.view.zoomAnimator = (function() { 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; + 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; + } } } 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 baf605e0d..07ba14e2b 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 @@ -3229,7 +3229,9 @@ RED.view = (function() { } function animateMomentumScroll() { - if (!momentumActive) return; + if (!momentumActive) { + return; + } var scrollX = chart.scrollLeft(); var scrollY = chart.scrollTop(); From c9e0aaf34ebf93ba9ce9af53a064eb804cfb72c1 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Tue, 21 Oct 2025 09:24:58 +0100 Subject: [PATCH 47/49] Make zoom scale 1 sticky to make it easier to get to --- .../@node-red/editor-client/src/js/ui/view.js | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 07ba14e2b..453a545ad 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 @@ -3073,7 +3073,12 @@ RED.view = (function() { if (Math.abs(scaleFactor - factor) < 0.001) { return; } + // Make scale 1 'sticky' + if (Math.abs(1.0 - factor) < 0.02) { + factor = 1 + } + console.log(factor) var screenSize = [chart.width(),chart.height()]; var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; var oldScaleFactor = scaleFactor; @@ -3130,6 +3135,11 @@ RED.view = (function() { if (Math.abs(scaleFactor - targetFactor) < 0.01) { return; } + // Make scale 1 'sticky' + if (Math.abs(1.0 - targetFactor) < 0.02) { + targetFactor = 1 + } + var startFactor = scaleFactor; var screenSize = [chart.width(), chart.height()]; From 351d25a7d62c4803c15bf4823502fe0b56bb4f0e Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 31 Oct 2025 11:28:53 +0000 Subject: [PATCH 48/49] Fix nls message name --- .../@node-red/editor-client/locales/en-US/editor.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index 905b8ea5a..8018b745f 100644 --- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json +++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json @@ -169,7 +169,7 @@ "zoom-out": "Zoom out", "zoom-reset": "Reset zoom", "zoom-in": "Zoom in", - "fit-to-screen": "Zoom to fit", + "zoom-fit": "Zoom to fit", "search-flows": "Search flows", "search-prev": "Previous", "search-next": "Next", From 7b6c838e7eef227de8af127c10553a8237f96ce8 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Fri, 31 Oct 2025 11:29:07 +0000 Subject: [PATCH 49/49] Reshow scrollbars and disable navigator temporary show --- .../editor-client/src/js/ui/view-navigator.js | 30 +++++++++---------- .../editor-client/src/sass/workspace.scss | 14 ++++----- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js index 438820227..d2275608c 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-navigator.js @@ -194,23 +194,23 @@ RED.view.navigator = (function() { // 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(); - }); + // 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); - } - }); + // 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/sass/workspace.scss b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss index a29932e9d..30a5c245f 100644 --- a/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss +++ b/packages/node_modules/@node-red/editor-client/src/sass/workspace.scss @@ -41,13 +41,13 @@ padding: 0; margin: 0; - // Hide scrollbars - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* Internet Explorer 10+ */ - &::-webkit-scrollbar { /* WebKit */ - width: 0; - height: 0; - } + // Hide scrollbars - to be done in a future iteration + // scrollbar-width: none; /* Firefox */ + // -ms-overflow-style: none; /* Internet Explorer 10+ */ + // &::-webkit-scrollbar { /* WebKit */ + // width: 0; + // height: 0; + // } // Reset SVG default margins > svg {