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