diff --git a/Cargo.toml b/Cargo.toml index 02325d1..66ba529 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,10 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", default-features = false, features = [ "BinaryType", + "ClipboardEvent", "CloseEvent", "console", + "DataTransfer", "Document", "DomRectReadOnly", "Element", @@ -54,6 +56,7 @@ web-sys = { version = "0.3", default-features = false, features = [ "ResizeObserver", "ResizeObserverEntry", "WebSocket", + "WheelEvent", "Window", ] } diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index da0970a..ddae227 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -29,6 +29,7 @@ | `clear`(含 `ESC[3J` 清 scrollback) | `core.rs::contains_erase_scrollback` | | alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` | | 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` | +| 粘贴(Ctrl-V / 浏览器菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` | --- @@ -36,14 +37,15 @@ ### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高 -**现状**:完全没有。无法选中终端文本、无法粘贴。这是「能用 vs 好用」的分界线。 +**现状**:粘贴**已完成**(见下);选区 / 复制尚未做。 **计划实现**: -1. **粘贴(先做,最简单)** - - 监听 `on:paste`(`ClipboardEvent`),读 `clipboard_data().get_data("text")`。 - - 直接作为 `ClientTerminalMessage::Input` 发送。 - - 若应用开启 bracketed paste 模式(vt100 的 `screen().bracketed_paste()` 为真),把粘贴内容包进 `ESC[200~ … ESC[201~`,避免 vim 等把粘贴当连续输入触发自动缩进。 -2. **选区(较复杂)** +1. ~~**粘贴(先做,最简单)**~~ ✅ **已完成** + - `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。 + - bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。 + - 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`(有单元测试)。 + - 右键不拦截,走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。 +2. **选区(较复杂,下一步)** - 因为渲染是 DOM(每行一个 `
`、每段一个 ``),可优先尝试**浏览器原生选区**:给可见行允许 `user-select: text`,监听 `copy` 事件,从 `window.getSelection()` 取文本。 - 但虚拟滚动只渲染可见窗口,跨窗口选区会断 → 需要把「逻辑行号 → 文本」的映射暴露给选区逻辑,从 `TerminalCore` 的 `history_rows + screen_rows` 直接取纯文本(已有 `row` → 文本的能力,测试里 `row_text` 即是)。 - MVP 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。 diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 6c04df4..3b2bca2 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -7,8 +7,8 @@ use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use web_sys::js_sys::{Array, Function}; use web_sys::{ - BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent, - ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, + BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent, + MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, }; use super::core::{TerminalCore, TerminalRow, TerminalSegment}; @@ -386,6 +386,50 @@ pub fn TerminalPanel() -> impl IntoView { } }; + // -- Paste handler ------------------------------------------------------ + // + // Send clipboard text to the PTY. If the running application enabled + // bracketed paste (vim, many shells), the text is wrapped in + // ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and + // skip e.g. auto-indenting every pasted line). + let handle_paste = { + let paste_mode = view_mode; + let paste_prog_scroll = programmatic_scroll_top; + let paste_core = core_signal; + + move |event: ClipboardEvent| { + let Some(socket) = ws_signal.get_untracked() else { + return; + }; + let Some(raw) = event + .clipboard_data() + .and_then(|dt| dt.get_data("text").ok()) + else { + return; + }; + if raw.is_empty() { + return; + } + + let bracketed = paste_core + .try_with_untracked(|core| core.bracketed_paste()) + == Some(true); + let data = prepare_paste(&raw, bracketed); + + // Pasting is an interaction at the prompt → return to live view. + if !paste_mode.get_untracked().is_live() { + paste_mode.set(ViewMode::Live); + sync_scroll_to_bottom(terminal_ref, paste_prog_scroll); + } + + event.prevent_default(); + send_terminal_message( + &socket, + &ClientTerminalMessage::Input { data }, + ); + } + }; + let handle_click = move |_| { focus_terminal(terminal_ref); }; @@ -437,6 +481,7 @@ pub fn TerminalPanel() -> impl IntoView { on:keydown=handle_keydown on:scroll=handle_scroll on:wheel=handle_wheel + on:paste=handle_paste >