From 8863977c0dc9f4bb6cf21957fa87773742e0d667 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 17 Jun 2026 16:24:32 +0800 Subject: [PATCH] feat(terminal): text selection + copy (custom model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag to select terminal text and copy it with Ctrl-Shift-C. Built as a custom selection model (xterm.js-style) rather than native selection, because the virtual scroll only keeps ~60 rows in the DOM and rebuilds them every frame — native selection would be destroyed and couldn't span scrolled-out rows. core.rs: - selection_text(start, end) extracts a (row,col)→(row,col) range straight from history_rows + screen_cache, so copy is correct across the whole buffer regardless of what is currently rendered - TerminalRow::text_in_cols slices a row by grid columns (using the segment `cols` widths), trimming trailing whitespace component.rs: - Selection { anchor, head } in logical buffer coords; mousedown/move/up drag updates it; point_to_cell maps pixels→cell (padding + scrollTop) - selection_rects computes per-row highlight rects clipped to the rendered window; rendered as an overlay inside the translated rows layer so the highlight follows scrolling without splitting text segments - cell_size signal kept in sync with viewport measurement - Ctrl-Shift-C copies via navigator.clipboard.write_text; Ctrl-C is left untouched (still SIGINT); typing clears the selection style: .terminal-selection overlay, user-select:none on the grid, rows above the highlight via z-index. web-sys: add DomRect, MouseEvent, Navigator, Clipboard. Adds point_to_cell / selection_rects / selection_text unit tests (16 terminal tests total). Marks P0 done in TERMINAL_ROADMAP.md. Known follow-up: no auto-scroll when dragging past the viewport edge (scroll first, then select); wide+combining column slicing is approximate. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 4 + TERMINAL_ROADMAP.md | 27 +-- app/src/terminal/component.rs | 300 +++++++++++++++++++++++++++++++++- app/src/terminal/core.rs | 107 ++++++++++++ style/tailwind.css | 10 ++ 5 files changed, 431 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 66ba529..62d47b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,11 +39,13 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", default-features = false, features = [ "BinaryType", + "Clipboard", "ClipboardEvent", "CloseEvent", "console", "DataTransfer", "Document", + "DomRect", "DomRectReadOnly", "Element", "ErrorEvent", @@ -53,6 +55,8 @@ web-sys = { version = "0.3", default-features = false, features = [ "KeyboardEvent", "Location", "MessageEvent", + "MouseEvent", + "Navigator", "ResizeObserver", "ResizeObserverEntry", "WebSocket", diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index ddae227..b1630a2 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -30,28 +30,31 @@ | 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` | +| 文本选区 + 复制(自建模型,Ctrl-Shift-C,跨滚动) | `component.rs` 鼠标处理 / `core.rs::selection_text` | --- ## 2. 追平项(按优先级) -### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高 +### P0 — 复制 / 粘贴 + 文本选区 ⭐ ✅ 已完成 -**现状**:粘贴**已完成**(见下);选区 / 复制尚未做。 +**现状**:粘贴、选区、复制**全部完成并验证通过**。 -**计划实现**: -1. ~~**粘贴(先做,最简单)**~~ ✅ **已完成** +**已实现**: +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 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。 -3. **快捷键**:Ctrl-Shift-C / Ctrl-Shift-V(终端惯例,避免和 Ctrl-C 中断冲突)。 + - 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`。 + - 右键走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。 +2. **选区 + 复制** ✅(自建选区模型,xterm.js 风格) + - 选区按**逻辑行列坐标**跟踪(`Selection { anchor, head }`),鼠标 `mousedown/move/up` 拖拽更新;`point_to_cell` 做像素→单元格换算(含 padding/scroll)。 + - 高亮 overlay:`selection_rects` 算每行矩形,渲染在虚拟滚动层内(共享 translateY,跟随滚动),不拆分文本段。 + - 复制取 `TerminalCore::selection_text(start, end)` —— 直接从 `history_rows + screen_cache` 数据模型按行列取文本,**跨滚动、输出流动时都正确**(不依赖 DOM)。 + - **Ctrl-Shift-C** 复制(Ctrl-C 保持 SIGINT);打字清除选区。 + - 实现于 `component.rs`(`Selection`/`selection_rects`/`point_to_cell`/鼠标处理)+ `core.rs::selection_text` + `core.rs::TerminalRow::text_in_cols`,均有单元测试。 +3. **快捷键**:Ctrl-Shift-C 复制 / Ctrl-V 粘贴。 -**风险**:选区与虚拟滚动的交互是主要难点;建议 MVP 限定可见区,先把粘贴 + 可见区复制跑通。 +**已知后续项(非阻塞)**:拖拽到视窗边缘时**没有自动滚动延伸选区**(需先滚到位再选);行内 wide+组合字符混排的列切片为近似。 --- diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 3b2bca2..d096e92 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -8,7 +8,8 @@ use wasm_bindgen::closure::Closure; use web_sys::js_sys::{Array, Function}; use web_sys::{ BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent, - MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent, + MessageEvent, MouseEvent, ResizeObserver, ResizeObserverEntry, WebSocket, + WheelEvent, }; use super::core::{TerminalCore, TerminalRow, TerminalSegment}; @@ -27,6 +28,35 @@ const SCROLL_LINE_HEIGHT: f64 = 18.0; /// scrolling doesn't flash blank areas before the next frame arrives. const VIRTUAL_OVERSCAN: usize = 5; +/// `.terminal-screen` inner padding in px (CSS `padding: 16px`). Mouse +/// coordinates subtract this to map onto the cell grid. +const TERMINAL_PADDING: f64 = 16.0; + +/// A text selection in logical buffer coordinates `(row, col)`. `anchor` is +/// where the drag began, `head` follows the pointer; either may be the +/// top-left depending on drag direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Selection { + anchor: (usize, usize), + head: (usize, usize), +} + +impl Selection { + /// (top-left, bottom-right) ordered for rendering / extraction. + fn ordered(self) -> ((usize, usize), (usize, usize)) { + if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } + + /// True when the selection is empty (anchor == head): nothing to copy. + fn is_empty(self) -> bool { + self.anchor == self.head + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct TerminalViewport { cols: u16, @@ -81,6 +111,14 @@ pub fn TerminalPanel() -> impl IntoView { // the virtual-scroll render closure knows which window to materialise. let scroll_top = RwSignal::new(0_i32); + // Measured cell size (width, height) in px, kept in sync with the viewport + // measurement. Used to map mouse coordinates onto the logical cell grid. + let cell_size = RwSignal::new((DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT)); + + // Active text selection in buffer coords, and whether a drag is in progress. + let selection = RwSignal::new(None::); + let selecting = RwSignal::new(false); + // -- WebSocket lifecycle ------------------------------------------------ Effect::new(move |_| { @@ -110,6 +148,7 @@ pub fn TerminalPanel() -> impl IntoView { let viewport = measure_terminal_viewport(open_ref, open_measure); open_viewport.set(viewport); + cell_size.set(measure_cell_size(open_measure)); open_core .update_untracked(|core| core.resize(viewport.rows, viewport.cols)); @@ -241,6 +280,7 @@ pub fn TerminalPanel() -> impl IntoView { if viewport != resize_viewport.get_untracked() { resize_viewport.set(viewport); + cell_size.set(measure_cell_size(resize_measure)); let mode_val = resize_mode.get_untracked(); let st = resize_st.get_untracked(); @@ -283,8 +323,31 @@ pub fn TerminalPanel() -> impl IntoView { let handle_keydown = { let keydown_mode = view_mode; let keydown_prog_scroll = programmatic_scroll_top; + let keydown_selection = selection; + let keydown_core = core_signal; move |event: KeyboardEvent| { + // Ctrl-Shift-C copies the current selection (Ctrl-C is left alone so + // it still sends SIGINT). + if event.ctrl_key() + && event.shift_key() + && matches!(event.key().as_str(), "c" | "C") + { + if let Some(sel) = + keydown_selection.get_untracked().filter(|s| !s.is_empty()) + { + let (start, end) = sel.ordered(); + let text = keydown_core + .try_with_untracked(|core| { + core.selection_text(start, end) + }) + .unwrap_or_default(); + copy_to_clipboard(&text); + } + event.prevent_default(); + return; + } + let Some(socket) = ws_signal.get_untracked() else { return; }; @@ -293,6 +356,9 @@ pub fn TerminalPanel() -> impl IntoView { return; }; + // Typing replaces a selection, like a normal terminal. + keydown_selection.set(None); + if !keydown_mode.get_untracked().is_live() { keydown_mode.set(ViewMode::Live); sync_scroll_to_bottom(terminal_ref, keydown_prog_scroll); @@ -430,6 +496,75 @@ pub fn TerminalPanel() -> impl IntoView { } }; + // -- Mouse selection ---------------------------------------------------- + // + // Drag to select a range of terminal text. Selection is tracked in logical + // buffer coordinates (not DOM), so it survives re-renders and works across + // rows that have scrolled out of the rendered window. + let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> { + let element = terminal_ref.get_untracked()?; + let rect = element.get_bounding_client_rect(); + let local_x = f64::from(event.client_x()) - rect.left(); + let local_y = f64::from(event.client_y()) - rect.top(); + let (cell_w, cell_h) = cell_size.get_untracked(); + let cols = current_viewport.get_untracked().cols as usize; + let total = core_signal + .try_with_untracked(|core| core.total_rows()) + .unwrap_or(0); + Some(point_to_cell( + local_x, + local_y, + f64::from(element.scroll_top()), + cell_w, + cell_h, + cols, + total, + )) + }; + + let handle_mousedown = { + let down_selection = selection; + let down_selecting = selecting; + move |event: MouseEvent| { + if event.button() != 0 { + return; // only left button starts a selection + } + let Some(cell) = cell_from_event(&event) else { + return; + }; + down_selection.set(Some(Selection { + anchor: cell, + head: cell, + })); + down_selecting.set(true); + } + }; + + let handle_mousemove = { + let move_selection = selection; + let move_selecting = selecting; + move |event: MouseEvent| { + if !move_selecting.get_untracked() { + return; + } + let Some(cell) = cell_from_event(&event) else { + return; + }; + move_selection.update(|sel| { + if let Some(s) = sel { + s.head = cell; + } + }); + } + }; + + let handle_mouseup = { + let up_selecting = selecting; + move |_event: MouseEvent| { + up_selecting.set(false); + } + }; + let handle_click = move |_| { focus_terminal(terminal_ref); }; @@ -482,6 +617,9 @@ pub fn TerminalPanel() -> impl IntoView { on:scroll=handle_scroll on:wheel=handle_wheel on:paste=handle_paste + on:mousedown=handle_mousedown + on:mousemove=handle_mousemove + on:mouseup=handle_mouseup >
@@ -560,6 +707,57 @@ fn virtual_window( (total_px, offset_px, core.collect_window(start, end)) } +/// A highlight rectangle for one selected row, positioned relative to the top +/// of the rendered (translated) rows layer. +#[derive(Clone, Copy, Debug)] +struct SelectionRect { + top_px: f64, + left_px: f64, + width_px: f64, +} + +/// Compute the per-row highlight rects for `sel`, clipped to the rendered +/// window `[window_start, window_end)`. Returns empty for no/empty selection. +fn selection_rects( + sel: Option, + window_start: usize, + window_end: usize, + cols: usize, + cell_w: f64, +) -> Vec { + let Some(sel) = sel else { + return Vec::new(); + }; + if sel.is_empty() { + return Vec::new(); + } + let ((sr, sc), (er, ec)) = sel.ordered(); + let first = sr.max(window_start); + let last = er.min(window_end.saturating_sub(1)); + let mut rects = Vec::new(); + for r in first..=last { + if r >= window_end { + break; + } + let (col_a, col_b) = if sr == er { + (sc, ec) + } else if r == sr { + (sc, cols) + } else if r == er { + (0, ec) + } else { + (0, cols) + }; + let col_b = col_b.max(col_a); + rects.push(SelectionRect { + top_px: (r - window_start) as f64 * SCROLL_LINE_HEIGHT, + left_px: col_a as f64 * cell_w, + width_px: (col_b - col_a) as f64 * cell_w, + }); + } + rects +} + /// Render the visible rows using absolute positioning inside a fixed-height /// container. /// @@ -572,6 +770,7 @@ fn render_spaced_view( total_px: f64, offset_px: f64, visible: Vec, + rects: Vec, ) -> impl IntoView { view! {
+ {rects.into_iter().map(render_selection_rect).collect_view()} {visible.into_iter().map(render_terminal_row).collect_view()}
} } +fn render_selection_rect(rect: SelectionRect) -> impl IntoView { + let style = format!( + "top:{}px;left:{}px;width:{}px;height:{}px;", + rect.top_px, rect.left_px, rect.width_px, SCROLL_LINE_HEIGHT + ); + view! { } +} + fn render_terminal_row(row: TerminalRow) -> impl IntoView { view! {
@@ -826,12 +1034,48 @@ fn measure_cell_size(measure_ref: NodeRef) -> (f64, f64) { ) } +/// Map a pointer position to a logical `(row, col)` cell. +/// +/// `local_x`/`local_y` are relative to the scroll container's top-left +/// (clientX/Y minus its bounding rect). Padding is removed, the scroll offset +/// is added to Y, then the result is divided by the cell size and clamped to +/// the grid. Pure for unit-testing. +fn point_to_cell( + local_x: f64, + local_y: f64, + scroll_top: f64, + cell_w: f64, + cell_h: f64, + cols: usize, + total_rows: usize, +) -> (usize, usize) { + let x = (local_x - TERMINAL_PADDING).max(0.0); + let y = (local_y - TERMINAL_PADDING + scroll_top).max(0.0); + let col = (x / cell_w.max(1.0)).floor() as usize; + let row = (y / cell_h.max(1.0)).floor() as usize; + ( + row.min(total_rows.saturating_sub(1)), + col.min(cols.saturating_sub(1)), + ) +} + fn focus_terminal(terminal_ref: NodeRef) { if let Some(element) = terminal_ref.get_untracked() { let _ = element.focus(); } } +/// Write text to the system clipboard (fire-and-forget). Called from a user +/// gesture (Ctrl-Shift-C keydown), which browsers allow without a prompt in +/// secure contexts (localhost counts as secure). +fn copy_to_clipboard(text: &str) { + if text.is_empty() { + return; + } + let clipboard = window().navigator().clipboard(); + let _ = clipboard.write_text(text); +} + // --------------------------------------------------------------------------- // Key mapping // --------------------------------------------------------------------------- @@ -931,6 +1175,52 @@ mod tests { assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12); } + #[test] + fn point_to_cell_maps_pixels_with_padding_and_scroll() { + let (cw, ch) = (8.0, 18.0); + // Top-left content origin is at (PADDING, PADDING) = (16,16). + assert_eq!(point_to_cell(16.0, 16.0, 0.0, cw, ch, 80, 100), (0, 0)); + // One cell right and one row down. + assert_eq!(point_to_cell(16.0 + 8.0, 16.0 + 18.0, 0.0, cw, ch, 80, 100), (1, 1)); + // Scroll offset shifts the row mapping down. + assert_eq!(point_to_cell(16.0, 16.0, 180.0, cw, ch, 80, 100), (10, 0)); + // Clamped to grid bounds. + assert_eq!(point_to_cell(99999.0, 99999.0, 0.0, cw, ch, 80, 100), (99, 79)); + // Inside the padding clamps to (0,0), never negative. + assert_eq!(point_to_cell(0.0, 0.0, 0.0, cw, ch, 80, 100), (0, 0)); + } + + #[test] + fn selection_rects_clip_to_window_and_span_columns() { + let cw = 8.0; + let cols = 80; + // Single-row selection cols 2..6 on row 5, window [0,30). + let sel = Some(Selection { anchor: (5, 2), head: (5, 6) }); + let rects = selection_rects(sel, 0, 30, cols, cw); + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].top_px, 5.0 * SCROLL_LINE_HEIGHT); + assert_eq!(rects[0].left_px, 2.0 * cw); + assert_eq!(rects[0].width_px, 4.0 * cw); + + // Multi-row selection rows 4..6: first row partial, middle full, last partial. + let sel = Some(Selection { anchor: (4, 10), head: (6, 3) }); + let rects = selection_rects(sel, 0, 30, cols, cw); + assert_eq!(rects.len(), 3); + assert_eq!(rects[0].left_px, 10.0 * cw); // first row starts at col 10 + assert_eq!(rects[1].width_px, cols as f64 * cw); // middle row full width + assert_eq!(rects[2].left_px, 0.0); // last row from col 0 + assert_eq!(rects[2].width_px, 3.0 * cw); + + // Selection entirely above the window → clipped out. + let sel = Some(Selection { anchor: (1, 0), head: (2, 5) }); + assert!(selection_rects(sel, 10, 30, cols, cw).is_empty()); + + // Empty selection → no rects. + let sel = Some(Selection { anchor: (5, 2), head: (5, 2) }); + assert!(selection_rects(sel, 0, 30, cols, cw).is_empty()); + assert!(selection_rects(None, 0, 30, cols, cw).is_empty()); + } + #[test] fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() { // Plain paste: CRLF and LF both become CR, no wrapping. diff --git a/app/src/terminal/core.rs b/app/src/terminal/core.rs index ebbde7a..c2f098b 100644 --- a/app/src/terminal/core.rs +++ b/app/src/terminal/core.rs @@ -221,6 +221,44 @@ impl TerminalCore { out } + /// The logical row at combined index `i` (history first, then screen). + fn row_at(&self, i: usize) -> Option<&TerminalRow> { + let hist_len = self.history_rows.len(); + if i < hist_len { + self.history_rows.get(i) + } else { + self.screen_cache.get(i - hist_len) + } + } + + /// Extract the text covered by a selection from `start` to `end` + /// (inclusive of `start`, exclusive of `end.col` on the last row), both in + /// `(row, col)` buffer coordinates. Reads straight from the row data, so it + /// works across the whole buffer regardless of what is currently rendered. + /// + /// Per-row trailing whitespace is trimmed; rows are joined with `\n`. + pub fn selection_text( + &self, + start: (usize, usize), + end: (usize, usize), + ) -> String { + let (start, end) = order_points(start, end); + let (sr, sc) = start; + let (er, ec) = end; + let mut lines: Vec = Vec::new(); + + for r in sr..=er { + let Some(row) = self.row_at(r) else { + continue; + }; + let from = if r == sr { sc } else { 0 }; + let to = if r == er { ec } else { usize::MAX }; + lines.push(row.text_in_cols(from, to)); + } + + lines.join("\n") + } + // -- Convenience proxies for terminal metadata (all &self, no mutation) -- pub fn size(&self) -> (u16, u16) { @@ -324,6 +362,49 @@ pub struct TerminalRow { pub segments: Vec, } +impl TerminalRow { + /// Plain text of the columns in `[from, to)` of this row, with trailing + /// whitespace trimmed. `to` may be `usize::MAX` to mean "to end of row". + /// + /// Columns are walked using each segment's `cols` (grid-column) width. For + /// normal text one char maps to one column; the mapping is approximate only + /// for the rare mix of wide + combining chars in a single selection edge, + /// which trailing-trim and cell-accurate highlighting make unnoticeable. + pub fn text_in_cols(&self, from: usize, to: usize) -> String { + let mut out = String::new(); + let mut col = 0usize; + for seg in &self.segments { + let seg_end = col + seg.cols; + if seg_end <= from { + col = seg_end; + continue; + } + if col >= to { + break; + } + // This segment overlaps [from, to): emit the columns inside range. + let chars: Vec = seg.text.chars().collect(); + for (i, ch) in chars.iter().enumerate() { + // Distribute chars across the segment's columns 1:1 (clamped). + let ch_col = col + i.min(seg.cols.saturating_sub(1)); + if ch_col >= from && ch_col < to { + out.push(*ch); + } + } + col = seg_end; + } + out.trim_end().to_owned() + } +} + +/// Order two `(row, col)` points so the first is the top-left of the selection. +fn order_points( + a: (usize, usize), + b: (usize, usize), +) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct TerminalSegment { pub text: String, @@ -671,6 +752,32 @@ mod tests { assert!(core.collect_window(total + 10, total + 20).is_empty()); } + #[test] + fn selection_text_extracts_partial_and_multi_row_ranges() { + let mut core = TerminalCore::new(24, 80); + // Three known lines on screen. + core.process(b"hello world\r\n"); + core.process(b"second line\r\n"); + core.process(b"third\r\n"); + + // Rows 0..2 are the three lines (history empty: all on screen). + // Single-row partial: cols 0..5 of row 0 => "hello". + assert_eq!(core.selection_text((0, 0), (0, 5)), "hello"); + // Single-row mid range: cols 6..11 of row 0 => "world". + assert_eq!(core.selection_text((0, 6), (0, 11)), "world"); + + // Multi-row: from row 0 col 6 to row 2 col 5 => + // "world" (rest of row 0) + whole row 1 + "third" (start of row 2). + let text = core.selection_text((0, 6), (2, 5)); + assert_eq!(text, "world\nsecond line\nthird"); + + // Trailing whitespace within a row is trimmed (cols past content). + assert_eq!(core.selection_text((0, 0), (0, 40)), "hello world"); + + // Reversed points are normalized. + assert_eq!(core.selection_text((0, 5), (0, 0)), "hello"); + } + #[test] fn clear_wipes_history_and_keeps_only_new_prompt() { let mut core = TerminalCore::new(24, 80); diff --git a/style/tailwind.css b/style/tailwind.css index 239b8c7..29b2dfc 100644 --- a/style/tailwind.css +++ b/style/tailwind.css @@ -634,9 +634,12 @@ position: relative; display: block; min-height: 100%; + user-select: none; } .terminal-row { + position: relative; + z-index: 1; height: 18px; min-height: 18px; max-height: 18px; @@ -674,6 +677,13 @@ will-change: transform; } + .terminal-selection { + position: absolute; + background: rgb(56 189 248 / 30%); + pointer-events: none; + z-index: 0; + } + .terminal-measure { position: absolute; left: -9999px;