|
| 1 | +import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; |
| 2 | +import { cx } from "cva"; |
| 3 | +import { |
| 4 | + type ComponentProps, |
| 5 | + createEffect, |
| 6 | + createSignal, |
| 7 | + onCleanup, |
| 8 | + Show, |
| 9 | +} from "solid-js"; |
| 10 | +import type { SetStoreFunction } from "solid-js/store"; |
| 11 | +import type { BackgroundBlurMode, CameraPreviewShape } from "~/utils/tauri"; |
| 12 | + |
| 13 | +export type CameraWindowState = { |
| 14 | + size: number; |
| 15 | + shape: CameraPreviewShape; |
| 16 | + mirrored: boolean; |
| 17 | + backgroundBlur: BackgroundBlurMode | boolean; |
| 18 | +}; |
| 19 | + |
| 20 | +export const CAMERA_MIN_SIZE = 150; |
| 21 | +export const CAMERA_MAX_SIZE = 600; |
| 22 | +export const CAMERA_DEFAULT_SIZE = 230; |
| 23 | +export const CAMERA_PRESET_SMALL = 230; |
| 24 | +export const CAMERA_PRESET_LARGE = 400; |
| 25 | +export const CAMERA_TOOLBAR_HEIGHT = 56; |
| 26 | +export const CAMERA_WINDOW_STATE_STORAGE_KEY = "cameraWindowState"; |
| 27 | + |
| 28 | +const BLUR_MODES: BackgroundBlurMode[] = ["off", "light", "heavy"]; |
| 29 | +const RESIZE_CORNERS = ["nw", "ne", "sw", "se"] as const; |
| 30 | + |
| 31 | +type ResizeCorner = (typeof RESIZE_CORNERS)[number]; |
| 32 | + |
| 33 | +export const getDefaultCameraWindowState = (): CameraWindowState => ({ |
| 34 | + size: CAMERA_DEFAULT_SIZE, |
| 35 | + shape: "round", |
| 36 | + mirrored: false, |
| 37 | + backgroundBlur: "off", |
| 38 | +}); |
| 39 | + |
| 40 | +export const clampCameraSize = (size: number) => |
| 41 | + Math.max(CAMERA_MIN_SIZE, Math.min(CAMERA_MAX_SIZE, size)); |
| 42 | + |
| 43 | +export const normalizeBackgroundBlurMode = ( |
| 44 | + mode: BackgroundBlurMode | boolean | undefined, |
| 45 | +): BackgroundBlurMode => { |
| 46 | + if (typeof mode === "boolean") return mode ? "heavy" : "off"; |
| 47 | + return mode ?? "off"; |
| 48 | +}; |
| 49 | + |
| 50 | +export const cycleBlurMode = ( |
| 51 | + current: BackgroundBlurMode | boolean, |
| 52 | +): BackgroundBlurMode => { |
| 53 | + if (typeof current === "boolean") { |
| 54 | + return current ? "heavy" : "light"; |
| 55 | + } |
| 56 | + const idx = BLUR_MODES.indexOf(current); |
| 57 | + return BLUR_MODES[(idx + 1) % BLUR_MODES.length]; |
| 58 | +}; |
| 59 | + |
| 60 | +export const blurModeLabel = (mode: BackgroundBlurMode | boolean): string => { |
| 61 | + if (typeof mode === "boolean") return mode ? "Blur" : ""; |
| 62 | + switch (mode) { |
| 63 | + case "light": |
| 64 | + return "Light"; |
| 65 | + case "heavy": |
| 66 | + return "Heavy"; |
| 67 | + default: |
| 68 | + return ""; |
| 69 | + } |
| 70 | +}; |
| 71 | + |
| 72 | +export const cameraToolbarScale = (size: number) => { |
| 73 | + const normalized = |
| 74 | + (clampCameraSize(size) - CAMERA_MIN_SIZE) / |
| 75 | + (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); |
| 76 | + return 0.7 + normalized * 0.3; |
| 77 | +}; |
| 78 | + |
| 79 | +export function cameraBorderRadius(state: CameraWindowState) { |
| 80 | + if (state.shape === "round") return "9999px"; |
| 81 | + const normalized = |
| 82 | + (clampCameraSize(state.size) - CAMERA_MIN_SIZE) / |
| 83 | + (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); |
| 84 | + const radius = 3 + normalized * 1.5; |
| 85 | + return `${radius}rem`; |
| 86 | +} |
| 87 | + |
| 88 | +export function CameraPreviewToolbar(props: { |
| 89 | + state: CameraWindowState; |
| 90 | + setState: SetStoreFunction<CameraWindowState>; |
| 91 | + visible: boolean; |
| 92 | + scale?: number; |
| 93 | + onClose?: () => void; |
| 94 | +}) { |
| 95 | + const toolbarClass = () => |
| 96 | + cx( |
| 97 | + "flex flex-row gap-1 p-1 rounded-xl transition-[opacity,transform] bg-gray-1 border border-white-transparent-20 text-gray-10", |
| 98 | + props.visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2", |
| 99 | + ); |
| 100 | + |
| 101 | + return ( |
| 102 | + <div |
| 103 | + class={toolbarClass()} |
| 104 | + style={{ transform: `scale(${props.scale ?? 1})` }} |
| 105 | + > |
| 106 | + <Show when={props.onClose}> |
| 107 | + {(onClose) => ( |
| 108 | + <ControlButton onClick={onClose()}> |
| 109 | + <IconCapCircleX class="size-5.5" /> |
| 110 | + </ControlButton> |
| 111 | + )} |
| 112 | + </Show> |
| 113 | + <ControlButton |
| 114 | + pressed={props.state.size >= CAMERA_PRESET_LARGE} |
| 115 | + onClick={() => { |
| 116 | + props.setState( |
| 117 | + "size", |
| 118 | + props.state.size < CAMERA_PRESET_LARGE |
| 119 | + ? CAMERA_PRESET_LARGE |
| 120 | + : CAMERA_PRESET_SMALL, |
| 121 | + ); |
| 122 | + }} |
| 123 | + > |
| 124 | + <IconCapEnlarge class="size-5.5" /> |
| 125 | + </ControlButton> |
| 126 | + <ControlButton |
| 127 | + pressed={props.state.shape !== "round"} |
| 128 | + onClick={() => |
| 129 | + props.setState("shape", (shape) => |
| 130 | + shape === "round" |
| 131 | + ? "square" |
| 132 | + : shape === "square" |
| 133 | + ? "full" |
| 134 | + : "round", |
| 135 | + ) |
| 136 | + } |
| 137 | + > |
| 138 | + {props.state.shape === "round" && <IconCapCircle class="size-5.5" />} |
| 139 | + {props.state.shape === "square" && <IconCapSquare class="size-5.5" />} |
| 140 | + {props.state.shape === "full" && ( |
| 141 | + <IconLucideRectangleHorizontal class="size-5.5" /> |
| 142 | + )} |
| 143 | + </ControlButton> |
| 144 | + <ControlButton |
| 145 | + pressed={props.state.mirrored} |
| 146 | + onClick={() => props.setState("mirrored", (mirrored) => !mirrored)} |
| 147 | + > |
| 148 | + <IconCapArrows class="size-5.5" /> |
| 149 | + </ControlButton> |
| 150 | + <ControlButton |
| 151 | + pressed={ |
| 152 | + props.state.backgroundBlur !== "off" && |
| 153 | + props.state.backgroundBlur !== false |
| 154 | + } |
| 155 | + onClick={() => |
| 156 | + props.setState("backgroundBlur", (mode) => cycleBlurMode(mode)) |
| 157 | + } |
| 158 | + > |
| 159 | + <div class="relative"> |
| 160 | + <IconLucidePersonStanding class="size-5.5" /> |
| 161 | + <Show |
| 162 | + when={ |
| 163 | + props.state.backgroundBlur !== "off" && |
| 164 | + props.state.backgroundBlur !== false |
| 165 | + } |
| 166 | + > |
| 167 | + <span class="absolute -bottom-1 left-1/2 -translate-x-1/2 text-[7px] font-bold leading-none whitespace-nowrap"> |
| 168 | + {blurModeLabel(props.state.backgroundBlur)} |
| 169 | + </span> |
| 170 | + </Show> |
| 171 | + </div> |
| 172 | + </ControlButton> |
| 173 | + </div> |
| 174 | + ); |
| 175 | +} |
| 176 | + |
| 177 | +function ControlButton( |
| 178 | + props: Omit<ComponentProps<typeof KToggleButton>, "type" | "class">, |
| 179 | +) { |
| 180 | + return ( |
| 181 | + <KToggleButton |
| 182 | + type="button" |
| 183 | + class="p-2 rounded-lg data-pressed:bg-gray-3 data-pressed:text-gray-12" |
| 184 | + {...props} |
| 185 | + /> |
| 186 | + ); |
| 187 | +} |
| 188 | + |
| 189 | +export function CameraResizeHandles(props: { |
| 190 | + state: CameraWindowState; |
| 191 | + setState: SetStoreFunction<CameraWindowState>; |
| 192 | + toolbarHeight: number; |
| 193 | + visible: boolean; |
| 194 | +}) { |
| 195 | + const [isResizing, setIsResizing] = createSignal(false); |
| 196 | + const [activeCorner, setActiveCorner] = createSignal<ResizeCorner | null>( |
| 197 | + null, |
| 198 | + ); |
| 199 | + const [resizeStart, setResizeStart] = createSignal({ |
| 200 | + size: 0, |
| 201 | + x: 0, |
| 202 | + y: 0, |
| 203 | + corner: "nw" as ResizeCorner, |
| 204 | + }); |
| 205 | + |
| 206 | + const handleResizeStart = (corner: ResizeCorner) => (e: MouseEvent) => { |
| 207 | + if (e.button !== 0) return; |
| 208 | + e.preventDefault(); |
| 209 | + e.stopPropagation(); |
| 210 | + setIsResizing(true); |
| 211 | + setActiveCorner(corner); |
| 212 | + setResizeStart({ |
| 213 | + size: props.state.size, |
| 214 | + x: e.clientX, |
| 215 | + y: e.clientY, |
| 216 | + corner, |
| 217 | + }); |
| 218 | + }; |
| 219 | + |
| 220 | + const handleResizeMove = (e: MouseEvent) => { |
| 221 | + if (!isResizing()) return; |
| 222 | + const start = resizeStart(); |
| 223 | + const deltaX = e.clientX - start.x; |
| 224 | + const deltaY = e.clientY - start.y; |
| 225 | + |
| 226 | + const hasE = start.corner.includes("e"); |
| 227 | + const hasW = start.corner.includes("w"); |
| 228 | + const hasS = start.corner.includes("s"); |
| 229 | + const hasN = start.corner.includes("n"); |
| 230 | + |
| 231 | + const dx = hasE ? deltaX : hasW ? -deltaX : 0; |
| 232 | + const dy = hasS ? deltaY : hasN ? -deltaY : 0; |
| 233 | + |
| 234 | + const delta = (hasE || hasW) && (hasN || hasS) ? Math.max(dx, dy) : dx + dy; |
| 235 | + |
| 236 | + props.setState("size", clampCameraSize(start.size + delta)); |
| 237 | + }; |
| 238 | + |
| 239 | + const handleResizeEnd = () => { |
| 240 | + setIsResizing(false); |
| 241 | + setActiveCorner(null); |
| 242 | + }; |
| 243 | + |
| 244 | + createEffect(() => { |
| 245 | + if (!isResizing()) return; |
| 246 | + window.addEventListener("mousemove", handleResizeMove); |
| 247 | + window.addEventListener("mouseup", handleResizeEnd); |
| 248 | + onCleanup(() => { |
| 249 | + window.removeEventListener("mousemove", handleResizeMove); |
| 250 | + window.removeEventListener("mouseup", handleResizeEnd); |
| 251 | + }); |
| 252 | + }); |
| 253 | + |
| 254 | + return ( |
| 255 | + <div |
| 256 | + class="pointer-events-none absolute inset-x-0 bottom-0 z-20" |
| 257 | + style={{ top: `${props.toolbarHeight}px` }} |
| 258 | + > |
| 259 | + {RESIZE_CORNERS.map((corner) => ( |
| 260 | + <ResizeCornerHandle |
| 261 | + corner={corner} |
| 262 | + onMouseDown={handleResizeStart(corner)} |
| 263 | + active={activeCorner() === corner} |
| 264 | + visible={props.visible || isResizing()} |
| 265 | + /> |
| 266 | + ))} |
| 267 | + </div> |
| 268 | + ); |
| 269 | +} |
| 270 | + |
| 271 | +function ResizeCornerHandle(props: { |
| 272 | + corner: ResizeCorner; |
| 273 | + onMouseDown: (e: MouseEvent) => void; |
| 274 | + active: boolean; |
| 275 | + visible: boolean; |
| 276 | +}) { |
| 277 | + const hitAreaClass = () => { |
| 278 | + switch (props.corner) { |
| 279 | + case "nw": |
| 280 | + return "top-0 left-0 cursor-nw-resize"; |
| 281 | + case "ne": |
| 282 | + return "top-0 right-0 cursor-ne-resize"; |
| 283 | + case "sw": |
| 284 | + return "bottom-0 left-0 cursor-sw-resize"; |
| 285 | + case "se": |
| 286 | + return "bottom-0 right-0 cursor-se-resize"; |
| 287 | + } |
| 288 | + }; |
| 289 | + |
| 290 | + const bracketPositionClass = () => { |
| 291 | + switch (props.corner) { |
| 292 | + case "nw": |
| 293 | + return "top-1.5 left-1.5 border-t-2 border-l-2 rounded-tl-[6px]"; |
| 294 | + case "ne": |
| 295 | + return "top-1.5 right-1.5 border-t-2 border-r-2 rounded-tr-[6px]"; |
| 296 | + case "sw": |
| 297 | + return "bottom-1.5 left-1.5 border-b-2 border-l-2 rounded-bl-[6px]"; |
| 298 | + case "se": |
| 299 | + return "bottom-1.5 right-1.5 border-b-2 border-r-2 rounded-br-[6px]"; |
| 300 | + } |
| 301 | + }; |
| 302 | + |
| 303 | + return ( |
| 304 | + <div |
| 305 | + data-tauri-drag-region="false" |
| 306 | + class={cx( |
| 307 | + "absolute z-20 w-7 h-7 group/handle select-none", |
| 308 | + hitAreaClass(), |
| 309 | + )} |
| 310 | + style={{ "pointer-events": "auto" }} |
| 311 | + onMouseDown={props.onMouseDown} |
| 312 | + > |
| 313 | + <div |
| 314 | + class={cx( |
| 315 | + "absolute w-3.5 h-3.5 border-white pointer-events-none", |
| 316 | + "transition-[opacity,transform,border-color] duration-150 ease-out", |
| 317 | + "opacity-0 scale-90", |
| 318 | + props.visible && "opacity-70 scale-100", |
| 319 | + "group-hover/handle:!opacity-100 group-hover/handle:!scale-110", |
| 320 | + props.active && "!opacity-100 !scale-110", |
| 321 | + bracketPositionClass(), |
| 322 | + )} |
| 323 | + style={{ |
| 324 | + filter: "drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6))", |
| 325 | + }} |
| 326 | + /> |
| 327 | + </div> |
| 328 | + ); |
| 329 | +} |
0 commit comments