From 21bf1b2ba02700302bc76b6bf9b01e4afef364eb Mon Sep 17 00:00:00 2001 From: Is14w Date: Thu, 13 Aug 2026 22:27:09 +0800 Subject: [PATCH 1/9] fix(ui): reduce image preview wheel zoom step --- .../components/workspace-editor/WorkspaceFilePreviewOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 94f090298..cb1235be4 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -83,7 +83,7 @@ const SPREADSHEET_MAX_COLUMNS = 80; const IMAGE_PREVIEW_MIN_SCALE = 0.25; const IMAGE_PREVIEW_MAX_SCALE = 4; const IMAGE_PREVIEW_SCALE_STEP = 0.25; -const IMAGE_PREVIEW_WHEEL_SCALE_STEP = 0.1; +const IMAGE_PREVIEW_WHEEL_SCALE_STEP = 0.05; const IMAGE_PREVIEW_ENTER_ANIMATION_MS = 200; type ImagePreviewTransitionDirection = -1 | 0 | 1; From c40b8d0ba28a69a7db9471c3a85ba87cd4f73259 Mon Sep 17 00:00:00 2001 From: Is14w Date: Fri, 14 Aug 2026 00:11:40 +0800 Subject: [PATCH 2/9] feat(workspace): add complete image preview controls --- Cargo.lock | 85 ++- .../agent-ui-adapters/workspacePreview.tsx | 124 ++++ .../web/test/workspace-overlay-stack.test.mjs | 178 ++++++ crates/agent-gui/src-tauri/Cargo.toml | 4 +- .../src-tauri/src/commands/app/system.rs | 55 ++ crates/agent-gui/src-tauri/src/lib.rs | 2 + .../agent-ui-adapters/workspacePreview.tsx | 20 + .../workspace-image-preview-adapter.test.mjs | 54 ++ .../test/workspace-image-viewer.test.mjs | 79 +++ .../WorkspaceFilePreviewOverlay.tsx | 529 ++++++++++++++++-- .../workspace-editor/workspaceImageViewer.ts | 124 ++++ .../agent-ui/src/i18n/sharedTranslations.ts | 36 ++ 12 files changed, 1239 insertions(+), 51 deletions(-) create mode 100644 crates/agent-gui/test/workspace-image-preview-adapter.test.mjs create mode 100644 crates/agent-gui/test/workspace-image-viewer.test.mjs create mode 100644 crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts diff --git a/Cargo.lock b/Cargo.lock index 3bb8b3ead..ec2764e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,9 +100,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", + "image", "log", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", @@ -841,6 +844,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1022,6 +1031,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -1777,6 +1792,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2204,6 +2225,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl 0.1.12", +] + [[package]] name = "gio" version = "0.18.4" @@ -2425,6 +2456,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2826,13 +2868,27 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "tiff", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3329,6 +3385,7 @@ dependencies = [ "futures-util", "globset", "ignore", + "image", "leveldb-core", "lopdf", "notify", @@ -3415,7 +3472,7 @@ dependencies = [ "thiserror 2.0.18", "time", "ttf-parser", - "weezl", + "weezl 0.2.1", ] [[package]] @@ -4828,6 +4885,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -6825,6 +6888,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl 0.1.12", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -7800,6 +7877,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "weezl" version = "0.2.1" diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx b/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx index 376c6c34b..1793700a2 100644 --- a/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx +++ b/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx @@ -5,6 +5,130 @@ export async function readWorkspaceClipboardText() { return null; } +function base64ToBytes(data: string) { + const binary = window.atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function fileExtension(fileName: string) { + const dot = fileName.lastIndexOf("."); + return dot > 0 && dot < fileName.length - 1 ? fileName.slice(dot).toLowerCase() : null; +} + +async function drawImageBlobToCanvas(source: Blob) { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Image clipboard canvas is unavailable"); + + if (typeof createImageBitmap === "function") { + try { + const bitmap = await createImageBitmap(source); + try { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + context.drawImage(bitmap, 0, 0); + return canvas; + } finally { + bitmap.close(); + } + } catch { + // SVG and some browser-supported image codecs are not always accepted + // by createImageBitmap, so use the regular image decoder as a fallback. + } + } + + const blobUrl = URL.createObjectURL(source); + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode image for clipboard")); + image.src = blobUrl; + }); + if (!image.naturalWidth || !image.naturalHeight) { + throw new Error("Image has no drawable dimensions"); + } + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + context.drawImage(image, 0, 0); + return canvas; + } finally { + URL.revokeObjectURL(blobUrl); + } +} + +export async function saveWorkspacePreviewImage(request: { + data: string; + fileName: string; + mimeType: string; +}) { + const bytes = base64ToBytes(request.data); + const blob = new Blob([bytes.buffer], { type: request.mimeType }); + const windowWithPicker = window as Window & { + showSaveFilePicker?: (options: { + suggestedName: string; + types?: Array<{ accept: Record; description: string }>; + }) => Promise<{ + createWritable: () => Promise<{ + close: () => Promise; + write: (data: Blob) => Promise; + }>; + }>; + }; + if (windowWithPicker.showSaveFilePicker) { + try { + const extension = fileExtension(request.fileName); + const handle = await windowWithPicker.showSaveFilePicker({ + suggestedName: request.fileName, + types: extension + ? [ + { + accept: { [request.mimeType || "application/octet-stream"]: [extension] }, + description: "Image", + }, + ] + : undefined, + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + throw error; + } + } + + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = request.fileName; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0); +} + +export async function copyWorkspacePreviewImage(request: { data: string; mimeType: string }) { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error("Image clipboard is unavailable"); + } + const source = new Blob([base64ToBytes(request.data).buffer], { type: request.mimeType }); + const canvas = await drawImageBlobToCanvas(source); + const png = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("Could not encode image for clipboard")); + }, "image/png"); + }); + await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); +} + export function WorkspaceOverlayTitleBar() { return null; } diff --git a/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs b/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs index 07d44985a..27c88b709 100644 --- a/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs +++ b/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs @@ -11,7 +11,185 @@ const loader = createWebModuleLoader({ const { workspaceOverlayStackClassName } = loader.loadModule( "@liveagent/adapters/workspacePreview", ); +const preview = loader.loadModule("@liveagent/adapters/workspacePreview"); + +function restoreGlobal(name, value) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; +} + +function restorePropertyDescriptor(name, descriptor) { + if (descriptor === undefined) delete globalThis[name]; + else Object.defineProperty(globalThis, name, descriptor); +} test("web workspace overlays keep the web shell stacking level", () => { assert.equal(workspaceOverlayStackClassName, "z-40"); }); + +test("web workspace image save uses the native picker and keeps source bytes", async () => { + const previousWindow = globalThis.window; + const writes = []; + let pickerOptions = null; + globalThis.window = { + atob, + async showSaveFilePicker(options) { + pickerOptions = options; + return { + async createWritable() { + return { + async write(blob) { + writes.push(blob); + }, + async close() {}, + }; + }, + }; + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + assert.equal(pickerOptions.suggestedName, "diagram.png"); + assert.deepEqual(pickerOptions.types, [{ accept: { "image/png": [".png"] }, description: "Image" }]); + assert.equal(writes.length, 1); + assert.equal(writes[0].type, "image/png"); + assert.deepEqual([...new Uint8Array(await writes[0].arrayBuffer())], [1, 2, 3]); + } finally { + restoreGlobal("window", previousWindow); + } +}); + +test("web workspace image save treats picker cancellation as a no-op", async () => { + const previousWindow = globalThis.window; + globalThis.window = { + atob, + async showSaveFilePicker() { + throw new DOMException("cancelled", "AbortError"); + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + } finally { + restoreGlobal("window", previousWindow); + } +}); + +test("web workspace image save falls back to a browser download", async () => { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const originalCreateObjectUrl = URL.createObjectURL; + const originalRevokeObjectUrl = URL.revokeObjectURL; + const downloads = []; + const revokedUrls = []; + URL.createObjectURL = () => "blob:workspace-preview"; + URL.revokeObjectURL = (url) => revokedUrls.push(url); + globalThis.window = { + atob, + setTimeout(callback) { + callback(); + return 1; + }, + }; + globalThis.document = { + body: { + appendChild() {}, + }, + createElement(tag) { + assert.equal(tag, "a"); + return { + style: {}, + click() { + downloads.push({ href: this.href, download: this.download }); + }, + remove() {}, + }; + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + assert.deepEqual(downloads, [{ href: "blob:workspace-preview", download: "diagram.png" }]); + assert.deepEqual(revokedUrls, ["blob:workspace-preview"]); + } finally { + URL.createObjectURL = originalCreateObjectUrl; + URL.revokeObjectURL = originalRevokeObjectUrl; + restoreGlobal("window", previousWindow); + restoreGlobal("document", previousDocument); + } +}); + +test("web workspace image copy writes a PNG clipboard item", async () => { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const previousCreateImageBitmap = globalThis.createImageBitmap; + const previousClipboardItem = globalThis.ClipboardItem; + const clipboardWrites = []; + let closed = false; + globalThis.window = { atob }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard: { + async write(items) { + clipboardWrites.push(items); + }, + }, + }, + }); + globalThis.createImageBitmap = async () => ({ + width: 2, + height: 3, + close() { + closed = true; + }, + }); + globalThis.ClipboardItem = class ClipboardItem { + constructor(items) { + this.items = items; + } + }; + globalThis.document = { + createElement(tag) { + assert.equal(tag, "canvas"); + return { + width: 0, + height: 0, + getContext() { + return { drawImage() {} }; + }, + toBlob(callback, type) { + callback(new Blob(["png"], { type })); + }, + }; + }, + }; + + try { + await preview.copyWorkspacePreviewImage({ data: "AQID", mimeType: "image/png" }); + assert.equal(closed, true); + assert.equal(clipboardWrites.length, 1); + const item = clipboardWrites[0][0]; + assert.equal(item.items["image/png"].type, "image/png"); + } finally { + restoreGlobal("window", previousWindow); + restoreGlobal("document", previousDocument); + restorePropertyDescriptor("navigator", previousNavigator); + restoreGlobal("createImageBitmap", previousCreateImageBitmap); + restoreGlobal("ClipboardItem", previousClipboardItem); + } +}); diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index ab3fee027..001001d52 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -42,8 +42,8 @@ thiserror = "2.0.18" walkdir = "2.5.0" notify = "8.2.0" rfd = "0.17.2" -# Text-only clipboard reads (image-data default feature intentionally off). -arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "3.6.1", features = ["wayland-data-control"] } +image = { version = "0.25.9", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"] } tauri-plugin-mcp-bridge = "0.12.0" dirs = "6.0.0" toml = "0.9.11" diff --git a/crates/agent-gui/src-tauri/src/commands/app/system.rs b/crates/agent-gui/src-tauri/src/commands/app/system.rs index 3f89e745c..f756eb823 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/system.rs @@ -1372,6 +1372,61 @@ pub async fn system_pick_file( .map_err(|e| format!("system_pick_file join 失败:{e}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn system_save_preview_file( + file_name: String, + mime_type: Option, + data_base64: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let bytes = BASE64_STANDARD + .decode(data_base64) + .map_err(|error| format!("Invalid preview file data: {error}"))?; + // Preserve the source extension in the suggested name. A MIME type is + // not a usable native dialog extension filter by itself. + let _ = mime_type; + let Some(path) = FileDialog::new().set_file_name(&file_name).save_file() else { + return Ok(false); + }; + fs::write(path, bytes).map_err(|error| format!("Failed to save preview file: {error}"))?; + Ok(true) + }) + .await + .map_err(|e| format!("system_save_preview_file join failed: {e}"))? +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_clipboard_write_image( + mime_type: Option, + data_base64: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let mime_type = mime_type.unwrap_or_default(); + if !mime_type.to_ascii_lowercase().starts_with("image/") { + return Err("The selected workspace preview is not an image".to_string()); + } + let encoded = BASE64_STANDARD + .decode(data_base64) + .map_err(|error| format!("Invalid image clipboard data: {error}"))?; + let image = image::load_from_memory(&encoded) + .map_err(|error| format!("Failed to decode image for clipboard: {error}"))? + .to_rgba8(); + let width = usize::try_from(image.width()).map_err(|_| "Image width is too large")?; + let height = usize::try_from(image.height()).map_err(|_| "Image height is too large")?; + let mut clipboard = + arboard::Clipboard::new().map_err(|error| format!("Clipboard unavailable: {error}"))?; + clipboard + .set_image(arboard::ImageData { + width, + height, + bytes: std::borrow::Cow::Owned(image.into_raw()), + }) + .map_err(|error| format!("Failed to write image clipboard: {error}")) + }) + .await + .map_err(|e| format!("system_clipboard_write_image join failed: {e}"))? +} + #[tauri::command(rename_all = "snake_case")] pub async fn system_create_project_folder( parent: String, diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index b19e1f6bb..ef042054b 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -243,6 +243,7 @@ macro_rules! app_invoke_handler { commands::git::git_stash_pop, commands::system::system_pick_folder, commands::system::system_pick_file, + commands::system::system_save_preview_file, commands::system::system_create_project_folder, commands::system::system_import_pasted_texts, commands::system::system_import_readable_file_paths, @@ -259,6 +260,7 @@ macro_rules! app_invoke_handler { commands::system::system_begin_power_activity, commands::system::system_end_power_activity, commands::system::system_clipboard_read_text, + commands::system::system_clipboard_write_image, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, diff --git a/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx b/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx index f4bcd8bb6..614360f90 100644 --- a/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; import { readClipboardText } from "../lib/system/clipboardText"; @@ -6,6 +7,25 @@ export const workspaceOverlayStackClassName = "z-50"; export const readWorkspaceClipboardText = readClipboardText; +export async function saveWorkspacePreviewImage(request: { + data: string; + fileName: string; + mimeType: string; +}) { + await invoke("system_save_preview_file", { + data_base64: request.data, + file_name: request.fileName, + mime_type: request.mimeType, + }); +} + +export async function copyWorkspacePreviewImage(request: { data: string; mimeType: string }) { + await invoke("system_clipboard_write_image", { + data_base64: request.data, + mime_type: request.mimeType, + }); +} + export function WorkspaceOverlayTitleBar() { return ; } diff --git a/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs b/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs new file mode 100644 index 000000000..b3aa7b857 --- /dev/null +++ b/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +const invokeCalls = []; +const loader = createTsModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), + mocks: { + "@tauri-apps/api/core": { + invoke(command, args) { + invokeCalls.push({ command, args }); + return Promise.resolve(command === "system_save_preview_file" ? false : undefined); + }, + }, + "../components/MacOsTitleBarSpacer": { MacOsTitleBarSpacer: () => null }, + "../lib/system/clipboardText": { readClipboardText: async () => null }, + }, +}); + +const preview = loader.loadModule("@liveagent/adapters/workspacePreview"); + +test("desktop workspace image actions preserve Tauri command payload names", async () => { + invokeCalls.length = 0; + + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + await preview.copyWorkspacePreviewImage({ + data: "AQID", + mimeType: "image/png", + }); + + assert.deepEqual(invokeCalls, [ + { + command: "system_save_preview_file", + args: { + data_base64: "AQID", + file_name: "diagram.png", + mime_type: "image/png", + }, + }, + { + command: "system_clipboard_write_image", + args: { + data_base64: "AQID", + mime_type: "image/png", + }, + }, + ]); +}); diff --git a/crates/agent-gui/test/workspace-image-viewer.test.mjs b/crates/agent-gui/test/workspace-image-viewer.test.mjs new file mode 100644 index 000000000..c50ec54d2 --- /dev/null +++ b/crates/agent-gui/test/workspace-image-viewer.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const viewer = loader.loadModule( + "@liveagent/ui/components/workspace-editor/workspaceImageViewer.ts", +); + +const viewport = { width: 800, height: 600 }; +const image = { width: 800, height: 450 }; + +test("image viewer normalizes rotation and clamps zoom scale", () => { + assert.equal(viewer.normalizeImageViewerRotation(-90), 270); + assert.equal(viewer.normalizeImageViewerRotation(450), 90); + assert.equal(viewer.clampImageViewerScale(-1), 0.25); + assert.equal(viewer.clampImageViewerScale(9), 4); +}); + +test("image viewer centres dimensions that fit inside the viewport", () => { + assert.deepEqual(viewer.clampImageViewerPan({ x: 80, y: -80 }, { + imageSize: image, + viewportSize: viewport, + scale: 1, + rotation: 0, + }), { x: 0, y: 0 }); +}); + +test("image viewer fits a rotated image inside the viewport", () => { + assert.deepEqual(viewer.fitImageViewerSize({ width: 1600, height: 900 }, viewport, 90), { + width: 600, + height: 337.5, + }); +}); + +test("image viewer clamps panning against scaled and rotated bounds", () => { + assert.deepEqual(viewer.clampImageViewerPan({ x: 900, y: -900 }, { + imageSize: image, + viewportSize: viewport, + scale: 2, + rotation: 0, + }), { x: 400, y: -150 }); + + assert.deepEqual(viewer.clampImageViewerPan({ x: 900, y: -900 }, { + imageSize: image, + viewportSize: viewport, + scale: 2, + rotation: 90, + }), { x: 50, y: -500 }); +}); + +test("image viewer keeps the image point beneath the zoom anchor stable", () => { + const before = { scale: 1, rotation: 90, x: 30, y: -20 }; + const anchor = { x: 120, y: -60 }; + const after = viewer.zoomImageViewerAtPoint(before, 1.5, anchor, { + imageSize: { width: 1600, height: 900 }, + viewportSize: viewport, + }); + + const beforePoint = { + x: (anchor.x - before.x) / before.scale, + y: (anchor.y - before.y) / before.scale, + }; + const afterPoint = { + x: (anchor.x - after.x) / after.scale, + y: (anchor.y - after.y) / after.scale, + }; + assert.deepEqual(afterPoint, beforePoint); +}); + +test("image viewer reset restores the default fit state", () => { + assert.deepEqual(viewer.resetImageViewerState(), { + scale: 1, + rotation: 0, + x: 0, + y: 0, + }); +}); diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index cb1235be4..76a34d38e 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -1,4 +1,6 @@ import { + copyWorkspacePreviewImage, + saveWorkspacePreviewImage, supportsExternalWorkspaceOpen, WorkspaceOverlayTitleBar, workspaceOverlayStackClassName, @@ -10,10 +12,14 @@ import { import { AlertTriangle, ChevronRight, + Copy, + Download, ExternalLink, FilePenLine, FileText, + Info, Loader2, + Maximize2, Minus, Plus, RefreshCw, @@ -26,11 +32,31 @@ import { isWorkspaceEditablePreviewPath, type WorkspacePreviewKind, } from "@liveagent/ui/components/workspace-editor/workspaceImagePreview"; +import { + clampImageViewerPan, + clampImageViewerState, + fitImageViewerSize, + IMAGE_VIEWER_MAX_SCALE, + IMAGE_VIEWER_MIN_SCALE, + type ImageViewerSize, + type ImageViewerState, + normalizeImageViewerRotation, + resetImageViewerState, + zoomImageViewerAtPoint, +} from "@liveagent/ui/components/workspace-editor/workspaceImageViewer"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; import { renderAsync } from "docx-preview"; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { read, utils } from "xlsx"; export type WorkspaceFilePreviewOpenRequest = { @@ -80,12 +106,8 @@ type SpreadsheetTable = { const FILE_PREVIEW_OVERLAY_ANIMATION_MS = 180; const SPREADSHEET_MAX_ROWS = 250; const SPREADSHEET_MAX_COLUMNS = 80; -const IMAGE_PREVIEW_MIN_SCALE = 0.25; -const IMAGE_PREVIEW_MAX_SCALE = 4; -const IMAGE_PREVIEW_SCALE_STEP = 0.25; +const IMAGE_PREVIEW_SCALE_STEP = 0.1; const IMAGE_PREVIEW_WHEEL_SCALE_STEP = 0.05; -const IMAGE_PREVIEW_ENTER_ANIMATION_MS = 200; - type ImagePreviewTransitionDirection = -1 | 0 | 1; function basename(path: string) { @@ -156,15 +178,6 @@ function decodePreviewText(bytes: Uint8Array) { return new TextDecoder("utf-8").decode(bytes); } -function clampImageScale(scale: number) { - return Math.min(Math.max(scale, IMAGE_PREVIEW_MIN_SCALE), IMAGE_PREVIEW_MAX_SCALE); -} - -function normalizeRotation(degrees: number) { - const next = degrees % 360; - return next < 0 ? next + 360 : next; -} - function normalizeImagePaths(paths: string[] | undefined, activePath: string) { const seen = new Set(); const normalized: string[] = []; @@ -596,6 +609,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr activeSheetName={activeSheetName} onOpenImagePath={openImagePath} onActiveSheetNameChange={setActiveSheetName} + onActionError={setError} onRenderError={setRenderError} /> ) : loading ? ( @@ -633,6 +647,7 @@ function PreviewBody(props: { activeSheetName: string; onOpenImagePath: (path: string, direction?: ImagePreviewTransitionDirection) => void; onActiveSheetNameChange: (sheetName: string) => void; + onActionError: (message: string | null) => void; onRenderError: (message: string | null) => void; }) { const { @@ -646,6 +661,7 @@ function PreviewBody(props: { activeSheetName, onOpenImagePath, onActiveSheetNameChange, + onActionError, onRenderError, } = props; const { t } = useLocale(); @@ -685,6 +701,7 @@ function PreviewBody(props: { isSwitchingImage={isSwitchingImage} preview={preview} onOpenImagePath={onOpenImagePath} + onActionError={onActionError} /> ); } @@ -825,16 +842,18 @@ function PreviewBody(props: { function ImagePreviewToolButton(props: { label: string; disabled?: boolean; + pressed?: boolean; onClick: () => void; children: ReactNode; }) { - const { label, disabled, onClick, children } = props; + const { label, disabled, pressed, onClick, children } = props; return ( + + + + + + + + + ) : null} ); diff --git a/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts new file mode 100644 index 000000000..4318f6444 --- /dev/null +++ b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts @@ -0,0 +1,124 @@ +export type ImageViewerSize = { + width: number; + height: number; +}; + +export type ImageViewerPan = { + x: number; + y: number; +}; + +export type ImageViewerState = ImageViewerPan & { + scale: number; + rotation: number; +}; + +export const IMAGE_VIEWER_MIN_SCALE = 0.25; +export const IMAGE_VIEWER_MAX_SCALE = 4; + +function finiteSize(value: number) { + return Number.isFinite(value) && value > 0 ? value : 0; +} + +export function clampImageViewerScale(scale: number) { + return Math.min(Math.max(scale, IMAGE_VIEWER_MIN_SCALE), IMAGE_VIEWER_MAX_SCALE); +} + +export function normalizeImageViewerRotation(degrees: number) { + const next = Math.round(degrees / 90) * 90; + return ((next % 360) + 360) % 360; +} + +export function fitImageViewerSize( + naturalSize: ImageViewerSize, + viewportSize: ImageViewerSize, + rotation = 0, +): ImageViewerSize { + const naturalWidth = finiteSize(naturalSize.width); + const naturalHeight = finiteSize(naturalSize.height); + const viewportWidth = finiteSize(viewportSize.width); + const viewportHeight = finiteSize(viewportSize.height); + if (!naturalWidth || !naturalHeight || !viewportWidth || !viewportHeight) { + return { width: 0, height: 0 }; + } + + const rotatedSize = rotatedImageViewerSize( + { width: naturalWidth, height: naturalHeight }, + rotation, + ); + const ratio = Math.min(viewportWidth / rotatedSize.width, viewportHeight / rotatedSize.height); + return { + width: naturalWidth * ratio, + height: naturalHeight * ratio, + }; +} + +export function rotatedImageViewerSize(size: ImageViewerSize, rotation: number): ImageViewerSize { + const normalizedRotation = normalizeImageViewerRotation(rotation); + if (normalizedRotation === 90 || normalizedRotation === 270) { + return { width: finiteSize(size.height), height: finiteSize(size.width) }; + } + return { width: finiteSize(size.width), height: finiteSize(size.height) }; +} + +export function clampImageViewerPan( + pan: ImageViewerPan, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + scale: number; + rotation: number; + }, +): ImageViewerPan { + const viewportWidth = finiteSize(options.viewportSize.width); + const viewportHeight = finiteSize(options.viewportSize.height); + const imageSize = rotatedImageViewerSize(options.imageSize, options.rotation); + const scale = clampImageViewerScale(options.scale); + if (!viewportWidth || !viewportHeight || !imageSize.width || !imageSize.height) { + return { x: 0, y: 0 }; + } + + const maxX = Math.max(0, (imageSize.width * scale - viewportWidth) / 2); + const maxY = Math.max(0, (imageSize.height * scale - viewportHeight) / 2); + return { + x: maxX ? Math.min(Math.max(Number.isFinite(pan.x) ? pan.x : 0, -maxX), maxX) : 0, + y: maxY ? Math.min(Math.max(Number.isFinite(pan.y) ? pan.y : 0, -maxY), maxY) : 0, + }; +} + +export function clampImageViewerState( + state: ImageViewerState, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + }, +): ImageViewerState { + const scale = clampImageViewerScale(state.scale); + const rotation = normalizeImageViewerRotation(state.rotation); + const pan = clampImageViewerPan(state, { ...options, scale, rotation }); + return { ...pan, scale, rotation }; +} + +export function zoomImageViewerAtPoint( + state: ImageViewerState, + nextScale: number, + anchor: ImageViewerPan, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + }, +): ImageViewerState { + const previousScale = clampImageViewerScale(state.scale); + const scale = clampImageViewerScale(nextScale); + if (scale === previousScale) return clampImageViewerState(state, options); + + // The pointer is expressed relative to the view centre. Preserving the same + // image-space coordinate beneath it works for every 90-degree rotation. + const x = anchor.x - ((anchor.x - state.x) * scale) / previousScale; + const y = anchor.y - ((anchor.y - state.y) * scale) / previousScale; + return clampImageViewerState({ ...state, x, y, scale }, options); +} + +export function resetImageViewerState(): ImageViewerState { + return { scale: 1, rotation: 0, x: 0, y: 0 }; +} diff --git a/crates/agent-ui/src/i18n/sharedTranslations.ts b/crates/agent-ui/src/i18n/sharedTranslations.ts index cc28d8607..09029ed9c 100644 --- a/crates/agent-ui/src/i18n/sharedTranslations.ts +++ b/crates/agent-ui/src/i18n/sharedTranslations.ts @@ -959,6 +959,24 @@ export const SHARED_TRANSLATIONS = { "workspaceFilePreview.zoomOut": "缩小", "workspaceFilePreview.zoomIn": "放大", "workspaceFilePreview.rotateImage": "旋转图片", + "workspaceFilePreview.rotateLeft": "向左旋转 90 度", + "workspaceFilePreview.rotateRight": "向右旋转 90 度", + "workspaceFilePreview.resetImage": "重置视图", + "workspaceFilePreview.downloadImage": "下载/另存为", + "workspaceFilePreview.saveImageFailed": "保存图片失败", + "workspaceFilePreview.copyImage": "复制图片", + "workspaceFilePreview.copyImageFailed": "复制图片失败", + "workspaceFilePreview.copyImagePath": "复制图片路径", + "workspaceFilePreview.copyPathFailed": "复制路径失败", + "workspaceFilePreview.toggleImageInfo": "显示文件信息", + "workspaceFilePreview.imageInfo": "图片信息", + "workspaceFilePreview.fileName": "文件名", + "workspaceFilePreview.dimensions": "尺寸", + "workspaceFilePreview.fileSize": "大小", + "workspaceFilePreview.fileType": "类型", + "workspaceFilePreview.fullscreen": "全屏查看", + "workspaceFilePreview.fullscreenFailed": "进入全屏失败", + "workspaceFilePreview.imageViewer": "图片查看器", "workspaceFilePreview.imageCounter": "{index} / {total}", "workspaceFilePreview.imageUnavailable": "图片暂不可用", "settings.navSystem": "系统设置", @@ -3177,6 +3195,24 @@ export const SHARED_TRANSLATIONS = { "workspaceFilePreview.zoomOut": "Zoom out", "workspaceFilePreview.zoomIn": "Zoom in", "workspaceFilePreview.rotateImage": "Rotate image", + "workspaceFilePreview.rotateLeft": "Rotate left 90 degrees", + "workspaceFilePreview.rotateRight": "Rotate right 90 degrees", + "workspaceFilePreview.resetImage": "Reset view", + "workspaceFilePreview.downloadImage": "Download / Save as", + "workspaceFilePreview.saveImageFailed": "Failed to save image", + "workspaceFilePreview.copyImage": "Copy image", + "workspaceFilePreview.copyImageFailed": "Failed to copy image", + "workspaceFilePreview.copyImagePath": "Copy image path", + "workspaceFilePreview.copyPathFailed": "Failed to copy path", + "workspaceFilePreview.toggleImageInfo": "Show file information", + "workspaceFilePreview.imageInfo": "Image information", + "workspaceFilePreview.fileName": "File name", + "workspaceFilePreview.dimensions": "Dimensions", + "workspaceFilePreview.fileSize": "Size", + "workspaceFilePreview.fileType": "Type", + "workspaceFilePreview.fullscreen": "View full screen", + "workspaceFilePreview.fullscreenFailed": "Failed to enter full screen", + "workspaceFilePreview.imageViewer": "Image viewer", "workspaceFilePreview.imageCounter": "{index} / {total}", "workspaceFilePreview.imageUnavailable": "Image unavailable", "settings.navSystem": "System", From 9eaafda7d1a2b3ff29b8612c834fa042e4bcf04c Mon Sep 17 00:00:00 2001 From: Is14w Date: Fri, 14 Aug 2026 10:08:56 +0800 Subject: [PATCH 3/9] fix(workspace): refine image preview interactions --- .../test/workspace-image-viewer.test.mjs | 24 ++ .../WorkspaceFilePreviewOverlay.tsx | 402 ++++++++++-------- .../workspace-editor/workspaceImageViewer.ts | 22 +- 3 files changed, 275 insertions(+), 173 deletions(-) diff --git a/crates/agent-gui/test/workspace-image-viewer.test.mjs b/crates/agent-gui/test/workspace-image-viewer.test.mjs index c50ec54d2..034de4d4e 100644 --- a/crates/agent-gui/test/workspace-image-viewer.test.mjs +++ b/crates/agent-gui/test/workspace-image-viewer.test.mjs @@ -18,6 +18,30 @@ test("image viewer normalizes rotation and clamps zoom scale", () => { assert.equal(viewer.clampImageViewerScale(9), 4); }); +test("image viewer scales proportionally for buttons and wheel input", () => { + assert.equal(viewer.imageViewerScaleAfterStep(1, 1), 1.05); + assert.equal(viewer.imageViewerScaleAfterStep(1, -1), 1 / 1.05); + assert.equal(viewer.imageViewerScaleAfterStep(0.25, -1), 0.25); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, -100, 0), 1.05); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, 100, 0), 1 / 1.05); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, -6.25, 1), 1.05); +}); + +test("image viewer retains continuous angles for rotation transitions", () => { + const rotatedLeft = viewer.clampImageViewerState( + { scale: 1, rotation: -90, x: 0, y: 0 }, + { imageSize: image, viewportSize: viewport }, + ); + const rotatedAcrossBoundary = viewer.clampImageViewerState( + { scale: 1, rotation: 360, x: 0, y: 0 }, + { imageSize: image, viewportSize: viewport }, + ); + + assert.equal(rotatedLeft.rotation, -90); + assert.equal(rotatedAcrossBoundary.rotation, 360); + assert.deepEqual(viewer.rotatedImageViewerSize(image, 360), image); +}); + test("image viewer centres dimensions that fit inside the viewport", () => { assert.deepEqual(viewer.clampImageViewerPan({ x: 80, y: -80 }, { imageSize: image, diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 76a34d38e..55e6728d6 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -40,7 +40,8 @@ import { IMAGE_VIEWER_MIN_SCALE, type ImageViewerSize, type ImageViewerState, - normalizeImageViewerRotation, + imageViewerScaleAfterStep, + imageViewerScaleAfterWheelDelta, resetImageViewerState, zoomImageViewerAtPoint, } from "@liveagent/ui/components/workspace-editor/workspaceImageViewer"; @@ -57,6 +58,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { read, utils } from "xlsx"; export type WorkspaceFilePreviewOpenRequest = { @@ -106,8 +108,6 @@ type SpreadsheetTable = { const FILE_PREVIEW_OVERLAY_ANIMATION_MS = 180; const SPREADSHEET_MAX_ROWS = 250; const SPREADSHEET_MAX_COLUMNS = 80; -const IMAGE_PREVIEW_SCALE_STEP = 0.1; -const IMAGE_PREVIEW_WHEEL_SCALE_STEP = 0.05; type ImagePreviewTransitionDirection = -1 | 0 | 1; function basename(path: string) { @@ -961,16 +961,22 @@ function WorkspaceImagePreviewBody(props: { const viewerOptions = useMemo(() => ({ imageSize, viewportSize }), [imageSize, viewportSize]); const displayDimensions = formatImageDimensions(naturalSize.width, naturalSize.height); const canPan = - clampImageViewerPan({ x: 1_000_000, y: 1_000_000 }, { - ...viewerOptions, - scale: viewerState.scale, - rotation: viewerState.rotation, - }).x > 0 || - clampImageViewerPan({ x: 1_000_000, y: 1_000_000 }, { - ...viewerOptions, - scale: viewerState.scale, - rotation: viewerState.rotation, - }).y > 0; + clampImageViewerPan( + { x: 1_000_000, y: 1_000_000 }, + { + ...viewerOptions, + scale: viewerState.scale, + rotation: viewerState.rotation, + }, + ).x > 0 || + clampImageViewerPan( + { x: 1_000_000, y: 1_000_000 }, + { + ...viewerOptions, + scale: viewerState.scale, + rotation: viewerState.rotation, + }, + ).y > 0; const openImageAt = useCallback( (index: number) => { @@ -1018,23 +1024,6 @@ function WorkspaceImagePreviewBody(props: { setViewerState((current) => clampImageViewerState(current, viewerOptions)); }, [viewerOptions]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape" && contextMenu) { - event.preventDefault(); - setContextMenu(null); - setContextMenuPosition(null); - return; - } - if (event.key === "0") { - event.preventDefault(); - setViewerState(resetImageViewerState()); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [contextMenu]); - useEffect(() => { if (!contextMenu) return; const onPointerDown = (event: PointerEvent) => { @@ -1049,21 +1038,45 @@ function WorkspaceImagePreviewBody(props: { const enterTranslateX = transitionDirection > 0 ? 18 : transitionDirection < 0 ? -18 : 0; const enterScale = transitionDirection === 0 ? 0.985 : 0.99; - const updateZoom = useCallback( - (nextScale: number, anchor = { x: 0, y: 0 }) => { - setViewerState((current) => zoomImageViewerAtPoint(current, nextScale, anchor, viewerOptions)); + const zoomByStep = useCallback( + (direction: -1 | 1, anchor = { x: 0, y: 0 }) => { + setViewerState((current) => + zoomImageViewerAtPoint( + current, + imageViewerScaleAfterStep(current.scale, direction), + anchor, + viewerOptions, + ), + ); }, [viewerOptions], ); - const rotateImage = useCallback((direction: -1 | 1) => { - setViewerState((current) => - clampImageViewerState( - { ...current, rotation: normalizeImageViewerRotation(current.rotation + direction * 90) }, - viewerOptions, - ), - ); - }, [viewerOptions]); + const zoomByWheel = useCallback( + (deltaY: number, deltaMode: number, anchor: { x: number; y: number }) => { + setViewerState((current) => + zoomImageViewerAtPoint( + current, + imageViewerScaleAfterWheelDelta(current.scale, deltaY, deltaMode), + anchor, + viewerOptions, + ), + ); + }, + [viewerOptions], + ); + + const rotateImage = useCallback( + (direction: -1 | 1) => { + setViewerState((current) => + clampImageViewerState( + { ...current, rotation: current.rotation + direction * 90 }, + viewerOptions, + ), + ); + }, + [viewerOptions], + ); const handleCopyImage = useCallback(async () => { try { @@ -1105,6 +1118,37 @@ function WorkspaceImagePreviewBody(props: { } }, [onActionError, t]); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (contextMenu) { + event.preventDefault(); + setContextMenu(null); + setContextMenuPosition(null); + return; + } + if (showInfo) { + event.preventDefault(); + setShowInfo(false); + return; + } + } + + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "c") { + event.preventDefault(); + void handleCopyImage(); + return; + } + + if (event.key === "0") { + event.preventDefault(); + setViewerState(resetImageViewerState()); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [contextMenu, handleCopyImage, showInfo]); + return (
@@ -1129,7 +1173,7 @@ function WorkspaceImagePreviewBody(props: { updateZoom(viewerState.scale - IMAGE_PREVIEW_SCALE_STEP)} + onClick={() => zoomByStep(-1)} > @@ -1139,7 +1183,7 @@ function WorkspaceImagePreviewBody(props: { updateZoom(viewerState.scale + IMAGE_PREVIEW_SCALE_STEP)} + onClick={() => zoomByStep(1)} > @@ -1199,11 +1243,7 @@ function WorkspaceImagePreviewBody(props: { onWheel={(event) => { if (event.deltaY === 0) return; event.preventDefault(); - const direction = event.deltaY < 0 ? 1 : -1; - updateZoom( - viewerState.scale + direction * IMAGE_PREVIEW_WHEEL_SCALE_STEP, - imageViewerAnchor(event, viewportRef.current), - ); + zoomByWheel(event.deltaY, event.deltaMode, imageViewerAnchor(event, viewportRef.current)); }} onPointerDown={(event) => { if (contextMenu) { @@ -1292,8 +1332,23 @@ function WorkspaceImagePreviewBody(props: {
{showInfo ? ( -
-
{t("workspaceFilePreview.imageInfo")}
+
+
+
{t("workspaceFilePreview.imageInfo")}
+ +
{t("workspaceFilePreview.fileName")}
@@ -1310,128 +1365,131 @@ function WorkspaceImagePreviewBody(props: {
) : null} - {contextMenu ? ( -
event.stopPropagation()} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - > - - - - - - - - -
- ) : null} + {contextMenu + ? createPortal( +
event.stopPropagation()} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + + + + + + + + +
, + document.body, + ) + : null}
); diff --git a/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts index 4318f6444..ab7cad89a 100644 --- a/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts +++ b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts @@ -15,6 +15,9 @@ export type ImageViewerState = ImageViewerPan & { export const IMAGE_VIEWER_MIN_SCALE = 0.25; export const IMAGE_VIEWER_MAX_SCALE = 4; +export const IMAGE_VIEWER_ZOOM_RATIO = 1.05; + +const IMAGE_VIEWER_WHEEL_ZOOM_SENSITIVITY = Math.log(IMAGE_VIEWER_ZOOM_RATIO) / 100; function finiteSize(value: number) { return Number.isFinite(value) && value > 0 ? value : 0; @@ -24,6 +27,21 @@ export function clampImageViewerScale(scale: number) { return Math.min(Math.max(scale, IMAGE_VIEWER_MIN_SCALE), IMAGE_VIEWER_MAX_SCALE); } +export function imageViewerScaleAfterStep(scale: number, direction: -1 | 1) { + const currentScale = clampImageViewerScale(scale); + return clampImageViewerScale(currentScale * IMAGE_VIEWER_ZOOM_RATIO ** direction); +} + +export function imageViewerScaleAfterWheelDelta(scale: number, deltaY: number, deltaMode: number) { + const currentScale = clampImageViewerScale(scale); + if (!Number.isFinite(deltaY) || deltaY === 0) return currentScale; + + const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 100 : deltaY; + return clampImageViewerScale( + currentScale * Math.exp(-pixels * IMAGE_VIEWER_WHEEL_ZOOM_SENSITIVITY), + ); +} + export function normalizeImageViewerRotation(degrees: number) { const next = Math.round(degrees / 90) * 90; return ((next % 360) + 360) % 360; @@ -94,7 +112,9 @@ export function clampImageViewerState( }, ): ImageViewerState { const scale = clampImageViewerScale(state.scale); - const rotation = normalizeImageViewerRotation(state.rotation); + // Keep the angle continuous for CSS transitions. Geometry helpers normalize it + // independently, so 360 degrees still has the same dimensions as 0 degrees. + const rotation = Number.isFinite(state.rotation) ? Math.round(state.rotation / 90) * 90 : 0; const pan = clampImageViewerPan(state, { ...options, scale, rotation }); return { ...pan, scale, rotation }; } From 16c84ede097500318e5424645bd92f782edf04bb Mon Sep 17 00:00:00 2001 From: Is14w Date: Fri, 14 Aug 2026 10:20:45 +0800 Subject: [PATCH 4/9] feat(workspace): open images in system viewer --- .../WorkspaceFilePreviewOverlay.tsx | 73 +++++++++++++++---- .../agent-ui/src/i18n/sharedTranslations.ts | 2 + 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 55e6728d6..f799640dc 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -492,8 +492,9 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr ); const canOpenEditor = Boolean(activePreviewRequest && isWorkspaceEditablePreviewPath(activePath)); const canOpenExternal = Boolean( - supportsExternalWorkspaceOpen && activePreviewRequest && activePath && !canOpenEditor, + supportsExternalWorkspaceOpen && activePreviewRequest && activePath, ); + const showHeaderOpenExternal = canOpenExternal && !canOpenEditor && kind !== "image"; const openImagePath = useCallback( (path: string, transitionDirection: ImagePreviewTransitionDirection = 0) => { @@ -554,7 +555,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr ) : null} - {canOpenExternal ? ( + {showHeaderOpenExternal ? ( + {canOpenInSystemViewer ? ( + + ) : null} + + ); }); From ebd00a10dacf7a7ede2524aba533b3df6f001b18 Mon Sep 17 00:00:00 2001 From: Is14w Date: Fri, 14 Aug 2026 11:26:00 +0800 Subject: [PATCH 8/9] fix(workspace): fill file row hit area --- crates/agent-gui/test/workspace-file-tree-row.test.mjs | 1 + crates/agent-ui/src/components/project-tools/file-tree/Row.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/agent-gui/test/workspace-file-tree-row.test.mjs b/crates/agent-gui/test/workspace-file-tree-row.test.mjs index aae522a53..54d28321d 100644 --- a/crates/agent-gui/test/workspace-file-tree-row.test.mjs +++ b/crates/agent-gui/test/workspace-file-tree-row.test.mjs @@ -72,6 +72,7 @@ test("workspace file tree opens files from anywhere in the hovered row", () => { const { tree, calls } = renderRow(); assert.equal(tree.props.onDoubleClick, undefined); + assert.match(tree.props.className, /\bw-full\b/); assert.equal(typeof tree.props.onClick, "function"); tree.props.onClick(); diff --git a/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx b/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx index 92f01d699..c7df3b6b9 100644 --- a/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx +++ b/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx @@ -61,7 +61,7 @@ export const FileTreeRow = memo(function FileTreeRow(props: FileTreeRowProps) { aria-expanded={kind === "dir" ? expanded : undefined} tabIndex={0} className={cn( - "group flex cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-xs leading-5 text-muted-foreground hover:bg-muted/70 hover:text-foreground", + "group flex w-full cursor-pointer select-none items-center gap-1 rounded-md pr-2 text-xs leading-5 text-muted-foreground hover:bg-muted/70 hover:text-foreground", selected && "bg-muted text-foreground", )} style={{ height: FILE_TREE_ROW_HEIGHT, paddingLeft: 6 + depth * 14 }} From f616828b08756ed9798707c1261079b41756d0f9 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 14 Aug 2026 12:54:01 +0800 Subject: [PATCH 9/9] fix(workspace): scope image viewer shortcuts to the focused viewport The image preview registered window-level keydown handlers that preventDefault-ed bare "0" and Ctrl/Cmd+C globally, breaking digit input and text copy in the sidebar and composer while the preview was open. Move both shortcuts onto the focusable viewport element (focused on pointer down) and keep only the Escape handling for the context menu and info panel on window, guarded by defaultPrevented. Also compute the pan bound once for canPan and drop the now-unused workspaceFilePreview.rotateImage i18n key. --- .../WorkspaceFilePreviewOverlay.tsx | 77 ++++++++++--------- .../src/i18n/translations/enUSCommon.ts | 1 - .../src/i18n/translations/zhCNCommon.ts | 1 - 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 1aee3ae82..5e5c1eda0 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -979,23 +979,15 @@ function WorkspaceImagePreviewBody(props: { ); const viewerOptions = useMemo(() => ({ imageSize, viewportSize }), [imageSize, viewportSize]); const displayDimensions = formatImageDimensions(naturalSize.width, naturalSize.height); - const canPan = - clampImageViewerPan( - { x: 1_000_000, y: 1_000_000 }, - { - ...viewerOptions, - scale: viewerState.scale, - rotation: viewerState.rotation, - }, - ).x > 0 || - clampImageViewerPan( - { x: 1_000_000, y: 1_000_000 }, - { - ...viewerOptions, - scale: viewerState.scale, - rotation: viewerState.rotation, - }, - ).y > 0; + const maxPan = clampImageViewerPan( + { x: 1_000_000, y: 1_000_000 }, + { + ...viewerOptions, + scale: viewerState.scale, + rotation: viewerState.rotation, + }, + ); + const canPan = maxPan.x > 0 || maxPan.y > 0; const openImageAt = useCallback( (index: number) => { @@ -1147,34 +1139,21 @@ function WorkspaceImagePreviewBody(props: { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - if (contextMenu) { - event.preventDefault(); - setContextMenu(null); - setContextMenuPosition(null); - return; - } - if (showInfo) { - event.preventDefault(); - setShowInfo(false); - return; - } - } - - if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "c") { + if (event.key !== "Escape" || event.defaultPrevented) return; + if (contextMenu) { event.preventDefault(); - void handleCopyImage(); + setContextMenu(null); + setContextMenuPosition(null); return; } - - if (event.key === "0") { + if (showInfo) { event.preventDefault(); - setViewerState(resetImageViewerState()); + setShowInfo(false); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [contextMenu, handleCopyImage, showInfo]); + }, [contextMenu, showInfo]); return (
@@ -1271,16 +1250,38 @@ function WorkspaceImagePreviewBody(props: { ref={viewportRef} role="application" aria-label={t("workspaceFilePreview.imageViewer")} + // biome-ignore lint/a11y/noNoninteractiveTabindex: The application-role viewport takes focus so its copy/reset shortcuts stay scoped to it instead of a window listener. + tabIndex={0} className={cn( - "relative min-h-0 flex-1 touch-none select-none overflow-hidden", + "relative min-h-0 flex-1 touch-none select-none overflow-hidden focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", isDragging ? "cursor-grabbing" : canPan ? "cursor-grab" : "cursor-default", )} + onKeyDown={(event) => { + // Scoped to the focused viewport so chat/sidebar inputs keep native + // copy and digit keys while the preview is open. + if (event.currentTarget !== event.target || event.defaultPrevented) return; + if ( + (event.ctrlKey || event.metaKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "c" + ) { + event.preventDefault(); + void handleCopyImage(); + return; + } + if (event.key === "0" && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + setViewerState(resetImageViewerState()); + } + }} onWheel={(event) => { if (event.deltaY === 0) return; event.preventDefault(); zoomByWheel(event.deltaY, event.deltaMode, imageViewerAnchor(event, viewportRef.current)); }} onPointerDown={(event) => { + event.currentTarget.focus({ preventScroll: true }); if (contextMenu) { setContextMenu(null); setContextMenuPosition(null); diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index 71a2cd17a..41aee2967 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -1001,7 +1001,6 @@ export const EN_US_COMMON_TRANSLATIONS = { "workspaceFilePreview.nextImage": "Next image", "workspaceFilePreview.zoomOut": "Zoom out", "workspaceFilePreview.zoomIn": "Zoom in", - "workspaceFilePreview.rotateImage": "Rotate image", "workspaceFilePreview.rotateLeft": "Rotate left 90 degrees", "workspaceFilePreview.rotateRight": "Rotate right 90 degrees", "workspaceFilePreview.resetImage": "Reset view", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index e66169494..74fb60c20 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -954,7 +954,6 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "workspaceFilePreview.nextImage": "下一张", "workspaceFilePreview.zoomOut": "缩小", "workspaceFilePreview.zoomIn": "放大", - "workspaceFilePreview.rotateImage": "旋转图片", "workspaceFilePreview.rotateLeft": "向左旋转 90 度", "workspaceFilePreview.rotateRight": "向右旋转 90 度", "workspaceFilePreview.resetImage": "重置视图",