@hunterchen/canvas 0.1.0
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/dist/components/canvas/canvas.d.ts +29 -0
- package/dist/components/canvas/canvas.d.ts.map +1 -0
- package/dist/components/canvas/canvas.js +419 -0
- package/dist/components/canvas/canvas.js.map +1 -0
- package/dist/components/canvas/component.d.ts +47 -0
- package/dist/components/canvas/component.d.ts.map +1 -0
- package/dist/components/canvas/component.js +177 -0
- package/dist/components/canvas/component.js.map +1 -0
- package/dist/components/canvas/cursor.d.ts +8 -0
- package/dist/components/canvas/cursor.d.ts.map +1 -0
- package/dist/components/canvas/cursor.js +32 -0
- package/dist/components/canvas/cursor.js.map +1 -0
- package/dist/components/canvas/draggable.d.ts +21 -0
- package/dist/components/canvas/draggable.d.ts.map +1 -0
- package/dist/components/canvas/draggable.js +163 -0
- package/dist/components/canvas/draggable.js.map +1 -0
- package/dist/components/canvas/navbar/index.d.ts +19 -0
- package/dist/components/canvas/navbar/index.d.ts.map +1 -0
- package/dist/components/canvas/navbar/index.js +106 -0
- package/dist/components/canvas/navbar/index.js.map +1 -0
- package/dist/components/canvas/navbar/single-button.d.ts +17 -0
- package/dist/components/canvas/navbar/single-button.d.ts.map +1 -0
- package/dist/components/canvas/navbar/single-button.js +97 -0
- package/dist/components/canvas/navbar/single-button.js.map +1 -0
- package/dist/components/canvas/offest.d.ts +6 -0
- package/dist/components/canvas/offest.d.ts.map +1 -0
- package/dist/components/canvas/offest.js +12 -0
- package/dist/components/canvas/offest.js.map +1 -0
- package/dist/components/canvas/reset.d.ts +5 -0
- package/dist/components/canvas/reset.d.ts.map +1 -0
- package/dist/components/canvas/reset.js +7 -0
- package/dist/components/canvas/reset.js.map +1 -0
- package/dist/components/canvas/toolbar.d.ts +7 -0
- package/dist/components/canvas/toolbar.d.ts.map +1 -0
- package/dist/components/canvas/toolbar.js +28 -0
- package/dist/components/canvas/toolbar.js.map +1 -0
- package/dist/components/canvas/wrapper.d.ts +26 -0
- package/dist/components/canvas/wrapper.d.ts.map +1 -0
- package/dist/components/canvas/wrapper.js +107 -0
- package/dist/components/canvas/wrapper.js.map +1 -0
- package/dist/components/ui/FolderIcon.d.ts +9 -0
- package/dist/components/ui/FolderIcon.d.ts.map +1 -0
- package/dist/components/ui/FolderIcon.js +25 -0
- package/dist/components/ui/FolderIcon.js.map +1 -0
- package/dist/components/ui/button.d.ts +14 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +54 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/label.d.ts +6 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +10 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/toast.d.ts +16 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +41 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/toaster.d.ts +2 -0
- package/dist/components/ui/toaster.d.ts.map +1 -0
- package/dist/components/ui/toaster.js +10 -0
- package/dist/components/ui/toaster.js.map +1 -0
- package/dist/contexts/CanvasContext.d.ts +26 -0
- package/dist/contexts/CanvasContext.d.ts.map +1 -0
- package/dist/contexts/CanvasContext.js +22 -0
- package/dist/contexts/CanvasContext.js.map +1 -0
- package/dist/contexts/PerformanceContext.d.ts +31 -0
- package/dist/contexts/PerformanceContext.d.ts.map +1 -0
- package/dist/contexts/PerformanceContext.js +56 -0
- package/dist/contexts/PerformanceContext.js.map +1 -0
- package/dist/hooks/use-mobile.d.ts +2 -0
- package/dist/hooks/use-mobile.d.ts.map +1 -0
- package/dist/hooks/use-mobile.js +16 -0
- package/dist/hooks/use-mobile.js.map +1 -0
- package/dist/hooks/use-toast.d.ts +45 -0
- package/dist/hooks/use-toast.d.ts.map +1 -0
- package/dist/hooks/use-toast.js +126 -0
- package/dist/hooks/use-toast.js.map +1 -0
- package/dist/hooks/usePerformanceMode.d.ts +6 -0
- package/dist/hooks/usePerformanceMode.d.ts.map +1 -0
- package/dist/hooks/usePerformanceMode.js +6 -0
- package/dist/hooks/usePerformanceMode.js.map +1 -0
- package/dist/hooks/useWindowDimensions.d.ts +7 -0
- package/dist/hooks/useWindowDimensions.d.ts.map +1 -0
- package/dist/hooks/useWindowDimensions.js +22 -0
- package/dist/hooks/useWindowDimensions.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/canvas.d.ts +35 -0
- package/dist/lib/canvas.d.ts.map +1 -0
- package/dist/lib/canvas.js +82 -0
- package/dist/lib/canvas.js.map +1 -0
- package/dist/lib/constants.d.ts +78 -0
- package/dist/lib/constants.d.ts.map +1 -0
- package/dist/lib/constants.js +122 -0
- package/dist/lib/constants.js.map +1 -0
- package/dist/lib/copy.d.ts +2 -0
- package/dist/lib/copy.d.ts.map +1 -0
- package/dist/lib/copy.js +20 -0
- package/dist/lib/copy.js.map +1 -0
- package/dist/lib/utils.d.ts +4 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +14 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/types/index.d.ts +19 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +14 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/performance.d.ts +9 -0
- package/dist/utils/performance.d.ts.map +1 -0
- package/dist/utils/performance.js +29 -0
- package/dist/utils/performance.js.map +1 -0
- package/package.json +55 -0
- package/src/components/canvas/canvas.tsx +728 -0
- package/src/components/canvas/component.tsx +230 -0
- package/src/components/canvas/cursor.tsx +161 -0
- package/src/components/canvas/draggable.tsx +298 -0
- package/src/components/canvas/navbar/index.tsx +213 -0
- package/src/components/canvas/navbar/single-button.tsx +199 -0
- package/src/components/canvas/offest.tsx +23 -0
- package/src/components/canvas/reset.tsx +21 -0
- package/src/components/canvas/toolbar.tsx +67 -0
- package/src/components/canvas/wrapper.tsx +219 -0
- package/src/components/ui/FolderIcon.tsx +116 -0
- package/src/components/ui/button.tsx +162 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/toast.tsx +136 -0
- package/src/components/ui/toaster.tsx +33 -0
- package/src/contexts/CanvasContext.tsx +54 -0
- package/src/contexts/PerformanceContext.tsx +81 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-toast.ts +186 -0
- package/src/hooks/usePerformanceMode.ts +5 -0
- package/src/hooks/useWindowDimensions.ts +32 -0
- package/src/index.ts +36 -0
- package/src/lib/canvas.ts +132 -0
- package/src/lib/constants.ts +153 -0
- package/src/lib/copy.ts +18 -0
- package/src/lib/utils.ts +18 -0
- package/src/types/index.ts +20 -0
- package/src/utils/performance.ts +37 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import {
|
|
2
|
+
motion,
|
|
3
|
+
type MotionValue,
|
|
4
|
+
type Point,
|
|
5
|
+
useMotionValue,
|
|
6
|
+
animate,
|
|
7
|
+
useTransform,
|
|
8
|
+
type Transition,
|
|
9
|
+
} from "framer-motion";
|
|
10
|
+
import React, {
|
|
11
|
+
useState,
|
|
12
|
+
useRef,
|
|
13
|
+
type PointerEvent,
|
|
14
|
+
type FC,
|
|
15
|
+
useEffect,
|
|
16
|
+
useCallback,
|
|
17
|
+
useMemo,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { CanvasProvider } from "../../contexts/CanvasContext";
|
|
20
|
+
import { useToast } from "../../hooks/use-toast";
|
|
21
|
+
import {
|
|
22
|
+
calcInitialBoxWidth,
|
|
23
|
+
canvasHeight,
|
|
24
|
+
canvasWidth,
|
|
25
|
+
getDistance,
|
|
26
|
+
getMidpoint,
|
|
27
|
+
getScreenSizeEnum,
|
|
28
|
+
getSectionPanCoordinates,
|
|
29
|
+
INTERACTIVE_SELECTOR,
|
|
30
|
+
MAX_ZOOM,
|
|
31
|
+
MIN_ZOOMS,
|
|
32
|
+
panToOffsetScene,
|
|
33
|
+
ZOOM_BOUND,
|
|
34
|
+
} from "../../lib/canvas";
|
|
35
|
+
import {
|
|
36
|
+
STAGE2_TRANSITION,
|
|
37
|
+
MOUSE_WHEEL_ZOOM_SENSITIVITY,
|
|
38
|
+
TRACKPAD_ZOOM_SENSITIVITY,
|
|
39
|
+
} from "../../lib/constants";
|
|
40
|
+
import useWindowDimensions from "../../hooks/useWindowDimensions";
|
|
41
|
+
import Navbar from "./navbar";
|
|
42
|
+
import Toolbar from "./toolbar";
|
|
43
|
+
import { CanvasSection, type SectionCoordinates } from "../../types";
|
|
44
|
+
import { CanvasWrapper } from "./wrapper";
|
|
45
|
+
import { usePerformanceMode } from "../../hooks/usePerformanceMode";
|
|
46
|
+
import type { ReactNode } from "react";
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
homeCoordinates: SectionCoordinates;
|
|
50
|
+
children: React.ReactNode;
|
|
51
|
+
|
|
52
|
+
// Navbar data (optional). If omitted, navbar is hidden.
|
|
53
|
+
navbarCoordinates?: Record<CanvasSection, SectionCoordinates>;
|
|
54
|
+
navbarSections?: CanvasSection[];
|
|
55
|
+
navbarHomeSection?: CanvasSection;
|
|
56
|
+
|
|
57
|
+
// ============== Intro Animation Customization ==============
|
|
58
|
+
/** Disable intro animation entirely */
|
|
59
|
+
skipIntro?: boolean;
|
|
60
|
+
/** Custom intro content during loading */
|
|
61
|
+
introContent?: ReactNode;
|
|
62
|
+
/** Custom loading text */
|
|
63
|
+
loadingText?: string;
|
|
64
|
+
/** Background gradient for intro */
|
|
65
|
+
introBackgroundGradient?: string;
|
|
66
|
+
/** Canvas box gradient */
|
|
67
|
+
canvasBoxGradient?: string;
|
|
68
|
+
/** Custom grow transition */
|
|
69
|
+
growTransition?: Transition;
|
|
70
|
+
/** Custom blur transition */
|
|
71
|
+
blurTransition?: Transition;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stopAllMotion = (
|
|
75
|
+
x: MotionValue<number>,
|
|
76
|
+
y: MotionValue<number>,
|
|
77
|
+
scale: MotionValue<number>,
|
|
78
|
+
) => {
|
|
79
|
+
x.stop();
|
|
80
|
+
y.stop();
|
|
81
|
+
scale.stop();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const Canvas: FC<Props> = ({
|
|
85
|
+
children,
|
|
86
|
+
homeCoordinates,
|
|
87
|
+
navbarCoordinates,
|
|
88
|
+
navbarSections,
|
|
89
|
+
navbarHomeSection,
|
|
90
|
+
skipIntro = false,
|
|
91
|
+
introContent,
|
|
92
|
+
loadingText,
|
|
93
|
+
introBackgroundGradient,
|
|
94
|
+
canvasBoxGradient,
|
|
95
|
+
growTransition,
|
|
96
|
+
blurTransition,
|
|
97
|
+
}) => {
|
|
98
|
+
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
|
99
|
+
const { toast } = useToast();
|
|
100
|
+
const hasToasted = useRef(false);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (windowWidth < 768 && !hasToasted.current) {
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
toast({
|
|
106
|
+
title: "Please use desktop for the best experience.",
|
|
107
|
+
duration: 6700,
|
|
108
|
+
variant: "cute",
|
|
109
|
+
});
|
|
110
|
+
hasToasted.current = true;
|
|
111
|
+
}, 1200);
|
|
112
|
+
}
|
|
113
|
+
}, [windowWidth, toast]);
|
|
114
|
+
|
|
115
|
+
const { mode } = usePerformanceMode();
|
|
116
|
+
|
|
117
|
+
const navSections = navbarSections ?? Object.values(CanvasSection);
|
|
118
|
+
const navHomeSection = navbarHomeSection ?? CanvasSection.Home;
|
|
119
|
+
const hasNavbarData = Boolean(navbarCoordinates);
|
|
120
|
+
|
|
121
|
+
const sceneWidth = canvasWidth;
|
|
122
|
+
const sceneHeight = canvasHeight;
|
|
123
|
+
|
|
124
|
+
const MIN_ZOOM = MIN_ZOOMS[getScreenSizeEnum(windowWidth)];
|
|
125
|
+
|
|
126
|
+
// tracks if user is panning the screen
|
|
127
|
+
const [isPanning, setIsPanning] = useState<boolean>(false);
|
|
128
|
+
// this one is moving from scene control, not from user
|
|
129
|
+
const [isSceneMoving, setIsSceneMoving] = useState<boolean>(false);
|
|
130
|
+
const [panStartPoint, setPanStartPoint] = useState<Point>({ x: 0, y: 0 });
|
|
131
|
+
const [initialPanOffsetOnDrag, setInitialPanOffsetOnDrag] = useState<Point>({
|
|
132
|
+
x: 0,
|
|
133
|
+
y: 0,
|
|
134
|
+
});
|
|
135
|
+
const [isResetting, setIsResetting] = useState<boolean>(false);
|
|
136
|
+
const [maxZIndex, setMaxZIndex] = useState<number>(50);
|
|
137
|
+
const [animationStage, setAnimationStage] = useState<number>(0); // 0: initial, 1: finish grow, 2: pan to home
|
|
138
|
+
const [nextTargetSection, setNextTargetSection] =
|
|
139
|
+
useState<CanvasSection | null>(null);
|
|
140
|
+
// Track if the intro (stage1 + stage2) is still running, to avoid accidental cancellation
|
|
141
|
+
const isIntroAnimatingRef = useRef(true);
|
|
142
|
+
|
|
143
|
+
const initialBoxWidth = useMemo(
|
|
144
|
+
() => calcInitialBoxWidth(windowWidth, windowHeight),
|
|
145
|
+
[windowWidth, windowHeight],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// somewhere near the middle-ish
|
|
149
|
+
const x = useMotionValue(0);
|
|
150
|
+
const y = useMotionValue(0);
|
|
151
|
+
const scale = useMotionValue(initialBoxWidth);
|
|
152
|
+
|
|
153
|
+
const offsetHomeCoordinates = useMemo(
|
|
154
|
+
() =>
|
|
155
|
+
getSectionPanCoordinates({
|
|
156
|
+
windowDimensions: { width: windowWidth, height: windowHeight },
|
|
157
|
+
coords: homeCoordinates,
|
|
158
|
+
targetZoom: 1,
|
|
159
|
+
}),
|
|
160
|
+
[homeCoordinates, windowWidth, windowHeight],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const onResetViewAndItems = useCallback(
|
|
164
|
+
(onComplete?: () => void): void => {
|
|
165
|
+
setIsResetting(true);
|
|
166
|
+
|
|
167
|
+
void panToOffsetScene(offsetHomeCoordinates, x, y, scale, 1).then(() => {
|
|
168
|
+
setIsResetting(false);
|
|
169
|
+
if (onComplete) onComplete();
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
[offsetHomeCoordinates, x, y, scale],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Shared intro progress (0->1) driven by CanvasWrapper
|
|
176
|
+
const introProgress = useMotionValue(0);
|
|
177
|
+
|
|
178
|
+
// Precompute final stage1 scale and offsets (snapshot dimensions once on mount)
|
|
179
|
+
const stage1Targets = useMemo(() => {
|
|
180
|
+
const finalScale = Math.max(
|
|
181
|
+
(windowWidth || 0) / canvasWidth,
|
|
182
|
+
(windowHeight || 0) / canvasHeight,
|
|
183
|
+
);
|
|
184
|
+
const endX = (windowWidth - canvasWidth * finalScale) / 2;
|
|
185
|
+
const endY = (windowHeight - canvasHeight * finalScale) / 2;
|
|
186
|
+
return { finalScale, endX, endY };
|
|
187
|
+
}, [windowWidth, windowHeight]);
|
|
188
|
+
|
|
189
|
+
// Replace direct motion values with derived transforms during stage1
|
|
190
|
+
const derivedScale = useTransform(
|
|
191
|
+
introProgress,
|
|
192
|
+
[0, 1],
|
|
193
|
+
[initialBoxWidth, stage1Targets.finalScale],
|
|
194
|
+
);
|
|
195
|
+
const derivedX = useTransform(introProgress, [0, 1], [0, stage1Targets.endX]);
|
|
196
|
+
const derivedY = useTransform(introProgress, [0, 1], [0, stage1Targets.endY]);
|
|
197
|
+
|
|
198
|
+
// While intro (stage1) is running, bind x/y/scale to derived versions.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const unsubscribeScale = derivedScale.on("change", (v) => {
|
|
201
|
+
if (animationStage === 0) scale.set(v);
|
|
202
|
+
});
|
|
203
|
+
const unsubscribeX = derivedX.on("change", (v) => {
|
|
204
|
+
if (animationStage === 0) x.set(v);
|
|
205
|
+
});
|
|
206
|
+
const unsubscribeY = derivedY.on("change", (v) => {
|
|
207
|
+
if (animationStage === 0) y.set(v);
|
|
208
|
+
});
|
|
209
|
+
return () => {
|
|
210
|
+
unsubscribeScale();
|
|
211
|
+
unsubscribeX();
|
|
212
|
+
unsubscribeY();
|
|
213
|
+
};
|
|
214
|
+
}, [derivedScale, derivedX, derivedY, animationStage, scale, x, y]);
|
|
215
|
+
|
|
216
|
+
// Kick off stage2 (pan to home) when grow completes (introProgress hits 1)
|
|
217
|
+
const startStage2 = useCallback(() => {
|
|
218
|
+
setAnimationStage(1);
|
|
219
|
+
|
|
220
|
+
Promise.all([
|
|
221
|
+
animate(x, offsetHomeCoordinates.x, STAGE2_TRANSITION),
|
|
222
|
+
animate(y, offsetHomeCoordinates.y, STAGE2_TRANSITION),
|
|
223
|
+
animate(scale, 1, STAGE2_TRANSITION),
|
|
224
|
+
])
|
|
225
|
+
.then(() => {
|
|
226
|
+
setAnimationStage(2);
|
|
227
|
+
isIntroAnimatingRef.current = false;
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
isIntroAnimatingRef.current = false;
|
|
231
|
+
});
|
|
232
|
+
}, [offsetHomeCoordinates, x, y, scale]);
|
|
233
|
+
|
|
234
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
235
|
+
const sceneRef = useRef<HTMLDivElement>(null);
|
|
236
|
+
|
|
237
|
+
// Stable wheel listener wrapper that always calls the latest handler via ref
|
|
238
|
+
const wheelHandlerRef = useRef<((e: WheelEvent) => void) | null>(null);
|
|
239
|
+
const wheelWrapper = useCallback((e: WheelEvent) => {
|
|
240
|
+
wheelHandlerRef.current?.(e);
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
// Ensure wheel listener attaches when the element actually mounts (wrapper delays child mount)
|
|
244
|
+
const setViewportRef = useCallback(
|
|
245
|
+
(node: HTMLDivElement | null) => {
|
|
246
|
+
// Clean up old listener if ref changes/unmounts
|
|
247
|
+
if (viewportRef.current) {
|
|
248
|
+
viewportRef.current.removeEventListener("wheel", wheelWrapper);
|
|
249
|
+
}
|
|
250
|
+
viewportRef.current = node;
|
|
251
|
+
if (node) {
|
|
252
|
+
node.addEventListener("wheel", wheelWrapper, { passive: false });
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
[wheelWrapper],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const activePointersRef = useRef<Map<number, PointerEvent<HTMLDivElement>>>(
|
|
259
|
+
new Map(),
|
|
260
|
+
);
|
|
261
|
+
const initialPinchStateRef = useRef<{
|
|
262
|
+
distance: number;
|
|
263
|
+
midpoint: Point;
|
|
264
|
+
zoom: number;
|
|
265
|
+
panOffset: Point;
|
|
266
|
+
} | null>(null);
|
|
267
|
+
|
|
268
|
+
const panToOffset = useCallback(
|
|
269
|
+
(
|
|
270
|
+
offset: Point,
|
|
271
|
+
viewportRef: React.RefObject<HTMLDivElement | null>,
|
|
272
|
+
onComplete?: () => void,
|
|
273
|
+
zoom?: number,
|
|
274
|
+
): void => {
|
|
275
|
+
if (!viewportRef.current) return;
|
|
276
|
+
setIsSceneMoving(true);
|
|
277
|
+
|
|
278
|
+
// Calculate bounds based on scene and viewport dimensions
|
|
279
|
+
const viewportWidth = viewportRef.current.offsetWidth;
|
|
280
|
+
const viewportHeight = viewportRef.current.offsetHeight;
|
|
281
|
+
|
|
282
|
+
const minPanX = viewportWidth - sceneWidth * (zoom ?? 1);
|
|
283
|
+
const maxPanX = 0;
|
|
284
|
+
const minPanY = viewportHeight - sceneHeight * (zoom ?? 1);
|
|
285
|
+
const maxPanY = 0;
|
|
286
|
+
|
|
287
|
+
// Clamp the offset to keep the scene within bounds, shouldn't be needed but still implemented
|
|
288
|
+
const clampedX = Math.min(Math.max(offset.x, minPanX), maxPanX);
|
|
289
|
+
const clampedY = Math.min(Math.max(offset.y, minPanY), maxPanY);
|
|
290
|
+
|
|
291
|
+
void panToOffsetScene(
|
|
292
|
+
{ x: clampedX, y: clampedY },
|
|
293
|
+
x,
|
|
294
|
+
y,
|
|
295
|
+
scale,
|
|
296
|
+
zoom,
|
|
297
|
+
).then(() => {
|
|
298
|
+
setIsSceneMoving(false);
|
|
299
|
+
if (onComplete) onComplete();
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
[sceneWidth, sceneHeight, x, y, scale],
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Guarded stop that ignores attempts during intro animations
|
|
306
|
+
const stopAllSceneMotion = useCallback(() => {
|
|
307
|
+
if (isIntroAnimatingRef.current) return; // ignore stops while intro runs
|
|
308
|
+
stopAllMotion(x, y, scale);
|
|
309
|
+
}, [x, y, scale]);
|
|
310
|
+
|
|
311
|
+
const handlePointerDown = useCallback(
|
|
312
|
+
(event: PointerEvent<HTMLDivElement>): void => {
|
|
313
|
+
if (animationStage < 2) return; // ignore during intro animations
|
|
314
|
+
activePointersRef.current.set(event.pointerId, event);
|
|
315
|
+
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
|
316
|
+
if (isResetting || isSceneMoving) return;
|
|
317
|
+
stopAllSceneMotion();
|
|
318
|
+
// pan with 1 pointer, pinch with 2 pointers
|
|
319
|
+
if (activePointersRef.current.size === 1) {
|
|
320
|
+
// do not pan from interactive elements
|
|
321
|
+
const targetElement = event.target as HTMLElement;
|
|
322
|
+
if (targetElement.closest(INTERACTIVE_SELECTOR)) {
|
|
323
|
+
activePointersRef.current.delete(event.pointerId);
|
|
324
|
+
(event.target as HTMLElement).releasePointerCapture(event.pointerId);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
setIsPanning(true);
|
|
329
|
+
setPanStartPoint({ x: event.clientX, y: event.clientY });
|
|
330
|
+
setInitialPanOffsetOnDrag({ x: x.get(), y: y.get() });
|
|
331
|
+
if (viewportRef.current) viewportRef.current.style.cursor = "grabbing";
|
|
332
|
+
} else if (activePointersRef.current.size === 2) {
|
|
333
|
+
setIsPanning(false);
|
|
334
|
+
const pointers = Array.from(activePointersRef.current.values());
|
|
335
|
+
initialPinchStateRef.current = {
|
|
336
|
+
distance: getDistance(pointers[0]!, pointers[1]!),
|
|
337
|
+
midpoint: getMidpoint(pointers[0]!, pointers[1]!),
|
|
338
|
+
zoom: scale.get(),
|
|
339
|
+
panOffset: { x: x.get(), y: y.get() },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
[
|
|
344
|
+
isResetting,
|
|
345
|
+
isSceneMoving,
|
|
346
|
+
setIsPanning,
|
|
347
|
+
setPanStartPoint,
|
|
348
|
+
setInitialPanOffsetOnDrag,
|
|
349
|
+
x,
|
|
350
|
+
y,
|
|
351
|
+
scale,
|
|
352
|
+
viewportRef,
|
|
353
|
+
animationStage,
|
|
354
|
+
stopAllSceneMotion,
|
|
355
|
+
],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const handlePointerMove = useCallback(
|
|
359
|
+
(event: PointerEvent<HTMLDivElement>): void => {
|
|
360
|
+
if (animationStage < 2) return;
|
|
361
|
+
if (isPanning || activePointersRef.current.size >= 2) {
|
|
362
|
+
stopAllSceneMotion();
|
|
363
|
+
}
|
|
364
|
+
if (!activePointersRef.current.has(event.pointerId) || isResetting)
|
|
365
|
+
return;
|
|
366
|
+
activePointersRef.current.set(event.pointerId, event);
|
|
367
|
+
|
|
368
|
+
if (isPanning && activePointersRef.current.size === 1) {
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
const deltaX = event.clientX - panStartPoint.x;
|
|
371
|
+
const deltaY = event.clientY - panStartPoint.y;
|
|
372
|
+
|
|
373
|
+
// UPDATE to use motion value
|
|
374
|
+
const minPanX = windowWidth - sceneWidth * scale.get();
|
|
375
|
+
const maxPanX = 0;
|
|
376
|
+
const minPanY = windowHeight - sceneHeight * scale.get();
|
|
377
|
+
const maxPanY = 0;
|
|
378
|
+
|
|
379
|
+
const newX = Math.min(
|
|
380
|
+
Math.max(initialPanOffsetOnDrag.x + deltaX, minPanX),
|
|
381
|
+
maxPanX,
|
|
382
|
+
);
|
|
383
|
+
const newY = Math.min(
|
|
384
|
+
Math.max(initialPanOffsetOnDrag.y + deltaY, minPanY),
|
|
385
|
+
maxPanY,
|
|
386
|
+
);
|
|
387
|
+
x.set(newX);
|
|
388
|
+
y.set(newY);
|
|
389
|
+
} else if (
|
|
390
|
+
activePointersRef.current.size >= 2 &&
|
|
391
|
+
initialPinchStateRef.current
|
|
392
|
+
) {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
const pointers = Array.from(activePointersRef.current.values());
|
|
395
|
+
const p1 = pointers[0]!;
|
|
396
|
+
const p2 = pointers[1]!;
|
|
397
|
+
|
|
398
|
+
const currentDistance = getDistance(p1, p2);
|
|
399
|
+
const currentMidpoint = getMidpoint(p1, p2);
|
|
400
|
+
|
|
401
|
+
const {
|
|
402
|
+
distance: initialDistance,
|
|
403
|
+
zoom: initialZoom,
|
|
404
|
+
panOffset: initialPanOffsetPinch,
|
|
405
|
+
} = initialPinchStateRef.current;
|
|
406
|
+
|
|
407
|
+
if (initialDistance === 0) return;
|
|
408
|
+
|
|
409
|
+
let newZoom = initialZoom * (currentDistance / initialDistance);
|
|
410
|
+
newZoom = Math.max(
|
|
411
|
+
(window.innerWidth / canvasWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
|
|
412
|
+
(window.innerHeight / canvasHeight) * ZOOM_BOUND, // Ensure zoom is at least the height of the canvas
|
|
413
|
+
Math.min(newZoom, 10),
|
|
414
|
+
MIN_ZOOM, // Ensure zoom is not less than MIN_ZOOM
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const mx = currentMidpoint.x;
|
|
418
|
+
const my = currentMidpoint.y;
|
|
419
|
+
|
|
420
|
+
const minPanX = windowWidth - sceneWidth * newZoom;
|
|
421
|
+
const maxPanX = 0;
|
|
422
|
+
const minPanY = windowHeight - sceneHeight * newZoom;
|
|
423
|
+
const maxPanY = 0;
|
|
424
|
+
|
|
425
|
+
let newPanX =
|
|
426
|
+
mx - ((mx - initialPanOffsetPinch.x) / initialZoom) * newZoom;
|
|
427
|
+
let newPanY =
|
|
428
|
+
my - ((my - initialPanOffsetPinch.y) / initialZoom) * newZoom;
|
|
429
|
+
|
|
430
|
+
// Clamp pan to prevent leaving bounds
|
|
431
|
+
newPanX = Math.min(Math.max(newPanX, minPanX), maxPanX);
|
|
432
|
+
newPanY = Math.min(Math.max(newPanY, minPanY), maxPanY);
|
|
433
|
+
|
|
434
|
+
scale.set(newZoom);
|
|
435
|
+
x.set(newPanX);
|
|
436
|
+
y.set(newPanY);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
[
|
|
440
|
+
isPanning,
|
|
441
|
+
isResetting,
|
|
442
|
+
x,
|
|
443
|
+
y,
|
|
444
|
+
scale,
|
|
445
|
+
panStartPoint.x,
|
|
446
|
+
panStartPoint.y,
|
|
447
|
+
windowWidth,
|
|
448
|
+
sceneWidth,
|
|
449
|
+
windowHeight,
|
|
450
|
+
sceneHeight,
|
|
451
|
+
initialPanOffsetOnDrag.x,
|
|
452
|
+
initialPanOffsetOnDrag.y,
|
|
453
|
+
MIN_ZOOM,
|
|
454
|
+
animationStage,
|
|
455
|
+
stopAllSceneMotion,
|
|
456
|
+
],
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const handlePointerUpOrCancel = useCallback(
|
|
460
|
+
(event: PointerEvent<HTMLDivElement>): void => {
|
|
461
|
+
if (animationStage < 2) {
|
|
462
|
+
event.preventDefault();
|
|
463
|
+
return; // ignore pointer up during intro
|
|
464
|
+
}
|
|
465
|
+
stopAllSceneMotion();
|
|
466
|
+
event.preventDefault();
|
|
467
|
+
if ((event.target as HTMLElement).hasPointerCapture(event.pointerId)) {
|
|
468
|
+
(event.target as HTMLElement).releasePointerCapture(event.pointerId);
|
|
469
|
+
}
|
|
470
|
+
activePointersRef.current.delete(event.pointerId);
|
|
471
|
+
|
|
472
|
+
if (isPanning && activePointersRef.current.size < 1) {
|
|
473
|
+
setIsPanning(false);
|
|
474
|
+
if (viewportRef.current)
|
|
475
|
+
viewportRef.current.style.cursor = "url('/customcursor.svg'), grab";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (initialPinchStateRef.current && activePointersRef.current.size < 2) {
|
|
479
|
+
initialPinchStateRef.current = null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (
|
|
483
|
+
!isPanning &&
|
|
484
|
+
activePointersRef.current.size === 1 &&
|
|
485
|
+
!initialPinchStateRef.current
|
|
486
|
+
) {
|
|
487
|
+
const lastPointer = Array.from(activePointersRef.current.values())[0]!;
|
|
488
|
+
setIsPanning(true);
|
|
489
|
+
setPanStartPoint({ x: lastPointer.clientX, y: lastPointer.clientY });
|
|
490
|
+
setInitialPanOffsetOnDrag({ x: x.get(), y: y.get() });
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
[x, y, isPanning, animationStage, stopAllSceneMotion],
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const handleWheelZoom = useCallback(
|
|
497
|
+
(event: WheelEvent) => {
|
|
498
|
+
if (animationStage < 2) {
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
return; // block wheel interaction during intro animations
|
|
501
|
+
}
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
// pinch gesture on track
|
|
504
|
+
const isPinch = event.ctrlKey || event.metaKey;
|
|
505
|
+
const isMouseWheelZoom =
|
|
506
|
+
event.deltaMode === WheelEvent.DOM_DELTA_LINE ||
|
|
507
|
+
Math.abs(event.deltaY) >= 100;
|
|
508
|
+
|
|
509
|
+
// mouse wheel zoom and track pad zoom have different sensitivities
|
|
510
|
+
const ZOOM_SENSITIVITY = isMouseWheelZoom
|
|
511
|
+
? MOUSE_WHEEL_ZOOM_SENSITIVITY
|
|
512
|
+
: TRACKPAD_ZOOM_SENSITIVITY;
|
|
513
|
+
|
|
514
|
+
if (isPinch) {
|
|
515
|
+
const currentZoom = scale.get();
|
|
516
|
+
const nextZoom = Math.max(
|
|
517
|
+
Math.min(
|
|
518
|
+
currentZoom * (1 - event.deltaY * ZOOM_SENSITIVITY),
|
|
519
|
+
MAX_ZOOM,
|
|
520
|
+
),
|
|
521
|
+
MIN_ZOOM,
|
|
522
|
+
(window.innerWidth / canvasWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
|
|
523
|
+
(window.innerHeight / canvasHeight) * ZOOM_BOUND, // Ensure zoom is at least the height of the canvas
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const rect = viewportRef.current?.getBoundingClientRect();
|
|
527
|
+
|
|
528
|
+
if (!rect) return;
|
|
529
|
+
|
|
530
|
+
const vpLeft = rect.left;
|
|
531
|
+
const vpTop = rect.top;
|
|
532
|
+
const viewportWidth = rect.width;
|
|
533
|
+
const viewportHeight = rect.height;
|
|
534
|
+
|
|
535
|
+
const cursorSceneX = (event.clientX - vpLeft - x.get()) / currentZoom;
|
|
536
|
+
const cursorSceneY = (event.clientY - vpTop - y.get()) / currentZoom;
|
|
537
|
+
|
|
538
|
+
let newPanX = event.clientX - vpLeft - cursorSceneX * nextZoom;
|
|
539
|
+
let newPanY = event.clientY - vpTop - cursorSceneY * nextZoom;
|
|
540
|
+
|
|
541
|
+
const minPanX = viewportWidth - sceneWidth * nextZoom;
|
|
542
|
+
const minPanY = viewportHeight - sceneHeight * nextZoom;
|
|
543
|
+
const maxPanX = 0;
|
|
544
|
+
const maxPanY = 0;
|
|
545
|
+
|
|
546
|
+
newPanX = Math.min(maxPanX, Math.max(minPanX, newPanX));
|
|
547
|
+
newPanY = Math.min(maxPanY, Math.max(minPanY, newPanY));
|
|
548
|
+
|
|
549
|
+
x.set(newPanX);
|
|
550
|
+
y.set(newPanY);
|
|
551
|
+
scale.set(nextZoom);
|
|
552
|
+
} else {
|
|
553
|
+
stopAllSceneMotion();
|
|
554
|
+
|
|
555
|
+
const scrollSpeed = 1;
|
|
556
|
+
const newPanX = x.get() - event.deltaX * scrollSpeed;
|
|
557
|
+
const newPanY = y.get() - event.deltaY * scrollSpeed;
|
|
558
|
+
|
|
559
|
+
const minPanX = windowWidth - sceneWidth * scale.get();
|
|
560
|
+
const maxPanX = 0;
|
|
561
|
+
const minPanY = windowHeight - sceneHeight * scale.get();
|
|
562
|
+
const maxPanY = 0;
|
|
563
|
+
|
|
564
|
+
const clampedPanX = Math.min(Math.max(newPanX, minPanX), maxPanX);
|
|
565
|
+
const clampedPanY = Math.min(Math.max(newPanY, minPanY), maxPanY);
|
|
566
|
+
|
|
567
|
+
x.set(clampedPanX);
|
|
568
|
+
y.set(clampedPanY);
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
[
|
|
572
|
+
scale,
|
|
573
|
+
MIN_ZOOM,
|
|
574
|
+
x,
|
|
575
|
+
y,
|
|
576
|
+
sceneWidth,
|
|
577
|
+
sceneHeight,
|
|
578
|
+
windowWidth,
|
|
579
|
+
windowHeight,
|
|
580
|
+
animationStage,
|
|
581
|
+
stopAllSceneMotion,
|
|
582
|
+
],
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
// Keep the wheel handler ref pointing to the latest implementation
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
wheelHandlerRef.current = handleWheelZoom;
|
|
588
|
+
}, [handleWheelZoom]);
|
|
589
|
+
|
|
590
|
+
const handlePanToOffset = useCallback(
|
|
591
|
+
(
|
|
592
|
+
offset: { x: number; y: number },
|
|
593
|
+
onComplete?: () => void,
|
|
594
|
+
zoom?: number,
|
|
595
|
+
) => {
|
|
596
|
+
panToOffset(
|
|
597
|
+
{
|
|
598
|
+
x: -offset.x,
|
|
599
|
+
y: -offset.y,
|
|
600
|
+
},
|
|
601
|
+
viewportRef,
|
|
602
|
+
onComplete,
|
|
603
|
+
zoom,
|
|
604
|
+
);
|
|
605
|
+
},
|
|
606
|
+
[panToOffset, viewportRef],
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
return (
|
|
610
|
+
<CanvasWrapper
|
|
611
|
+
introProgress={introProgress}
|
|
612
|
+
onIntroGrowComplete={startStage2}
|
|
613
|
+
skipIntro={skipIntro}
|
|
614
|
+
introContent={introContent}
|
|
615
|
+
loadingText={loadingText}
|
|
616
|
+
introBackgroundGradient={introBackgroundGradient}
|
|
617
|
+
canvasBoxGradient={canvasBoxGradient}
|
|
618
|
+
growTransition={growTransition}
|
|
619
|
+
blurTransition={blurTransition}
|
|
620
|
+
>
|
|
621
|
+
<CanvasProvider
|
|
622
|
+
x={x}
|
|
623
|
+
y={y}
|
|
624
|
+
scale={scale}
|
|
625
|
+
isResetting={isResetting}
|
|
626
|
+
maxZIndex={maxZIndex}
|
|
627
|
+
setMaxZIndex={setMaxZIndex}
|
|
628
|
+
animationStage={animationStage}
|
|
629
|
+
nextTargetSection={nextTargetSection}
|
|
630
|
+
setNextTargetSection={setNextTargetSection}
|
|
631
|
+
>
|
|
632
|
+
{animationStage >= 2 && (
|
|
633
|
+
<>
|
|
634
|
+
<Toolbar homeCoordinates={offsetHomeCoordinates} />
|
|
635
|
+
{hasNavbarData && navbarCoordinates ? (
|
|
636
|
+
<Navbar
|
|
637
|
+
panToOffset={handlePanToOffset}
|
|
638
|
+
onReset={onResetViewAndItems}
|
|
639
|
+
coordinates={navbarCoordinates}
|
|
640
|
+
sections={navSections as CanvasSection[]}
|
|
641
|
+
homeSection={navHomeSection}
|
|
642
|
+
/>
|
|
643
|
+
) : null}
|
|
644
|
+
</>
|
|
645
|
+
)}
|
|
646
|
+
<div
|
|
647
|
+
ref={setViewportRef}
|
|
648
|
+
className="relative h-full w-full touch-none select-none overflow-hidden"
|
|
649
|
+
style={{
|
|
650
|
+
touchAction: "none",
|
|
651
|
+
pointerEvents: animationStage >= 2 ? "auto" : "none",
|
|
652
|
+
overscrollBehavior: "contain",
|
|
653
|
+
}}
|
|
654
|
+
onPointerDown={handlePointerDown}
|
|
655
|
+
onPointerMove={handlePointerMove}
|
|
656
|
+
onPointerUp={handlePointerUpOrCancel}
|
|
657
|
+
onPointerLeave={handlePointerUpOrCancel}
|
|
658
|
+
onPointerCancel={handlePointerUpOrCancel}
|
|
659
|
+
>
|
|
660
|
+
<motion.div
|
|
661
|
+
ref={sceneRef}
|
|
662
|
+
className="absolute z-20 origin-top-left"
|
|
663
|
+
initial={{ opacity: 0 }}
|
|
664
|
+
animate={{ opacity: 1 }}
|
|
665
|
+
transition={{ duration: 0.3, ease: "easeIn" }}
|
|
666
|
+
style={{
|
|
667
|
+
width: `${canvasWidth}px`,
|
|
668
|
+
height: `${canvasHeight}px`,
|
|
669
|
+
x,
|
|
670
|
+
y,
|
|
671
|
+
scale,
|
|
672
|
+
willChange:
|
|
673
|
+
mode !== "high" && (animationStage < 2 || isPanning)
|
|
674
|
+
? "transform"
|
|
675
|
+
: "auto",
|
|
676
|
+
}}
|
|
677
|
+
>
|
|
678
|
+
<Gradient />
|
|
679
|
+
{animationStage >= 1 &&
|
|
680
|
+
(mode === "high" ? (
|
|
681
|
+
<motion.div
|
|
682
|
+
initial={{ opacity: 0 }}
|
|
683
|
+
animate={{ opacity: 1 }}
|
|
684
|
+
transition={{ duration: 0.5, ease: "easeIn" }}
|
|
685
|
+
>
|
|
686
|
+
<Filter />
|
|
687
|
+
<Dots />
|
|
688
|
+
</motion.div>
|
|
689
|
+
) : (
|
|
690
|
+
<>
|
|
691
|
+
<Filter />
|
|
692
|
+
<Dots />
|
|
693
|
+
</>
|
|
694
|
+
))}
|
|
695
|
+
{children}
|
|
696
|
+
</motion.div>
|
|
697
|
+
</div>
|
|
698
|
+
</CanvasProvider>
|
|
699
|
+
</CanvasWrapper>
|
|
700
|
+
);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
export const gradientBgImage = `radial-gradient(ellipse ${canvasWidth}px ${canvasHeight}px at ${canvasWidth / 2}px ${canvasHeight}px, var(--coral) 0%, var(--salmon) 41%, var(--lilac) 59%, var(--beige) 90%)`;
|
|
704
|
+
|
|
705
|
+
const Gradient = React.memo(function Gradient() {
|
|
706
|
+
return (
|
|
707
|
+
<div
|
|
708
|
+
className="pointer-events-none absolute inset-0 h-full w-full opacity-100"
|
|
709
|
+
style={{
|
|
710
|
+
backgroundImage: gradientBgImage,
|
|
711
|
+
}}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const Dots = React.memo(function Dots() {
|
|
717
|
+
return (
|
|
718
|
+
<div className="pointer-events-none absolute inset-0 h-full w-full bg-[radial-gradient(#776780_1.5px,transparent_1px)] opacity-35 [background-size:22px_22px] " />
|
|
719
|
+
);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const Filter = React.memo(function Filter() {
|
|
723
|
+
return (
|
|
724
|
+
<div className="contrast-60 md:bg-noise pointer-events-none absolute inset-0 hidden h-full w-full bg-none opacity-60 filter md:inline" />
|
|
725
|
+
);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
export default Canvas;
|