]> git.proxmox.com Git - mirror_novnc.git/blob - core/display.js
4007ea7a88fdeca21fc3c9b4923df0579dd57619
[mirror_novnc.git] / core / display.js
1 /*
2 * noVNC: HTML5 VNC client
3 * Copyright (C) 2019 The noVNC Authors
4 * Licensed under MPL 2.0 (see LICENSE.txt)
5 *
6 * See README.md for usage and integration instructions.
7 */
8
9 import * as Log from './util/logging.js';
10 import Base64 from "./base64.js";
11 import { supportsImageMetadata } from './util/browser.js';
12
13 export default class Display {
14 constructor(target) {
15 this._drawCtx = null;
16
17 this._renderQ = []; // queue drawing actions for in-oder rendering
18 this._flushing = false;
19
20 // the full frame buffer (logical canvas) size
21 this._fb_width = 0;
22 this._fb_height = 0;
23
24 this._prevDrawStyle = "";
25 this._tile = null;
26 this._tile16x16 = null;
27 this._tile_x = 0;
28 this._tile_y = 0;
29
30 Log.Debug(">> Display.constructor");
31
32 // The visible canvas
33 this._target = target;
34
35 if (!this._target) {
36 throw new Error("Target must be set");
37 }
38
39 if (typeof this._target === 'string') {
40 throw new Error('target must be a DOM element');
41 }
42
43 if (!this._target.getContext) {
44 throw new Error("no getContext method");
45 }
46
47 this._targetCtx = this._target.getContext('2d');
48
49 // the visible canvas viewport (i.e. what actually gets seen)
50 this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
51
52 // The hidden canvas, where we do the actual rendering
53 this._backbuffer = document.createElement('canvas');
54 this._drawCtx = this._backbuffer.getContext('2d');
55
56 this._damageBounds = { left: 0, top: 0,
57 right: this._backbuffer.width,
58 bottom: this._backbuffer.height };
59
60 Log.Debug("User Agent: " + navigator.userAgent);
61
62 // Check canvas features
63 if (!('createImageData' in this._drawCtx)) {
64 throw new Error("Canvas does not support createImageData");
65 }
66
67 this._tile16x16 = this._drawCtx.createImageData(16, 16);
68 Log.Debug("<< Display.constructor");
69
70 // ===== PROPERTIES =====
71
72 this._scale = 1.0;
73 this._clipViewport = false;
74
75 // ===== EVENT HANDLERS =====
76
77 this.onflush = () => {}; // A flush request has finished
78 }
79
80 // ===== PROPERTIES =====
81
82 get scale() { return this._scale; }
83 set scale(scale) {
84 this._rescale(scale);
85 }
86
87 get clipViewport() { return this._clipViewport; }
88 set clipViewport(viewport) {
89 this._clipViewport = viewport;
90 // May need to readjust the viewport dimensions
91 const vp = this._viewportLoc;
92 this.viewportChangeSize(vp.w, vp.h);
93 this.viewportChangePos(0, 0);
94 }
95
96 get width() {
97 return this._fb_width;
98 }
99
100 get height() {
101 return this._fb_height;
102 }
103
104 // ===== PUBLIC METHODS =====
105
106 viewportChangePos(deltaX, deltaY) {
107 const vp = this._viewportLoc;
108 deltaX = Math.floor(deltaX);
109 deltaY = Math.floor(deltaY);
110
111 if (!this._clipViewport) {
112 deltaX = -vp.w; // clamped later of out of bounds
113 deltaY = -vp.h;
114 }
115
116 const vx2 = vp.x + vp.w - 1;
117 const vy2 = vp.y + vp.h - 1;
118
119 // Position change
120
121 if (deltaX < 0 && vp.x + deltaX < 0) {
122 deltaX = -vp.x;
123 }
124 if (vx2 + deltaX >= this._fb_width) {
125 deltaX -= vx2 + deltaX - this._fb_width + 1;
126 }
127
128 if (vp.y + deltaY < 0) {
129 deltaY = -vp.y;
130 }
131 if (vy2 + deltaY >= this._fb_height) {
132 deltaY -= (vy2 + deltaY - this._fb_height + 1);
133 }
134
135 if (deltaX === 0 && deltaY === 0) {
136 return;
137 }
138 Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
139
140 vp.x += deltaX;
141 vp.y += deltaY;
142
143 this._damage(vp.x, vp.y, vp.w, vp.h);
144
145 this.flip();
146 }
147
148 viewportChangeSize(width, height) {
149
150 if (!this._clipViewport ||
151 typeof(width) === "undefined" ||
152 typeof(height) === "undefined") {
153
154 Log.Debug("Setting viewport to full display region");
155 width = this._fb_width;
156 height = this._fb_height;
157 }
158
159 width = Math.floor(width);
160 height = Math.floor(height);
161
162 if (width > this._fb_width) {
163 width = this._fb_width;
164 }
165 if (height > this._fb_height) {
166 height = this._fb_height;
167 }
168
169 const vp = this._viewportLoc;
170 if (vp.w !== width || vp.h !== height) {
171 vp.w = width;
172 vp.h = height;
173
174 const canvas = this._target;
175 canvas.width = width;
176 canvas.height = height;
177
178 // The position might need to be updated if we've grown
179 this.viewportChangePos(0, 0);
180
181 this._damage(vp.x, vp.y, vp.w, vp.h);
182 this.flip();
183
184 // Update the visible size of the target canvas
185 this._rescale(this._scale);
186 }
187 }
188
189 absX(x) {
190 if (this._scale === 0) {
191 return 0;
192 }
193 return x / this._scale + this._viewportLoc.x;
194 }
195
196 absY(y) {
197 if (this._scale === 0) {
198 return 0;
199 }
200 return y / this._scale + this._viewportLoc.y;
201 }
202
203 resize(width, height) {
204 this._prevDrawStyle = "";
205
206 this._fb_width = width;
207 this._fb_height = height;
208
209 const canvas = this._backbuffer;
210 if (canvas.width !== width || canvas.height !== height) {
211
212 // We have to save the canvas data since changing the size will clear it
213 let saveImg = null;
214 if (canvas.width > 0 && canvas.height > 0) {
215 saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
216 }
217
218 if (canvas.width !== width) {
219 canvas.width = width;
220 }
221 if (canvas.height !== height) {
222 canvas.height = height;
223 }
224
225 if (saveImg) {
226 this._drawCtx.putImageData(saveImg, 0, 0);
227 }
228 }
229
230 // Readjust the viewport as it may be incorrectly sized
231 // and positioned
232 const vp = this._viewportLoc;
233 this.viewportChangeSize(vp.w, vp.h);
234 this.viewportChangePos(0, 0);
235 }
236
237 // Track what parts of the visible canvas that need updating
238 _damage(x, y, w, h) {
239 if (x < this._damageBounds.left) {
240 this._damageBounds.left = x;
241 }
242 if (y < this._damageBounds.top) {
243 this._damageBounds.top = y;
244 }
245 if ((x + w) > this._damageBounds.right) {
246 this._damageBounds.right = x + w;
247 }
248 if ((y + h) > this._damageBounds.bottom) {
249 this._damageBounds.bottom = y + h;
250 }
251 }
252
253 // Update the visible canvas with the contents of the
254 // rendering canvas
255 flip(from_queue) {
256 if (this._renderQ.length !== 0 && !from_queue) {
257 this._renderQ_push({
258 'type': 'flip'
259 });
260 } else {
261 let x = this._damageBounds.left;
262 let y = this._damageBounds.top;
263 let w = this._damageBounds.right - x;
264 let h = this._damageBounds.bottom - y;
265
266 let vx = x - this._viewportLoc.x;
267 let vy = y - this._viewportLoc.y;
268
269 if (vx < 0) {
270 w += vx;
271 x -= vx;
272 vx = 0;
273 }
274 if (vy < 0) {
275 h += vy;
276 y -= vy;
277 vy = 0;
278 }
279
280 if ((vx + w) > this._viewportLoc.w) {
281 w = this._viewportLoc.w - vx;
282 }
283 if ((vy + h) > this._viewportLoc.h) {
284 h = this._viewportLoc.h - vy;
285 }
286
287 if ((w > 0) && (h > 0)) {
288 // FIXME: We may need to disable image smoothing here
289 // as well (see copyImage()), but we haven't
290 // noticed any problem yet.
291 this._targetCtx.drawImage(this._backbuffer,
292 x, y, w, h,
293 vx, vy, w, h);
294 }
295
296 this._damageBounds.left = this._damageBounds.top = 65535;
297 this._damageBounds.right = this._damageBounds.bottom = 0;
298 }
299 }
300
301 pending() {
302 return this._renderQ.length > 0;
303 }
304
305 flush() {
306 if (this._renderQ.length === 0) {
307 this.onflush();
308 } else {
309 this._flushing = true;
310 }
311 }
312
313 fillRect(x, y, width, height, color, from_queue) {
314 if (this._renderQ.length !== 0 && !from_queue) {
315 this._renderQ_push({
316 'type': 'fill',
317 'x': x,
318 'y': y,
319 'width': width,
320 'height': height,
321 'color': color
322 });
323 } else {
324 this._setFillColor(color);
325 this._drawCtx.fillRect(x, y, width, height);
326 this._damage(x, y, width, height);
327 }
328 }
329
330 copyImage(old_x, old_y, new_x, new_y, w, h, from_queue) {
331 if (this._renderQ.length !== 0 && !from_queue) {
332 this._renderQ_push({
333 'type': 'copy',
334 'old_x': old_x,
335 'old_y': old_y,
336 'x': new_x,
337 'y': new_y,
338 'width': w,
339 'height': h,
340 });
341 } else {
342 // Due to this bug among others [1] we need to disable the image-smoothing to
343 // avoid getting a blur effect when copying data.
344 //
345 // 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
346 //
347 // We need to set these every time since all properties are reset
348 // when the the size is changed
349 this._drawCtx.mozImageSmoothingEnabled = false;
350 this._drawCtx.webkitImageSmoothingEnabled = false;
351 this._drawCtx.msImageSmoothingEnabled = false;
352 this._drawCtx.imageSmoothingEnabled = false;
353
354 this._drawCtx.drawImage(this._backbuffer,
355 old_x, old_y, w, h,
356 new_x, new_y, w, h);
357 this._damage(new_x, new_y, w, h);
358 }
359 }
360
361 imageRect(x, y, width, height, mime, arr) {
362 /* The internal logic cannot handle empty images, so bail early */
363 if ((width === 0) || (height === 0)) {
364 return;
365 }
366
367 const img = new Image();
368 img.src = "data: " + mime + ";base64," + Base64.encode(arr);
369
370 this._renderQ_push({
371 'type': 'img',
372 'img': img,
373 'x': x,
374 'y': y,
375 'width': width,
376 'height': height
377 });
378 }
379
380 // start updating a tile
381 startTile(x, y, width, height, color) {
382 this._tile_x = x;
383 this._tile_y = y;
384 if (width === 16 && height === 16) {
385 this._tile = this._tile16x16;
386 } else {
387 this._tile = this._drawCtx.createImageData(width, height);
388 }
389
390 const red = color[2];
391 const green = color[1];
392 const blue = color[0];
393
394 const data = this._tile.data;
395 for (let i = 0; i < width * height * 4; i += 4) {
396 data[i] = red;
397 data[i + 1] = green;
398 data[i + 2] = blue;
399 data[i + 3] = 255;
400 }
401 }
402
403 // update sub-rectangle of the current tile
404 subTile(x, y, w, h, color) {
405 const red = color[2];
406 const green = color[1];
407 const blue = color[0];
408 const xend = x + w;
409 const yend = y + h;
410
411 const data = this._tile.data;
412 const width = this._tile.width;
413 for (let j = y; j < yend; j++) {
414 for (let i = x; i < xend; i++) {
415 const p = (i + (j * width)) * 4;
416 data[p] = red;
417 data[p + 1] = green;
418 data[p + 2] = blue;
419 data[p + 3] = 255;
420 }
421 }
422 }
423
424 // draw the current tile to the screen
425 finishTile() {
426 this._drawCtx.putImageData(this._tile, this._tile_x, this._tile_y);
427 this._damage(this._tile_x, this._tile_y,
428 this._tile.width, this._tile.height);
429 }
430
431 blitImage(x, y, width, height, arr, offset, from_queue) {
432 if (this._renderQ.length !== 0 && !from_queue) {
433 // NB(directxman12): it's technically more performant here to use preallocated arrays,
434 // but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
435 // this probably isn't getting called *nearly* as much
436 const new_arr = new Uint8Array(width * height * 4);
437 new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
438 this._renderQ_push({
439 'type': 'blit',
440 'data': new_arr,
441 'x': x,
442 'y': y,
443 'width': width,
444 'height': height,
445 });
446 } else {
447 this._bgrxImageData(x, y, width, height, arr, offset);
448 }
449 }
450
451 blitRgbImage(x, y, width, height, arr, offset, from_queue) {
452 if (this._renderQ.length !== 0 && !from_queue) {
453 // NB(directxman12): it's technically more performant here to use preallocated arrays,
454 // but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
455 // this probably isn't getting called *nearly* as much
456 const new_arr = new Uint8Array(width * height * 3);
457 new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
458 this._renderQ_push({
459 'type': 'blitRgb',
460 'data': new_arr,
461 'x': x,
462 'y': y,
463 'width': width,
464 'height': height,
465 });
466 } else {
467 this._rgbImageData(x, y, width, height, arr, offset);
468 }
469 }
470
471 blitRgbxImage(x, y, width, height, arr, offset, from_queue) {
472 if (this._renderQ.length !== 0 && !from_queue) {
473 // NB(directxman12): it's technically more performant here to use preallocated arrays,
474 // but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
475 // this probably isn't getting called *nearly* as much
476 const new_arr = new Uint8Array(width * height * 4);
477 new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
478 this._renderQ_push({
479 'type': 'blitRgbx',
480 'data': new_arr,
481 'x': x,
482 'y': y,
483 'width': width,
484 'height': height,
485 });
486 } else {
487 this._rgbxImageData(x, y, width, height, arr, offset);
488 }
489 }
490
491 drawImage(img, x, y) {
492 this._drawCtx.drawImage(img, x, y);
493 this._damage(x, y, img.width, img.height);
494 }
495
496 autoscale(containerWidth, containerHeight) {
497 let scaleRatio;
498
499 if (containerWidth === 0 || containerHeight === 0) {
500 scaleRatio = 0;
501
502 } else {
503
504 const vp = this._viewportLoc;
505 const targetAspectRatio = containerWidth / containerHeight;
506 const fbAspectRatio = vp.w / vp.h;
507
508 if (fbAspectRatio >= targetAspectRatio) {
509 scaleRatio = containerWidth / vp.w;
510 } else {
511 scaleRatio = containerHeight / vp.h;
512 }
513 }
514
515 this._rescale(scaleRatio);
516 }
517
518 // ===== PRIVATE METHODS =====
519
520 _rescale(factor) {
521 this._scale = factor;
522 const vp = this._viewportLoc;
523
524 // NB(directxman12): If you set the width directly, or set the
525 // style width to a number, the canvas is cleared.
526 // However, if you set the style width to a string
527 // ('NNNpx'), the canvas is scaled without clearing.
528 const width = factor * vp.w + 'px';
529 const height = factor * vp.h + 'px';
530
531 if ((this._target.style.width !== width) ||
532 (this._target.style.height !== height)) {
533 this._target.style.width = width;
534 this._target.style.height = height;
535 }
536 }
537
538 _setFillColor(color) {
539 const newStyle = 'rgb(' + color[2] + ',' + color[1] + ',' + color[0] + ')';
540 if (newStyle !== this._prevDrawStyle) {
541 this._drawCtx.fillStyle = newStyle;
542 this._prevDrawStyle = newStyle;
543 }
544 }
545
546 _rgbImageData(x, y, width, height, arr, offset) {
547 const img = this._drawCtx.createImageData(width, height);
548 const data = img.data;
549 for (let i = 0, j = offset; i < width * height * 4; i += 4, j += 3) {
550 data[i] = arr[j];
551 data[i + 1] = arr[j + 1];
552 data[i + 2] = arr[j + 2];
553 data[i + 3] = 255; // Alpha
554 }
555 this._drawCtx.putImageData(img, x, y);
556 this._damage(x, y, img.width, img.height);
557 }
558
559 _bgrxImageData(x, y, width, height, arr, offset) {
560 const img = this._drawCtx.createImageData(width, height);
561 const data = img.data;
562 for (let i = 0, j = offset; i < width * height * 4; i += 4, j += 4) {
563 data[i] = arr[j + 2];
564 data[i + 1] = arr[j + 1];
565 data[i + 2] = arr[j];
566 data[i + 3] = 255; // Alpha
567 }
568 this._drawCtx.putImageData(img, x, y);
569 this._damage(x, y, img.width, img.height);
570 }
571
572 _rgbxImageData(x, y, width, height, arr, offset) {
573 // NB(directxman12): arr must be an Type Array view
574 let img;
575 if (supportsImageMetadata) {
576 img = new ImageData(new Uint8ClampedArray(arr.buffer, arr.byteOffset, width * height * 4), width, height);
577 } else {
578 img = this._drawCtx.createImageData(width, height);
579 img.data.set(new Uint8ClampedArray(arr.buffer, arr.byteOffset, width * height * 4));
580 }
581 this._drawCtx.putImageData(img, x, y);
582 this._damage(x, y, img.width, img.height);
583 }
584
585 _renderQ_push(action) {
586 this._renderQ.push(action);
587 if (this._renderQ.length === 1) {
588 // If this can be rendered immediately it will be, otherwise
589 // the scanner will wait for the relevant event
590 this._scan_renderQ();
591 }
592 }
593
594 _resume_renderQ() {
595 // "this" is the object that is ready, not the
596 // display object
597 this.removeEventListener('load', this._noVNC_display._resume_renderQ);
598 this._noVNC_display._scan_renderQ();
599 }
600
601 _scan_renderQ() {
602 let ready = true;
603 while (ready && this._renderQ.length > 0) {
604 const a = this._renderQ[0];
605 switch (a.type) {
606 case 'flip':
607 this.flip(true);
608 break;
609 case 'copy':
610 this.copyImage(a.old_x, a.old_y, a.x, a.y, a.width, a.height, true);
611 break;
612 case 'fill':
613 this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
614 break;
615 case 'blit':
616 this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
617 break;
618 case 'blitRgb':
619 this.blitRgbImage(a.x, a.y, a.width, a.height, a.data, 0, true);
620 break;
621 case 'blitRgbx':
622 this.blitRgbxImage(a.x, a.y, a.width, a.height, a.data, 0, true);
623 break;
624 case 'img':
625 /* IE tends to set "complete" prematurely, so check dimensions */
626 if (a.img.complete && (a.img.width !== 0) && (a.img.height !== 0)) {
627 if (a.img.width !== a.width || a.img.height !== a.height) {
628 Log.Error("Decoded image has incorrect dimensions. Got " +
629 a.img.width + "x" + a.img.height + ". Expected " +
630 a.width + "x" + a.height + ".");
631 return;
632 }
633 this.drawImage(a.img, a.x, a.y);
634 } else {
635 a.img._noVNC_display = this;
636 a.img.addEventListener('load', this._resume_renderQ);
637 // We need to wait for this image to 'load'
638 // to keep things in-order
639 ready = false;
640 }
641 break;
642 }
643
644 if (ready) {
645 this._renderQ.shift();
646 }
647 }
648
649 if (this._renderQ.length === 0 && this._flushing) {
650 this._flushing = false;
651 this.onflush();
652 }
653 }
654 }