mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
working on a standalone terminal element that can be embedded
This commit is contained in:
parent
90e31dfa48
commit
d6bd277384
100
frontend/app/element/termelem/fitaddon.tsx
Normal file
100
frontend/app/element/termelem/fitaddon.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
// This file is a copy of the original xterm.js file, with the following changes:
|
||||||
|
// - removed the allowance for the scrollbar
|
||||||
|
|
||||||
|
import type { FitAddon as IFitApi } from "@xterm/addon-fit";
|
||||||
|
import type { ITerminalAddon, Terminal } from "@xterm/xterm";
|
||||||
|
import { IRenderDimensions } from "@xterm/xterm/src/browser/renderer/shared/Types";
|
||||||
|
|
||||||
|
interface ITerminalDimensions {
|
||||||
|
/**
|
||||||
|
* The number of rows in the terminal.
|
||||||
|
*/
|
||||||
|
rows: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of columns in the terminal.
|
||||||
|
*/
|
||||||
|
cols: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIMUM_COLS = 2;
|
||||||
|
const MINIMUM_ROWS = 1;
|
||||||
|
|
||||||
|
export class FitAddon implements ITerminalAddon, IFitApi {
|
||||||
|
private _terminal: Terminal | undefined;
|
||||||
|
public noScrollbar: boolean = false;
|
||||||
|
|
||||||
|
public activate(terminal: Terminal): void {
|
||||||
|
this._terminal = terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {}
|
||||||
|
|
||||||
|
public fit(): void {
|
||||||
|
const dims = this.proposeDimensions();
|
||||||
|
if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove reliance on private API
|
||||||
|
const core = (this._terminal as any)._core;
|
||||||
|
|
||||||
|
// Force a full render
|
||||||
|
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
|
||||||
|
core._renderService.clear();
|
||||||
|
this._terminal.resize(dims.cols, dims.rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||||
|
if (!this._terminal) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._terminal.element || !this._terminal.element.parentElement) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Remove reliance on private API
|
||||||
|
const core = (this._terminal as any)._core;
|
||||||
|
const dims: IRenderDimensions = core._renderService.dimensions;
|
||||||
|
|
||||||
|
if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATED CODE (removed reliance on FALLBACK_SCROLL_BAR_WIDTH in viewport)
|
||||||
|
const measuredScrollBarWidth =
|
||||||
|
core.viewport._viewportElement.offsetWidth - core.viewport._scrollArea.offsetWidth;
|
||||||
|
let scrollbarWidth = this._terminal.options.scrollback === 0 ? 0 : measuredScrollBarWidth;
|
||||||
|
if (this.noScrollbar) {
|
||||||
|
scrollbarWidth = 0;
|
||||||
|
}
|
||||||
|
// END UPDATED CODE
|
||||||
|
|
||||||
|
const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement);
|
||||||
|
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue("height"));
|
||||||
|
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue("width")));
|
||||||
|
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||||
|
const elementPadding = {
|
||||||
|
top: parseInt(elementStyle.getPropertyValue("padding-top")),
|
||||||
|
bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")),
|
||||||
|
right: parseInt(elementStyle.getPropertyValue("padding-right")),
|
||||||
|
left: parseInt(elementStyle.getPropertyValue("padding-left")),
|
||||||
|
};
|
||||||
|
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
|
||||||
|
const elementPaddingHor = elementPadding.right + elementPadding.left;
|
||||||
|
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||||
|
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||||
|
const geometry = {
|
||||||
|
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
||||||
|
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)),
|
||||||
|
};
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
}
|
220
frontend/app/element/termelem/termelem.tsx
Normal file
220
frontend/app/element/termelem/termelem.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { PLATFORM } from "@/store/global";
|
||||||
|
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||||
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
|
import * as TermTypes from "@xterm/xterm";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { debounce } from "throttle-debounce";
|
||||||
|
import { FitAddon } from "./fitaddon";
|
||||||
|
|
||||||
|
const MinDataProcessedForCache = 100 * 1024;
|
||||||
|
|
||||||
|
// detect webgl support
|
||||||
|
function detectWebGLSupport(): boolean {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("webgl");
|
||||||
|
return !!ctx;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebGLSupported = detectWebGLSupport();
|
||||||
|
let loggedWebGL = false;
|
||||||
|
|
||||||
|
export type TermWrapOptions = {
|
||||||
|
xtermOpts: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions;
|
||||||
|
keydownHandler?: (e: KeyboardEvent) => boolean;
|
||||||
|
useWebGl?: boolean;
|
||||||
|
useWebLinksAddon?: boolean;
|
||||||
|
useSerializeAddon?: boolean;
|
||||||
|
onOpenLink?: (uri: string) => void;
|
||||||
|
onCwdChange?: (newCwd: string) => void;
|
||||||
|
handleInputData?: (data: string) => void;
|
||||||
|
resyncController?: (reason: string) => void;
|
||||||
|
onResize?: (termSize: TermSize) => void;
|
||||||
|
onSelectionChange?: (selectedText: string) => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onMount?: () => void;
|
||||||
|
onDispose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TermWrap {
|
||||||
|
ptyOffset: number;
|
||||||
|
dataBytesProcessed: number;
|
||||||
|
terminal: Terminal;
|
||||||
|
connectElem?: HTMLDivElement;
|
||||||
|
fitAddon: FitAddon;
|
||||||
|
serializeAddon?: SerializeAddon;
|
||||||
|
handleResize_debounced: () => void;
|
||||||
|
hasResized: boolean;
|
||||||
|
termOpts: TermWrapOptions;
|
||||||
|
|
||||||
|
constructor(waveOptions: TermWrapOptions) {
|
||||||
|
this.termOpts = waveOptions;
|
||||||
|
this.ptyOffset = 0;
|
||||||
|
this.dataBytesProcessed = 0;
|
||||||
|
this.hasResized = false;
|
||||||
|
this.terminal = new Terminal(this.termOpts.xtermOpts);
|
||||||
|
this.fitAddon = new FitAddon();
|
||||||
|
this.fitAddon.noScrollbar = PLATFORM == "darwin";
|
||||||
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
|
if (this.termOpts.useSerializeAddon) {
|
||||||
|
this.serializeAddon = new SerializeAddon();
|
||||||
|
this.terminal.loadAddon(this.serializeAddon);
|
||||||
|
}
|
||||||
|
if (this.termOpts.useWebLinksAddon && this.termOpts.onOpenLink) {
|
||||||
|
this.terminal.loadAddon(
|
||||||
|
new WebLinksAddon((e, uri) => {
|
||||||
|
e.preventDefault();
|
||||||
|
switch (PLATFORM) {
|
||||||
|
case "darwin":
|
||||||
|
if (e.metaKey) {
|
||||||
|
this.termOpts.onOpenLink?.(uri);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
this.termOpts.onOpenLink?.(uri);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (WebGLSupported && this.termOpts.useWebGl) {
|
||||||
|
const webglAddon = new WebglAddon();
|
||||||
|
webglAddon.onContextLoss(() => {
|
||||||
|
webglAddon.dispose();
|
||||||
|
});
|
||||||
|
this.terminal.loadAddon(webglAddon);
|
||||||
|
if (!loggedWebGL) {
|
||||||
|
console.log("loaded webgl!");
|
||||||
|
loggedWebGL = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.terminal.parser.registerOscHandler(7, (data: string) => {
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (data.startsWith("file://")) {
|
||||||
|
data = data.substring(7);
|
||||||
|
const nextSlashIdx = data.indexOf("/");
|
||||||
|
if (nextSlashIdx == -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
data = data.substring(nextSlashIdx);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.termOpts?.onCwdChange?.(data);
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.handleResize_debounced = debounce(50, this.handleResize.bind(this));
|
||||||
|
if (this.termOpts.keydownHandler != null) {
|
||||||
|
this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||||
|
return this.termOpts.keydownHandler?.(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.termOpts.onFocus != null) {
|
||||||
|
this.terminal.textarea.addEventListener("focus", () => {
|
||||||
|
this.termOpts.onFocus?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initTerminal(connectElem: HTMLDivElement) {
|
||||||
|
this.connectElem = connectElem;
|
||||||
|
this.terminal.open(this.connectElem);
|
||||||
|
this.handleResize();
|
||||||
|
this.terminal.onData(this.handleTermData.bind(this));
|
||||||
|
this.terminal.onSelectionChange(() => {
|
||||||
|
const selectedText = this.terminal.getSelection();
|
||||||
|
this.termOpts.onSelectionChange?.(selectedText);
|
||||||
|
});
|
||||||
|
this.termOpts.onMount?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.termOpts.onDispose?.();
|
||||||
|
this.terminal.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTermData(data: string) {
|
||||||
|
this.termOpts.handleInputData?.(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFocusListener(focusFn: () => void) {
|
||||||
|
this.terminal.textarea.addEventListener("focus", focusFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise<void> {
|
||||||
|
let resolve: () => void = null;
|
||||||
|
let prtn = new Promise<void>((presolve, _) => {
|
||||||
|
resolve = presolve;
|
||||||
|
});
|
||||||
|
this.terminal.write(data, () => {
|
||||||
|
if (setPtyOffset != null) {
|
||||||
|
this.ptyOffset = setPtyOffset;
|
||||||
|
} else {
|
||||||
|
this.ptyOffset += data.length;
|
||||||
|
this.dataBytesProcessed += data.length;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
return prtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeTerminal(termSize: TermSize) {
|
||||||
|
this.terminal.resize(termSize.cols, termSize.rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
resyncController(reason: string) {
|
||||||
|
this.termOpts.resyncController?.(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize() {
|
||||||
|
const oldRows = this.terminal.rows;
|
||||||
|
const oldCols = this.terminal.cols;
|
||||||
|
this.fitAddon.fit();
|
||||||
|
if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) {
|
||||||
|
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
||||||
|
this.termOpts.onResize?.(termSize);
|
||||||
|
}
|
||||||
|
if (!this.hasResized) {
|
||||||
|
this.hasResized = true;
|
||||||
|
this.resyncController("initial resize");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDataBytesProcessed(): number {
|
||||||
|
return this.dataBytesProcessed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminalCacheData(): { data: string; ptyOffset: number; termSize: TermSize } {
|
||||||
|
if (this.serializeAddon == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serializedOutput = this.serializeAddon.serialize();
|
||||||
|
const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols };
|
||||||
|
return { data: serializedOutput, ptyOffset: this.ptyOffset, termSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TermElem = (props: { termOpts: TermWrapOptions }) => {
|
||||||
|
const connectElemRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termWrapRef = useRef<TermWrap>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
termWrapRef.current = new TermWrap(props.termOpts);
|
||||||
|
termWrapRef.current.initTerminal(connectElemRef.current);
|
||||||
|
return () => {
|
||||||
|
termWrapRef.current.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return <div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>;
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user