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/
|
│ ├── AiChatBox/
|
||||||
│ └── AiChatMessage/
|
│ └── 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
|
└── 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
|
## 🔍 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
|
## Quick Grep Commands
|
||||||
|
|
||||||
```bash
|
```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
|
# Find usage of a specific token
|
||||||
grep -r "theme-color-primary" packages/
|
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,49 +1,31 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {
|
import React, { CSSProperties, MouseEvent, MouseEventHandler, SyntheticEvent } from 'react';
|
||||||
CSSProperties,
|
|
||||||
MouseEvent,
|
import css from './LegacyIconButton.module.scss';
|
||||||
MouseEventHandler,
|
|
||||||
SyntheticEvent,
|
export enum LegacyIconButtonIcon {
|
||||||
} from 'react';
|
VerticalDots = 'vertical-dots',
|
||||||
import css from './LegacyIconButton.module.scss';
|
Close = 'close',
|
||||||
|
CloseDark = 'close-dark',
|
||||||
export enum LegacyIconButtonIcon {
|
CaretDown = 'caret-down',
|
||||||
VerticalDots = 'vertical-dots',
|
Generate = 'generate'
|
||||||
Close = 'close',
|
}
|
||||||
CloseDark = 'close-dark',
|
|
||||||
CaretDown = 'caret-down',
|
export interface LegacyIconButtonProps {
|
||||||
Generate = 'generate',
|
icon: LegacyIconButtonIcon;
|
||||||
}
|
isRotated180?: boolean;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
export interface LegacyIconButtonProps {
|
style?: CSSProperties;
|
||||||
icon: LegacyIconButtonIcon;
|
testId?: string;
|
||||||
isRotated180?: boolean;
|
}
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
||||||
style?: CSSProperties;
|
export function LegacyIconButton({ icon, isRotated180, style, onClick, testId }: LegacyIconButtonProps) {
|
||||||
testId?: string;
|
return (
|
||||||
}
|
<button className={css['Root']} onClick={onClick} style={style} data-test={testId}>
|
||||||
|
<img
|
||||||
export function LegacyIconButton({
|
className={classNames([css['Icon'], isRotated180 && css['is-rotated-180']])}
|
||||||
icon,
|
src={`/assets/icons/icon-button/${icon}.svg`}
|
||||||
isRotated180,
|
/>
|
||||||
style,
|
</button>
|
||||||
onClick,
|
);
|
||||||
testId,
|
}
|
||||||
}: LegacyIconButtonProps) {
|
|
||||||
return (
|
|
||||||
<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`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,12 +103,12 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
height: 0;
|
height: 0;
|
||||||
overflow: overlay;
|
overflow: hidden;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.InputWrapper {
|
.InputWrapper {
|
||||||
overflow-x: overlay;
|
overflow-x: hidden;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-top: 1px;
|
padding-top: 1px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
.Root {
|
.Root {
|
||||||
}
|
}
|
||||||
|
|
||||||
.Track {
|
.Track {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background-color: var(--theme-color-bg-1);
|
background-color: var(--theme-color-bg-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
border: 1px solid var(--theme-color-border-default);
|
||||||
|
}
|
||||||
.Indicator {
|
|
||||||
width: 18px;
|
.Indicator {
|
||||||
height: 18px;
|
width: 18px;
|
||||||
border-radius: 50%;
|
height: 18px;
|
||||||
background-color: var(--theme-color-bg-3);
|
border-radius: 50%;
|
||||||
transition: transform var(--speed-quick) var(--easing-base),
|
background-color: var(--theme-color-bg-3);
|
||||||
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 {
|
&.is-checked {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-always-active-color,
|
&.is-always-active-color,
|
||||||
&.is-checked {
|
&.is-checked {
|
||||||
background-color: var(--theme-color-secondary-bright);
|
background-color: var(--theme-color-secondary-bright);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Input {
|
.Input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,105 @@
|
|||||||
.Root {
|
.Root {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow: hidden overlay;
|
overflow: hidden overlay;
|
||||||
|
|
||||||
&.is-width-small {
|
&.is-width-small {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-width-default {
|
&.is-width-default {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-width-medium {
|
&.is-width-medium {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-width-large {
|
&.is-width-large {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Item {
|
.Item {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
transition: background-color var(--speed-turbo) var(--easing-base);
|
transition: background-color var(--speed-turbo) var(--easing-base);
|
||||||
|
|
||||||
&.is-highlighted:not(.is-disabled) {
|
&.is-highlighted:not(.is-disabled) {
|
||||||
background-color: var(--theme-color-secondary-highlight);
|
background-color: var(--theme-color-primary);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
color: var(--theme-color-on-secondary);
|
color: var(--theme-color-on-primary);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.Label span {
|
||||||
&.is-danger {
|
color: var(--theme-color-on-primary);
|
||||||
.Icon path {
|
}
|
||||||
fill: var(--theme-color-danger) !important;
|
|
||||||
}
|
.Icon path {
|
||||||
}
|
fill: var(--theme-color-on-primary);
|
||||||
|
}
|
||||||
&.is-disabled {
|
}
|
||||||
.Icon path {
|
|
||||||
fill: var(--theme-color-fg-muted);
|
&.is-danger {
|
||||||
}
|
.Icon path {
|
||||||
|
fill: var(--theme-color-danger) !important;
|
||||||
.Label span {
|
}
|
||||||
color: var(--theme-color-fg-muted);
|
}
|
||||||
}
|
|
||||||
}
|
&.is-disabled {
|
||||||
|
.Icon path {
|
||||||
&:not(.has-component):not(.is-highlighted):not(.is-disabled):hover {
|
fill: var(--theme-color-fg-muted);
|
||||||
background-color: var(--theme-color-bg-3);
|
}
|
||||||
|
|
||||||
.Label span {
|
.Label span {
|
||||||
color: var(--theme-color-fg-highlight);
|
color: var(--theme-color-fg-muted);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&.is-danger .Label span {
|
|
||||||
color: var(--theme-color-danger);
|
&:not(.has-component):not(.is-highlighted):not(.is-disabled):hover {
|
||||||
}
|
background-color: var(--theme-color-bg-3);
|
||||||
}
|
|
||||||
}
|
.Label span {
|
||||||
|
color: var(--theme-color-fg-highlight);
|
||||||
.Label {
|
}
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
&.is-danger .Label span {
|
||||||
|
color: var(--theme-color-danger);
|
||||||
&.has-bottom-spacing {
|
}
|
||||||
padding-bottom: 8px;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.Label {
|
||||||
.Icon {
|
display: flex;
|
||||||
margin: -4px 8px -4px 0;
|
align-items: center;
|
||||||
|
|
||||||
path {
|
&.has-bottom-spacing {
|
||||||
fill: var(--theme-color-fg-default-contrast);
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
.Item:not(.has-component):not(.is-disabled):hover & {
|
}
|
||||||
fill: var(--theme-color-fg-highlight);
|
|
||||||
}
|
.Icon {
|
||||||
}
|
margin: -4px 8px -4px 0;
|
||||||
}
|
|
||||||
|
path {
|
||||||
.Divider {
|
fill: var(--theme-color-fg-default-contrast);
|
||||||
width: calc(100% + 8px);
|
|
||||||
height: 1px;
|
.Item:not(.has-component):not(.is-disabled):hover & {
|
||||||
background-color: var(--theme-color-bg-3);
|
fill: var(--theme-color-fg-highlight);
|
||||||
margin: 4px -4px;
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.EndSlot {
|
|
||||||
display: flex;
|
.Divider {
|
||||||
align-items: center;
|
width: calc(100% + 8px);
|
||||||
}
|
height: 1px;
|
||||||
|
background-color: var(--theme-color-bg-3);
|
||||||
|
margin: 4px -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EndSlot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Persist view mode changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -219,6 +229,15 @@ export function Launcher({
|
|||||||
}
|
}
|
||||||
}, [useMockData, projects]);
|
}, [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
|
// Determine which projects to use and if toggle should be available
|
||||||
const hasRealProjects = Boolean(projects && projects.length > 0);
|
const hasRealProjects = Boolean(projects && projects.length > 0);
|
||||||
const activeProjects = useMockData ? MOCK_PROJECTS : projects || MOCK_PROJECTS;
|
const activeProjects = useMockData ? MOCK_PROJECTS : projects || MOCK_PROJECTS;
|
||||||
@@ -264,6 +283,8 @@ export function Launcher({
|
|||||||
setUseMockData,
|
setUseMockData,
|
||||||
projects: activeProjects,
|
projects: activeProjects,
|
||||||
hasRealProjects,
|
hasRealProjects,
|
||||||
|
selectedFolderId,
|
||||||
|
setSelectedFolderId,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
onLaunchProject,
|
onLaunchProject,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface LauncherContextValue {
|
|||||||
projects: LauncherProjectData[];
|
projects: LauncherProjectData[];
|
||||||
hasRealProjects: boolean; // Indicates if real projects were provided to Launcher
|
hasRealProjects: boolean; // Indicates if real projects were provided to Launcher
|
||||||
|
|
||||||
|
// Folder organization
|
||||||
|
selectedFolderId: string | null;
|
||||||
|
setSelectedFolderId: (folderId: string | null) => void;
|
||||||
|
|
||||||
// Project management callbacks
|
// Project management callbacks
|
||||||
onCreateProject?: () => void;
|
onCreateProject?: () => void;
|
||||||
onOpenProject?: () => 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 { UserBadgeProps, UserBadgeSize } from '@noodl-core-ui/components/user/UserBadge';
|
||||||
import { UserBadgeList } from '@noodl-core-ui/components/user/UserBadgeList';
|
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';
|
import css from './LauncherProjectCard.module.scss';
|
||||||
|
|
||||||
// FIXME: Use the timeSince function from the editor package when this is moved there
|
// 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 {
|
export interface LauncherProjectCardProps extends LauncherProjectData {
|
||||||
contextMenuItems: ContextMenuProps[];
|
contextMenuItems: ContextMenuProps[];
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LauncherProjectCard({
|
export function LauncherProjectCard({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
cloudSyncMeta,
|
cloudSyncMeta,
|
||||||
|
localPath,
|
||||||
lastOpened,
|
lastOpened,
|
||||||
pullAmount,
|
pullAmount,
|
||||||
pushAmount,
|
pushAmount,
|
||||||
uncommittedChangesAmount,
|
uncommittedChangesAmount,
|
||||||
imageSrc,
|
imageSrc,
|
||||||
contextMenuItems,
|
contextMenuItems,
|
||||||
contributors
|
contributors,
|
||||||
|
onClick
|
||||||
}: LauncherProjectCardProps) {
|
}: LauncherProjectCardProps) {
|
||||||
|
const { tags, getProjectMeta } = useProjectOrganization();
|
||||||
|
|
||||||
|
// Get project tags
|
||||||
|
const projectMeta = getProjectMeta(localPath);
|
||||||
|
const projectTags = projectMeta ? tags.filter((tag) => projectMeta.tagIds.includes(tag.id)) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card background={CardBackground.Bg2} hoverBackground={CardBackground.Bg3} onClick={onClick}>
|
||||||
background={CardBackground.Bg2}
|
|
||||||
hoverBackground={CardBackground.Bg3}
|
|
||||||
onClick={() => alert('FIXME: open project')}
|
|
||||||
>
|
|
||||||
<Stack direction="row">
|
<Stack direction="row">
|
||||||
<div className={css.Image} style={{ backgroundImage: `url(${imageSrc})` }} />
|
<div className={css.Image} style={{ backgroundImage: `url(${imageSrc})` }} />
|
||||||
|
|
||||||
@@ -102,6 +109,16 @@ export function LauncherProjectCard({
|
|||||||
<Title hasBottomSpacing size={TitleSize.Medium}>
|
<Title hasBottomSpacing size={TitleSize.Medium}>
|
||||||
{title}
|
{title}
|
||||||
</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>
|
<Label variant={TextType.Shy}>Last opened {timeSince(new Date(lastOpened))} ago</Label>
|
||||||
</div>
|
</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 { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
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 { HStack } from '@noodl-core-ui/components/layout/Stack';
|
||||||
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
|
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
|
||||||
import { TextType } from '@noodl-core-ui/components/typography/Text';
|
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 { LauncherPage } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherPage';
|
||||||
import {
|
import {
|
||||||
CloudSyncType,
|
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 { ProjectSettingsModal } from '@noodl-core-ui/preview/launcher/Launcher/components/ProjectSettingsModal';
|
||||||
import { ViewModeToggle } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
|
import { ViewModeToggle } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
|
||||||
import { useProjectList } from '@noodl-core-ui/preview/launcher/Launcher/hooks/useProjectList';
|
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 { MOCK_PROJECTS } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||||
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
import { useLauncherContext, ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ export function Projects({}: ProjectsViewProps) {
|
|||||||
viewMode,
|
viewMode,
|
||||||
setViewMode,
|
setViewMode,
|
||||||
projects: allProjects,
|
projects: allProjects,
|
||||||
|
selectedFolderId,
|
||||||
|
setSelectedFolderId,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
onLaunchProject,
|
onLaunchProject,
|
||||||
@@ -40,8 +44,38 @@ export function Projects({}: ProjectsViewProps) {
|
|||||||
onDeleteProject
|
onDeleteProject
|
||||||
} = useLauncherContext();
|
} = useLauncherContext();
|
||||||
|
|
||||||
|
const { getProjectMeta, getProjectsInFolder, folders, moveProjectToFolder } = useProjectOrganization();
|
||||||
|
|
||||||
const [selectedProjectId, setSelectedProjectId] = useState(null);
|
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[] = [
|
const visibleTypesDropdownItems: SelectOption[] = [
|
||||||
{ label: 'All projects', value: 'all' },
|
{ label: 'All projects', value: 'all' },
|
||||||
...uniqueTypes.map((type) => ({ label: `Only ${type.toLowerCase()} projects`, value: type }))
|
...uniqueTypes.map((type) => ({ label: `Only ${type.toLowerCase()} projects`, value: type }))
|
||||||
@@ -54,7 +88,7 @@ export function Projects({}: ProjectsViewProps) {
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
setSearchTerm
|
setSearchTerm
|
||||||
} = useLauncherSearchBar({
|
} = useLauncherSearchBar({
|
||||||
allItems: allProjects,
|
allItems: filteredByFolder,
|
||||||
filterDropdownItems: visibleTypesDropdownItems,
|
filterDropdownItems: visibleTypesDropdownItems,
|
||||||
propertyNameToFilter: 'cloudSyncMeta.type'
|
propertyNameToFilter: 'cloudSyncMeta.type'
|
||||||
});
|
});
|
||||||
@@ -74,6 +108,21 @@ export function Projects({}: ProjectsViewProps) {
|
|||||||
setSelectedProjectId(null);
|
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() {
|
function onImportProjectClick() {
|
||||||
onOpenProject?.();
|
onOpenProject?.();
|
||||||
}
|
}
|
||||||
@@ -83,103 +132,256 @@ export function Projects({}: ProjectsViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LauncherPage
|
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
title="Recent Projects"
|
{/* Folder Tree Sidebar */}
|
||||||
headerSlot={
|
<div style={{ width: '240px', borderRight: '1px solid var(--theme-color-border-default)', flexShrink: 0 }}>
|
||||||
<HStack hasSpacing>
|
<FolderTree
|
||||||
<PrimaryButton
|
selectedFolderId={selectedFolderId}
|
||||||
label="Open project"
|
onFolderSelect={setSelectedFolderId}
|
||||||
size={PrimaryButtonSize.Small}
|
totalProjectCount={allProjects.length}
|
||||||
variant={PrimaryButtonVariant.Muted}
|
uncategorizedProjectCount={uncategorizedCount}
|
||||||
onClick={onImportProjectClick}
|
|
||||||
/>
|
|
||||||
<PrimaryButton label="Create new project" size={PrimaryButtonSize.Small} onClick={onNewProjectClick} />
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ProjectSettingsModal
|
|
||||||
isVisible={selectedProjectId !== null}
|
|
||||||
onClose={onCloseProjectSettings}
|
|
||||||
projectData={projects.find((project) => project.id === selectedProjectId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HStack hasSpacing={4} UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<LauncherSearchBar
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
setSearchTerm={setSearchTerm}
|
|
||||||
filterValue={filterValue}
|
|
||||||
setFilterValue={setFilterValue}
|
|
||||||
filterDropdownItems={visibleTypesDropdownItems}
|
|
||||||
/>
|
/>
|
||||||
<ViewModeToggle mode={viewMode} onChange={setViewMode} />
|
</div>
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<Box hasTopSpacing={4}>
|
{/* Main Content */}
|
||||||
{viewMode === ViewMode.List ? (
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
<ProjectList
|
<LauncherPage
|
||||||
projects={sortedProjects}
|
title="Recent Projects"
|
||||||
sortField={sortField}
|
headerSlot={
|
||||||
sortDirection={sortDirection}
|
<HStack hasSpacing>
|
||||||
onSort={setSorting}
|
<PrimaryButton
|
||||||
onProjectClick={(project) => onLaunchProject?.(project.id)}
|
label="Open project"
|
||||||
onOpenFolder={(project) => onOpenProjectFolder?.(project.id)}
|
size={PrimaryButtonSize.Small}
|
||||||
onSettings={(project) => onOpenProjectSettings(project.id)}
|
variant={PrimaryButtonVariant.Muted}
|
||||||
onDelete={(project) => onDeleteProject?.(project.id)}
|
onClick={onImportProjectClick}
|
||||||
|
/>
|
||||||
|
<PrimaryButton label="Create new project" size={PrimaryButtonSize.Small} onClick={onNewProjectClick} />
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProjectSettingsModal
|
||||||
|
isVisible={selectedProjectId !== null}
|
||||||
|
onClose={onCloseProjectSettings}
|
||||||
|
projectData={projects.find((project) => project.id === selectedProjectId)}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* TODO: make project list legend and grid reusable */}
|
|
||||||
<Box hasBottomSpacing={4}>
|
|
||||||
<HStack hasSpacing>
|
|
||||||
<div style={{ width: 100 }} />
|
|
||||||
<div style={{ width: '100%' }}>
|
|
||||||
<Columns layoutString={'1 1 1'}>
|
|
||||||
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
|
||||||
Version control
|
|
||||||
</Label>
|
|
||||||
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
|
||||||
Contributors
|
|
||||||
</Label>
|
|
||||||
</Columns>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
<Columns layoutString="1" hasXGap hasYGap>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<LauncherProjectCard
|
|
||||||
key={project.id}
|
|
||||||
{...project}
|
|
||||||
contextMenuItems={[
|
|
||||||
{
|
|
||||||
label: 'Launch project',
|
|
||||||
onClick: () => onLaunchProject?.(project.id)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open project folder',
|
|
||||||
onClick: () => onOpenProjectFolder?.(project.id)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Open project settings',
|
|
||||||
onClick: () => onOpenProjectSettings(project.id)
|
|
||||||
},
|
|
||||||
|
|
||||||
'divider',
|
<HStack hasSpacing={4} UNSAFE_style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
{
|
<LauncherSearchBar
|
||||||
label: 'Delete project',
|
searchTerm={searchTerm}
|
||||||
onClick: () => onDeleteProject?.(project.id),
|
setSearchTerm={setSearchTerm}
|
||||||
icon: IconName.Trash,
|
filterValue={filterValue}
|
||||||
isDangerous: true
|
setFilterValue={setFilterValue}
|
||||||
}
|
filterDropdownItems={visibleTypesDropdownItems}
|
||||||
]}
|
/>
|
||||||
/>
|
<ViewModeToggle mode={viewMode} onChange={setViewMode} />
|
||||||
))}
|
</HStack>
|
||||||
</Columns>
|
|
||||||
</>
|
<Box hasTopSpacing={4}>
|
||||||
)}
|
{viewMode === ViewMode.List ? (
|
||||||
</Box>
|
<ProjectList
|
||||||
</LauncherPage>
|
projects={sortedProjects}
|
||||||
|
sortField={sortField}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={setSorting}
|
||||||
|
onProjectClick={(project) => onLaunchProject?.(project.id)}
|
||||||
|
onOpenFolder={(project) => onOpenProjectFolder?.(project.id)}
|
||||||
|
onSettings={(project) => onOpenProjectSettings(project.id)}
|
||||||
|
onDelete={(project) => onDeleteProject?.(project.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* TODO: make project list legend and grid reusable */}
|
||||||
|
<Box hasBottomSpacing={4}>
|
||||||
|
<HStack hasSpacing>
|
||||||
|
<div style={{ width: 100 }} />
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<Columns layoutString={'1 1 1'}>
|
||||||
|
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
||||||
|
Version control
|
||||||
|
</Label>
|
||||||
|
<Label variant={TextType.Shy} size={LabelSize.Small}>
|
||||||
|
Contributors
|
||||||
|
</Label>
|
||||||
|
</Columns>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
<Columns layoutString="1" hasXGap hasYGap>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<LauncherProjectCard
|
||||||
|
key={project.id}
|
||||||
|
{...project}
|
||||||
|
onClick={() => onLaunchProject?.(project.id)}
|
||||||
|
contextMenuItems={[
|
||||||
|
{
|
||||||
|
label: 'Launch project',
|
||||||
|
onClick: () => onLaunchProject?.(project.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open project folder',
|
||||||
|
onClick: () => onOpenProjectFolder?.(project.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Move to folder...',
|
||||||
|
onClick: () => onMoveToFolder(project)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open project settings',
|
||||||
|
onClick: () => onOpenProjectSettings(project.id)
|
||||||
|
},
|
||||||
|
|
||||||
|
'divider',
|
||||||
|
{
|
||||||
|
label: 'Delete project',
|
||||||
|
onClick: () => onDeleteProject?.(project.id),
|
||||||
|
icon: IconName.Trash,
|
||||||
|
isDangerous: true
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Columns>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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 { ipcRenderer, shell } from 'electron';
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { filesystem } from '@noodl/platform';
|
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 { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||||
|
|
||||||
|
import { useEventListener } from '../../hooks/useEventListener';
|
||||||
import { IRouteProps } from '../../pages/AppRoute';
|
import { IRouteProps } from '../../pages/AppRoute';
|
||||||
import { LocalProjectsModel } from '../../utils/LocalProjectsModel';
|
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
|
||||||
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
|
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
|
||||||
|
|
||||||
export interface ProjectsPageProps extends IRouteProps {
|
export interface ProjectsPageProps extends IRouteProps {
|
||||||
from: TSFixme;
|
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) {
|
export function ProjectsPage(props: ProjectsPageProps) {
|
||||||
|
// Real projects from LocalProjectsModel
|
||||||
|
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
|
||||||
|
|
||||||
|
// Fetch projects on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Switch main window size to editor size
|
// Switch main window size to editor size
|
||||||
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
|
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 () => {
|
const handleCreateProject = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const direntry = await filesystem.openDialog({
|
const direntry = await filesystem.openDialog({
|
||||||
@@ -196,6 +238,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Launcher
|
<Launcher
|
||||||
|
projects={realProjects}
|
||||||
onCreateProject={handleCreateProject}
|
onCreateProject={handleCreateProject}
|
||||||
onOpenProject={handleOpenProject}
|
onOpenProject={handleOpenProject}
|
||||||
onLaunchProject={handleLaunchProject}
|
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 {
|
.components-panel-item-selected {
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
background-color: var(--theme-color-secondary);
|
background-color: var(--theme-color-bg-4);
|
||||||
color: var(--theme-color-on-secondary);
|
color: var(--theme-color-fg-highlight);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.components-panel-item-selected .caret-icon-container {
|
.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 {
|
.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,
|
||||||
.components-panel-item-selected:hover .caret-icon-container {
|
.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-edit-button:hover,
|
||||||
.components-panel-item-selected .components-panel-item-dropdown:hover {
|
.components-panel-item-selected .components-panel-item-dropdown:hover {
|
||||||
background-color: var(--theme-color-secondary);
|
background-color: var(--theme-color-bg-3);
|
||||||
color: var(--theme-color-on-secondary);
|
color: var(--theme-color-fg-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-folder-component:hover .components-panel-folder-label {
|
.is-folder-component:hover .components-panel-folder-label {
|
||||||
|
|||||||
@@ -122,9 +122,10 @@
|
|||||||
width: 0;
|
width: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-bottom-color: var(--theme-color-bg-5);
|
border-bottom-color: var(--theme-color-bg-4);
|
||||||
border-width: 10px;
|
border-width: 10px;
|
||||||
margin-left: -10px;
|
margin-left: -10px;
|
||||||
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-layer-popout-arrow.right {
|
.popup-layer-popout-arrow.right {
|
||||||
|
|||||||
@@ -1,111 +1,112 @@
|
|||||||
.Root {
|
.Root {
|
||||||
container-name: editortopbar;
|
container-name: editortopbar;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background-color: var(--theme-color-bg-2);
|
background-color: var(--theme-color-bg-2);
|
||||||
border-bottom: 2px solid var(--theme-color-bg-1);
|
border-bottom: 2px solid var(--theme-color-bg-1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftSide,
|
.LeftSide,
|
||||||
.RightSide {
|
.RightSide {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
||||||
// please use these general selectors with great caution
|
// please use these general selectors with great caution
|
||||||
> div {
|
> div {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftSide {
|
.LeftSide {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-padded-s {
|
.is-padded-s {
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-padded {
|
.is-padded {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-padded-l {
|
.is-padded-l {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.LeftSide {
|
.LeftSide {
|
||||||
> div:not(:last-child) {
|
> div:not(:last-child) {
|
||||||
border-right: 1px solid var(--theme-color-bg-1);
|
border-right: 1px solid var(--theme-color-bg-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.RightSide {
|
.RightSide {
|
||||||
> div:not(:first-child) {
|
> div:not(:first-child) {
|
||||||
border-left: 1px solid var(--theme-color-bg-1);
|
border-left: 1px solid var(--theme-color-bg-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.UrlBarWrapper {
|
.UrlBarWrapper {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin-right: -1px;
|
margin-right: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.UrlBarTextInput {
|
.UrlBarTextInput {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
path {
|
path {
|
||||||
fill: var(--theme-color-fg-highlight);
|
fill: var(--theme-color-fg-highlight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.TooltipPositioner {
|
.TooltipPositioner {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding-bottom: 1px;
|
padding-bottom: 1px;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.TopbarSelect {
|
.TopbarSelect {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
color: var(--theme-color-fg-default);
|
color: var(--theme-color-fg-highlight);
|
||||||
fill: var(--theme-color-fg-default);
|
fill: var(--theme-color-fg-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover * {
|
&:hover * {
|
||||||
color: var(--theme-color-fg-highlight);
|
color: var(--theme-color-fg-highlight);
|
||||||
fill: var(--theme-color-fg-highlight);
|
fill: var(--theme-color-fg-highlight);
|
||||||
}
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.ZoomSelect {
|
|
||||||
padding-right: 4px;
|
.ZoomSelect {
|
||||||
padding-left: 8px;
|
padding-right: 4px;
|
||||||
}
|
padding-left: 8px;
|
||||||
|
}
|
||||||
.DesignPreviewModeButton {
|
|
||||||
cursor: pointer;
|
.DesignPreviewModeButton {
|
||||||
pointer-events: all;
|
cursor: pointer;
|
||||||
}
|
pointer-events: all;
|
||||||
|
}
|
||||||
.DeployButton {
|
|
||||||
.Root.is-small & {
|
.DeployButton {
|
||||||
padding: 0 4px 0 8px;
|
.Root.is-small & {
|
||||||
min-width: 0;
|
padding: 0 4px 0 8px;
|
||||||
}
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-current {
|
&.is-current {
|
||||||
background-color: var(--theme-color-secondary);
|
background-color: var(--theme-color-bg-4);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { ReactNode, useEffect, useState } from 'react';
|
import React, { ReactNode, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { NodeType } from '@noodl-constants/NodeType';
|
import { NodeType } from '@noodl-constants/NodeType';
|
||||||
|
|
||||||
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
|
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 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 {
|
interface NodePickerCategoryProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -86,7 +88,7 @@ export default function NodePickerCategory({
|
|||||||
css['Arrow'],
|
css['Arrow'],
|
||||||
isCollapsedState ? css['Arrow--is-collapsed'] : css['Arrow--is-not-collapsed']
|
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>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function NodeLibrary({ model, parentModel, pos, attachToRoot, runtimeType
|
|||||||
createNewComment(model, pos);
|
createNewComment(model, pos);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
icon={<img src="../assets/icons/comment.svg" />}
|
icon={<img src="/assets/icons/comment.svg" />}
|
||||||
/>
|
/>
|
||||||
</NodePickerSection>
|
</NodePickerSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lesson-item.selected {
|
.lesson-item.selected {
|
||||||
background-color: var(--theme-color-fg-highlight);
|
background-color: var(--theme-color-bg-4);
|
||||||
color: var(--theme-color-secondary-dim);
|
color: var(--theme-color-fg-highlight);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: background-color 200ms, opacity 200ms;
|
transition: background-color 200ms, opacity 200ms;
|
||||||
}
|
}
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lesson-item-popup {
|
.lesson-item-popup {
|
||||||
background-color: var(--theme-color-secondary);
|
background-color: var(--theme-color-bg-3);
|
||||||
width: 512px;
|
width: 512px;
|
||||||
color: white;
|
color: var(--theme-color-fg-highlight);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background-color: var(--theme-color-secondary);
|
background-color: var(--theme-color-bg-4);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--theme-color-secondary-highlight);
|
background-color: var(--theme-color-bg-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
|
|
||||||
.SearchResults {
|
.SearchResults {
|
||||||
overflow: hidden overlay;
|
overflow: hidden overlay;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user