@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,213 @@
|
|
|
1
|
+
import { motion, useMotionValueEvent } from "framer-motion";
|
|
2
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
3
|
+
import SingleButton from "./single-button";
|
|
4
|
+
// TODO: These should be passed as props or provided via context from the app
|
|
5
|
+
// For now, apps will need to re-export from this library
|
|
6
|
+
import { CanvasSection } from "../../../types";
|
|
7
|
+
import { useCanvasContext } from "../../../contexts/CanvasContext";
|
|
8
|
+
import useWindowDimensions from "../../../hooks/useWindowDimensions";
|
|
9
|
+
import { usePerformanceMode } from "../../../hooks/usePerformanceMode";
|
|
10
|
+
import {
|
|
11
|
+
getScreenSizeEnum,
|
|
12
|
+
getSectionPanCoordinates,
|
|
13
|
+
} from "../../../lib/canvas";
|
|
14
|
+
import {
|
|
15
|
+
RESPONSIVE_ZOOM_MAP,
|
|
16
|
+
NAVBAR_DEBOUNCE_MS,
|
|
17
|
+
} from "../../../lib/constants";
|
|
18
|
+
|
|
19
|
+
interface NavbarProps {
|
|
20
|
+
panToOffset: (
|
|
21
|
+
offset: { x: number; y: number },
|
|
22
|
+
onComplete?: () => void,
|
|
23
|
+
zoom?: number,
|
|
24
|
+
) => void;
|
|
25
|
+
onReset: () => void;
|
|
26
|
+
// App must provide section coordinates mapping
|
|
27
|
+
coordinates: Record<string, { x: number; y: number; width: number; height: number }>;
|
|
28
|
+
sections: CanvasSection[];
|
|
29
|
+
homeSection: CanvasSection;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function Navbar({
|
|
33
|
+
panToOffset,
|
|
34
|
+
onReset,
|
|
35
|
+
coordinates,
|
|
36
|
+
sections,
|
|
37
|
+
homeSection,
|
|
38
|
+
}: NavbarProps) {
|
|
39
|
+
const { x, y, scale, animationStage, setNextTargetSection } =
|
|
40
|
+
useCanvasContext();
|
|
41
|
+
const [expandedButton, setExpandedButton] = useState<string | null>(null);
|
|
42
|
+
const activePans = useRef(0);
|
|
43
|
+
const panTimeout = useRef<NodeJS.Timeout | null>(null);
|
|
44
|
+
|
|
45
|
+
// Debounce state
|
|
46
|
+
const debounceBlocked = useRef(false);
|
|
47
|
+
const debounceCooldownTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
|
48
|
+
null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const { height, width } = useWindowDimensions();
|
|
52
|
+
const { mode } = usePerformanceMode();
|
|
53
|
+
|
|
54
|
+
const defaultZoom = RESPONSIVE_ZOOM_MAP[getScreenSizeEnum(width)];
|
|
55
|
+
|
|
56
|
+
// Derive debounce duration from performance mode
|
|
57
|
+
const debounceMs = NAVBAR_DEBOUNCE_MS[mode] ?? 0;
|
|
58
|
+
|
|
59
|
+
// Leading-edge debounce handler
|
|
60
|
+
const handleDebouncedClick = useCallback(
|
|
61
|
+
(callback: () => void) => {
|
|
62
|
+
if (debounceMs === 0) {
|
|
63
|
+
callback();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (debounceBlocked.current) {
|
|
68
|
+
// We're in the cooldown window; ignore this click
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Enter cooldown and perform the click immediately
|
|
73
|
+
debounceBlocked.current = true;
|
|
74
|
+
callback();
|
|
75
|
+
|
|
76
|
+
if (debounceCooldownTimeout.current) {
|
|
77
|
+
clearTimeout(debounceCooldownTimeout.current);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
debounceCooldownTimeout.current = setTimeout(() => {
|
|
81
|
+
debounceBlocked.current = false;
|
|
82
|
+
debounceCooldownTimeout.current = null;
|
|
83
|
+
}, debounceMs);
|
|
84
|
+
},
|
|
85
|
+
[debounceMs],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const updateExpandedButton = () => {
|
|
89
|
+
// reset activePans if no movement has occurred recently
|
|
90
|
+
if (panTimeout.current) clearTimeout(panTimeout.current);
|
|
91
|
+
panTimeout.current = setTimeout(() => {
|
|
92
|
+
activePans.current = 0;
|
|
93
|
+
}, 500);
|
|
94
|
+
|
|
95
|
+
if (activePans.current == 0) setExpandedButton(null);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
useMotionValueEvent(x, "change", updateExpandedButton);
|
|
99
|
+
useMotionValueEvent(y, "change", updateExpandedButton);
|
|
100
|
+
useMotionValueEvent(scale, "change", updateExpandedButton);
|
|
101
|
+
|
|
102
|
+
const handlePan = useCallback(
|
|
103
|
+
function handlePan(section: CanvasSection) {
|
|
104
|
+
setExpandedButton(section);
|
|
105
|
+
activePans.current++;
|
|
106
|
+
|
|
107
|
+
// Predictive pre-render hint: mark the target section so its CanvasComponent can
|
|
108
|
+
// render even before it comes fully into view.
|
|
109
|
+
setNextTargetSection(section);
|
|
110
|
+
|
|
111
|
+
if (section === homeSection) {
|
|
112
|
+
onReset();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sectionCoords = coordinates[section];
|
|
117
|
+
if (!sectionCoords) return;
|
|
118
|
+
|
|
119
|
+
const panCoords = getSectionPanCoordinates({
|
|
120
|
+
windowDimensions: { width, height },
|
|
121
|
+
coords: sectionCoords,
|
|
122
|
+
targetZoom: defaultZoom,
|
|
123
|
+
negative: true,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
panToOffset(
|
|
127
|
+
panCoords,
|
|
128
|
+
() => {
|
|
129
|
+
activePans.current--;
|
|
130
|
+
},
|
|
131
|
+
defaultZoom,
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
[panToOffset, onReset, width, height, defaultZoom, setNextTargetSection],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Clean up timers on unmount
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (animationStage < 2) return;
|
|
140
|
+
handlePan(CanvasSection.Home);
|
|
141
|
+
return () => {
|
|
142
|
+
if (panTimeout.current) clearTimeout(panTimeout.current);
|
|
143
|
+
if (debounceCooldownTimeout.current)
|
|
144
|
+
clearTimeout(debounceCooldownTimeout.current);
|
|
145
|
+
};
|
|
146
|
+
}, [handlePan, animationStage]);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
className="bottom-12 md:bottom-4"
|
|
151
|
+
style={{
|
|
152
|
+
position: "fixed",
|
|
153
|
+
left: "50%",
|
|
154
|
+
transform: "translateX(-50%)",
|
|
155
|
+
zIndex: 1000,
|
|
156
|
+
pointerEvents: "auto",
|
|
157
|
+
display: "flex",
|
|
158
|
+
justifyContent: "center",
|
|
159
|
+
alignItems: "center",
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{/* padding to prevent edge bug */}
|
|
163
|
+
<div className="px-4 md:px-8">
|
|
164
|
+
<motion.div className="flex select-none items-center justify-center gap-1 rounded-[10px] border-[1px] border-border bg-offwhite p-1 shadow-[0_6px_12px_rgba(0,0,0,0.10)]">
|
|
165
|
+
<div className="flex items-center gap-1">
|
|
166
|
+
<SingleButton
|
|
167
|
+
label="Home"
|
|
168
|
+
icon="Home"
|
|
169
|
+
onClick={() => handlePan(CanvasSection.Home)}
|
|
170
|
+
isPushed={expandedButton === CanvasSection.Home}
|
|
171
|
+
onDebouncedClick={handleDebouncedClick}
|
|
172
|
+
/>
|
|
173
|
+
<SingleButton
|
|
174
|
+
label="About"
|
|
175
|
+
icon="Info"
|
|
176
|
+
onClick={() => handlePan(CanvasSection.About)}
|
|
177
|
+
isPushed={expandedButton === CanvasSection.About}
|
|
178
|
+
onDebouncedClick={handleDebouncedClick}
|
|
179
|
+
/>
|
|
180
|
+
<SingleButton
|
|
181
|
+
label="Projects"
|
|
182
|
+
icon="LayoutDashboard"
|
|
183
|
+
onClick={() => handlePan(CanvasSection.Projects)}
|
|
184
|
+
isPushed={expandedButton === CanvasSection.Projects}
|
|
185
|
+
onDebouncedClick={handleDebouncedClick}
|
|
186
|
+
/>
|
|
187
|
+
<SingleButton
|
|
188
|
+
label="Sponsors"
|
|
189
|
+
icon="Handshake"
|
|
190
|
+
onClick={() => handlePan(CanvasSection.Sponsors)}
|
|
191
|
+
isPushed={expandedButton === CanvasSection.Sponsors}
|
|
192
|
+
onDebouncedClick={handleDebouncedClick}
|
|
193
|
+
/>
|
|
194
|
+
<SingleButton
|
|
195
|
+
label="FAQ"
|
|
196
|
+
icon="HelpCircle"
|
|
197
|
+
onClick={() => handlePan(CanvasSection.FAQ)}
|
|
198
|
+
isPushed={expandedButton === CanvasSection.FAQ}
|
|
199
|
+
onDebouncedClick={handleDebouncedClick}
|
|
200
|
+
/>
|
|
201
|
+
<SingleButton
|
|
202
|
+
label="Team"
|
|
203
|
+
icon="Users"
|
|
204
|
+
onClick={() => handlePan(CanvasSection.Team)}
|
|
205
|
+
isPushed={expandedButton === CanvasSection.Team}
|
|
206
|
+
onDebouncedClick={handleDebouncedClick}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
</motion.div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import * as LucideIcons from "lucide-react";
|
|
3
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
4
|
+
import { useToast } from "../../../hooks/use-toast";
|
|
5
|
+
import { copyText } from "../../../lib/copy";
|
|
6
|
+
|
|
7
|
+
type IconName = keyof typeof LucideIcons;
|
|
8
|
+
|
|
9
|
+
interface SingleButtonProps {
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: IconName;
|
|
12
|
+
customIcon?: React.ComponentType<{ className?: string }>;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
isPushed: boolean;
|
|
15
|
+
link?: string;
|
|
16
|
+
emailAddress?: string;
|
|
17
|
+
onDebouncedClick?: (callback: () => void) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function SingleButton({
|
|
21
|
+
label,
|
|
22
|
+
icon,
|
|
23
|
+
customIcon,
|
|
24
|
+
onClick,
|
|
25
|
+
isPushed,
|
|
26
|
+
link,
|
|
27
|
+
emailAddress,
|
|
28
|
+
onDebouncedClick,
|
|
29
|
+
}: SingleButtonProps) {
|
|
30
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
31
|
+
const [showTag, setShowTag] = useState(false);
|
|
32
|
+
const [copiedEmail, setCopiedEmail] = useState(false);
|
|
33
|
+
const Icon = icon ? (LucideIcons[icon] as LucideIcons.LucideIcon) : null;
|
|
34
|
+
const CustomIcon = customIcon;
|
|
35
|
+
const TagDelay = 100;
|
|
36
|
+
const { toast } = useToast();
|
|
37
|
+
|
|
38
|
+
// Ensure either icon or customIcon is provided
|
|
39
|
+
if (!Icon && !CustomIcon) {
|
|
40
|
+
throw new Error("Either 'icon' or 'customIcon' prop must be provided");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
let timeoutId: NodeJS.Timeout;
|
|
45
|
+
|
|
46
|
+
if (isHovered) {
|
|
47
|
+
timeoutId = setTimeout(() => {
|
|
48
|
+
setShowTag(true);
|
|
49
|
+
}, TagDelay);
|
|
50
|
+
} else {
|
|
51
|
+
setShowTag(false);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
clearTimeout(timeoutId);
|
|
56
|
+
};
|
|
57
|
+
}, [isHovered]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setShowTag(false);
|
|
61
|
+
setIsHovered(false);
|
|
62
|
+
}, [isPushed]);
|
|
63
|
+
|
|
64
|
+
// Reset copied email state after 2 seconds
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (copiedEmail) {
|
|
67
|
+
const timeoutId = setTimeout(() => {
|
|
68
|
+
setCopiedEmail(false);
|
|
69
|
+
}, 2000);
|
|
70
|
+
return () => clearTimeout(timeoutId);
|
|
71
|
+
}
|
|
72
|
+
}, [copiedEmail]);
|
|
73
|
+
|
|
74
|
+
const performClick = () => {
|
|
75
|
+
if (emailAddress) {
|
|
76
|
+
const mailto = `mailto:${emailAddress}`;
|
|
77
|
+
|
|
78
|
+
void (async () => {
|
|
79
|
+
const copied =
|
|
80
|
+
(await copyText(mailto)) || (await copyText(emailAddress));
|
|
81
|
+
|
|
82
|
+
if (copied) {
|
|
83
|
+
setCopiedEmail(true);
|
|
84
|
+
toast({
|
|
85
|
+
title: "Email copied!",
|
|
86
|
+
variant: "cute",
|
|
87
|
+
duration: 2000,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
window.open(mailto, "_blank");
|
|
91
|
+
toast({
|
|
92
|
+
title: "Email app opened!",
|
|
93
|
+
duration: 3000,
|
|
94
|
+
variant: "cute",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (link) {
|
|
103
|
+
window.open(link, "_blank", "noopener,noreferrer");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
onClick?.();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleClick = () => {
|
|
111
|
+
if (onDebouncedClick) {
|
|
112
|
+
onDebouncedClick(performClick);
|
|
113
|
+
} else {
|
|
114
|
+
performClick();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const displayLabel = copiedEmail ? "Email copied!" : label;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<motion.button
|
|
122
|
+
aria-label={label}
|
|
123
|
+
className={`relative flex items-center rounded-md p-2 text-medium transition-colors duration-200 ${isPushed ? "bg-[#EEE2FB]" : isHovered ? "bg-highlight" : ""
|
|
124
|
+
}`}
|
|
125
|
+
onClick={handleClick}
|
|
126
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
127
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
128
|
+
whileTap={{ scale: 0.95 }}
|
|
129
|
+
transition={{
|
|
130
|
+
type: "spring",
|
|
131
|
+
stiffness: 400,
|
|
132
|
+
damping: 25,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{isPushed ? (
|
|
136
|
+
<div className="flex items-center gap-2">
|
|
137
|
+
<div>
|
|
138
|
+
{Icon ? (
|
|
139
|
+
<Icon
|
|
140
|
+
className={`h-5 w-5 flex-shrink-0 ${isPushed ? "text-emphasis" : "text-medium"}`}
|
|
141
|
+
/>
|
|
142
|
+
) : CustomIcon ? (
|
|
143
|
+
<CustomIcon
|
|
144
|
+
className={`h-5 w-5 flex-shrink-0 ${isPushed ? "text-white" : "text-medium"
|
|
145
|
+
}`}
|
|
146
|
+
/>
|
|
147
|
+
) : null}
|
|
148
|
+
</div>
|
|
149
|
+
<motion.span
|
|
150
|
+
initial={{ opacity: 0, width: 0 }}
|
|
151
|
+
animate={{ opacity: 1, width: "auto" }}
|
|
152
|
+
exit={{ opacity: 0, width: 0 }}
|
|
153
|
+
transition={{
|
|
154
|
+
duration: 0.1,
|
|
155
|
+
ease: "easeInOut",
|
|
156
|
+
}}
|
|
157
|
+
className="overflow-hidden whitespace-nowrap font-figtree text-sm font-medium text-emphasis"
|
|
158
|
+
>
|
|
159
|
+
{displayLabel}
|
|
160
|
+
</motion.span>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<div>
|
|
164
|
+
{Icon ? (
|
|
165
|
+
<Icon
|
|
166
|
+
className={`h-5 w-5 flex-shrink-0 ${isPushed ? "text-white" : "text-medium"
|
|
167
|
+
}`}
|
|
168
|
+
/>
|
|
169
|
+
) : CustomIcon ? (
|
|
170
|
+
<CustomIcon
|
|
171
|
+
className={`h-5 w-5 flex-shrink-0 ${isPushed ? "text-white" : "text-medium"
|
|
172
|
+
}`}
|
|
173
|
+
/>
|
|
174
|
+
) : null}
|
|
175
|
+
<AnimatePresence>
|
|
176
|
+
{showTag && !isPushed && (
|
|
177
|
+
<motion.div
|
|
178
|
+
initial={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
|
|
179
|
+
animate={{ opacity: 1, y: 0, scale: 1, x: "-50%" }}
|
|
180
|
+
exit={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
|
|
181
|
+
transition={{
|
|
182
|
+
duration: 0.05,
|
|
183
|
+
ease: "easeOut",
|
|
184
|
+
}}
|
|
185
|
+
className="pointer-events-none absolute -top-10 left-1/2 z-50"
|
|
186
|
+
>
|
|
187
|
+
<div className="rounded-sm bg-gradient-to-t from-black/10 to-transparent px-[1px] pb-[2.5px] pt-[1px]">
|
|
188
|
+
<div className="whitespace-nowrap rounded-sm bg-offwhite px-2 py-1 font-figtree text-sm text-medium">
|
|
189
|
+
{displayLabel}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</motion.div>
|
|
193
|
+
)}
|
|
194
|
+
</AnimatePresence>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</motion.button>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { motion, type Point } from "framer-motion";
|
|
2
|
+
|
|
3
|
+
export const OffsetComponent = ({
|
|
4
|
+
offset,
|
|
5
|
+
children,
|
|
6
|
+
}: {
|
|
7
|
+
offset: Point;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}) => {
|
|
10
|
+
return (
|
|
11
|
+
<motion.div
|
|
12
|
+
style={{
|
|
13
|
+
position: "absolute",
|
|
14
|
+
top: offset.y,
|
|
15
|
+
left: offset.x,
|
|
16
|
+
width: "100%",
|
|
17
|
+
height: "100%",
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</motion.div>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
|
|
3
|
+
const Reset = ({
|
|
4
|
+
onResetViewAndItems,
|
|
5
|
+
}: {
|
|
6
|
+
onResetViewAndItems: () => void;
|
|
7
|
+
}) => {
|
|
8
|
+
return (
|
|
9
|
+
<div className="absolute bottom-4 left-4 z-[1000] flex cursor-[url('/customcursor.svg'),auto] select-none">
|
|
10
|
+
<button
|
|
11
|
+
className="rounded bg-gray-700 p-1.5 font-mono text-xs text-white shadow-md transition-colors hover:bg-gray-600 md:text-sm"
|
|
12
|
+
onClick={onResetViewAndItems}
|
|
13
|
+
onPointerDown={(e: React.PointerEvent) => e.stopPropagation()}
|
|
14
|
+
>
|
|
15
|
+
<Image src="/images/reset.svg" alt="Reset" width={18} height={18} />
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default Reset;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type Point, useTransform, motion } from "framer-motion";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCanvasContext } from "../../contexts/CanvasContext";
|
|
4
|
+
import {
|
|
5
|
+
TOOLBAR_OPACITY_POS_EPS,
|
|
6
|
+
TOOLBAR_OPACITY_SCALE_EPS,
|
|
7
|
+
} from "../../lib/constants";
|
|
8
|
+
|
|
9
|
+
type ToolbarProps = {
|
|
10
|
+
homeCoordinates?: Point;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const Toolbar = ({ homeCoordinates = { x: 0, y: 0 } }: ToolbarProps) => {
|
|
14
|
+
const { x, y, scale } = useCanvasContext();
|
|
15
|
+
const [hasMounted, setHasMounted] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setHasMounted(true);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
// numeric MotionValues
|
|
22
|
+
const rawDx = useTransform(
|
|
23
|
+
[x, scale],
|
|
24
|
+
([lx, ls]) => -((lx as number) / (ls as number)) + homeCoordinates.x,
|
|
25
|
+
);
|
|
26
|
+
const rawDy = useTransform(
|
|
27
|
+
[y, scale],
|
|
28
|
+
([ly, ls]) => -((ly as number) / (ls as number)) + homeCoordinates.y,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// formatted MotionValues
|
|
32
|
+
const displayX = useTransform(rawDx, (v) => Math.round(v).toString());
|
|
33
|
+
const displayY = useTransform(rawDy, (v) => Math.round(v).toString());
|
|
34
|
+
const displayScale = useTransform(scale, (v) => v.toFixed(2));
|
|
35
|
+
|
|
36
|
+
const opacity = useTransform([rawDx, rawDy, scale], ([dx, dy, ls]) =>
|
|
37
|
+
Math.abs(dx as number) < TOOLBAR_OPACITY_POS_EPS &&
|
|
38
|
+
Math.abs(dy as number) < TOOLBAR_OPACITY_POS_EPS &&
|
|
39
|
+
Math.abs((ls as number) - 1) < TOOLBAR_OPACITY_SCALE_EPS
|
|
40
|
+
? 0
|
|
41
|
+
: 1,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const handlePointerDown = (e: React.PointerEvent) => e.stopPropagation();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<motion.div
|
|
48
|
+
className="absolute left-4 top-6 z-[1000] cursor-default select-none rounded-[10px] border border-border bg-offwhite p-2 font-mono text-xs text-heavy shadow-[0_6px_12px_rgba(0,0,0,0.10)] sm:top-4 md:text-sm"
|
|
49
|
+
onPointerDown={handlePointerDown}
|
|
50
|
+
data-toolbar-button
|
|
51
|
+
style={{ opacity }}
|
|
52
|
+
>
|
|
53
|
+
{hasMounted ? (
|
|
54
|
+
<>
|
|
55
|
+
(<motion.span>{displayX}</motion.span>,{" "}
|
|
56
|
+
<motion.span>{displayY}</motion.span>)
|
|
57
|
+
<span className="text-light"> |</span>{" "}
|
|
58
|
+
<motion.span>{displayScale}</motion.span>x
|
|
59
|
+
</>
|
|
60
|
+
) : (
|
|
61
|
+
<span style={{ opacity: 0 }}>(0, 0) | 1.00x</span>
|
|
62
|
+
)}
|
|
63
|
+
</motion.div>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default Toolbar;
|