diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json
index 5991565da6..f832fff88f 100644
--- a/apps/desktop/src-tauri/capabilities/default.json
+++ b/apps/desktop/src-tauri/capabilities/default.json
@@ -24,6 +24,7 @@
"core:path:default",
"core:event:default",
"core:menu:default",
+ "core:resources:allow-close",
"core:window:default",
"core:window:allow-close",
"core:window:allow-destroy",
diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs
index d2c3bfaae1..ce47b56205 100644
--- a/apps/desktop/src-tauri/src/general_settings.rs
+++ b/apps/desktop/src-tauri/src/general_settings.rs
@@ -70,8 +70,6 @@ pub struct GeneralSettingsStore {
pub upload_individual_files: bool,
#[serde(default)]
pub hide_dock_icon: bool,
- #[serde(default = "true_b")]
- pub haptics_enabled: bool,
#[serde(default)]
pub auto_create_shareable_link: bool,
#[serde(default = "true_b")]
@@ -160,7 +158,6 @@ impl Default for GeneralSettingsStore {
instance_id: uuid::Uuid::new_v4(),
upload_individual_files: false,
hide_dock_icon: false,
- haptics_enabled: true,
auto_create_shareable_link: false,
enable_notifications: true,
disable_auto_open_links: false,
diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs
index 267ec61b9c..bbf185f2a7 100644
--- a/apps/desktop/src-tauri/src/platform/mod.rs
+++ b/apps/desktop/src-tauri/src/platform/mod.rs
@@ -11,6 +11,7 @@ pub use macos::*;
use tracing::instrument;
#[derive(Debug, Serialize, Deserialize, Type, Default)]
+#[serde(rename_all = "camelCase")]
#[repr(isize)]
pub enum HapticPattern {
Alignment = 0,
@@ -20,6 +21,7 @@ pub enum HapticPattern {
}
#[derive(Debug, Serialize, Deserialize, Type, Default)]
+#[serde(rename_all = "camelCase")]
#[repr(usize)]
pub enum HapticPerformanceTime {
Default = 0,
diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs
index 5245414184..922baf9065 100644
--- a/apps/desktop/src-tauri/src/windows.rs
+++ b/apps/desktop/src-tauri/src/windows.rs
@@ -575,6 +575,7 @@ impl ShowCapWindow {
.maximized(false)
.fullscreen(false)
.shadow(false)
+ .resizable(false)
.always_on_top(true)
.content_protected(should_protect)
.skip_taskbar(true)
@@ -605,7 +606,7 @@ impl ShowCapWindow {
#[cfg(target_os = "macos")]
crate::platform::set_window_level(
window.as_ref().window(),
- objc2_app_kit::NSScreenSaverWindowLevel,
+ objc2_app_kit::NSPopUpMenuWindowLevel,
);
// Hide the main window if the target monitor is the same
diff --git a/apps/desktop/src/components/CapErrorBoundary.tsx b/apps/desktop/src/components/CapErrorBoundary.tsx
index 5fc9e35cfb..2f43b84812 100644
--- a/apps/desktop/src/components/CapErrorBoundary.tsx
+++ b/apps/desktop/src/components/CapErrorBoundary.tsx
@@ -1,4 +1,5 @@
import { Button } from "@cap/ui-solid";
+import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { ErrorBoundary, type ParentProps } from "solid-js";
@@ -32,6 +33,15 @@ export function CapErrorBoundary(props: ParentProps) {
>
Reload
+
{import.meta.env.DEV && (
diff --git a/apps/desktop/src/components/CropAreaRenderer.tsx b/apps/desktop/src/components/CropAreaRenderer.tsx
deleted file mode 100644
index b40e0d7adb..0000000000
--- a/apps/desktop/src/components/CropAreaRenderer.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import {
- createEffect,
- createSignal,
- onCleanup,
- onMount,
- type ParentProps,
-} from "solid-js";
-import { createHiDPICanvasContext } from "~/utils/canvas";
-import type { LogicalBounds } from "~/utils/tauri";
-
-type DrawContext = {
- ctx: CanvasRenderingContext2D;
- bounds: LogicalBounds;
- radius: number;
- prefersDark: boolean;
- highlighted: boolean;
- selected: boolean;
-};
-
-function drawHandles({
- ctx,
- bounds,
- radius,
- highlighted,
- selected,
-}: DrawContext) {
- const {
- position: { x, y },
- size: { width, height },
- } = bounds;
- const minSizeForSideHandles = 100;
-
- ctx.strokeStyle = selected
- ? "rgba(255, 255, 255, 1)"
- : highlighted
- ? "rgba(60, 150, 280, 1)"
- : "rgba(255, 255, 255, 0.8)";
-
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.roundRect(x, y, width, height, radius);
- ctx.stroke();
-
- ctx.lineWidth = 5;
- ctx.lineCap = "round";
- ctx.setLineDash([]);
-
- const cornerHandleLength = radius === 0 ? 20 : 10;
-
- // Corner handles
- const adjustedRadius = Math.min(radius, width / 2, height / 2);
-
- const x2 = x + width;
- const y2 = y + height;
-
- // top left
- ctx.beginPath();
-
- ctx.moveTo(x, y + adjustedRadius + cornerHandleLength);
- ctx.arcTo(x, y, x2, y, adjustedRadius);
- ctx.lineTo(x + adjustedRadius + cornerHandleLength, y);
-
- // top right
- ctx.moveTo(x2 - adjustedRadius - cornerHandleLength, y);
- ctx.arcTo(x2, y, x2, y2, adjustedRadius);
- ctx.lineTo(x2, y + adjustedRadius + cornerHandleLength);
-
- // bottom left
- ctx.moveTo(x + adjustedRadius + cornerHandleLength, y2);
- ctx.arcTo(x, y2, x, y, adjustedRadius);
- ctx.lineTo(x, y2 - adjustedRadius - cornerHandleLength);
-
- // bottom right
- ctx.moveTo(x2, y2 - adjustedRadius - cornerHandleLength);
- ctx.arcTo(x2, y2, x, y2, adjustedRadius);
- ctx.lineTo(x2 - adjustedRadius - cornerHandleLength, y2);
-
- ctx.stroke();
-
- // Only draw side handles if there's enough space.
- if (!(width > minSizeForSideHandles && height > minSizeForSideHandles)) {
- return;
- }
-
- // Center handles
- const handleLength = 35;
- const sideHandleDistance = 0;
- const centerX = x + width / 2;
- const centerY = y + height / 2;
-
- ctx.beginPath();
-
- // top center
- ctx.moveTo(centerX - handleLength / 2, y - sideHandleDistance);
- ctx.lineTo(centerX + handleLength / 2, y - sideHandleDistance);
-
- // bottom center
- ctx.moveTo(centerX - handleLength / 2, y + height + sideHandleDistance);
- ctx.lineTo(centerX + handleLength / 2, y + height + sideHandleDistance);
-
- // left center
- ctx.moveTo(x - sideHandleDistance, centerY - handleLength / 2);
- ctx.lineTo(x - sideHandleDistance, centerY + handleLength / 2);
-
- // right center
- ctx.moveTo(x + width + sideHandleDistance, centerY - handleLength / 2);
- ctx.lineTo(x + width + sideHandleDistance, centerY + handleLength / 2);
-
- ctx.stroke();
-}
-
-// Rule of thirds guide lines and center crosshair
-function drawGuideLines({
- ctx,
- bounds: { position, size },
- prefersDark,
-}: DrawContext) {
- ctx.strokeStyle = prefersDark
- ? "rgba(255, 255, 255, 0.5)"
- : "rgba(0, 0, 0, 0.5)";
- ctx.lineWidth = 1;
- ctx.setLineDash([5, 2]);
-
- // Rule of thirds
- ctx.beginPath();
- for (let i = 1; i < 3; i++) {
- const x = position.x + (size.width * i) / 3;
- ctx.moveTo(x, position.y);
- ctx.lineTo(x, position.y + size.height);
- }
- ctx.stroke();
-
- ctx.beginPath();
- for (let i = 1; i < 3; i++) {
- const y = position.y + (size.height * i) / 3;
- ctx.moveTo(position.x, y);
- ctx.lineTo(position.x + size.width, y);
- }
- ctx.stroke();
-
- // Center crosshair
- const centerX = Math.round(position.x + size.width / 2);
- const centerY = Math.round(position.y + size.height / 2);
-
- ctx.setLineDash([]);
- ctx.lineWidth = 2;
- const crosshairLength = 7;
-
- ctx.beginPath();
- ctx.moveTo(centerX - crosshairLength, centerY);
- ctx.lineTo(centerX + crosshairLength, centerY);
- ctx.stroke();
-
- ctx.beginPath();
- ctx.moveTo(centerX, centerY - crosshairLength);
- ctx.lineTo(centerX, centerY + crosshairLength);
- ctx.stroke();
-}
-
-// Main draw function
-function draw(
- ctx: CanvasRenderingContext2D,
- { position, size }: LogicalBounds,
- radius: number,
- guideLines: boolean,
- showHandles: boolean,
- highlighted: boolean,
- selected: boolean,
- prefersDark: boolean,
-) {
- if (size.width <= 0 || size.height <= 0) return;
- const drawContext: DrawContext = {
- ctx,
- bounds: { position, size },
- radius,
- prefersDark,
- highlighted,
- selected,
- };
-
- ctx.save();
- ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
- ctx.fillStyle = "rgba(0, 0, 0, 0.65)";
- ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-
- // Shadow
- ctx.save();
- ctx.shadowColor = "rgba(0, 0, 0, 0.8)";
- ctx.shadowBlur = 200;
- ctx.shadowOffsetY = 25;
- ctx.beginPath();
- ctx.roundRect(position.x, position.y, size.width, size.height, radius);
- ctx.fill();
- ctx.restore();
-
- if (showHandles) drawHandles(drawContext);
-
- ctx.beginPath();
- ctx.roundRect(position.x, position.y, size.width, size.height, radius);
- ctx.clip();
- ctx.clearRect(position.x, position.y, size.width, size.height);
-
- if (guideLines) drawGuideLines(drawContext);
-
- ctx.restore();
-}
-
-export default function CropAreaRenderer(
- props: ParentProps<{
- bounds: LogicalBounds;
- guideLines?: boolean;
- handles?: boolean;
- borderRadius?: number;
- highlighted?: boolean;
- selected?: boolean;
- }>,
-) {
- let canvasRef: HTMLCanvasElement | undefined;
- const [prefersDarkScheme, setPrefersDarkScheme] = createSignal(false);
-
- onMount(() => {
- if (!canvasRef) {
- console.error("Canvas ref was not setup");
- return;
- }
-
- const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
- setPrefersDarkScheme(colorSchemeQuery.matches);
- const handleChange = (e: MediaQueryListEvent) =>
- setPrefersDarkScheme(e.matches);
- colorSchemeQuery.addEventListener("change", handleChange);
-
- const hidpiCanvas = createHiDPICanvasContext(canvasRef, (ctx) =>
- draw(
- ctx,
- props.bounds,
- props.borderRadius || 0,
- props.guideLines || false,
- props.handles || false,
- props.highlighted || false,
- props.selected || false,
- prefersDarkScheme(),
- ),
- );
- const ctx = hidpiCanvas?.ctx;
- if (!ctx) return;
-
- let lastAnimationFrameId: number | undefined;
- createEffect(() => {
- if (lastAnimationFrameId) cancelAnimationFrame(lastAnimationFrameId);
-
- const {
- guideLines,
- handles,
- borderRadius,
- highlighted,
- selected,
- bounds: { ...bounds },
- } = props;
-
- const prefersDark = prefersDarkScheme();
- lastAnimationFrameId = requestAnimationFrame(() =>
- draw(
- ctx,
- bounds,
- borderRadius || 0,
- guideLines || false,
- handles || false,
- highlighted || false,
- selected || false,
- prefersDark,
- ),
- );
- });
-
- onCleanup(() => {
- if (lastAnimationFrameId) cancelAnimationFrame(lastAnimationFrameId);
- hidpiCanvas.cleanup();
- colorSchemeQuery.removeEventListener("change", handleChange);
- });
- });
-
- return (
-
- );
-}
diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx
index 88379b2213..7243f28042 100644
--- a/apps/desktop/src/components/Cropper.tsx
+++ b/apps/desktop/src/components/Cropper.tsx
@@ -1,771 +1,1500 @@
import { createEventListenerMap } from "@solid-primitives/event-listener";
-import { makePersisted } from "@solid-primitives/storage";
-import { type CheckMenuItemOptions, Menu } from "@tauri-apps/api/menu";
+import { createResizeObserver } from "@solid-primitives/resize-observer";
import { type as ostype } from "@tauri-apps/plugin-os";
import {
- batch,
+ type Accessor,
+ children,
createEffect,
createMemo,
- createResource,
createRoot,
createSignal,
For,
on,
- onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js";
import { createStore } from "solid-js/store";
import { Transition } from "solid-transition-group";
-import { generalSettingsStore } from "~/store";
-import Box from "~/utils/box";
-import { type Crop, commands, type XY } from "~/utils/tauri";
-import CropAreaRenderer from "./CropAreaRenderer";
+
+import { commands } from "~/utils/tauri";
+export interface CropBounds {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+export const CROP_ZERO: CropBounds = { x: 0, y: 0, width: 0, height: 0 };
type Direction = "n" | "e" | "s" | "w" | "nw" | "ne" | "se" | "sw";
+type BoundsConstraints = {
+ top: boolean;
+ right: boolean;
+ bottom: boolean;
+ left: boolean;
+};
+type Vec2 = { x: number; y: number };
+
type HandleSide = {
x: "l" | "r" | "c";
y: "t" | "b" | "c";
direction: Direction;
- cursor: "ew" | "ns" | "nesw" | "nwse";
+ cursor: string;
+ movable: BoundsConstraints;
+ origin: Vec2;
+ isCorner: boolean;
};
-const HANDLES: HandleSide[] = [
- { x: "l", y: "t", direction: "nw", cursor: "nwse" },
- { x: "r", y: "t", direction: "ne", cursor: "nesw" },
- { x: "l", y: "b", direction: "sw", cursor: "nesw" },
- { x: "r", y: "b", direction: "se", cursor: "nwse" },
- { x: "c", y: "t", direction: "n", cursor: "ns" },
- { x: "c", y: "b", direction: "s", cursor: "ns" },
- { x: "l", y: "c", direction: "w", cursor: "ew" },
- { x: "r", y: "c", direction: "e", cursor: "ew" },
-];
-
-type Ratio = [number, number];
-const COMMON_RATIOS: Ratio[] = [
+const HANDLES: readonly HandleSide[] = [
+ { x: "l", y: "t", direction: "nw", cursor: "nwse-resize" },
+ { x: "r", y: "t", direction: "ne", cursor: "nesw-resize" },
+ { x: "l", y: "b", direction: "sw", cursor: "nesw-resize" },
+ { x: "r", y: "b", direction: "se", cursor: "nwse-resize" },
+ { x: "c", y: "t", direction: "n", cursor: "ns-resize" },
+ { x: "c", y: "b", direction: "s", cursor: "ns-resize" },
+ { x: "l", y: "c", direction: "w", cursor: "ew-resize" },
+ { x: "r", y: "c", direction: "e", cursor: "ew-resize" },
+].map(
+ (handle) =>
+ ({
+ ...handle,
+ movable: {
+ top: handle.y === "t",
+ bottom: handle.y === "b",
+ left: handle.x === "l",
+ right: handle.x === "r",
+ },
+ origin: {
+ x: handle.x === "l" ? 1 : handle.x === "r" ? 0 : 0.5,
+ y: handle.y === "t" ? 1 : handle.y === "b" ? 0 : 0.5,
+ },
+ isCorner: handle.x !== "c" && handle.y !== "c",
+ }) as HandleSide,
+);
+export type Ratio = [number, number];
+export const COMMON_RATIOS: readonly Ratio[] = [
[1, 1],
- [4, 3],
+ [2, 1],
[3, 2],
+ [4, 3],
+ [9, 16],
[16, 9],
- [2, 1],
+ [16, 10],
[21, 9],
];
+const ORIGIN_CENTER: Vec2 = { x: 0.5, y: 0.5 };
-const KEY_MAPPINGS = new Map([
- ["ArrowRight", "e"],
- ["ArrowDown", "s"],
- ["ArrowLeft", "w"],
- ["ArrowUp", "n"],
-]);
+const ratioToValue = (r: Ratio) => r[0] / r[1];
+const clamp = (n: number, min = 0, max = 1) => Math.max(min, Math.min(max, n));
+const easeInOutCubic = (t: number) =>
+ t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
-const ORIGIN_CENTER: XY = { x: 0.5, y: 0.5 };
+const shouldTriggerHaptic = ostype() === "macos";
+function triggerHaptic() {
+ if (shouldTriggerHaptic) commands.performHapticFeedback("alignment", null);
+}
-function clamp(n: number, min = 0, max = 1) {
- return Math.max(min, Math.min(max, n));
+function findClosestRatio(
+ width: number,
+ height: number,
+ threshold = 0.01,
+): Ratio | null {
+ const currentRatio = width / height;
+ for (const ratio of COMMON_RATIOS) {
+ if (Math.abs(currentRatio - ratio[0] / ratio[1]) < threshold)
+ return [ratio[0], ratio[1]];
+ if (Math.abs(currentRatio - ratio[1] / ratio[0]) < threshold)
+ return [ratio[1], ratio[0]];
+ }
+ return null;
}
-function distanceOf(firstPoint: Touch, secondPoint: Touch): number {
- const dx = firstPoint.clientX - secondPoint.clientX;
- const dy = firstPoint.clientY - secondPoint.clientY;
- return Math.sqrt(dx * dx + dy * dy);
+// -----------------------------
+// Bounds helpers
+// -----------------------------
+function moveBounds(
+ bounds: CropBounds,
+ x: number | null,
+ y: number | null,
+): CropBounds {
+ return {
+ ...bounds,
+ x: x !== null ? Math.round(x) : bounds.x,
+ y: y !== null ? Math.round(y) : bounds.y,
+ };
}
-export function cropToFloor(value: Crop): Crop {
+function resizeBounds(
+ bounds: CropBounds,
+ newWidth: number,
+ newHeight: number,
+ origin: Vec2,
+): CropBounds {
+ const fromX = bounds.x + bounds.width * origin.x;
+ const fromY = bounds.y + bounds.height * origin.y;
return {
- size: {
- x: Math.floor(value.size.x),
- y: Math.floor(value.size.y),
- },
- position: {
- x: Math.floor(value.position.x),
- y: Math.floor(value.position.y),
- },
+ x: Math.round(fromX - newWidth * origin.x),
+ y: Math.round(fromY - newHeight * origin.y),
+ width: Math.round(newWidth),
+ height: Math.round(newHeight),
};
}
+function scaleBounds(bounds: CropBounds, factor: number, origin: Vec2) {
+ return resizeBounds(
+ bounds,
+ bounds.width * factor,
+ bounds.height * factor,
+ origin,
+ );
+}
+
+function constrainBoundsToRatio(
+ bounds: CropBounds,
+ ratio: number,
+ origin: Vec2,
+) {
+ const currentRatio = bounds.width / bounds.height;
+ if (Math.abs(currentRatio - ratio) < 0.001) return bounds;
+ return resizeBounds(bounds, bounds.width, bounds.width / ratio, origin);
+}
+
+function constrainBoundsToSize(
+ bounds: CropBounds,
+ max: Vec2 | null,
+ min: Vec2 | null,
+ origin: Vec2,
+ ratio: number | null = null,
+) {
+ let next = { ...bounds };
+ let maxW = max?.x ?? null;
+ let maxH = max?.y ?? null;
+ let minW = min?.x ?? null;
+ let minH = min?.y ?? null;
+
+ if (ratio) {
+ // Correctly calculate effective min/max sizes when a ratio is present
+ if (minW && minH) {
+ const effectiveMinW = Math.max(minW, minH * ratio);
+ minW = effectiveMinW;
+ minH = effectiveMinW / ratio;
+ }
+ if (maxW && maxH) {
+ const effectiveMaxW = Math.min(maxW, maxH * ratio);
+ maxW = effectiveMaxW;
+ maxH = effectiveMaxW / ratio;
+ }
+ }
+
+ if (maxW && next.width > maxW)
+ next = resizeBounds(next, maxW, ratio ? maxW / ratio : next.height, origin);
+ if (maxH && next.height > maxH)
+ next = resizeBounds(next, ratio ? maxH * ratio : next.width, maxH, origin);
+ if (minW && next.width < minW)
+ next = resizeBounds(next, minW, ratio ? minW / ratio : next.height, origin);
+ if (minH && next.height < minH)
+ next = resizeBounds(next, ratio ? minH * ratio : next.width, minH, origin);
+
+ return next;
+}
+
+function slideBoundsIntoContainer(
+ bounds: CropBounds,
+ containerWidth: number,
+ containerHeight: number,
+): CropBounds {
+ let { x, y, width, height } = bounds;
+
+ if (x < 0) x = 0;
+ if (y < 0) y = 0;
+ if (x + width > containerWidth) x = containerWidth - width;
+ if (y + height > containerHeight) y = containerHeight - height;
+
+ return { ...bounds, x, y };
+}
+
+export type CropperRef = {
+ fill: () => void;
+ reset: () => void;
+ setCropProperty: (field: keyof CropBounds, value: number) => void;
+ setCrop: (
+ value: CropBounds | ((b: CropBounds) => CropBounds),
+ origin?: Vec2,
+ ) => void;
+ bounds: Accessor;
+ animateTo: (real: CropBounds, durationMs?: number) => void;
+};
+
export default function Cropper(
props: ParentProps<{
+ onCropChange?: (bounds: CropBounds) => void;
+ onInteraction?: (interacting: boolean) => void;
+ onContextMenu?: (event: PointerEvent) => void;
+ ref?: CropperRef | ((ref: CropperRef) => void);
class?: string;
- onCropChange: (value: Crop) => void;
- value: Crop;
- mappedSize?: XY;
- minSize?: XY;
- initialSize?: XY;
- aspectRatio?: number;
- showGuideLines?: boolean;
+ minSize?: Vec2;
+ maxSize?: Vec2;
+ targetSize?: Vec2;
+ initialCrop?: CropBounds | (() => CropBounds | undefined);
+ aspectRatio?: Ratio;
+ showBounds?: boolean;
+ snapToRatioEnabled?: boolean;
+ useBackdropFilter?: boolean;
+ allowLightMode?: boolean;
}>,
) {
- const position = () => props.value.position;
- const size = () => props.value.size;
+ let containerRef: HTMLDivElement | undefined;
+ let regionRef: HTMLDivElement | undefined;
+ let occTopRef: HTMLDivElement | undefined;
+ let occBottomRef: HTMLDivElement | undefined;
+ let occLeftRef: HTMLDivElement | undefined;
+ let occRightRef: HTMLDivElement | undefined;
+
+ const resolvedChildren = children(() => props.children);
+
+ // raw bounds are in "logical" coordinates (not scaled to targetSize)
+ const [rawBounds, setRawBounds] = createSignal(CROP_ZERO);
+ const [displayRawBounds, setDisplayRawBounds] =
+ createSignal(CROP_ZERO);
+
+ const [isAnimating, setIsAnimating] = createSignal(false);
+ let animationFrameId: number | null = null;
+ const [isReady, setIsReady] = createSignal(false);
+
+ function stopAnimation() {
+ if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ setIsAnimating(false);
+ setDisplayRawBounds(rawBounds());
+ }
- const [containerSize, setContainerSize] = createSignal({ x: 0, y: 0 });
- const mappedSize = createMemo(() => props.mappedSize || containerSize());
- const minSize = createMemo(() => {
- const mapped = mappedSize();
- return {
- x: Math.min(100, mapped.x * 0.1),
- y: Math.min(100, mapped.y * 0.1),
+ const boundsTooSmall = createMemo(
+ () => displayRawBounds().width <= 30 || displayRawBounds().height <= 30,
+ );
+
+ const [state, setState] = createStore({
+ dragging: false,
+ resizing: false,
+ overlayDragging: false,
+ cursorStyle: null as string | null,
+ hoveringHandle: null as HandleSide | null,
+ });
+
+ createEffect(() => props.onInteraction?.(state.dragging || state.resizing));
+
+ const [aspectState, setAspectState] = createStore({
+ snapped: null as Ratio | null,
+ value: null as number | null,
+ });
+
+ createEffect(() => {
+ const min = props.minSize;
+ const max = props.maxSize;
+
+ if (min && max) {
+ if (min.x > max.x)
+ throw new Error(
+ `Cropper constraint error: minSize.x (${min.x}px) exceeds maxSize.x (${max.x}px). Please adjust the size constraints.`,
+ );
+ if (min.y > max.y)
+ throw new Error(
+ `Cropper constraint error: minSize.y (${min.y}px) exceeds maxSize.y (${max.y}px). Please adjust the size constraints.`,
+ );
+ }
+ });
+
+ createEffect(
+ on(
+ () => props.aspectRatio,
+ (v) => {
+ const nextRatio = v ? ratioToValue(v) : null;
+ setAspectState("value", nextRatio);
+
+ if (!isReady() || !nextRatio) return;
+ let targetBounds = rawBounds();
+
+ targetBounds = constrainBoundsToRatio(
+ targetBounds,
+ nextRatio,
+ ORIGIN_CENTER,
+ );
+ setRawBoundsAndAnimate(targetBounds);
+ },
+ ),
+ );
+
+ const [containerSize, setContainerSize] = createSignal({ x: 1, y: 1 });
+ const targetSize = createMemo(() => props.targetSize || containerSize());
+
+ const logicalScale = createMemo(() => {
+ if (props.targetSize) {
+ const target = props.targetSize;
+ const container = containerSize();
+ return { x: target.x / container.x, y: target.y / container.y };
+ }
+ return { x: 1, y: 1 };
+ });
+
+ const realBounds = createMemo(() => {
+ const { x, y, width, height } = rawBounds();
+ const scale = logicalScale();
+ const target = targetSize();
+ const bounds = {
+ x: Math.round(x * scale.x),
+ y: Math.round(y * scale.y),
+ width: Math.round(width * scale.x),
+ height: Math.round(height * scale.y),
};
+
+ if (bounds.width > target.x) bounds.width = target.x;
+ if (bounds.height > target.y) bounds.height = target.y;
+ if (bounds.x < 0) bounds.x = 0;
+ if (bounds.y < 0) bounds.y = 0;
+ if (bounds.x + bounds.width > target.x) bounds.x = target.x - bounds.width;
+ if (bounds.y + bounds.height > target.y)
+ bounds.y = target.y - bounds.height;
+
+ props.onCropChange?.(bounds);
+ return bounds;
});
- const containerToMappedSizeScale = createMemo(() => {
- const container = containerSize();
- const mapped = mappedSize();
+ function calculateLabelTransform(handle: HandleSide) {
+ const bounds = rawBounds();
+ if (!containerRef) return { x: 0, y: 0 };
+ const containerRect = containerRef.getBoundingClientRect();
+ const labelWidth = 80;
+ const labelHeight = 25;
+ const margin = 25;
+
+ const handleScreenX =
+ containerRect.left +
+ bounds.x +
+ bounds.width * (handle.x === "l" ? 0 : handle.x === "r" ? 1 : 0.5);
+ const handleScreenY =
+ containerRect.top +
+ bounds.y +
+ bounds.height * (handle.y === "t" ? 0 : handle.y === "b" ? 1 : 0.5);
+
+ let idealX = handleScreenX;
+ let idealY = handleScreenY;
+
+ if (handle.x === "l") idealX -= labelWidth + margin;
+ else if (handle.x === "r") idealX += margin;
+ else idealX -= labelWidth / 2;
+
+ if (handle.y === "t") idealY -= labelHeight + margin;
+ else if (handle.y === "b") idealY += margin;
+ else idealY -= labelHeight / 2;
+
+ const finalX = clamp(
+ idealX,
+ margin,
+ window.innerWidth - labelWidth - margin,
+ );
+ const finalY = clamp(
+ idealY,
+ margin,
+ window.innerHeight - labelHeight - margin,
+ );
+
+ return { x: finalX, y: finalY };
+ }
+
+ const labelTransform = createMemo(() =>
+ state.resizing && state.hoveringHandle
+ ? calculateLabelTransform(state.hoveringHandle)
+ : null,
+ );
+
+ function boundsToRaw(real: CropBounds) {
+ const scale = logicalScale();
return {
- x: container.x / mapped.x,
- y: container.y / mapped.y,
+ x: Math.max(0, real.x / scale.x),
+ y: Math.max(0, real.y / scale.y),
+ width: Math.max(0, real.width / scale.x),
+ height: Math.max(0, real.height / scale.y),
};
- });
+ }
+
+ function animateToRawBounds(target: CropBounds, durationMs = 240) {
+ const start = displayRawBounds();
+ if (
+ target.x === start.x &&
+ target.y === start.y &&
+ target.width === start.width &&
+ target.height === start.height
+ ) {
+ return;
+ }
+
+ setIsAnimating(true);
+ if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
+ const startTime = performance.now();
+
+ const step = () => {
+ const now = performance.now();
+ const t = Math.min(1, (now - startTime) / durationMs);
+ const e = easeInOutCubic(t);
+ setDisplayRawBounds({
+ x: start.x + (target.x - start.x) * e,
+ y: start.y + (target.y - start.y) * e,
+ width: start.width + (target.width - start.width) * e,
+ height: start.height + (target.height - start.height) * e,
+ });
+ if (t < 1) animationFrameId = requestAnimationFrame(step);
+ else {
+ animationFrameId = null;
+ setIsAnimating(false);
+ triggerHaptic();
+ }
+ };
+
+ animationFrameId = requestAnimationFrame(step);
+ }
- const displayScaledCrop = createMemo(() => {
- const mapped = mappedSize();
+ function setRawBoundsAndAnimate(
+ bounds: CropBounds,
+ origin?: Vec2,
+ durationMs = 240,
+ ) {
+ if (animationFrameId !== null) cancelAnimationFrame(animationFrameId);
+ setIsAnimating(true);
+ setRawBoundsConstraining(bounds, origin);
+ animateToRawBounds(rawBounds(), durationMs);
+ }
+
+ function computeInitialBounds(): CropBounds {
+ const target = targetSize();
+ const initialCrop =
+ typeof props.initialCrop === "function"
+ ? props.initialCrop()
+ : props.initialCrop;
+
+ const startBoundsReal = initialCrop ?? {
+ x: 0,
+ y: 0,
+ width: Math.round(target.x / 2),
+ height: Math.round(target.y / 2),
+ };
+
+ let bounds = boundsToRaw(startBoundsReal);
+ const ratioValue = aspectState.value;
+ if (ratioValue)
+ bounds = constrainBoundsToRatio(bounds, ratioValue, ORIGIN_CENTER);
const container = containerSize();
+
+ if (bounds.width > container.x)
+ bounds = scaleBounds(bounds, container.x / bounds.width, ORIGIN_CENTER);
+ if (bounds.height > container.y)
+ bounds = scaleBounds(bounds, container.y / bounds.height, ORIGIN_CENTER);
+
+ bounds = slideBoundsIntoContainer(bounds, container.x, container.y);
+
+ if (!initialCrop)
+ bounds = moveBounds(
+ bounds,
+ container.x / 2 - bounds.width / 2,
+ container.y / 2 - bounds.height / 2,
+ );
+ return bounds;
+ }
+
+ function rawSizeConstraint() {
+ const scale = logicalScale();
return {
- x: (position().x / mapped.x) * container.x,
- y: (position().y / mapped.y) * container.y,
- width: (size().x / mapped.x) * container.x,
- height: (size().y / mapped.y) * container.y,
+ min: props.minSize
+ ? { x: props.minSize.x / scale.x, y: props.minSize.y / scale.y }
+ : null,
+ max: props.maxSize
+ ? { x: props.maxSize.x / scale.x, y: props.maxSize.y / scale.y }
+ : null,
};
- });
+ }
+
+ function setRawBoundsConstraining(
+ bounds: CropBounds,
+ origin = ORIGIN_CENTER,
+ ) {
+ const ratioValue = aspectState.value;
+ const container = containerSize();
+ const { min, max } = rawSizeConstraint();
+ let newBounds = { ...bounds };
+
+ newBounds = constrainBoundsToSize(newBounds, max, min, origin, ratioValue);
+
+ if (ratioValue)
+ newBounds = constrainBoundsToRatio(newBounds, ratioValue, origin);
+
+ if (newBounds.width > container.x)
+ newBounds = scaleBounds(newBounds, container.x / newBounds.width, origin);
+ if (newBounds.height > container.y)
+ newBounds = scaleBounds(
+ newBounds,
+ container.y / newBounds.height,
+ origin,
+ );
+
+ newBounds = slideBoundsIntoContainer(newBounds, container.x, container.y);
+ setRawBounds(newBounds);
+ if (!isAnimating()) setDisplayRawBounds(newBounds);
+ }
+
+ function fill() {
+ const container = containerSize();
+ const targetRaw = {
+ x: 0,
+ y: 0,
+ width: container.x,
+ height: container.y,
+ };
+ setRawBoundsAndAnimate(targetRaw);
+ setAspectState("snapped", null);
+ }
- let containerRef: HTMLDivElement | undefined;
onMount(() => {
if (!containerRef) return;
+ let initialized = false;
- const updateContainerSize = () => {
- setContainerSize({
- x: containerRef!.clientWidth,
- y: containerRef!.clientHeight,
- });
- };
+ const updateContainerSize = (width: number, height: number) => {
+ const prevScale = logicalScale();
+ const currentRaw = rawBounds();
+ const preservedReal = {
+ x: Math.round(currentRaw.x * prevScale.x),
+ y: Math.round(currentRaw.y * prevScale.y),
+ width: Math.round(currentRaw.width * prevScale.x),
+ height: Math.round(currentRaw.height * prevScale.y),
+ };
- updateContainerSize();
- const resizeObserver = new ResizeObserver(updateContainerSize);
- resizeObserver.observe(containerRef);
- onCleanup(() => resizeObserver.disconnect());
+ setContainerSize({ x: width, y: height });
- const mapped = mappedSize();
- const initial = props.initialSize || {
- x: mapped.x / 2,
- y: mapped.y / 2,
- };
+ setRawBoundsConstraining(boundsToRaw(preservedReal));
- const width = clamp(initial.x, minSize().x, mapped.x);
- const height = clamp(initial.y, minSize().y, mapped.y);
+ if (!initialized && width > 1 && height > 1) {
+ initialized = true;
+ init();
+ }
+ };
- const box = Box.from(
- { x: (mapped.x - width) / 2, y: (mapped.y - height) / 2 },
- { x: width, y: height },
+ createResizeObserver(containerRef, (e) =>
+ updateContainerSize(e.width, e.height),
);
- box.constrainAll(box, containerSize(), ORIGIN_CENTER, props.aspectRatio);
+ updateContainerSize(containerRef.clientWidth, containerRef.clientHeight);
- setCrop({
- size: { x: width, y: height },
- position: {
- x: (mapped.x - width) / 2,
- y: (mapped.y - height) / 2,
- },
- });
- });
+ setDisplayRawBounds(rawBounds());
- createEffect(
- on(
- () => props.aspectRatio,
- () => {
- if (!props.aspectRatio) return;
- const box = Box.from(position(), size());
- box.constrainToRatio(props.aspectRatio, ORIGIN_CENTER);
- box.constrainToBoundary(mappedSize().x, mappedSize().y, ORIGIN_CENTER);
- setCrop(box.toBounds());
- },
- ),
- );
+ function init() {
+ const bounds = computeInitialBounds();
+ setRawBoundsConstraining(bounds);
+ setDisplayRawBounds(bounds);
+ setIsReady(true);
+ }
- const [snapToRatioEnabled, setSnapToRatioEnabled] = makePersisted(
- createSignal(true),
- { name: "cropSnapsToRatio" },
- );
- const [snappedRatio, setSnappedRatio] = createSignal(null);
- const [dragging, setDragging] = createSignal(false);
- const [gestureState, setGestureState] = createStore<{
- isTrackpadGesture: boolean;
- lastTouchCenter: XY | null;
- initialPinchDistance: number;
- initialSize: { width: number; height: number };
- }>({
- isTrackpadGesture: false,
- lastTouchCenter: null,
- initialPinchDistance: 0,
- initialSize: { width: 0, height: 0 },
+ if (props.ref) {
+ const cropperRef: CropperRef = {
+ reset: () => {
+ const bounds = computeInitialBounds();
+ setRawBoundsAndAnimate(bounds);
+ setAspectState("snapped", null);
+ },
+ fill,
+ setCropProperty: (field, value) => {
+ setAspectState("snapped", null);
+ setRawBoundsConstraining(
+ boundsToRaw({ ...realBounds(), [field]: value }),
+ { x: 0, y: 0 },
+ );
+ },
+ setCrop: (value, origin) =>
+ setRawBoundsConstraining(
+ boundsToRaw(
+ typeof value === "function" ? value(rawBounds()) : value,
+ ),
+ origin,
+ ),
+ get bounds() {
+ return realBounds;
+ },
+ animateTo: (real, durationMs) =>
+ setRawBoundsAndAnimate(boundsToRaw(real), undefined, durationMs),
+ };
+
+ if (typeof props.ref === "function") props.ref(cropperRef);
+ else props.ref = cropperRef;
+ }
});
- function handleDragStart(event: MouseEvent) {
- if (gestureState.isTrackpadGesture) return; // Don't start drag if we're in a trackpad gesture
- event.stopPropagation();
- setDragging(true);
- let lastValidPos = { x: event.clientX, y: event.clientY };
- const box = Box.from(position(), size());
- const scaleFactors = containerToMappedSizeScale();
+ function onRegionPointerDown(e: PointerEvent) {
+ if (!containerRef || e.button !== 0) return;
+
+ stopAnimation();
+ e.stopPropagation();
+ setState({ cursorStyle: "grabbing", dragging: true });
+ let currentBounds = rawBounds();
+ const containerRect = containerRef.getBoundingClientRect();
+ const startOffset = {
+ x: e.clientX - containerRect.left - currentBounds.x,
+ y: e.clientY - containerRect.top - currentBounds.y,
+ };
- createRoot((dispose) => {
- const mapped = mappedSize();
+ createRoot((dispose) =>
createEventListenerMap(window, {
- mouseup: () => {
- setDragging(false);
+ pointerup: () => {
+ setState({ cursorStyle: null, dragging: false });
dispose();
},
- mousemove: (e) => {
- requestAnimationFrame(() => {
- const dx = (e.clientX - lastValidPos.x) / scaleFactors.x;
- const dy = (e.clientY - lastValidPos.y) / scaleFactors.y;
-
- box.move(
- clamp(box.x + dx, 0, mapped.x - box.width),
- clamp(box.y + dy, 0, mapped.y - box.height),
- );
-
- const newBox = box;
- if (newBox.x !== position().x || newBox.y !== position().y) {
- lastValidPos = { x: e.clientX, y: e.clientY };
- setCrop(newBox.toBounds());
- }
- });
+ pointermove: (e) => {
+ let newX = e.clientX - containerRect.left - startOffset.x;
+ let newY = e.clientY - containerRect.top - startOffset.y;
+
+ newX = clamp(newX, 0, containerRect.width - currentBounds.width);
+ newY = clamp(newY, 0, containerRect.height - currentBounds.height);
+
+ currentBounds = moveBounds(currentBounds, newX, newY);
+ setRawBounds(currentBounds);
+
+ if (!isAnimating()) setDisplayRawBounds(currentBounds);
},
- });
- });
+ }),
+ );
}
- function handleWheel(event: WheelEvent) {
- event.preventDefault();
- const box = Box.from(position(), size());
- const mapped = mappedSize();
+ // Helper: update handle movable sides when switching between anchor <-> center-origin mode
+ function updateHandleForModeSwitch(
+ handle: HandleSide,
+ currentBounds: CropBounds,
+ pointX: number,
+ pointY: number,
+ ) {
+ const center = {
+ x: currentBounds.x + currentBounds.width / 2,
+ y: currentBounds.y + currentBounds.height / 2,
+ };
+ const newMovable = { ...handle.movable };
+ if (handle.movable.left || handle.movable.right) {
+ newMovable.left = pointX < center.x;
+ newMovable.right = pointX >= center.x;
+ }
+ if (handle.movable.top || handle.movable.bottom) {
+ newMovable.top = pointY < center.y;
+ newMovable.bottom = pointY >= center.y;
+ }
+ return { ...handle, movable: newMovable };
+ }
- if (event.ctrlKey) {
- setGestureState("isTrackpadGesture", true);
+ type ResizeSessionState = {
+ startBounds: CropBounds;
+ isAltMode: boolean;
+ activeHandle: HandleSide;
+ originalHandle: HandleSide;
+ containerRect: DOMRect;
+ };
- const velocity = Math.max(0.001, Math.abs(event.deltaY) * 0.001);
- const scale = 1 - event.deltaY * velocity;
+ function handleResizePointerMove(
+ e: PointerEvent,
+ context: ResizeSessionState,
+ ) {
+ const pointX = e.clientX - context.containerRect.left;
+ const pointY = e.clientY - context.containerRect.top;
+
+ if (e.altKey !== context.isAltMode) {
+ context.isAltMode = e.altKey;
+ context.startBounds = rawBounds();
+ if (!context.isAltMode)
+ context.activeHandle = updateHandleForModeSwitch(
+ context.originalHandle,
+ context.startBounds,
+ pointX,
+ pointY,
+ );
+ else context.activeHandle = context.originalHandle;
+ }
- box.resize(
- clamp(box.width * scale, minSize().x, mapped.x),
- clamp(box.height * scale, minSize().y, mapped.y),
- ORIGIN_CENTER,
+ const { min, max } = rawSizeConstraint();
+ const shiftKey = e.shiftKey;
+ const ratioValue = aspectState.value;
+
+ const options: ResizeOptions = {
+ container: containerSize(),
+ min,
+ max,
+ isAltMode: context.isAltMode,
+ shiftKey,
+ ratioValue,
+ snapToRatioEnabled: !!props.snapToRatioEnabled && !boundsTooSmall(),
+ };
+
+ let nextBounds: CropBounds;
+
+ if (ratioValue !== null) {
+ nextBounds = computeAspectRatioResize(
+ pointX,
+ pointY,
+ context.startBounds,
+ context.activeHandle,
+ options,
);
- box.constrainAll(box, mapped, ORIGIN_CENTER, props.aspectRatio);
- setTimeout(() => setGestureState("isTrackpadGesture", false), 100);
- setSnappedRatio(null);
} else {
- const velocity = Math.max(1, Math.abs(event.deltaY) * 0.01);
- const scaleFactors = containerToMappedSizeScale();
- const dx = (-event.deltaX * velocity) / scaleFactors.x;
- const dy = (-event.deltaY * velocity) / scaleFactors.y;
-
- box.move(
- clamp(box.x + dx, 0, mapped.x - box.width),
- clamp(box.y + dy, 0, mapped.y - box.height),
+ const { bounds, snappedRatio } = computeFreeResize(
+ pointX,
+ pointY,
+ context.startBounds,
+ context.activeHandle,
+ options,
);
+ nextBounds = bounds;
+ if (snappedRatio && !aspectState.snapped) {
+ triggerHaptic();
+ }
+ setAspectState("snapped", snappedRatio);
}
- setCrop(box.toBounds());
- }
+ const finalBounds = slideBoundsIntoContainer(
+ nextBounds,
+ containerSize().x,
+ containerSize().y,
+ );
- function handleTouchStart(event: TouchEvent) {
- if (event.touches.length === 2) {
- // Initialize pinch zoom
- const distance = distanceOf(event.touches[0], event.touches[1]);
-
- // Initialize touch center
- const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
- const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
-
- batch(() => {
- setGestureState("initialPinchDistance", distance);
- setGestureState("initialSize", {
- width: size().x,
- height: size().y,
- });
- setGestureState("lastTouchCenter", { x: centerX, y: centerY });
- });
- } else if (event.touches.length === 1) {
- // Handle single touch as drag
- batch(() => {
- setDragging(true);
- setGestureState("lastTouchCenter", {
- x: event.touches[0].clientX,
- y: event.touches[0].clientY,
- });
- });
- }
+ setRawBounds(finalBounds);
+ if (!isAnimating()) setDisplayRawBounds(finalBounds);
}
- function handleTouchMove(event: TouchEvent) {
- if (event.touches.length === 2) {
- // Handle pinch zoom
- const currentDistance = distanceOf(event.touches[0], event.touches[1]);
- const scale = currentDistance / gestureState.initialPinchDistance;
-
- const box = Box.from(position(), size());
- const mapped = mappedSize();
-
- // Calculate new dimensions while maintaining aspect ratio
- const currentRatio = size().x / size().y;
- let newWidth = clamp(
- gestureState.initialSize.width * scale,
- minSize().x,
- mapped.x,
- );
- let newHeight = newWidth / currentRatio;
+ function onHandlePointerDown(handle: HandleSide, e: PointerEvent) {
+ if (!containerRef || e.button !== 0) return;
+ e.stopPropagation();
- // Adjust if height exceeds bounds
- if (newHeight < minSize().y || newHeight > mapped.y) {
- newHeight = clamp(newHeight, minSize().y, mapped.y);
- newWidth = newHeight * currentRatio;
- }
+ stopAnimation();
+ setState({ cursorStyle: handle.cursor, resizing: true });
- // Resize from center
- box.resize(newWidth, newHeight, ORIGIN_CENTER);
-
- // Handle two-finger pan
- const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
- const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;
+ const context: ResizeSessionState = {
+ containerRect: containerRef.getBoundingClientRect(),
+ startBounds: rawBounds(),
+ isAltMode: e.altKey,
+ activeHandle: { ...handle },
+ originalHandle: handle,
+ };
- if (gestureState.lastTouchCenter) {
- const scaleFactors = containerToMappedSizeScale();
- const dx = (centerX - gestureState.lastTouchCenter.x) / scaleFactors.x;
- const dy = (centerY - gestureState.lastTouchCenter.y) / scaleFactors.y;
+ createRoot((dispose) =>
+ createEventListenerMap(window, {
+ pointerup: () => {
+ setState({ cursorStyle: null, resizing: false });
+ // Note: may need to be added back
+ // setAspectState("snapped", null);
+ dispose();
+ },
+ pointermove: (e) => handleResizePointerMove(e, context),
+ }),
+ );
+ }
- box.move(
- clamp(box.x + dx, 0, mapped.x - box.width),
- clamp(box.y + dy, 0, mapped.y - box.height),
- );
- }
+ function onHandleDoubleClick(handle: HandleSide, e: MouseEvent) {
+ e.stopPropagation();
+ const currentBounds = rawBounds();
+ const container = containerSize();
- setGestureState("lastTouchCenter", { x: centerX, y: centerY });
- setCrop(box.toBounds());
- } else if (event.touches.length === 1 && dragging()) {
- // Handle single touch drag
- const box = Box.from(position(), size());
- const scaleFactors = containerToMappedSizeScale();
- const mapped = mappedSize();
-
- if (gestureState.lastTouchCenter) {
- const dx =
- (event.touches[0].clientX - gestureState.lastTouchCenter.x) /
- scaleFactors.x;
- const dy =
- (event.touches[0].clientY - gestureState.lastTouchCenter.y) /
- scaleFactors.y;
-
- box.move(
- clamp(box.x + dx, 0, mapped.x - box.width),
- clamp(box.y + dy, 0, mapped.y - box.height),
- );
- }
+ const newBounds = { ...currentBounds };
- setGestureState("lastTouchCenter", {
- x: event.touches[0].clientX,
- y: event.touches[0].clientY,
- });
- setCrop(box.toBounds());
+ if (handle.movable.top) {
+ newBounds.height = currentBounds.y + currentBounds.height;
+ newBounds.y = 0;
}
- }
-
- function handleTouchEnd(event: TouchEvent) {
- if (event.touches.length === 0) {
- setDragging(false);
- setGestureState("lastTouchCenter", null);
- } else if (event.touches.length === 1) {
- setGestureState("lastTouchCenter", {
- x: event.touches[0].clientX,
- y: event.touches[0].clientY,
- });
+ if (handle.movable.bottom) {
+ newBounds.height = container.y - currentBounds.y;
+ }
+ if (handle.movable.left) {
+ newBounds.width = currentBounds.x + currentBounds.width;
+ newBounds.x = 0;
+ }
+ if (handle.movable.right) {
+ newBounds.width = container.x - currentBounds.x;
}
- }
- function handleResizeStartTouch(event: TouchEvent, dir: Direction) {
- if (event.touches.length !== 1) return;
- event.stopPropagation();
- const touch = event.touches[0];
- handleResizeStart(touch.clientX, touch.clientY, dir);
+ setRawBoundsAndAnimate(newBounds, handle.origin);
}
- function findClosestRatio(
- width: number,
- height: number,
- threshold = 0.01,
- ): Ratio | null {
- if (props.aspectRatio) return null;
- const currentRatio = width / height;
- for (const ratio of COMMON_RATIOS) {
- if (Math.abs(currentRatio - ratio[0] / ratio[1]) < threshold) {
- return [ratio[0], ratio[1]];
- }
- if (Math.abs(currentRatio - ratio[1] / ratio[0]) < threshold) {
- return [ratio[1], ratio[0]];
- }
- }
- return null;
- }
+ function onOverlayPointerDown(e: PointerEvent) {
+ if (!containerRef || e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+
+ const initialBounds = { ...rawBounds() };
+ const SE_HANDLE_INDEX = 3; // use bottom-right as the temporary handle
+ const handle = HANDLES[SE_HANDLE_INDEX];
+
+ setState({
+ cursorStyle: "crosshair",
+ overlayDragging: true,
+ resizing: true,
+ });
+
+ const containerRect = containerRef.getBoundingClientRect();
+ const startPoint = {
+ x: e.clientX - containerRect.left,
+ y: e.clientY - containerRect.top,
+ };
- function handleResizeStart(clientX: number, clientY: number, dir: Direction) {
- const origin: XY = {
- x: dir.includes("w") ? 1 : 0,
- y: dir.includes("n") ? 1 : 0,
+ const startBounds: CropBounds = {
+ x: startPoint.x,
+ y: startPoint.y,
+ width: 1,
+ height: 1,
};
- let lastValidPos = { x: clientX, y: clientY };
- const box = Box.from(position(), size());
- const scaleFactors = containerToMappedSizeScale();
- const mapped = mappedSize();
+ const context: ResizeSessionState = {
+ containerRect,
+ startBounds,
+ isAltMode: e.altKey,
+ activeHandle: { ...handle },
+ originalHandle: handle,
+ };
createRoot((dispose) => {
createEventListenerMap(window, {
- mouseup: dispose,
- touchend: dispose,
- touchmove: (e) =>
- requestAnimationFrame(() => {
- if (e.touches.length !== 1) return;
- handleResizeMove(e.touches[0].clientX, e.touches[0].clientY);
- }),
- mousemove: (e) =>
- requestAnimationFrame(() =>
- handleResizeMove(e.clientX, e.clientY, e.altKey),
- ),
+ pointerup: () => {
+ setState({
+ cursorStyle: null,
+ overlayDragging: false,
+ resizing: false,
+ });
+ const bounds = rawBounds();
+ if (bounds.width < 5 || bounds.height < 5) {
+ setRawBounds(initialBounds);
+ if (!isAnimating()) setDisplayRawBounds(initialBounds);
+ }
+ dispose();
+ },
+ pointermove: (e) => handleResizePointerMove(e, context),
});
});
+ }
- const [hapticsEnabled, hapticsEnabledOptions] = createResource(
- async () =>
- (await generalSettingsStore.get())?.hapticsEnabled &&
- ostype() === "macos",
- );
- generalSettingsStore.listen(() => hapticsEnabledOptions.refetch());
+ const KEY_MAPPINGS = new Map([
+ ["ArrowRight", "e"],
+ ["ArrowDown", "s"],
+ ["ArrowLeft", "w"],
+ ["ArrowUp", "n"],
+ ]);
+
+ const [keyboardState, setKeyboardState] = createStore({
+ pressedKeys: new Set(),
+ shift: false,
+ alt: false,
+ meta: false, // Cmd or Ctrl
+ });
- function handleResizeMove(
- moveX: number,
- moveY: number,
- centerOrigin = false,
- ) {
- const dx = (moveX - lastValidPos.x) / scaleFactors.x;
- const dy = (moveY - lastValidPos.y) / scaleFactors.y;
-
- const scaleMultiplier = centerOrigin ? 2 : 1;
- const currentBox = box.toBounds();
-
- let newWidth =
- dir.includes("e") || dir.includes("w")
- ? clamp(
- dir.includes("w")
- ? currentBox.size.x - dx * scaleMultiplier
- : currentBox.size.x + dx * scaleMultiplier,
- minSize().x,
- mapped.x,
- )
- : currentBox.size.x;
-
- let newHeight =
- dir.includes("n") || dir.includes("s")
- ? clamp(
- dir.includes("n")
- ? currentBox.size.y - dy * scaleMultiplier
- : currentBox.size.y + dy * scaleMultiplier,
- minSize().y,
- mapped.y,
- )
- : currentBox.size.y;
-
- const closest = findClosestRatio(newWidth, newHeight);
- if (dir.length === 2 && snapToRatioEnabled() && closest) {
- const ratio = closest[0] / closest[1];
- if (dir.includes("n") || dir.includes("s")) {
- newWidth = newHeight * ratio;
- } else {
- newHeight = newWidth / ratio;
- }
- if (!snappedRatio() && hapticsEnabled()) {
- commands.performHapticFeedback("Alignment", "Now");
- }
- setSnappedRatio(closest);
- } else {
- setSnappedRatio(null);
- }
+ let keyboardFrameId: number | null = null;
- const newOrigin = centerOrigin ? ORIGIN_CENTER : origin;
- box.resize(newWidth, newHeight, newOrigin);
+ function keyboardActionLoop() {
+ const currentBounds = rawBounds();
+ const { pressedKeys, shift, alt, meta } = keyboardState;
- if (props.aspectRatio) {
- box.constrainToRatio(
- props.aspectRatio,
- newOrigin,
- dir.includes("n") || dir.includes("s") ? "width" : "height",
- );
- }
- box.constrainToBoundary(mapped.x, mapped.y, newOrigin);
-
- const newBox = box.toBounds();
- if (
- newBox.size.x !== size().x ||
- newBox.size.y !== size().y ||
- newBox.position.x !== position().x ||
- newBox.position.y !== position().y
- ) {
- lastValidPos = { x: moveX, y: moveY };
- props.onCropChange(newBox);
- }
+ const delta = shift ? 10 : 2;
+
+ if (meta) {
+ // Resize
+ const origin = alt ? ORIGIN_CENTER : { x: 0, y: 0 };
+ let newWidth = currentBounds.width;
+ let newHeight = currentBounds.height;
+
+ if (pressedKeys.has("ArrowLeft")) newWidth -= delta;
+ if (pressedKeys.has("ArrowRight")) newWidth += delta;
+ if (pressedKeys.has("ArrowUp")) newHeight -= delta;
+ if (pressedKeys.has("ArrowDown")) newHeight += delta;
+
+ newWidth = Math.max(1, newWidth);
+ newHeight = Math.max(1, newHeight);
+
+ const resized = resizeBounds(currentBounds, newWidth, newHeight, origin);
+
+ setRawBoundsConstraining(resized, origin);
+ } else {
+ // Move
+ let dx = 0;
+ let dy = 0;
+
+ if (pressedKeys.has("ArrowLeft")) dx -= delta;
+ if (pressedKeys.has("ArrowRight")) dx += delta;
+ if (pressedKeys.has("ArrowUp")) dy -= delta;
+ if (pressedKeys.has("ArrowDown")) dy += delta;
+
+ const moved = moveBounds(
+ currentBounds,
+ currentBounds.x + dx,
+ currentBounds.y + dy,
+ );
+
+ setRawBoundsConstraining(moved);
}
- }
- function setCrop(value: Crop) {
- props.onCropChange(value);
+ keyboardFrameId = requestAnimationFrame(keyboardActionLoop);
}
- const pressedKeys = new Set([]);
- let lastKeyHandleFrame: number | null = null;
- function handleKeyDown(event: KeyboardEvent) {
- if (dragging()) return;
- const dir = KEY_MAPPINGS.get(event.key);
- if (!dir) return;
- event.preventDefault();
- pressedKeys.add(event.key);
-
- if (lastKeyHandleFrame) return;
- lastKeyHandleFrame = requestAnimationFrame(() => {
- const box = Box.from(position(), size());
- const mapped = mappedSize();
- const scaleFactors = containerToMappedSizeScale();
-
- const moveDelta = event.shiftKey ? 20 : 5;
- const origin = event.altKey ? ORIGIN_CENTER : { x: 0, y: 0 };
-
- for (const key of pressedKeys) {
- const dir = KEY_MAPPINGS.get(key);
- if (!dir) continue;
-
- const isUpKey = dir === "n";
- const isLeftKey = dir === "w";
- const isDownKey = dir === "s";
- const isRightKey = dir === "e";
-
- if (event.metaKey || event.ctrlKey) {
- const scaleMultiplier = event.altKey ? 2 : 1;
- const currentBox = box.toBounds();
-
- let newWidth = currentBox.size.x;
- let newHeight = currentBox.size.y;
-
- if (isLeftKey || isRightKey) {
- newWidth = clamp(
- isLeftKey
- ? currentBox.size.x - moveDelta * scaleMultiplier
- : currentBox.size.x + moveDelta * scaleMultiplier,
- minSize().x,
- mapped.x,
- );
- }
+ function handleKeyDown(e: KeyboardEvent) {
+ if (!KEY_MAPPINGS.has(e.key) || state.dragging || state.resizing) return;
- if (isUpKey || isDownKey) {
- newHeight = clamp(
- isUpKey
- ? currentBox.size.y - moveDelta * scaleMultiplier
- : currentBox.size.y + moveDelta * scaleMultiplier,
- minSize().y,
- mapped.y,
- );
- }
+ e.preventDefault();
+ e.stopPropagation();
- box.resize(newWidth, newHeight, origin);
- } else {
- const dx =
- (isRightKey ? moveDelta : isLeftKey ? -moveDelta : 0) /
- scaleFactors.x;
- const dy =
- (isDownKey ? moveDelta : isUpKey ? -moveDelta : 0) / scaleFactors.y;
-
- box.move(
- clamp(box.x + dx, 0, mapped.x - box.width),
- clamp(box.y + dy, 0, mapped.y - box.height),
- );
- }
- }
+ setKeyboardState("pressedKeys", (p) => p.add(e.key));
+ setKeyboardState({
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
+ });
+
+ if (!keyboardFrameId) {
+ stopAnimation();
+ keyboardActionLoop();
+ }
+ }
- if (props.aspectRatio) box.constrainToRatio(props.aspectRatio, origin);
- box.constrainToBoundary(mapped.x, mapped.y, origin);
- setCrop(box.toBounds());
+ function handleKeyUp(e: KeyboardEvent) {
+ if (
+ !KEY_MAPPINGS.has(e.key) &&
+ !["Shift", "Alt", "Meta", "Control"].includes(e.key)
+ )
+ return;
- pressedKeys.clear();
- lastKeyHandleFrame = null;
+ e.preventDefault();
+ e.stopPropagation();
+
+ setKeyboardState("pressedKeys", (p) => {
+ p.delete(e.key);
+ return p;
+ });
+
+ setKeyboardState({
+ shift: e.shiftKey,
+ alt: e.altKey,
+ meta: e.metaKey || e.ctrlKey,
});
+
+ if (keyboardState.pressedKeys.size === 0) {
+ if (keyboardFrameId) {
+ cancelAnimationFrame(keyboardFrameId);
+ keyboardFrameId = null;
+ }
+ }
}
+ // Only update during a frame animation.
+ // Note: Doing this any other way can very likely cause a huge memory usage or even leak until the resizing stops.
+ createEffect(
+ on(displayRawBounds, (b, _prevIn, prevFrameId) => {
+ if (prevFrameId) cancelAnimationFrame(prevFrameId);
+ return requestAnimationFrame(() => {
+ if (regionRef) {
+ regionRef.style.width = `${Math.round(b.width)}px`;
+ regionRef.style.height = `${Math.round(b.height)}px`;
+ regionRef.style.transform = `translate(${Math.round(b.x)}px,${Math.round(b.y)}px)`;
+ }
+ if (occLeftRef) {
+ occLeftRef.style.width = `${Math.max(0, Math.round(b.x))}px`;
+ }
+ if (occRightRef) {
+ occRightRef.style.left = `${Math.round(b.x + b.width)}px`;
+ }
+ if (occTopRef) {
+ occTopRef.style.left = `${Math.round(b.x)}px`;
+ occTopRef.style.width = `${Math.round(b.width)}px`;
+ occTopRef.style.height = `${Math.max(0, Math.round(b.y))}px`;
+ }
+ if (occBottomRef) {
+ occBottomRef.style.top = `${Math.round(b.y + b.height)}px`;
+ occBottomRef.style.left = `${Math.round(b.x)}px`;
+ occBottomRef.style.width = `${Math.round(b.width)}px`;
+ }
+ });
+ }),
+ );
+
return (
{
- // e.preventDefault();
- // const menu = await Menu.new({
- // id: "crop-options",
- // items: [
- // {
- // id: "enableRatioSnap",
- // text: "Snap to aspect ratios",
- // checked: snapToRatioEnabled(),
- // action: () => {
- // setSnapToRatioEnabled((v) => !v);
- // },
- // } satisfies CheckMenuItemOptions,
- // ],
- // });
- // menu.popup();
- // }}
+ onContextMenu={props.onContextMenu}
+ onDblClick={() => fill()}
>
-
- {props.children}
-
+
+ {(transform) => (
+
+ {realBounds().width} x {realBounds().height}
+
+ )}
+
+
+
+ {resolvedChildren()}
+
+ {/* Occluder */}
-
+
+ {/* Crop region container */}
+
+
e.stopPropagation()}
+ >
+
+
{
- const animation = el.animate(
- [
- { opacity: 0, transform: "translateY(-8px)" },
- { opacity: 0.65, transform: "translateY(0)" },
- ],
- {
- duration: 100,
- easing: "ease-out",
- },
- );
- animation.finished.then(done);
- }}
- onExit={(el, done) => {
- const animation = el.animate(
- [
- { opacity: 0.65, transform: "translateY(0)" },
- { opacity: 0, transform: "translateY(-8px)" },
- ],
- {
- duration: 100,
- easing: "ease-in",
- },
- );
- animation.finished.then(done);
- }}
+ appear
+ enterClass="opacity-0"
+ enterActiveClass="transition-opacity duration-300"
+ enterToClass="opacity-100"
+ exitClass="opacity-100"
+ exitActiveClass="transition-opacity duration-300"
+ exitToClass="opacity-0"
>
-
-
- {snappedRatio()![0]}:{snappedRatio()![1]}
+
+
-
-
- {(handle) => {
- const isCorner = handle.x !== "c" && handle.y !== "c";
- return isCorner ? (
- {
- e.stopPropagation();
- handleResizeStart(e.clientX, e.clientY, handle.direction);
- }}
- onTouchStart={(e) =>
- handleResizeStartTouch(e, handle.direction)
- }
- />
- ) : (
-
+ {(handle) =>
+ handle.isCorner ? (
+
+ ) : (
+
+
+
);
}
+
+type ResizeOptions = {
+ container: Vec2;
+ min: Vec2 | null;
+ max: Vec2 | null;
+ isAltMode: boolean;
+ shiftKey: boolean;
+ ratioValue: number | null;
+ snapToRatioEnabled: boolean;
+};
+
+function computeAspectRatioResize(
+ pointX: number,
+ pointY: number,
+ startBounds: CropBounds,
+ handle: HandleSide,
+ options: ResizeOptions,
+): CropBounds {
+ const { container, min, max, ratioValue } = options;
+ if (ratioValue === null) return startBounds;
+
+ // Determine the stationary anchor point.
+ const anchorX = startBounds.x + (handle.movable.left ? startBounds.width : 0);
+ const anchorY = startBounds.y + (handle.movable.top ? startBounds.height : 0);
+
+ // Calculate raw dimensions from anchor to the clamped mouse position
+ const mX = clamp(pointX, 0, container.x);
+ const mY = clamp(pointY, 0, container.y);
+ const rawWidth = Math.abs(mX - anchorX);
+ const rawHeight = Math.abs(mY - anchorY);
+
+ // Determine the "ideal" size by respecting the aspect ratio based on the dominant mouse movement
+ let targetW: number;
+ let targetH: number;
+
+ if (handle.isCorner) {
+ // For corners, let the dominant mouse direction drive the aspect ratio
+ if (rawWidth / ratioValue > rawHeight) {
+ targetW = rawWidth;
+ targetH = targetW / ratioValue;
+ } else {
+ targetH = rawHeight;
+ targetW = targetH * ratioValue;
+ }
+ } else if (handle.x !== "c") {
+ targetW = rawWidth;
+ targetH = targetW / ratioValue;
+ } else {
+ targetH = rawHeight;
+ targetW = targetH * ratioValue;
+ }
+
+ const newX = mX < anchorX ? anchorX - targetW : anchorX;
+ const newY = mY < anchorY ? anchorY - targetH : anchorY;
+ let finalBounds = { x: newX, y: newY, width: targetW, height: targetH };
+
+ const resizeOrigin = { x: mX < anchorX ? 1 : 0, y: mY < anchorY ? 1 : 0 };
+ finalBounds = constrainBoundsToSize(
+ finalBounds,
+ max,
+ min,
+ resizeOrigin,
+ ratioValue,
+ );
+
+ if (finalBounds.width > container.x) {
+ const scale = container.x / finalBounds.width;
+ finalBounds.width = container.x;
+ finalBounds.height *= scale;
+ }
+ if (finalBounds.height > container.y) {
+ const scale = container.y / finalBounds.height;
+ finalBounds.height = container.y;
+ finalBounds.width *= scale;
+ }
+
+ finalBounds = slideBoundsIntoContainer(finalBounds, container.x, container.y);
+
+ return {
+ x: Math.round(finalBounds.x),
+ y: Math.round(finalBounds.y),
+ width: Math.round(Math.max(1, finalBounds.width)),
+ height: Math.round(Math.max(1, finalBounds.height)),
+ };
+}
+
+function computeFreeResize(
+ pointX: number,
+ pointY: number,
+ startBounds: CropBounds,
+ handle: HandleSide,
+ options: ResizeOptions,
+): { bounds: CropBounds; snappedRatio: Ratio | null } {
+ const { container, min, max, isAltMode, shiftKey, snapToRatioEnabled } =
+ options;
+ let snappedRatio: Ratio | null = null;
+
+ let bounds: CropBounds;
+
+ if (isAltMode) {
+ const center = {
+ x: startBounds.x + startBounds.width / 2,
+ y: startBounds.y + startBounds.height / 2,
+ };
+
+ const distW = Math.abs(pointX - center.x);
+ const distH = Math.abs(pointY - center.y);
+
+ const expLeft = Math.min(distW, center.x);
+ const expRight = Math.min(distW, container.x - center.x);
+ const expTop = Math.min(distH, center.y);
+ const expBottom = Math.min(distH, container.y - center.y);
+
+ let newW = expLeft + expRight;
+ let newH = expTop + expBottom;
+
+ if (min) {
+ newW = Math.max(newW, min.x);
+ newH = Math.max(newH, min.y);
+ }
+ if (max) {
+ newW = Math.min(newW, max.x);
+ newH = Math.min(newH, max.y);
+ }
+
+ if (!shiftKey && handle.isCorner && snapToRatioEnabled) {
+ const closest = findClosestRatio(newW, newH);
+ if (closest) {
+ const r = ratioToValue(closest);
+ if (handle.movable.top || handle.movable.bottom) newW = newH * r;
+ else newH = newW / r;
+ snappedRatio = closest;
+ }
+ }
+
+ bounds = {
+ x: Math.round(center.x - newW / 2),
+ y: Math.round(center.y - newH / 2),
+ width: Math.round(newW),
+ height: Math.round(newH),
+ };
+ } else {
+ const anchor = {
+ x: startBounds.x + (handle.movable.left ? startBounds.width : 0),
+ y: startBounds.y + (handle.movable.top ? startBounds.height : 0),
+ };
+ const clampedX = clamp(pointX, 0, container.x);
+ const clampedY = clamp(pointY, 0, container.y);
+
+ let x1 =
+ handle.movable.left || handle.movable.right ? clampedX : startBounds.x;
+ let y1 =
+ handle.movable.top || handle.movable.bottom ? clampedY : startBounds.y;
+ let x2 = anchor.x;
+ let y2 = anchor.y;
+
+ if (!handle.movable.left && !handle.movable.right) {
+ x1 = startBounds.x;
+ x2 = startBounds.x + startBounds.width;
+ }
+ if (!handle.movable.top && !handle.movable.bottom) {
+ y1 = startBounds.y;
+ y2 = startBounds.y + startBounds.height;
+ }
+
+ let newX = Math.min(x1, x2);
+ let newY = Math.min(y1, y2);
+ let newW = Math.abs(x1 - x2);
+ let newH = Math.abs(y1 - y2);
+
+ if (min) {
+ if (newW < min.x) {
+ const diff = min.x - newW;
+ newW = min.x;
+ if (clampedX < anchor.x) newX -= diff;
+ }
+ if (newH < min.y) {
+ const diff = min.y - newH;
+ newH = min.y;
+ if (clampedY < anchor.y) newY -= diff;
+ }
+ }
+ if (max) {
+ if (newW > max.x) {
+ const diff = newW - max.x;
+ newW = max.x;
+ if (clampedX < anchor.x) newX += diff;
+ }
+ if (newH > max.y) {
+ const diff = newH - max.y;
+ newH = max.y;
+ if (clampedY < anchor.y) newY += diff;
+ }
+ }
+
+ if (!shiftKey && handle.isCorner && snapToRatioEnabled) {
+ const closest = findClosestRatio(newW, newH);
+ if (closest) {
+ const r = ratioToValue(closest);
+ if (handle.movable.top || handle.movable.bottom) newW = newH * r;
+ else newH = newW / r;
+ if (clampedX < anchor.x) newX = anchor.x - newW;
+ if (clampedY < anchor.y) newY = anchor.y - newH;
+ snappedRatio = closest;
+ }
+ }
+
+ bounds = {
+ x: Math.round(newX),
+ y: Math.round(newY),
+ width: Math.round(newW),
+ height: Math.round(newH),
+ };
+ }
+ return { bounds, snappedRatio };
+}
+
+import type {
+ CheckMenuItemOptions,
+ PredefinedMenuItemOptions,
+} from "@tauri-apps/api/menu";
+
+export function createCropOptionsMenuItems(options: {
+ aspect: Ratio | null;
+ snapToRatioEnabled: boolean;
+ onAspectSet: (aspect: Ratio | null) => void;
+ onSnapToRatioSet: (enabled: boolean) => void;
+}) {
+ return [
+ {
+ text: "Free",
+ checked: !options.aspect,
+ action: () => options.onAspectSet(null),
+ } satisfies CheckMenuItemOptions,
+ ...COMMON_RATIOS.map(
+ (ratio) =>
+ ({
+ text: `${ratio[0]}:${ratio[1]}`,
+ checked: options.aspect === ratio,
+ action: () => options.onAspectSet(ratio),
+ }) satisfies CheckMenuItemOptions,
+ ),
+ { item: "Separator" } satisfies PredefinedMenuItemOptions,
+ {
+ text: "Snap to ratios",
+ checked: options.snapToRatioEnabled,
+ action: () => options.onSnapToRatioSet(!options.snapToRatioEnabled),
+ } satisfies CheckMenuItemOptions,
+ ];
+}
diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx
index 5a98956cc4..3fce9f4b55 100644
--- a/apps/desktop/src/routes/(window-chrome)/(main).tsx
+++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx
@@ -665,7 +665,7 @@ function AreaSelectButton(props: {
onMount(async () => {
const unlistenCaptureAreaWindow =
await getCurrentWebviewWindow().listen
(
- "cap-window://capture-area/state/pending",
+ "setCaptureAreaPending",
(event) => setAreaSelection("pending", event.payload),
);
onCleanup(unlistenCaptureAreaWindow);
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
index c1b1475165..d707595833 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
@@ -407,12 +407,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
handleChange("enableNotifications", value);
}}
/>
- handleChange("hapticsEnabled", v)}
- />
)}
diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx
index 5a00adda96..cdf89cb6a5 100644
--- a/apps/desktop/src/routes/capture-area.tsx
+++ b/apps/desktop/src/routes/capture-area.tsx
@@ -1,78 +1,109 @@
-import { Tooltip } from "@kobalte/core";
-import { createEventListenerMap } from "@solid-primitives/event-listener";
+import { createEventListener } from "@solid-primitives/event-listener";
+import { createScheduled, debounce } from "@solid-primitives/scheduled";
import { makePersisted } from "@solid-primitives/storage";
+import { LogicalPosition } from "@tauri-apps/api/dpi";
+import { Menu } from "@tauri-apps/api/menu";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
-import { createSignal, onCleanup, onMount, Show } from "solid-js";
+import { type as ostype } from "@tauri-apps/plugin-os";
+import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { Transition } from "solid-transition-group";
-import Cropper from "~/components/Cropper";
+import Cropper, {
+ CROP_ZERO,
+ type CropBounds,
+ type CropperRef,
+ createCropOptionsMenuItems,
+ type Ratio,
+} from "~/components/Cropper";
import { createOptionsQuery } from "~/utils/queries";
-import type { Crop } from "~/utils/tauri";
+import type { DisplayId } from "~/utils/tauri";
+
+const MIN_SIZE = { width: 150, height: 150 };
export default function CaptureArea() {
- const { rawOptions, setOptions } = createOptionsQuery();
const webview = getCurrentWebviewWindow();
- const [state, setState] = makePersisted(
- createStore({
- showGrid: true,
- }),
- { name: "captureArea" },
- );
-
const setPendingState = (pending: boolean) =>
- webview.emitTo("main", "cap-window://capture-area/state/pending", pending);
+ webview.emitTo("main", "setCaptureAreaPending", pending);
- let unlisten: () => void | undefined;
onMount(async () => {
setPendingState(true);
- unlisten = await webview.onCloseRequested(() => setPendingState(false));
+ const unlisten = await webview.onCloseRequested(() =>
+ setPendingState(false),
+ );
+ onCleanup(() => unlisten());
});
- onCleanup(() => unlisten?.());
- const [windowSize, setWindowSize] = createSignal({
- x: window.innerWidth,
- y: window.innerHeight,
- });
+ const [state, setState] = makePersisted(
+ createStore<{
+ snapToRatio: boolean;
+ lastSelectedBounds: { screenId: DisplayId; bounds: CropBounds }[];
+ }>({ snapToRatio: true, lastSelectedBounds: [] }),
+ {
+ name: "capture-area",
+ },
+ );
- onMount(() => {
- createEventListenerMap(window, {
- resize: () =>
- setWindowSize({ x: window.innerWidth, y: window.innerHeight }),
- keydown: (e) => {
- if (e.key === "Escape") close();
- else if (e.key === "Enter") handleConfirm();
- },
- });
+ createEventListener(window, "keydown", (e) => {
+ if (e.key === "Escape") close();
+ else if (e.key === "Enter") handleConfirm();
});
- const [crop, setCrop] = createStore({
- size: { x: 0, y: 0 },
- position: { x: 0, y: 0 },
+ let cropperRef: CropperRef | undefined;
+
+ const [crop, setCrop] = createSignal(CROP_ZERO);
+
+ const scheduled = createScheduled((fn) => debounce(fn, 50));
+
+ const isValid = createMemo((p: boolean = true) => {
+ const b = crop();
+ return scheduled()
+ ? b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height
+ : p;
});
+ const { rawOptions, setOptions } = createOptionsQuery();
+
async function handleConfirm() {
+ const currentBounds = cropperRef?.bounds();
+ if (!currentBounds) throw new Error("Cropper not initialized");
+ if (
+ currentBounds.width < MIN_SIZE.width ||
+ currentBounds.height < MIN_SIZE.height
+ )
+ return;
+
const target = rawOptions.captureTarget;
if (target.variant !== "display") return;
- setPendingState(false);
+ const existingIndex = state.lastSelectedBounds?.findIndex(
+ (item) => item.screenId === target.id,
+ );
+
+ if (existingIndex >= 0) {
+ setState("lastSelectedBounds", existingIndex, {
+ screenId: target.id,
+ bounds: currentBounds,
+ });
+ } else {
+ setState("lastSelectedBounds", [
+ ...state.lastSelectedBounds,
+ { screenId: target.id, bounds: currentBounds },
+ ]);
+ }
+
+ const b = crop();
setOptions(
"captureTarget",
reconcile({
variant: "area",
screen: target.id,
bounds: {
- position: {
- x: crop.position.x,
- y: crop.position.y,
- },
- size: {
- width: crop.size.x,
- height: crop.size.y,
- },
+ position: { x: b.x, y: b.y },
+ size: { width: b.width, height: b.height },
},
}),
);
@@ -90,55 +121,131 @@ export default function CaptureArea() {
}, 250);
}
+ const [aspect, setAspect] = createSignal(null);
+
+ function reset() {
+ cropperRef?.reset();
+ setAspect(null);
+
+ const target = rawOptions.captureTarget;
+ if (target.variant !== "display") return;
+ setState("lastSelectedBounds", (values) =>
+ values?.filter((v) => v.screenId !== target.id),
+ );
+ }
+
+ async function showCropOptionsMenu(e: UIEvent, positionAtCursor = false) {
+ e.preventDefault();
+ const items = createCropOptionsMenuItems({
+ aspect: aspect(),
+ snapToRatioEnabled: state.snapToRatio,
+ onAspectSet: setAspect,
+ onSnapToRatioSet: (enabled) => setState("snapToRatio", enabled),
+ });
+ const menu = await Menu.new({ items });
+ let pos: LogicalPosition | undefined;
+ if (!positionAtCursor) {
+ const rect = (e.target as HTMLDivElement).getBoundingClientRect();
+ pos = new LogicalPosition(rect.x, rect.y + 50);
+ }
+ await menu.popup(pos);
+ await menu.close();
+ }
+
return (
-
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
- Rule of Thirds
-
-
-
-
-
-
+
@@ -147,16 +254,25 @@ export default function CaptureArea() {
{
+ const target = rawOptions.captureTarget;
+ if (target.variant === "display")
+ return state.lastSelectedBounds?.find(
+ (m) => m.screenId === target.id,
+ )?.bounds;
+ else return undefined;
+ }}
+ onContextMenu={(e) => showCropOptionsMenu(e, true)}
/>
diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx
index a4e4facfe8..7020ceea33 100644
--- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx
+++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx
@@ -942,9 +942,7 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
},
};
- const generalSettings = generalSettingsStore.createQuery();
- const hapticsEnabled = () =>
- generalSettings.data?.hapticsEnabled && ostype() === "macos";
+ const hapticsEnabled = ostype() === "macos";
return (
@@ -1470,14 +1468,14 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
if (
!moveEvent.shiftKey &&
- hapticsEnabled() &&
+ hapticsEnabled &&
project.background.source.type ===
"gradient" &&
project.background.source.angle !== newAngle
) {
commands.performHapticFeedback(
- "Alignment",
- "Now",
+ "alignment",
+ "now",
);
}
diff --git a/apps/desktop/src/routes/editor/Editor.tsx b/apps/desktop/src/routes/editor/Editor.tsx
index 6bdf7466d9..807ac472a1 100644
--- a/apps/desktop/src/routes/editor/Editor.tsx
+++ b/apps/desktop/src/routes/editor/Editor.tsx
@@ -1,10 +1,12 @@
import { Button } from "@cap/ui-solid";
+import { NumberField } from "@kobalte/core/number-field";
import { trackDeep } from "@solid-primitives/deep";
import { throttle } from "@solid-primitives/scheduled";
import { makePersisted } from "@solid-primitives/storage";
import { createMutation } from "@tanstack/solid-query";
import { convertFileSrc } from "@tauri-apps/api/core";
-import { cx } from "cva";
+import { LogicalPosition } from "@tauri-apps/api/dpi";
+import { Menu } from "@tauri-apps/api/menu";
import {
createEffect,
createMemo,
@@ -15,12 +17,18 @@ import {
Switch,
} from "solid-js";
import { createStore } from "solid-js/store";
-
-import Cropper, { cropToFloor } from "~/components/Cropper";
+import { Transition } from "solid-transition-group";
+import Cropper, {
+ CROP_ZERO,
+ type CropBounds,
+ type CropperRef,
+ createCropOptionsMenuItems,
+ type Ratio,
+} from "~/components/Cropper";
import { Toggle } from "~/components/Toggle";
-import Tooltip from "~/components/Tooltip";
+import { composeEventHandlers } from "~/utils/composeEventHandlers";
import { createTauriEventListener } from "~/utils/createEventListener";
-import { type Crop, events } from "~/utils/tauri";
+import { events } from "~/utils/tauri";
import { ConfigSidebar } from "./ConfigSidebar";
import {
EditorContextProvider,
@@ -293,20 +301,75 @@ function Dialogs() {
{(dialog) => {
const { setProject: setState, editorInstance } =
useEditorContext();
- const [crop, setCrop] = createStore({
- position: dialog().position,
- size: dialog().size,
- });
- const [cropOptions, setCropOptions] = makePersisted(
- createStore({
- showGrid: false,
- }),
- { name: "cropOptionsState" },
+ const firstSegment = editorInstance.recordings.segments[0];
+ if (!firstSegment)
+ throw new Error("Project doesn't have a first segment");
+ const display = firstSegment.display;
+
+ let cropperRef: CropperRef | undefined;
+ const [crop, setCrop] = createSignal(CROP_ZERO);
+ const [aspect, setAspect] = createSignal(null);
+
+ const initialBounds = {
+ x: dialog().position.x,
+ y: dialog().position.y,
+ width: dialog().size.x,
+ height: dialog().size.y,
+ };
+
+ const [snapToRatio, setSnapToRatioEnabled] = makePersisted(
+ createSignal(true),
+ { name: "editorCropSnapToRatio" },
);
- const display = editorInstance.recordings.segments[0].display;
+ async function showCropOptionsMenu(
+ e: UIEvent,
+ positionAtCursor = false,
+ ) {
+ e.preventDefault();
+ const items = createCropOptionsMenuItems({
+ aspect: aspect(),
+ snapToRatioEnabled: snapToRatio(),
+ onAspectSet: setAspect,
+ onSnapToRatioSet: setSnapToRatioEnabled,
+ });
+ const menu = await Menu.new({ items });
+ let pos: LogicalPosition | undefined;
+ if (!positionAtCursor) {
+ const rect = (
+ e.target as HTMLDivElement
+ ).getBoundingClientRect();
+ pos = new LogicalPosition(rect.x, rect.y + 40);
+ }
+ await menu.popup(pos);
+ await menu.close();
+ }
- const adjustedCrop = createMemo(() => cropToFloor(crop));
+ function BoundInput(props: {
+ field: keyof CropBounds;
+ min?: number;
+ max?: number;
+ }) {
+ return (
+ {
+ cropperRef?.setCropProperty(props.field, v);
+ }}
+ changeOnWheel={true}
+ format={false}
+ >
+ ([
+ (e) => e.stopPropagation(),
+ ])}
+ />
+
+ );
+ }
return (
<>
@@ -315,109 +378,78 @@ function Dialogs() {
-
-
-
+
+
+
}
+ onClick={() => cropperRef?.fill()}
+ disabled={
+ crop().width === display.width &&
+ crop().height === display.height
+ }
+ >
+ Full
+
}
- onClick={() =>
- setCrop({
- position: { x: 0, y: 0 },
- size: {
- x: display.width,
- y: display.height,
- },
- })
+ onClick={() => {
+ cropperRef?.reset();
+ setAspect(null);
+ }}
+ disabled={
+ crop().x === dialog().position.x &&
+ crop().y === dialog().position.y &&
+ crop().width === dialog().size.x &&
+ crop().height === dialog().size.y
}
>
Reset
@@ -426,15 +458,17 @@ function Dialogs() {
-
+
showCropOptionsMenu(e, true)}
>
{
- setState("background", "crop", adjustedCrop());
+ const bounds = crop();
+ setState("background", "crop", {
+ position: {
+ x: bounds.x,
+ y: bounds.y,
+ },
+ size: {
+ x: bounds.width,
+ y: bounds.height,
+ },
+ });
setDialog((d) => ({ ...d, open: false }));
}}
>
diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx
index 90258054d0..72ba891e7d 100644
--- a/apps/desktop/src/routes/target-select-overlay.tsx
+++ b/apps/desktop/src/routes/target-select-overlay.tsx
@@ -1,19 +1,20 @@
import { Button } from "@cap/ui-solid";
-import {
- createEventListener,
- createEventListenerMap,
-} from "@solid-primitives/event-listener";
+import { createEventListener } from "@solid-primitives/event-listener";
+import { createResizeObserver } from "@solid-primitives/resize-observer";
import { useSearchParams } from "@solidjs/router";
import { createQuery, useMutation } from "@tanstack/solid-query";
+import { LogicalPosition } from "@tauri-apps/api/dpi";
import { emit } from "@tauri-apps/api/event";
-import { CheckMenuItem, Menu, Submenu } from "@tauri-apps/api/menu";
-import { cx } from "cva";
import {
- type ComponentProps,
- createEffect,
- createRoot,
+ CheckMenuItem,
+ Menu,
+ MenuItem,
+ PredefinedMenuItem,
+} from "@tauri-apps/api/menu";
+import { type as ostype } from "@tauri-apps/plugin-os";
+import {
+ createMemo,
createSignal,
- type JSX,
Match,
onCleanup,
Show,
@@ -21,6 +22,13 @@ import {
Switch,
} from "solid-js";
import { createStore, reconcile } from "solid-js/store";
+import Cropper, {
+ CROP_ZERO,
+ type CropBounds,
+ type CropperRef,
+ createCropOptionsMenuItems,
+ type Ratio,
+} from "~/components/Cropper";
import ModeSelect from "~/components/ModeSelect";
import { authStore, generalSettingsStore } from "~/store";
import { createOptionsQuery } from "~/utils/queries";
@@ -93,39 +101,12 @@ function Inner() {
params.displayId !== undefined && rawOptions.targetMode === "display",
}));
- const [bounds, _setBounds] = createStore({
- position: { x: 0, y: 0 },
- size: { width: 400, height: 300 },
- });
+ const [crop, setCrop] = createSignal(CROP_ZERO);
- const setBounds = (newBounds: typeof bounds) => {
- const clampedBounds = {
- position: {
- x: Math.max(0, newBounds.position.x),
- y: Math.max(0, newBounds.position.y),
- },
- size: {
- width: Math.max(
- 150,
- Math.min(
- window.innerWidth - Math.max(0, newBounds.position.x),
- newBounds.size.width,
- ),
- ),
- height: Math.max(
- 150,
- Math.min(
- window.innerHeight - Math.max(0, newBounds.position.y),
- newBounds.size.height,
- ),
- ),
- },
- };
-
- _setBounds(clampedBounds);
- };
+ const [initialAreaBounds, setInitialAreaBounds] = createSignal<
+ CropBounds | undefined
+ >(undefined);
- // We do this so any Cap window, (or external in the case of a bug) that are focused can trigger the close shortcut
const unsubOnEscapePress = events.onEscapePress.listen(() => {
setOptions("targetMode", null);
commands.closeTargetSelectOverlays();
@@ -226,7 +207,6 @@ function Inner() {
{
- setBounds(windowUnderCursor.bounds);
+ setInitialAreaBounds({
+ x: windowUnderCursor.bounds.position.x,
+ y: windowUnderCursor.bounds.position.y,
+ width: windowUnderCursor.bounds.size.width,
+ height: windowUnderCursor.bounds.size.height,
+ });
setOptions({
targetMode: "area",
});
@@ -255,446 +240,135 @@ function Inner() {
- {(_) => (
-
- }
- >
- {(_) => {
- const [dragging, setDragging] = createSignal(false);
- // Track whether the controls should be placed above the selection to avoid window bottom overflow
- const [placeControlsAbove, setPlaceControlsAbove] =
- createSignal(false);
- let controlsEl: HTMLDivElement | undefined;
-
- // Recompute placement when bounds change or window resizes
- createEffect(() => {
- // Read reactive dependencies
- const top = bounds.position.y;
- const height = bounds.size.height;
- // Measure controls height (fallback to 64px if not yet mounted)
- const ctrlH = controlsEl?.offsetHeight ?? 64;
- const margin = 16;
-
- const wouldOverflow =
- top + height + margin + ctrlH > window.innerHeight;
- setPlaceControlsAbove(wouldOverflow);
- });
-
- // Handle window resize to keep placement responsive
- createRoot((dispose) => {
- const onResize = () => {
- const ctrlH = controlsEl?.offsetHeight ?? 64;
- const margin = 16;
- const wouldOverflow =
- bounds.position.y + bounds.size.height + margin + ctrlH >
- window.innerHeight;
- setPlaceControlsAbove(wouldOverflow);
- };
- window.addEventListener("resize", onResize);
- onCleanup(() => {
- window.removeEventListener("resize", onResize);
- dispose();
- });
- });
-
- function createOnMouseDown(
- onDrag: (
- startBounds: typeof bounds,
- delta: { x: number; y: number },
- ) => void,
- ) {
- return (downEvent: MouseEvent) => {
- const startBounds = {
- position: { ...bounds.position },
- size: { ...bounds.size },
- };
-
- let animationFrame: number | null = null;
-
- createRoot((dispose) => {
- createEventListenerMap(window, {
- mouseup: () => {
- if (animationFrame)
- cancelAnimationFrame(animationFrame);
- dispose();
- },
- mousemove: (moveEvent) => {
- if (animationFrame)
- cancelAnimationFrame(animationFrame);
-
- animationFrame = requestAnimationFrame(() => {
- onDrag(startBounds, {
- x: moveEvent.clientX - downEvent.clientX, // Remove Math.max constraint
- y: moveEvent.clientY - downEvent.clientY, // Remove Math.max constraint
- });
- });
- },
- });
- });
- };
- }
+ {(_) => {
+ let controlsEl: HTMLDivElement | undefined;
+ let cropperRef: CropperRef | undefined;
+
+ const [aspect, setAspect] = createSignal(null);
+ const [snapToRatioEnabled, setSnapToRatioEnabled] =
+ createSignal(true);
+
+ async function showCropOptionsMenu(e: UIEvent) {
+ e.preventDefault();
+ const items = [
+ {
+ text: "Reset selection",
+ action: () => {
+ cropperRef?.reset();
+ setAspect(null);
+ },
+ },
+ await PredefinedMenuItem.new({
+ item: "Separator",
+ }),
+ ...createCropOptionsMenuItems({
+ aspect: aspect(),
+ snapToRatioEnabled: snapToRatioEnabled(),
+ onAspectSet: setAspect,
+ onSnapToRatioSet: setSnapToRatioEnabled,
+ }),
+ ];
+ const menu = await Menu.new({ items });
+ await menu.popup();
+ await menu.close();
+ }
- function ResizeHandles() {
- return (
- <>
- {/* Top Left Button */}
- {
- const width = startBounds.size.width - delta.x;
- const limitedWidth = Math.max(width, 150);
-
- const height = startBounds.size.height - delta.y;
- const limitedHeight = Math.max(height, 150);
-
- setBounds({
- position: {
- x:
- startBounds.position.x +
- delta.x -
- (limitedWidth - width),
- y:
- startBounds.position.y +
- delta.y -
- (limitedHeight - height),
- },
- size: {
- width: limitedWidth,
- height: limitedHeight,
- },
- });
- })}
- />
-
- {/* Top Right Button */}
- {
- const width = startBounds.size.width + delta.x;
- const limitedWidth = Math.max(width, 150);
-
- const height = startBounds.size.height - delta.y;
- const limitedHeight = Math.max(height, 150);
-
- setBounds({
- position: {
- x: startBounds.position.x,
- y:
- startBounds.position.y +
- delta.y -
- (limitedHeight - height),
- },
- size: {
- width: limitedWidth,
- height: limitedHeight,
- },
- });
- })}
- />
-
- {/* Bottom Left Button */}
- {
- const width = startBounds.size.width - delta.x;
- const limitedWidth = Math.max(width, 150);
-
- const height = startBounds.size.height + delta.y;
- const limitedHeight = Math.max(height, 150);
-
- setBounds({
- position: {
- x:
- startBounds.position.x +
- delta.x -
- (limitedWidth - width),
- y: startBounds.position.y,
- },
- size: {
- width: limitedWidth,
- height: limitedHeight,
- },
- });
- })}
- />
-
- {/* Bottom Right Button */}
- {
- const width = startBounds.size.width + delta.x;
- const limitedWidth = Math.max(width, 150);
-
- const height = startBounds.size.height + delta.y;
- const limitedHeight = Math.max(height, 150);
-
- setBounds({
- position: {
- x: startBounds.position.x,
- y: startBounds.position.y,
- },
- size: {
- width: limitedWidth,
- height: limitedHeight,
- },
- });
- })}
- />
-
- {/* Top Edge Button */}
- {
- const height = startBounds.size.height - delta.y;
- const limitedHeight = Math.max(height, 150);
-
- setBounds({
- position: {
- x: startBounds.position.x,
- y:
- startBounds.position.y +
- delta.y -
- (limitedHeight - height),
- },
- size: {
- width: startBounds.size.width,
- height: limitedHeight,
- },
- });
- })}
- />
-
- {/* Right Edge Button */}
- {
- setBounds({
- position: {
- x: startBounds.position.x,
- y: startBounds.position.y,
- },
- size: {
- width: Math.max(
- 150,
- startBounds.size.width + delta.x,
- ),
- height: startBounds.size.height,
- },
- });
- })}
- />
-
- {/* Bottom Edge Button */}
- {
- setBounds({
- position: {
- x: startBounds.position.x,
- y: startBounds.position.y,
- },
- size: {
- width: startBounds.size.width,
- height: Math.max(
- 150,
- startBounds.size.height + delta.y,
- ),
- },
- });
- })}
- />
-
- {/* Left Edge Button */}
- {
- const width = startBounds.size.width - delta.x;
- const limitedWidth = Math.max(150, width);
-
- setBounds({
- position: {
- x:
- startBounds.position.x +
- delta.x -
- (limitedWidth - width),
- y: startBounds.position.y,
- },
- size: {
- width: limitedWidth,
- height: startBounds.size.height,
- },
- });
- })}
- />
- >
- );
- }
+ const [controlsSize, setControlsSize] = createStore({
+ width: 0,
+ height: 0,
+ });
+ createResizeObserver(
+ () => controlsEl,
+ ({ width, height }) => {
+ setControlsSize({ width, height });
+ },
+ );
+
+ // Spacing rules:
+ // Prefer below the crop (smaller margin)
+ // If no space below, place above the crop (larger top margin)
+ // Otherwise, place inside at the top of the crop (small inner margin)
+ const macos = ostype() === "macos";
+ const SIDE_MARGIN = 16;
+ const MARGIN_BELOW = 16;
+ const MARGIN_TOP_OUTSIDE = 16;
+ const MARGIN_TOP_INSIDE = macos ? 40 : 28;
+ const TOP_SAFE_MARGIN = macos ? 40 : 10; // keep clear of notch on MacBooks
+
+ const controlsStyle = createMemo(() => {
+ const bounds = crop();
+ const size = controlsSize;
+
+ if (size.width === 0 || bounds.width === 0) {
+ return { transform: "translate(-1000px, -1000px)" }; // Hide off-screen initially
+ }
- function Occluders() {
- return (
- <>
- {/* Left */}
-
- {/* Right */}
-
- {/* Top center */}
-
- {/* Bottom center */}
-
- >
- );
+ const centerX = bounds.x + bounds.width / 2;
+ let finalY: number;
+
+ // Try below the crop
+ const belowY = bounds.y + bounds.height + MARGIN_BELOW;
+ if (belowY + size.height <= window.innerHeight) {
+ finalY = belowY;
+ } else {
+ // Try above the crop with a larger top margin
+ const aboveY = bounds.y - size.height - MARGIN_TOP_OUTSIDE;
+ if (aboveY >= TOP_SAFE_MARGIN) {
+ finalY = aboveY;
+ } else {
+ // Default to inside
+ finalY = bounds.y + MARGIN_TOP_INSIDE;
}
+ }
- return (
-
-
-
-
{
- setDragging(true);
- const startPosition = { ...bounds.position };
-
- createRoot((dispose) => {
- createEventListenerMap(window, {
- mousemove: (moveEvent) => {
- const newPosition = {
- x:
- startPosition.x +
- moveEvent.clientX -
- downEvent.clientX,
- y:
- startPosition.y +
- moveEvent.clientY -
- downEvent.clientY,
- };
-
- if (newPosition.x < 0) newPosition.x = 0;
- if (newPosition.y < 0) newPosition.y = 0;
- if (
- newPosition.x + bounds.size.width >
- window.innerWidth
- )
- newPosition.x =
- window.innerWidth - bounds.size.width;
- if (
- newPosition.y + bounds.size.height >
- window.innerHeight
- )
- newPosition.y =
- window.innerHeight - bounds.size.height;
-
- _setBounds("position", newPosition);
- },
- mouseup: () => {
- setDragging(false);
- dispose();
- },
- });
- });
- }}
- >
-
-
-
-
-
-
-
+ const finalX = Math.max(
+ SIDE_MARGIN,
+ Math.min(
+ centerX - size.width / 2,
+ window.innerWidth - size.width - SIDE_MARGIN,
+ ),
+ );
+
+ return {
+ transform: `translate(${finalX}px, ${finalY}px)`,
+ };
+ });
+
+ return (
+
+
+
+
+
-
Click and drag area to record
-
- );
- }}
-
- )}
+
showCropOptionsMenu(e)}
+ />
+
+ );
+ }}
);
@@ -729,39 +403,49 @@ function RecordingControls(props: {
],
});
- const countdownMenu = async () =>
- await Submenu.new({
- text: "Recording Countdown",
+ const countdownItems = async () => [
+ await CheckMenuItem.new({
+ text: "Off",
+ action: () => generalSettingsStore.set({ recordingCountdown: 0 }),
+ checked:
+ !generalSetings.data?.recordingCountdown ||
+ generalSetings.data?.recordingCountdown === 0,
+ }),
+ await CheckMenuItem.new({
+ text: "3 seconds",
+ action: () => generalSettingsStore.set({ recordingCountdown: 3 }),
+ checked: generalSetings.data?.recordingCountdown === 3,
+ }),
+ await CheckMenuItem.new({
+ text: "5 seconds",
+ action: () => generalSettingsStore.set({ recordingCountdown: 5 }),
+ checked: generalSetings.data?.recordingCountdown === 5,
+ }),
+ await CheckMenuItem.new({
+ text: "10 seconds",
+ action: () => generalSettingsStore.set({ recordingCountdown: 10 }),
+ checked: generalSetings.data?.recordingCountdown === 10,
+ }),
+ ];
+
+ const preRecordingMenu = async () => {
+ return await Menu.new({
items: [
- await CheckMenuItem.new({
- text: "Off",
- action: () => generalSettingsStore.set({ recordingCountdown: 0 }),
- checked:
- !generalSetings.data?.recordingCountdown ||
- generalSetings.data?.recordingCountdown === 0,
- }),
- await CheckMenuItem.new({
- text: "3 seconds",
- action: () => generalSettingsStore.set({ recordingCountdown: 3 }),
- checked: generalSetings.data?.recordingCountdown === 3,
- }),
- await CheckMenuItem.new({
- text: "5 seconds",
- action: () => generalSettingsStore.set({ recordingCountdown: 5 }),
- checked: generalSetings.data?.recordingCountdown === 5,
- }),
- await CheckMenuItem.new({
- text: "10 seconds",
- action: () => generalSettingsStore.set({ recordingCountdown: 10 }),
- checked: generalSetings.data?.recordingCountdown === 10,
+ await MenuItem.new({
+ text: "Recording Countdown",
+ enabled: false,
}),
+ ...(await countdownItems()),
],
});
-
- const preRecordingMenu = async () => {
- return await Menu.new({ items: [await countdownMenu()] });
};
+ function showMenu(menu: Promise