@xterm/addon-image 0.7.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Copyright (c) 2020 The xterm.js authors. All rights reserved.
3
+ * @license MIT
4
+ */
5
+
6
+ import { toRGBA8888 } from 'sixel/lib/Colors';
7
+ import { IDisposable } from '@xterm/xterm';
8
+ import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
9
+ import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
10
+
11
+
12
+ const PLACEHOLDER_LENGTH = 4096;
13
+ const PLACEHOLDER_HEIGHT = 24;
14
+
15
+ /**
16
+ * ImageRenderer - terminal frontend extension:
17
+ * - provide primitives for canvas, ImageData, Bitmap (static)
18
+ * - add canvas layer to DOM (browser only for now)
19
+ * - draw image tiles onRender
20
+ */
21
+ export class ImageRenderer extends Disposable implements IDisposable {
22
+ public canvas: HTMLCanvasElement | undefined;
23
+ private _ctx: CanvasRenderingContext2D | null | undefined;
24
+ private _placeholder: HTMLCanvasElement | undefined;
25
+ private _placeholderBitmap: ImageBitmap | undefined;
26
+ private _optionsRefresh = this.register(new MutableDisposable());
27
+ private _oldOpen: ((parent: HTMLElement) => void) | undefined;
28
+ private _renderService: IRenderService | undefined;
29
+ private _oldSetRenderer: ((renderer: any) => void) | undefined;
30
+
31
+ // drawing primitive - canvas
32
+ public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement {
33
+ /**
34
+ * NOTE: We normally dont care, from which document the canvas
35
+ * gets created, so we can fall back to global document,
36
+ * if the terminal has no document associated yet.
37
+ * This way early image loads before calling .open keep working
38
+ * (still discouraged though, as the metrics will be screwed up).
39
+ * Only the DOM output canvas should be on the terminal's document,
40
+ * which gets explicitly checked in `insertLayerToDom`.
41
+ */
42
+ const canvas = (localDocument || document).createElement('canvas');
43
+ canvas.width = width | 0;
44
+ canvas.height = height | 0;
45
+ return canvas;
46
+ }
47
+
48
+ // drawing primitive - ImageData with optional buffer
49
+ public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData {
50
+ if (typeof ImageData !== 'function') {
51
+ const imgData = ctx.createImageData(width, height);
52
+ if (buffer) {
53
+ imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4));
54
+ }
55
+ return imgData;
56
+ }
57
+ return buffer
58
+ ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height)
59
+ : new ImageData(width, height);
60
+ }
61
+
62
+ // drawing primitive - ImageBitmap
63
+ public static createImageBitmap(img: ImageBitmapSource): Promise<ImageBitmap | undefined> {
64
+ if (typeof createImageBitmap !== 'function') {
65
+ return Promise.resolve(undefined);
66
+ }
67
+ return createImageBitmap(img);
68
+ }
69
+
70
+
71
+ constructor(private _terminal: ITerminalExt) {
72
+ super();
73
+ this._oldOpen = this._terminal._core.open;
74
+ this._terminal._core.open = (parent: HTMLElement): void => {
75
+ this._oldOpen?.call(this._terminal._core, parent);
76
+ this._open();
77
+ };
78
+ if (this._terminal._core.screenElement) {
79
+ this._open();
80
+ }
81
+ // hack to spot fontSize changes
82
+ this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => {
83
+ if (option === 'fontSize') {
84
+ this.rescaleCanvas();
85
+ this._renderService?.refreshRows(0, this._terminal.rows);
86
+ }
87
+ });
88
+ this.register(toDisposable(() => {
89
+ this.removeLayerFromDom();
90
+ if (this._terminal._core && this._oldOpen) {
91
+ this._terminal._core.open = this._oldOpen;
92
+ this._oldOpen = undefined;
93
+ }
94
+ if (this._renderService && this._oldSetRenderer) {
95
+ this._renderService.setRenderer = this._oldSetRenderer;
96
+ this._oldSetRenderer = undefined;
97
+ }
98
+ this._renderService = undefined;
99
+ this.canvas = undefined;
100
+ this._ctx = undefined;
101
+ this._placeholderBitmap?.close();
102
+ this._placeholderBitmap = undefined;
103
+ this._placeholder = undefined;
104
+ }));
105
+ }
106
+
107
+ /**
108
+ * Enable the placeholder.
109
+ */
110
+ public showPlaceholder(value: boolean): void {
111
+ if (value) {
112
+ if (!this._placeholder && this.cellSize.height !== -1) {
113
+ this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
114
+ }
115
+ } else {
116
+ this._placeholderBitmap?.close();
117
+ this._placeholderBitmap = undefined;
118
+ this._placeholder = undefined;
119
+ }
120
+ this._renderService?.refreshRows(0, this._terminal.rows);
121
+ }
122
+
123
+ /**
124
+ * Dimensions of the terminal.
125
+ * Forwarded from internal render service.
126
+ */
127
+ public get dimensions(): IRenderDimensions | undefined {
128
+ return this._renderService?.dimensions;
129
+ }
130
+
131
+ /**
132
+ * Current cell size (float).
133
+ */
134
+ public get cellSize(): ICellSize {
135
+ return {
136
+ width: this.dimensions?.css.cell.width || -1,
137
+ height: this.dimensions?.css.cell.height || -1
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Clear a region of the image layer canvas.
143
+ */
144
+ public clearLines(start: number, end: number): void {
145
+ this._ctx?.clearRect(
146
+ 0,
147
+ start * (this.dimensions?.css.cell.height || 0),
148
+ this.dimensions?.css.canvas.width || 0,
149
+ (++end - start) * (this.dimensions?.css.cell.height || 0)
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Clear whole image canvas.
155
+ */
156
+ public clearAll(): void {
157
+ this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
158
+ }
159
+
160
+ /**
161
+ * Draw neighboring tiles on the image layer canvas.
162
+ */
163
+ public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
164
+ if (!this._ctx) {
165
+ return;
166
+ }
167
+ const { width, height } = this.cellSize;
168
+
169
+ // Don't try to draw anything, if we cannot get valid renderer metrics.
170
+ if (width === -1 || height === -1) {
171
+ return;
172
+ }
173
+
174
+ this._rescaleImage(imgSpec, width, height);
175
+ const img = imgSpec.actual!;
176
+ const cols = Math.ceil(img.width / width);
177
+
178
+ const sx = (tileId % cols) * width;
179
+ const sy = Math.floor(tileId / cols) * height;
180
+ const dx = col * width;
181
+ const dy = row * height;
182
+
183
+ // safari bug: never access image source out of bounds
184
+ const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
185
+ const finalHeight = sy + height > img.height ? img.height - sy : height;
186
+
187
+ // Floor all pixel offsets to get stable tile mapping without any overflows.
188
+ // Note: For not pixel perfect aligned cells like in the DOM renderer
189
+ // this will move a tile slightly to the top/left (subpixel range, thus ignore it).
190
+ // FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
191
+ this._ctx.drawImage(
192
+ img,
193
+ Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
194
+ Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Extract a single tile from an image.
200
+ */
201
+ public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined {
202
+ const { width, height } = this.cellSize;
203
+ // Don't try to draw anything, if we cannot get valid renderer metrics.
204
+ if (width === -1 || height === -1) {
205
+ return;
206
+ }
207
+ this._rescaleImage(imgSpec, width, height);
208
+ const img = imgSpec.actual!;
209
+ const cols = Math.ceil(img.width / width);
210
+ const sx = (tileId % cols) * width;
211
+ const sy = Math.floor(tileId / cols) * height;
212
+ const finalWidth = width + sx > img.width ? img.width - sx : width;
213
+ const finalHeight = sy + height > img.height ? img.height - sy : height;
214
+
215
+ const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
216
+ const ctx = canvas.getContext('2d');
217
+ if (ctx) {
218
+ ctx.drawImage(
219
+ img,
220
+ Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
221
+ 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
222
+ );
223
+ return canvas;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Draw a line with placeholder on the image layer canvas.
229
+ */
230
+ public drawPlaceholder(col: number, row: number, count: number = 1): void {
231
+ if (this._ctx) {
232
+ const { width, height } = this.cellSize;
233
+
234
+ // Don't try to draw anything, if we cannot get valid renderer metrics.
235
+ if (width === -1 || height === -1) {
236
+ return;
237
+ }
238
+
239
+ if (!this._placeholder) {
240
+ this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
241
+ } else if (height >= this._placeholder!.height) {
242
+ this._createPlaceHolder(height + 1);
243
+ }
244
+ if (!this._placeholder) return;
245
+ this._ctx.drawImage(
246
+ this._placeholderBitmap || this._placeholder!,
247
+ col * width,
248
+ (row * height) % 2 ? 0 : 1, // needs %2 offset correction
249
+ width * count,
250
+ height,
251
+ col * width,
252
+ row * height,
253
+ width * count,
254
+ height
255
+ );
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Rescale image layer canvas if needed.
261
+ * Checked once from `ImageStorage.render`.
262
+ */
263
+ public rescaleCanvas(): void {
264
+ if (!this.canvas) {
265
+ return;
266
+ }
267
+ if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
268
+ this.canvas.width = this.dimensions!.css.canvas.width || 0;
269
+ this.canvas.height = this.dimensions!.css.canvas.height || 0;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Rescale image in storage if needed.
275
+ */
276
+ private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void {
277
+ if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) {
278
+ return;
279
+ }
280
+ const { width: originalWidth, height: originalHeight } = spec.origCellSize;
281
+ if (currentWidth === originalWidth && currentHeight === originalHeight) {
282
+ spec.actual = spec.orig;
283
+ spec.actualCellSize.width = originalWidth;
284
+ spec.actualCellSize.height = originalHeight;
285
+ return;
286
+ }
287
+ const canvas = ImageRenderer.createCanvas(
288
+ this.document,
289
+ Math.ceil(spec.orig!.width * currentWidth / originalWidth),
290
+ Math.ceil(spec.orig!.height * currentHeight / originalHeight)
291
+ );
292
+ const ctx = canvas.getContext('2d');
293
+ if (ctx) {
294
+ ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
295
+ spec.actual = canvas;
296
+ spec.actualCellSize.width = currentWidth;
297
+ spec.actualCellSize.height = currentHeight;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Lazy init for the renderer.
303
+ */
304
+ private _open(): void {
305
+ this._renderService = this._terminal._core._renderService;
306
+ this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
307
+ this._renderService.setRenderer = (renderer: any) => {
308
+ this.removeLayerFromDom();
309
+ this._oldSetRenderer?.call(this._renderService, renderer);
310
+ };
311
+ }
312
+
313
+ public insertLayerToDom(): void {
314
+ // make sure that the terminal is attached to a document and to DOM
315
+ if (this.document && this._terminal._core.screenElement) {
316
+ if (!this.canvas) {
317
+ this.canvas = ImageRenderer.createCanvas(
318
+ this.document, this.dimensions?.css.canvas.width || 0,
319
+ this.dimensions?.css.canvas.height || 0
320
+ );
321
+ this.canvas.classList.add('xterm-image-layer');
322
+ this._terminal._core.screenElement.appendChild(this.canvas);
323
+ this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
324
+ this.clearAll();
325
+ }
326
+ } else {
327
+ console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
328
+ }
329
+ }
330
+
331
+ public removeLayerFromDom(): void {
332
+ if (this.canvas) {
333
+ this._ctx = undefined;
334
+ this.canvas.remove();
335
+ this.canvas = undefined;
336
+ }
337
+ }
338
+
339
+ private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
340
+ this._placeholderBitmap?.close();
341
+ this._placeholderBitmap = undefined;
342
+
343
+ // create blueprint to fill placeholder with
344
+ const bWidth = 32; // must be 2^n
345
+ const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height);
346
+ const ctx = blueprint.getContext('2d', { alpha: false });
347
+ if (!ctx) return;
348
+ const imgData = ImageRenderer.createImageData(ctx, bWidth, height);
349
+ const d32 = new Uint32Array(imgData.data.buffer);
350
+ const black = toRGBA8888(0, 0, 0);
351
+ const white = toRGBA8888(255, 255, 255);
352
+ d32.fill(black);
353
+ for (let y = 0; y < height; ++y) {
354
+ const shift = y % 2;
355
+ const offset = y * bWidth;
356
+ for (let x = 0; x < bWidth; x += 2) {
357
+ d32[offset + x + shift] = white;
358
+ }
359
+ }
360
+ ctx.putImageData(imgData, 0, 0);
361
+
362
+ // create placeholder line, width aligned to blueprint width
363
+ const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
364
+ this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
365
+ const ctx2 = this._placeholder.getContext('2d', { alpha: false });
366
+ if (!ctx2) {
367
+ this._placeholder = undefined;
368
+ return;
369
+ }
370
+ for (let i = 0; i < width; i += bWidth) {
371
+ ctx2.drawImage(blueprint, i, 0);
372
+ }
373
+ ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
374
+ }
375
+
376
+ public get document(): Document | undefined {
377
+ return this._terminal._core._coreBrowserService?.window.document;
378
+ }
379
+ }