diff --git a/mengyaconnect-frontend/src/App.vue b/mengyaconnect-frontend/src/App.vue index fae0d63..1c5d2cd 100644 --- a/mengyaconnect-frontend/src/App.vue +++ b/mengyaconnect-frontend/src/App.vue @@ -82,9 +82,63 @@ >
{{ session.title }} - {{ statusLabel(session.status) }} +
+ {{ statusLabel(session.status) }} + +
+
+
+
+
+
+ 选区 + + {{ selectionSizeText(session) }} + + + + + + +
+
+ 原生复制 + +
-
@@ -976,6 +1030,11 @@ function createSession() { ws: null, term: null, fit: null, + viewport: null, + touchState: null, + selectionActive: false, + fontSize: 14, + nativeTextMode: false, }; sessions.value.push(session); @@ -998,15 +1057,18 @@ function createSession() { function initSession(session, payload) { const container = terminalRefs.get(session.id); if (!container) return; + session.fontSize = getTerminalFontSize(); + session.nativeTextMode = false; const term = markRaw( new Terminal({ fontFamily: `"JetBrains Mono", "Fira Code", "Cascadia Code", monospace`, - fontSize: 14, + fontSize: session.fontSize, lineHeight: 1.4, cursorBlink: true, scrollback: 8000, - smoothScrollDuration: 140, + // 移动端拖动历史输出时,平滑滚动会显得黏滞,直接关闭更跟手。 + smoothScrollDuration: 0, scrollSensitivity: 1.15, fastScrollSensitivity: 4, theme: { @@ -1040,6 +1102,8 @@ function initSession(session, payload) { term.focus(); term.writeln("\x1b[90m[正在建立连接...]\x1b[0m"); + session.viewport = term.element?.querySelector(".xterm-viewport") || null; + const ws = markRaw(new WebSocket(wsUrl.value)); session.ws = ws; session.term = term; @@ -1108,14 +1172,70 @@ function initSession(session, payload) { } }); + term.onSelectionChange(() => { + session.selectionActive = term.hasSelection(); + }); + term.onResize(() => sendResize()); focusSession(session); } +function getTerminalFontSize() { + if (typeof window === "undefined") return 14; + return window.matchMedia("(max-width: 768px)").matches ? 11 : 14; +} + +function syncTerminalFontSize(session) { + if (!session?.term) return; + const nextSize = getTerminalFontSize(); + if (session.fontSize === nextSize) return; + session.fontSize = nextSize; + session.term.options.fontSize = nextSize; + session.fit?.fit(); + if (session.ws?.readyState === WebSocket.OPEN) { + session.ws.send( + JSON.stringify({ + type: "resize", + cols: session.term.cols, + rows: session.term.rows, + }) + ); + } +} + +function setNativeCopyMode(session, enabled) { + const term = session?.term; + if (!term) return; + session.nativeTextMode = enabled; + session.selectionActive = false; + term.clearSelection(); + term.options.screenReaderMode = enabled; + if (term.element) { + term.element.classList.toggle("native-terminal-copy-mode", enabled); + } + if (!enabled) { + const selection = document.getSelection?.(); + selection?.removeAllRanges?.(); + } +} + +function toggleNativeCopyMode(session, force) { + const next = typeof force === "boolean" ? force : !session.nativeTextMode; + if (!session?.term || next === session.nativeTextMode) return; + setNativeCopyMode(session, next); + if (next) { + blurTerminal(session); + showNotice("已进入原生复制模式,长按文字后点复制", "info", 2200); + } else { + focusSession(session); + } +} + function focusSession(session) { if (!session || !session.term || !session.fit) return; nextTick(() => { + syncTerminalFontSize(session); session.fit.fit(); if (session.ws?.readyState === WebSocket.OPEN) { session.ws.send( @@ -1126,11 +1246,290 @@ function focusSession(session) { }) ); } - session.term.scrollToBottom(); session.term.focus(); }); } +function getTerminalTouchState(session) { + if (!session.touchState) { + session.touchState = { + timer: null, + mode: "idle", + touched: false, + moved: false, + longPress: false, + startX: 0, + startY: 0, + lastY: 0, + rowHeight: 0, + bodyRect: null, + bodyPaddingTop: 0, + bodyPaddingBottom: 0, + selectionStartRow: 0, + selectionEndRow: 0, + }; + } + return session.touchState; +} + +function clearTerminalTouchTimer(state) { + if (state?.timer) { + clearTimeout(state.timer); + state.timer = null; + } +} + +function getVisibleBufferRow(session, clientY) { + if (!session.term || !session.touchState?.bodyRect) return null; + const state = session.touchState; + const rowHeight = state.rowHeight || 1; + const y = clientY - state.bodyRect.top - state.bodyPaddingTop; + const row = Math.floor(y / rowHeight); + const clamped = Math.max(0, Math.min(session.term.rows - 1, row)); + return session.term.buffer.active.viewportY + clamped; +} + +function getWrappedSelectionRange(term, bufferRow) { + const buffer = term?.buffer?.active; + if (!buffer) return null; + let start = bufferRow; + let end = bufferRow; + while (start > 0) { + const line = buffer.getLine(start); + const prev = buffer.getLine(start - 1); + if (!line?.isWrapped || !prev) break; + start -= 1; + } + while (end < buffer.length - 1) { + const next = buffer.getLine(end + 1); + if (!next?.isWrapped) break; + end += 1; + } + return { start, end }; +} + +function applyTerminalSelection(session, startRow, endRow) { + const term = session.term; + if (!term) return; + const startRange = getWrappedSelectionRange(term, startRow); + const endRange = getWrappedSelectionRange(term, endRow); + if (!startRange || !endRange) return; + const start = Math.min(startRange.start, endRange.start); + const end = Math.max(startRange.end, endRange.end); + term.clearSelection(); + term.selectLines(start, end); + session.selectionActive = true; +} + +function blurTerminal(session) { + session.term?.blur(); + const activeElement = document.activeElement; + if (activeElement && typeof activeElement.blur === "function") { + activeElement.blur(); + } +} + +function getNativeSelectionText(session) { + const term = session?.term; + if (!term?.element) return ""; + const selection = document.getSelection?.(); + if (!selection) return ""; + const text = selection.toString(); + if (!text) return ""; + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + if (!term.element.contains(anchorNode) && !term.element.contains(focusNode)) { + return ""; + } + return text; +} + +function selectionSizeText(session) { + const state = session.touchState; + if (!state) return ""; + const top = Math.min(state.selectionStartRow, state.selectionEndRow); + const bottom = Math.max(state.selectionStartRow, state.selectionEndRow); + return `${bottom - top + 1} 行`; +} + +function extendTerminalSelection(session, direction) { + const term = session.term; + const state = session.touchState; + if (!term || !state) return; + blurTerminal(session); + const buffer = term.buffer.active; + let top = Math.min(state.selectionStartRow, state.selectionEndRow); + let bottom = Math.max(state.selectionStartRow, state.selectionEndRow); + + if (direction === "up") { + top = Math.max(0, top - 1); + } else if (direction === "down") { + bottom = Math.min(buffer.length - 1, bottom + 1); + } else { + return; + } + + state.selectionStartRow = top; + state.selectionEndRow = bottom; + applyTerminalSelection(session, top, bottom); +} + +function shrinkTerminalSelection(session) { + const term = session.term; + const state = session.touchState; + if (!term || !state) return; + blurTerminal(session); + let top = Math.min(state.selectionStartRow, state.selectionEndRow); + let bottom = Math.max(state.selectionStartRow, state.selectionEndRow); + if (bottom - top <= 0) return; + const shrinkFromTop = state.startY < (state.bodyRect?.top || 0) + (state.bodyRect?.height || 0) / 2; + if (shrinkFromTop) { + top += 1; + } else { + bottom -= 1; + } + state.selectionStartRow = top; + state.selectionEndRow = bottom; + applyTerminalSelection(session, top, bottom); +} + +async function copyTerminalSelection(session) { + const term = session.term; + if (!term) return false; + blurTerminal(session); + const text = session.nativeTextMode ? getNativeSelectionText(session) : term.getSelection(); + if (!text) return false; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "readonly"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + textarea.remove(); + if (!ok) throw new Error("copy command failed"); + } + showNotice("已复制终端输出", "success"); + return true; + } catch (err) { + console.error(err); + showNotice("复制失败,请重试", "error"); + return false; + } +} + +function clearTerminalSelection(session) { + blurTerminal(session); + if (session.nativeTextMode) { + setNativeCopyMode(session, false); + } + session.term?.clearSelection(); + session.selectionActive = false; + if (session.touchState) { + session.touchState.selectionStartRow = 0; + session.touchState.selectionEndRow = 0; + } +} + +function handleTerminalTouchStart(session, event) { + const touch = event.touches?.[0]; + if (!touch || !session.term) return; + const state = getTerminalTouchState(session); + const body = terminalRefs.get(session.id); + if (!body) return; + clearTerminalTouchTimer(state); + const rect = body.getBoundingClientRect(); + const style = window.getComputedStyle(body); + const paddingTop = parseFloat(style.paddingTop) || 0; + const paddingBottom = parseFloat(style.paddingBottom) || 0; + const usableHeight = Math.max(1, rect.height - paddingTop - paddingBottom); + state.touched = true; + state.moved = false; + state.longPress = false; + state.mode = "pending"; + state.startX = touch.clientX; + state.startY = touch.clientY; + state.lastY = touch.clientY; + state.rowHeight = usableHeight / Math.max(1, session.term.rows); + state.bodyRect = rect; + state.bodyPaddingTop = paddingTop; + state.bodyPaddingBottom = paddingBottom; + + state.timer = window.setTimeout(async () => { + if (!state.touched || state.moved || !session.term) return; + clearTerminalTouchTimer(state); + state.mode = "select"; + state.longPress = true; + const bufferRow = getVisibleBufferRow(session, state.startY); + if (bufferRow == null) return; + state.selectionStartRow = bufferRow; + state.selectionEndRow = bufferRow; + applyTerminalSelection(session, bufferRow, bufferRow); + showNotice("已选中终端内容,点“复制”即可复制", "info", 2200); + }, 520); +} + +function handleTerminalTouchMove(session, event) { + const touch = event.touches?.[0]; + if (!touch || !session.term) return; + const state = session.touchState; + if (!state || !state.touched) return; + + const dx = touch.clientX - state.startX; + const dy = touch.clientY - state.startY; + if (state.mode === "select") { + const bufferRow = getVisibleBufferRow(session, touch.clientY); + if (bufferRow == null) return; + state.selectionEndRow = bufferRow; + applyTerminalSelection(session, state.selectionStartRow, state.selectionEndRow); + event.preventDefault(); + return; + } + + if (!state.moved && Math.hypot(dx, dy) > 8) { + state.moved = true; + state.mode = "scroll"; + clearTerminalTouchTimer(state); + } + + if (state.mode !== "scroll") return; + const viewport = session.viewport; + if (!viewport) return; + event.preventDefault(); + viewport.scrollTop += state.lastY - touch.clientY; + state.lastY = touch.clientY; +} + +function handleTerminalTouchEnd(session) { + const state = session.touchState; + if (!state) return; + clearTerminalTouchTimer(state); + const shouldFocus = state.touched && state.mode === "pending" && !state.moved; + state.touched = false; + state.moved = false; + state.longPress = false; + state.mode = "idle"; + if (shouldFocus) { + focusSession(session); + } +} + +function handleTerminalTouchCancel(session) { + const state = session.touchState; + if (!state) return; + clearTerminalTouchTimer(state); + state.touched = false; + state.moved = false; + state.longPress = false; + state.mode = "idle"; +} + function setActive(id) { activeId.value = id; } @@ -1153,6 +1552,7 @@ function closeSession(id) { function handleWindowResize() { const session = sessions.value.find((item) => item.id === activeId.value); if (!session) return; + syncTerminalFontSize(session); focusSession(session); } @@ -1287,6 +1687,20 @@ function handlePaste(event) { sendImageToTerminal(file); } +function isMobileTouchInput() { + if (typeof window === "undefined") return false; + return window.matchMedia("(pointer: coarse)").matches; +} + +function handleAppPointerDown(event) { + if (!isMobileTouchInput()) return; + if ("pointerType" in event && event.pointerType !== "touch") return; + const target = event.target; + if (!(target instanceof Element)) return; + if (!target.closest("button")) return; + event.preventDefault(); +} + // 根元素引用,用于动态调整高度以适配移动端虚拟键盘 const appRef = ref(null); let viewportFitTimer = null; @@ -1294,14 +1708,11 @@ let viewportFitTimer = null; function updateAppHeight() { const vv = window.visualViewport; const h = vv ? vv.height : window.innerHeight; - // 部分移动端浏览器键盘弹出时高度不缩,但会覆盖页面;用 padding-bottom 兜底 - const keyboard = - vv && typeof vv.height === "number" - ? Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0)) - : 0; if (appRef.value) { appRef.value.style.height = h + "px"; - appRef.value.style.setProperty("--keyboard-inset", keyboard + "px"); + // 这里已经直接跟随 visualViewport.height,不再额外预留整块键盘高度, + // 否则手机端会在快捷键栏和系统键盘之间留下很大的空白。 + appRef.value.style.setProperty("--keyboard-inset", "0px"); } // 触发布局变化后,让终端重新适配可视区域 requestAnimationFrame(() => { @@ -1328,6 +1739,7 @@ function hideSplashScreen() { onMounted(() => { window.addEventListener("resize", handleWindowResize); document.addEventListener("paste", handlePaste); + document.addEventListener("pointerdown", handleAppPointerDown, true); // 移动端虚拟键盘弹出时,visualViewport 会缩减,用它动态撑高度 if (window.visualViewport) { window.visualViewport.addEventListener("resize", updateAppHeight); @@ -1345,6 +1757,7 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener("resize", handleWindowResize); document.removeEventListener("paste", handlePaste); + document.removeEventListener("pointerdown", handleAppPointerDown, true); if (viewportFitTimer) { clearTimeout(viewportFitTimer); viewportFitTimer = null; @@ -1673,8 +2086,6 @@ onBeforeUnmount(() => { 0 30px 80px rgba(15, 23, 42, 0.95), 0 0 40px rgba(56, 189, 248, 0.12); overflow: hidden; - /* 虚拟键盘覆盖时,给底部留白避免遮挡终端最后几行 */ - padding-bottom: var(--keyboard-inset, 0px); min-height: 0; } @@ -1815,6 +2226,13 @@ onBeforeUnmount(() => { color: #e5e7eb; } +.terminal-header-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + .terminal-header .status-text { color: #9ca3af; } @@ -1825,6 +2243,86 @@ onBeforeUnmount(() => { min-height: 0; overflow: hidden; overscroll-behavior: contain; + position: relative; +} + +.terminal-touch-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: none; + background: transparent; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +.native-terminal-copy-mode { + cursor: text; + user-select: text; + -webkit-user-select: text; +} + +.native-terminal-copy-mode .xterm-screen canvas { + opacity: 0 !important; + pointer-events: none !important; +} + +.native-terminal-copy-mode .xterm-accessibility:not(.debug) { + pointer-events: auto !important; + color: #e5e7eb !important; +} + +.native-terminal-copy-mode .xterm-accessibility-tree { + user-select: text !important; + -webkit-user-select: text !important; + color: #e5e7eb !important; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 11px; + line-height: 1.4; +} + +.native-terminal-copy-mode .xterm-accessibility-tree * { + user-select: text !important; + -webkit-user-select: text !important; +} + +.native-terminal-copy-mode .xterm-accessibility-tree *::selection { + background: rgba(59, 130, 246, 0.35); + color: #fff; +} + +.terminal-selection-bar { + position: absolute; + left: 50%; + bottom: 10px; + transform: translateX(-50%); + z-index: 12; + display: none; + align-items: center; + gap: 6px; + max-width: calc(100% - 16px); + padding: 6px 8px; + border-radius: 16px; + background: rgba(2, 6, 23, 0.9); + border: 1px solid rgba(59, 130, 246, 0.35); + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.55); + backdrop-filter: blur(12px); + font-size: 11px; + color: #dbeafe; + flex-wrap: wrap; + justify-content: center; +} + +.native-selection-bar { + gap: 8px; +} + +.selection-size { + color: #93c5fd; + font-variant-numeric: tabular-nums; + font-size: 10px; + padding: 0 4px; } .quick-keys { @@ -2192,6 +2690,42 @@ textarea { .overlay { padding: 10px; } + + .terminal-header { + gap: 8px; + } + + .terminal-header-actions { + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .terminal-header-actions .secondary.tiny { + padding: 4px 7px; + font-size: 11px; + } + + .terminal-touch-overlay { + display: block; + } + + .native-terminal-copy-mode .xterm-screen canvas { + display: none !important; + } + + .native-terminal-copy-mode .xterm-accessibility:not(.debug) { + inset: 0 !important; + } + + .terminal-selection-bar { + display: flex; + } + + .terminal-selection-bar .secondary.tiny { + padding: 4px 7px; + min-width: 0; + } }