import {
IconDeviceFloppy,
IconPencil,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useMemo, useRef, useState } from "react";
import {
type FeatureConfig,
type RegionGuideRect,
useFeatureConfig,
} from "../shared/config";
const MIN_RECT_SIZE = 0.012;
const DEFAULT_DRAW_ASPECT_RATIO = 1;
const RESIZE_CORNERS = ["nw", "ne", "sw", "se"] as const;
type ResizeCorner = (typeof RESIZE_CORNERS)[number];
interface Point {
x: number;
y: number;
}
interface SurfaceSize {
width: number;
height: number;
}
interface DrawState {
kind: "draw";
pointerId: number;
id: string;
start: Point;
}
interface MoveState {
kind: "move";
pointerId: number;
id: string;
start: Point;
origin: RegionGuideRect;
}
interface ResizeState {
kind: "resize";
pointerId: number;
id: string;
corner: ResizeCorner;
origin: RegionGuideRect;
aspectRatio: number;
}
type InteractionState = DrawState | MoveState | ResizeState;
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeRect(rect: RegionGuideRect): RegionGuideRect | null {
const width = Math.min(1, Math.max(0, rect.width));
const height = Math.min(1, Math.max(0, rect.height));
if (width < MIN_RECT_SIZE || height < MIN_RECT_SIZE) return null;
const x = clamp(rect.x, 0, 1 - width);
const y = clamp(rect.y, 0, 1 - height);
return { ...rect, x, y, width, height };
}
function normalizeRects(
rects: RegionGuideRect[] | undefined,
): RegionGuideRect[] {
return (rects ?? [])
.map(normalizeRect)
.filter((rect): rect is RegionGuideRect => rect !== null);
}
function createRectId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `guide-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function pointForEvent(
event: React.PointerEvent,
element: HTMLElement | null,
): Point {
if (!element) return { x: 0, y: 0 };
const bounds = element.getBoundingClientRect();
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
return {
x: clamp01((event.clientX - bounds.left) / width),
y: clamp01((event.clientY - bounds.top) / height),
};
}
function surfaceSize(element: HTMLElement | null): SurfaceSize {
if (!element) return { width: 1, height: 1 };
const bounds = element.getBoundingClientRect();
return {
width: Math.max(1, bounds.width),
height: Math.max(1, bounds.height),
};
}
function rectFromPoints(
id: string,
a: Point,
b: Point,
): RegionGuideRect | null {
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
const width = Math.abs(a.x - b.x);
const height = Math.abs(a.y - b.y);
return normalizeRect({ id, x, y, width, height });
}
function fixedCornerForResize(rect: RegionGuideRect, corner: ResizeCorner) {
return {
x: corner.includes("w") ? rect.x + rect.width : rect.x,
y: corner.includes("n") ? rect.y + rect.height : rect.y,
};
}
function visualAspectRatio(rect: RegionGuideRect, size: SurfaceSize): number {
const widthPx = Math.max(1, rect.width * size.width);
const heightPx = Math.max(1, rect.height * size.height);
return widthPx / heightPx;
}
function aspectLockedRectFromPoints({
id,
anchor,
point,
aspectRatio,
size,
}: {
id: string;
anchor: Point;
point: Point;
aspectRatio: number;
size: SurfaceSize;
}): RegionGuideRect | null {
const safeAspect =
Number.isFinite(aspectRatio) && aspectRatio > 0
? aspectRatio
: DEFAULT_DRAW_ASPECT_RATIO;
const signX = point.x >= anchor.x ? 1 : -1;
const signY = point.y >= anchor.y ? 1 : -1;
const widthPxFromPointer = Math.abs(point.x - anchor.x) * size.width;
const heightPxFromPointer = Math.abs(point.y - anchor.y) * size.height;
let widthPx = Math.max(
MIN_RECT_SIZE * size.width,
widthPxFromPointer,
heightPxFromPointer * safeAspect,
);
let heightPx = widthPx / safeAspect;
const maxWidthPx = (signX > 0 ? 1 - anchor.x : anchor.x) * size.width;
const maxHeightPx = (signY > 0 ? 1 - anchor.y : anchor.y) * size.height;
if (widthPx > maxWidthPx) {
widthPx = maxWidthPx;
heightPx = widthPx / safeAspect;
}
if (heightPx > maxHeightPx) {
heightPx = maxHeightPx;
widthPx = heightPx * safeAspect;
}
return rectFromPoints(id, anchor, {
x: anchor.x + (signX * widthPx) / size.width,
y: anchor.y + (signY * heightPx) / size.height,
});
}
function squareRectFromPoints(
id: string,
start: Point,
point: Point,
size: SurfaceSize,
): RegionGuideRect | null {
return aspectLockedRectFromPoints({
id,
anchor: start,
point,
aspectRatio: DEFAULT_DRAW_ASPECT_RATIO,
size,
});
}
function movedRect(
origin: RegionGuideRect,
start: Point,
point: Point,
): RegionGuideRect | null {
return normalizeRect({
...origin,
x: origin.x + point.x - start.x,
y: origin.y + point.y - start.y,
});
}
function resizedRect({
origin,
corner,
point,
keepAspectRatio,
aspectRatio,
size,
}: {
origin: RegionGuideRect;
corner: ResizeCorner;
point: Point;
keepAspectRatio: boolean;
aspectRatio: number;
size: SurfaceSize;
}): RegionGuideRect | null {
const anchor = fixedCornerForResize(origin, corner);
if (keepAspectRatio) {
return aspectLockedRectFromPoints({
id: origin.id,
anchor,
point,
aspectRatio,
size,
});
}
return rectFromPoints(origin.id, anchor, point);
}
function rectStyle(rect: RegionGuideRect): React.CSSProperties {
return {
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
};
}
function regionGuideConfig(config: FeatureConfig | null) {
return (
config?.regionGuides ?? {
enabled: false,
rects: [],
}
);
}
export function RegionGuides() {
const config = useFeatureConfig();
const guides = regionGuideConfig(config);
const rects = useMemo(() => normalizeRects(guides.rects), [guides.rects]);
useEffect(() => {
if (!config) return;
if (guides.enabled && rects.length > 0) return;
getCurrentWindow()
.close()
.catch(() => {});
}, [config, guides.enabled, rects.length]);
if (!guides.enabled || rects.length === 0) return null;
return (
{rects.map((rect) => (
))}
);
}
export function RegionGuideEditor({
mode = "preset",
}: {
mode?: "preset" | "capture";
}) {
const captureMode = mode === "capture";
const config = useFeatureConfig();
const guides = regionGuideConfig(config);
const surfaceRef = useRef(null);
const interactionRef = useRef(null);
const [rects, setRects] = useState([]);
const [draft, setDraft] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
useEffect(() => {
if (dirty) return;
setRects(normalizeRects(guides.rects));
}, [dirty, guides.rects]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (interactionRef.current) {
interactionRef.current = null;
setDraft(null);
return;
}
closeEditor();
}
if (event.key === "Backspace" || event.key === "Delete") {
if (!selectedId) return;
event.preventDefault();
deleteSelected();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
});
function updateRect(nextRect: RegionGuideRect | null) {
if (!nextRect) return;
setRects((current) =>
current.map((rect) => (rect.id === nextRect.id ? nextRect : rect)),
);
setDirty(true);
setMessage(null);
}
function deleteSelected() {
if (!selectedId) return;
setRects((current) => current.filter((rect) => rect.id !== selectedId));
setSelectedId(null);
setDirty(true);
setMessage(null);
}
function clearRects() {
setRects([]);
setSelectedId(null);
setDirty(true);
setMessage(null);
}
async function useSelectedCaptureRegion() {
const selected =
(selectedId ? rects.find((rect) => rect.id === selectedId) : null) ??
rects[0] ??
null;
const rect = selected ? normalizeRect(selected) : null;
if (!rect) {
setMessage("Draw a region first.");
return;
}
await emit("clips:region-capture-selected", {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
}).catch(() => {});
getCurrentWindow()
.close()
.catch(() => {});
}
async function savePreset() {
if (!config || saving) return;
setSaving(true);
setMessage(null);
const nextRects = normalizeRects(rects);
try {
await invoke("set_feature_config", {
config: {
...config,
regionGuides: {
...config.regionGuides,
enabled: nextRects.length > 0,
rects: nextRects,
alwaysVisible:
nextRects.length > 0
? (config.regionGuides.alwaysVisible ?? false)
: false,
},
},
});
setRects(nextRects);
setSelectedId((current) =>
current && nextRects.some((rect) => rect.id === current)
? current
: null,
);
setDirty(false);
setMessage(nextRects.length > 0 ? "Preset saved." : "Preset cleared.");
} catch (err) {
setMessage(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function closeEditor() {
if (captureMode) {
emit("clips:region-capture-cancelled").catch(() => {});
}
getCurrentWindow()
.close()
.catch(() => {});
}
function startDrawing(event: React.PointerEvent) {
if (event.button !== 0) return;
const target = event.target as HTMLElement;
if (
target.closest("[data-region-toolbar]") ||
target.closest("[data-region-rect]")
) {
return;
}
const point = pointForEvent(event, surfaceRef.current);
const id = createRectId();
interactionRef.current = {
kind: "draw",
pointerId: event.pointerId,
id,
start: point,
};
setSelectedId(null);
setMessage(null);
if (captureMode) {
setRects([]);
setDirty(true);
}
setDraft(null);
surfaceRef.current?.setPointerCapture(event.pointerId);
}
function startMoving(
event: React.PointerEvent,
rect: RegionGuideRect,
) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const point = pointForEvent(event, surfaceRef.current);
interactionRef.current = {
kind: "move",
pointerId: event.pointerId,
id: rect.id,
start: point,
origin: rect,
};
setSelectedId(rect.id);
setMessage(null);
surfaceRef.current?.setPointerCapture(event.pointerId);
}
function startResizing(
event: React.PointerEvent,
rect: RegionGuideRect,
corner: ResizeCorner,
) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
interactionRef.current = {
kind: "resize",
pointerId: event.pointerId,
id: rect.id,
corner,
origin: rect,
aspectRatio: visualAspectRatio(rect, surfaceSize(surfaceRef.current)),
};
setSelectedId(rect.id);
setMessage(null);
surfaceRef.current?.setPointerCapture(event.pointerId);
}
function updateInteraction(event: React.PointerEvent) {
const interaction = interactionRef.current;
if (!interaction || interaction.pointerId !== event.pointerId) {
return;
}
const point = pointForEvent(event, surfaceRef.current);
const size = surfaceSize(surfaceRef.current);
if (interaction.kind === "draw") {
setDraft(
captureMode
? rectFromPoints(interaction.id, interaction.start, point)
: squareRectFromPoints(
interaction.id,
interaction.start,
point,
size,
),
);
return;
}
if (interaction.kind === "move") {
updateRect(movedRect(interaction.origin, interaction.start, point));
return;
}
updateRect(
resizedRect({
origin: interaction.origin,
corner: interaction.corner,
point,
keepAspectRatio: event.shiftKey,
aspectRatio: interaction.aspectRatio,
size,
}),
);
}
function finishInteraction(event: React.PointerEvent) {
const interaction = interactionRef.current;
if (!interaction || interaction.pointerId !== event.pointerId) {
return;
}
if (interaction.kind === "draw") {
const point = pointForEvent(event, surfaceRef.current);
const nextRect = captureMode
? rectFromPoints(interaction.id, interaction.start, point)
: squareRectFromPoints(
interaction.id,
interaction.start,
point,
surfaceSize(surfaceRef.current),
);
if (nextRect) {
setRects((current) =>
captureMode ? [nextRect] : [...current, nextRect],
);
setSelectedId(nextRect.id);
setDirty(true);
}
}
interactionRef.current = null;
setDraft(null);
try {
const surface = surfaceRef.current;
if (surface?.hasPointerCapture(event.pointerId)) {
surface.releasePointerCapture(event.pointerId);
}
} catch {
// ignore — pointer capture is best-effort here.
}
}
return (
{captureMode ? "Recording region" : "Region guide preset"}
{rects.length} {rects.length === 1 ? "box" : "boxes"}
{message ? (
{message}
) : null}
{captureMode
? "Draw the area to record. Drag the box to move it, or pull a corner to resize."
: "Square guides. Corners resize. Drag a box to move."}
{rects.map((rect) => (
startMoving(event, rect)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedId(rect.id);
}
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
setRects((current) =>
current.filter((currentRect) => currentRect.id !== rect.id),
);
setSelectedId(null);
setDirty(true);
}
}}
>
{selectedId === rect.id
? RESIZE_CORNERS.map((corner) => (
startResizing(event, rect, corner)}
/>
))
: null}
))}
{draft ? (
) : null}
);
}