Skip to content

Commit 2bbfb63

Browse files
committed
feat: add port label visibility control based on zoom level and settings
1 parent 0f51790 commit 2bbfb63

9 files changed

Lines changed: 148 additions & 12 deletions

File tree

src/components/canvas/CanvasBase.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import * as React from "react";
55
import { useNodeCanvas } from "../../contexts/composed/canvas/viewport/context";
66
import { useEditorActionState } from "../../contexts/composed/EditorActionStateContext";
7+
import { useNodeEditor } from "../../contexts/composed/node-editor/context";
78
import { applyZoomDelta } from "../../utils/zoomUtils";
89
import { SelectionBox } from "./SelectionBox";
910
import styles from "./CanvasBase.module.css";
@@ -40,6 +41,7 @@ const hasNodePayload = (event: React.DragEvent): boolean => {
4041
export const CanvasBase: React.FC<CanvasBaseProps> = ({ children, className, showGrid, onNodeDrop }) => {
4142
const { state: canvasState, actions: canvasActions, canvasRef, utils, setContainerElement } = useNodeCanvas();
4243
const { actions: actionActions } = useEditorActionState();
44+
const { settings } = useNodeEditor();
4345
const containerRef = React.useRef<HTMLDivElement>(null);
4446
const rawGridPatternId = React.useId();
4547
const gridPatternId = React.useMemo(() => rawGridPatternId.replace(/[^a-zA-Z0-9_-]/g, "_"), [rawGridPatternId]);
@@ -52,7 +54,9 @@ export const CanvasBase: React.FC<CanvasBaseProps> = ({ children, className, sho
5254
return () => setContainerElement(null);
5355
}, [setContainerElement]);
5456

55-
const shouldShowGrid = showGrid ?? canvasState.gridSettings.showGrid;
57+
const currentScale = canvasState.viewport.scale;
58+
const shouldShowGrid =
59+
(showGrid ?? canvasState.gridSettings.showGrid) && currentScale >= settings.gridVisibilityThreshold;
5660

5761
// Canvas transform based on viewport - optimized string creation
5862
const canvasTransform = React.useMemo(() => {

src/components/node/NodeViewContainer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type NodeViewContainerProps = {
3838
nodeRenderer?: (props: NodeRendererProps) => React.ReactNode;
3939
externalData?: unknown;
4040
onUpdateNode?: (updates: Partial<Node>) => void;
41+
/** Port label visibility - calculated at NodeLayer level for performance */
42+
showPortLabels?: boolean;
4143
};
4244

4345
const NodeViewContainerComponent: React.FC<NodeViewContainerProps> = ({
@@ -59,6 +61,7 @@ const NodeViewContainerComponent: React.FC<NodeViewContainerProps> = ({
5961
connectedPortIds,
6062
connectablePorts,
6163
candidatePortId,
64+
showPortLabels,
6265
}) => {
6366
const { actions: nodeEditorActions, getNodePorts, getNodeById } = useNodeEditorApi();
6467
// Use split hooks for better performance
@@ -224,6 +227,7 @@ const NodeViewContainerComponent: React.FC<NodeViewContainerProps> = ({
224227
connectedPortIds={connectedPortIds}
225228
connectablePorts={connectablePorts}
226229
candidatePortId={candidatePortId}
230+
showPortLabels={showPortLabels}
227231
/>
228232
);
229233
};

src/components/node/NodeViewPresenter.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export type NodeViewPresenterProps = {
6666
connectedPortIds?: ReadonlySet<string>;
6767
connectablePorts?: ConnectablePortsResult;
6868
candidatePortId?: string;
69+
/** Control visibility of port labels. When undefined, derived from zoom level. */
70+
showPortLabels?: boolean;
6971
};
7072

7173
const DEBUG_NODEVIEW_PRESENTER_RERENDERS = false;
@@ -109,6 +111,7 @@ const NodeViewPresenterComponent: React.FC<NodeViewPresenterProps> = ({
109111
connectedPortIds,
110112
connectablePorts,
111113
candidatePortId,
114+
showPortLabels,
112115
}) => {
113116
const nodeRef = React.useRef<HTMLDivElement>(null);
114117
const lastTransformRef = React.useRef<Position | null>(null);
@@ -267,6 +270,7 @@ const NodeViewPresenterComponent: React.FC<NodeViewPresenterProps> = ({
267270
connectablePorts={connectablePorts}
268271
connectingPortId={connectingPortId}
269272
candidatePortId={candidatePortId}
273+
showLabels={showPortLabels}
270274
/>
271275

272276
{isSelected && !node.locked && (
@@ -390,6 +394,11 @@ const arePresenterPropsEqual = (
390394
return false;
391395
}
392396

397+
if (prevProps.showPortLabels !== nextProps.showPortLabels) {
398+
debugLog("showPortLabels changed");
399+
return false;
400+
}
401+
393402
if (DEBUG_NODEVIEW_PRESENTER_RERENDERS) {
394403
console.log(`[NodeViewPresenter:${nodeId}] Skipped re-render`);
395404
}

src/components/node/layer/NodeLayer.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
useDragNodeIdsSets,
1414
} from "../../../contexts/composed/canvas/interaction/context";
1515
import {
16+
useNodeEditor,
1617
useNodeEditorConnectedPortIdsByNode,
1718
useNodeEditorConnectedPorts,
1819
useNodeEditorSelector,
1920
useNodeEditorSortedNodeIds,
2021
} from "../../../contexts/composed/node-editor/context";
22+
import { useNodeCanvasViewportScale } from "../../../contexts/composed/canvas/viewport/context";
2123
import { useGroupManagement } from "../../../contexts/composed/node-editor/hooks/useGroupManagement";
2224
import { useNodeResize } from "../../../contexts/composed/canvas/interaction/hooks/useNodeResize";
2325
import { useVisibleNodes } from "../../../contexts/composed/canvas/viewport/hooks/useVisibleNodes";
@@ -58,6 +60,7 @@ type NodeItemProps = {
5860
candidatePortIdForNode?: string;
5961
connectedPortIdsByNode: ReadonlyMap<string, ReadonlySet<string>>;
6062
NodeComponent: React.ComponentType<NodeViewProps>;
63+
showPortLabels: boolean;
6164
};
6265

6366
const NodeItemComponent: React.FC<NodeItemProps> = ({
@@ -79,6 +82,7 @@ const NodeItemComponent: React.FC<NodeItemProps> = ({
7982
candidatePortIdForNode,
8083
connectedPortIdsByNode,
8184
NodeComponent,
85+
showPortLabels,
8286
}) => {
8387
const node = useNodeEditorSelector((state) => state.nodes[nodeId], { areEqual: (a, b) => a === b });
8488
if (!node) {
@@ -104,6 +108,7 @@ const NodeItemComponent: React.FC<NodeItemProps> = ({
104108
hoveredPort={hoveredPortForNode}
105109
connectedPortIds={connectedPortIdsByNode.get(node.id)}
106110
candidatePortId={candidatePortIdForNode}
111+
showPortLabels={showPortLabels}
107112
/>
108113
);
109114
};
@@ -125,6 +130,11 @@ const NodeLayerComponent: React.FC<NodeLayerProps> = ({ doubleClickToEdit }) =>
125130
const connectionDragMeta = useCanvasInteractionConnectionDragMeta();
126131
const gridSettings = useNodeCanvasGridSettings();
127132
const { node: NodeComponent } = useRenderers();
133+
const { settings } = useNodeEditor();
134+
const scale = useNodeCanvasViewportScale();
135+
136+
// Calculate showPortLabels once for all nodes
137+
const showPortLabels = scale >= settings.portLabelVisibilityThreshold;
128138

129139
// Initialize hooks
130140
useNodeResize({
@@ -237,6 +247,7 @@ const NodeLayerComponent: React.FC<NodeLayerProps> = ({ doubleClickToEdit }) =>
237247
candidatePortIdForNode={candidatePortIdForNode}
238248
connectedPortIdsByNode={connectedPortIdsByNode}
239249
NodeComponent={NodeComponent}
250+
showPortLabels={showPortLabels}
240251
/>
241252
);
242253
})}

src/components/ports/NodePortsRenderer.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,38 @@ import { isPortConnectable } from "../../core/port/connectivity/connectableTypes
88
import { PortView } from "./PortView";
99
import { useOptionalRenderers } from "../../contexts/RendererContext";
1010
import { hasPortIdChanged } from "../../core/port/identity/comparators";
11+
import { NodeCanvasContext } from "../../contexts/composed/canvas/viewport/context";
12+
import { NodeEditorContext } from "../../contexts/composed/node-editor/context";
13+
import { useExternalStoreSelector } from "../../hooks/useExternalStoreSelector";
1114
import styles from "./NodePortsRenderer.module.css";
1215

16+
// Stable fallback functions for when context is not available
17+
const noopUnsubscribe = () => {};
18+
const noopSubscribe = () => noopUnsubscribe;
19+
const defaultGetState = () => ({ viewport: { scale: 1 } });
20+
const selectScale = (state: { viewport: { scale: number } }) => state.viewport.scale;
21+
22+
/**
23+
* Hook to determine if port labels should be visible based on zoom level.
24+
* Returns true (show labels) if context is not available, for backwards compatibility.
25+
*/
26+
const useShowPortLabels = (): boolean => {
27+
const canvasContext = React.useContext(NodeCanvasContext);
28+
const editorContext = React.useContext(NodeEditorContext);
29+
30+
const scale = useExternalStoreSelector(
31+
canvasContext?.store.subscribe ?? noopSubscribe,
32+
canvasContext?.store.getState ?? defaultGetState,
33+
selectScale,
34+
);
35+
36+
if (!canvasContext || !editorContext) {
37+
return true;
38+
}
39+
40+
return scale >= editorContext.settings.portLabelVisibilityThreshold;
41+
};
42+
1343
export type NodePortsRendererProps = {
1444
ports: Port[];
1545
onPortPointerDown?: (e: React.PointerEvent, port: Port) => void;
@@ -26,9 +56,10 @@ export type NodePortsRendererProps = {
2656
};
2757

2858
/**
29-
* Renders ports for a node
59+
* Pure component that renders ports for a node.
60+
* Does not subscribe to any context - relies on parent to provide showLabels.
3061
*/
31-
const NodePortsRendererComponent: React.FC<NodePortsRendererProps> = ({
62+
const NodePortsRendererPure: React.FC<NodePortsRendererProps & { showLabels: boolean }> = ({
3263
ports,
3364
onPortPointerDown,
3465
onPortPointerUp,
@@ -41,6 +72,7 @@ const NodePortsRendererComponent: React.FC<NodePortsRendererProps> = ({
4172
connectablePorts,
4273
connectingPortId,
4374
candidatePortId,
75+
showLabels,
4476
}) => {
4577
const renderers = useOptionalRenderers();
4678
const PortComponent = renderers?.port ?? PortView;
@@ -68,18 +100,39 @@ const NodePortsRendererComponent: React.FC<NodePortsRendererProps> = ({
68100
isCandidate={candidatePortId === port.id}
69101
isHovered={hoveredPort?.id === port.id}
70102
isConnected={connectedPortIds?.has(port.id)}
103+
showLabel={showLabels}
71104
/>
72105
);
73106
})}
74107
</div>
75108
);
76109
};
77110

111+
/**
112+
* Wrapper component that derives showLabels from context.
113+
* Use this when you need automatic zoom-based label visibility.
114+
*/
115+
const NodePortsRendererWithAutoLabels: React.FC<NodePortsRendererProps> = (props) => {
116+
const showLabels = useShowPortLabels();
117+
return <NodePortsRendererPure {...props} showLabels={showLabels} />;
118+
};
119+
78120
// Temporary debug flag - set to true to enable detailed re-render logging
79121
const DEBUG_NODEPORTSRENDERER_RERENDERS = false;
80122

123+
// Props type with optional showLabels for backwards compatibility
124+
export type NodePortsRendererPropsWithLabels = NodePortsRendererProps & { showLabels?: boolean };
125+
81126
// Memoized version with custom comparison
82-
export const NodePortsRenderer = React.memo(NodePortsRendererComponent, (prevProps, nextProps) => {
127+
export const NodePortsRenderer = React.memo(
128+
(props: NodePortsRendererPropsWithLabels) => {
129+
// Use showLabels from props if provided, otherwise derive from context
130+
if (props.showLabels !== undefined) {
131+
return <NodePortsRendererPure {...props} showLabels={props.showLabels} />;
132+
}
133+
return <NodePortsRendererWithAutoLabels {...props} />;
134+
},
135+
(prevProps: NodePortsRendererPropsWithLabels, nextProps: NodePortsRendererPropsWithLabels) => {
83136
// Get nodeId for debugging (from first port if available)
84137
const nodeId = prevProps.ports?.[0]?.nodeId || nextProps.ports?.[0]?.nodeId || "unknown";
85138
const debugLog = (reason: string, details?: Record<string, unknown>) => {
@@ -128,10 +181,15 @@ export const NodePortsRenderer = React.memo(NodePortsRendererComponent, (prevPro
128181
});
129182
return false;
130183
}
184+
if (prevProps.showLabels !== nextProps.showLabels) {
185+
debugLog("showLabels changed", { prev: prevProps.showLabels, next: nextProps.showLabels });
186+
return false;
187+
}
131188

132189
// Event handlers are assumed to be stable (useCallback)
133190
if (DEBUG_NODEPORTSRENDERER_RERENDERS) {
134191
console.log(`[NodePortsRenderer:${nodeId}] Skipped re-render (props are equal)`);
135192
}
136193
return true;
137-
});
194+
},
195+
);

src/components/ports/PortView.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export type PortViewProps = {
4545
* Visual: inner circle changes to success color (green)
4646
*/
4747
isConnected?: boolean;
48+
/**
49+
* Control visibility of port label.
50+
* When false, label is hidden (e.g., when zoomed out).
51+
* Defaults to true.
52+
*/
53+
showLabel?: boolean;
4854
};
4955

5056
/**
@@ -64,6 +70,7 @@ export const PortView: React.FC<PortViewProps> = ({
6470
isCandidate = false,
6571
isHovered = false,
6672
isConnected = false,
73+
showLabel = true,
6774
}) => {
6875
const resizeOverride = useCanvasInteractionSelector(
6976
(state) => {
@@ -180,14 +187,14 @@ export const PortView: React.FC<PortViewProps> = ({
180187
title={port.label}
181188
>
182189
<div className={styles.portInner} />
183-
{port.label && (
190+
{port.label && showLabel && (
184191
<span className={styles.portLabel} data-port-label-position={port.position}>
185192
{port.label}
186193
</span>
187194
)}
188195
</div>
189196
),
190-
[port, isConnecting, isConnectable, isCandidate, isHovered, isConnected, portPositionStyle],
197+
[port, isConnecting, isConnectable, isCandidate, isHovered, isConnected, portPositionStyle, showLabel],
191198
);
192199

193200
// Check if there's a custom renderer
@@ -204,6 +211,7 @@ export const PortView: React.FC<PortViewProps> = ({
204211
isCandidate,
205212
isHovered,
206213
isConnected,
214+
showLabel,
207215
position: portPosition
208216
? {
209217
x: portPosition.renderPosition.x,

src/hooks/useSettings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ type Settings = {
2020
fontSize: number;
2121
gridSize: number;
2222
gridOpacity: number;
23+
gridVisibilityThreshold: number;
24+
portLabelVisibilityThreshold: number;
2325
canvasBackground: string;
2426
nodeSearchViewMode: NodeSearchViewMode;
2527
nodeSearchFilterMode: NodeSearchFilterMode;
@@ -41,6 +43,8 @@ const defaultSettings: Settings = {
4143
fontSize: 14,
4244
gridSize: 20,
4345
gridOpacity: 0.3,
46+
gridVisibilityThreshold: 0.3,
47+
portLabelVisibilityThreshold: 0.5,
4448
canvasBackground: "#ffffff",
4549
nodeSearchViewMode: "list",
4650
nodeSearchFilterMode: "filter",
@@ -161,6 +165,16 @@ export function useSettings(settingsManager?: SettingsManager): Settings {
161165
fontSize: getNumberSetting(settingsManager, "appearance.fontSize", defaultSettings.fontSize),
162166
gridSize: getNumberSetting(settingsManager, "appearance.gridSize", defaultSettings.gridSize),
163167
gridOpacity: getNumberSetting(settingsManager, "appearance.gridOpacity", defaultSettings.gridOpacity),
168+
gridVisibilityThreshold: getNumberSetting(
169+
settingsManager,
170+
"appearance.gridVisibilityThreshold",
171+
defaultSettings.gridVisibilityThreshold,
172+
),
173+
portLabelVisibilityThreshold: getNumberSetting(
174+
settingsManager,
175+
"appearance.portLabelVisibilityThreshold",
176+
defaultSettings.portLabelVisibilityThreshold,
177+
),
164178
canvasBackground: getStringSetting(
165179
settingsManager,
166180
"appearance.canvasBackground",

0 commit comments

Comments
 (0)