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