Fixed visual issues with new dashboard and added folder attribution

This commit is contained in:
Richard Osborne
2025-12-31 21:40:47 +01:00
parent 73b5a42122
commit 2e46ab7ea7
41 changed files with 6481 additions and 1619 deletions

View File

@@ -169,9 +169,65 @@ packages/noodl-core-ui/src/
│ ├── AiChatBox/
│ └── AiChatMessage/
├── preview/ # 📱 Preview/Launcher UI
│ └── launcher/
│ ├── Launcher.tsx → Main launcher container
│ ├── LauncherContext.tsx → Shared state context
│ │
│ ├── components/ # Launcher-specific components
│ │ ├── LauncherProjectCard/ → Project card display
│ │ ├── FolderTree/ → Folder hierarchy UI
│ │ ├── FolderTreeItem/ → Individual folder item
│ │ ├── TagPill/ → Tag display badge
│ │ ├── TagSelector/ → Tag assignment UI
│ │ ├── ProjectList/ → List view components
│ │ ├── GitStatusBadge/ → Git status indicator
│ │ └── ViewModeToggle/ → Card/List toggle
│ │
│ ├── hooks/ # Launcher hooks
│ │ ├── useProjectOrganization.ts → Folder/tag management
│ │ ├── useProjectList.ts → Project list logic
│ │ └── usePersistentTab.ts → Tab state persistence
│ │
│ └── views/ # Launcher view pages
│ ├── Projects.tsx → Projects tab view
│ └── Templates.tsx → Templates tab view
└── styles/ # 🎨 Global styles
└── custom-properties/
├── colors.css → Design tokens (colors)
├── fonts.css → Typography tokens
└── spacing.css → Spacing tokens
```
#### 🚀 Launcher/Projects Organization System (Phase 3)
The Launcher includes a complete project organization system with folders and tags:
**Key Components:**
- **FolderTree**: Hierarchical folder display with expand/collapse
- **TagPill**: Colored badge for displaying project tags (9 predefined colors)
- **TagSelector**: Checkbox-based UI for assigning tags to projects
- **useProjectOrganization**: Hook for folder/tag management (uses LocalStorage for Storybook compatibility)
**Data Flow:**
```
ProjectOrganizationService (editor)
↓ (via LauncherContext)
useProjectOrganization hook
FolderTree / TagPill / TagSelector components
```
**Storage:**
- Projects identified by `localPath` (stable across renames)
- Folders: hierarchical structure with parent/child relationships
- Tags: 9 predefined colors (#EF4444, #F97316, #EAB308, #22C55E, #06B6D4, #3B82F6, #8B5CF6, #EC4899, #6B7280)
- Persisted via `ProjectOrganizationService` → LocalStorage (Storybook) or electron-store (production)
---
## 🔍 Finding Things

File diff suppressed because it is too large Load Diff

View File

@@ -290,6 +290,135 @@ Before completing any UI task, verify:
---
## Part 9: Selected/Active State Patterns
### Decision Matrix: Which Background to Use?
When styling selected or active items, choose based on the **level of emphasis** needed:
| Context | Background Token | Text Color | Use Case |
| -------------------- | ----------------------- | --------------------------------------- | ---------------------------------------------- |
| **Subtle highlight** | `--theme-color-bg-4` | `--theme-color-fg-highlight` | Breadcrumb current page, sidebar selected item |
| **Medium highlight** | `--theme-color-bg-5` | `--theme-color-fg-highlight` | Hovered list items, tabs |
| **Bold accent** | `--theme-color-primary` | `var(--theme-color-on-primary)` (white) | Dropdown selected item, focused input |
### Common Pattern: Dropdown/Menu Selected Items
```scss
.MenuItem {
padding: 8px 12px;
cursor: pointer;
// Default state
color: var(--theme-color-fg-default);
background-color: transparent;
// Hover state (if not selected)
&:hover:not(.is-selected) {
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-highlight);
}
// Selected state - BOLD accent for visibility
&.is-selected {
background-color: var(--theme-color-primary);
color: var(--theme-color-on-primary);
// Icons and child elements also need white
svg path {
fill: var(--theme-color-on-primary);
}
}
// Disabled state
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
```
### Common Pattern: Navigation/Breadcrumb Current Item
```scss
.BreadcrumbItem {
padding: 6px 12px;
border-radius: 4px;
color: var(--theme-color-fg-default);
// Current/active page - SUBTLE highlight
&.is-current {
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
}
}
```
### ⚠️ CRITICAL: Never Use These for Backgrounds
**DO NOT use these tokens for selected/active backgrounds:**
```scss
/* ❌ WRONG - These are now WHITE after token consolidation */
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-fg-highlight);
/* ❌ WRONG - Poor contrast on dark backgrounds */
background-color: var(--theme-color-bg-1); /* Too dark */
background-color: var(--theme-color-bg-2); /* Too dark */
```
### Visual Hierarchy Example
```scss
// List with multiple states
.ListItem {
// Normal
background: transparent;
color: var(--theme-color-fg-default);
// Hover (not selected)
&:hover:not(.is-selected) {
background: var(--theme-color-bg-3); // Subtle lift
}
// Selected
&.is-selected {
background: var(--theme-color-primary); // Bold, can't miss it
color: white;
}
// Selected AND hovered
&.is-selected:hover {
background: var(--theme-color-primary-highlight); // Slightly lighter red
}
}
```
### Accessibility Checklist for Selected States
- [ ] Selected item is **immediately visible** (high contrast)
- [ ] Color is not the **only** indicator (use icons/checkmarks too)
- [ ] Keyboard focus state is **distinct** from selection
- [ ] Text contrast meets **WCAG AA** (4.5:1 minimum)
### Real-World Examples
**Good patterns** (fixed December 2025):
- `MenuDialog.module.scss` - Uses `--theme-color-primary` for selected dropdown items
- `NodeGraphComponentTrail.module.scss` - Uses `--theme-color-bg-4` for current breadcrumb
- `search-panel.module.scss` - Uses `--theme-color-bg-4` for active search result
**Anti-patterns** (to avoid):
- Using `--theme-color-secondary` as background (it's white now!)
- No visual distinction between selected and unselected items
- Low contrast text on selected backgrounds
---
## Quick Grep Commands
```bash
@@ -301,8 +430,11 @@ grep -rE '#[0-9a-fA-F]{3,6}' packages/noodl-editor/src/editor/src/styles/
# Find usage of a specific token
grep -r "theme-color-primary" packages/
# Find potential white-on-white issues
grep -r "theme-color-secondary" packages/ --include="*.scss" --include="*.css"
```
---
_Last Updated: December 2024_
_Last Updated: December 2025_

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,6 @@
import classNames from 'classnames';
import React, {
CSSProperties,
MouseEvent,
MouseEventHandler,
SyntheticEvent,
} from 'react';
import React, { CSSProperties, MouseEvent, MouseEventHandler, SyntheticEvent } from 'react';
import css from './LegacyIconButton.module.scss';
export enum LegacyIconButtonIcon {
@@ -12,7 +8,7 @@ export enum LegacyIconButtonIcon {
Close = 'close',
CloseDark = 'close-dark',
CaretDown = 'caret-down',
Generate = 'generate',
Generate = 'generate'
}
export interface LegacyIconButtonProps {
@@ -23,26 +19,12 @@ export interface LegacyIconButtonProps {
testId?: string;
}
export function LegacyIconButton({
icon,
isRotated180,
style,
onClick,
testId,
}: LegacyIconButtonProps) {
export function LegacyIconButton({ icon, isRotated180, style, onClick, testId }: LegacyIconButtonProps) {
return (
<button
className={css['Root']}
onClick={onClick}
style={style}
data-test={testId}
>
<button className={css['Root']} onClick={onClick} style={style} data-test={testId}>
<img
className={classNames([
css['Icon'],
isRotated180 && css['is-rotated-180'],
])}
src={`../assets/icons/icon-button/${icon}.svg`}
className={classNames([css['Icon'], isRotated180 && css['is-rotated-180']])}
src={`/assets/icons/icon-button/${icon}.svg`}
/>
</button>
);

View File

@@ -103,12 +103,12 @@
left: 0;
visibility: hidden;
height: 0;
overflow: overlay;
overflow: hidden;
white-space: pre;
}
.InputWrapper {
overflow-x: overlay;
overflow-x: hidden;
flex-grow: 1;
padding-top: 1px;
}

View File

@@ -6,13 +6,14 @@
height: 24px;
width: 42px;
border-radius: 12px;
background-color: var(--theme-color-bg-1);
background-color: var(--theme-color-bg-3);
cursor: pointer;
display: flex;
align-items: center;
padding: 3px;
margin-left: 8px;
margin-right: 8px;
border: 1px solid var(--theme-color-border-default);
}
.Indicator {
@@ -20,8 +21,7 @@
height: 18px;
border-radius: 50%;
background-color: var(--theme-color-bg-3);
transition: transform var(--speed-quick) var(--easing-base),
background-color var(--speed-quick) var(--easing-base);
transition: transform var(--speed-quick) var(--easing-base), background-color var(--speed-quick) var(--easing-base);
&.is-checked {
transform: translateX(100%);

View File

@@ -27,10 +27,18 @@
transition: background-color var(--speed-turbo) var(--easing-base);
&.is-highlighted:not(.is-disabled) {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-primary);
h2 {
color: var(--theme-color-on-secondary);
color: var(--theme-color-on-primary);
}
.Label span {
color: var(--theme-color-on-primary);
}
.Icon path {
fill: var(--theme-color-on-primary);
}
}

View File

@@ -199,6 +199,16 @@ export function Launcher({
}
});
// Folder selection state with localStorage persistence
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(() => {
try {
const stored = localStorage.getItem('launcher:selectedFolderId');
return stored === 'null' ? null : stored;
} catch {
return null; // Default to "All Projects"
}
});
// Persist view mode changes
useEffect(() => {
try {
@@ -219,6 +229,15 @@ export function Launcher({
}
}, [useMockData, projects]);
// Persist folder selection
useEffect(() => {
try {
localStorage.setItem('launcher:selectedFolderId', selectedFolderId === null ? 'null' : selectedFolderId);
} catch (error) {
console.warn('Failed to persist folder selection:', error);
}
}, [selectedFolderId]);
// Determine which projects to use and if toggle should be available
const hasRealProjects = Boolean(projects && projects.length > 0);
const activeProjects = useMockData ? MOCK_PROJECTS : projects || MOCK_PROJECTS;
@@ -264,6 +283,8 @@ export function Launcher({
setUseMockData,
projects: activeProjects,
hasRealProjects,
selectedFolderId,
setSelectedFolderId,
onCreateProject,
onOpenProject,
onLaunchProject,

View File

@@ -26,6 +26,10 @@ export interface LauncherContextValue {
projects: LauncherProjectData[];
hasRealProjects: boolean; // Indicates if real projects were provided to Launcher
// Folder organization
selectedFolderId: string | null;
setSelectedFolderId: (folderId: string | null) => void;
// Project management callbacks
onCreateProject?: () => void;
onOpenProject?: () => void;

View File

@@ -0,0 +1,200 @@
/**
* FolderTree Styles
*/
.Root {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2);
height: 100%;
}
.VirtualFolders {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.Divider {
height: 1px;
background-color: var(--theme-color-border-default);
margin: var(--spacing-2) 0;
}
.UserFolders {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
flex: 1;
overflow-y: auto;
}
.RenameInput {
padding: var(--spacing-1) var(--spacing-2);
}
.RenameInputField {
width: 100%;
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
outline: none;
&:focus {
border-color: var(--theme-color-primary);
}
}
.NewFolderButton {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
margin-bottom: var(--spacing-4);
background: none;
border: 1px dashed var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default-shy);
cursor: pointer;
transition: all 0.15s ease;
font-size: var(--theme-font-size-default);
&:hover {
background-color: var(--theme-color-bg-3);
border-color: var(--theme-color-border-highlight);
color: var(--theme-color-fg-default);
}
&:active {
background-color: var(--theme-color-bg-4);
}
}
.NewFolderIcon {
display: flex;
flex-shrink: 0;
}
.NewFolderLabel {
flex: 1;
text-align: left;
}
.CreateFolderInput {
padding: var(--spacing-1) var(--spacing-2);
}
.CreateFolderInputField {
width: 100%;
padding: var(--spacing-2);
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-primary);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
outline: none;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
}
/* Delete Confirmation Dialog */
.DeleteConfirmation {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.DeleteConfirmationBackdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.DeleteConfirmationDialog {
position: relative;
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-lg);
padding: var(--spacing-6);
min-width: 400px;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.DeleteConfirmationTitle {
font-size: 20px;
font-weight: 600;
color: var(--theme-color-fg-default);
margin: 0 0 var(--spacing-3) 0;
line-height: 1.3;
}
.DeleteConfirmationMessage {
font-size: var(--theme-font-size-default);
color: var(--theme-color-fg-default);
margin-bottom: var(--spacing-6);
line-height: 1.5;
}
.DeleteConfirmationButtons {
display: flex;
gap: var(--spacing-3);
justify-content: flex-end;
}
.DeleteConfirmationCancelButton {
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--radius-default);
color: var(--theme-color-fg-default);
font-size: var(--theme-font-size-default);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-4);
border-color: var(--theme-color-border-highlight);
}
&:active {
transform: scale(0.98);
}
}
.DeleteConfirmationDeleteButton {
padding: var(--spacing-2) var(--spacing-4);
background-color: var(--theme-color-danger);
border: 1px solid var(--theme-color-danger);
border-radius: var(--radius-default);
color: white;
font-size: var(--theme-font-size-default);
font-weight: var(--theme-font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-danger-hover);
border-color: var(--theme-color-danger-hover);
}
&:active {
transform: scale(0.98);
}
}

View File

@@ -0,0 +1,276 @@
/**
* FolderTree - Project organization folder tree
*
* Displays virtual folders (All Projects, Uncategorized) and user-created folders
* with expand/collapse functionality and folder management actions.
*
* @module noodl-core-ui/preview/launcher
*/
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { FolderTreeItem } from '@noodl-core-ui/preview/launcher/Launcher/components/FolderTreeItem';
import { Folder, useProjectOrganization } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import css from './FolderTree.module.scss';
export interface FolderTreeProps {
/** Currently selected folder ID (null for "All Projects", 'uncategorized' for uncategorized) */
selectedFolderId: string | null;
/** Called when a folder is selected */
onFolderSelect: (folderId: string | null) => void;
/** Total number of projects */
totalProjectCount: number;
/** Number of projects without a folder */
uncategorizedProjectCount: number;
}
/**
* FolderTree displays the project organization structure:
* - Virtual folders ("All Projects", "Uncategorized")
* - User-created folders with expand/collapse
* - "+ New Folder" button
*/
export function FolderTree({
selectedFolderId,
onFolderSelect,
totalProjectCount,
uncategorizedProjectCount
}: FolderTreeProps) {
const { folders, createFolder, renameFolder, deleteFolder, getProjectCountInFolder } = useProjectOrganization();
// Track expanded folders (folder IDs that are expanded)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(new Set());
// Track folder being renamed
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Track new folder creation
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
// Track folder deletion confirmation
const [deletingFolder, setDeletingFolder] = useState<Folder | null>(null);
const toggleExpand = (folderId: string) => {
setExpandedFolderIds((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
};
const handleCreateFolder = () => {
setIsCreatingFolder(true);
setNewFolderName('');
};
const handleCreateFolderSubmit = () => {
if (newFolderName.trim()) {
createFolder(newFolderName.trim());
}
setIsCreatingFolder(false);
setNewFolderName('');
};
const handleCreateFolderCancel = () => {
setIsCreatingFolder(false);
setNewFolderName('');
};
const handleRenameFolder = (folder: Folder) => {
setRenamingFolderId(folder.id);
setRenameValue(folder.name);
};
const handleRenameSubmit = (folderId: string) => {
if (renameValue.trim()) {
renameFolder(folderId, renameValue.trim());
}
setRenamingFolderId(null);
setRenameValue('');
};
const handleRenameCancel = () => {
setRenamingFolderId(null);
setRenameValue('');
};
const handleDeleteFolder = (folder: Folder) => {
setDeletingFolder(folder);
};
const handleDeleteFolderConfirm = () => {
if (deletingFolder) {
deleteFolder(deletingFolder.id);
// If deleted folder was selected, reset to "All Projects"
if (selectedFolderId === deletingFolder.id) {
onFolderSelect(null);
}
setDeletingFolder(null);
}
};
const handleDeleteFolderCancel = () => {
setDeletingFolder(null);
};
// Organize folders into root and nested
const rootFolders = folders.filter((f) => f.parentId === null);
const getFolderChildren = (parentId: string) => {
return folders.filter((f) => f.parentId === parentId);
};
return (
<div className={css['Root']}>
{/* Virtual Folders */}
<div className={css['VirtualFolders']}>
{/* All Projects */}
<FolderTreeItem
folder={{ id: 'all', name: 'All Projects', parentId: null, order: 0, createdAt: '' }}
projectCount={totalProjectCount}
isSelected={selectedFolderId === null}
onClick={() => onFolderSelect(null)}
/>
{/* Uncategorized */}
<FolderTreeItem
folder={{
id: 'uncategorized',
name: 'Uncategorized',
parentId: null,
order: 1,
createdAt: ''
}}
projectCount={uncategorizedProjectCount}
isSelected={selectedFolderId === 'uncategorized'}
onClick={() => onFolderSelect('uncategorized')}
/>
</div>
{/* Divider */}
{rootFolders.length > 0 && <div className={css['Divider']} />}
{/* User Folders */}
<div className={css['UserFolders']}>
{rootFolders.map((folder) => {
const children = getFolderChildren(folder.id);
const isExpanded = expandedFolderIds.has(folder.id);
const projectCount = getProjectCountInFolder(folder.id);
return (
<div key={folder.id}>
{renamingFolderId === folder.id ? (
<div className={css['RenameInput']}>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameSubmit(folder.id);
} else if (e.key === 'Escape') {
handleRenameCancel();
}
}}
onBlur={() => handleRenameSubmit(folder.id)}
autoFocus
className={css['RenameInputField']}
/>
</div>
) : (
<FolderTreeItem
folder={folder}
projectCount={projectCount}
isSelected={selectedFolderId === folder.id}
hasChildren={children.length > 0}
isExpanded={isExpanded}
onClick={() => onFolderSelect(folder.id)}
onToggleExpand={() => toggleExpand(folder.id)}
onRename={() => handleRenameFolder(folder)}
onDelete={() => handleDeleteFolder(folder)}
/>
)}
{/* Render children if expanded */}
{isExpanded &&
children.map((childFolder) => {
const childProjectCount = getProjectCountInFolder(childFolder.id);
return (
<FolderTreeItem
key={childFolder.id}
folder={childFolder}
projectCount={childProjectCount}
isSelected={selectedFolderId === childFolder.id}
level={1}
onClick={() => onFolderSelect(childFolder.id)}
onRename={() => handleRenameFolder(childFolder)}
onDelete={() => handleDeleteFolder(childFolder)}
/>
);
})}
</div>
);
})}
</div>
{/* New Folder Button */}
{isCreatingFolder ? (
<div className={css['CreateFolderInput']}>
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateFolderSubmit();
} else if (e.key === 'Escape') {
handleCreateFolderCancel();
}
}}
onBlur={handleCreateFolderSubmit}
placeholder="Folder name..."
autoFocus
className={css['CreateFolderInputField']}
/>
</div>
) : (
<button className={css['NewFolderButton']} onClick={handleCreateFolder}>
<Icon icon={IconName.Plus} size={IconSize.Small} UNSAFE_className={css['NewFolderIcon']} />
<span className={css['NewFolderLabel']}>New Folder</span>
</button>
)}
{/* Delete Confirmation Overlay */}
{deletingFolder && (
<div className={css['DeleteConfirmation']}>
<div className={css['DeleteConfirmationBackdrop']} onClick={handleDeleteFolderCancel} />
<div className={css['DeleteConfirmationDialog']}>
<div className={css['DeleteConfirmationTitle']}>Delete Folder</div>
<div className={css['DeleteConfirmationMessage']}>
{getProjectCountInFolder(deletingFolder.id) > 0
? `Delete "${deletingFolder.name}"? ${getProjectCountInFolder(
deletingFolder.id
)} project(s) will be moved to Uncategorized.`
: `Delete "${deletingFolder.name}"?`}
</div>
<div className={css['DeleteConfirmationButtons']}>
<button className={css['DeleteConfirmationCancelButton']} onClick={handleDeleteFolderCancel}>
Cancel
</button>
<button className={css['DeleteConfirmationDeleteButton']} onClick={handleDeleteFolderConfirm}>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { FolderTree } from './FolderTree';
export type { FolderTreeProps } from './FolderTree';

View File

@@ -0,0 +1,87 @@
/**
* FolderTreeItem Styles
*/
.Root {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-1-5) var(--spacing-2);
border-radius: var(--radius-default);
cursor: pointer;
transition: background-color 0.15s ease;
min-height: 32px;
user-select: none;
&:hover {
background-color: var(--theme-color-bg-3);
}
&--selected {
background-color: var(--theme-color-bg-4);
&:hover {
background-color: var(--theme-color-bg-4);
}
}
}
.Chevron {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: var(--theme-color-fg-default-shy);
transition: color 0.15s ease;
flex-shrink: 0;
&:hover {
color: var(--theme-color-fg-default);
}
}
.ChevronIcon {
display: flex;
}
.FolderIcon {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-color-fg-default-shy);
flex-shrink: 0;
}
.Icon {
display: flex;
}
.FolderName {
flex: 1;
min-width: 0; /* Allow text truncation */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Badge {
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--spacing-1-5);
background-color: var(--theme-color-bg-3);
border-radius: var(--radius-full);
min-width: 20px;
height: 18px;
flex-shrink: 0;
}
.ContextMenuTrigger {
display: flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}

View File

@@ -0,0 +1,148 @@
/**
* FolderTreeItem - Individual folder row in the folder tree
*
* Displays a folder with icon, name, project count badge, and context menu.
* Supports nested folders with expand/collapse chevron.
*
* @module noodl-core-ui/preview/launcher
*/
import classNames from 'classnames';
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { ContextMenu, ContextMenuProps } from '@noodl-core-ui/components/popups/ContextMenu';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Folder } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import css from './FolderTreeItem.module.scss';
export interface FolderTreeItemProps {
folder: Folder;
/** Project count in this folder */
projectCount: number;
/** Whether this folder is currently selected for filtering */
isSelected?: boolean;
/** Whether this folder has nested children */
hasChildren?: boolean;
/** Whether children are expanded (only relevant if hasChildren is true) */
isExpanded?: boolean;
/** Indentation level for nested folders (0 = root) */
level?: number;
/** Called when folder is clicked for filtering */
onClick?: () => void;
/** Called when expand/collapse chevron is clicked */
onToggleExpand?: () => void;
/** Called when rename is requested */
onRename?: () => void;
/** Called when delete is requested */
onDelete?: () => void;
}
/**
* FolderTreeItem displays a single folder in the tree with:
* - Folder icon (open/closed based on expansion)
* - Folder name
* - Project count badge
* - Context menu for rename/delete
* - Expand/collapse chevron for nested folders
*/
export function FolderTreeItem({
folder,
projectCount,
isSelected = false,
hasChildren = false,
isExpanded = false,
level = 0,
onClick,
onToggleExpand,
onRename,
onDelete
}: FolderTreeItemProps) {
const [isHovered, setIsHovered] = useState(false);
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleExpand?.();
};
const contextMenuItems: ContextMenuProps['menuItems'] = [
{
label: 'Rename folder',
icon: IconName.PencilLine,
onClick: () => onRename?.()
},
'divider',
{
label: 'Delete folder',
icon: IconName.Trash,
onClick: () => onDelete?.(),
isDangerous: true
}
];
const paddingLeft = 8 + level * 16; // Base padding + indent per level
return (
<div
className={classNames(css['Root'], {
[css['Root--selected']]: isSelected,
[css['Root--hasChildren']]: hasChildren
})}
style={{ paddingLeft: `${paddingLeft}px` }}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Expand/Collapse Chevron */}
{hasChildren && (
<button
className={css['Chevron']}
onClick={handleChevronClick}
aria-label={isExpanded ? 'Collapse folder' : 'Expand folder'}
>
<Icon
icon={isExpanded ? IconName.CaretDown : IconName.CaretRight}
size={IconSize.Small}
UNSAFE_className={css['ChevronIcon']}
/>
</button>
)}
{/* Folder Icon */}
<div className={css['FolderIcon']}>
<Icon
icon={isExpanded ? IconName.FolderOpen : IconName.FolderClosed}
size={IconSize.Default}
UNSAFE_className={css['Icon']}
/>
</div>
{/* Folder Name */}
<Label
size={LabelSize.Default}
variant={isSelected ? TextType.Default : TextType.Shy}
UNSAFE_className={css['FolderName']}
>
{folder.name}
</Label>
{/* Project Count Badge */}
{projectCount > 0 && (
<span className={css['Badge']}>
<Label size={LabelSize.Small} variant={TextType.Shy}>
{String(projectCount)}
</Label>
</span>
)}
{/* Context Menu */}
{isHovered && (
<div className={css['ContextMenuTrigger']} onClick={(e) => e.stopPropagation()}>
<ContextMenu menuItems={contextMenuItems} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { FolderTreeItem } from './FolderTreeItem';
export type { FolderTreeItemProps } from './FolderTreeItem';

View File

@@ -19,6 +19,8 @@ import { Title, TitleSize } from '@noodl-core-ui/components/typography/Title';
import { UserBadgeProps, UserBadgeSize } from '@noodl-core-ui/components/user/UserBadge';
import { UserBadgeList } from '@noodl-core-ui/components/user/UserBadgeList';
import { useProjectOrganization } from '../../hooks/useProjectOrganization';
import { TagPill, TagPillSize } from '../TagPill';
import css from './LauncherProjectCard.module.scss';
// FIXME: Use the timeSince function from the editor package when this is moved there
@@ -73,26 +75,31 @@ export interface LauncherProjectData {
export interface LauncherProjectCardProps extends LauncherProjectData {
contextMenuItems: ContextMenuProps[];
onClick?: () => void;
}
export function LauncherProjectCard({
id,
title,
cloudSyncMeta,
localPath,
lastOpened,
pullAmount,
pushAmount,
uncommittedChangesAmount,
imageSrc,
contextMenuItems,
contributors
contributors,
onClick
}: LauncherProjectCardProps) {
const { tags, getProjectMeta } = useProjectOrganization();
// Get project tags
const projectMeta = getProjectMeta(localPath);
const projectTags = projectMeta ? tags.filter((tag) => projectMeta.tagIds.includes(tag.id)) : [];
return (
<Card
background={CardBackground.Bg2}
hoverBackground={CardBackground.Bg3}
onClick={() => alert('FIXME: open project')}
>
<Card background={CardBackground.Bg2} hoverBackground={CardBackground.Bg3} onClick={onClick}>
<Stack direction="row">
<div className={css.Image} style={{ backgroundImage: `url(${imageSrc})` }} />
@@ -102,6 +109,16 @@ export function LauncherProjectCard({
<Title hasBottomSpacing size={TitleSize.Medium}>
{title}
</Title>
{/* Tags */}
{projectTags.length > 0 && (
<HStack hasSpacing={2} UNSAFE_style={{ marginBottom: 'var(--spacing-2)', flexWrap: 'wrap' }}>
{projectTags.map((tag) => (
<TagPill key={tag.id} tag={tag} size={TagPillSize.Small} />
))}
</HStack>
)}
<Label variant={TextType.Shy}>Last opened {timeSince(new Date(lastOpened))} ago</Label>
</div>

View File

@@ -0,0 +1,80 @@
/**
* TagPill Styles
*
* Small colored pill displaying a tag name.
* Uses tag.color for background, white text for readability.
*/
.Root {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
border-radius: 12px;
transition: opacity 0.15s ease;
user-select: none;
flex-shrink: 0;
&.Clickable {
cursor: pointer;
&:hover {
opacity: 0.85;
}
&:active {
opacity: 0.7;
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: 2px;
}
}
}
/* Size Variants */
.Size-small {
padding: 2px 8px;
min-height: 20px;
}
.Size-medium {
padding: 4px 10px;
min-height: 24px;
}
/* Label */
.Label {
color: #ffffff;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
/* Remove Button */
.RemoveButton {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 2px;
cursor: pointer;
color: #ffffff;
opacity: 0.7;
transition: opacity 0.15s ease;
border-radius: 4px;
&:hover {
opacity: 1;
}
&:active {
opacity: 0.6;
}
&:focus-visible {
outline: 1px solid #ffffff;
outline-offset: 1px;
}
}

View File

@@ -0,0 +1,91 @@
/**
* TagPill Component
*
* Displays a tag as a small colored pill/badge.
* Used to show project tags in cards and lists.
*/
import React from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { Tag } from '../../hooks/useProjectOrganization';
import css from './TagPill.module.scss';
export enum TagPillSize {
Small = 'small',
Medium = 'medium'
}
export interface TagPillProps {
/** The tag data to display */
tag: Tag;
/** Size variant */
size?: TagPillSize;
/** Whether to show remove button */
removable?: boolean;
/** Callback when remove button is clicked */
onRemove?: () => void;
/** Callback when pill is clicked */
onClick?: () => void;
/** Custom className */
className?: string;
}
/**
* TagPill - Displays a tag as a colored pill badge
*
* @example
* ```tsx
* <TagPill
* tag={{ id: '1', name: 'Frontend', color: '#3B82F6' }}
* size={TagPillSize.Small}
* />
* ```
*/
export function TagPill({
tag,
size = TagPillSize.Medium,
removable = false,
onRemove,
onClick,
className
}: TagPillProps) {
const handleRemoveClick = (e: React.MouseEvent) => {
e.stopPropagation();
onRemove?.();
};
const handlePillClick = (e: React.MouseEvent) => {
if (onClick) {
e.stopPropagation();
onClick();
}
};
return (
<div
className={`${css.Root} ${css[`Size-${size}`]} ${onClick ? css.Clickable : ''} ${className || ''}`}
style={{ backgroundColor: tag.color }}
onClick={handlePillClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<Label size={size === TagPillSize.Small ? LabelSize.Small : LabelSize.Default} UNSAFE_className={css.Label}>
{tag.name}
</Label>
{removable && (
<button
className={css.RemoveButton}
onClick={handleRemoveClick}
aria-label={`Remove ${tag.name} tag`}
type="button"
>
<Icon icon={IconName.Close} size={size === TagPillSize.Small ? IconSize.Tiny : IconSize.Small} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TagPill, TagPillSize } from './TagPill';
export type { TagPillProps } from './TagPill';

View File

@@ -0,0 +1,35 @@
/**
* TagSelector Styles
*
* UI for selecting/managing tags for a project.
*/
.Root {
padding: var(--spacing-4);
min-width: 240px;
max-width: 320px;
}
/* Tag Item (Checkbox + Pill) */
.TagItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-3);
}
}
/* Checkbox */
.Checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
accent-color: var(--theme-color-primary);
}

View File

@@ -0,0 +1,142 @@
/**
* TagSelector Component
*
* Allows users to assign/remove tags from a project.
* Displays all available tags with checkboxes and option to create new tags.
*/
import React, { useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Tag, useProjectOrganization } from '../../hooks/useProjectOrganization';
import { TagPill, TagPillSize } from '../TagPill';
import css from './TagSelector.module.scss';
export interface TagSelectorProps {
/** Project path to manage tags for */
projectPath: string;
/** Callback when tags are changed */
onTagsChanged?: () => void;
}
/**
* TagSelector - UI for assigning/removing tags from a project
*
* Shows all available tags with checkboxes, and allows creating new tags inline.
*/
export function TagSelector({ projectPath, onTagsChanged }: TagSelectorProps) {
const { tags, getProjectMeta, addTagToProject, removeTagFromProject, createTag } = useProjectOrganization();
const [isCreatingTag, setIsCreatingTag] = useState(false);
const [newTagName, setNewTagName] = useState('');
// Get current project tags
const projectMeta = getProjectMeta(projectPath);
const assignedTagIds = projectMeta?.tagIds || [];
const handleToggleTag = (tagId: string) => {
if (assignedTagIds.includes(tagId)) {
removeTagFromProject(projectPath, tagId);
} else {
addTagToProject(projectPath, tagId);
}
onTagsChanged?.();
};
const handleCreateTag = () => {
if (!newTagName.trim()) return;
const newTag = createTag(newTagName.trim());
addTagToProject(projectPath, newTag.id);
setNewTagName('');
setIsCreatingTag(false);
onTagsChanged?.();
};
const handleCancelCreate = () => {
setNewTagName('');
setIsCreatingTag(false);
};
return (
<div className={css.Root}>
{/* Tag list */}
{tags.length > 0 ? (
<VStack hasSpacing={2}>
{tags.map((tag) => {
const isAssigned = assignedTagIds.includes(tag.id);
return (
<label key={tag.id} className={css.TagItem}>
<input
type="checkbox"
checked={isAssigned}
onChange={() => handleToggleTag(tag.id)}
className={css.Checkbox}
/>
<TagPill tag={tag} size={TagPillSize.Small} />
</label>
);
})}
</VStack>
) : (
<Label variant={TextType.Shy} size={LabelSize.Small}>
No tags yet. Create one below.
</Label>
)}
{/* Create new tag */}
<Box hasTopSpacing={4}>
{!isCreatingTag ? (
<PrimaryButton
label="Create new tag"
icon={IconName.Plus}
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={() => setIsCreatingTag(true)}
UNSAFE_style={{ width: '100%' }}
/>
) : (
<VStack hasSpacing={2}>
<TextInput
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name..."
variant={TextInputVariant.Default}
isAutoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateTag();
} else if (e.key === 'Escape') {
handleCancelCreate();
}
}}
/>
<HStack hasSpacing={2}>
<PrimaryButton
label="Create"
size={PrimaryButtonSize.Small}
onClick={handleCreateTag}
isDisabled={!newTagName.trim()}
UNSAFE_style={{ flex: 1 }}
/>
<PrimaryButton
label="Cancel"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={handleCancelCreate}
UNSAFE_style={{ flex: 1 }}
/>
</HStack>
</VStack>
)}
</Box>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TagSelector } from './TagSelector';
export type { TagSelectorProps } from './TagSelector';

View File

@@ -0,0 +1,354 @@
/**
* useProjectOrganization
*
* React hook for managing project organization (folders and tags).
* Interfaces with ProjectOrganizationService from noodl-editor.
*/
import { useEffect, useMemo, useState } from 'react';
// Import types and service from noodl-editor
// Note: This assumes the service is available in the editor context
// We'll need to expose it through the window or context
declare global {
interface Window {
ProjectOrganizationService?: any;
}
}
export interface Folder {
id: string;
name: string;
parentId: string | null;
order: number;
createdAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
createdAt: string;
}
export interface ProjectMeta {
folderId: string | null;
tagIds: string[];
customName?: string;
notes?: string;
}
export interface UseProjectOrganizationReturn {
// Data
folders: Folder[];
tags: Tag[];
// Folder operations
createFolder: (name: string, parentId?: string | null) => Folder;
renameFolder: (id: string, name: string) => void;
deleteFolder: (id: string) => void;
getProjectCountInFolder: (folderId: string | null) => number;
// Tag operations
createTag: (name: string, color?: string) => Tag;
renameTag: (id: string, name: string) => void;
changeTagColor: (id: string, color: string) => void;
deleteTag: (id: string) => void;
// Project organization
moveProjectToFolder: (projectPath: string, folderId: string | null) => void;
addTagToProject: (projectPath: string, tagId: string) => void;
removeTagFromProject: (projectPath: string, tagId: string) => void;
getProjectMeta: (projectPath: string) => ProjectMeta | null;
getProjectsInFolder: (folderId: string | null) => string[];
getProjectsWithTag: (tagId: string) => string[];
}
/**
* Hook to manage project organization through folders and tags.
*
* Note: Currently uses localStorage directly as a fallback.
* In production, this should interface with ProjectOrganizationService
* from the Electron main process.
*/
export function useProjectOrganization(): UseProjectOrganizationReturn {
const [folders, setFolders] = useState<Folder[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [, setUpdateTrigger] = useState(0);
// Memoize service to prevent infinite loop - service must be stable across renders
const service = useMemo(() => {
// TODO: In production, get this from window context or inject it
// For now, we'll implement a minimal localStorage version
return createLocalStorageService();
}, []); // Empty deps - create service once
// Subscribe to service events and load initial data
useEffect(() => {
// Initial load
setFolders(service.getFolders());
setTags(service.getTags());
// Subscribe to changes
const handleDataChange = () => {
setFolders(service.getFolders());
setTags(service.getTags());
setUpdateTrigger((prev) => prev + 1);
};
service.on('dataChanged', handleDataChange);
// Cleanup
return () => {
service.off('dataChanged', handleDataChange);
};
}, [service]);
return {
// Data
folders,
tags,
// Folder operations
createFolder: (name, parentId) => service.createFolder(name, parentId),
renameFolder: (id, name) => service.renameFolder(id, name),
deleteFolder: (id) => service.deleteFolder(id),
getProjectCountInFolder: (folderId) => service.getProjectCountInFolder(folderId),
// Tag operations
createTag: (name, color) => service.createTag(name, color),
renameTag: (id, name) => service.renameTag(id, name),
changeTagColor: (id, color) => service.changeTagColor(id, color),
deleteTag: (id) => service.deleteTag(id),
// Project organization
moveProjectToFolder: (projectPath, folderId) => service.moveProjectToFolder(projectPath, folderId),
addTagToProject: (projectPath, tagId) => service.addTagToProject(projectPath, tagId),
removeTagFromProject: (projectPath, tagId) => service.removeTagFromProject(projectPath, tagId),
getProjectMeta: (projectPath) => service.getProjectMeta(projectPath),
getProjectsInFolder: (folderId) => service.getProjectsInFolder(folderId),
getProjectsWithTag: (tagId) => service.getProjectsWithTag(tagId)
};
}
// ============================================================================
// Minimal localStorage-based service for noodl-core-ui
// This allows the hook to work in Storybook and standalone contexts
// ============================================================================
class EventEmitter {
private listeners: Map<string, Array<(data: any) => void>> = new Map();
on(event: string, callback: (data: any) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
off(event: string, callback?: (data: any) => void) {
if (!callback) {
this.listeners.delete(event);
return;
}
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
emit(event: string, data?: any) {
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
}
}
function createLocalStorageService() {
const emitter = new EventEmitter();
const storageKey = 'projectOrganization';
interface StorageData {
version: 1;
folders: Folder[];
tags: Tag[];
projectMeta: Record<string, ProjectMeta>;
}
const loadData = (): StorageData => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('[useProjectOrganization] Failed to load data:', error);
}
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
};
let data = loadData();
const saveData = () => {
try {
localStorage.setItem(storageKey, JSON.stringify(data));
emitter.emit('dataChanged', data);
} catch (error) {
console.error('[useProjectOrganization] Failed to save data:', error);
}
};
const generateId = (prefix: string) => {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
const TAG_COLORS = [
'#EF4444',
'#F97316',
'#EAB308',
'#22C55E',
'#06B6D4',
'#3B82F6',
'#8B5CF6',
'#EC4899',
'#6B7280'
];
const getNextTagColor = () => {
const usedColors = data.tags.map((t) => t.color);
const availableColors = TAG_COLORS.filter((c) => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : TAG_COLORS[0];
};
return {
// Event methods
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
// Data methods
getFolders: () => [...data.folders].sort((a, b) => a.order - b.order),
getTags: () => [...data.tags],
createFolder: (name: string, parentId?: string | null): Folder => {
const folder: Folder = {
id: generateId('folder'),
name,
parentId: parentId || null,
order: data.folders.length,
createdAt: new Date().toISOString()
};
data.folders.push(folder);
saveData();
return folder;
},
renameFolder: (id: string, name: string) => {
const folder = data.folders.find((f) => f.id === id);
if (folder) {
folder.name = name;
saveData();
}
},
deleteFolder: (id: string) => {
data.folders = data.folders.filter((f) => f.id !== id && f.parentId !== id);
Object.keys(data.projectMeta).forEach((projectPath) => {
if (data.projectMeta[projectPath].folderId === id) {
data.projectMeta[projectPath].folderId = null;
}
});
saveData();
},
getProjectCountInFolder: (folderId: string | null): number => {
return Object.values(data.projectMeta).filter((meta) => meta.folderId === folderId).length;
},
createTag: (name: string, color?: string): Tag => {
const tag: Tag = {
id: generateId('tag'),
name,
color: color || getNextTagColor(),
createdAt: new Date().toISOString()
};
data.tags.push(tag);
saveData();
return tag;
},
renameTag: (id: string, name: string) => {
const tag = data.tags.find((t) => t.id === id);
if (tag) {
tag.name = name;
saveData();
}
},
changeTagColor: (id: string, color: string) => {
const tag = data.tags.find((t) => t.id === id);
if (tag) {
tag.color = color;
saveData();
}
},
deleteTag: (id: string) => {
data.tags = data.tags.filter((t) => t.id !== id);
Object.keys(data.projectMeta).forEach((projectPath) => {
data.projectMeta[projectPath].tagIds = data.projectMeta[projectPath].tagIds.filter((tagId) => tagId !== id);
});
saveData();
},
moveProjectToFolder: (projectPath: string, folderId: string | null) => {
if (!data.projectMeta[projectPath]) {
data.projectMeta[projectPath] = { folderId: null, tagIds: [] };
}
data.projectMeta[projectPath].folderId = folderId;
saveData();
},
addTagToProject: (projectPath: string, tagId: string) => {
if (!data.projectMeta[projectPath]) {
data.projectMeta[projectPath] = { folderId: null, tagIds: [] };
}
const meta = data.projectMeta[projectPath];
if (!meta.tagIds.includes(tagId)) {
meta.tagIds.push(tagId);
saveData();
}
},
removeTagFromProject: (projectPath: string, tagId: string) => {
const meta = data.projectMeta[projectPath];
if (meta) {
meta.tagIds = meta.tagIds.filter((id) => id !== tagId);
saveData();
}
},
getProjectMeta: (projectPath: string): ProjectMeta | null => {
return data.projectMeta[projectPath] || null;
},
getProjectsInFolder: (folderId: string | null): string[] => {
return Object.keys(data.projectMeta).filter((projectPath) => data.projectMeta[projectPath].folderId === folderId);
},
getProjectsWithTag: (tagId: string): string[] => {
return Object.keys(data.projectMeta).filter((projectPath) =>
data.projectMeta[projectPath].tagIds.includes(tagId)
);
}
};
}

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
@@ -9,6 +9,7 @@ import { Columns } from '@noodl-core-ui/components/layout/Columns';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
import { TextType } from '@noodl-core-ui/components/typography/Text';
import { FolderTree } from '@noodl-core-ui/preview/launcher/Launcher/components/FolderTree';
import { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
import {
CloudSyncType,
@@ -23,6 +24,7 @@ import { ProjectList } from '@noodl-core-ui/preview/launcher/Launcher/components
import { ProjectSettingsModal } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectSettingsModal';
import { ViewModeToggle } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { useProjectList } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectList';
import { useProjectOrganization } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectOrganization';
import { MOCK_PROJECTS } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
@@ -33,6 +35,8 @@ export function Projects({}: ProjectsViewProps) {
viewMode,
setViewMode,
projects: allProjects,
selectedFolderId,
setSelectedFolderId,
onCreateProject,
onOpenProject,
onLaunchProject,
@@ -40,8 +44,38 @@ export function Projects({}: ProjectsViewProps) {
onDeleteProject
} = useLauncherContext();
const { getProjectMeta, getProjectsInFolder, folders, moveProjectToFolder } = useProjectOrganization();
const [selectedProjectId, setSelectedProjectId] = useState(null);
const uniqueTypes = [...new Set(allProjects.map((item) => item.cloudSyncMeta.type))];
const [movingProject, setMovingProject] = useState<LauncherProjectData | null>(null);
// Filter projects based on selected folder
const filteredByFolder = useMemo(() => {
if (selectedFolderId === null) {
// "All Projects" - show everything
return allProjects;
} else if (selectedFolderId === 'uncategorized') {
// "Uncategorized" - show projects without a folder
return allProjects.filter((project) => {
const meta = getProjectMeta(project.localPath);
return !meta || meta.folderId === null;
});
} else {
// Specific folder - show projects in that folder
const projectPathsInFolder = getProjectsInFolder(selectedFolderId);
return allProjects.filter((project) => projectPathsInFolder.includes(project.localPath));
}
}, [allProjects, selectedFolderId, getProjectMeta, getProjectsInFolder]);
// Calculate counts for folder tree
const uncategorizedCount = useMemo(() => {
return allProjects.filter((project) => {
const meta = getProjectMeta(project.localPath);
return !meta || meta.folderId === null;
}).length;
}, [allProjects, getProjectMeta]);
const uniqueTypes = [...new Set(filteredByFolder.map((item) => item.cloudSyncMeta.type))];
const visibleTypesDropdownItems: SelectOption[] = [
{ label: 'All projects', value: 'all' },
...uniqueTypes.map((type) => ({ label: `Only ${type.toLowerCase()} projects`, value: type }))
@@ -54,7 +88,7 @@ export function Projects({}: ProjectsViewProps) {
searchTerm,
setSearchTerm
} = useLauncherSearchBar({
allItems: allProjects,
allItems: filteredByFolder,
filterDropdownItems: visibleTypesDropdownItems,
propertyNameToFilter: 'cloudSyncMeta.type'
});
@@ -74,6 +108,21 @@ export function Projects({}: ProjectsViewProps) {
setSelectedProjectId(null);
}
function onMoveToFolder(project: LauncherProjectData) {
setMovingProject(project);
}
function onCloseFolderPicker() {
setMovingProject(null);
}
function handleMoveToFolder(folderId: string | null) {
if (movingProject) {
moveProjectToFolder(movingProject.localPath, folderId);
setMovingProject(null);
}
}
function onImportProjectClick() {
onOpenProject?.();
}
@@ -83,6 +132,19 @@ export function Projects({}: ProjectsViewProps) {
}
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
{/* Folder Tree Sidebar */}
<div style={{ width: '240px', borderRight: '1px solid var(--theme-color-border-default)', flexShrink: 0 }}>
<FolderTree
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
totalProjectCount={allProjects.length}
uncategorizedProjectCount={uncategorizedCount}
/>
</div>
{/* Main Content */}
<div style={{ flex: 1, overflow: 'auto' }}>
<LauncherPage
title="Recent Projects"
headerSlot={
@@ -152,6 +214,7 @@ export function Projects({}: ProjectsViewProps) {
<LauncherProjectCard
key={project.id}
{...project}
onClick={() => onLaunchProject?.(project.id)}
contextMenuItems={[
{
label: 'Launch project',
@@ -161,6 +224,10 @@ export function Projects({}: ProjectsViewProps) {
label: 'Open project folder',
onClick: () => onOpenProjectFolder?.(project.id)
},
{
label: 'Move to folder...',
onClick: () => onMoveToFolder(project)
},
{
label: 'Open project settings',
onClick: () => onOpenProjectSettings(project.id)
@@ -180,6 +247,141 @@ export function Projects({}: ProjectsViewProps) {
</>
)}
</Box>
{/* Folder Picker Modal */}
{movingProject && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)'
}}
onClick={onCloseFolderPicker}
/>
<div
style={{
position: 'relative',
backgroundColor: 'var(--theme-color-bg-2)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--spacing-6)',
minWidth: '400px',
maxWidth: '500px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
zIndex: 1001
}}
>
<h3
style={{
fontSize: '20px',
fontWeight: 600,
color: 'var(--theme-color-fg-default)',
margin: '0 0 var(--spacing-3) 0',
lineHeight: 1.3
}}
>
Move "{movingProject.title}" to folder
</h3>
<div
style={{
marginBottom: 'var(--spacing-6)',
maxHeight: '300px',
overflowY: 'auto'
}}
>
<button
onClick={() => handleMoveToFolder(null)}
style={{
width: '100%',
textAlign: 'left',
padding: 'var(--spacing-2) var(--spacing-3)',
marginBottom: 'var(--spacing-1)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
Uncategorized
</button>
{folders.map((folder) => (
<button
key={folder.id}
onClick={() => handleMoveToFolder(folder.id)}
style={{
width: '100%',
textAlign: 'left',
padding: 'var(--spacing-2) var(--spacing-3)',
marginBottom: 'var(--spacing-1)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
{folder.name}
</button>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={onCloseFolderPicker}
style={{
padding: 'var(--spacing-2) var(--spacing-4)',
backgroundColor: 'var(--theme-color-bg-3)',
border: '1px solid var(--theme-color-border-default)',
borderRadius: 'var(--radius-default)',
color: 'var(--theme-color-fg-default)',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-color-bg-3)';
}}
>
Cancel
</button>
</div>
</div>
</div>
)}
</LauncherPage>
</div>
</div>
);
}

View File

@@ -6,25 +6,67 @@
*/
import { ipcRenderer, shell } from 'electron';
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { filesystem } from '@noodl/platform';
import {
CloudSyncType,
LauncherProjectData
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
import { useEventListener } from '../../hooks/useEventListener';
import { IRouteProps } from '../../pages/AppRoute';
import { LocalProjectsModel } from '../../utils/LocalProjectsModel';
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
export interface ProjectsPageProps extends IRouteProps {
from: TSFixme;
}
/**
* Map LocalProjectsModel ProjectItem to LauncherProjectData format
*/
function mapProjectToLauncherData(project: ProjectItem): LauncherProjectData {
return {
id: project.id,
title: project.name || 'Untitled',
localPath: project.retainedProjectDirectory,
lastOpened: new Date(project.latestAccessed).toISOString(),
imageSrc: project.thumbURI || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
cloudSyncMeta: {
type: CloudSyncType.None // TODO: Detect git repos in future
}
// Git-related fields will be populated in future tasks
};
}
export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// Fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initial load
const loadProjects = async () => {
await LocalProjectsModel.instance.fetch();
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
};
loadProjects();
}, []);
// Subscribe to project list changes
useEventListener(LocalProjectsModel.instance, 'myProjectsChanged', () => {
console.log('🔔 Projects list changed, updating dashboard');
const projects = LocalProjectsModel.instance.getProjects();
setRealProjects(projects.map(mapProjectToLauncherData));
});
const handleCreateProject = useCallback(async () => {
try {
const direntry = await filesystem.openDialog({
@@ -196,6 +238,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
return (
<Launcher
projects={realProjects}
onCreateProject={handleCreateProject}
onOpenProject={handleOpenProject}
onLaunchProject={handleLaunchProject}

View File

@@ -0,0 +1,340 @@
/**
* ProjectOrganizationService
*
* Manages project organization through folders and tags.
* Data is stored client-side in electron-store and keyed by project path.
*/
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
// ============================================================================
// Types
// ============================================================================
export interface Folder {
id: string;
name: string;
parentId: string | null; // null = root level
order: number;
createdAt: string;
}
export interface Tag {
id: string;
name: string;
color: string; // hex color
createdAt: string;
}
export interface ProjectMeta {
folderId: string | null;
tagIds: string[];
customName?: string;
notes?: string;
}
export interface ProjectOrganizationData {
version: 1;
folders: Folder[];
tags: Tag[];
projectMeta: Record<string, ProjectMeta>; // keyed by project path
}
// Tag color palette
export const TAG_COLORS = [
'#EF4444', // Red
'#F97316', // Orange
'#EAB308', // Yellow
'#22C55E', // Green
'#06B6D4', // Cyan
'#3B82F6', // Blue
'#8B5CF6', // Purple
'#EC4899', // Pink
'#6B7280' // Gray
];
// ============================================================================
// Service
// ============================================================================
export class ProjectOrganizationService extends EventDispatcher {
private static _instance: ProjectOrganizationService;
private data: ProjectOrganizationData;
private storageKey = 'projectOrganization';
private constructor() {
super();
this.data = this.loadData();
}
static get instance(): ProjectOrganizationService {
if (!ProjectOrganizationService._instance) {
ProjectOrganizationService._instance = new ProjectOrganizationService();
}
return ProjectOrganizationService._instance;
}
// ============================================================================
// Storage
// ============================================================================
private loadData(): ProjectOrganizationData {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (error) {
console.error('[ProjectOrganizationService] Failed to load data:', error);
}
// Return default empty structure
return {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
}
private saveData(): void {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
this.notifyListeners('dataChanged', this.data);
} catch (error) {
console.error('[ProjectOrganizationService] Failed to save data:', error);
}
}
// ============================================================================
// Folder Operations
// ============================================================================
createFolder(name: string, parentId?: string | null): Folder {
const folder: Folder = {
id: this.generateId('folder'),
name,
parentId: parentId || null,
order: this.data.folders.length,
createdAt: new Date().toISOString()
};
this.data.folders.push(folder);
this.saveData();
this.notifyListeners('folderCreated', folder);
return folder;
}
renameFolder(id: string, name: string): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) {
console.warn('[ProjectOrganizationService] Folder not found:', id);
return;
}
folder.name = name;
this.saveData();
this.notifyListeners('folderRenamed', { id, name });
}
deleteFolder(id: string): void {
// Remove folder
this.data.folders = this.data.folders.filter((f) => f.id !== id);
// Remove child folders
this.data.folders = this.data.folders.filter((f) => f.parentId !== id);
// Move projects in deleted folder to uncategorized
Object.keys(this.data.projectMeta).forEach((projectPath) => {
if (this.data.projectMeta[projectPath].folderId === id) {
this.data.projectMeta[projectPath].folderId = null;
}
});
this.saveData();
this.notifyListeners('folderDeleted', id);
}
reorderFolder(id: string, newOrder: number): void {
const folder = this.data.folders.find((f) => f.id === id);
if (!folder) return;
folder.order = newOrder;
this.saveData();
this.notifyListeners('folderReordered', { id, order: newOrder });
}
getFolders(): Folder[] {
return [...this.data.folders].sort((a, b) => a.order - b.order);
}
getFolder(id: string): Folder | undefined {
return this.data.folders.find((f) => f.id === id);
}
// ============================================================================
// Tag Operations
// ============================================================================
createTag(name: string, color?: string): Tag {
const tag: Tag = {
id: this.generateId('tag'),
name,
color: color || this.getNextTagColor(),
createdAt: new Date().toISOString()
};
this.data.tags.push(tag);
this.saveData();
this.notifyListeners('tagCreated', tag);
return tag;
}
renameTag(id: string, name: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) {
console.warn('[ProjectOrganizationService] Tag not found:', id);
return;
}
tag.name = name;
this.saveData();
this.notifyListeners('tagRenamed', { id, name });
}
changeTagColor(id: string, color: string): void {
const tag = this.data.tags.find((t) => t.id === id);
if (!tag) return;
tag.color = color;
this.saveData();
this.notifyListeners('tagColorChanged', { id, color });
}
deleteTag(id: string): void {
// Remove tag
this.data.tags = this.data.tags.filter((t) => t.id !== id);
// Remove tag from all projects
Object.keys(this.data.projectMeta).forEach((projectPath) => {
const meta = this.data.projectMeta[projectPath];
meta.tagIds = meta.tagIds.filter((tagId) => tagId !== id);
});
this.saveData();
this.notifyListeners('tagDeleted', id);
}
getTags(): Tag[] {
return [...this.data.tags];
}
getTag(id: string): Tag | undefined {
return this.data.tags.find((t) => t.id === id);
}
private getNextTagColor(): string {
const usedColors = this.data.tags.map((t) => t.color);
const availableColors = TAG_COLORS.filter((c) => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : TAG_COLORS[0];
}
// ============================================================================
// Project Organization
// ============================================================================
moveProjectToFolder(projectPath: string, folderId: string | null): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
this.data.projectMeta[projectPath].folderId = folderId;
this.saveData();
this.notifyListeners('projectMoved', { projectPath, folderId });
}
addTagToProject(projectPath: string, tagId: string): void {
if (!this.data.projectMeta[projectPath]) {
this.data.projectMeta[projectPath] = {
folderId: null,
tagIds: []
};
}
const meta = this.data.projectMeta[projectPath];
if (!meta.tagIds.includes(tagId)) {
meta.tagIds.push(tagId);
this.saveData();
this.notifyListeners('projectTagAdded', { projectPath, tagId });
}
}
removeTagFromProject(projectPath: string, tagId: string): void {
const meta = this.data.projectMeta[projectPath];
if (!meta) return;
meta.tagIds = meta.tagIds.filter((id) => id !== tagId);
this.saveData();
this.notifyListeners('projectTagRemoved', { projectPath, tagId });
}
getProjectMeta(projectPath: string): ProjectMeta | null {
return this.data.projectMeta[projectPath] || null;
}
// ============================================================================
// Queries
// ============================================================================
getProjectsInFolder(folderId: string | null): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.folderId === folderId;
});
}
getProjectsWithTag(tagId: string): string[] {
return Object.keys(this.data.projectMeta).filter((projectPath) => {
const meta = this.data.projectMeta[projectPath];
return meta.tagIds.includes(tagId);
});
}
getProjectCountInFolder(folderId: string | null): number {
return this.getProjectsInFolder(folderId).length;
}
// ============================================================================
// Utilities
// ============================================================================
private generateId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// For debugging
exportData(): ProjectOrganizationData {
return JSON.parse(JSON.stringify(this.data));
}
importData(data: ProjectOrganizationData): void {
this.data = data;
this.saveData();
}
clearAll(): void {
this.data = {
version: 1,
folders: [],
tags: [],
projectMeta: {}
};
this.saveData();
this.notifyListeners('dataCleared', null);
}
}

View File

@@ -219,13 +219,13 @@ TODO: review this icon
.components-panel-item-selected {
line-height: 36px;
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
}
.components-panel-item-selected .caret-icon-container {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
}
.components-panel-item-selected .components-panel-item-selected {
@@ -234,13 +234,13 @@ TODO: review this icon
.components-panel-item-selected:hover,
.components-panel-item-selected:hover .caret-icon-container {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
.components-panel-item-selected .components-panel-edit-button:hover,
.components-panel-item-selected .components-panel-item-dropdown:hover {
background-color: var(--theme-color-secondary);
color: var(--theme-color-on-secondary);
background-color: var(--theme-color-bg-3);
color: var(--theme-color-fg-highlight);
}
.is-folder-component:hover .components-panel-folder-label {

View File

@@ -122,9 +122,10 @@
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: var(--theme-color-bg-5);
border-bottom-color: var(--theme-color-bg-4);
border-width: 10px;
margin-left: -10px;
transform: translateX(0);
}
.popup-layer-popout-arrow.right {

View File

@@ -83,13 +83,14 @@
cursor: pointer;
* {
color: var(--theme-color-fg-default);
fill: var(--theme-color-fg-default);
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
}
&:hover * {
color: var(--theme-color-fg-highlight);
fill: var(--theme-color-fg-highlight);
opacity: 0.8;
}
}

View File

@@ -76,7 +76,7 @@
}
&.is-current {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
position: relative;
z-index: 1;
}

View File

@@ -1,12 +1,14 @@
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import classNames from 'classnames';
import React, { ReactNode, useEffect, useState } from 'react';
import { NodeType } from '@noodl-constants/NodeType';
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import css from './NodePickerCategory.module.scss';
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { Text, TextSize, TextType } from '@noodl-core-ui/components/typography/Text';
interface NodePickerCategoryProps {
title: string;
@@ -86,7 +88,7 @@ export default function NodePickerCategory({
css['Arrow'],
isCollapsedState ? css['Arrow--is-collapsed'] : css['Arrow--is-not-collapsed']
])}
src="../assets/icons/editor/right_arrow_22.svg"
src="/assets/icons/editor/right_arrow_22.svg"
/>
</header>

View File

@@ -180,7 +180,7 @@ export function NodeLibrary({ model, parentModel, pos, attachToRoot, runtimeType
createNewComment(model, pos);
e.stopPropagation();
}}
icon={<img src="../assets/icons/comment.svg" />}
icon={<img src="/assets/icons/comment.svg" />}
/>
</NodePickerSection>
) : null}

View File

@@ -48,8 +48,8 @@
}
.lesson-item.selected {
background-color: var(--theme-color-fg-highlight);
color: var(--theme-color-secondary-dim);
background-color: var(--theme-color-bg-4);
color: var(--theme-color-fg-highlight);
opacity: 1;
transition: background-color 200ms, opacity 200ms;
}
@@ -128,9 +128,9 @@
}
.lesson-item-popup {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-3);
width: 512px;
color: white;
color: var(--theme-color-fg-highlight);
padding: 8px;
}

View File

@@ -17,10 +17,10 @@
}
&.is-active {
background-color: var(--theme-color-secondary);
background-color: var(--theme-color-bg-4);
&:hover {
background-color: var(--theme-color-secondary-highlight);
background-color: var(--theme-color-bg-5);
}
}
}