fix: improve mobile terminal interaction

This commit is contained in:
root
2026-04-10 08:59:22 +08:00
parent cd63f328ab
commit b86ce4a73a

View File

@@ -82,9 +82,63 @@
>
<div class="terminal-header">
<span>{{ session.title }}</span>
<span class="status-text">{{ statusLabel(session.status) }}</span>
<div class="terminal-header-actions">
<span class="status-text">{{ statusLabel(session.status) }}</span>
<button
class="secondary tiny"
type="button"
@click="toggleNativeCopyMode(session)"
>
{{ session.nativeTextMode ? "退出原生复制" : "原生复制" }}
</button>
</div>
</div>
<div class="terminal-body" :ref="(el) => setTerminalRef(session.id, el)">
<div
v-if="!session.nativeTextMode"
class="terminal-touch-overlay"
@touchstart.stop.prevent="handleTerminalTouchStart(session, $event)"
@touchmove.stop.prevent="handleTerminalTouchMove(session, $event)"
@touchend.stop.prevent="handleTerminalTouchEnd(session, $event)"
@touchcancel.stop.prevent="handleTerminalTouchCancel(session)"
></div>
</div>
<div
v-if="!session.nativeTextMode && session.selectionActive && session.id === activeId"
class="terminal-selection-bar"
>
<span>选区</span>
<span class="selection-size">
{{ selectionSizeText(session) }}
</span>
<button class="secondary tiny" type="button" @click="shrinkTerminalSelection(session)">
缩小
</button>
<button class="secondary tiny" type="button" @click="extendTerminalSelection(session, 'up')">
上扩
</button>
<button class="secondary tiny" type="button" @click="extendTerminalSelection(session, 'down')">
下扩
</button>
<button class="secondary tiny" type="button" @click="copyTerminalSelection(session)">
复制
</button>
<button class="secondary tiny" type="button" @click="clearTerminalSelection(session)">
取消
</button>
</div>
<div
v-if="session.nativeTextMode && session.id === activeId"
class="terminal-selection-bar native-selection-bar"
>
<span>原生复制</span>
<button class="secondary tiny" type="button" @click="copyTerminalSelection(session)">
复制
</button>
<button class="secondary tiny" type="button" @click="toggleNativeCopyMode(session, false)">
退出
</button>
</div>
<div class="terminal-body" :ref="(el) => setTerminalRef(session.id, el)"></div>
</div>
</div>
@@ -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;
}
}
</style>