@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.
Files changed (141) hide show
  1. package/dist/components/canvas/canvas.d.ts +29 -0
  2. package/dist/components/canvas/canvas.d.ts.map +1 -0
  3. package/dist/components/canvas/canvas.js +419 -0
  4. package/dist/components/canvas/canvas.js.map +1 -0
  5. package/dist/components/canvas/component.d.ts +47 -0
  6. package/dist/components/canvas/component.d.ts.map +1 -0
  7. package/dist/components/canvas/component.js +177 -0
  8. package/dist/components/canvas/component.js.map +1 -0
  9. package/dist/components/canvas/cursor.d.ts +8 -0
  10. package/dist/components/canvas/cursor.d.ts.map +1 -0
  11. package/dist/components/canvas/cursor.js +32 -0
  12. package/dist/components/canvas/cursor.js.map +1 -0
  13. package/dist/components/canvas/draggable.d.ts +21 -0
  14. package/dist/components/canvas/draggable.d.ts.map +1 -0
  15. package/dist/components/canvas/draggable.js +163 -0
  16. package/dist/components/canvas/draggable.js.map +1 -0
  17. package/dist/components/canvas/navbar/index.d.ts +19 -0
  18. package/dist/components/canvas/navbar/index.d.ts.map +1 -0
  19. package/dist/components/canvas/navbar/index.js +106 -0
  20. package/dist/components/canvas/navbar/index.js.map +1 -0
  21. package/dist/components/canvas/navbar/single-button.d.ts +17 -0
  22. package/dist/components/canvas/navbar/single-button.d.ts.map +1 -0
  23. package/dist/components/canvas/navbar/single-button.js +97 -0
  24. package/dist/components/canvas/navbar/single-button.js.map +1 -0
  25. package/dist/components/canvas/offest.d.ts +6 -0
  26. package/dist/components/canvas/offest.d.ts.map +1 -0
  27. package/dist/components/canvas/offest.js +12 -0
  28. package/dist/components/canvas/offest.js.map +1 -0
  29. package/dist/components/canvas/reset.d.ts +5 -0
  30. package/dist/components/canvas/reset.d.ts.map +1 -0
  31. package/dist/components/canvas/reset.js +7 -0
  32. package/dist/components/canvas/reset.js.map +1 -0
  33. package/dist/components/canvas/toolbar.d.ts +7 -0
  34. package/dist/components/canvas/toolbar.d.ts.map +1 -0
  35. package/dist/components/canvas/toolbar.js +28 -0
  36. package/dist/components/canvas/toolbar.js.map +1 -0
  37. package/dist/components/canvas/wrapper.d.ts +26 -0
  38. package/dist/components/canvas/wrapper.d.ts.map +1 -0
  39. package/dist/components/canvas/wrapper.js +107 -0
  40. package/dist/components/canvas/wrapper.js.map +1 -0
  41. package/dist/components/ui/FolderIcon.d.ts +9 -0
  42. package/dist/components/ui/FolderIcon.d.ts.map +1 -0
  43. package/dist/components/ui/FolderIcon.js +25 -0
  44. package/dist/components/ui/FolderIcon.js.map +1 -0
  45. package/dist/components/ui/button.d.ts +14 -0
  46. package/dist/components/ui/button.d.ts.map +1 -0
  47. package/dist/components/ui/button.js +54 -0
  48. package/dist/components/ui/button.js.map +1 -0
  49. package/dist/components/ui/label.d.ts +6 -0
  50. package/dist/components/ui/label.d.ts.map +1 -0
  51. package/dist/components/ui/label.js +10 -0
  52. package/dist/components/ui/label.js.map +1 -0
  53. package/dist/components/ui/toast.d.ts +16 -0
  54. package/dist/components/ui/toast.d.ts.map +1 -0
  55. package/dist/components/ui/toast.js +41 -0
  56. package/dist/components/ui/toast.js.map +1 -0
  57. package/dist/components/ui/toaster.d.ts +2 -0
  58. package/dist/components/ui/toaster.d.ts.map +1 -0
  59. package/dist/components/ui/toaster.js +10 -0
  60. package/dist/components/ui/toaster.js.map +1 -0
  61. package/dist/contexts/CanvasContext.d.ts +26 -0
  62. package/dist/contexts/CanvasContext.d.ts.map +1 -0
  63. package/dist/contexts/CanvasContext.js +22 -0
  64. package/dist/contexts/CanvasContext.js.map +1 -0
  65. package/dist/contexts/PerformanceContext.d.ts +31 -0
  66. package/dist/contexts/PerformanceContext.d.ts.map +1 -0
  67. package/dist/contexts/PerformanceContext.js +56 -0
  68. package/dist/contexts/PerformanceContext.js.map +1 -0
  69. package/dist/hooks/use-mobile.d.ts +2 -0
  70. package/dist/hooks/use-mobile.d.ts.map +1 -0
  71. package/dist/hooks/use-mobile.js +16 -0
  72. package/dist/hooks/use-mobile.js.map +1 -0
  73. package/dist/hooks/use-toast.d.ts +45 -0
  74. package/dist/hooks/use-toast.d.ts.map +1 -0
  75. package/dist/hooks/use-toast.js +126 -0
  76. package/dist/hooks/use-toast.js.map +1 -0
  77. package/dist/hooks/usePerformanceMode.d.ts +6 -0
  78. package/dist/hooks/usePerformanceMode.d.ts.map +1 -0
  79. package/dist/hooks/usePerformanceMode.js +6 -0
  80. package/dist/hooks/usePerformanceMode.js.map +1 -0
  81. package/dist/hooks/useWindowDimensions.d.ts +7 -0
  82. package/dist/hooks/useWindowDimensions.d.ts.map +1 -0
  83. package/dist/hooks/useWindowDimensions.js +22 -0
  84. package/dist/hooks/useWindowDimensions.js.map +1 -0
  85. package/dist/index.d.ts +26 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +28 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/lib/canvas.d.ts +35 -0
  90. package/dist/lib/canvas.d.ts.map +1 -0
  91. package/dist/lib/canvas.js +82 -0
  92. package/dist/lib/canvas.js.map +1 -0
  93. package/dist/lib/constants.d.ts +78 -0
  94. package/dist/lib/constants.d.ts.map +1 -0
  95. package/dist/lib/constants.js +122 -0
  96. package/dist/lib/constants.js.map +1 -0
  97. package/dist/lib/copy.d.ts +2 -0
  98. package/dist/lib/copy.d.ts.map +1 -0
  99. package/dist/lib/copy.js +20 -0
  100. package/dist/lib/copy.js.map +1 -0
  101. package/dist/lib/utils.d.ts +4 -0
  102. package/dist/lib/utils.d.ts.map +1 -0
  103. package/dist/lib/utils.js +14 -0
  104. package/dist/lib/utils.js.map +1 -0
  105. package/dist/types/index.d.ts +19 -0
  106. package/dist/types/index.d.ts.map +1 -0
  107. package/dist/types/index.js +14 -0
  108. package/dist/types/index.js.map +1 -0
  109. package/dist/utils/performance.d.ts +9 -0
  110. package/dist/utils/performance.d.ts.map +1 -0
  111. package/dist/utils/performance.js +29 -0
  112. package/dist/utils/performance.js.map +1 -0
  113. package/package.json +55 -0
  114. package/src/components/canvas/canvas.tsx +728 -0
  115. package/src/components/canvas/component.tsx +230 -0
  116. package/src/components/canvas/cursor.tsx +161 -0
  117. package/src/components/canvas/draggable.tsx +298 -0
  118. package/src/components/canvas/navbar/index.tsx +213 -0
  119. package/src/components/canvas/navbar/single-button.tsx +199 -0
  120. package/src/components/canvas/offest.tsx +23 -0
  121. package/src/components/canvas/reset.tsx +21 -0
  122. package/src/components/canvas/toolbar.tsx +67 -0
  123. package/src/components/canvas/wrapper.tsx +219 -0
  124. package/src/components/ui/FolderIcon.tsx +116 -0
  125. package/src/components/ui/button.tsx +162 -0
  126. package/src/components/ui/label.tsx +24 -0
  127. package/src/components/ui/toast.tsx +136 -0
  128. package/src/components/ui/toaster.tsx +33 -0
  129. package/src/contexts/CanvasContext.tsx +54 -0
  130. package/src/contexts/PerformanceContext.tsx +81 -0
  131. package/src/hooks/use-mobile.ts +21 -0
  132. package/src/hooks/use-toast.ts +186 -0
  133. package/src/hooks/usePerformanceMode.ts +5 -0
  134. package/src/hooks/useWindowDimensions.ts +32 -0
  135. package/src/index.ts +36 -0
  136. package/src/lib/canvas.ts +132 -0
  137. package/src/lib/constants.ts +153 -0
  138. package/src/lib/copy.ts +18 -0
  139. package/src/lib/utils.ts +18 -0
  140. package/src/types/index.ts +20 -0
  141. 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;