| 1 | "use client"; |
| 2 | |
| 3 | import type { |
| 4 | CSSProperties, |
| 5 | KeyboardEvent as ReactKeyboardEvent, |
| 6 | PointerEvent as ReactPointerEvent, |
| 7 | } from "react"; |
| 8 | |
| 9 | import { cn } from "~/lib/utils"; |
| 10 | |
| 11 | import { |
| 12 | BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, |
| 13 | type BrowserViewportLayout, |
| 14 | type BrowserViewportResizeDirection, |
| 15 | } from "./browserViewportLayout"; |
| 16 | |
| 17 | interface 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 | |
| 30 | type HandleKind = "horizontal" | "vertical" | "corner"; |
| 31 | |
| 32 | const EDGE_BUTTON_CLASS = |
| 33 | "group absolute z-20 touch-none border-0 bg-transparent p-0 outline-none focus-visible:bg-foreground/[0.04]"; |
| 34 | const 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 | |
| 37 | function 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 | |
| 101 | export 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 | } |