@hunterchen/canvas 0.6.0 → 0.8.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 (39) hide show
  1. package/README.md +239 -9
  2. package/dist/components/canvas/backgrounds.d.ts +4 -4
  3. package/dist/components/canvas/backgrounds.d.ts.map +1 -1
  4. package/dist/components/canvas/backgrounds.js +7 -7
  5. package/dist/components/canvas/backgrounds.js.map +1 -1
  6. package/dist/components/canvas/canvas.d.ts +5 -1
  7. package/dist/components/canvas/canvas.d.ts.map +1 -1
  8. package/dist/components/canvas/canvas.js +16 -16
  9. package/dist/components/canvas/canvas.js.map +1 -1
  10. package/dist/components/canvas/navbar/index.d.ts +4 -2
  11. package/dist/components/canvas/navbar/index.d.ts.map +1 -1
  12. package/dist/components/canvas/navbar/index.js +56 -11
  13. package/dist/components/canvas/navbar/index.js.map +1 -1
  14. package/dist/components/canvas/navbar/single-button.d.ts +10 -1
  15. package/dist/components/canvas/navbar/single-button.d.ts.map +1 -1
  16. package/dist/components/canvas/navbar/single-button.js +95 -15
  17. package/dist/components/canvas/navbar/single-button.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/lib/canvas.d.ts.map +1 -1
  21. package/dist/lib/canvas.js +3 -3
  22. package/dist/lib/canvas.js.map +1 -1
  23. package/dist/lib/constants.d.ts +2 -2
  24. package/dist/lib/constants.d.ts.map +1 -1
  25. package/dist/lib/constants.js +2 -2
  26. package/dist/lib/constants.js.map +1 -1
  27. package/dist/styles.css +1 -1
  28. package/dist/types/index.d.ts +69 -0
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/package.json +4 -1
  31. package/src/components/canvas/backgrounds.tsx +7 -7
  32. package/src/components/canvas/canvas.tsx +30 -15
  33. package/src/components/canvas/navbar/index.tsx +91 -15
  34. package/src/components/canvas/navbar/single-button.tsx +210 -56
  35. package/src/index.ts +5 -0
  36. package/src/lib/canvas.ts +4 -4
  37. package/src/lib/constants.ts +2 -2
  38. package/src/styles.css +15 -13
  39. package/src/types/index.ts +91 -0
@@ -32,8 +32,8 @@ export interface DefaultCanvasBackgroundProps {
32
32
  children?: ReactNode;
33
33
  }
34
34
 
35
- /** The default canvas gradient (neutral gray) */
36
- export const DEFAULT_CANVAS_GRADIENT = `radial-gradient(ellipse ${canvasWidth}px ${canvasHeight}px at ${canvasWidth / 2}px ${canvasHeight}px, #e5e5e5 0%, #d4d4d4 41%, #a3a3a3 59%, #f5f5f5 90%)`;
35
+ /** The default canvas gradient (neutral light gray) */
36
+ export const DEFAULT_CANVAS_GRADIENT = `radial-gradient(ellipse ${canvasWidth}px ${canvasHeight}px at ${canvasWidth / 2}px ${canvasHeight}px, #fafafa 0%, #f5f5f5 41%, #e5e5e5 59%, #fafafa 90%)`;
37
37
 
38
38
  /**
39
39
  * Default canvas background with gradient, dots, and noise filter.
@@ -42,7 +42,7 @@ export const DEFAULT_CANVAS_GRADIENT = `radial-gradient(ellipse ${canvasWidth}px
42
42
  export const DefaultCanvasBackground: React.FC<DefaultCanvasBackgroundProps> = ({
43
43
  gradientStyle,
44
44
  showDots = true,
45
- dotColor = "#888888",
45
+ dotColor = "#a3a3a3",
46
46
  dotSize = 1.5,
47
47
  dotSpacing = 22,
48
48
  dotOpacity = 0.35,
@@ -117,7 +117,7 @@ export interface DefaultWrapperBackgroundProps {
117
117
  * Default wrapper/intro background gradient.
118
118
  */
119
119
  export const DefaultWrapperBackground: React.FC<DefaultWrapperBackgroundProps> = ({
120
- gradient = "linear-gradient(to top, #d4d4d4 0%, #e5e5e5 50%, #f5f5f5 100%)",
120
+ gradient = "linear-gradient(to top, #f5f5f5 0%, #fafafa 50%, #ffffff 100%)",
121
121
  className,
122
122
  style,
123
123
  }) => {
@@ -197,9 +197,9 @@ export const DefaultIntroContent: React.FC<DefaultIntroContentProps> = ({
197
197
  );
198
198
  };
199
199
 
200
- // Default gradient values for export (neutral grays)
200
+ // Default gradient values for export (neutral light grays)
201
201
  export const DEFAULT_INTRO_GRADIENT =
202
- "linear-gradient(to top, #d4d4d4 0%, #e5e5e5 50%, #f5f5f5 100%)";
202
+ "linear-gradient(to top, #f5f5f5 0%, #fafafa 50%, #ffffff 100%)";
203
203
 
204
204
  export const DEFAULT_CANVAS_BOX_GRADIENT =
205
- "radial-gradient(130.38% 95% at 50.03% 97.25%, #d4d4d4 0%, #e5e5e5 48.09%, #f5f5f5 100%)";
205
+ "radial-gradient(130.38% 95% at 50.03% 97.25%, #f5f5f5 0%, #fafafa 48.09%, #ffffff 100%)";
@@ -35,6 +35,8 @@ import {
35
35
  STAGE2_TRANSITION,
36
36
  MOUSE_WHEEL_ZOOM_SENSITIVITY,
37
37
  TRACKPAD_ZOOM_SENSITIVITY,
38
+ DEFAULT_CANVAS_WIDTH,
39
+ DEFAULT_CANVAS_HEIGHT,
38
40
  } from "../../lib/constants";
39
41
  import useWindowDimensions from "../../hooks/useWindowDimensions";
40
42
  import Navbar from "./navbar";
@@ -42,6 +44,7 @@ import Toolbar from "./toolbar";
42
44
  import type {
43
45
  CanvasSection,
44
46
  NavItem,
47
+ NavbarConfig,
45
48
  SectionCoordinates,
46
49
  ToolbarConfig,
47
50
  } from "../../types";
@@ -54,6 +57,10 @@ interface Props {
54
57
  homeCoordinates: SectionCoordinates;
55
58
  children: React.ReactNode;
56
59
 
60
+ // Optional height and with params, if omitted sizing will be 6000x4000
61
+ canvasWidth?:number;
62
+ canvasHeight?:number;
63
+
57
64
  // Navbar data (optional). If omitted, navbar is hidden.
58
65
  /** Array of navigation items for the navbar. If omitted, navbar is hidden. */
59
66
  navItems?: NavItem[];
@@ -83,6 +90,10 @@ interface Props {
83
90
  // ============== Toolbar Customization ==============
84
91
  /** Toolbar customization options */
85
92
  toolbarConfig?: ToolbarConfig;
93
+
94
+ // ============== Navbar Customization ==============
95
+ /** Navbar customization options */
96
+ navbarConfig?: NavbarConfig;
86
97
  }
87
98
 
88
99
  const stopAllMotion = (
@@ -109,6 +120,9 @@ const Canvas: FC<Props> = ({
109
120
  canvasBackground,
110
121
  wrapperBackground,
111
122
  toolbarConfig,
123
+ navbarConfig,
124
+ canvasHeight,
125
+ canvasWidth,
112
126
  }) => {
113
127
  const { height: windowHeight, width: windowWidth } = useWindowDimensions();
114
128
 
@@ -116,8 +130,8 @@ const Canvas: FC<Props> = ({
116
130
 
117
131
  const hasNavbar = Boolean(navItems && navItems.length > 0);
118
132
 
119
- const sceneWidth = canvasWidth;
120
- const sceneHeight = canvasHeight;
133
+ const sceneWidth = canvasWidth ?? DEFAULT_CANVAS_WIDTH;
134
+ const sceneHeight = canvasHeight ?? DEFAULT_CANVAS_HEIGHT;
121
135
 
122
136
  const MIN_ZOOM = MIN_ZOOMS[getScreenSizeEnum(windowWidth)];
123
137
 
@@ -176,13 +190,13 @@ const Canvas: FC<Props> = ({
176
190
  // Precompute final stage1 scale and offsets (snapshot dimensions once on mount)
177
191
  const stage1Targets = useMemo(() => {
178
192
  const finalScale = Math.max(
179
- (windowWidth || 0) / canvasWidth,
180
- (windowHeight || 0) / canvasHeight
193
+ (windowWidth || 0) / sceneWidth,
194
+ (windowHeight || 0) / sceneHeight
181
195
  );
182
- const endX = (windowWidth - canvasWidth * finalScale) / 2;
183
- const endY = (windowHeight - canvasHeight * finalScale) / 2;
196
+ const endX = (windowWidth - sceneWidth * finalScale) / 2;
197
+ const endY = (windowHeight - sceneHeight * finalScale) / 2;
184
198
  return { finalScale, endX, endY };
185
- }, [windowWidth, windowHeight]);
199
+ }, [windowWidth, windowHeight, sceneWidth, sceneHeight]);
186
200
 
187
201
  // Replace direct motion values with derived transforms during stage1
188
202
  const derivedScale = useTransform(
@@ -406,8 +420,8 @@ const Canvas: FC<Props> = ({
406
420
 
407
421
  let newZoom = initialZoom * (currentDistance / initialDistance);
408
422
  newZoom = Math.max(
409
- (window.innerWidth / canvasWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
410
- (window.innerHeight / canvasHeight) * ZOOM_BOUND, // Ensure zoom is at least the height of the canvas
423
+ (window.innerWidth / sceneWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
424
+ (window.innerHeight / sceneHeight) * ZOOM_BOUND, // Ensure zoom is at least the height of the canvas
411
425
  Math.min(newZoom, 10),
412
426
  MIN_ZOOM // Ensure zoom is not less than MIN_ZOOM
413
427
  );
@@ -517,8 +531,8 @@ const Canvas: FC<Props> = ({
517
531
  MAX_ZOOM
518
532
  ),
519
533
  MIN_ZOOM,
520
- (window.innerWidth / canvasWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
521
- (window.innerHeight / canvasHeight) * ZOOM_BOUND // Ensure zoom is at least the height of the canvas
534
+ (window.innerWidth / sceneWidth) * ZOOM_BOUND, // Ensure zoom is at least the width of the canvas
535
+ (window.innerHeight / sceneHeight) * ZOOM_BOUND // Ensure zoom is at least the height of the canvas
522
536
  );
523
537
 
524
538
  const rect = viewportRef.current?.getBoundingClientRect();
@@ -636,13 +650,14 @@ const Canvas: FC<Props> = ({
636
650
  config={toolbarConfig}
637
651
  />
638
652
  )}
639
- {hasNavbar && navItems ? (
653
+ {hasNavbar && navItems && !navbarConfig?.hidden && (
640
654
  <Navbar
641
655
  panToOffset={handlePanToOffset}
642
656
  onReset={onResetViewAndItems}
643
657
  items={navItems}
658
+ config={navbarConfig}
644
659
  />
645
- ) : null}
660
+ )}
646
661
  </>
647
662
  )}
648
663
  <div
@@ -666,8 +681,8 @@ const Canvas: FC<Props> = ({
666
681
  animate={{ opacity: 1 }}
667
682
  transition={{ duration: 0.3, ease: "easeIn" }}
668
683
  style={{
669
- width: `${canvasWidth}px`,
670
- height: `${canvasHeight}px`,
684
+ width: `${sceneWidth}px`,
685
+ height: `${sceneHeight}px`,
671
686
  x,
672
687
  y,
673
688
  scale,
@@ -1,7 +1,7 @@
1
1
  import { motion, useMotionValueEvent } from "framer-motion";
2
2
  import { useState, useRef, useEffect, useCallback, useMemo } from "react";
3
3
  import SingleButton from "./single-button";
4
- import type { NavItem } from "../../../types";
4
+ import type { NavItem, NavbarConfig, NavbarPosition } from "../../../types";
5
5
  import { useCanvasContext } from "../../../contexts/CanvasContext";
6
6
  import useWindowDimensions from "../../../hooks/useWindowDimensions";
7
7
  import { usePerformanceMode } from "../../../hooks/usePerformanceMode";
@@ -13,6 +13,7 @@ import {
13
13
  RESPONSIVE_ZOOM_MAP,
14
14
  NAVBAR_DEBOUNCE_MS,
15
15
  } from "../../../lib/constants";
16
+ import { cn } from "../../../lib/utils";
16
17
 
17
18
  interface NavbarProps {
18
19
  panToOffset: (
@@ -23,12 +24,50 @@ interface NavbarProps {
23
24
  onReset: () => void;
24
25
  /** Array of navigation items defining sections, their icons, and coordinates */
25
26
  items: NavItem[];
27
+ /** Navbar configuration options */
28
+ config?: NavbarConfig;
26
29
  }
27
30
 
31
+ const positionStyles: Record<NavbarPosition, React.CSSProperties> = {
32
+ top: {
33
+ top: "1rem",
34
+ bottom: "auto",
35
+ left: "50%",
36
+ transform: "translateX(-50%)",
37
+ },
38
+ bottom: {
39
+ bottom: "1rem",
40
+ top: "auto",
41
+ left: "50%",
42
+ transform: "translateX(-50%)",
43
+ },
44
+ left: {
45
+ left: "1rem",
46
+ right: "auto",
47
+ top: "50%",
48
+ transform: "translateY(-50%)",
49
+ },
50
+ right: {
51
+ right: "1rem",
52
+ left: "auto",
53
+ top: "50%",
54
+ transform: "translateY(-50%)",
55
+ },
56
+ };
57
+
58
+ // Responsive position adjustments (mobile vs desktop)
59
+ const responsivePositionClasses: Record<NavbarPosition, string> = {
60
+ top: "top-12 md:top-4",
61
+ bottom: "bottom-12 md:bottom-4",
62
+ left: "left-4",
63
+ right: "right-4",
64
+ };
65
+
28
66
  export default function Navbar({
29
67
  panToOffset,
30
68
  onReset,
31
69
  items,
70
+ config = {},
32
71
  }: NavbarProps) {
33
72
  const { x, y, scale, animationStage, setNextTargetSection } =
34
73
  useCanvasContext();
@@ -50,6 +89,20 @@ export default function Navbar({
50
89
  // Derive debounce duration from performance mode
51
90
  const debounceMs = NAVBAR_DEBOUNCE_MS[mode] ?? 0;
52
91
 
92
+ // Extract config values
93
+ const {
94
+ display = "icons",
95
+ position = "bottom",
96
+ className,
97
+ style,
98
+ buttonConfig,
99
+ tooltipConfig,
100
+ gap,
101
+ padding,
102
+ } = config;
103
+
104
+ const isVertical = position === "left" || position === "right";
105
+
53
106
  // Find the home section from items
54
107
  const homeItem = useMemo(() => items.find((item) => item.isHome), [items]);
55
108
 
@@ -141,24 +194,43 @@ export default function Navbar({
141
194
  };
142
195
  }, [handlePan, animationStage, homeItem]);
143
196
 
197
+ // Compute container styles (positioning only)
198
+ const containerStyle: React.CSSProperties = {
199
+ position: "fixed",
200
+ zIndex: 1000,
201
+ pointerEvents: "auto",
202
+ display: "flex",
203
+ justifyContent: "center",
204
+ alignItems: "center",
205
+ ...positionStyles[position],
206
+ };
207
+
208
+ // Compute inner container styles (visual styling)
209
+ const innerStyle: React.CSSProperties = {
210
+ ...(gap !== undefined && { gap: `${gap}px` }),
211
+ ...(padding !== undefined && { padding: `${padding}px` }),
212
+ ...(isVertical && { flexDirection: "column" }),
213
+ ...style,
214
+ };
215
+
144
216
  return (
145
217
  <div
146
- className="bottom-12 md:bottom-4"
147
- style={{
148
- position: "fixed",
149
- left: "50%",
150
- transform: "translateX(-50%)",
151
- zIndex: 1000,
152
- pointerEvents: "auto",
153
- display: "flex",
154
- justifyContent: "center",
155
- alignItems: "center",
156
- }}
218
+ className={responsivePositionClasses[position]}
219
+ style={containerStyle}
157
220
  >
158
221
  {/* padding to prevent edge bug */}
159
- <div className="px-4 md:px-8">
160
- <motion.div className="flex select-none items-center justify-center gap-1 rounded-[10px] border-[1px] border-border bg-canvas-offwhite p-1 shadow-[0_6px_12px_rgba(0,0,0,0.10)]">
161
- <div className="flex items-center gap-1">
222
+ <div className={isVertical ? "py-4 md:py-8" : "px-4 md:px-8"}>
223
+ <motion.div
224
+ className={cn(
225
+ "flex select-none items-center justify-center gap-1 rounded-[10px] border p-1 shadow-[0_6px_12px_rgba(0,0,0,0.08)]",
226
+ !style?.backgroundColor && "bg-white",
227
+ !style?.borderColor && "border-neutral-200",
228
+ isVertical && "flex-col",
229
+ className,
230
+ )}
231
+ style={innerStyle}
232
+ >
233
+ <div className={cn("flex items-center gap-1", isVertical && "flex-col")}>
162
234
  {items.map((item) => (
163
235
  <SingleButton
164
236
  key={item.id}
@@ -167,6 +239,10 @@ export default function Navbar({
167
239
  onClick={() => handlePan(item)}
168
240
  isPushed={expandedButton === item.id}
169
241
  onDebouncedClick={handleDebouncedClick}
242
+ displayMode={display}
243
+ buttonConfig={buttonConfig}
244
+ tooltipConfig={tooltipConfig}
245
+ isVertical={isVertical}
170
246
  />
171
247
  ))}
172
248
  </div>
@@ -1,6 +1,12 @@
1
1
  import { useState, useEffect } from "react";
2
2
  import * as LucideIcons from "lucide-react";
3
3
  import { AnimatePresence, motion } from "framer-motion";
4
+ import type {
5
+ NavbarDisplayMode,
6
+ NavbarButtonConfig,
7
+ NavbarTooltipConfig,
8
+ } from "../../../types";
9
+ import { cn } from "../../../lib/utils";
4
10
 
5
11
  interface SingleButtonProps {
6
12
  label: string;
@@ -10,6 +16,14 @@ interface SingleButtonProps {
10
16
  isPushed: boolean;
11
17
  link?: string;
12
18
  onDebouncedClick?: (callback: () => void) => void;
19
+ /** Display mode for the button */
20
+ displayMode?: NavbarDisplayMode;
21
+ /** Button styling configuration */
22
+ buttonConfig?: NavbarButtonConfig;
23
+ /** Tooltip configuration */
24
+ tooltipConfig?: NavbarTooltipConfig;
25
+ /** Whether the navbar is in vertical layout */
26
+ isVertical?: boolean;
13
27
  }
14
28
 
15
29
  export default function SingleButton({
@@ -19,17 +33,48 @@ export default function SingleButton({
19
33
  isPushed,
20
34
  link,
21
35
  onDebouncedClick,
36
+ displayMode = "icons",
37
+ buttonConfig = {},
38
+ tooltipConfig = {},
39
+ isVertical = false,
22
40
  }: SingleButtonProps) {
23
41
  const [isHovered, setIsHovered] = useState(false);
24
42
  const [showTag, setShowTag] = useState(false);
25
43
  const [copiedEmail, setCopiedEmail] = useState(false);
44
+
26
45
  const isLucideIconName = typeof icon === "string";
27
46
  const IconComponent = isLucideIconName
28
47
  ? (LucideIcons[icon as keyof typeof LucideIcons] as LucideIcons.LucideIcon | undefined)
29
48
  : icon;
30
- const TagDelay = 100;
31
49
 
32
- if (!IconComponent) {
50
+ // Extract config values
51
+ const {
52
+ className: buttonClassName,
53
+ style: buttonStyle,
54
+ activeClassName,
55
+ activeStyle,
56
+ hoverClassName,
57
+ hoverStyle,
58
+ iconClassName,
59
+ iconSize = 20,
60
+ labelClassName,
61
+ labelStyle,
62
+ } = buttonConfig;
63
+
64
+ const {
65
+ disabled: tooltipDisabled = false,
66
+ className: tooltipClassName,
67
+ style: tooltipStyle,
68
+ delay: tooltipDelay = 100,
69
+ } = tooltipConfig;
70
+
71
+ // Determine what to show based on display mode
72
+ const showIcon = displayMode !== "labels";
73
+ const allowExpand = displayMode === "icons"; // Only expand on active in icons mode
74
+ const showTooltip = (displayMode === "icons" || displayMode === "compact") && !tooltipDisabled;
75
+
76
+ // Validate icon component for modes that need it
77
+ if (showIcon && !IconComponent) {
33
78
  throw new Error(
34
79
  "A valid 'icon' prop is required (Lucide icon name or custom icon component).",
35
80
  );
@@ -38,10 +83,10 @@ export default function SingleButton({
38
83
  useEffect(() => {
39
84
  let timeoutId: NodeJS.Timeout;
40
85
 
41
- if (isHovered) {
86
+ if (isHovered && showTooltip) {
42
87
  timeoutId = setTimeout(() => {
43
88
  setShowTag(true);
44
- }, TagDelay);
89
+ }, tooltipDelay);
45
90
  } else {
46
91
  setShowTag(false);
47
92
  }
@@ -49,7 +94,7 @@ export default function SingleButton({
49
94
  return () => {
50
95
  clearTimeout(timeoutId);
51
96
  };
52
- }, [isHovered]);
97
+ }, [isHovered, showTooltip, tooltipDelay]);
53
98
 
54
99
  useEffect(() => {
55
100
  setShowTag(false);
@@ -85,11 +130,168 @@ export default function SingleButton({
85
130
 
86
131
  const displayLabel = copiedEmail ? "Email copied!" : label;
87
132
 
133
+ // Compute button classes
134
+ const baseButtonClass = "relative flex items-center rounded-md p-2 text-neutral-500 transition-colors duration-200 focus:outline-none";
135
+ // Only apply default classes if no custom style is provided
136
+ const stateClass = isPushed
137
+ ? (activeClassName || (!activeStyle && "bg-neutral-200"))
138
+ : isHovered
139
+ ? (hoverClassName || (!hoverStyle && "bg-neutral-100"))
140
+ : "";
141
+
142
+ // Compute button styles
143
+ const computedButtonStyle: React.CSSProperties = {
144
+ ...buttonStyle,
145
+ ...(isPushed && activeStyle),
146
+ ...(isHovered && !isPushed && hoverStyle),
147
+ };
148
+
149
+ // Compute icon classes and styles
150
+ const iconSizeStyle = { width: iconSize, height: iconSize };
151
+ const baseIconClass = "flex-shrink-0";
152
+ // Only apply default icon colors if no custom button color style is provided
153
+ const hasCustomColor = buttonStyle?.color;
154
+ const iconColorClass = hasCustomColor
155
+ ? ""
156
+ : isPushed
157
+ ? "text-neutral-700"
158
+ : "text-neutral-500";
159
+
160
+ // Compute label classes
161
+ const baseLabelClass = "whitespace-nowrap font-canvas-figtree text-sm font-medium text-neutral-700";
162
+
163
+ // Tooltip position based on vertical layout
164
+ const tooltipPositionClass = isVertical
165
+ ? "left-full top-1/2 -translate-y-1/2 ml-2"
166
+ : "-top-10 left-1/2";
167
+ const tooltipTransform = isVertical
168
+ ? { x: 0, y: "-50%" }
169
+ : { x: "-50%" };
170
+
171
+ // Render icon element
172
+ const renderIcon = () => {
173
+ if (!showIcon || !IconComponent) return null;
174
+ return (
175
+ <IconComponent
176
+ className={cn(baseIconClass, iconColorClass, iconClassName)}
177
+ style={iconSizeStyle}
178
+ />
179
+ );
180
+ };
181
+
182
+ // Render label element
183
+ const renderLabel = (animated = false) => {
184
+ if (animated) {
185
+ return (
186
+ <motion.span
187
+ initial={{ opacity: 0, width: 0 }}
188
+ animate={{ opacity: 1, width: "auto" }}
189
+ exit={{ opacity: 0, width: 0 }}
190
+ transition={{
191
+ duration: 0.1,
192
+ ease: "easeInOut",
193
+ }}
194
+ className={cn("overflow-hidden", baseLabelClass, labelClassName)}
195
+ style={labelStyle}
196
+ >
197
+ {displayLabel}
198
+ </motion.span>
199
+ );
200
+ }
201
+ return (
202
+ <span
203
+ className={cn(baseLabelClass, labelClassName)}
204
+ style={labelStyle}
205
+ >
206
+ {displayLabel}
207
+ </span>
208
+ );
209
+ };
210
+
211
+ // Render tooltip
212
+ const renderTooltip = () => {
213
+ if (!showTooltip || !showTag || isPushed) return null;
214
+
215
+ return (
216
+ <AnimatePresence>
217
+ <motion.div
218
+ initial={{ opacity: 0, y: isVertical ? 0 : 5, scale: 0.9, ...tooltipTransform }}
219
+ animate={{ opacity: 1, y: 0, scale: 1, ...tooltipTransform }}
220
+ exit={{ opacity: 0, y: isVertical ? 0 : 5, scale: 0.9, ...tooltipTransform }}
221
+ transition={{
222
+ duration: 0.05,
223
+ ease: "easeOut",
224
+ }}
225
+ className={cn("pointer-events-none absolute z-50", tooltipPositionClass)}
226
+ >
227
+ <div className="rounded-sm bg-gradient-to-t from-black/10 to-transparent px-[1px] pb-[2.5px] pt-[1px]">
228
+ <div
229
+ className={cn(
230
+ "whitespace-nowrap rounded-sm px-2 py-1 font-canvas-figtree text-sm",
231
+ !tooltipStyle?.backgroundColor && "bg-neutral-50",
232
+ !tooltipStyle?.color && "text-neutral-600",
233
+ tooltipClassName,
234
+ )}
235
+ style={tooltipStyle}
236
+ >
237
+ {displayLabel}
238
+ </div>
239
+ </div>
240
+ </motion.div>
241
+ </AnimatePresence>
242
+ );
243
+ };
244
+
245
+ // Render based on display mode
246
+ const renderContent = () => {
247
+ // Labels only mode
248
+ if (displayMode === "labels") {
249
+ return renderLabel();
250
+ }
251
+
252
+ // Icons + labels always mode
253
+ if (displayMode === "icons-labels") {
254
+ return (
255
+ <div className="flex items-center gap-2">
256
+ {renderIcon()}
257
+ {renderLabel()}
258
+ </div>
259
+ );
260
+ }
261
+
262
+ // Compact mode - icons only, no expansion
263
+ if (displayMode === "compact") {
264
+ return (
265
+ <>
266
+ {renderIcon()}
267
+ {renderTooltip()}
268
+ </>
269
+ );
270
+ }
271
+
272
+ // Icons mode (default) - expands on active
273
+ if (isPushed && allowExpand) {
274
+ return (
275
+ <div className="flex items-center gap-2">
276
+ <div>{renderIcon()}</div>
277
+ {renderLabel(true)}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ return (
283
+ <>
284
+ {renderIcon()}
285
+ {renderTooltip()}
286
+ </>
287
+ );
288
+ };
289
+
88
290
  return (
89
291
  <motion.button
90
292
  aria-label={label}
91
- className={`relative flex items-center rounded-md p-2 text-canvas-medium transition-colors duration-200 ${isPushed ? "bg-[#EEE2FB]" : isHovered ? "bg-canvas-highlight" : ""
92
- }`}
293
+ className={cn(baseButtonClass, stateClass, buttonClassName)}
294
+ style={computedButtonStyle}
93
295
  onClick={handleClick}
94
296
  onMouseEnter={() => setIsHovered(true)}
95
297
  onMouseLeave={() => setIsHovered(false)}
@@ -100,55 +302,7 @@ export default function SingleButton({
100
302
  damping: 25,
101
303
  }}
102
304
  >
103
- {isPushed ? (
104
- <div className="flex items-center gap-2">
105
- <div>
106
- <IconComponent
107
- className={`h-5 w-5 flex-shrink-0 ${isPushed ? (isLucideIconName ? "text-canvas-emphasis" : "text-white") : "text-canvas-medium"
108
- }`}
109
- />
110
- </div>
111
- <motion.span
112
- initial={{ opacity: 0, width: 0 }}
113
- animate={{ opacity: 1, width: "auto" }}
114
- exit={{ opacity: 0, width: 0 }}
115
- transition={{
116
- duration: 0.1,
117
- ease: "easeInOut",
118
- }}
119
- className="overflow-hidden whitespace-nowrap font-canvas-figtree text-sm font-medium text-canvas-emphasis"
120
- >
121
- {displayLabel}
122
- </motion.span>
123
- </div>
124
- ) : (
125
- <div>
126
- <IconComponent
127
- className={`h-5 w-5 flex-shrink-0 ${isPushed ? (isLucideIconName ? "text-canvas-emphasis" : "text-white") : "text-canvas-medium"
128
- }`}
129
- />
130
- <AnimatePresence>
131
- {showTag && !isPushed && (
132
- <motion.div
133
- initial={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
134
- animate={{ opacity: 1, y: 0, scale: 1, x: "-50%" }}
135
- exit={{ opacity: 0, y: 5, scale: 0.9, x: "-50%" }}
136
- transition={{
137
- duration: 0.05,
138
- ease: "easeOut",
139
- }}
140
- className="pointer-events-none absolute -top-10 left-1/2 z-50"
141
- >
142
- <div className="rounded-sm bg-gradient-to-t from-black/10 to-transparent px-[1px] pb-[2.5px] pt-[1px]">
143
- <div className="whitespace-nowrap rounded-sm bg-canvas-offwhite px-2 py-1 font-canvas-figtree text-sm text-canvas-medium">
144
- {displayLabel}
145
- </div>
146
- </div>
147
- </motion.div>
148
- )}
149
- </AnimatePresence>
150
- </div>
151
- )}
305
+ {renderContent()}
152
306
  </motion.button>
153
307
  );
154
308
  }
package/src/index.ts CHANGED
@@ -56,4 +56,9 @@ export type {
56
56
  ToolbarConfig,
57
57
  ToolbarPosition,
58
58
  ToolbarDisplayMode,
59
+ NavbarConfig,
60
+ NavbarPosition,
61
+ NavbarDisplayMode,
62
+ NavbarButtonConfig,
63
+ NavbarTooltipConfig,
59
64
  } from "./types";
package/src/lib/canvas.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { animate, type MotionValue, type Point } from "framer-motion";
2
2
  import { useMemo } from "react";
3
3
  import {
4
- CANVAS_WIDTH,
5
- CANVAS_HEIGHT,
4
+ DEFAULT_CANVAS_WIDTH,
5
+ DEFAULT_CANVAS_HEIGHT,
6
6
  MAX_DIM_RATIO,
7
7
  INTRO_ASPECT_RATIO,
8
8
  PAN_SPRING,
9
9
  ScreenSizeEnum,
10
10
  } from "./constants";
11
11
 
12
- export const canvasWidth = CANVAS_WIDTH;
13
- export const canvasHeight = CANVAS_HEIGHT;
12
+ export const canvasWidth = DEFAULT_CANVAS_WIDTH;
13
+ export const canvasHeight = DEFAULT_CANVAS_HEIGHT;
14
14
 
15
15
  // Re-export ScreenSizeEnum for backward compatibility
16
16
  export { ScreenSizeEnum } from "./constants";
@@ -24,10 +24,10 @@ export enum ScreenSizeEnum {
24
24
  // ============================================================================
25
25
 
26
26
  /** Default canvas width in pixels */
27
- export const CANVAS_WIDTH = 6000;
27
+ export const DEFAULT_CANVAS_WIDTH = 6000;
28
28
 
29
29
  /** Default canvas height in pixels */
30
- export const CANVAS_HEIGHT = 4000;
30
+ export const DEFAULT_CANVAS_HEIGHT = 4000;
31
31
 
32
32
  // ============================================================================
33
33
  // INTRO ANIMATION