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