mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Fixed visual issues with new dashboard and added folder attribution
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
# TASK-000I Changelog
|
||||
|
||||
## Overview
|
||||
|
||||
This changelog tracks the implementation of Node Graph Visual Improvements, covering visual polish, node comments, and port organization features.
|
||||
|
||||
### Implementation Sessions
|
||||
|
||||
1. **Session 1**: Sub-Task A - Rounded Corners & Colors
|
||||
2. **Session 2**: Sub-Task A - Connection Points & Label Truncation
|
||||
3. **Session 3**: Sub-Task B - Comment Data Layer & Icon
|
||||
4. **Session 4**: Sub-Task B - Hover Preview & Edit Modal
|
||||
5. **Session 5**: Sub-Task C - Port Grouping System
|
||||
6. **Session 6**: Sub-Task C - Type Icons & Connection Preview
|
||||
7. **Session 7**: Integration & Polish
|
||||
|
||||
---
|
||||
|
||||
## [Date TBD] - Task Created
|
||||
|
||||
### Summary
|
||||
|
||||
Task documentation created for Node Graph Visual Improvements based on product planning discussion.
|
||||
|
||||
### Files Created
|
||||
|
||||
- `dev-docs/tasks/phase-3/TASK-010-node-graph-visual/README.md` - Full task specification
|
||||
- `dev-docs/tasks/phase-3/TASK-010-node-graph-visual/CHECKLIST.md` - Implementation checklist
|
||||
- `dev-docs/tasks/phase-3/TASK-010-node-graph-visual/CHANGELOG.md` - This file
|
||||
- `dev-docs/tasks/phase-3/TASK-010-node-graph-visual/NOTES.md` - Working notes
|
||||
|
||||
### Context
|
||||
|
||||
Discussion identified three key areas for improvement:
|
||||
|
||||
1. Nodes look dated (sharp corners, flat colors)
|
||||
2. No way to document individual nodes with comments
|
||||
3. Dense nodes with many ports become hard to read
|
||||
|
||||
Decision made to implement as three sub-tasks that can be tackled incrementally.
|
||||
|
||||
---
|
||||
|
||||
## Progress Summary
|
||||
|
||||
| Sub-Task | Status | Date Started | Date Completed |
|
||||
| ---------------------- | ----------- | ------------ | -------------- |
|
||||
| A1: Rounded Corners | Not Started | - | - |
|
||||
| A2: Color Palette | Not Started | - | - |
|
||||
| A3: Connection Points | Not Started | - | - |
|
||||
| A4: Label Truncation | Not Started | - | - |
|
||||
| B1: Comment Data Layer | Not Started | - | - |
|
||||
| B2: Comment Icon | Not Started | - | - |
|
||||
| B3: Hover Preview | Not Started | - | - |
|
||||
| B4: Edit Modal | Not Started | - | - |
|
||||
| B5: Click Integration | Not Started | - | - |
|
||||
| C1: Port Grouping | Not Started | - | - |
|
||||
| C2: Type Icons | Not Started | - | - |
|
||||
| C3: Connection Preview | Not Started | - | - |
|
||||
|
||||
---
|
||||
|
||||
## Template for Session Entries
|
||||
|
||||
```markdown
|
||||
## [YYYY-MM-DD] - Session N: [Sub-Task Name]
|
||||
|
||||
### Summary
|
||||
|
||||
[Brief description of what was accomplished]
|
||||
|
||||
### Files Created
|
||||
|
||||
- `path/to/file.ts` - [Purpose]
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `path/to/file.ts` - [What changed and why]
|
||||
|
||||
### Technical Notes
|
||||
|
||||
- [Key decisions made]
|
||||
- [Patterns discovered]
|
||||
- [Gotchas encountered]
|
||||
|
||||
### Visual Changes
|
||||
|
||||
- [Before/after description]
|
||||
- [Screenshot references]
|
||||
|
||||
### Testing Notes
|
||||
|
||||
- [What was tested]
|
||||
- [Edge cases discovered]
|
||||
|
||||
### Next Steps
|
||||
|
||||
- [What needs to be done next]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blockers Log
|
||||
|
||||
| Date | Blocker | Resolution | Time Lost |
|
||||
| ---- | ------- | ---------- | --------- |
|
||||
| - | - | - | - |
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
| Scenario | Before | After | Notes |
|
||||
| -------------------- | ------ | ----- | ------------ |
|
||||
| Render 50 nodes | - | - | Baseline TBD |
|
||||
| Render 100 nodes | - | - | Baseline TBD |
|
||||
| Pan/zoom performance | - | - | Baseline TBD |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions Log
|
||||
|
||||
| Decision | Options Considered | Choice Made | Rationale |
|
||||
| ------------------- | ------------------------------- | ----------- | ------------------------------ |
|
||||
| Corner radius | 4px, 6px, 8px | TBD | - |
|
||||
| Comment icon | Speech bubble, Note icon, "i" | TBD | - |
|
||||
| Preview delay | 200ms, 300ms, 500ms | 300ms | Balance responsiveness vs spam |
|
||||
| Port group collapse | Remember state, Reset on reload | Reset | Simpler, no persistence needed |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
_Add before/after screenshots as implementation progresses_
|
||||
|
||||
### Before (Baseline)
|
||||
|
||||
- [ ] Capture current node appearance
|
||||
- [ ] Capture dense node example
|
||||
- [ ] Capture current colors
|
||||
|
||||
### After Sub-Task A
|
||||
|
||||
- [ ] New rounded corners
|
||||
- [ ] Updated colors
|
||||
- [ ] Refined connection points
|
||||
|
||||
### After Sub-Task B
|
||||
|
||||
- [ ] Comment icon on node
|
||||
- [ ] Hover preview
|
||||
- [ ] Edit modal
|
||||
|
||||
### After Sub-Task C
|
||||
|
||||
- [ ] Grouped ports example
|
||||
- [ ] Type icons
|
||||
- [ ] Connection preview highlight
|
||||
@@ -0,0 +1,224 @@
|
||||
# TASK-000I Implementation Checklist
|
||||
|
||||
## Pre-Implementation
|
||||
|
||||
- [ ] Review `NodeGraphEditorNode.ts` paint() method thoroughly
|
||||
- [ ] Review `colors.css` current color definitions
|
||||
- [ ] Review `NodeGraphNode.ts` metadata structure
|
||||
- [ ] Test Canvas roundRect() browser support
|
||||
- [ ] Set up test project with complex node graphs
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task A: Visual Polish
|
||||
|
||||
### A1: Rounded Corners
|
||||
|
||||
- [ ] Create `canvasHelpers.ts` with roundRect utility
|
||||
- [ ] Replace background `fillRect` with roundRect in paint()
|
||||
- [ ] Update border drawing to use roundRect
|
||||
- [ ] Update selection highlight to use roundRect
|
||||
- [ ] Update error/annotation borders to use roundRect
|
||||
- [ ] Handle title bar corners (top only vs all)
|
||||
- [ ] Test at various zoom levels
|
||||
- [ ] Verify no visual artifacts
|
||||
|
||||
### A2: Color Palette Update
|
||||
|
||||
- [ ] Document current color values
|
||||
- [ ] Design new palette following design system
|
||||
- [ ] Update `--theme-color-node-data-*` variables
|
||||
- [ ] Update `--theme-color-node-visual-*` variables
|
||||
- [ ] Update `--theme-color-node-logic-*` variables
|
||||
- [ ] Update `--theme-color-node-custom-*` variables
|
||||
- [ ] Update `--theme-color-node-component-*` variables
|
||||
- [ ] Update connection colors if needed
|
||||
- [ ] Verify contrast ratios (WCAG AA minimum)
|
||||
- [ ] Test in dark theme
|
||||
- [ ] Get feedback on new colors
|
||||
|
||||
### A3: Connection Point Styling
|
||||
|
||||
- [ ] Identify all port indicator drawing code
|
||||
- [ ] Increase hit area size (4px → 6px?)
|
||||
- [ ] Add subtle inner highlight effect
|
||||
- [ ] Improve anti-aliasing
|
||||
- [ ] Test connection dragging still works
|
||||
- [ ] Verify hover states visible
|
||||
|
||||
### A4: Port Label Truncation
|
||||
|
||||
- [ ] Create truncateText utility function
|
||||
- [ ] Integrate into drawPlugs() function
|
||||
- [ ] Calculate available width correctly
|
||||
- [ ] Add ellipsis character (…)
|
||||
- [ ] Verify tooltip shows full name on hover
|
||||
- [ ] Test with various label lengths
|
||||
- [ ] Test with RTL text (if applicable)
|
||||
|
||||
### A: Integration & Polish
|
||||
|
||||
- [ ] Full visual review of all node types
|
||||
- [ ] Performance profiling
|
||||
- [ ] Update any hardcoded colors
|
||||
- [ ] Screenshots for documentation
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task B: Node Comments System
|
||||
|
||||
### B1: Data Layer
|
||||
|
||||
- [ ] Add `comment?: string` to NodeMetadata interface
|
||||
- [ ] Implement `getComment()` method
|
||||
- [ ] Implement `setComment()` method with undo support
|
||||
- [ ] Implement `hasComment()` helper
|
||||
- [ ] Add 'commentChanged' event emission
|
||||
- [ ] Verify comment persists in project JSON
|
||||
- [ ] Verify comment included in node copy/paste
|
||||
- [ ] Write unit tests for data layer
|
||||
|
||||
### B2: Comment Icon Rendering
|
||||
|
||||
- [ ] Design/source comment icon (speech bubble)
|
||||
- [ ] Add icon drawing in paint() after title
|
||||
- [ ] Show solid icon when comment exists
|
||||
- [ ] Show faded icon on hover when no comment
|
||||
- [ ] Calculate correct icon position
|
||||
- [ ] Store hit bounds for click detection
|
||||
- [ ] Test icon visibility at all zoom levels
|
||||
|
||||
### B3: Hover Preview
|
||||
|
||||
- [ ] Add hover state tracking for comment icon
|
||||
- [ ] Implement 300ms debounce timer
|
||||
- [ ] Create preview content formatter
|
||||
- [ ] Position preview near icon, not obscuring node
|
||||
- [ ] Set max dimensions (250px × 150px)
|
||||
- [ ] Add scroll for long comments
|
||||
- [ ] Clear preview on mouse leave
|
||||
- [ ] Clear preview on pan/zoom start
|
||||
- [ ] Test rapid mouse movement (no spam)
|
||||
|
||||
### B4: Edit Modal
|
||||
|
||||
- [ ] Create `NodeCommentEditor.tsx` component
|
||||
- [ ] Create `NodeCommentEditor.module.scss` styles
|
||||
- [ ] Implement draggable header
|
||||
- [ ] Implement textarea with auto-focus
|
||||
- [ ] Handle Save button click
|
||||
- [ ] Handle Cancel button click
|
||||
- [ ] Handle Cmd+Enter to save
|
||||
- [ ] Handle Escape to cancel
|
||||
- [ ] Show node name in header
|
||||
- [ ] Position modal near node initially
|
||||
- [ ] Prevent duplicate modals for same node
|
||||
|
||||
### B5: Click Handler Integration
|
||||
|
||||
- [ ] Add comment icon click detection
|
||||
- [ ] Open modal on icon click
|
||||
- [ ] Prevent node selection on icon click
|
||||
- [ ] Handle modal close callback
|
||||
- [ ] Update node display after comment change
|
||||
|
||||
### B: Integration & Polish
|
||||
|
||||
- [ ] End-to-end test: create, edit, delete comment
|
||||
- [ ] Test with very long comments
|
||||
- [ ] Test with special characters
|
||||
- [ ] Test undo/redo flow
|
||||
- [ ] Test save/load project
|
||||
- [ ] Test export behavior
|
||||
- [ ] Accessibility review (keyboard nav)
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task C: Port Organization & Smart Connections
|
||||
|
||||
### C1: Port Grouping - Data Model
|
||||
|
||||
- [ ] Define PortGroup interface
|
||||
- [ ] Add portGroups to node type schema
|
||||
- [ ] Create port group configuration for HTTP node
|
||||
- [ ] Create port group configuration for Object node
|
||||
- [ ] Create port group configuration for Function node
|
||||
- [ ] Create auto-grouping logic for unconfigured nodes
|
||||
- [ ] Store group expand state in view
|
||||
|
||||
### C1: Port Grouping - Rendering
|
||||
|
||||
- [ ] Modify measure() to account for groups
|
||||
- [ ] Implement group header drawing
|
||||
- [ ] Implement expand/collapse chevron
|
||||
- [ ] Draw ports within expanded groups
|
||||
- [ ] Skip ports in collapsed groups
|
||||
- [ ] Update connection positioning for grouped ports
|
||||
- [ ] Handle click on group header
|
||||
|
||||
### C1: Port Grouping - Testing
|
||||
|
||||
- [ ] Test grouped node rendering
|
||||
- [ ] Test collapse/expand toggle
|
||||
- [ ] Test connections to grouped ports
|
||||
- [ ] Test node without groups (unchanged)
|
||||
- [ ] Test dynamic ports (wildcard matching)
|
||||
- [ ] Verify no regression on existing projects
|
||||
|
||||
### C2: Port Type Icons
|
||||
|
||||
- [ ] Design icon set (signal, string, number, boolean, object, array, color, any)
|
||||
- [ ] Create icon paths/characters in `portIcons.ts`
|
||||
- [ ] Integrate icon drawing into port rendering
|
||||
- [ ] Size icons appropriately (10-12px)
|
||||
- [ ] Match icon color to port type
|
||||
- [ ] Test visibility at minimum zoom
|
||||
- [ ] Ensure icons don't interfere with labels
|
||||
|
||||
### C3: Connection Preview on Hover
|
||||
|
||||
- [ ] Add highlightedPort state to NodeGraphEditor
|
||||
- [ ] Detect port hover in mouse event handling
|
||||
- [ ] Implement `getPortCompatibility()` method
|
||||
- [ ] Highlight compatible ports (glow effect)
|
||||
- [ ] Dim incompatible ports (reduce opacity)
|
||||
- [ ] Draw preview line from source to cursor
|
||||
- [ ] Clear highlight on mouse leave
|
||||
- [ ] Test with various type combinations
|
||||
- [ ] Performance test with many visible nodes
|
||||
|
||||
### C: Integration & Polish
|
||||
|
||||
- [ ] Full interaction test
|
||||
- [ ] Performance profiling
|
||||
- [ ] Edge case testing
|
||||
- [ ] Documentation for port group configuration
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
- [ ] All three sub-tasks complete
|
||||
- [ ] No console errors
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] Performance acceptable
|
||||
- [ ] Existing projects load correctly
|
||||
- [ ] All node types render correctly
|
||||
- [ ] Copy/paste works
|
||||
- [ ] Undo/redo works
|
||||
- [ ] Save/load works
|
||||
- [ ] Export works
|
||||
- [ ] Screenshots captured for changelog
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] LEARNINGS.md updated with discoveries
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
| Sub-Task | Completed | Date | Notes |
|
||||
| -------------------- | --------- | ---- | ----- |
|
||||
| A: Visual Polish | ☐ | - | - |
|
||||
| B: Node Comments | ☐ | - | - |
|
||||
| C: Port Organization | ☐ | - | - |
|
||||
| Final Integration | ☐ | - | - |
|
||||
@@ -0,0 +1,306 @@
|
||||
# TASK-000I Working Notes
|
||||
|
||||
## Key Code Locations
|
||||
|
||||
### Node Rendering
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts
|
||||
|
||||
Key methods:
|
||||
- paint() - Main render function (~line 200-400)
|
||||
- drawPlugs() - Port indicator rendering
|
||||
- measure() - Calculate node dimensions
|
||||
- handleMouseEvent() - Click/hover handling
|
||||
```
|
||||
|
||||
### Colors
|
||||
|
||||
```
|
||||
packages/noodl-core-ui/src/styles/custom-properties/colors.css
|
||||
|
||||
Node colors section (~line 30-60):
|
||||
- --theme-color-node-data-*
|
||||
- --theme-color-node-visual-*
|
||||
- --theme-color-node-logic-*
|
||||
- --theme-color-node-custom-*
|
||||
- --theme-color-node-component-*
|
||||
```
|
||||
|
||||
### Node Model
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts
|
||||
|
||||
- metadata object already exists
|
||||
- Add comment storage here
|
||||
```
|
||||
|
||||
### Node Type Definitions
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/nodelibrary/
|
||||
|
||||
- Port groups would be defined in node type registration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Canvas API Notes
|
||||
|
||||
### roundRect Support
|
||||
|
||||
- Native `ctx.roundRect()` available in modern browsers
|
||||
- Fallback for older browsers:
|
||||
|
||||
```javascript
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
```
|
||||
|
||||
### Text Measurement
|
||||
|
||||
```javascript
|
||||
const width = ctx.measureText(text).width;
|
||||
```
|
||||
|
||||
### Hit Testing
|
||||
|
||||
Currently done manually by checking bounds - no need to change pattern.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette Ideas
|
||||
|
||||
### Current (approximate from inspection)
|
||||
|
||||
```css
|
||||
/* Data nodes - current olive green */
|
||||
--base-color-node-green-700: #4a5d23;
|
||||
--base-color-node-green-600: #5c7029;
|
||||
|
||||
/* Visual nodes - current muted blue */
|
||||
--base-color-node-blue-700: #2d4a6d;
|
||||
--base-color-node-blue-600: #3a5f8a;
|
||||
|
||||
/* Logic nodes - current grey */
|
||||
--base-color-node-grey-700: #3d3d3d;
|
||||
--base-color-node-grey-600: #4a4a4a;
|
||||
|
||||
/* Custom nodes - current pink/magenta */
|
||||
--base-color-node-pink-700: #7d3a5d;
|
||||
--base-color-node-pink-600: #9a4872;
|
||||
```
|
||||
|
||||
### Proposed Direction
|
||||
|
||||
```css
|
||||
/* Data nodes - richer emerald */
|
||||
--base-color-node-green-700: #166534;
|
||||
--base-color-node-green-600: #15803d;
|
||||
|
||||
/* Visual nodes - cleaner slate */
|
||||
--base-color-node-blue-700: #334155;
|
||||
--base-color-node-blue-600: #475569;
|
||||
|
||||
/* Logic nodes - warmer charcoal */
|
||||
--base-color-node-grey-700: #3f3f46;
|
||||
--base-color-node-grey-600: #52525b;
|
||||
|
||||
/* Custom nodes - refined rose */
|
||||
--base-color-node-pink-700: #9f1239;
|
||||
--base-color-node-pink-600: #be123c;
|
||||
```
|
||||
|
||||
_Need to test contrast ratios and get visual feedback_
|
||||
|
||||
---
|
||||
|
||||
## Port Type Icons
|
||||
|
||||
### Character-based approach (simpler)
|
||||
|
||||
```typescript
|
||||
const PORT_TYPE_ICONS = {
|
||||
signal: '⚡', // or custom glyph
|
||||
string: 'T',
|
||||
number: '#',
|
||||
boolean: '◐',
|
||||
object: '{}',
|
||||
array: '[]',
|
||||
color: '●',
|
||||
any: '◇'
|
||||
};
|
||||
```
|
||||
|
||||
### SVG path approach (more control)
|
||||
|
||||
```typescript
|
||||
const PORT_TYPE_PATHS = {
|
||||
signal: 'M4 0 L8 4 L4 8 L0 4 Z' // lightning bolt
|
||||
// ... etc
|
||||
};
|
||||
```
|
||||
|
||||
_Need to evaluate which looks better at 10-12px_
|
||||
|
||||
---
|
||||
|
||||
## Port Grouping Logic
|
||||
|
||||
### Auto-grouping heuristics
|
||||
|
||||
```typescript
|
||||
function autoGroupPorts(ports: Port[]): PortGroup[] {
|
||||
const signals = ports.filter((p) => isSignalType(p.type));
|
||||
const dataInputs = ports.filter((p) => p.direction === 'input' && !isSignalType(p.type));
|
||||
const dataOutputs = ports.filter((p) => p.direction === 'output' && !isSignalType(p.type));
|
||||
|
||||
const groups: PortGroup[] = [];
|
||||
|
||||
if (signals.length > 0) {
|
||||
groups.push({ name: 'Events', ports: signals, expanded: true });
|
||||
}
|
||||
if (dataInputs.length > 0) {
|
||||
groups.push({ name: 'Inputs', ports: dataInputs, expanded: true });
|
||||
}
|
||||
if (dataOutputs.length > 0) {
|
||||
groups.push({ name: 'Outputs', ports: dataOutputs, expanded: true });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isSignalType(type: string): boolean {
|
||||
return type === 'signal' || type === '*'; // Check actual type names
|
||||
}
|
||||
```
|
||||
|
||||
### Explicit group configuration example (HTTP node)
|
||||
|
||||
```typescript
|
||||
{
|
||||
portGroups: [
|
||||
{
|
||||
name: 'Request',
|
||||
ports: ['url', 'method', 'body', 'headers-*'],
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Response',
|
||||
ports: ['status', 'response', 'responseHeaders'],
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Control',
|
||||
ports: ['send', 'success', 'failure', 'error'],
|
||||
defaultExpanded: true
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Compatibility
|
||||
|
||||
### Existing type checking
|
||||
|
||||
```typescript
|
||||
// Check NodeLibrary for existing type compatibility logic
|
||||
NodeLibrary.instance.canConnect(sourceType, targetType);
|
||||
```
|
||||
|
||||
### Visual feedback states
|
||||
|
||||
1. **Source port** - Normal rendering (this is what user is hovering)
|
||||
2. **Compatible** - Brighter, subtle glow, maybe pulse animation
|
||||
3. **Incompatible** - Dimmed to 50% opacity, greyed connection point
|
||||
|
||||
---
|
||||
|
||||
## Comment Modal Positioning
|
||||
|
||||
### Algorithm
|
||||
|
||||
```typescript
|
||||
function calculateModalPosition(node: NodeGraphEditorNode): { x: number; y: number } {
|
||||
const nodeScreenPos = canvasToScreen(node.global.x, node.global.y);
|
||||
const nodeWidth = node.nodeSize.width * currentScale;
|
||||
const nodeHeight = node.nodeSize.height * currentScale;
|
||||
|
||||
// Position to the right of the node
|
||||
let x = nodeScreenPos.x + nodeWidth + 20;
|
||||
let y = nodeScreenPos.y;
|
||||
|
||||
// Check if off-screen right, move to left
|
||||
if (x + MODAL_WIDTH > window.innerWidth) {
|
||||
x = nodeScreenPos.x - MODAL_WIDTH - 20;
|
||||
}
|
||||
|
||||
// Check if off-screen bottom
|
||||
if (y + MODAL_HEIGHT > window.innerHeight) {
|
||||
y = window.innerHeight - MODAL_HEIGHT - 20;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Learnings to Add to LEARNINGS.md
|
||||
|
||||
_Add these after implementation:_
|
||||
|
||||
- [ ] Canvas roundRect browser support findings
|
||||
- [ ] Performance impact of rounded corners
|
||||
- [ ] Comment storage in metadata - any gotchas
|
||||
- [ ] Port grouping measurement calculations
|
||||
- [ ] Connection preview performance considerations
|
||||
|
||||
---
|
||||
|
||||
## Questions to Resolve
|
||||
|
||||
1. ~~Should rounded corners apply to title bar only or whole node?~~
|
||||
|
||||
- Decision: Whole node with consistent radius
|
||||
|
||||
2. What happens to comments when node is copied to different project?
|
||||
|
||||
- Need to test metadata handling in import/export
|
||||
|
||||
3. Should port groups be user-customizable or only defined in node types?
|
||||
|
||||
- Start with node type definitions, user customization is future enhancement
|
||||
|
||||
4. How to handle groups for Component nodes (user-defined ports)?
|
||||
- Auto-group based on port direction (input/output)
|
||||
|
||||
---
|
||||
|
||||
## Reference Screenshots
|
||||
|
||||
_Add reference screenshots here during implementation for comparison_
|
||||
|
||||
### Design References
|
||||
|
||||
- [ ] Modern node-based tools (Unreal Blueprints, Blender Geometry Nodes)
|
||||
- [ ] Other low-code tools for comparison
|
||||
|
||||
### OpenNoodl Current State
|
||||
|
||||
- [ ] Capture before screenshots
|
||||
- [ ] Note specific problem areas
|
||||
@@ -0,0 +1,786 @@
|
||||
# TASK-000I: Node Graph Visual Improvements
|
||||
|
||||
## Overview
|
||||
|
||||
Modernize the visual appearance of the node graph canvas, add a node comments system, and improve port label handling. This is a high-impact visual refresh that maintains backward compatibility while significantly improving the user experience for complex node graphs.
|
||||
|
||||
**Phase:** 3 (Visual Improvements)
|
||||
**Priority:** High
|
||||
**Estimated Time:** 35-50 hours total
|
||||
**Risk Level:** Low-Medium
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The node graph is the heart of OpenNoodl's visual programming experience. While functionally solid, the current visual design shows its age:
|
||||
|
||||
- Nodes have sharp corners and flat colors that feel dated
|
||||
- No way to attach documentation/comments to individual nodes
|
||||
- Port labels overflow on nodes with many connections
|
||||
- Dense nodes (Object, State, Function) become hard to read
|
||||
|
||||
This task addresses these pain points through three sub-tasks that can be implemented incrementally.
|
||||
|
||||
### Current Architecture
|
||||
|
||||
The node graph uses a **hybrid rendering approach**:
|
||||
|
||||
1. **HTML5 Canvas** (`NodeGraphEditorNode.ts`) - Renders:
|
||||
|
||||
- Node backgrounds via `ctx.fillRect()`
|
||||
- Borders via `ctx.rect()` and `ctx.strokeRect()`
|
||||
- Port indicators (dots/arrows) via `ctx.arc()` and triangle paths
|
||||
- Connection lines via bezier curves
|
||||
- Text labels via `ctx.fillText()`
|
||||
|
||||
2. **DOM Layer** (`domElementContainer`) - Renders:
|
||||
|
||||
- Comment layer (existing, React-based)
|
||||
- Some overlays and tooltips
|
||||
|
||||
3. **Color System** - Node colors come from:
|
||||
- `NodeLibrary.instance.colorSchemeForNodeType()`
|
||||
- Maps to CSS variables in `colors.css`
|
||||
- Already abstracted - we can update colors without touching Canvas code
|
||||
|
||||
### Key Files
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
├── nodegrapheditor.ts # Main editor, paint loop
|
||||
├── nodegrapheditor/
|
||||
│ ├── NodeGraphEditorNode.ts # Node rendering (PRIMARY TARGET)
|
||||
│ ├── NodeGraphEditorConnection.ts # Connection line rendering
|
||||
│ └── ...
|
||||
├── commentlayer.ts # Existing comment system
|
||||
|
||||
packages/noodl-core-ui/src/styles/custom-properties/
|
||||
├── colors.css # Design tokens (color updates)
|
||||
|
||||
packages/noodl-editor/src/editor/src/models/
|
||||
├── nodegraphmodel/NodeGraphNode.ts # Node data model (metadata storage)
|
||||
├── nodelibrary/ # Node type definitions, port groups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sub-Tasks
|
||||
|
||||
### Sub-Task A: Visual Polish (8-12 hours)
|
||||
|
||||
Modernize node appearance without changing functionality.
|
||||
|
||||
### Sub-Task B: Node Comments System (12-18 hours)
|
||||
|
||||
Add ability to attach documentation to individual nodes.
|
||||
|
||||
### Sub-Task C: Port Organization & Smart Connections (15-20 hours)
|
||||
|
||||
Improve port label handling and add connection preview on hover.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task A: Visual Polish
|
||||
|
||||
### Scope
|
||||
|
||||
1. **Rounded corners** on all node rectangles
|
||||
2. **Updated color palette** following design system
|
||||
3. **Refined connection points** (port dots/arrows)
|
||||
4. **Port label truncation** with ellipsis for overflow
|
||||
|
||||
### Implementation
|
||||
|
||||
#### A1: Rounded Corners (2-3 hours)
|
||||
|
||||
**Current code** in `NodeGraphEditorNode.ts`:
|
||||
|
||||
```typescript
|
||||
// Background
|
||||
ctx.fillRect(x, y, this.nodeSize.width, this.nodeSize.height);
|
||||
|
||||
// Border
|
||||
ctx.rect(x, y, this.nodeSize.width, this.nodeSize.height);
|
||||
```
|
||||
|
||||
**New approach** - Create helper function:
|
||||
|
||||
```typescript
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, width, height, radius); // Native Canvas API
|
||||
ctx.closePath();
|
||||
}
|
||||
```
|
||||
|
||||
**Apply to:**
|
||||
|
||||
- Node background fill
|
||||
- Node border stroke
|
||||
- Selection highlight
|
||||
- Error/annotation borders
|
||||
- Title bar area (top corners only, or clip)
|
||||
|
||||
**Radius recommendation:** 6-8px for nodes, 4px for smaller elements
|
||||
|
||||
#### A2: Color Palette Update (2-3 hours)
|
||||
|
||||
Update CSS variables in `colors.css` to use more modern, saturated colors while maintaining the existing semantic meanings:
|
||||
|
||||
| Node Type | Current | Proposed Direction |
|
||||
| ------------------ | ------------ | -------------------------------- |
|
||||
| Data (green) | Olive/muted | Richer emerald green |
|
||||
| Visual (blue) | Muted blue | Cleaner slate blue |
|
||||
| Logic (grey) | Flat grey | Warmer charcoal with subtle tint |
|
||||
| Custom (pink) | Magenta-pink | Refined rose/coral |
|
||||
| Component (purple) | Muted purple | Cleaner violet |
|
||||
|
||||
**Also update:**
|
||||
|
||||
- `--theme-color-signal` (connection lines)
|
||||
- `--theme-color-data` (connection lines)
|
||||
- Background contrast between header and body
|
||||
|
||||
**Constraint:** Keep changes within design system tokens, ensure sufficient contrast.
|
||||
|
||||
#### A3: Connection Point Styling (2-3 hours)
|
||||
|
||||
Current port indicators are simple:
|
||||
|
||||
- **Dots** (`ctx.arc`) for data sources
|
||||
- **Triangles** (manual path) for signals/targets
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Slightly larger hit areas (currently 4px radius)
|
||||
- Subtle inner highlight or ring effect
|
||||
- Smoother anti-aliasing
|
||||
- Consider pill-shaped indicators for "connected" state
|
||||
|
||||
**Files:** `NodeGraphEditorNode.ts` - `drawPlugs()` function
|
||||
|
||||
#### A4: Port Label Truncation (2-3 hours)
|
||||
|
||||
**Problem:** Long port names overflow the node boundary.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```typescript
|
||||
function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
||||
const ellipsis = '…';
|
||||
let truncated = text;
|
||||
|
||||
while (ctx.measureText(truncated + ellipsis).width > maxWidth && truncated.length > 0) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
|
||||
return truncated.length < text.length ? truncated + ellipsis : text;
|
||||
}
|
||||
```
|
||||
|
||||
**Apply in** `drawPlugs()` before `ctx.fillText()`.
|
||||
|
||||
**Tooltip:** Full port name should show on hover (existing tooltip system).
|
||||
|
||||
### Success Criteria - Sub-Task A
|
||||
|
||||
- [ ] All nodes render with rounded corners (radius configurable)
|
||||
- [ ] Color palette updated, passes contrast checks
|
||||
- [ ] Connection points are visually refined
|
||||
- [ ] Long port labels truncate with ellipsis
|
||||
- [ ] Full port name visible on hover
|
||||
- [ ] No visual regressions in existing projects
|
||||
- [ ] Performance unchanged (canvas render time)
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task B: Node Comments System
|
||||
|
||||
### Scope
|
||||
|
||||
Allow users to attach plain-text comments to any node, with:
|
||||
|
||||
- Small indicator icon when comment exists
|
||||
- Hover preview (debounced to avoid bombardment)
|
||||
- Click to open edit modal
|
||||
- Comments persist with project
|
||||
|
||||
### Design Decisions
|
||||
|
||||
**Storage:** `node.metadata.comment: string`
|
||||
|
||||
- Already have `metadata` object on NodeGraphNode
|
||||
- Persists with project JSON
|
||||
- No schema changes needed
|
||||
|
||||
**UI Pattern:** Icon + Hover Preview + Modal
|
||||
|
||||
- Comment icon in title bar (only shows if comment exists OR on hover)
|
||||
- Hover over icon shows preview tooltip (300ms delay)
|
||||
- Click opens sticky modal for editing
|
||||
- Modal can be dragged, stays open while working
|
||||
|
||||
**Why not inline expansion?**
|
||||
|
||||
- Would affect node measurement/layout calculations
|
||||
- Creates cascade effects on connections
|
||||
- More invasive to existing code
|
||||
|
||||
### Implementation
|
||||
|
||||
#### B1: Data Layer (1-2 hours)
|
||||
|
||||
**Add to `NodeGraphNode.ts`:**
|
||||
|
||||
```typescript
|
||||
// In metadata interface
|
||||
interface NodeMetadata {
|
||||
// ... existing fields
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
getComment(): string | undefined {
|
||||
return this.metadata?.comment;
|
||||
}
|
||||
|
||||
setComment(comment: string | undefined, args?: { undo?: boolean }) {
|
||||
if (!this.metadata) this.metadata = {};
|
||||
|
||||
const oldComment = this.metadata.comment;
|
||||
this.metadata.comment = comment || undefined; // Remove if empty
|
||||
|
||||
this.notifyListeners('commentChanged', { comment });
|
||||
|
||||
if (args?.undo) {
|
||||
UndoQueue.instance.push({
|
||||
label: 'Edit comment',
|
||||
do: () => this.setComment(comment),
|
||||
undo: () => this.setComment(oldComment)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hasComment(): boolean {
|
||||
return !!this.metadata?.comment?.trim();
|
||||
}
|
||||
```
|
||||
|
||||
#### B2: Comment Icon Rendering (2-3 hours)
|
||||
|
||||
**In `NodeGraphEditorNode.ts` paint function:**
|
||||
|
||||
```typescript
|
||||
// After drawing title, before drawing ports
|
||||
if (this.model.hasComment() || this.isHovered) {
|
||||
this.drawCommentIcon(ctx, x, y, titlebarHeight);
|
||||
}
|
||||
|
||||
private drawCommentIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number, y: number,
|
||||
titlebarHeight: number
|
||||
) {
|
||||
const iconX = x + this.nodeSize.width - 24; // Right side of title
|
||||
const iconY = y + titlebarHeight / 2;
|
||||
const hasComment = this.model.hasComment();
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = hasComment ? 1 : 0.4;
|
||||
ctx.fillStyle = hasComment ? '#ffffff' : nc.text;
|
||||
|
||||
// Draw speech bubble icon (simple path or loaded SVG)
|
||||
// ... icon drawing code
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Store hit area for click detection
|
||||
this.commentIconBounds = { x: iconX - 8, y: iconY - 8, width: 16, height: 16 };
|
||||
}
|
||||
```
|
||||
|
||||
#### B3: Hover Preview (3-4 hours)
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- 300ms delay before showing (avoid bombardment on pan/scroll)
|
||||
- Cancel if mouse leaves before delay
|
||||
- Position near node but not obscuring it
|
||||
- Max width ~250px, max height ~150px with scroll
|
||||
|
||||
**Implementation approach:**
|
||||
|
||||
- Track mouse position in `NodeGraphEditorNode.handleMouseEvent`
|
||||
- Use `setTimeout` with cleanup for debounce
|
||||
- Render preview using existing `PopupLayer.showTooltip()` or custom
|
||||
|
||||
```typescript
|
||||
// In handleMouseEvent, on 'move-in' to comment icon area:
|
||||
this.commentPreviewTimer = setTimeout(() => {
|
||||
if (this.model.hasComment()) {
|
||||
PopupLayer.instance.showTooltip({
|
||||
content: this.model.getComment(),
|
||||
position: { x: iconX, y: iconY + 20 },
|
||||
maxWidth: 250
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// On 'move-out':
|
||||
clearTimeout(this.commentPreviewTimer);
|
||||
PopupLayer.instance.hideTooltip();
|
||||
```
|
||||
|
||||
#### B4: Edit Modal (4-6 hours)
|
||||
|
||||
**Create new component:** `NodeCommentEditor.tsx`
|
||||
|
||||
```typescript
|
||||
interface NodeCommentEditorProps {
|
||||
node: NodeGraphNode;
|
||||
initialPosition: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeCommentEditor({ node, initialPosition, onClose }: NodeCommentEditorProps) {
|
||||
const [comment, setComment] = useState(node.getComment() || '');
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
|
||||
const handleSave = () => {
|
||||
node.setComment(comment.trim() || undefined, { undo: true });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable position={position} onDrag={setPosition}>
|
||||
<div className={styles.CommentEditor}>
|
||||
<div className={styles.Header}>
|
||||
<span>Comment: {node.label}</span>
|
||||
<button onClick={onClose}>×</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment to document this node..."
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.Footer}>
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Dark theme matching editor
|
||||
- ~300px wide, resizable
|
||||
- Draggable header
|
||||
- Save on Cmd+Enter
|
||||
|
||||
**Integration:**
|
||||
|
||||
- Open via `PopupLayer` or dedicated overlay
|
||||
- Track open editors to prevent duplicates
|
||||
- Close on Escape
|
||||
|
||||
#### B5: Click Handler Integration (2-3 hours)
|
||||
|
||||
**In `NodeGraphEditorNode.handleMouseEvent`:**
|
||||
|
||||
```typescript
|
||||
case 'up':
|
||||
if (this.isClickInCommentIcon(evt)) {
|
||||
this.owner.openCommentEditor(this);
|
||||
return; // Don't process as node selection
|
||||
}
|
||||
// ... existing click handling
|
||||
```
|
||||
|
||||
**In `NodeGraphEditor`:**
|
||||
|
||||
```typescript
|
||||
openCommentEditor(node: NodeGraphEditorNode) {
|
||||
const screenPos = this.canvasToScreen(node.global.x, node.global.y);
|
||||
|
||||
PopupLayer.instance.showPopup({
|
||||
content: NodeCommentEditor,
|
||||
props: {
|
||||
node: node.model,
|
||||
initialPosition: { x: screenPos.x + node.nodeSize.width + 20, y: screenPos.y }
|
||||
},
|
||||
modal: false, // Allow interaction with canvas
|
||||
closeOnOutsideClick: false
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Success Criteria - Sub-Task B
|
||||
|
||||
- [ ] Comments stored in node.metadata.comment
|
||||
- [ ] Icon visible on nodes with comments
|
||||
- [ ] Icon appears on hover for nodes without comments
|
||||
- [ ] Hover preview shows after 300ms delay
|
||||
- [ ] No preview bombardment when scrolling/panning
|
||||
- [ ] Click opens editable modal
|
||||
- [ ] Modal is draggable, stays open
|
||||
- [ ] Save with Cmd+Enter, cancel with Escape
|
||||
- [ ] Undo/redo works for comment changes
|
||||
- [ ] Comments persist when project saved/loaded
|
||||
- [ ] Comments included in copy/paste of nodes
|
||||
- [ ] Comments visible in exported project (or gracefully ignored)
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task C: Port Organization & Smart Connections
|
||||
|
||||
### Scope
|
||||
|
||||
1. **Port grouping system** for nodes with many ports
|
||||
2. **Type icons** for ports (classy, minimal)
|
||||
3. **Connection preview on hover** - highlight compatible ports
|
||||
|
||||
### Implementation
|
||||
|
||||
#### C1: Port Grouping System (6-8 hours)
|
||||
|
||||
**The challenge:** How do we define which ports belong to which group?
|
||||
|
||||
**Proposed solution:** Define groups in node type definitions.
|
||||
|
||||
**In node type registration:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'net.noodl.httpnode',
|
||||
displayName: 'HTTP Request',
|
||||
// ... existing config
|
||||
|
||||
portGroups: [
|
||||
{
|
||||
name: 'Request',
|
||||
ports: ['url', 'method', 'body', 'headers-*'], // Wildcard for dynamic ports
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Response',
|
||||
ports: ['status', 'response', 'headers'],
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
ports: ['send', 'success', 'failure'],
|
||||
defaultExpanded: true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**For nodes without explicit groups:** Auto-group by:
|
||||
|
||||
- Signal ports (Run, Do, Done, Success, Failure)
|
||||
- Data inputs
|
||||
- Data outputs
|
||||
|
||||
**Rendering changes in `NodeGraphEditorNode.ts`:**
|
||||
|
||||
```typescript
|
||||
interface PortGroup {
|
||||
name: string;
|
||||
ports: PlugInfo[];
|
||||
expanded: boolean;
|
||||
y: number; // Calculated position
|
||||
}
|
||||
|
||||
private portGroups: PortGroup[] = [];
|
||||
|
||||
measure() {
|
||||
// Build groups from node type config or auto-detect
|
||||
this.portGroups = this.buildPortGroups();
|
||||
|
||||
// Calculate height based on expanded groups
|
||||
let height = this.titlebarHeight();
|
||||
for (const group of this.portGroups) {
|
||||
height += GROUP_HEADER_HEIGHT;
|
||||
if (group.expanded) {
|
||||
height += group.ports.length * NodeGraphEditorNode.propertyConnectionHeight;
|
||||
}
|
||||
}
|
||||
|
||||
this.nodeSize.height = height;
|
||||
// ...
|
||||
}
|
||||
|
||||
private drawPortGroups(ctx: CanvasRenderingContext2D) {
|
||||
let y = this.titlebarHeight();
|
||||
|
||||
for (const group of this.portGroups) {
|
||||
// Draw group header with expand/collapse arrow
|
||||
this.drawGroupHeader(ctx, group, y);
|
||||
y += GROUP_HEADER_HEIGHT;
|
||||
|
||||
if (group.expanded) {
|
||||
for (const port of group.ports) {
|
||||
this.drawPort(ctx, port, y);
|
||||
y += NodeGraphEditorNode.propertyConnectionHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Group header click handling:**
|
||||
|
||||
- Click toggles expanded state
|
||||
- State stored in view (not model) - doesn't persist
|
||||
|
||||
**Fallback:** Nodes without groups render exactly as before (flat list).
|
||||
|
||||
#### C2: Port Type Icons (4-6 hours)
|
||||
|
||||
**Design principle:** Minimal, monochrome, recognizable at small sizes.
|
||||
|
||||
**Icon set (12x12px or smaller):**
|
||||
| Type | Icon | Description |
|
||||
|------|------|-------------|
|
||||
| Signal | `⚡` or lightning bolt | Trigger/event |
|
||||
| String | `T` or `""` | Text data |
|
||||
| Number | `#` | Numeric data |
|
||||
| Boolean | `◐` | True/false (half-filled circle) |
|
||||
| Object | `{ }` | Object/record |
|
||||
| Array | `[ ]` | List/collection |
|
||||
| Color | `◉` | Filled circle (could show actual color) |
|
||||
| Any | `◇` | Diamond (accepts anything) |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Create SVG icons, convert to Canvas-drawable paths
|
||||
- Or use a minimal icon font
|
||||
- Draw before/instead of colored dot
|
||||
|
||||
```typescript
|
||||
private drawPortIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
type: string,
|
||||
x: number, y: number,
|
||||
connected: boolean
|
||||
) {
|
||||
const icon = PORT_TYPE_ICONS[type] || PORT_TYPE_ICONS.any;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = connected ? connectionColor : '#666';
|
||||
ctx.font = '10px Inter-Regular';
|
||||
ctx.fillText(icon.char, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative:** Small inline SVG paths drawn with Canvas path commands.
|
||||
|
||||
#### C3: Connection Preview on Hover (5-6 hours)
|
||||
|
||||
**Behavior:**
|
||||
|
||||
1. User hovers over an output port
|
||||
2. All compatible input ports on other nodes highlight
|
||||
3. Incompatible ports dim or show "incompatible" indicator
|
||||
4. Works in reverse (hover input, show compatible outputs)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// In NodeGraphEditor
|
||||
private highlightedPort: { node: NodeGraphEditorNode; port: string; side: 'input' | 'output' } | null = null;
|
||||
|
||||
setHighlightedPort(node: NodeGraphEditorNode, portName: string, side: 'input' | 'output') {
|
||||
this.highlightedPort = { node, port: portName, side };
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
clearHighlightedPort() {
|
||||
this.highlightedPort = null;
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
// In paint loop, for each node's ports:
|
||||
if (this.highlightedPort) {
|
||||
const compatibility = this.getPortCompatibility(
|
||||
this.highlightedPort,
|
||||
currentNode,
|
||||
currentPort
|
||||
);
|
||||
|
||||
if (compatibility === 'compatible') {
|
||||
// Draw with highlight glow
|
||||
} else if (compatibility === 'incompatible') {
|
||||
// Draw dimmed
|
||||
}
|
||||
// 'source' = this is the hovered port, draw normal
|
||||
}
|
||||
|
||||
getPortCompatibility(source, targetNode, targetPort): 'compatible' | 'incompatible' | 'source' {
|
||||
if (source.node === targetNode && source.port === targetPort) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
// Can't connect to same node
|
||||
if (source.node === targetNode) {
|
||||
return 'incompatible';
|
||||
}
|
||||
|
||||
// Check type compatibility
|
||||
const sourceType = source.node.model.getPort(source.port)?.type;
|
||||
const targetType = targetNode.model.getPort(targetPort)?.type;
|
||||
|
||||
return NodeLibrary.instance.canConnect(sourceType, targetType)
|
||||
? 'compatible'
|
||||
: 'incompatible';
|
||||
}
|
||||
```
|
||||
|
||||
**Visual treatment:**
|
||||
|
||||
- Compatible: Subtle pulse/glow animation, brighter color
|
||||
- Incompatible: 50% opacity, greyed out
|
||||
- Draw connection preview line from source to mouse cursor
|
||||
|
||||
### Success Criteria - Sub-Task C
|
||||
|
||||
- [ ] Port groups configurable in node type definitions
|
||||
- [ ] Auto-grouping fallback for unconfigured nodes
|
||||
- [ ] Groups collapsible with click
|
||||
- [ ] Group state doesn't affect existing projects
|
||||
- [ ] Port type icons render clearly at small sizes
|
||||
- [ ] Icons follow design system (not emoji-style)
|
||||
- [ ] Hovering output port highlights compatible inputs
|
||||
- [ ] Hovering input port highlights compatible outputs
|
||||
- [ ] Incompatible ports visually dimmed
|
||||
- [ ] Preview works during connection drag
|
||||
- [ ] Performance acceptable with many nodes visible
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
├── nodegrapheditor/
|
||||
│ ├── NodeCommentEditor.tsx # Comment edit modal
|
||||
│ ├── NodeCommentEditor.module.scss # Styles
|
||||
│ ├── canvasHelpers.ts # roundRect, truncateText utilities
|
||||
│ └── portIcons.ts # SVG paths for port type icons
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
├── nodegrapheditor.ts # Connection preview logic
|
||||
├── nodegrapheditor/
|
||||
│ ├── NodeGraphEditorNode.ts # PRIMARY: All rendering changes
|
||||
│ └── NodeGraphEditorConnection.ts # Minor: Updated colors
|
||||
|
||||
packages/noodl-editor/src/editor/src/models/
|
||||
├── nodegraphmodel/NodeGraphNode.ts # Comment storage methods
|
||||
|
||||
packages/noodl-core-ui/src/styles/custom-properties/
|
||||
├── colors.css # Updated palette
|
||||
|
||||
packages/noodl-editor/src/editor/src/models/
|
||||
├── nodelibrary/index.ts # Port group definitions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Polish
|
||||
|
||||
- [ ] Rounded corners render correctly at all zoom levels
|
||||
- [ ] Colors match design system, sufficient contrast
|
||||
- [ ] Connection points visible and clickable
|
||||
- [ ] Truncated labels show tooltip on hover
|
||||
- [ ] Selection/error states still visible with new styling
|
||||
|
||||
### Node Comments
|
||||
|
||||
- [ ] Create comment on node without existing comment
|
||||
- [ ] Edit existing comment
|
||||
- [ ] Delete comment (clear text)
|
||||
- [ ] Undo/redo comment changes
|
||||
- [ ] Comment persists after save/reload
|
||||
- [ ] Comment included when copying node
|
||||
- [ ] Hover preview appears after delay
|
||||
- [ ] No preview spam when panning quickly
|
||||
- [ ] Modal draggable and stays open
|
||||
- [ ] Multiple comment modals can be open
|
||||
|
||||
### Port Organization
|
||||
|
||||
- [ ] Grouped ports render correctly
|
||||
- [ ] Ungrouped nodes unchanged
|
||||
- [ ] Collapse/expand works
|
||||
- [ ] Node height adjusts correctly
|
||||
- [ ] Connections still work with grouped ports
|
||||
- [ ] Port icons render at all zoom levels
|
||||
- [ ] Connection preview highlights correct ports
|
||||
- [ ] Performance acceptable with 50+ visible nodes
|
||||
|
||||
### Regression Testing
|
||||
|
||||
- [ ] Open existing complex project
|
||||
- [ ] All nodes render correctly
|
||||
- [ ] All connections intact
|
||||
- [ ] Copy/paste works
|
||||
- [ ] Undo/redo works
|
||||
- [ ] No console errors
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
| ------------------------------------------- | ---------- | ------ | ------------------------------------------------- |
|
||||
| Performance regression with rounded corners | Low | Medium | Profile canvas render time, optimize path caching |
|
||||
| Port grouping breaks connection logic | Medium | High | Extensive testing, feature flag for rollback |
|
||||
| Comment data loss on export | Low | High | Verify metadata included in all export paths |
|
||||
| Hover preview annoying | Medium | Low | Configurable delay, easy to disable |
|
||||
| Color changes controversial | Medium | Low | Document old colors, provide theme option |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Blocked by:** None
|
||||
|
||||
**Blocks:** None (standalone visual improvements)
|
||||
|
||||
**Related:**
|
||||
|
||||
- Phase 3 design system work (colors should align)
|
||||
- Future node editor enhancements
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- Markdown support in comments
|
||||
- Comment search/filter
|
||||
- Comment export to documentation
|
||||
- Custom node colors per-instance
|
||||
- Animated connections
|
||||
- Minimap improvements
|
||||
- Node grouping/frames (separate feature)
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Current node rendering: `NodeGraphEditorNode.ts` paint() method
|
||||
- Color system: `colors.css` and `NodeLibrary.colorSchemeForNodeType()`
|
||||
- Existing comment layer: `commentlayer.ts` (for patterns, not reuse)
|
||||
- Canvas roundRect API: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect
|
||||
@@ -0,0 +1,472 @@
|
||||
# TASK-000I-A: Node Graph Visual Polish
|
||||
|
||||
**Parent Task:** TASK-009I Node Graph Visual Improvements
|
||||
**Estimated Time:** 8-12 hours
|
||||
**Risk Level:** Low
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Modernize the visual appearance of nodes on the canvas without changing functionality. This is a purely cosmetic update that improves the perceived quality and modernity of the editor.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Rounded corners** on all node rectangles
|
||||
2. **Updated color palette** following design system
|
||||
3. **Refined connection points** (port dots/arrows)
|
||||
4. **Port label truncation** with ellipsis for overflow
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Node sizing changes
|
||||
- Layout algorithm changes
|
||||
- New functionality
|
||||
- Port grouping (Sub-Task C)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase A1: Rounded Corners (2-3 hours)
|
||||
|
||||
#### Current Code
|
||||
|
||||
In `NodeGraphEditorNode.ts` paint() method:
|
||||
|
||||
```typescript
|
||||
// Background - sharp corners
|
||||
ctx.fillStyle = nc.header;
|
||||
ctx.fillRect(x, y, this.nodeSize.width, this.nodeSize.height);
|
||||
|
||||
// Border - sharp corners
|
||||
ctx.rect(x, y, this.nodeSize.width, this.nodeSize.height);
|
||||
ctx.stroke();
|
||||
```
|
||||
|
||||
#### New Approach
|
||||
|
||||
**Create helper file** `canvasHelpers.ts`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Draw a rounded rectangle path
|
||||
* Uses native roundRect if available, falls back to arcTo
|
||||
*/
|
||||
export function roundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number | { tl: number; tr: number; br: number; bl: number }
|
||||
): void {
|
||||
const r = typeof radius === 'number' ? { tl: radius, tr: radius, br: radius, bl: radius } : radius;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r.tl, y);
|
||||
ctx.lineTo(x + width - r.tr, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + r.tr, r.tr);
|
||||
ctx.lineTo(x + width, y + height - r.br);
|
||||
ctx.arcTo(x + width, y + height, x + width - r.br, y + height, r.br);
|
||||
ctx.lineTo(x + r.bl, y + height);
|
||||
ctx.arcTo(x, y + height, x, y + height - r.bl, r.bl);
|
||||
ctx.lineTo(x, y + r.tl);
|
||||
ctx.arcTo(x, y, x + r.tl, y, r.tl);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a rounded rectangle
|
||||
*/
|
||||
export function fillRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number
|
||||
): void {
|
||||
roundRect(ctx, x, y, width, height, radius);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stroke a rounded rectangle
|
||||
*/
|
||||
export function strokeRoundRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number
|
||||
): void {
|
||||
roundRect(ctx, x, y, width, height, radius);
|
||||
ctx.stroke();
|
||||
}
|
||||
```
|
||||
|
||||
#### Changes to NodeGraphEditorNode.ts
|
||||
|
||||
```typescript
|
||||
import { fillRoundRect, strokeRoundRect } from './canvasHelpers';
|
||||
|
||||
// Constants
|
||||
const NODE_CORNER_RADIUS = 6;
|
||||
|
||||
// In paint() method:
|
||||
|
||||
// Background - replace fillRect
|
||||
ctx.fillStyle = nc.header;
|
||||
fillRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NODE_CORNER_RADIUS);
|
||||
|
||||
// Body area - need to clip to rounded shape
|
||||
ctx.save();
|
||||
roundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NODE_CORNER_RADIUS);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = nc.base;
|
||||
ctx.fillRect(x, y + titlebarHeight, this.nodeSize.width, this.nodeSize.height - titlebarHeight);
|
||||
ctx.restore();
|
||||
|
||||
// Selection border
|
||||
if (this.selected || this.borderHighlighted) {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
strokeRoundRect(ctx, x, y, this.nodeSize.width, this.nodeSize.height, NODE_CORNER_RADIUS);
|
||||
}
|
||||
|
||||
// Error border
|
||||
if (!health.healthy) {
|
||||
ctx.setLineDash([5]);
|
||||
ctx.strokeStyle = '#F57569';
|
||||
strokeRoundRect(ctx, x - 1, y - 1, this.nodeSize.width + 2, this.nodeSize.height + 2, NODE_CORNER_RADIUS + 1);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
```
|
||||
|
||||
#### Locations to Update
|
||||
|
||||
1. **Node background** (~line 220)
|
||||
2. **Node body fill** (~line 230)
|
||||
3. **Highlight overlay** (~line 240)
|
||||
4. **Selection border** (~line 290)
|
||||
5. **Error/unhealthy border** (~line 280)
|
||||
6. **Annotation borders** (~line 300)
|
||||
|
||||
#### Testing
|
||||
|
||||
- [ ] Nodes render with rounded corners at 100% zoom
|
||||
- [ ] Corners visible at 50% zoom
|
||||
- [ ] Corners not distorted at 150% zoom
|
||||
- [ ] Selection highlight follows rounded shape
|
||||
- [ ] Error dashed border follows rounded shape
|
||||
- [ ] No visual artifacts at corner intersections
|
||||
|
||||
---
|
||||
|
||||
### Phase A2: Color Palette Update (2-3 hours)
|
||||
|
||||
#### File to Modify
|
||||
|
||||
`packages/noodl-core-ui/src/styles/custom-properties/colors.css`
|
||||
|
||||
#### Current vs Proposed
|
||||
|
||||
Document current values first, then update:
|
||||
|
||||
```css
|
||||
/* ===== NODE COLORS ===== */
|
||||
|
||||
/* Data nodes - Green */
|
||||
/* Current: muted olive */
|
||||
/* Proposed: richer emerald */
|
||||
--base-color-node-green-900: #052e16;
|
||||
--base-color-node-green-700: #166534;
|
||||
--base-color-node-green-600: #16a34a;
|
||||
--base-color-node-green-500: #22c55e;
|
||||
|
||||
/* Visual nodes - Blue */
|
||||
/* Current: muted blue */
|
||||
/* Proposed: cleaner slate */
|
||||
--base-color-node-blue-900: #0f172a;
|
||||
--base-color-node-blue-700: #334155;
|
||||
--base-color-node-blue-600: #475569;
|
||||
--base-color-node-blue-500: #64748b;
|
||||
--base-color-node-blue-400: #94a3b8;
|
||||
--base-color-node-blue-300: #cbd5e1;
|
||||
--base-color-node-blue-200: #e2e8f0;
|
||||
|
||||
/* Logic nodes - Grey */
|
||||
/* Current: flat grey */
|
||||
/* Proposed: warmer zinc */
|
||||
--base-color-node-grey-900: #18181b;
|
||||
--base-color-node-grey-700: #3f3f46;
|
||||
--base-color-node-grey-600: #52525b;
|
||||
|
||||
/* Custom nodes - Pink */
|
||||
/* Current: magenta */
|
||||
/* Proposed: refined rose */
|
||||
--base-color-node-pink-900: #4c0519;
|
||||
--base-color-node-pink-700: #be123c;
|
||||
--base-color-node-pink-600: #e11d48;
|
||||
|
||||
/* Component nodes - Purple */
|
||||
/* Current: muted purple */
|
||||
/* Proposed: cleaner violet */
|
||||
--base-color-node-purple-900: #2e1065;
|
||||
--base-color-node-purple-700: #6d28d9;
|
||||
--base-color-node-purple-600: #7c3aed;
|
||||
```
|
||||
|
||||
#### Process
|
||||
|
||||
1. **Document current** - Screenshot and hex values
|
||||
2. **Design new palette** - Use design system principles
|
||||
3. **Update CSS variables** - One category at a time
|
||||
4. **Test contrast** - WCAG AA minimum (4.5:1 for text)
|
||||
5. **Visual review** - Check all node types
|
||||
|
||||
#### Contrast Checking
|
||||
|
||||
Use browser dev tools or online checker:
|
||||
|
||||
- Header text on header background
|
||||
- Port labels on body background
|
||||
- Selection highlight visibility
|
||||
|
||||
#### Testing
|
||||
|
||||
- [ ] Data nodes (green) - legible, modern
|
||||
- [ ] Visual nodes (blue) - legible, modern
|
||||
- [ ] Logic nodes (grey) - legible, modern
|
||||
- [ ] Custom nodes (pink) - legible, modern
|
||||
- [ ] Component nodes (purple) - legible, modern
|
||||
- [ ] All text passes contrast check
|
||||
- [ ] Colors distinguish node types clearly
|
||||
|
||||
---
|
||||
|
||||
### Phase A3: Connection Point Styling (2-3 hours)
|
||||
|
||||
#### Current Implementation
|
||||
|
||||
In `NodeGraphEditorNode.ts` drawPlugs():
|
||||
|
||||
```typescript
|
||||
function dot(side, color) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + (side === 'left' ? 0 : _this.nodeSize.width), ty, 4, 0, 2 * Math.PI, false);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function arrow(side, color) {
|
||||
const dx = side === 'left' ? 4 : -4;
|
||||
const cx = x + (side === 'left' ? 0 : _this.nodeSize.width);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - dx, ty - 4);
|
||||
ctx.lineTo(cx + dx, ty);
|
||||
ctx.lineTo(cx - dx, ty + 4);
|
||||
ctx.fill();
|
||||
}
|
||||
```
|
||||
|
||||
#### Improvements
|
||||
|
||||
```typescript
|
||||
const PORT_RADIUS = 5; // Increased from 4
|
||||
const PORT_INNER_RADIUS = 2;
|
||||
|
||||
function drawPort(side: 'left' | 'right', type: 'dot' | 'arrow', color: string, connected: boolean) {
|
||||
const cx = x + (side === 'left' ? 0 : _this.nodeSize.width);
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (type === 'dot') {
|
||||
// Outer circle
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, ty, PORT_RADIUS, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// Inner highlight (connected state)
|
||||
if (connected) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, ty, PORT_INNER_RADIUS, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
} else {
|
||||
// Arrow (signal)
|
||||
const dx = side === 'left' ? PORT_RADIUS : -PORT_RADIUS;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - dx, ty - PORT_RADIUS);
|
||||
ctx.lineTo(cx + dx, ty);
|
||||
ctx.lineTo(cx - dx, ty + PORT_RADIUS);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
|
||||
- [ ] Port dots larger and easier to click
|
||||
- [ ] Connected ports have visual distinction
|
||||
- [ ] Arrows properly sized
|
||||
- [ ] Hit detection still works
|
||||
- [ ] Dragging connections works
|
||||
- [ ] Hover states visible
|
||||
|
||||
---
|
||||
|
||||
### Phase A4: Port Label Truncation (2-3 hours)
|
||||
|
||||
#### Problem
|
||||
|
||||
Long port names overflow the node boundary, appearing outside the node rectangle.
|
||||
|
||||
#### Solution
|
||||
|
||||
**Add to canvasHelpers.ts:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Truncate text to fit within maxWidth, adding ellipsis if needed
|
||||
*/
|
||||
export function truncateText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const ellipsis = '…';
|
||||
let truncated = text;
|
||||
|
||||
while (truncated.length > 0) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
if (ctx.measureText(truncated + ellipsis).width <= maxWidth) {
|
||||
return truncated + ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
return ellipsis;
|
||||
}
|
||||
```
|
||||
|
||||
#### Integration in drawPlugs()
|
||||
|
||||
```typescript
|
||||
// Calculate available width for label
|
||||
const labelMaxWidth =
|
||||
side === 'left'
|
||||
? _this.nodeSize.width / 2 - horizontalSpacing - PORT_RADIUS
|
||||
: _this.nodeSize.width / 2 - horizontalSpacing - PORT_RADIUS;
|
||||
|
||||
// Truncate if needed
|
||||
const displayName = truncateText(ctx, p.displayName || p.property, labelMaxWidth);
|
||||
ctx.fillText(displayName, tx, ty);
|
||||
|
||||
// Store full name for tooltip
|
||||
p.fullDisplayName = p.displayName || p.property;
|
||||
```
|
||||
|
||||
#### Tooltip Integration
|
||||
|
||||
Verify existing tooltip system shows full port name on hover. If not working:
|
||||
|
||||
```typescript
|
||||
// In handleMouseEvent, on port hover:
|
||||
if (p.fullDisplayName !== displayName) {
|
||||
PopupLayer.instance.showTooltip({
|
||||
content: p.fullDisplayName,
|
||||
position: { x: mouseX, y: mouseY }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
|
||||
- [ ] Long labels truncate with ellipsis
|
||||
- [ ] Short labels unchanged
|
||||
- [ ] Truncation respects node width
|
||||
- [ ] Tooltip shows full name on hover
|
||||
- [ ] Left and right aligned labels both work
|
||||
- [ ] No text overflow outside node bounds
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
└── canvasHelpers.ts # Utility functions
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
└── NodeGraphEditorNode.ts # Main rendering changes
|
||||
|
||||
packages/noodl-core-ui/src/styles/custom-properties/
|
||||
└── colors.css # Color palette updates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Verification
|
||||
|
||||
- [ ] Open existing project with many node types
|
||||
- [ ] All nodes render with rounded corners
|
||||
- [ ] Colors updated and consistent
|
||||
- [ ] Port indicators refined
|
||||
- [ ] Labels truncate properly
|
||||
|
||||
### Functional Verification
|
||||
|
||||
- [ ] Node selection works
|
||||
- [ ] Connection dragging works
|
||||
- [ ] Copy/paste works
|
||||
- [ ] Undo/redo works
|
||||
- [ ] Zoom in/out renders correctly
|
||||
|
||||
### Performance
|
||||
|
||||
- [ ] No noticeable slowdown
|
||||
- [ ] Smooth panning with 50+ nodes
|
||||
- [ ] Profile render time if concerned
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] All nodes have rounded corners (6px radius)
|
||||
- [ ] Color palette modernized
|
||||
- [ ] Port indicators larger and cleaner
|
||||
- [ ] Long labels truncate with ellipsis
|
||||
- [ ] Full port name visible on hover
|
||||
- [ ] No visual regressions
|
||||
- [ ] No functional regressions
|
||||
- [ ] Performance unchanged
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise:
|
||||
|
||||
1. Revert `NodeGraphEditorNode.ts` changes
|
||||
2. Revert `colors.css` changes
|
||||
3. Delete `canvasHelpers.ts`
|
||||
|
||||
All changes are isolated to rendering code with no data model changes.
|
||||
@@ -0,0 +1,786 @@
|
||||
# TASK-009I-B: Node Comments System
|
||||
|
||||
**Parent Task:** TASK-000I Node Graph Visual Improvements
|
||||
**Estimated Time:** 12-18 hours
|
||||
**Risk Level:** Medium
|
||||
**Dependencies:** None (can be done in parallel with A)
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Allow users to attach plain-text documentation to individual nodes, making it easier to understand and maintain complex node graphs, especially when picking up someone else's project.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Data storage** - Comments stored in node metadata
|
||||
2. **Visual indicator** - Icon shows when node has comment
|
||||
3. **Hover preview** - Quick preview with debounce (no spam)
|
||||
4. **Edit modal** - Draggable editor for writing comments
|
||||
5. **Persistence** - Comments save with project
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Markdown formatting
|
||||
- Rich text
|
||||
- Comment threading/replies
|
||||
- Search across comments
|
||||
- Character limits
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
| ---------------- | ----------------------- | ---------------------------------------------------- |
|
||||
| Storage location | `node.metadata.comment` | Existing structure, persists automatically |
|
||||
| Preview trigger | Hover with 300ms delay | Balance between accessible and not annoying |
|
||||
| Edit trigger | Click on icon | Explicit action, won't interfere with node selection |
|
||||
| Modal behavior | Draggable, stays open | User can see context while editing |
|
||||
| Text format | Plain text, no limit | Simple, no parsing overhead |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase B1: Data Layer (1-2 hours)
|
||||
|
||||
#### File: `NodeGraphNode.ts`
|
||||
|
||||
**Add to metadata interface** (if typed):
|
||||
|
||||
```typescript
|
||||
interface NodeMetadata {
|
||||
// ... existing fields
|
||||
comment?: string;
|
||||
colorOverride?: string;
|
||||
typeLabelOverride?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Add helper methods:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Get the comment attached to this node
|
||||
*/
|
||||
getComment(): string | undefined {
|
||||
return this.metadata?.comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node has a non-empty comment
|
||||
*/
|
||||
hasComment(): boolean {
|
||||
return !!this.metadata?.comment?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the comment on this node
|
||||
* @param comment - The comment text, or undefined/empty to clear
|
||||
* @param args - Options including undo support
|
||||
*/
|
||||
setComment(comment: string | undefined, args?: { undo?: boolean; label?: string }): void {
|
||||
const oldComment = this.metadata?.comment;
|
||||
const newComment = comment?.trim() || undefined;
|
||||
|
||||
// No change
|
||||
if (oldComment === newComment) return;
|
||||
|
||||
// Initialize metadata if needed
|
||||
if (!this.metadata) {
|
||||
this.metadata = {};
|
||||
}
|
||||
|
||||
// Set or delete
|
||||
if (newComment) {
|
||||
this.metadata.comment = newComment;
|
||||
} else {
|
||||
delete this.metadata.comment;
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('metadataChanged', { key: 'comment', data: newComment });
|
||||
|
||||
// Undo support
|
||||
if (args?.undo) {
|
||||
const _this = this;
|
||||
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
|
||||
|
||||
undo.push({
|
||||
label: args.label || 'Edit comment',
|
||||
do: () => _this.setComment(newComment),
|
||||
undo: () => _this.setComment(oldComment)
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Verify Persistence
|
||||
|
||||
Comments should automatically persist because:
|
||||
|
||||
1. `metadata` is included in `toJSON()`
|
||||
2. `metadata` is restored in constructor/fromJSON
|
||||
|
||||
**Test by:**
|
||||
|
||||
1. Add comment to node
|
||||
2. Save project
|
||||
3. Close and reopen
|
||||
4. Verify comment still exists
|
||||
|
||||
#### Verify Copy/Paste
|
||||
|
||||
When nodes are copied, metadata should be included.
|
||||
|
||||
**Check in** `NodeGraphEditor.ts` or `NodeGraphModel.ts`:
|
||||
|
||||
- `copySelected()`
|
||||
- `getNodeSetFromClipboard()`
|
||||
- `insertNodeSet()`
|
||||
|
||||
---
|
||||
|
||||
### Phase B2: Comment Icon Rendering (2-3 hours)
|
||||
|
||||
#### Icon Design
|
||||
|
||||
Simple speech bubble icon, rendered via Canvas path:
|
||||
|
||||
```typescript
|
||||
// In NodeGraphEditorNode.ts or separate file
|
||||
|
||||
const COMMENT_ICON_SIZE = 14;
|
||||
|
||||
function drawCommentIcon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
filled: boolean,
|
||||
alpha: number = 1
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Speech bubble path (14x14)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 2, y + 2);
|
||||
ctx.lineTo(x + 12, y + 2);
|
||||
ctx.quadraticCurveTo(x + 14, y + 2, x + 14, y + 4);
|
||||
ctx.lineTo(x + 14, y + 9);
|
||||
ctx.quadraticCurveTo(x + 14, y + 11, x + 12, y + 11);
|
||||
ctx.lineTo(x + 6, y + 11);
|
||||
ctx.lineTo(x + 3, y + 14);
|
||||
ctx.lineTo(x + 3, y + 11);
|
||||
ctx.lineTo(x + 2, y + 11);
|
||||
ctx.quadraticCurveTo(x, y + 11, x, y + 9);
|
||||
ctx.lineTo(x, y + 4);
|
||||
ctx.quadraticCurveTo(x, y + 2, x + 2, y + 2);
|
||||
ctx.closePath();
|
||||
|
||||
if (filled) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
#### Integration in paint()
|
||||
|
||||
```typescript
|
||||
// After drawing title, in paint() method
|
||||
|
||||
// Comment icon position - right side of title bar
|
||||
const commentIconX = x + this.nodeSize.width - COMMENT_ICON_SIZE - 8;
|
||||
const commentIconY = y + 6;
|
||||
|
||||
// Store bounds for hit detection
|
||||
this.commentIconBounds = {
|
||||
x: commentIconX - 4,
|
||||
y: commentIconY - 4,
|
||||
width: COMMENT_ICON_SIZE + 8,
|
||||
height: COMMENT_ICON_SIZE + 8
|
||||
};
|
||||
|
||||
// Draw icon
|
||||
const hasComment = this.model.hasComment();
|
||||
const isHoveringIcon = this.isHoveringCommentIcon;
|
||||
|
||||
if (hasComment) {
|
||||
// Always show filled icon if comment exists
|
||||
drawCommentIcon(ctx, commentIconX, commentIconY, true, 1);
|
||||
} else if (isHoveringIcon || this.owner.isHighlighted(this)) {
|
||||
// Show outline icon on hover
|
||||
drawCommentIcon(ctx, commentIconX, commentIconY, false, 0.5);
|
||||
}
|
||||
```
|
||||
|
||||
#### Hit Detection
|
||||
|
||||
Add bounds checking in `handleMouseEvent`:
|
||||
|
||||
```typescript
|
||||
private isPointInCommentIcon(x: number, y: number): boolean {
|
||||
if (!this.commentIconBounds) return false;
|
||||
|
||||
const b = this.commentIconBounds;
|
||||
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase B3: Hover Preview (3-4 hours)
|
||||
|
||||
#### Requirements
|
||||
|
||||
- 300ms delay before showing
|
||||
- Cancel if mouse leaves before delay
|
||||
- Clear on pan/zoom
|
||||
- Max dimensions with scroll for long comments
|
||||
- Position near icon, not obscuring node
|
||||
|
||||
#### State Management
|
||||
|
||||
```typescript
|
||||
// In NodeGraphEditorNode.ts
|
||||
|
||||
private commentPreviewTimer: NodeJS.Timeout | null = null;
|
||||
private isHoveringCommentIcon: boolean = false;
|
||||
|
||||
private showCommentPreview(): void {
|
||||
if (!this.model.hasComment()) return;
|
||||
|
||||
const comment = this.model.getComment();
|
||||
const screenPos = this.owner.canvasToScreen(
|
||||
this.global.x + this.nodeSize.width,
|
||||
this.global.y
|
||||
);
|
||||
|
||||
PopupLayer.instance.showTooltip({
|
||||
content: this.createPreviewContent(comment),
|
||||
position: { x: screenPos.x + 10, y: screenPos.y },
|
||||
maxWidth: 250,
|
||||
maxHeight: 150
|
||||
});
|
||||
}
|
||||
|
||||
private createPreviewContent(comment: string): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'node-comment-preview';
|
||||
div.style.cssText = `
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
div.textContent = comment;
|
||||
return div;
|
||||
}
|
||||
|
||||
private hideCommentPreview(): void {
|
||||
PopupLayer.instance.hideTooltip();
|
||||
}
|
||||
|
||||
private cancelCommentPreviewTimer(): void {
|
||||
if (this.commentPreviewTimer) {
|
||||
clearTimeout(this.commentPreviewTimer);
|
||||
this.commentPreviewTimer = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mouse Event Handling
|
||||
|
||||
```typescript
|
||||
// In handleMouseEvent()
|
||||
|
||||
case 'move':
|
||||
const inCommentIcon = this.isPointInCommentIcon(localX, localY);
|
||||
|
||||
if (inCommentIcon && !this.isHoveringCommentIcon) {
|
||||
// Entered comment icon area
|
||||
this.isHoveringCommentIcon = true;
|
||||
this.owner.repaint();
|
||||
|
||||
// Start preview timer
|
||||
if (this.model.hasComment()) {
|
||||
this.cancelCommentPreviewTimer();
|
||||
this.commentPreviewTimer = setTimeout(() => {
|
||||
this.showCommentPreview();
|
||||
}, 300);
|
||||
}
|
||||
} else if (!inCommentIcon && this.isHoveringCommentIcon) {
|
||||
// Left comment icon area
|
||||
this.isHoveringCommentIcon = false;
|
||||
this.cancelCommentPreviewTimer();
|
||||
this.hideCommentPreview();
|
||||
this.owner.repaint();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'move-out':
|
||||
// Clear all hover states
|
||||
this.isHoveringCommentIcon = false;
|
||||
this.cancelCommentPreviewTimer();
|
||||
this.hideCommentPreview();
|
||||
break;
|
||||
```
|
||||
|
||||
#### Clear on Pan/Zoom
|
||||
|
||||
In `NodeGraphEditor.ts`, when pan/zoom starts:
|
||||
|
||||
```typescript
|
||||
// In mouse wheel handler or pan start
|
||||
this.forEachNode((node) => {
|
||||
node.cancelCommentPreviewTimer?.();
|
||||
node.hideCommentPreview?.();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase B4: Edit Modal (4-6 hours)
|
||||
|
||||
#### Create Component
|
||||
|
||||
**File:** `views/nodegrapheditor/NodeCommentEditor.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
import { NodeGraphNode } from '@noodl-models/nodegraphmodel';
|
||||
|
||||
import styles from './NodeCommentEditor.module.scss';
|
||||
|
||||
export interface NodeCommentEditorProps {
|
||||
node: NodeGraphNode;
|
||||
initialPosition: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NodeCommentEditor({ node, initialPosition, onClose }: NodeCommentEditorProps) {
|
||||
const [comment, setComment] = useState(node.getComment() || '');
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-focus textarea
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
}, []);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(() => {
|
||||
node.setComment(comment, { undo: true, label: 'Edit node comment' });
|
||||
onClose();
|
||||
}, [node, comment, onClose]);
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleCancel, handleSave]
|
||||
);
|
||||
|
||||
// Dragging handlers
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('textarea, button')) return;
|
||||
|
||||
setIsDragging(true);
|
||||
setDragOffset({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y
|
||||
});
|
||||
},
|
||||
[position]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setPosition({
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
return (
|
||||
<div className={styles.CommentEditor} style={{ left: position.x, top: position.y }} onKeyDown={handleKeyDown}>
|
||||
<div className={styles.Header} onMouseDown={handleDragStart}>
|
||||
<span className={styles.Title}>Comment: {node.label}</span>
|
||||
<button className={styles.CloseButton} onClick={handleCancel} title="Close (Escape)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={styles.TextArea}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment to document this node..."
|
||||
/>
|
||||
|
||||
<div className={styles.Footer}>
|
||||
<span className={styles.Hint}>{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to save</span>
|
||||
<div className={styles.Buttons}>
|
||||
<button className={styles.CancelButton} onClick={handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={styles.SaveButton} onClick={handleSave}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Styles
|
||||
|
||||
**File:** `views/nodegrapheditor/NodeCommentEditor.module.scss`
|
||||
|
||||
```scss
|
||||
.CommentEditor {
|
||||
position: fixed;
|
||||
width: 320px;
|
||||
background: var(--theme-color-bg-2);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--theme-color-bg-3);
|
||||
border-bottom: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.Title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--theme-color-fg-default);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.CloseButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.TextArea {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
max-height: 300px;
|
||||
margin: 12px;
|
||||
padding: 10px;
|
||||
background: var(--theme-color-bg-1);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-color-fg-default);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.Footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--theme-color-border-default);
|
||||
}
|
||||
|
||||
.Hint {
|
||||
font-size: 11px;
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
|
||||
.Buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.CancelButton,
|
||||
.SaveButton {
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.CancelButton {
|
||||
background: var(--theme-color-bg-3);
|
||||
border: 1px solid var(--theme-color-border-default);
|
||||
color: var(--theme-color-fg-default);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
.SaveButton {
|
||||
background: var(--theme-color-primary);
|
||||
border: none;
|
||||
color: var(--theme-color-on-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-color-primary-highlight);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase B5: Click Handler Integration (2-3 hours)
|
||||
|
||||
#### Open Modal on Click
|
||||
|
||||
In `NodeGraphEditorNode.ts` handleMouseEvent():
|
||||
|
||||
```typescript
|
||||
case 'up':
|
||||
// Check comment icon click FIRST
|
||||
if (this.isPointInCommentIcon(localX, localY)) {
|
||||
this.owner.openCommentEditor(this);
|
||||
return; // Don't process as node selection
|
||||
}
|
||||
|
||||
// ... existing click handling
|
||||
```
|
||||
|
||||
#### NodeGraphEditor Integration
|
||||
|
||||
In `NodeGraphEditor.ts`:
|
||||
|
||||
```typescript
|
||||
import { NodeCommentEditor } from './nodegrapheditor/NodeCommentEditor';
|
||||
|
||||
// Track open editors to prevent duplicates
|
||||
private openCommentEditors: Map<string, () => void> = new Map();
|
||||
|
||||
openCommentEditor(node: NodeGraphEditorNode): void {
|
||||
const nodeId = node.model.id;
|
||||
|
||||
// Check if already open
|
||||
if (this.openCommentEditors.has(nodeId)) {
|
||||
return; // Already open
|
||||
}
|
||||
|
||||
// Calculate initial position
|
||||
const screenPos = this.canvasToScreen(node.global.x, node.global.y);
|
||||
const initialX = Math.min(
|
||||
screenPos.x + node.nodeSize.width * this.getPanAndScale().scale + 20,
|
||||
window.innerWidth - 340
|
||||
);
|
||||
const initialY = Math.min(
|
||||
screenPos.y,
|
||||
window.innerHeight - 250
|
||||
);
|
||||
|
||||
// Create close handler
|
||||
const closeEditor = () => {
|
||||
this.openCommentEditors.delete(nodeId);
|
||||
PopupLayer.instance.hidePopup(popupId);
|
||||
this.repaint(); // Update comment icon state
|
||||
};
|
||||
|
||||
// Show modal
|
||||
const popupId = PopupLayer.instance.showPopup({
|
||||
content: NodeCommentEditor,
|
||||
props: {
|
||||
node: node.model,
|
||||
initialPosition: { x: initialX, y: initialY },
|
||||
onClose: closeEditor
|
||||
},
|
||||
modal: false,
|
||||
closeOnOutsideClick: false,
|
||||
closeOnEscape: false // We handle Escape in component
|
||||
});
|
||||
|
||||
this.openCommentEditors.set(nodeId, closeEditor);
|
||||
}
|
||||
|
||||
// Helper method
|
||||
canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number } {
|
||||
const panAndScale = this.getPanAndScale();
|
||||
return {
|
||||
x: (canvasX + panAndScale.x) * panAndScale.scale,
|
||||
y: (canvasY + panAndScale.y) * panAndScale.scale
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
├── NodeCommentEditor.tsx
|
||||
└── NodeCommentEditor.module.scss
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/models/nodegraphmodel/
|
||||
└── NodeGraphNode.ts # Add comment methods
|
||||
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
└── NodeGraphEditorNode.ts # Icon rendering, hover, click
|
||||
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
└── nodegrapheditor.ts # openCommentEditor integration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Data Layer
|
||||
|
||||
- [ ] getComment() returns undefined for new node
|
||||
- [ ] setComment() stores comment
|
||||
- [ ] hasComment() returns true when comment exists
|
||||
- [ ] setComment('') clears comment
|
||||
- [ ] Comment persists after save/reload
|
||||
- [ ] Comment copied when node copied
|
||||
- [ ] Undo restores previous comment
|
||||
- [ ] Redo re-applies comment
|
||||
|
||||
### Icon Rendering
|
||||
|
||||
- [ ] Icon shows (filled) on nodes with comments
|
||||
- [ ] Icon shows (outline) on hover for nodes without comments
|
||||
- [ ] Icon positioned correctly in title bar
|
||||
- [ ] Icon visible at various zoom levels
|
||||
- [ ] Icon doesn't overlap with node label
|
||||
|
||||
### Hover Preview
|
||||
|
||||
- [ ] Preview shows after 300ms hover
|
||||
- [ ] Preview doesn't show immediately (no spam)
|
||||
- [ ] Preview clears when mouse leaves
|
||||
- [ ] Preview clears on pan/zoom
|
||||
- [ ] Long comments scroll in preview
|
||||
- [ ] Preview positioned near icon, not obscuring node
|
||||
|
||||
### Edit Modal
|
||||
|
||||
- [ ] Opens on icon click
|
||||
- [ ] Shows current comment
|
||||
- [ ] Textarea auto-focused
|
||||
- [ ] Can edit comment text
|
||||
- [ ] Save button saves and closes
|
||||
- [ ] Cancel button discards and closes
|
||||
- [ ] Cmd+Enter saves
|
||||
- [ ] Escape cancels
|
||||
- [ ] Modal is draggable
|
||||
- [ ] Can have multiple modals open (different nodes)
|
||||
- [ ] Cannot open duplicate modal for same node
|
||||
|
||||
### Integration
|
||||
|
||||
- [ ] Clicking icon doesn't select node
|
||||
- [ ] Can still select node by clicking elsewhere
|
||||
- [ ] Comment updates reflected after save
|
||||
- [ ] Node repainted after comment change
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Comments stored in node.metadata.comment
|
||||
- [ ] Filled icon visible on nodes with comments
|
||||
- [ ] Outline icon on hover for nodes without comments
|
||||
- [ ] Hover preview after 300ms, no spam on pan/scroll
|
||||
- [ ] Click opens draggable edit modal
|
||||
- [ ] Cmd+Enter to save, Escape to cancel
|
||||
- [ ] Undo/redo works for comment changes
|
||||
- [ ] Comments persist in project save/load
|
||||
- [ ] Comments included in copy/paste
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
1. Revert `NodeGraphNode.ts` comment methods
|
||||
2. Revert `NodeGraphEditorNode.ts` icon/hover code
|
||||
3. Revert `nodegrapheditor.ts` openCommentEditor
|
||||
4. Delete `NodeCommentEditor.tsx` and `.scss`
|
||||
|
||||
Data layer changes are additive - existing projects won't break even if code is partially reverted.
|
||||
@@ -0,0 +1,858 @@
|
||||
# TASK-009I-C: Port Organization & Smart Connections
|
||||
|
||||
**Parent Task:** TASK-000I Node Graph Visual Improvements
|
||||
**Estimated Time:** 15-20 hours
|
||||
**Risk Level:** Medium
|
||||
**Dependencies:** Sub-Task A (visual polish) recommended first
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Improve the usability of nodes with many ports through visual organization, type indicators, and smart connection previews that highlight compatible ports.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Port grouping system** - Collapsible groups for nodes with many ports
|
||||
2. **Port type icons** - Small, classy icons indicating data types
|
||||
3. **Connection preview on hover** - Highlight compatible ports when hovering
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Two-column port layout
|
||||
- Hiding unused ports
|
||||
- User-customizable groups (node type defines groups)
|
||||
- Animated connections
|
||||
|
||||
---
|
||||
|
||||
## Target Nodes
|
||||
|
||||
These nodes have the most ports and will benefit most:
|
||||
|
||||
| Node Type | Typical Port Count | Pain Point |
|
||||
| -------------------------- | ------------------ | ------------------------- |
|
||||
| Object | 10-30+ | Dynamic properties |
|
||||
| States | 5-20+ | State transitions |
|
||||
| Function/Script | Variable | User-defined I/O |
|
||||
| Component I/O | Variable | Exposed ports |
|
||||
| HTTP Request | 15+ | Headers, params, response |
|
||||
| Visual nodes (Group, etc.) | 20+ | Style properties |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase C1: Port Grouping System (6-8 hours)
|
||||
|
||||
#### Design: Group Configuration
|
||||
|
||||
Groups can be defined in two ways:
|
||||
|
||||
**1. Explicit configuration in node type definition:**
|
||||
|
||||
```typescript
|
||||
// In node type registration
|
||||
{
|
||||
name: 'net.noodl.httpnode',
|
||||
displayName: 'HTTP Request',
|
||||
|
||||
portGroups: [
|
||||
{
|
||||
name: 'Request',
|
||||
ports: ['url', 'method', 'body'],
|
||||
dynamicPorts: 'header-*', // Wildcard for dynamic ports
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Query Parameters',
|
||||
ports: ['queryParams'],
|
||||
dynamicPorts: 'param-*',
|
||||
defaultExpanded: false
|
||||
},
|
||||
{
|
||||
name: 'Response',
|
||||
ports: ['status', 'response', 'responseHeaders', 'error'],
|
||||
defaultExpanded: true
|
||||
},
|
||||
{
|
||||
name: 'Control',
|
||||
ports: ['send', 'success', 'failure'],
|
||||
defaultExpanded: true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**2. Auto-grouping fallback:**
|
||||
|
||||
```typescript
|
||||
// For nodes without explicit groups
|
||||
function autoGroupPorts(node: NodeGraphEditorNode): PortGroup[] {
|
||||
const ports = node.getAllPorts();
|
||||
|
||||
const inputs = ports.filter((p) => p.direction === 'input' && p.type !== 'signal');
|
||||
const outputs = ports.filter((p) => p.direction === 'output' && p.type !== 'signal');
|
||||
const signals = ports.filter((p) => p.type === 'signal');
|
||||
|
||||
const groups: PortGroup[] = [];
|
||||
|
||||
// Only create groups if node has many ports
|
||||
const GROUPING_THRESHOLD = 8;
|
||||
if (ports.length < GROUPING_THRESHOLD) {
|
||||
return []; // No grouping, render flat
|
||||
}
|
||||
|
||||
if (signals.length > 0) {
|
||||
groups.push({
|
||||
name: 'Events',
|
||||
ports: signals,
|
||||
expanded: true,
|
||||
isAutoGenerated: true
|
||||
});
|
||||
}
|
||||
|
||||
if (inputs.length > 0) {
|
||||
groups.push({
|
||||
name: 'Inputs',
|
||||
ports: inputs,
|
||||
expanded: true,
|
||||
isAutoGenerated: true
|
||||
});
|
||||
}
|
||||
|
||||
if (outputs.length > 0) {
|
||||
groups.push({
|
||||
name: 'Outputs',
|
||||
ports: outputs,
|
||||
expanded: true,
|
||||
isAutoGenerated: true
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
```
|
||||
|
||||
#### Data Structures
|
||||
|
||||
**File:** `views/nodegrapheditor/portGrouping.ts`
|
||||
|
||||
```typescript
|
||||
export interface PortGroupDefinition {
|
||||
name: string;
|
||||
ports: string[]; // Explicit port names
|
||||
dynamicPorts?: string; // Wildcard pattern like 'header-*'
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface PortGroup {
|
||||
name: string;
|
||||
ports: PlugInfo[];
|
||||
expanded: boolean;
|
||||
isAutoGenerated: boolean;
|
||||
yPosition?: number; // Calculated during layout
|
||||
}
|
||||
|
||||
export const GROUP_HEADER_HEIGHT = 24;
|
||||
export const GROUP_INDENT = 8;
|
||||
|
||||
/**
|
||||
* Build port groups for a node
|
||||
*/
|
||||
export function buildPortGroups(node: NodeGraphEditorNode, plugs: PlugInfo[]): PortGroup[] {
|
||||
const typeDefinition = node.model.type;
|
||||
|
||||
// Check for explicit group configuration
|
||||
if (typeDefinition.portGroups && typeDefinition.portGroups.length > 0) {
|
||||
return buildExplicitGroups(typeDefinition.portGroups, plugs);
|
||||
}
|
||||
|
||||
// Fall back to auto-grouping
|
||||
return autoGroupPorts(plugs);
|
||||
}
|
||||
|
||||
function buildExplicitGroups(definitions: PortGroupDefinition[], plugs: PlugInfo[]): PortGroup[] {
|
||||
const groups: PortGroup[] = [];
|
||||
const assignedPorts = new Set<string>();
|
||||
|
||||
for (const def of definitions) {
|
||||
const groupPorts: PlugInfo[] = [];
|
||||
|
||||
// Match explicit port names
|
||||
for (const portName of def.ports) {
|
||||
const plug = plugs.find((p) => p.property === portName);
|
||||
if (plug) {
|
||||
groupPorts.push(plug);
|
||||
assignedPorts.add(portName);
|
||||
}
|
||||
}
|
||||
|
||||
// Match dynamic ports via wildcard
|
||||
if (def.dynamicPorts) {
|
||||
const pattern = def.dynamicPorts.replace('*', '(.*)');
|
||||
const regex = new RegExp(`^${pattern}$`);
|
||||
|
||||
for (const plug of plugs) {
|
||||
if (!assignedPorts.has(plug.property) && regex.test(plug.property)) {
|
||||
groupPorts.push(plug);
|
||||
assignedPorts.add(plug.property);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groupPorts.length > 0) {
|
||||
groups.push({
|
||||
name: def.name,
|
||||
ports: groupPorts,
|
||||
expanded: def.defaultExpanded !== false,
|
||||
isAutoGenerated: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add ungrouped ports to "Other" group
|
||||
const ungrouped = plugs.filter((p) => !assignedPorts.has(p.property));
|
||||
if (ungrouped.length > 0) {
|
||||
groups.push({
|
||||
name: 'Other',
|
||||
ports: ungrouped,
|
||||
expanded: true,
|
||||
isAutoGenerated: true
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
```
|
||||
|
||||
#### Rendering Changes
|
||||
|
||||
**In `NodeGraphEditorNode.ts`:**
|
||||
|
||||
```typescript
|
||||
import { buildPortGroups, PortGroup, GROUP_HEADER_HEIGHT } from './portGrouping';
|
||||
|
||||
// Add to class
|
||||
private portGroups: PortGroup[] = [];
|
||||
private groupExpandState: Map<string, boolean> = new Map();
|
||||
|
||||
// Modify measure() method
|
||||
measure() {
|
||||
// ... existing size calculations
|
||||
|
||||
// Build port groups
|
||||
this.portGroups = buildPortGroups(this, this.plugs);
|
||||
|
||||
// Apply saved expand states
|
||||
for (const group of this.portGroups) {
|
||||
const savedState = this.groupExpandState.get(group.name);
|
||||
if (savedState !== undefined) {
|
||||
group.expanded = savedState;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate height
|
||||
if (this.portGroups.length > 0) {
|
||||
let height = this.titlebarHeight();
|
||||
|
||||
for (const group of this.portGroups) {
|
||||
height += GROUP_HEADER_HEIGHT;
|
||||
if (group.expanded) {
|
||||
height += group.ports.length * NodeGraphEditorNode.propertyConnectionHeight;
|
||||
}
|
||||
}
|
||||
|
||||
this.nodeSize.height = Math.max(height, NodeGraphEditorNode.size.height);
|
||||
}
|
||||
|
||||
// ... rest of measure
|
||||
}
|
||||
|
||||
// Add group header drawing
|
||||
private drawGroupHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
group: PortGroup,
|
||||
x: number,
|
||||
y: number
|
||||
): void {
|
||||
const headerY = y;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
|
||||
ctx.fillRect(x, headerY, this.nodeSize.width, GROUP_HEADER_HEIGHT);
|
||||
|
||||
// Chevron
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
|
||||
ctx.font = '10px Inter-Regular';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const chevron = group.expanded ? '▼' : '▶';
|
||||
ctx.fillText(chevron, x + 8, headerY + GROUP_HEADER_HEIGHT / 2);
|
||||
|
||||
// Group name
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.font = '11px Inter-Medium';
|
||||
ctx.fillText(group.name, x + 22, headerY + GROUP_HEADER_HEIGHT / 2);
|
||||
|
||||
// Port count
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.font = '10px Inter-Regular';
|
||||
ctx.fillText(`(${group.ports.length})`, x + 22 + ctx.measureText(group.name).width + 6, headerY + GROUP_HEADER_HEIGHT / 2);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Store hit area for click detection
|
||||
group.headerBounds = {
|
||||
x: x,
|
||||
y: headerY,
|
||||
width: this.nodeSize.width,
|
||||
height: GROUP_HEADER_HEIGHT
|
||||
};
|
||||
}
|
||||
|
||||
// Modify drawPlugs or create new drawGroupedPlugs
|
||||
private drawGroupedPorts(ctx: CanvasRenderingContext2D, x: number, startY: number): void {
|
||||
let y = startY;
|
||||
|
||||
for (const group of this.portGroups) {
|
||||
// Draw header
|
||||
this.drawGroupHeader(ctx, group, x, y);
|
||||
y += GROUP_HEADER_HEIGHT;
|
||||
group.yPosition = y;
|
||||
|
||||
// Draw ports if expanded
|
||||
if (group.expanded) {
|
||||
for (const plug of group.ports) {
|
||||
this.drawPort(ctx, plug, x, y);
|
||||
y += NodeGraphEditorNode.propertyConnectionHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Click Handling for Expand/Collapse
|
||||
|
||||
```typescript
|
||||
// In handleMouseEvent
|
||||
case 'up':
|
||||
// Check group header clicks
|
||||
for (const group of this.portGroups) {
|
||||
if (group.headerBounds && this.isPointInBounds(localX, localY, group.headerBounds)) {
|
||||
this.toggleGroupExpanded(group);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ... rest of click handling
|
||||
|
||||
private toggleGroupExpanded(group: PortGroup): void {
|
||||
group.expanded = !group.expanded;
|
||||
this.groupExpandState.set(group.name, group.expanded);
|
||||
|
||||
// Remeasure and repaint
|
||||
this.measuredSize = null;
|
||||
this.owner.relayout();
|
||||
this.owner.repaint();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase C2: Port Type Icons (4-6 hours)
|
||||
|
||||
#### Icon Design
|
||||
|
||||
Small, monochrome icons that indicate data type at a glance.
|
||||
|
||||
**File:** `views/nodegrapheditor/portIcons.ts`
|
||||
|
||||
```typescript
|
||||
export type PortType =
|
||||
| 'signal'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'object'
|
||||
| 'array'
|
||||
| 'color'
|
||||
| 'any'
|
||||
| 'component'
|
||||
| 'enum';
|
||||
|
||||
export interface PortIcon {
|
||||
char?: string; // Single character fallback
|
||||
path?: Path2D; // Canvas path for precise control
|
||||
}
|
||||
|
||||
// Simple character-based icons (reliable, easy)
|
||||
export const PORT_ICONS: Record<PortType, PortIcon> = {
|
||||
signal: { char: '⚡' }, // Lightning bolt
|
||||
string: { char: 'T' }, // Text
|
||||
number: { char: '#' }, // Number sign
|
||||
boolean: { char: '◐' }, // Half circle
|
||||
object: { char: '{ }' }, // Braces (might need path)
|
||||
array: { char: '[ ]' }, // Brackets
|
||||
color: { char: '●' }, // Filled circle
|
||||
any: { char: '◇' }, // Diamond
|
||||
component: { char: '◈' }, // Diamond with dot
|
||||
enum: { char: '≡' } // Menu/list
|
||||
};
|
||||
|
||||
// Size constants
|
||||
export const PORT_ICON_SIZE = 10;
|
||||
export const PORT_ICON_PADDING = 4;
|
||||
|
||||
/**
|
||||
* Map Noodl internal type names to our icon types
|
||||
*/
|
||||
export function getPortIconType(type: string | undefined): PortType {
|
||||
if (!type) return 'any';
|
||||
|
||||
const typeMap: Record<string, PortType> = {
|
||||
signal: 'signal',
|
||||
'*': 'signal',
|
||||
string: 'string',
|
||||
number: 'number',
|
||||
boolean: 'boolean',
|
||||
object: 'object',
|
||||
array: 'array',
|
||||
color: 'color',
|
||||
component: 'component',
|
||||
enum: 'enum'
|
||||
};
|
||||
|
||||
return typeMap[type.toLowerCase()] || 'any';
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a port type icon
|
||||
*/
|
||||
export function drawPortIcon(ctx: CanvasRenderingContext2D, type: PortType, x: number, y: number, color: string): void {
|
||||
const icon = PORT_ICONS[type];
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${PORT_ICON_SIZE}px Inter-Regular`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(icon.char || '?', x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
#### Integration
|
||||
|
||||
```typescript
|
||||
// In drawPort() or drawPlugs()
|
||||
|
||||
// After drawing the connection dot/arrow, add type icon
|
||||
const portType = getPortIconType(plug.type);
|
||||
const iconX =
|
||||
side === 'left'
|
||||
? x + PORT_RADIUS + PORT_ICON_PADDING + PORT_ICON_SIZE / 2
|
||||
: x + this.nodeSize.width - PORT_RADIUS - PORT_ICON_PADDING - PORT_ICON_SIZE / 2;
|
||||
|
||||
drawPortIcon(ctx, portType, iconX, ty, 'rgba(255, 255, 255, 0.5)');
|
||||
|
||||
// Adjust label position to account for icon
|
||||
const labelX = side === 'left' ? iconX + PORT_ICON_SIZE / 2 + 4 : iconX - PORT_ICON_SIZE / 2 - 4;
|
||||
```
|
||||
|
||||
#### Alternative: SVG Path Icons
|
||||
|
||||
For more precise control:
|
||||
|
||||
```typescript
|
||||
// Create paths once
|
||||
const signalPath = new Path2D('M4 0 L8 4 L6 4 L6 8 L2 8 L2 4 L0 4 Z');
|
||||
|
||||
export function drawPortIconPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
type: PortType,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
scale: number = 1
|
||||
): void {
|
||||
const path = PORT_ICON_PATHS[type];
|
||||
if (!path) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.translate(x - 4 * scale, y - 4 * scale);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fill(path);
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase C3: Connection Preview on Hover (5-6 hours)
|
||||
|
||||
#### Behavior Specification
|
||||
|
||||
1. User hovers over a port (input or output)
|
||||
2. System identifies all compatible ports on other nodes
|
||||
3. Compatible ports are highlighted (brighter, glow effect)
|
||||
4. Incompatible ports are dimmed (reduced opacity)
|
||||
5. Preview clears when mouse leaves port area
|
||||
|
||||
#### State Management
|
||||
|
||||
**In `NodeGraphEditor.ts`:**
|
||||
|
||||
```typescript
|
||||
// Add state
|
||||
private highlightedPort: {
|
||||
node: NodeGraphEditorNode;
|
||||
plug: PlugInfo;
|
||||
isOutput: boolean;
|
||||
} | null = null;
|
||||
|
||||
// Methods
|
||||
setHighlightedPort(
|
||||
node: NodeGraphEditorNode,
|
||||
plug: PlugInfo,
|
||||
isOutput: boolean
|
||||
): void {
|
||||
this.highlightedPort = { node, plug, isOutput };
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
clearHighlightedPort(): void {
|
||||
if (this.highlightedPort) {
|
||||
this.highlightedPort = null;
|
||||
this.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a port is compatible with the currently highlighted port
|
||||
*/
|
||||
getPortCompatibility(
|
||||
targetNode: NodeGraphEditorNode,
|
||||
targetPlug: PlugInfo,
|
||||
targetIsOutput: boolean
|
||||
): 'source' | 'compatible' | 'incompatible' | 'neutral' {
|
||||
if (!this.highlightedPort) return 'neutral';
|
||||
|
||||
const source = this.highlightedPort;
|
||||
|
||||
// Same port = source
|
||||
if (source.node === targetNode && source.plug.property === targetPlug.property) {
|
||||
return 'source';
|
||||
}
|
||||
|
||||
// Same node = incompatible (can't connect to self)
|
||||
if (source.node === targetNode) {
|
||||
return 'incompatible';
|
||||
}
|
||||
|
||||
// Same direction = incompatible (output to output, input to input)
|
||||
if (source.isOutput === targetIsOutput) {
|
||||
return 'incompatible';
|
||||
}
|
||||
|
||||
// Check type compatibility
|
||||
const sourceType = source.plug.type || '*';
|
||||
const targetType = targetPlug.type || '*';
|
||||
|
||||
// Use existing type compatibility logic
|
||||
const compatible = this.checkTypeCompatibility(sourceType, targetType);
|
||||
|
||||
return compatible ? 'compatible' : 'incompatible';
|
||||
}
|
||||
|
||||
private checkTypeCompatibility(sourceType: string, targetType: string): boolean {
|
||||
// Signals connect to signals
|
||||
if (sourceType === '*' || sourceType === 'signal') {
|
||||
return targetType === '*' || targetType === 'signal';
|
||||
}
|
||||
|
||||
// Any type (*) is compatible with anything
|
||||
if (sourceType === '*' || targetType === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same type
|
||||
if (sourceType === targetType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number compatible with string (coercion)
|
||||
if ((sourceType === 'number' && targetType === 'string') ||
|
||||
(sourceType === 'string' && targetType === 'number')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Could add more rules based on NodeLibrary
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
#### Visual Rendering
|
||||
|
||||
**In `NodeGraphEditorNode.ts` drawPort():**
|
||||
|
||||
```typescript
|
||||
private drawPort(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
plug: PlugInfo,
|
||||
x: number,
|
||||
y: number,
|
||||
isOutput: boolean
|
||||
): void {
|
||||
// Get compatibility state
|
||||
const compatibility = this.owner.getPortCompatibility(this, plug, isOutput);
|
||||
|
||||
// Determine visual style
|
||||
let alpha = 1;
|
||||
let glowColor: string | null = null;
|
||||
|
||||
switch (compatibility) {
|
||||
case 'source':
|
||||
// This is the hovered port - normal rendering
|
||||
break;
|
||||
|
||||
case 'compatible':
|
||||
// Highlight compatible ports
|
||||
glowColor = 'rgba(100, 200, 255, 0.6)';
|
||||
break;
|
||||
|
||||
case 'incompatible':
|
||||
// Dim incompatible ports
|
||||
alpha = 0.3;
|
||||
break;
|
||||
|
||||
case 'neutral':
|
||||
// No highlighting active
|
||||
break;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Draw glow for compatible ports
|
||||
if (glowColor) {
|
||||
ctx.shadowColor = glowColor;
|
||||
ctx.shadowBlur = 8;
|
||||
}
|
||||
|
||||
// ... existing port drawing code
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
#### Mouse Event Handling
|
||||
|
||||
**In `NodeGraphEditorNode.ts` handleMouseEvent():**
|
||||
|
||||
```typescript
|
||||
case 'move':
|
||||
// Check if hovering a port
|
||||
const hoveredPlug = this.getPlugAtPosition(localX, localY);
|
||||
|
||||
if (hoveredPlug) {
|
||||
const isOutput = hoveredPlug.side === 'right';
|
||||
this.owner.setHighlightedPort(this, hoveredPlug.plug, isOutput);
|
||||
} else if (this.owner.highlightedPort?.node === this) {
|
||||
// Was hovering our port, now not - clear
|
||||
this.owner.clearHighlightedPort();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'move-out':
|
||||
// Clear if we were the source
|
||||
if (this.owner.highlightedPort?.node === this) {
|
||||
this.owner.clearHighlightedPort();
|
||||
}
|
||||
break;
|
||||
|
||||
// Helper method
|
||||
private getPlugAtPosition(x: number, y: number): { plug: PlugInfo; side: 'left' | 'right' } | null {
|
||||
const portRadius = 8; // Hit area
|
||||
|
||||
for (const plug of this.plugs) {
|
||||
// Left side ports
|
||||
if (plug.leftCons?.length || plug.leftIcon) {
|
||||
const px = 0;
|
||||
const py = plug.yPosition; // Need to track this during layout
|
||||
|
||||
if (Math.abs(x - px) < portRadius && Math.abs(y - py) < portRadius) {
|
||||
return { plug, side: 'left' };
|
||||
}
|
||||
}
|
||||
|
||||
// Right side ports
|
||||
if (plug.rightCons?.length || plug.rightIcon) {
|
||||
const px = this.nodeSize.width;
|
||||
const py = plug.yPosition;
|
||||
|
||||
if (Math.abs(x - px) < portRadius && Math.abs(y - py) < portRadius) {
|
||||
return { plug, side: 'right' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Performance Consideration
|
||||
|
||||
With many nodes visible, checking compatibility for every port on every paint could be slow.
|
||||
|
||||
**Optimization:**
|
||||
|
||||
```typescript
|
||||
// Cache compatibility results when highlight changes
|
||||
private compatibilityCache: Map<string, 'compatible' | 'incompatible'> = new Map();
|
||||
|
||||
setHighlightedPort(...) {
|
||||
this.highlightedPort = { node, plug, isOutput };
|
||||
this.rebuildCompatibilityCache();
|
||||
this.repaint();
|
||||
}
|
||||
|
||||
private rebuildCompatibilityCache(): void {
|
||||
this.compatibilityCache.clear();
|
||||
|
||||
if (!this.highlightedPort) return;
|
||||
|
||||
// Pre-calculate for all visible nodes
|
||||
this.forEachNode(node => {
|
||||
for (const plug of node.plugs) {
|
||||
const key = `${node.model.id}:${plug.property}`;
|
||||
const compat = this.calculateCompatibility(node, plug);
|
||||
this.compatibilityCache.set(key, compat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPortCompatibility(node, plug, isOutput): string {
|
||||
if (!this.highlightedPort) return 'neutral';
|
||||
|
||||
const key = `${node.model.id}:${plug.property}`;
|
||||
return this.compatibilityCache.get(key) || 'neutral';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
├── portGrouping.ts # Group logic and interfaces
|
||||
└── portIcons.ts # Type icon definitions
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
```
|
||||
packages/noodl-editor/src/editor/src/views/nodegrapheditor/
|
||||
└── NodeGraphEditorNode.ts # Grouped rendering, icons, hover
|
||||
|
||||
packages/noodl-editor/src/editor/src/views/
|
||||
└── nodegrapheditor.ts # Highlight state management
|
||||
|
||||
packages/noodl-editor/src/editor/src/models/nodelibrary/
|
||||
└── [node definitions] # Add portGroups config (optional)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Port Grouping
|
||||
|
||||
- [ ] Nodes with explicit groups render correctly
|
||||
- [ ] Nodes without groups use auto-grouping (if >8 ports)
|
||||
- [ ] Nodes with few ports render flat (no groups)
|
||||
- [ ] Group headers display name and count
|
||||
- [ ] Click expands/collapses group
|
||||
- [ ] Collapsed group hides ports
|
||||
- [ ] Node height adjusts with collapse
|
||||
- [ ] Connections still work with grouped ports
|
||||
- [ ] Group state doesn't persist (intentional)
|
||||
|
||||
### Port Type Icons
|
||||
|
||||
- [ ] Icons render for all port types
|
||||
- [ ] Icons visible at 100% zoom
|
||||
- [ ] Icons visible at 50% zoom
|
||||
- [ ] Icons don't overlap labels
|
||||
- [ ] Color matches port state
|
||||
- [ ] Icons for unknown types fallback to 'any'
|
||||
|
||||
### Connection Preview
|
||||
|
||||
- [ ] Hovering output highlights compatible inputs
|
||||
- [ ] Hovering input highlights compatible outputs
|
||||
- [ ] Same node ports dimmed
|
||||
- [ ] Same direction ports dimmed
|
||||
- [ ] Type-incompatible ports dimmed
|
||||
- [ ] Highlight clears when mouse leaves
|
||||
- [ ] Highlight clears on pan/zoom
|
||||
- [ ] Performance acceptable with 50+ nodes
|
||||
|
||||
### Integration
|
||||
|
||||
- [ ] Grouping + icons work together
|
||||
- [ ] Grouping + connection preview work together
|
||||
- [ ] No regression on ungrouped nodes
|
||||
- [ ] Copy/paste works with grouped nodes
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Port groups configurable per node type
|
||||
- [ ] Auto-grouping fallback for unconfigured nodes
|
||||
- [ ] Groups collapsible with visual feedback
|
||||
- [ ] Port type icons clear and minimal
|
||||
- [ ] Connection preview highlights compatible ports
|
||||
- [ ] Incompatible ports visually dimmed
|
||||
- [ ] Performance acceptable
|
||||
- [ ] No regression on existing functionality
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
**Port grouping:**
|
||||
|
||||
- Revert `NodeGraphEditorNode.ts` measure/draw changes
|
||||
- Delete `portGrouping.ts`
|
||||
- Nodes will render flat (original behavior)
|
||||
|
||||
**Type icons:**
|
||||
|
||||
- Delete `portIcons.ts`
|
||||
- Remove icon drawing from port render
|
||||
- Ports will show dots/arrows only (original behavior)
|
||||
|
||||
**Connection preview:**
|
||||
|
||||
- Remove highlight state from `nodegrapheditor.ts`
|
||||
- Remove compatibility rendering from node
|
||||
- No visual change on hover (original behavior)
|
||||
|
||||
All features are independent and can be rolled back separately.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- User-customizable port groups
|
||||
- Persistent group expand state per project
|
||||
- Search/filter ports within node
|
||||
- Port group templates (reusable across node types)
|
||||
- Connection line preview during hover
|
||||
- Animated highlight effects
|
||||
Reference in New Issue
Block a user