Skip to content

Commit 1ff640b

Browse files
committed
feat(desktop): add CameraPreviewChrome shared component module
1 parent d22a4d9 commit 1ff640b

1 file changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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

Comments
 (0)