diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..30e16a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Checks + runs-on: windows-2025 + timeout-minutes: 45 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.14.0 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check version consistency + run: pnpm version:check + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Verify native application + run: | + cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check + cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets -- -D warnings + cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..580536c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +name: Release + +on: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release + +jobs: + detect: + name: Detect version change + runs-on: ubuntu-latest + outputs: + release: ${{ steps.check.outputs.release }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for a matching tag + id: check + run: | + VERSION=$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then + echo "release=false" >> "$GITHUB_OUTPUT" + else + echo "release=true" >> "$GITHUB_OUTPUT" + fi + + build: + name: Build and publish + needs: detect + if: needs.detect.outputs.release == 'true' + runs-on: windows-2025 + timeout-minutes: 60 + env: + VERSION: ${{ needs.detect.outputs.version }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.14.0 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/desktop/src-tauri + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check version consistency + run: pnpm version:check + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Verify native application + run: | + cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check + cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets -- -D warnings + cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml + + - name: Build Windows installer + env: + CAPKIT_COMMIT: ${{ github.sha }} + run: pnpm bundle:windows + + - name: Publish GitHub release + shell: powershell + env: + GH_TOKEN: ${{ github.token }} + run: | + $installer = "apps/desktop/src-tauri/target/release/bundle/nsis/CapKit_${{ env.VERSION }}_x64-setup.exe" + if (!(Test-Path -LiteralPath $installer)) { + throw "Missing installer at $installer" + } + $hash = (Get-FileHash -Algorithm SHA256 -Path $installer).Hash.ToLowerInvariant() + $checksum = "$installer.sha256" + Set-Content -LiteralPath $checksum -Value $hash -NoNewline + gh api "repos/${{ github.repository }}/releases/generate-notes" -f tag_name="v${{ env.VERSION }}" -f target_commitish="${{ github.sha }}" --jq .body > notes.md + Add-Content -Path notes.md -Value "" + Add-Content -Path notes.md -Value "Full changelog: CHANGELOG-PUBLIC.md" + gh release create "v${{ env.VERSION }}" $installer $checksum --target "${{ github.sha }}" --title "CapKit ${{ env.VERSION }}" --notes-file notes.md + + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: CapKit-${{ env.VERSION }}-windows-x64 + path: apps/desktop/src-tauri/target/release/bundle/nsis/CapKit_${{ env.VERSION }}_x64-setup.exe + if-no-files-found: error + compression-level: 0 diff --git a/AGENTS.md b/AGENTS.md index 32e2b0f..57ab701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Also verify affected components, multi-monitor/DPI-sensitive geometry, loading, - Landing page: Next.js 16.2 App Router, React 19, TypeScript 5, Tailwind CSS 4, Framer Motion 12 - Tooling: ESLint 9, Vitest 4, Testing Library, Cargo - Targets: Windows 11 supported; macOS, Ubuntu, and Fedora provisional -- CI: no every-PR workflow is currently present; setup records the requested CI policy +- CI: `ci.yml` on every PR to master; `release.yml` publishes a GitHub Release when the version changes on master ## Commands @@ -89,6 +89,8 @@ Also verify affected components, multi-monitor/DPI-sensitive geometry, loading, | Landing lint | `pnpm.cmd --filter @snaphub/landing-page lint` | | Store package | `pnpm.cmd bundle:store` | | Store package test | `pnpm.cmd test:store` | +| Version bump | `pnpm.cmd version:bump minor` | +| Version check | `pnpm.cmd version:check` | ## Project docs diff --git a/CHANGELOG-PUBLIC.md b/CHANGELOG-PUBLIC.md index aed0bdb..0572784 100644 --- a/CHANGELOG-PUBLIC.md +++ b/CHANGELOG-PUBLIC.md @@ -2,20 +2,30 @@ User-facing changes only, grouped by date under Added / Improved / Fixed. -## 2026-09-26 +## 0.2.0 — 2026-09-26 -### Fixed +### Added -- Text in Screen Draw now appears where you click. -- Recording opens again from Record and its shortcut. +- Select tool in Screen Draw: move, resize and delete what you've drawn. It's now the default. +- Copy & Save to selected-region completion, with a configurable `A` shortcut by default. It copies the finished image first and saves the same rendered pixels to your configured folder. +- Save the whole screen with your Screen Draw annotations by pressing S. ### Improved +- Capture opens faster and closes instantly. - Settings are organized into General, Screenshots, Screen Draw and Shortcuts tabs. - Record now holds your recordings and editing; Dashboard is renamed Screenshots. -- Capture opens faster and closes instantly. - Drag pinned images anywhere with the handle in their corner. +### Fixed + +- Closing capture no longer leaves a black screen. +- Text in Screen Draw now appears where you click. +- Recording opens again from Record and its shortcut. +- The capture screen no longer shifts slightly when it opens, and saved screenshots match the selected area exactly. +- Capture no longer flashes dark when it opens. +- The Screen Draw laser follows the cursor smoothly and fades out after you release it. + ### Removed - Click-through for pinned images. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0ecde0d..32b25aa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@snaphub/desktop", - "version": "0.1.1", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index e4ab4c3..110ea20 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -604,7 +604,7 @@ dependencies = [ [[package]] name = "capkit-desktop" -version = "0.1.1" +version = "0.2.0" dependencies = [ "ab_glyph", "arboard", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 535272d..769d431 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capkit-desktop" -version = "0.1.1" +version = "0.2.0" description = "CapKit lightweight desktop toolkit to capture, record, and showcase" authors = ["CapKit"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 95112d2..9137d93 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -32,7 +32,9 @@ use tauri::{ }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; #[cfg(target_os = "windows")] -use windows::Win32::UI::WindowsAndMessaging::{AW_BLEND, AnimateWindow, SetForegroundWindow}; +use windows::Win32::UI::WindowsAndMessaging::{ + AW_BLEND, AnimateWindow, SW_HIDE, SetForegroundWindow, ShowWindow, +}; // AnimateWindow's cross-fade masks the one or two frames before WebView2's swap chain // has composited fresh content, which otherwise reads as a black flash on reveal. @@ -46,6 +48,13 @@ fn reveal_window_smoothly(window: &tauri::WebviewWindow) -> Result<(), SnaphubEr if let Ok(hwnd) = window.hwnd() { let hwnd = windows::Win32::Foundation::HWND(hwnd.0); if unsafe { AnimateWindow(hwnd, CAPTURE_REVEAL_MS, AW_BLEND) }.is_ok() { + // AnimateWindow shows the window through Win32 without telling Tauri, so + // tao's diff-based `set_visible` still believes it is hidden and a later + // `window.hide()` becomes a no-op. Calling `window.show()` on the already + // visible window only syncs tao's `VISIBLE` flag so future hides work. + window + .show() + .map_err(|error| SnaphubError::Window(error.to_string()))?; return Ok(()); } } @@ -918,6 +927,16 @@ fn hide_capture_window(app: &AppHandle) -> Result<(), SnaphubError> { window .hide() .map_err(|error| SnaphubError::Window(error.to_string()))?; + // Second safeguard for any other direct-Win32 show path (see + // `reveal_window_smoothly`): hide through Win32 as well so the overlay + // cannot stay visible when tao's `VISIBLE` flag is out of sync. + #[cfg(target_os = "windows")] + { + if let Ok(hwnd) = window.hwnd() { + let hwnd = windows::Win32::Foundation::HWND(hwnd.0); + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + } + } } Ok(()) } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2553bb8..a27c89b 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CapKit", - "version": "0.1.1", + "version": "0.2.0", "identifier": "com.snaphub.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -45,7 +45,10 @@ "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: http://asset.localhost blob: data:; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost", "assetProtocol": { "enable": true, - "scope": ["$TEMP/CapKit/**", "$PICTURE/CapKit/**"] + "scope": [ + "$TEMP/CapKit/**", + "$PICTURE/CapKit/**" + ] } } }, diff --git a/apps/desktop/src/components/OnScreenOverlay.test.tsx b/apps/desktop/src/components/OnScreenOverlay.test.tsx index a3c7690..388d5ce 100644 --- a/apps/desktop/src/components/OnScreenOverlay.test.tsx +++ b/apps/desktop/src/components/OnScreenOverlay.test.tsx @@ -126,8 +126,9 @@ describe("OnScreenOverlay", () => { render(); expect(await screen.findByRole("toolbar", { name: "On-screen drawing tools" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Select, shortcut V" })).toHaveAttribute("aria-pressed", "true"); expect(screen.getByRole("button", { name: "Pencil, shortcut 1" })).toBeVisible(); - expect(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })).toBeVisible(); expect(screen.getByRole("button", { name: "Blur, shortcut 0" })).toBeVisible(); expect(screen.getByRole("button", { name: "Clear all on-screen changes" })).toHaveClass("bg-[#ff5b4d]"); expect(screen.queryByRole("group", { name: "Drawing colors" })).not.toBeInTheDocument(); @@ -195,6 +196,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); const initialRenderCount = onRender.mock.calls.length; const dot = screen.getByTestId("on-screen-pointer-dot"); @@ -223,6 +225,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); fireEvent.pointerDown(surface, { button: 0, clientX: 120, @@ -258,6 +261,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); const pointerLayer = screen.getByTestId("on-screen-pointer-trail"); const corePath = screen.getByTestId("on-screen-pointer-core"); @@ -311,6 +315,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); drawLaserStroke(surface, frames, 1); const fadingTrail = screen.getByTestId("on-screen-fading-trail"); @@ -334,6 +339,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); vi.useFakeTimers(); const frames = createAnimationFrameQueue(); drawLaserStroke(surface, frames, 2); @@ -355,6 +361,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); for (let pointerId = 1; pointerId <= 4; pointerId += 1) { drawLaserStroke(surface, frames, pointerId); @@ -369,6 +376,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); drawLaserStroke(surface, frames, 1); expect(screen.getByTestId("on-screen-fading-trail")).toBeInTheDocument(); @@ -384,6 +392,7 @@ describe("OnScreenOverlay", () => { const surface = await screen.findByRole("application", { name: "On-screen annotation surface", }); + fireEvent.click(screen.getByRole("button", { name: "Presentation pointer, shortcut 8" })); drawLaserStroke(surface, frames, 1); expect(screen.queryByTestId("on-screen-fading-trail")).not.toBeInTheDocument(); @@ -728,9 +737,245 @@ describe("OnScreenOverlay", () => { await screen.findByRole("toolbar", { name: "On-screen drawing tools" }); const highlight = screen.getByTestId("on-screen-tool-highlight"); - expect(highlight).toHaveStyle({ transform: "translate3d(350px, 0, 0)" }); + expect(highlight).toHaveStyle({ transform: "translate3d(0px, 0, 0)" }); fireEvent.pointerEnter(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); - expect(highlight).toHaveStyle({ transform: "translate3d(50px, 0, 0)" }); + expect(highlight).toHaveStyle({ transform: "translate3d(100px, 0, 0)" }); + }); + + it("moves a selected rectangle with one update and restores it with undo", async () => { + const frames = createAnimationFrameQueue(); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 80, clientY: 90, pointerId: 4 }); + fireEvent.pointerMove(surface, { clientX: 240, clientY: 210, pointerId: 4 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 4 }); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 5 }); + fireEvent.pointerMove(surface, { clientX: 130, clientY: 120, pointerId: 5 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + + const moved = document.querySelector("[data-onscreen-object]"); + expect(moved?.getAttribute("x")).toBe("110"); + expect(moved?.getAttribute("y")).toBe("110"); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Undo on-screen change" })); + const restored = document.querySelector("[data-onscreen-object]"); + expect(restored?.getAttribute("x")).toBe("80"); + expect(restored?.getAttribute("y")).toBe("90"); + }); + + it("resizes a rectangle from its corner handle and drags only an arrow end", async () => { + const frames = createAnimationFrameQueue(); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 80, clientY: 90, pointerId: 4 }); + fireEvent.pointerMove(surface, { clientX: 240, clientY: 210, pointerId: 4 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 4 }); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 5 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + // Bottom-right handle sits at (240, 210). + fireEvent.pointerDown(surface, { button: 0, clientX: 240, clientY: 210, pointerId: 6 }); + fireEvent.pointerMove(surface, { clientX: 280, clientY: 250, pointerId: 6 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 6 }); + + const resized = document.querySelector("[data-onscreen-object]"); + expect(resized?.getAttribute("width")).toBe("200"); + expect(resized?.getAttribute("height")).toBe("160"); + + fireEvent.click(screen.getByRole("button", { name: "Arrow, shortcut 4" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 50, clientY: 50, pointerId: 7 }); + fireEvent.pointerMove(surface, { clientX: 150, clientY: 150, pointerId: 7 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 7 }); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + const lines = document.querySelectorAll("[data-onscreen-object]"); + const arrow = lines[lines.length - 1]; + expect(arrow?.tagName.toLowerCase()).toBe("line"); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 8 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 8 }); + // End handle sits at (150, 150). + fireEvent.pointerDown(surface, { button: 0, clientX: 150, clientY: 150, pointerId: 9 }); + fireEvent.pointerMove(surface, { clientX: 180, clientY: 120, pointerId: 9 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 9 }); + + const movedArrow = document.querySelectorAll("[data-onscreen-object]"); + const moved = movedArrow[movedArrow.length - 1]; + expect(moved?.getAttribute("x1")).toBe("50"); + expect(moved?.getAttribute("y1")).toBe("50"); + expect(moved?.getAttribute("x2")).toBe("180"); + expect(moved?.getAttribute("y2")).toBe("120"); + }); + + it("resizes selected text within 12–120", async () => { + const frames = createAnimationFrameQueue(); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Text, shortcut 5" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 180, clientY: 140, pointerId: 3 }); + const editor = screen.getByRole("textbox", { name: "On-screen text" }); + fireEvent.change(editor, { target: { value: "Hi" } }); + fireEvent.blur(editor); + expect(await screen.findByText("Hi")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + const textElement = screen.getByText("Hi"); + expect(textElement.getAttribute("font-size")).toBe("20"); + + fireEvent.pointerDown(surface, { button: 0, clientX: 190, clientY: 135, pointerId: 5 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); + + // Handle sits at the bottom-right of the text bounds: x=228, y=140. + fireEvent.pointerDown(surface, { button: 0, clientX: 228, clientY: 140, pointerId: 6 }); + fireEvent.pointerMove(surface, { clientX: 228, clientY: 150, pointerId: 6 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 6 }); + + expect(screen.getByText("Hi").getAttribute("font-size")).toBe("30"); + }); + + it("deletes a selected drawing with the keyboard and the delete button, undoing each", async () => { + const frames = createAnimationFrameQueue(); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 80, clientY: 90, pointerId: 4 }); + fireEvent.pointerMove(surface, { clientX: 240, clientY: 210, pointerId: 4 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 4 }); + expect(document.querySelector("[data-onscreen-object]")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 5 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "Delete" }); + expect(document.querySelector("[data-onscreen-object]")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Undo on-screen change" })); + expect(document.querySelector("[data-onscreen-object]")).not.toBeNull(); + + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 6 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 6 }); + fireEvent.click(screen.getByRole("button", { name: "Delete selected drawing" })); + expect(document.querySelector("[data-onscreen-object]")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Undo on-screen change" })); + expect(document.querySelector("[data-onscreen-object]")).not.toBeNull(); + }); + + it("deselects on empty space and only closes Screen Draw on the second Escape", async () => { + const { dismissOnScreen } = await import("../lib/tauri"); + const frames = createAnimationFrameQueue(); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 80, clientY: 90, pointerId: 4 }); + fireEvent.pointerMove(surface, { clientX: 240, clientY: 210, pointerId: 4 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 4 }); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 5 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); + + fireEvent.pointerDown(surface, { button: 0, clientX: 1000, clientY: 600, pointerId: 6 }); + expect(screen.queryByTestId("on-screen-selection")).not.toBeInTheDocument(); + + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 7 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 7 }); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByTestId("on-screen-selection")).not.toBeInTheDocument(); + expect(dismissOnScreen).not.toHaveBeenCalled(); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(dismissOnScreen).toHaveBeenCalledTimes(1); + }); + + it("switches to Select with V and ignores it while editing or with modifiers", async () => { + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + expect(screen.getByRole("button", { name: "Rectangle, shortcut 2" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.keyDown(window, { key: "v" }); + expect(screen.getByRole("button", { name: "Select, shortcut V" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByRole("button", { name: "Text, shortcut 5" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 180, clientY: 140, pointerId: 3 }); + const editor = screen.getByRole("textbox", { name: "On-screen text" }); + + fireEvent.keyDown(editor, { key: "v" }); + expect(screen.getByRole("button", { name: "Text, shortcut 5" })).toHaveAttribute("aria-pressed", "true"); + + fireEvent.keyDown(window, { key: "v", ctrlKey: true }); + expect(screen.getByRole("button", { name: "Text, shortcut 5" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("hides the selection chrome while saving the screen", async () => { + const frames = createAnimationFrameQueue(); + const savedPath = deferred(); + vi.mocked(saveOnScreenCapture).mockReturnValueOnce(savedPath.promise); + render(); + const surface = await screen.findByRole("application", { + name: "On-screen annotation surface", + }); + + fireEvent.click(screen.getByRole("button", { name: "Rectangle, shortcut 2" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 80, clientY: 90, pointerId: 4 }); + fireEvent.pointerMove(surface, { clientX: 240, clientY: 210, pointerId: 4 }); + frames.flushNextFrame(); + fireEvent.pointerUp(surface, { button: 0, pointerId: 4 }); + + fireEvent.click(screen.getByRole("button", { name: "Select, shortcut V" })); + fireEvent.pointerDown(surface, { button: 0, clientX: 100, clientY: 100, pointerId: 5 }); + fireEvent.pointerUp(surface, { button: 0, pointerId: 5 }); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: "s" }); + expect(screen.queryByTestId("on-screen-selection")).not.toBeInTheDocument(); + expect(screen.getByTestId("on-screen-dock")).toHaveStyle({ visibility: "hidden" }); + + frames.flushNextFrame(); + frames.flushNextFrame(); + await waitFor(() => expect(saveOnScreenCapture).toHaveBeenCalledTimes(1)); + await act(async () => { + savedPath.resolve("C:/Captures/CapKit.png"); + await savedPath.promise; + }); + expect(screen.getByTestId("on-screen-selection")).toBeInTheDocument(); }); it("restores runtime-validated drawings when persistence is enabled", async () => { diff --git a/apps/desktop/src/components/OnScreenOverlay.tsx b/apps/desktop/src/components/OnScreenOverlay.tsx index c3c76f7..2171f9c 100644 --- a/apps/desktop/src/components/OnScreenOverlay.tsx +++ b/apps/desktop/src/components/OnScreenOverlay.tsx @@ -10,6 +10,7 @@ import { Pencil, PresentationPointer, Redo, + SelectPointer, SquareShape, Text, Undo, @@ -27,18 +28,26 @@ import { import type { Point } from "../domain/capture"; import { onScreenToolIds, + type OnScreenDrawingToolId, type OnScreenToolId, useSnaphubSettings, } from "../domain/settings"; import { initialOnScreenHistory, + moveArrowEndpoint, normalizedObjectRect, objectAtPoint, + objectBounds, onScreenHistoryReducer, parsePersistedOnScreenScene, pointsToSvgPath, + resizeShape, + resizeText, + translateObject, + type ArrowEndpoint, type OnScreenObject, type OnScreenShapeObject, + type ResizeHandle, } from "../domain/onScreen"; import { onScreenCursor } from "../lib/cursor"; import { @@ -59,13 +68,14 @@ type SaveState = | { phase: "capturing" } | { phase: "message"; text: string; tone: "success" | "error" }; const persistedSceneStorageKey = "capkit.onscreen.scene.v1"; -const defaultOnScreenTool: OnScreenToolId = "pointer"; +const defaultOnScreenTool: OnScreenToolId = "select"; const toolCatalog: readonly { id: OnScreenToolId; label: string; icon: CapkitIconComponent; }[] = [ + { id: "select", label: "Select", icon: SelectPointer }, { id: "pencil", label: "Pencil", icon: Pencil }, { id: "rectangle", label: "Rectangle", icon: SquareShape }, { id: "ellipse", label: "Ellipse", icon: CircleShape }, @@ -78,6 +88,113 @@ const toolCatalog: readonly { { id: "blur", label: "Blur", icon: Blur }, ]; +type SelectDrag = + | { + kind: "move"; + origin: Point; + initial: OnScreenObject; + } + | { + kind: "resize"; + handle: ResizeHandle | ArrowEndpoint; + origin: Point; + initial: OnScreenObject; + } + | null; + +const SELECT_HANDLE_SIZE = 10; +const SELECT_HANDLE_HIT = 10; + +function resizeCursorForHandle(handle: ResizeHandle | ArrowEndpoint): string { + if (handle === "start" || handle === "end") return "move"; + switch (handle) { + case "nw": + case "se": + return "nwse-resize"; + case "ne": + case "sw": + return "nesw-resize"; + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + } +} + +function handlesForObject(object: OnScreenObject): readonly (ResizeHandle | ArrowEndpoint)[] { + if (object.kind === "arrow") return ["start", "end"]; + if (object.kind === "text") return ["se"]; + if (object.kind === "pencil") return []; + return ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; +} + +function handlePosition(bounds: { x: number; y: number; width: number; height: number }, handle: ResizeHandle | ArrowEndpoint, object?: OnScreenObject): Point { + if ((handle === "start" || handle === "end") && object?.kind === "arrow") { + return handle === "start" ? object.start : object.end; + } + const x2 = bounds.x + bounds.width; + const y2 = bounds.y + bounds.height; + const cx = bounds.x + bounds.width / 2; + const cy = bounds.y + bounds.height / 2; + switch (handle) { + case "nw": + return { x: bounds.x, y: bounds.y }; + case "n": + return { x: cx, y: bounds.y }; + case "ne": + return { x: x2, y: bounds.y }; + case "e": + return { x: x2, y: cy }; + case "se": + return { x: x2, y: y2 }; + case "s": + return { x: cx, y: y2 }; + case "sw": + return { x: bounds.x, y: y2 }; + case "w": + return { x: bounds.x, y: cy }; + case "start": + case "end": + return { x: x2, y: y2 }; + } +} + +function handleAtPoint(object: OnScreenObject, point: Point): (ResizeHandle | ArrowEndpoint) | null { + const bounds = objectBounds(object); + for (const handle of handlesForObject(object)) { + const center = handlePosition(bounds, handle, object); + if ( + Math.abs(point.x - center.x) <= SELECT_HANDLE_HIT && + Math.abs(point.y - center.y) <= SELECT_HANDLE_HIT + ) { + return handle; + } + } + return null; +} + +function previewSelectObject(initial: OnScreenObject, drag: Exclude, point: Point): OnScreenObject { + if (drag.kind === "move") { + return translateObject(initial, point.x - drag.origin.x, point.y - drag.origin.y); + } + if (drag.handle === "start" || drag.handle === "end") { + return moveArrowEndpoint(initial, drag.handle, point); + } + if (initial.kind === "text") { + return resizeText(initial, point.y); + } + if ( + initial.kind === "rectangle" || + initial.kind === "ellipse" || + initial.kind === "blur" + ) { + return resizeShape(initial, drag.handle, point); + } + return initial; +} + const drawingTools: readonly DrawingTool[] = [ "pencil", "rectangle", @@ -103,6 +220,10 @@ export function OnScreenOverlay(): React.JSX.Element { const [cursorPoint, setCursorPoint] = useState({ x: 0, y: 0 }); const [textEditor, setTextEditor] = useState(null); const [pointerHeld, setPointerHeld] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [selectCursor, setSelectCursor] = useState("default"); + const selectDrag = useRef(null); + const selectPreviewRef = useRef(null); const [fadingTrails, setFadingTrails] = useState<{ id: string; d: string }[]>([]); const [saveState, setSaveState] = useState({ phase: "idle" }); const [error, setError] = useState(null); @@ -136,10 +257,6 @@ export function OnScreenOverlay(): React.JSX.Element { () => onScreenCursor(settings.onScreen.cursor, settings.accentColor), [settings.accentColor, settings.onScreen.cursor], ); - const blurObjects = useMemo( - () => history.present.filter(isBlurObject), - [history.present], - ); const textEditorOpen = textEditor !== null; const isCapturing = saveState.phase === "capturing"; @@ -206,6 +323,10 @@ export function OnScreenOverlay(): React.JSX.Element { lifecycleToken.current += 1; clearSaveMessageTimer(); clearFadingTrails(); + selectDrag.current = null; + selectPreviewRef.current = null; + setSelectedId(null); + setDraft(null); void dismissOnScreen(); }, [clearFadingTrails, clearSaveMessageTimer]); @@ -285,12 +406,16 @@ export function OnScreenOverlay(): React.JSX.Element { pendingDraft.current = null; drawingPointer.current = null; drawingTool.current = null; + selectDrag.current = null; + selectPreviewRef.current = null; setDraft(null); cancelText(); pointerHeldRef.current = false; clearLivePointerPath(); pointerDotRef.current?.setAttribute("visibility", "hidden"); setPointerHeld(false); + setSelectedId(null); + setSelectCursor("default"); setActiveTool(defaultOnScreenTool); dispatch({ type: "clear" }); }, [cancelPointerFrame, cancelText, clearFadingTrails, clearLivePointerPath]); @@ -298,6 +423,13 @@ export function OnScreenOverlay(): React.JSX.Element { const selectTool = useCallback( (tool: OnScreenToolId): void => { commitText(); + selectDrag.current = null; + selectPreviewRef.current = null; + setSelectedId(null); + setSelectCursor("default"); + draftRef.current = null; + pendingDraft.current = null; + setDraft(null); setActiveTool(tool); }, [commitText], @@ -397,6 +529,17 @@ export function OnScreenOverlay(): React.JSX.Element { window.localStorage.removeItem(persistedSceneStorageKey); }, [history.present, settings.onScreen.persistDrawings]); + useEffect(() => { + if (selectedId === null) return; + if (!history.present.some((object) => object.id === selectedId)) { + selectDrag.current = null; + selectPreviewRef.current = null; + setSelectedId(null); + setSelectCursor("default"); + setDraft(null); + } + }, [history.present, selectedId]); + useEffect(() => { if ( session === null || @@ -446,6 +589,13 @@ export function OnScreenOverlay(): React.JSX.Element { cancelText(); return; } + if (selectedId !== null) { + selectDrag.current = null; + setSelectedId(null); + setSelectCursor("default"); + setDraft(null); + return; + } dismissScreenDraw(); return; } @@ -468,18 +618,64 @@ export function OnScreenOverlay(): React.JSX.Element { dispatch({ type: event.shiftKey ? "redo" : "undo" }); return; } + const hasModifiers = event.ctrlKey || event.altKey || event.metaKey; + if ( + event.key.toLowerCase() === "v" + && !hasModifiers + && !event.shiftKey + && textEditorRef.current === null + ) { + event.preventDefault(); + commitText(); + selectDrag.current = null; + setSelectedId(null); + setSelectCursor("default"); + setActiveTool("select"); + return; + } + if (textEditorRef.current !== null) return; + if (selectedId !== null && !hasModifiers) { + const selected = history.present.find((object) => object.id === selectedId) ?? null; + if (selected !== null) { + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + selectDrag.current = null; + setSelectedId(null); + setSelectCursor("default"); + setDraft(null); + dispatch({ type: "remove", id: selectedId }); + return; + } + const nudge = event.shiftKey ? 10 : 1; + let delta: Point | null = null; + if (event.key === "ArrowLeft") delta = { x: -nudge, y: 0 }; + else if (event.key === "ArrowRight") delta = { x: nudge, y: 0 }; + else if (event.key === "ArrowUp") delta = { x: 0, y: -nudge }; + else if (event.key === "ArrowDown") delta = { x: 0, y: nudge }; + if (delta !== null) { + event.preventDefault(); + dispatch({ type: "update", object: translateObject(selected, delta.x, delta.y) }); + return; + } + } + } + if (hasModifiers || event.shiftKey) return; const tool = onScreenToolIds.find( - (candidate) => settings.onScreen.toolShortcuts[candidate] === event.key, + (candidate) => + candidate !== "select" && settings.onScreen.toolShortcuts[candidate] === event.key, ); if (tool !== undefined) { event.preventDefault(); setActiveTool(tool); cancelText(); + selectDrag.current = null; + setSelectedId(null); + setSelectCursor("default"); } } window.addEventListener("keydown", handleKeyboard, true); return (): void => window.removeEventListener("keydown", handleKeyboard, true); - }, [cancelText, dismissScreenDraw, saveScreen, settings.onScreen.toolShortcuts]); + }, [cancelText, commitText, dismissScreenDraw, history.present, saveScreen, selectedId, settings.onScreen.toolShortcuts]); function localPoint(event: React.PointerEvent): Point { return { x: event.clientX, y: event.clientY }; @@ -549,6 +745,38 @@ export function OnScreenOverlay(): React.JSX.Element { const point = localPoint(event); if (toolTracksCursor(activeTool)) setCursorPoint(point); if (activeTool === "spotlight" || activeTool === "magnifier") return; + if (activeTool === "select") { + const selected = selectedId !== null + ? (history.present.find((object) => object.id === selectedId) ?? null) + : null; + if (selected !== null) { + const handle = handleAtPoint(selected, point); + if (handle !== null) { + event.currentTarget.setPointerCapture(event.pointerId); + drawingPointer.current = event.pointerId; + drawingTool.current = activeTool; + selectDrag.current = { kind: "resize", handle, origin: point, initial: selected }; + setSelectCursor(resizeCursorForHandle(handle)); + return; + } + } + const object = objectAtPoint(history.present, point); + if (object !== null) { + event.currentTarget.setPointerCapture(event.pointerId); + drawingPointer.current = event.pointerId; + drawingTool.current = activeTool; + selectDrag.current = { kind: "move", origin: point, initial: object }; + setSelectedId(object.id); + setSelectCursor("move"); + return; + } + selectDrag.current = null; + selectPreviewRef.current = null; + setSelectedId(null); + setSelectCursor("default"); + setDraft(null); + return; + } if (activeTool === "eraser") { const object = objectAtPoint(history.present, point); if (object !== null) dispatch({ type: "remove", id: object.id }); @@ -593,6 +821,28 @@ export function OnScreenOverlay(): React.JSX.Element { schedulePointerRender(points); return; } + if (activeTool === "select") { + const drag = selectDrag.current; + if (drag !== null && drawingPointer.current === event.pointerId) { + const preview = previewSelectObject(drag.initial, drag, point); + selectPreviewRef.current = preview; + scheduleDraftRender(preview); + return; + } + if (drawingPointer.current !== null) return; + const selected = selectedId !== null + ? (history.present.find((object) => object.id === selectedId) ?? null) + : null; + if (selected !== null) { + const handle = handleAtPoint(selected, point); + if (handle !== null) { + setSelectCursor(resizeCursorForHandle(handle)); + return; + } + } + setSelectCursor(objectAtPoint(history.present, point) !== null ? "move" : "default"); + return; + } if (toolTracksCursor(activeTool)) scheduleCursorRender(point); if (drawingPointer.current !== event.pointerId) return; const nextDraft = updateDraft(draftRef.current, point); @@ -616,6 +866,26 @@ export function OnScreenOverlay(): React.JSX.Element { setPointerHeld(false); return; } + if (completedTool === "select") { + const drag = selectDrag.current; + selectDrag.current = null; + if (draftFrame.current !== null) { + window.cancelAnimationFrame(draftFrame.current); + draftFrame.current = null; + } + pendingDraft.current = null; + draftRef.current = null; + setDraft(null); + const preview = selectPreviewRef.current; + selectPreviewRef.current = null; + if (drag !== null && preview !== null && preview.id === drag.initial.id) { + if (JSON.stringify(preview) !== JSON.stringify(drag.initial)) { + dispatch({ type: "update", object: preview }); + } + } + setSelectCursor("default"); + return; + } const completedDraft = draftRef.current; draftRef.current = null; pendingDraft.current = null; @@ -636,12 +906,34 @@ export function OnScreenOverlay(): React.JSX.Element { const displayWidth = session.display.bounds.width; const displayHeight = session.display.bounds.height; + const selectedObject = selectedId !== null + ? (history.present.find((object) => object.id === selectedId) ?? null) + : null; + const selectionPreview = selectedId !== null && draft !== null && draft.id === selectedId + ? draft + : null; + const previewingSelected = selectionPreview !== null; + const visiblePresent = previewingSelected + ? history.present.filter((object) => object.id !== selectedId) + : history.present; + const visibleBlurObjects = visiblePresent.filter(isBlurObject); + const surfaceCursor = activeTool === "select" ? selectCursor : cursor; + + const deleteSelected = (): void => { + if (selectedId === null) return; + selectDrag.current = null; + setSelectedId(null); + setSelectCursor("default"); + setDraft(null); + dispatch({ type: "remove", id: selectedId }); + }; + return (
event.preventDefault()} onPointerCancel={handlePointerUp} onPointerDown={handlePointerDown} @@ -665,7 +957,7 @@ export function OnScreenOverlay(): React.JSX.Element { {draft?.kind === "blur" && snapshotUrl !== null ? ( @@ -679,9 +971,16 @@ export function OnScreenOverlay(): React.JSX.Element { /> ) : null} + {selectedObject !== null && !isCapturing ? ( + + ) : null}
; } +function SelectionChrome({ + accent, + object, + onDelete, +}: { + accent: string; + object: OnScreenObject; + onDelete: () => void; +}): React.JSX.Element { + const bounds = objectBounds(object); + const handles = handlesForObject(object); + const viewportWidth = typeof window === "undefined" ? 1280 : window.innerWidth; + const deleteLeft = Math.max(8, Math.min(bounds.x + bounds.width - 16, viewportWidth - 40)); + const deleteTop = Math.max(8, bounds.y - 40); + return ( + <> + + + + ); +} + const OnScreenDock = memo(function OnScreenDock({ activeTool, canUndo, @@ -1120,7 +1486,7 @@ const OnScreenDock = memo(function OnScreenDock({ canRedo: boolean; capturing: boolean; saveDisabled: boolean; - toolShortcuts: Record; + toolShortcuts: Record; onToolChange: (tool: OnScreenToolId) => void; onUndo: () => void; onRedo: () => void; @@ -1142,7 +1508,7 @@ const OnScreenDock = memo(function OnScreenDock({ /> {toolCatalog.map((tool) => { const Icon = tool.icon; - const shortcut = toolShortcuts[tool.id]; + const shortcut = tool.id === "select" ? "V" : toolShortcuts[tool.id]; const highlighted = highlightedTool === tool.id; return ; })} diff --git a/apps/desktop/src/components/dashboard/DashboardPanel.test.tsx b/apps/desktop/src/components/dashboard/DashboardPanel.test.tsx index 2a436b0..4d86261 100644 --- a/apps/desktop/src/components/dashboard/DashboardPanel.test.tsx +++ b/apps/desktop/src/components/dashboard/DashboardPanel.test.tsx @@ -1,7 +1,11 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DashboardPanel } from "./DashboardPanel"; +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: vi.fn(() => Promise.resolve("0.2.0")), +})); + describe("DashboardPanel", () => { afterEach(() => cleanup()); @@ -10,6 +14,12 @@ describe("DashboardPanel", () => { document.documentElement.classList.remove("dark"); }); + it("shows the app version and commit in the sidebar", async () => { + render(); + + expect(await screen.findByText(/v0\.2\.0 ·/)).toBeVisible(); + }); + it("opens on the screenshots dashboard", async () => { render(); diff --git a/apps/desktop/src/components/dashboard/DashboardPanel.tsx b/apps/desktop/src/components/dashboard/DashboardPanel.tsx index 0595ea5..311436c 100644 --- a/apps/desktop/src/components/dashboard/DashboardPanel.tsx +++ b/apps/desktop/src/components/dashboard/DashboardPanel.tsx @@ -1,3 +1,4 @@ +import { getVersion } from "@tauri-apps/api/app"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { useEffect, useState } from "react"; import { useSnaphubSettings } from "../../domain/settings"; @@ -29,8 +30,23 @@ const navigation = [ export function DashboardPanel(): React.JSX.Element { const [activeTab, setActiveTab] = useState("screenshots"); const [showcaseCapture, setShowcaseCapture] = useState(null); + const [version, setVersion] = useState(null); const { settings, updateSettings } = useSnaphubSettings(); + useEffect(() => { + let cancelled = false; + void getVersion() + .then((appVersion) => { + if (!cancelled) setVersion(appVersion); + }) + .catch(() => { + if (!cancelled) setVersion(null); + }); + return (): void => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!isTauri()) return; const frame = window.requestAnimationFrame(() => { @@ -114,7 +130,7 @@ export function DashboardPanel(): React.JSX.Element { Ready in the system tray -

v0.1.1 · Preferences stay on this device

+

v{version ?? "—"} · {__CAPKIT_COMMIT__} · Preferences stay on this device

diff --git a/apps/desktop/src/components/dashboard/SettingsView.tsx b/apps/desktop/src/components/dashboard/SettingsView.tsx index 3945417..71261e2 100644 --- a/apps/desktop/src/components/dashboard/SettingsView.tsx +++ b/apps/desktop/src/components/dashboard/SettingsView.tsx @@ -23,6 +23,7 @@ import { defaultOnScreenSettings, defaultSnaphubSettings, onScreenToolIds, + type OnScreenDrawingToolId, type OnScreenToolId, type SnaphubSettings, useSnaphubSettings, @@ -41,6 +42,7 @@ import { ToolbarConfiguration } from "./ToolbarConfiguration"; const neonColors = ["#d9ff43", "#39ff88", "#39e7ff", "#7c5cff", "#ff4fd8", "#ff5b4d", "#ffb547", "#ffffff", "#171717"] as const; const accentColors = ["#d9ff43", "#39ff88", "#39e7ff", "#7c5cff", "#ff4fd8", "#ffb547"] as const; const onScreenToolLabels: Record = { + select: "Select", pencil: "Pencil", rectangle: "Rectangle", ellipse: "Ellipse", @@ -53,6 +55,10 @@ const onScreenToolLabels: Record = { blur: "Blur", }; +const configurableOnScreenToolIds: readonly OnScreenDrawingToolId[] = onScreenToolIds.filter( + (tool): tool is OnScreenDrawingToolId => tool !== "select", +); + type ShortcutField = keyof SnaphubSettings["shortcuts"]; /** @@ -301,9 +307,9 @@ export function SettingsView(): React.JSX.Element { setShortcutMessage(`${labels[field]} shortcut reset`); } - function updateOnScreenToolShortcut(tool: OnScreenToolId, value: string): void { + function updateOnScreenToolShortcut(tool: OnScreenDrawingToolId, value: string): void { const toolShortcuts = Object.fromEntries( - onScreenToolIds.map((candidate) => [ + configurableOnScreenToolIds.map((candidate) => [ candidate, candidate === tool ? value @@ -480,6 +486,7 @@ export function SettingsView(): React.JSX.Element { + update({ ...settings, palette: defaultSnaphubSettings.palette, customColors: [], annotation: defaultSnaphubSettings.annotation })} /> @@ -565,7 +572,7 @@ export function SettingsView(): React.JSX.Element { While active
- {onScreenToolIds.map((tool) => ( + {configurableOnScreenToolIds.map((tool) => (