diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index 36886da181..cdd107e28e 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -1011,4 +1011,56 @@ describe('Terminal', () => { expect(term.buffer.lines.get(0).loadCell(79, cell).getChars()).eql(''); // empty cell after fullwidth }); }); + + describe('get range as HTML', () => { + beforeEach(() => { + term.wraparoundMode = true; + term.write(Array(INIT_COLS + 1).join('0')); + term.write(Array(INIT_COLS + 1).join('1')); + term.write(Array(INIT_COLS + 1).join('2')); + (term as any)._colorManager = { + colors: { + foreground: { css: 'white' }, + background: { css: 'black' }, + ansi: { + 1: { css: 'red' }, + 2: { css: 'green' }, + 209: { css: '#ff875f' } + } + } + }; + }); + + afterEach(() => { + term.clear(); + }); + + it('should work within single lines', () => { + const html = term.getRangeAsHTML({ startRow: 1, startColumn: 10, endRow: 1, endColumn: 15 }); + expect(html).eq('
11111
'); + }); + + it('should work within multiple lines', () => { + const html = term.getRangeAsHTML({ startRow: 0, startColumn: INIT_COLS - 5, endRow: 1, endColumn: 5 }); + expect(html).eq('
00000
11111
'); + }); + + it('should work with multiple styles', () => { + term.write('1\x1b[1m2'); + const html = term.getRangeAsHTML({ startRow: 3, startColumn: 0, endRow: 3, endColumn: 2 }); + expect(html).eq('
12
'); + }); + + it('should work with italics and underlines', () => { + term.write('\x1b[3mitalic\x1b[0m\x1b[4munderline'); + const html = term.getRangeAsHTML({ startRow: 3, startColumn: 0, endRow: 3, endColumn: 15 }); + expect(html).eq('
italicunderline
'); + }); + + it('should work with ANSI palette, 256 color palette and TrueColor', () => { + term.write('\x1b[31mred\x1b[0m\x1b[42mgreenbg\x1b[0m\x1b[38;5;209msalmon1\x1b[38;2;255;100;0mtruecolor'); + const html = term.getRangeAsHTML({ startRow: 3, startColumn: 0, endRow: 3, endColumn: 26 }); + expect(html).eq('
redgreenbgsalmon1truecolor
'); + }); + }); }); diff --git a/src/Terminal.ts b/src/Terminal.ts index ccb41527fc..41e4fabd66 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -44,7 +44,7 @@ import { DomRenderer } from './renderer/dom/DomRenderer'; import { IKeyboardEvent, KeyboardResultType, ICharset, IBufferLine, IAttributeData } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { Attributes, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { Attributes, AttributeData, CellData, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { applyWindowsMode } from './WindowsMode'; import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; @@ -1904,6 +1904,72 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.buffer.tabs[this.buffer.x] = true; } + public getRangeAsHTML(range: ISelectionPosition): string { + const fontFamily = this.optionsService.getOption('fontFamily'); + let html = `
`; + if (range.startRow === range.endRow) { + html += this._getRowAsHTML(range.startRow, range.startColumn, range.endColumn); + } else { + html += this._getRowAsHTML(range.startRow, range.startColumn, this.cols); + for (let y = range.startRow + 1; y < range.endRow; y++) { + html += this._getRowAsHTML(y, 0, this.cols); + } + html += this._getRowAsHTML(range.endRow, 0, range.endColumn); + } + html += '
'; + return html; + } + + private _getCSSColor(mode: Attributes, color: number): string | null { + if (mode === Attributes.CM_RGB) { + let css = '#'; + for (const channel of AttributeData.toColorRGB(color)) { + if (channel < 16) { + css += '0'; + } + css += channel.toString(16); + } + return css; + } + if (mode === Attributes.CM_P16 || mode === Attributes.CM_P256) { + return this._colorManager.colors.ansi[color].css; + } + return null; + } + + private _getRowAsHTML(y: number, start: number, end: number): string { + let html = '
'; + let lastStyle = null; + const line = this.buffers.active.lines.get(y); + const cell = new CellData(); + for (let i = start; i < end; i++) { + line.loadCell(i, cell); + const fg = this._getCSSColor(cell.getFgColorMode(), cell.getFgColor()) || this._colorManager.colors.foreground.css; + const bg = this._getCSSColor(cell.getBgColorMode(), cell.getBgColor()) || this._colorManager.colors.background.css; + + let style = `color: ${fg}; background: ${bg};`; + if (cell.isBold()) { + style += ' font-weight: bold;'; + } + if (cell.isItalic()) { + style += ' font-style: italic;'; + } + if (cell.isUnderline()) { + style += ' text-decoration: underline;'; + } + if (style !== lastStyle) { + if (lastStyle) { + html += ''; + } + html += ``; + lastStyle = style; + } + html += line.getString(i) || ' '; + } + html += '
'; + return html; + } + // TODO: Remove cancel function and cancelEvents option public cancel(ev: Event, force?: boolean): boolean { if (!this.options.cancelEvents && !force) { diff --git a/src/TestUtils.test.ts b/src/TestUtils.test.ts index 6b92bf0b9c..9b94781ed8 100644 --- a/src/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -121,6 +121,9 @@ export class MockTerminal implements ITerminal { writeUtf8(data: Uint8Array): void { throw new Error('Method not implemented.'); } + getRangeAsHTML(range: ISelectionPosition): string { + throw new Error('Method not implemented.'); + } bracketedPasteMode: boolean; mouseHelper: IMouseHelper; renderer: IRenderer; diff --git a/src/Types.ts b/src/Types.ts index 721ab063dd..3845e41b65 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -269,6 +269,7 @@ export interface IPublicTerminal extends IDisposable { clear(): void; write(data: string): void; writeUtf8(data: Uint8Array): void; + getRangeAsHTML(range: ISelectionPosition): string; refresh(start: number, end: number): void; reset(): void; } diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 0dae4def84..69874cf672 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -125,6 +125,9 @@ export class Terminal implements ITerminalApi { public writeUtf8(data: Uint8Array): void { this._core.writeUtf8(data); } + public getRangeAsHTML(range: ISelectionPosition): string { + return this._core.getRangeAsHTML(range) + } public getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold' | 'rendererType' | 'termName'): string; public getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'debug' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell'): boolean; public getOption(key: 'colors'): string[]; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 042ca5379d..7e8506ff7a 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -767,6 +767,12 @@ declare module 'xterm' { */ setOption(key: string, value: any): void; + /** + * Returns HTML representing the specified content range. + * @param range Range of cells to be converted. + */ + getRangeAsHTML(range: ISelectionPosition): string; + /** * Tells the renderer to refresh terminal content between two rows * (inclusive) at the next opportunity.