diff --git a/.config/hakari.toml b/.config/hakari.toml index 0dea44fe3f..108e078a08 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -21,6 +21,7 @@ platforms = [ # "x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", ] # Write out exact versions rather than a semver range. (Defaults to false.) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77afebc97f..1cc28576b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,7 @@ jobs: clippy: name: Clippy + runs-on: macos-latest needs: changes if: needs.changes.outputs.rust == 'true' strategy: @@ -103,10 +104,10 @@ jobs: settings: - target: aarch64-apple-darwin runner: macos-latest - # Windows can't take the disk usage lol - # - target: x86_64-pc-windows-msvc - # runner: windows-latest - runs-on: ${{ matrix.settings.runner }} + - target: x86_64-pc-windows-msvc + runner: windows-latest + - target: aarch64-pc-windows-msvc + runner: windows-latest permissions: contents: read steps: @@ -173,6 +174,8 @@ jobs: runner: macos-latest - target: x86_64-pc-windows-msvc runner: windows-latest + - target: aarch64-pc-windows-msvc + runner: windows-latest runs-on: ${{ matrix.settings.runner }} env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} @@ -250,6 +253,8 @@ jobs: runner: macos-latest - target: x86_64-pc-windows-msvc runner: windows-latest + - target: aarch64-pc-windows-msvc + runner: windows-latest runs-on: ${{ matrix.settings.runner }} steps: - name: Checkout @@ -279,7 +284,7 @@ jobs: run: cargo check --all --release - name: Run Clippy - if: ${{ matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' }} + if: ${{ matrix.settings.target == 'aarch64-apple-darwin' || matrix.settings.target == 'x86_64-pc-windows-msvc' || matrix.settings.target == 'aarch64-pc-windows-msvc' }} uses: actions-rs-plus/clippy-check@v2 with: args: --workspace --all-features --locked diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8056edd4d..b6938d899e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,20 @@ on: description: "Discord Interaction ID" required: false type: string + windowsTarget: + description: "Windows build target" + required: false + default: all + type: choice + options: + - all + - x64 + - arm64 + buildMac: + description: "Build macOS installers" + required: false + default: true + type: boolean env: CN_APPLICATION: cap/cap @@ -38,12 +52,13 @@ jobs: - name: Create tag id: create_tag - if: ${{ steps.create_tag.outputs.tag_existed != 'true' }} uses: actions/github-script@v7 with: script: | - const tag = "cap-v${{ steps.read_version.outputs.value }}"; + const version = "${{ steps.read_version.outputs.value }}"; + const tag = `cap-v${version}`; const tagRef = `tags/${tag}`; + const appCargoToml = "${{ env.APP_CARGO_TOML }}"; const TAG_EXISTED = "tag_existed"; const TAG_NAME = "tag_name"; @@ -60,25 +75,30 @@ jobs: repo: context.repo.repo, }); - tagExisted = true; - core.notice(`Release skipped as tag '${tag}' already exists. Update the version in '${{ env.APP_CARGO_TOML }}' before starting another release.`); + core.notice( + `Release skipped as tag '${tag}' already exists. Update the version in '${appCargoToml}' before starting another release.`, + ); } catch (error) { - if ("status" in error && error.status === 404) tagExisted = false; - else throw error; + if ("status" in error && error.status === 404) { + tagExisted = false; + } else { + throw error; + } } - core.setOutput(TAG_EXISTED, tagExisted); + core.setOutput(TAG_EXISTED, tagExisted); - if (!tagExisted) + if (!tagExisted) { await github.rest.git.createRef({ ref: `refs/${tagRef}`, owner: context.repo.owner, repo: context.repo.repo, sha: context.sha, }); + } } - main(); + await main(); - name: Create draft CN release id: create_cn_release @@ -106,26 +126,31 @@ jobs: const token = await core.getIDToken("cap-discord-bot"); const cnReleaseId = JSON.parse(`${{ steps.create_cn_release.outputs.stdout }}`).id; - const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", { - method: "POST", - body: JSON.stringify({ - type: "release-ready", - tag: "${{ steps.create_tag.outputs.tag_name }}", - version: "${{ steps.read_version.outputs.value }}", - releaseUrl: "${{ steps.create_gh_release.outputs.url }}", - interactionId: "${{ inputs.interactionId }}", - cnReleaseId - }), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - } - }); - - if(resp.status !== 200) throw new Error(await resp.text()); + const resp = await fetch( + "https://cap-discord-bot.brendonovich.workers.dev/github-workflow", + { + method: "POST", + body: JSON.stringify({ + type: "release-ready", + tag: "${{ steps.create_tag.outputs.tag_name }}", + version: "${{ steps.read_version.outputs.value }}", + releaseUrl: "${{ steps.create_gh_release.outputs.url }}", + interactionId: "${{ inputs.interactionId }}", + cnReleaseId, + }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (resp.status !== 200) { + throw new Error(await resp.text()); + } } - main(); + await main(); build: needs: draft @@ -139,43 +164,60 @@ jobs: settings: - target: x86_64-apple-darwin runner: macos-latest-xlarge + platform: macos + arch: x64 - target: aarch64-apple-darwin runner: macos-latest-xlarge + platform: macos + arch: arm64 - target: x86_64-pc-windows-msvc - runner: windows-latest + runner: windows-latest-l + platform: windows + arch: x64 + - target: aarch64-pc-windows-msvc + runner: windows-latest-l + platform: windows + arch: arm64 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + RUN_BUILD: ${{ (matrix.settings.platform == 'macos' && inputs.buildMac) || (matrix.settings.platform == 'windows' && (inputs.windowsTarget == 'all' || inputs.windowsTarget == matrix.settings.arch)) }} runs-on: ${{ matrix.settings.runner }} steps: - name: Checkout repository + if: ${{ env.RUN_BUILD == 'true' }} uses: actions/checkout@v4 - name: Create API Key File + if: ${{ env.RUN_BUILD == 'true' }} run: echo "${{ secrets.APPLE_API_KEY_FILE }}" > api.p8 - uses: apple-actions/import-codesign-certs@v2 - if: ${{ matrix.settings.runner == 'macos-latest-xlarge' }} + if: ${{ env.RUN_BUILD == 'true' && matrix.settings.runner == 'macos-latest-xlarge' }} with: p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - name: Verify certificate - if: ${{ matrix.settings.runner == 'macos-latest-xlarge' }} + if: ${{ env.RUN_BUILD == 'true' && matrix.settings.runner == 'macos-latest-xlarge' }} run: security find-identity -v -p codesigning ${{ runner.temp }}/build.keychain - name: Rust setup + if: ${{ env.RUN_BUILD == 'true' }} uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.settings.target }} - uses: ./.github/actions/setup-rust-cache + if: ${{ env.RUN_BUILD == 'true' }} with: target: ${{ matrix.settings.target }} - uses: ./.github/actions/setup-js + if: ${{ env.RUN_BUILD == 'true' }} - name: Create .env file in root + if: ${{ env.RUN_BUILD == 'true' }} run: | echo "appVersion=${{ needs.draft.outputs.version }}" >> .env echo "VITE_ENVIRONMENT=production" >> .env @@ -188,6 +230,7 @@ jobs: echo 'RUST_TARGET_TRIPLE=${{ matrix.settings.target }}' >> .env - name: Build app + if: ${{ env.RUN_BUILD == 'true' }} working-directory: apps/desktop run: | pnpm -w cap-setup @@ -255,6 +298,7 @@ jobs: # Get-ChildItem -Path $bundleDir -Filter *.exe | ForEach-Object { Write-Host " - $($_.Name)" } - name: Upload assets + if: ${{ env.RUN_BUILD == 'true' }} uses: crabnebula-dev/cloud-release@v0 with: working-directory: apps/desktop @@ -264,9 +308,10 @@ jobs: TAURI_BUNDLE_PATH: ../.. - uses: matbour/setup-sentry-cli@8ef22a4ff03bcd1ebbcaa3a36a81482ca8e3872e + if: ${{ env.RUN_BUILD == 'true' }} - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'macOS' }} + if: ${{ env.RUN_BUILD == 'true' && runner.os == 'macOS' }} shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -274,7 +319,7 @@ jobs: sentry-cli debug-files upload -o ${{ env.SENTRY_ORG }} -p ${{ env.SENTRY_PROJECT }} target/Cap.dSYM - name: Upload debug symbols to Sentry - if: ${{ runner.os == 'Windows' }} + if: ${{ env.RUN_BUILD == 'true' && runner.os == 'Windows' }} shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -297,22 +342,27 @@ jobs: const token = await core.getIDToken("cap-discord-bot"); const cnReleaseId = JSON.parse(`${{ needs.draft.outputs.cn_release_stdout }}`).id; - const resp = await fetch("https://cap-discord-bot.brendonovich.workers.dev/github-workflow", { - method: "POST", - body: JSON.stringify({ - type: "release-done", - interactionId: "${{ inputs.interactionId }}", - version: "${{ needs.draft.outputs.version }}", - releaseUrl: "${{ needs.draft.outputs.gh_release_url }}", - cnReleaseId - }), - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - } - }); - - if(resp.status !== 200) throw new Error(await resp.text()); + const resp = await fetch( + "https://cap-discord-bot.brendonovich.workers.dev/github-workflow", + { + method: "POST", + body: JSON.stringify({ + type: "release-done", + interactionId: "${{ inputs.interactionId }}", + version: "${{ needs.draft.outputs.version }}", + releaseUrl: "${{ needs.draft.outputs.gh_release_url }}", + cnReleaseId, + }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (resp.status !== 200) { + throw new Error(await resp.text()); + } } - main(); + await main(); diff --git a/Cargo.lock b/Cargo.lock index 1141acf3cd..337df96fac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.82" +version = "0.3.83" dependencies = [ "anyhow", "async-stream", @@ -11856,25 +11856,37 @@ dependencies = [ "arrayvec", "axum", "bitflags 2.9.4", + "block2 0.6.1", "bytemuck", "chrono", "clang-sys", "clap", "clap_builder", + "concurrent-queue", + "core-foundation 0.9.4", + "core-foundation-sys", + "crossbeam-utils", "deranged", + "dispatch2", "either", "flate2", "form_urlencoded", "futures-channel", "futures-core", "futures-executor", + "futures-io", "futures-sink", "futures-task", "futures-util", "getrandom 0.2.16", + "getrandom 0.3.3", "gif", + "half", "hashbrown 0.15.5", + "hyper 1.7.0", + "hyper-util", "idna", + "indexmap 2.11.4", "itertools 0.12.1", "itertools 0.13.0", "libc", @@ -11884,6 +11896,14 @@ dependencies = [ "nom", "num-rational", "num-traits", + "objc", + "objc2 0.6.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-foundation 0.3.1", + "objc2-quartz-core 0.3.1", + "once_cell", "percent-encoding", "phf_shared 0.10.0", "phf_shared 0.11.3", @@ -11897,7 +11917,9 @@ dependencies = [ "reqwest 0.12.24", "rgb", "rustc-hash 1.1.0", + "rustix 1.1.2", "schemars 0.8.22", + "scopeguard", "semver", "serde", "serde_core", @@ -11907,15 +11929,30 @@ dependencies = [ "specta", "specta-macros", "stable_deref_trait", + "swift-rs", "syn 2.0.106", "tauri-utils", "thiserror 2.0.16", "time", "tokio", + "tokio-util", + "tower", "tracing", "tracing-core", "tracing-subscriber", "uuid", + "winapi", + "windows 0.58.0", + "windows 0.60.0", + "windows-core 0.60.1", + "windows-core 0.61.2", + "windows-result 0.3.4", + "windows-strings 0.4.2", + "windows-sys 0.48.0", + "windows-sys 0.52.0", + "windows-sys 0.59.0", + "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 02b6b27389..a6cf8cfe17 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.82" +version = "0.3.83" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index f832fff88f..5991565da6 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -24,7 +24,6 @@ "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 fbd6189853..7bc7d3e710 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -70,6 +70,8 @@ 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")] @@ -164,6 +166,7 @@ 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/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d5ccc3c6f7..115008412b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -285,9 +285,6 @@ pub struct RecordingDeleted { path: PathBuf, } -#[derive(specta::Type, tauri_specta::Event, Serialize)] -pub struct SetCaptureAreaPending(bool); - #[derive(Deserialize, specta::Type, Serialize, tauri_specta::Event, Debug, Clone)] pub struct NewScreenshotAdded { path: PathBuf, @@ -1613,7 +1610,7 @@ async fn seek_to(editor_instance: WindowEditorInstance, frame_number: u32) -> Re async fn get_mic_waveforms(editor_instance: WindowEditorInstance) -> Result>, String> { let mut out = Vec::new(); - for segment in editor_instance.segment_medias.iter() { + for segment in editor_instance.segments.iter() { if let Some(audio) = &segment.audio { out.push(audio::get_waveform(audio)); } else { @@ -1632,7 +1629,7 @@ async fn get_system_audio_waveforms( ) -> Result>, String> { let mut out = Vec::new(); - for segment in editor_instance.segment_medias.iter() { + for segment in editor_instance.segments.iter() { if let Some(audio) = &segment.system_audio { out.push(audio::get_waveform(audio)); } else { @@ -1923,7 +1920,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { target_select_overlay::TargetUnderCursor, hotkeys::OnEscapePress, upload::UploadProgressEvent, - SetCaptureAreaPending, ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() diff --git a/apps/desktop/src-tauri/src/platform/mod.rs b/apps/desktop/src-tauri/src/platform/mod.rs index e51d3e709a..b409f600d7 100644 --- a/apps/desktop/src-tauri/src/platform/mod.rs +++ b/apps/desktop/src-tauri/src/platform/mod.rs @@ -11,7 +11,6 @@ pub use macos::*; use tracing::instrument; #[derive(Debug, Serialize, Deserialize, Type, Default)] -#[serde(rename_all = "camelCase")] #[repr(isize)] pub enum HapticPattern { Alignment = 0, @@ -21,7 +20,6 @@ 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/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 9529e3210e..bada34367c 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1309,11 +1309,23 @@ fn project_config_from_recording( .segments .iter() .enumerate() - .map(|(i, segment)| TimelineSegment { - recording_clip: i as u32, - start: 0.0, - end: segment.duration(), - timescale: 1.0, + .map(|(i, segment)| { + let mut duration = segment.duration(); + + if !duration.is_finite() || duration <= 0.0 { + warn!( + segment_index = i, + duration, "Segment duration was invalid; clamping to zero" + ); + duration = 0.0; + } + + TimelineSegment { + recording_segment: i as u32, + start: 0.0, + end: duration, + timescale: 1.0, + } }) .collect::>(); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index ba6d5a71f6..47828293ae 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -578,7 +578,6 @@ impl ShowCapWindow { .maximized(false) .fullscreen(false) .shadow(false) - .resizable(false) .always_on_top(true) .content_protected(should_protect) .skip_taskbar(true) @@ -609,7 +608,7 @@ impl ShowCapWindow { #[cfg(target_os = "macos")] crate::platform::set_window_level( window.as_ref().window(), - objc2_app_kit::NSPopUpMenuWindowLevel, + objc2_app_kit::NSScreenSaverWindowLevel, ); // 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 5bf7fc132e..5fc9e35cfb 100644 --- a/apps/desktop/src/components/CapErrorBoundary.tsx +++ b/apps/desktop/src/components/CapErrorBoundary.tsx @@ -1,5 +1,4 @@ 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"; @@ -33,12 +32,6 @@ 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 new file mode 100644 index 0000000000..b40e0d7adb --- /dev/null +++ b/apps/desktop/src/components/CropAreaRenderer.tsx @@ -0,0 +1,290 @@ +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 ( +
+ +
{props.children}
+
+ ); +} diff --git a/apps/desktop/src/components/Cropper.tsx b/apps/desktop/src/components/Cropper.tsx index 6509ce71cf..88379b2213 100644 --- a/apps/desktop/src/components/Cropper.tsx +++ b/apps/desktop/src/components/Cropper.tsx @@ -1,1520 +1,771 @@ import { createEventListenerMap } from "@solid-primitives/event-listener"; -import { createResizeObserver } from "@solid-primitives/resize-observer"; -import type { - CheckMenuItemOptions, - PredefinedMenuItemOptions, -} from "@tauri-apps/api/menu"; +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 { - type Accessor, - children, + batch, 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 { createKeyDownSignal } from "~/utils/events"; - -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 }; +import { generalSettingsStore } from "~/store"; +import Box from "~/utils/box"; +import { type Crop, commands, type XY } from "~/utils/tauri"; +import CropAreaRenderer from "./CropAreaRenderer"; 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: string; - movable: BoundsConstraints; - origin: Vec2; - isCorner: boolean; + cursor: "ew" | "ns" | "nesw" | "nwse"; }; -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[] = [ +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[] = [ [1, 1], - [2, 1], - [3, 2], [4, 3], - [9, 16], + [3, 2], [16, 9], - [16, 10], + [2, 1], [21, 9], ]; -const ORIGIN_CENTER: Vec2 = { x: 0.5, y: 0.5 }; -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 KEY_MAPPINGS = new Map([ + ["ArrowRight", "e"], + ["ArrowDown", "s"], + ["ArrowLeft", "w"], + ["ArrowUp", "n"], +]); -const shouldTriggerHaptic = ostype() === "macos"; -function triggerHaptic() { - if (shouldTriggerHaptic) commands.performHapticFeedback("alignment", null); -} +const ORIGIN_CENTER: XY = { x: 0.5, y: 0.5 }; -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 clamp(n: number, min = 0, max = 1) { + return Math.max(min, Math.min(max, n)); } -// ----------------------------- -// 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, - }; +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); } -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; +export function cropToFloor(value: Crop): Crop { return { - x: Math.round(fromX - newWidth * origin.x), - y: Math.round(fromY - newHeight * origin.y), - width: Math.round(newWidth), - height: Math.round(newHeight), + 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), + }, }; } -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 function Cropper( +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; + onCropChange: (value: Crop) => void; + value: Crop; + mappedSize?: XY; + minSize?: XY; + initialSize?: XY; + aspectRatio?: number; + showGuideLines?: boolean; }>, ) { - 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 boundsTooSmall = createMemo( - () => displayRawBounds().width <= 30 || displayRawBounds().height <= 30, - ); + const position = () => props.value.position; + const size = () => props.value.size; - const [mouseState, setMouseState] = createStore< - ( - | { drag: null | "region" | "overlay" } - | { drag: "handle"; cursor: string } - ) & { hoveringHandle: HandleSide | null } - >({ drag: null, hoveringHandle: null }); - - const resizing = () => - mouseState.drag === "handle" || mouseState.drag === "overlay"; - const cursorStyle = () => { - if (mouseState.drag === "region" || mouseState.drag === "overlay") - return "grabbing"; - if (mouseState.drag === "handle") return mouseState.cursor; - }; - - createEffect(() => props.onInteraction?.(mouseState.drag !== null)); - - 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; - }); - - 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(() => - resizing() && mouseState.hoveringHandle - ? calculateLabelTransform(mouseState.hoveringHandle) - : null, - ); - - function boundsToRaw(real: CropBounds) { - const scale = logicalScale(); + const [containerSize, setContainerSize] = createSignal({ x: 0, y: 0 }); + const mappedSize = createMemo(() => props.mappedSize || containerSize()); + const minSize = createMemo(() => { + const mapped = mappedSize(); 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) { - 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); - } - - 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), + x: Math.min(100, mapped.x * 0.1), + y: Math.min(100, mapped.y * 0.1), }; + }); - let bounds = boundsToRaw(startBoundsReal); - const ratioValue = aspectState.value; - if (ratioValue) - bounds = constrainBoundsToRatio(bounds, ratioValue, ORIGIN_CENTER); + const containerToMappedSizeScale = createMemo(() => { 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(); + const mapped = mappedSize(); 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, + x: container.x / mapped.x, + y: container.y / mapped.y, }; - } - - 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 displayScaledCrop = createMemo(() => { + const mapped = mappedSize(); const container = containerSize(); - const targetRaw = { - x: 0, - y: 0, - width: container.x, - height: container.y, + 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, }; - setRawBoundsAndAnimate(targetRaw); - setAspectState("snapped", null); - } + }); + let containerRef: HTMLDivElement | undefined; onMount(() => { if (!containerRef) return; - let initialized = false; - 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), - }; - - setContainerSize({ x: width, y: height }); + const updateContainerSize = () => { + setContainerSize({ + x: containerRef!.clientWidth, + y: containerRef!.clientHeight, + }); + }; - setRawBoundsConstraining(boundsToRaw(preservedReal)); + updateContainerSize(); + const resizeObserver = new ResizeObserver(updateContainerSize); + resizeObserver.observe(containerRef); + onCleanup(() => resizeObserver.disconnect()); - if (!initialized && width > 1 && height > 1) { - initialized = true; - init(); - } + const mapped = mappedSize(); + const initial = props.initialSize || { + x: mapped.x / 2, + y: mapped.y / 2, }; - createResizeObserver(containerRef, (e) => - updateContainerSize(e.width, e.height), - ); - updateContainerSize(containerRef.clientWidth, containerRef.clientHeight); + const width = clamp(initial.x, minSize().x, mapped.x); + const height = clamp(initial.y, minSize().y, mapped.y); - setDisplayRawBounds(rawBounds()); + 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); - function init() { - const bounds = computeInitialBounds(); - setRawBoundsConstraining(bounds); - setDisplayRawBounds(bounds); - setIsReady(true); - } + setCrop({ + size: { x: width, y: height }, + position: { + x: (mapped.x - width) / 2, + y: (mapped.y - height) / 2, + }, + }); + }); - 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), - }; + 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()); + }, + ), + ); - if (typeof props.ref === "function") props.ref(cropperRef); - else props.ref = cropperRef; - } + 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 }, }); - function onRegionPointerDown(e: PointerEvent) { - if (!containerRef || e.button !== 0) return; - - stopAnimation(); - e.stopPropagation(); - setMouseState({ drag: "region" }); - let currentBounds = rawBounds(); - const containerRect = containerRef.getBoundingClientRect(); - const startOffset = { - x: e.clientX - containerRect.left - currentBounds.x, - y: e.clientY - containerRect.top - currentBounds.y, - }; + 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(); - createRoot((dispose) => + createRoot((dispose) => { + const mapped = mappedSize(); createEventListenerMap(window, { - pointerup: () => { - setMouseState({ drag: null }); + mouseup: () => { + setDragging(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); + 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()); + } + }); }, - }), - ); - } - - // 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 }; + }); + }); } - type ResizeSessionState = { - startBounds: CropBounds; - isAltMode: boolean; - activeHandle: HandleSide; - originalHandle: HandleSide; - containerRect: DOMRect; - }; + function handleWheel(event: WheelEvent) { + event.preventDefault(); + const box = Box.from(position(), size()); + const mapped = mappedSize(); - 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 = context.originalHandle; - } else { - context.activeHandle = updateHandleForModeSwitch( - context.originalHandle, - context.startBounds, - pointX, - pointY, - ); - } - } + if (event.ctrlKey) { + setGestureState("isTrackpadGesture", true); - 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(), - }; + const velocity = Math.max(0.001, Math.abs(event.deltaY) * 0.001); + const scale = 1 - event.deltaY * velocity; - let nextBounds: CropBounds; - - if (ratioValue !== null) { - nextBounds = - computeAspectRatioResize( - pointX, - pointY, - context.startBounds, - context.activeHandle, - options, - ) ?? rawBounds(); + box.resize( + clamp(box.width * scale, minSize().x, mapped.x), + clamp(box.height * scale, minSize().y, mapped.y), + ORIGIN_CENTER, + ); + box.constrainAll(box, mapped, ORIGIN_CENTER, props.aspectRatio); + setTimeout(() => setGestureState("isTrackpadGesture", false), 100); + setSnappedRatio(null); } else { - const { bounds, snappedRatio } = computeFreeResize( - pointX, - pointY, - context.startBounds, - context.activeHandle, - options, + 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), ); - nextBounds = bounds; - if (snappedRatio && !aspectState.snapped) { - triggerHaptic(); - } - setAspectState("snapped", snappedRatio); } - const finalBounds = slideBoundsIntoContainer( - nextBounds, - containerSize().x, - containerSize().y, - ); + setCrop(box.toBounds()); + } - setRawBounds(finalBounds); - if (!isAnimating()) setDisplayRawBounds(finalBounds); + 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 onHandlePointerDown(handle: HandleSide, e: PointerEvent) { - if (!containerRef || e.button !== 0) return; - e.stopPropagation(); + 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; - stopAnimation(); - setMouseState({ drag: "handle", cursor: handle.cursor }); + // Adjust if height exceeds bounds + if (newHeight < minSize().y || newHeight > mapped.y) { + newHeight = clamp(newHeight, minSize().y, mapped.y); + newWidth = newHeight * currentRatio; + } - const context: ResizeSessionState = { - containerRect: containerRef.getBoundingClientRect(), - startBounds: rawBounds(), - isAltMode: e.altKey, - activeHandle: { ...handle }, - originalHandle: handle, - }; + // Resize from center + box.resize(newWidth, newHeight, ORIGIN_CENTER); - createRoot((dispose) => - createEventListenerMap(window, { - pointerup: () => { - setMouseState({ drag: null }); - // Note: may need to be added back - // setAspectState("snapped", null); - dispose(); - }, - pointermove: (e) => handleResizePointerMove(e, context), - }), - ); - } + // 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; - function onHandleDoubleClick(handle: HandleSide, e: MouseEvent) { - e.stopPropagation(); - const currentBounds = rawBounds(); - const container = containerSize(); + if (gestureState.lastTouchCenter) { + const scaleFactors = containerToMappedSizeScale(); + const dx = (centerX - gestureState.lastTouchCenter.x) / scaleFactors.x; + const dy = (centerY - gestureState.lastTouchCenter.y) / scaleFactors.y; - const newBounds = { ...currentBounds }; + box.move( + clamp(box.x + dx, 0, mapped.x - box.width), + clamp(box.y + dy, 0, mapped.y - box.height), + ); + } - if (handle.movable.top) { - newBounds.height = currentBounds.y + currentBounds.height; - newBounds.y = 0; - } - 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; - } + 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), + ); + } - setRawBoundsAndAnimate(newBounds, handle.origin); + setGestureState("lastTouchCenter", { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }); + setCrop(box.toBounds()); + } } - 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]; + 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, + }); + } + } - setMouseState({ drag: "overlay" }); + 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); + } - const containerRect = containerRef.getBoundingClientRect(); - const startPoint = { - x: e.clientX - containerRect.left, - y: e.clientY - containerRect.top, - }; + 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; + } - const startBounds: CropBounds = { - x: startPoint.x, - y: startPoint.y, - width: 1, - height: 1, + function handleResizeStart(clientX: number, clientY: number, dir: Direction) { + const origin: XY = { + x: dir.includes("w") ? 1 : 0, + y: dir.includes("n") ? 1 : 0, }; - const context: ResizeSessionState = { - containerRect, - startBounds, - isAltMode: e.altKey, - activeHandle: { ...handle }, - originalHandle: handle, - }; + let lastValidPos = { x: clientX, y: clientY }; + const box = Box.from(position(), size()); + const scaleFactors = containerToMappedSizeScale(); + const mapped = mappedSize(); createRoot((dispose) => { createEventListenerMap(window, { - pointerup: () => { - setMouseState({ drag: null }); - const bounds = rawBounds(); - if (bounds.width < 5 || bounds.height < 5) { - setRawBounds(initialBounds); - if (!isAnimating()) setDisplayRawBounds(initialBounds); - } - dispose(); - }, - pointermove: (e) => handleResizePointerMove(e, context), + 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), + ), }); }); - } - - 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 - }); - - let keyboardFrameId: number | null = null; - 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 [hapticsEnabled, hapticsEnabledOptions] = createResource( + async () => + (await generalSettingsStore.get())?.hapticsEnabled && + ostype() === "macos", + ); + generalSettingsStore.listen(() => hapticsEnabledOptions.refetch()); - const resized = resizeBounds(currentBounds, newWidth, newHeight, origin); + 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); + } - 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, - ); + const newOrigin = centerOrigin ? ORIGIN_CENTER : origin; + box.resize(newWidth, newHeight, newOrigin); - setRawBoundsConstraining(moved); + 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); + } } - - keyboardFrameId = requestAnimationFrame(keyboardActionLoop); } - function handleKeyDown(e: KeyboardEvent) { - if (!KEY_MAPPINGS.has(e.key) || mouseState.drag !== null) return; - - e.preventDefault(); - e.stopPropagation(); - - setKeyboardState("pressedKeys", (p) => p.add(e.key)); - setKeyboardState({ - shift: e.shiftKey, - alt: e.altKey, - meta: e.metaKey || e.ctrlKey, - }); - - if (!keyboardFrameId) { - stopAnimation(); - keyboardActionLoop(); - } + function setCrop(value: Crop) { + props.onCropChange(value); } - function handleKeyUp(e: KeyboardEvent) { - if ( - !KEY_MAPPINGS.has(e.key) && - !["Shift", "Alt", "Meta", "Control"].includes(e.key) - ) - return; - - e.preventDefault(); - - setKeyboardState("pressedKeys", (p) => { - p.delete(e.key); - return p; - }); + 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, + ); + } - setKeyboardState({ - shift: e.shiftKey, - alt: e.altKey, - meta: e.metaKey || e.ctrlKey, - }); + if (isUpKey || isDownKey) { + newHeight = clamp( + isUpKey + ? currentBox.size.y - moveDelta * scaleMultiplier + : currentBox.size.y + moveDelta * scaleMultiplier, + minSize().y, + mapped.y, + ); + } - if (keyboardState.pressedKeys.size === 0) { - if (keyboardFrameId) { - cancelAnimationFrame(keyboardFrameId); - keyboardFrameId = null; + 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), + ); + } } - } - } - // 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`; - } - }); - }), - ); + if (props.aspectRatio) box.constrainToRatio(props.aspectRatio, origin); + box.constrainToBoundary(mapped.x, mapped.y, origin); + setCrop(box.toBounds()); - const altDown = createKeyDownSignal(window, "Alt"); + pressedKeys.clear(); + lastKeyHandleFrame = null; + }); + } return (
fill()} + // onContextMenu={async (e) => { + // 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(); + // }} > - - - {(transform) => ( -
- {realBounds().width} x {realBounds().height} -
- )} -
-
- - {resolvedChildren()} - - {/* Occluder */} + {props.children} +