mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Added three new experimental views
This commit is contained in:
373
dev-docs/reference/CANVAS-OVERLAY-ARCHITECTURE.md
Normal file
373
dev-docs/reference/CANVAS-OVERLAY-ARCHITECTURE.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# Canvas Overlay Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how canvas overlays integrate with the NodeGraphEditor and the editor's data flow.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. NodeGraphEditor Initialization
|
||||
|
||||
The overlay is created when the NodeGraphEditor is constructed:
|
||||
|
||||
```typescript
|
||||
// In nodegrapheditor.ts constructor
|
||||
export default class NodeGraphEditor {
|
||||
commentLayer: CommentLayer;
|
||||
|
||||
constructor(domElement, options) {
|
||||
// ... canvas setup
|
||||
|
||||
// Create overlay
|
||||
this.commentLayer = new CommentLayer(this);
|
||||
this.commentLayer.setReadOnly(this.readOnly);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. DOM Structure
|
||||
|
||||
The overlay requires two divs in the DOM hierarchy:
|
||||
|
||||
```html
|
||||
<div id="nodegraph-editor">
|
||||
<canvas id="nodegraph-canvas"></canvas>
|
||||
<div id="nodegraph-background-layer"></div>
|
||||
<!-- Behind canvas -->
|
||||
<div id="nodegraph-dom-layer"></div>
|
||||
<!-- In front of canvas -->
|
||||
</div>
|
||||
```
|
||||
|
||||
CSS z-index layering:
|
||||
|
||||
- Background layer: `z-index: 0`
|
||||
- Canvas: `z-index: 1`
|
||||
- Foreground layer: `z-index: 2`
|
||||
|
||||
### 3. Render Target Setup
|
||||
|
||||
The overlay attaches to the DOM layers:
|
||||
|
||||
```typescript
|
||||
// In nodegrapheditor.ts
|
||||
const backgroundDiv = this.el.find('#nodegraph-background-layer').get(0);
|
||||
const foregroundDiv = this.el.find('#nodegraph-dom-layer').get(0);
|
||||
|
||||
this.commentLayer.renderTo(backgroundDiv, foregroundDiv);
|
||||
```
|
||||
|
||||
### 4. Viewport Synchronization
|
||||
|
||||
The overlay updates whenever the canvas pan/zoom changes:
|
||||
|
||||
```typescript
|
||||
// In nodegrapheditor.ts paint() method
|
||||
paint() {
|
||||
// ... canvas drawing
|
||||
|
||||
// Update overlay transform
|
||||
this.commentLayer.setPanAndScale({
|
||||
x: this.xOffset,
|
||||
y: this.yOffset,
|
||||
scale: this.scale
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### EventDispatcher Integration
|
||||
|
||||
Overlays typically subscribe to model changes using EventDispatcher:
|
||||
|
||||
```typescript
|
||||
class MyOverlay {
|
||||
setComponentModel(model: ComponentModel) {
|
||||
if (this.model) {
|
||||
this.model.off(this); // Clean up old subscriptions
|
||||
}
|
||||
|
||||
this.model = model;
|
||||
|
||||
// Subscribe to changes
|
||||
model.on('nodeAdded', this.onNodeAdded.bind(this), this);
|
||||
model.on('nodeRemoved', this.onNodeRemoved.bind(this), this);
|
||||
model.on('connectionChanged', this.onConnectionChanged.bind(this), this);
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
onNodeAdded(node) {
|
||||
// Update overlay state
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Typical Data Flow
|
||||
|
||||
```
|
||||
User Action
|
||||
↓
|
||||
Model Change (ProjectModel/ComponentModel)
|
||||
↓
|
||||
EventDispatcher fires event
|
||||
↓
|
||||
Overlay handler receives event
|
||||
↓
|
||||
Overlay updates React state
|
||||
↓
|
||||
React re-renders overlay
|
||||
```
|
||||
|
||||
## Lifecycle Management
|
||||
|
||||
### Creation
|
||||
|
||||
```typescript
|
||||
constructor(nodegraphEditor: NodeGraphEditor) {
|
||||
this.nodegraphEditor = nodegraphEditor;
|
||||
this.props = { /* initial state */ };
|
||||
}
|
||||
```
|
||||
|
||||
### Attachment
|
||||
|
||||
```typescript
|
||||
renderTo(backgroundDiv: HTMLDivElement, foregroundDiv: HTMLDivElement) {
|
||||
this.backgroundDiv = backgroundDiv;
|
||||
this.foregroundDiv = foregroundDiv;
|
||||
|
||||
// Create React roots
|
||||
this.backgroundRoot = createRoot(backgroundDiv);
|
||||
this.foregroundRoot = createRoot(foregroundDiv);
|
||||
|
||||
// Initial render
|
||||
this._renderReact();
|
||||
}
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```typescript
|
||||
setPanAndScale(viewport: Viewport) {
|
||||
// Update CSS transform
|
||||
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
|
||||
this.backgroundDiv.style.transform = transform;
|
||||
this.foregroundDiv.style.transform = transform;
|
||||
|
||||
// Notify React if scale changed (important for react-rnd)
|
||||
if (this.props.scale !== viewport.scale) {
|
||||
this.props.scale = viewport.scale;
|
||||
this._renderReact();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disposal
|
||||
|
||||
```typescript
|
||||
dispose() {
|
||||
// Unmount React
|
||||
if (this.backgroundRoot) {
|
||||
this.backgroundRoot.unmount();
|
||||
}
|
||||
if (this.foregroundRoot) {
|
||||
this.foregroundRoot.unmount();
|
||||
}
|
||||
|
||||
// Unsubscribe from models
|
||||
if (this.model) {
|
||||
this.model.off(this);
|
||||
}
|
||||
|
||||
// Clean up DOM event listeners
|
||||
// (CommentLayer uses a clever cloneNode trick to remove all listeners)
|
||||
}
|
||||
```
|
||||
|
||||
## Component Model Integration
|
||||
|
||||
### Accessing Graph Data
|
||||
|
||||
The overlay has access to the full component graph through NodeGraphEditor:
|
||||
|
||||
```typescript
|
||||
class MyOverlay {
|
||||
getNodesInView(): NodeGraphNode[] {
|
||||
const model = this.nodegraphEditor.nodeGraphModel;
|
||||
const nodes: NodeGraphNode[] = [];
|
||||
|
||||
model.forEachNode((node) => {
|
||||
nodes.push(node);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
getConnections(): Connection[] {
|
||||
const model = this.nodegraphEditor.nodeGraphModel;
|
||||
return model.getAllConnections();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Node Position Access
|
||||
|
||||
Node positions are available through the graph model:
|
||||
|
||||
```typescript
|
||||
getNodeScreenPosition(nodeId: string): Point | null {
|
||||
const model = this.nodegraphEditor.nodeGraphModel;
|
||||
const node = model.findNodeWithId(nodeId);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
// Node positions are in canvas space
|
||||
return {
|
||||
x: node.x,
|
||||
y: node.y
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Communication with NodeGraphEditor
|
||||
|
||||
### From Overlay to Canvas
|
||||
|
||||
The overlay can trigger canvas operations:
|
||||
|
||||
```typescript
|
||||
// Clear canvas selection
|
||||
this.nodegraphEditor.clearSelection();
|
||||
|
||||
// Select nodes on canvas
|
||||
this.nodegraphEditor.selectNode(node);
|
||||
|
||||
// Trigger repaint
|
||||
this.nodegraphEditor.repaint();
|
||||
|
||||
// Navigate to node
|
||||
this.nodegraphEditor.zoomToFitNodes([node]);
|
||||
```
|
||||
|
||||
### From Canvas to Overlay
|
||||
|
||||
The canvas notifies the overlay of changes:
|
||||
|
||||
```typescript
|
||||
// In nodegrapheditor.ts
|
||||
selectNode(node) {
|
||||
// ... canvas logic
|
||||
|
||||
// Notify overlay
|
||||
this.commentLayer.clearSelection();
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
1. **Clean up subscriptions** - Always unsubscribe from EventDispatcher on dispose
|
||||
2. **Use the context object pattern** - Pass `this` as context to EventDispatcher subscriptions
|
||||
3. **Batch updates** - Group multiple state changes before calling render
|
||||
4. **Check for existence** - Always check if DOM elements exist before using them
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
1. **Don't modify canvas directly** - Work through NodeGraphEditor API
|
||||
2. **Don't store duplicate data** - Reference the model as the source of truth
|
||||
3. **Don't subscribe without context** - Direct EventDispatcher subscriptions leak
|
||||
4. **Don't assume initialization order** - Check for null before accessing properties
|
||||
|
||||
## Example: Complete Overlay Setup
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
|
||||
import { ComponentModel } from '@noodl-models/componentmodel';
|
||||
|
||||
import { NodeGraphEditor } from './nodegrapheditor';
|
||||
|
||||
export default class DataLineageOverlay {
|
||||
private nodegraphEditor: NodeGraphEditor;
|
||||
private model: ComponentModel;
|
||||
private root: Root;
|
||||
private container: HTMLDivElement;
|
||||
private viewport: Viewport;
|
||||
|
||||
constructor(nodegraphEditor: NodeGraphEditor) {
|
||||
this.nodegraphEditor = nodegraphEditor;
|
||||
}
|
||||
|
||||
renderTo(container: HTMLDivElement) {
|
||||
this.container = container;
|
||||
this.root = createRoot(container);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setComponentModel(model: ComponentModel) {
|
||||
if (this.model) {
|
||||
this.model.off(this);
|
||||
}
|
||||
|
||||
this.model = model;
|
||||
|
||||
if (model) {
|
||||
model.on('connectionChanged', this.onDataChanged.bind(this), this);
|
||||
model.on('nodeRemoved', this.onDataChanged.bind(this), this);
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
setPanAndScale(viewport: Viewport) {
|
||||
this.viewport = viewport;
|
||||
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
|
||||
this.container.style.transform = transform;
|
||||
}
|
||||
|
||||
private onDataChanged() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.root) return;
|
||||
|
||||
const paths = this.calculateDataPaths();
|
||||
|
||||
this.root.render(
|
||||
<DataLineageView paths={paths} viewport={this.viewport} onPathClick={this.handlePathClick.bind(this)} />
|
||||
);
|
||||
}
|
||||
|
||||
private calculateDataPaths() {
|
||||
// Analyze graph connections to build data flow paths
|
||||
// ...
|
||||
}
|
||||
|
||||
private handlePathClick(path: DataPath) {
|
||||
// Select nodes involved in this path
|
||||
const nodeIds = path.nodes.map((n) => n.id);
|
||||
this.nodegraphEditor.selectNodes(nodeIds);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
}
|
||||
if (this.model) {
|
||||
this.model.off(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
|
||||
- [Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md)
|
||||
- [React Integration](./CANVAS-OVERLAY-REACT.md)
|
||||
328
dev-docs/reference/CANVAS-OVERLAY-COORDINATES.md
Normal file
328
dev-docs/reference/CANVAS-OVERLAY-COORDINATES.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Canvas Overlay Coordinate Transforms
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how coordinate transformation works between canvas space and screen space in overlay systems.
|
||||
|
||||
## Coordinate Systems
|
||||
|
||||
### Canvas Space (Graph Space)
|
||||
|
||||
- **Origin**: Arbitrary (user-defined)
|
||||
- **Units**: Graph units (nodes have x, y positions)
|
||||
- **Affected by**: Nothing - absolute positions in the graph
|
||||
- **Example**: Node at `{ x: 500, y: 300 }` in canvas space
|
||||
|
||||
### Screen Space (Pixel Space)
|
||||
|
||||
- **Origin**: Top-left of the canvas element
|
||||
- **Units**: CSS pixels
|
||||
- **Affected by**: Pan and zoom transformations
|
||||
- **Example**: Same node might be at `{ x: 800, y: 450 }` on screen when zoomed in
|
||||
|
||||
## The Transform Strategy
|
||||
|
||||
CommentLayer uses CSS transforms on the container to handle all coordinate transformation automatically:
|
||||
|
||||
```typescript
|
||||
setPanAndScale(viewport: { x: number; y: number; scale: number }) {
|
||||
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
|
||||
this.container.style.transform = transform;
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Is Brilliant
|
||||
|
||||
1. **No per-element calculations** - Set transform once on container
|
||||
2. **Browser-optimized** - Hardware accelerated CSS transforms
|
||||
3. **Simple** - Child elements automatically transform
|
||||
4. **Performant** - Avoids layout thrashing
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
User pans/zooms canvas
|
||||
↓
|
||||
NodeGraphEditor.paint() called
|
||||
↓
|
||||
overlay.setPanAndScale({ x, y, scale })
|
||||
↓
|
||||
CSS transform applied to container
|
||||
↓
|
||||
Browser automatically transforms all children
|
||||
```
|
||||
|
||||
## Transform Math (If You Need It)
|
||||
|
||||
Sometimes you need manual transformations (e.g., calculating if a point hits an element):
|
||||
|
||||
### Canvas to Screen
|
||||
|
||||
```typescript
|
||||
function canvasToScreen(
|
||||
canvasPoint: { x: number; y: number },
|
||||
viewport: { x: number; y: number; scale: number }
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (canvasPoint.x + viewport.x) * viewport.scale,
|
||||
y: (canvasPoint.y + viewport.y) * viewport.scale
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const nodePos = { x: 100, y: 200 }; // Canvas space
|
||||
const viewport = { x: 50, y: 30, scale: 1.5 };
|
||||
|
||||
const screenPos = canvasToScreen(nodePos, viewport);
|
||||
// Result: { x: 225, y: 345 }
|
||||
```
|
||||
|
||||
### Screen to Canvas
|
||||
|
||||
```typescript
|
||||
function screenToCanvas(
|
||||
screenPoint: { x: number; y: number },
|
||||
viewport: { x: number; y: number; scale: number }
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: screenPoint.x / viewport.scale - viewport.x,
|
||||
y: screenPoint.y / viewport.scale - viewport.y
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
const clickPos = { x: 225, y: 345 }; // Screen pixels
|
||||
const viewport = { x: 50, y: 30, scale: 1.5 };
|
||||
|
||||
const canvasPos = screenToCanvas(clickPos, viewport);
|
||||
// Result: { x: 100, y: 200 }
|
||||
```
|
||||
|
||||
## React Component Positioning
|
||||
|
||||
### Using Transform (Preferred)
|
||||
|
||||
React components positioned in canvas space:
|
||||
|
||||
```tsx
|
||||
function OverlayElement({ x, y, children }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x, // Canvas coordinates
|
||||
top: y
|
||||
// Parent container handles transform!
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The parent container's CSS transform automatically converts canvas coords to screen coords.
|
||||
|
||||
### Manual Calculation (Avoid)
|
||||
|
||||
Only if you must position outside the transformed container:
|
||||
|
||||
```tsx
|
||||
function OverlayElement({ x, y, viewport, children }: Props) {
|
||||
const screenPos = canvasToScreen({ x, y }, viewport);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenPos.x,
|
||||
top: screenPos.y
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Node Overlay Badge
|
||||
|
||||
Show a badge on a specific node:
|
||||
|
||||
```tsx
|
||||
function NodeBadge({ nodeId, nodegraphEditor }: Props) {
|
||||
const node = nodegraphEditor.nodeGraphModel.findNodeWithId(nodeId);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
// Use canvas coordinates directly
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: node.x + node.w, // Right edge of node
|
||||
top: node.y
|
||||
}}
|
||||
>
|
||||
<Badge>!</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Connection Path Highlight
|
||||
|
||||
Highlight a connection between two nodes:
|
||||
|
||||
```tsx
|
||||
function ConnectionHighlight({ fromNode, toNode }: Props) {
|
||||
// Calculate path in canvas space
|
||||
const path = `M ${fromNode.x} ${fromNode.y} L ${toNode.x} ${toNode.y}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
<path d={path} stroke="blue" strokeWidth={3} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Mouse Hit Testing
|
||||
|
||||
Determine if a click hits an overlay element:
|
||||
|
||||
```typescript
|
||||
function handleMouseDown(evt: MouseEvent) {
|
||||
// Get click position relative to canvas
|
||||
const canvasElement = this.nodegraphEditor.canvasElement;
|
||||
const rect = canvasElement.getBoundingClientRect();
|
||||
|
||||
const screenPos = {
|
||||
x: evt.clientX - rect.left,
|
||||
y: evt.clientY - rect.top
|
||||
};
|
||||
|
||||
// Convert to canvas space for hit testing
|
||||
const canvasPos = this.nodegraphEditor.relativeCoordsToNodeGraphCords(screenPos);
|
||||
|
||||
// Check if click hits any of our elements
|
||||
const hitElement = this.elements.find((el) => pointInsideRectangle(canvasPos, el.bounds));
|
||||
}
|
||||
```
|
||||
|
||||
## Scale Considerations
|
||||
|
||||
### Scale-Dependent Sizes
|
||||
|
||||
Some overlay elements should scale with the canvas:
|
||||
|
||||
```tsx
|
||||
// Node comment - scales with canvas
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: 200, // Canvas units - scales automatically
|
||||
fontSize: 14 // Canvas units - scales automatically
|
||||
}}
|
||||
>
|
||||
{comment}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Scale-Independent Sizes
|
||||
|
||||
Some elements should stay the same pixel size regardless of zoom:
|
||||
|
||||
```tsx
|
||||
// Control button - stays same size
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: 20 / viewport.scale, // Inverse scale
|
||||
height: 20 / viewport.scale,
|
||||
fontSize: 12 / viewport.scale
|
||||
}}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
1. **Use container transform** - Let CSS do the work
|
||||
2. **Store positions in canvas space** - Easier to reason about
|
||||
3. **Calculate once** - Transform in render, not on every frame
|
||||
4. **Cache viewport** - Store current viewport for calculations
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
1. **Don't recalculate on every mouse move** - Only when needed
|
||||
2. **Don't mix coordinate systems** - Be consistent
|
||||
3. **Don't forget about scale** - Always consider zoom level
|
||||
4. **Don't transform twice** - Either container OR manual, not both
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Visualize Coordinate Systems
|
||||
|
||||
```tsx
|
||||
function CoordinateDebugger({ viewport }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Canvas origin */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: 'red'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid lines every 100 canvas units */}
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<line key={i} x1={i * 100} y1={0} x2={i * 100} y2={2000} stroke="rgba(255,0,0,0.1)" />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Log Transforms
|
||||
|
||||
```typescript
|
||||
console.log('Canvas pos:', { x: node.x, y: node.y });
|
||||
console.log('Viewport:', viewport);
|
||||
console.log('Screen pos:', canvasToScreen({ x: node.x, y: node.y }, viewport));
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
|
||||
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
|
||||
- [Mouse Events](./CANVAS-OVERLAY-EVENTS.md)
|
||||
314
dev-docs/reference/CANVAS-OVERLAY-EVENTS.md
Normal file
314
dev-docs/reference/CANVAS-OVERLAY-EVENTS.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Canvas Overlay Mouse Event Handling
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how mouse events are handled when overlays sit in front of the canvas. This is complex because events hit the overlay first but sometimes need to be routed to the canvas.
|
||||
|
||||
## The Challenge
|
||||
|
||||
```
|
||||
DOM Layering:
|
||||
┌─────────────────────┐ ← Mouse events hit here first
|
||||
│ Foreground Overlay │ (z-index: 2)
|
||||
├─────────────────────┤
|
||||
│ Canvas │ (z-index: 1)
|
||||
├─────────────────────┤
|
||||
│ Background Overlay │ (z-index: 0)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
When the user clicks:
|
||||
|
||||
1. Does it hit overlay UI (button, resize handle)?
|
||||
2. Does it hit a node visible through the overlay?
|
||||
3. Does it hit empty space?
|
||||
|
||||
The overlay must intelligently decide whether to handle or forward the event.
|
||||
|
||||
## CommentLayer's Solution
|
||||
|
||||
### Step 1: Capture All Mouse Events
|
||||
|
||||
Attach listeners to the foreground overlay div:
|
||||
|
||||
```typescript
|
||||
setupMouseEventHandling(foregroundDiv: HTMLDivElement) {
|
||||
const events = {
|
||||
mousedown: 'down',
|
||||
mouseup: 'up',
|
||||
mousemove: 'move',
|
||||
click: 'click'
|
||||
};
|
||||
|
||||
for (const eventName in events) {
|
||||
foregroundDiv.addEventListener(eventName, (evt) => {
|
||||
this.handleMouseEvent(evt, events[eventName]);
|
||||
}, true); // Capture phase!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Check for Overlay UI
|
||||
|
||||
```typescript
|
||||
handleMouseEvent(evt: MouseEvent, type: string) {
|
||||
// Is this an overlay control?
|
||||
if (evt.target && evt.target.closest('.comment-controls')) {
|
||||
// Let it through - user is interacting with overlay UI
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if canvas should handle it...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Forward to Canvas if Needed
|
||||
|
||||
```typescript
|
||||
// Convert mouse position to canvas coordinates
|
||||
const tl = this.nodegraphEditor.topLeftCanvasPos;
|
||||
const pos = {
|
||||
x: evt.pageX - tl[0],
|
||||
y: evt.pageY - tl[1]
|
||||
};
|
||||
|
||||
// Ask canvas if it wants this event
|
||||
const consumed = this.nodegraphEditor.mouse(type, pos, evt, {
|
||||
eventPropagatedFromCommentLayer: true
|
||||
});
|
||||
|
||||
if (consumed) {
|
||||
// Canvas handled it (e.g., hit a node)
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
```
|
||||
|
||||
## Event Flow Diagram
|
||||
|
||||
```
|
||||
Mouse Click
|
||||
↓
|
||||
Foreground Overlay receives event
|
||||
↓
|
||||
Is target .comment-controls?
|
||||
├─ Yes → Let event propagate normally (overlay handles)
|
||||
└─ No → Continue checking
|
||||
↓
|
||||
Forward to NodeGraphEditor.mouse()
|
||||
↓
|
||||
Did canvas consume event?
|
||||
├─ Yes → Stop propagation (canvas handled)
|
||||
└─ No → Let event propagate (overlay handles)
|
||||
```
|
||||
|
||||
## Preventing Infinite Loops
|
||||
|
||||
The `eventPropagatedFromCommentLayer` flag prevents recursion:
|
||||
|
||||
```typescript
|
||||
// In NodeGraphEditor
|
||||
mouse(type, pos, evt, args) {
|
||||
// Don't start another check if this came from overlay
|
||||
if (args && args.eventPropagatedFromCommentLayer) {
|
||||
// Just check if we hit something
|
||||
const hitNode = this.findNodeAtPosition(pos);
|
||||
return !!hitNode;
|
||||
}
|
||||
|
||||
// Normal mouse handling...
|
||||
}
|
||||
```
|
||||
|
||||
## Pointer Events CSS
|
||||
|
||||
Use `pointer-events` to control which elements receive events:
|
||||
|
||||
```css
|
||||
/* Overlay container - pass through clicks */
|
||||
.overlay-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* But controls receive clicks */
|
||||
.overlay-controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
```
|
||||
|
||||
## Mouse Wheel Handling
|
||||
|
||||
Wheel events have special handling:
|
||||
|
||||
```typescript
|
||||
foregroundDiv.addEventListener('wheel', (evt) => {
|
||||
// Allow scroll in textarea
|
||||
if (evt.target.tagName === 'TEXTAREA' && !evt.ctrlKey && !evt.metaKey) {
|
||||
return; // Let it scroll
|
||||
}
|
||||
|
||||
// Otherwise zoom the canvas
|
||||
const tl = this.nodegraphEditor.topLeftCanvasPos;
|
||||
this.nodegraphEditor.handleMouseWheelEvent(evt, {
|
||||
offsetX: evt.pageX - tl[0],
|
||||
offsetY: evt.pageY - tl[1]
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Click vs Down/Up
|
||||
|
||||
NodeGraphEditor doesn't use `click` events, only `down`/`up`. Handle this:
|
||||
|
||||
```typescript
|
||||
let ignoreNextClick = false;
|
||||
|
||||
if (type === 'down' || type === 'up') {
|
||||
if (consumed) {
|
||||
// Canvas consumed the up/down, so ignore the click that follows
|
||||
ignoreNextClick = true;
|
||||
setTimeout(() => { ignoreNextClick = false; }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'click' && ignoreNextClick) {
|
||||
ignoreNextClick = false;
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Select Drag Initiation
|
||||
|
||||
Start dragging selected nodes/comments from overlay:
|
||||
|
||||
```typescript
|
||||
if (type === 'down') {
|
||||
const hasSelection = this.props.selectedIds.length > 1 || this.nodegraphEditor.selector.active;
|
||||
|
||||
if (hasSelection) {
|
||||
const canvasPos = this.nodegraphEditor.relativeCoordsToNodeGraphCords(pos);
|
||||
|
||||
// Check if starting drag on a selected item
|
||||
const clickedItem = this.findItemAtPosition(canvasPos);
|
||||
if (clickedItem && this.isSelected(clickedItem)) {
|
||||
this.nodegraphEditor.startDraggingNodes(this.nodegraphEditor.selector.nodes);
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Overlay Button
|
||||
|
||||
```tsx
|
||||
<button className="overlay-button" onClick={() => this.handleButtonClick()} style={{ pointerEvents: 'auto' }}>
|
||||
Delete
|
||||
</button>
|
||||
```
|
||||
|
||||
The `className` check catches this button, event doesn't forward to canvas.
|
||||
|
||||
### Pattern 2: Draggable Overlay Element
|
||||
|
||||
```tsx
|
||||
// Using react-rnd
|
||||
<Rnd
|
||||
position={{ x: comment.x, y: comment.y }}
|
||||
onDragStart={() => {
|
||||
// Disable canvas mouse events during drag
|
||||
this.nodegraphEditor.setMouseEventsEnabled(false);
|
||||
}}
|
||||
onDragStop={() => {
|
||||
// Re-enable canvas mouse events
|
||||
this.nodegraphEditor.setMouseEventsEnabled(true);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Rnd>
|
||||
```
|
||||
|
||||
### Pattern 3: Clickthrough SVG Overlay
|
||||
|
||||
```tsx
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none', // Pass all events through
|
||||
...
|
||||
}}
|
||||
>
|
||||
<path d={highlightPath} stroke="blue" />
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Keyboard Events
|
||||
|
||||
Forward keyboard events unless typing in an input:
|
||||
|
||||
```typescript
|
||||
foregroundDiv.addEventListener('keydown', (evt) => {
|
||||
if (evt.target.tagName === 'TEXTAREA' || evt.target.tagName === 'INPUT') {
|
||||
// Let the input handle it
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward to KeyboardHandler
|
||||
KeyboardHandler.instance.executeCommandMatchingKeyEvent(evt, 'down');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
1. **Use capture phase** - `addEventListener(event, handler, true)`
|
||||
2. **Check target element** - `evt.target.closest('.my-controls')`
|
||||
3. **Prevent after handling** - Call `stopPropagation()` and `preventDefault()`
|
||||
4. **Handle wheel specially** - Allow textarea scroll, forward canvas zoom
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
1. **Don't forward everything** - Check if overlay should handle first
|
||||
2. **Don't forget click events** - Handle the click/down/up difference
|
||||
3. **Don't block all events** - Use `pointer-events: none` strategically
|
||||
4. **Don't recurse** - Use flags to prevent infinite forwarding
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Log Event Flow
|
||||
|
||||
```typescript
|
||||
handleMouseEvent(evt, type) {
|
||||
console.log('Event:', type, 'Target:', evt.target.className);
|
||||
|
||||
const consumed = this.nodegraphEditor.mouse(type, pos, evt, args);
|
||||
|
||||
console.log('Canvas consumed:', consumed);
|
||||
}
|
||||
```
|
||||
|
||||
### Visualize Hit Areas
|
||||
|
||||
```css
|
||||
/* Temporarily add borders to debug */
|
||||
.comment-controls {
|
||||
border: 2px solid red !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Check Pointer Events
|
||||
|
||||
```typescript
|
||||
console.log('Pointer events:', window.getComputedStyle(element).pointerEvents);
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
|
||||
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
|
||||
- [Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)
|
||||
179
dev-docs/reference/CANVAS-OVERLAY-PATTERN.md
Normal file
179
dev-docs/reference/CANVAS-OVERLAY-PATTERN.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Canvas Overlay Pattern
|
||||
|
||||
## Overview
|
||||
|
||||
**Status:** ✅ Proven Pattern (CommentLayer is production-ready)
|
||||
**Location:** `packages/noodl-editor/src/editor/src/views/commentlayer.ts`
|
||||
**Created:** Phase 4 PREREQ-003
|
||||
|
||||
This document describes the pattern for creating React overlays that float above the HTML5 Canvas in the Node Graph Editor. The pattern is proven and production-tested via CommentLayer.
|
||||
|
||||
## What This Pattern Enables
|
||||
|
||||
React components that:
|
||||
|
||||
- Float over the HTML5 Canvas
|
||||
- Stay synchronized with canvas pan/zoom
|
||||
- Handle mouse events intelligently (overlay vs canvas)
|
||||
- Integrate with the existing EventDispatcher system
|
||||
- Use modern React 19 APIs
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Phase 4 visualization views need this pattern:
|
||||
|
||||
- **VIEW-005: Data Lineage** - Glowing path highlights
|
||||
- **VIEW-006: Impact Radar** - Dependency visualization
|
||||
- **VIEW-007: Semantic Layers** - Node visibility filtering
|
||||
|
||||
All of these require React UI floating over the canvas with proper coordinate transformation and event handling.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
This pattern is documented across several focused files:
|
||||
|
||||
1. **[Architecture Overview](./CANVAS-OVERLAY-ARCHITECTURE.md)** - How overlays integrate with NodeGraphEditor
|
||||
2. **[Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)** - Canvas space ↔ Screen space conversion
|
||||
3. **[Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md)** - Intelligent event routing
|
||||
4. **[React Integration](./CANVAS-OVERLAY-REACT.md)** - React 19 patterns and lifecycle
|
||||
5. **[Code Examples](./CANVAS-OVERLAY-EXAMPLES.md)** - Practical implementation examples
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimal Overlay Example
|
||||
|
||||
```typescript
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
|
||||
import { NodeGraphEditor } from './nodegrapheditor';
|
||||
|
||||
class SimpleOverlay {
|
||||
private root: Root;
|
||||
private container: HTMLDivElement;
|
||||
|
||||
constructor(private nodegraphEditor: NodeGraphEditor) {}
|
||||
|
||||
renderTo(container: HTMLDivElement) {
|
||||
this.container = container;
|
||||
this.root = createRoot(container);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setPanAndScale(panAndScale: { x: number; y: number; scale: number }) {
|
||||
const transform = `scale(${panAndScale.scale}) translate(${panAndScale.x}px, ${panAndScale.y}px)`;
|
||||
this.container.style.transform = transform;
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.root.render(<div>My Overlay Content</div>);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.root) {
|
||||
this.root.unmount();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with NodeGraphEditor
|
||||
|
||||
```typescript
|
||||
// In nodegrapheditor.ts
|
||||
this.myOverlay = new SimpleOverlay(this);
|
||||
this.myOverlay.renderTo(overlayDiv);
|
||||
|
||||
// Update on pan/zoom
|
||||
this.myOverlay.setPanAndScale(this.getPanAndScale());
|
||||
```
|
||||
|
||||
## Key Insights from CommentLayer
|
||||
|
||||
### 1. CSS Transform Strategy (Brilliant!)
|
||||
|
||||
The entire overlay stays in sync via a single CSS transform on the container:
|
||||
|
||||
```typescript
|
||||
const transform = `scale(${scale}) translate(${x}px, ${y}px)`;
|
||||
container.style.transform = transform;
|
||||
```
|
||||
|
||||
No complex calculations per element - the browser handles it all!
|
||||
|
||||
### 2. React Root Reuse
|
||||
|
||||
Create roots once, reuse for all re-renders:
|
||||
|
||||
```typescript
|
||||
if (!this.root) {
|
||||
this.root = createRoot(this.container);
|
||||
}
|
||||
this.root.render(<MyComponent {...props} />);
|
||||
```
|
||||
|
||||
### 3. Two-Layer System
|
||||
|
||||
CommentLayer uses two layers:
|
||||
|
||||
- **Background layer** - Behind canvas (e.g., colored comment boxes)
|
||||
- **Foreground layer** - In front of canvas (e.g., comment controls, resize handles)
|
||||
|
||||
This allows visual layering: comments behind nodes, but controls in front.
|
||||
|
||||
### 4. Mouse Event Forwarding
|
||||
|
||||
Complex but powerful: overlay determines if clicks should go to canvas or stay in overlay. See [Mouse Event Handling](./CANVAS-OVERLAY-EVENTS.md) for details.
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
### ❌ Don't: Create new roots on every render
|
||||
|
||||
```typescript
|
||||
// BAD - memory leak!
|
||||
render() {
|
||||
this.root = createRoot(this.container);
|
||||
this.root.render(<Component />);
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Do: Create once, reuse
|
||||
|
||||
```typescript
|
||||
// GOOD
|
||||
constructor() {
|
||||
this.root = createRoot(this.container);
|
||||
}
|
||||
render() {
|
||||
this.root.render(<Component />);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't: Manually calculate positions for every element
|
||||
|
||||
```typescript
|
||||
// BAD - complex and slow
|
||||
elements.forEach((el) => {
|
||||
el.style.left = (el.x + pan.x) * scale + 'px';
|
||||
el.style.top = (el.y + pan.y) * scale + 'px';
|
||||
});
|
||||
```
|
||||
|
||||
### ✅ Do: Use container transform
|
||||
|
||||
```typescript
|
||||
// GOOD - browser handles it
|
||||
container.style.transform = `scale(${scale}) translate(${pan.x}px, ${pan.y}px)`;
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [Architecture Overview](./CANVAS-OVERLAY-ARCHITECTURE.md) to understand integration
|
||||
- Review [CommentLayer source](../../packages/noodl-editor/src/editor/src/views/commentlayer.ts) for full example
|
||||
- Check [Code Examples](./CANVAS-OVERLAY-EXAMPLES.md) for specific patterns
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CommentLayer Implementation Analysis](./LEARNINGS.md#canvas-overlay-pattern)
|
||||
- [Phase 4 Prerequisites](../tasks/phase-4-canvas-visualisation-views/PREREQ-003-canvas-overlay-pattern/)
|
||||
- [NodeGraphEditor Integration](./CODEBASE-MAP.md#node-graph-editor)
|
||||
337
dev-docs/reference/CANVAS-OVERLAY-REACT.md
Normal file
337
dev-docs/reference/CANVAS-OVERLAY-REACT.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Canvas Overlay React Integration
|
||||
|
||||
## Overview
|
||||
|
||||
This document covers React 19 specific patterns for canvas overlays, including root management, lifecycle, and common gotchas.
|
||||
|
||||
## React 19 Root API
|
||||
|
||||
CommentLayer uses the modern React 19 `createRoot` API:
|
||||
|
||||
```typescript
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
|
||||
class MyOverlay {
|
||||
private backgroundRoot: Root;
|
||||
private foregroundRoot: Root;
|
||||
|
||||
renderTo(backgroundDiv: HTMLDivElement, foregroundDiv: HTMLDivElement) {
|
||||
// Create roots once
|
||||
this.backgroundRoot = createRoot(backgroundDiv);
|
||||
this.foregroundRoot = createRoot(foregroundDiv);
|
||||
|
||||
// Render
|
||||
this._renderReact();
|
||||
}
|
||||
|
||||
private _renderReact() {
|
||||
this.backgroundRoot.render(<Background {...this.props} />);
|
||||
this.foregroundRoot.render(<Foreground {...this.props} />);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.backgroundRoot.unmount();
|
||||
this.foregroundRoot.unmount();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Pattern: Root Reuse
|
||||
|
||||
**✅ Create once, render many times:**
|
||||
|
||||
```typescript
|
||||
// Good - root created once in constructor/setup
|
||||
constructor() {
|
||||
this.root = createRoot(this.container);
|
||||
}
|
||||
|
||||
updateData() {
|
||||
// Reuse existing root
|
||||
this.root.render(<Component data={this.newData} />);
|
||||
}
|
||||
```
|
||||
|
||||
**❌ Never recreate roots:**
|
||||
|
||||
```typescript
|
||||
// Bad - memory leak!
|
||||
updateData() {
|
||||
this.root = createRoot(this.container); // Creates new root every time
|
||||
this.root.render(<Component data={this.newData} />);
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Props Pattern (CommentLayer's Approach)
|
||||
|
||||
Store state in the overlay class, pass as props:
|
||||
|
||||
```typescript
|
||||
class DataLineageOverlay {
|
||||
private props: {
|
||||
paths: DataPath[];
|
||||
selectedPath: string | null;
|
||||
viewport: Viewport;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.props = {
|
||||
paths: [],
|
||||
selectedPath: null,
|
||||
viewport: { x: 0, y: 0, scale: 1 }
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedPath(pathId: string) {
|
||||
this.props.selectedPath = pathId;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.root.render(<LineageView {...this.props} />);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### React State (If Needed)
|
||||
|
||||
For complex overlays, use React state internally:
|
||||
|
||||
```typescript
|
||||
function LineageView({ paths, onPathSelect }: Props) {
|
||||
const [hoveredPath, setHoveredPath] = useState<string | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{paths.map((path) => (
|
||||
<PathHighlight
|
||||
key={path.id}
|
||||
path={path}
|
||||
isHovered={hoveredPath === path.id}
|
||||
onMouseEnter={() => setHoveredPath(path.id)}
|
||||
onMouseLeave={() => setHoveredPath(null)}
|
||||
onClick={() => onPathSelect(path.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Scale Prop Special Case
|
||||
|
||||
**Important:** react-rnd needs `scale` prop on mount for proper setup:
|
||||
|
||||
```typescript
|
||||
setPanAndScale(viewport: Viewport) {
|
||||
const transform = `scale(${viewport.scale}) translate(${viewport.x}px, ${viewport.y}px)`;
|
||||
this.container.style.transform = transform;
|
||||
|
||||
// Must re-render if scale changed (for react-rnd)
|
||||
if (this.props.scale !== viewport.scale) {
|
||||
this.props.scale = viewport.scale;
|
||||
this._renderReact();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
From CommentLayer:
|
||||
|
||||
```tsx
|
||||
// react-rnd requires "scale" to be set when this mounts
|
||||
if (props.scale === undefined) {
|
||||
return null; // Don't render until scale is set
|
||||
}
|
||||
```
|
||||
|
||||
## Async Rendering Workaround
|
||||
|
||||
React effects that trigger renders cause warnings. Use setTimeout:
|
||||
|
||||
```typescript
|
||||
renderTo(container: HTMLDivElement) {
|
||||
this.container = container;
|
||||
this.root = createRoot(container);
|
||||
|
||||
// Ugly workaround to avoid React warnings
|
||||
// when mounting inside another React effect
|
||||
setTimeout(() => {
|
||||
this._renderReact();
|
||||
}, 1);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Memoization
|
||||
|
||||
```tsx
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
const PathHighlight = memo(function PathHighlight({ path, viewport }: Props) {
|
||||
// Expensive path calculation
|
||||
const svgPath = useMemo(() => {
|
||||
return calculateSVGPath(path.nodes, viewport);
|
||||
}, [path.nodes, viewport.scale]); // Re-calc only when needed
|
||||
|
||||
return <path d={svgPath} stroke="blue" strokeWidth={3} />;
|
||||
});
|
||||
```
|
||||
|
||||
### Virtualization
|
||||
|
||||
For many overlay elements (100+), consider virtualization:
|
||||
|
||||
```tsx
|
||||
import { FixedSizeList } from 'react-window';
|
||||
|
||||
function ManyOverlayElements({ items, viewport }: Props) {
|
||||
return (
|
||||
<FixedSizeList height={viewport.height} itemCount={items.length} itemSize={50} width={viewport.width}>
|
||||
{({ index, style }) => (
|
||||
<div style={style}>
|
||||
<OverlayElement item={items[index]} />
|
||||
</div>
|
||||
)}
|
||||
</FixedSizeList>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Conditional Rendering Based on Scale
|
||||
|
||||
```tsx
|
||||
function AdaptiveOverlay({ scale }: Props) {
|
||||
// Hide detailed UI when zoomed out
|
||||
if (scale < 0.5) {
|
||||
return <SimplifiedView />;
|
||||
}
|
||||
|
||||
return <DetailedView />;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Portal for Tooltips
|
||||
|
||||
Tooltips should escape the transformed container:
|
||||
|
||||
```tsx
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
function OverlayWithTooltip({ tooltip }: Props) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onMouseEnter={() => setShowTooltip(true)}>Hover me</div>
|
||||
|
||||
{showTooltip &&
|
||||
createPortal(
|
||||
<Tooltip>{tooltip}</Tooltip>,
|
||||
document.body // Render outside transformed container
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: React + External Library (react-rnd)
|
||||
|
||||
CommentLayer uses react-rnd for draggable comments:
|
||||
|
||||
```tsx
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
<Rnd
|
||||
position={{ x: comment.x, y: comment.y }}
|
||||
size={{ width: comment.w, height: comment.h }}
|
||||
scale={scale} // Pass viewport scale
|
||||
onDragStop={(e, d) => {
|
||||
updateComment(
|
||||
comment.id,
|
||||
{
|
||||
x: d.x,
|
||||
y: d.y
|
||||
},
|
||||
{ commit: true }
|
||||
);
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, delta, position) => {
|
||||
updateComment(
|
||||
comment.id,
|
||||
{
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
w: ref.offsetWidth,
|
||||
h: ref.offsetHeight
|
||||
},
|
||||
{ commit: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Rnd>;
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### ❌ Gotcha 1: Transform Affects Event Coordinates
|
||||
|
||||
```tsx
|
||||
// Event coordinates are in screen space, not canvas space
|
||||
function handleClick(evt: React.MouseEvent) {
|
||||
// Wrong - these are screen coordinates
|
||||
console.log(evt.clientX, evt.clientY);
|
||||
|
||||
// Need to convert to canvas space
|
||||
const canvasPos = screenToCanvas({ x: evt.clientX, y: evt.clientY }, viewport);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Gotcha 2: CSS Transform Affects Children
|
||||
|
||||
All children inherit the container transform. For fixed-size UI:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
style={{
|
||||
// This size will be scaled by container transform
|
||||
width: 20 / scale, // Compensate for scale
|
||||
height: 20 / scale
|
||||
}}
|
||||
>
|
||||
Fixed size button
|
||||
</div>
|
||||
```
|
||||
|
||||
### ❌ Gotcha 3: React Dev Tools Performance
|
||||
|
||||
React Dev Tools can slow down overlays with many elements. Disable in production builds.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
1. **Create roots once** - In constructor/renderTo, not on every render
|
||||
2. **Memoize expensive calculations** - Use useMemo for complex math
|
||||
3. **Use React.memo for components** - Especially for list items
|
||||
4. **Handle scale changes** - Re-render when scale changes (for react-rnd)
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
1. **Don't recreate roots** - Causes memory leaks
|
||||
2. **Don't render before scale is set** - react-rnd breaks
|
||||
3. **Don't forget to unmount** - Call `root.unmount()` in dispose()
|
||||
4. **Don't use useState in overlay class** - Use class properties + props
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Main Overview](./CANVAS-OVERLAY-PATTERN.md)
|
||||
- [Architecture](./CANVAS-OVERLAY-ARCHITECTURE.md)
|
||||
- [Mouse Events](./CANVAS-OVERLAY-EVENTS.md)
|
||||
- [Coordinate Transforms](./CANVAS-OVERLAY-COORDINATES.md)
|
||||
192
dev-docs/reference/DEBUG-INFRASTRUCTURE.md
Normal file
192
dev-docs/reference/DEBUG-INFRASTRUCTURE.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Debug Infrastructure
|
||||
|
||||
> **Purpose:** Documents Noodl's existing runtime debugging capabilities that the Trigger Chain Debugger will extend.
|
||||
|
||||
**Status:** Initial documentation (Phase 1A of VIEW-003)
|
||||
**Last Updated:** January 3, 2026
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Noodl has powerful runtime debugging that shows what's happening in the preview window:
|
||||
|
||||
- **Connection pulsing** - Connections animate when data flows
|
||||
- **Inspector values** - Shows live data in pinned inspectors
|
||||
- **Runtime→Editor bridge** - Events flow from preview to editor canvas
|
||||
|
||||
The Trigger Chain Debugger extends this by **recording** these events into a reviewable timeline.
|
||||
|
||||
---
|
||||
|
||||
## DebugInspector System
|
||||
|
||||
**Location:** `packages/noodl-editor/src/editor/src/utils/debuginspector.js`
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. `DebugInspector` (Singleton)
|
||||
|
||||
Manages connection pulse animations and inspector values.
|
||||
|
||||
**Key Properties:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
connectionsToPulseState: {}, // Active pulsing connections
|
||||
connectionsToPulseIDs: [], // Cached array of IDs
|
||||
inspectorValues: {}, // Current inspector values
|
||||
enabled: true // Debug mode toggle
|
||||
}
|
||||
```
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
- `setConnectionsToPulse(connections)` - Start pulsing connections
|
||||
- `setInspectorValues(inspectorValues)` - Update inspector data
|
||||
- `isConnectionPulsing(connection)` - Check if connection is animating
|
||||
- `valueForConnection(connection)` - Get current value
|
||||
- `reset()` - Clear all debug state
|
||||
|
||||
#### 2. `DebugInspector.InspectorsModel`
|
||||
|
||||
Manages pinned inspector positions and persistence.
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
- `addInspectorForConnection(args)` - Pin a connection inspector
|
||||
- `addInspectorForNode(args)` - Pin a node inspector
|
||||
- `removeInspector(inspector)` - Unpin inspector
|
||||
|
||||
---
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ RUNTIME (Preview) │
|
||||
│ │
|
||||
│ Node executes → Data flows → Connection pulses │
|
||||
│ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Sends event to editor │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ VIEWER CONNECTION │
|
||||
│ │
|
||||
│ - Receives 'debuginspectorconnectionpulse' command │
|
||||
│ - Receives 'debuginspectorvalues' command │
|
||||
│ - Forwards to DebugInspector │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DEBUG INSPECTOR │
|
||||
│ │
|
||||
│ - Updates connectionsToPulseState │
|
||||
│ - Updates inspectorValues │
|
||||
│ - Notifies listeners │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NODE GRAPH EDITOR │
|
||||
│ │
|
||||
│ - Subscribes to 'DebugInspectorConnectionPulseChanged' │
|
||||
│ - Animates connections on canvas │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Events Emitted
|
||||
|
||||
DebugInspector uses `EventDispatcher` to notify listeners:
|
||||
|
||||
| Event Name | When Fired | Data |
|
||||
| ----------------------------------------- | ----------------------- | ----------- |
|
||||
| `DebugInspectorConnectionPulseChanged` | Connection pulse state | None |
|
||||
| `DebugInspectorDataChanged.<inspectorId>` | Inspector value updated | `{ value }` |
|
||||
| `DebugInspectorReset` | Debug state cleared | None |
|
||||
| `DebugInspectorEnabledChanged` | Debug mode toggled | None |
|
||||
|
||||
---
|
||||
|
||||
## ViewerConnection Bridge
|
||||
|
||||
**Location:** `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
|
||||
|
||||
### Commands from Runtime
|
||||
|
||||
| Command | Content | Handler |
|
||||
| ------------------------------- | ------------------------ | ------------------------- |
|
||||
| `debuginspectorconnectionpulse` | `{ connectionsToPulse }` | `setConnectionsToPulse()` |
|
||||
| `debuginspectorvalues` | `{ inspectors }` | `setInspectorValues()` |
|
||||
|
||||
### Commands to Runtime
|
||||
|
||||
| Command | Content | Purpose |
|
||||
| ----------------------- | ---------------- | -------------------------------- |
|
||||
| `debuginspector` | `{ inspectors }` | Send inspector config to runtime |
|
||||
| `debuginspectorenabled` | `{ enabled }` | Enable/disable debug mode |
|
||||
|
||||
---
|
||||
|
||||
## Connection Pulse Animation
|
||||
|
||||
Connections "pulse" when data flows through them:
|
||||
|
||||
1. Runtime detects connection activity
|
||||
2. Sends connection ID to editor
|
||||
3. DebugInspector adds to `connectionsToPulseState`
|
||||
4. Animation frame loop updates opacity/offset
|
||||
5. Canvas redraws with animated styling
|
||||
|
||||
**Animation Properties:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
created: timestamp, // When pulse started
|
||||
offset: number, // Animation offset (life / 20)
|
||||
opacity: number, // Fade in/out (0-1)
|
||||
removed: timestamp // When pulse ended (or false)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For Trigger Chain Recorder
|
||||
|
||||
**What we can leverage:**
|
||||
|
||||
✅ **Connection pulse events** - Tells us when nodes fire
|
||||
✅ **Inspector values** - Gives us data flowing through connections
|
||||
✅ **ViewerConnection bridge** - Already connects runtime↔editor
|
||||
✅ **Event timing** - `performance.now()` used for timestamps
|
||||
|
||||
**What we need to add:**
|
||||
|
||||
❌ **Causal tracking** - What triggered what?
|
||||
❌ **Component boundaries** - When entering/exiting components
|
||||
❌ **Event persistence** - Currently only shows "now", we need history
|
||||
❌ **Node types** - What kind of node fired (REST, Variable, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Phase 1B)
|
||||
|
||||
1. Investigate runtime node execution hooks
|
||||
2. Find where to intercept node events
|
||||
3. Determine how to track causality
|
||||
4. Design TriggerChainRecorder interface
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- `packages/noodl-editor/src/editor/src/utils/debuginspector.js`
|
||||
- `packages/noodl-editor/src/editor/src/ViewerConnection.ts`
|
||||
- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` (pulse rendering)
|
||||
@@ -4,6 +4,329 @@ This document captures important discoveries and gotchas encountered during Open
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Canvas Overlay Pattern: React Over HTML5 Canvas (Jan 3, 2026)
|
||||
|
||||
### The Transform Trick: CSS scale() + translate() for Automatic Coordinate Transformation
|
||||
|
||||
**Context**: Phase 4 PREREQ-003 - Studying CommentLayer to understand how React components overlay the HTML5 Canvas node graph. Need to build Data Lineage, Impact Radar, and Semantic Layer visualizations using the same pattern.
|
||||
|
||||
**The Discovery**: The most elegant solution for overlaying React on Canvas uses CSS transforms on a parent container. Child React components automatically position themselves in canvas coordinates without manual recalculation.
|
||||
|
||||
**The Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Manual coordinate transformation for every element
|
||||
function OverlayComponent({ node, viewport }) {
|
||||
const screenX = (node.x + viewport.pan.x) * viewport.scale;
|
||||
const screenY = (node.y + viewport.pan.y) * viewport.scale;
|
||||
|
||||
return <div style={{ left: screenX, top: screenY }}>...</div>;
|
||||
// Problem: Must recalculate for every element, every render
|
||||
}
|
||||
|
||||
// ✅ RIGHT - CSS transform on parent container
|
||||
function OverlayContainer({ children, viewport }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${viewport.scale}) translate(${viewport.pan.x}px, ${viewport.pan.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{/* All children automatically positioned in canvas coordinates! */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// React children use canvas coordinates directly
|
||||
function NodeBadge({ node }) {
|
||||
return (
|
||||
<div style={{ position: 'absolute', left: node.x, top: node.y }}>
|
||||
{/* Works perfectly - transform handles the rest */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters**:
|
||||
|
||||
- **Automatic transformation**: React children don't need coordinate math
|
||||
- **Performance**: No per-element calculations on every render
|
||||
- **Simplicity**: Overlay components use canvas coordinates naturally
|
||||
- **Consistency**: Same coordinate system as canvas drawing code
|
||||
|
||||
**React 19 Root API Pattern** - Critical for overlays:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Creates new root on every render (memory leak)
|
||||
function updateOverlay() {
|
||||
createRoot(container).render(<Overlay />); // ☠️ New root each time
|
||||
}
|
||||
|
||||
// ✅ RIGHT - Create once, reuse forever
|
||||
class CanvasOverlay {
|
||||
private root: Root;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.root = createRoot(container); // Create once
|
||||
}
|
||||
|
||||
render(props: OverlayProps) {
|
||||
this.root.render(<Overlay {...props} />); // Reuse root
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.root.unmount(); // Clean up properly
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Two-Layer System** - CommentLayer's architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Foreground Layer (z-index: 2) │ ← Interactive controls
|
||||
├─────────────────────────────────────┤
|
||||
│ HTML5 Canvas (z-index: 1) │ ← Node graph
|
||||
├─────────────────────────────────────┤
|
||||
│ Background Layer (z-index: 0) │ ← Comment boxes with shadows
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
This allows:
|
||||
|
||||
- Comment boxes render **behind** canvas (no z-fighting with nodes)
|
||||
- Interactive controls render **in front** of canvas (draggable handles)
|
||||
- No z-index conflicts between overlay elements
|
||||
|
||||
**Mouse Event Forwarding** - The click-through solution:
|
||||
|
||||
```typescript
|
||||
// Three-step pattern for handling clicks
|
||||
overlayContainer.addEventListener('mousedown', (event) => {
|
||||
// Step 1: Capture the event
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Step 2: Check if clicking on actual UI
|
||||
const clickedOnUI = target.style.pointerEvents !== 'none';
|
||||
|
||||
// Step 3: If not UI, forward to canvas
|
||||
if (!clickedOnUI) {
|
||||
const canvasEvent = new MouseEvent('mousedown', event);
|
||||
canvasElement.dispatchEvent(canvasEvent);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**EventDispatcher Context Pattern** - Must use context object:
|
||||
|
||||
```typescript
|
||||
// ✅ BEST - Use useEventListener hook (built-in context handling)
|
||||
import { useEventListener } from '@noodl-hooks/useEventListener';
|
||||
|
||||
// ❌ WRONG - Direct subscription in React (breaks on cleanup)
|
||||
useEffect(() => {
|
||||
editor.on('viewportChanged', handler);
|
||||
return () => editor.off('viewportChanged', handler); // ☠️ Can't unsubscribe
|
||||
}, []);
|
||||
|
||||
// ✅ RIGHT - Use context object for cleanup
|
||||
useEffect(() => {
|
||||
const context = {};
|
||||
editor.on('viewportChanged', handler, context);
|
||||
return () => editor.off(context); // Removes all subscriptions with context
|
||||
}, []);
|
||||
|
||||
useEventListener(editor, 'viewportChanged', (viewport) => {
|
||||
// Automatically handles context and cleanup
|
||||
});
|
||||
```
|
||||
|
||||
**Scale-Dependent vs Scale-Independent Sizing**:
|
||||
|
||||
```scss
|
||||
// Scale-dependent - Grows/shrinks with zoom
|
||||
.node-badge {
|
||||
font-size: 12px; // Affected by parent transform
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
// Scale-independent - Stays same size
|
||||
.floating-panel {
|
||||
position: fixed; // Not affected by transform
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
font-size: 14px; // Always 14px regardless of zoom
|
||||
}
|
||||
```
|
||||
|
||||
**Common Gotchas**:
|
||||
|
||||
1. **React-rnd scale prop**: Must set scale on mount, can't update dynamically
|
||||
|
||||
```typescript
|
||||
// Set scale once when component mounts
|
||||
<Rnd scale={this.scale} onMount={...} />
|
||||
```
|
||||
|
||||
2. **Transform affects ALL children**: Can't exempt specific elements
|
||||
|
||||
- Solution: Use two overlays (one transformed, one not)
|
||||
|
||||
3. **Async rendering timing**: React 19 may batch updates
|
||||
|
||||
```typescript
|
||||
// Force immediate render with setTimeout
|
||||
setTimeout(() => this.root.render(<Overlay />), 0);
|
||||
```
|
||||
|
||||
4. **EventDispatcher cleanup**: Must use context object, not direct references
|
||||
|
||||
**Documentation Created**:
|
||||
|
||||
- `CANVAS-OVERLAY-PATTERN.md` - Overview and quick start
|
||||
- `CANVAS-OVERLAY-ARCHITECTURE.md` - Integration with NodeGraphEditor
|
||||
- `CANVAS-OVERLAY-COORDINATES.md` - Coordinate transformation details
|
||||
- `CANVAS-OVERLAY-EVENTS.md` - Mouse event handling
|
||||
- `CANVAS-OVERLAY-REACT.md` - React 19 specific patterns
|
||||
|
||||
**Impact**: This pattern unblocks all Phase 4 visualization views:
|
||||
|
||||
- VIEW-005: Data Lineage (path highlighting)
|
||||
- VIEW-006: Impact Radar (dependency visualization)
|
||||
- VIEW-007: Semantic Layers (node filtering)
|
||||
|
||||
**Critical Rules**:
|
||||
|
||||
1. **Use CSS transform on parent** - Let CSS handle coordinate transformation
|
||||
2. **Create React root once** - Reuse for all renders, unmount on disposal
|
||||
3. **Use two layers when needed** - Background and foreground for z-index control
|
||||
4. **Forward mouse events** - Check pointer-events before forwarding to canvas
|
||||
5. **Use EventDispatcher context** - Never subscribe without context object
|
||||
|
||||
**Time Saved**: This documentation will save ~4-6 hours per visualization view by providing proven patterns instead of trial-and-error.
|
||||
|
||||
**Location**:
|
||||
|
||||
- Study file: `packages/noodl-editor/src/editor/src/views/nodegrapheditor/commentlayer.ts`
|
||||
- Documentation: `dev-docs/reference/CANVAS-OVERLAY-*.md` (5 files)
|
||||
- Task CHANGELOG: `dev-docs/tasks/phase-4-canvas-visualisation-views/PREREQ-003-canvas-overlay-pattern/CHANGELOG.md`
|
||||
|
||||
**Keywords**: canvas overlay, React over canvas, CSS transform, coordinate transformation, React 19, createRoot, EventDispatcher, mouse forwarding, pointer-events, two-layer system, CommentLayer, viewport, pan, zoom, scale
|
||||
|
||||
---
|
||||
|
||||
## 🔄 React UseMemo Array Reference Equality (Jan 3, 2026)
|
||||
|
||||
### The Invisible Update: When UseMemo Recalculates But React Doesn't Re-render
|
||||
|
||||
**Context**: Phase 2 TASK-008 - Sheet dropdown in Components Panel wasn't updating when sheets were created/deleted. Events fired correctly, useMemo recalculated correctly, but the UI didn't update.
|
||||
|
||||
**The Problem**: React's useMemo uses reference equality (`===`) to determine if a value has changed. Even when useMemo recalculates an array with new values, if the dependencies haven't changed by reference, React may return the same memoized reference, preventing child components from detecting the change.
|
||||
|
||||
**The Broken Pattern**:
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Recalculation doesn't guarantee new reference
|
||||
const sheets = useMemo((): Sheet[] => {
|
||||
const sheetSet = new Set<string>();
|
||||
// ... calculate sheets ...
|
||||
return result; // Same reference if deps unchanged
|
||||
}, [rawComponents, allComponents, hideSheets]);
|
||||
|
||||
// Child component receives same array reference
|
||||
<SheetSelector sheets={sheets} />; // No re-render!
|
||||
```
|
||||
|
||||
**The Solution** - Add an update counter to force new references:
|
||||
|
||||
```typescript
|
||||
// ✅ RIGHT - Update counter forces new reference
|
||||
const [updateCounter, setUpdateCounter] = useState(0);
|
||||
|
||||
// Increment counter when model changes
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => setUpdateCounter((c) => c + 1);
|
||||
ProjectModel.instance.on(EVENTS, handleUpdate, group);
|
||||
return () => ProjectModel.instance.off(group);
|
||||
}, []);
|
||||
|
||||
// Counter in deps forces new reference on every recalculation
|
||||
const sheets = useMemo((): Sheet[] => {
|
||||
const sheetSet = new Set<string>();
|
||||
// ... calculate sheets ...
|
||||
return result; // New reference when updateCounter changes!
|
||||
}, [rawComponents, allComponents, hideSheets, updateCounter]);
|
||||
|
||||
// Child component detects new reference and re-renders
|
||||
<SheetSelector sheets={sheets} />; // Re-renders correctly!
|
||||
```
|
||||
|
||||
**Why This Matters**:
|
||||
|
||||
- **useMemo is an optimization, not a guarantee**: It may return the cached value even when recalculating
|
||||
- **Reference equality drives React updates**: Components only re-render when props change by reference
|
||||
- **Update counters bypass the cache**: Changing a simple number in deps forces a full recalculation with a new reference
|
||||
|
||||
**The Debug Journey**:
|
||||
|
||||
1. ✅ Events fire correctly (componentAdded, componentRemoved)
|
||||
2. ✅ Event handlers execute (updateCounter increments)
|
||||
3. ✅ useMemo recalculates (new sheet values computed)
|
||||
4. ❌ But child components don't re-render (same array reference)
|
||||
|
||||
**Common Symptoms**:
|
||||
|
||||
- Events fire but UI doesn't update
|
||||
- Data is correct when logged but not displayed
|
||||
- Refreshing the page shows correct state
|
||||
- Direct state changes work but derived state doesn't
|
||||
|
||||
**Critical Rules**:
|
||||
|
||||
1. **Never assume useMemo creates new references** - It's an optimization, not a forcing mechanism
|
||||
2. **Use update counters for event-driven data** - Simple incrementing values in deps force re-computation
|
||||
3. **Always verify reference changes** - Log array/object references to confirm they change
|
||||
4. **Test with React DevTools** - Check component re-render highlighting to confirm updates
|
||||
|
||||
**Alternative Patterns**:
|
||||
|
||||
```typescript
|
||||
// Pattern 1: Force re-creation with spreading (less efficient)
|
||||
const sheets = useMemo(() => {
|
||||
const result = calculateSheets();
|
||||
return [...result]; // Always new array
|
||||
}, [deps, updateCounter]);
|
||||
|
||||
// Pattern 2: Skip useMemo for frequently-changing data
|
||||
const sheets = calculateSheets(); // Recalculate every render
|
||||
// Only use when calculation is cheap
|
||||
|
||||
// Pattern 3: Use useCallback for stable references with changing data
|
||||
const getSheets = useCallback(() => {
|
||||
return calculateSheets(); // Fresh calculation on every call
|
||||
}, [deps]);
|
||||
```
|
||||
|
||||
**Related Issues**:
|
||||
|
||||
- Similar to React's "stale closure" problem
|
||||
- Related to React.memo's shallow comparison
|
||||
- Connected to PureComponent update blocking
|
||||
|
||||
**Time Lost**: 2-3 hours debugging "why events work but UI doesn't update"
|
||||
|
||||
**Location**:
|
||||
|
||||
- Fixed in: `packages/noodl-editor/src/editor/src/views/panels/ComponentsPanelNew/hooks/useComponentsPanel.ts` (line 153)
|
||||
- Task: Phase 2 TASK-008 ComponentsPanel Menus and Sheets
|
||||
- CHANGELOG: `dev-docs/tasks/phase-2-react-migration/TASK-008-componentspanel-menus-and-sheets/CHANGELOG.md`
|
||||
|
||||
**Keywords**: React, useMemo, reference equality, array reference, update counter, force re-render, shallow comparison, React optimization, derived state, memoization
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Port Hover Compatibility Highlighting Failed Attempt (Jan 1, 2026)
|
||||
|
||||
### The Invisible Compatibility: Why Port Hover Preview Didn't Work
|
||||
|
||||
Reference in New Issue
Block a user