Skip to content

Commit ce7695c

Browse files
committed
feat(devtools): add devtools
1 parent 0a84887 commit ce7695c

File tree

22 files changed

+1416
-483
lines changed

22 files changed

+1416
-483
lines changed

biome.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"frontend/**/*.ts",
1313
"!frontend/packages",
1414
"!rivetkit-openapi/openapi.json",
15+
"rivetkit-typescript/**/devtools/**/*.tsx",
1516
"!scripts",
1617
"!website",
1718
"!site"

pnpm-lock.yaml

Lines changed: 549 additions & 481 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# RivetKit DevTools
2+
3+
4+
## Contributing
5+
6+
To contribute to the RivetKit DevTools package, please follow these steps:
7+
8+
1. Set up assets server for the `dist` folder:
9+
```bash
10+
pnpm dlx serve dist
11+
```
12+
13+
2. Set your `CUSTOM_RIVETKIT_DEVTOOLS_URL` environment variable to point to the assets server (default is `http://localhost:3000`):
14+
```bash
15+
export CUSTOM_RIVETKIT_DEVTOOLS_URL=http://localhost:5000
16+
```
17+
18+
This will ensure that the RivetKit will use local devtool assets instead of fetching them from the CDN.
19+
20+
3. In another terminal, run the development build:
21+
```bash
22+
pnpm dev
23+
```
24+
25+
or run the production build:
26+
```bash
27+
pnpm build
28+
```
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@rivetkit/devtools",
3+
"private": true,
4+
"version": "2.0.24-rc.1",
5+
"description": "RivetKit DevTools - A set of development tools for RivetKit",
6+
"license": "Apache-2.0",
7+
"keywords": [
8+
"rivetkit"
9+
],
10+
"sideEffects": [
11+
"./dist/chunk-*.js",
12+
"./dist/chunk-*.cjs"
13+
],
14+
"files": [
15+
"dist",
16+
"package.json"
17+
],
18+
"exports": {
19+
".": {
20+
"import": {
21+
"types": "./dist/mod.d.mts",
22+
"default": "./dist/mod.mjs"
23+
},
24+
"require": {
25+
"types": "./dist/mod.d.ts",
26+
"default": "./dist/mod.js"
27+
}
28+
}
29+
},
30+
"scripts": {
31+
"build": "tsup src/mod.tsx",
32+
"check-types": "tsc --noEmit"
33+
},
34+
"devDependencies": {
35+
"rivetkit": "workspace:*",
36+
"tsup": "^8.4.0",
37+
"typescript": "^5.5.2"
38+
},
39+
"stableVersion": "0.8.0",
40+
"dependencies": {
41+
"@floating-ui/react": "^0.27.16",
42+
"@types/react": "^19.2.2",
43+
"@types/react-dom": "^19.2.2",
44+
"motion": "^12.23.25",
45+
"react": "^19.2.1",
46+
"react-dom": "^19.2.1"
47+
}
48+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {
2+
arrow,
3+
flip,
4+
offset,
5+
shift,
6+
useFloating,
7+
useHover,
8+
useInteractions,
9+
} from "@floating-ui/react";
10+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11+
import {
12+
getCornerButtonStyle,
13+
getCornerFromPosition,
14+
useCornerPosition,
15+
} from "../hooks/useCornerPosition";
16+
import { useDraggable } from "../hooks/useDraggable";
17+
18+
const INDICATOR_PADDING = 20;
19+
const STORAGE_KEY = "__rivetkit-devtools";
20+
21+
interface DevButtonProps {
22+
children: React.ReactNode;
23+
onClick?: () => void;
24+
}
25+
26+
export function DevButton({ children, onClick }: DevButtonProps) {
27+
const [isOpen, setIsOpen] = useState(false);
28+
const arrowRef = useRef(null);
29+
30+
const { refs, floatingStyles, context, middlewareData } = useFloating({
31+
open: isOpen,
32+
onOpenChange: setIsOpen,
33+
placement: "top",
34+
middleware: [
35+
offset(10),
36+
flip(),
37+
shift({ padding: 8 }),
38+
arrow({ element: arrowRef }),
39+
],
40+
});
41+
42+
const hover = useHover(context, {
43+
delay: { open: 300, close: 0 },
44+
});
45+
46+
const { getReferenceProps, getFloatingProps } = useInteractions([hover]);
47+
48+
const { updateCorner, isRightSide, isBottom } = useCornerPosition({
49+
storageKey: STORAGE_KEY,
50+
defaultCorner: "bottom-right",
51+
});
52+
53+
const handleBeforeSnap = useCallback((x: number, y: number) => {
54+
const newCorner = getCornerFromPosition(x, y);
55+
const isRightSide = newCorner.endsWith("right");
56+
const isBottom = newCorner.startsWith("bottom");
57+
58+
return {
59+
...(isBottom
60+
? { bottom: INDICATOR_PADDING }
61+
: { top: INDICATOR_PADDING }),
62+
...(isRightSide
63+
? { right: INDICATOR_PADDING }
64+
: { left: INDICATOR_PADDING }),
65+
};
66+
}, []);
67+
68+
const handleDragEnd = useCallback(
69+
(x: number, y: number) => {
70+
const newCorner = getCornerFromPosition(x, y);
71+
updateCorner(newCorner);
72+
},
73+
[updateCorner],
74+
);
75+
76+
const {
77+
ref: dragRef,
78+
isDragging,
79+
hasDragged,
80+
handlers,
81+
} = useDraggable<HTMLButtonElement>({
82+
onBeforeSnap: handleBeforeSnap,
83+
onDragEnd: handleDragEnd,
84+
});
85+
86+
const buttonStyle = getCornerButtonStyle({
87+
isBottom,
88+
isRightSide,
89+
isDragging,
90+
padding: INDICATOR_PADDING,
91+
});
92+
93+
// Merge refs for draggable and floating UI
94+
const setRefs = useCallback(
95+
(node: HTMLButtonElement | null) => {
96+
dragRef.current = node;
97+
refs.setReference(node);
98+
},
99+
[refs],
100+
);
101+
102+
// Close tooltip when dragging starts
103+
useEffect(() => {
104+
if (isDragging && isOpen) {
105+
setIsOpen(false);
106+
}
107+
}, [isDragging, isOpen]);
108+
109+
const arrowX = middlewareData.arrow?.x;
110+
const arrowY = middlewareData.arrow?.y;
111+
112+
// Get reference props but don't spread all of them to avoid conflicts
113+
const referenceProps = getReferenceProps();
114+
115+
// Determine arrow side based on actual placement (after flip)
116+
const { placement } = context;
117+
const arrowSide = placement.split("-")[0];
118+
119+
const arrowStyle = useMemo((): React.CSSProperties => {
120+
const style: React.CSSProperties = {
121+
left: arrowX != null ? `${arrowX}px` : "",
122+
top: arrowY != null ? `${arrowY}px` : "",
123+
};
124+
125+
// Position arrow on the correct side
126+
if (arrowSide === "top") {
127+
style.bottom = "-4px";
128+
} else if (arrowSide === "bottom") {
129+
style.top = "-4px";
130+
} else if (arrowSide === "left") {
131+
style.right = "-4px";
132+
} else if (arrowSide === "right") {
133+
style.left = "-4px";
134+
}
135+
136+
return style;
137+
}, [arrowX, arrowY, arrowSide]);
138+
139+
const handleClick = useCallback(() => {
140+
if (!hasDragged && onClick) {
141+
onClick();
142+
}
143+
}, [hasDragged, onClick]);
144+
145+
return (
146+
<>
147+
<button
148+
ref={setRefs}
149+
type="button"
150+
{...handlers}
151+
onClick={handleClick}
152+
onMouseEnter={
153+
referenceProps.onMouseEnter as React.MouseEventHandler<HTMLButtonElement>
154+
}
155+
onMouseLeave={
156+
referenceProps.onMouseLeave as React.MouseEventHandler<HTMLButtonElement>
157+
}
158+
style={buttonStyle}
159+
>
160+
{children}
161+
</button>
162+
{isOpen && !isDragging && (
163+
<div
164+
ref={refs.setFloating}
165+
className="tooltip-container"
166+
style={floatingStyles}
167+
{...getFloatingProps()}
168+
>
169+
<div className="tooltip">Open Inspector</div>
170+
<div
171+
ref={arrowRef}
172+
className="tooltip-arrow"
173+
style={arrowStyle}
174+
/>
175+
</div>
176+
)}
177+
</>
178+
);
179+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module "*.css" {
2+
const content: string;
3+
export default content;
4+
}
5+
6+
declare module "*.svg" {
7+
const content: string;
8+
export default content;
9+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useEffect, useState } from "react";
2+
3+
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
4+
5+
interface UseCornerPositionOptions {
6+
storageKey: string;
7+
defaultCorner?: Corner;
8+
}
9+
10+
export function useCornerPosition(options: UseCornerPositionOptions) {
11+
const { storageKey, defaultCorner = "bottom-right" } = options;
12+
const [corner, setCorner] = useState<Corner>(defaultCorner);
13+
14+
useEffect(() => {
15+
const saved = localStorage.getItem(storageKey);
16+
if (saved) {
17+
setCorner(saved as Corner);
18+
}
19+
}, [storageKey]);
20+
21+
const updateCorner = (newCorner: Corner) => {
22+
setCorner(newCorner);
23+
localStorage.setItem(storageKey, newCorner);
24+
};
25+
26+
const isRightSide = corner.endsWith("right");
27+
const isBottom = corner.startsWith("bottom");
28+
29+
return {
30+
corner,
31+
updateCorner,
32+
isRightSide,
33+
isBottom,
34+
};
35+
}
36+
37+
interface CornerButtonStyleOptions {
38+
isBottom: boolean;
39+
isRightSide: boolean;
40+
isDragging: boolean;
41+
padding?: number;
42+
paddingVertical?: number;
43+
paddingHorizontal?: number;
44+
}
45+
46+
export function getCornerButtonStyle(
47+
options: CornerButtonStyleOptions,
48+
): React.CSSProperties {
49+
const { isBottom, isRightSide, isDragging, padding = 20, paddingVertical, paddingHorizontal } = options;
50+
51+
const verticalPadding = paddingVertical ?? padding;
52+
const horizontalPadding = paddingHorizontal ?? padding;
53+
54+
return {
55+
position: "fixed",
56+
...(isBottom ? { bottom: verticalPadding } : { top: verticalPadding }),
57+
...(isRightSide ? { right: horizontalPadding } : { left: horizontalPadding }),
58+
transform: isDragging
59+
? "translate(var(--drag-x, 0px), var(--drag-y, 0px))"
60+
: undefined,
61+
cursor: isDragging ? "grabbing" : "pointer",
62+
flexDirection: isRightSide ? "row-reverse" : "row",
63+
};
64+
}
65+
66+
export function getCornerFromPosition(x: number, y: number): Corner {
67+
const centerX = window.innerWidth / 2;
68+
const centerY = window.innerHeight / 2;
69+
70+
if (x < centerX && y < centerY) {
71+
return "top-left";
72+
}
73+
if (x >= centerX && y < centerY) {
74+
return "top-right";
75+
}
76+
if (x < centerX && y >= centerY) {
77+
return "bottom-left";
78+
}
79+
return "bottom-right";
80+
}

0 commit comments

Comments
 (0)