mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 23:32:55 +01:00
620 lines
18 KiB
Markdown
620 lines
18 KiB
Markdown
# 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());
|
|
});
|
|
```
|