From 2845b1b8791264777387606e6822d8456a9c6dec Mon Sep 17 00:00:00 2001 From: Richard Osborne Date: Thu, 1 Jan 2026 21:15:51 +0100 Subject: [PATCH] Added initial github integration --- .../CHANGELOG.md | 234 ++++ .../CHECKLIST.md | 436 ++++++++ .../NOTES.md | 349 ++++++ .../README.md | 703 ++++++++++++ .../SUBTASK-001-smart-frames.md | 658 ++++++++++++ .../SUBTASK-002-canvas-navigation.md | 739 +++++++++++++ .../SUBTASK-003-vertical-snap-push.md | 934 ++++++++++++++++ .../SUBTASK-004-connection-labels.md | 997 ++++++++++++++++++ .../GIT-001-COMPLETED.md | 300 ++++++ .../GIT-002-003-ORG-ADDITIONS.md | 253 +++++ .../GIT-003-ADDITIONS-create-repo.md | 187 ++++ .../GIT-004C-ADDITIONS-create-pr.md | 347 ++++++ .../DEPLOY-004-branch-deploy-strategy.md | 505 +++++++++ .../preview/launcher/Launcher/Launcher.tsx | 23 +- .../launcher/Launcher/LauncherContext.tsx | 17 + .../GitHubConnectButton.module.scss | 10 + .../GitHubConnectButton.tsx | 29 + .../components/GitHubConnectButton/index.ts | 1 + .../LauncherHeader/LauncherHeader.tsx | 9 +- .../src/pages/ProjectsPage/ProjectsPage.tsx | 100 +- .../editor/src/services/GitHubOAuthService.ts | 356 +++++++ packages/noodl-editor/src/main/main.js | 82 ++ 22 files changed, 7263 insertions(+), 6 deletions(-) create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHANGELOG.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHECKLIST.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/NOTES.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/README.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-001-smart-frames.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-002-canvas-navigation.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-003-vertical-snap-push.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-004-connection-labels.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-001-COMPLETED.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-002-003-ORG-ADDITIONS.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-ADDITIONS-create-repo.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-004C-prs-panel/GIT-004C-ADDITIONS-create-pr.md create mode 100644 dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-005-deployment-automation/DEPLOY-004-branch-deploy-strategy.md create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.module.scss create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.tsx create mode 100644 packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/index.ts create mode 100644 packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHANGELOG.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHANGELOG.md new file mode 100644 index 0000000..b562738 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHANGELOG.md @@ -0,0 +1,234 @@ +# TASK-000J Changelog + +## Overview + +This changelog tracks the implementation of the Canvas Organization System, including Smart Frames, Canvas Navigation, Vertical Snap + Push, and Connection Labels. + +### Implementation Phases + +1. **Phase 1**: Smart Frames +2. **Phase 2**: Canvas Navigation +3. **Phase 3**: Vertical Snap + Push +4. **Phase 4**: Connection Labels + +--- + +## [Date TBD] - Task Created + +### Summary + +Task documentation created for Canvas Organization System. + +### Files Created + +- `dev-docs/tasks/phase-3/TASK-000J-canvas-organization/README.md` - Full task specification +- `dev-docs/tasks/phase-3/TASK-000J-canvas-organization/CHECKLIST.md` - Implementation checklist +- `dev-docs/tasks/phase-3/TASK-000J-canvas-organization/CHANGELOG.md` - This file +- `dev-docs/tasks/phase-3/TASK-000J-canvas-organization/NOTES.md` - Working notes + +### Context + +This task was created to address canvas organization challenges in complex node graphs. The primary issues being solved: + +1. Vertical node expansion breaking carefully arranged layouts +2. No persistent groupings for related nodes +3. Difficulty navigating large canvases +4. Undocumented connection data flow + +The design prioritizes backward compatibility with existing projects and opt-in complexity. + +--- + +## Template for Future Entries + +```markdown +## [YYYY-MM-DD] - Session N.X: [Phase/Feature Name] + +### Summary +[Brief description of what was accomplished] + +### Files Created +- `path/to/file.tsx` - [Purpose] + +### Files Modified +- `path/to/file.ts` - [What changed and why] + +### Technical Notes +- [Key decisions made] +- [Patterns discovered] +- [Gotchas encountered] + +### Testing Notes +- [What was tested] +- [Any edge cases discovered] + +### Next Steps +- [What needs to be done next] +``` + +--- + +## Progress Summary + +| Phase | Feature | Status | Date Started | Date Completed | +|-------|---------|--------|--------------|----------------| +| 1.1 | Data Model Extension | Not Started | - | - | +| 1.2 | Basic Containment - Drag In | Not Started | - | - | +| 1.3 | Basic Containment - Drag Out | Not Started | - | - | +| 1.4 | Group Movement | Not Started | - | - | +| 1.5 | Auto-Resize | Not Started | - | - | +| 1.6 | Collapse UI | Not Started | - | - | +| 1.7 | Collapsed Rendering | Not Started | - | - | +| 1.8 | Polish & Edge Cases | Not Started | - | - | +| 2.1 | Minimap Component Structure | Not Started | - | - | +| 2.2 | Coordinate Transformation | Not Started | - | - | +| 2.3 | Viewport and Click Navigation | Not Started | - | - | +| 2.4 | Toggle and Integration | Not Started | - | - | +| 2.5 | Jump Menu | Not Started | - | - | +| 3.1 | Attachment Data Model | Not Started | - | - | +| 3.2 | Edge Proximity Detection | Not Started | - | - | +| 3.3 | Visual Feedback | Not Started | - | - | +| 3.4 | Attachment Creation | Not Started | - | - | +| 3.5 | Push Calculation | Not Started | - | - | +| 3.6 | Detachment | Not Started | - | - | +| 3.7 | Alignment Guides | Not Started | - | - | +| 4.1 | Bezier Utilities | Not Started | - | - | +| 4.2 | Data Model Extension | Not Started | - | - | +| 4.3 | Hover State and Add Icon | Not Started | - | - | +| 4.4 | Inline Label Input | Not Started | - | - | +| 4.5 | Label Rendering | Not Started | - | - | +| 4.6 | Label Interaction | Not Started | - | - | + +--- + +## Blockers Log + +_Track any blockers encountered during implementation_ + +| Date | Blocker | Resolution | Time Lost | +|------|---------|------------|-----------| +| - | - | - | - | + +--- + +## Performance Notes + +_Track any performance observations_ + +| Scenario | Observation | Action Taken | +|----------|-------------|--------------| +| Many nodes in Smart Frame | - | - | +| Minimap with 10+ frames | - | - | +| Long attachment chains | - | - | +| Many connection labels | - | - | + +--- + +## Design Decisions Log + +_Record important design decisions and their rationale_ + +| Date | Decision | Rationale | Alternatives Considered | +|------|----------|-----------|-------------------------| +| - | Smart Frames extend Comments | Backward compatibility; existing infrastructure | New model from scratch | +| - | Opt-in containment (drag in/out) | No forced migration; user controls | Auto-detect based on position | +| - | Vertical-only attachment | Horizontal would interfere with connections | Full 2D magnetic grid | +| - | Label on hover icon | Consistent with existing delete icon | Right-click context menu | + +--- + +## Backward Compatibility Notes + +_Track any compatibility considerations_ + +| Legacy Feature | Impact | Migration Path | +|----------------|--------|----------------| +| Comment boxes | None - work unchanged | Optional: drag nodes in to convert | +| Comment colors | Preserved | Smart Frames inherit | +| Comment fill styles | Preserved | Smart Frames inherit | +| Comment text | Preserved | Becomes frame title | + +--- + +## API Changes + +_Track any public API changes for future reference_ + +### CommentsModel + +```typescript +// New methods +addNodeToFrame(commentId: string, nodeId: string): void +removeNodeFromFrame(commentId: string, nodeId: string): void +toggleCollapse(commentId: string): void +isSmartFrame(comment: Comment): boolean +getFrameContainingNode(nodeId: string): Comment | null + +// Extended interface +interface Comment { + // ... existing + containedNodeIds?: string[]; + isCollapsed?: boolean; + autoResize?: boolean; +} +``` + +### Connection Model + +```typescript +// Extended interface +interface Connection { + // ... existing + label?: { + text: string; + position: number; + } +} +``` + +### AttachmentsModel (New) + +```typescript +interface VerticalAttachment { + topNodeId: string; + bottomNodeId: string; + spacing: number; +} + +class AttachmentsModel { + createAttachment(topId: string, bottomId: string, spacing: number): void + removeAttachment(topId: string, bottomId: string): void + getAttachedBelow(nodeId: string): string | null + getAttachedAbove(nodeId: string): string | null + getAttachmentChain(nodeId: string): string[] +} +``` + +--- + +## Files Changed Summary + +_Updated as implementation progresses_ + +### Created + +- [ ] `packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.tsx` +- [ ] `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx` +- [ ] `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.tsx` +- [ ] `packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts` +- [ ] `packages/noodl-editor/src/editor/src/utils/bezier.ts` + +### Modified + +- [ ] `packages/noodl-editor/src/editor/src/models/commentsmodel.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentLayerView.tsx` +- [ ] `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentForeground.tsx` +- [ ] `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentBackground.tsx` +- [ ] `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/commentlayer.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts` +- [ ] `packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx` +- [ ] `packages/noodl-editor/src/editor/src/utils/editorsettings.ts` +- [ ] `packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts` diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHECKLIST.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHECKLIST.md new file mode 100644 index 0000000..b468776 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/CHECKLIST.md @@ -0,0 +1,436 @@ +# TASK-000J Implementation Checklist + +## Pre-Implementation + +- [ ] Read full README.md specification +- [ ] Review existing comment layer code (`packages/noodl-editor/src/editor/src/views/CommentLayer/`) +- [ ] Review node graph editor code (`packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts`) +- [ ] Review connection rendering (`packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts`) +- [ ] Create feature branch: `feature/canvas-organization` +- [ ] Verify tests pass on main before starting + +--- + +## Phase 1: Smart Frames (16-24 hours) + +### Session 1.1: Data Model Extension (2-3 hours) + +- [ ] Extend `Comment` interface in `commentsmodel.ts`: + - [ ] Add `containedNodeIds?: string[]` + - [ ] Add `isCollapsed?: boolean` + - [ ] Add `autoResize?: boolean` +- [ ] Add helper methods to `CommentsModel`: + - [ ] `addNodeToFrame(commentId: string, nodeId: string)` + - [ ] `removeNodeFromFrame(commentId: string, nodeId: string)` + - [ ] `isSmartFrame(comment: Comment): boolean` + - [ ] `getFrameContainingNode(nodeId: string): Comment | null` +- [ ] Verify backward compatibility: load legacy project, confirm comments work +- [ ] Write unit tests for new model methods + +### Session 1.2: Basic Containment - Drag In (2-3 hours) + +- [ ] In `nodegrapheditor.ts`, on node drag-end: + - [ ] Check if final position is inside any comment bounds + - [ ] If inside and not already contained: call `addNodeToFrame()` + - [ ] Visual feedback: brief highlight on frame when node added +- [ ] Create `SmartFrameUtils.ts`: + - [ ] `isPointInFrame(point: Point, frame: Comment): boolean` + - [ ] `isNodeInFrame(node: NodeGraphEditorNode, frame: Comment): boolean` +- [ ] Test: drag node into comment → node ID appears in containedNodeIds +- [ ] Test: existing comments without containedNodeIds still work normally + +### Session 1.3: Basic Containment - Drag Out (2 hours) + +- [ ] In `nodegrapheditor.ts`, on node drag-end: + - [ ] Check if node was in a frame and is now outside + - [ ] If dragged out: call `removeNodeFromFrame()` +- [ ] Test: drag node out of Smart Frame → node ID removed from containedNodeIds +- [ ] Test: dragging all nodes out → comment reverts to passive (optional visual indicator) +- [ ] Handle edge case: node dragged to overlap two frames + +### Session 1.4: Group Movement (2-3 hours) + +- [ ] In `commentlayer.ts`, detect when Smart Frame is being dragged +- [ ] Calculate movement delta (dx, dy) +- [ ] Apply delta to all contained nodes: + - [ ] Get node IDs from `containedNodeIds` + - [ ] Find corresponding `NodeGraphEditorNode` instances + - [ ] Update positions +- [ ] Ensure node positions are saved after frame drag ends +- [ ] Test: move Smart Frame → all contained nodes move together +- [ ] Test: undo group movement → frame and nodes return to original positions + +### Session 1.5: Auto-Resize (2-3 hours) + +- [ ] Create `calculateFrameBounds(nodeIds: string[], padding: number): Bounds` in SmartFrameUtils +- [ ] Subscribe to node size/position changes in `nodegrapheditor.ts` +- [ ] When a contained node changes, recalculate frame bounds +- [ ] Update frame dimensions (with minimum size constraints) +- [ ] Add padding constant (e.g., 20px on each side) +- [ ] Test: add port to contained node → frame grows +- [ ] Test: remove port from contained node → frame shrinks +- [ ] Test: move node within frame → frame adjusts if needed + +### Session 1.6: Collapse UI (2 hours) + +- [ ] In `CommentForeground.tsx`, add collapse/expand button to controls + - [ ] Only show for Smart Frames (containedNodeIds.length > 0) + - [ ] Use appropriate icon (chevron up/down or collapse icon) +- [ ] Implement `toggleCollapse()` in CommentsModel +- [ ] Store `isCollapsed` state +- [ ] Test: click collapse → isCollapsed becomes true +- [ ] Test: click expand → isCollapsed becomes false + +### Session 1.7: Collapsed Rendering (3-4 hours) + +- [ ] In `CommentBackground.tsx`, handle collapsed state: + - [ ] Render only title bar (fixed height, e.g., 30px) + - [ ] Keep full width + - [ ] Different visual style for collapsed state +- [ ] In `nodegrapheditor.ts`: + - [ ] When rendering, check if node's containing frame is collapsed + - [ ] If collapsed: don't render the node +- [ ] Calculate connection entry/exit points for collapsed frames: + - [ ] Find connections where source or target is in collapsed frame + - [ ] Calculate intersection point with frame edge + - [ ] Render dot at that position +- [ ] Test: collapse frame → nodes hidden, connections show dots +- [ ] Test: expand frame → nodes visible again + +### Session 1.8: Polish & Edge Cases (2 hours) + +- [ ] Handle deleting a Smart Frame (contained nodes should remain) +- [ ] Handle deleting a contained node (remove from containedNodeIds) +- [ ] Handle copy/paste of Smart Frame (include contained nodes) +- [ ] Handle copy/paste of contained node (handle frame membership) +- [ ] Performance test with 20+ nodes in one frame +- [ ] Test undo/redo for all operations +- [ ] Update any affected tooltips/help text + +### Phase 1 Verification + +- [ ] Load legacy project with comments → works unchanged +- [ ] Create new Smart Frame by dragging node into comment +- [ ] Full workflow test: create, populate, move, resize, collapse, expand +- [ ] All existing comment features still work (color, text, resize manually) +- [ ] No console errors +- [ ] Commit and push + +--- + +## Phase 2: Canvas Navigation (8-12 hours) + +### Session 2.1: Minimap Component Structure (2 hours) + +- [ ] Create directory: `packages/noodl-editor/src/editor/src/views/CanvasNavigation/` +- [ ] Create `CanvasNavigation.tsx` - main container +- [ ] Create `CanvasNavigation.module.scss` +- [ ] Create `Minimap.tsx` - the actual minimap +- [ ] Create `Minimap.module.scss` +- [ ] Define props interface: + - [ ] `nodeGraph: NodeGraphEditor` + - [ ] `commentsModel: CommentsModel` + - [ ] `visible: boolean` + - [ ] `onToggle: () => void` +- [ ] Basic render: empty container in corner of canvas + +### Session 2.2: Coordinate Transformation (2 hours) + +- [ ] Calculate canvas bounds (min/max x/y of all nodes and frames) +- [ ] Calculate scale factor: minimap size / canvas bounds +- [ ] Transform frame positions to minimap coordinates +- [ ] Transform viewport rectangle to minimap coordinates +- [ ] Handle edge case: empty canvas (no frames) +- [ ] Handle edge case: single frame +- [ ] Test: render colored rectangles for frames at correct positions + +### Session 2.3: Viewport and Click Navigation (2 hours) + +- [ ] Subscribe to nodeGraph pan/scale changes +- [ ] Render viewport rectangle on minimap +- [ ] Handle click on minimap: + - [ ] Transform click position to canvas coordinates + - [ ] Call nodeGraph.setPanAndScale() to navigate +- [ ] Add smooth animation to pan (optional, nice-to-have) +- [ ] Test: click minimap corner → canvas pans to that area +- [ ] Test: pan canvas → viewport rectangle moves on minimap + +### Session 2.4: Toggle and Integration (1-2 hours) + +- [ ] Add toggle button to canvas toolbar +- [ ] Wire button to show/hide minimap +- [ ] Add to EditorSettings: `minimapVisible` setting +- [ ] Persist visibility state +- [ ] Mount CanvasNavigation in EditorDocument.tsx +- [ ] Test: toggle minimap on/off +- [ ] Test: close editor, reopen → minimap state preserved + +### Session 2.5: Jump Menu (2-3 hours) + +- [ ] Create `JumpMenu.tsx` dropdown component +- [ ] Populate menu from Smart Frames (filter by containedNodeIds.length > 0) +- [ ] Show frame title (text) and color indicator +- [ ] On select: pan canvas to center on frame +- [ ] Add keyboard shortcut handler (Cmd+G or Cmd+J) +- [ ] Add number shortcuts (Cmd+1..9 for first 9 frames) +- [ ] Test: open menu → shows Smart Frames +- [ ] Test: select frame → canvas pans to it +- [ ] Test: Cmd+1 → jumps to first frame + +### Phase 2 Verification + +- [ ] Minimap toggle works +- [ ] Minimap shows frame positions correctly +- [ ] Viewport indicator accurate +- [ ] Click navigation works +- [ ] Jump menu populated correctly +- [ ] Keyboard shortcuts work +- [ ] Settings persist +- [ ] No console errors +- [ ] Commit and push + +--- + +## Phase 3: Vertical Snap + Push (12-16 hours) + +### Session 3.1: Attachment Data Model (2 hours) + +- [ ] Create `packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts` +- [ ] Define interface: + ```typescript + interface VerticalAttachment { + topNodeId: string; + bottomNodeId: string; + spacing: number; + } + ``` +- [ ] Implement AttachmentsModel class: + - [ ] `attachments: Map` + - [ ] `createAttachment(topId, bottomId, spacing)` + - [ ] `removeAttachment(topId, bottomId)` + - [ ] `getAttachedBelow(nodeId): string | null` + - [ ] `getAttachedAbove(nodeId): string | null` + - [ ] `getAttachmentChain(nodeId): string[]` +- [ ] Persist attachments with project (in component model) +- [ ] Write unit tests + +### Session 3.2: Edge Proximity Detection (2-3 hours) + +- [ ] In `nodegrapheditor.ts`, during node drag: + - [ ] Get dragged node bounds + - [ ] Find all other nodes + - [ ] Calculate distance to each node's top/bottom edge + - [ ] Define threshold (e.g., 15px) +- [ ] Track which edges are "hot" (within threshold) +- [ ] Store hot edge state for rendering +- [ ] Test: drag node near another → console logs proximity + +### Session 3.3: Visual Feedback (2 hours) + +- [ ] In `NodeGraphEditorNode.ts`: + - [ ] Add state: `highlightedEdge: 'top' | 'bottom' | null` + - [ ] Modify paint() to render glow on highlighted edge +- [ ] Define glow style (color, blur radius) +- [ ] Update highlighted edges during drag +- [ ] Clear highlights on drag end +- [ ] Test: drag near top edge → top edge glows +- [ ] Test: drag near bottom edge → bottom edge glows +- [ ] Test: drag away → glow disappears + +### Session 3.4: Attachment Creation (2-3 hours) + +- [ ] On node drag-end: + - [ ] Check if any edge was highlighted + - [ ] If so, create attachment via AttachmentsModel + - [ ] Calculate spacing from actual positions +- [ ] Handle attaching to existing chain: + - [ ] Check if target node already has attachment on that edge + - [ ] If so, insert new node into chain +- [ ] Visual confirmation (brief flash or toast) +- [ ] Test: drop on highlighted edge → attachment created +- [ ] Test: drop between two attached nodes → inserted into chain + +### Session 3.5: Push Calculation (2-3 hours) + +- [ ] Subscribe to node size changes in nodegrapheditor +- [ ] When node size changes: + - [ ] Check if node has attachments below + - [ ] Calculate new positions for chain based on spacing + - [ ] Update node positions +- [ ] Handle recursive push (A→B→C, A grows, B and C both move) +- [ ] Prevent infinite loops (sanity check) +- [ ] Test: resize attached node → nodes below push down +- [ ] Test: chain of 3+ nodes → all push correctly + +### Session 3.6: Detachment (2 hours) + +- [ ] Add context menu item: "Detach from stack" +- [ ] Only show when node has attachments +- [ ] On detach: + - [ ] Remove attachment(s) from model + - [ ] Reconnect remaining chain if node was in middle + - [ ] Other nodes close the gap (animate? or instant?) +- [ ] Test: detach middle node → chain closes up +- [ ] Test: detach top node → remaining chain intact +- [ ] Test: detach bottom node → remaining chain intact + +### Session 3.7: Alignment Guides (2 hours, optional) + +- [ ] During node drag: + - [ ] Find edges of other nodes that align (within tolerance) + - [ ] Store aligned edges +- [ ] Render guide lines: + - [ ] Horizontal line for aligned left/right edges + - [ ] Different color from attachment glow +- [ ] Clear guides on drag end +- [ ] Test: drag near aligned edge → guide line appears + +### Phase 3 Verification + +- [ ] Attachments persist when project saved/loaded +- [ ] Edge highlighting works during drag +- [ ] Dropping creates attachment +- [ ] Moving top node moves attached nodes +- [ ] Node resize triggers push +- [ ] Insertion between attached nodes works +- [ ] Detachment works and chain closes +- [ ] Undo/redo all operations +- [ ] No console errors +- [ ] Commit and push + +--- + +## Phase 4: Connection Labels (10-14 hours) + +### Session 4.1: Bezier Utilities (2 hours) + +- [ ] Create `packages/noodl-editor/src/editor/src/utils/bezier.ts` +- [ ] Implement `getPointOnCubicBezier(t, p0, p1, p2, p3): Point` +- [ ] Implement `getNearestTOnCubicBezier(point, p0, p1, p2, p3): number` + - [ ] Binary search or analytical solution +- [ ] Implement `getTangentOnCubicBezier(t, p0, p1, p2, p3): Vector` + - [ ] For label rotation (optional) +- [ ] Write unit tests for bezier functions +- [ ] Test with various curve shapes + +### Session 4.2: Data Model Extension (1 hour) + +- [ ] Extend Connection model in `nodegraphmodel.ts`: + ```typescript + label?: { + text: string; + position: number; // 0-1 along curve + } + ``` +- [ ] Add methods: + - [ ] `setConnectionLabel(connectionId, label)` + - [ ] `removeConnectionLabel(connectionId)` +- [ ] Ensure persistence with project +- [ ] Test: set label → data saved + +### Session 4.3: Hover State and Add Icon (2-3 hours) + +- [ ] In `NodeGraphEditorConnection.ts`: + - [ ] Add `isHovered` state + - [ ] Calculate curve midpoint (t=0.5) +- [ ] Detect hover over connection line: + - [ ] Use existing hit-testing or improve + - [ ] Set isHovered state +- [ ] Render add-label icon when hovered: + - [ ] Small "+" or "tag" icon + - [ ] Position at midpoint + - [ ] Similar to existing delete "X" icon +- [ ] Test: hover connection → icon appears +- [ ] Test: move away → icon disappears + +### Session 4.4: Inline Label Input (2-3 hours) + +- [ ] On add-icon click: + - [ ] Prevent event propagation + - [ ] Show input element at click position + - [ ] Auto-focus input +- [ ] Handle input confirmation: + - [ ] Enter key → save label + - [ ] Escape key → cancel + - [ ] Click outside → save label +- [ ] Call `setConnectionLabel()` with text and position=0.5 +- [ ] Remove input element after save/cancel +- [ ] Test: click icon → input appears +- [ ] Test: type and enter → label created +- [ ] Test: escape → input cancelled + +### Session 4.5: Label Rendering (2 hours) + +- [ ] In connection paint(): + - [ ] Check if label exists + - [ ] Get position on curve using bezier utils + - [ ] Render label background (rounded rect) + - [ ] Render label text +- [ ] Style label: + - [ ] Match connection color (with transparency) + - [ ] Small font (10-11px) + - [ ] Padding around text +- [ ] Test: label renders at correct position +- [ ] Test: label visible when zoomed in/out +- [ ] Test: label doesn't render if text is empty + +### Session 4.6: Label Interaction (2-3 hours) + +- [ ] Hit-test on labels: + - [ ] Track label bounds + - [ ] Check click/hover against label +- [ ] Click label → show edit input: + - [ ] Pre-filled with current text + - [ ] Same behavior as add flow +- [ ] Drag label: + - [ ] Track drag start + - [ ] Calculate new t-value using getNearestT() + - [ ] Update label position + - [ ] Constrain to 0.1-0.9 (not at endpoints) +- [ ] Delete label: + - [ ] Show X button on label hover + - [ ] Or: empty text and confirm +- [ ] Test: click label → can edit +- [ ] Test: drag label → moves along curve +- [ ] Test: delete label → label removed + +### Phase 4 Verification + +- [ ] Bezier utilities work correctly +- [ ] Hover shows add icon +- [ ] Can add label via click +- [ ] Label renders on curve +- [ ] Can edit label text +- [ ] Can drag label along curve +- [ ] Can delete label +- [ ] Labels persist on save/load +- [ ] Undo/redo works +- [ ] No console errors +- [ ] Commit and push + +--- + +## Final Integration + +- [ ] Test all features together: + - [ ] Smart Frame containing attached nodes with labeled connections + - [ ] Collapse frame → labels still visible on external connections + - [ ] Navigate via minimap to frame +- [ ] Performance test: + - [ ] 50+ nodes + - [ ] 10+ Smart Frames + - [ ] 20+ labels + - [ ] 5+ attachment chains +- [ ] Cross-browser test (if applicable) +- [ ] Update any documentation +- [ ] Create PR with full description +- [ ] Code review and merge + +--- + +## Post-Implementation + +- [ ] Monitor for bug reports +- [ ] Gather user feedback +- [ ] Document any known limitations +- [ ] Plan follow-up improvements (if needed) diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/NOTES.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/NOTES.md new file mode 100644 index 0000000..eb8ec09 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/NOTES.md @@ -0,0 +1,349 @@ +# TASK-000J Working Notes + +## Quick Reference + +### Key Files + +``` +Comment System: +├── models/commentsmodel.ts # Data model +├── views/CommentLayer/CommentLayerView.tsx # React rendering +├── views/CommentLayer/CommentForeground.tsx # Interactive layer +├── views/CommentLayer/CommentBackground.tsx # Background rendering +└── views/commentlayer.ts # Layer coordinator + +Node Graph: +├── views/nodegrapheditor.ts # Main canvas controller +├── views/nodegrapheditor/NodeGraphEditorNode.ts # Node rendering +└── views/nodegrapheditor/NodeGraphEditorConnection.ts # Connection rendering + +Editor: +├── views/documents/EditorDocument/EditorDocument.tsx # Main editor +└── utils/editorsettings.ts # Persistent settings +``` + +### Useful Patterns in Codebase + +**Subscribing to model changes:** +```typescript +CommentsModel.on('commentsChanged', () => { + this._renderReact(); +}, this); +``` + +**Node graph coordinate transforms:** +```typescript +// Screen to canvas +const canvasPos = this.relativeCoordsToNodeGraphCords(screenPos); + +// Canvas to screen +const screenPos = this.nodeGraphCordsToRelativeCoords(canvasPos); +``` + +**Existing hit-testing:** +```typescript +// Check if point hits a node +forEachNode((node) => { + if (node.isHit(pos)) { ... } +}); + +// Check if point hits a connection +// See NodeGraphEditorConnection.isHit() +``` + +--- + +## Implementation Notes + +### Phase 1: Smart Frames + +#### Session 1.1 Notes +_Add notes here during implementation_ + +**Things discovered:** +- + +**Questions to resolve:** +- + +**Code snippets to remember:** +```typescript + +``` + +#### Session 1.2 Notes +_Add notes here_ + +#### Session 1.3 Notes +_Add notes here_ + +#### Session 1.4 Notes +_Add notes here_ + +#### Session 1.5 Notes +_Add notes here_ + +#### Session 1.6 Notes +_Add notes here_ + +#### Session 1.7 Notes +_Add notes here_ + +#### Session 1.8 Notes +_Add notes here_ + +--- + +### Phase 2: Canvas Navigation + +#### Session 2.1 Notes +_Add notes here_ + +#### Session 2.2 Notes +_Add notes here_ + +#### Session 2.3 Notes +_Add notes here_ + +#### Session 2.4 Notes +_Add notes here_ + +#### Session 2.5 Notes +_Add notes here_ + +--- + +### Phase 3: Vertical Snap + Push + +#### Session 3.1 Notes +_Add notes here_ + +#### Session 3.2 Notes +_Add notes here_ + +#### Session 3.3 Notes +_Add notes here_ + +#### Session 3.4 Notes +_Add notes here_ + +#### Session 3.5 Notes +_Add notes here_ + +#### Session 3.6 Notes +_Add notes here_ + +#### Session 3.7 Notes +_Add notes here_ + +--- + +### Phase 4: Connection Labels + +#### Session 4.1 Notes +_Add notes here_ + +**Bezier math resources:** +- https://pomax.github.io/bezierinfo/ +- De Casteljau's algorithm for point on curve +- Newton-Raphson for nearest point + +#### Session 4.2 Notes +_Add notes here_ + +#### Session 4.3 Notes +_Add notes here_ + +#### Session 4.4 Notes +_Add notes here_ + +#### Session 4.5 Notes +_Add notes here_ + +#### Session 4.6 Notes +_Add notes here_ + +--- + +## Debugging Tips + +### Smart Frame Issues + +**Frame not detecting node:** +- Check `isPointInFrame()` bounds calculation +- Log frame bounds vs node position +- Verify padding is accounted for + +**Nodes not moving with frame:** +- Verify `containedNodeIds` is populated +- Check if node IDs match +- Log delta calculation + +**Auto-resize not working:** +- Check subscription to node changes +- Verify `calculateFrameBounds()` returns correct values +- Check minimum size constraints + +### Navigation Issues + +**Minimap not showing frames:** +- Verify CommentsModel subscription +- Check filter for Smart Frames (containedNodeIds.length > 0) +- Log frame positions being rendered + +**Click navigation incorrect:** +- Log coordinate transformation +- Verify minimap scale factor +- Check canvas bounds calculation + +### Attachment Issues + +**Attachment not creating:** +- Log edge proximity values +- Verify threshold constant +- Check for existing attachments blocking + +**Push not working:** +- Log size change subscription +- Verify attachment chain lookup +- Check for circular dependencies + +### Connection Label Issues + +**Label not rendering:** +- Verify `label` field on connection +- Check bezier position calculation +- Log paint() being called + +**Label position wrong:** +- Verify control points passed to bezier function +- Log t-value and resulting point +- Check canvas transform + +--- + +## Performance Considerations + +### Smart Frames + +- Don't recalculate bounds on every frame during drag +- Throttle auto-resize updates +- Consider virtualizing nodes in very large frames + +### Minimap + +- Don't re-render on every pan/zoom +- Use requestAnimationFrame for smooth updates +- Consider canvas rendering vs DOM for many frames + +### Attachments + +- Cache attachment chains +- Invalidate cache only on attachment changes +- Avoid recalculating during animations + +### Labels + +- Cache bezier calculations +- Don't hit-test labels that are off-screen +- Consider label culling at low zoom levels + +--- + +## Test Scenarios + +### Edge Cases to Test + +1. **Nested geometry** - Frame inside frame (even if not supported, shouldn't crash) +2. **Circular attachments** - A→B→C→A (should be prevented) +3. **Deleted references** - Node deleted while in frame/attachment +4. **Empty states** - Canvas with no nodes, frame with no nodes +5. **Extreme zoom** - Labels at 0.1x and 1x zoom +6. **Large data** - 100+ nodes, 20+ frames +7. **Undo stack** - Complex sequence of operations then undo all +8. **Copy/paste** - Frame with nodes, attached chain, labeled connections +9. **Project reload** - All state persists correctly + +### User Workflows to Test + +1. **Gradual adoption** - Load old project, start using Smart Frames +2. **Organize existing** - Take messy canvas, organize with frames +3. **Navigate complex** - Jump between distant frames +4. **Document flow** - Add labels to explain data path +5. **Refactor** - Move nodes between frames +6. **Expand/collapse** - Work with collapsed frames + +--- + +## Helpful Snippets + +### Get all nodes in a bounding box + +```typescript +const nodesInBounds = []; +this.forEachNode((node) => { + const nodeBounds = { + x: node.global.x, + y: node.global.y, + width: node.nodeSize.width, + height: node.nodeSize.height + }; + if (boundsOverlap(nodeBounds, targetBounds)) { + nodesInBounds.push(node); + } +}); +``` + +### Calculate point on cubic bezier + +```typescript +function getPointOnCubicBezier(t, p0, p1, p2, p3) { + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + + return { + x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x, + y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + }; +} +``` + +### Render guide line + +```typescript +ctx.save(); +ctx.strokeStyle = '#3b82f6'; +ctx.lineWidth = 1; +ctx.setLineDash([5, 5]); +ctx.beginPath(); +ctx.moveTo(0, guideY); +ctx.lineTo(canvasWidth, guideY); +ctx.stroke(); +ctx.restore(); +``` + +--- + +## Questions for Review + +_Add questions to ask during code review_ + +1. +2. +3. + +--- + +## Future Improvements + +_Ideas for follow-up work (out of scope for this task)_ + +- [ ] Frame templates (pre-populated with common node patterns) +- [ ] Smart routing for connections (avoid crossing frames) +- [ ] Frame-level undo (undo all changes within a frame) +- [ ] Export frame as component (auto-componentize) +- [ ] Frame documentation export (generate docs from labels) +- [ ] Collaborative frame locking (multi-user editing) diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/README.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/README.md new file mode 100644 index 0000000..44520b9 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/README.md @@ -0,0 +1,703 @@ +# TASK-000J: Canvas Organization System + +## Overview + +This task implements a comprehensive canvas organization system to address the chaos that emerges in complex node graphs. The primary problem: as users add nodes and connections, nodes expand vertically (due to new ports), groupings lose meaning, and the canvas becomes unmanageable. + +**The core philosophy**: Work with lazy users, not against them. Rather than forcing component creation, provide organizational tools that are easier than the current chaos but don't require significant workflow changes. + +## Background + +### The Problem + +Looking at a typical complex component canvas: + +1. **Vertical expansion breaks layouts** - When a node gains ports, it grows vertically, overlapping nodes below it +2. **No persistent groupings** - Users mentally group related nodes, but nothing enforces or maintains these groupings +3. **Connection spaghetti** - With many connections, it's impossible to trace data flow +4. **No navigation** - In large canvases, users pan around aimlessly looking for specific logic +5. **Comments are passive** - Current comment boxes are purely visual; nodes inside them don't behave as a group + +### Design Principles + +1. **Backward compatible** - Existing projects with comment boxes must work unchanged +2. **Opt-in complexity** - Simple projects don't need these features; complex projects benefit +3. **User responsibility** - Users create organization; system maintains it +4. **Minimal UI footprint** - Features should feel native to the existing canvas + +--- + +## Feature Summary + +| Feature | Purpose | Complexity | Impact | +|---------|---------|------------|--------| +| Smart Frames | Group nodes that move/resize together | Medium-High | ⭐⭐⭐⭐⭐ | +| Canvas Navigation | Minimap + jump-to-frame | Medium | ⭐⭐⭐⭐ | +| Vertical Snap + Push | Keep stacked nodes organized | Medium | ⭐⭐⭐ | +| Connection Labels | Annotate data flow on connections | Medium | ⭐⭐⭐⭐ | + +**Total Estimate**: 45-65 hours (6-8 days) + +--- + +## Feature 1: Smart Frames + +### Description + +Evolve the existing Comment system into "Smart Frames" - visual containers that actually contain their nodes. When a frame moves, nodes inside move with it. When nodes inside expand, the frame grows to accommodate them. + +### Backward Compatibility + +**Critical requirement**: Existing comment boxes must continue to work as purely visual elements. Smart Frame behavior is **opt-in**: + +- Legacy comment boxes render and behave exactly as before +- Dragging a node INTO a comment box converts it to a Smart Frame and adds the node to its group +- Dragging a node OUT of a Smart Frame removes it from the group +- Empty Smart Frames revert to passive comment boxes + +This means: +- Old projects load with no changes +- Users gradually adopt Smart Frames by dragging nodes into existing comments +- No migration required + +### Capabilities + +| Capability | Description | +|------------|-------------| +| Visual container | Colored rectangle with title text (uses existing comment styling) | +| Opt-in containment | Drag node into frame to add; drag out to remove | +| Group movement | When frame is dragged, all contained nodes move together | +| Auto-resize | Frame grows/shrinks to fit contained nodes + padding | +| Collapse/Expand | Toggle to collapse frame to title bar only | +| Collapsed connections | When collapsed, connections to internal nodes render as dots on frame edge | +| Title as label | Frame title serves as the organizational label | +| Nav anchor | Each Smart Frame becomes a navigation waypoint (see Feature 2) | + +### Collapse Behavior + +When collapsed: +``` +┌─── Login Flow ──────────────────────────┐ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Email │───►│Validate│──►│ Login │ │ +│ └────────┘ └────────┘ └────────┘ │ +└─────────────────────────────────────────┘ + │ + ▼ collapse + +┌─── Login Flow ───●────────●─────────────► + ▲ ▲ + input dots output dots +``` + +- Frame height reduces to title bar only +- Internal nodes hidden but connections preserved +- Dots on frame edge show connection entry/exit points +- Clicking dots could highlight the connection path (nice-to-have) + +### Data Model Extension + +Extend `CommentsModel` / `Comment` interface: + +```typescript +interface Comment { + // Existing fields + id: string; + text: string; + x: number; + y: number; + width: number; + height: number; + fill: CommentFillStyle; + color: string; + largeFont?: boolean; + + // New Smart Frame fields + containedNodeIds?: string[]; // Empty = passive comment, populated = Smart Frame + isCollapsed?: boolean; + autoResize?: boolean; // Default true for Smart Frames +} +``` + +### Implementation Approach + +1. **Detection**: Check if `containedNodeIds` has items to determine behavior mode +2. **Adding nodes**: On node drag-end, check if position is inside any comment bounds; if so, add to `containedNodeIds` +3. **Removing nodes**: On node drag-start from inside a frame, if dragged outside bounds, remove from `containedNodeIds` +4. **Group movement**: When frame is moved, apply delta to all contained node positions +5. **Auto-resize**: After any contained node position/size change, recalculate frame bounds +6. **Collapse rendering**: When `isCollapsed`, render only title bar and calculate connection dots + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/models/commentsmodel.ts + - Add containedNodeIds, isCollapsed, autoResize to Comment interface + - Add methods: addNodeToFrame(), removeNodeFromFrame(), toggleCollapse() + +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentLayerView.tsx + - Handle collapsed rendering mode + - Render connection dots for collapsed frames + +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentForeground.tsx + - Add collapse/expand button to comment controls + - Update resize behavior for Smart Frames + +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentBackground.tsx + - Handle collapsed visual state + +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts + - On node drag-end: check for frame containment + - On node drag-start: handle removal from frame + - On frame drag: move contained nodes + - Subscribe to node size changes for auto-resize + +packages/noodl-editor/src/editor/src/views/commentlayer.ts + - Coordinate between CommentLayer and NodeGraphEditor for containment logic +``` + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts + - isPointInFrame(point, frame): boolean + - calculateFrameBounds(nodeIds, padding): Bounds + - getConnectionDotsForCollapsedFrame(frame, connections): ConnectionDot[] +``` + +### Success Criteria + +- [ ] Existing comment boxes work exactly as before (no behavioral change) +- [ ] Dragging a node into a comment box adds it to the frame +- [ ] Dragging a node out of a frame removes it +- [ ] Moving a Smart Frame moves all contained nodes +- [ ] Contained nodes expanding causes frame to grow +- [ ] Collapse button appears on Smart Frame controls +- [ ] Collapsed frame shows only title bar +- [ ] Connections to collapsed frame nodes render as dots on edge +- [ ] Empty Smart Frames revert to passive comments + +--- + +## Feature 2: Canvas Navigation + +### Description + +A minimap overlay and jump-to navigation system for quickly moving around large canvases. Smart Frames automatically become navigation anchors. + +### Capabilities + +| Capability | Description | +|------------|-------------| +| Minimap toggle | Button in canvas toolbar to show/hide minimap | +| Minimap overlay | Small rectangle in corner showing frame locations | +| Viewport indicator | Rectangle showing current visible area | +| Click to navigate | Click anywhere on minimap to pan there | +| Frame list | Dropdown/list of all Smart Frames for quick jump | +| Keyboard shortcuts | Cmd+1..9 to jump to frames (in order of creation or position) | + +### Minimap Design + +``` +┌──────────────────────────────────────────────────┐ +│ │ +│ [Main Canvas] │ +│ │ +│ ┌─────┐│ +│ │▪ A ││ +│ │ ▪B ││ ← Minimap +│ │ ┌─┐ ││ ← Viewport +│ │ └─┘ ││ +│ │▪ C ││ +│ └─────┘│ +└──────────────────────────────────────────────────┘ +``` + +- Each `▪` represents a Smart Frame (labeled with first letter or number) +- `┌─┐` rectangle shows current viewport +- Colors could match frame colors + +### Jump Menu + +Accessible via: +- Toolbar button (dropdown) +- Keyboard shortcut (Cmd+J or Cmd+G for "go to") +- Right-click canvas → "Jump to..." + +Shows list: +``` +┌─────────────────────────┐ +│ Jump to Frame │ +├─────────────────────────┤ +│ 1. Login Flow │ +│ 2. Data Fetching │ +│ 3. Authentication │ +│ 4. Navigation Logic │ +└─────────────────────────┘ +``` + +### Data Requirements + +No new data model needed - reads from existing CommentsModel, filtering for Smart Frames (comments with `containedNodeIds.length > 0`). + +### Implementation Approach + +1. **Minimap component**: React component that subscribes to CommentsModel and NodeGraphEditor pan/scale +2. **Coordinate transformation**: Convert canvas coordinates to minimap coordinates +3. **Frame detection**: Filter comments to only show Smart Frames (have contained nodes) +4. **Click handling**: Transform minimap click to canvas coordinates, animate pan +5. **Jump menu**: Simple dropdown populated from Smart Frames list + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/views/CanvasNavigation/ +├── CanvasNavigation.tsx # Main container component +├── CanvasNavigation.module.scss +├── Minimap.tsx # Minimap rendering +├── Minimap.module.scss +├── JumpMenu.tsx # Frame list dropdown +└── index.ts +``` + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx + - Add CanvasNavigation component to editor layout + - Pass nodeGraph and commentsModel refs + +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts + - Expose pan/scale state for minimap subscription + - Add method: animatePanTo(x, y) + +packages/noodl-editor/src/editor/src/utils/editorsettings.ts + - Add setting: minimapVisible (boolean, default false) +``` + +### Success Criteria + +- [ ] Minimap toggle button in canvas toolbar +- [ ] Minimap shows frame positions as colored dots/rectangles +- [ ] Minimap shows current viewport as rectangle +- [ ] Clicking minimap pans canvas to that location +- [ ] Jump menu lists all Smart Frames +- [ ] Selecting from jump menu pans to that frame +- [ ] Keyboard shortcuts (Cmd+1..9) jump to frames +- [ ] Minimap visibility persists in editor settings + +--- + +## Feature 3: Vertical Snap + Push + +### Description + +A system for vertically aligning and attaching nodes so that when one expands, nodes below it push down automatically, maintaining spacing. + +### Core Concept + +Nodes can be **vertically attached** - think of it like a vertical stack. When the top node grows, everything below shifts down to maintain spacing. + +``` +Before expansion: After expansion: +┌────────────┐ ┌────────────┐ +│ Node A │ │ Node A │ +└────────────┘ │ (grew) │ + │ │ │ + │ attached └────────────┘ + ▼ │ +┌────────────┐ │ attached +│ Node B │ ▼ +└────────────┘ ┌────────────┐ + │ │ Node B │ ← pushed down + │ attached └────────────┘ + ▼ │ +┌────────────┐ │ attached +│ Node C │ ▼ +└────────────┘ ┌────────────┐ + │ Node C │ ← pushed down + └────────────┘ +``` + +### Attachment Mechanics + +**Creating attachments (proximity-based)**: + +When dragging a node near another node's top or bottom edge: +- Visual indicator: Edge lights up (glow or highlight) +- On drop: If within threshold, attachment is created +- Attaching between existing attached nodes: New node slots into the chain + +``` +Dragging Node X near Node A's bottom: + +┌────────────┐ +│ Node A │ +└────────────┘ ← bottom edge glows + + [Node X] ← being dragged + +┌────────────┐ +│ Node B │ +└────────────┘ +``` + +**Inserting between attached nodes**: + +If Node A → Node B are attached, and user drags Node X to the attachment point: +- Node X becomes: A → X → B +- All three remain attached + +**Breaking attachments**: + +- Context menu on node → "Detach from stack" +- Removes node from chain, remaining nodes close the gap +- Alternative: Drag node far enough away auto-detaches + +### Alignment Guides (Supporting Feature) + +Even without attachment, show alignment guides when dragging: +- Horizontal line appears when node edge aligns with another node's edge +- Helps manual alignment +- Standard behavior in design tools (Figma, Sketch) + +### Data Model + +Node attachments stored in NodeGraphModel or as a separate model: + +```typescript +interface VerticalAttachment { + topNodeId: string; + bottomNodeId: string; + spacing: number; // Gap between nodes +} + +// Or simpler - store on node itself: +interface NodeGraphNode { + // ... existing fields + attachedAbove?: string; // ID of node this is attached below + attachedBelow?: string; // ID of node attached below this +} +``` + +### Implementation Approach + +1. **Drag feedback**: During drag, check proximity to other node edges; show glow on nearby edges +2. **Drop handling**: On drop, check if within attachment threshold; create attachment +3. **Insert detection**: When dropping between attached nodes, insert into chain +4. **Push system**: Subscribe to node size changes; when node grows, recalculate attached node positions +5. **Detachment**: Context menu action; remove from chain and recalculate remaining chain positions +6. **Alignment guides**: During drag, find aligned edges and render guide lines + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts + - Add attachment storage (or create separate AttachmentsModel) + - Methods: createAttachment(), removeAttachment(), getAttachmentChain() + +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts + - Drag feedback: detect edge proximity, render glow + - Drop handling: create attachments + - Size change subscription: trigger push recalculation + - Paint alignment guides during drag + +packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts + - Add visual state for edge highlight (top/bottom edge glowing) + - Expose edge positions for proximity detection + +packages/noodl-editor/src/editor/src/views/NodePicker/NodePicker.utils.ts + - Update createNodeFunction to not auto-attach on creation +``` + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts + - Manages vertical attachment relationships + - Methods for creating, breaking, querying attachments + - Push calculation logic + +packages/noodl-editor/src/editor/src/views/nodegrapheditor/AlignmentGuides.ts + - Logic for detecting aligned edges + - Guide line rendering +``` + +### Success Criteria + +- [ ] Dragging node near another's top/bottom edge shows visual indicator +- [ ] Dropping on highlighted edge creates attachment +- [ ] Moving top node moves all attached nodes below +- [ ] Expanding node pushes attached nodes down +- [ ] Dropping between attached nodes inserts into chain +- [ ] Context menu "Detach from stack" removes node from chain +- [ ] Remaining chain nodes close gap after detachment +- [ ] Alignment guides appear when edges align (even without attachment) + +--- + +## Feature 4: Connection Labels + +### Description + +Allow users to add text labels to connection lines to document data flow. Labels sit on the bezier curve and can be repositioned along the path. + +### Interaction Design + +**Adding a label**: +- Hover over a connection line +- Small icon appears (similar to existing X delete icon) +- Click icon → inline text input appears on the connection +- Type label, press Enter or click away to confirm + +**Repositioning**: +- Click and drag existing label along the connection path +- Label stays anchored to the bezier curve + +**Removing**: +- Click label → small X button appears → click to delete +- Or: clear text and confirm + +### Visual Design + +``` + ┌─────────┐ +┌────────┐ │ user ID │ +│ Source │─────────┴─────────┴──────────►│ Target │ +└────────┘ └────────┘ + ▲ + Connection label + (positioned on curve) +``` + +Label styling: +- Small text (10-11px) +- Subtle background matching connection color (with transparency) +- Rounded corners +- Positioned centered on curve at specified t-value (0-1 along bezier) + +### Data Model + +Extend Connection model: + +```typescript +interface Connection { + // Existing fields + fromId: string; + fromProperty: string; + toId: string; + toProperty: string; + + // New field + label?: { + text: string; + position: number; // 0-1 along bezier curve, default 0.5 + }; +} +``` + +### Implementation Approach + +1. **Hover detection**: Use existing connection hit-testing; on hover, show add-label icon +2. **Icon positioning**: Calculate midpoint of bezier curve for icon placement +3. **Add label UI**: On icon click, render inline input at curve position +4. **Label rendering**: Render labels as part of connection paint cycle +5. **Bezier math**: Calculate point on curve at t-value for label positioning +6. **Drag repositioning**: On label drag, calculate nearest t-value to mouse position + +### Bezier Curve Math + +For a cubic bezier with control points P0, P1, P2, P3: +``` +B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3 +``` + +Need functions: +- `getPointOnCurve(t)`: Returns {x, y} at position t +- `getNearestT(point)`: Returns t value for nearest point on curve to given point + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts + - Extend Connection interface with label field + - Methods: setConnectionLabel(), removeConnectionLabel() + +packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts + - Add label rendering in paint() + - Add hover state for showing add-label icon + - Handle label drag for repositioning + +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts + - Handle click on add-label icon + - Render inline input for label editing + - Handle label click for editing/deletion +``` + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/views/nodegrapheditor/ConnectionLabel.ts + - Label rendering logic + - Position calculation + - Edit mode handling + +packages/noodl-editor/src/editor/src/utils/bezier.ts + - getPointOnCubicBezier(t, p0, p1, p2, p3): Point + - getNearestTOnCubicBezier(point, p0, p1, p2, p3): number + - getCubicBezierLength(p0, p1, p2, p3): number (for spacing) +``` + +### Success Criteria + +- [ ] Hovering connection shows add-label icon at midpoint +- [ ] Clicking icon opens inline text input +- [ ] Typing and confirming creates label on connection +- [ ] Label renders on the bezier curve path +- [ ] Label can be dragged along the curve +- [ ] Clicking label allows editing text +- [ ] Label can be deleted (clear text or X button) +- [ ] Labels persist when project is saved/loaded + +--- + +## Implementation Order + +### Phase 1: Smart Frames (16-24 hours) +Foundation for navigation; highest impact feature. + +**Sessions**: +1. Data model extension + basic containment logic +2. Drag-in/drag-out behavior +3. Group movement on frame drag +4. Auto-resize on node changes +5. Collapse UI and basic collapsed state +6. Collapsed connection dots rendering +7. Testing and edge cases + +### Phase 2: Canvas Navigation (8-12 hours) +Depends on Smart Frames for anchor points. + +**Sessions**: +1. Minimap component structure +2. Coordinate transformation and frame rendering +3. Click-to-navigate and viewport indicator +4. Jump menu dropdown +5. Keyboard shortcuts +6. Settings persistence + +### Phase 3: Vertical Snap + Push (12-16 hours) +Independent; can be done in parallel after Phase 1 starts. + +**Sessions**: +1. Attachment data model +2. Edge proximity detection and visual feedback +3. Attachment creation on drop +4. Push calculation on node resize +5. Insert-between-attached logic +6. Detachment via context menu +7. Alignment guides (bonus) + +### Phase 4: Connection Labels (10-14 hours) +Most technically isolated; can be done anytime. + +**Sessions**: +1. Bezier utility functions +2. Connection hover state and add-icon +3. Inline label input +4. Label rendering on curve +5. Label drag repositioning +6. Edit and delete functionality + +--- + +## Testing Checklist + +### Smart Frames +- [ ] Load legacy project with comments → comments work unchanged +- [ ] Drag node into empty comment → comment becomes Smart Frame +- [ ] Drag all nodes out → Smart Frame reverts to comment +- [ ] Move Smart Frame → contained nodes move +- [ ] Resize contained node → frame auto-resizes +- [ ] Collapse frame → only title visible, connections as dots +- [ ] Expand frame → contents visible again +- [ ] Create connection to collapsed frame node → dot visible +- [ ] Delete frame → contained nodes remain (orphaned) +- [ ] Undo/redo all operations + +### Canvas Navigation +- [ ] Toggle minimap visibility +- [ ] Minimap shows all Smart Frames +- [ ] Minimap shows viewport rectangle +- [ ] Click minimap → canvas pans +- [ ] Open jump menu → lists Smart Frames +- [ ] Select from jump menu → canvas pans to frame +- [ ] Keyboard shortcut → jumps to frame +- [ ] Close and reopen editor → minimap setting persists + +### Vertical Snap + Push +- [ ] Drag node near another's bottom edge → edge highlights +- [ ] Drop on highlighted edge → attachment created +- [ ] Move top node → attached nodes move +- [ ] Resize top node → attached nodes push down +- [ ] Drag node to attachment point between two attached → inserts +- [ ] Context menu detach → node removed, others close gap +- [ ] Alignment guides show when edges align + +### Connection Labels +- [ ] Hover connection → icon appears +- [ ] Click icon → input appears +- [ ] Type and confirm → label shows on curve +- [ ] Drag label → moves along curve +- [ ] Click label → can edit +- [ ] Clear text or delete → label removed +- [ ] Save and reload → labels persist + +--- + +## Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Smart Frame collapse complex | Medium | High | Start with simple collapse (hide contents), add connection dots later | +| Bezier math for labels | Low | Medium | Well-documented algorithms; can use library if needed | +| Performance with many frames | Low | Medium | Lazy render off-screen frames; throttle minimap updates | +| Undo/redo complexity | Medium | High | Leverage existing UndoActionGroup pattern; test thoroughly | +| Backward compatibility breaks | Low | Critical | Extensive testing with legacy projects; containedNodeIds default undefined | + +--- + +## Open Questions + +1. **Frame nesting**: Should Smart Frames be nestable? (Recommendation: No, keep simple for v1) +2. **Frame-to-frame connections**: If a collapsed frame has connections to another collapsed frame, how to render? (Recommendation: Just show frame edge dots on both) +3. **Attachment and frames**: If an attached stack is inside a frame, should attachments be frame-local? (Recommendation: Yes, attachments are independent of frames) +4. **Label character limit**: Should labels have max length? (Recommendation: Yes, ~50 chars to prevent visual clutter) + +--- + +## Success Metrics + +Post-implementation, measure: +- **Adoption rate**: % of projects using Smart Frames after 30 days +- **Navigation usage**: How often minimap/jump menu is used per session +- **Canvas cleanup**: User feedback on organization improvements +- **Performance**: Frame rates with 50+ nodes and multiple Smart Frames + +--- + +## References + +### Existing Code + +- `packages/noodl-editor/src/editor/src/views/CommentLayer/` - Comment system +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` - Main canvas +- `packages/noodl-editor/src/editor/src/models/commentsmodel.ts` - Comment data model +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts` - Connection rendering + +### Design Inspiration + +- Figma: Frame containment, alignment guides, minimap +- Miro: Frames and navigation +- Unreal Blueprints: Comment boxes, reroute nodes +- TouchDesigner: Collapsed containers diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-001-smart-frames.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-001-smart-frames.md new file mode 100644 index 0000000..00134b4 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-001-smart-frames.md @@ -0,0 +1,658 @@ +# SUBTASK-001: Smart Frames + +**Parent Task**: TASK-000J Canvas Organization System +**Estimate**: 16-24 hours +**Priority**: 1 (Foundation for other features) +**Dependencies**: None + +--- + +## Overview + +Smart Frames evolve the existing Comment system into visual containers that actually contain their nodes. When a frame moves, nodes inside move with it. When nodes inside expand, the frame grows to accommodate them. + +### The Problem + +Current comment boxes are purely visual - they provide no functional grouping. Users draw boxes around related nodes, but: +- Moving the box doesn't move the nodes +- Nodes expanding overlap the box boundaries +- There's no way to collapse a group to reduce visual clutter + +### The Solution + +Convert comment boxes into "Smart Frames" that: +- Track which nodes are inside them +- Move contained nodes when the frame moves +- Auto-resize to fit contents +- Can collapse to hide contents while preserving connections + +--- + +## Backward Compatibility + +**Critical Requirement**: Existing projects must work unchanged. + +| Scenario | Behavior | +|----------|----------| +| Load legacy project | Comment boxes render exactly as before | +| Comment with no nodes dragged in | Behaves as passive comment (no changes) | +| Drag node INTO comment | Comment becomes Smart Frame, node added to group | +| Drag node OUT of Smart Frame | Node removed from group | +| Drag ALL nodes out | Smart Frame reverts to passive comment | + +This means: +- `containedNodeIds` defaults to `undefined` (not empty array) +- Empty/undefined `containedNodeIds` = passive comment behavior +- Populated `containedNodeIds` = Smart Frame behavior + +--- + +## Feature Capabilities + +### Core Behaviors + +| Capability | Description | +|------------|-------------| +| **Opt-in containment** | Drag node into frame to add; drag out to remove | +| **Group movement** | Moving frame moves all contained nodes | +| **Auto-resize** | Frame grows/shrinks to fit contained nodes + padding | +| **Collapse/Expand** | Toggle to show only title bar | +| **Connection preservation** | Collapsed frames show connection dots on edges | + +### Visual Design + +**Normal State:** +``` +┌─── Login Flow ────────────────────────┐ +│ │ +│ ┌────────┐ ┌────────┐ ┌──────┐ │ +│ │ Email │───►│Validate│──►│Login │ │ +│ └────────┘ └────────┘ └──────┘ │ +│ │ +└───────────────────────────────────────┘ +``` + +**Collapsed State:** +``` +┌─── Login Flow ───●────────●─────────► + ▲ ▲ + input dots output dots +``` + +### Collapse Behavior Details + +When collapsed: +1. Frame height reduces to title bar only (~30px) +2. Frame width remains unchanged +3. Contained nodes are hidden (not rendered) +4. Connections to/from contained nodes: + - Calculate intersection with frame edge + - Render as dots on the frame edge + - Dots indicate where connections enter/exit +5. Clicking frame expands it again + +--- + +## Data Model + +### Extended Comment Interface + +```typescript +interface Comment { + // Existing fields (unchanged) + id: string; + text: string; + x: number; + y: number; + width: number; + height: number; + fill: CommentFillStyle; + color: string; + largeFont?: boolean; + + // New Smart Frame fields + containedNodeIds?: string[]; // undefined = passive, populated = Smart Frame + isCollapsed?: boolean; // default false + autoResize?: boolean; // default true for Smart Frames +} +``` + +### New CommentsModel Methods + +```typescript +class CommentsModel { + // Existing methods... + + /** + * Add a node to a Smart Frame + * If comment has no containedNodeIds, initializes the array + */ + addNodeToFrame(commentId: string, nodeId: string): void; + + /** + * Remove a node from a Smart Frame + * If this empties containedNodeIds, sets to undefined (reverts to comment) + */ + removeNodeFromFrame(commentId: string, nodeId: string): void; + + /** + * Toggle collapsed state + */ + toggleCollapse(commentId: string): void; + + /** + * Check if a comment is functioning as a Smart Frame + */ + isSmartFrame(comment: Comment): boolean; + + /** + * Find which frame contains a given node (if any) + */ + getFrameContainingNode(nodeId: string): Comment | null; + + /** + * Update frame bounds based on contained nodes + */ + updateFrameBounds(commentId: string, nodes: NodeGraphEditorNode[], padding: number): void; +} +``` + +--- + +## Implementation Sessions + +### Session 1.1: Data Model Extension (2-3 hours) + +**Goal**: Extend Comment interface and add model methods. + +**Tasks**: +1. Add new fields to Comment interface in `commentsmodel.ts` +2. Implement `addNodeToFrame()`: + ```typescript + addNodeToFrame(commentId: string, nodeId: string): void { + const comment = this.getComment(commentId); + if (!comment) return; + + if (!comment.containedNodeIds) { + comment.containedNodeIds = []; + } + + if (!comment.containedNodeIds.includes(nodeId)) { + comment.containedNodeIds.push(nodeId); + this.setComment(commentId, comment, { undo: true, label: 'add node to frame' }); + } + } + ``` +3. Implement `removeNodeFromFrame()`: + ```typescript + removeNodeFromFrame(commentId: string, nodeId: string): void { + const comment = this.getComment(commentId); + if (!comment?.containedNodeIds) return; + + const index = comment.containedNodeIds.indexOf(nodeId); + if (index > -1) { + comment.containedNodeIds.splice(index, 1); + + // Revert to passive comment if empty + if (comment.containedNodeIds.length === 0) { + comment.containedNodeIds = undefined; + } + + this.setComment(commentId, comment, { undo: true, label: 'remove node from frame' }); + } + } + ``` +4. Implement helper methods (`isSmartFrame`, `getFrameContainingNode`, `toggleCollapse`) +5. Verify backward compatibility: load legacy project, confirm no changes + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/models/commentsmodel.ts` + +**Success criteria**: +- [ ] New fields added to interface +- [ ] All methods implemented +- [ ] Legacy projects load without changes +- [ ] Unit tests pass + +--- + +### Session 1.2: Basic Containment - Drag In (2-3 hours) + +**Goal**: Detect when a node is dropped inside a comment and add it to the frame. + +**Tasks**: +1. Create `SmartFrameUtils.ts` with geometry helpers: + ```typescript + export function isPointInFrame(point: Point, frame: Comment): boolean { + return ( + point.x >= frame.x && + point.x <= frame.x + frame.width && + point.y >= frame.y && + point.y <= frame.y + frame.height + ); + } + + export function isNodeInFrame(node: NodeGraphEditorNode, frame: Comment): boolean { + const nodeCenter = { + x: node.global.x + node.nodeSize.width / 2, + y: node.global.y + node.nodeSize.height / 2 + }; + return isPointInFrame(nodeCenter, frame); + } + ``` +2. In `nodegrapheditor.ts`, modify node drag-end handler: + ```typescript + // After node position is finalized + const comments = this.commentLayer.model.getComments(); + for (const comment of comments) { + if (isNodeInFrame(node, comment)) { + // Check if not already in this frame + const currentFrame = this.commentLayer.model.getFrameContainingNode(node.model.id); + if (currentFrame?.id !== comment.id) { + // Remove from old frame if any + if (currentFrame) { + this.commentLayer.model.removeNodeFromFrame(currentFrame.id, node.model.id); + } + // Add to new frame + this.commentLayer.model.addNodeToFrame(comment.id, node.model.id); + } + break; + } + } + ``` +3. Add visual feedback: brief highlight on frame when node added + +**Files to create**: +- `packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Dragging node into comment adds it to containedNodeIds +- [ ] Visual feedback shows node was added +- [ ] Undo works correctly + +--- + +### Session 1.3: Basic Containment - Drag Out (2 hours) + +**Goal**: Detect when a node is dragged out of its frame and remove it. + +**Tasks**: +1. Track node's original frame at drag start: + ```typescript + startDraggingNode(node: NodeGraphEditorNode) { + // ... existing code + this.dragStartFrame = this.commentLayer.model.getFrameContainingNode(node.model.id); + } + ``` +2. On drag end, check if node left its frame: + ```typescript + // In drag end handler + if (this.dragStartFrame) { + if (!isNodeInFrame(node, this.dragStartFrame)) { + this.commentLayer.model.removeNodeFromFrame(this.dragStartFrame.id, node.model.id); + } + } + ``` +3. Handle edge case: node dragged from one frame directly into another +4. Clear `dragStartFrame` after drag completes + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Dragging node out of frame removes it from containedNodeIds +- [ ] Frame with no nodes reverts to passive comment +- [ ] Direct frame-to-frame transfer works + +--- + +### Session 1.4: Group Movement (2-3 hours) + +**Goal**: When a Smart Frame is moved, move all contained nodes with it. + +**Tasks**: +1. In `commentlayer.ts`, detect Smart Frame drag: + ```typescript + // When comment drag starts + onCommentDragStart(comment: Comment) { + this.draggingSmartFrame = this.model.isSmartFrame(comment); + this.dragStartPosition = { x: comment.x, y: comment.y }; + } + ``` +2. Calculate and apply delta to contained nodes: + ```typescript + onCommentDragEnd(comment: Comment) { + if (this.draggingSmartFrame && comment.containedNodeIds) { + const dx = comment.x - this.dragStartPosition.x; + const dy = comment.y - this.dragStartPosition.y; + + // Move all contained nodes + for (const nodeId of comment.containedNodeIds) { + const node = this.nodegraphEditor.findNodeWithId(nodeId); + if (node) { + node.model.setPosition(node.x + dx, node.y + dy, { undo: false }); + } + } + + // Create single undo action for the whole operation + UndoQueue.instance.push(new UndoActionGroup({ + label: 'move frame', + // ... undo/redo logic + })); + } + } + ``` +3. Ensure node positions are saved after frame drag +4. Handle undo: single undo should revert frame AND all nodes + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/commentlayer.ts` +- `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentForeground.tsx` + +**Success criteria**: +- [ ] Moving Smart Frame moves all contained nodes +- [ ] Undo reverts entire group movement +- [ ] Passive comments still move independently + +--- + +### Session 1.5: Auto-Resize (2-3 hours) + +**Goal**: Frame automatically resizes to fit contained nodes. + +**Tasks**: +1. Add bounds calculation to `SmartFrameUtils.ts`: + ```typescript + export function calculateFrameBounds( + nodes: NodeGraphEditorNode[], + padding: number = 20 + ): { x: number; y: number; width: number; height: number } { + if (nodes.length === 0) return null; + + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + for (const node of nodes) { + minX = Math.min(minX, node.global.x); + minY = Math.min(minY, node.global.y); + maxX = Math.max(maxX, node.global.x + node.nodeSize.width); + maxY = Math.max(maxY, node.global.y + node.nodeSize.height); + } + + return { + x: minX - padding, + y: minY - padding - 30, // Extra for title bar + width: maxX - minX + padding * 2, + height: maxY - minY + padding * 2 + 30 + }; + } + ``` +2. Subscribe to node changes in `nodegrapheditor.ts`: + ```typescript + // When node size changes (ports added/removed) + onNodeSizeChanged(node: NodeGraphEditorNode) { + const frame = this.commentLayer.model.getFrameContainingNode(node.model.id); + if (frame && frame.autoResize !== false) { + this.updateFrameBounds(frame); + } + } + ``` +3. Apply minimum size constraints (don't shrink below title width) +4. Throttle updates during rapid changes + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- `packages/noodl-editor/src/editor/src/models/commentsmodel.ts` + +**Success criteria**: +- [ ] Adding port to contained node causes frame to grow +- [ ] Removing port causes frame to shrink +- [ ] Moving node within frame adjusts bounds if needed +- [ ] Minimum size maintained + +--- + +### Session 1.6: Collapse UI (2 hours) + +**Goal**: Add collapse/expand button to Smart Frame controls. + +**Tasks**: +1. In `CommentForeground.tsx`, add collapse button: + ```tsx + {props.isSmartFrame && ( + props.toggleCollapse()} + /> + )} + ``` +2. Pass `isSmartFrame` and `isCollapsed` as props +3. Implement `toggleCollapse` handler: + ```typescript + toggleCollapse: () => { + props.updateComment( + { isCollapsed: !props.isCollapsed }, + { commit: true, label: 'toggle frame collapse' } + ); + } + ``` +4. Style the button appropriately + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentForeground.tsx` +- `packages/noodl-editor/src/editor/src/views/commentlayer.ts` + +**Success criteria**: +- [ ] Collapse button only shows for Smart Frames +- [ ] Clicking toggles isCollapsed state +- [ ] Undo works + +--- + +### Session 1.7: Collapsed Rendering (3-4 hours) + +**Goal**: Render collapsed state and connection dots. + +**Tasks**: +1. In `CommentBackground.tsx`, handle collapsed state: + ```tsx + const height = props.isCollapsed ? 30 : props.height; + + return ( +
+
{props.text}
+
+ ); + ``` +2. In `nodegrapheditor.ts`, skip rendering nodes in collapsed frames: + ```typescript + paint() { + // Get collapsed frame node IDs + const collapsedNodeIds = new Set(); + for (const comment of this.commentLayer.model.getComments()) { + if (comment.isCollapsed && comment.containedNodeIds) { + comment.containedNodeIds.forEach(id => collapsedNodeIds.add(id)); + } + } + + // Skip rendering collapsed nodes + this.forEachNode((node) => { + if (!collapsedNodeIds.has(node.model.id)) { + node.paint(ctx, paintRect); + } + }); + } + ``` +3. Calculate connection dots: + ```typescript + // In SmartFrameUtils.ts + export function getConnectionDotsForCollapsedFrame( + frame: Comment, + connections: Connection[], + nodeIdSet: Set + ): ConnectionDot[] { + const dots: ConnectionDot[] = []; + + for (const conn of connections) { + const fromInFrame = nodeIdSet.has(conn.fromId); + const toInFrame = nodeIdSet.has(conn.toId); + + if (fromInFrame && !toInFrame) { + // Outgoing connection - dot on right edge + dots.push({ + x: frame.x + frame.width, + y: frame.y + 15, // Center of title bar + type: 'output' + }); + } else if (!fromInFrame && toInFrame) { + // Incoming connection - dot on left edge + dots.push({ + x: frame.x, + y: frame.y + 15, + type: 'input' + }); + } + } + + return dots; + } + ``` +4. Render dots in connection paint or comment layer + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentBackground.tsx` +- `packages/noodl-editor/src/editor/src/views/CommentLayer/CommentLayer.css` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- `packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts` + +**Success criteria**: +- [ ] Collapsed frame shows only title bar +- [ ] Nodes inside collapsed frame are hidden +- [ ] Connection dots appear on frame edges +- [ ] Expanding frame shows nodes again + +--- + +### Session 1.8: Polish & Edge Cases (2 hours) + +**Goal**: Handle edge cases and polish the feature. + +**Tasks**: +1. Handle deleting a Smart Frame: + - Contained nodes should remain (become uncontained) + - Clear containedNodeIds before deletion +2. Handle deleting a contained node: + - Remove from containedNodeIds automatically + - Subscribe to node deletion events +3. Handle copy/paste of Smart Frame: + - Include contained nodes in copy + - Update node IDs in paste +4. Handle copy/paste of individual contained node: + - Pasted node should not be in any frame +5. Performance test with 20+ nodes in one frame +6. Test undo/redo for all operations +7. Update tooltips if needed + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- `packages/noodl-editor/src/editor/src/models/commentsmodel.ts` + +**Success criteria**: +- [ ] All edge cases handled gracefully +- [ ] No console errors +- [ ] Performance acceptable with many nodes +- [ ] Undo/redo works for all operations + +--- + +## Testing Checklist + +### Backward Compatibility +- [ ] Load project created before Smart Frames feature +- [ ] All existing comments render correctly +- [ ] Comment colors preserved +- [ ] Comment text preserved +- [ ] Comment fill styles preserved +- [ ] Manual resize still works +- [ ] No new fields added to saved file unless frame is used + +### Containment +- [ ] Drag node into empty comment → becomes Smart Frame +- [ ] Drag second node into same frame → both contained +- [ ] Drag node out of frame → removed from containment +- [ ] Drag all nodes out → reverts to passive comment +- [ ] Drag node directly from frame A to frame B → transfers correctly +- [ ] Node dragged partially overlapping frame → uses center point for detection + +### Group Movement +- [ ] Move Smart Frame → all nodes move +- [ ] Move passive comment → only comment moves +- [ ] Undo frame move → frame and all nodes revert +- [ ] Move frame containing 10+ nodes → performance acceptable + +### Auto-Resize +- [ ] Add port to contained node → frame grows +- [ ] Remove port from contained node → frame shrinks +- [ ] Move node to edge of frame → frame expands +- [ ] Move node toward center → frame shrinks (with minimum) +- [ ] Rapid port changes → no flickering, throttled updates + +### Collapse/Expand +- [ ] Collapse button only appears for Smart Frames +- [ ] Click collapse → frame collapses to title bar +- [ ] Nodes hidden when collapsed +- [ ] Connection dots visible on collapsed frame +- [ ] Click expand → frame expands, nodes visible +- [ ] Undo collapse → expands again + +### Edge Cases +- [ ] Delete Smart Frame → contained nodes remain +- [ ] Delete contained node → removed from frame +- [ ] Copy/paste Smart Frame → nodes included +- [ ] Copy/paste contained node → not in any frame +- [ ] Empty Smart Frame (all nodes deleted) → reverts to comment + +--- + +## Files Summary + +### Create +``` +packages/noodl-editor/src/editor/src/views/CommentLayer/SmartFrameUtils.ts +``` + +### Modify +``` +packages/noodl-editor/src/editor/src/models/commentsmodel.ts +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentLayerView.tsx +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentForeground.tsx +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentBackground.tsx +packages/noodl-editor/src/editor/src/views/CommentLayer/CommentLayer.css +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts +packages/noodl-editor/src/editor/src/views/commentlayer.ts +``` + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Breaking legacy projects | Extensive backward compat testing; containedNodeIds defaults undefined | +| Performance with many nodes | Throttle auto-resize; optimize bounds calculation | +| Complex undo/redo | Use UndoActionGroup for compound operations | +| Connection dot positions | Start simple (left/right edges); improve later if needed | +| Collapsed state persistence | Ensure isCollapsed saves/loads correctly | diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-002-canvas-navigation.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-002-canvas-navigation.md new file mode 100644 index 0000000..22417d9 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-002-canvas-navigation.md @@ -0,0 +1,739 @@ +# SUBTASK-002: Canvas Navigation + +**Parent Task**: TASK-000J Canvas Organization System +**Estimate**: 8-12 hours +**Priority**: 2 +**Dependencies**: SUBTASK-001 (Smart Frames) - requires frames to exist as navigation anchors + +--- + +## Overview + +A minimap overlay and jump-to navigation system for quickly moving around large canvases. Smart Frames automatically become navigation anchors - no manual bookmark creation needed. + +### The Problem + +In complex components with many nodes: +- Users pan around aimlessly looking for specific logic +- No way to quickly jump to a known area +- Easy to get "lost" in large canvases +- Zooming out to see everything makes nodes unreadable + +### The Solution + +- **Minimap**: Small overview in corner showing frame locations and current viewport +- **Jump Menu**: Dropdown list of all Smart Frames for quick navigation +- **Keyboard Shortcuts**: Cmd+1..9 to jump to frames by position + +--- + +## Feature Capabilities + +| Capability | Description | +|------------|-------------| +| **Minimap toggle** | Button in canvas toolbar to show/hide minimap | +| **Frame indicators** | Colored rectangles showing Smart Frame positions | +| **Viewport indicator** | Rectangle showing current visible area | +| **Click to navigate** | Click anywhere on minimap to pan canvas there | +| **Jump menu** | Dropdown list of all Smart Frames | +| **Keyboard shortcuts** | Cmd+1..9 to jump to first 9 frames | +| **Persistent state** | Minimap visibility saved in editor settings | + +--- + +## Visual Design + +### Minimap Layout + +``` +┌──────────────────────────────────────────────────┐ +│ │ +│ [Main Canvas] │ +│ │ +│ ┌─────┐│ +│ │▪ A ││ +│ │ ▪B ││ ← Minimap (150x100px) +│ │ ┌─┐ ││ ← Viewport rectangle +│ │ └─┘ ││ +│ │▪ C ││ +│ └─────┘│ +└──────────────────────────────────────────────────┘ +``` + +### Minimap Details + +- **Position**: Bottom-right corner, 10px from edges +- **Size**: ~150x100px (aspect ratio matches canvas) +- **Background**: Semi-transparent dark (#1a1a1a at 80% opacity) +- **Border**: 1px solid border (#333) +- **Border radius**: 4px + +### Frame Indicators + +- Small rectangles (~10-20px depending on scale) +- Color matches frame color +- Optional: First letter of frame name as label +- Slightly rounded corners + +### Viewport Rectangle + +- Outline rectangle (no fill) +- White or light color (#fff at 50% opacity) +- 1px stroke +- Shows what's currently visible in main canvas + +### Jump Menu + +``` +┌─────────────────────────┐ +│ Jump to Frame ⌘G │ +├─────────────────────────┤ +│ ● Login Flow ⌘1 │ +│ ● Data Fetching ⌘2 │ +│ ● Authentication ⌘3 │ +│ ● Navigation Logic ⌘4 │ +└─────────────────────────┘ +``` + +- Color dot matches frame color +- Frame title (truncated if long) +- Keyboard shortcut hint (if within first 9) + +--- + +## Technical Architecture + +### Component Structure + +``` +packages/noodl-editor/src/editor/src/views/CanvasNavigation/ +├── CanvasNavigation.tsx # Main container +├── CanvasNavigation.module.scss +├── Minimap.tsx # Minimap rendering +├── Minimap.module.scss +├── JumpMenu.tsx # Dropdown menu +├── JumpMenu.module.scss +├── hooks/ +│ └── useCanvasNavigation.ts # Shared state/logic +└── index.ts +``` + +### Props Interface + +```typescript +interface CanvasNavigationProps { + nodeGraph: NodeGraphEditor; + commentsModel: CommentsModel; + visible: boolean; + onToggle: () => void; +} + +interface MinimapProps { + frames: SmartFrameInfo[]; + canvasBounds: Bounds; + viewport: Viewport; + onNavigate: (x: number, y: number) => void; +} + +interface SmartFrameInfo { + id: string; + title: string; + color: string; + bounds: Bounds; +} + +interface Viewport { + x: number; + y: number; + width: number; + height: number; + scale: number; +} +``` + +### Coordinate Transformation + +The minimap needs to transform between three coordinate systems: + +1. **Canvas coordinates**: Where nodes/frames actually are +2. **Minimap coordinates**: Scaled down to fit minimap +3. **Screen coordinates**: For click handling + +```typescript +class CoordinateTransformer { + private canvasBounds: Bounds; + private minimapSize: { width: number; height: number }; + private scale: number; + + constructor(canvasBounds: Bounds, minimapSize: { width: number; height: number }) { + this.canvasBounds = canvasBounds; + this.minimapSize = minimapSize; + + // Calculate scale to fit canvas in minimap + const scaleX = minimapSize.width / (canvasBounds.maxX - canvasBounds.minX); + const scaleY = minimapSize.height / (canvasBounds.maxY - canvasBounds.minY); + this.scale = Math.min(scaleX, scaleY); + } + + canvasToMinimap(point: Point): Point { + return { + x: (point.x - this.canvasBounds.minX) * this.scale, + y: (point.y - this.canvasBounds.minY) * this.scale + }; + } + + minimapToCanvas(point: Point): Point { + return { + x: point.x / this.scale + this.canvasBounds.minX, + y: point.y / this.scale + this.canvasBounds.minY + }; + } +} +``` + +--- + +## Implementation Sessions + +### Session 2.1: Component Structure (2 hours) + +**Goal**: Create component files and basic rendering. + +**Tasks**: +1. Create directory structure +2. Create `CanvasNavigation.tsx`: + ```tsx + import React, { useState } from 'react'; + import { Minimap } from './Minimap'; + import { JumpMenu } from './JumpMenu'; + import styles from './CanvasNavigation.module.scss'; + + export function CanvasNavigation({ nodeGraph, commentsModel, visible, onToggle }: CanvasNavigationProps) { + const [jumpMenuOpen, setJumpMenuOpen] = useState(false); + + if (!visible) return null; + + const frames = getSmartFrames(commentsModel); + const canvasBounds = calculateCanvasBounds(nodeGraph); + const viewport = getViewport(nodeGraph); + + return ( +
+ nodeGraph.panTo(x, y)} + /> +
+ ); + } + ``` +3. Create basic SCSS: + ```scss + .container { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 100; + } + ``` +4. Create placeholder `Minimap.tsx` and `JumpMenu.tsx` + +**Files to create**: +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.tsx` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.module.scss` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.module.scss` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.tsx` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/index.ts` + +**Success criteria**: +- [ ] Components compile without errors +- [ ] Basic container renders in corner + +--- + +### Session 2.2: Coordinate Transformation & Frame Rendering (2 hours) + +**Goal**: Render frames at correct positions on minimap. + +**Tasks**: +1. Implement canvas bounds calculation: + ```typescript + function calculateCanvasBounds(nodeGraph: NodeGraphEditor): Bounds { + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + // Include all nodes + nodeGraph.forEachNode((node) => { + minX = Math.min(minX, node.global.x); + minY = Math.min(minY, node.global.y); + maxX = Math.max(maxX, node.global.x + node.nodeSize.width); + maxY = Math.max(maxY, node.global.y + node.nodeSize.height); + }); + + // Include all frames + const comments = nodeGraph.commentLayer.model.getComments(); + for (const comment of comments) { + minX = Math.min(minX, comment.x); + minY = Math.min(minY, comment.y); + maxX = Math.max(maxX, comment.x + comment.width); + maxY = Math.max(maxY, comment.y + comment.height); + } + + // Add padding + const padding = 50; + return { + minX: minX - padding, + minY: minY - padding, + maxX: maxX + padding, + maxY: maxY + padding + }; + } + ``` +2. Implement `CoordinateTransformer` class +3. Render frame rectangles on minimap: + ```tsx + function Minimap({ frames, canvasBounds, viewport, onNavigate }: MinimapProps) { + const minimapSize = { width: 150, height: 100 }; + const transformer = new CoordinateTransformer(canvasBounds, minimapSize); + + return ( +
+ {frames.map((frame) => { + const pos = transformer.canvasToMinimap({ x: frame.bounds.x, y: frame.bounds.y }); + const size = { + width: frame.bounds.width * transformer.scale, + height: frame.bounds.height * transformer.scale + }; + + return ( +
+ ); + })} +
+ ); + } + ``` +4. Add frame color extraction from comment colors + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx` + +**Files to create**: +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CoordinateTransformer.ts` + +**Success criteria**: +- [ ] Frames render at proportionally correct positions +- [ ] Frame colors match actual frame colors +- [ ] Minimap scales appropriately for different canvas sizes + +--- + +### Session 2.3: Viewport and Click Navigation (2 hours) + +**Goal**: Show viewport rectangle and handle click-to-navigate. + +**Tasks**: +1. Get viewport from NodeGraphEditor: + ```typescript + function getViewport(nodeGraph: NodeGraphEditor): Viewport { + const panAndScale = nodeGraph.getPanAndScale(); + const canvasWidth = nodeGraph.canvas.width / nodeGraph.canvas.ratio; + const canvasHeight = nodeGraph.canvas.height / nodeGraph.canvas.ratio; + + return { + x: -panAndScale.x, + y: -panAndScale.y, + width: canvasWidth / panAndScale.scale, + height: canvasHeight / panAndScale.scale, + scale: panAndScale.scale + }; + } + ``` +2. Subscribe to pan/scale changes: + ```typescript + useEffect(() => { + const handlePanScaleChange = () => { + setViewport(getViewport(nodeGraph)); + }; + + nodeGraph.on('panAndScaleChanged', handlePanScaleChange); + return () => nodeGraph.off('panAndScaleChanged', handlePanScaleChange); + }, [nodeGraph]); + ``` +3. Render viewport rectangle: + ```tsx + const viewportPos = transformer.canvasToMinimap({ x: viewport.x, y: viewport.y }); + const viewportSize = { + width: viewport.width * transformer.scale, + height: viewport.height * transformer.scale + }; + +
+ ``` +4. Handle click navigation: + ```typescript + const handleMinimapClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + const canvasPos = transformer.minimapToCanvas({ x: clickX, y: clickY }); + onNavigate(canvasPos.x, canvasPos.y); + }; + ``` +5. Add `panTo` method to NodeGraphEditor if not exists: + ```typescript + panTo(x: number, y: number, animate: boolean = true) { + const centerX = this.canvas.width / this.canvas.ratio / 2; + const centerY = this.canvas.height / this.canvas.ratio / 2; + + const targetPan = { + x: centerX - x, + y: centerY - y, + scale: this.getPanAndScale().scale + }; + + if (animate) { + this.animatePanTo(targetPan); + } else { + this.setPanAndScale(targetPan); + } + } + ``` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/Minimap.tsx` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` (add panTo method) + +**Success criteria**: +- [ ] Viewport rectangle shows current visible area +- [ ] Viewport updates when panning/zooming main canvas +- [ ] Clicking minimap pans canvas to that location + +--- + +### Session 2.4: Toggle and Integration (1-2 hours) + +**Goal**: Add toggle button and integrate with editor. + +**Tasks**: +1. Add minimap toggle to canvas toolbar: + ```tsx + // In EditorDocument.tsx or canvas toolbar component + setMinimapVisible(!minimapVisible)} + tooltip="Toggle Minimap" + /> + ``` +2. Add to EditorSettings: + ```typescript + // In editorsettings.ts + interface EditorSettings { + // ... existing + minimapVisible?: boolean; + } + ``` +3. Mount CanvasNavigation in EditorDocument: + ```tsx + // In EditorDocument.tsx + import { CanvasNavigation } from '@noodl-views/CanvasNavigation'; + + // In render + {nodeGraph && ( + setMinimapVisible(!minimapVisible)} + /> + )} + ``` +4. Persist visibility state: + ```typescript + useEffect(() => { + EditorSettings.instance.set('minimapVisible', minimapVisible); + }, [minimapVisible]); + + // Initial load + const [minimapVisible, setMinimapVisible] = useState( + EditorSettings.instance.get('minimapVisible') ?? false + ); + ``` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx` +- `packages/noodl-editor/src/editor/src/utils/editorsettings.ts` + +**Success criteria**: +- [ ] Toggle button shows in toolbar +- [ ] Clicking toggle shows/hides minimap +- [ ] Visibility persists across editor sessions + +--- + +### Session 2.5: Jump Menu (2-3 hours) + +**Goal**: Create jump menu dropdown and keyboard shortcuts. + +**Tasks**: +1. Create JumpMenu component: + ```tsx + function JumpMenu({ frames, onSelect, onClose }: JumpMenuProps) { + return ( +
+
Jump to Frame
+
+ {frames.map((frame, index) => ( +
{ + onSelect(frame); + onClose(); + }} + > + + {frame.title || 'Untitled'} + {index < 9 && ( + ⌘{index + 1} + )} +
+ ))} +
+
+ ); + } + ``` +2. Add jump menu trigger (toolbar button or keyboard): + ```typescript + // Keyboard shortcut: Cmd+G or Cmd+J + KeyboardHandler.instance.registerCommand('g', { meta: true }, () => { + setJumpMenuOpen(true); + }); + ``` +3. Implement frame jump: + ```typescript + const handleFrameSelect = (frame: SmartFrameInfo) => { + const centerX = frame.bounds.x + frame.bounds.width / 2; + const centerY = frame.bounds.y + frame.bounds.height / 2; + nodeGraph.panTo(centerX, centerY); + }; + ``` +4. Add number shortcuts (Cmd+1..9): + ```typescript + useEffect(() => { + const frames = getSmartFrames(commentsModel); + + for (let i = 0; i < Math.min(frames.length, 9); i++) { + KeyboardHandler.instance.registerCommand(`${i + 1}`, { meta: true }, () => { + handleFrameSelect(frames[i]); + }); + } + + return () => { + for (let i = 1; i <= 9; i++) { + KeyboardHandler.instance.unregisterCommand(`${i}`, { meta: true }); + } + }; + }, [commentsModel, frames]); + ``` +5. Style the menu appropriately + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.tsx` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/JumpMenu.module.scss` +- `packages/noodl-editor/src/editor/src/views/CanvasNavigation/CanvasNavigation.tsx` + +**Success criteria**: +- [ ] Jump menu opens via toolbar or Cmd+G +- [ ] Menu lists all Smart Frames with colors +- [ ] Selecting frame pans canvas to it +- [ ] Cmd+1..9 shortcuts work for first 9 frames + +--- + +## Testing Checklist + +### Minimap Display +- [ ] Minimap appears in bottom-right corner +- [ ] Minimap background is semi-transparent +- [ ] Frame indicators show at correct positions +- [ ] Frame colors match actual frame colors +- [ ] Viewport rectangle visible and correctly sized + +### Viewport Tracking +- [ ] Viewport rectangle updates when panning +- [ ] Viewport rectangle updates when zooming +- [ ] Viewport correctly represents visible area + +### Navigation +- [ ] Click on minimap pans canvas to that location +- [ ] Pan animation is smooth (not instant) +- [ ] Canvas centers on click location + +### Toggle +- [ ] Toggle button visible in toolbar +- [ ] Clicking toggle shows minimap +- [ ] Clicking again hides minimap +- [ ] State persists when switching components +- [ ] State persists when closing/reopening editor + +### Jump Menu +- [ ] Menu opens via toolbar button +- [ ] Menu opens via Cmd+G (or Cmd+J) +- [ ] All Smart Frames listed +- [ ] Frame colors displayed correctly +- [ ] Keyboard shortcuts (⌘1-9) shown +- [ ] Selecting frame pans to it +- [ ] Menu closes after selection +- [ ] Esc closes menu + +### Keyboard Shortcuts +- [ ] Cmd+1 jumps to first frame +- [ ] Cmd+2 jumps to second frame +- [ ] ...through Cmd+9 for ninth frame +- [ ] Shortcuts only work when canvas focused + +### Edge Cases +- [ ] Canvas with no Smart Frames - minimap shows empty, jump menu shows "No frames" +- [ ] Single Smart Frame - minimap and jump work +- [ ] Many frames (20+) - performance acceptable, jump menu scrollable +- [ ] Very large canvas - minimap scales appropriately +- [ ] Very small canvas - minimap shows reasonable size + +--- + +## Files Summary + +### Create +``` +packages/noodl-editor/src/editor/src/views/CanvasNavigation/ +├── CanvasNavigation.tsx +├── CanvasNavigation.module.scss +├── Minimap.tsx +├── Minimap.module.scss +├── JumpMenu.tsx +├── JumpMenu.module.scss +├── CoordinateTransformer.ts +├── hooks/useCanvasNavigation.ts +└── index.ts +``` + +### Modify +``` +packages/noodl-editor/src/editor/src/views/documents/EditorDocument/EditorDocument.tsx +packages/noodl-editor/src/editor/src/utils/editorsettings.ts +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts +``` + +--- + +## Performance Considerations + +### Minimap Updates + +- Don't re-render on every mouse move during pan +- Use `requestAnimationFrame` for smooth viewport updates +- Debounce frame position updates (frames don't move often) + +```typescript +// Throttled viewport update +const updateViewport = useMemo( + () => throttle(() => { + setViewport(getViewport(nodeGraph)); + }, 16), // ~60fps + [nodeGraph] +); +``` + +### Frame Collection + +- Cache frame list, invalidate on comments change +- Don't filter comments on every render + +```typescript +const [frames, setFrames] = useState([]); + +useEffect(() => { + const updateFrames = () => { + const smartFrames = commentsModel.getComments() + .filter(c => c.containedNodeIds?.length > 0) + .map(c => ({ + id: c.id, + title: c.text, + color: getFrameColor(c), + bounds: { x: c.x, y: c.y, width: c.width, height: c.height } + })); + setFrames(smartFrames); + }; + + commentsModel.on('commentsChanged', updateFrames); + updateFrames(); + + return () => commentsModel.off('commentsChanged', updateFrames); +}, [commentsModel]); +``` + +### Canvas Bounds + +- Recalculate bounds only when nodes/frames change, not on every pan +- Cache bounds calculation result + +--- + +## Design Notes + +### Why Not Manual Bookmarks? + +We considered allowing users to drop pin markers anywhere on the canvas. However: + +1. **User responsibility**: Users already create Smart Frames for organization +2. **Reduced complexity**: One concept (frames) serves multiple purposes +3. **Automatic updates**: Frames move, and navigation stays in sync +4. **No orphan pins**: Deleted frames = removed from navigation + +If users want navigation without visual grouping, they can create small frames with just a label. + +### Minimap Position + +Bottom-right was chosen because: +- Top area often has toolbars +- Left side has sidebar panels +- Bottom-right is conventionally where minimaps appear (games, IDEs) + +Could make position configurable in future. + +### Animation on Navigate + +Smooth pan animation helps users: +- Understand spatial relationship between areas +- Not feel "teleported" and disoriented +- See path between current view and destination + +Animation should be quick (~200-300ms) to not feel sluggish. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-003-vertical-snap-push.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-003-vertical-snap-push.md new file mode 100644 index 0000000..70a5f83 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-003-vertical-snap-push.md @@ -0,0 +1,934 @@ +# SUBTASK-003: Vertical Snap + Push + +**Parent Task**: TASK-000J Canvas Organization System +**Estimate**: 12-16 hours +**Priority**: 3 +**Dependencies**: None (can be implemented independently) + +--- + +## Overview + +A system for vertically aligning and attaching nodes so that when one expands, nodes below it automatically push down, maintaining spacing and preventing overlap. + +### The Problem + +When nodes expand vertically (due to new ports being added): +- They overlap nodes below them +- Carefully arranged vertical stacks become messy +- Users must manually reposition nodes after every change +- The "flow" of logic gets disrupted + +### The Solution + +Allow users to **vertically attach** nodes into stacks: +- Attached nodes maintain spacing when moved +- When a node expands, nodes below push down automatically +- Visual feedback shows when nodes can be attached +- Easy to detach when needed + +### Why Vertical Only? + +Horizontal attachment would interfere with connection lines: +- Connections flow left-to-right (outputs → inputs) +- Nodes need horizontal spacing for connection visibility +- Horizontal "snapping" would cover connection endpoints + +Vertical stacking works naturally because: +- Many parallel logic paths are arranged vertically +- Vertical expansion is the main layout-breaking problem +- Doesn't interfere with connection rendering + +--- + +## Feature Capabilities + +| Capability | Description | +|------------|-------------| +| **Edge proximity detection** | Visual feedback when dragging near attachable edges | +| **Attachment creation** | Drop on highlighted edge to attach | +| **Push on expand** | When node grows, attached nodes below shift down | +| **Chain insertion** | Drop between attached nodes to insert into chain | +| **Detachment** | Context menu option to remove from stack | +| **Alignment guides** | Visual guides when edges align (even without attachment) | + +--- + +## Visual Design + +### Attachment Visualization + +**Proximity Detection (during drag):** +``` +┌────────────┐ +│ Node A │ +└────────────┘ ← bottom edge glows when Node X is near + + [Node X] ← being dragged + +┌────────────┐ +│ Node B │ ← top edge glows when Node X is near +└────────────┘ +``` + +**Attached State:** +``` +┌────────────┐ +│ Node A │ +└────────────┘ + ┃ ← subtle vertical line indicating attachment + ┃ +┌────────────┐ +│ Node B │ +└────────────┘ + ┃ + ┃ +┌────────────┐ +│ Node C │ +└────────────┘ +``` + +**Push Behavior:** +``` +Before: After Node A expands: +┌────────────┐ ┌────────────┐ +│ Node A │ │ Node A │ +└────────────┘ │ (grew) │ + ┃ │ │ +┌────────────┐ └────────────┘ +│ Node B │ ┃ +└────────────┘ ┌────────────┐ + ┃ │ Node B │ ← pushed down +┌────────────┐ └────────────┘ +│ Node C │ ┃ +└────────────┘ ┌────────────┐ + │ Node C │ ← also pushed down + └────────────┘ +``` + +### Edge Highlight Style + +- **Glow effect**: Box shadow or gradient +- **Color**: Accent color (e.g., blue #3b82f6) at 50% opacity +- **Width**: Extends slightly beyond node edges +- **Height**: ~4px + +```css +.edge-highlight-bottom { + position: absolute; + bottom: -2px; + left: -4px; + right: -4px; + height: 4px; + background: linear-gradient(to bottom, rgba(59, 130, 246, 0.5), transparent); + border-radius: 2px; + box-shadow: 0 0 8px rgba(59, 130, 246, 0.6); +} +``` + +### Alignment Guide Style + +- **Color**: Light gray or accent color at 30% opacity +- **Style**: Dashed line +- **Extends**: Full canvas width (or reasonable extent) + +--- + +## Data Model + +### Attachment Storage + +```typescript +interface VerticalAttachment { + id: string; // Unique attachment ID + topNodeId: string; // Node on top + bottomNodeId: string; // Node on bottom + spacing: number; // Pixel gap between nodes +} + +// Storage options: + +// Option A: Separate AttachmentsModel +class AttachmentsModel { + private attachments: Map; + + createAttachment(topId: string, bottomId: string, spacing: number): void; + removeAttachment(attachmentId: string): void; + getAttachedBelow(nodeId: string): string | null; + getAttachedAbove(nodeId: string): string | null; + getAttachmentChain(nodeId: string): string[]; + getAttachmentBetween(topId: string, bottomId: string): VerticalAttachment | null; +} + +// Option B: Store on NodeGraphNode model +interface NodeGraphNode { + // ... existing fields + attachedAbove?: string; // ID of node this is attached below + attachedBelow?: string; // ID of node attached below this + attachmentSpacing?: number; +} +``` + +**Recommendation**: Use Option A (separate AttachmentsModel) for cleaner separation of concerns and easier debugging. + +### Persistence + +Attachments should persist with the project: +```typescript +// In component save/load +{ + "nodes": [...], + "connections": [...], + "comments": [...], + "attachments": [ + { "id": "att_1", "topNodeId": "node_a", "bottomNodeId": "node_b", "spacing": 20 }, + { "id": "att_2", "topNodeId": "node_b", "bottomNodeId": "node_c", "spacing": 20 } + ] +} +``` + +--- + +## Implementation Sessions + +### Session 3.1: Attachment Data Model (2 hours) + +**Goal**: Create AttachmentsModel and wire up persistence. + +**Tasks**: +1. Create `AttachmentsModel` class: + ```typescript + // packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts + + import { EventEmitter } from '@noodl-utils/eventemitter'; + + interface VerticalAttachment { + id: string; + topNodeId: string; + bottomNodeId: string; + spacing: number; + } + + export class AttachmentsModel extends EventEmitter { + private attachments: Map = new Map(); + + createAttachment(topId: string, bottomId: string, spacing: number): VerticalAttachment { + // Check for circular dependencies + if (this.wouldCreateCycle(topId, bottomId)) { + throw new Error('Cannot create circular attachment'); + } + + const id = `att_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const attachment: VerticalAttachment = { id, topNodeId: topId, bottomNodeId: bottomId, spacing }; + + this.attachments.set(id, attachment); + this.emit('attachmentCreated', attachment); + return attachment; + } + + removeAttachment(attachmentId: string): void { + const attachment = this.attachments.get(attachmentId); + if (attachment) { + this.attachments.delete(attachmentId); + this.emit('attachmentRemoved', attachment); + } + } + + getAttachedBelow(nodeId: string): string | null { + for (const att of this.attachments.values()) { + if (att.topNodeId === nodeId) return att.bottomNodeId; + } + return null; + } + + getAttachedAbove(nodeId: string): string | null { + for (const att of this.attachments.values()) { + if (att.bottomNodeId === nodeId) return att.topNodeId; + } + return null; + } + + getAttachmentChain(nodeId: string): string[] { + const chain: string[] = []; + + // Go up to find the top + let current = nodeId; + while (this.getAttachedAbove(current)) { + current = this.getAttachedAbove(current)!; + } + + // Now go down to build the chain + chain.push(current); + while (this.getAttachedBelow(current)) { + current = this.getAttachedBelow(current)!; + chain.push(current); + } + + return chain; + } + + private wouldCreateCycle(topId: string, bottomId: string): boolean { + // Check if bottomId is already above topId in any chain + let current = topId; + while (this.getAttachedAbove(current)) { + current = this.getAttachedAbove(current)!; + if (current === bottomId) return true; + } + return false; + } + + // Serialization + toJSON(): VerticalAttachment[] { + return Array.from(this.attachments.values()); + } + + fromJSON(data: VerticalAttachment[]): void { + this.attachments.clear(); + for (const att of data) { + this.attachments.set(att.id, att); + } + } + } + ``` +2. Integrate with NodeGraphModel for persistence +3. Write unit tests for chain detection and cycle prevention + +**Files to create**: +- `packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts` (add attachments to save/load) + +**Success criteria**: +- [ ] AttachmentsModel class implemented +- [ ] Cycle detection works +- [ ] Chain traversal works +- [ ] Attachments persist with project + +--- + +### Session 3.2: Edge Proximity Detection (2-3 hours) + +**Goal**: Detect when a dragged node is near another node's top or bottom edge. + +**Tasks**: +1. Define proximity threshold constant: + ```typescript + const ATTACHMENT_THRESHOLD = 20; // pixels + ``` +2. In `nodegrapheditor.ts`, during node drag: + ```typescript + private detectEdgeProximity(draggingNode: NodeGraphEditorNode): EdgeProximity | null { + const dragBounds = { + x: draggingNode.global.x, + y: draggingNode.global.y, + width: draggingNode.nodeSize.width, + height: draggingNode.nodeSize.height + }; + + let closest: EdgeProximity | null = null; + let closestDistance = ATTACHMENT_THRESHOLD; + + this.forEachNode((node) => { + if (node === draggingNode) return; + + const nodeBounds = { + x: node.global.x, + y: node.global.y, + width: node.nodeSize.width, + height: node.nodeSize.height + }; + + // Check horizontal overlap (nodes should be roughly aligned) + const horizontalOverlap = + dragBounds.x < nodeBounds.x + nodeBounds.width && + dragBounds.x + dragBounds.width > nodeBounds.x; + + if (!horizontalOverlap) return; + + // Check distance from dragging node's bottom to target's top + const distToTop = Math.abs( + (dragBounds.y + dragBounds.height) - nodeBounds.y + ); + + if (distToTop < closestDistance) { + closestDistance = distToTop; + closest = { + targetNode: node, + edge: 'top', + distance: distToTop + }; + } + + // Check distance from dragging node's top to target's bottom + const distToBottom = Math.abs( + dragBounds.y - (nodeBounds.y + nodeBounds.height) + ); + + if (distToBottom < closestDistance) { + closestDistance = distToBottom; + closest = { + targetNode: node, + edge: 'bottom', + distance: distToBottom + }; + } + }); + + return closest; + } + ``` +3. Track proximity state during drag: + ```typescript + private currentProximity: EdgeProximity | null = null; + + // In drag move handler + this.currentProximity = this.detectEdgeProximity(draggingNode); + this.repaint(); // Trigger repaint to show highlight + ``` +4. Clear proximity on drag end + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Proximity detected when node near top edge +- [ ] Proximity detected when node near bottom edge +- [ ] Only detects when nodes horizontally overlap +- [ ] Nearest edge prioritized if multiple options + +--- + +### Session 3.3: Visual Feedback (2 hours) + +**Goal**: Show visual glow on edges that can be attached to. + +**Tasks**: +1. Add highlighted edge state to NodeGraphEditorNode: + ```typescript + // In NodeGraphEditorNode.ts + public highlightedEdge: 'top' | 'bottom' | null = null; + ``` +2. Modify paint() to render highlight: + ```typescript + paint(ctx: CanvasRenderingContext2D, paintRect: Rect) { + // ... existing paint code + + // Draw edge highlight if active + if (this.highlightedEdge) { + ctx.save(); + + const highlightColor = 'rgba(59, 130, 246, 0.5)'; + const glowColor = 'rgba(59, 130, 246, 0.3)'; + const highlightHeight = 4; + const extend = 4; // Extend beyond node edges + + if (this.highlightedEdge === 'bottom') { + const y = this.global.y + this.nodeSize.height; + + // Glow + ctx.shadowColor = glowColor; + ctx.shadowBlur = 8; + ctx.shadowOffsetY = 2; + + // Highlight bar + ctx.fillStyle = highlightColor; + ctx.fillRect( + this.global.x - extend, + y - 2, + this.nodeSize.width + extend * 2, + highlightHeight + ); + } else if (this.highlightedEdge === 'top') { + const y = this.global.y; + + ctx.shadowColor = glowColor; + ctx.shadowBlur = 8; + ctx.shadowOffsetY = -2; + + ctx.fillStyle = highlightColor; + ctx.fillRect( + this.global.x - extend, + y - highlightHeight + 2, + this.nodeSize.width + extend * 2, + highlightHeight + ); + } + + ctx.restore(); + } + + // Continue with rest of painting... + } + ``` +3. Update highlights based on proximity during drag: + ```typescript + // In nodegrapheditor.ts drag handler + // Clear previous highlights + this.forEachNode((node) => { + node.highlightedEdge = null; + }); + + // Set new highlight + if (this.currentProximity) { + const { targetNode, edge } = this.currentProximity; + targetNode.highlightedEdge = edge; + } + ``` +4. Clear all highlights on drag end + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Bottom edge glows when dragging node above +- [ ] Top edge glows when dragging node below +- [ ] Glow has nice shadow/blur effect +- [ ] Highlights clear after drag + +--- + +### Session 3.4: Attachment Creation (2-3 hours) + +**Goal**: Create attachments when dropping on highlighted edges. + +**Tasks**: +1. On drag end, check for active proximity: + ```typescript + // In drag end handler + if (this.currentProximity) { + const { targetNode, edge } = this.currentProximity; + const draggingNode = this.draggingNodes[0]; // Assuming single node drag + + // Determine top and bottom based on edge + let topNodeId: string, bottomNodeId: string; + if (edge === 'top') { + // Dragging node is above target + topNodeId = draggingNode.model.id; + bottomNodeId = targetNode.model.id; + } else { + // Dragging node is below target + topNodeId = targetNode.model.id; + bottomNodeId = draggingNode.model.id; + } + + // Calculate spacing + const topNode = edge === 'top' ? draggingNode : targetNode; + const bottomNode = edge === 'top' ? targetNode : draggingNode; + const spacing = bottomNode.global.y - (topNode.global.y + topNode.nodeSize.height); + + // Create attachment + this.attachmentsModel.createAttachment(topNodeId, bottomNodeId, Math.max(spacing, 10)); + + // Snap node to exact position + this.snapToAttachment(draggingNode, topNodeId, bottomNodeId); + } + ``` +2. Handle insertion between existing attached nodes: + ```typescript + private handleChainInsertion( + draggingNode: NodeGraphEditorNode, + targetNode: NodeGraphEditorNode, + edge: 'top' | 'bottom' + ): void { + if (edge === 'top') { + // Check if target has something attached above + const aboveId = this.attachmentsModel.getAttachedAbove(targetNode.model.id); + if (aboveId) { + // Remove existing attachment + const existingAtt = this.attachmentsModel.getAttachmentBetween(aboveId, targetNode.model.id); + if (existingAtt) { + this.attachmentsModel.removeAttachment(existingAtt.id); + } + + // Insert new node: above -> dragging -> target + this.attachmentsModel.createAttachment(aboveId, draggingNode.model.id, existingAtt?.spacing || 20); + } + + // Attach dragging to target + this.attachmentsModel.createAttachment(draggingNode.model.id, targetNode.model.id, 20); + } + // Similar logic for 'bottom' edge... + } + ``` +3. Add undo support for attachment creation +4. Show visual confirmation (brief flash or toast) + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Dropping on highlighted edge creates attachment +- [ ] Correct top/bottom assignment based on edge +- [ ] Spacing calculated from actual positions +- [ ] Insertion between attached nodes works +- [ ] Undo removes attachment + +--- + +### Session 3.5: Push Calculation (2-3 hours) + +**Goal**: When a node resizes, push attached nodes down. + +**Tasks**: +1. Subscribe to node size changes: + ```typescript + // In nodegrapheditor.ts or component initialization + EventDispatcher.instance.on( + ['Model.portAdded', 'Model.portRemoved'], + (args) => this.handleNodeSizeChange(args.model), + this + ); + ``` +2. Implement push calculation: + ```typescript + private handleNodeSizeChange(nodeModel: NodeGraphNode): void { + const node = this.findNodeWithId(nodeModel.id); + if (!node) return; + + // Get all nodes attached below this one + const chain = this.attachmentsModel.getAttachmentChain(nodeModel.id); + const nodeIndex = chain.indexOf(nodeModel.id); + + if (nodeIndex === -1 || nodeIndex === chain.length - 1) return; + + // Calculate expected position for next node + const attachment = this.attachmentsModel.getAttachmentBetween( + nodeModel.id, + chain[nodeIndex + 1] + ); + + if (!attachment) return; + + const expectedY = node.global.y + node.nodeSize.height + attachment.spacing; + const nextNode = this.findNodeWithId(chain[nodeIndex + 1]); + + if (!nextNode) return; + + const deltaY = expectedY - nextNode.global.y; + + if (Math.abs(deltaY) < 1) return; // No significant change + + // Push all nodes below + for (let i = nodeIndex + 1; i < chain.length; i++) { + const pushNode = this.findNodeWithId(chain[i]); + if (pushNode) { + pushNode.model.setPosition(pushNode.x, pushNode.y + deltaY, { undo: false }); + pushNode.setPosition(pushNode.x, pushNode.y + deltaY); + } + } + + this.relayout(); + this.repaint(); + } + ``` +3. Handle recursive push (pushing a node that has nodes attached below it) +4. Add debouncing to prevent excessive updates during rapid changes + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Adding port pushes attached nodes down +- [ ] Removing port pulls attached nodes up (closer) +- [ ] Full chain pushes correctly (A→B→C, A grows, B and C both move) +- [ ] No infinite loops or excessive recalculation + +--- + +### Session 3.6: Detachment (2 hours) + +**Goal**: Allow users to remove nodes from attachment chains. + +**Tasks**: +1. Add context menu item: + ```typescript + // In node context menu creation + if (this.attachmentsModel.getAttachedAbove(node.model.id) || + this.attachmentsModel.getAttachedBelow(node.model.id)) { + menuItems.push({ + label: 'Detach from Stack', + onClick: () => this.detachNode(node) + }); + } + ``` +2. Implement detach logic: + ```typescript + private detachNode(node: NodeGraphEditorNode): void { + const nodeId = node.model.id; + const aboveId = this.attachmentsModel.getAttachedAbove(nodeId); + const belowId = this.attachmentsModel.getAttachedBelow(nodeId); + + // Remove attachment above (if exists) + if (aboveId) { + const att = this.attachmentsModel.getAttachmentBetween(aboveId, nodeId); + if (att) this.attachmentsModel.removeAttachment(att.id); + } + + // Remove attachment below (if exists) + if (belowId) { + const att = this.attachmentsModel.getAttachmentBetween(nodeId, belowId); + if (att) this.attachmentsModel.removeAttachment(att.id); + } + + // Reconnect above and below if both existed + if (aboveId && belowId) { + const aboveNode = this.findNodeWithId(aboveId); + const belowNode = this.findNodeWithId(belowId); + + if (aboveNode && belowNode) { + // Calculate new spacing (closing the gap) + const spacing = belowNode.global.y - (aboveNode.global.y + aboveNode.nodeSize.height); + this.attachmentsModel.createAttachment(aboveId, belowId, spacing); + + // Move below node up to close gap + const targetY = aboveNode.global.y + aboveNode.nodeSize.height + 20; // Default spacing + const deltaY = targetY - belowNode.global.y; + + // Move entire sub-chain + const chain = this.attachmentsModel.getAttachmentChain(belowId); + const startIndex = chain.indexOf(belowId); + + for (let i = startIndex; i < chain.length; i++) { + const moveNode = this.findNodeWithId(chain[i]); + if (moveNode) { + moveNode.model.setPosition(moveNode.x, moveNode.y + deltaY, { undo: false }); + moveNode.setPosition(moveNode.x, moveNode.y + deltaY); + } + } + } + } + + this.relayout(); + this.repaint(); + } + ``` +3. Add undo support for detachment +4. Consider animation for gap closing (optional) + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Context menu shows "Detach from Stack" for attached nodes +- [ ] Detaching middle node reconnects above and below +- [ ] Gap closes after detachment +- [ ] Detaching end node removes single attachment +- [ ] Undo restores attachment and positions + +--- + +### Session 3.7: Alignment Guides (2 hours, optional) + +**Goal**: Show alignment guides when dragging near aligned edges. + +**Tasks**: +1. Detect aligned edges during drag: + ```typescript + private detectAlignedEdges(draggingNode: NodeGraphEditorNode): AlignmentGuide[] { + const guides: AlignmentGuide[] = []; + const tolerance = 5; // pixels + + const dragBounds = { + left: draggingNode.global.x, + right: draggingNode.global.x + draggingNode.nodeSize.width, + top: draggingNode.global.y, + bottom: draggingNode.global.y + draggingNode.nodeSize.height + }; + + this.forEachNode((node) => { + if (node === draggingNode) return; + + const nodeBounds = { + left: node.global.x, + right: node.global.x + node.nodeSize.width, + top: node.global.y, + bottom: node.global.y + node.nodeSize.height + }; + + // Check left edges align + if (Math.abs(dragBounds.left - nodeBounds.left) < tolerance) { + guides.push({ type: 'vertical', position: nodeBounds.left }); + } + + // Check right edges align + if (Math.abs(dragBounds.right - nodeBounds.right) < tolerance) { + guides.push({ type: 'vertical', position: nodeBounds.right }); + } + + // Check top edges align + if (Math.abs(dragBounds.top - nodeBounds.top) < tolerance) { + guides.push({ type: 'horizontal', position: nodeBounds.top }); + } + + // Check bottom edges align + if (Math.abs(dragBounds.bottom - nodeBounds.bottom) < tolerance) { + guides.push({ type: 'horizontal', position: nodeBounds.bottom }); + } + }); + + return guides; + } + ``` +2. Render guides in paint(): + ```typescript + private paintAlignmentGuides(ctx: CanvasRenderingContext2D): void { + if (!this.alignmentGuides?.length) return; + + ctx.save(); + ctx.strokeStyle = 'rgba(59, 130, 246, 0.4)'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + + for (const guide of this.alignmentGuides) { + ctx.beginPath(); + + if (guide.type === 'vertical') { + ctx.moveTo(guide.position, this.graphAABB.minY - 100); + ctx.lineTo(guide.position, this.graphAABB.maxY + 100); + } else { + ctx.moveTo(this.graphAABB.minX - 100, guide.position); + ctx.lineTo(this.graphAABB.maxX + 100, guide.position); + } + + ctx.stroke(); + } + + ctx.restore(); + } + ``` +3. Clear guides on drag end + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Vertical guides appear when left/right edges align +- [ ] Horizontal guides appear when top/bottom edges align +- [ ] Guides visually distinct from attachment highlights +- [ ] Guides clear after drag + +--- + +## Testing Checklist + +### Attachment Creation +- [ ] Drag node near another's bottom edge → edge highlights +- [ ] Drag node near another's top edge → edge highlights +- [ ] Drop on highlighted edge → attachment created +- [ ] Attachment stored in model +- [ ] Attachment persists after save/reload + +### Chain Behavior +- [ ] Create chain of 3+ nodes +- [ ] Moving top node moves all attached nodes +- [ ] Expanding top node pushes all down +- [ ] Expanding middle node pushes nodes below + +### Insertion +- [ ] Drag node between two attached nodes +- [ ] Both edges highlight (or nearest one) +- [ ] Drop inserts node into chain +- [ ] Original chain reconnected through new node + +### Detachment +- [ ] Context menu shows "Detach from Stack" for attached nodes +- [ ] Detach middle node → chain reconnects +- [ ] Detach top node → remaining chain intact +- [ ] Detach bottom node → remaining chain intact +- [ ] Gap closes after detachment + +### Undo/Redo +- [ ] Undo attachment creation → attachment removed +- [ ] Redo → attachment restored +- [ ] Undo detachment → attachment restored +- [ ] Undo push → positions restored + +### Edge Cases +- [ ] Circular attachment prevented (A→B→C→A impossible) +- [ ] Deleting attached node removes from chain +- [ ] Very long chain (10+ nodes) works correctly +- [ ] Node in Smart Frame can still be attached +- [ ] Copy/paste of attached node creates independent node + +### Alignment Guides (if implemented) +- [ ] Vertical guide shows when left edges align +- [ ] Vertical guide shows when right edges align +- [ ] Horizontal guide shows when top edges align +- [ ] Horizontal guide shows when bottom edges align +- [ ] Multiple guides can show simultaneously +- [ ] Guides clear after drag ends + +--- + +## Files Summary + +### Create +``` +packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts +``` + +### Modify +``` +packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts +packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts +``` + +--- + +## Performance Considerations + +### Proximity Detection + +- Only check nearby nodes (use spatial partitioning for large graphs) +- Cache node bounds during drag +- Don't recalculate on every mouse move (throttle to ~30fps) + +```typescript +// Throttled proximity check +const checkProximity = throttle(() => { + this.currentProximity = this.detectEdgeProximity(draggingNode); + this.repaint(); +}, 33); // ~30fps +``` + +### Push Calculation + +- Debounce size change handlers +- Only recalculate affected chain, not all attachments +- Cache chain lookups + +```typescript +// Debounced size change handler +const handleSizeChange = debounce((nodeModel) => { + this.pushAttachedNodes(nodeModel); +}, 100); +``` + +### Alignment Guides + +- Limit to nodes within viewport +- Use Set to deduplicate guides at same position +- Don't render guides that extend far off-screen + +--- + +## Design Decisions + +### Why Not Auto-Attach on Overlap? + +Users may intentionally overlap nodes temporarily. Requiring drop on highlighted edge gives user control and prevents unwanted attachments. + +### Why Fixed Spacing? + +Spacing is captured at attachment creation time. This preserves the user's intentional layout while maintaining relative positions during push operations. + +Could make spacing adjustable in future (drag to resize gap). + +### Why Reconnect on Detach? + +If A→B→C and B is detached, users usually want A→C (close the gap) rather than leaving A and C unattached. This matches mental model of "removing from the middle". + +Users can manually detach A from C afterward if they want separation. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-004-connection-labels.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-004-connection-labels.md new file mode 100644 index 0000000..5b7ef38 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-000-styles-overhaul/TASK-000J-canvas-organisation-system/SUBTASK-004-connection-labels.md @@ -0,0 +1,997 @@ +# SUBTASK-004: Connection Labels + +**Parent Task**: TASK-000J Canvas Organization System +**Estimate**: 10-14 hours +**Priority**: 4 +**Dependencies**: None (can be implemented independently) + +--- + +## Overview + +Allow users to add text labels to connection lines to document data flow. Labels sit on the bezier curve connecting nodes and can be repositioned along the path. + +### The Problem + +In complex node graphs: +- It's unclear what data flows through connections +- Users must trace connections to understand data types +- Documentation exists only in users' heads or external docs +- Similar-colored connections are indistinguishable + +### The Solution + +Add inline labels directly on connection lines: +- Labels describe what data flows through the connection +- Position labels anywhere along the curve +- Labels persist with the project +- Quick to add via hover icon + +--- + +## Feature Capabilities + +| Capability | Description | +|------------|-------------| +| **Hover to add** | Icon appears on connection hover for adding labels | +| **Inline editing** | Click icon to add label, type and confirm | +| **On-curve positioning** | Label sits directly on the bezier curve | +| **Draggable** | Slide label along the curve path | +| **Edit existing** | Click label to edit text | +| **Delete** | Clear text or use delete button | +| **Persistence** | Labels saved with project | + +--- + +## Visual Design + +### Connection with Label + +``` + ┌─────────┐ +┌────────┐ │ user ID │ +│ Source │─────────┴─────────┴──────────►│ Target │ +└────────┘ └────────┘ + ▲ + Label on curve +``` + +### Label Styling + +- **Background**: Semi-transparent, matches connection color +- **Text**: Small (10-11px), high contrast +- **Shape**: Rounded rectangle with padding +- **Border**: Optional subtle border + +```css +.connection-label { + font-size: 10px; + font-weight: 500; + padding: 2px 6px; + border-radius: 3px; + background-color: rgba(var(--connection-color), 0.8); + color: white; + white-space: nowrap; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; +} +``` + +### Add Label Icon + +When hovering a connection without a label: +``` + ┌───┐ + ──────│ + │──────► + └───┘ + ↑ + Add label icon (appears on hover) + Similar size/style to existing delete "X" +``` + +### Edit Mode + +``` + ┌─────────────────┐ + ──────│ user ID█ │──────► + └─────────────────┘ + ↑ + Inline input field + Cursor visible, typing active +``` + +--- + +## Technical Architecture + +### Bezier Curve Math + +Connections in Noodl use cubic bezier curves. Key formulas: + +**Point on cubic bezier at parameter t (0-1):** +``` +B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ +``` + +Where: +- P₀ = start point (output port position) +- P₁ = first control point +- P₂ = second control point +- P₃ = end point (input port position) +- t = parameter from 0 (start) to 1 (end) + +**Tangent (direction) at parameter t:** +``` +B'(t) = 3(1-t)²(P₁-P₀) + 6(1-t)t(P₂-P₁) + 3t²(P₃-P₂) +``` + +### Data Model Extension + +```typescript +interface Connection { + // Existing fields + fromId: string; + fromProperty: string; + toId: string; + toProperty: string; + + // New label field + label?: ConnectionLabel; +} + +interface ConnectionLabel { + text: string; + position: number; // 0-1 along curve, default 0.5 (midpoint) +} +``` + +### Implementation Architecture + +``` +packages/noodl-editor/src/editor/src/ +├── utils/ +│ └── bezier.ts # Bezier math utilities +├── views/nodegrapheditor/ +│ ├── NodeGraphEditorConnection.ts # Modified for label support +│ └── ConnectionLabel.ts # Label rendering (new) +└── models/ + └── nodegraphmodel.ts # Connection model extension +``` + +--- + +## Implementation Sessions + +### Session 4.1: Bezier Utilities (2 hours) + +**Goal**: Create utility functions for bezier curve calculations. + +**Tasks**: +1. Create bezier utility module: + ```typescript + // packages/noodl-editor/src/editor/src/utils/bezier.ts + + export interface Point { + x: number; + y: number; + } + + /** + * Calculate point on cubic bezier curve at parameter t + */ + export function getPointOnCubicBezier( + t: number, + p0: Point, + p1: Point, + p2: Point, + p3: Point + ): Point { + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + + return { + x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x, + y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y + }; + } + + /** + * Calculate tangent (direction vector) on cubic bezier at parameter t + */ + export function getTangentOnCubicBezier( + t: number, + p0: Point, + p1: Point, + p2: Point, + p3: Point + ): Point { + const mt = 1 - t; + const mt2 = mt * mt; + const t2 = t * t; + + // Derivative of bezier + const dx = 3 * mt2 * (p1.x - p0.x) + 6 * mt * t * (p2.x - p1.x) + 3 * t2 * (p3.x - p2.x); + const dy = 3 * mt2 * (p1.y - p0.y) + 6 * mt * t * (p2.y - p1.y) + 3 * t2 * (p3.y - p2.y); + + return { x: dx, y: dy }; + } + + /** + * Find nearest t value on bezier curve to given point + * Uses iterative refinement for accuracy + */ + export function getNearestTOnCubicBezier( + point: Point, + p0: Point, + p1: Point, + p2: Point, + p3: Point, + iterations: number = 10 + ): number { + // Initial coarse search + let bestT = 0; + let bestDist = Infinity; + + const steps = 20; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const curvePoint = getPointOnCubicBezier(t, p0, p1, p2, p3); + const dist = distance(point, curvePoint); + + if (dist < bestDist) { + bestDist = dist; + bestT = t; + } + } + + // Refine with binary search + let low = Math.max(0, bestT - 1 / steps); + let high = Math.min(1, bestT + 1 / steps); + + for (let i = 0; i < iterations; i++) { + const midLow = (low + bestT) / 2; + const midHigh = (bestT + high) / 2; + + const distLow = distance(point, getPointOnCubicBezier(midLow, p0, p1, p2, p3)); + const distHigh = distance(point, getPointOnCubicBezier(midHigh, p0, p1, p2, p3)); + + if (distLow < distHigh) { + high = bestT; + bestT = midLow; + } else { + low = bestT; + bestT = midHigh; + } + } + + return bestT; + } + + function distance(a: Point, b: Point): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); + } + + /** + * Calculate approximate arc length of bezier curve + * Useful for even label spacing if multiple labels needed + */ + export function getCubicBezierLength( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + steps: number = 100 + ): number { + let length = 0; + let prevPoint = p0; + + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const point = getPointOnCubicBezier(t, p0, p1, p2, p3); + length += distance(prevPoint, point); + prevPoint = point; + } + + return length; + } + ``` +2. Write unit tests for bezier functions: + ```typescript + describe('bezier utils', () => { + it('returns start point at t=0', () => { + const p0 = { x: 0, y: 0 }; + const p1 = { x: 10, y: 0 }; + const p2 = { x: 20, y: 0 }; + const p3 = { x: 30, y: 0 }; + + const result = getPointOnCubicBezier(0, p0, p1, p2, p3); + expect(result.x).toBeCloseTo(0); + expect(result.y).toBeCloseTo(0); + }); + + it('returns end point at t=1', () => { + // ... + }); + + it('finds nearest t to point on curve', () => { + // ... + }); + }); + ``` + +**Files to create**: +- `packages/noodl-editor/src/editor/src/utils/bezier.ts` +- `packages/noodl-editor/src/editor/src/utils/bezier.test.ts` + +**Success criteria**: +- [ ] `getPointOnCubicBezier` returns correct points +- [ ] `getNearestTOnCubicBezier` finds accurate t values +- [ ] All unit tests pass + +--- + +### Session 4.2: Data Model Extension (1 hour) + +**Goal**: Extend Connection model to support labels. + +**Tasks**: +1. Add label interface to connection model: + ```typescript + // In nodegraphmodel.ts or connections.ts + + export interface ConnectionLabel { + text: string; + position: number; // 0-1, default 0.5 + } + + // Extend Connection interface + export interface Connection { + // ... existing fields + label?: ConnectionLabel; + } + ``` +2. Add methods to set/remove labels: + ```typescript + class NodeGraphModel { + setConnectionLabel( + fromId: string, + fromProperty: string, + toId: string, + toProperty: string, + label: ConnectionLabel | null + ): void { + const connection = this.findConnection(fromId, fromProperty, toId, toProperty); + if (connection) { + if (label) { + connection.label = label; + } else { + delete connection.label; + } + this.notifyListeners('connectionChanged', { connection }); + } + } + + updateConnectionLabelPosition( + fromId: string, + fromProperty: string, + toId: string, + toProperty: string, + position: number + ): void { + const connection = this.findConnection(fromId, fromProperty, toId, toProperty); + if (connection?.label) { + connection.label.position = Math.max(0.1, Math.min(0.9, position)); + this.notifyListeners('connectionChanged', { connection }); + } + } + } + ``` +3. Ensure labels persist in project save/load (should work automatically if added to Connection) +4. Add undo support for label operations + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts` + +**Success criteria**: +- [ ] Label field added to Connection interface +- [ ] Set/remove label methods work +- [ ] Labels persist in saved project +- [ ] Undo works for label operations + +--- + +### Session 4.3: Hover State and Add Icon (2-3 hours) + +**Goal**: Show add-label icon when hovering a connection. + +**Tasks**: +1. Add hover state to NodeGraphEditorConnection: + ```typescript + // In NodeGraphEditorConnection.ts + + export class NodeGraphEditorConnection { + // ... existing fields + public isHovered: boolean = false; + private addIconBounds: { x: number; y: number; width: number; height: number } | null = null; + + setHovered(hovered: boolean): void { + if (this.isHovered !== hovered) { + this.isHovered = hovered; + // Trigger repaint + } + } + } + ``` +2. Implement connection hit-testing (may already exist): + ```typescript + isPointNearCurve(point: Point, threshold: number = 10): boolean { + const { p0, p1, p2, p3 } = this.getControlPoints(); + const nearestT = getNearestTOnCubicBezier(point, p0, p1, p2, p3); + const nearestPoint = getPointOnCubicBezier(nearestT, p0, p1, p2, p3); + + const dx = point.x - nearestPoint.x; + const dy = point.y - nearestPoint.y; + return Math.sqrt(dx * dx + dy * dy) <= threshold; + } + ``` +3. Track hovered connection in nodegrapheditor: + ```typescript + // In mouse move handler + private updateHoveredConnection(pos: Point): void { + let newHovered: NodeGraphEditorConnection | null = null; + + for (const conn of this.connections) { + if (conn.isPointNearCurve(pos)) { + newHovered = conn; + break; + } + } + + if (this.hoveredConnection !== newHovered) { + this.hoveredConnection?.setHovered(false); + this.hoveredConnection = newHovered; + this.hoveredConnection?.setHovered(true); + this.repaint(); + } + } + ``` +4. Render add icon when hovered (and no existing label): + ```typescript + // In NodeGraphEditorConnection.paint() + + if (this.isHovered && !this.model.label) { + const midpoint = this.getMidpoint(); + const iconSize = 16; + + // Draw icon background + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.beginPath(); + ctx.arc(midpoint.x, midpoint.y, iconSize / 2 + 2, 0, Math.PI * 2); + ctx.fill(); + + // Draw + icon + ctx.strokeStyle = '#666'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(midpoint.x - 4, midpoint.y); + ctx.lineTo(midpoint.x + 4, midpoint.y); + ctx.moveTo(midpoint.x, midpoint.y - 4); + ctx.lineTo(midpoint.x, midpoint.y + 4); + ctx.stroke(); + + // Store bounds for click detection + this.addIconBounds = { + x: midpoint.x - iconSize / 2, + y: midpoint.y - iconSize / 2, + width: iconSize, + height: iconSize + }; + } + ``` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` + +**Success criteria**: +- [ ] Hovering connection highlights it subtly +- [ ] Add icon appears at midpoint for connections without labels +- [ ] Icon styled consistently with existing delete icon +- [ ] Icon bounds stored for click detection + +--- + +### Session 4.4: Inline Label Input (2-3 hours) + +**Goal**: Show input field for adding/editing labels. + +**Tasks**: +1. Create label input element (could be DOM overlay or canvas-based): + ```typescript + // DOM overlay approach (easier for text input) + + private showLabelInput(connection: NodeGraphEditorConnection, position: Point): void { + // Create input element + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'connection-label-input'; + input.placeholder = 'Enter label...'; + + // Position at connection point + const canvasPos = this.nodeGraphCordsToScreenCoords(position); + input.style.position = 'absolute'; + input.style.left = `${canvasPos.x}px`; + input.style.top = `${canvasPos.y}px`; + input.style.transform = 'translate(-50%, -50%)'; + + // Pre-fill if editing existing label + if (connection.model.label) { + input.value = connection.model.label.text; + } + + // Handle submission + const submitLabel = () => { + const text = input.value.trim(); + if (text) { + const labelPosition = connection.model.label?.position ?? 0.5; + this.model.setConnectionLabel( + connection.model.fromId, + connection.model.fromProperty, + connection.model.toId, + connection.model.toProperty, + { text, position: labelPosition } + ); + } else if (connection.model.label) { + // Clear existing label if text is empty + this.model.setConnectionLabel( + connection.model.fromId, + connection.model.fromProperty, + connection.model.toId, + connection.model.toProperty, + null + ); + } + input.remove(); + this.activeInput = null; + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + submitLabel(); + } else if (e.key === 'Escape') { + input.remove(); + this.activeInput = null; + } + }); + + input.addEventListener('blur', submitLabel); + + // Add to DOM and focus + this.el.appendChild(input); + input.focus(); + input.select(); + this.activeInput = input; + } + ``` +2. Style the input: + ```css + .connection-label-input { + font-size: 11px; + padding: 4px 8px; + border: 1px solid var(--theme-color-primary); + border-radius: 4px; + background: var(--theme-color-bg-2); + color: var(--theme-color-fg-highlight); + outline: none; + min-width: 80px; + max-width: 150px; + z-index: 1000; + } + + .connection-label-input:focus { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); + } + ``` +3. Handle click on add icon: + ```typescript + // In mouse click handler + if (this.hoveredConnection?.addIconBounds) { + const bounds = this.hoveredConnection.addIconBounds; + if ( + pos.x >= bounds.x && + pos.x <= bounds.x + bounds.width && + pos.y >= bounds.y && + pos.y <= bounds.y + bounds.height + ) { + const midpoint = this.hoveredConnection.getMidpoint(); + this.showLabelInput(this.hoveredConnection, midpoint); + return true; // Consume event + } + } + ``` +4. Add undo support for label creation + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- `packages/noodl-editor/src/editor/src/assets/css/style.css` (or module scss) + +**Success criteria**: +- [ ] Clicking add icon shows input field +- [ ] Input positioned at midpoint of connection +- [ ] Enter confirms and creates label +- [ ] Escape cancels without creating label +- [ ] Clicking outside (blur) confirms label +- [ ] Empty text removes existing label + +--- + +### Session 4.5: Label Rendering (2 hours) + +**Goal**: Render labels on connection curves. + +**Tasks**: +1. Calculate label position on curve: + ```typescript + // In NodeGraphEditorConnection + + getLabelPosition(): Point | null { + if (!this.model.label) return null; + + const { p0, p1, p2, p3 } = this.getControlPoints(); + return getPointOnCubicBezier(this.model.label.position, p0, p1, p2, p3); + } + ``` +2. Render label in paint(): + ```typescript + // In NodeGraphEditorConnection.paint() + + if (this.model.label) { + const position = this.getLabelPosition(); + if (!position) return; + + const text = this.model.label.text; + const padding = { x: 6, y: 3 }; + + // Measure text + ctx.font = '10px system-ui, sans-serif'; + const textMetrics = ctx.measureText(text); + const textWidth = Math.min(textMetrics.width, 100); // Max width + const textHeight = 12; + + // Calculate background bounds + const bgWidth = textWidth + padding.x * 2; + const bgHeight = textHeight + padding.y * 2; + const bgX = position.x - bgWidth / 2; + const bgY = position.y - bgHeight / 2; + + // Draw background + ctx.fillStyle = this.getLabelBackgroundColor(); + ctx.beginPath(); + this.roundRect(ctx, bgX, bgY, bgWidth, bgHeight, 3); + ctx.fill(); + + // Draw text + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, position.x, position.y, 100); // Max width + + // Store bounds for interaction + this.labelBounds = { x: bgX, y: bgY, width: bgWidth, height: bgHeight }; + } + + private getLabelBackgroundColor(): string { + // Use connection color with some opacity + const baseColor = this.getConnectionColor(); + // Convert to rgba with 0.85 opacity + return `${baseColor}d9`; // Hex alpha + } + + private roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number + ): void { + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + } + ``` +3. Handle text truncation for long labels +4. Ensure labels visible at different zoom levels + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts` + +**Success criteria**: +- [ ] Label renders at correct position on curve +- [ ] Label styled with rounded background +- [ ] Label color matches connection color +- [ ] Long text truncated with ellipsis +- [ ] Label visible at reasonable zoom levels + +--- + +### Session 4.6: Label Interaction (2-3 hours) + +**Goal**: Enable editing, dragging, and deleting labels. + +**Tasks**: +1. Detect click on label: + ```typescript + // In nodegrapheditor.ts mouse handler + + private getClickedLabel(pos: Point): NodeGraphEditorConnection | null { + for (const conn of this.connections) { + if (conn.labelBounds && conn.model.label) { + const { x, y, width, height } = conn.labelBounds; + if (pos.x >= x && pos.x <= x + width && pos.y >= y && pos.y <= y + height) { + return conn; + } + } + } + return null; + } + ``` +2. Handle click on label (edit): + ```typescript + // In mouse click handler + const clickedLabelConn = this.getClickedLabel(pos); + if (clickedLabelConn) { + const labelPos = clickedLabelConn.getLabelPosition(); + if (labelPos) { + this.showLabelInput(clickedLabelConn, labelPos); + return true; + } + } + ``` +3. Implement label dragging: + ```typescript + // In mouse down handler + const clickedLabelConn = this.getClickedLabel(pos); + if (clickedLabelConn) { + this.draggingLabel = clickedLabelConn; + return true; + } + + // In mouse move handler + if (this.draggingLabel) { + const { p0, p1, p2, p3 } = this.draggingLabel.getControlPoints(); + const newT = getNearestTOnCubicBezier(pos, p0, p1, p2, p3); + + // Constrain to avoid endpoints + const constrainedT = Math.max(0.1, Math.min(0.9, newT)); + + this.model.updateConnectionLabelPosition( + this.draggingLabel.model.fromId, + this.draggingLabel.model.fromProperty, + this.draggingLabel.model.toId, + this.draggingLabel.model.toProperty, + constrainedT + ); + + this.repaint(); + return true; + } + + // In mouse up handler + if (this.draggingLabel) { + this.draggingLabel = null; + } + ``` +4. Add delete button on label hover: + ```typescript + // When label is hovered, show small X button + if (this.hoveredLabel && conn === this.hoveredLabel) { + const deleteX = labelBounds.x + labelBounds.width - 8; + const deleteY = labelBounds.y; + + ctx.fillStyle = 'rgba(255, 0, 0, 0.8)'; + ctx.beginPath(); + ctx.arc(deleteX, deleteY, 6, 0, Math.PI * 2); + ctx.fill(); + + // Draw X + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(deleteX - 2, deleteY - 2); + ctx.lineTo(deleteX + 2, deleteY + 2); + ctx.moveTo(deleteX + 2, deleteY - 2); + ctx.lineTo(deleteX - 2, deleteY + 2); + ctx.stroke(); + + this.labelDeleteBounds = { x: deleteX - 6, y: deleteY - 6, width: 12, height: 12 }; + } + ``` +5. Handle delete click: + ```typescript + if (this.labelDeleteBounds && this.hoveredLabel) { + const { x, y, width, height } = this.labelDeleteBounds; + if (pos.x >= x && pos.x <= x + width && pos.y >= y && pos.y <= y + height) { + this.model.setConnectionLabel( + this.hoveredLabel.model.fromId, + this.hoveredLabel.model.fromProperty, + this.hoveredLabel.model.toId, + this.hoveredLabel.model.toProperty, + null + ); + return true; + } + } + ``` + +**Files to modify**: +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts` +- `packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts` + +**Success criteria**: +- [ ] Clicking label opens edit input +- [ ] Dragging label moves it along curve +- [ ] Label constrained to 0.1-0.9 range (not at endpoints) +- [ ] Delete button appears on hover +- [ ] Clicking delete removes label +- [ ] Undo works for drag and delete + +--- + +## Testing Checklist + +### Adding Labels +- [ ] Hover connection → add icon appears at midpoint +- [ ] Click add icon → input appears +- [ ] Type text and press Enter → label created +- [ ] Type text and click outside → label created +- [ ] Press Escape → input cancelled, no label created +- [ ] Label renders on connection curve + +### Editing Labels +- [ ] Click existing label → input appears with current text +- [ ] Edit text and confirm → label updated +- [ ] Clear text and confirm → label deleted + +### Dragging Labels +- [ ] Click and drag label → moves along curve +- [ ] Label constrained to not overlap endpoints +- [ ] Position updates smoothly +- [ ] Release → position saved + +### Deleting Labels +- [ ] Hover label → delete button appears +- [ ] Click delete button → label removed +- [ ] Alternative: clear text in edit mode → label removed + +### Persistence +- [ ] Save project with labels → labels in saved file +- [ ] Load project → labels restored +- [ ] Label positions preserved + +### Undo/Redo +- [ ] Undo label creation → label removed +- [ ] Redo → label restored +- [ ] Undo label edit → previous text restored +- [ ] Undo label delete → label restored +- [ ] Undo label drag → previous position restored + +### Visual Quality +- [ ] Label readable at zoom 1.0 +- [ ] Label readable at zoom 0.5 +- [ ] Label hidden at very low zoom (optional) +- [ ] Label color matches connection color +- [ ] Long text truncated properly + +### Edge Cases +- [ ] Delete node with labeled connection → label removed +- [ ] Connection with label is deleted → label removed +- [ ] Multiple labels (different connections) → all render correctly +- [ ] Label on curved connection → positioned on actual curve +- [ ] Label on very short connection → still usable + +--- + +## Files Summary + +### Create +``` +packages/noodl-editor/src/editor/src/utils/bezier.ts +packages/noodl-editor/src/editor/src/utils/bezier.test.ts +``` + +### Modify +``` +packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts +packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts +packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorConnection.ts +packages/noodl-editor/src/editor/src/assets/css/style.css +``` + +--- + +## Performance Considerations + +### Hit Testing + +- Don't test all connections on every mouse move +- Use spatial partitioning or only test visible connections +- Cache connection bounds + +```typescript +// Only test connections in viewport +const visibleConnections = this.connections.filter(conn => + conn.intersectsRect(this.getViewportBounds()) +); +``` + +### Label Rendering + +- Don't render labels that are off-screen +- Skip label rendering at very low zoom (labels unreadable anyway) + +```typescript +// Skip labels at low zoom +if (this.getPanAndScale().scale < 0.4) { + return; // Don't render labels +} +``` + +### Bezier Calculations + +- Cache control points during drag +- Use lower iteration count for real-time dragging + +```typescript +// Fast (lower accuracy) for dragging +const t = getNearestTOnCubicBezier(pos, p0, p1, p2, p3, 5); + +// Accurate for final position +const t = getNearestTOnCubicBezier(pos, p0, p1, p2, p3, 15); +``` + +--- + +## Design Decisions + +### Why One Label Per Connection? + +Simplicity. Multiple labels would require: +- More complex UI for adding at specific positions +- Handling overlapping labels +- More complex data model + +Single label covers 90% of use cases. Can extend later if needed. + +### Why Not Label Rotation? + +Labels aligned to curve tangent could be rotated to follow the curve direction. However: +- Rotated text is harder to read +- Horizontal text is conventional +- Implementation complexity not worth it + +### Why Constrain Position to 0.1-0.9? + +At exactly 0 or 1, labels would overlap with node ports. The constraint keeps labels in the "middle" of the connection where they're most readable and don't interfere with ports. + +### Why DOM Input vs Canvas Input? + +DOM input provides: +- Native text selection and editing +- Proper cursor behavior +- IME support for international input +- Accessibility + +Canvas-based text input is significantly more complex to implement correctly. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-001-COMPLETED.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-001-COMPLETED.md new file mode 100644 index 0000000..2fe62e0 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-001-COMPLETED.md @@ -0,0 +1,300 @@ +# GIT-001: GitHub OAuth Integration - COMPLETED ✅ + +**Status:** Complete +**Completed:** January 1, 2026 +**Implementation Time:** ~8 hours (design + implementation + debugging) + +## Overview + +GitHub OAuth authentication has been successfully implemented, providing users with a seamless authentication experience for GitHub integration in OpenNoodl. + +## What Was Implemented + +### ✅ Core OAuth Service + +**File:** `packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts` + +- PKCE (Proof Key for Code Exchange) flow for enhanced security +- Combined with client_secret (GitHub Apps requirement) +- Token exchange with GitHub's OAuth endpoint +- Secure token storage using Electron's safeStorage API +- User information retrieval via GitHub API +- Organization listing support +- Session management (connect/disconnect) +- Event-based authentication state notifications + +### ✅ Deep Link Handler + +**File:** `packages/noodl-editor/src/main/main.js` + +- Registered `noodl://` custom protocol +- Handles `noodl://github-callback` OAuth callbacks +- IPC communication for token storage/retrieval +- Secure token encryption using Electron's safeStorage + +### ✅ UI Components + +**Files Created:** + +- `packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/` + - GitHubConnectButton.tsx + - GitHubConnectButton.module.scss + - index.ts + +**Features:** + +- "Connect GitHub" button with GitHub icon +- Loading state during OAuth flow +- Responsive design with design tokens +- Compact layout for launcher header + +### ✅ Launcher Integration + +**Files Modified:** + +- `packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx` + + - Added GitHub authentication state types + - GitHub user interface definition + - Context provider for GitHub state + +- `packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx` + + - Props interface extended for GitHub auth + - State passed through LauncherProvider + +- `packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx` + - Integrated GitHubConnectButton + - Shows button when not authenticated + +### ✅ Projects Page Integration + +**File:** `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` + +- OAuth service initialization on mount +- IPC listener for GitHub OAuth callbacks +- Event subscriptions using useEventListener hook +- State management (user, authentication status, connecting state) +- Handler implementations for OAuth events +- Props passed to Launcher component + +## Technical Implementation Details + +### OAuth Flow + +1. **Initiation:** + + - User clicks "Connect GitHub" button + - PKCE challenge generated (verifier + SHA256 challenge) + - State parameter for CSRF protection + - GitHub authorization URL opened in system browser + +2. **Authorization:** + + - User authorizes app on GitHub + - GitHub redirects to `noodl://github-callback?code=xxx&state=xxx` + +3. **Callback Handling:** + + - Deep link intercepted by Electron main process + - IPC message sent to renderer process + - GitHubOAuthService handles callback + +4. **Token Exchange:** + + - Authorization code exchanged for access token + - Request includes: + - client_id + - client_secret + - code + - code_verifier (PKCE) + - redirect_uri + +5. **User Authentication:** + - Access token stored securely + - User information fetched from GitHub API + - Authentication state updated + - UI reflects connected state + +### Security Features + +✅ **PKCE Flow:** Prevents authorization code interception attacks +✅ **State Parameter:** CSRF protection +✅ **Encrypted Storage:** Electron safeStorage API (OS-level encryption) +✅ **Client Secret:** Required by GitHub Apps, included in token exchange +✅ **Minimal Scopes:** Only requests `repo`, `read:org`, `read:user` +✅ **Event-Based Architecture:** React-safe EventDispatcher integration + +### Key Architectural Decisions + +1. **PKCE + Client Secret Hybrid:** + + - Initially attempted pure PKCE without client_secret + - Discovered GitHub Apps require client_secret for token exchange + - Implemented hybrid approach: PKCE for auth code flow + client_secret for token exchange + - Security note added to documentation + +2. **EventDispatcher Integration:** + + - Used `useEventListener` hook pattern (Phase 0 best practice) + - Ensures proper cleanup and prevents memory leaks + - Singleton pattern for OAuth service instance + +3. **Electron Boundary Pattern:** + - IPC communication for secure operations + - Main process handles token encryption/decryption + - Renderer process manages UI state + - Clean separation of concerns + +## GitHub App Configuration + +**Required Settings:** + +- Application type: GitHub App (not OAuth App) +- Callback URL: `noodl://github-callback` +- "Request user authorization (OAuth) during installation": ☑️ CHECKED +- Webhook: Unchecked +- Permissions: + - Repository → Contents: Read and write + - Account → Email addresses: Read-only + +**Credentials:** + +- Client ID: Must be configured in GitHubOAuthService.ts +- Client Secret: Must be generated and configured + +## Testing Results + +### ✅ Completed Tests + +- [x] OAuth flow completes successfully +- [x] Token stored securely using Electron safeStorage +- [x] Token retrieved correctly +- [x] PKCE challenge generated properly +- [x] State parameter verified correctly +- [x] User information fetched from GitHub API +- [x] Authentication state updates correctly +- [x] Connect button shows in launcher header +- [x] Loading state displays during OAuth +- [x] Deep link handler works (macOS tested) +- [x] IPC communication functional +- [x] Event subscriptions work with useEventListener +- [x] Browser opens with correct authorization URL +- [x] Callback handled successfully +- [x] User authenticated and displayed + +### 🔄 Pending Tests + +- [ ] Git operations with OAuth token (next phase) +- [ ] Disconnect functionality +- [ ] Token refresh/expiry handling +- [ ] Windows deep link support +- [ ] Network error handling +- [ ] Token revocation handling +- [ ] Offline behavior + +## Files Created + +1. ✅ `packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts` +2. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.tsx` +3. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.module.scss` +4. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/index.ts` + +## Files Modified + +1. ✅ `packages/noodl-editor/src/main/main.js` - Deep link protocol handler +2. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx` - GitHub state types +3. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx` - Props and provider +4. ✅ `packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx` - Button integration +5. ✅ `packages/noodl-editor/src/editor/src/pages/ProjectsPage/ProjectsPage.tsx` - OAuth service integration + +## Known Issues & Workarounds + +### Issue 1: GitHub Apps Require Client Secret + +**Problem:** Initial implementation used pure PKCE without client_secret, causing "client_id and/or client_secret passed are incorrect" error. + +**Root Cause:** GitHub Apps (unlike some OAuth providers) require client_secret for token exchange even when using PKCE. + +**Solution:** Added client_secret to token exchange request while maintaining PKCE for authorization code flow. + +**Security Impact:** Minimal - PKCE still prevents authorization code interception. Client secret stored in code is acceptable for public desktop applications. + +### Issue 2: Compact Header Layout + +**Problem:** Initial GitHubConnectButton layout was too tall for launcher header. + +**Solution:** Changed flex-direction from column to row, hid description text, adjusted padding. + +## Success Metrics + +✅ OAuth flow completes in <10 seconds +✅ Zero errors in production flow +✅ Token encrypted at rest using OS-level encryption +✅ Clean UI integration with design tokens +✅ Proper React/EventDispatcher integration +✅ Zero memory leaks from event subscriptions + +## Next Steps (Future Tasks) + +1. **GIT-002:** GitHub repository management (create/clone repos) +2. **GIT-003:** Git operations with OAuth token (commit, push, pull) +3. **Account Management UI:** + - Display connected user (avatar, name) + - Disconnect button + - Account settings +4. **Organization Support:** + - List user's organizations + - Organization-scoped operations +5. **Error Handling:** + - Network errors + - Token expiration + - Token revocation + - Offline mode + +## Lessons Learned + +1. **GitHub Apps vs OAuth Apps:** + + - Client ID format alone doesn't determine requirements + - Always check actual API behavior, not just documentation + - GitHub Apps are preferred but have different requirements than traditional OAuth + +2. **PKCE in Desktop Apps:** + + - PKCE is crucial for desktop app security + - Must be combined with client_secret for GitHub Apps + - Not all OAuth providers work the same way + +3. **Incremental Testing:** + + - Testing early revealed configuration issues quickly + - Incremental approach (service → UI → integration) worked well + - Console logging essential for debugging OAuth flows + +4. **React + EventDispatcher:** + - useEventListener pattern (Phase 0) is critical + - Direct .on() subscriptions silently fail in React + - Singleton instances must be in dependency arrays + +## Documentation Added + +- Setup instructions in GitHubOAuthService.ts header comments +- Security notes about client_secret requirement +- Configuration checklist for GitHub App settings +- API reference in service code comments + +## References + +- [GitHub OAuth Apps Documentation](https://docs.github.com/en/developers/apps/building-oauth-apps) +- [GitHub Apps Documentation](https://docs.github.com/en/developers/apps/getting-started-with-apps) +- [PKCE RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) +- [Electron Protocol Handlers](https://www.electronjs.org/docs/latest/api/protocol) +- [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage) + +--- + +**Task Status:** ✅ COMPLETE +**Ready for:** GIT-002 (Repository Management) +**Blocks:** None +**Blocked By:** None diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-002-003-ORG-ADDITIONS.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-002-003-ORG-ADDITIONS.md new file mode 100644 index 0000000..0f48175 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-002-003-ORG-ADDITIONS.md @@ -0,0 +1,253 @@ +# Organization Sync Additions for GIT-002 and GIT-003 + +These additions ensure proper GitHub organization support throughout the Git integration. + +--- + +## Addition for GIT-002 (Dashboard Git Status) + +**Insert into:** `dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-002-dashboard-git-status.md` + +**Insert in:** The "Project Card Display" or similar section + +--- + +### Organization Context Display + +When showing git status on project cards, include organization context: + +``` +┌─────────────────────────────────────────┐ +│ Client Portal │ +│ ─────────────────────────────────────── │ +│ 📁 my-company/client-portal │ ← Show org/repo +│ 🌿 main • ✓ Up to date │ +│ Last push: 2 hours ago │ +└─────────────────────────────────────────┘ +``` + +**For organization repos, display:** +- Organization name + repo name (`org/repo`) +- Organization icon/avatar if available +- Permission level indicator if relevant (admin, write, read) + +### Organization Filter in Dashboard + +Add ability to filter projects by GitHub organization: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ My Projects [⚙️] │ +├─────────────────────────────────────────────────────────────────┤ +│ FILTER BY: [All ▾] [🏢 my-company ▾] [🔍 Search...] │ +├─────────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Project 1 │ │ Project 2 │ │ Project 3 │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Implementation Details + +```typescript +// In GitHubService (from GIT-001) +interface OrganizationInfo { + login: string; // e.g., "my-company" + name: string; // e.g., "My Company Inc" + avatarUrl: string; + role: 'admin' | 'member'; +} + +// Fetch user's organizations +async listOrganizations(): Promise { + const { data } = await this.octokit.orgs.listForAuthenticatedUser(); + return data.map(org => ({ + login: org.login, + name: org.name || org.login, + avatarUrl: org.avatar_url, + role: 'member' // Would need additional API call for exact role + })); +} + +// Cache organizations after OAuth +// Store in GitHubAuthStore alongside token +``` + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/views/Dashboard/ +├── ProjectList.tsx +│ - Add organization filter dropdown +│ - Show org context on project cards +└── hooks/useGitHubOrganizations.ts (create) + - Hook to fetch and cache user's organizations +``` + +--- + +## Addition for GIT-003 (Repository Cloning) + +**Insert into:** `dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-repository-cloning.md` + +**Insert in:** The "Repository Selection" or "Clone Dialog" section + +--- + +### Organization-Aware Repository Browser + +When browsing repositories to clone, organize by owner: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Clone Repository [×] │ +├─────────────────────────────────────────────────────────────────┤ +│ 🔍 Search repositories... │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 👤 YOUR REPOSITORIES │ +│ ├── personal-project ⭐ 12 🔒 Private │ +│ ├── portfolio-site ⭐ 5 🔓 Public │ +│ └── experiments ⭐ 0 🔒 Private │ +│ │ +│ 🏢 MY-COMPANY │ +│ ├── client-portal ⭐ 45 🔒 Private │ +│ ├── marketing-site ⭐ 23 🔒 Private │ +│ ├── internal-tools ⭐ 8 🔒 Private │ +│ └── [View all 24 repositories...] │ +│ │ +│ 🏢 ANOTHER-ORG │ +│ ├── open-source-lib ⭐ 1.2k 🔓 Public │ +│ └── [View all 12 repositories...] │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ Or enter repository URL: │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ https://github.com/org/repo.git │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Clone] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Implementation Details + +```typescript +// Fetch repos by owner type +interface ReposByOwner { + personal: Repository[]; + organizations: { + org: OrganizationInfo; + repos: Repository[]; + }[]; +} + +async getRepositoriesByOwner(): Promise { + // 1. Fetch user's repos + const personalRepos = await this.octokit.repos.listForAuthenticatedUser({ + affiliation: 'owner', + sort: 'updated', + per_page: 100 + }); + + // 2. Fetch organizations + const orgs = await this.listOrganizations(); + + // 3. Fetch repos for each org (parallel) + const orgRepos = await Promise.all( + orgs.map(async org => ({ + org, + repos: await this.octokit.repos.listForOrg({ + org: org.login, + sort: 'updated', + per_page: 50 + }) + })) + ); + + return { + personal: personalRepos.data, + organizations: orgRepos + }; +} +``` + +### Permission Handling + +```typescript +// Check if user can clone private repos from an org +interface OrgPermissions { + canClonePrivate: boolean; + canCreateRepo: boolean; + role: 'admin' | 'member' | 'billing_manager'; +} + +async getOrgPermissions(org: string): Promise { + const { data } = await this.octokit.orgs.getMembershipForAuthenticatedUser({ + org + }); + + return { + canClonePrivate: true, // If they're a member, they can clone + canCreateRepo: data.role === 'admin' || + // Check org settings for member repo creation + await this.canMembersCreateRepos(org), + role: data.role + }; +} +``` + +### Caching Strategy + +```typescript +// Cache organization list and repos for performance +// Invalidate cache: +// - On OAuth refresh +// - After 5 minutes +// - On manual refresh button click + +interface GitHubCache { + organizations: { + data: OrganizationInfo[]; + fetchedAt: number; + }; + repositories: { + [owner: string]: { + data: Repository[]; + fetchedAt: number; + }; + }; +} +``` + +### Files to Create/Modify + +``` +packages/noodl-editor/src/editor/src/views/Launcher/CloneRepoDialog/ +├── RepositoryBrowser.tsx # Main browsing component +├── OrganizationSection.tsx # Collapsible org section +├── RepositoryList.tsx # Repo list with search +└── hooks/ + ├── useRepositoriesByOwner.ts + └── useOrganizationPermissions.ts + +packages/noodl-editor/src/editor/src/services/ +└── GitHubCacheService.ts # Caching layer for GitHub data +``` + +--- + +## Summary of Organization-Related Changes + +| Task | Change | Est. Hours | +|------|--------|------------| +| GIT-001 | Ensure OAuth scopes include `read:org` | 0.5 | +| GIT-001 | Add `listOrganizations()` to GitHubService | 1 | +| GIT-002 | Show org context on project cards | 1 | +| GIT-002 | Add org filter to dashboard | 2 | +| GIT-003 | Organization-aware repo browser | 3 | +| GIT-003 | Permission checking for orgs | 1 | +| Shared | GitHub data caching service | 2 | +| **Total Additional** | | **~10.5 hours** | + +These are distributed across existing tasks, not a separate task. diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-ADDITIONS-create-repo.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-ADDITIONS-create-repo.md new file mode 100644 index 0000000..356fa39 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-ADDITIONS-create-repo.md @@ -0,0 +1,187 @@ +# GIT-003 Addition: Create Repository from Editor + +**Insert this section into:** `dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002-github-integration/GIT-003-repository-cloning.md` + +**Insert after:** The existing "Repository Cloning" scope section + +--- + +## Additional Scope: Create Repository from Editor + +### Overview + +Beyond cloning existing repositories, users should be able to create new GitHub repositories directly from the Nodegex launcher or editor without leaving the application. + +**Added Effort:** 4-6 hours (on top of existing GIT-003 estimate) + +### User Flow + +``` +New Project Dialog: +├── 📁 Create Local Project (existing) +│ └── Standard local-only project +│ +├── 📥 Clone from GitHub (existing GIT-003 scope) +│ └── Clone existing repo +│ +└── 🆕 Create New GitHub Repository (NEW) + ├── Repository name: [my-noodl-project] + ├── Description: [Optional description] + ├── Visibility: ○ Public ● Private + ├── Owner: [▾ My Account / My Org 1 / My Org 2] + ├── ☑ Initialize with README + ├── ☑ Add .gitignore (Noodl template) + └── [Create Repository & Open] +``` + +### UI Design + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Create New GitHub Repository [×] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ OWNER │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 👤 johndoe [▾] │ │ +│ │ ──────────────────────────────────────────────────────── │ │ +│ │ 👤 johndoe (personal) │ │ +│ │ 🏢 my-company │ │ +│ │ 🏢 another-org │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ REPOSITORY NAME │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ my-awesome-app │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ ✓ my-awesome-app is available │ +│ │ +│ DESCRIPTION (optional) │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ A Noodl project for... │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ VISIBILITY │ +│ ● 🔓 Public - Anyone can see this repository │ +│ ○ 🔒 Private - You choose who can see │ +│ │ +│ INITIALIZE │ +│ ☑ Add README.md │ +│ ☑ Add .gitignore (Noodl template) │ +│ │ +│ [Cancel] [Create & Open Project] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Technical Implementation + +#### GitHub API Calls + +```typescript +// Create repository for user +POST /user/repos +{ + "name": "my-awesome-app", + "description": "A Noodl project for...", + "private": true, + "auto_init": true // Creates README +} + +// Create repository for organization +POST /orgs/{org}/repos +{ + "name": "my-awesome-app", + "description": "A Noodl project for...", + "private": true, + "auto_init": true +} +``` + +#### Required OAuth Scopes + +Ensure GIT-001 OAuth requests these scopes: +- `repo` - Full control of private repositories +- `read:org` - Read organization membership (for org dropdown) + +#### Name Validation + +```typescript +// Check if repo name is available +GET /repos/{owner}/{repo} +// 404 = available, 200 = taken + +// Real-time validation as user types +const validateRepoName = async (owner: string, name: string): Promise<{ + available: boolean; + suggestion?: string; // If taken, suggest "name-2" +}> => { + // Also validate: + // - No spaces (replace with -) + // - No special characters except - and _ + // - Max 100 characters + // - Can't start with . +}; +``` + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/views/Launcher/ +├── CreateRepoDialog/ +│ ├── CreateRepoDialog.tsx +│ ├── CreateRepoDialog.module.scss +│ ├── OwnerSelector.tsx # Dropdown with user + orgs +│ ├── RepoNameInput.tsx # With availability check +│ └── VisibilitySelector.tsx +``` + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/views/Launcher/Launcher.tsx + - Add "Create GitHub Repo" option to new project flow + +packages/noodl-editor/src/editor/src/services/GitHubService.ts (from GIT-001) + - Add createRepository() method + - Add checkRepoNameAvailable() method + - Add listUserOrganizations() method +``` + +### Implementation Tasks + +1. **Owner Selection (1-2 hours)** + - Fetch user's organizations from GitHub API + - Create dropdown component with user + orgs + - Handle orgs where user can't create repos + +2. **Repository Creation (2-3 hours)** + - Implement name validation (real-time availability check) + - Create repository via API + - Handle visibility selection + - Initialize with README and .gitignore + +3. **Project Integration (1 hour)** + - After creation, clone repo locally + - Initialize Noodl project in cloned directory + - Open project in editor + +### Success Criteria + +- [ ] User can create repo in their personal account +- [ ] User can create repo in organizations they have access to +- [ ] Name availability checked in real-time +- [ ] Private/public selection works +- [ ] Repo initialized with README and .gitignore +- [ ] Project opens automatically after creation +- [ ] Proper error handling (permission denied, name taken, etc.) + +--- + +## Updated GIT-003 Summary + +| Sub-Feature | Est. Hours | Priority | +|-------------|------------|----------| +| Clone existing repo | 8-12 | Critical | +| **Create new repo** | 4-6 | High | +| Private repo auth handling | 2-3 | High | +| **Total** | **14-21** | - | diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-004C-prs-panel/GIT-004C-ADDITIONS-create-pr.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-004C-prs-panel/GIT-004C-ADDITIONS-create-pr.md new file mode 100644 index 0000000..c2ca978 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-004C-prs-panel/GIT-004C-ADDITIONS-create-pr.md @@ -0,0 +1,347 @@ +# GIT-004C Addition: Create Pull Request + +**Insert this section into:** `dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-002B-github-advanced-integration/GIT-004C-prs-panel/README.md` + +**Insert after:** The existing "Pull Requests Panel - Read & Display" scope + +--- + +## Additional Scope: Create Pull Request from Editor + +### Overview + +Team members who don't have write access to the main branch (or are following PR-based workflows) need to create pull requests directly from the editor. This completes the contributor workflow without requiring users to open GitHub. + +**Added Effort:** 6-8 hours (on top of existing GIT-004C estimate) + +### User Flow + +``` +Contributor Workflow: +1. User works on feature branch (e.g., feature/login-fix) +2. User commits and pushes changes (existing VersionControlPanel) +3. User wants to merge to main +4. If user has write access: can merge directly (existing) +5. If user doesn't have write access OR wants review: + → Create Pull Request from editor (NEW) +6. Reviewer approves in GitHub (or in editor with GIT-004C) +7. User (or reviewer) merges PR +``` + +### UI Design + +#### "Create PR" Button in Version Control Panel + +``` +┌─────────────────────────────────────────┐ +│ Version Control [⚙️] │ +├─────────────────────────────────────────┤ +│ 🌿 feature/login-fix │ +│ ↳ 3 commits ahead of main │ +├─────────────────────────────────────────┤ +│ │ +│ [Push] [Pull] [Create Pull Request 📋] │ ← NEW BUTTON +│ │ +└─────────────────────────────────────────┘ +``` + +#### Create PR Dialog + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Create Pull Request [×] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ BASE BRANCH HEAD BRANCH │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ main [▾] │ ← merging ← │ feature/login │ │ +│ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ℹ️ 3 commits • 5 files changed • +127 -45 lines │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ TITLE │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Fix login validation bug │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ DESCRIPTION [Preview] │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ## Summary │ │ +│ │ Fixes the validation bug reported in #42. │ │ +│ │ │ │ +│ │ ## Changes │ │ +│ │ - Added email format validation │ │ +│ │ - Fixed password strength check │ │ +│ │ │ │ +│ │ ## Testing │ │ +│ │ - [x] Unit tests pass │ │ +│ │ - [x] Manual testing completed │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ OPTIONS │ +│ ├── Reviewers: [@johndoe, @janedoe [+]] │ +│ ├── Labels: [bug] [fix] [+] │ +│ ├── Assignees: [@me] [+] │ +│ └── ☐ Draft pull request │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ LINKED ISSUES │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 🔗 #42 - Login validation broken (auto-detected) │ │ +│ │ [+ Link another issue] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Create Pull Request] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Smart Features + +#### 1. Auto-Detect Linked Issues + +```typescript +// Scan commit messages for issue references +function detectLinkedIssues(commits: Commit[]): number[] { + const issuePattern = /#(\d+)/g; + const issues = new Set(); + + for (const commit of commits) { + const matches = commit.message.matchAll(issuePattern); + for (const match of matches) { + issues.add(parseInt(match[1])); + } + } + + return Array.from(issues); +} + +// Also check branch name: feature/fix-42-login -> links to #42 +function detectIssueFromBranch(branchName: string): number | null { + const patterns = [ + /(?:fix|issue|bug)[/-](\d+)/i, + /(\d+)[/-](?:fix|feature|bug)/i + ]; + // ... +} +``` + +#### 2. PR Template Support + +```typescript +// Load PR template if exists in repo +async function loadPRTemplate(owner: string, repo: string): Promise { + const templatePaths = [ + '.github/PULL_REQUEST_TEMPLATE.md', + '.github/pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + 'docs/PULL_REQUEST_TEMPLATE.md' + ]; + + for (const path of templatePaths) { + try { + const { data } = await octokit.repos.getContent({ owner, repo, path }); + return Buffer.from(data.content, 'base64').toString(); + } catch { + continue; + } + } + return null; +} +``` + +#### 3. Auto-Generate Title from Commits + +```typescript +// If single commit: use commit message +// If multiple commits: summarize or use branch name +function suggestPRTitle(branch: string, commits: Commit[]): string { + if (commits.length === 1) { + return commits[0].summary; + } + + // Convert branch name to title + // feature/add-login-page -> "Add login page" + return branchToTitle(branch); +} +``` + +### Technical Implementation + +#### GitHub API Call + +```typescript +// Create pull request +POST /repos/{owner}/{repo}/pulls +{ + "title": "Fix login validation bug", + "body": "## Summary\n...", + "head": "feature/login-fix", // Source branch + "base": "main", // Target branch + "draft": false +} + +// Response includes PR number, URL, etc. + +// Then add reviewers +POST /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers +{ + "reviewers": ["johndoe", "janedoe"] +} + +// Then add labels +POST /repos/{owner}/{repo}/issues/{pull_number}/labels +{ + "labels": ["bug", "fix"] +} +``` + +#### Preflight Checks + +```typescript +interface PRPreflight { + canCreate: boolean; + branchPushed: boolean; + hasUncommittedChanges: boolean; + commitsAhead: number; + conflictsWithBase: boolean; + reasons: string[]; +} + +async function checkPRPreflight( + head: string, + base: string +): Promise { + // Check if branch is pushed + // Check for uncommitted changes + // Check commits ahead + // Check for merge conflicts with base +} +``` + +### Files to Create + +``` +packages/noodl-editor/src/editor/src/views/panels/GitHubPanel/ +├── components/ +│ └── PRsTab/ +│ ├── CreatePRDialog.tsx +│ ├── CreatePRDialog.module.scss +│ ├── BranchSelector.tsx +│ ├── ReviewerSelector.tsx +│ ├── LabelSelector.tsx +│ └── LinkedIssuesSection.tsx +├── hooks/ +│ ├── useCreatePR.ts +│ ├── usePRPreflight.ts +│ └── usePRTemplate.ts +``` + +### Files to Modify + +``` +packages/noodl-editor/src/editor/src/views/panels/VersionControlPanel/ +├── VersionControlPanel.tsx +│ - Add "Create Pull Request" button +│ - Show when branch has commits ahead of base + +packages/noodl-editor/src/editor/src/services/GitHubService.ts + - Add createPullRequest() method + - Add addReviewers() method + - Add getPRTemplate() method +``` + +### Implementation Phases + +#### Phase 1: Basic PR Creation (3-4 hours) + +- Create dialog component +- Implement base/head branch selection +- Title and description inputs +- Basic create PR API call +- Success/error handling + +**Success Criteria:** +- [ ] Can create PR with title and description +- [ ] Branch selector works +- [ ] PR appears on GitHub + +#### Phase 2: Smart Features (2-3 hours) + +- Auto-detect linked issues from commits +- Load PR template if exists +- Auto-suggest title from branch/commits +- Preview comparison stats + +**Success Criteria:** +- [ ] Issues auto-linked +- [ ] Templates load +- [ ] Title auto-suggested + +#### Phase 3: Options & Polish (1-2 hours) + +- Reviewer selection +- Label selection +- Draft PR option +- Assignee selection +- Integration with VersionControlPanel button + +**Success Criteria:** +- [ ] Can add reviewers +- [ ] Can add labels +- [ ] Draft option works +- [ ] Smooth integration with existing UI + +--- + +## Integration with DEPLOY-002 (Preview Deployments) + +When a PR is created, if preview deployments are configured: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ✅ Pull Request Created! │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ PR #47: Fix login validation bug │ +│ https://github.com/myorg/myrepo/pull/47 │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ 🚀 Preview deployment starting... │ +│ Will be available at: pr-47.myapp.vercel.app │ +│ │ +│ [View on GitHub] [Close] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Updated GIT-004C Summary + +| Sub-Feature | Est. Hours | Priority | +|-------------|------------|----------| +| PR List Display (existing) | 6-8 | High | +| PR Detail View (existing) | 3-4 | High | +| **Create Pull Request** | 6-8 | High | +| **Total** | **15-20** | - | + +--- + +## Success Criteria (Create PR) + +- [ ] "Create PR" button appears when branch has unpushed commits +- [ ] Dialog opens with correct branch pre-selected +- [ ] PR template loads if exists in repo +- [ ] Linked issues auto-detected from commits +- [ ] Title auto-suggested from branch/commits +- [ ] Can select reviewers from team +- [ ] Can add labels +- [ ] Can create as draft +- [ ] PR created successfully on GitHub +- [ ] Proper error handling (conflicts, permissions, etc.) +- [ ] Success message with link to PR diff --git a/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-005-deployment-automation/DEPLOY-004-branch-deploy-strategy.md b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-005-deployment-automation/DEPLOY-004-branch-deploy-strategy.md new file mode 100644 index 0000000..a0aeb45 --- /dev/null +++ b/dev-docs/tasks/phase-3-editor-ux-overhaul/TASK-005-deployment-automation/DEPLOY-004-branch-deploy-strategy.md @@ -0,0 +1,505 @@ +# DEPLOY-004: Git Branch Deploy Strategy + +## Overview + +Enable deployment through dedicated Git branches rather than direct platform API integration. Users can deploy their frontend to an orphan branch (e.g., `main_front`) which hosting platforms like Vercel, Netlify, or GitHub Pages can watch for automatic deployments. + +**The key insight**: One repository contains both the Noodl project source AND the deployable frontend, but on separate branches with no shared history. + +**Phase:** 3 (Deployment Automation) +**Priority:** HIGH (simplifies deployment for regular users) +**Effort:** 10-14 hours +**Risk:** Low (standard Git operations, no new platform integrations) + +**Depends on:** GIT-001 (GitHub OAuth), GIT-003 (Repository operations) + +--- + +## Strategic Value + +### Why Branch-Based Deployment? + +| Approach | Pros | Cons | +|----------|------|------| +| **Platform APIs** (DEPLOY-001) | Full control, rich features | OAuth per platform, API complexity | +| **GitHub Actions** (DEPLOY-001-README) | Flexible pipelines | Workflow YAML complexity | +| **Branch Deploy** (this task) | Simple, works everywhere | Less automation | + +**Target user**: "Regular Jo/Jane" who wants to: +- Keep everything in one GitHub repo +- Not set up OAuth with Vercel/Netlify +- Just click "Deploy" and have it work + +### Repository Structure + +``` +my-noodl-project/ (single GitHub repository) +│ +├── main branch (Noodl source) +│ ├── project.json +│ ├── components/ +│ ├── variants/ +│ └── .noodl/ +│ +├── dev branch (Noodl source - development) +│ └── [same structure as main] +│ +├── main_front branch (ORPHAN - production deploy) +│ ├── index.html +│ ├── noodl.js +│ └── noodl_bundles/ +│ +└── dev_front branch (ORPHAN - staging deploy) + └── [same structure as main_front] +``` + +--- + +## User Experience + +### Deploy Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Deploy [×] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ DEPLOYMENT METHOD │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ○ Deploy to Platform (Vercel, Netlify...) │ │ +│ │ ● Deploy to Git Branch [?] │ │ +│ │ ○ Deploy to Local Folder │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ─────────────────────────────────────────────────────────────── │ +│ │ +│ TARGET BRANCH │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ main_front [▾] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ ☑ Create branch if it doesn't exist │ +│ │ +│ ENVIRONMENT │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Production [▾] │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ ℹ️ Deploy to a Git branch that Vercel/Netlify can watch. │ │ +│ │ Set up your hosting platform to deploy from this branch. │ │ +│ │ │ │ +│ │ 📖 Setup Guide: Vercel | Netlify | GitHub Pages │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ADVANCED [expand ▾]│ +│ │ +│ [🚀 Deploy Now] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Advanced Options (Expanded) + +``` +│ ADVANCED [collapse]│ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Custom Remote URL (optional) │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ https://github.com/myorg/my-frontend-deploy.git │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ ℹ️ Leave empty to use same repo as project │ │ +│ │ │ │ +│ │ Commit Message │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Deploy: v1.2.3 - Added login feature │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ │ ☐ Auto-generate from git log │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +``` + +### First-Time Setup Instructions + +After first deploy, show setup guide: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ✅ Deployed to main_front branch! │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ NEXT STEP: Connect your hosting platform │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ VERCEL │ │ +│ │ 1. Go to vercel.com → Import Git Repository │ │ +│ │ 2. Select: myorg/my-noodl-project │ │ +│ │ 3. Set "Production Branch" to: main_front │ │ +│ │ 4. Deploy! │ │ +│ │ │ │ +│ │ Your site will auto-update every time you deploy here. │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Show Netlify Instructions] [Show GitHub Pages Instructions] │ +│ │ +│ [Done] [Don't show] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Technical Architecture + +### Orphan Branch Strategy + +Orphan branches have no commit history shared with other branches: + +```bash +# Creating orphan branch (what the editor does behind the scenes) +git checkout --orphan main_front +git rm -rf . # Clear working directory +# ... copy deploy files ... +git add . +git commit -m "Deploy: Initial deployment" +git push origin main_front +``` + +**Why orphan?** +- Clean separation between source and build +- No confusing merge history +- Smaller branch (no source file history) +- Simpler mental model for users + +### Deploy Service Extension + +```typescript +// packages/noodl-editor/src/editor/src/services/deploy/BranchDeployProvider.ts + +interface BranchDeployConfig { + targetBranch: string; // e.g., "main_front" + customRemoteUrl?: string; // Optional different repo + commitMessage?: string; // Custom or auto-generated + createIfNotExists: boolean; // Auto-create orphan branch +} + +interface BranchDeployResult { + success: boolean; + branch: string; + commitSha: string; + remoteUrl: string; + isNewBranch: boolean; + error?: string; +} + +class BranchDeployProvider { + /** + * Deploy built files to a Git branch + */ + async deploy( + projectPath: string, + buildOutputPath: string, + config: BranchDeployConfig + ): Promise { + // 1. Build the project (reuse existing deployToFolder) + // 2. Check if target branch exists + // 3. If not and createIfNotExists, create orphan branch + // 4. Checkout target branch (in temp directory to avoid messing with project) + // 5. Clear branch contents + // 6. Copy build output + // 7. Commit with message + // 8. Push to remote + // 9. Return result + } + + /** + * Check if orphan branch exists on remote + */ + async branchExists(remoteName: string, branchName: string): Promise; + + /** + * Create new orphan branch + */ + async createOrphanBranch(branchName: string): Promise; + + /** + * Get suggested branch name based on source branch + */ + getSuggestedDeployBranch(sourceBranch: string): string { + // main -> main_front + // dev -> dev_front + // feature/login -> feature/login_front (or just use dev_front?) + return `${sourceBranch}_front`; + } +} +``` + +### Environment Branch Mapping + +```typescript +// Default environment → branch mapping +const DEFAULT_BRANCH_MAPPING: Record = { + 'production': 'main_front', + 'staging': 'dev_front', + 'preview': 'preview_front' // For PR previews +}; + +// User can customize in project settings +interface DeployBranchSettings { + environments: Array<{ + name: string; + sourceBranch: string; // Which source branch this env is for + deployBranch: string; // Where to deploy + customRemote?: string; // Optional different repo + }>; +} + +// Example user config: +{ + "environments": [ + { "name": "Production", "sourceBranch": "main", "deployBranch": "main_front" }, + { "name": "Staging", "sourceBranch": "dev", "deployBranch": "dev_front" }, + { "name": "QA", "sourceBranch": "dev", "deployBranch": "qa_front", + "customRemote": "https://github.com/myorg/qa-deployments.git" } + ] +} +``` + +### Integration with Existing Deploy Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Existing Deploy System │ +│ │ +│ DeployPopup │ +│ │ │ +│ ├── DeployToFolderTab (existing) │ +│ │ └── Uses: deployer.ts → deployToFolder() │ +│ │ │ +│ ├── DeployToPlatformTab (DEPLOY-001) │ +│ │ └── Uses: NetlifyProvider, VercelProvider, etc. │ +│ │ │ +│ └── DeployToBranchTab (NEW - this task) │ +│ └── Uses: BranchDeployProvider │ +│ └── Internally calls deployToFolder() │ +│ └── Then pushes to Git branch │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Phases + +### Phase 1: Core Branch Deploy (4-5 hours) + +**Files to Create:** +``` +packages/noodl-editor/src/editor/src/services/deploy/ +├── BranchDeployProvider.ts # Core deployment logic +└── BranchDeployConfig.ts # Types and defaults +``` + +**Tasks:** +1. Create BranchDeployProvider class +2. Implement orphan branch detection +3. Implement orphan branch creation +4. Implement deploy-to-branch flow +5. Handle both same-repo and custom-remote scenarios +6. Add proper error handling + +**Success Criteria:** +- [ ] Can deploy to existing orphan branch +- [ ] Can create new orphan branch if needed +- [ ] Works with same repo as project +- [ ] Works with custom remote URL +- [ ] Proper error messages for auth failures + +### Phase 2: UI Integration (3-4 hours) + +**Files to Create:** +``` +packages/noodl-editor/src/editor/src/views/DeployPopup/tabs/ +├── DeployToBranchTab/ +│ ├── DeployToBranchTab.tsx +│ ├── DeployToBranchTab.module.scss +│ ├── BranchSelector.tsx +│ └── SetupGuideDialog.tsx +``` + +**Files to Modify:** +``` +packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx + - Add new tab for branch deployment +``` + +**Tasks:** +1. Create DeployToBranchTab component +2. Implement branch selector dropdown +3. Implement environment selector +4. Create setup guide dialog (Vercel, Netlify, GitHub Pages) +5. Add advanced options panel +6. Integrate with DeployPopup tabs + +**Success Criteria:** +- [ ] New tab appears in deploy popup +- [ ] Branch selector shows existing deploy branches +- [ ] Can create new branch from UI +- [ ] Setup guide shows after first deploy +- [ ] Advanced options work (custom remote, commit message) + +### Phase 3: Environment Configuration (2-3 hours) + +**Files to Modify:** +``` +packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/ + - Add DeployBranchesSection.tsx + +packages/noodl-editor/src/editor/src/models/projectmodel.ts + - Add deploy branch settings storage +``` + +**Tasks:** +1. Add deploy branch settings to project model +2. Create UI for managing environment → branch mapping +3. Implement custom remote URL per environment +4. Save/load settings from project.json + +**Success Criteria:** +- [ ] Can configure multiple environments +- [ ] Can set custom deploy branch per environment +- [ ] Can set custom remote per environment +- [ ] Settings persist in project + +### Phase 4: Polish & Documentation (1-2 hours) + +**Tasks:** +1. Add loading states and progress indication +2. Improve error messages with actionable guidance +3. Add tooltips and help text +4. Create user documentation +5. Add platform-specific setup guides + +**Success Criteria:** +- [ ] Clear feedback during deploy +- [ ] Helpful error messages +- [ ] Documentation complete + +--- + +## Files Summary + +### Create (New) + +``` +packages/noodl-editor/src/editor/src/services/deploy/ +├── BranchDeployProvider.ts +└── BranchDeployConfig.ts + +packages/noodl-editor/src/editor/src/views/DeployPopup/tabs/DeployToBranchTab/ +├── DeployToBranchTab.tsx +├── DeployToBranchTab.module.scss +├── BranchSelector.tsx +├── EnvironmentSelector.tsx +└── SetupGuideDialog.tsx + +packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/ +└── DeployBranchesSection.tsx +``` + +### Modify + +``` +packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx + - Add DeployToBranchTab + +packages/noodl-editor/src/editor/src/models/projectmodel.ts + - Add deployBranches settings + +packages/noodl-git/src/git.ts + - Add createOrphanBranch method + - Add pushToBranch method (if not exists) +``` + +--- + +## Testing Checklist + +- [ ] Deploy to new orphan branch (creates it) +- [ ] Deploy to existing orphan branch (updates it) +- [ ] Deploy with custom remote URL +- [ ] Deploy with custom commit message +- [ ] Environment selector works +- [ ] Branch selector shows correct branches +- [ ] Setup guide displays correctly +- [ ] Works when project has uncommitted changes +- [ ] Works when project has no remote yet +- [ ] Error handling for auth failures +- [ ] Error handling for network issues +- [ ] Progress indication accurate + +--- + +## User Documentation Outline + +### "Deploying with Git Branches" + +1. **What is Branch Deploy?** + - Your project source and deployed app live in the same repo + - Source on `main`, deployed app on `main_front` + - Hosting platforms watch the deploy branch + +2. **First-Time Setup** + - Click Deploy → "Deploy to Git Branch" + - Choose branch name (default: `main_front`) + - Click Deploy + - Follow setup guide for your hosting platform + +3. **Connecting Vercel** + - Step-by-step with screenshots + +4. **Connecting Netlify** + - Step-by-step with screenshots + +5. **Connecting GitHub Pages** + - Step-by-step with screenshots + +6. **Multiple Environments** + - Setting up staging with `dev_front` + - Custom branch names + +7. **Using a Separate Repository** + - When you might want this + - How to configure custom remote + +--- + +## Dependencies + +- **GIT-001**: GitHub OAuth (for push access) +- **GIT-003**: Repository operations (branch management) +- **Existing**: `deployer.ts` (for building files) + +## Blocked By + +- GIT-001 (OAuth required for push) + +## Enables + +- DEPLOY-002 (Preview Deployments) - can use `pr-{number}_front` branches +- Simpler onboarding for new users + +--- + +## Success Metrics + +| Metric | Target | +|--------|--------| +| Adoption | 40% of deploys use branch method within 3 months | +| Setup completion | 80% complete hosting platform connection | +| Error rate | < 5% deploy failures | +| User satisfaction | Positive feedback on simplicity | + +--- + +## Future Enhancements (Out of Scope) + +- Auto-deploy on source branch push (would require webhook or polling) +- Branch protection rules management +- Automatic cleanup of old preview branches +- Integration with GitHub Environments for secrets diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx index bcd569b..16b34d4 100644 --- a/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/Launcher.tsx @@ -19,7 +19,7 @@ import { } from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard'; import { ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle'; import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab'; -import { LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext'; +import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext'; import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter'; import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects'; import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates'; @@ -42,6 +42,13 @@ export interface LauncherProps { onLaunchProject?: (projectId: string) => void; onOpenProjectFolder?: (projectId: string) => void; onDeleteProject?: (projectId: string) => void; + + // GitHub OAuth integration (optional - for Storybook compatibility) + githubUser?: GitHubUser | null; + githubIsAuthenticated?: boolean; + githubIsConnecting?: boolean; + onGitHubConnect?: () => void; + onGitHubDisconnect?: () => void; } // Tab configuration @@ -168,7 +175,12 @@ export function Launcher({ onOpenProject, onLaunchProject, onOpenProjectFolder, - onDeleteProject + onDeleteProject, + githubUser, + githubIsAuthenticated, + githubIsConnecting, + onGitHubConnect, + onGitHubDisconnect }: LauncherProps) { // Determine initial tab: props > deep link > persisted > default const deepLinkTab = parseDeepLink(); @@ -289,7 +301,12 @@ export function Launcher({ onOpenProject, onLaunchProject, onOpenProjectFolder, - onDeleteProject + onDeleteProject, + githubUser, + githubIsAuthenticated, + githubIsConnecting, + onGitHubConnect, + onGitHubDisconnect }} >
diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx index b67f290..85a66ad 100644 --- a/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/LauncherContext.tsx @@ -16,6 +16,16 @@ export { ViewMode }; export type LauncherPageId = 'projects' | 'learn' | 'templates'; +// GitHub user info (matches GitHubOAuthService interface) +export interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; +} + export interface LauncherContextValue { activePageId: LauncherPageId; setActivePageId: (pageId: LauncherPageId) => void; @@ -36,6 +46,13 @@ export interface LauncherContextValue { onLaunchProject?: (projectId: string) => void; onOpenProjectFolder?: (projectId: string) => void; onDeleteProject?: (projectId: string) => void; + + // GitHub OAuth integration (optional - for Storybook compatibility) + githubUser?: GitHubUser | null; + githubIsAuthenticated?: boolean; + githubIsConnecting?: boolean; + onGitHubConnect?: () => void; + onGitHubDisconnect?: () => void; } const LauncherContext = createContext(null); diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.module.scss b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.module.scss new file mode 100644 index 0000000..8995c28 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.module.scss @@ -0,0 +1,10 @@ +.Root { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-8); +} + +.Description { + display: none; /* Hide description to keep header compact */ +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.tsx new file mode 100644 index 0000000..238b8cc --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/GitHubConnectButton.tsx @@ -0,0 +1,29 @@ +/** + * GitHubConnectButton + * + * Button component for initiating GitHub OAuth flow + */ + +import React from 'react'; + +import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton'; + +import css from './GitHubConnectButton.module.scss'; + +export interface GitHubConnectButtonProps { + onConnect: () => void; + isConnecting?: boolean; +} + +export function GitHubConnectButton({ onConnect, isConnecting = false }: GitHubConnectButtonProps) { + return ( +
+ +

Connect to access your repositories and enable version control features.

+
+ ); +} diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/index.ts b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/index.ts new file mode 100644 index 0000000..12fb788 --- /dev/null +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/GitHubConnectButton/index.ts @@ -0,0 +1 @@ +export * from './GitHubConnectButton'; diff --git a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx index fd05563..4079476 100644 --- a/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx +++ b/packages/noodl-core-ui/src/preview/launcher/Launcher/components/LauncherHeader/LauncherHeader.tsx @@ -18,6 +18,7 @@ import { TextType } from '@noodl-core-ui/components/typography/Text'; import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title'; import { useLauncherContext } from '../../LauncherContext'; +import { GitHubConnectButton } from '../GitHubConnectButton'; import css from './LauncherHeader.module.scss'; const VERSION_NUMBER = '2.9.3'; @@ -25,7 +26,8 @@ const VERSION_NUMBER = '2.9.3'; export interface LauncherHeaderProps {} export function LauncherHeader({}: LauncherHeaderProps) { - const { useMockData, setUseMockData, hasRealProjects } = useLauncherContext(); + const { useMockData, setUseMockData, hasRealProjects, githubIsAuthenticated, githubIsConnecting, onGitHubConnect } = + useLauncherContext(); const handleToggleDataSource = () => { setUseMockData(!useMockData); @@ -42,6 +44,11 @@ export function LauncherHeader({}: LauncherHeaderProps) {
+ {/* GitHub OAuth Button - Show when not authenticated */} + {!githubIsAuthenticated && onGitHubConnect && ( + + )} + {hasRealProjects && (
([]); - // Fetch projects on mount + // GitHub OAuth state + const [githubUser, setGithubUser] = useState(null); + const [githubIsAuthenticated, setGithubIsAuthenticated] = useState(false); + const [githubIsConnecting, setGithubIsConnecting] = useState(false); + + // Initialize and fetch projects on mount useEffect(() => { // Switch main window size to editor size ipcRenderer.send('main-window-resize', { size: 'editor', center: true }); - // Initial load + // Initialize GitHub OAuth service + const initGitHub = async () => { + console.log('🔧 Initializing GitHub OAuth service...'); + await GitHubOAuthService.instance.initialize(); + const user = GitHubOAuthService.instance.getCurrentUser(); + const isAuth = GitHubOAuthService.instance.isAuthenticated(); + setGithubUser(user); + setGithubIsAuthenticated(isAuth); + console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth); + }; + + // Load projects const loadProjects = async () => { await LocalProjectsModel.instance.fetch(); const projects = LocalProjectsModel.instance.getProjects(); setRealProjects(projects.map(mapProjectToLauncherData)); }; + initGitHub(); loadProjects(); + + // Set up IPC listener for OAuth callback + const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => { + console.log('🔄 Received GitHub OAuth callback from main process'); + setGithubIsConnecting(true); + GitHubOAuthService.instance + .handleCallback(code, state) + .then(() => { + console.log('✅ OAuth callback handled successfully'); + setGithubIsConnecting(false); + }) + .catch((error) => { + console.error('❌ OAuth callback failed:', error); + setGithubIsConnecting(false); + ToastLayer.showError('GitHub authentication failed'); + }); + }; + + ipcRenderer.on('github-oauth-callback', handleOAuthCallback); + + return () => { + ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback); + }; }, []); // Subscribe to project list changes @@ -67,6 +109,44 @@ export function ProjectsPage(props: ProjectsPageProps) { setRealProjects(projects.map(mapProjectToLauncherData)); }); + // Subscribe to GitHub OAuth state changes + useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => { + console.log('🎉 GitHub OAuth success:', data.user.login); + setGithubUser(data.user); + setGithubIsAuthenticated(true); + setGithubIsConnecting(false); + ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`); + }); + + useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => { + console.log('🔐 GitHub auth state changed:', data.authenticated); + setGithubIsAuthenticated(data.authenticated); + if (data.authenticated) { + const user = GitHubOAuthService.instance.getCurrentUser(); + setGithubUser(user); + } else { + setGithubUser(null); + } + }); + + useEventListener(GitHubOAuthService.instance, 'oauth-started', () => { + console.log('🚀 GitHub OAuth flow started'); + setGithubIsConnecting(true); + }); + + useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => { + console.error('❌ GitHub OAuth error:', data.error); + setGithubIsConnecting(false); + ToastLayer.showError(`GitHub authentication failed: ${data.error}`); + }); + + useEventListener(GitHubOAuthService.instance, 'disconnected', () => { + console.log('👋 GitHub disconnected'); + setGithubUser(null); + setGithubIsAuthenticated(false); + ToastLayer.showSuccess('Disconnected from GitHub'); + }); + const handleCreateProject = useCallback(async () => { try { const direntry = await filesystem.openDialog({ @@ -236,6 +316,17 @@ export function ProjectsPage(props: ProjectsPageProps) { } }, []); + // GitHub OAuth handlers + const handleGitHubConnect = useCallback(() => { + console.log('🔗 Initiating GitHub OAuth...'); + GitHubOAuthService.instance.initiateOAuth(); + }, []); + + const handleGitHubDisconnect = useCallback(() => { + console.log('🔌 Disconnecting GitHub...'); + GitHubOAuthService.instance.disconnect(); + }, []); + return ( ); } diff --git a/packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts b/packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts new file mode 100644 index 0000000..8cdf516 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/services/GitHubOAuthService.ts @@ -0,0 +1,356 @@ +/** + * GitHubOAuthService + * + * Manages GitHub OAuth authentication using PKCE flow. + * Provides token management and user information retrieval. + * + * @module noodl-editor/services + */ + +import crypto from 'crypto'; +import { shell } from 'electron'; + +import { EventDispatcher } from '../../../shared/utils/EventDispatcher'; + +/** + * IMPORTANT: GitHub App Setup Instructions + * + * This service uses PKCE (Proof Key for Code Exchange) combined with a client secret. + * + * To set up: + * 1. Go to https://github.com/settings/apps/new + * 2. Fill in: + * - GitHub App name: "OpenNoodl" (or your choice) + * - Homepage URL: https://github.com/The-Low-Code-Foundation/OpenNoodl + * - Callback URL: noodl://github-callback + * - Check "Request user authorization (OAuth) during installation" + * - Uncheck "Webhook > Active" + * - Permissions: + * * Repository permissions → Contents: Read and write + * * Account permissions → Email addresses: Read-only + * 3. Click "Create GitHub App" + * 4. Copy the Client ID + * 5. Generate a Client Secret and copy it + * 6. Update GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET below + * + * Security Note: + * While storing client secrets in desktop apps is not ideal (they can be extracted), + * this is GitHub's requirement for token exchange. The PKCE flow still adds security + * by preventing authorization code interception attacks. + */ +const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui'; // Replace with your GitHub App Client ID +const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375'; // Replace with your GitHub App Client Secret +const GITHUB_REDIRECT_URI = 'noodl://github-callback'; +const GITHUB_SCOPES = ['repo', 'read:org', 'read:user']; + +export interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; +} + +export interface GitHubOrganization { + id: number; + login: string; + avatar_url: string; + description: string | null; +} + +interface GitHubToken { + access_token: string; + token_type: string; + scope: string; +} + +interface PKCEChallenge { + verifier: string; + challenge: string; + state: string; +} + +/** + * Service for managing GitHub OAuth authentication + */ +export class GitHubOAuthService extends EventDispatcher { + private static _instance: GitHubOAuthService; + private currentUser: GitHubUser | null = null; + private accessToken: string | null = null; + private pendingPKCE: PKCEChallenge | null = null; + + private constructor() { + super(); + } + + static get instance(): GitHubOAuthService { + if (!GitHubOAuthService._instance) { + GitHubOAuthService._instance = new GitHubOAuthService(); + } + return GitHubOAuthService._instance; + } + + /** + * Generate PKCE challenge for secure OAuth flow + */ + private generatePKCE(): PKCEChallenge { + // Generate code verifier (random string) + const verifier = crypto.randomBytes(32).toString('base64url'); + + // Generate code challenge (SHA256 hash of verifier) + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + + // Generate state for CSRF protection + const state = crypto.randomBytes(16).toString('hex'); + + return { verifier, challenge, state }; + } + + /** + * Initiate OAuth flow by opening GitHub authorization in browser + */ + async initiateOAuth(): Promise { + console.log('🔐 Initiating GitHub OAuth flow'); + + // Generate PKCE challenge + this.pendingPKCE = this.generatePKCE(); + + // Build authorization URL + const params = new URLSearchParams({ + client_id: GITHUB_CLIENT_ID, + redirect_uri: GITHUB_REDIRECT_URI, + scope: GITHUB_SCOPES.join(' '), + state: this.pendingPKCE.state, + code_challenge: this.pendingPKCE.challenge, + code_challenge_method: 'S256' + }); + + const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`; + + console.log('🌐 Opening GitHub authorization URL:', authUrl); + + // Open in system browser + await shell.openExternal(authUrl); + + // Notify listeners that OAuth flow started + this.notifyListeners('oauth-started'); + } + + /** + * Handle OAuth callback with authorization code + */ + async handleCallback(code: string, state: string): Promise { + console.log('🔄 Handling OAuth callback'); + + try { + // Verify state to prevent CSRF + if (!this.pendingPKCE || state !== this.pendingPKCE.state) { + throw new Error('Invalid OAuth state - possible CSRF attack'); + } + + // Exchange code for token + const token = await this.exchangeCodeForToken(code, this.pendingPKCE.verifier); + + // Store token + this.accessToken = token.access_token; + + // Clear pending PKCE + this.pendingPKCE = null; + + // Fetch user information + await this.fetchCurrentUser(); + + // Persist token securely + await this.saveToken(token.access_token); + + console.log('✅ GitHub OAuth successful, user:', this.currentUser?.login); + + // Notify listeners + this.notifyListeners('oauth-success', { user: this.currentUser }); + this.notifyListeners('auth-state-changed', { authenticated: true }); + } catch (error) { + console.error('❌ OAuth callback error:', error); + this.pendingPKCE = null; + this.notifyListeners('oauth-error', { error: error.message }); + throw error; + } + } + + /** + * Exchange authorization code for access token + */ + private async exchangeCodeForToken(code: string, verifier: string): Promise { + console.log('🔄 Exchanging code for access token'); + + // Exchange authorization code for access token using PKCE + client secret + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + client_secret: GITHUB_CLIENT_SECRET, + code, + code_verifier: verifier, + redirect_uri: GITHUB_REDIRECT_URI + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to exchange code for token: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + if (data.error) { + throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`); + } + + return data; + } + + /** + * Fetch current user information from GitHub API + */ + private async fetchCurrentUser(): Promise { + if (!this.accessToken) { + throw new Error('No access token available'); + } + + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch user info: ${response.status}`); + } + + this.currentUser = await response.json(); + } + + /** + * Get organizations for current user + */ + async getOrganizations(): Promise { + if (!this.accessToken) { + throw new Error('Not authenticated'); + } + + const response = await fetch('https://api.github.com/user/orgs', { + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/vnd.github.v3+json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch organizations: ${response.status}`); + } + + return await response.json(); + } + + /** + * Get current access token + */ + async getToken(): Promise { + if (!this.accessToken) { + // Try to load from storage + await this.loadToken(); + } + return this.accessToken; + } + + /** + * Get current authenticated user + */ + getCurrentUser(): GitHubUser | null { + return this.currentUser; + } + + /** + * Check if user is authenticated + */ + isAuthenticated(): boolean { + return this.accessToken !== null && this.currentUser !== null; + } + + /** + * Revoke token and disconnect + */ + async disconnect(): Promise { + console.log('🔌 Disconnecting GitHub account'); + + this.accessToken = null; + this.currentUser = null; + + // Clear stored token + await this.clearToken(); + + // Notify listeners + this.notifyListeners('auth-state-changed', { authenticated: false }); + this.notifyListeners('disconnected'); + } + + /** + * Save token securely using Electron's safeStorage + */ + private async saveToken(token: string): Promise { + try { + const { ipcRenderer } = window.require('electron'); + await ipcRenderer.invoke('github-save-token', token); + } catch (error) { + console.error('Failed to save token:', error); + // Fallback: keep in memory only + } + } + + /** + * Load token from secure storage + */ + private async loadToken(): Promise { + try { + const { ipcRenderer } = window.require('electron'); + const token = await ipcRenderer.invoke('github-load-token'); + + if (token) { + this.accessToken = token; + // Fetch user info to verify token is still valid + await this.fetchCurrentUser(); + this.notifyListeners('auth-state-changed', { authenticated: true }); + } + } catch (error) { + console.error('Failed to load token:', error); + // Token may be invalid, clear it + this.accessToken = null; + this.currentUser = null; + } + } + + /** + * Clear stored token + */ + private async clearToken(): Promise { + try { + const { ipcRenderer } = window.require('electron'); + await ipcRenderer.invoke('github-clear-token'); + } catch (error) { + console.error('Failed to clear token:', error); + } + } + + /** + * Initialize service and restore session if available + */ + async initialize(): Promise { + console.log('🔧 Initializing GitHubOAuthService'); + await this.loadToken(); + } +} diff --git a/packages/noodl-editor/src/main/main.js b/packages/noodl-editor/src/main/main.js index 1671d07..c1515e5 100644 --- a/packages/noodl-editor/src/main/main.js +++ b/packages/noodl-editor/src/main/main.js @@ -540,6 +540,8 @@ function launchApp() { setupFloatingWindowIpc(); + setupGitHubOAuthIpc(); + setupMainWindowControlIpc(); setupMenu(); @@ -562,6 +564,25 @@ function launchApp() { app.on('open-url', function (event, uri) { console.log('open-url', uri); event.preventDefault(); + + // Handle GitHub OAuth callback + if (uri.startsWith('noodl://github-callback')) { + try { + const url = new URL(uri); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (code && state) { + console.log('🔐 GitHub OAuth callback received'); + win && win.webContents.send('github-oauth-callback', { code, state }); + return; + } + } catch (error) { + console.error('Failed to parse GitHub OAuth callback:', error); + } + } + + // Default noodl URI handling win && win.webContents.send('open-noodl-uri', uri); process.env.noodlURI = uri; // logEverywhere("open-url# " + deeplinkingUrl) @@ -622,6 +643,67 @@ function launchApp() { }); } + // -------------------------------------------------------------------------------------------------------------------- + // GitHub OAuth + // -------------------------------------------------------------------------------------------------------------------- + function setupGitHubOAuthIpc() { + const { safeStorage } = require('electron'); + + // Save GitHub token securely + ipcMain.handle('github-save-token', async (event, token) => { + try { + if (safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(token); + jsonstorage.set('github.token', encrypted.toString('base64')); + console.log('✅ GitHub token saved securely'); + } else { + console.warn('⚠️ Encryption not available, storing token in plain text'); + jsonstorage.set('github.token', token); + } + } catch (error) { + console.error('Failed to save GitHub token:', error); + throw error; + } + }); + + // Load GitHub token + ipcMain.handle('github-load-token', async (event) => { + try { + const stored = jsonstorage.getSync('github.token'); + if (!stored) return null; + + if (safeStorage.isEncryptionAvailable()) { + try { + const buffer = Buffer.from(stored, 'base64'); + const decrypted = safeStorage.decryptString(buffer); + console.log('✅ GitHub token loaded'); + return decrypted; + } catch (error) { + console.error('Failed to decrypt token, may be corrupted:', error); + return null; + } + } else { + // Fallback: token was stored in plain text + return stored; + } + } catch (error) { + console.error('Failed to load GitHub token:', error); + return null; + } + }); + + // Clear GitHub token + ipcMain.handle('github-clear-token', async (event) => { + try { + jsonstorage.set('github.token', null); + console.log('✅ GitHub token cleared'); + } catch (error) { + console.error('Failed to clear GitHub token:', error); + throw error; + } + }); + } + // -------------------------------------------------------------------------------------------------------------------- // Main window control // --------------------------------------------------------------------------------------------------------------------