Files
noVNC/include/display.js
Joel Martin 54e7cbdf8f Viewport handling in include/display.js
Part of mobile device support:
https://github.com/kanaka/noVNC/issues/48

The Display object is redefined as a larger display region with
an equal or smaller visible viewport. The size of the full display
region is set/changed using resize(). The viewport is set/changed
using viewportChange().

All exposed routines that draw on the display now take coordinates
that are absolute (relative to the full display region). For example,
the result of fillRect(100, 100, 10, 10, [255,0,0]) will appear in the
canvas at (0,0) if the viewport is set to (100,100).

Details:

- Move the generic part of the viewport code from tests/viewport.html
  into include/display.

- Add two new routines to the Display interface:

    - viewportChange(deltaX, deltaY, width, height)
        - This adjusts the position of the visible viewport and/or the
          size of the viewport.

        - deltaX and deltaY specify how the position of the viewport
          should be shifted. The position of the viewport is clamped
          to the full region size (i.e. cannot outside the display
          region).

        - The clean and dirty regions of the display are updated based
          on calls to this routine. For example, if the viewport width
          is increased, then there is now a dirty box on the right
          side of the viewport. Another example, if the viewport is
          shifted down and to the left over the display region, there
          are now two dirty boxes: one on the left side and one
          on the bottom of the viewport.

    - getCleanDirtyReset()
        - This returns an object with the clean box and a list of
          dirty boxes (that need to be redrawn).

            {'cleanBox':
                {'x': x, 'y': y, 'w': w, 'h': h},
             'dirtyBoxes':
                [{'x': x, 'y': y, 'w': w, 'h': h}, ...]
             }

        - The coordinates in the clean and dirty boxes are absolute
          coordinates (relative to the full display region) but they
          are clipped to the visible viewport.

        - Calling this function also resets the clean rectangle to be
          the whole viewport (i.e. nothing visible needs to be redrawn
          dirty) so the caller of this routine is responsible for
          redrawing any
2011-09-13 09:54:44 -05:00

745 lines
22 KiB
JavaScript

/*
* noVNC: HTML5 VNC client
* Copyright (C) 2011 Joel Martin
* Licensed under LGPL-3 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*/
/*jslint browser: true, white: false, bitwise: false */
/*global Util, Base64, changeCursor */
function Display(defaults) {
"use strict";
var that = {}, // Public API methods
conf = {}, // Configuration attributes
// Private Display namespace variables
c_ctx = null,
c_forceCanvas = false,
c_imageData, c_rgbxImage, c_cmapImage,
// Predefine function variables (jslint)
imageDataCreate, imageDataGet, rgbxImageData, cmapImageData,
rgbxImageFill, cmapImageFill, setFillColor, rescale, flush,
// The full frame buffer (logical canvas) size
fb_width = 0,
fb_height = 0,
// The visible "physical canvas" viewport
viewport = {'x': 0, 'y': 0, 'w' : 0, 'h' : 0 },
cleanRect = {'x1': 0, 'y1': 0, 'x2': -1, 'y2': -1},
c_prevStyle = "",
c_webkit_bug = false,
c_flush_timer = null;
// Configuration attributes
Util.conf_defaults(conf, that, defaults, [
['target', 'wo', 'dom', null, 'Canvas element for rendering'],
['context', 'ro', 'raw', null, 'Canvas 2D context for rendering (read-only)'],
['logo', 'rw', 'raw', null, 'Logo to display when cleared: {"width": width, "height": height, "data": data}'],
['true_color', 'rw', 'bool', true, 'Use true-color pixel data'],
['colourMap', 'rw', 'arr', [], 'Colour map array (when not true-color)'],
['scale', 'rw', 'float', 1.0, 'Display area scale factor 0.0 - 1.0'],
['width', 'rw', 'int', null, 'Display area width'],
['height', 'rw', 'int', null, 'Display area height'],
['render_mode', 'ro', 'str', '', 'Canvas rendering mode (read-only)'],
['prefer_js', 'rw', 'str', null, 'Prefer Javascript over canvas methods'],
['cursor_uri', 'rw', 'raw', null, 'Can we render cursor using data URI']
]);
// Override some specific getters/setters
that.get_context = function () { return c_ctx; };
that.set_scale = function(scale) { rescale(scale); };
that.set_width = function (val) { that.resize(val, fb_height); };
that.get_width = function() { return fb_width; };
that.set_height = function (val) { that.resize(fb_width, val); };
that.get_height = function() { return fb_height; };
that.set_prefer_js = function(val) {
if (val && c_forceCanvas) {
Util.Warn("Preferring Javascript to Canvas ops is not supported");
return false;
}
conf.prefer_js = val;
return true;
};
//
// Private functions
//
// Create the public API interface
function constructor() {
Util.Debug(">> Display.constructor");
var c, func, imgTest, tval, i, curDat, curSave,
has_imageData = false, UE = Util.Engine;
if (! conf.target) { throw("target must be set"); }
if (typeof conf.target === 'string') {
throw("target must be a DOM element");
}
c = conf.target;
if (! c.getContext) { throw("no getContext method"); }
if (! c_ctx) { c_ctx = c.getContext('2d'); }
Util.Debug("User Agent: " + navigator.userAgent);
if (UE.gecko) { Util.Debug("Browser: gecko " + UE.gecko); }
if (UE.webkit) { Util.Debug("Browser: webkit " + UE.webkit); }
if (UE.trident) { Util.Debug("Browser: trident " + UE.trident); }
if (UE.presto) { Util.Debug("Browser: presto " + UE.presto); }
that.clear();
/*
* Determine browser Canvas feature support
* and select fastest rendering methods
*/
tval = 0;
try {
imgTest = c_ctx.getImageData(0, 0, 1,1);
imgTest.data[0] = 123;
imgTest.data[3] = 255;
c_ctx.putImageData(imgTest, 0, 0);
tval = c_ctx.getImageData(0, 0, 1, 1).data[0];
if (tval === 123) {
has_imageData = true;
}
} catch (exc1) {}
if (has_imageData) {
Util.Info("Canvas supports imageData");
c_forceCanvas = false;
if (c_ctx.createImageData) {
// If it's there, it's faster
Util.Info("Using Canvas createImageData");
conf.render_mode = "createImageData rendering";
c_imageData = imageDataCreate;
} else if (c_ctx.getImageData) {
// I think this is mostly just Opera
Util.Info("Using Canvas getImageData");
conf.render_mode = "getImageData rendering";
c_imageData = imageDataGet;
}
Util.Info("Prefering javascript operations");
if (conf.prefer_js === null) {
conf.prefer_js = true;
}
c_rgbxImage = rgbxImageData;
c_cmapImage = cmapImageData;
} else {
Util.Warn("Canvas lacks imageData, using fillRect (slow)");
conf.render_mode = "fillRect rendering (slow)";
c_forceCanvas = true;
conf.prefer_js = false;
c_rgbxImage = rgbxImageFill;
c_cmapImage = cmapImageFill;
}
if (UE.webkit && UE.webkit >= 534.7 && UE.webkit <= 534.9) {
// Workaround WebKit canvas rendering bug #46319
conf.render_mode += ", webkit bug workaround";
Util.Debug("Working around WebKit bug #46319");
c_webkit_bug = true;
for (func in {"fillRect":1, "copyImage":1, "rgbxImage":1,
"cmapImage":1, "blitStringImage":1}) {
that[func] = (function() {
var myfunc = that[func]; // Save original function
//Util.Debug("Wrapping " + func);
return function() {
myfunc.apply(this, arguments);
if (!c_flush_timer) {
c_flush_timer = setTimeout(flush, 100);
}
};
}());
}
}
/*
* Determine browser support for setting the cursor via data URI
* scheme
*/
curDat = [];
for (i=0; i < 8 * 8 * 4; i += 1) {
curDat.push(255);
}
try {
curSave = c.style.cursor;
changeCursor(conf.target, curDat, curDat, 2, 2, 8, 8);
if (c.style.cursor) {
if (conf.cursor_uri === null) {
conf.cursor_uri = true;
}
Util.Info("Data URI scheme cursor supported");
} else {
if (conf.cursor_uri === null) {
conf.cursor_uri = false;
}
Util.Warn("Data URI scheme cursor not supported");
}
c.style.cursor = curSave;
} catch (exc2) {
Util.Error("Data URI scheme cursor test exception: " + exc2);
conf.cursor_uri = false;
}
Util.Debug("<< Display.constructor");
return that ;
}
rescale = function(factor) {
var c, tp, x, y,
properties = ['transform', 'WebkitTransform', 'MozTransform', null];
c = conf.target;
tp = properties.shift();
while (tp) {
if (typeof c.style[tp] !== 'undefined') {
break;
}
tp = properties.shift();
}
if (tp === null) {
Util.Debug("No scaling support");
return;
}
if (typeof(factor) === "undefined") {
factor = conf.scale;
} else if (factor > 1.0) {
factor = 1.0;
} else if (factor < 0.1) {
factor = 0.1;
}
if (conf.scale === factor) {
//Util.Debug("Display already scaled to '" + factor + "'");
return;
}
conf.scale = factor;
x = c.width - c.width * factor;
y = c.height - c.height * factor;
c.style[tp] = "scale(" + conf.scale + ") translate(-" + x + "px, -" + y + "px)";
};
that.viewportChange = function(deltaX, deltaY, width, height) {
var c = conf.target, v = viewport, cr = cleanRect,
saveImg = null, saveStyle, x1, y1, vx2, vy2, w, h;
if (typeof(deltaX) === "undefined") { deltaX = 0; }
if (typeof(deltaY) === "undefined") { deltaY = 0; }
if (typeof(width) === "undefined") { width = v.w; }
if (typeof(height) === "undefined") { height = v.h; }
// Size change
if (width > fb_width) { width = fb_width; }
if (height > fb_height) { height = fb_height; }
if ((v.w !== width) || (v.h !== height)) {
// Change width
if ((width < v.w) && (cr.x2 > v.x + width -1)) {
cr.x2 = v.x + width - 1;
}
v.w = width;
// Change height
if ((height < v.h) && (cr.y2 > v.y + height -1)) {
cr.y2 = v.y + height - 1;
}
v.h = height;
if (v.w > 0 && v.h > 0) {
saveImg = c_ctx.getImageData(0, 0,
(c.width < v.w) ? c.width : v.w,
(c.height < v.h) ? c.height : v.h);
}
c.width = v.w;
c.height = v.h;
if (saveImg) {
c_ctx.putImageData(saveImg, 0, 0);
}
}
vx2 = v.x + v.w - 1;
vy2 = v.y + v.h - 1;
// Position change
if ((deltaX < 0) && ((v.x + deltaX) < 0)) {
deltaX = - v.x;
}
if ((vx2 + deltaX) >= fb_width) {
deltaX -= ((vx2 + deltaX) - fb_width + 1);
}
if ((v.y + deltaY) < 0) {
deltaY = - v.y;
}
if ((vy2 + deltaY) >= fb_height) {
deltaY -= ((vy2 + deltaY) - fb_height + 1);
}
if ((deltaX === 0) && (deltaY === 0)) {
//message("skipping");
return;
}
message("deltaX: " + deltaX + ", deltaY: " + deltaY);
v.x += deltaX;
vx2 += deltaX;
v.y += deltaY;
vy2 += deltaY;
// Update the clean rectangle
if (v.x > cr.x1) {
cr.x1 = v.x;
}
if (vx2 < cr.x2) {
cr.x2 = vx2;
}
if (v.y > cr.y1) {
cr.y1 = v.y;
}
if (vy2 < cr.y2) {
cr.y2 = vy2;
}
if (deltaX < 0) {
// Shift viewport left, redraw left section
x1 = 0;
w = - deltaX;
} else {
// Shift viewport right, redraw right section
x1 = v.w - deltaX;
w = deltaX;
}
if (deltaY < 0) {
// Shift viewport up, redraw top section
y1 = 0;
h = - deltaY;
} else {
// Shift viewport down, redraw bottom section
y1 = v.h - deltaY;
h = deltaY;
}
// Copy the valid part of the viewport to the shifted location
saveStyle = c_ctx.fillStyle;
c_ctx.fillStyle = "rgb(255,255,255)";
if (deltaX !== 0) {
//that.copyImage(0, 0, -deltaX, 0, v.w, v.h);
//that.fillRect(x1, 0, w, v.h, [255,255,255]);
c_ctx.drawImage(c, 0, 0, v.w, v.h, -deltaX, 0, v.w, v.h);
c_ctx.fillRect(x1, 0, w, v.h);
}
if (deltaY !== 0) {
//that.copyImage(0, 0, 0, -deltaY, v.w, v.h);
//that.fillRect(0, y1, v.w, h, [255,255,255]);
c_ctx.drawImage(c, 0, 0, v.w, v.h, 0, -deltaY, v.w, v.h);
c_ctx.fillRect(0, y1, v.w, h);
}
c_ctx.fillStyle = saveStyle;
}
that.getCleanDirtyReset = function() {
var v = viewport, c = cleanRect, cleanBox, dirtyBoxes = [],
vx2 = v.x + v.w - 1, vy2 = v.y + v.h - 1;
// Copy the cleanRect
cleanBox = {'x': c.x1, 'y': c.y1,
'w': c.x2 - c.x1 + 1, 'h': c.y2 - c.y1 + 1};
if ((c.x1 >= c.x2) || (c.y1 >= c.y2)) {
// Whole viewport is dirty
dirtyBoxes.push({'x': v.x, 'y': v.y, 'w': v.w, 'h': v.h});
} else {
// Redraw dirty regions
if (v.x < c.x1) {
// left side dirty region
dirtyBoxes.push({'x': v.x, 'y': v.y,
'w': c.x1 - v.x + 1, 'h': v.h});
}
if (vx2 > c.x2) {
// right side dirty region
dirtyBoxes.push({'x': c.x2 + 1, 'y': v.y,
'w': vx2 - c.x2, 'h': v.h});
}
if (v.y < c.y1) {
// top/middle dirty region
dirtyBoxes.push({'x': c.x1, 'y': v.y,
'w': c.x2 - c.x1 + 1, 'h': c.y1 - v.y});
}
if (vy2 > c.y2) {
// bottom/middle dirty region
dirtyBoxes.push({'x': c.x1, 'y': c.y2 + 1,
'w': c.x2 - c.x1 + 1, 'h': vy2 - c.y2});
}
}
// Reset the cleanRect to the whole viewport
cleanRect = {'x1': v.x, 'y1': v.y,
'x2': v.x + v.w - 1, 'y2': v.y + v.h - 1};
return {'cleanBox': cleanBox, 'dirtyBoxes': dirtyBoxes};
}
// Force canvas redraw (for webkit bug #46319 workaround)
flush = function() {
var old_val;
//Util.Debug(">> flush");
old_val = conf.target.style.marginRight;
conf.target.style.marginRight = "1px";
c_flush_timer = null;
setTimeout(function () {
conf.target.style.marginRight = old_val;
}, 1);
};
setFillColor = function(color) {
var rgb, newStyle;
if (conf.true_color) {
rgb = color;
} else {
rgb = conf.colourMap[color[0]];
}
newStyle = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
if (newStyle !== c_prevStyle) {
c_ctx.fillStyle = newStyle;
c_prevStyle = newStyle;
}
};
//
// Public API interface functions
//
that.resize = function(width, height) {
c_prevStyle = "";
fb_width = width;
fb_height = height;
rescale(conf.scale);
that.viewportChange();
};
that.clear = function() {
if (conf.logo) {
that.resize(conf.logo.width, conf.logo.height);
that.viewportChange(0, 0, conf.logo.width, conf.logo.height);
that.blitStringImage(conf.logo.data, 0, 0);
} else {
that.resize(640, 20);
that.viewportChange(0, 0, 640, 20);
c_ctx.clearRect(0, 0, viewport.w, viewport.h);
}
// No benefit over default ("source-over") in Chrome and firefox
//c_ctx.globalCompositeOperation = "copy";
};
that.fillRect = function(x, y, width, height, color) {
setFillColor(color);
c_ctx.fillRect(x - viewport.x, y - viewport.y, width, height);
};
that.copyImage = function(old_x, old_y, new_x, new_y, w, h) {
var x1 = old_x - viewport.x, y1 = old_y - viewport.y,
x2 = new_x - viewport.x, y2 = new_y - viewport.y;
c_ctx.drawImage(conf.target, x1, y1, w, h, x2, y2, w, h);
};
/*
* Tile rendering functions optimized for rendering engines.
*
* - In Chrome/webkit, Javascript image data array manipulations are
* faster than direct Canvas fillStyle, fillRect rendering. In
* gecko, Javascript array handling is much slower.
*/
that.getTile = function(x, y, width, height, color) {
var img, data = [], rgb, red, green, blue, i;
img = {'x': x, 'y': y, 'width': width, 'height': height,
'data': data};
if (conf.prefer_js) {
if (conf.true_color) {
rgb = color;
} else {
rgb = conf.colourMap[color[0]];
}
red = rgb[0];
green = rgb[1];
blue = rgb[2];
for (i = 0; i < (width * height * 4); i+=4) {
data[i ] = red;
data[i + 1] = green;
data[i + 2] = blue;
}
} else {
that.fillRect(x, y, width, height, color);
}
return img;
};
that.setSubTile = function(img, x, y, w, h, color) {
var data, p, rgb, red, green, blue, width, j, i, xend, yend;
if (conf.prefer_js) {
data = img.data;
width = img.width;
if (conf.true_color) {
rgb = color;
} else {
rgb = conf.colourMap[color[0]];
}
red = rgb[0];
green = rgb[1];
blue = rgb[2];
xend = x + w;
yend = y + h;
for (j = y; j < yend; j += 1) {
for (i = x; i < xend; i += 1) {
p = (i + (j * width) ) * 4;
data[p ] = red;
data[p + 1] = green;
data[p + 2] = blue;
}
}
} else {
that.fillRect(img.x + x, img.y + y, w, h, color);
}
};
that.putTile = function(img) {
if (conf.prefer_js) {
c_rgbxImage(img.x, img.y, img.width, img.height, img.data, 0);
}
// else: No-op, under gecko already done by setSubTile
};
imageDataGet = function(width, height) {
return c_ctx.getImageData(0, 0, width, height);
};
imageDataCreate = function(width, height) {
return c_ctx.createImageData(width, height);
};
rgbxImageData = function(x, y, width, height, arr, offset) {
var img, i, j, data;
img = c_imageData(width, height);
data = img.data;
for (i=0, j=offset; i < (width * height * 4); i=i+4, j=j+4) {
data[i ] = arr[j ];
data[i + 1] = arr[j + 1];
data[i + 2] = arr[j + 2];
data[i + 3] = 255; // Set Alpha
}
c_ctx.putImageData(img, x - viewport.x, y - viewport.y);
};
// really slow fallback if we don't have imageData
rgbxImageFill = function(x, y, width, height, arr, offset) {
var i, j, sx = 0, sy = 0;
for (i=0, j=offset; i < (width * height); i+=1, j+=4) {
that.fillRect(x+sx, y+sy, 1, 1, [arr[j], arr[j+1], arr[j+2]]);
sx += 1;
if ((sx % width) === 0) {
sx = 0;
sy += 1;
}
}
};
cmapImageData = function(x, y, width, height, arr, offset) {
var img, i, j, data, rgb, cmap;
img = c_imageData(width, height);
data = img.data;
cmap = conf.colourMap;
for (i=0, j=offset; i < (width * height * 4); i+=4, j+=1) {
rgb = cmap[arr[j]];
data[i ] = rgb[0];
data[i + 1] = rgb[1];
data[i + 2] = rgb[2];
data[i + 3] = 255; // Set Alpha
}
c_ctx.putImageData(img, x - viewport.x, y - viewport.y);
};
cmapImageFill = function(x, y, width, height, arr, offset) {
var i, j, sx = 0, sy = 0, cmap;
cmap = conf.colourMap;
for (i=0, j=offset; i < (width * height); i+=1, j+=1) {
that.fillRect(x+sx, y+sy, 1, 1, [arr[j]]);
sx += 1;
if ((sx % width) === 0) {
sx = 0;
sy += 1;
}
}
};
that.blitImage = function(x, y, width, height, arr, offset) {
if (conf.true_color) {
c_rgbxImage(x, y, width, height, arr, offset);
} else {
c_cmapImage(x, y, width, height, arr, offset);
}
};
that.blitStringImage = function(str, x, y) {
var img = new Image();
img.onload = function () {
c_ctx.drawImage(img, x - viewport.x, y - viewport.y);
};
img.src = str;
};
that.changeCursor = function(pixels, mask, hotx, hoty, w, h) {
if (conf.cursor_uri === false) {
Util.Warn("changeCursor called but no cursor data URI support");
return;
}
if (conf.true_color) {
changeCursor(conf.target, pixels, mask, hotx, hoty, w, h);
} else {
changeCursor(conf.target, pixels, mask, hotx, hoty, w, h, conf.colourMap);
}
};
that.defaultCursor = function() {
conf.target.style.cursor = "default";
};
return constructor(); // Return the public API interface
} // End of Display()
/* Set CSS cursor property using data URI encoded cursor file */
function changeCursor(target, pixels, mask, hotx, hoty, w, h, cmap) {
"use strict";
var cur = [], rgb, IHDRsz, RGBsz, ANDsz, XORsz, url, idx, alpha, x, y;
//Util.Debug(">> changeCursor, x: " + hotx + ", y: " + hoty + ", w: " + w + ", h: " + h);
// Push multi-byte little-endian values
cur.push16le = function (num) {
this.push((num ) & 0xFF,
(num >> 8) & 0xFF );
};
cur.push32le = function (num) {
this.push((num ) & 0xFF,
(num >> 8) & 0xFF,
(num >> 16) & 0xFF,
(num >> 24) & 0xFF );
};
IHDRsz = 40;
RGBsz = w * h * 4;
XORsz = Math.ceil( (w * h) / 8.0 );
ANDsz = Math.ceil( (w * h) / 8.0 );
// Main header
cur.push16le(0); // 0: Reserved
cur.push16le(2); // 2: .CUR type
cur.push16le(1); // 4: Number of images, 1 for non-animated ico
// Cursor #1 header (ICONDIRENTRY)
cur.push(w); // 6: width
cur.push(h); // 7: height
cur.push(0); // 8: colors, 0 -> true-color
cur.push(0); // 9: reserved
cur.push16le(hotx); // 10: hotspot x coordinate
cur.push16le(hoty); // 12: hotspot y coordinate
cur.push32le(IHDRsz + RGBsz + XORsz + ANDsz);
// 14: cursor data byte size
cur.push32le(22); // 18: offset of cursor data in the file
// Cursor #1 InfoHeader (ICONIMAGE/BITMAPINFO)
cur.push32le(IHDRsz); // 22: Infoheader size
cur.push32le(w); // 26: Cursor width
cur.push32le(h*2); // 30: XOR+AND height
cur.push16le(1); // 34: number of planes
cur.push16le(32); // 36: bits per pixel
cur.push32le(0); // 38: Type of compression
cur.push32le(XORsz + ANDsz); // 43: Size of Image
// Gimp leaves this as 0
cur.push32le(0); // 46: reserved
cur.push32le(0); // 50: reserved
cur.push32le(0); // 54: reserved
cur.push32le(0); // 58: reserved
// 62: color data (RGBQUAD icColors[])
for (y = h-1; y >= 0; y -= 1) {
for (x = 0; x < w; x += 1) {
idx = y * Math.ceil(w / 8) + Math.floor(x/8);
alpha = (mask[idx] << (x % 8)) & 0x80 ? 255 : 0;
if (cmap) {
idx = (w * y) + x;
rgb = cmap[pixels[idx]];
cur.push(rgb[2]); // blue
cur.push(rgb[1]); // green
cur.push(rgb[0]); // red
cur.push(alpha); // alpha
} else {
idx = ((w * y) + x) * 4;
cur.push(pixels[idx + 2]); // blue
cur.push(pixels[idx + 1]); // green
cur.push(pixels[idx ]); // red
cur.push(alpha); // alpha
}
}
}
// XOR/bitmask data (BYTE icXOR[])
// (ignored, just needs to be right size)
for (y = 0; y < h; y += 1) {
for (x = 0; x < Math.ceil(w / 8); x += 1) {
cur.push(0x00);
}
}
// AND/bitmask data (BYTE icAND[])
// (ignored, just needs to be right size)
for (y = 0; y < h; y += 1) {
for (x = 0; x < Math.ceil(w / 8); x += 1) {
cur.push(0x00);
}
}
url = "data:image/x-icon;base64," + Base64.encode(cur);
target.style.cursor = "url(" + url + ") " + hotx + " " + hoty + ", default";
//Util.Debug("<< changeCursor, cur.length: " + cur.length);
}