first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { isFunction } from "../helpers";
/**
* Debouncing functions
*
* https://www.freecodecamp.org/news/javascript-debounce-example
*
* https://programmingwithmosh.com/javascript/javascript/throttle-and-debounce-patterns/
*/
const DEBOUNCE_TIME = 0;
export function bounce(fn: TimerHandler, ...args: unknown[]): number {
return setTimeout(fn, DEBOUNCE_TIME, ...args);
}
/**
* Delay the execution of the function until a pause happens
* @param fn The function to execute
* @returns A function that limits the intermediate calls to `fn`
*/
export function debounce(fn: TimerHandler) {
let flag: number;
return (...args: unknown[]) => {
if (!isFunction(fn)) return;
clearTimeout(flag);
flag = bounce(fn, ...args);
};
}

View File

@@ -0,0 +1,42 @@
import type { IDisposable } from "../types";
/**
* Disposables
*
* https://blog.hediet.de/post/the_disposable_pattern_in_typescript
*
* https://github.com/xtermjs/xterm.js/blob/master/src/common/Lifecycle.ts
*/
export default class Disposable implements IDisposable {
// private store for states
#disposables: IDisposable[];
public isDisposed: boolean;
constructor() {
this.isDisposed = false;
this.#disposables = [];
}
/**
* Registers a disposable object
* @param d The disposable to register
*/
public register<T extends IDisposable>(d: T): void {
if (this.isDisposed) {
d?.dispose();
} else {
this.#disposables.push(d);
}
}
/**
* Disposes the object, triggering the `dispose` method on all registered disposables
*/
public dispose(): void {
if (this.isDisposed) return;
this.isDisposed = true;
this.#disposables.forEach((d) => d?.dispose());
}
}

View File

@@ -0,0 +1,13 @@
export class XError extends Error {
constructor(message: string) {
message = "[x] " + message;
super(message);
this.name = "XTerminalError";
}
}
export const TARGET_INVALID_ERR =
"mount: A parent HTMLElement (target) is required";
export const TARGET_NOT_CONNECTED_ERR =
"'mount' was called on an HTMLElement (target) that is not attached to DOM.";

View File

@@ -0,0 +1,70 @@
import { isFunction } from "../helpers";
import type { IDisposable } from "../types";
/**
* Reactivity
* => https://github.com/henryhale/reactivity
*/
/**
* Effect - callback triggered when a reactive value changes
*/
export type IEffect = () => void;
/**
* Reactive value -> all effects are disposable
*/
export interface IReactive<T> extends IDisposable {
value: T;
}
/**
* Global observer
*/
let observer: IEffect | null;
/**
* Function that creates a disposable reactive object from a primitive value
* @param value The primitive value (initial)
* @returns Reactive object
*/
export function ref<T>(value: T): IReactive<T> {
const observers = new Set<IEffect>();
let disposed = false;
return {
get value() {
// allow subscriptons only when the object is not disposed
if (!disposed && isFunction(observer)) {
observers.add(observer);
}
return value;
},
set value(newValue) {
value = newValue;
// alert subscribers only when the object is not disposed
if (!disposed) {
observers.forEach((o) => o.call(undefined));
}
},
dispose() {
if (disposed) return;
disposed = true;
// remove all subscriptions
observers.clear();
}
};
}
/**
* Function that opens a subscription to reactive values
* @param fn The subscription (function) to be made
*/
export function createEffect(fn: IEffect): void {
if (!isFunction(fn)) return;
observer = fn;
try {
fn.call(undefined);
} finally {
observer = null;
}
}

View File

@@ -0,0 +1,77 @@
import Disposable from "../base/disposable";
import type {
XEventEmitter as IEventEmitter,
IEventListener,
IEventName
} from "../types";
import type { IEmitterState } from "./interface";
/**
* EventEmitter class
*
* https://nodejs.dev/api/events.html
*
* https://nodejs.dev/en/learn/the-nodejs-event-emitter/
*/
export default class XEventEmitter extends Disposable implements IEventEmitter {
// private store for states
#state: IEmitterState;
constructor() {
super();
this.#state = {
stack: [],
store: new Map()
};
this.register({ dispose: () => this.#state.store.clear() });
}
public on(eventName: IEventName, listener: IEventListener): void {
const store = this.#state.store;
if (store.has(eventName)) {
store.get(eventName)?.add(listener);
} else {
store.set(eventName, new Set([listener]));
}
}
public once(eventName: IEventName, listener: IEventListener): void {
const evlistener = (...args: unknown[]) => {
listener.call(undefined, ...args);
this.off(eventName, evlistener);
};
const store = this.#state.store;
if (store.has(eventName)) {
store.get(eventName)?.add(evlistener);
} else {
store.set(eventName, new Set([evlistener]));
}
}
public off(eventName: IEventName, listener: IEventListener): void {
const all = this.#state.store.get(eventName);
if (all) {
for (const fn of all) {
if (fn === listener) {
all.delete(listener);
break;
}
}
}
}
public emit(eventName: IEventName, ...args: unknown[]): void {
if (this.isDisposed) return;
const stack = this.#state.stack;
if (stack.includes(eventName)) return;
const listeners = this.#state.store.get(eventName);
if (listeners) {
stack.push(eventName);
for (const fn of listeners) {
fn.call(undefined, ...args);
}
stack.pop();
}
return;
}
}

View File

@@ -0,0 +1,9 @@
import type { IEventName, IEventListener } from "../types";
/**
* State of the Event Emitter
*/
export type IEmitterState = {
store: Map<IEventName, Set<IEventListener>>;
stack: IEventName[];
};

View File

@@ -0,0 +1,28 @@
export const isArray = Array.isArray;
export function isObject(val: unknown): val is object {
return typeof val === "object" && val !== null;
}
export function isFunction(val: unknown): val is (...a: unknown[]) => unknown {
return typeof val === "function";
}
/**
* Detecting a mobile browser
* https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser
*/
// TODO: compatibility check
export function isMobile(): boolean {
const _window = window || {};
const _navigator = navigator || {};
// Check for touch support
if ("ontouchstart" in _window || _navigator.maxTouchPoints) {
// Check for mobile user agent
if (/Mobi/.test(_navigator.userAgent)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,46 @@
import { isArray } from "../helpers";
import type { IHistory } from "./interface";
/**
* History stack
*/
export default class XHistory implements IHistory {
private store;
private ptr;
constructor(initialState: string[] = []) {
this.store = isArray(initialState) ? initialState : [];
this.ptr = -1;
}
private get size(): number {
return this.store.length;
}
public get list(): string[] {
return [].slice.call(this.store).reverse();
}
add(input: string): void {
if (input && input !== this.store[0]) {
this.store.unshift(input);
}
this.ptr = -1;
}
previous(): string {
this.ptr++;
if (this.ptr >= this.size) this.ptr = this.size - 1;
return this.store[this.ptr] || "";
}
next(): string {
this.ptr--;
if (this.ptr <= -1) this.ptr = -1;
return this.store[this.ptr] || "";
}
clear(): void {
this.store.splice(0);
}
}

View File

@@ -0,0 +1,33 @@
/**
* Interface: History
*/
export interface IHistory {
/**
* Array containing a copy of entries
*/
list: string[];
/**
* Getter: access one entry at a time (forward)
*/
next(): string;
/**
* Getter: access one entry at a time (backwards)
*/
previous(): string;
/**
* Insert an input string to the stack
*
* Returns `false` if the `input` is the same as the previous entry
*
* @returns boolean
*/
add(input: string): void;
/**
* Empty the stack of entries
*/
clear(): void;
}

139
xterminal/source/index.ts Normal file
View File

@@ -0,0 +1,139 @@
import XEventEmitter from "./emitter/index";
import { isFunction } from "./helpers";
import type { ITerminalOptions } from "./types";
import type { ITerminalState } from "./interface";
import { CLEAR_EVENT, PAUSE_EVENT, RESUME_EVENT, setup } from "./instance";
import {
TARGET_INVALID_ERR,
TARGET_NOT_CONNECTED_ERR,
XError
} from "./base/error";
import { escapeHTML } from "./output/index";
import { NEWLINE } from "./renderer/dom";
export default class XTerminal extends XEventEmitter {
#state!: ITerminalState;
public isMounted: boolean;
constructor(options?: ITerminalOptions) {
super();
this.isMounted = false;
if (options && options?.target) {
this.mount(options.target);
}
}
public focus(): void {
this.#state.input.focus();
}
public blur(): void {
this.#state.input.blur();
}
public pause(): void {
this.#state.input.pause();
this.emit(PAUSE_EVENT);
}
public resume(): void {
this.#state.input.resume();
this.emit(RESUME_EVENT);
}
public setInput(value: string): void {
this.#state.input.setValue(value);
}
public clearInput(): void {
this.#state.input.clear();
}
public write(data: string | number, callback?: () => void): void {
this.#state.output.write("" + data, callback);
}
public writeln(data: string | number, callback?: () => void): void {
this.#state.output.write("" + data + NEWLINE, callback);
}
public writeSafe(data: string | number, callback?: () => void): void {
this.#state.output.writeSafe("" + data, callback);
}
public writelnSafe(data: string | number, callback?: () => void): void {
this.#state.output.writeSafe("" + data + NEWLINE, callback);
}
public clear(): void {
this.#state.output.clear();
this.emit(CLEAR_EVENT);
}
public clearLast(): void {
this.#state.output.clearLast();
}
public get history(): string[] {
return this.#state.history.list || [];
}
public set history(newState: string[]) {
newState.forEach((item) => this.#state.history.add(item));
}
public clearHistory(): void {
this.#state.history.clear();
}
public setCompleter(fn: (data: string) => string): void {
if (!isFunction(fn)) return;
this.#state.completer = fn;
}
public mount(target: HTMLElement | string): void {
if (this.isMounted) return;
if (target && typeof target === "string") {
target = document.querySelector<HTMLElement>(target) as HTMLElement;
}
if (!(target instanceof HTMLElement)) {
throw new XError(TARGET_INVALID_ERR);
}
if (!target.isConnected && console) {
console.warn(TARGET_NOT_CONNECTED_ERR);
}
target.innerHTML = "";
this.#state = setup(this, target);
this.isMounted = true;
}
public dispose(): void {
super.dispose();
const state = this.#state;
state.history.clear();
state.completer = undefined;
state.input.dispose();
const box = state.output.el.parentNode;
box?.parentNode?.removeChild(box);
this.isMounted = false;
}
static get version() {
return "__VERSION__";
}
static get XEventEmitter() {
return XEventEmitter;
}
static escapeHTML(data?: string): string {
return escapeHTML(data);
}
}

View File

@@ -0,0 +1,29 @@
import type { IReactive } from "../base/reactivity";
/**
* Get current cursor position in a textbox
* https://stackoverflow.com/16105482/get-current-cursor-position-in-a-textbox
*/
// TODO: compatibility check
function getCursorPosition<T extends HTMLElement>(field: T): number {
if ("selectionStart" in field) {
return field.selectionStart as number;
}
return 0;
}
export function updateCursor(
el: HTMLTextAreaElement,
data: IReactive<string>,
ptr: IReactive<number>
) {
let pos = getCursorPosition(el);
const len = data.value.length;
if (pos > len) {
pos = len;
} else if (pos < 0) {
pos = 0;
}
ptr.value = pos;
}

View File

@@ -0,0 +1,169 @@
import Disposable from "../base/disposable";
import type { IReactive } from "../base/reactivity";
import { isFunction } from "../helpers";
import { inputBuild } from "../renderer/index";
import { SPACE, THEME, h } from "../renderer/dom";
import { ENTER_KEY, addEvent } from "../renderer/events";
import { updateCursor } from "./cursor";
import { debounce } from "../base/debouncer";
import { createEffect, ref } from "../base/reactivity";
import type { IOutputInterface } from "../output/interface";
import type { IInputInterface } from "./interface";
import type { IKeyPress } from "../types";
/**
* Input Component
*/
export default class XInputComponent
extends Disposable
implements IInputInterface
{
public readonly el: HTMLTextAreaElement;
private buffer: string;
private data: IReactive<string>;
private ptr: IReactive<number>;
private isActive: IReactive<boolean>;
public showInput: IReactive<boolean>;
public isFocused: IReactive<boolean>;
public onkeypress?: ((ev: IKeyPress) => void) | undefined;
constructor(target: HTMLElement) {
super();
this.el = inputBuild(target);
this.buffer = "";
this.data = ref<string>("");
this.ptr = ref<number>(0);
this.isActive = ref<boolean>(true);
this.showInput = ref<boolean>(true);
this.isFocused = ref<boolean>(false);
// All reactive values are disposable
this.register(this.data);
this.register(this.ptr);
this.register(this.isActive);
this.register(this.showInput);
this.register(this.isFocused);
const cursorUpdater = () => updateCursor(this.el, this.data, this.ptr);
const cursorHandler = debounce(cursorUpdater);
const inputHandler = debounce(() => {
this.data.value = this.buffer = this.el.value;
});
createEffect(cursorUpdater);
this.register(
addEvent(this.el, "blur", () => (this.isFocused.value = false))
);
this.register(
addEvent(
this.el,
"focus",
() => (this.isFocused.value = true),
false
)
);
this.register(
addEvent(
this.el,
"keyup",
() => this.isActive.value && cursorHandler(),
false
)
);
this.register(
addEvent(this.el, "input", () => {
inputHandler();
cursorHandler();
})
);
this.register(
addEvent(this.el, "keydown", (ev: KeyboardEvent) => {
ev.stopImmediatePropagation();
const value = this.data.value;
if (ev.key === ENTER_KEY) {
ev.preventDefault();
if (this.el) this.el.value = "";
this.data.value = "";
this.buffer = "";
this.showInput.value = true;
}
if (!this.isActive.value) return;
if (!isFunction(this.onkeypress)) return;
this.onkeypress({
key: ev.key,
altKey: ev.altKey,
ctrlKey: ev.ctrlKey,
metaKey: ev.metaKey,
shiftKey: ev.shiftKey,
value,
cancel() {
ev.preventDefault();
ev.stopPropagation();
}
});
cursorHandler();
})
);
}
public blur(): void {
if (this.el) this.el.blur();
}
public focus(): void {
if (this.el) this.el.focus();
}
public pause(): void {
this.isActive.value = false;
}
public resume(): void {
this.isActive.value = true;
}
public setValue(str: string): void {
str = str || this.buffer;
if (this.el) this.el.value = str;
this.data.value = str;
}
public clear(): void {
this.buffer = "";
this.data.value = "";
this.el.value = "";
}
public pipe(output: IOutputInterface): void {
const txtBefore = h<HTMLSpanElement>("span");
const cursor = h<HTMLSpanElement>("span", {
class: THEME.CURSOR,
html: SPACE
});
const txtAfter = h<HTMLSpanElement>("span");
output.el?.append(txtBefore, cursor, txtAfter);
createEffect(() => {
const i = this.ptr.value;
const d = this.data.value;
if (!this.isActive.value || !this.showInput.value) {
txtBefore.textContent = "";
cursor.innerHTML = SPACE;
txtAfter.textContent = "";
return;
}
txtBefore.textContent = d.substring(0, i);
cursor.innerHTML = d.substring(i, i + 1).trim() || SPACE;
txtAfter.textContent = d.substring(i + 1);
});
}
}

View File

@@ -0,0 +1,47 @@
import type { IDisposable, IKeyPress } from "../types";
import type { IOutputInterface } from "../output/interface";
/**
* Interface: Input Component
*/
export interface IInputInterface extends IDisposable {
/**
* Blur the input element
*/
blur(): void;
/**
* Focus the input element
*/
focus(): void;
/**
* Deactivate the component
*/
pause(): void;
/**
* Activate the component
*/
resume(): void;
/**
* Callback function invoked on every key press
*/
onkeypress?: (ev: IKeyPress) => void;
/**
* Bridge the input to the output component: cursor & input
*/
pipe(output: IOutputInterface): void;
/**
* Set the value of the input element, updates the cursor
*/
setValue(str: string): void;
/**
* Clears the value of the input element
*/
clear(): void;
}

View File

@@ -0,0 +1,169 @@
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;
}

View File

@@ -0,0 +1,28 @@
import type { IInputInterface } from "./input/interface";
import type { IOutputInterface } from "./output/interface";
import type { IHistory } from "./history/interface";
/**
* Terminal State
*/
export type ITerminalState = {
/**
* Input component for the terminal
*/
input: IInputInterface;
/**
* Output component for the terminal
*/
output: IOutputInterface;
/**
* History: store of inputs
*/
history: IHistory;
/**
* Autocomplete function invoked on TAB key
*/
completer?: (data: string) => string;
};

View File

@@ -0,0 +1,65 @@
import { isFunction } from "../helpers";
import outputBuild from "../renderer/index";
import { SPACE, h } from "../renderer/dom";
import type { IOutputInterface } from "./interface";
const TAB_SIZE = 4;
function parseOutput(data = ""): string {
return ("" + data)
.replace(/(\n)|(\n\r)|(\r\n)/g, "<br/>")
.replace(/\s{2}/g, SPACE.repeat(2))
.replace(/\t/g, SPACE.repeat(TAB_SIZE));
}
/**
* Escapes user input so it can be safely rendered as HTML text.
* - preserves all characters by converting them to HTML entities where needed.
*/
export function escapeHTML(data = ""): string {
const span = document.createElement("span");
span.textContent = data;
return span.innerHTML;
}
/**
* Output Component
*/
export default class XOutputComponent implements IOutputInterface {
public el: HTMLDivElement;
private console: HTMLSpanElement;
private lastOutput?: HTMLSpanElement;
public onoutput?: () => void;
constructor(target: HTMLElement) {
const { outputBox, consoleBox } = outputBuild(target);
this.el = outputBox;
this.console = consoleBox;
}
public write(data: string, callback?: () => void): void {
this.lastOutput = h<HTMLSpanElement>("span", {
html: parseOutput(data)
});
this.console.appendChild(this.lastOutput);
if (isFunction(this.onoutput)) this.onoutput();
if (isFunction(callback)) callback();
}
public writeSafe(data: string, callback?: () => void): void {
this.write(escapeHTML(data), callback);
}
public clear(): void {
if (this.console) {
this.console.innerHTML = "";
}
}
public clearLast(): void {
if (this.lastOutput) {
this.lastOutput.parentNode?.removeChild(this.lastOutput);
}
this.lastOutput = undefined;
}
}

View File

@@ -0,0 +1,34 @@
/**
* Interface: Output Component
*/
export interface IOutputInterface {
/**
* Container element housing the console box
*/
el: HTMLDivElement;
/**
* Inbuilt callback function for every write operation
*/
onoutput?: () => void;
/**
* Output data to the console
*/
write(data: string, callback?: () => void): void;
/**
* Safely output data to the console
*/
writeSafe(data: string, callback?: () => void): void;
/**
* Clear the console
*/
clear(): void;
/**
* Remove the element containing the previous output
*/
clearLast(): void;
}

View File

@@ -0,0 +1,5 @@
# XTerminal
## Structure
![](../assets/internals.png)

View File

@@ -0,0 +1,63 @@
import { isArray, isObject } from "../helpers";
import type { IElementProps } from "./interface";
export const NEWLINE = "\n";
export const SPACE = "&nbsp;";
/**
* CSS class names
*/
export const THEME = {
CONTAINER: "xt",
INACTIVE: "xt-inactive",
CURSOR: "xt-cursor",
OUTPUT: "xt-stdout",
INPUT: "xt-stdin"
};
export function scrollDown(el: HTMLElement): void {
if (el) el.scrollTo(0, el.scrollHeight);
}
/**
* Create a ready to use HTMLElement
*
* https://github.com/henryhale/render-functions
*
* https://vuejs.org/guide/extras/render-function.html
*
* @param tag The HTML element tag
* @param options The some properties of the element
* @returns The HTML Element
*/
export function h<T extends HTMLElement>(
tag: string,
options?: IElementProps
): T {
const elem = document.createElement(tag);
if (!isObject(options)) {
return elem as T;
}
if (options?.id) {
elem.id = options.id || "";
}
if (options?.class) {
elem.className = options.class || "";
}
if (options?.content) {
elem.appendChild(document.createTextNode(options.content || ""));
}
if (options?.html) {
elem.innerHTML = options.html;
}
if (isArray(options?.children)) {
options.children.forEach((c) => elem.append(c));
}
if (isObject(options?.props)) {
Object.entries(options.props).forEach((v) =>
elem.setAttribute(v[0], v[1])
);
}
return elem as T;
}

View File

@@ -0,0 +1,28 @@
import type { IDisposable } from "../types";
export const ENTER_KEY = "Enter",
TAB_KEY = "Tab",
ARROW_UP_KEY = "ArrowUp",
ARROW_DOWN_KEY = "ArrowDown";
/**
* Attaches an event listener to the element returning a disposable object
* to remove the event listener
*/
export function addEvent(
el: Element | Document | Window | VisualViewport | EventTarget,
type: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (e: any) => void,
opt?: boolean | AddEventListenerOptions
): IDisposable {
el.addEventListener(type, handler, opt);
let disposed = false;
return {
dispose() {
if (disposed) return;
el.removeEventListener(type, handler, opt);
disposed = true;
}
};
}

View File

@@ -0,0 +1,68 @@
import { THEME, h } from "./dom";
/**
* Generate a build for the output component
* @param target The parent element in which it is mounted
* @returns DOM reference to the console box and output container
*/
export default function outputBuild(target: HTMLElement) {
const consoleBox = h<HTMLSpanElement>("span");
const outputBox = h<HTMLDivElement>("div", {
class: THEME.OUTPUT,
children: [consoleBox]
});
target.appendChild(outputBox);
return { outputBox, consoleBox };
}
/**
* Generate a build for the input component
* @param target The parent element in which it is mounted
* @returns DOM reference to the input element
*/
export function inputBuild(target: HTMLElement) {
const inputBox = h<HTMLTextAreaElement>("textarea", {
props: {
// 明确声明为普通文本输入,降低移动端将其识别为登录表单的概率。
spellcheck: false,
autocorrect: "off",
autocapitalize: "off",
autocomplete: "off",
name: "xterminal_input",
inputmode: "text",
enterkeyhint: "enter",
rows: 1,
wrap: "off"
}
});
// 部分密码管理器会读取 data-* 做识别,显式标记为非登录用途。
inputBox.setAttribute("data-form-type", "other");
inputBox.setAttribute("data-lpignore", "true");
inputBox.setAttribute("data-1p-ignore", "true");
inputBox.setAttribute("data-bwignore", "true");
inputBox.setAttribute("aria-autocomplete", "none");
inputBox.setAttribute("autocapitalize", "off");
inputBox.setAttribute("autocorrect", "off");
// 某些浏览器在首帧点击文本输入框时会误触发凭据弹窗;
// 先只读,获得焦点后立刻解除,可显著降低“刷新后点一下就弹登录”的概率。
inputBox.readOnly = true;
inputBox.addEventListener(
"focus",
() => {
inputBox.readOnly = false;
},
{ once: true }
);
const stdin = h<HTMLDivElement>("div", {
class: THEME.INPUT,
children: [inputBox]
});
target.appendChild(stdin);
return inputBox;
}

View File

@@ -0,0 +1,37 @@
import type { IDisposable } from "../types";
/**
* Render function - element props
*/
export interface IElementProps {
id?: string;
class?: string;
content?: string;
html?: string;
children?: (string | Node)[];
props?: object;
}
/**
* Key Bindings to the Input
*/
interface IKeyBindingAction {
(arg1: unknown, arg2?: unknown): void;
}
export interface IKeyBindings {
[key: string]: IKeyBindingAction;
}
/**
* Renderer
*/
export interface IRenderer extends IDisposable {
canInput: boolean;
setKeyBindings(options: IKeyBindings): void;
mount(el: HTMLElement): void;
focusInput(): void;
blurInput(): void;
clearConsole(): void;
output(data: string): void;
}

283
xterminal/source/types.d.ts vendored Normal file
View File

@@ -0,0 +1,283 @@
/**
* @author Henry Hale <https://github.com/henryhale>
* @license MIT
*
* This contains the type declarations for the `xterminal` library. Note that
* some interfaces differ between this file and the actual implementation in
* source/, that's because this file declares the *Public* API which is intended
* to be stable and consumed by external programs.
*/
/// <reference lib="dom"/>
/**
* An object that can be disposed via a dispose function.
*/
export declare class IDisposable {
/**
* Clean up
*/
dispose(): void;
}
/**
* Type of event name.
*/
export type IEventName = string | symbol;
/**
* Callback function invoked when the event is dispatched.
*/
export type IEventListener = (...args: unknown[]) => void;
/**
* Event map
*/
interface IEventMap {
clear: () => void;
data: (input: string) => void;
keypress: (ev: IKeyPress) => void;
pause: () => void;
resume: () => void;
}
/**
* Object passed to callback functions invoked on `keypress` event
*/
export type IKeyPress = {
key: string;
value: string;
altKey: boolean;
metaKey: boolean;
shiftKey: boolean;
ctrlKey: boolean;
cancel(): void;
};
/**
* Event emitter
*
* It extends the Disposable class.
*/
export declare class XEventEmitter extends IDisposable {
/**
* Appends a event listener to the specified event.
*
* The listener is invoked everytime the event is dispatched.
*
* - return disposable object to remove the event listener
*/
on<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
on(event: IEventName, listener: IEventListener): void;
/**
* Appends a event listener to the specified event.
*
* The listener is invoked _only once_ when the event is dispatched.
*
* _It is deleted thereafter._
*/
once<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
once(event: IEventName, listener: IEventListener): void;
/**
* Removes an event listener from the specified event.
*
* The listener won't be invoked on event dispatch thereafter.
*/
off<K extends keyof IEventMap>(event: K, listener: IEventMap[K]): void;
off(event: IEventName, listener: IEventListener): void;
/**
* Triggers an event
* @param event The event name to dispatch.
* @param args data to be passed to the event listener.
*/
emit<K extends keyof IEventMap>(event: K, ...args: unknown[]): void;
emit(event: IEventName, ...args: unknown[]): void;
}
/**
* Terminal Options
*/
export type ITerminalOptions = {
/**
* An HTMLElement in which the terminal will be mounted
*/
target?: HTMLElement;
};
/**
* Plugin
*/
// export interface IPlugin {
// install(context: IPluginContext): void;
// }
/**
* XTerminal
*
* Create a new terminal instance
*/
declare class XTerminal extends XEventEmitter {
constructor(options?: ITerminalOptions);
/**
* Mounts the terminal instance in the `target` HTMLElement.
*
* If the selector is given, the first element is used.
*
* @param target An HTMLElement in which the terminal will be mounted.
*/
mount(target: HTMLElement | string): void;
/**
* Focus the terminal - ready for input.
*/
focus(): void;
/**
* Blurs the terminal.
*/
blur(): void;
/**
* Write data to the terminal.
*
* - use `XTerminal.writeSafe` when printing arbitrary data like user input
*
* @param data The data to write to the terminal
* @param callback Optional function invoked on successful write
* @returns void
*/
write(data: string | number, callback?: () => void): void;
/**
* Safely write data to the terminal.
*
* (recommended) to prevent malicious attacks like XSS
*
* Example:
* ```js
* term.writeSafe('<h1>hello</h1>');
* // &lt;h1&gt;hello&lt;/h1&gt;
* ```
*
* @param data The data to write to the terminal
* @param callback Optional function invoked on successful write
* @returns void
*/
writeSafe(data: string | number, callback?: () => void): void;
/**
* Write data to the terminal, followed by a break line character (\n).
*
* - use `XTerminal.writelnSafe` when printing arbitrary data like user input
*
* @param data The data to write to the terminal
* @param callback Optional function invoked on successful write
* @returns void
*/
writeln(data: string | number, callback?: () => void): void;
/**
* Safely write data to the terminal, followed by a break line character (\n).
*
* (recommended) to prevent malicious attacks like XSS
*
* Example:
* ```js
* term.writelnSafe('<h1>hello</h1>');
* // &lt;h1&gt;hello&lt;/h1&gt;<br/>
* ```
* @param data The data to write to the terminal
* @param callback Optional function invoked on successful write
* @returns void
*/
writelnSafe(data: string | number, callback?: () => void): void;
/**
* Clear the entire terminal.
*
* This method triggers the `clear` event.
*/
clear(): void;
/**
* Remove the element containing the previous output
*/
clearLast(): void;
/**
* Access the history stack
*/
history: string[];
/**
* Clears the entire history stack.
*/
clearHistory(): void;
/**
* Sets the autocomplete function that is invoked on Tab key.
*
* @param fn completer function that takes a string input and return a better
* completion.
*/
setCompleter(fn: (data: string) => string): void;
/**
* Deactivate the terminal input
*/
pause(): void;
/**
* Activate the terminal input
*/
resume(): void;
/**
* Gracefully close the terminal instance.
*
* This detaches all event listeners, unmounts the terminal from the DOM,
* and clears the backing functionality of the terminal.
*
* _The terminal should not be used again once disposed._
*
*/
dispose(): void;
/**
* Install an addon/plugin
*/
// use(plugin: IPlugin): void;
/**
* Version number
*/
static readonly version: string;
/**
* Event emitter
*/
static readonly XEventEmitter: XEventEmitter;
/**
* Escapes user input so it can be safely rendered as HTML text.
* - preserves all characters by converting them to HTML entities where needed.
* - recommended for use on user input or any arbitrary data
*/
static escapeHTML(data?: string): string;
/**
* Clears the input buffer
*/
clearInput(): void;
/**
* Sets the input buffer to some value, updates the cursor
*/
setInput(str: string): void;
}
export default XTerminal;