Merge pull request #5312 from node-red/pan-zoom

Workspace pan/zoom updates
pull/5371/head
Nick O'Leary 2025-12-04 13:40:20 +00:00 committed by GitHub
commit ebc079730d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1383 additions and 184 deletions

View File

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

View File

@ -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",

View File

@ -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",

View File

@ -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 = $('<div>').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: $('<button class="red-ui-footer-button-toggle single" id="red-ui-view-navigate"><i class="fa fa-map-o"></i></button>')
})
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 = $('<div>').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: $('<button class="red-ui-footer-button-toggle single" id="red-ui-view-navigate"><i class="fa fa-map-o"></i></button>')
})
$("#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,

View File

@ -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
};
})();

View File

@ -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
};

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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;