| 1 | "use client"; |
| 2 | |
| 3 | import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; |
| 4 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; |
| 5 | |
| 6 | import { Button } from "~/components/ui/button"; |
| 7 | import { |
| 8 | Menu, |
| 9 | MenuItem, |
| 10 | MenuPopup, |
| 11 | MenuRadioGroup, |
| 12 | MenuRadioItem, |
| 13 | MenuGroup, |
| 14 | MenuGroupLabel, |
| 15 | MenuSeparator, |
| 16 | MenuSub, |
| 17 | MenuSubPopup, |
| 18 | MenuSubTrigger, |
| 19 | MenuTrigger, |
| 20 | } from "~/components/ui/menu"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 22 | |
| 23 | const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ |
| 24 | value: DesktopPreviewColorScheme; |
| 25 | label: string; |
| 26 | }> = [ |
| 27 | { value: "system", label: "System" }, |
| 28 | { value: "light", label: "Light" }, |
| 29 | { value: "dark", label: "Dark" }, |
| 30 | ]; |
| 31 | |
| 32 | /** |
| 33 | * What the menu's items do for the active tab. Desktop tabs run them through |
| 34 | * the desktop bridge; server tabs, whether streamed or rendered natively by |
| 35 | * the desktop app, run them through the environment server. |
| 36 | */ |
| 37 | export interface PreviewMoreMenuActions { |
| 38 | readonly hardReload: () => void; |
| 39 | readonly setColorScheme: (colorScheme: DesktopPreviewColorScheme) => void; |
| 40 | readonly zoomIn: () => void; |
| 41 | readonly zoomOut: () => void; |
| 42 | readonly resetZoom: () => void; |
| 43 | readonly clearCookies: () => void; |
| 44 | readonly clearCache: () => void; |
| 45 | /** Absent where there is no inspector to open, such as a streamed server tab. */ |
| 46 | readonly openDevTools?: () => void; |
| 47 | /** Absent where the tab cannot get its own window. */ |
| 48 | readonly toggleNativePictureInPicture?: () => void; |
| 49 | } |
| 50 | |
| 51 | interface Props { |
| 52 | /** False until the tab can take these actions, such as before its page attaches. */ |
| 53 | enabled: boolean; |
| 54 | actions: PreviewMoreMenuActions; |
| 55 | /** Current zoom factor as a number (1.0 = 100%). */ |
| 56 | zoomFactor: number; |
| 57 | /** Emulated `prefers-color-scheme` for the page. */ |
| 58 | colorScheme: DesktopPreviewColorScheme; |
| 59 | /** Fixed viewport modes expose the device toolbar and resize rails. */ |
| 60 | deviceToolbarVisible: boolean; |
| 61 | /** Switches between fill-panel mode and a fixed responsive viewport. */ |
| 62 | onToggleDeviceToolbar: () => void; |
| 63 | /** Whether the separate native always-on-top preview window is open. */ |
| 64 | nativePictureInPicture: boolean; |
| 65 | /** Profile display name, shown so the menu says which data is being cleared. */ |
| 66 | profileName: string | undefined; |
| 67 | /** Reopens the tab in the other browser it can run in. */ |
| 68 | move?: { readonly label: string; readonly onMove: () => void }; |
| 69 | } |
| 70 | |
| 71 | const MenuTriggerButton = () => ( |
| 72 | <Tooltip> |
| 73 | <TooltipTrigger |
| 74 | render={ |
| 75 | <MenuTrigger |
| 76 | render={<Button variant="ghost" size="icon-xs" type="button" aria-label="Preview menu" />} |
| 77 | /> |
| 78 | } |
| 79 | > |
| 80 | <MoreVertical /> |
| 81 | </TooltipTrigger> |
| 82 | <TooltipPopup>More</TooltipPopup> |
| 83 | </Tooltip> |
| 84 | ); |
| 85 | |
| 86 | /** Three-dot menu in the chrome row; the same items for every kind of tab. */ |
| 87 | export function PreviewMoreMenu({ |
| 88 | enabled, |
| 89 | actions, |
| 90 | zoomFactor, |
| 91 | colorScheme, |
| 92 | deviceToolbarVisible, |
| 93 | onToggleDeviceToolbar, |
| 94 | nativePictureInPicture, |
| 95 | profileName, |
| 96 | move, |
| 97 | }: Props) { |
| 98 | const zoomLabel = `${Math.round(zoomFactor * 100)}%`; |
| 99 | const disabled = !enabled; |
| 100 | return ( |
| 101 | <Menu> |
| 102 | <MenuTriggerButton /> |
| 103 | <MenuPopup align="end" sideOffset={6}> |
| 104 | <MenuItem onClick={actions.hardReload} disabled={disabled}> |
| 105 | Hard reload |
| 106 | </MenuItem> |
| 107 | {actions.openDevTools ? ( |
| 108 | <MenuItem onClick={actions.openDevTools} disabled={disabled}> |
| 109 | Open DevTools |
| 110 | </MenuItem> |
| 111 | ) : null} |
| 112 | {actions.toggleNativePictureInPicture ? ( |
| 113 | <MenuItem onClick={actions.toggleNativePictureInPicture} disabled={disabled}> |
| 114 | {nativePictureInPicture |
| 115 | ? "Close separate preview window" |
| 116 | : "Open separate preview window"} |
| 117 | </MenuItem> |
| 118 | ) : null} |
| 119 | {move ? <MenuItem onClick={move.onMove}>{move.label}</MenuItem> : null} |
| 120 | <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}> |
| 121 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} |
| 122 | </MenuItem> |
| 123 | <MenuSub> |
| 124 | <MenuSubTrigger disabled={disabled}>Appearance</MenuSubTrigger> |
| 125 | <MenuSubPopup> |
| 126 | <MenuRadioGroup |
| 127 | value={colorScheme} |
| 128 | onValueChange={(value) => actions.setColorScheme(value as DesktopPreviewColorScheme)} |
| 129 | > |
| 130 | {COLOR_SCHEME_OPTIONS.map((option) => ( |
| 131 | <MenuRadioItem key={option.value} value={option.value}> |
| 132 | {option.label} |
| 133 | </MenuRadioItem> |
| 134 | ))} |
| 135 | </MenuRadioGroup> |
| 136 | </MenuSubPopup> |
| 137 | </MenuSub> |
| 138 | <MenuSeparator /> |
| 139 | {/* |
| 140 | Zoom row: label + inline control cluster. `closeOnClick=false` |
| 141 | keeps the menu open while the user clicks the +/− buttons. |
| 142 | */} |
| 143 | <MenuItem |
| 144 | closeOnClick={false} |
| 145 | onClick={(event: React.MouseEvent) => event.preventDefault()} |
| 146 | className="justify-between" |
| 147 | disabled={disabled} |
| 148 | > |
| 149 | <span>Zoom</span> |
| 150 | <span className="flex items-center gap-1"> |
| 151 | <Button |
| 152 | variant="outline" |
| 153 | size="icon-xs" |
| 154 | type="button" |
| 155 | onClick={actions.zoomOut} |
| 156 | aria-label="Zoom out" |
| 157 | disabled={disabled} |
| 158 | > |
| 159 | <Minus /> |
| 160 | </Button> |
| 161 | <span className="min-w-12 text-center text-xs tabular-nums text-muted-foreground"> |
| 162 | {zoomLabel} |
| 163 | </span> |
| 164 | <Button |
| 165 | variant="outline" |
| 166 | size="icon-xs" |
| 167 | type="button" |
| 168 | onClick={actions.zoomIn} |
| 169 | aria-label="Zoom in" |
| 170 | disabled={disabled} |
| 171 | > |
| 172 | <PlusIcon /> |
| 173 | </Button> |
| 174 | <Button |
| 175 | variant="ghost" |
| 176 | size="icon-xs" |
| 177 | type="button" |
| 178 | onClick={actions.resetZoom} |
| 179 | aria-label="Reset zoom" |
| 180 | disabled={disabled} |
| 181 | > |
| 182 | <RotateCcw /> |
| 183 | </Button> |
| 184 | </span> |
| 185 | </MenuItem> |
| 186 | <MenuSeparator /> |
| 187 | {/* |
| 188 | Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel` |
| 189 | reads its context and throws without one. The heading also answers |
| 190 | which profile the tab is in, which is otherwise invisible: it is fixed |
| 191 | at open and nothing else in the chrome shows it. |
| 192 | */} |
| 193 | <MenuGroup> |
| 194 | {profileName ? ( |
| 195 | // Truncation needs a block box: `text-overflow` on an inline child |
| 196 | // never applies and a long name would push the popup past its width. |
| 197 | <MenuGroupLabel className="max-w-64"> |
| 198 | <span className="block truncate">Profile: {profileName}</span> |
| 199 | </MenuGroupLabel> |
| 200 | ) : null} |
| 201 | <MenuItem onClick={actions.clearCookies}>Clear cookies</MenuItem> |
| 202 | <MenuItem onClick={actions.clearCache}>Clear cache</MenuItem> |
| 203 | </MenuGroup> |
| 204 | </MenuPopup> |
| 205 | </Menu> |
| 206 | ); |
| 207 | } |