Files
terminal-lab/xterminal/source/instance.ts
douboer@gmail.com 3b7c1d558a first commit
2026-03-03 13:23:14 +08:00

170 lines
5.2 KiB
TypeScript

import XTerminal from "./index";
import XHistory from "./history/index";
import XOutputComponent from "./output/index";
import XInputComponent from "./input/index";
import { createEffect } from "./base/reactivity";
import { NEWLINE, THEME, h, scrollDown } from "./renderer/dom";
import { isFunction, isMobile } from "./helpers";
import {
ARROW_DOWN_KEY,
ARROW_UP_KEY,
ENTER_KEY,
TAB_KEY,
addEvent
} from "./renderer/events";
import { bounce } from "./base/debouncer";
import { ITerminalState } from "./interface";
/**
* Public Events
*/
export const DATA_EVENT = "data";
export const CLEAR_EVENT = "clear";
export const KEYPRESS_EVENT = "keypress";
export const PAUSE_EVENT = "pause";
export const RESUME_EVENT = "resume";
/**
* Composes the components of the terminal object and integrates them
* @param instance The terminal object
* @param target The DOM element in which the terminal is to be mounted
*/
export function setup(
instance: XTerminal,
target: HTMLElement
): ITerminalState {
const term = h<HTMLDivElement>("div", {
class: THEME.CONTAINER,
props: { tabindex: 0 }
});
const xhistory = new XHistory();
const output = new XOutputComponent(term);
const input = new XInputComponent(term);
const state = {
input,
output,
history: xhistory,
completer: (x: string) => x
};
// connect the input to output (cursor & input text)
input.pipe(output);
// scroll to the bottom on every output operation
output.onoutput = () => scrollDown(term);
const checkScrollPosition = () => {
if (input.isFocused.value) {
// 给软键盘弹出和页面重排一点时间
setTimeout(() => {
let bottomInset = 0;
if (window.visualViewport) {
// 当键盘弹出时,如果布局视口高于可视视口,计算出被遮挡的高度(键盘+辅助栏等)
bottomInset = Math.max(
0,
window.innerHeight -
(window.visualViewport.height +
window.visualViewport.offsetTop)
);
}
// 给输入区底部补充内间距,这样只需滚动内部容器,完全不需要让浏览器滚动 body (这会导致 Safari 底栏失去透明度)
term.style.paddingBottom =
bottomInset > 0
? `calc(var(--xt-padding) + var(--tc-toolbar-height, 0px) + ${bottomInset}px)`
: "";
scrollDown(term);
const cursor = term.querySelector(
`.${THEME.CURSOR}`
) as HTMLElement;
if (cursor) {
try {
cursor.scrollIntoView({
block: "nearest",
inline: "nearest"
});
} catch {
// fallback
}
}
}, 100);
} else {
term.style.paddingBottom = "";
}
};
if (window.visualViewport) {
instance.register(
addEvent(window.visualViewport, "resize", checkScrollPosition)
);
} else {
instance.register(addEvent(window, "resize", checkScrollPosition));
}
instance.register(
addEvent(term, "keydown", function (ev: KeyboardEvent) {
// focus input element
input.focus();
// redirect the `keyboard` event to the input in the next event loop
bounce(() => {
input.el.dispatchEvent(new KeyboardEvent("keydown", ev));
input.el.dispatchEvent(new KeyboardEvent("input", ev));
});
})
);
instance.register(
addEvent(term, "focus", () => (input.isFocused.value = true))
);
instance.register(
addEvent(term, "blur", () => (input.isFocused.value = false))
);
if (isMobile()) {
// Toggle keyboard on mobile devices (touchscreen) on tap
instance.register(addEvent(term, "click", input.focus.bind(input)));
}
target.appendChild(term);
// handle keydown event
input.onkeypress = (ev) => {
if (ev.key == ENTER_KEY) {
ev.cancel();
xhistory.add(ev.value);
output.writeSafe(ev.value + NEWLINE);
instance.emit(DATA_EVENT, ev.value);
} else if (ev.key == TAB_KEY) {
ev.cancel();
if (isFunction(state.completer)) {
input.setValue(state.completer(ev.value));
}
} else if (ev.key == ARROW_DOWN_KEY) {
ev.cancel();
input.setValue(xhistory.next());
} else if (ev.key == ARROW_UP_KEY) {
ev.cancel();
input.setValue(xhistory.previous());
} else {
instance.emit(KEYPRESS_EVENT, ev);
}
scrollDown(term);
};
createEffect(() => {
// Fill the cursor if focused, otherwise outline
if (input.isFocused.value) {
term.classList.remove(THEME.INACTIVE);
} else {
term.classList.add(THEME.INACTIVE);
}
});
return state;
}