fix: improve mobile terminal interaction
This commit is contained in:
@@ -82,9 +82,63 @@
|
|||||||
>
|
>
|
||||||
<div class="terminal-header">
|
<div class="terminal-header">
|
||||||
<span>{{ session.title }}</span>
|
<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>
|
||||||
<div class="terminal-body" :ref="(el) => setTerminalRef(session.id, el)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -976,6 +1030,11 @@ function createSession() {
|
|||||||
ws: null,
|
ws: null,
|
||||||
term: null,
|
term: null,
|
||||||
fit: null,
|
fit: null,
|
||||||
|
viewport: null,
|
||||||
|
touchState: null,
|
||||||
|
selectionActive: false,
|
||||||
|
fontSize: 14,
|
||||||
|
nativeTextMode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
sessions.value.push(session);
|
sessions.value.push(session);
|
||||||
@@ -998,15 +1057,18 @@ function createSession() {
|
|||||||
function initSession(session, payload) {
|
function initSession(session, payload) {
|
||||||
const container = terminalRefs.get(session.id);
|
const container = terminalRefs.get(session.id);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
session.fontSize = getTerminalFontSize();
|
||||||
|
session.nativeTextMode = false;
|
||||||
|
|
||||||
const term = markRaw(
|
const term = markRaw(
|
||||||
new Terminal({
|
new Terminal({
|
||||||
fontFamily: `"JetBrains Mono", "Fira Code", "Cascadia Code", monospace`,
|
fontFamily: `"JetBrains Mono", "Fira Code", "Cascadia Code", monospace`,
|
||||||
fontSize: 14,
|
fontSize: session.fontSize,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
scrollback: 8000,
|
scrollback: 8000,
|
||||||
smoothScrollDuration: 140,
|
// 移动端拖动历史输出时,平滑滚动会显得黏滞,直接关闭更跟手。
|
||||||
|
smoothScrollDuration: 0,
|
||||||
scrollSensitivity: 1.15,
|
scrollSensitivity: 1.15,
|
||||||
fastScrollSensitivity: 4,
|
fastScrollSensitivity: 4,
|
||||||
theme: {
|
theme: {
|
||||||
@@ -1040,6 +1102,8 @@ function initSession(session, payload) {
|
|||||||
term.focus();
|
term.focus();
|
||||||
term.writeln("\x1b[90m[正在建立连接...]\x1b[0m");
|
term.writeln("\x1b[90m[正在建立连接...]\x1b[0m");
|
||||||
|
|
||||||
|
session.viewport = term.element?.querySelector(".xterm-viewport") || null;
|
||||||
|
|
||||||
const ws = markRaw(new WebSocket(wsUrl.value));
|
const ws = markRaw(new WebSocket(wsUrl.value));
|
||||||
session.ws = ws;
|
session.ws = ws;
|
||||||
session.term = term;
|
session.term = term;
|
||||||
@@ -1108,14 +1172,70 @@ function initSession(session, payload) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
term.onSelectionChange(() => {
|
||||||
|
session.selectionActive = term.hasSelection();
|
||||||
|
});
|
||||||
|
|
||||||
term.onResize(() => sendResize());
|
term.onResize(() => sendResize());
|
||||||
|
|
||||||
focusSession(session);
|
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) {
|
function focusSession(session) {
|
||||||
if (!session || !session.term || !session.fit) return;
|
if (!session || !session.term || !session.fit) return;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
syncTerminalFontSize(session);
|
||||||
session.fit.fit();
|
session.fit.fit();
|
||||||
if (session.ws?.readyState === WebSocket.OPEN) {
|
if (session.ws?.readyState === WebSocket.OPEN) {
|
||||||
session.ws.send(
|
session.ws.send(
|
||||||
@@ -1126,11 +1246,290 @@ function focusSession(session) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
session.term.scrollToBottom();
|
|
||||||
session.term.focus();
|
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) {
|
function setActive(id) {
|
||||||
activeId.value = id;
|
activeId.value = id;
|
||||||
}
|
}
|
||||||
@@ -1153,6 +1552,7 @@ function closeSession(id) {
|
|||||||
function handleWindowResize() {
|
function handleWindowResize() {
|
||||||
const session = sessions.value.find((item) => item.id === activeId.value);
|
const session = sessions.value.find((item) => item.id === activeId.value);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
syncTerminalFontSize(session);
|
||||||
focusSession(session);
|
focusSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,6 +1687,20 @@ function handlePaste(event) {
|
|||||||
sendImageToTerminal(file);
|
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);
|
const appRef = ref(null);
|
||||||
let viewportFitTimer = null;
|
let viewportFitTimer = null;
|
||||||
@@ -1294,14 +1708,11 @@ let viewportFitTimer = null;
|
|||||||
function updateAppHeight() {
|
function updateAppHeight() {
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
const h = vv ? vv.height : window.innerHeight;
|
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) {
|
if (appRef.value) {
|
||||||
appRef.value.style.height = h + "px";
|
appRef.value.style.height = h + "px";
|
||||||
appRef.value.style.setProperty("--keyboard-inset", keyboard + "px");
|
// 这里已经直接跟随 visualViewport.height,不再额外预留整块键盘高度,
|
||||||
|
// 否则手机端会在快捷键栏和系统键盘之间留下很大的空白。
|
||||||
|
appRef.value.style.setProperty("--keyboard-inset", "0px");
|
||||||
}
|
}
|
||||||
// 触发布局变化后,让终端重新适配可视区域
|
// 触发布局变化后,让终端重新适配可视区域
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -1328,6 +1739,7 @@ function hideSplashScreen() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener("resize", handleWindowResize);
|
window.addEventListener("resize", handleWindowResize);
|
||||||
document.addEventListener("paste", handlePaste);
|
document.addEventListener("paste", handlePaste);
|
||||||
|
document.addEventListener("pointerdown", handleAppPointerDown, true);
|
||||||
// 移动端虚拟键盘弹出时,visualViewport 会缩减,用它动态撑高度
|
// 移动端虚拟键盘弹出时,visualViewport 会缩减,用它动态撑高度
|
||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
window.visualViewport.addEventListener("resize", updateAppHeight);
|
window.visualViewport.addEventListener("resize", updateAppHeight);
|
||||||
@@ -1345,6 +1757,7 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("resize", handleWindowResize);
|
window.removeEventListener("resize", handleWindowResize);
|
||||||
document.removeEventListener("paste", handlePaste);
|
document.removeEventListener("paste", handlePaste);
|
||||||
|
document.removeEventListener("pointerdown", handleAppPointerDown, true);
|
||||||
if (viewportFitTimer) {
|
if (viewportFitTimer) {
|
||||||
clearTimeout(viewportFitTimer);
|
clearTimeout(viewportFitTimer);
|
||||||
viewportFitTimer = null;
|
viewportFitTimer = null;
|
||||||
@@ -1673,8 +2086,6 @@ onBeforeUnmount(() => {
|
|||||||
0 30px 80px rgba(15, 23, 42, 0.95),
|
0 30px 80px rgba(15, 23, 42, 0.95),
|
||||||
0 0 40px rgba(56, 189, 248, 0.12);
|
0 0 40px rgba(56, 189, 248, 0.12);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* 虚拟键盘覆盖时,给底部留白避免遮挡终端最后几行 */
|
|
||||||
padding-bottom: var(--keyboard-inset, 0px);
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1815,6 +2226,13 @@ onBeforeUnmount(() => {
|
|||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-header .status-text {
|
.terminal-header .status-text {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
@@ -1825,6 +2243,86 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: contain;
|
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 {
|
.quick-keys {
|
||||||
@@ -2192,6 +2690,42 @@ textarea {
|
|||||||
.overlay {
|
.overlay {
|
||||||
padding: 10px;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user