From 5ef0e0ebb264b66c97ebf78a5ddea073595f6b7c Mon Sep 17 00:00:00 2001 From: Ilya <47112191+ItsEeleeya@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:43:09 +0330 Subject: [PATCH 01/14] New Cropper, updated CaptureArea window --- apps/desktop/src/components/Cropper.tsx | 1955 +++++++++++------ .../src/routes/(window-chrome)/(main).tsx | 2 +- apps/desktop/src/routes/capture-area.tsx | 274 ++- apps/desktop/src/routes/editor/Editor.tsx | 261 ++- packages/ui-solid/src/auto-imports.d.ts | 4 + 5 files changed, 1669 insertions(+), 827 deletions(-) diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx index 88379b221..00226ef5b 100644 --- a/apps/desktop/src/components/Cropper.tsx +++ b/apps/desktop/src/components/Cropper.tsx @@ -1,771 +1,1456 @@ import { createEventListenerMap } from "@solid-primitives/event-listener"; -import { makePersisted } from "@solid-primitives/storage"; -import { type CheckMenuItemOptions, Menu } from "@tauri-apps/api/menu"; -import { type as ostype } from "@tauri-apps/plugin-os"; +import { createResizeObserver } from "@solid-primitives/resize-observer"; 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 }; +function triggerHaptic() { + 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), }; } -export default function Cropper( - props: ParentProps<{ - class?: string; - onCropChange: (value: Crop) => void; - value: Crop; - mappedSize?: XY; - minSize?: XY; - initialSize?: XY; - aspectRatio?: number; - showGuideLines?: boolean; - }>, +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 position = () => props.value.position; - const size = () => props.value.size; + const currentRatio = bounds.width / bounds.height; + if (Math.abs(currentRatio - ratio) < 0.001) return bounds; + return resizeBounds(bounds, bounds.width, bounds.width / ratio, origin); +} - 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), - }; - }); +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; + } + } - const containerToMappedSizeScale = createMemo(() => { - const container = containerSize(); - const mapped = mappedSize(); - return { - x: container.x / mapped.x, - y: container.y / mapped.y, - }; - }); + 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); - const displayScaledCrop = createMemo(() => { - const mapped = mappedSize(); - const container = containerSize(); - 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, - }; - }); + return next; +} + +function slideBoundsIntoContainer( + bounds: CropBounds, + containerWidth: number, + containerHeight: number, +): CropBounds { + let { x, y, width, height } = bounds; + + if (width > containerWidth) width = containerWidth; + if (height > containerHeight) height = containerHeight; + + 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; + minSize?: Vec2; + maxSize?: Vec2; + targetSize?: Vec2; + initialCrop?: CropBounds | (() => CropBounds | undefined); + aspectRatio?: Ratio; + showBounds?: boolean; + snapToRatioEnabled?: boolean; + useBackdropFilter?: boolean; + allowLightMode?: boolean; + }>, +) { let containerRef: HTMLDivElement | undefined; - onMount(() => { - if (!containerRef) return; + 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 updateContainerSize = () => { - setContainerSize({ - x: containerRef!.clientWidth, - y: containerRef!.clientHeight, - }); - }; + const boundsTooSmall = createMemo( + () => displayRawBounds().width <= 30 || displayRawBounds().height <= 30, + ); - updateContainerSize(); - const resizeObserver = new ResizeObserver(updateContainerSize); - resizeObserver.observe(containerRef); - onCleanup(() => resizeObserver.disconnect()); + const [state, setState] = createStore({ + dragging: false, + resizing: false, + overlayDragging: false, + cursorStyle: null as string | null, + hoveringHandle: null as HandleSide | null, + }); - const mapped = mappedSize(); - const initial = props.initialSize || { - x: mapped.x / 2, - y: mapped.y / 2, - }; + createEffect(() => props.onInteraction?.(state.dragging || state.resizing)); - const width = clamp(initial.x, minSize().x, mapped.x); - const height = clamp(initial.y, minSize().y, mapped.y); + const [aspectState, setAspectState] = createStore({ + snapped: null as Ratio | null, + value: null as number | null, + }); - const box = Box.from( - { x: (mapped.x - width) / 2, y: (mapped.y - height) / 2 }, - { x: width, y: height }, - ); - box.constrainAll(box, containerSize(), ORIGIN_CENTER, props.aspectRatio); + createEffect(() => { + const min = props.minSize; + const max = props.maxSize; - setCrop({ - size: { x: width, y: height }, - position: { - x: (mapped.x - width) / 2, - y: (mapped.y - height) / 2, - }, - }); + if (min && max) { + if (min.x > max.x) + throw new Error( + `Cropper error: minSize.x (${min.x}) cannot be greater than maxSize.x (${max.x}).`, + ); + if (min.y > max.y) + throw new Error( + `Cropper error: minSize.y (${min.y}) cannot be greater than maxSize.y (${max.y}).`, + ); + } }); 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()); + (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 [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 }, + 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; }); - 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 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, + ); - createRoot((dispose) => { - const mapped = mappedSize(); - createEventListenerMap(window, { - mouseup: () => { - setDragging(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()); - } - }); - }, + 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: 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) { + setIsAnimating(true); + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + const start = displayRawBounds(); + 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); + } + }; + + animationFrameId = requestAnimationFrame(step); + } + + function setRawBoundsAndAnimate(bounds: CropBounds, durationMs = 240) { + if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); + setIsAnimating(true); + setRawBoundsConstraining(bounds); + animateToRawBounds(rawBounds(), durationMs); } - function handleWheel(event: WheelEvent) { - event.preventDefault(); - const box = Box.from(position(), size()); - const mapped = mappedSize(); + 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 (event.ctrlKey) { - setGestureState("isTrackpadGesture", true); + 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); - const velocity = Math.max(0.001, Math.abs(event.deltaY) * 0.001); - const scale = 1 - event.deltaY * velocity; + bounds = slideBoundsIntoContainer(bounds, container.x, container.y); - box.resize( - clamp(box.width * scale, minSize().x, mapped.x), - clamp(box.height * scale, minSize().y, mapped.y), - ORIGIN_CENTER, + if (!initialCrop) + bounds = moveBounds( + bounds, + container.x / 2 - bounds.width / 2, + container.y / 2 - bounds.height / 2, ); - 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), - ); - } - - setCrop(box.toBounds()); + return bounds; } - 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, - }); - }); - } + function rawSizeConstraint() { + const scale = logicalScale(); + return { + 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 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, + 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, ); - let newHeight = newWidth / currentRatio; - // Adjust if height exceeds bounds - if (newHeight < minSize().y || newHeight > mapped.y) { - newHeight = clamp(newHeight, minSize().y, mapped.y); - newWidth = newHeight * currentRatio; - } + newBounds = slideBoundsIntoContainer(newBounds, container.x, container.y); + setRawBounds(newBounds); + if (!isAnimating()) setDisplayRawBounds(newBounds); + } - // Resize from center - box.resize(newWidth, newHeight, ORIGIN_CENTER); + onMount(() => { + if (!containerRef) return; + let initialized = false; - // 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 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), + }; - if (gestureState.lastTouchCenter) { - const scaleFactors = containerToMappedSizeScale(); - const dx = (centerX - gestureState.lastTouchCenter.x) / scaleFactors.x; - const dy = (centerY - gestureState.lastTouchCenter.y) / scaleFactors.y; + setContainerSize({ x: width, y: height }); - box.move( - clamp(box.x + dx, 0, mapped.x - box.width), - clamp(box.y + dy, 0, mapped.y - box.height), - ); - } + setRawBoundsConstraining(boundsToRaw(preservedReal)); - 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), - ); + if (!initialized && width > 1 && height > 1) { + initialized = true; + init(); } + }; - setGestureState("lastTouchCenter", { - x: event.touches[0].clientX, - y: event.touches[0].clientY, - }); - setCrop(box.toBounds()); + createResizeObserver(containerRef, (e) => + updateContainerSize(e.width, e.height), + ); + updateContainerSize(containerRef.clientWidth, containerRef.clientHeight); + + setDisplayRawBounds(rawBounds()); + + function init() { + const bounds = computeInitialBounds(); + setRawBoundsConstraining(bounds); + setDisplayRawBounds(bounds); + setIsReady(true); } - } - 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 (props.ref) { + const fill = () => { + const container = containerSize(); + const targetRaw = { + x: 0, + y: 0, + width: container.x, + height: container.y, + }; + setRawBoundsAndAnimate(targetRaw); + setAspectState("snapped", null); + }; + + 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), durationMs), + }; + + if (typeof props.ref === "function") props.ref(cropperRef); + else props.ref = cropperRef; } + }); + + 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) => + createEventListenerMap(window, { + pointerup: () => { + setState({ cursorStyle: null, dragging: false }); + dispose(); + }, + 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 handleResizeStartTouch(event: TouchEvent, dir: Direction) { - if (event.touches.length !== 1) return; - event.stopPropagation(); - const touch = event.touches[0]; - handleResizeStart(touch.clientX, touch.clientY, dir); + // 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 }; } - 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]]; + type ResizeSessionState = { + startBounds: CropBounds; + isAltMode: boolean; + activeHandle: HandleSide; + originalHandle: HandleSide; + containerRect: DOMRect; + }; + + 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; + } + + 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, + ); + } else { + const { bounds, snappedRatio } = computeFreeResize( + pointX, + pointY, + context.startBounds, + context.activeHandle, + options, + ); + nextBounds = bounds; + if (snappedRatio && !aspectState.snapped) { + triggerHaptic(); } + setAspectState("snapped", snappedRatio); } - return null; + + const finalBounds = slideBoundsIntoContainer( + nextBounds, + containerSize().x, + containerSize().y, + ); + + setRawBounds(finalBounds); + if (!isAnimating()) setDisplayRawBounds(finalBounds); } - function handleResizeStart(clientX: number, clientY: number, dir: Direction) { - const origin: XY = { - x: dir.includes("w") ? 1 : 0, - y: dir.includes("n") ? 1 : 0, + function onHandlePointerDown(handle: HandleSide, e: PointerEvent) { + if (!containerRef || e.button !== 0) return; + e.stopPropagation(); + + stopAnimation(); + setState({ cursorStyle: handle.cursor, resizing: true }); + + const context: ResizeSessionState = { + containerRect: containerRef.getBoundingClientRect(), + startBounds: rawBounds(), + isAltMode: e.altKey, + activeHandle: { ...handle }, + originalHandle: handle, + }; + + 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), + }), + ); + } + + 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, }; - let lastValidPos = { x: clientX, y: clientY }; - const box = Box.from(position(), size()); - const scaleFactors = containerToMappedSizeScale(); - const mapped = mappedSize(); + const startBounds: CropBounds = { + x: startPoint.x, + y: startPoint.y, + width: 1, + height: 1, + }; + + 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()); - - 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); - } + 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 + }); - const newOrigin = centerOrigin ? ORIGIN_CENTER : origin; - box.resize(newWidth, newHeight, newOrigin); + let keyboardFrameId: number | null = null; - 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); - } + function keyboardActionLoop() { + const currentBounds = rawBounds(); + const { pressedKeys, shift, alt, meta } = keyboardState; + + 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(); + } + } + + function handleKeyUp(e: KeyboardEvent) { + if ( + !KEY_MAPPINGS.has(e.key) && + !["Shift", "Alt", "Meta", "Control"].includes(e.key) + ) + return; - if (props.aspectRatio) box.constrainToRatio(props.aspectRatio, origin); - box.constrainToBoundary(mapped.x, mapped.y, origin); - setCrop(box.toBounds()); + e.preventDefault(); + e.stopPropagation(); - pressedKeys.clear(); - lastKeyHandleFrame = null; + 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} > - - {props.children} - + + {(transform) => ( +
+ {realBounds().width} x {realBounds().height} +
+ )} +
+ + + {resolvedChildren()} + + {/* Occluder */}