zelune 1.5.4 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/plugins/viteReactNativeFallbackPlugin.ts +13 -53
- package/src/canvas/CanvasApp.tsx +165 -57
- package/src/canvas/FlowArrowsLayer.tsx +2 -2
- package/src/canvas/ScreenArtboard.tsx +17 -3
- package/src/main.tsx +1 -0
- package/src/runtime/MockNavigationContext.tsx +577 -56
- package/src/runtime/nativewindShim.ts +972 -0
- package/src/runtime/reactNativeShim.ts +99 -0
- package/vite.config.ts +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Zelune — React Native Visual Canvas & MCP** project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.5.6] - 2026-10-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **NativeWind / Tailwind CSS `className` Runtime Engine (`src/runtime/nativewindShim.ts`, `src/runtime/reactNativeShim.ts`, `src/runtime/MockNavigationContext.tsx`, `vite.config.ts`)**:
|
|
9
|
+
- Automatically loads the target project's `tailwind.config.js` / `tailwind.config.ts` (`colors`, `fontSize`, `borderRadius`, `spacing`, `opacity`, `fontFamily`) in `vite.config.ts` and injects `window.__ZELUNE_TAILWIND_THEME__`.
|
|
10
|
+
- Added `resolveTailwindClassNameToStyle` and `withNativeWind` wrapper across all React Native and Expo primitives (`View`, `Text`, `Pressable`, `TouchableOpacity`, `ScrollView`, `FlatList`, `KeyboardAvoidingView`, `TextInput`, `Image`, `SafeAreaView`, `LinearGradient`, `BlurView`, `createAnimatedComponent`) so screens and UI components styled with NativeWind `className="..."` (including M3 semantic tokens like `bg-surface-container-high`, `text-on-surface`, `text-headline-md`, `text-title-sm`, `bg-primary/10`, etc.) render with full CSS styles instead of unstyled stacks.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Pinned Mobile Artboard `Dimensions` (`src/runtime/reactNativeShim.ts`, `src/runtime/MockNavigationContext.tsx`)**: Overrode `Dimensions.get('window' | 'screen')` and `useWindowDimensions()` to return `{ width: 393, height: 852, scale: 3, fontScale: 1 }` instead of the desktop browser window's `1440×900` dimensions, and upgraded the `expo-image` `<Image>` shim to use CSS `object-fit` inside an `overflow: hidden` container so carousel cards and hero images (e.g., on `HomeScreen`) size to exact 393×852 mobile proportions.
|
|
14
|
+
- **Wide Zoom-Out Range (`MIN_ZOOM = 0.08`), Multi-Row Grid Layout & Zero-Re-Render Pinch Zoom (`src/canvas/CanvasApp.tsx`)**:
|
|
15
|
+
- Lowered `MIN_ZOOM` from `25%` (`0.25`) to `8%` (`0.08`), updated `buildDefaultLayout` ("Grid" button) to arrange 6+ screens into a balanced multi-row grid, and enabled automatic fit-all on initial load so all screens can be viewed at once.
|
|
16
|
+
- Updated `applyFocalZoom` to keep full floating-point precision during active trackpad pinch/Ctrl-wheel zoom (eliminating subpixel focal-point oscillation), mutate `worldContainerRef.current.style.transform` and `zoomLabelRef.current.textContent` directly on the DOM without triggering 120Hz React re-renders of `<ScreenArtboard />`, and removed `willChange: 'transform'` bitmap caching so zooming in and out is completely smooth and vibration-free.
|
|
17
|
+
|
|
18
|
+
## [1.5.5] - 2026-10-23
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Zod, React Hook Form & Sizing Utility Shims (`src/runtime/MockNavigationContext.tsx`, `plugins/viteReactNativeFallbackPlugin.ts`)**: Added `createSafeChainableProxy()` / `SAFE_CHAINABLE_PROXY` (wrapping a callable function target in a recursive `Proxy` for unlimited method/property chains), full Zod schema shims (`z`, `string`, `number`, `boolean`, `object`, `array`, `union`, `literal`, `record`, `tuple`, `any`, `unknown`, `optional`, `nullable`, `coerce`, `zodResolver`, `yupResolver`), React Hook Form shims (`useForm`, `useFormContext`, `FormProvider`, `useWatch`, `useFieldArray`, `Controller` supporting `render={({ field, fieldState, formState }) => ...}`), and React Native responsive sizing/classname helpers (`scale`, `verticalScale`, `moderateScale`, `RFValue`, `RFPercentage`, `wp`, `hp`, `clsx`, `cn`, `twMerge`).
|
|
22
|
+
- **Universal Unmounted React Context Fallback (`src/runtime/MockNavigationContext.tsx`, `src/main.tsx`)**: Wrapped `React.createContext` and `React.useContext` (after `react-native-web` initializes its internal contexts) with `SAFE_CONTEXT_FALLBACK` (pre-populated with preview `user`, `profile`, `session`, `isAuthenticated: true`, `isLoading: false`, `colors`, `items: []`, `signIn`, `signOut`, `logout`, `refresh`, etc.) so screens rendered outside their root `app/_layout.tsx` providers never throw `useAuth must be used within an AuthProvider` (or `useTheme` / `useCart` / `useNotifications` equivalents).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Eliminated Canvas Panning Vibration / Jitter (`src/canvas/CanvasApp.tsx`, `src/canvas/ScreenArtboard.tsx`, `src/canvas/FlowArrowsLayer.tsx`)**:
|
|
26
|
+
- Updated `handleCanvasWheel` and `handleCanvasPointerDown` in `CanvasApp.tsx` to mutate `worldContainerRef.current.style.transform` (`translate3d(...) scale(...)`) directly on the DOM node with device-pixel rounding (`snapToDevicePixel`) and debounce `setPan` state synchronization to 120ms after movement stops, eliminating 120Hz React re-renders of all artboards while panning.
|
|
27
|
+
- Wrapped `ScreenArtboard` and `FlowArrowsLayer` in `React.memo`, scoped `selectedElement` and `inspectedElement` props to only the active screen, and suppressed `handlePointerMoveCapture` (`computeRelativeBox` / `setHoverBox`) while the canvas is moving or pointer buttons are pressed.
|
|
28
|
+
- Stabilized `useFocusEffect`, `useSegments`, `useQuery`, `useInfiniteQuery`, `useMutation`, and `useQueryClient` in `MockNavigationContext.tsx` so screens with inline `useFocusEffect` or `useEffect` dependencies do not trigger continuous re-render loops.
|
|
29
|
+
- **Light-Themed Screen Root Background & Image Overflow (`src/canvas/ScreenArtboard.tsx`)**: Set `backgroundColor: '#FFFFFF'`, `color: '#0F172A'`, `overflowY: 'auto'`, `overflowX: 'hidden'`, `minWidth: 0`, and `minHeight: 0` on the inner `<ScreenComponent />` mount container so screens that rely on React Native's default white root window background render with proper contrast and horizontally overflowing images are clipped to the 393px device frame.
|
|
30
|
+
|
|
5
31
|
## [1.5.4] - 2026-10-23
|
|
6
32
|
|
|
7
33
|
### Added
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zelune",
|
|
3
3
|
"displayName": "Zelune — React Native Visual Canvas",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.6",
|
|
5
5
|
"description": "Zelune MCP & Figma-style multi-screen React Native visual canvas with live surgical AST patching and navigation flow visualization for Google Antigravity.",
|
|
6
6
|
"logo": "assets/logo.svg",
|
|
7
7
|
"suggestedPrompts": [
|
|
@@ -484,53 +484,18 @@ export function viteReactNativeFallbackPlugin(
|
|
|
484
484
|
`import { View } from 'react-native';`,
|
|
485
485
|
`export * from ${JSON.stringify(normalizedShimPath)};`,
|
|
486
486
|
`import * as KnownShims from ${JSON.stringify(normalizedShimPath)};`,
|
|
487
|
-
`import DefaultShim from ${JSON.stringify(normalizedShimPath)};`,
|
|
488
|
-
`function createSafeProxy() {`,
|
|
489
|
-
` const fnTarget = function(...args) {`,
|
|
490
|
-
` if (args.length === 1 && (typeof args[0] === 'number' || typeof args[0] === 'string')) {`,
|
|
491
|
-
` return args[0];`,
|
|
492
|
-
` }`,
|
|
493
|
-
` return proxy;`,
|
|
494
|
-
` };`,
|
|
495
|
-
` const arrTarget = [];`,
|
|
496
|
-
` const proxy = new Proxy(arrTarget, {`,
|
|
497
|
-
` get(target, prop) {`,
|
|
498
|
-
` if (prop === 'length') return 0;`,
|
|
499
|
-
` if (prop === Symbol.iterator) {`,
|
|
500
|
-
` return function* () {`,
|
|
501
|
-
` yield proxy;`,
|
|
502
|
-
` yield fnTarget;`,
|
|
503
|
-
` };`,
|
|
504
|
-
` }`,
|
|
505
|
-
` if (prop === Symbol.toPrimitive) {`,
|
|
506
|
-
` return (hint) => (hint === 'number' ? 0 : '');`,
|
|
507
|
-
` }`,
|
|
508
|
-
` if (prop === 'valueOf') return () => 0;`,
|
|
509
|
-
` if (prop === 'toString') return () => '';`,
|
|
510
|
-
` if (prop === 'then') {`,
|
|
511
|
-
` return (onFulfilled, onRejected) => Promise.resolve({ data: null, error: null }).then(onFulfilled, onRejected);`,
|
|
512
|
-
` }`,
|
|
513
|
-
` if (prop === 'catch') return () => Promise.resolve({ data: null, error: null });`,
|
|
514
|
-
` if (prop === 'finally') return (cb) => Promise.resolve({ data: null, error: null }).finally(cb);`,
|
|
515
|
-
` if (prop === 'isLoading' || prop === 'loading' || prop === 'isPending' || prop === 'isError' || prop === 'disabled') return false;`,
|
|
516
|
-
` if (prop === 'isReady' || prop === 'granted' || prop === 'isSuccess' || prop === ' isAuthenticated') return true;`,
|
|
517
|
-
` if (prop === 'error' || prop === 'user' || prop === 'session') return null;`,
|
|
518
|
-
` if (prop === 'status') return 'granted';`,
|
|
519
|
-
` if (prop === 'data' || prop === 'items' || prop === 'results' || prop === 'list') return [];`,
|
|
520
|
-
` if (prop === 'ref') return { current: null };`,
|
|
521
|
-
` if (prop === 'value') return 0;`,
|
|
522
|
-
` if (prop === 'map' || prop === 'filter' || prop === 'slice' || prop === 'flatMap') return () => [];`,
|
|
523
|
-
` if (prop === 'forEach') return () => undefined;`,
|
|
524
|
-
` if (prop === 'reduce') return (_fn, init) => init;`,
|
|
525
|
-
` if (prop in target) return Reflect.get(target, prop);`,
|
|
526
|
-
` return fnTarget;`,
|
|
527
|
-
` },`,
|
|
528
|
-
` });`,
|
|
529
|
-
` return proxy;`,
|
|
530
|
-
`}`,
|
|
487
|
+
`import DefaultShim, { SAFE_CHAINABLE_PROXY } from ${JSON.stringify(normalizedShimPath)};`,
|
|
531
488
|
`function createUniversalComponentStub(displayName) {`,
|
|
532
489
|
` const Comp = React.forwardRef(function UniversalNativeStub(props, ref) {`,
|
|
533
|
-
`
|
|
490
|
+
` if (typeof props?.render === 'function') {`,
|
|
491
|
+
` return props.render({`,
|
|
492
|
+
` field: { name: props?.name ?? '', value: props?.defaultValue ?? '', onChange: () => undefined, onBlur: () => undefined, ref: () => undefined },`,
|
|
493
|
+
` fieldState: { invalid: false, isTouched: false, isDirty: false, error: undefined },`,
|
|
494
|
+
` formState: { errors: {}, isSubmitting: false, isValid: true },`,
|
|
495
|
+
` });`,
|
|
496
|
+
` }`,
|
|
497
|
+
` const content = typeof props?.children === 'function' ? props.children(SAFE_CHAINABLE_PROXY) : props?.children;`,
|
|
498
|
+
` return React.createElement(View, { style: props?.style, className: props?.className, ref }, content);`,
|
|
534
499
|
` });`,
|
|
535
500
|
` Comp.displayName = displayName;`,
|
|
536
501
|
` return new Proxy(Comp, {`,
|
|
@@ -539,21 +504,16 @@ export function viteReactNativeFallbackPlugin(
|
|
|
539
504
|
` if (typeof prop === 'string' && /^[A-Z]/.test(prop)) {`,
|
|
540
505
|
` return createUniversalComponentStub(displayName + '.' + prop);`,
|
|
541
506
|
` }`,
|
|
542
|
-
` return
|
|
507
|
+
` return SAFE_CHAINABLE_PROXY;`,
|
|
543
508
|
` },`,
|
|
544
509
|
` });`,
|
|
545
510
|
`}`,
|
|
546
511
|
`function resolveStubExport(name) {`,
|
|
547
512
|
` if (name in KnownShims) return KnownShims[name];`,
|
|
548
513
|
` if (DefaultShim && name in DefaultShim) return DefaultShim[name];`,
|
|
549
|
-
` if (/^use[A-Z]/.test(name)) return () =>
|
|
514
|
+
` if (/^use[A-Z]/.test(name)) return () => SAFE_CHAINABLE_PROXY;`,
|
|
550
515
|
` if (/^[A-Z]/.test(name)) return createUniversalComponentStub(name);`,
|
|
551
|
-
` return
|
|
552
|
-
` if (args.length === 1 && (typeof args[0] === 'number' || typeof args[0] === 'string')) {`,
|
|
553
|
-
` return args[0];`,
|
|
554
|
-
` }`,
|
|
555
|
-
` return createSafeProxy();`,
|
|
556
|
-
` };`,
|
|
516
|
+
` return SAFE_CHAINABLE_PROXY;`,
|
|
557
517
|
`}`,
|
|
558
518
|
`export default DefaultShim;`,
|
|
559
519
|
];
|
package/src/canvas/CanvasApp.tsx
CHANGED
|
@@ -36,21 +36,33 @@ import type { PatchPayload, ReorderPayload } from '../server/astEngine';
|
|
|
36
36
|
const DEFAULT_WIDTH = 393;
|
|
37
37
|
const DEFAULT_HEIGHT = 852;
|
|
38
38
|
const ARTBOARD_GAP = 130;
|
|
39
|
+
const ARTBOARD_ROW_GAP = 140;
|
|
39
40
|
const INITIAL_OFFSET_X = 80;
|
|
40
41
|
const INITIAL_OFFSET_Y = 70;
|
|
41
|
-
const MIN_ZOOM = 0.
|
|
42
|
-
const MAX_ZOOM =
|
|
42
|
+
const MIN_ZOOM = 0.08;
|
|
43
|
+
const MAX_ZOOM = 2.0;
|
|
43
44
|
|
|
44
45
|
function clampZoom(z: number): number {
|
|
45
|
-
return
|
|
46
|
+
return Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function snapToDevicePixel(v: number): number {
|
|
50
|
+
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
51
|
+
return Math.round(v * dpr) / dpr;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
function buildDefaultLayout(screens: ScreenMetadata[]): ArtboardLayoutMap {
|
|
49
55
|
const map: ArtboardLayoutMap = {};
|
|
56
|
+
const cols =
|
|
57
|
+
screens.length <= 5
|
|
58
|
+
? Math.max(1, screens.length)
|
|
59
|
+
: Math.min(7, Math.ceil(Math.sqrt(screens.length * 1.6)));
|
|
50
60
|
screens.forEach((screen, idx) => {
|
|
61
|
+
const col = idx % cols;
|
|
62
|
+
const row = Math.floor(idx / cols);
|
|
51
63
|
map[screen.id] = {
|
|
52
|
-
x: INITIAL_OFFSET_X +
|
|
53
|
-
y: INITIAL_OFFSET_Y,
|
|
64
|
+
x: INITIAL_OFFSET_X + col * (DEFAULT_WIDTH + ARTBOARD_GAP),
|
|
65
|
+
y: INITIAL_OFFSET_Y + row * (DEFAULT_HEIGHT + ARTBOARD_ROW_GAP),
|
|
54
66
|
width: DEFAULT_WIDTH,
|
|
55
67
|
height: DEFAULT_HEIGHT,
|
|
56
68
|
};
|
|
@@ -128,18 +140,63 @@ export const CanvasApp: React.FC = () => {
|
|
|
128
140
|
}>({ undoAvailable: 0, redoAvailable: 0 });
|
|
129
141
|
|
|
130
142
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
|
143
|
+
const worldContainerRef = useRef<HTMLDivElement | null>(null);
|
|
144
|
+
const zoomLabelRef = useRef<HTMLSpanElement | null>(null);
|
|
131
145
|
const cameraTimerRef = useRef<number | null>(null);
|
|
146
|
+
const panSyncTimerRef = useRef<number | null>(null);
|
|
147
|
+
const hasInitialFitRef = useRef<boolean>(false);
|
|
132
148
|
|
|
133
149
|
// Refs for synchronous access inside non-passive native wheel/gesture listeners
|
|
134
150
|
const zoomRef = useRef<number>(zoom);
|
|
135
151
|
const panRef = useRef<{ x: number; y: number }>(pan);
|
|
136
152
|
useEffect(() => {
|
|
137
153
|
zoomRef.current = zoom;
|
|
154
|
+
if (zoomLabelRef.current) {
|
|
155
|
+
zoomLabelRef.current.textContent = `${Math.round(zoom * 100)}%`;
|
|
156
|
+
}
|
|
138
157
|
}, [zoom]);
|
|
139
158
|
useEffect(() => {
|
|
140
159
|
panRef.current = pan;
|
|
141
160
|
}, [pan]);
|
|
142
161
|
|
|
162
|
+
const scheduleCameraStateSync = useCallback((commitImmediately = false) => {
|
|
163
|
+
if (panSyncTimerRef.current) {
|
|
164
|
+
window.clearTimeout(panSyncTimerRef.current);
|
|
165
|
+
panSyncTimerRef.current = null;
|
|
166
|
+
}
|
|
167
|
+
if (commitImmediately) {
|
|
168
|
+
setIsCameraAnimating(false);
|
|
169
|
+
setZoom(zoomRef.current);
|
|
170
|
+
setPan(panRef.current);
|
|
171
|
+
} else {
|
|
172
|
+
panSyncTimerRef.current = window.setTimeout(() => {
|
|
173
|
+
panSyncTimerRef.current = null;
|
|
174
|
+
setIsCameraAnimating(false);
|
|
175
|
+
setZoom(zoomRef.current);
|
|
176
|
+
setPan(panRef.current);
|
|
177
|
+
}, 140);
|
|
178
|
+
}
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
const applyDirectPanTransform = useCallback(
|
|
182
|
+
(rawX: number, rawY: number, commitImmediately = false) => {
|
|
183
|
+
const nextPan = {
|
|
184
|
+
x: snapToDevicePixel(rawX),
|
|
185
|
+
y: snapToDevicePixel(rawY),
|
|
186
|
+
};
|
|
187
|
+
panRef.current = nextPan;
|
|
188
|
+
|
|
189
|
+
const worldEl = worldContainerRef.current;
|
|
190
|
+
if (worldEl) {
|
|
191
|
+
worldEl.style.transition = 'none';
|
|
192
|
+
worldEl.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${zoomRef.current})`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
scheduleCameraStateSync(commitImmediately);
|
|
196
|
+
},
|
|
197
|
+
[scheduleCameraStateSync]
|
|
198
|
+
);
|
|
199
|
+
|
|
143
200
|
const triggerCameraTransition = useCallback(() => {
|
|
144
201
|
if (cameraTimerRef.current) {
|
|
145
202
|
window.clearTimeout(cameraTimerRef.current);
|
|
@@ -191,7 +248,34 @@ export const CanvasApp: React.FC = () => {
|
|
|
191
248
|
setLayout((prev) => {
|
|
192
249
|
const base = buildDefaultLayout(sorted);
|
|
193
250
|
const saved = layoutJson.ok && layoutJson.layout ? layoutJson.layout : {};
|
|
194
|
-
|
|
251
|
+
const merged = { ...base, ...saved, ...prev };
|
|
252
|
+
if (!hasInitialFitRef.current && sorted.length > 0 && canvasViewportRef.current) {
|
|
253
|
+
hasInitialFitRef.current = true;
|
|
254
|
+
const rect = canvasViewportRef.current.getBoundingClientRect();
|
|
255
|
+
const positions = Object.values(merged);
|
|
256
|
+
if (positions.length > 0 && rect.width > 0 && rect.height > 0) {
|
|
257
|
+
const minX = Math.min(...positions.map((p) => p.x));
|
|
258
|
+
const minY = Math.min(...positions.map((p) => p.y));
|
|
259
|
+
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
|
260
|
+
const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
|
|
261
|
+
const contentWidth = maxX - minX + 140;
|
|
262
|
+
const contentHeight = maxY - minY + 140;
|
|
263
|
+
const nextZoom = clampZoom(
|
|
264
|
+
Math.min(rect.width / contentWidth, rect.height / contentHeight, 0.85)
|
|
265
|
+
);
|
|
266
|
+
const centerX = (minX + maxX) / 2;
|
|
267
|
+
const centerY = (minY + maxY) / 2;
|
|
268
|
+
const nextPan = {
|
|
269
|
+
x: Math.round(rect.width / 2 - centerX * nextZoom),
|
|
270
|
+
y: Math.round(rect.height / 2 - centerY * nextZoom),
|
|
271
|
+
};
|
|
272
|
+
zoomRef.current = nextZoom;
|
|
273
|
+
panRef.current = nextPan;
|
|
274
|
+
setZoom(nextZoom);
|
|
275
|
+
setPan(nextPan);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return merged;
|
|
195
279
|
});
|
|
196
280
|
}
|
|
197
281
|
} catch (err) {
|
|
@@ -456,6 +540,7 @@ export const CanvasApp: React.FC = () => {
|
|
|
456
540
|
if (nextZoom === prevZoom) return;
|
|
457
541
|
|
|
458
542
|
// Pin the world coordinate directly under the user's cursor during two-finger pinch
|
|
543
|
+
// Keep full floating-point precision during active zoom gesture so (cursorX - worldX * nextZoom) never oscillates
|
|
459
544
|
const cursorX = clientX - rect.left;
|
|
460
545
|
const cursorY = clientY - rect.top;
|
|
461
546
|
const worldX = (cursorX - prevPan.x) / prevZoom;
|
|
@@ -466,11 +551,16 @@ export const CanvasApp: React.FC = () => {
|
|
|
466
551
|
y: cursorY - worldY * nextZoom,
|
|
467
552
|
};
|
|
468
553
|
|
|
469
|
-
setIsCameraAnimating(false);
|
|
470
554
|
zoomRef.current = nextZoom;
|
|
471
555
|
panRef.current = nextPan;
|
|
472
|
-
|
|
473
|
-
|
|
556
|
+
if (worldContainerRef.current) {
|
|
557
|
+
worldContainerRef.current.style.transition = 'none';
|
|
558
|
+
worldContainerRef.current.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${nextZoom})`;
|
|
559
|
+
}
|
|
560
|
+
if (zoomLabelRef.current) {
|
|
561
|
+
zoomLabelRef.current.textContent = `${Math.round(nextZoom * 100)}%`;
|
|
562
|
+
}
|
|
563
|
+
scheduleCameraStateSync(false);
|
|
474
564
|
};
|
|
475
565
|
|
|
476
566
|
// 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
|
|
@@ -483,13 +573,11 @@ export const CanvasApp: React.FC = () => {
|
|
|
483
573
|
const zoomFactor = Math.exp(-e.deltaY * 0.0085);
|
|
484
574
|
applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
|
|
485
575
|
} else {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
panRef.current = nextPan;
|
|
492
|
-
setPan(nextPan);
|
|
576
|
+
applyDirectPanTransform(
|
|
577
|
+
panRef.current.x - e.deltaX,
|
|
578
|
+
panRef.current.y - e.deltaY,
|
|
579
|
+
false
|
|
580
|
+
);
|
|
493
581
|
}
|
|
494
582
|
};
|
|
495
583
|
|
|
@@ -537,7 +625,7 @@ export const CanvasApp: React.FC = () => {
|
|
|
537
625
|
document.removeEventListener('gesturechange', handleGestureChange);
|
|
538
626
|
document.removeEventListener('gestureend', handleGestureEnd);
|
|
539
627
|
};
|
|
540
|
-
}, []);
|
|
628
|
+
}, [applyDirectPanTransform, scheduleCameraStateSync]);
|
|
541
629
|
|
|
542
630
|
// Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
|
|
543
631
|
const focusOnScreen = useCallback(
|
|
@@ -562,48 +650,58 @@ export const CanvasApp: React.FC = () => {
|
|
|
562
650
|
);
|
|
563
651
|
|
|
564
652
|
// Fit all screens in viewport
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
653
|
+
const fitCameraToLayout = useCallback(
|
|
654
|
+
(targetLayout: ArtboardLayoutMap) => {
|
|
655
|
+
const viewport = canvasViewportRef.current;
|
|
656
|
+
if (!viewport) return;
|
|
657
|
+
const rect = viewport.getBoundingClientRect();
|
|
569
658
|
|
|
570
|
-
|
|
571
|
-
|
|
659
|
+
const positions = Object.values(targetLayout);
|
|
660
|
+
if (positions.length === 0) return;
|
|
572
661
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
662
|
+
const minX = Math.min(...positions.map((p) => p.x));
|
|
663
|
+
const minY = Math.min(...positions.map((p) => p.y));
|
|
664
|
+
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
|
665
|
+
const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
|
|
577
666
|
|
|
578
|
-
|
|
579
|
-
|
|
667
|
+
const contentWidth = maxX - minX + 140;
|
|
668
|
+
const contentHeight = maxY - minY + 140;
|
|
580
669
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
670
|
+
const scaleX = rect.width / contentWidth;
|
|
671
|
+
const scaleY = rect.height / contentHeight;
|
|
672
|
+
const nextZoom = clampZoom(Math.min(scaleX, scaleY, 1.0));
|
|
584
673
|
|
|
585
|
-
|
|
586
|
-
|
|
674
|
+
const centerX = (minX + maxX) / 2;
|
|
675
|
+
const centerY = (minY + maxY) / 2;
|
|
676
|
+
const nextPan = {
|
|
677
|
+
x: Math.round(rect.width / 2 - centerX * nextZoom),
|
|
678
|
+
y: Math.round(rect.height / 2 - centerY * nextZoom),
|
|
679
|
+
};
|
|
587
680
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
|
|
681
|
+
triggerCameraTransition();
|
|
682
|
+
zoomRef.current = nextZoom;
|
|
683
|
+
panRef.current = nextPan;
|
|
684
|
+
setZoom(nextZoom);
|
|
685
|
+
setPan(nextPan);
|
|
686
|
+
},
|
|
687
|
+
[triggerCameraTransition]
|
|
688
|
+
);
|
|
595
689
|
|
|
596
|
-
|
|
690
|
+
const handleFitAllScreens = useCallback(() => {
|
|
691
|
+
fitCameraToLayout(layout);
|
|
692
|
+
}, [fitCameraToLayout, layout]);
|
|
693
|
+
|
|
694
|
+
// Reset layout to clean multi-row grid and fit in viewport
|
|
597
695
|
const handleResetGridLayout = useCallback(() => {
|
|
598
696
|
const reset = buildDefaultLayout(screens);
|
|
599
|
-
triggerCameraTransition();
|
|
600
697
|
setLayout(reset);
|
|
698
|
+
fitCameraToLayout(reset);
|
|
601
699
|
void fetch('/api/layout', {
|
|
602
700
|
method: 'POST',
|
|
603
701
|
headers: { 'Content-Type': 'application/json' },
|
|
604
702
|
body: JSON.stringify(reset),
|
|
605
703
|
});
|
|
606
|
-
}, [screens,
|
|
704
|
+
}, [screens, fitCameraToLayout]);
|
|
607
705
|
|
|
608
706
|
// Move single artboard
|
|
609
707
|
const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
|
|
@@ -630,7 +728,14 @@ export const CanvasApp: React.FC = () => {
|
|
|
630
728
|
[focusOnScreen]
|
|
631
729
|
);
|
|
632
730
|
|
|
633
|
-
|
|
731
|
+
const handleSelectFlow = useCallback(
|
|
732
|
+
(edge: NavigationEdge) => {
|
|
733
|
+
focusOnScreen(edge.toScreen);
|
|
734
|
+
},
|
|
735
|
+
[focusOnScreen]
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// Canvas background pointer pan (0ms direct DOM manipulation override)
|
|
634
739
|
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
635
740
|
if (e.button !== 0 && e.button !== 1) return;
|
|
636
741
|
setIsCameraAnimating(false);
|
|
@@ -638,20 +743,20 @@ export const CanvasApp: React.FC = () => {
|
|
|
638
743
|
|
|
639
744
|
const startX = e.clientX;
|
|
640
745
|
const startY = e.clientY;
|
|
641
|
-
const startPanX =
|
|
642
|
-
const startPanY =
|
|
746
|
+
const startPanX = panRef.current.x;
|
|
747
|
+
const startPanY = panRef.current.y;
|
|
643
748
|
|
|
644
749
|
const handleMove = (moveEvent: PointerEvent) => {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
setPan(nextPan);
|
|
750
|
+
applyDirectPanTransform(
|
|
751
|
+
startPanX + (moveEvent.clientX - startX),
|
|
752
|
+
startPanY + (moveEvent.clientY - startY),
|
|
753
|
+
false
|
|
754
|
+
);
|
|
651
755
|
};
|
|
652
756
|
|
|
653
757
|
const handleUp = () => {
|
|
654
758
|
setIsPanning(false);
|
|
759
|
+
applyDirectPanTransform(panRef.current.x, panRef.current.y, true);
|
|
655
760
|
window.removeEventListener('pointermove', handleMove);
|
|
656
761
|
window.removeEventListener('pointerup', handleUp);
|
|
657
762
|
};
|
|
@@ -913,6 +1018,7 @@ export const CanvasApp: React.FC = () => {
|
|
|
913
1018
|
</button>
|
|
914
1019
|
|
|
915
1020
|
<span
|
|
1021
|
+
ref={zoomLabelRef}
|
|
916
1022
|
className="tabular-nums"
|
|
917
1023
|
style={{
|
|
918
1024
|
minWidth: 46,
|
|
@@ -1299,13 +1405,13 @@ export const CanvasApp: React.FC = () => {
|
|
|
1299
1405
|
|
|
1300
1406
|
{/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
|
|
1301
1407
|
<div
|
|
1408
|
+
ref={worldContainerRef}
|
|
1302
1409
|
style={{
|
|
1303
1410
|
position: 'absolute',
|
|
1304
1411
|
top: 0,
|
|
1305
1412
|
left: 0,
|
|
1306
1413
|
transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
|
|
1307
1414
|
transformOrigin: '0 0',
|
|
1308
|
-
willChange: 'transform',
|
|
1309
1415
|
transition: isCameraAnimating
|
|
1310
1416
|
? 'transform var(--duration-camera) var(--ease-out-expo)'
|
|
1311
1417
|
: 'none',
|
|
@@ -1317,7 +1423,7 @@ export const CanvasApp: React.FC = () => {
|
|
|
1317
1423
|
layout={layout}
|
|
1318
1424
|
activeScreenId={activeScreenId}
|
|
1319
1425
|
highlightedScreenId={highlightedScreenId}
|
|
1320
|
-
onSelectFlow={
|
|
1426
|
+
onSelectFlow={handleSelectFlow}
|
|
1321
1427
|
/>
|
|
1322
1428
|
)}
|
|
1323
1429
|
|
|
@@ -1329,6 +1435,7 @@ export const CanvasApp: React.FC = () => {
|
|
|
1329
1435
|
height: DEFAULT_HEIGHT,
|
|
1330
1436
|
};
|
|
1331
1437
|
const ScreenComponent = SCREEN_COMPONENTS[screen.id];
|
|
1438
|
+
const isSelectedScreen = selectedElement?.screenId === screen.id;
|
|
1332
1439
|
|
|
1333
1440
|
return (
|
|
1334
1441
|
<ScreenArtboard
|
|
@@ -1340,8 +1447,9 @@ export const CanvasApp: React.FC = () => {
|
|
|
1340
1447
|
mode={mode}
|
|
1341
1448
|
isActiveScreen={activeScreenId === screen.id}
|
|
1342
1449
|
isHighlightedScreen={highlightedScreenId === screen.id}
|
|
1343
|
-
|
|
1344
|
-
|
|
1450
|
+
isCanvasMoving={isPanning}
|
|
1451
|
+
selectedElement={isSelectedScreen ? selectedElement : null}
|
|
1452
|
+
inspectedElement={isSelectedScreen ? inspectedData : null}
|
|
1345
1453
|
onSelectElement={handleSelectElement}
|
|
1346
1454
|
onSelectScreen={setActiveScreenId}
|
|
1347
1455
|
onFocusScreen={focusOnScreen}
|
|
@@ -11,7 +11,7 @@ interface FlowArrowsLayerProps {
|
|
|
11
11
|
|
|
12
12
|
const ARTBOARD_HEADER_OFFSET = 38;
|
|
13
13
|
|
|
14
|
-
export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = ({
|
|
14
|
+
export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = React.memo(({
|
|
15
15
|
screens,
|
|
16
16
|
layout,
|
|
17
17
|
activeScreenId,
|
|
@@ -194,4 +194,4 @@ export const FlowArrowsLayer: React.FC<FlowArrowsLayerProps> = ({
|
|
|
194
194
|
})}
|
|
195
195
|
</svg>
|
|
196
196
|
);
|
|
197
|
-
};
|
|
197
|
+
});
|
|
@@ -29,6 +29,7 @@ interface ScreenArtboardProps {
|
|
|
29
29
|
mode: CanvasMode;
|
|
30
30
|
isActiveScreen: boolean;
|
|
31
31
|
isHighlightedScreen: boolean;
|
|
32
|
+
isCanvasMoving?: boolean;
|
|
32
33
|
selectedElement: SelectedElementRef | null;
|
|
33
34
|
inspectedElement: InspectedElement | null;
|
|
34
35
|
onSelectElement: (ref: SelectedElementRef) => void;
|
|
@@ -83,7 +84,7 @@ function escapeAstPathSelector(astPath: string): string {
|
|
|
83
84
|
: astPath.replace(/["\\]/g, '\\$&');
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
87
|
+
export const ScreenArtboard: React.FC<ScreenArtboardProps> = React.memo(({
|
|
87
88
|
screen,
|
|
88
89
|
ScreenComponent,
|
|
89
90
|
position,
|
|
@@ -91,6 +92,7 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
91
92
|
mode,
|
|
92
93
|
isActiveScreen,
|
|
93
94
|
isHighlightedScreen,
|
|
95
|
+
isCanvasMoving = false,
|
|
94
96
|
selectedElement,
|
|
95
97
|
inspectedElement,
|
|
96
98
|
onSelectElement,
|
|
@@ -732,8 +734,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
732
734
|
};
|
|
733
735
|
|
|
734
736
|
// Inspect Mode pointer handlers
|
|
737
|
+
useEffect(() => {
|
|
738
|
+
if (isCanvasMoving) {
|
|
739
|
+
setHoverBox(null);
|
|
740
|
+
}
|
|
741
|
+
}, [isCanvasMoving]);
|
|
742
|
+
|
|
735
743
|
const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
736
|
-
if (mode !== 'inspect' || activeGizmoAction) return;
|
|
744
|
+
if (mode !== 'inspect' || activeGizmoAction || isCanvasMoving || e.buttons !== 0) return;
|
|
737
745
|
const targetEl = findNearestAgyElement(e.target, viewportRef.current);
|
|
738
746
|
if (!targetEl) {
|
|
739
747
|
setHoverBox(null);
|
|
@@ -992,6 +1000,12 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
992
1000
|
flexDirection: 'column',
|
|
993
1001
|
height: '100%',
|
|
994
1002
|
width: '100%',
|
|
1003
|
+
minHeight: 0,
|
|
1004
|
+
minWidth: 0,
|
|
1005
|
+
backgroundColor: '#FFFFFF',
|
|
1006
|
+
color: '#0F172A',
|
|
1007
|
+
overflowY: 'auto',
|
|
1008
|
+
overflowX: 'hidden',
|
|
995
1009
|
}}
|
|
996
1010
|
>
|
|
997
1011
|
<ScreenComponent />
|
|
@@ -1474,4 +1488,4 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
1474
1488
|
</div>
|
|
1475
1489
|
</div>
|
|
1476
1490
|
);
|
|
1477
|
-
};
|
|
1491
|
+
});
|