diff --git a/apps/web/package.json b/apps/web/package.json index 55813622..92ae13bb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "html-to-image": "^1.11.13", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "oxfmt": "^0.42.0", diff --git a/apps/web/src/components/ScreenshotTool.tsx b/apps/web/src/components/ScreenshotTool.tsx new file mode 100644 index 00000000..be418be8 --- /dev/null +++ b/apps/web/src/components/ScreenshotTool.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { toPng } from "html-to-image"; +import { CameraIcon, XIcon } from "lucide-react"; + +import { useScreenshotStore } from "~/screenshotStore"; +import { toastManager } from "~/components/ui/toast"; +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "~/components/ui/tooltip"; +import { cn, isMacPlatform } from "~/lib/utils"; + +// ── Types ─────────────────────────────────────────────────────────── + +interface SelectionRect { + startX: number; + startY: number; + endX: number; + endY: number; +} + +function normalizeRect(rect: SelectionRect) { + return { + x: Math.min(rect.startX, rect.endX), + y: Math.min(rect.startY, rect.endY), + width: Math.abs(rect.endX - rect.startX), + height: Math.abs(rect.endY - rect.startY), + }; +} + +// ── Capture Logic ─────────────────────────────────────────────────── + +async function captureRegion(rect: { + x: number; + y: number; + width: number; + height: number; +}): Promise { + const dpr = window.devicePixelRatio || 1; + + // Capture the full page at device resolution + const rootElement = document.documentElement; + const dataUrl = await toPng(rootElement, { + width: rootElement.scrollWidth, + height: rootElement.scrollHeight, + pixelRatio: dpr, + // Exclude our own overlay from the capture + filter: (node) => { + if (node instanceof HTMLElement && node.dataset.screenshotOverlay === "true") { + return false; + } + return true; + }, + }); + + // Load into an Image to crop + const img = await loadImage(dataUrl); + + // Crop to the selected region + const canvas = document.createElement("canvas"); + const cropX = rect.x * dpr; + const cropY = rect.y * dpr; + const cropW = rect.width * dpr; + const cropH = rect.height * dpr; + + canvas.width = cropW; + canvas.height = cropH; + + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas 2D context"); + + ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error("Canvas toBlob returned null")); + }, + "image/png", + 1.0, + ); + }); +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +async function copyBlobToClipboard(blob: Blob): Promise { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Minimum selection threshold ───────────────────────────────────── + +const MIN_SELECTION_SIZE = 8; + +// ── Selection Overlay ─────────────────────────────────────────────── + +function ScreenshotOverlay() { + const deactivate = useScreenshotStore((s) => s.deactivate); + const [selection, setSelection] = useState(null); + const [isCapturing, setIsCapturing] = useState(false); + const isDragging = useRef(false); + const overlayRef = useRef(null); + + // Cancel on Escape + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + deactivate(); + } + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [deactivate]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (isCapturing) return; + e.preventDefault(); + isDragging.current = true; + setSelection({ + startX: e.clientX, + startY: e.clientY, + endX: e.clientX, + endY: e.clientY, + }); + }, + [isCapturing], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isDragging.current || isCapturing) return; + setSelection((prev) => (prev ? { ...prev, endX: e.clientX, endY: e.clientY } : null)); + }, + [isCapturing], + ); + + const handleMouseUp = useCallback(async () => { + if (!isDragging.current || isCapturing) return; + isDragging.current = false; + + if (!selection) { + deactivate(); + return; + } + + const rect = normalizeRect(selection); + + // If the selection is too small, treat as a cancelled click + if (rect.width < MIN_SELECTION_SIZE || rect.height < MIN_SELECTION_SIZE) { + setSelection(null); + return; + } + + setIsCapturing(true); + + try { + const blob = await captureRegion(rect); + + // Copy to clipboard + await copyBlobToClipboard(blob); + + toastManager.add({ + type: "success", + title: "Screenshot copied", + description: "Image copied to clipboard", + data: { dismissAfterVisibleMs: 3000 }, + actionProps: { + children: "Save file", + onClick: () => { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + downloadBlob(blob, `screenshot-${timestamp}.png`); + }, + }, + }); + } catch (error) { + console.error("Screenshot capture failed:", error); + toastManager.add({ + type: "error", + title: "Screenshot failed", + description: error instanceof Error ? error.message : "Could not capture screenshot", + data: { dismissAfterVisibleMs: 5000 }, + }); + } finally { + setIsCapturing(false); + deactivate(); + } + }, [selection, isCapturing, deactivate]); + + const normalized = selection ? normalizeRect(selection) : null; + const hasValidSelection = + normalized && normalized.width >= MIN_SELECTION_SIZE && normalized.height >= MIN_SELECTION_SIZE; + + return ( +
+ {/* Dimmed backdrop - uses CSS clip-path to create a "hole" for the selection */} +
+ + {/* Selection rectangle border */} + {hasValidSelection && ( +
+ {/* Dimension badge */} +
+ {Math.round(normalized.width)} x {Math.round(normalized.height)} +
+
+ )} + + {/* Instructions banner */} + {!hasValidSelection && !isCapturing && ( +
+
+ + Click and drag to select an area + | + Esc + to cancel +
+
+ )} + + {/* Capturing indicator */} + {isCapturing && ( +
+
+
+ Capturing... +
+
+ )} +
+ ); +} + +// ── Screenshot Button ─────────────────────────────────────────────── + +function ScreenshotButton() { + const active = useScreenshotStore((s) => s.active); + const toggle = useScreenshotStore((s) => s.toggle); + const isMac = isMacPlatform(navigator.platform); + const shortcutLabel = isMac ? "⌘⇧S" : "Ctrl+Shift+S"; + + return ( + + + } + > + {active ? : } + + + {active ? "Cancel screenshot" : "Take screenshot"} ({shortcutLabel}) + + + ); +} + +// ── Main Export ────────────────────────────────────────────────────── + +function ScreenshotTool() { + const active = useScreenshotStore((s) => s.active); + return active ? : null; +} + +export { ScreenshotTool, ScreenshotButton }; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9b162fb2..23b5998e 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -5,6 +5,7 @@ import { type CSSProperties, useEffect } from "react"; import ThreadSidebar from "../components/Sidebar"; import { CommandPalette } from "../components/CommandPalette"; +import { ScreenshotTool, ScreenshotButton } from "../components/ScreenshotTool"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -13,6 +14,7 @@ import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useScreenshotStore } from "../screenshotStore"; import { useStore } from "../store"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useAppSettings } from "~/appSettings"; @@ -67,6 +69,7 @@ function ChatRouteGlobalShortcuts() { const paletteOpen = useCommandPaletteStore((state) => state.open); const pushMruThread = useCommandPaletteStore((state) => state.pushMruThread); const pushMruProject = useCommandPaletteStore((state) => state.pushMruProject); + const toggleScreenshot = useScreenshotStore((state) => state.toggle); const storeProjects = useStore((state) => state.projects); const storeThreads = useStore((state) => state.threads); const navigate = useNavigate(); @@ -101,6 +104,14 @@ function ChatRouteGlobalShortcuts() { return; } + // ── Screenshot: Cmd+Shift+S (Mac) / Ctrl+Shift+S (non-Mac) ── + if (key === "s" && modKey && event.shiftKey && !event.altKey && !isTerminalFocused()) { + event.preventDefault(); + event.stopPropagation(); + toggleScreenshot(); + return; + } + // ── Project switching: Cmd+1-9 (Mac) / Ctrl+1-9 (non-Mac) ─ if ( modKey && @@ -194,6 +205,7 @@ function ChatRouteGlobalShortcuts() { storeThreads, terminalOpen, togglePalette, + toggleScreenshot, appSettings.defaultThreadEnvMode, ]); @@ -236,6 +248,10 @@ function ChatRouteLayout() { + +
+ +
void; + deactivate: () => void; + toggle: () => void; +} + +type ScreenshotStore = ScreenshotState & ScreenshotActions; + +// ── Store ─────────────────────────────────────────────────────────── + +export const useScreenshotStore = create((set, get) => ({ + active: false, + + activate: () => set({ active: true }), + deactivate: () => set({ active: false }), + toggle: () => set({ active: !get().active }), +})); diff --git a/bun.lock b/bun.lock index 32a646d2..e7e15d0c 100644 --- a/bun.lock +++ b/bun.lock @@ -181,6 +181,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "html-to-image": "^1.11.13", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "oxfmt": "^0.42.0", @@ -1581,6 +1582,8 @@ "hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="], + "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],