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