mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Finished inital project migration workflow
This commit is contained in:
@@ -0,0 +1,911 @@
|
||||
# Task: React 19 Node Modernization
|
||||
|
||||
## Overview
|
||||
|
||||
Update all frontend visual nodes in `noodl-viewer-react` to take advantage of React 19 features, remove deprecated patterns, and prepare the infrastructure for future React 19-only features like View Transitions.
|
||||
|
||||
**Priority:** High
|
||||
**Estimated Effort:** 16-24 hours
|
||||
**Branch:** `feature/react19-node-modernization`
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
With the editor upgraded to React 19 and the runtime to React 18.3 (95% compatible), we have an opportunity to modernize our node infrastructure. This work removes technical debt, simplifies code, and prepares the foundation for React 19-exclusive features.
|
||||
|
||||
### React 19 Changes That Affect Nodes
|
||||
|
||||
1. **`ref` as a regular prop** - No more `forwardRef` wrapper needed
|
||||
2. **Improved `useTransition`** - Can now handle async functions
|
||||
3. **`useDeferredValue` with initial value** - New parameter for better loading states
|
||||
4. **Native document metadata** - `<title>`, `<meta>` render directly
|
||||
5. **Better Suspense** - Works with more scenarios
|
||||
6. **`use()` hook** - Read resources in render (promises, context)
|
||||
7. **Form actions** - `useActionState`, `useFormStatus`, `useOptimistic`
|
||||
8. **Cleaner cleanup** - Ref cleanup functions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure Updates
|
||||
|
||||
### 1.1 Update `createNodeFromReactComponent` Wrapper
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/react-component-node.js` (or `.ts`)
|
||||
|
||||
**Changes:**
|
||||
- Remove automatic `forwardRef` wrapping logic
|
||||
- Add support for `ref` as a standard prop
|
||||
- Add optional `useTransition` integration for state updates
|
||||
- Add optional `useDeferredValue` wrapper for specified inputs
|
||||
|
||||
**New Options:**
|
||||
```javascript
|
||||
createNodeFromReactComponent({
|
||||
// ... existing options
|
||||
|
||||
// NEW: React 19 options
|
||||
react19: {
|
||||
// Enable transition wrapping for specified inputs
|
||||
transitionInputs: ['items', 'filter'],
|
||||
|
||||
// Enable deferred value for specified inputs
|
||||
deferredInputs: ['searchQuery'],
|
||||
|
||||
// Enable form action support
|
||||
formActions: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 1.2 Update Base Node Classes
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/visual-base.js`
|
||||
- Any shared base classes for visual nodes
|
||||
|
||||
**Changes:**
|
||||
- Remove `forwardRef` patterns
|
||||
- Update ref handling to use callback ref pattern
|
||||
- Add utility methods for transitions:
|
||||
- `this.startTransition(callback)` - wrap updates in transition
|
||||
- `this.getDeferredValue(inputName)` - get deferred version of input
|
||||
|
||||
### 1.3 Update TypeScript Definitions
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/static/viewer/global.d.ts.keep`
|
||||
- Any relevant `.d.ts` files
|
||||
|
||||
**Changes:**
|
||||
- Update component prop types to include `ref` as regular prop
|
||||
- Add types for new React 19 hooks
|
||||
- Update `Noodl` namespace types if needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Visual Nodes
|
||||
|
||||
### 2.1 Group Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/group.js`
|
||||
|
||||
**Current Issues:**
|
||||
- Likely uses `forwardRef` or class component with ref forwarding
|
||||
- May have legacy lifecycle patterns
|
||||
|
||||
**Updates:**
|
||||
- Convert to functional component with `ref` as prop
|
||||
- Use `useEffect` cleanup returns properly
|
||||
- Add optional `useDeferredValue` for children rendering (large lists)
|
||||
|
||||
**New Capabilities:**
|
||||
- `Defer Children` input (boolean) - uses `useDeferredValue` for smoother updates
|
||||
- `Is Updating` output - true when deferred update pending
|
||||
|
||||
### 2.2 Text Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/text.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Simplify ref handling
|
||||
|
||||
### 2.3 Image Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/image.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add resource preloading hints for React 19's `preload()` API (future enhancement slot)
|
||||
|
||||
### 2.4 Video Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/video.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Ensure ref cleanup is proper
|
||||
|
||||
### 2.5 Circle Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/circle.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
|
||||
### 2.6 Icon Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/icon.js` (or `net.noodl.visual.icon`)
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI Control Nodes
|
||||
|
||||
### 3.1 Button Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/button.js` (or `net.noodl.controls.button`)
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add form action support preparation:
|
||||
- `formAction` input (string) - for future form integration
|
||||
- `Is Pending` output - when used in form with pending action
|
||||
|
||||
### 3.2 Text Input Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/textinput.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Consider `useDeferredValue` for `onChange` value updates
|
||||
- Add form integration preparation
|
||||
|
||||
**New Capabilities (Optional):**
|
||||
- `Defer Updates` input - delays `Value` output updates for performance
|
||||
- `Immediate Value` output - non-deferred value for UI feedback
|
||||
|
||||
### 3.3 Checkbox Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/checkbox.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Add optimistic update preparation (`useOptimistic` slot)
|
||||
|
||||
### 3.4 Radio Button / Radio Button Group
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/radiobutton.js`
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/radiobuttongroup.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrappers
|
||||
- Ensure proper group state management
|
||||
|
||||
### 3.5 Options/Dropdown Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/options.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Consider `useDeferredValue` for large option lists
|
||||
|
||||
### 3.6 Range/Slider Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/range.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- `useDeferredValue` for value output (prevent render thrashing during drag)
|
||||
|
||||
**New Capabilities:**
|
||||
- `Deferred Value` output - smoothed value for expensive downstream renders
|
||||
- `Immediate Value` output - raw value for UI display
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Navigation Nodes
|
||||
|
||||
### 4.1 Page Router / Router Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/router.js`
|
||||
|
||||
**Updates:**
|
||||
- Add `useTransition` wrapping for navigation
|
||||
- Prepare for View Transitions API integration
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Transitioning` output - true during page transition
|
||||
- `Use Transition` input (boolean, default true) - wrap navigation in React transition
|
||||
|
||||
### 4.2 Router Navigate Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/routernavigate.js`
|
||||
|
||||
**Updates:**
|
||||
- Wrap navigation in `startTransition`
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Pending` output - navigation in progress
|
||||
- `Transition Priority` input (enum: 'normal', 'urgent') - for future prioritization
|
||||
|
||||
### 4.3 Page Stack / Component Stack
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pagestack.js`
|
||||
|
||||
**Updates:**
|
||||
- Add `useTransition` for push/pop operations
|
||||
|
||||
**New Capabilities:**
|
||||
- `Is Transitioning` output
|
||||
- Prepare for animation coordination with View Transitions
|
||||
|
||||
### 4.4 Page Inputs Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/pageinputs.js`
|
||||
|
||||
**Updates:**
|
||||
- Standard cleanup, ensure no deprecated patterns
|
||||
|
||||
### 4.5 Popup Nodes
|
||||
|
||||
**Files:**
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/showpopup.js`
|
||||
- `packages/noodl-viewer-react/src/nodes/std-library/closepopup.js`
|
||||
|
||||
**Updates:**
|
||||
- Consider `useTransition` for popup show/hide
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Layout Nodes
|
||||
|
||||
### 5.1 Columns Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/columns.js`
|
||||
|
||||
**Updates:**
|
||||
- Remove `forwardRef` wrapper
|
||||
- Remove `React.cloneElement` if present (React 19 has better patterns)
|
||||
- Consider using CSS Grid native features
|
||||
|
||||
### 5.2 Repeater (For Each) Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/foreach.js`
|
||||
|
||||
**Critical Updates:**
|
||||
- Add `useDeferredValue` for items array
|
||||
- Add `useTransition` for item updates
|
||||
|
||||
**New Capabilities:**
|
||||
- `Defer Updates` input (boolean) - uses deferred value for items
|
||||
- `Is Updating` output - true when deferred update pending
|
||||
- `Transition Updates` input (boolean) - wrap updates in transition
|
||||
|
||||
**Why This Matters:**
|
||||
Large list updates currently cause jank. With these options:
|
||||
- User toggles `Defer Updates` → list updates don't block UI
|
||||
- `Is Updating` output → can show loading indicator
|
||||
|
||||
### 5.3 Component Children Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentchildren.js`
|
||||
|
||||
**Updates:**
|
||||
- Standard cleanup
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Data/Object Nodes
|
||||
|
||||
### 6.1 Component Object Node
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/componentobject.js`
|
||||
|
||||
**Updates:**
|
||||
- Consider context-based implementation for React 19
|
||||
- `use(Context)` can now be called conditionally in React 19
|
||||
|
||||
### 6.2 Parent Component Object Node
|
||||
|
||||
**File:** Similar location
|
||||
|
||||
**Updates:**
|
||||
- Same as Component Object
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: SEO/Document Nodes (New Capability)
|
||||
|
||||
### 7.1 Update Page Node for Document Metadata
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/std-library/page.js`
|
||||
|
||||
**New Capabilities:**
|
||||
React 19 allows rendering `<title>`, `<meta>`, `<link>` directly in components and they hoist to `<head>`.
|
||||
|
||||
**New Inputs:**
|
||||
- `Page Title` - renders `<title>` (already exists, but implementation changes)
|
||||
- `Meta Description` - renders `<meta name="description">`
|
||||
- `Meta Keywords` - renders `<meta name="keywords">`
|
||||
- `Canonical URL` - renders `<link rel="canonical">`
|
||||
- `OG Title` - renders `<meta property="og:title">`
|
||||
- `OG Description` - renders `<meta property="og:description">`
|
||||
- `OG Image` - renders `<meta property="og:image">`
|
||||
|
||||
**Implementation:**
|
||||
```jsx
|
||||
function PageComponent({ title, description, ogTitle, ...props }) {
|
||||
return (
|
||||
<>
|
||||
{title && <title>{title}</title>}
|
||||
{description && <meta name="description" content={description} />}
|
||||
{ogTitle && <meta property="og:title" content={ogTitle} />}
|
||||
{/* ... rest of component */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the hacky SSR string replacement currently in `packages/noodl-viewer-react/static/ssr/index.js`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Testing & Validation
|
||||
|
||||
### 8.1 Unit Tests
|
||||
|
||||
**Update/Create Tests For:**
|
||||
- `createNodeFromReactComponent` with new options
|
||||
- Each updated node renders correctly
|
||||
- Ref forwarding works without `forwardRef`
|
||||
- Deferred values update correctly
|
||||
- Transitions wrap updates properly
|
||||
|
||||
### 8.2 Integration Tests
|
||||
|
||||
- Page navigation with transitions
|
||||
- Repeater with large datasets
|
||||
- Form interactions with new patterns
|
||||
|
||||
### 8.3 Visual Regression Tests
|
||||
|
||||
- Ensure no visual changes from modernization
|
||||
- Test all visual states (hover, pressed, disabled)
|
||||
- Test variants still work
|
||||
|
||||
### 8.4 Performance Benchmarks
|
||||
|
||||
**Before/After Metrics:**
|
||||
- Repeater with 1000 items - render time
|
||||
- Page navigation - transition smoothness
|
||||
- Text input rapid typing - lag measurement
|
||||
|
||||
---
|
||||
|
||||
## File List Summary
|
||||
|
||||
### Infrastructure Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/
|
||||
├── react-component-node.js # Main wrapper factory
|
||||
├── nodes/std-library/
|
||||
│ └── visual-base.js # Base class for visual nodes
|
||||
```
|
||||
|
||||
### Visual Element Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── group.js
|
||||
├── text.js
|
||||
├── image.js
|
||||
├── video.js
|
||||
├── circle.js
|
||||
├── icon.js (or net.noodl.visual.icon)
|
||||
```
|
||||
|
||||
### UI Control Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── button.js (or net.noodl.controls.button)
|
||||
├── textinput.js
|
||||
├── checkbox.js
|
||||
├── radiobutton.js
|
||||
├── radiobuttongroup.js
|
||||
├── options.js
|
||||
├── range.js
|
||||
```
|
||||
|
||||
### Navigation Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── router.js
|
||||
├── routernavigate.js
|
||||
├── pagestack.js
|
||||
├── pageinputs.js
|
||||
├── showpopup.js
|
||||
├── closepopup.js
|
||||
```
|
||||
|
||||
### Layout Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── columns.js
|
||||
├── foreach.js
|
||||
├── componentchildren.js
|
||||
```
|
||||
|
||||
### Data Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── componentobject.js
|
||||
├── parentcomponentobject.js
|
||||
```
|
||||
|
||||
### Page/SEO Nodes
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/std-library/
|
||||
├── page.js
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
```
|
||||
packages/noodl-viewer-react/static/viewer/
|
||||
├── global.d.ts.keep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Week 1: Foundation
|
||||
1. Update `createNodeFromReactComponent` infrastructure
|
||||
2. Update base classes
|
||||
3. Update Group node (most used, good test case)
|
||||
4. Update Text node
|
||||
5. Create test suite for modernized patterns
|
||||
|
||||
### Week 2: Controls & Navigation
|
||||
6. Update all UI Control nodes (Button, TextInput, etc.)
|
||||
7. Update Navigation nodes with transition support
|
||||
8. Update Repeater with deferred value support
|
||||
9. Test navigation flow end-to-end
|
||||
|
||||
### Week 3: Polish & New Features
|
||||
10. Update remaining nodes (Columns, Component Object, etc.)
|
||||
11. Add Page metadata support
|
||||
12. Performance testing and optimization
|
||||
13. Documentation updates
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Must Have
|
||||
- [ ] All nodes render correctly after updates
|
||||
- [ ] No `forwardRef` usage in visual nodes
|
||||
- [ ] All refs work correctly (DOM access, focus, etc.)
|
||||
- [ ] No breaking changes to existing projects
|
||||
- [ ] Tests pass
|
||||
|
||||
### Should Have
|
||||
- [ ] Repeater has `Defer Updates` option
|
||||
- [ ] Page Router has `Is Transitioning` output
|
||||
- [ ] Page node has SEO metadata inputs
|
||||
|
||||
### Nice to Have
|
||||
- [ ] Performance improvement measurable in benchmarks
|
||||
- [ ] Text Input deferred value option
|
||||
- [ ] Range slider deferred value option
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
These changes should be **fully backward compatible**:
|
||||
- Existing projects continue to work unchanged
|
||||
- New features are opt-in via new inputs
|
||||
- No changes to how nodes are wired together
|
||||
|
||||
### Runtime Considerations
|
||||
|
||||
Since runtime is React 18.3:
|
||||
- `useTransition` works (available since React 18)
|
||||
- `useDeferredValue` works (available since React 18)
|
||||
- `ref` as prop works (React 18.3 forward-ported this)
|
||||
- Native metadata hoisting does NOT work (React 19 only)
|
||||
- For runtime, metadata nodes will need polyfill/fallback
|
||||
|
||||
**Strategy:** Build features for React 19 editor, provide graceful degradation for React 18.3 runtime. Eventually upgrade runtime to React 19.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Before: forwardRef Pattern
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return React.forwardRef((props, ref) => {
|
||||
return <div ref={ref} style={props.style}>{props.children}</div>;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### After: ref as Prop Pattern
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function GroupComponent({ ref, style, children }) {
|
||||
return <div ref={ref} style={style}>{children}</div>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Deferred Value Support
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function RepeaterComponent({ items, deferUpdates, onIsUpdating }) {
|
||||
const deferredItems = React.useDeferredValue(items);
|
||||
const isStale = items !== deferredItems;
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsUpdating?.(isStale);
|
||||
}, [isStale, onIsUpdating]);
|
||||
|
||||
const itemsToRender = deferUpdates ? deferredItems : items;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{itemsToRender.map(item => /* render item */)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Transition Support
|
||||
```javascript
|
||||
getReactComponent() {
|
||||
return function RouterComponent({ onNavigate, onIsTransitioning }) {
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsTransitioning?.(isPending);
|
||||
}, [isPending, onIsTransitioning]);
|
||||
|
||||
const handleNavigate = (target) => {
|
||||
startTransition(() => {
|
||||
onNavigate(target);
|
||||
});
|
||||
};
|
||||
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions for Implementation
|
||||
|
||||
1. **File locations:** Need to verify actual file paths in `noodl-viewer-react` - the paths above are educated guesses based on patterns.
|
||||
|
||||
2. **Runtime compatibility:** Should we add feature detection to gracefully degrade on React 18.3 runtime, or assume eventual runtime upgrade?
|
||||
|
||||
3. **New inputs/outputs:** Should new capabilities (like `Defer Updates`) be hidden by default and exposed via a "React 19 Features" toggle in project settings?
|
||||
|
||||
4. **Breaking changes policy:** If we find any patterns that would break (unlikely), what's the policy? Migration path vs versioning?
|
||||
|
||||
---
|
||||
|
||||
## Related Future Work
|
||||
|
||||
This modernization enables but does not include:
|
||||
- **Magic Transition Node** - View Transitions API wrapper
|
||||
- **AI Component Node** - Generative UI with streaming
|
||||
- **Async Boundary Node** - Suspense wrapper with error boundaries
|
||||
- **Form Action Node** - React 19 form actions
|
||||
|
||||
These will be separate tasks building on this foundation.
|
||||
|
||||
|
||||
# React 19 Node Modernization - Implementation Checklist
|
||||
|
||||
Quick reference checklist for implementation. See full spec for details.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Flight Checks
|
||||
|
||||
- [ ] Verify React 19 is installed in editor package
|
||||
- [ ] Verify React 18.3 is installed in runtime package
|
||||
- [ ] Create feature branch: `feature/react19-node-modernization`
|
||||
- [ ] Locate all node files in `packages/noodl-viewer-react/src/nodes/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Infrastructure
|
||||
|
||||
### createNodeFromReactComponent
|
||||
- [ ] Find file: `packages/noodl-viewer-react/src/react-component-node.js`
|
||||
- [ ] Remove automatic forwardRef wrapping
|
||||
- [ ] Add `ref` prop passthrough to components
|
||||
- [ ] Add optional `react19.transitionInputs` config
|
||||
- [ ] Add optional `react19.deferredInputs` config
|
||||
- [ ] Test: Basic node still renders
|
||||
- [ ] Test: Ref forwarding works
|
||||
|
||||
### Base Classes
|
||||
- [ ] Find visual-base.js or equivalent
|
||||
- [ ] Add `this.startTransition()` utility method
|
||||
- [ ] Add `this.getDeferredValue()` utility method
|
||||
- [ ] Update TypeScript definitions if applicable
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Core Visual Nodes
|
||||
|
||||
### Group Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Use `ref` as regular prop
|
||||
- [ ] Test: Renders correctly
|
||||
- [ ] Test: Ref accessible for DOM manipulation
|
||||
- [ ] Optional: Add `Defer Children` input
|
||||
- [ ] Optional: Add `Is Updating` output
|
||||
|
||||
### Text Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Image Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Video Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Ensure proper ref cleanup
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Circle Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
### Icon Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Renders correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI Control Nodes
|
||||
|
||||
### Button Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Click events work
|
||||
- [ ] Test: Visual states work (hover, pressed, disabled)
|
||||
- [ ] Optional: Add `Is Pending` output for forms
|
||||
|
||||
### Text Input Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Value binding works
|
||||
- [ ] Test: Focus/blur events work
|
||||
- [ ] Optional: Add `Defer Updates` input
|
||||
- [ ] Optional: Add `Immediate Value` output
|
||||
|
||||
### Checkbox Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Checked state works
|
||||
|
||||
### Radio Button Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Selection works
|
||||
|
||||
### Radio Button Group Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Group behavior works
|
||||
|
||||
### Options/Dropdown Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Selection works
|
||||
- [ ] Optional: useDeferredValue for large option lists
|
||||
|
||||
### Range/Slider Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Test: Value updates work
|
||||
- [ ] Optional: Add `Deferred Value` output
|
||||
- [ ] Optional: Add `Immediate Value` output
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Navigation Nodes
|
||||
|
||||
### Router Node
|
||||
- [ ] Remove forwardRef if present
|
||||
- [ ] Add useTransition for navigation
|
||||
- [ ] Add `Is Transitioning` output
|
||||
- [ ] Test: Page navigation works
|
||||
- [ ] Test: Is Transitioning output fires correctly
|
||||
|
||||
### Router Navigate Node
|
||||
- [ ] Wrap navigation in startTransition
|
||||
- [ ] Add `Is Pending` output
|
||||
- [ ] Test: Navigation triggers correctly
|
||||
|
||||
### Page Stack Node
|
||||
- [ ] Add useTransition for push/pop
|
||||
- [ ] Add `Is Transitioning` output
|
||||
- [ ] Test: Stack operations work
|
||||
|
||||
### Page Inputs Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Parameters pass correctly
|
||||
|
||||
### Show Popup Node
|
||||
- [ ] Consider useTransition
|
||||
- [ ] Test: Popup shows/hides
|
||||
|
||||
### Close Popup Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Popup closes
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Layout Nodes
|
||||
|
||||
### Columns Node
|
||||
- [ ] Remove forwardRef
|
||||
- [ ] Remove React.cloneElement if present
|
||||
- [ ] Test: Column layout works
|
||||
|
||||
### Repeater (For Each) Node ⭐ HIGH VALUE
|
||||
- [ ] Remove forwardRef if present
|
||||
- [ ] Add useDeferredValue for items
|
||||
- [ ] Add useTransition for updates
|
||||
- [ ] Add `Defer Updates` input
|
||||
- [ ] Add `Is Updating` output
|
||||
- [ ] Add `Transition Updates` input
|
||||
- [ ] Test: Basic rendering works
|
||||
- [ ] Test: Large list performance improved
|
||||
- [ ] Test: Is Updating output fires correctly
|
||||
|
||||
### Component Children Node
|
||||
- [ ] Standard cleanup
|
||||
- [ ] Test: Children render correctly
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Data Nodes
|
||||
|
||||
### Component Object Node
|
||||
- [ ] Review implementation
|
||||
- [ ] Consider React 19 context patterns
|
||||
- [ ] Test: Object access works
|
||||
|
||||
### Parent Component Object Node
|
||||
- [ ] Same as Component Object
|
||||
- [ ] Test: Parent access works
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Page/SEO Node ⭐ HIGH VALUE
|
||||
|
||||
### Page Node
|
||||
- [ ] Add `Page Title` input → renders `<title>`
|
||||
- [ ] Add `Meta Description` input → renders `<meta name="description">`
|
||||
- [ ] Add `Canonical URL` input → renders `<link rel="canonical">`
|
||||
- [ ] Add `OG Title` input → renders `<meta property="og:title">`
|
||||
- [ ] Add `OG Description` input
|
||||
- [ ] Add `OG Image` input
|
||||
- [ ] Test: Metadata renders in head
|
||||
- [ ] Test: SSR works correctly
|
||||
- [ ] Provide fallback for React 18.3 runtime
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Testing
|
||||
|
||||
### Unit Tests
|
||||
- [ ] createNodeFromReactComponent tests
|
||||
- [ ] Ref forwarding tests
|
||||
- [ ] Deferred value tests
|
||||
- [ ] Transition tests
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Full navigation flow
|
||||
- [ ] Repeater with large data
|
||||
- [ ] Form interactions
|
||||
|
||||
### Visual Tests
|
||||
- [ ] All nodes render same as before
|
||||
- [ ] Visual states work
|
||||
- [ ] Variants work
|
||||
|
||||
### Performance Tests
|
||||
- [ ] Benchmark: Repeater 1000 items
|
||||
- [ ] Benchmark: Page navigation
|
||||
- [ ] Benchmark: Text input typing
|
||||
|
||||
---
|
||||
|
||||
## Final Steps
|
||||
|
||||
- [ ] Update documentation
|
||||
- [ ] Update changelog
|
||||
- [ ] Create PR
|
||||
- [ ] Test in sample projects
|
||||
- [ ] Deploy to staging
|
||||
- [ ] User testing
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Pattern Changes
|
||||
|
||||
### forwardRef Removal
|
||||
|
||||
**Before:**
|
||||
```jsx
|
||||
React.forwardRef((props, ref) => <div ref={ref} />)
|
||||
```
|
||||
|
||||
**After:**
|
||||
```jsx
|
||||
function Component({ ref, ...props }) { return <div ref={ref} /> }
|
||||
```
|
||||
|
||||
### Adding Deferred Value
|
||||
|
||||
```jsx
|
||||
function Component({ items, deferUpdates, onIsUpdating }) {
|
||||
const deferredItems = React.useDeferredValue(items);
|
||||
const isStale = items !== deferredItems;
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsUpdating?.(isStale);
|
||||
}, [isStale]);
|
||||
|
||||
return /* render deferUpdates ? deferredItems : items */;
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Transitions
|
||||
|
||||
```jsx
|
||||
function Component({ onNavigate, onIsPending }) {
|
||||
const [isPending, startTransition] = React.useTransition();
|
||||
|
||||
React.useEffect(() => {
|
||||
onIsPending?.(isPending);
|
||||
}, [isPending]);
|
||||
|
||||
const handleNav = (target) => {
|
||||
startTransition(() => onNavigate(target));
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Document Metadata (React 19)
|
||||
|
||||
```jsx
|
||||
function Page({ title, description }) {
|
||||
return (
|
||||
<>
|
||||
{title && <title>{title}</title>}
|
||||
{description && <meta name="description" content={description} />}
|
||||
{/* rest of page */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- High value items marked with ⭐
|
||||
- Start with infrastructure, then Group node as test case
|
||||
- Test frequently - small iterations
|
||||
- Keep backward compatibility - no breaking changes
|
||||
@@ -0,0 +1,111 @@
|
||||
# Responsive Breakpoints System
|
||||
|
||||
## Feature Overview
|
||||
|
||||
A built-in responsive breakpoint system that works like visual states (hover/pressed/disabled) but for viewport widths. Users can define breakpoint-specific property values directly in the property panel without wiring up states nodes.
|
||||
|
||||
**Current Pain Point:**
|
||||
Users must manually wire `[Screen Width] → [States Node] → [Visual Node]` for every responsive property, cluttering the node graph and making responsive design tedious.
|
||||
|
||||
**Solution:**
|
||||
In the property panel, a breakpoint selector lets users switch between Desktop/Tablet/Phone/Small Phone views. When a breakpoint is selected, users see and edit that breakpoint's values. Values cascade down (desktop → tablet → phone) unless explicitly overridden.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Terminology | "Breakpoints" |
|
||||
| Default breakpoints | Desktop (≥1024px), Tablet (768-1023px), Phone (320-767px), Small Phone (<320px) |
|
||||
| Cascade direction | Configurable (desktop-first default, mobile-first option) |
|
||||
| Editor preview sync | Independent (changing breakpoint doesn't resize preview, and vice versa) |
|
||||
|
||||
## Breakpoint-Aware Properties
|
||||
|
||||
Only layout/dimension properties support breakpoints (not colors/shadows):
|
||||
|
||||
**✅ Supported:**
|
||||
- **Dimensions**: width, height, minWidth, maxWidth, minHeight, maxHeight
|
||||
- **Spacing**: marginTop/Right/Bottom/Left, paddingTop/Right/Bottom/Left, gap
|
||||
- **Typography**: fontSize, lineHeight, letterSpacing
|
||||
- **Layout**: flexDirection, alignItems, justifyContent, flexWrap, flexGrow, flexShrink
|
||||
- **Visibility**: visible, mounted
|
||||
|
||||
**❌ Not Supported:**
|
||||
- Colors (backgroundColor, borderColor, textColor, etc.)
|
||||
- Borders (borderWidth, borderRadius, borderStyle)
|
||||
- Shadows (boxShadow)
|
||||
- Effects (opacity, transform)
|
||||
|
||||
## Data Model
|
||||
|
||||
```javascript
|
||||
// Node model storage
|
||||
{
|
||||
parameters: {
|
||||
marginTop: '40px', // desktop (default breakpoint)
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { marginTop: '24px' },
|
||||
phone: { marginTop: '16px' },
|
||||
smallPhone: { marginTop: '12px' }
|
||||
},
|
||||
// Optional: combined visual state + breakpoint
|
||||
stateBreakpointParameters: {
|
||||
'hover:tablet': { /* ... */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Project settings
|
||||
{
|
||||
breakpoints: {
|
||||
desktop: { minWidth: 1024, isDefault: true },
|
||||
tablet: { minWidth: 768, maxWidth: 1023 },
|
||||
phone: { minWidth: 320, maxWidth: 767 },
|
||||
smallPhone: { minWidth: 0, maxWidth: 319 }
|
||||
},
|
||||
breakpointOrder: ['desktop', 'tablet', 'phone', 'smallPhone'],
|
||||
cascadeDirection: 'desktop-first' // or 'mobile-first'
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
| Phase | Name | Estimate | Dependencies |
|
||||
|-------|------|----------|--------------|
|
||||
| 1 | Foundation - Data Model | 2-3 days | None |
|
||||
| 2 | Editor UI - Property Panel | 3-4 days | Phase 1 |
|
||||
| 3 | Runtime - Viewport Detection | 2-3 days | Phase 1 |
|
||||
| 4 | Variants Integration | 1-2 days | Phases 1-3 |
|
||||
| 5 | Visual States Combo | 2 days | Phases 1-4 |
|
||||
|
||||
**Total Estimate: 10-14 days**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Users can set different margin/padding/width values per breakpoint without any node wiring
|
||||
2. Values cascade automatically (tablet inherits desktop unless overridden)
|
||||
3. Property panel clearly shows inherited vs overridden values
|
||||
4. Runtime automatically applies correct values based on viewport width
|
||||
5. Variants support breakpoint-specific values
|
||||
6. Project settings allow customizing breakpoint thresholds
|
||||
7. Both desktop-first and mobile-first workflows supported
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
tasks/responsive-breakpoints/
|
||||
├── 00-OVERVIEW.md (this file)
|
||||
├── 01-FOUNDATION.md (Phase 1: Data model)
|
||||
├── 02-EDITOR-UI.md (Phase 2: Property panel)
|
||||
├── 03-RUNTIME.md (Phase 3: Viewport detection)
|
||||
├── 04-VARIANTS.md (Phase 4: Variants integration)
|
||||
└── 05-VISUAL-STATES-COMBO.md (Phase 5: Combined states)
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `codebase/nodes/visual-states.md` - Existing visual states system (pattern to follow)
|
||||
- `codebase/nodes/variants.md` - Existing variants system
|
||||
- `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/` - Property panel implementation
|
||||
- `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` - Node model
|
||||
- `packages/noodl-runtime/src/models/nodemodel.js` - Runtime node model
|
||||
@@ -0,0 +1,369 @@
|
||||
# Phase 1: Foundation - Data Model
|
||||
|
||||
## Overview
|
||||
|
||||
Establish the data structures and model layer support for responsive breakpoints. This phase adds `breakpointParameters` storage to nodes, extends the model proxy, and adds project-level breakpoint configuration.
|
||||
|
||||
**Estimate:** 2-3 days
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `breakpointParameters` field to NodeGraphNode model
|
||||
2. Extend NodeModel (runtime) with breakpoint parameter support
|
||||
3. Add breakpoint configuration to project settings
|
||||
4. Extend ModelProxy to handle breakpoint context
|
||||
5. Add `allowBreakpoints` flag support to node definitions
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Data Storage Pattern
|
||||
|
||||
Following the existing visual states pattern (`stateParameters`), we add parallel `breakpointParameters`:
|
||||
|
||||
```javascript
|
||||
// NodeGraphNode / NodeModel
|
||||
{
|
||||
id: 'group-1',
|
||||
type: 'Group',
|
||||
parameters: {
|
||||
marginTop: '40px', // base/default breakpoint value
|
||||
backgroundColor: '#fff' // non-breakpoint property
|
||||
},
|
||||
stateParameters: { // existing - visual states
|
||||
hover: { backgroundColor: '#eee' }
|
||||
},
|
||||
breakpointParameters: { // NEW - breakpoints
|
||||
tablet: { marginTop: '24px' },
|
||||
phone: { marginTop: '16px' },
|
||||
smallPhone: { marginTop: '12px' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Project Settings Schema
|
||||
|
||||
```javascript
|
||||
// project.settings.responsiveBreakpoints
|
||||
{
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first', // or 'mobile-first'
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'desktop' },
|
||||
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'tablet' },
|
||||
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'phone' },
|
||||
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'phone-small' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Node Definition Flag
|
||||
|
||||
```javascript
|
||||
// In node definition
|
||||
{
|
||||
inputs: {
|
||||
marginTop: {
|
||||
type: { name: 'number', units: ['px', '%'], defaultUnit: 'px' },
|
||||
allowBreakpoints: true, // NEW flag
|
||||
group: 'Margin and Padding'
|
||||
},
|
||||
backgroundColor: {
|
||||
type: 'color',
|
||||
allowVisualStates: true,
|
||||
allowBreakpoints: false // colors don't support breakpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend NodeGraphNode Model
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
// Add to class properties
|
||||
breakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
// Add to constructor/initialization
|
||||
this.breakpointParameters = args.breakpointParameters || {};
|
||||
|
||||
// Add new methods
|
||||
hasBreakpointParameter(name: string, breakpoint: string): boolean {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getBreakpointParameter(name: string, breakpoint: string): any {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name];
|
||||
}
|
||||
|
||||
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any): void {
|
||||
// Similar pattern to setParameter but for breakpoint-specific values
|
||||
// Include undo support
|
||||
}
|
||||
|
||||
// Extend getParameter to support breakpoint context
|
||||
getParameter(name: string, args?: { state?: string, breakpoint?: string }): any {
|
||||
// If breakpoint specified, check breakpointParameters first
|
||||
// Then cascade to larger breakpoints
|
||||
// Finally fall back to base parameters
|
||||
}
|
||||
|
||||
// Extend toJSON to include breakpointParameters
|
||||
toJSON(): object {
|
||||
return {
|
||||
...existingFields,
|
||||
breakpointParameters: this.breakpointParameters
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend Runtime NodeModel
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/nodemodel.js`
|
||||
|
||||
```javascript
|
||||
// Add breakpointParameters storage
|
||||
NodeModel.prototype.setBreakpointParameter = function(name, value, breakpoint) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.breakpointParameters[breakpoint][name];
|
||||
} else {
|
||||
this.breakpointParameters[breakpoint][name] = value;
|
||||
}
|
||||
|
||||
this.emit("breakpointParameterUpdated", { name, value, breakpoint });
|
||||
};
|
||||
|
||||
NodeModel.prototype.setBreakpointParameters = function(breakpointParameters) {
|
||||
this.breakpointParameters = breakpointParameters;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Add Project Settings Schema
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
|
||||
|
||||
```typescript
|
||||
// Add default breakpoint settings
|
||||
const DEFAULT_BREAKPOINT_SETTINGS = {
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first',
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', name: 'Desktop', minWidth: 1024, icon: 'DeviceDesktop' },
|
||||
{ id: 'tablet', name: 'Tablet', minWidth: 768, maxWidth: 1023, icon: 'DeviceTablet' },
|
||||
{ id: 'phone', name: 'Phone', minWidth: 320, maxWidth: 767, icon: 'DevicePhone' },
|
||||
{ id: 'smallPhone', name: 'Small Phone', minWidth: 0, maxWidth: 319, icon: 'DevicePhone' }
|
||||
]
|
||||
};
|
||||
|
||||
// Add helper methods
|
||||
getBreakpointSettings(): BreakpointSettings {
|
||||
return this.settings.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
|
||||
}
|
||||
|
||||
setBreakpointSettings(settings: BreakpointSettings): void {
|
||||
this.setSetting('responsiveBreakpoints', settings);
|
||||
}
|
||||
|
||||
getBreakpointForWidth(width: number): string {
|
||||
const settings = this.getBreakpointSettings();
|
||||
const breakpoints = settings.breakpoints;
|
||||
|
||||
// Find matching breakpoint based on width
|
||||
for (const bp of breakpoints) {
|
||||
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
|
||||
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
|
||||
if (minMatch && maxMatch) return bp.id;
|
||||
}
|
||||
|
||||
return settings.defaultBreakpoint;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Extend ModelProxy
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
model: NodeGraphNode;
|
||||
editMode: string;
|
||||
visualState: string;
|
||||
breakpoint: string; // NEW
|
||||
|
||||
constructor(args) {
|
||||
this.model = args.model;
|
||||
this.visualState = 'neutral';
|
||||
this.breakpoint = 'desktop'; // NEW - default breakpoint
|
||||
}
|
||||
|
||||
setBreakpoint(breakpoint: string) {
|
||||
this.breakpoint = breakpoint;
|
||||
}
|
||||
|
||||
// Extend getParameter to handle breakpoints
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// Check if this property supports breakpoints
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
// Check for breakpoint-specific value
|
||||
const breakpointValue = source.getBreakpointParameter(name, this.breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
|
||||
// Cascade to larger breakpoints (desktop-first)
|
||||
// TODO: Support mobile-first cascade
|
||||
}
|
||||
|
||||
// Check visual state
|
||||
if (this.visualState && this.visualState !== 'neutral') {
|
||||
// existing visual state logic
|
||||
}
|
||||
|
||||
// Fall back to base parameters
|
||||
return source.getParameter(name, { state: this.visualState });
|
||||
}
|
||||
|
||||
// Extend setParameter to handle breakpoints
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// If setting a breakpoint-specific value
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
args.breakpoint = this.breakpoint;
|
||||
}
|
||||
|
||||
// existing state handling
|
||||
args.state = this.visualState;
|
||||
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
|
||||
if (args.breakpoint) {
|
||||
target.setBreakpointParameter(name, value, args.breakpoint, args);
|
||||
} else {
|
||||
target.setParameter(name, value, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current value is inherited or explicitly set
|
||||
isBreakpointValueInherited(name: string): boolean {
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return !source.hasBreakpointParameter(name, this.breakpoint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Node Type Registration
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodelibrary/nodelibrary.ts`
|
||||
|
||||
```typescript
|
||||
// When registering node types, process allowBreakpoints flag
|
||||
// Similar to how allowVisualStates is handled
|
||||
|
||||
processNodeType(nodeType) {
|
||||
// existing processing...
|
||||
|
||||
// Process allowBreakpoints for inputs
|
||||
if (nodeType.inputs) {
|
||||
for (const [name, input] of Object.entries(nodeType.inputs)) {
|
||||
if (input.allowBreakpoints) {
|
||||
// Mark this port as breakpoint-aware
|
||||
// This will be used by property panel to show breakpoint controls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update GraphModel (Runtime)
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
// Add method to update breakpoint parameters
|
||||
GraphModel.prototype.updateNodeBreakpointParameter = function(
|
||||
nodeId,
|
||||
parameterName,
|
||||
parameterValue,
|
||||
breakpoint
|
||||
) {
|
||||
const node = this.getNodeWithId(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
node.setBreakpointParameter(parameterName, parameterValue, breakpoint);
|
||||
};
|
||||
|
||||
// Extend project settings handling
|
||||
GraphModel.prototype.getBreakpointSettings = function() {
|
||||
return this.settings?.responsiveBreakpoints || DEFAULT_BREAKPOINT_SETTINGS;
|
||||
};
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add breakpointParameters field, getter/setter methods |
|
||||
| `packages/noodl-editor/src/editor/src/models/projectmodel.ts` | Add breakpoint settings helpers |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Add breakpoint context, extend get/setParameter |
|
||||
| `packages/noodl-runtime/src/models/nodemodel.js` | Add breakpoint parameter methods |
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Add breakpoint settings handling |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/breakpointSettings.ts` | TypeScript interfaces for breakpoint settings |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] NodeGraphNode can store and retrieve breakpointParameters
|
||||
- [ ] NodeGraphNode serializes breakpointParameters to JSON correctly
|
||||
- [ ] NodeGraphNode loads breakpointParameters from JSON correctly
|
||||
- [ ] ModelProxy correctly returns breakpoint-specific values
|
||||
- [ ] ModelProxy correctly identifies inherited vs explicit values
|
||||
- [ ] Project settings store and load breakpoint configuration
|
||||
- [ ] Cascade works correctly (tablet falls back to desktop)
|
||||
- [ ] Undo/redo works for breakpoint parameter changes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Can programmatically set `node.setBreakpointParameter('marginTop', '24px', 'tablet')`
|
||||
2. ✅ Can retrieve with `node.getBreakpointParameter('marginTop', 'tablet')`
|
||||
3. ✅ Project JSON includes breakpointParameters when saved
|
||||
4. ✅ Project JSON loads breakpointParameters when opened
|
||||
5. ✅ ModelProxy returns correct value based on current breakpoint context
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Undo Support**: Make sure breakpoint parameter changes are undoable. Follow the same pattern as `setParameter` with undo groups.
|
||||
|
||||
2. **Cascade Order**: Desktop-first means `tablet` inherits from `desktop`, `phone` inherits from `tablet`, `smallPhone` inherits from `phone`. Mobile-first reverses this.
|
||||
|
||||
3. **Default Breakpoint**: When `breakpoint === 'desktop'` (or whatever the default is), we should NOT use breakpointParameters - use base parameters instead.
|
||||
|
||||
4. **Parameter Migration**: Existing projects won't have breakpointParameters. Handle gracefully (undefined → empty object).
|
||||
|
||||
5. **Port Flag**: The `allowBreakpoints` flag on ports determines which properties show breakpoint controls in the UI. This is read-only metadata, not stored per-node.
|
||||
|
||||
## Confidence Checkpoints
|
||||
|
||||
After completing each step, verify:
|
||||
|
||||
| Step | Checkpoint |
|
||||
|------|------------|
|
||||
| 1 | Can add/get breakpoint params in editor console |
|
||||
| 2 | Runtime node model accepts breakpoint params |
|
||||
| 3 | Project settings UI shows breakpoint config |
|
||||
| 4 | ModelProxy returns correct value per breakpoint |
|
||||
| 5 | Saving/loading project preserves breakpoint data |
|
||||
@@ -0,0 +1,600 @@
|
||||
# Phase 2: Editor UI - Property Panel
|
||||
|
||||
## Overview
|
||||
|
||||
Add the breakpoint selector UI to the property panel and implement the visual feedback for inherited vs overridden values. Users should be able to switch between breakpoints and see/edit breakpoint-specific values.
|
||||
|
||||
**Estimate:** 3-4 days
|
||||
|
||||
**Dependencies:** Phase 1 (Foundation)
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add breakpoint selector component to property panel
|
||||
2. Show inherited vs overridden values with visual distinction
|
||||
3. Add reset button to clear breakpoint-specific overrides
|
||||
4. Show badge summary of overrides per breakpoint
|
||||
5. Add breakpoint configuration section to Project Settings
|
||||
6. Filter property panel to only show breakpoint controls on `allowBreakpoints` properties
|
||||
|
||||
## UI Design
|
||||
|
||||
### Property Panel with Breakpoint Selector
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Group │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Breakpoint: [🖥️] [💻] [📱] [📱] │
|
||||
│ Des Tab Pho Sml │
|
||||
│ ───────────────────── │
|
||||
│ ▲ selected │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ┌─ Dimensions ────────────────────────────────┐ │
|
||||
│ │ Width [100%] │ │
|
||||
│ │ Height [auto] (inherited) [↺] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Margin and Padding ────────────────────────┐ │
|
||||
│ │ Margin Top [24px] ● changed │ │
|
||||
│ │ Padding [16px] (inherited) [↺] │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ Style ─────────────────────────────────────┐ │
|
||||
│ │ Background [#ffffff] (no breakpoints) │ │
|
||||
│ │ Border [1px solid] (no breakpoints) │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 💻 2 overrides 📱 3 overrides 📱 1 override │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Visual States
|
||||
|
||||
| State | Appearance |
|
||||
|-------|------------|
|
||||
| Base value (desktop) | Normal text, no indicator |
|
||||
| Inherited from larger breakpoint | Dimmed/italic text, "(inherited)" label |
|
||||
| Explicitly set for this breakpoint | Normal text, filled dot indicator (●) |
|
||||
| Reset button | Shows on hover for overridden values |
|
||||
|
||||
### Project Settings - Breakpoints Section
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Responsive Breakpoints │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ☑ Enable responsive breakpoints │
|
||||
│ │
|
||||
│ Cascade direction: [Desktop-first ▼] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ Name Min Width Max Width │ │
|
||||
│ │ ─────────────────────────────────────────│ │
|
||||
│ │ 🖥️ Desktop 1024px — [Default]│ │
|
||||
│ │ 💻 Tablet 768px 1023px │ │
|
||||
│ │ 📱 Phone 320px 767px │ │
|
||||
│ │ 📱 Small Phone 0px 319px │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ Add Breakpoint] [Reset to Defaults] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create BreakpointSelector Component
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
|
||||
import css from './BreakpointSelector.module.scss';
|
||||
|
||||
export interface Breakpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconName;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export interface BreakpointSelectorProps {
|
||||
breakpoints: Breakpoint[];
|
||||
selectedBreakpoint: string;
|
||||
overrideCounts: Record<string, number>; // { tablet: 2, phone: 3 }
|
||||
onBreakpointChange: (breakpointId: string) => void;
|
||||
}
|
||||
|
||||
export function BreakpointSelector({
|
||||
breakpoints,
|
||||
selectedBreakpoint,
|
||||
overrideCounts,
|
||||
onBreakpointChange
|
||||
}: BreakpointSelectorProps) {
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<span className={css.Label}>Breakpoint:</span>
|
||||
<div className={css.ButtonGroup}>
|
||||
{breakpoints.map((bp) => (
|
||||
<Tooltip
|
||||
key={bp.id}
|
||||
content={`${bp.name}${bp.minWidth ? ` (${bp.minWidth}px+)` : ''}`}
|
||||
>
|
||||
<button
|
||||
className={classNames(css.Button, {
|
||||
[css.isSelected]: selectedBreakpoint === bp.id,
|
||||
[css.hasOverrides]: overrideCounts[bp.id] > 0
|
||||
})}
|
||||
onClick={() => onBreakpointChange(bp.id)}
|
||||
>
|
||||
<Icon icon={getIconForBreakpoint(bp.icon)} />
|
||||
{overrideCounts[bp.id] > 0 && (
|
||||
<span className={css.OverrideCount}>{overrideCounts[bp.id]}</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getIconForBreakpoint(icon: string): IconName {
|
||||
switch (icon) {
|
||||
case 'desktop': return IconName.DeviceDesktop;
|
||||
case 'tablet': return IconName.DeviceTablet;
|
||||
case 'phone':
|
||||
case 'phone-small':
|
||||
default: return IconName.DevicePhone;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss`
|
||||
|
||||
```scss
|
||||
.Root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--theme-color-bg-3);
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
|
||||
.Label {
|
||||
font-size: 12px;
|
||||
color: var(--theme-color-fg-default);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ButtonGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
background-color: var(--theme-color-primary);
|
||||
|
||||
svg path {
|
||||
fill: var(--theme-color-on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
svg path {
|
||||
fill: var(--theme-color-fg-default);
|
||||
}
|
||||
}
|
||||
|
||||
.OverrideCount {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--theme-color-on-primary);
|
||||
background-color: var(--theme-color-secondary);
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Create Inherited Value Indicator
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx`
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { Tooltip } from '@noodl-core-ui/components/popups/Tooltip';
|
||||
import css from './InheritedIndicator.module.scss';
|
||||
|
||||
export interface InheritedIndicatorProps {
|
||||
isInherited: boolean;
|
||||
inheritedFrom?: string; // 'desktop', 'tablet', etc.
|
||||
isBreakpointAware: boolean;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function InheritedIndicator({
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
isBreakpointAware,
|
||||
onReset
|
||||
}: InheritedIndicatorProps) {
|
||||
if (!isBreakpointAware) {
|
||||
return null; // Don't show anything for non-breakpoint properties
|
||||
}
|
||||
|
||||
if (isInherited) {
|
||||
return (
|
||||
<Tooltip content={`Inherited from ${inheritedFrom}`}>
|
||||
<span className={css.Inherited}>
|
||||
(inherited)
|
||||
{onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="Value set for this breakpoint">
|
||||
<span className={css.Changed}>
|
||||
<span className={css.Dot}>●</span>
|
||||
{onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Integrate into Property Editor
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts`
|
||||
|
||||
```typescript
|
||||
// Add to existing property editor
|
||||
|
||||
import { BreakpointSelector } from './components/BreakpointSelector';
|
||||
|
||||
// In render method, add breakpoint selector after visual states
|
||||
renderBreakpointSelector() {
|
||||
const node = this.model;
|
||||
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
|
||||
|
||||
if (!hasBreakpointPorts) return; // Don't show if no breakpoint-aware properties
|
||||
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const overrideCounts = this.calculateOverrideCounts();
|
||||
|
||||
const props = {
|
||||
breakpoints: settings.breakpoints.map(bp => ({
|
||||
id: bp.id,
|
||||
name: bp.name,
|
||||
icon: bp.icon,
|
||||
minWidth: bp.minWidth,
|
||||
maxWidth: bp.maxWidth
|
||||
})),
|
||||
selectedBreakpoint: this.modelProxy.breakpoint || settings.defaultBreakpoint,
|
||||
overrideCounts,
|
||||
onBreakpointChange: this.onBreakpointChanged.bind(this)
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
React.createElement(BreakpointSelector, props),
|
||||
this.$('.breakpoint-selector')[0]
|
||||
);
|
||||
}
|
||||
|
||||
onBreakpointChanged(breakpointId: string) {
|
||||
this.modelProxy.setBreakpoint(breakpointId);
|
||||
this.scheduleRenderPortsView();
|
||||
}
|
||||
|
||||
hasBreakpointAwarePorts(): boolean {
|
||||
const ports = this.model.getPorts('input');
|
||||
return ports.some(p => p.allowBreakpoints);
|
||||
}
|
||||
|
||||
calculateOverrideCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
|
||||
for (const bp of settings.breakpoints) {
|
||||
if (bp.id === settings.defaultBreakpoint) continue;
|
||||
|
||||
const overrides = this.model.breakpointParameters?.[bp.id];
|
||||
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Property Panel Row Component
|
||||
|
||||
**File:** `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx`
|
||||
|
||||
```tsx
|
||||
// Extend PropertyPanelRow to show inherited indicator
|
||||
|
||||
export interface PropertyPanelRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
|
||||
// NEW props for breakpoint support
|
||||
isBreakpointAware?: boolean;
|
||||
isInherited?: boolean;
|
||||
inheritedFrom?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyPanelRow({
|
||||
label,
|
||||
children,
|
||||
isBreakpointAware,
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
onReset
|
||||
}: PropertyPanelRowProps) {
|
||||
return (
|
||||
<div className={classNames(css.Root, { [css.isInherited]: isInherited })}>
|
||||
<label className={css.Label}>{label}</label>
|
||||
<div className={css.InputContainer}>
|
||||
{children}
|
||||
{isBreakpointAware && (
|
||||
<InheritedIndicator
|
||||
isInherited={isInherited}
|
||||
inheritedFrom={inheritedFrom}
|
||||
isBreakpointAware={isBreakpointAware}
|
||||
onReset={!isInherited ? onReset : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update Ports View
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts`
|
||||
|
||||
```typescript
|
||||
// Extend the Ports view to pass breakpoint info to each property row
|
||||
|
||||
renderPort(port) {
|
||||
const isBreakpointAware = port.allowBreakpoints;
|
||||
const currentBreakpoint = this.modelProxy.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
let isInherited = false;
|
||||
let inheritedFrom = null;
|
||||
|
||||
if (isBreakpointAware && currentBreakpoint !== defaultBreakpoint) {
|
||||
isInherited = this.modelProxy.isBreakpointValueInherited(port.name);
|
||||
inheritedFrom = this.getInheritedFromBreakpoint(port.name, currentBreakpoint);
|
||||
}
|
||||
|
||||
// Pass these to the PropertyPanelRow component
|
||||
return {
|
||||
...existingPortRenderData,
|
||||
isBreakpointAware,
|
||||
isInherited,
|
||||
inheritedFrom,
|
||||
onReset: isBreakpointAware && !isInherited
|
||||
? () => this.resetBreakpointValue(port.name, currentBreakpoint)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
resetBreakpointValue(portName: string, breakpoint: string) {
|
||||
this.modelProxy.setParameter(portName, undefined, {
|
||||
breakpoint,
|
||||
undo: true,
|
||||
label: `reset ${portName} for ${breakpoint}`
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
getInheritedFromBreakpoint(portName: string, currentBreakpoint: string): string {
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const breakpointOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = breakpointOrder.indexOf(currentBreakpoint);
|
||||
|
||||
// Walk up the cascade to find where value comes from
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (this.model.hasBreakpointParameter(portName, bp)) {
|
||||
return settings.breakpoints.find(b => b.id === bp)?.name || bp;
|
||||
}
|
||||
}
|
||||
|
||||
return settings.breakpoints[0]?.name || 'Desktop'; // Default
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Add Breakpoint Settings to Project Settings Panel
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx`
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { ProjectModel } from '@noodl-models/projectmodel';
|
||||
import { PropertyPanelCheckbox } from '@noodl-core-ui/components/property-panel/PropertyPanelCheckbox';
|
||||
import { PropertyPanelSelectInput } from '@noodl-core-ui/components/property-panel/PropertyPanelSelectInput';
|
||||
import { PropertyPanelRow } from '@noodl-core-ui/components/property-panel/PropertyPanelInput';
|
||||
import { CollapsableSection } from '@noodl-core-ui/components/sidebar/CollapsableSection';
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
|
||||
export function BreakpointSettingsSection() {
|
||||
const [settings, setSettings] = useState(
|
||||
ProjectModel.instance.getBreakpointSettings()
|
||||
);
|
||||
|
||||
function handleEnabledChange(enabled: boolean) {
|
||||
const newSettings = { ...settings, enabled };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
function handleCascadeDirectionChange(direction: string) {
|
||||
const newSettings = { ...settings, cascadeDirection: direction };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
function handleBreakpointChange(index: number, field: string, value: any) {
|
||||
const newBreakpoints = [...settings.breakpoints];
|
||||
newBreakpoints[index] = { ...newBreakpoints[index], [field]: value };
|
||||
|
||||
const newSettings = { ...settings, breakpoints: newBreakpoints };
|
||||
setSettings(newSettings);
|
||||
ProjectModel.instance.setBreakpointSettings(newSettings);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsableSection title="Responsive Breakpoints" hasGutter>
|
||||
<PropertyPanelRow label="Enable breakpoints">
|
||||
<PropertyPanelCheckbox
|
||||
value={settings.enabled}
|
||||
onChange={handleEnabledChange}
|
||||
/>
|
||||
</PropertyPanelRow>
|
||||
|
||||
<PropertyPanelRow label="Cascade direction">
|
||||
<PropertyPanelSelectInput
|
||||
value={settings.cascadeDirection}
|
||||
onChange={handleCascadeDirectionChange}
|
||||
options={[
|
||||
{ label: 'Desktop-first', value: 'desktop-first' },
|
||||
{ label: 'Mobile-first', value: 'mobile-first' }
|
||||
]}
|
||||
/>
|
||||
</PropertyPanelRow>
|
||||
|
||||
<div className={css.BreakpointList}>
|
||||
{settings.breakpoints.map((bp, index) => (
|
||||
<BreakpointRow
|
||||
key={bp.id}
|
||||
breakpoint={bp}
|
||||
isDefault={bp.id === settings.defaultBreakpoint}
|
||||
onChange={(field, value) => handleBreakpointChange(index, field, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsableSection>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Add Template to Property Editor HTML
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/templates/propertyeditor.html`
|
||||
|
||||
Add breakpoint selector container:
|
||||
|
||||
```html
|
||||
<!-- Add after visual-states div -->
|
||||
<div class="breakpoint-selector"></div>
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/propertyeditor.ts` | Add breakpoint selector rendering, integrate with ModelProxy |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass breakpoint info to property rows |
|
||||
| `packages/noodl-editor/src/editor/src/templates/propertyeditor.html` | Add breakpoint selector container |
|
||||
| `packages/noodl-core-ui/src/components/property-panel/PropertyPanelRow/PropertyPanelRow.tsx` | Add inherited indicator support |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/ProjectSettingsPanel.tsx` | Add breakpoint settings section |
|
||||
| `packages/noodl-editor/src/editor/src/styles/propertyeditor/` | Add breakpoint-related styles |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.tsx` | Main breakpoint selector component |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/BreakpointSelector.module.scss` | Styles for breakpoint selector |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/BreakpointSelector/index.ts` | Export |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.tsx` | Inherited value indicator |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/InheritedIndicator.module.scss` | Styles |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/InheritedIndicator/index.ts` | Export |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/ProjectSettingsPanel/sections/BreakpointSettingsSection.tsx` | Project settings UI |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Breakpoint selector appears in property panel for nodes with breakpoint-aware properties
|
||||
- [ ] Breakpoint selector does NOT appear for nodes without breakpoint-aware properties
|
||||
- [ ] Clicking breakpoint buttons switches the current breakpoint
|
||||
- [ ] Property values update to show breakpoint-specific values when switching
|
||||
- [ ] Inherited values show dimmed with "(inherited)" label
|
||||
- [ ] Override values show with dot indicator (●)
|
||||
- [ ] Reset button appears on hover for overridden values
|
||||
- [ ] Clicking reset removes the breakpoint-specific value
|
||||
- [ ] Override count badges show correct counts
|
||||
- [ ] Project Settings shows breakpoint configuration
|
||||
- [ ] Can change cascade direction in project settings
|
||||
- [ ] Can modify breakpoint thresholds in project settings
|
||||
- [ ] Changes persist after saving and reloading project
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Users can switch between breakpoints in property panel
|
||||
2. ✅ Clear visual distinction between inherited and overridden values
|
||||
3. ✅ Can set breakpoint-specific values by editing while breakpoint is selected
|
||||
4. ✅ Can reset breakpoint-specific values to inherit from larger breakpoint
|
||||
5. ✅ Override counts visible at a glance
|
||||
6. ✅ Project settings allow breakpoint customization
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Visual States Coexistence**: The breakpoint selector should appear ABOVE the visual states selector (if present). They're independent axes.
|
||||
|
||||
2. **Port Filtering**: Only ports with `allowBreakpoints: true` should show the inherited/override indicators. Non-breakpoint properties look normal.
|
||||
|
||||
3. **Connected Ports**: If a port is connected (has a wire), it shouldn't show breakpoint controls - the connection takes precedence.
|
||||
|
||||
4. **Performance**: Calculating override counts could be expensive if done on every render. Consider caching or only recalculating when breakpointParameters change.
|
||||
|
||||
5. **Mobile-First Logic**: When cascade direction is mobile-first, the inheritance flows the OTHER direction (phone → tablet → desktop). Make sure the `getInheritedFromBreakpoint` logic handles both.
|
||||
|
||||
6. **Keyboard Navigation**: Consider adding keyboard shortcuts to switch breakpoints (e.g., Ctrl+1/2/3/4).
|
||||
|
||||
## UI/UX Refinements (Optional)
|
||||
|
||||
- Animate the transition when switching breakpoints
|
||||
- Add tooltips showing the pixel range for each breakpoint
|
||||
- Consider a "copy to all breakpoints" action
|
||||
- Add visual preview of how values differ across breakpoints
|
||||
@@ -0,0 +1,619 @@
|
||||
# Phase 3: Runtime - Viewport Detection
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the runtime system that detects viewport width changes and applies the correct breakpoint-specific values to nodes. This includes creating a BreakpointManager singleton, wiring up resize listeners, and ensuring nodes reactively update when the breakpoint changes.
|
||||
|
||||
**Estimate:** 2-3 days
|
||||
|
||||
**Dependencies:** Phase 1 (Foundation)
|
||||
|
||||
## Goals
|
||||
|
||||
1. Create BreakpointManager singleton for viewport detection
|
||||
2. Implement viewport resize listener with debouncing
|
||||
3. Wire nodes to respond to breakpoint changes
|
||||
4. Implement value resolution with cascade logic
|
||||
5. Support both desktop-first and mobile-first cascades
|
||||
6. Ensure smooth transitions when breakpoint changes
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### BreakpointManager
|
||||
|
||||
Central singleton that:
|
||||
- Monitors `window.innerWidth`
|
||||
- Determines current breakpoint based on project settings
|
||||
- Notifies subscribers when breakpoint changes
|
||||
- Handles both desktop-first and mobile-first cascade
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ BreakpointManager │
|
||||
├─────────────────────────────────────────┤
|
||||
│ - currentBreakpoint: string │
|
||||
│ - settings: BreakpointSettings │
|
||||
│ - listeners: Set<Function> │
|
||||
├─────────────────────────────────────────┤
|
||||
│ + initialize(settings) │
|
||||
│ + getCurrentBreakpoint(): string │
|
||||
│ + getBreakpointForWidth(width): string │
|
||||
│ + subscribe(callback): unsubscribe │
|
||||
│ + getCascadeOrder(): string[] │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
│ notifies
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Visual Nodes │
|
||||
│ (subscribe to breakpoint changes) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Value Resolution Flow
|
||||
|
||||
```
|
||||
getResolvedValue(propertyName)
|
||||
│
|
||||
▼
|
||||
Is property breakpoint-aware?
|
||||
│
|
||||
├─ No → return parameters[propertyName]
|
||||
│
|
||||
└─ Yes → Get current breakpoint
|
||||
│
|
||||
▼
|
||||
Check breakpointParameters[currentBreakpoint]
|
||||
│
|
||||
├─ Has value → return it
|
||||
│
|
||||
└─ No value → Cascade to next breakpoint
|
||||
│
|
||||
▼
|
||||
(repeat until found or reach default)
|
||||
│
|
||||
▼
|
||||
return parameters[propertyName]
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Create BreakpointManager
|
||||
|
||||
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
|
||||
|
||||
```javascript
|
||||
'use strict';
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: true,
|
||||
cascadeDirection: 'desktop-first',
|
||||
defaultBreakpoint: 'desktop',
|
||||
breakpoints: [
|
||||
{ id: 'desktop', minWidth: 1024 },
|
||||
{ id: 'tablet', minWidth: 768, maxWidth: 1023 },
|
||||
{ id: 'phone', minWidth: 320, maxWidth: 767 },
|
||||
{ id: 'smallPhone', minWidth: 0, maxWidth: 319 }
|
||||
]
|
||||
};
|
||||
|
||||
class BreakpointManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.settings = DEFAULT_SETTINGS;
|
||||
this.currentBreakpoint = DEFAULT_SETTINGS.defaultBreakpoint;
|
||||
this._resizeTimeout = null;
|
||||
this._boundHandleResize = this._handleResize.bind(this);
|
||||
|
||||
// Don't auto-initialize - wait for settings from project
|
||||
}
|
||||
|
||||
initialize(settings) {
|
||||
this.settings = settings || DEFAULT_SETTINGS;
|
||||
this.currentBreakpoint = this.settings.defaultBreakpoint;
|
||||
|
||||
// Set up resize listener
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this._boundHandleResize);
|
||||
window.addEventListener('resize', this._boundHandleResize);
|
||||
|
||||
// Initial detection
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', this._boundHandleResize);
|
||||
}
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
_handleResize() {
|
||||
// Debounce resize events
|
||||
if (this._resizeTimeout) {
|
||||
clearTimeout(this._resizeTimeout);
|
||||
}
|
||||
|
||||
this._resizeTimeout = setTimeout(() => {
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
|
||||
_updateBreakpoint(width) {
|
||||
const newBreakpoint = this.getBreakpointForWidth(width);
|
||||
|
||||
if (newBreakpoint !== this.currentBreakpoint) {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = newBreakpoint;
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: newBreakpoint,
|
||||
previousBreakpoint,
|
||||
width
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getBreakpointForWidth(width) {
|
||||
if (!this.settings.enabled) {
|
||||
return this.settings.defaultBreakpoint;
|
||||
}
|
||||
|
||||
const breakpoints = this.settings.breakpoints;
|
||||
|
||||
for (const bp of breakpoints) {
|
||||
const minMatch = bp.minWidth === undefined || width >= bp.minWidth;
|
||||
const maxMatch = bp.maxWidth === undefined || width <= bp.maxWidth;
|
||||
|
||||
if (minMatch && maxMatch) {
|
||||
return bp.id;
|
||||
}
|
||||
}
|
||||
|
||||
return this.settings.defaultBreakpoint;
|
||||
}
|
||||
|
||||
getCurrentBreakpoint() {
|
||||
return this.currentBreakpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cascade order for value inheritance.
|
||||
* Desktop-first: ['desktop', 'tablet', 'phone', 'smallPhone']
|
||||
* Mobile-first: ['smallPhone', 'phone', 'tablet', 'desktop']
|
||||
*/
|
||||
getCascadeOrder() {
|
||||
const breakpointIds = this.settings.breakpoints.map(bp => bp.id);
|
||||
|
||||
if (this.settings.cascadeDirection === 'mobile-first') {
|
||||
return breakpointIds.slice().reverse();
|
||||
}
|
||||
|
||||
return breakpointIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakpoints that a given breakpoint inherits from.
|
||||
* For desktop-first with current='phone':
|
||||
* returns ['tablet', 'desktop'] (phone inherits from tablet, which inherits from desktop)
|
||||
*/
|
||||
getInheritanceChain(breakpointId) {
|
||||
const cascadeOrder = this.getCascadeOrder();
|
||||
const currentIndex = cascadeOrder.indexOf(breakpointId);
|
||||
|
||||
if (currentIndex <= 0) return []; // First in cascade inherits from nothing
|
||||
|
||||
return cascadeOrder.slice(0, currentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to breakpoint changes.
|
||||
* Returns unsubscribe function.
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this.on('breakpointChanged', callback);
|
||||
return () => this.off('breakpointChanged', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a breakpoint (for testing/preview).
|
||||
* Pass null to return to auto-detection.
|
||||
*/
|
||||
forceBreakpoint(breakpointId) {
|
||||
if (breakpointId === null) {
|
||||
// Return to auto-detection
|
||||
if (typeof window !== 'undefined') {
|
||||
this._updateBreakpoint(window.innerWidth);
|
||||
}
|
||||
} else {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = breakpointId;
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: breakpointId,
|
||||
previousBreakpoint,
|
||||
forced: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const breakpointManager = new BreakpointManager();
|
||||
|
||||
module.exports = breakpointManager;
|
||||
```
|
||||
|
||||
### Step 2: Integrate with GraphModel
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('../breakpointmanager');
|
||||
|
||||
// In setSettings method, initialize breakpoint manager
|
||||
GraphModel.prototype.setSettings = function(settings) {
|
||||
this.settings = settings;
|
||||
|
||||
// Initialize breakpoint manager with project settings
|
||||
if (settings.responsiveBreakpoints) {
|
||||
breakpointManager.initialize(settings.responsiveBreakpoints);
|
||||
}
|
||||
|
||||
this.emit('projectSettingsChanged', settings);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Add Value Resolution to Node Base
|
||||
|
||||
**File:** `packages/noodl-runtime/src/nodes/nodebase.js` (or equivalent base class)
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('../breakpointmanager');
|
||||
|
||||
// Add to node initialization
|
||||
{
|
||||
_initializeBreakpointSupport() {
|
||||
// Subscribe to breakpoint changes
|
||||
this._breakpointUnsubscribe = breakpointManager.subscribe(
|
||||
this._onBreakpointChanged.bind(this)
|
||||
);
|
||||
},
|
||||
|
||||
_disposeBreakpointSupport() {
|
||||
if (this._breakpointUnsubscribe) {
|
||||
this._breakpointUnsubscribe();
|
||||
this._breakpointUnsubscribe = null;
|
||||
}
|
||||
},
|
||||
|
||||
_onBreakpointChanged({ breakpoint, previousBreakpoint }) {
|
||||
// Re-apply all breakpoint-aware properties
|
||||
this._applyBreakpointValues();
|
||||
},
|
||||
|
||||
_applyBreakpointValues() {
|
||||
const ports = this.getPorts ? this.getPorts('input') : [];
|
||||
|
||||
for (const port of ports) {
|
||||
if (port.allowBreakpoints) {
|
||||
const value = this.getResolvedParameterValue(port.name);
|
||||
this._applyParameterValue(port.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Force re-render if this is a React node
|
||||
if (this.forceUpdate) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the resolved value for a parameter, considering breakpoints and cascade.
|
||||
*/
|
||||
getResolvedParameterValue(name) {
|
||||
const port = this.getPort ? this.getPort(name, 'input') : null;
|
||||
|
||||
// If not breakpoint-aware, just return the base value
|
||||
if (!port || !port.allowBreakpoints) {
|
||||
return this.getParameterValue(name);
|
||||
}
|
||||
|
||||
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
|
||||
const settings = breakpointManager.settings;
|
||||
|
||||
// If at default breakpoint, use base parameters
|
||||
if (currentBreakpoint === settings.defaultBreakpoint) {
|
||||
return this.getParameterValue(name);
|
||||
}
|
||||
|
||||
// Check for value at current breakpoint
|
||||
if (this._model.breakpointParameters?.[currentBreakpoint]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[currentBreakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade: check inheritance chain
|
||||
const inheritanceChain = breakpointManager.getInheritanceChain(currentBreakpoint);
|
||||
|
||||
for (const bp of inheritanceChain.reverse()) { // Check from closest to furthest
|
||||
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to base parameters
|
||||
return this.getParameterValue(name);
|
||||
},
|
||||
|
||||
_applyParameterValue(name, value) {
|
||||
// Override in specific node types to apply the value
|
||||
// For visual nodes, this might update CSS properties
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Integrate with Visual Nodes
|
||||
|
||||
**File:** `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` (or equivalent)
|
||||
|
||||
```javascript
|
||||
const breakpointManager = require('@noodl/runtime/src/breakpointmanager');
|
||||
|
||||
// In visual node base
|
||||
|
||||
{
|
||||
initialize() {
|
||||
// ... existing initialization
|
||||
|
||||
// Set up breakpoint support
|
||||
this._initializeBreakpointSupport();
|
||||
},
|
||||
|
||||
_onNodeDeleted() {
|
||||
// ... existing cleanup
|
||||
|
||||
this._disposeBreakpointSupport();
|
||||
},
|
||||
|
||||
// Override to apply CSS property values
|
||||
_applyParameterValue(name, value) {
|
||||
// Map parameter name to CSS property
|
||||
const cssProperty = this._getCSSPropertyForParameter(name);
|
||||
|
||||
if (cssProperty && this._internal.element) {
|
||||
this._internal.element.style[cssProperty] = value;
|
||||
}
|
||||
|
||||
// Or if using React, set state/props
|
||||
if (this._internal.reactComponent) {
|
||||
// Trigger re-render with new value
|
||||
this.forceUpdate();
|
||||
}
|
||||
},
|
||||
|
||||
_getCSSPropertyForParameter(name) {
|
||||
// Map Noodl parameter names to CSS properties
|
||||
const mapping = {
|
||||
marginTop: 'marginTop',
|
||||
marginRight: 'marginRight',
|
||||
marginBottom: 'marginBottom',
|
||||
marginLeft: 'marginLeft',
|
||||
paddingTop: 'paddingTop',
|
||||
paddingRight: 'paddingRight',
|
||||
paddingBottom: 'paddingBottom',
|
||||
paddingLeft: 'paddingLeft',
|
||||
width: 'width',
|
||||
height: 'height',
|
||||
minWidth: 'minWidth',
|
||||
maxWidth: 'maxWidth',
|
||||
minHeight: 'minHeight',
|
||||
maxHeight: 'maxHeight',
|
||||
fontSize: 'fontSize',
|
||||
lineHeight: 'lineHeight',
|
||||
letterSpacing: 'letterSpacing',
|
||||
flexDirection: 'flexDirection',
|
||||
alignItems: 'alignItems',
|
||||
justifyContent: 'justifyContent',
|
||||
flexWrap: 'flexWrap',
|
||||
gap: 'gap'
|
||||
};
|
||||
|
||||
return mapping[name];
|
||||
},
|
||||
|
||||
// Override getStyle to use resolved breakpoint values
|
||||
getStyle(name) {
|
||||
// Check if this is a breakpoint-aware property
|
||||
const port = this.getPort(name, 'input');
|
||||
|
||||
if (port?.allowBreakpoints) {
|
||||
return this.getResolvedParameterValue(name);
|
||||
}
|
||||
|
||||
// Fall back to existing behavior
|
||||
return this._existingGetStyle(name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update React Component Props
|
||||
|
||||
**File:** For React-based visual nodes, update how props are computed
|
||||
|
||||
```javascript
|
||||
// In the React component wrapper
|
||||
|
||||
getReactProps() {
|
||||
const props = {};
|
||||
const ports = this.getPorts('input');
|
||||
|
||||
for (const port of ports) {
|
||||
// Use resolved value for breakpoint-aware properties
|
||||
if (port.allowBreakpoints) {
|
||||
props[port.name] = this.getResolvedParameterValue(port.name);
|
||||
} else {
|
||||
props[port.name] = this.getParameterValue(port.name);
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Add Transition Support (Optional Enhancement)
|
||||
|
||||
**File:** `packages/noodl-runtime/src/breakpointmanager.js`
|
||||
|
||||
```javascript
|
||||
// Add transition support for smooth breakpoint changes
|
||||
|
||||
class BreakpointManager extends EventEmitter {
|
||||
// ... existing code
|
||||
|
||||
_updateBreakpoint(width) {
|
||||
const newBreakpoint = this.getBreakpointForWidth(width);
|
||||
|
||||
if (newBreakpoint !== this.currentBreakpoint) {
|
||||
const previousBreakpoint = this.currentBreakpoint;
|
||||
this.currentBreakpoint = newBreakpoint;
|
||||
|
||||
// Add CSS class for transitions
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.classList.add('noodl-breakpoint-transitioning');
|
||||
|
||||
// Remove after transition completes
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove('noodl-breakpoint-transitioning');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.emit('breakpointChanged', {
|
||||
breakpoint: newBreakpoint,
|
||||
previousBreakpoint,
|
||||
width
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CSS:** Add to runtime styles
|
||||
|
||||
```css
|
||||
/* Smooth transitions when breakpoint changes */
|
||||
.noodl-breakpoint-transitioning * {
|
||||
transition:
|
||||
margin 0.2s ease-out,
|
||||
padding 0.2s ease-out,
|
||||
width 0.2s ease-out,
|
||||
height 0.2s ease-out,
|
||||
font-size 0.2s ease-out,
|
||||
gap 0.2s ease-out !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Editor-Runtime Communication
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts`
|
||||
|
||||
```typescript
|
||||
// When breakpoint settings change in editor, sync to runtime
|
||||
|
||||
onBreakpointSettingsChanged(settings: BreakpointSettings) {
|
||||
this.tryWebviewCall(() => {
|
||||
this.webview.executeJavaScript(`
|
||||
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
|
||||
window.NoodlRuntime.breakpointManager.initialize(${JSON.stringify(settings)});
|
||||
}
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
// Optionally: Force breakpoint for preview purposes
|
||||
forceRuntimeBreakpoint(breakpointId: string | null) {
|
||||
this.tryWebviewCall(() => {
|
||||
this.webview.executeJavaScript(`
|
||||
if (window.NoodlRuntime && window.NoodlRuntime.breakpointManager) {
|
||||
window.NoodlRuntime.breakpointManager.forceBreakpoint(${JSON.stringify(breakpointId)});
|
||||
}
|
||||
`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Initialize breakpointManager with settings |
|
||||
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add breakpoint value resolution |
|
||||
| `packages/noodl-viewer-react/src/nodes/visual/visualbase.js` | Wire up breakpoint subscriptions |
|
||||
| `packages/noodl-editor/src/editor/src/views/VisualCanvas/CanvasView.ts` | Add breakpoint sync to runtime |
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `packages/noodl-runtime/src/breakpointmanager.js` | Central breakpoint detection and management |
|
||||
| `packages/noodl-runtime/src/styles/breakpoints.css` | Optional transition styles |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] BreakpointManager correctly detects breakpoint from window width
|
||||
- [ ] BreakpointManager fires 'breakpointChanged' event on resize
|
||||
- [ ] Debouncing prevents excessive events during resize drag
|
||||
- [ ] Nodes receive breakpoint change notifications
|
||||
- [ ] Nodes apply correct breakpoint-specific values
|
||||
- [ ] Cascade works correctly (tablet inherits desktop values)
|
||||
- [ ] Mobile-first cascade works when configured
|
||||
- [ ] Values update smoothly during breakpoint transitions
|
||||
- [ ] `forceBreakpoint` works for testing/preview
|
||||
- [ ] Memory cleanup works (no leaks on node deletion)
|
||||
- [ ] Works in both editor preview and deployed app
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Resizing browser window changes applied breakpoint
|
||||
2. ✅ Visual nodes update their dimensions/spacing instantly
|
||||
3. ✅ Values cascade correctly when not overridden
|
||||
4. ✅ Both desktop-first and mobile-first work
|
||||
5. ✅ No performance issues with many nodes
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **SSR Considerations**: If Noodl supports SSR, `window` won't exist on server. Guard all window access with `typeof window !== 'undefined'`.
|
||||
|
||||
2. **Performance**: With many nodes subscribed, breakpoint changes could cause many re-renders. Consider:
|
||||
- Batch updates using requestAnimationFrame
|
||||
- Only re-render nodes whose values actually changed
|
||||
|
||||
3. **Debounce Tuning**: 100ms debounce is a starting point. May need adjustment based on feel.
|
||||
|
||||
4. **Transition Timing**: The CSS transition duration (0.2s) should match user expectations. Could make configurable.
|
||||
|
||||
5. **Initial Load**: On first load, breakpoint should be set BEFORE first render to avoid flash of wrong layout.
|
||||
|
||||
6. **Testing Breakpoints**: Add `breakpointManager.forceBreakpoint()` to allow testing different breakpoints without resizing window.
|
||||
|
||||
7. **React Strict Mode**: If using React Strict Mode, ensure subscriptions are properly cleaned up (may fire twice in dev).
|
||||
|
||||
## Performance Optimization Ideas
|
||||
|
||||
1. **Selective Updates**: Track which properties actually differ between breakpoints, only update those.
|
||||
|
||||
2. **CSS Variables**: Consider using CSS custom properties for breakpoint values, letting browser handle changes:
|
||||
```javascript
|
||||
// Set CSS variable per breakpoint
|
||||
document.documentElement.style.setProperty('--node-123-margin-top', '24px');
|
||||
```
|
||||
|
||||
3. **Batch Notifications**: Collect all changed nodes and update in single batch:
|
||||
```javascript
|
||||
requestAnimationFrame(() => {
|
||||
changedNodes.forEach(node => node.forceUpdate());
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,511 @@
|
||||
# Phase 4: Variants Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the existing Variants system to support breakpoint-specific values. When a user creates a variant (e.g., "Big Blue Button"), they should be able to define different margin/padding/width values for each breakpoint within that variant.
|
||||
|
||||
**Estimate:** 1-2 days
|
||||
|
||||
**Dependencies:** Phases 1-3
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `breakpointParameters` to VariantModel
|
||||
2. Extend variant editing UI to show breakpoint selector
|
||||
3. Implement value resolution hierarchy: Variant breakpoint → Variant base → Node base
|
||||
4. Ensure variant updates propagate to all nodes using that variant
|
||||
|
||||
## Value Resolution Hierarchy
|
||||
|
||||
When a node uses a variant, values are resolved in this order:
|
||||
|
||||
```
|
||||
1. Node instance breakpointParameters[currentBreakpoint][property]
|
||||
↓ (if undefined)
|
||||
2. Node instance parameters[property]
|
||||
↓ (if undefined)
|
||||
3. Variant breakpointParameters[currentBreakpoint][property]
|
||||
↓ (if undefined, cascade to larger breakpoints)
|
||||
4. Variant parameters[property]
|
||||
↓ (if undefined)
|
||||
5. Node type default
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
// Variant "Big Blue Button"
|
||||
{
|
||||
name: 'Big Blue Button',
|
||||
typename: 'net.noodl.visual.controls.button',
|
||||
parameters: {
|
||||
paddingLeft: '24px', // base padding
|
||||
paddingRight: '24px'
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { paddingLeft: '16px', paddingRight: '16px' },
|
||||
phone: { paddingLeft: '12px', paddingRight: '12px' }
|
||||
}
|
||||
}
|
||||
|
||||
// Node instance using this variant
|
||||
{
|
||||
variantName: 'Big Blue Button',
|
||||
parameters: {}, // no instance overrides
|
||||
breakpointParameters: {
|
||||
phone: { paddingLeft: '8px' } // only override phone left padding
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution for paddingLeft on phone:
|
||||
// 1. Check node.breakpointParameters.phone.paddingLeft → '8px' ✓ (use this)
|
||||
|
||||
// Resolution for paddingRight on phone:
|
||||
// 1. Check node.breakpointParameters.phone.paddingRight → undefined
|
||||
// 2. Check node.parameters.paddingRight → undefined
|
||||
// 3. Check variant.breakpointParameters.phone.paddingRight → '12px' ✓ (use this)
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend VariantModel
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
|
||||
|
||||
```typescript
|
||||
export class VariantModel extends Model {
|
||||
name: string;
|
||||
typename: string;
|
||||
parameters: Record<string, any>;
|
||||
stateParameters: Record<string, Record<string, any>>;
|
||||
stateTransitions: Record<string, any>;
|
||||
defaultStateTransitions: any;
|
||||
|
||||
// NEW
|
||||
breakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
constructor(args) {
|
||||
super();
|
||||
|
||||
this.name = args.name;
|
||||
this.typename = args.typename;
|
||||
this.parameters = {};
|
||||
this.stateParameters = {};
|
||||
this.stateTransitions = {};
|
||||
this.breakpointParameters = {}; // NEW
|
||||
}
|
||||
|
||||
// NEW methods
|
||||
hasBreakpointParameter(name: string, breakpoint: string): boolean {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getBreakpointParameter(name: string, breakpoint: string): any {
|
||||
return this.breakpointParameters?.[breakpoint]?.[name];
|
||||
}
|
||||
|
||||
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
|
||||
|
||||
const oldValue = this.breakpointParameters[breakpoint][name];
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.breakpointParameters[breakpoint][name];
|
||||
} else {
|
||||
this.breakpointParameters[breakpoint][name] = value;
|
||||
}
|
||||
|
||||
this.notifyListeners('variantParametersChanged', {
|
||||
name,
|
||||
value,
|
||||
breakpoint
|
||||
});
|
||||
|
||||
// Undo support
|
||||
if (args?.undo) {
|
||||
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
|
||||
|
||||
undo.push({
|
||||
label: args.label || 'change variant breakpoint parameter',
|
||||
do: () => this.setBreakpointParameter(name, value, breakpoint),
|
||||
undo: () => this.setBreakpointParameter(name, oldValue, breakpoint)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extend getParameter to support breakpoint context
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
let value;
|
||||
|
||||
// Check breakpoint-specific value
|
||||
if (args?.breakpoint && args.breakpoint !== 'desktop') {
|
||||
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// Check state-specific value (existing logic)
|
||||
if (args?.state && args.state !== 'neutral') {
|
||||
if (this.stateParameters?.[args.state]?.[name] !== undefined) {
|
||||
value = this.stateParameters[args.state][name];
|
||||
}
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// Check base parameters
|
||||
value = this.parameters[name];
|
||||
if (value !== undefined) return value;
|
||||
|
||||
// Get default from port
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
|
||||
// Check current breakpoint
|
||||
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade to larger breakpoints
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = cascadeOrder.indexOf(breakpoint);
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = cascadeOrder[i];
|
||||
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extend updateFromNode to include breakpoint parameters
|
||||
updateFromNode(node) {
|
||||
_merge(this.parameters, node.parameters);
|
||||
|
||||
// Merge breakpoint parameters
|
||||
if (node.breakpointParameters) {
|
||||
if (!this.breakpointParameters) this.breakpointParameters = {};
|
||||
for (const breakpoint in node.breakpointParameters) {
|
||||
if (!this.breakpointParameters[breakpoint]) {
|
||||
this.breakpointParameters[breakpoint] = {};
|
||||
}
|
||||
_merge(this.breakpointParameters[breakpoint], node.breakpointParameters[breakpoint]);
|
||||
}
|
||||
}
|
||||
|
||||
// ... existing state parameter merging
|
||||
|
||||
this.notifyListeners('variantParametersChanged');
|
||||
}
|
||||
|
||||
// Extend toJSON
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
typename: this.typename,
|
||||
parameters: this.parameters,
|
||||
stateParameters: this.stateParameters,
|
||||
stateTransitions: this.stateTransitions,
|
||||
defaultStateTransitions: this.defaultStateTransitions,
|
||||
breakpointParameters: this.breakpointParameters // NEW
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend Runtime Variant Handling
|
||||
|
||||
**File:** `packages/noodl-runtime/src/models/graphmodel.js`
|
||||
|
||||
```javascript
|
||||
// Add method to update variant breakpoint parameters
|
||||
GraphModel.prototype.updateVariantBreakpointParameter = function(
|
||||
variantName,
|
||||
variantTypeName,
|
||||
parameterName,
|
||||
parameterValue,
|
||||
breakpoint
|
||||
) {
|
||||
const variant = this.getVariant(variantTypeName, variantName);
|
||||
if (!variant) {
|
||||
console.log("updateVariantBreakpointParameter: can't find variant", variantName, variantTypeName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!variant.breakpointParameters) {
|
||||
variant.breakpointParameters = {};
|
||||
}
|
||||
|
||||
if (!variant.breakpointParameters[breakpoint]) {
|
||||
variant.breakpointParameters[breakpoint] = {};
|
||||
}
|
||||
|
||||
if (parameterValue === undefined) {
|
||||
delete variant.breakpointParameters[breakpoint][parameterName];
|
||||
} else {
|
||||
variant.breakpointParameters[breakpoint][parameterName] = parameterValue;
|
||||
}
|
||||
|
||||
this.emit('variantUpdated', variant);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Extend ModelProxy for Variant Editing
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
// ... existing properties
|
||||
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
|
||||
// Breakpoint handling
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
// Check for breakpoint-specific value in source
|
||||
const breakpointValue = source.getBreakpointParameter?.(name, this.breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
|
||||
// Cascade logic...
|
||||
}
|
||||
|
||||
// ... existing visual state and base parameter logic
|
||||
|
||||
return source.getParameter(name, {
|
||||
state: this.visualState,
|
||||
breakpoint: this.breakpoint
|
||||
});
|
||||
}
|
||||
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
|
||||
// If setting a breakpoint-specific value
|
||||
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
|
||||
target.setBreakpointParameter(name, value, this.breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing parameter setting logic
|
||||
}
|
||||
|
||||
isBreakpointValueInherited(name: string): boolean {
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return !source.hasBreakpointParameter?.(name, this.breakpoint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Variant Editor UI
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx`
|
||||
|
||||
```tsx
|
||||
// Add breakpoint selector to variant editing mode
|
||||
|
||||
export class VariantsEditor extends React.Component<VariantsEditorProps, State> {
|
||||
// ... existing implementation
|
||||
|
||||
renderEditMode() {
|
||||
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<div className="variants-edit-mode-header">Edit variant</div>
|
||||
|
||||
{/* Show breakpoint selector in variant edit mode */}
|
||||
{hasBreakpointPorts && (
|
||||
<BreakpointSelector
|
||||
breakpoints={this.getBreakpoints()}
|
||||
selectedBreakpoint={this.state.breakpoint || 'desktop'}
|
||||
overrideCounts={this.calculateVariantOverrideCounts()}
|
||||
onBreakpointChange={this.onBreakpointChanged.bind(this)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="variants-section">
|
||||
<label>{this.state.variant.name}</label>
|
||||
<button
|
||||
className="variants-button teal"
|
||||
style={{ marginLeft: 'auto', width: '78px' }}
|
||||
onClick={this.onDoneEditingVariant.bind(this)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onBreakpointChanged(breakpoint: string) {
|
||||
this.setState({ breakpoint });
|
||||
this.props.onBreakpointChanged?.(breakpoint);
|
||||
}
|
||||
|
||||
calculateVariantOverrideCounts(): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
const variant = this.state.variant;
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
|
||||
for (const bp of settings.breakpoints) {
|
||||
if (bp.id === settings.defaultBreakpoint) continue;
|
||||
|
||||
const overrides = variant.breakpointParameters?.[bp.id];
|
||||
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
hasBreakpointAwarePorts(): boolean {
|
||||
const type = NodeLibrary.instance.getNodeTypeWithName(this.state.variant?.typename);
|
||||
if (!type?.ports) return false;
|
||||
|
||||
return type.ports.some(p => p.allowBreakpoints);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Update NodeGraphNode Value Resolution
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
let value;
|
||||
|
||||
// 1. Check instance breakpoint parameters
|
||||
if (args?.breakpoint && args.breakpoint !== 'desktop') {
|
||||
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// 2. Check instance base parameters
|
||||
value = this.parameters[name];
|
||||
if (value !== undefined) return value;
|
||||
|
||||
// 3. Check variant (if has one)
|
||||
if (this.variant) {
|
||||
value = this.variant.getParameter(name, args);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
// 4. Get port default
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
|
||||
// Check current breakpoint
|
||||
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade to larger breakpoints (instance level)
|
||||
const settings = ProjectModel.instance.getBreakpointSettings();
|
||||
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
|
||||
const currentIndex = cascadeOrder.indexOf(breakpoint);
|
||||
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const bp = cascadeOrder[i];
|
||||
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
// Check variant breakpoint parameters
|
||||
if (this.variant) {
|
||||
return this.variant.getBreakpointParameterWithCascade(name, breakpoint);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Sync Variant Changes to Runtime
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/projectmodel.ts`
|
||||
|
||||
```typescript
|
||||
// When variant breakpoint parameters change, sync to runtime
|
||||
|
||||
onVariantParametersChanged(variant: VariantModel, changeInfo: any) {
|
||||
// ... existing sync logic
|
||||
|
||||
// If breakpoint parameter changed, notify runtime
|
||||
if (changeInfo.breakpoint) {
|
||||
this.graphModel.updateVariantBreakpointParameter(
|
||||
variant.name,
|
||||
variant.typename,
|
||||
changeInfo.name,
|
||||
changeInfo.value,
|
||||
changeInfo.breakpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/VariantModel.ts` | Add breakpointParameters field and methods |
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Update value resolution to check variant breakpoints |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle variant breakpoint context |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx` | Add breakpoint selector to variant edit mode |
|
||||
| `packages/noodl-runtime/src/models/graphmodel.js` | Add variant breakpoint parameter update method |
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Can create variant with breakpoint-specific values
|
||||
- [ ] Variant breakpoint values are saved to project JSON
|
||||
- [ ] Variant breakpoint values are loaded from project JSON
|
||||
- [ ] Node instance inherits variant breakpoint values correctly
|
||||
- [ ] Node instance can override specific variant breakpoint values
|
||||
- [ ] Cascade works: variant tablet inherits from variant desktop
|
||||
- [ ] Editing variant in "Edit variant" mode shows breakpoint selector
|
||||
- [ ] Changes to variant breakpoint values propagate to all instances
|
||||
- [ ] Undo/redo works for variant breakpoint changes
|
||||
- [ ] Runtime applies variant breakpoint values correctly
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Variants can have different values per breakpoint
|
||||
2. ✅ Node instances inherit variant breakpoint values
|
||||
3. ✅ Node instances can selectively override variant values
|
||||
4. ✅ UI allows editing variant breakpoint values
|
||||
5. ✅ Runtime correctly resolves variant + breakpoint hierarchy
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Resolution Order**: The hierarchy is complex. Make sure tests cover all combinations:
|
||||
- Instance breakpoint override > Instance base > Variant breakpoint > Variant base > Type default
|
||||
|
||||
2. **Variant Edit Mode**: When editing a variant, the breakpoint selector edits the VARIANT's breakpoint values, not the instance's.
|
||||
|
||||
3. **Variant Update Propagation**: When a variant's breakpoint values change, ALL nodes using that variant need to update. This could be performance-sensitive.
|
||||
|
||||
4. **State + Breakpoint + Variant**: The full combination is: variant/instance × state × breakpoint. For simplicity, we might NOT support visual state variations within variant breakpoint values (e.g., no "variant hover on tablet"). Confirm this is acceptable.
|
||||
|
||||
5. **Migration**: Existing variants won't have breakpointParameters. Handle gracefully (undefined → empty object).
|
||||
|
||||
## Complexity Note
|
||||
|
||||
This phase adds a third dimension to the value resolution:
|
||||
- **Visual States**: hover, pressed, disabled
|
||||
- **Breakpoints**: desktop, tablet, phone
|
||||
- **Variants**: named style variations
|
||||
|
||||
The full matrix can get complex. For this phase, we're keeping visual states and breakpoints as independent axes (they don't interact with each other within variants). A future phase could add combined state+breakpoint support if needed.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Phase 5: Visual States + Breakpoints Combo
|
||||
|
||||
## Overview
|
||||
|
||||
Enable granular control where users can define values for specific combinations of visual state AND breakpoint. For example: "button hover state on tablet" can have different padding than "button hover state on desktop".
|
||||
|
||||
**Estimate:** 2 days
|
||||
|
||||
**Dependencies:** Phases 1-4
|
||||
|
||||
## Goals
|
||||
|
||||
1. Add `stateBreakpointParameters` storage for combined state+breakpoint values
|
||||
2. Implement resolution hierarchy with combo values at highest priority
|
||||
3. Update property panel UI to show combo editing option
|
||||
4. Ensure runtime correctly resolves combo values
|
||||
|
||||
## When This Is Useful
|
||||
|
||||
Without combo support:
|
||||
- Button hover padding is `20px` (all breakpoints)
|
||||
- Button tablet padding is `16px` (all states)
|
||||
- When hovering on tablet → ambiguous! Which wins?
|
||||
|
||||
With combo support:
|
||||
- Can explicitly set: "button hover ON tablet = `18px`"
|
||||
- Clear, deterministic resolution
|
||||
|
||||
## Data Model
|
||||
|
||||
```javascript
|
||||
{
|
||||
parameters: {
|
||||
paddingLeft: '24px' // base
|
||||
},
|
||||
stateParameters: {
|
||||
hover: { paddingLeft: '28px' } // hover state (all breakpoints)
|
||||
},
|
||||
breakpointParameters: {
|
||||
tablet: { paddingLeft: '16px' } // tablet (all states)
|
||||
},
|
||||
// NEW: Combined state + breakpoint
|
||||
stateBreakpointParameters: {
|
||||
'hover:tablet': { paddingLeft: '20px' }, // hover ON tablet
|
||||
'hover:phone': { paddingLeft: '14px' }, // hover ON phone
|
||||
'pressed:tablet': { paddingLeft: '18px' } // pressed ON tablet
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resolution Hierarchy
|
||||
|
||||
From highest to lowest priority:
|
||||
|
||||
```
|
||||
1. stateBreakpointParameters['hover:tablet'] // Most specific
|
||||
↓ (if undefined)
|
||||
2. stateParameters['hover'] // State-specific
|
||||
↓ (if undefined)
|
||||
3. breakpointParameters['tablet'] // Breakpoint-specific
|
||||
↓ (if undefined, cascade to larger breakpoints)
|
||||
4. parameters // Base value
|
||||
↓ (if undefined)
|
||||
5. variant values (same hierarchy)
|
||||
↓ (if undefined)
|
||||
6. type default
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Extend NodeGraphNode Model
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
export class NodeGraphNode {
|
||||
// ... existing properties
|
||||
|
||||
// NEW
|
||||
stateBreakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
constructor(args) {
|
||||
// ... existing initialization
|
||||
this.stateBreakpointParameters = args.stateBreakpointParameters || {};
|
||||
}
|
||||
|
||||
// NEW methods
|
||||
getStateBreakpointKey(state: string, breakpoint: string): string {
|
||||
return `${state}:${breakpoint}`;
|
||||
}
|
||||
|
||||
hasStateBreakpointParameter(name: string, state: string, breakpoint: string): boolean {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
return this.stateBreakpointParameters?.[key]?.[name] !== undefined;
|
||||
}
|
||||
|
||||
getStateBreakpointParameter(name: string, state: string, breakpoint: string): any {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
return this.stateBreakpointParameters?.[key]?.[name];
|
||||
}
|
||||
|
||||
setStateBreakpointParameter(
|
||||
name: string,
|
||||
value: any,
|
||||
state: string,
|
||||
breakpoint: string,
|
||||
args?: any
|
||||
): void {
|
||||
const key = this.getStateBreakpointKey(state, breakpoint);
|
||||
|
||||
if (!this.stateBreakpointParameters) {
|
||||
this.stateBreakpointParameters = {};
|
||||
}
|
||||
if (!this.stateBreakpointParameters[key]) {
|
||||
this.stateBreakpointParameters[key] = {};
|
||||
}
|
||||
|
||||
const oldValue = this.stateBreakpointParameters[key][name];
|
||||
|
||||
if (value === undefined) {
|
||||
delete this.stateBreakpointParameters[key][name];
|
||||
// Clean up empty objects
|
||||
if (Object.keys(this.stateBreakpointParameters[key]).length === 0) {
|
||||
delete this.stateBreakpointParameters[key];
|
||||
}
|
||||
} else {
|
||||
this.stateBreakpointParameters[key][name] = value;
|
||||
}
|
||||
|
||||
this.notifyListeners('parametersChanged', {
|
||||
name,
|
||||
value,
|
||||
state,
|
||||
breakpoint,
|
||||
combo: true
|
||||
});
|
||||
|
||||
// Undo support
|
||||
if (args?.undo) {
|
||||
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
|
||||
|
||||
undo.push({
|
||||
label: args.label || `change ${name} for ${state} on ${breakpoint}`,
|
||||
do: () => this.setStateBreakpointParameter(name, value, state, breakpoint),
|
||||
undo: () => this.setStateBreakpointParameter(name, oldValue, state, breakpoint)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Updated getParameter with full resolution
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
const state = args?.state;
|
||||
const breakpoint = args?.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
// 1. Check state + breakpoint combo (most specific)
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== defaultBreakpoint) {
|
||||
const comboValue = this.getStateBreakpointParameter(name, state, breakpoint);
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
|
||||
// 2. Check state-specific value
|
||||
if (state && state !== 'neutral') {
|
||||
if (this.stateParameters?.[state]?.[name] !== undefined) {
|
||||
return this.stateParameters[state][name];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check breakpoint-specific value (with cascade)
|
||||
if (breakpoint && breakpoint !== defaultBreakpoint) {
|
||||
const breakpointValue = this.getBreakpointParameterWithCascade(name, breakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
}
|
||||
|
||||
// 4. Check base parameters
|
||||
if (this.parameters[name] !== undefined) {
|
||||
return this.parameters[name];
|
||||
}
|
||||
|
||||
// 5. Check variant (with same hierarchy)
|
||||
if (this.variant) {
|
||||
return this.variant.getParameter(name, args);
|
||||
}
|
||||
|
||||
// 6. Type default
|
||||
const port = this.getPort(name, 'input');
|
||||
return port?.default;
|
||||
}
|
||||
|
||||
// Extend toJSON
|
||||
toJSON(): object {
|
||||
return {
|
||||
...existingFields,
|
||||
stateBreakpointParameters: this.stateBreakpointParameters
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Extend ModelProxy
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts`
|
||||
|
||||
```typescript
|
||||
export class ModelProxy {
|
||||
// ... existing properties
|
||||
|
||||
getParameter(name: string) {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
// Check if both state and breakpoint are set (combo scenario)
|
||||
const hasState = state && state !== 'neutral';
|
||||
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
|
||||
|
||||
// For combo: only check if BOTH the property allows states AND breakpoints
|
||||
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
const comboValue = source.getStateBreakpointParameter?.(name, state, breakpoint);
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
|
||||
// ... existing resolution logic
|
||||
return source.getParameter(name, { state, breakpoint });
|
||||
}
|
||||
|
||||
setParameter(name: string, value: any, args: any = {}) {
|
||||
const port = this.model.getPort(name, 'input');
|
||||
const target = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
const defaultBreakpoint = ProjectModel.instance.getBreakpointSettings().defaultBreakpoint;
|
||||
|
||||
const hasState = state && state !== 'neutral';
|
||||
const hasBreakpoint = breakpoint && breakpoint !== defaultBreakpoint;
|
||||
|
||||
// If BOTH state and breakpoint are active, and property supports both
|
||||
if (hasState && hasBreakpoint && port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
target.setStateBreakpointParameter(name, value, state, breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If only breakpoint (and property supports it)
|
||||
if (hasBreakpoint && port?.allowBreakpoints) {
|
||||
target.setBreakpointParameter(name, value, breakpoint, {
|
||||
...args,
|
||||
undo: args.undo
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ... existing parameter setting logic (state or base)
|
||||
args.state = state;
|
||||
target.setParameter(name, value, args);
|
||||
}
|
||||
|
||||
// NEW: Check if current value is from combo
|
||||
isComboValue(name: string): boolean {
|
||||
if (!this.visualState || this.visualState === 'neutral') return false;
|
||||
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
|
||||
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
return source.hasStateBreakpointParameter?.(name, this.visualState, this.breakpoint) || false;
|
||||
}
|
||||
|
||||
// NEW: Get info about where current value comes from
|
||||
getValueSource(name: string): 'combo' | 'state' | 'breakpoint' | 'base' {
|
||||
const source = this.editMode === 'variant' ? this.model.variant : this.model;
|
||||
const state = this.visualState;
|
||||
const breakpoint = this.breakpoint;
|
||||
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
|
||||
if (source.hasStateBreakpointParameter?.(name, state, breakpoint)) {
|
||||
return 'combo';
|
||||
}
|
||||
}
|
||||
|
||||
if (state && state !== 'neutral') {
|
||||
if (source.stateParameters?.[state]?.[name] !== undefined) {
|
||||
return 'state';
|
||||
}
|
||||
}
|
||||
|
||||
if (breakpoint && breakpoint !== 'desktop') {
|
||||
if (source.hasBreakpointParameter?.(name, breakpoint)) {
|
||||
return 'breakpoint';
|
||||
}
|
||||
}
|
||||
|
||||
return 'base';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Update Property Panel UI
|
||||
|
||||
**File:** Update property row to show combo indicators
|
||||
|
||||
```tsx
|
||||
// In PropertyPanelRow or equivalent
|
||||
|
||||
export function PropertyPanelRow({
|
||||
label,
|
||||
children,
|
||||
isBreakpointAware,
|
||||
allowsVisualStates,
|
||||
valueSource, // 'combo' | 'state' | 'breakpoint' | 'base'
|
||||
currentState,
|
||||
currentBreakpoint,
|
||||
onReset
|
||||
}: PropertyPanelRowProps) {
|
||||
|
||||
function getIndicator() {
|
||||
switch (valueSource) {
|
||||
case 'combo':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentState} on ${currentBreakpoint}`}>
|
||||
<span className={css.ComboIndicator}>
|
||||
● {currentState} + {currentBreakpoint}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'state':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentState} state`}>
|
||||
<span className={css.StateIndicator}>● {currentState}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'breakpoint':
|
||||
return (
|
||||
<Tooltip content={`Set for ${currentBreakpoint}`}>
|
||||
<span className={css.BreakpointIndicator}>● {currentBreakpoint}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case 'base':
|
||||
default:
|
||||
if (currentState !== 'neutral' || currentBreakpoint !== 'desktop') {
|
||||
return <span className={css.Inherited}>(inherited)</span>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.Root}>
|
||||
<label className={css.Label}>{label}</label>
|
||||
<div className={css.InputContainer}>
|
||||
{children}
|
||||
{getIndicator()}
|
||||
{valueSource !== 'base' && onReset && (
|
||||
<button className={css.ResetButton} onClick={onReset}>
|
||||
<Icon icon={IconName.Undo} size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Runtime Resolution
|
||||
|
||||
**File:** `packages/noodl-runtime/src/nodes/nodebase.js`
|
||||
|
||||
```javascript
|
||||
{
|
||||
getResolvedParameterValue(name) {
|
||||
const port = this.getPort ? this.getPort(name, 'input') : null;
|
||||
const currentBreakpoint = breakpointManager.getCurrentBreakpoint();
|
||||
const currentState = this._internal?.currentVisualState || 'default';
|
||||
const defaultBreakpoint = breakpointManager.settings?.defaultBreakpoint || 'desktop';
|
||||
|
||||
// 1. Check combo value (state + breakpoint)
|
||||
if (port?.allowVisualStates && port?.allowBreakpoints) {
|
||||
if (currentState !== 'default' && currentBreakpoint !== defaultBreakpoint) {
|
||||
const comboKey = `${currentState}:${currentBreakpoint}`;
|
||||
const comboValue = this._model.stateBreakpointParameters?.[comboKey]?.[name];
|
||||
if (comboValue !== undefined) return comboValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check state-specific value
|
||||
if (port?.allowVisualStates && currentState !== 'default') {
|
||||
const stateValue = this._model.stateParameters?.[currentState]?.[name];
|
||||
if (stateValue !== undefined) return stateValue;
|
||||
}
|
||||
|
||||
// 3. Check breakpoint-specific value (with cascade)
|
||||
if (port?.allowBreakpoints && currentBreakpoint !== defaultBreakpoint) {
|
||||
const breakpointValue = this.getBreakpointValueWithCascade(name, currentBreakpoint);
|
||||
if (breakpointValue !== undefined) return breakpointValue;
|
||||
}
|
||||
|
||||
// 4. Base parameters
|
||||
return this.getParameterValue(name);
|
||||
},
|
||||
|
||||
getBreakpointValueWithCascade(name, breakpoint) {
|
||||
// Check current breakpoint
|
||||
if (this._model.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[breakpoint][name];
|
||||
}
|
||||
|
||||
// Cascade
|
||||
const inheritanceChain = breakpointManager.getInheritanceChain(breakpoint);
|
||||
for (const bp of inheritanceChain.reverse()) {
|
||||
if (this._model.breakpointParameters?.[bp]?.[name] !== undefined) {
|
||||
return this._model.breakpointParameters[bp][name];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Extend VariantModel (Optional)
|
||||
|
||||
If we want variants to also support combo values:
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/VariantModel.ts`
|
||||
|
||||
```typescript
|
||||
export class VariantModel extends Model {
|
||||
// ... existing properties
|
||||
|
||||
stateBreakpointParameters: Record<string, Record<string, any>>;
|
||||
|
||||
// Add similar methods as NodeGraphNode:
|
||||
// - hasStateBreakpointParameter
|
||||
// - getStateBreakpointParameter
|
||||
// - setStateBreakpointParameter
|
||||
|
||||
// Update getParameter to include combo resolution
|
||||
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
|
||||
const state = args?.state;
|
||||
const breakpoint = args?.breakpoint;
|
||||
|
||||
// 1. Check combo
|
||||
if (state && state !== 'neutral' && breakpoint && breakpoint !== 'desktop') {
|
||||
const comboKey = `${state}:${breakpoint}`;
|
||||
if (this.stateBreakpointParameters?.[comboKey]?.[name] !== undefined) {
|
||||
return this.stateBreakpointParameters[comboKey][name];
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of resolution hierarchy
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Update Serialization
|
||||
|
||||
**File:** `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts`
|
||||
|
||||
```typescript
|
||||
// In toJSON()
|
||||
toJSON(): object {
|
||||
const json: any = {
|
||||
id: this.id,
|
||||
type: this.type.name,
|
||||
parameters: this.parameters,
|
||||
// ... other fields
|
||||
};
|
||||
|
||||
// Only include if not empty
|
||||
if (this.stateParameters && Object.keys(this.stateParameters).length > 0) {
|
||||
json.stateParameters = this.stateParameters;
|
||||
}
|
||||
|
||||
if (this.breakpointParameters && Object.keys(this.breakpointParameters).length > 0) {
|
||||
json.breakpointParameters = this.breakpointParameters;
|
||||
}
|
||||
|
||||
if (this.stateBreakpointParameters && Object.keys(this.stateBreakpointParameters).length > 0) {
|
||||
json.stateBreakpointParameters = this.stateBreakpointParameters;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
// In fromJSON / constructor
|
||||
static fromJSON(json) {
|
||||
return new NodeGraphNode({
|
||||
...json,
|
||||
stateBreakpointParameters: json.stateBreakpointParameters || {}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts` | Add stateBreakpointParameters field and methods |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts` | Handle combo context in get/setParameter |
|
||||
| `packages/noodl-editor/src/editor/src/views/panels/propertyeditor/DataTypes/Ports.ts` | Pass combo info to property rows |
|
||||
| `packages/noodl-runtime/src/nodes/nodebase.js` | Add combo resolution to getResolvedParameterValue |
|
||||
| `packages/noodl-runtime/src/models/nodemodel.js` | Add stateBreakpointParameters storage |
|
||||
|
||||
## Files to Create
|
||||
|
||||
None - this phase extends existing files.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Can set combo value (e.g., hover + tablet)
|
||||
- [ ] Combo value takes priority over individual state/breakpoint values
|
||||
- [ ] When state OR breakpoint changes, combo value is no longer used (falls through to next priority)
|
||||
- [ ] Combo values are saved to project JSON
|
||||
- [ ] Combo values are loaded from project JSON
|
||||
- [ ] UI shows correct indicator for combo values
|
||||
- [ ] Reset button clears combo value correctly
|
||||
- [ ] Runtime applies combo values correctly when both conditions match
|
||||
- [ ] Undo/redo works for combo value changes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. ✅ Can define "hover on tablet" as distinct from "hover" and "tablet"
|
||||
2. ✅ Clear UI indication of what level value is set at
|
||||
3. ✅ Values fall through correctly when combo doesn't match
|
||||
4. ✅ Runtime correctly identifies when combo conditions are met
|
||||
|
||||
## Gotchas & Notes
|
||||
|
||||
1. **Complexity**: This adds significant complexity. Consider if this is really needed, or if the simpler "state values apply across all breakpoints" is sufficient.
|
||||
|
||||
2. **UI Clarity**: The UI needs to clearly communicate which level a value is set at. Consider using different colors:
|
||||
- Purple dot: combo value (state + breakpoint)
|
||||
- Blue dot: state value only
|
||||
- Green dot: breakpoint value only
|
||||
- Gray/no dot: base value
|
||||
|
||||
3. **Property Support**: Only properties that have BOTH `allowVisualStates: true` AND `allowBreakpoints: true` can have combo values. In practice, this might be a small subset (mostly spacing properties for interactive elements).
|
||||
|
||||
4. **Variant Complexity**: If variants also support combos, the full hierarchy becomes:
|
||||
- Instance combo → Instance state → Instance breakpoint → Instance base
|
||||
- → Variant combo → Variant state → Variant breakpoint → Variant base
|
||||
- → Type default
|
||||
|
||||
This is 9 levels! Consider if variant combo support is worth it.
|
||||
|
||||
5. **Performance**: With 4 breakpoints × 4 states × N properties, the parameter space grows quickly. Make sure resolution is efficient.
|
||||
|
||||
## Alternative: Simpler Approach
|
||||
|
||||
If combo complexity is too high, consider this simpler alternative:
|
||||
|
||||
**States inherit from breakpoint, not base:**
|
||||
|
||||
```
|
||||
Current: state value = same across all breakpoints
|
||||
Alternative: state value = applied ON TOP OF current breakpoint value
|
||||
```
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
// Base: paddingLeft = 24px
|
||||
// Tablet: paddingLeft = 16px
|
||||
// Hover state: paddingLeft = +4px (relative)
|
||||
|
||||
// Result:
|
||||
// Desktop hover = 24 + 4 = 28px
|
||||
// Tablet hover = 16 + 4 = 20px
|
||||
```
|
||||
|
||||
This avoids needing explicit combo values but requires supporting relative/delta values for states, which has its own complexity.
|
||||
@@ -0,0 +1,489 @@
|
||||
# TASK: Video Player Node
|
||||
|
||||
**Task ID:** NODES-001
|
||||
**Priority:** Medium-High
|
||||
**Estimated Effort:** 16-24 hours
|
||||
**Prerequisites:** React 18.3+ runtime (completed)
|
||||
**Status:** Ready for Implementation
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Create a comprehensive Video Player node that handles video playback from URLs or blobs with rich inputs and outputs for complete video management. This addresses a gap in Noodl's visual node offerings - currently users must resort to Function nodes for anything beyond basic video display.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- **Table stakes feature** - Users expect video playback in any modern low-code tool
|
||||
- **App builder unlock** - Enables video-centric apps (portfolios, e-learning, social, editors)
|
||||
- **Blob support differentiator** - Play local files without server upload (rare in competitors)
|
||||
- **Community requested** - Direct request from OpenNoodl community
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Video plays from URL (mp4, webm)
|
||||
- [ ] Video plays from blob/File object (from File Picker node)
|
||||
- [ ] All playback controls work via signal inputs
|
||||
- [ ] Time tracking outputs update in real-time
|
||||
- [ ] Events fire correctly for all lifecycle moments
|
||||
- [ ] Fullscreen and Picture-in-Picture work cross-browser
|
||||
- [ ] Frame capture produces valid base64 image
|
||||
- [ ] Captions/subtitles display from VTT file
|
||||
- [ ] Works in both editor preview and deployed apps
|
||||
- [ ] Performance: time updates don't cause UI jank
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Node Registration
|
||||
|
||||
```
|
||||
Location: packages/noodl-viewer-react/src/nodes/visual/videoplayer.js (new file)
|
||||
Type: Visual/Frontend node using createNodeFromReactComponent
|
||||
Category: "Visual" or "UI Elements" > "Media"
|
||||
Name: net.noodl.visual.videoplayer
|
||||
Display Name: Video Player
|
||||
```
|
||||
|
||||
### Core Implementation Pattern
|
||||
|
||||
```javascript
|
||||
import { createNodeFromReactComponent } from '@noodl/react-component-node';
|
||||
|
||||
const VideoPlayer = createNodeFromReactComponent({
|
||||
name: 'net.noodl.visual.videoplayer',
|
||||
displayName: 'Video Player',
|
||||
category: 'Visual',
|
||||
docs: 'https://docs.noodl.net/nodes/visual/video-player',
|
||||
|
||||
// Standard visual node frame options
|
||||
frame: {
|
||||
dimensions: true,
|
||||
position: true,
|
||||
margins: true,
|
||||
align: true
|
||||
},
|
||||
|
||||
allowChildren: false,
|
||||
|
||||
getReactComponent() {
|
||||
return VideoPlayerComponent; // Defined below
|
||||
},
|
||||
|
||||
// ... inputs/outputs defined below
|
||||
});
|
||||
```
|
||||
|
||||
### React Component Structure
|
||||
|
||||
```javascript
|
||||
function VideoPlayerComponent(props) {
|
||||
const videoRef = useRef(null);
|
||||
const [state, setState] = useState({
|
||||
isPlaying: false,
|
||||
isPaused: true,
|
||||
isEnded: false,
|
||||
isBuffering: false,
|
||||
isSeeking: false,
|
||||
isFullscreen: false,
|
||||
isPiP: false,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
bufferedPercent: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0
|
||||
});
|
||||
|
||||
// Use deferred value for time to prevent jank
|
||||
const deferredTime = useDeferredValue(state.currentTime);
|
||||
|
||||
// ... event handlers, effects, signal handlers
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={props.style}
|
||||
src={props.url || undefined}
|
||||
poster={props.posterImage}
|
||||
controls={props.controlsVisible}
|
||||
loop={props.loop}
|
||||
muted={props.muted}
|
||||
autoPlay={props.autoplay}
|
||||
playsInline={props.playsInline}
|
||||
preload={props.preload}
|
||||
crossOrigin={props.crossOrigin}
|
||||
// ... all event handlers
|
||||
>
|
||||
{props.captionsUrl && (
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={props.captionsUrl}
|
||||
srcLang={props.captionsLanguage || 'en'}
|
||||
default={props.captionsEnabled}
|
||||
/>
|
||||
)}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input/Output Specification
|
||||
|
||||
### Inputs - Source
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| URL | string | - | Video URL (mp4, webm, ogg, hls) |
|
||||
| Blob | any | - | File/Blob object from File Picker |
|
||||
| Poster Image | string | - | Thumbnail URL shown before play |
|
||||
| Source Type | enum | auto | auto/mp4/webm/ogg/hls |
|
||||
|
||||
### Inputs - Playback Control (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Play | signal | Start playback |
|
||||
| Pause | signal | Pause playback |
|
||||
| Toggle Play/Pause | signal | Toggle current state |
|
||||
| Stop | signal | Pause and seek to 0 |
|
||||
| Seek To | signal | Seek to "Seek Time" value |
|
||||
| Skip Forward | signal | Skip forward by "Skip Amount" |
|
||||
| Skip Backward | signal | Skip backward by "Skip Amount" |
|
||||
|
||||
### Inputs - Playback Settings
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Seek Time | number | 0 | Target time for Seek To (seconds) |
|
||||
| Skip Amount | number | 10 | Seconds to skip forward/backward |
|
||||
| Volume | number | 1 | Volume level 0-1 |
|
||||
| Muted | boolean | false | Mute audio |
|
||||
| Playback Rate | number | 1 | Speed: 0.25-4 |
|
||||
| Loop | boolean | false | Loop playback |
|
||||
| Autoplay | boolean | false | Auto-start on load |
|
||||
| Preload | enum | auto | none/metadata/auto |
|
||||
| Controls Visible | boolean | true | Show native controls |
|
||||
|
||||
### Inputs - Advanced
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Start Time | number | 0 | Auto-seek on load |
|
||||
| End Time | number | - | Auto-pause/loop point |
|
||||
| Plays Inline | boolean | true | iOS inline playback |
|
||||
| Cross Origin | enum | anonymous | anonymous/use-credentials |
|
||||
| PiP Enabled | boolean | true | Allow Picture-in-Picture |
|
||||
|
||||
### Inputs - Captions
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| Captions URL | string | - | VTT subtitle file URL |
|
||||
| Captions Enabled | boolean | false | Show captions |
|
||||
| Captions Language | string | en | Language code |
|
||||
|
||||
### Inputs - Actions (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Enter Fullscreen | signal | Request fullscreen mode |
|
||||
| Exit Fullscreen | signal | Exit fullscreen mode |
|
||||
| Toggle Fullscreen | signal | Toggle fullscreen state |
|
||||
| Enter PiP | signal | Enter Picture-in-Picture |
|
||||
| Exit PiP | signal | Exit Picture-in-Picture |
|
||||
| Capture Frame | signal | Capture current frame to output |
|
||||
| Reload | signal | Reload video source |
|
||||
|
||||
### Outputs - State
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Is Playing | boolean | Currently playing |
|
||||
| Is Paused | boolean | Currently paused |
|
||||
| Is Ended | boolean | Playback ended |
|
||||
| Is Buffering | boolean | Waiting for data |
|
||||
| Is Seeking | boolean | Currently seeking |
|
||||
| Is Fullscreen | boolean | In fullscreen mode |
|
||||
| Is Picture-in-Picture | boolean | In PiP mode |
|
||||
| Has Error | boolean | Error occurred |
|
||||
| Error Message | string | Error description |
|
||||
|
||||
### Outputs - Time
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Current Time | number | Current position (seconds) |
|
||||
| Duration | number | Total duration (seconds) |
|
||||
| Progress | number | Position 0-1 |
|
||||
| Remaining Time | number | Time remaining (seconds) |
|
||||
| Formatted Current | string | "1:23" or "1:23:45" |
|
||||
| Formatted Duration | string | Total as formatted string |
|
||||
| Formatted Remaining | string | Remaining as formatted string |
|
||||
|
||||
### Outputs - Media Info
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Video Width | number | Native video width |
|
||||
| Video Height | number | Native video height |
|
||||
| Aspect Ratio | number | Width/height ratio |
|
||||
| Buffered Percent | number | Download progress 0-1 |
|
||||
| Ready State | number | HTML5 readyState 0-4 |
|
||||
|
||||
### Outputs - Events (Signals)
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Loaded Metadata | signal | Duration/dimensions available |
|
||||
| Can Play | signal | Ready to start playback |
|
||||
| Can Play Through | signal | Can play to end without buffering |
|
||||
| Play Started | signal | Playback started |
|
||||
| Paused | signal | Playback paused |
|
||||
| Ended | signal | Playback ended |
|
||||
| Seeking | signal | Seek operation started |
|
||||
| Seeked | signal | Seek operation completed |
|
||||
| Time Updated | signal | Time changed (frequent) |
|
||||
| Volume Changed | signal | Volume or mute changed |
|
||||
| Rate Changed | signal | Playback rate changed |
|
||||
| Entered Fullscreen | signal | Entered fullscreen |
|
||||
| Exited Fullscreen | signal | Exited fullscreen |
|
||||
| Entered PiP | signal | Entered Picture-in-Picture |
|
||||
| Exited PiP | signal | Exited Picture-in-Picture |
|
||||
| Error Occurred | signal | Error happened |
|
||||
| Buffering Started | signal | Started buffering |
|
||||
| Buffering Ended | signal | Finished buffering |
|
||||
|
||||
### Outputs - Special
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| Captured Frame | string | Base64 data URL of captured frame |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Playback (4-6 hours)
|
||||
- [ ] Create node file structure
|
||||
- [ ] Basic video element with URL support
|
||||
- [ ] Play/Pause/Stop signal inputs
|
||||
- [ ] Basic state outputs (isPlaying, isPaused, etc.)
|
||||
- [ ] Time outputs (currentTime, duration, progress)
|
||||
- [ ] Register node in node library
|
||||
|
||||
### Phase 2: Extended Controls (4-6 hours)
|
||||
- [ ] Seek functionality (seekTo, skipForward, skipBackward)
|
||||
- [ ] Volume and mute controls
|
||||
- [ ] Playback rate control
|
||||
- [ ] Loop and autoplay
|
||||
- [ ] All time-related event signals
|
||||
- [ ] Formatted time outputs
|
||||
|
||||
### Phase 3: Advanced Features (4-6 hours)
|
||||
- [ ] Blob/File support (from File Picker)
|
||||
- [ ] Fullscreen API integration
|
||||
- [ ] Picture-in-Picture API integration
|
||||
- [ ] Frame capture functionality
|
||||
- [ ] Start/End time range support
|
||||
- [ ] Buffering state and events
|
||||
|
||||
### Phase 4: Polish & Testing (4-6 hours)
|
||||
- [ ] Captions/subtitles support
|
||||
- [ ] Cross-browser testing (Chrome, Firefox, Safari, Edge)
|
||||
- [ ] Mobile testing (iOS Safari, Android Chrome)
|
||||
- [ ] Performance optimization (useDeferredValue for time)
|
||||
- [ ] Error handling and edge cases
|
||||
- [ ] Documentation
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
### New Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/visual/videoplayer.js # Main node
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/index.js # Register node
|
||||
packages/noodl-runtime/src/nodelibraryexport.js # Add to UI Elements category
|
||||
```
|
||||
|
||||
### Reference Files (existing patterns)
|
||||
```
|
||||
packages/noodl-viewer-react/src/nodes/visual/image.js # Similar visual node
|
||||
packages/noodl-viewer-react/src/nodes/visual/video.js # Existing basic video (if exists)
|
||||
packages/noodl-viewer-react/src/nodes/controls/button.js # Signal input patterns
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Manual Testing
|
||||
|
||||
**Basic Playback**
|
||||
- [ ] MP4 URL loads and plays
|
||||
- [ ] WebM URL loads and plays
|
||||
- [ ] Poster image shows before play
|
||||
- [ ] Native controls appear when enabled
|
||||
- [ ] Native controls hidden when disabled
|
||||
|
||||
**Signal Controls**
|
||||
- [ ] Play signal starts playback
|
||||
- [ ] Pause signal pauses playback
|
||||
- [ ] Toggle Play/Pause works correctly
|
||||
- [ ] Stop pauses and seeks to 0
|
||||
- [ ] Seek To jumps to correct time
|
||||
- [ ] Skip Forward/Backward work with Skip Amount
|
||||
|
||||
**State Outputs**
|
||||
- [ ] Is Playing true when playing, false otherwise
|
||||
- [ ] Is Paused true when paused
|
||||
- [ ] Is Ended true when video ends
|
||||
- [ ] Is Buffering true during buffering
|
||||
- [ ] Current Time updates during playback
|
||||
- [ ] Duration correct after load
|
||||
- [ ] Progress 0-1 range correct
|
||||
|
||||
**Events**
|
||||
- [ ] Loaded Metadata fires when ready
|
||||
- [ ] Play Started fires on play
|
||||
- [ ] Paused fires on pause
|
||||
- [ ] Ended fires when complete
|
||||
- [ ] Time Updated fires during playback
|
||||
|
||||
**Advanced Features**
|
||||
- [ ] Blob from File Picker plays correctly
|
||||
- [ ] Fullscreen enter/exit works
|
||||
- [ ] PiP enter/exit works (where supported)
|
||||
- [ ] Frame Capture produces valid image
|
||||
- [ ] Captions display from VTT file
|
||||
- [ ] Start Time auto-seeks on load
|
||||
- [ ] End Time auto-pauses/loops
|
||||
|
||||
**Cross-Browser**
|
||||
- [ ] Chrome (latest)
|
||||
- [ ] Firefox (latest)
|
||||
- [ ] Safari (latest)
|
||||
- [ ] Edge (latest)
|
||||
- [ ] iOS Safari
|
||||
- [ ] Android Chrome
|
||||
|
||||
**Edge Cases**
|
||||
- [ ] Invalid URL shows error state
|
||||
- [ ] Network error during playback
|
||||
- [ ] Rapid play/pause doesn't break
|
||||
- [ ] Seeking while buffering
|
||||
- [ ] Source change during playback
|
||||
- [ ] Multiple Video Player nodes on same page
|
||||
|
||||
---
|
||||
|
||||
## Code Examples for Users
|
||||
|
||||
### Basic Video Playback
|
||||
```
|
||||
[Video URL] → [Video Player]
|
||||
↓
|
||||
[Is Playing] → [If node for UI state]
|
||||
```
|
||||
|
||||
### Custom Controls
|
||||
```
|
||||
[Button "Play"] → Play signal → [Video Player]
|
||||
[Button "Pause"] → Pause signal ↗
|
||||
[Slider] → Seek Time + Seek To signal ↗
|
||||
↓
|
||||
[Current Time] → [Text display]
|
||||
[Duration] → [Text display]
|
||||
```
|
||||
|
||||
### Video Upload Preview
|
||||
```
|
||||
[File Picker] → Blob → [Video Player]
|
||||
↓
|
||||
[Capture Frame] → [Image node for thumbnail]
|
||||
```
|
||||
|
||||
### E-Learning Progress Tracking
|
||||
```
|
||||
[Video Player]
|
||||
↓
|
||||
[Progress] → [Progress Bar]
|
||||
[Ended] → [Mark Lesson Complete logic]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Time Update Throttling**: The `timeupdate` event fires frequently (4-66Hz). Use `useDeferredValue` to prevent connected nodes from causing frame drops.
|
||||
|
||||
2. **Blob Memory**: When using blob sources, ensure proper cleanup on source change to prevent memory leaks.
|
||||
|
||||
3. **Frame Capture**: Canvas operations are synchronous. For large videos, this may cause brief UI freeze. Document this limitation.
|
||||
|
||||
4. **Multiple Instances**: Test with 3-5 Video Player nodes on same page to ensure no conflicts.
|
||||
|
||||
---
|
||||
|
||||
## React 19 Benefits
|
||||
|
||||
While this node works on React 18.3, React 19 offers:
|
||||
|
||||
1. **`ref` as prop** - Cleaner implementation without `forwardRef` wrapper
|
||||
2. **`useDeferredValue` improvements** - Better time update performance
|
||||
3. **`useTransition` for seeking** - Non-blocking seek operations
|
||||
|
||||
```javascript
|
||||
// React 19 pattern for smooth seeking
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSeek(time) {
|
||||
startTransition(() => {
|
||||
videoRef.current.currentTime = time;
|
||||
});
|
||||
}
|
||||
// isPending can drive "Is Seeking" output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
After implementation, create:
|
||||
- [ ] Node reference page for docs site
|
||||
- [ ] Example project: "Video Gallery"
|
||||
- [ ] Example project: "Custom Video Controls"
|
||||
- [ ] Migration guide from Function-based video handling
|
||||
|
||||
---
|
||||
|
||||
## Notes & Gotchas
|
||||
|
||||
1. **iOS Autoplay**: iOS requires `playsInline` and `muted` for autoplay to work
|
||||
2. **CORS**: External videos may need proper CORS headers for frame capture
|
||||
3. **HLS/DASH**: May require additional libraries (hls.js, dash.js) - consider Phase 2 enhancement
|
||||
4. **Safari PiP**: Has different API than Chrome/Firefox
|
||||
5. **Fullscreen**: Different browsers have different fullscreen APIs - use unified helper
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- HLS/DASH streaming support via hls.js
|
||||
- Video filters/effects
|
||||
- Multiple audio tracks
|
||||
- Chapter markers
|
||||
- Thumbnail preview on seek (sprite sheet)
|
||||
- Analytics integration
|
||||
- DRM support
|
||||
Reference in New Issue
Block a user