From f8b214239992657e787522d8b27dc2795bac1641 Mon Sep 17 00:00:00 2001 From: Nakul Srivastava Date: Sat, 26 Sep 2026 22:14:30 +0530 Subject: [PATCH 1/4] fix(capture): keep the window layer in sync so the overlay hides on exit Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src-tauri/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0bc6a8e..2143787 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(()); } } @@ -933,6 +942,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(()) } From a94692d96b047247af4f5d6dab303a435939d2b4 Mon Sep 17 00:00:00 2001 From: Nakul Srivastava Date: Sat, 26 Sep 2026 22:28:32 +0530 Subject: [PATCH 2/4] feat(screen-draw): add a default Select tool to move, resize and delete drawings Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-PUBLIC.md | 10 + .../src/components/OnScreenOverlay.test.tsx | 251 ++++++++++- .../src/components/OnScreenOverlay.tsx | 390 +++++++++++++++++- .../src/components/dashboard/SettingsView.tsx | 12 +- apps/desktop/src/domain/onScreen.test.ts | 204 +++++++++ apps/desktop/src/domain/onScreen.ts | 127 ++++++ apps/desktop/src/domain/settings.ts | 3 + docs/capture-ux-spec.md | 4 +- docs/current-status.md | 2 +- docs/decision-log.md | 8 + 10 files changed, 990 insertions(+), 21 deletions(-) diff --git a/CHANGELOG-PUBLIC.md b/CHANGELOG-PUBLIC.md index 7cd4b0b..71aed63 100644 --- a/CHANGELOG-PUBLIC.md +++ b/CHANGELOG-PUBLIC.md @@ -2,6 +2,16 @@ User-facing changes only, grouped by date under Added / Improved / Fixed. +## 0.2.0 — 2026-09-26 + +### Added + +- Select tool in Screen Draw: move, resize and delete what you've drawn. It's now the default. + +### Fixed + +- Closing capture no longer leaves a black screen. + ## 2026-09-26 ### Fixed 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/SettingsView.tsx b/apps/desktop/src/components/dashboard/SettingsView.tsx index 9104320..c34ead6 100644 --- a/apps/desktop/src/components/dashboard/SettingsView.tsx +++ b/apps/desktop/src/components/dashboard/SettingsView.tsx @@ -22,6 +22,7 @@ import { defaultOnScreenSettings, defaultSnaphubSettings, onScreenToolIds, + type OnScreenDrawingToolId, type OnScreenToolId, type SnaphubSettings, useSnaphubSettings, @@ -39,6 +40,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", @@ -51,6 +53,10 @@ const onScreenToolLabels: Record = { blur: "Blur", }; +const configurableOnScreenToolIds: readonly OnScreenDrawingToolId[] = onScreenToolIds.filter( + (tool): tool is OnScreenDrawingToolId => tool !== "select", +); + type ShortcutField = keyof SnaphubSettings["shortcuts"]; export function SettingsView(): React.JSX.Element { @@ -238,9 +244,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 @@ -464,7 +470,7 @@ export function SettingsView(): React.JSX.Element { While active
- {onScreenToolIds.map((tool) => ( + {configurableOnScreenToolIds.map((tool) => (