1"use client";
2
3import type {
4 CSSProperties,
5 KeyboardEvent as ReactKeyboardEvent,
6 PointerEvent as ReactPointerEvent,
7} from "react";
8
9import { cn } from "~/lib/utils";
10
11import {
12 BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
13 type BrowserViewportLayout,
14 type BrowserViewportResizeDirection,
15} from "./browserViewportLayout";
16
17interface Props {
18 readonly layout: BrowserViewportLayout;
19 readonly activeDirection: BrowserViewportResizeDirection | null;
20 readonly onPointerDown: (
21 direction: BrowserViewportResizeDirection,
22 event: ReactPointerEvent<HTMLButtonElement>,
23 ) => void;
24 readonly onKeyDown: (
25 direction: BrowserViewportResizeDirection,
26 event: ReactKeyboardEvent<HTMLButtonElement>,
27 ) => void;
28}
29
30type HandleKind = "horizontal" | "vertical" | "corner";
31
32const EDGE_BUTTON_CLASS =
33 "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]";
34const EDGE_GRIP_CLASS =
35 "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center text-muted-foreground/55 transition-colors duration-150 group-hover:text-foreground/85 group-focus-visible:text-foreground group-active:text-foreground";
36
37function ResizeHandle(props: {
38 readonly direction: BrowserViewportResizeDirection;
39 readonly label: string;
40 readonly kind: HandleKind;
41 readonly cursorClassName: string;
42 readonly style: CSSProperties;
43 readonly active: boolean;
44 readonly mirrorCorner?: boolean;
45 readonly onPointerDown: Props["onPointerDown"];
46 readonly onKeyDown: Props["onKeyDown"];
47}) {
48 const {
49 direction,
50 label,
51 kind,
52 cursorClassName,
53 style,
54 active,
55 mirrorCorner = false,
56 onPointerDown,
57 onKeyDown,
58 } = props;
59 return (
60 <button
61 type="button"
62 aria-label={`${label}. Use arrow keys to resize.`}
63 className={cn(EDGE_BUTTON_CLASS, kind === "corner" && "z-30", cursorClassName)}
64 style={style}
65 onPointerDown={(event) => onPointerDown(direction, event)}
66 onKeyDown={(event) => onKeyDown(direction, event)}
67 >
68 <span
69 className={cn(
70 EDGE_GRIP_CLASS,
71 kind === "vertical" && "h-8 w-1.5",
72 kind === "horizontal" && "h-1.5 w-8",
73 kind === "corner" && "size-3",
74 active && "text-foreground",
75 )}
76 >
77 {kind === "vertical" ? (
78 <span className="flex gap-px" aria-hidden="true">
79 <span className="h-6 w-px rounded-full bg-current" />
80 <span className="h-6 w-px rounded-full bg-current" />
81 </span>
82 ) : kind === "horizontal" ? (
83 <span className="flex flex-col gap-px" aria-hidden="true">
84 <span className="h-px w-6 rounded-full bg-current" />
85 <span className="h-px w-6 rounded-full bg-current" />
86 </span>
87 ) : (
88 <span
89 className={cn("relative block size-3", mirrorCorner && "-scale-x-100")}
90 aria-hidden="true"
91 >
92 <span className="absolute bottom-[3px] left-0 h-px w-3 -rotate-45 rounded-full bg-current" />
93 <span className="absolute bottom-0 left-[5px] h-px w-2 -rotate-45 rounded-full bg-current" />
94 </span>
95 )}
96 </span>
97 </button>
98 );
99}
100
101export function BrowserViewportResizeHandles({
102 layout,
103 activeDirection,
104 onPointerDown,
105 onKeyDown,
106}: Props) {
107 const left = layout.viewportX;
108 const top = layout.viewportY;
109 const right = left + layout.viewportWidth;
110 const bottom = top + layout.viewportHeight;
111 const railSize = BROWSER_VIEWPORT_RESIZE_RAIL_SIZE;
112
113 const shared = { activeDirection, onPointerDown, onKeyDown };
114 return (
115 <>
116 <ResizeHandle
117 direction="west"
118 label="Resize browser viewport from left edge"
119 kind="vertical"
120 cursorClassName="cursor-ew-resize"
121 style={{ left: left - railSize, top, width: railSize, height: layout.viewportHeight }}
122 active={shared.activeDirection === "west"}
123 onPointerDown={shared.onPointerDown}
124 onKeyDown={shared.onKeyDown}
125 />
126 <ResizeHandle
127 direction="east"
128 label="Resize browser viewport from right edge"
129 kind="vertical"
130 cursorClassName="cursor-ew-resize"
131 style={{ left: right, top, width: railSize, height: layout.viewportHeight }}
132 active={shared.activeDirection === "east"}
133 onPointerDown={shared.onPointerDown}
134 onKeyDown={shared.onKeyDown}
135 />
136 <ResizeHandle
137 direction="south"
138 label="Resize browser viewport from bottom edge"
139 kind="horizontal"
140 cursorClassName="cursor-ns-resize"
141 style={{ left, top: bottom, width: layout.viewportWidth, height: railSize }}
142 active={shared.activeDirection === "south"}
143 onPointerDown={shared.onPointerDown}
144 onKeyDown={shared.onKeyDown}
145 />
146 <ResizeHandle
147 direction="southwest"
148 label="Resize browser viewport from bottom-left corner"
149 kind="corner"
150 cursorClassName="cursor-nesw-resize"
151 style={{ left: left - railSize, top: bottom, width: railSize, height: railSize }}
152 active={shared.activeDirection === "southwest"}
153 mirrorCorner
154 onPointerDown={shared.onPointerDown}
155 onKeyDown={shared.onKeyDown}
156 />
157 <ResizeHandle
158 direction="southeast"
159 label="Resize browser viewport from bottom-right corner"
160 kind="corner"
161 cursorClassName="cursor-nwse-resize"
162 style={{ left: right, top: bottom, width: railSize, height: railSize }}
163 active={shared.activeDirection === "southeast"}
164 onPointerDown={shared.onPointerDown}
165 onKeyDown={shared.onKeyDown}
166 />
167 </>
168 );
169}