From 6dc813bdff4abca8bea4c67bfb609f6a50e737ac Mon Sep 17 00:00:00 2001 From: Simon Lamon Date: Sat, 29 Jan 2022 18:44:32 +0000 Subject: [PATCH] html serialization --- .../src/SerializeAddon.ts | 159 +++++++++++++++++- .../xterm-addon-serialize/src/tsconfig.json | 6 + demo/client.ts | 75 ++++++--- demo/index.html | 4 + 4 files changed, 219 insertions(+), 25 deletions(-) diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index 24020b74..8a1c9659 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -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 += '' + + '
';
+    // TODO: fetch options and remove hardcoded values
+    this._htmlContent += '
'; + } + + protected _afterSerialize(): void { + this._htmlContent += '
'; + this._htmlContent += '
'; + } + + protected _rowEnd(row: number, isLastRow: boolean): void { + this._htmlContent += '' + this._currentRow + '
'; + 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 ? + `` : + ``; + } + + // handles actual content + if (isEmptyCell) { + this._currentRow += ' '; + } else { + this._currentRow += cell.getChars(); + } + } + + protected _serializeString(): string { + return this._htmlContent; + } +} diff --git a/addons/xterm-addon-serialize/src/tsconfig.json b/addons/xterm-addon-serialize/src/tsconfig.json index ef4a14ab..38ef6a7f 100644 --- a/addons/xterm-addon-serialize/src/tsconfig.json +++ b/addons/xterm-addon-serialize/src/tsconfig.json @@ -14,6 +14,9 @@ "paths": { "common/*": [ "../../../src/common/*" + ], + "browser/*": [ + "../../../src/browser/*" ] }, "strict": true, @@ -28,6 +31,9 @@ "references": [ { "path": "../../../src/common" + }, + { + "path": "../../../src/browser" } ] } diff --git a/demo/client.ts b/demo/client.ts index 58d719a1..cc73cfa4 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -59,27 +59,27 @@ interface IDemoAddon { 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} = { +const addons: { [T in AddonType]: IDemoAddon } = { 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'); diff --git a/demo/index.html b/demo/index.html index 9c86783b..1ddce948 100644 --- a/demo/index.html +++ b/demo/index.html @@ -49,6 +49,10 @@
+ + + +