display.js 15 KB

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