mirror of
https://git.proxmox.com/git/mirror_novnc
synced 2025-04-29 02:10:13 +00:00

Try to be more consistent in how we capitalize things. Both the "Title Case" and "Sentence case" styles are popular, so either would work. Google and Mozilla both prefer "Sentence case", so let's follow them.
576 lines
18 KiB
JavaScript
576 lines
18 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 { toSigned32bit } from './util/int.js';
|
|
|
|
export default class Display {
|
|
constructor(target) {
|
|
this._drawCtx = null;
|
|
|
|
this._renderQ = []; // queue drawing actions for in-oder rendering
|
|
this._flushPromise = null;
|
|
|
|
// the full frame buffer (logical canvas) size
|
|
this._fbWidth = 0;
|
|
this._fbHeight = 0;
|
|
|
|
this._prevDrawStyle = "";
|
|
|
|
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);
|
|
|
|
Log.Debug("<< Display.constructor");
|
|
|
|
// ===== PROPERTIES =====
|
|
|
|
this._scale = 1.0;
|
|
this._clipViewport = false;
|
|
}
|
|
|
|
// ===== 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._fbWidth;
|
|
}
|
|
|
|
get height() {
|
|
return this._fbHeight;
|
|
}
|
|
|
|
// ===== 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._fbWidth) {
|
|
deltaX -= vx2 + deltaX - this._fbWidth + 1;
|
|
}
|
|
|
|
if (vp.y + deltaY < 0) {
|
|
deltaY = -vp.y;
|
|
}
|
|
if (vy2 + deltaY >= this._fbHeight) {
|
|
deltaY -= (vy2 + deltaY - this._fbHeight + 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._fbWidth;
|
|
height = this._fbHeight;
|
|
}
|
|
|
|
width = Math.floor(width);
|
|
height = Math.floor(height);
|
|
|
|
if (width > this._fbWidth) {
|
|
width = this._fbWidth;
|
|
}
|
|
if (height > this._fbHeight) {
|
|
height = this._fbHeight;
|
|
}
|
|
|
|
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 toSigned32bit(x / this._scale + this._viewportLoc.x);
|
|
}
|
|
|
|
absY(y) {
|
|
if (this._scale === 0) {
|
|
return 0;
|
|
}
|
|
return toSigned32bit(y / this._scale + this._viewportLoc.y);
|
|
}
|
|
|
|
resize(width, height) {
|
|
this._prevDrawStyle = "";
|
|
|
|
this._fbWidth = width;
|
|
this._fbHeight = 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);
|
|
}
|
|
|
|
getImageData() {
|
|
return this._drawCtx.getImageData(0, 0, this.width, this.height);
|
|
}
|
|
|
|
toDataURL(type, encoderOptions) {
|
|
return this._backbuffer.toDataURL(type, encoderOptions);
|
|
}
|
|
|
|
toBlob(callback, type, quality) {
|
|
return this._backbuffer.toBlob(callback, type, quality);
|
|
}
|
|
|
|
// 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(fromQueue) {
|
|
if (this._renderQ.length !== 0 && !fromQueue) {
|
|
this._renderQPush({
|
|
'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) {
|
|
return Promise.resolve();
|
|
} else {
|
|
if (this._flushPromise === null) {
|
|
this._flushPromise = new Promise((resolve) => {
|
|
this._flushResolve = resolve;
|
|
});
|
|
}
|
|
return this._flushPromise;
|
|
}
|
|
}
|
|
|
|
fillRect(x, y, width, height, color, fromQueue) {
|
|
if (this._renderQ.length !== 0 && !fromQueue) {
|
|
this._renderQPush({
|
|
'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(oldX, oldY, newX, newY, w, h, fromQueue) {
|
|
if (this._renderQ.length !== 0 && !fromQueue) {
|
|
this._renderQPush({
|
|
'type': 'copy',
|
|
'oldX': oldX,
|
|
'oldY': oldY,
|
|
'x': newX,
|
|
'y': newY,
|
|
'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,
|
|
oldX, oldY, w, h,
|
|
newX, newY, w, h);
|
|
this._damage(newX, newY, w, h);
|
|
}
|
|
}
|
|
|
|
imageRect(x, y, width, height, 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._renderQPush({
|
|
'type': 'img',
|
|
'img': img,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height
|
|
});
|
|
}
|
|
|
|
videoFrame(x, y, width, height, frame) {
|
|
this._renderQPush({
|
|
'type': 'frame',
|
|
'frame': frame,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height
|
|
});
|
|
}
|
|
|
|
blitImage(x, y, width, height, arr, offset, fromQueue) {
|
|
if (this._renderQ.length !== 0 && !fromQueue) {
|
|
// 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 newArr = new Uint8Array(width * height * 4);
|
|
newArr.set(new Uint8Array(arr.buffer, 0, newArr.length));
|
|
this._renderQPush({
|
|
'type': 'blit',
|
|
'data': newArr,
|
|
'x': x,
|
|
'y': y,
|
|
'width': width,
|
|
'height': height,
|
|
});
|
|
} else {
|
|
// NB(directxman12): arr must be an Type Array view
|
|
let data = new Uint8ClampedArray(arr.buffer,
|
|
arr.byteOffset + offset,
|
|
width * height * 4);
|
|
let img = new ImageData(data, width, height);
|
|
this._drawCtx.putImageData(img, x, y);
|
|
this._damage(x, y, width, height);
|
|
}
|
|
}
|
|
|
|
drawImage(img, ...args) {
|
|
this._drawCtx.drawImage(img, ...args);
|
|
|
|
if (args.length <= 4) {
|
|
const [x, y] = args;
|
|
this._damage(x, y, img.width, img.height);
|
|
} else {
|
|
const [,, sw, sh, dx, dy] = args;
|
|
this._damage(dx, dy, sw, sh);
|
|
}
|
|
}
|
|
|
|
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[0] + ',' + color[1] + ',' + color[2] + ')';
|
|
if (newStyle !== this._prevDrawStyle) {
|
|
this._drawCtx.fillStyle = newStyle;
|
|
this._prevDrawStyle = newStyle;
|
|
}
|
|
}
|
|
|
|
_renderQPush(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._scanRenderQ();
|
|
}
|
|
}
|
|
|
|
_resumeRenderQ() {
|
|
// "this" is the object that is ready, not the
|
|
// display object
|
|
this.removeEventListener('load', this._noVNCDisplay._resumeRenderQ);
|
|
this._noVNCDisplay._scanRenderQ();
|
|
}
|
|
|
|
_scanRenderQ() {
|
|
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.oldX, a.oldY, 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 'img':
|
|
if (a.img.complete) {
|
|
if (a.img.width !== a.width || a.img.height !== a.height) {
|
|
Log.Error("Decoded image has incorrect dimensions. Got " +
|
|
a.img.width + "x" + a.img.height + ". Expected " +
|
|
a.width + "x" + a.height + ".");
|
|
return;
|
|
}
|
|
this.drawImage(a.img, a.x, a.y);
|
|
} else {
|
|
a.img._noVNCDisplay = this;
|
|
a.img.addEventListener('load', this._resumeRenderQ);
|
|
// We need to wait for this image to 'load'
|
|
// to keep things in-order
|
|
ready = false;
|
|
}
|
|
break;
|
|
case 'frame':
|
|
if (a.frame.ready) {
|
|
// The encoded frame may be larger than the rect due to
|
|
// limitations of the encoder, so we need to crop the
|
|
// frame.
|
|
let frame = a.frame.frame;
|
|
if (frame.codedWidth < a.width || frame.codedHeight < a.height) {
|
|
Log.Warn("Decoded video frame does not cover its full rectangle area. Expecting at least " +
|
|
a.width + "x" + a.height + " but got " +
|
|
frame.codedWidth + "x" + frame.codedHeight);
|
|
}
|
|
const sx = 0;
|
|
const sy = 0;
|
|
const sw = a.width;
|
|
const sh = a.height;
|
|
const dx = a.x;
|
|
const dy = a.y;
|
|
const dw = sw;
|
|
const dh = sh;
|
|
this.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
frame.close();
|
|
} else {
|
|
let display = this;
|
|
a.frame.promise.then(() => {
|
|
display._scanRenderQ();
|
|
});
|
|
ready = false;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (ready) {
|
|
this._renderQ.shift();
|
|
}
|
|
}
|
|
|
|
if (this._renderQ.length === 0 &&
|
|
this._flushPromise !== null) {
|
|
this._flushResolve();
|
|
this._flushPromise = null;
|
|
this._flushResolve = null;
|
|
}
|
|
}
|
|
}
|