mirror of
https://github.com/kasmtech/KasmVNC.git
synced 2024-11-30 03:53:09 +01:00
8be70e5ae0
Internet Explorer seems to flag images as loaded prematurely, which can result in rendering bugs. We can detect this by looking at the dimensions though.
654 lines
20 KiB
JavaScript
654 lines
20 KiB
JavaScript
/*
|
|
* noVNC: HTML5 VNC client
|
|
* Copyright (C) 2019 The noVNC Authors
|
|
* Licensed under MPL 2.0 (see LICENSE.txt)
|
|
*
|
|
* See README.md for usage and integration instructions.
|
|
*/
|
|
|
|
import * as Log from './util/logging.js';
|
|
import Base64 from "./base64.js";
|
|
import { supportsImageMetadata } from './util/browser.js';
|
|
|
|
export default class Display {
|
|
constructor(target) {
|
|
this._drawCtx = null;
|
|
this._c_forceCanvas = false;
|
|
|
|
this._renderQ = []; // queue drawing actions for in-oder rendering
|
|
this._flushing = false;
|
|
|
|
// the full frame buffer (logical canvas) size
|
|
this._fb_width = 0;
|
|
this._fb_height = 0;
|
|
|
|
this._prevDrawStyle = "";
|
|
this._tile = null;
|
|
this._tile16x16 = null;
|
|
this._tile_x = 0;
|
|
this._tile_y = 0;
|
|
|
|
Log.Debug(">> Display.constructor");
|
|
|
|
// The visible canvas
|
|
this._target = target;
|
|
|
|
if (!this._target) {
|
|
throw new Error("Target must be set");
|
|
}
|
|
|
|
if (typeof this._target === 'string') {
|
|
throw new Error('target must be a DOM element');
|
|
}
|
|
|
|
if (!this._target.getContext) {
|
|
throw new Error("no getContext method");
|
|
}
|
|
|
|
this._targetCtx = this._target.getContext('2d');
|
|
|
|
// the visible canvas viewport (i.e. what actually gets seen)
|
|
this._viewportLoc = { 'x': 0, 'y': 0, 'w': this._target.width, 'h': this._target.height };
|
|
|
|
// The hidden canvas, where we do the actual rendering
|
|
this._backbuffer = document.createElement('canvas');
|
|
this._drawCtx = this._backbuffer.getContext('2d');
|
|
|
|
this._damageBounds = { left: 0, top: 0,
|
|
right: this._backbuffer.width,
|
|
bottom: this._backbuffer.height };
|
|
|
|
Log.Debug("User Agent: " + navigator.userAgent);
|
|
|
|
// Check canvas features
|
|
if (!('createImageData' in this._drawCtx)) {
|
|
throw new Error("Canvas does not support createImageData");
|
|
}
|
|
|
|
this._tile16x16 = this._drawCtx.createImageData(16, 16);
|
|
Log.Debug("<< Display.constructor");
|
|
|
|
// ===== PROPERTIES =====
|
|
|
|
this._scale = 1.0;
|
|
this._clipViewport = false;
|
|
|
|
// ===== EVENT HANDLERS =====
|
|
|
|
this.onflush = () => {}; // A flush request has finished
|
|
}
|
|
|
|
// ===== PROPERTIES =====
|
|
|
|
get scale() { return this._scale; }
|
|
set scale(scale) {
|
|
this._rescale(scale);
|
|
}
|
|
|
|
get clipViewport() { return this._clipViewport; }
|
|
set clipViewport(viewport) {
|
|
this._clipViewport = viewport;
|
|
// May need to readjust the viewport dimensions
|
|
const vp = this._viewportLoc;
|
|
this.viewportChangeSize(vp.w, vp.h);
|
|
this.viewportChangePos(0, 0);
|
|
}
|
|
|
|
get width() {
|
|
return this._fb_width;
|
|
}
|
|
|
|
get height() {
|
|
return this._fb_height;
|
|
}
|
|
|
|
// ===== PUBLIC METHODS =====
|
|
|
|
viewportChangePos(deltaX, deltaY) {
|
|
const vp = this._viewportLoc;
|
|
deltaX = Math.floor(deltaX);
|
|
deltaY = Math.floor(deltaY);
|
|
|
|
if (!this._clipViewport) {
|
|
deltaX = -vp.w; // clamped later of out of bounds
|
|
deltaY = -vp.h;
|
|
}
|
|
|
|
const vx2 = vp.x + vp.w - 1;
|
|
const vy2 = vp.y + vp.h - 1;
|
|
|
|
// Position change
|
|
|
|
if (deltaX < 0 && vp.x + deltaX < 0) {
|
|
deltaX = -vp.x;
|
|
}
|
|
if (vx2 + deltaX >= this._fb_width) {
|
|
deltaX -= vx2 + deltaX - this._fb_width + 1;
|
|
}
|
|
|
|
if (vp.y + deltaY < 0) {
|
|
deltaY = -vp.y;
|
|
}
|
|
if (vy2 + deltaY >= this._fb_height) {
|
|
deltaY -= (vy2 + deltaY - this._fb_height + 1);
|
|
}
|
|
|
|
if (deltaX === 0 && deltaY === 0) {
|
|
return;
|
|
}
|
|
Log.Debug("viewportChange deltaX: " + deltaX + ", deltaY: " + deltaY);
|
|
|
|
vp.x += deltaX;
|
|
vp.y += deltaY;
|
|
|
|
this._damage(vp.x, vp.y, vp.w, vp.h);
|
|
|
|
this.flip();
|
|
}
|
|
|
|
viewportChangeSize(width, height) {
|
|
|
|
if (!this._clipViewport ||
|
|
typeof(width) === "undefined" ||
|
|
typeof(height) === "undefined") {
|
|
|
|
Log.Debug("Setting viewport to full display region");
|
|
width = this._fb_width;
|
|
height = this._fb_height;
|
|
}
|
|
|
|
width = Math.floor(width);
|
|
height = Math.floor(height);
|
|
|
|
if (width > this._fb_width) {
|
|
width = this._fb_width;
|
|
}
|
|
if (height > this._fb_height) {
|
|
height = this._fb_height;
|
|
}
|
|
|
|
const vp = this._viewportLoc;
|
|
if (vp.w !== width || vp.h !== height) {
|
|
vp.w = width;
|
|
vp.h = height;
|
|
|
|
const canvas = this._target;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
|
|
// The position might need to be updated if we've grown
|
|
this.viewportChangePos(0, 0);
|
|
|
|
this._damage(vp.x, vp.y, vp.w, vp.h);
|
|
this.flip();
|
|
|
|
// Update the visible size of the target canvas
|
|
this._rescale(this._scale);
|
|
}
|
|
}
|
|
|
|
absX(x) {
|
|
if (this._scale === 0) {
|
|
return 0;
|
|
}
|
|
return x / this._scale + this._viewportLoc.x;
|
|
}
|
|
|
|
absY(y) {
|
|
if (this._scale === 0) {
|
|
return 0;
|
|
}
|
|
return y / this._scale + this._viewportLoc.y;
|
|
}
|
|
|
|
resize(width, height) {
|
|
this._prevDrawStyle = "";
|
|
|
|
this._fb_width = width;
|
|
this._fb_height = height;
|
|
|
|
const canvas = this._backbuffer;
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
|
|
// We have to save the canvas data since changing the size will clear it
|
|
let saveImg = null;
|
|
if (canvas.width > 0 && canvas.height > 0) {
|
|
saveImg = this._drawCtx.getImageData(0, 0, canvas.width, canvas.height);
|
|
}
|
|
|
|
if (canvas.width !== width) {
|
|
canvas.width = width;
|
|
}
|
|
if (canvas.height !== height) {
|
|
canvas.height = height;
|
|
}
|
|
|
|
if (saveImg) {
|
|
this._drawCtx.putImageData(saveImg, 0, 0);
|
|
}
|
|
}
|
|
|
|
// Readjust the viewport as it may be incorrectly sized
|
|
// and positioned
|
|
const vp = this._viewportLoc;
|
|
this.viewportChangeSize(vp.w, vp.h);
|
|
this.viewportChangePos(0, 0);
|
|
}
|
|
|
|
// Track what parts of the visible canvas that need updating
|
|
_damage(x, y, w, h) {
|
|
if (x < this._damageBounds.left) {
|
|
this._damageBounds.left = x;
|
|
}
|
|
if (y < this._damageBounds.top) {
|
|
this._damageBounds.top = y;
|
|
}
|
|
if ((x + w) > this._damageBounds.right) {
|
|
this._damageBounds.right = x + w;
|
|
}
|
|
if ((y + h) > this._damageBounds.bottom) {
|
|
this._damageBounds.bottom = y + h;
|
|
}
|
|
}
|
|
|
|
// Update the visible canvas with the contents of the
|
|
// rendering canvas
|
|
flip(from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
this._renderQ_push({
|
|
'type': 'flip'
|
|
});
|
|
} else {
|
|
let x = this._damageBounds.left;
|
|
let y = this._damageBounds.top;
|
|
let w = this._damageBounds.right - x;
|
|
let h = this._damageBounds.bottom - y;
|
|
|
|
let vx = x - this._viewportLoc.x;
|
|
let vy = y - this._viewportLoc.y;
|
|
|
|
if (vx < 0) {
|
|
w += vx;
|
|
x -= vx;
|
|
vx = 0;
|
|
}
|
|
if (vy < 0) {
|
|
h += vy;
|
|
y -= vy;
|
|
vy = 0;
|
|
}
|
|
|
|
if ((vx + w) > this._viewportLoc.w) {
|
|
w = this._viewportLoc.w - vx;
|
|
}
|
|
if ((vy + h) > this._viewportLoc.h) {
|
|
h = this._viewportLoc.h - vy;
|
|
}
|
|
|
|
if ((w > 0) && (h > 0)) {
|
|
// FIXME: We may need to disable image smoothing here
|
|
// as well (see copyImage()), but we haven't
|
|
// noticed any problem yet.
|
|
this._targetCtx.drawImage(this._backbuffer,
|
|
x, y, w, h,
|
|
vx, vy, w, h);
|
|
}
|
|
|
|
this._damageBounds.left = this._damageBounds.top = 65535;
|
|
this._damageBounds.right = this._damageBounds.bottom = 0;
|
|
}
|
|
}
|
|
|
|
pending() {
|
|
return this._renderQ.length > 0;
|
|
}
|
|
|
|
flush() {
|
|
if (this._renderQ.length === 0) {
|
|
this.onflush();
|
|
} else {
|
|
this._flushing = true;
|
|
}
|
|
}
|
|
|
|
fillRect(x, y, width, height, color, from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
this._renderQ_push({
|
|
'type': 'fill',
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
'color': color
|
|
});
|
|
} else {
|
|
this._setFillColor(color);
|
|
this._drawCtx.fillRect(x, y, width, height);
|
|
this._damage(x, y, width, height);
|
|
}
|
|
}
|
|
|
|
copyImage(old_x, old_y, new_x, new_y, w, h, from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
this._renderQ_push({
|
|
'type': 'copy',
|
|
'old_x': old_x,
|
|
'old_y': old_y,
|
|
'x': new_x,
|
|
'y': new_y,
|
|
'width': w,
|
|
'height': h,
|
|
});
|
|
} else {
|
|
// Due to this bug among others [1] we need to disable the image-smoothing to
|
|
// avoid getting a blur effect when copying data.
|
|
//
|
|
// 1. https://bugzilla.mozilla.org/show_bug.cgi?id=1194719
|
|
//
|
|
// We need to set these every time since all properties are reset
|
|
// when the the size is changed
|
|
this._drawCtx.mozImageSmoothingEnabled = false;
|
|
this._drawCtx.webkitImageSmoothingEnabled = false;
|
|
this._drawCtx.msImageSmoothingEnabled = false;
|
|
this._drawCtx.imageSmoothingEnabled = false;
|
|
|
|
this._drawCtx.drawImage(this._backbuffer,
|
|
old_x, old_y, w, h,
|
|
new_x, new_y, w, h);
|
|
this._damage(new_x, new_y, w, h);
|
|
}
|
|
}
|
|
|
|
imageRect(x, y, w, h, mime, arr) {
|
|
/* The internal logic cannot handle empty images, so bail early */
|
|
if ((width === 0) || (height === 0)) {
|
|
return;
|
|
}
|
|
|
|
const img = new Image();
|
|
img.src = "data: " + mime + ";base64," + Base64.encode(arr);
|
|
|
|
this._renderQ_push({
|
|
'type': 'img',
|
|
'img': img,
|
|
'x': x,
|
|
'y': y,
|
|
'w': w,
|
|
'h': h
|
|
});
|
|
}
|
|
|
|
// start updating a tile
|
|
startTile(x, y, width, height, color) {
|
|
this._tile_x = x;
|
|
this._tile_y = y;
|
|
if (width === 16 && height === 16) {
|
|
this._tile = this._tile16x16;
|
|
} else {
|
|
this._tile = this._drawCtx.createImageData(width, height);
|
|
}
|
|
|
|
const red = color[2];
|
|
const green = color[1];
|
|
const blue = color[0];
|
|
|
|
const data = this._tile.data;
|
|
for (let i = 0; i < width * height * 4; i += 4) {
|
|
data[i] = red;
|
|
data[i + 1] = green;
|
|
data[i + 2] = blue;
|
|
data[i + 3] = 255;
|
|
}
|
|
}
|
|
|
|
// update sub-rectangle of the current tile
|
|
subTile(x, y, w, h, color) {
|
|
const red = color[2];
|
|
const green = color[1];
|
|
const blue = color[0];
|
|
const xend = x + w;
|
|
const yend = y + h;
|
|
|
|
const data = this._tile.data;
|
|
const width = this._tile.width;
|
|
for (let j = y; j < yend; j++) {
|
|
for (let i = x; i < xend; i++) {
|
|
const p = (i + (j * width)) * 4;
|
|
data[p] = red;
|
|
data[p + 1] = green;
|
|
data[p + 2] = blue;
|
|
data[p + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
|
|
// draw the current tile to the screen
|
|
finishTile() {
|
|
this._drawCtx.putImageData(this._tile, this._tile_x, this._tile_y);
|
|
this._damage(this._tile_x, this._tile_y,
|
|
this._tile.width, this._tile.height);
|
|
}
|
|
|
|
blitImage(x, y, width, height, arr, offset, from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
// NB(directxman12): it's technically more performant here to use preallocated arrays,
|
|
// but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
|
|
// this probably isn't getting called *nearly* as much
|
|
const new_arr = new Uint8Array(width * height * 4);
|
|
new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
|
|
this._renderQ_push({
|
|
'type': 'blit',
|
|
'data': new_arr,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
});
|
|
} else {
|
|
this._bgrxImageData(x, y, width, height, arr, offset);
|
|
}
|
|
}
|
|
|
|
blitRgbImage(x, y, width, height, arr, offset, from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
// NB(directxman12): it's technically more performant here to use preallocated arrays,
|
|
// but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
|
|
// this probably isn't getting called *nearly* as much
|
|
const new_arr = new Uint8Array(width * height * 3);
|
|
new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
|
|
this._renderQ_push({
|
|
'type': 'blitRgb',
|
|
'data': new_arr,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
});
|
|
} else {
|
|
this._rgbImageData(x, y, width, height, arr, offset);
|
|
}
|
|
}
|
|
|
|
blitRgbxImage(x, y, width, height, arr, offset, from_queue) {
|
|
if (this._renderQ.length !== 0 && !from_queue) {
|
|
// NB(directxman12): it's technically more performant here to use preallocated arrays,
|
|
// but it's a lot of extra work for not a lot of payoff -- if we're using the render queue,
|
|
// this probably isn't getting called *nearly* as much
|
|
const new_arr = new Uint8Array(width * height * 4);
|
|
new_arr.set(new Uint8Array(arr.buffer, 0, new_arr.length));
|
|
this._renderQ_push({
|
|
'type': 'blitRgbx',
|
|
'data': new_arr,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
});
|
|
} else {
|
|
this._rgbxImageData(x, y, width, height, arr, offset);
|
|
}
|
|
}
|
|
|
|
drawImage(img, x, y, w, h) {
|
|
if (img.width != w || img.height != h) {
|
|
this._drawCtx.drawImage(img, x, y, w, h);
|
|
} else {
|
|
this._drawCtx.drawImage(img, x, y);
|
|
}
|
|
this._damage(x, y, w, h);
|
|
}
|
|
|
|
autoscale(containerWidth, containerHeight) {
|
|
let scaleRatio;
|
|
|
|
if (containerWidth === 0 || containerHeight === 0) {
|
|
scaleRatio = 0;
|
|
|
|
} else {
|
|
|
|
const vp = this._viewportLoc;
|
|
const targetAspectRatio = containerWidth / containerHeight;
|
|
const fbAspectRatio = vp.w / vp.h;
|
|
|
|
if (fbAspectRatio >= targetAspectRatio) {
|
|
scaleRatio = containerWidth / vp.w;
|
|
} else {
|
|
scaleRatio = containerHeight / vp.h;
|
|
}
|
|
}
|
|
|
|
this._rescale(scaleRatio);
|
|
}
|
|
|
|
// ===== PRIVATE METHODS =====
|
|
|
|
_rescale(factor) {
|
|
this._scale = factor;
|
|
const vp = this._viewportLoc;
|
|
|
|
// NB(directxman12): If you set the width directly, or set the
|
|
// style width to a number, the canvas is cleared.
|
|
// However, if you set the style width to a string
|
|
// ('NNNpx'), the canvas is scaled without clearing.
|
|
const width = factor * vp.w + 'px';
|
|
const height = factor * vp.h + 'px';
|
|
|
|
if ((this._target.style.width !== width) ||
|
|
(this._target.style.height !== height)) {
|
|
this._target.style.width = width;
|
|
this._target.style.height = height;
|
|
}
|
|
}
|
|
|
|
_setFillColor(color) {
|
|
const newStyle = 'rgb(' + color[2] + ',' + color[1] + ',' + color[0] + ')';
|
|
if (newStyle !== this._prevDrawStyle) {
|
|
this._drawCtx.fillStyle = newStyle;
|
|
this._prevDrawStyle = newStyle;
|
|
}
|
|
}
|
|
|
|
_rgbImageData(x, y, width, height, arr, offset) {
|
|
const img = this._drawCtx.createImageData(width, height);
|
|
const data = img.data;
|
|
for (let i = 0, j = offset; i < width * height * 4; i += 4, j += 3) {
|
|
data[i] = arr[j];
|
|
data[i + 1] = arr[j + 1];
|
|
data[i + 2] = arr[j + 2];
|
|
data[i + 3] = 255; // Alpha
|
|
}
|
|
this._drawCtx.putImageData(img, x, y);
|
|
this._damage(x, y, img.width, img.height);
|
|
}
|
|
|
|
_bgrxImageData(x, y, width, height, arr, offset) {
|
|
const img = this._drawCtx.createImageData(width, height);
|
|
const data = img.data;
|
|
for (let i = 0, j = offset; i < width * height * 4; i += 4, j += 4) {
|
|
data[i] = arr[j + 2];
|
|
data[i + 1] = arr[j + 1];
|
|
data[i + 2] = arr[j];
|
|
data[i + 3] = 255; // Alpha
|
|
}
|
|
this._drawCtx.putImageData(img, x, y);
|
|
this._damage(x, y, img.width, img.height);
|
|
}
|
|
|
|
_rgbxImageData(x, y, width, height, arr, offset) {
|
|
// NB(directxman12): arr must be an Type Array view
|
|
let img;
|
|
if (supportsImageMetadata) {
|
|
img = new ImageData(new Uint8ClampedArray(arr.buffer, arr.byteOffset, width * height * 4), width, height);
|
|
} else {
|
|
img = this._drawCtx.createImageData(width, height);
|
|
img.data.set(new Uint8ClampedArray(arr.buffer, arr.byteOffset, width * height * 4));
|
|
}
|
|
this._drawCtx.putImageData(img, x, y);
|
|
this._damage(x, y, img.width, img.height);
|
|
}
|
|
|
|
_renderQ_push(action) {
|
|
this._renderQ.push(action);
|
|
if (this._renderQ.length === 1) {
|
|
// If this can be rendered immediately it will be, otherwise
|
|
// the scanner will wait for the relevant event
|
|
this._scan_renderQ();
|
|
}
|
|
}
|
|
|
|
_resume_renderQ() {
|
|
// "this" is the object that is ready, not the
|
|
// display object
|
|
this.removeEventListener('load', this._noVNC_display._resume_renderQ);
|
|
this._noVNC_display._scan_renderQ();
|
|
}
|
|
|
|
_scan_renderQ() {
|
|
let ready = true;
|
|
while (ready && this._renderQ.length > 0) {
|
|
const a = this._renderQ[0];
|
|
switch (a.type) {
|
|
case 'flip':
|
|
this.flip(true);
|
|
break;
|
|
case 'copy':
|
|
this.copyImage(a.old_x, a.old_y, a.x, a.y, a.width, a.height, true);
|
|
break;
|
|
case 'fill':
|
|
this.fillRect(a.x, a.y, a.width, a.height, a.color, true);
|
|
break;
|
|
case 'blit':
|
|
this.blitImage(a.x, a.y, a.width, a.height, a.data, 0, true);
|
|
break;
|
|
case 'blitRgb':
|
|
this.blitRgbImage(a.x, a.y, a.width, a.height, a.data, 0, true);
|
|
break;
|
|
case 'blitRgbx':
|
|
this.blitRgbxImage(a.x, a.y, a.width, a.height, a.data, 0, true);
|
|
break;
|
|
case 'img':
|
|
/* IE tends to set "complete" prematurely, so check dimensions */
|
|
if (a.img.complete && (a.img.width !== 0) && (a.img.height !== 0)) {
|
|
this.drawImage(a.img, a.x, a.y, a.w, a.h);
|
|
} else {
|
|
a.img._noVNC_display = this;
|
|
a.img.addEventListener('load', this._resume_renderQ);
|
|
// We need to wait for this image to 'load'
|
|
// to keep things in-order
|
|
ready = false;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (ready) {
|
|
this._renderQ.shift();
|
|
}
|
|
}
|
|
|
|
if (this._renderQ.length === 0 && this._flushing) {
|
|
this._flushing = false;
|
|
this.onflush();
|
|
}
|
|
}
|
|
}
|