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