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/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json index c1c9316d8..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,6 +169,7 @@ "zoom-out": "Zoom out", "zoom-reset": "Reset zoom", "zoom-in": "Zoom in", + "zoom-fit": "Zoom to fit", "search-flows": "Search flows", "search-prev": "Previous", "search-next": "Next", 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", 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..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 @@ -15,139 +15,202 @@ **/ - 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; - - 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()]; - navBorder.attr('x',scrollPos[0]/nav_scale) - .attr('y',scrollPos[1]/nav_scale) - .attr('width',chartSize[0]/nav_scale/scaleFactor) - .attr('height',chartSize[1]/nav_scale/scaleFactor) - } - } - function toggle() { - if (!isShowing) { - isShowing = true; - $("#red-ui-view-navigate").addClass("selected"); - resizeNavBorder(); - refreshNodes(); - $("#red-ui-workspace-chart").on("scroll",onScroll); - navContainer.fadeIn(200); - } else { - isShowing = false; - navContainer.fadeOut(100); - $("#red-ui-workspace-chart").off("scroll",onScroll); - $("#red-ui-view-navigate").removeClass("selected"); - } - } - - 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 - }).appendTo("#red-ui-workspace").hide(); - - navBox = d3.select(navContainer[0]) - .append("svg:svg") - .attr("width", nav_width) - .attr("height", nav_height) - .attr("pointer-events", "all") - .attr("id","red-ui-navigator-canvas") - - navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({ - fill:"none", - stroke:"none", - pointerEvents:"all" - }).on("mousedown", function() { - // Update these in case they have changed - scaleFactor = RED.view.scale(); - chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()]; - dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor]; - var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]); - var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]); - navBorder.attr('x',newX).attr('y',newY); - isDragging = true; - $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor); - $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor); - }).on("mousemove", function() { - if (!isDragging) { return } - if (d3.event.buttons === 0) { - isDragging = false; - return; - } - var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]); - var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]); - navBorder.attr('x',newX).attr('y',newY); - $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor); - $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor); - }).on("mouseup", function() { - isDragging = false; - }) - - navBorder = navBox.append("rect").attr("class","red-ui-navigator-border") - - navVis = navBox.append("svg:g") - - RED.statusBar.add({ - id: "view-navigator", - align: "right", - element: $('') - }) +RED.view.navigator = (function() { + var nav_scale = 50; + var nav_width = 8000/nav_scale; + var nav_height = 8000/nav_scale; + var navContainer; + var navBox; + var navBorder; + var navVis; + var scrollPos; + var scaleFactor; + var chartSize; + var dimensions; + var isDragging; + var isShowing = false; + var toggleTimeout; + var autoHideTimeout; + var isManuallyToggled = false; + var isTemporaryShow = false; + function refreshNodes() { + if (!isShowing) { + return; + } + var navNode = navVis.selectAll(".red-ui-navigator-node").data(RED.view.getActiveNodes(),function(d){return d.id}); + navNode.exit().remove(); + navNode.enter().insert("rect") + .attr('class','red-ui-navigator-node') + .attr("pointer-events", "none"); + navNode.each(function(d) { + d3.select(this).attr("x",function(d) { return (d.x-d.w/2)/nav_scale }) + .attr("y",function(d) { return (d.y-d.h/2)/nav_scale }) + .attr("width",function(d) { return Math.max(9,d.w/nav_scale) }) + .attr("height",function(d) { return Math.max(3,d.h/nav_scale) }) + .attr("fill",function(d) { return RED.utils.getNodeColor(d.type,d._def);}) + }); + } + function onScroll() { + if (!isDragging) { + resizeNavBorder(); + } + } + function resizeNavBorder() { + if (navBorder) { + scaleFactor = RED.view.scale(); + chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()]; + scrollPos = [$("#red-ui-workspace-chart").scrollLeft(),$("#red-ui-workspace-chart").scrollTop()]; + // Convert scroll position (in scaled pixels) to workspace coordinates, then to minimap coordinates + // scrollPos is in scaled canvas pixels, divide by scaleFactor to get workspace coords + navBorder.attr('x',scrollPos[0]/scaleFactor/nav_scale) + .attr('y',scrollPos[1]/scaleFactor/nav_scale) + .attr('width',chartSize[0]/nav_scale/scaleFactor) + .attr('height',chartSize[1]/nav_scale/scaleFactor) + } + } + function show () { + if (!isShowing) { + isShowing = true; + clearTimeout(autoHideTimeout); + $("#red-ui-view-navigate").addClass("selected"); + resizeNavBorder(); + refreshNodes(); + $("#red-ui-workspace-chart").on("scroll",onScroll); + navContainer.addClass('red-ui-navigator-container'); + navContainer.show(); + clearTimeout(toggleTimeout) + toggleTimeout = setTimeout(function() { + navContainer.addClass('red-ui-navigator-visible'); + }, 10); + } + } + function hide () { + if (isShowing) { + isShowing = false; + isTemporaryShow = false; + isManuallyToggled = false; + clearTimeout(autoHideTimeout); + navContainer.removeClass('red-ui-navigator-visible'); + clearTimeout(toggleTimeout) + toggleTimeout = setTimeout(function() { + navContainer.hide(); + }, 300); + $("#red-ui-workspace-chart").off("scroll",onScroll); + $("#red-ui-view-navigate").removeClass("selected"); + } + } + function toggle() { + if (!isShowing) { + isManuallyToggled = true; + show() + } else { + isManuallyToggled = false; + hide() + } + } + function setupAutoHide () { + clearTimeout(autoHideTimeout); + autoHideTimeout = setTimeout(function() { + hide() + }, 2000) + } + function showTemporary() { + if (!isManuallyToggled) { + isTemporaryShow = true + clearTimeout(autoHideTimeout); + show() + setupAutoHide() + } + } + return { + init: function() { + $(window).on("resize", resizeNavBorder); + RED.events.on("sidebar:resize",resizeNavBorder); + RED.actions.add("core:toggle-navigator",toggle); + navContainer = $('
').css({ + "position":"absolute", + "bottom":$("#red-ui-workspace-footer").height(), + "right":0, + zIndex: 1 + }).addClass('red-ui-navigator-container').appendTo("#red-ui-workspace").hide(); + navBox = d3.select(navContainer[0]) + .append("svg:svg") + .attr("width", nav_width) + .attr("height", nav_height) + .attr("pointer-events", "all") + .attr("id","red-ui-navigator-canvas") + navBox.append("rect").attr("x",0).attr("y",0).attr("width",nav_width).attr("height",nav_height).style({ + fill:"none", + stroke:"none", + pointerEvents:"all" + }).on("mousedown", function() { + // Update these in case they have changed + scaleFactor = RED.view.scale(); + chartSize = [ $("#red-ui-workspace-chart").width(), $("#red-ui-workspace-chart").height()]; + dimensions = [chartSize[0]/nav_scale/scaleFactor, chartSize[1]/nav_scale/scaleFactor]; + var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]); + var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]); + navBorder.attr('x',newX).attr('y',newY); + isDragging = true; + $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor); + $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor); + }).on("mousemove", function() { + if (!isDragging) { return } + if (d3.event.buttons === 0) { + isDragging = false; + return; + } + var newX = Math.max(0,Math.min(d3.event.offsetX+dimensions[0]/2,nav_width)-dimensions[0]); + var newY = Math.max(0,Math.min(d3.event.offsetY+dimensions[1]/2,nav_height)-dimensions[1]); + navBorder.attr('x',newX).attr('y',newY); + $("#red-ui-workspace-chart").scrollLeft(newX*nav_scale*scaleFactor); + $("#red-ui-workspace-chart").scrollTop(newY*nav_scale*scaleFactor); + }).on("mouseup", function() { + isDragging = false; + }).on("mouseenter", function () { + if (isTemporaryShow) { + // If user hovers over the minimap while it's temporarily shown, keep it shown + clearTimeout(autoHideTimeout); + } + }).on("mouseleave", function () { + if (isTemporaryShow) { + // Restart the auto-hide timer after mouse leaves the minimap + setupAutoHide() + } + }) + navBorder = navBox.append("rect").attr("class","red-ui-navigator-border") + navVis = navBox.append("svg:g") + RED.statusBar.add({ + id: "view-navigator", + align: "right", + element: $('') + }) $("#red-ui-view-navigate").on("click", function(evt) { evt.preventDefault(); toggle(); }) RED.popover.tooltip($("#red-ui-view-navigate"),RED._('actions.toggle-navigator'),'core:toggle-navigator'); + + // Listen for canvas interactions to show minimap temporarily + // Only show on actual pan/zoom navigation, not selection changes + // RED.events.on("view:navigate", function() { + // showTemporary(); + // }); + + // Show minimap briefly when workspace changes (includes initial load) + // RED.events.on("workspace:change", function(event) { + // // Only show if there's an active workspace with nodes + // if (event.workspace && RED.nodes.getWorkspaceOrder().length > 0) { + // // Small delay to ensure nodes are rendered + // setTimeout(function() { + // var activeNodes = RED.nodes.filterNodes({z: event.workspace}); + // if (activeNodes.length > 0) { + // showTemporary(); + // } + // }, 100); + // } + // }); }, refresh: refreshNodes, resize: resizeNavBorder, diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js new file mode 100644 index 000000000..cda28591c --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-animator.js @@ -0,0 +1,257 @@ +/** + * Copyright JS Foundation and other contributors, http://js.foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + **/ + +RED.view.zoomAnimator = (function() { + + /** + * Easing function for smooth deceleration + * Creates natural-feeling animation curves + * @param {number} t - Progress from 0 to 1 + * @returns {number} - Eased value from 0 to 1 + */ + function easeOut(t) { + // Cubic ease-out for smooth deceleration + return 1 - Math.pow(1 - t, 3); + } + + /** + * Animate values using requestAnimationFrame with easing + * Based on Excalidraw's implementation for smooth zoom transitions + * + * @param {Object} options - Animation options + * @param {Object} options.fromValues - Starting values object + * @param {Object} options.toValues - Target values object + * @param {Function} options.onStep - Callback for each animation frame + * @param {number} [options.duration=250] - Animation duration in ms + * @param {Function} [options.interpolateValue] - Custom interpolation function + * @param {Function} [options.onStart] - Animation start callback + * @param {Function} [options.onEnd] - Animation end callback + * @param {Function} [options.onCancel] - Animation cancel callback + * @returns {Function} - Cancel function to stop animation + */ + function easeToValuesRAF(options) { + const { + fromValues, + toValues, + onStep, + duration = 250, + interpolateValue, + onStart, + onEnd, + onCancel + } = options; + + let startTime = null; + let animationId = null; + let cancelled = false; + + function step(timestamp) { + if (cancelled) { + return; + } + + if (!startTime) { + startTime = timestamp; + if (onStart) { + onStart(); + } + } + + const elapsed = timestamp - startTime; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easeOut(progress); + + const interpolatedValues = {}; + + for (const key in fromValues) { + if (fromValues.hasOwnProperty(key)) { + const from = fromValues[key]; + const to = toValues[key]; + + if (interpolateValue && key === 'zoom') { + // Special interpolation for zoom to feel more natural + // Exponential interpolation preserves relative zoom feel + interpolatedValues[key] = from * Math.pow(to / from, easedProgress); + } else { + // Linear interpolation for other values + interpolatedValues[key] = from + (to - from) * easedProgress; + } + } + } + + onStep(interpolatedValues); + + if (progress < 1) { + animationId = requestAnimationFrame(step); + } else { + if (onEnd) { + onEnd(); + } + } + } + + animationId = requestAnimationFrame(step); + + // Return cancel function + return function cancel() { + cancelled = true; + if (animationId) { + cancelAnimationFrame(animationId); + } + if (onCancel) { + onCancel(); + } + }; + } + + /** + * Calculate smooth zoom delta with acceleration + * Provides consistent zoom speed regardless of input device + * + * @param {number} currentScale - Current zoom scale + * @param {number} delta - Input delta (wheel, gesture, etc) + * @param {boolean} isTrackpad - Whether input is from trackpad + * @returns {number} - Calculated zoom delta + */ + function calculateZoomDelta(currentScale, delta, isTrackpad) { + // Normalize delta across different input devices + let normalizedDelta = delta; + + if (isTrackpad) { + // Trackpad deltas are typically smaller and more frequent + normalizedDelta = delta * 0.005; // Reduced from 0.01 for gentler zoom + } else { + // Mouse wheel deltas are larger and less frequent + // Reduce zoom out speed more than zoom in + normalizedDelta = delta > 0 ? 0.06 : -0.08; // Reduced from 0.1, asymmetric for gentler zoom out + } + + // Apply gentler acceleration based on current zoom level + // Less aggressive acceleration to prevent rapid zoom out + const acceleration = Math.max(0.7, Math.min(1.1, 1 / currentScale)); // Reduced from 0.5-1.2 to 0.7-1.1 + + return normalizedDelta * acceleration; + } + + /** + * Gesture state management for consistent focal points + */ + const gestureState = { + active: false, + initialFocalPoint: null, // Will store workspace coordinates + initialScale: 1, + currentScale: 1, + lastDistance: 0, + scrollPosAtStart: null, // Store initial scroll position + scaleFatorAtStart: 1 // Store initial scale factor + }; + + /** + * Start a zoom gesture with fixed focal point + * @param {Array} focalPoint - [x, y] coordinates of focal point in workspace + * @param {number} scale - Initial scale value + * @param {Array} scrollPos - Current scroll position [x, y] + * @param {number} currentScaleFactor - Current scale factor for coordinate conversion + */ + function startGesture(focalPoint, scale, scrollPos, currentScaleFactor) { + gestureState.active = true; + // Store the focal point in workspace coordinates for stability + // This ensures the point remains fixed even if scroll changes due to canvas edge constraints + if (focalPoint && scrollPos && currentScaleFactor) { + gestureState.initialFocalPoint = [ + (scrollPos[0] + focalPoint[0]) / currentScaleFactor, + (scrollPos[1] + focalPoint[1]) / currentScaleFactor + ]; + gestureState.scrollPosAtStart = [...scrollPos]; + gestureState.scaleFatorAtStart = currentScaleFactor; + } else { + gestureState.initialFocalPoint = focalPoint ? [...focalPoint] : null; + } + gestureState.initialScale = scale; + gestureState.currentScale = scale; + return gestureState; + } + + /** + * Update gesture maintaining fixed focal point + * @param {number} newScale - New scale value + * @returns {Object} - Gesture state with fixed focal point + */ + function updateGesture(newScale) { + if (!gestureState.active) { + return null; + } + + gestureState.currentScale = newScale; + + return { + scale: newScale, + focalPoint: gestureState.initialFocalPoint, + active: gestureState.active + }; + } + + /** + * End the current gesture + */ + function endGesture() { + gestureState.active = false; + gestureState.initialFocalPoint = null; + gestureState.lastDistance = 0; + } + + /** + * Check if a gesture is currently active + */ + function isGestureActive() { + return gestureState.active; + } + + /** + * Get the fixed focal point for the current gesture + * @param {Array} currentScrollPos - Current scroll position [x, y] + * @param {number} currentScaleFactor - Current scale factor + * @returns {Array} - Focal point in screen coordinates or null + */ + function getGestureFocalPoint(currentScrollPos, currentScaleFactor) { + if (!gestureState.initialFocalPoint) { + return null; + } + + // If we stored workspace coordinates, convert back to screen coordinates + if (gestureState.scrollPosAtStart && currentScrollPos && currentScaleFactor) { + // Convert workspace coordinates back to current screen coordinates + return [ + gestureState.initialFocalPoint[0] * currentScaleFactor - currentScrollPos[0], + gestureState.initialFocalPoint[1] * currentScaleFactor - currentScrollPos[1] + ]; + } + + return gestureState.initialFocalPoint; + } + + return { + easeOut: easeOut, + easeToValuesRAF: easeToValuesRAF, + calculateZoomDelta: calculateZoomDelta, + gestureState: gestureState, + startGesture: startGesture, + updateGesture: updateGesture, + endGesture: endGesture, + isGestureActive: isGestureActive, + getGestureFocalPoint: getGestureFocalPoint + }; +})(); \ No newline at end of file diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js new file mode 100644 index 000000000..9b10afd82 --- /dev/null +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view-zoom-constants.js @@ -0,0 +1,21 @@ +/** + * Zoom configuration constants + */ +RED.view.zoomConstants = { + // Zoom limits + MIN_ZOOM: 0.05, // Default minimum, will be dynamically calculated to fit canvas + MAX_ZOOM: 2.0, + + // Zoom step for keyboard/button controls + ZOOM_STEP: 0.2, + + // Animation settings + DEFAULT_ZOOM_DURATION: 125, // ms, faster animation + + // Gesture thresholds + PINCH_THRESHOLD: 10, // minimum pixel movement to trigger zoom + + // Momentum and friction for smooth scrolling + FRICTION: 0.92, + BOUNCE_DAMPING: 0.6 +}; \ No newline at end of file diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js index c459de1ff..4aa9bc128 100644 --- a/packages/node_modules/@node-red/editor-client/src/js/ui/view.js +++ b/packages/node_modules/@node-red/editor-client/src/js/ui/view.js @@ -38,11 +38,29 @@ RED.view = (function() { node_height = 30, dblClickInterval = 650; + var cancelInProgressAnimation = null; // For smooth zoom animation + var touchLongPressTimeout = 1000, startTouchDistance = 0, startTouchCenter = [], moveTouchCenter = [], - touchStartTime = 0; + touchStartTime = 0, + gesture = {}; + + var spacebarPressed = false; + + // Momentum scrolling state + var scrollVelocity = { x: 0, y: 0 }; + var lastScrollTime = 0; + var lastScrollPos = { x: 0, y: 0 }; + var scrollAnimationId = null; + var momentumActive = false; + + // Bounce effect parameters + var BOUNCE_DAMPING = 0.6; + var BOUNCE_TENSION = 0.3; + var MIN_VELOCITY = 0.5; + var FRICTION = 0.95; var workspaceScrollPositions = {}; @@ -319,6 +337,24 @@ RED.view = (function() { function init() { chart = $("#red-ui-workspace-chart"); + + // Add invisible spacer div to ensure scrollable area matches canvas dimensions + // At minimum zoom with "cover" behavior, SVG may be smaller than viewport in one dimension + // This spacer forces the browser to calculate scrollWidth/Height based on full canvas size + // Browser's maxScroll = scrollWidth - viewport will then correctly show canvas edges + var scrollSpacer = $('
') + .css({ + position: 'absolute', + top: 0, + left: 0, + width: space_width + 'px', + height: space_height + 'px', + pointerEvents: 'none', + visibility: 'hidden' + }) + .attr('id', 'red-ui-workspace-scroll-spacer') + .appendTo(chart); + chart.on('contextmenu', function(evt) { if (RED.view.DEBUG) { console.warn("contextmenu", { mouse_mode, event: d3.event }); @@ -363,8 +399,9 @@ RED.view = (function() { lasso.remove(); lasso = null; } - } else if (mouse_mode === RED.state.PANNING && d3.event.buttons !== 4) { - resetMouseVars(); + } else if (mouse_mode === RED.state.PANNING) { + // ensure the cursor is set to grab when re-entering the canvas while panning + outer.style('cursor', 'grabbing'); } else if (slicePath) { if (d3.event.buttons !== 2) { slicePath.remove(); @@ -381,11 +418,15 @@ RED.view = (function() { if (RED.touch.radialMenu.active()) { return; } + // End gesture when touches end + RED.view.zoomAnimator.endGesture(); canvasMouseUp.call(this); }) .on("touchcancel", function() { if (RED.view.DEBUG) { console.warn("eventLayer.touchcancel", mouse_mode); } d3.event.preventDefault(); + // End gesture when touches are cancelled + RED.view.zoomAnimator.endGesture(); canvasMouseUp.call(this); }) .on("touchstart", function() { @@ -411,6 +452,20 @@ RED.view = (function() { touch1["pageY"]+(a/2) ] startTouchDistance = Math.sqrt((a*a)+(b*b)); + + // Store initial scale for ratio-based zoom calculation + gesture = { + initialScale: scaleFactor, + initialDistance: startTouchDistance, + mode: null // Will be determined on first significant move + }; + + // Start gesture with fixed focal point (store in workspace coordinates) + var focalPoint = [ + (touch0["pageX"] + touch1["pageX"]) / 2 - offset.left, + (touch0["pageY"] + touch1["pageY"]) / 2 - offset.top + ]; + RED.view.zoomAnimator.startGesture(focalPoint, scaleFactor, scrollPos, scaleFactor); } else { var obj = d3.select(document.body); touch0 = d3.event.touches.item(0); @@ -460,33 +515,93 @@ RED.view = (function() { var offset = chart.offset(); var scrollPos = [chart.scrollLeft(),chart.scrollTop()]; var moveTouchDistance = Math.sqrt((a*a)+(b*b)); - var touchCenter = [ - touch1["pageX"]+(b/2), - touch1["pageY"]+(a/2) + + // Calculate center point of two fingers + var currentTouchCenter = [ + (touch0["pageX"] + touch1["pageX"]) / 2, + (touch0["pageY"] + touch1["pageY"]) / 2 ]; if (!isNaN(moveTouchDistance)) { - oldScaleFactor = scaleFactor; - scaleFactor = Math.min(2,Math.max(0.3, scaleFactor + (Math.floor(((moveTouchDistance*100)-(startTouchDistance*100)))/10000))); + // Determine gesture mode on first significant movement + if (!gesture.mode) { + var distanceChange = Math.abs(moveTouchDistance - startTouchDistance); + var centerChange = moveTouchCenter ? + Math.sqrt(Math.pow(currentTouchCenter[0] - moveTouchCenter[0], 2) + + Math.pow(currentTouchCenter[1] - moveTouchCenter[1], 2)) : 0; + + // Lock into zoom mode if distance changes significantly (>10px) + // Lock into pan mode if center moves significantly (>5px) without distance change + if (distanceChange > 10) { + gesture.mode = 'zoom'; + } else if (centerChange > 5) { + gesture.mode = 'pan'; + } + } + + // Once mode is determined, stay in that mode for the entire gesture + if (gesture.mode === 'zoom') { + oldScaleFactor = scaleFactor; + // Use smooth ratio-based scaling for natural pinch-to-zoom + var zoomRatio = moveTouchDistance / startTouchDistance; + var minZoom = calculateMinZoom(); + var newScaleFactor = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(minZoom, gesture.initialScale * zoomRatio)); - var deltaTouchCenter = [ // Try to pan whilst zooming - not 100% - startTouchCenter[0]*(scaleFactor-oldScaleFactor),//-(touchCenter[0]-moveTouchCenter[0]), - startTouchCenter[1]*(scaleFactor-oldScaleFactor) //-(touchCenter[1]-moveTouchCenter[1]) - ]; + // Use gesture state management to maintain fixed focal point + var gestureState = RED.view.zoomAnimator.updateGesture(newScaleFactor); + + // Only call zoomView if scale is actually changing (not at limits) + if (Math.abs(scaleFactor - newScaleFactor) >= 0.001) { + // Get focal point converted back to current screen coordinates + var currentScrollPos = [chart.scrollLeft(), chart.scrollTop()]; + var focalPoint = RED.view.zoomAnimator.getGestureFocalPoint(currentScrollPos, scaleFactor); + + if (focalPoint) { + // Use the fixed focal point from gesture start (converted from workspace coords) + zoomView(newScaleFactor, focalPoint); + } else { + // Fallback to current behavior if gesture not active + var touchCenter = [ + touch1["pageX"]+(b/2), + touch1["pageY"]+(a/2) + ]; + var pinchCenter = [ + touchCenter[0] - offset.left, + touchCenter[1] - offset.top + ]; + zoomView(newScaleFactor, pinchCenter); + } + } + } else if (gesture.mode === 'pan' || !gesture.mode) { + // Two-finger pan: allow immediate panning even if mode not determined + // Clear touchStartTime to prevent issues with next gesture + if (touchStartTime) { + clearTimeout(touchStartTime); + touchStartTime = null; + } + if (moveTouchCenter) { + var dx = currentTouchCenter[0] - moveTouchCenter[0]; + var dy = currentTouchCenter[1] - moveTouchCenter[1]; + + // Pan the canvas + var currentScroll = [chart.scrollLeft(), chart.scrollTop()]; + chart.scrollLeft(currentScroll[0] - dx); + chart.scrollTop(currentScroll[1] - dy); + RED.events.emit("view:navigate"); + } + // Update the center for next move + moveTouchCenter = currentTouchCenter; + } - startTouchDistance = moveTouchDistance; - moveTouchCenter = touchCenter; - - chart.scrollLeft(scrollPos[0]+deltaTouchCenter[0]); - chart.scrollTop(scrollPos[1]+deltaTouchCenter[1]); - redraw(); + // Don't update startTouchDistance - keep initial distance for ratio calculation } } d3.event.preventDefault(); }); - - - const handleAltToggle = (event) => { + + const handleChartKeyboardEvents = (event) => { + // Handle Alt toggle for pulling nodes out of groups if (mouse_mode === RED.state.MOVING_ACTIVE && event.key === 'Alt' && groupAddParentGroup) { RED.nodes.group(groupAddParentGroup).dirty = true for (let n = 0; n'+ ''+ ''+ + ''+ '') }) - $("#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'); - chart.on("DOMMouseScroll mousewheel", function (evt) { - if ( evt.altKey ) { + $("#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 ) { + // 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; + var trackpadGestureTimer = null; + var lastWheelEventTime = 0; + 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) { + if (mouse_mode === RED.state.PANNING) { + // Ignore wheel events while panning + return; + } + // ctrlKey is set during pinch gestures on trackpads + if (evt.ctrlKey || evt.altKey || spacebarPressed) { evt.preventDefault(); evt.stopPropagation(); - var move = -(evt.originalEvent.detail) || evt.originalEvent.wheelDelta; - if (move <= 0) { zoomOut(); } - else { zoomIn(); } + + var currentTime = Date.now(); + var timeSinceLastEvent = currentTime - lastWheelEventTime; + + // 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; + + // For trackpad pinch (Ctrl+wheel), use smooth proportional zoom + if (evt.ctrlKey && !evt.altKey && !spacebarPressed) { + // 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, isTrackpadInput); + var minZoom = calculateMinZoom(); + var newScale = Math.min(RED.view.zoomConstants.MAX_ZOOM, + Math.max(minZoom, scaleFactor + scaleDelta)); + + // 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 last event time for continuity tracking + lastWheelEventTime = currentTime; + + // Reset gesture timeout - end gesture when no more events come in for gestureEndThreshold + 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); // Use 500ms timeout for gesture end detection + } else { + // Regular Alt+scroll or Space+scroll - use smooth zoom without animation + // 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)); + + // 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) { + // 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 + 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); + + // Emit navigate event for minimap + RED.events.emit("view:navigate"); + + // 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); } }); @@ -786,6 +1130,12 @@ 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; + // Revert cursor when chart loses focus + outer.style('cursor', ''); + } }); RED.actions.add("core:copy-selection-to-internal-clipboard",copySelection); @@ -850,6 +1200,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)}); @@ -965,6 +1316,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) @@ -1242,12 +1596,26 @@ 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()]; + // Change cursor to grabbing while actively panning + outer.style('cursor', 'grabbing'); + return; + } + if (d3.event.button === 1) { // Middle Click pan d3.event.preventDefault(); 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) { @@ -1779,6 +2147,27 @@ RED.view = (function() { redraw(); } + function startPanning () { + + } + window.addEventListener('mousemove', function (event) { + if (mouse_mode === RED.state.PANNING) { + var pos = [event.pageX, event.pageY]; + // if (d3.event.touches) { + // var touch0 = d3.event.touches.item(0); + // pos = [touch0.pageX, touch0.pageY]; + // } + var deltaPos = [ + mouse_position[0]-pos[0], + mouse_position[1]-pos[1] + ]; + + chart.scrollLeft(scroll_position[0]+deltaPos[0]) + chart.scrollTop(scroll_position[1]+deltaPos[1]) + RED.events.emit("view:navigate"); + return + } + }) function canvasMouseMove() { var i; var node; @@ -1793,18 +2182,8 @@ RED.view = (function() { //console.log(d3.mouse(this),container.offsetWidth,container.offsetHeight,container.scrollLeft,container.scrollTop); if (mouse_mode === RED.state.PANNING) { - var pos = [d3.event.pageX,d3.event.pageY]; - if (d3.event.touches) { - var touch0 = d3.event.touches.item(0); - pos = [touch0.pageX, touch0.pageY]; - } - var deltaPos = [ - mouse_position[0]-pos[0], - mouse_position[1]-pos[1] - ]; - - chart.scrollLeft(scroll_position[0]+deltaPos[0]) - chart.scrollTop(scroll_position[1]+deltaPos[1]) + // A window-level handler is used for panning so the mouse can leave the confines of the chart + // but continue panning return } @@ -2148,6 +2527,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) { @@ -2479,39 +2864,454 @@ RED.view = (function() { } - function zoomIn() { - if (scaleFactor < 2) { - zoomView(scaleFactor+0.1); + 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); + } + + // 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) { + 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() { - if (scaleFactor > 0.3) { - zoomView(scaleFactor-0.1); + function zoomOut(focalPoint) { + var minZoom = calculateMinZoom(); + if (scaleFactor > minZoom) { + 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() { zoomView(1); } + function zoomZero() { + // Reset button zoom focal point for zoom reset + clearTimeout(buttonZoomTimeout); + buttonZoomWorkspaceCenter = null; + animatedZoomView(1); + } + + function zoomToFitAll() { + // Refresh active nodes to ensure we have the latest + updateActiveNodes(); + + // 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 in workspace coordinates + var centerX = (minX + maxX) / 2; + var centerY = (minY + maxY) / 2; + + // 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]; + + // If zoom level won't change significantly, animate just the pan + if (Math.abs(scaleFactor - targetZoom) < 0.01) { + 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")); } function searchPrev() { RED.actions.invoke("core:search-previous"); } function searchNext() { RED.actions.invoke("core:search-next"); } - function zoomView(factor) { + 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; + } + // 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 center = [(scrollPos[0] + screenSize[0]/2)/scaleFactor,(scrollPos[1] + screenSize[1]/2)/scaleFactor]; + 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])/oldScaleFactor, (scrollPos[1] + focalPoint[1])/oldScaleFactor]; + } else { + // Default to viewport center in workspace coordinates + center = [(scrollPos[0] + screenSize[0]/2)/oldScaleFactor, (scrollPos[1] + screenSize[1]/2)/oldScaleFactor]; + } + scaleFactor = factor; - var 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]); + + // 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 + chart.scrollLeft(center[0] * scaleFactor - focalPoint[0]); + chart.scrollTop(center[1] * scaleFactor - focalPoint[1]); + } else { + // Keep viewport center on the same workspace coordinates + 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(); redraw(); + RED.events.emit("view:navigate"); if (RED.settings.get("editor.view.view-store-zoom")) { RED.settings.setLocal('zoom-level', factor.toFixed(1)) } } + function animatedZoomView(targetFactor, focalPoint, workspaceCenter) { + // Cancel any in-progress animation + if (cancelInProgressAnimation) { + cancelInProgressAnimation(); + cancelInProgressAnimation = null; + } + + // Calculate the actual minimum zoom to fit canvas + var minZoom = calculateMinZoom(); + + // Clamp target factor to valid range + targetFactor = Math.max(minZoom, + 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; + } + // Make scale 1 'sticky' + if (Math.abs(1.0 - targetFactor) < 0.02) { + targetFactor = 1 + } + + + 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 (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 { + // Default to viewport center + 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(200, Math.min(350, logChange / 0.693 * RED.view.zoomConstants.DEFAULT_ZOOM_DURATION * 2)); + + // Start the animation + cancelInProgressAnimation = RED.view.zoomAnimator.easeToValuesRAF({ + fromValues: { + zoom: startFactor + }, + toValues: { + zoom: targetFactor + }, + duration: 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]); + + // 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(); + }, + 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 + 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)); + } + }, + onCancel: function() { + cancelInProgressAnimation = null; + // Ensure scaleFactor is set to current target on cancel + scaleFactor = targetFactor; + } + }); + } + + // 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; @@ -3216,6 +4016,9 @@ RED.view = (function() { function portMouseDown(d,portType,portIndex, evt) { if (RED.view.DEBUG) { console.warn("portMouseDown", mouse_mode,d,portType,portIndex); } + if (spacebarPressed) { + return + } clearSuggestedFlow(); RED.contextMenu.hide(); evt = evt || d3.event; @@ -3645,6 +4448,9 @@ RED.view = (function() { (d3.event || event).stopPropagation(); return; } + if (spacebarPressed) { + return + } clearTimeout(portLabelHoverTimeout); var active = (mouse_mode!=RED.state.JOINING && mouse_mode != RED.state.QUICK_JOINING) || // Not currently joining - all ports active ( @@ -3801,6 +4607,9 @@ RED.view = (function() { } function nodeMouseDown(d) { if (RED.view.DEBUG) { console.warn("nodeMouseDown", mouse_mode,d); } + if (spacebarPressed) { + return + } clearSuggestedFlow() focusView(); RED.contextMenu.hide(); @@ -3979,6 +4788,9 @@ RED.view = (function() { function nodeMouseOver(d) { if (RED.view.DEBUG) { console.warn("nodeMouseOver", mouse_mode,d); } + if (spacebarPressed) { + return + } if (mouse_mode === 0 || mouse_mode === RED.state.SELECTING_NODE) { if (mouse_mode === RED.state.SELECTING_NODE && selectNodesOptions && selectNodesOptions.filter) { if (selectNodesOptions.filter(d)) { @@ -4152,6 +4964,9 @@ RED.view = (function() { if (RED.view.DEBUG) { console.warn("groupMouseDown", { mouse_mode, point: mouse, event: d3.event }); } + if (spacebarPressed) { + return + } RED.contextMenu.hide(); focusView(); if (d3.event.button === 1) { @@ -4611,6 +5426,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 (forceFullRedraw || showAllLinkPorts !== -1 || mouse_mode != RED.state.JOINING) { forceFullRedraw = false 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..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 @@ -17,8 +17,9 @@ **/ -body { +html, body { overflow: hidden; + touch-action: manipulation; } .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: manipulation; } #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..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 @@ -37,6 +37,25 @@ right:0px; box-sizing:border-box; transition: right 0.2s ease; + touch-action: none; + padding: 0; + margin: 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 { + display: block; + margin: 0; + padding: 0; + } + &:focus { outline: none; } @@ -150,6 +169,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;