mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
html serialization
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Terminal, ITerminalAddon, IBuffer, IBufferCell } from 'xterm';
|
||||
|
||||
import { IColorSet } from 'browser/Types';
|
||||
|
||||
function constrain(value: number, low: number, high: number): number {
|
||||
return Math.max(low, Math.min(value, high));
|
||||
@@ -406,6 +406,13 @@ export class SerializeAddon implements ITerminalAddon {
|
||||
return handler.serialize(maxRows - correctRows, maxRows);
|
||||
}
|
||||
|
||||
private _htmlserializeBuffer(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {
|
||||
const maxRows = buffer.length;
|
||||
const handler = new HTMLSerializeHandler(buffer, terminal);
|
||||
const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);
|
||||
return handler.serialize(maxRows - correctRows, maxRows);
|
||||
}
|
||||
|
||||
private _serializeModes(terminal: Terminal): string {
|
||||
let content = '';
|
||||
const modes = terminal.modes;
|
||||
@@ -460,6 +467,14 @@ export class SerializeAddon implements ITerminalAddon {
|
||||
return content;
|
||||
}
|
||||
|
||||
public htmlserialize(options?: IHtmlSerializeOptions): string {
|
||||
if (!this._terminal) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
|
||||
return this._htmlserializeBuffer(this._terminal, this._terminal.buffer.normal, options?.scrollback);
|
||||
}
|
||||
|
||||
public dispose(): void { }
|
||||
}
|
||||
|
||||
@@ -469,3 +484,145 @@ interface ISerializeOptions {
|
||||
excludeModes?: boolean;
|
||||
excludeAltBuffer?: boolean;
|
||||
}
|
||||
|
||||
interface IHtmlSerializeOptions {
|
||||
scrollback?: number;
|
||||
}
|
||||
|
||||
export class HTMLSerializeHandler extends BaseSerializeHandler {
|
||||
private _currentRow: string = '';
|
||||
|
||||
private _htmlContent = '';
|
||||
|
||||
private _inverseStyle = 'color: #000000; background-color: #BFBFBF;';
|
||||
private _blinkStyle = 'text-decoration: blink;';
|
||||
private _boldStyle = 'font-weight: bold;';
|
||||
private _italicStyle = 'font-style: italic;';
|
||||
private _underlineStyle = 'text-decoration: underline;';
|
||||
private _strikethroughStyle = 'text-decoration: line-through;';
|
||||
private _invisibleStyle = 'visibility: hidden;';
|
||||
private _dimStyle = 'opacity: 0.5;';
|
||||
|
||||
private _colors: IColorSet;
|
||||
|
||||
constructor(
|
||||
buffer: IBuffer,
|
||||
private readonly _terminal: Terminal
|
||||
) {
|
||||
super(buffer);
|
||||
|
||||
// https://github.com/xtermjs/xterm.js/issues/3601
|
||||
this._colors = (_terminal as any)._core._colorManager.colors;
|
||||
}
|
||||
|
||||
private _padStart(target: string, targetLength: number, padString: string): string {
|
||||
targetLength = targetLength >> 0;
|
||||
padString = String(typeof padString !== 'undefined' ? padString : ' ');
|
||||
if (target.length > targetLength) {
|
||||
return target;
|
||||
}
|
||||
|
||||
targetLength = targetLength - target.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return padString.slice(0, targetLength) + target;
|
||||
}
|
||||
|
||||
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
||||
this._htmlContent += '<html><head><meta name=\'generator\' content=\'xtermjs\'/>'
|
||||
+ '<meta http-equiv=\'Content-Type\' content=\'text/html; charset=UTF-8\'/></head><body><!--StartFragment--><pre>';
|
||||
// TODO: fetch options and remove hardcoded values
|
||||
this._htmlContent += '<div style=\'color: #ffffff; background-color: #000000; font-family: Fira Code, courier-new, courier, monospace; font-size: 15px;\'>';
|
||||
}
|
||||
|
||||
protected _afterSerialize(): void {
|
||||
this._htmlContent += '</div>';
|
||||
this._htmlContent += '</pre><!--EndFragment--></body></html>';
|
||||
}
|
||||
|
||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||
this._htmlContent += '<span>' + this._currentRow + '</span><br/>';
|
||||
this._currentRow = '';
|
||||
}
|
||||
|
||||
private _getHexColor(cell: IBufferCell, isFg: boolean): string | undefined {
|
||||
const color = isFg ? cell.getFgColor() : cell.getBgColor();
|
||||
if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {
|
||||
const rgb = [
|
||||
color >>> 0xFF0000 & 255,
|
||||
color >>> 0xFF00 & 255,
|
||||
color & 255
|
||||
];
|
||||
return rgb.map(x => this._padStart(x.toString(16), 2, '0')).join('');
|
||||
}
|
||||
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
||||
return this._colors.ansi[color].css;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
|
||||
const content: string[] = [];
|
||||
|
||||
const fgChanged = !equalFg(cell, oldCell);
|
||||
const bgChanged = !equalBg(cell, oldCell);
|
||||
const flagsChanged = !equalFlags(cell, oldCell);
|
||||
|
||||
if (fgChanged || bgChanged || flagsChanged) {
|
||||
const fgHexColor = this._getHexColor(cell, true);
|
||||
if (fgHexColor) {
|
||||
content.push('color: ' + fgHexColor + ';');
|
||||
}
|
||||
|
||||
const bgHexColor = this._getHexColor(cell, false);
|
||||
if (bgHexColor) {
|
||||
content.push('background-color: ' + bgHexColor + ';');
|
||||
}
|
||||
|
||||
if (cell.isInverse()) { content.push(this._inverseStyle); }
|
||||
if (cell.isBold()) { content.push(this._boldStyle); }
|
||||
if (cell.isUnderline()) { content.push(this._underlineStyle); }
|
||||
if (cell.isBlink()) { content.push(this._blinkStyle); }
|
||||
if (cell.isInvisible()) { content.push(this._invisibleStyle); }
|
||||
if (cell.isItalic()) { content.push(this._italicStyle); }
|
||||
if (cell.isDim()) { content.push(this._dimStyle); }
|
||||
if (cell.isStrikethrough()) { content.push(this._strikethroughStyle); }
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void {
|
||||
// a width 0 cell don't need to be count because it is just a placeholder after a CJK character;
|
||||
const isPlaceHolderCell = cell.getWidth() === 0;
|
||||
if (isPlaceHolderCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this cell don't have content
|
||||
const isEmptyCell = cell.getChars() === '';
|
||||
|
||||
const styleDefinitions = this._diffStyle(cell, oldCell);
|
||||
|
||||
// handles style change
|
||||
if (styleDefinitions) {
|
||||
this._currentRow += styleDefinitions.length === 0 ?
|
||||
`</span><span>` :
|
||||
`</span><span style='${styleDefinitions.join(' ')}'>`;
|
||||
}
|
||||
|
||||
// handles actual content
|
||||
if (isEmptyCell) {
|
||||
this._currentRow += ' ';
|
||||
} else {
|
||||
this._currentRow += cell.getChars();
|
||||
}
|
||||
}
|
||||
|
||||
protected _serializeString(): string {
|
||||
return this._htmlContent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
@@ -28,6 +31,9 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src/common"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+51
-24
@@ -59,27 +59,27 @@ interface IDemoAddon<T extends AddonType> {
|
||||
name: T;
|
||||
canChange: boolean;
|
||||
ctor:
|
||||
T extends 'attach' ? typeof AttachAddon :
|
||||
T extends 'fit' ? typeof FitAddon :
|
||||
T extends 'search' ? typeof SearchAddon :
|
||||
T extends 'serialize' ? typeof SerializeAddon :
|
||||
T extends 'web-links' ? typeof WebLinksAddon :
|
||||
T extends 'unicode11' ? typeof Unicode11Addon :
|
||||
T extends 'ligatures' ? typeof LigaturesAddon :
|
||||
typeof WebglAddon;
|
||||
instance?:
|
||||
T extends 'attach' ? AttachAddon :
|
||||
T extends 'fit' ? FitAddon :
|
||||
T extends 'search' ? SearchAddon :
|
||||
T extends 'serialize' ? SerializeAddon :
|
||||
T extends 'web-links' ? WebLinksAddon :
|
||||
T extends 'webgl' ? WebglAddon :
|
||||
T extends 'unicode11' ? typeof Unicode11Addon :
|
||||
T extends 'ligatures' ? typeof LigaturesAddon :
|
||||
never;
|
||||
T extends 'attach' ? typeof AttachAddon :
|
||||
T extends 'fit' ? typeof FitAddon :
|
||||
T extends 'search' ? typeof SearchAddon :
|
||||
T extends 'serialize' ? typeof SerializeAddon :
|
||||
T extends 'web-links' ? typeof WebLinksAddon :
|
||||
T extends 'unicode11' ? typeof Unicode11Addon :
|
||||
T extends 'ligatures' ? typeof LigaturesAddon :
|
||||
typeof WebglAddon;
|
||||
instance?:
|
||||
T extends 'attach' ? AttachAddon :
|
||||
T extends 'fit' ? FitAddon :
|
||||
T extends 'search' ? SearchAddon :
|
||||
T extends 'serialize' ? SerializeAddon :
|
||||
T extends 'web-links' ? WebLinksAddon :
|
||||
T extends 'webgl' ? WebglAddon :
|
||||
T extends 'unicode11' ? typeof Unicode11Addon :
|
||||
T extends 'ligatures' ? typeof LigaturesAddon :
|
||||
never;
|
||||
}
|
||||
|
||||
const addons: { [T in AddonType]: IDemoAddon<T>} = {
|
||||
const addons: { [T in AddonType]: IDemoAddon<T> } = {
|
||||
attach: { name: 'attach', ctor: AttachAddon, canChange: false },
|
||||
fit: { name: 'fit', ctor: FitAddon, canChange: false },
|
||||
search: { name: 'search', ctor: SearchAddon, canChange: true },
|
||||
@@ -147,6 +147,7 @@ if (document.location.pathname === '/test') {
|
||||
createTerminal();
|
||||
document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler);
|
||||
document.getElementById('serialize').addEventListener('click', serializeButtonHandler);
|
||||
document.getElementById('htmlserialize').addEventListener('click', htmlserializeButtonHandler);
|
||||
document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler);
|
||||
document.getElementById('load-test').addEventListener('click', loadTest);
|
||||
}
|
||||
@@ -186,7 +187,7 @@ function createTerminal(): void {
|
||||
const rows = size.rows;
|
||||
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows;
|
||||
|
||||
fetch(url, {method: 'POST'});
|
||||
fetch(url, { method: 'POST' });
|
||||
});
|
||||
protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
|
||||
socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/terminals/';
|
||||
@@ -216,7 +217,7 @@ function createTerminal(): void {
|
||||
// Set terminal size again to set the specific dimensions on the demo
|
||||
updateTerminalSize();
|
||||
|
||||
fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then((res) => {
|
||||
fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, { method: 'POST' }).then((res) => {
|
||||
res.text().then((processId) => {
|
||||
pid = processId;
|
||||
socketURL += processId;
|
||||
@@ -261,7 +262,7 @@ function runFakeTerminal(): void {
|
||||
if (ev.keyCode === 13) {
|
||||
term.prompt();
|
||||
} else if (ev.keyCode === 8) {
|
||||
// Do not delete the prompt
|
||||
// Do not delete the prompt
|
||||
if (term._core.buffer.x > 2) {
|
||||
term.write('\b \b');
|
||||
}
|
||||
@@ -353,7 +354,7 @@ function initOptions(term: TerminalType): void {
|
||||
} else if (o === 'scrollSensitivity') {
|
||||
term.options.scrollSensitivity = parseFloat(input.value);
|
||||
updateTerminalSize();
|
||||
} else if(o === 'scrollback') {
|
||||
} else if (o === 'scrollback') {
|
||||
term.options.scrollback = parseInt(input.value);
|
||||
setTimeout(() => updateTerminalSize(), 5);
|
||||
} else {
|
||||
@@ -380,7 +381,7 @@ function initAddons(term: TerminalType): void {
|
||||
if (!addon.canChange) {
|
||||
checkbox.disabled = true;
|
||||
}
|
||||
if(name === 'unicode11' && checkbox.checked) {
|
||||
if (name === 'unicode11' && checkbox.checked) {
|
||||
term.unicode.activeVersion = '11';
|
||||
}
|
||||
addDomListener(checkbox, 'change', () => {
|
||||
@@ -447,6 +448,32 @@ function serializeButtonHandler(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function htmlserializeButtonHandler(): void {
|
||||
const output = addons.serialize.instance.htmlserialize();
|
||||
document.getElementById('htmlserialize-output').innerText = output;
|
||||
|
||||
var type = "text/html";
|
||||
var blob = new Blob([output], { type });
|
||||
// @ts-ignore
|
||||
var data = [new ClipboardItem({ [type]: blob })];
|
||||
|
||||
const permissionName = "clipboard-write" as PermissionName;
|
||||
navigator.permissions.query({name: permissionName }).then(result => {
|
||||
if (result.state == "granted" || result.state == "prompt") {
|
||||
navigator.clipboard.write(data).then(
|
||||
() => {
|
||||
document.getElementById("htmlserialize-output-result").innerText
|
||||
= "Copied to clipboard";
|
||||
},
|
||||
() => {
|
||||
document.getElementById("htmlserialize-output-result").innerText
|
||||
= "Can't copy to clipboard.";
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function writeCustomGlyphHandler() {
|
||||
term.write('\n\r');
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
<button id="serialize">Serialize the content of terminal</button>
|
||||
<label><input type="checkbox" id="write-to-terminal">Write back to terminal</label>
|
||||
<div><pre id="serialize-output"></pre></div>
|
||||
|
||||
<button id="htmlserialize">Serialize the content of terminal in HTML</button>
|
||||
<span id="htmlserialize-output-result"></span>
|
||||
<div><pre id="htmlserialize-output"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="style" class="tabContent">
|
||||
|
||||
Reference in New Issue
Block a user