826 lines
30 KiB
TypeScript
826 lines
30 KiB
TypeScript
/**
|
|
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*
|
|
* (EXPERIMENTAL) This Addon is still under development
|
|
*/
|
|
|
|
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
|
|
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
|
|
import { IColor } from 'common/Types';
|
|
import { IAttributeData } from 'common/buffer/Types';
|
|
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
|
import { UnderlineStyle } from 'common/buffer/Constants';
|
|
|
|
function constrain(value: number, low: number, high: number): number {
|
|
return Math.max(low, Math.min(value, high));
|
|
}
|
|
|
|
function escapeHTMLChar(c: string): string {
|
|
switch (c) {
|
|
case '&': return '&';
|
|
case '<': return '<';
|
|
}
|
|
return c;
|
|
}
|
|
|
|
// TODO: Refine this template class later
|
|
abstract class BaseSerializeHandler {
|
|
constructor(
|
|
protected readonly _buffer: IBuffer
|
|
) {
|
|
}
|
|
|
|
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
|
|
// we need two of them to flip between old and new cell
|
|
const cell1 = this._buffer.getNullCell();
|
|
const cell2 = this._buffer.getNullCell();
|
|
let oldCell = cell1;
|
|
|
|
const startRow = range.start.y;
|
|
const endRow = range.end.y;
|
|
const startColumn = range.start.x;
|
|
const endColumn = range.end.x;
|
|
|
|
this._beforeSerialize(endRow - startRow, startRow, endRow);
|
|
|
|
for (let row = startRow; row <= endRow; row++) {
|
|
const line = this._buffer.getLine(row);
|
|
if (line) {
|
|
const startLineColumn = row === range.start.y ? startColumn : 0;
|
|
const endLineColumn = row === range.end.y ? endColumn: line.length;
|
|
for (let col = startLineColumn; col < endLineColumn; col++) {
|
|
const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1);
|
|
if (!c) {
|
|
console.warn(`Can't get cell at row=${row}, col=${col}`);
|
|
continue;
|
|
}
|
|
this._nextCell(c, oldCell, row, col);
|
|
oldCell = c;
|
|
}
|
|
}
|
|
this._rowEnd(row, row === endRow);
|
|
}
|
|
|
|
this._afterSerialize();
|
|
|
|
return this._serializeString(excludeFinalCursorPosition);
|
|
}
|
|
|
|
protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { }
|
|
protected _rowEnd(row: number, isLastRow: boolean): void { }
|
|
protected _beforeSerialize(rows: number, startRow: number, endRow: number): void { }
|
|
protected _afterSerialize(): void { }
|
|
protected _serializeString(excludeFinalCursorPosition?: boolean): string { return ''; }
|
|
}
|
|
|
|
function equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
|
return cell1.getFgColorMode() === cell2.getFgColorMode()
|
|
&& cell1.getFgColor() === cell2.getFgColor();
|
|
}
|
|
|
|
function equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
|
return cell1.getBgColorMode() === cell2.getBgColorMode()
|
|
&& cell1.getBgColor() === cell2.getBgColor();
|
|
}
|
|
|
|
function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
|
// If neither cell has underline, consider them equal regardless of internal underline color
|
|
// values
|
|
if (!cell1.isUnderline() && !cell2.isUnderline()) {
|
|
return true;
|
|
}
|
|
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
|
|
return false;
|
|
}
|
|
const cell1Default = cell1.isUnderlineColorDefault();
|
|
const cell2Default = cell2.isUnderlineColorDefault();
|
|
if (cell1Default && cell2Default) {
|
|
return true;
|
|
}
|
|
if (cell1Default !== cell2Default) {
|
|
return false;
|
|
}
|
|
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
|
|
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
|
|
}
|
|
|
|
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
|
return cell1.isInverse() === cell2.isInverse()
|
|
&& cell1.isBold() === cell2.isBold()
|
|
&& cell1.isUnderline() === cell2.isUnderline()
|
|
&& equalUnderline(cell1, cell2)
|
|
&& cell1.isOverline() === cell2.isOverline()
|
|
&& cell1.isBlink() === cell2.isBlink()
|
|
&& cell1.isInvisible() === cell2.isInvisible()
|
|
&& cell1.isItalic() === cell2.isItalic()
|
|
&& cell1.isDim() === cell2.isDim()
|
|
&& cell1.isStrikethrough() === cell2.isStrikethrough();
|
|
}
|
|
|
|
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
|
const cell1AsBufferCell = cell1 as IBufferCell;
|
|
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
|
|
return cell1AsBufferCell.attributesEquals(cell2);
|
|
}
|
|
return equalFg(cell1, cell2)
|
|
&& equalBg(cell1, cell2)
|
|
&& equalFlags(cell1, cell2);
|
|
}
|
|
|
|
class StringSerializeHandler extends BaseSerializeHandler {
|
|
private _rowIndex: number = 0;
|
|
private _allRows: string[] = new Array<string>();
|
|
private _allRowSeparators: string[] = new Array<string>();
|
|
private _currentRow: string = '';
|
|
private _nullCellCount: number = 0;
|
|
|
|
// we can see a full colored cell and a null cell that only have background the same style
|
|
// but the information isn't preserved by null cell itself
|
|
// so wee need to record it when required.
|
|
private _cursorStyle: IBufferCell = this._buffer.getNullCell();
|
|
|
|
// where exact the cursor styles comes from
|
|
// because we can't copy the cell directly
|
|
// so we remember where the content comes from instead
|
|
private _cursorStyleRow: number = 0;
|
|
private _cursorStyleCol: number = 0;
|
|
|
|
// this is a null cell for reference for checking whether background is empty or not
|
|
private _backgroundCell: IBufferCell = this._buffer.getNullCell();
|
|
|
|
private _firstRow: number = 0;
|
|
private _lastCursorRow: number = 0;
|
|
private _lastCursorCol: number = 0;
|
|
private _lastContentCursorRow: number = 0;
|
|
private _lastContentCursorCol: number = 0;
|
|
|
|
constructor(
|
|
buffer: IBuffer,
|
|
private readonly _terminal: Terminal
|
|
) {
|
|
super(buffer);
|
|
}
|
|
|
|
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
|
this._allRows = new Array<string>(rows);
|
|
this._lastContentCursorRow = start;
|
|
this._lastCursorRow = start;
|
|
this._firstRow = start;
|
|
}
|
|
|
|
private _thisRowLastChar: IBufferCell = this._buffer.getNullCell();
|
|
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
|
|
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
|
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
|
// if there is colorful empty cell at line end, we must pad it back, or the color block
|
|
// will missing
|
|
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
|
|
// use clear right to set background.
|
|
this._currentRow += `\u001b[${this._nullCellCount}X`;
|
|
}
|
|
|
|
let rowSeparator = '';
|
|
|
|
// handle row separator
|
|
if (!isLastRow) {
|
|
// Enable BCE
|
|
if (row - this._firstRow >= this._terminal.rows) {
|
|
this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol, this._backgroundCell);
|
|
}
|
|
|
|
// Fetch current line
|
|
const currentLine = this._buffer.getLine(row)!;
|
|
// Fetch next line
|
|
const nextLine = this._buffer.getLine(row + 1)!;
|
|
|
|
if (!nextLine.isWrapped) {
|
|
// just insert the line break
|
|
rowSeparator = '\r\n';
|
|
// we sended the enter
|
|
this._lastCursorRow = row + 1;
|
|
this._lastCursorCol = 0;
|
|
} else {
|
|
rowSeparator = '';
|
|
const thisRowLastChar = currentLine.getCell(currentLine.length - 1, this._thisRowLastChar)!;
|
|
const thisRowLastSecondChar = currentLine.getCell(currentLine.length - 2, this._thisRowLastSecondChar)!;
|
|
const nextRowFirstChar = nextLine.getCell(0, this._nextRowFirstChar)!;
|
|
const isNextRowFirstCharDoubleWidth = nextRowFirstChar.getWidth() > 1;
|
|
|
|
// validate whether this line wrap is ever possible
|
|
// which mean whether cursor can placed at a overflow position (x === row) naturally
|
|
let isValid = false;
|
|
|
|
if (
|
|
// you must output character to cause overflow, control sequence can't do this
|
|
nextRowFirstChar.getChars() &&
|
|
isNextRowFirstCharDoubleWidth ? this._nullCellCount <= 1 : this._nullCellCount <= 0
|
|
) {
|
|
if (
|
|
// the last character can't be null,
|
|
// you can't use control sequence to move cursor to (x === row)
|
|
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
|
|
// change background of the first wrapped cell also affects BCE
|
|
// so we mark it as invalid to simplify the process to determine line separator
|
|
equalBg(thisRowLastChar, nextRowFirstChar)
|
|
) {
|
|
isValid = true;
|
|
}
|
|
|
|
if (
|
|
// the second to last character can't be null if the next line starts with CJK,
|
|
// you can't use control sequence to move cursor to (x === row)
|
|
isNextRowFirstCharDoubleWidth &&
|
|
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
|
|
// change background of the first wrapped cell also affects BCE
|
|
// so we mark it as invalid to simplify the process to determine line separator
|
|
equalBg(thisRowLastChar, nextRowFirstChar) &&
|
|
equalBg(thisRowLastSecondChar, nextRowFirstChar)
|
|
) {
|
|
isValid = true;
|
|
}
|
|
}
|
|
|
|
if (!isValid) {
|
|
// force the wrap with magic
|
|
// insert enough character to force the wrap
|
|
rowSeparator = '-'.repeat(this._nullCellCount + 1);
|
|
// move back and erase next line head
|
|
rowSeparator += '\u001b[1D\u001b[1X';
|
|
|
|
if (this._nullCellCount > 0) {
|
|
// do these because we filled the last several null slot, which we shouldn't
|
|
rowSeparator += '\u001b[A';
|
|
rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}C`;
|
|
rowSeparator += `\u001b[${this._nullCellCount}X`;
|
|
rowSeparator += `\u001b[${currentLine.length - this._nullCellCount}D`;
|
|
rowSeparator += '\u001b[B';
|
|
}
|
|
|
|
// This is content and need the be serialized even it is invisible.
|
|
// without this, wrap will be missing from outputs.
|
|
this._lastContentCursorRow = row + 1;
|
|
this._lastContentCursorCol = 0;
|
|
|
|
// force commit the cursor position
|
|
this._lastCursorRow = row + 1;
|
|
this._lastCursorCol = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
this._allRows[this._rowIndex] = this._currentRow;
|
|
this._allRowSeparators[this._rowIndex++] = rowSeparator;
|
|
this._currentRow = '';
|
|
this._nullCellCount = 0;
|
|
}
|
|
|
|
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
|
|
const sgrSeq: number[] = [];
|
|
if (attributesEquals(cell, oldCell)) {
|
|
return sgrSeq;
|
|
}
|
|
const fgChanged = !equalFg(cell, oldCell);
|
|
const bgChanged = !equalBg(cell, oldCell);
|
|
const flagsChanged = !equalFlags(cell, oldCell);
|
|
|
|
if (fgChanged || bgChanged || flagsChanged) {
|
|
if (cell.isAttributeDefault()) {
|
|
if (!oldCell.isAttributeDefault()) {
|
|
sgrSeq.push(0);
|
|
}
|
|
} else {
|
|
if (fgChanged) {
|
|
const color = cell.getFgColor();
|
|
if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }
|
|
else if (cell.isFgPalette()) {
|
|
if (color >= 16) { sgrSeq.push(38, 5, color); }
|
|
else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); }
|
|
}
|
|
else { sgrSeq.push(39); }
|
|
}
|
|
if (bgChanged) {
|
|
const color = cell.getBgColor();
|
|
if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); }
|
|
else if (cell.isBgPalette()) {
|
|
if (color >= 16) { sgrSeq.push(48, 5, color); }
|
|
else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); }
|
|
}
|
|
else { sgrSeq.push(49); }
|
|
}
|
|
if (flagsChanged) {
|
|
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
|
|
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
|
|
if (!equalUnderline(cell, oldCell)) {
|
|
const style = cell.getUnderlineStyle();
|
|
if (style === UnderlineStyle.NONE) {
|
|
sgrSeq.push(24);
|
|
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
|
|
sgrSeq.push(4);
|
|
} else {
|
|
// Use SGR 4:X format for underline styles
|
|
sgrSeq.push('4:' + style as unknown as number);
|
|
// Handle underline color
|
|
if (!cell.isUnderlineColorDefault()) {
|
|
const color = cell.getUnderlineColor();
|
|
if (cell.isUnderlineColorRGB()) {
|
|
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
|
|
} else {
|
|
sgrSeq.push('58:5:' + color as unknown as number);
|
|
}
|
|
}
|
|
}
|
|
} else if (cell.isUnderline() !== oldCell.isUnderline()) {
|
|
sgrSeq.push(cell.isUnderline() ? 4 : 24);
|
|
}
|
|
if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); }
|
|
if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); }
|
|
if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); }
|
|
if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); }
|
|
if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); }
|
|
if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); }
|
|
}
|
|
}
|
|
}
|
|
|
|
return sgrSeq;
|
|
}
|
|
|
|
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 sgrSeq = this._diffStyle(cell, this._cursorStyle);
|
|
|
|
// the empty cell style is only assumed to be changed when background changed, because
|
|
// foreground is always 0.
|
|
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0;
|
|
|
|
/**
|
|
* handles style change
|
|
*/
|
|
if (styleChanged) {
|
|
// before update the style, we need to fill empty cell back
|
|
if (this._nullCellCount > 0) {
|
|
// use clear right to set background.
|
|
if (!equalBg(this._cursorStyle, this._backgroundCell)) {
|
|
this._currentRow += `\u001b[${this._nullCellCount}X`;
|
|
}
|
|
// use move right to move cursor.
|
|
this._currentRow += `\u001b[${this._nullCellCount}C`;
|
|
this._nullCellCount = 0;
|
|
}
|
|
|
|
this._lastContentCursorRow = this._lastCursorRow = row;
|
|
this._lastContentCursorCol = this._lastCursorCol = col;
|
|
|
|
this._currentRow += `\u001b[${sgrSeq.join(';')}m`;
|
|
|
|
// update the last cursor style
|
|
const line = this._buffer.getLine(row);
|
|
if (line !== undefined) {
|
|
line.getCell(col, this._cursorStyle);
|
|
this._cursorStyleRow = row;
|
|
this._cursorStyleCol = col;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* handles actual content
|
|
*/
|
|
if (isEmptyCell) {
|
|
this._nullCellCount += cell.getWidth();
|
|
} else {
|
|
if (this._nullCellCount > 0) {
|
|
// we can just assume we have same style with previous one here
|
|
// because style change is handled by previous stage
|
|
// use move right when background is empty, use clear right when there is background.
|
|
if (equalBg(this._cursorStyle, this._backgroundCell)) {
|
|
this._currentRow += `\u001b[${this._nullCellCount}C`;
|
|
} else {
|
|
this._currentRow += `\u001b[${this._nullCellCount}X`;
|
|
this._currentRow += `\u001b[${this._nullCellCount}C`;
|
|
}
|
|
this._nullCellCount = 0;
|
|
}
|
|
|
|
this._currentRow += cell.getChars();
|
|
|
|
// update cursor
|
|
this._lastContentCursorRow = this._lastCursorRow = row;
|
|
this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth();
|
|
}
|
|
}
|
|
|
|
protected _serializeString(excludeFinalCursorPosition: boolean): string {
|
|
let rowEnd = this._allRows.length;
|
|
|
|
// the fixup is only required for data without scrollback
|
|
// because it will always be placed at last line otherwise
|
|
if (this._buffer.length - this._firstRow <= this._terminal.rows) {
|
|
rowEnd = this._lastContentCursorRow + 1 - this._firstRow;
|
|
this._lastCursorCol = this._lastContentCursorCol;
|
|
this._lastCursorRow = this._lastContentCursorRow;
|
|
}
|
|
|
|
let content = '';
|
|
|
|
for (let i = 0; i < rowEnd; i++) {
|
|
content += this._allRows[i];
|
|
if (i + 1 < rowEnd) {
|
|
content += this._allRowSeparators[i];
|
|
}
|
|
}
|
|
|
|
// restore the cursor
|
|
if (!excludeFinalCursorPosition) {
|
|
const realCursorRow = this._buffer.baseY + this._buffer.cursorY;
|
|
const realCursorCol = this._buffer.cursorX;
|
|
|
|
const cursorMoved = (realCursorRow !== this._lastCursorRow || realCursorCol !== this._lastCursorCol);
|
|
|
|
const moveRight = (offset: number): void => {
|
|
if (offset > 0) {
|
|
content += `\u001b[${offset}C`;
|
|
} else if (offset < 0) {
|
|
content += `\u001b[${-offset}D`;
|
|
}
|
|
};
|
|
const moveDown = (offset: number): void => {
|
|
if (offset > 0) {
|
|
content += `\u001b[${offset}B`;
|
|
} else if (offset < 0) {
|
|
content += `\u001b[${-offset}A`;
|
|
}
|
|
};
|
|
|
|
if (cursorMoved) {
|
|
moveDown(realCursorRow - this._lastCursorRow);
|
|
moveRight(realCursorCol - this._lastCursorCol);
|
|
}
|
|
}
|
|
|
|
// Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677
|
|
// HACK: Internal API access since it's awkward to expose this in the API and serialize will
|
|
// likely be the only consumer
|
|
const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;
|
|
const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);
|
|
if (sgrSeq.length > 0) {
|
|
content += `\u001b[${sgrSeq.join(';')}m`;
|
|
}
|
|
|
|
return content;
|
|
}
|
|
}
|
|
|
|
export class SerializeAddon implements ITerminalAddon, ISerializeApi {
|
|
private _terminal: Terminal | undefined;
|
|
|
|
public activate(terminal: Terminal): void {
|
|
this._terminal = terminal;
|
|
}
|
|
|
|
private _serializeBufferByScrollback(terminal: Terminal, buffer: IBuffer, scrollback?: number): string {
|
|
const maxRows = buffer.length;
|
|
const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);
|
|
return this._serializeBufferByRange(terminal, buffer, {
|
|
start: maxRows - correctRows,
|
|
end: maxRows - 1
|
|
}, false);
|
|
}
|
|
|
|
private _serializeBufferByRange(terminal: Terminal, buffer: IBuffer, range: ISerializeRange, excludeFinalCursorPosition: boolean): string {
|
|
const handler = new StringSerializeHandler(buffer, terminal);
|
|
return handler.serialize({
|
|
start: { x: 0, y: typeof range.start === 'number' ? range.start : range.start.line },
|
|
end: { x: terminal.cols, y: typeof range.end === 'number' ? range.end : range.end.line }
|
|
}, excludeFinalCursorPosition);
|
|
}
|
|
|
|
private _serializeBufferAsHTML(terminal: Terminal, options: Partial<IHTMLSerializeOptions>): string {
|
|
const buffer = terminal.buffer.active;
|
|
const handler = new HTMLSerializeHandler(buffer, terminal, options);
|
|
const onlySelection = options.onlySelection ?? false;
|
|
const range = options.range;
|
|
if (range) {
|
|
return handler.serialize({
|
|
start: { x: range.startCol, y: typeof range.startLine === 'number' ? range.startLine : range.startLine },
|
|
end: { x: terminal.cols, y: typeof range.endLine === 'number' ? range.endLine : range.endLine }
|
|
});
|
|
}
|
|
if (!onlySelection) {
|
|
const maxRows = buffer.length;
|
|
const scrollback = options.scrollback;
|
|
const correctRows = (scrollback === undefined) ? maxRows : constrain(scrollback + terminal.rows, 0, maxRows);
|
|
return handler.serialize({
|
|
start: { x: 0, y: maxRows - correctRows },
|
|
end: { x: terminal.cols, y: maxRows - 1 }
|
|
});
|
|
}
|
|
|
|
const selection = this._terminal?.getSelectionPosition();
|
|
if (selection !== undefined) {
|
|
return handler.serialize({
|
|
start: { x: selection.start.x, y: selection.start.y },
|
|
end: { x: selection.end.x, y: selection.end.y }
|
|
});
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Serializes the scroll region (DECSTBM) if it's not set to the full terminal size.
|
|
* Uses internal API access since scroll region is not exposed in the public API.
|
|
*/
|
|
private _serializeScrollRegion(terminal: Terminal): string {
|
|
// HACK: Internal API access since scroll region is not exposed in the public API
|
|
const buffer = (terminal as any)._core.buffer;
|
|
const scrollTop: number = buffer.scrollTop;
|
|
const scrollBottom: number = buffer.scrollBottom;
|
|
|
|
// Only serialize if scroll region is not the default (full terminal size)
|
|
if (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) {
|
|
// DECSTBM uses 1-based indices: CSI Ps ; Ps r
|
|
return `\x1b[${scrollTop + 1};${scrollBottom + 1}r`;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private _serializeModes(terminal: Terminal): string {
|
|
let content = '';
|
|
const modes = terminal.modes;
|
|
|
|
// Default: false
|
|
if (modes.applicationCursorKeysMode) content += '\x1b[?1h';
|
|
if (modes.applicationKeypadMode) content += '\x1b[?66h';
|
|
if (modes.bracketedPasteMode) content += '\x1b[?2004h';
|
|
if (modes.insertMode) content += '\x1b[4h';
|
|
if (modes.originMode) content += '\x1b[?6h';
|
|
if (modes.reverseWraparoundMode) content += '\x1b[?45h';
|
|
if (modes.sendFocusMode) content += '\x1b[?1004h';
|
|
// synchronizedOutputMode doesn't need to be serialized as it's a temporary mode
|
|
|
|
// Default: true
|
|
if (modes.wraparoundMode === false) content += '\x1b[?7l';
|
|
|
|
// Default: 'none'
|
|
if (modes.mouseTrackingMode !== 'none') {
|
|
switch (modes.mouseTrackingMode) {
|
|
case 'x10': content += '\x1b[?9h'; break;
|
|
case 'vt200': content += '\x1b[?1000h'; break;
|
|
case 'drag': content += '\x1b[?1002h'; break;
|
|
case 'any': content += '\x1b[?1003h'; break;
|
|
}
|
|
}
|
|
|
|
// Cursor visibility (DECTCEM)
|
|
// Default: visible
|
|
if (!modes.showCursor) {
|
|
content += '\x1b[?25l';
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
public serialize(options?: ISerializeOptions): string {
|
|
// TODO: Add combinedData support
|
|
if (!this._terminal) {
|
|
throw new Error('Cannot use addon until it has been loaded');
|
|
}
|
|
|
|
// Normal buffer
|
|
let content = options?.range
|
|
? this._serializeBufferByRange(this._terminal, this._terminal.buffer.normal, options.range, true)
|
|
: this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.normal, options?.scrollback);
|
|
|
|
// Alternate buffer
|
|
if (!options?.excludeAltBuffer) {
|
|
if (this._terminal.buffer.active.type === 'alternate') {
|
|
const alternativeScreenContent = this._serializeBufferByScrollback(this._terminal, this._terminal.buffer.alternate, undefined);
|
|
content += `\u001b[?1049h\u001b[H${alternativeScreenContent}`;
|
|
}
|
|
}
|
|
|
|
// Modes and scroll region
|
|
if (!options?.excludeModes) {
|
|
content += this._serializeModes(this._terminal);
|
|
content += this._serializeScrollRegion(this._terminal);
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
public serializeAsHTML(options?: Partial<IHTMLSerializeOptions>): string {
|
|
if (!this._terminal) {
|
|
throw new Error('Cannot use addon until it has been loaded');
|
|
}
|
|
|
|
return this._serializeBufferAsHTML(this._terminal, options ?? {});
|
|
}
|
|
|
|
public dispose(): void { }
|
|
}
|
|
|
|
export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|
private _currentRow: string = '';
|
|
|
|
private _htmlContent = '';
|
|
|
|
private _ansiColors: Readonly<IColor[]>;
|
|
|
|
constructor(
|
|
buffer: IBuffer,
|
|
private readonly _terminal: Terminal,
|
|
private readonly _options: Partial<IHTMLSerializeOptions>
|
|
) {
|
|
super(buffer);
|
|
|
|
// For xterm headless: fallback to ansi colors
|
|
if ((_terminal as any)._core._themeService) {
|
|
this._ansiColors = (_terminal as any)._core._themeService.colors.ansi;
|
|
}
|
|
else {
|
|
this._ansiColors = DEFAULT_ANSI_COLORS;
|
|
}
|
|
}
|
|
|
|
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
|
this._htmlContent += '<html><body><!--StartFragment--><pre>';
|
|
|
|
let foreground = '#000000';
|
|
let background = '#ffffff';
|
|
if (this._options.includeGlobalBackground ?? false) {
|
|
foreground = this._terminal.options.theme?.foreground ?? '#ffffff';
|
|
background = this._terminal.options.theme?.background ?? '#000000';
|
|
}
|
|
|
|
const globalStyleDefinitions = [];
|
|
globalStyleDefinitions.push('color: ' + foreground + ';');
|
|
globalStyleDefinitions.push('background-color: ' + background + ';');
|
|
globalStyleDefinitions.push('font-family: ' + this._terminal.options.fontFamily + ';');
|
|
globalStyleDefinitions.push('font-size: ' + this._terminal.options.fontSize + 'px;');
|
|
this._htmlContent += '<div style=\'' + globalStyleDefinitions.join(' ') + '\'>';
|
|
}
|
|
|
|
protected _afterSerialize(): void {
|
|
this._htmlContent += '</div>';
|
|
this._htmlContent += '</pre><!--EndFragment--></body></html>';
|
|
}
|
|
|
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
|
this._htmlContent += '<div><span>' + this._currentRow + '</span></div>';
|
|
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 >> 16) & 255,
|
|
(color >> 8) & 255,
|
|
(color ) & 255
|
|
];
|
|
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
|
return this._ansiColors[color].css;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private _getUnderlineColor(cell: IBufferCell): string | undefined {
|
|
if (cell.isUnderlineColorDefault()) {
|
|
return undefined;
|
|
}
|
|
const color = cell.getUnderlineColor();
|
|
if (cell.isUnderlineColorRGB()) {
|
|
const rgb = [
|
|
(color >> 16) & 255,
|
|
(color >> 8) & 255,
|
|
(color ) & 255
|
|
];
|
|
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
// Palette color
|
|
return this._ansiColors[color].css;
|
|
}
|
|
|
|
private _getUnderlineStyle(cell: IBufferCell): string {
|
|
switch (cell.getUnderlineStyle()) {
|
|
case UnderlineStyle.SINGLE:
|
|
return 'underline';
|
|
case UnderlineStyle.DOUBLE:
|
|
return 'underline double';
|
|
case UnderlineStyle.CURLY:
|
|
return 'underline wavy';
|
|
case UnderlineStyle.DOTTED:
|
|
return 'underline dotted';
|
|
case UnderlineStyle.DASHED:
|
|
return 'underline dashed';
|
|
default:
|
|
return 'underline';
|
|
}
|
|
}
|
|
|
|
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
|
|
const content: string[] = [];
|
|
|
|
if (attributesEquals(cell, oldCell)) {
|
|
return undefined;
|
|
}
|
|
|
|
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('color: #000000; background-color: #BFBFBF;'); }
|
|
if (cell.isBold()) { content.push('font-weight: bold;'); }
|
|
|
|
// Handle text-decoration (underline, overline, strikethrough, blink)
|
|
const decorations: string[] = [];
|
|
if (cell.isUnderline()) {
|
|
decorations.push(this._getUnderlineStyle(cell));
|
|
}
|
|
if (cell.isOverline()) {
|
|
decorations.push('overline');
|
|
}
|
|
if (cell.isStrikethrough()) {
|
|
decorations.push('line-through');
|
|
}
|
|
if (cell.isBlink()) {
|
|
decorations.push('blink');
|
|
}
|
|
if (decorations.length > 0) {
|
|
content.push('text-decoration: ' + decorations.join(' ') + ';');
|
|
}
|
|
|
|
// Handle underline color
|
|
if (cell.isUnderline()) {
|
|
const underlineColor = this._getUnderlineColor(cell);
|
|
if (underlineColor) {
|
|
content.push('text-decoration-color: ' + underlineColor + ';');
|
|
}
|
|
}
|
|
|
|
if (cell.isInvisible()) { content.push('visibility: hidden;'); }
|
|
if (cell.isItalic()) { content.push('font-style: italic;'); }
|
|
if (cell.isDim()) { content.push('opacity: 0.5;'); }
|
|
|
|
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 += escapeHTMLChar(cell.getChars());
|
|
}
|
|
}
|
|
|
|
protected _serializeString(): string {
|
|
return this._htmlContent;
|
|
}
|
|
}
|