1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
7 * Provides view port management utilities below for a desktop remoting session.
8 * - Enabling bump scrolling
9 * - Resizing the viewport to fit the host desktop
10 * - Resizing the host desktop to fit the client viewport.
13 /** @suppress {duplicate} */
14 var remoting = remoting || {};
21 * @param {HTMLElement} rootElement The outer element with id=scroller that we
22 * are showing scrollbars on.
23 * @param {remoting.HostDesktop} hostDesktop
24 * @param {remoting.Host.Options} hostOptions
27 * @implements {base.Disposable}
29 remoting.DesktopViewport = function(rootElement, hostDesktop, hostOptions) {
31 this.rootElement_ = rootElement;
33 // TODO(kelvinp): Query the container by class name instead of id.
34 this.pluginContainer_ = rootElement.querySelector('#client-container');
36 this.pluginElement_ = rootElement.querySelector('embed');
38 this.hostDesktop_ = hostDesktop;
40 this.hostOptions_ = hostOptions;
41 /** @private {number?} */
42 this.resizeTimer_ = null;
43 /** @private {remoting.BumpScroller} */
44 this.bumpScroller_ = null;
45 // Bump-scroll test variables. Override to use a fake value for the width
46 // and height of the client plugin so that bump-scrolling can be tested
47 // without relying on the actual size of the host desktop.
48 /** @private {number} */
49 this.pluginWidthForBumpScrollTesting_ = 0;
50 /** @private {number} */
51 this.pluginHeightForBumpScrollTesting_ = 0;
53 this.eventHooks_ = new base.Disposables(
55 this.hostDesktop_, remoting.HostDesktop.Events.sizeChanged,
56 this.onDesktopSizeChanged_.bind(this)),
57 // TODO(kelvinp): Move window shape related logic into
58 // remoting.AppConnectedView.
60 this.hostDesktop_, remoting.HostDesktop.Events.shapeChanged,
61 remoting.windowShape.setDesktopRects.bind(remoting.windowShape)));
63 if (this.hostOptions_.resizeToClient) {
64 this.resizeHostDesktop_();
66 this.onDesktopSizeChanged_();
70 remoting.DesktopViewport.prototype.dispose = function() {
71 base.dispose(this.eventHooks_);
72 this.eventHooks_ = null;
73 base.dispose(this.bumpScroller_);
74 this.bumpScroller_ = null;
78 * @return {boolean} True if shrink-to-fit is enabled; false otherwise.
80 remoting.DesktopViewport.prototype.getShrinkToFit = function() {
81 return this.hostOptions_.shrinkToFit;
85 * @return {boolean} True if resize-to-client is enabled; false otherwise.
87 remoting.DesktopViewport.prototype.getResizeToClient = function() {
88 return this.hostOptions_.resizeToClient;
92 * @return {{top:number, left:number}} The top-left corner of the plugin.
94 remoting.DesktopViewport.prototype.getPluginPositionForTesting = function() {
96 * @param {number|string} value
99 function toFloat(value) {
100 var number = parseFloat(value);
101 return isNaN(number) ? 0 : number;
104 top: toFloat(this.pluginContainer_.style.marginTop),
105 left: toFloat(this.pluginContainer_.style.marginLeft)
110 * @param {number} width
111 * @param {number} height
113 remoting.DesktopViewport.prototype.setPluginSizeForBumpScrollTesting =
114 function(width, height) {
115 this.pluginWidthForBumpScrollTesting_ = width;
116 this.pluginHeightForBumpScrollTesting_ = height;
120 * @return {remoting.BumpScroller}
122 remoting.DesktopViewport.prototype.getBumpScrollerForTesting = function() {
123 return this.bumpScroller_;
127 * Set the shrink-to-fit and resize-to-client flags and save them if this is
128 * a Me2Me connection.
130 * @param {boolean} shrinkToFit True if the remote desktop should be scaled
131 * down if it is larger than the client window; false if scroll-bars
132 * should be added in this case.
133 * @param {boolean} resizeToClient True if window resizes should cause the
134 * host to attempt to resize its desktop to match the client window size;
135 * false to disable this behaviour for subsequent window resizes--the
136 * current host desktop size is not restored in this case.
137 * @return {void} Nothing.
139 remoting.DesktopViewport.prototype.setScreenMode =
140 function(shrinkToFit, resizeToClient) {
141 if (resizeToClient && !this.hostOptions_.resizeToClient) {
142 this.resizeHostDesktop_();
145 // If enabling shrink, reset bump-scroll offsets.
146 var needsScrollReset = shrinkToFit && !this.hostOptions_.shrinkToFit;
147 this.hostOptions_.shrinkToFit = shrinkToFit;
148 this.hostOptions_.resizeToClient = resizeToClient;
149 this.hostOptions_.save();
150 this.updateScrollbarVisibility_();
152 this.updateDimensions_();
153 if (needsScrollReset) {
159 * Scroll the client plugin by the specified amount, keeping it visible.
160 * Note that this is only used in content full-screen mode (not windowed or
161 * browser full-screen modes), where window.scrollBy and the scrollTop and
162 * scrollLeft properties don't work.
164 * @param {number} dx The amount by which to scroll horizontally. Positive to
165 * scroll right; negative to scroll left.
166 * @param {number} dy The amount by which to scroll vertically. Positive to
167 * scroll down; negative to scroll up.
168 * @return {boolean} False if the requested scroll had no effect because both
169 * vertical and horizontal edges of the screen have been reached.
171 remoting.DesktopViewport.prototype.scroll = function(dx, dy) {
173 * Helper function for x- and y-scrolling
174 * @param {number|string} curr The current margin, eg. "10px".
175 * @param {number} delta The requested scroll amount.
176 * @param {number} windowBound The size of the window, in pixels.
177 * @param {number} pluginBound The size of the plugin, in pixels.
178 * @param {{stop: boolean}} stop Reference parameter used to indicate when
179 * the scroll has reached one of the edges and can be stopped in that
181 * @return {string} The new margin value.
183 var adjustMargin = function(curr, delta, windowBound, pluginBound, stop) {
184 var minMargin = Math.min(0, windowBound - pluginBound);
185 var result = (curr ? parseFloat(curr) : 0) - delta;
186 result = Math.min(0, Math.max(minMargin, result));
187 stop.stop = (result === 0 || result == minMargin);
188 return result + 'px';
191 var style = this.pluginContainer_.style;
194 this.pluginWidthForBumpScrollTesting_ || this.pluginElement_.clientWidth;
195 var pluginHeight = this.pluginHeightForBumpScrollTesting_ ||
196 this.pluginElement_.clientHeight;
198 var clientArea = this.getClientArea();
199 var stopX = { stop: false };
201 adjustMargin(style.marginLeft, dx, clientArea.width, pluginWidth, stopX);
203 var stopY = { stop: false };
205 adjustMargin(style.marginTop, dy, clientArea.height, pluginHeight, stopY);
206 return !stopX.stop || !stopY.stop;
210 * Enable or disable bump-scrolling. When disabling bump scrolling, also reset
211 * the scroll offsets to (0, 0).
212 * @param {boolean} enable True to enable bump-scrolling, false to disable it.
214 remoting.DesktopViewport.prototype.enableBumpScroll = function(enable) {
216 this.bumpScroller_ = new remoting.BumpScroller(this);
218 base.dispose(this.bumpScroller_);
219 this.bumpScroller_ = null;
225 * This is a callback that gets called when the window is resized.
227 * @return {void} Nothing.
229 remoting.DesktopViewport.prototype.onResize = function() {
230 this.updateDimensions_();
232 if (this.resizeTimer_) {
233 window.clearTimeout(this.resizeTimer_);
234 this.resizeTimer_ = null;
237 // Defer notifying the host of the change until the window stops resizing, to
238 // avoid overloading the control channel with notifications.
239 if (this.hostOptions_.resizeToClient) {
240 var kResizeRateLimitMs = 250;
241 var clientArea = this.getClientArea();
242 this.resizeTimer_ = window.setTimeout(this.resizeHostDesktop_.bind(this),
246 // If bump-scrolling is enabled, adjust the plugin margins to fully utilize
247 // the new window area.
249 this.updateScrollbarVisibility_();
253 * @return {{width:number, height:number}} The height of the window's client
254 * area. This differs between apps v1 and apps v2 due to the custom window
255 * borders used by the latter.
257 remoting.DesktopViewport.prototype.getClientArea = function() {
258 return remoting.windowFrame ?
259 remoting.windowFrame.getClientArea() :
260 { 'width': window.innerWidth, 'height': window.innerHeight };
264 * Notifies the host of the client's current dimensions and DPI.
265 * Also takes into account per-host scaling factor, if configured.
268 remoting.DesktopViewport.prototype.resizeHostDesktop_ = function() {
269 var clientArea = this.getClientArea();
270 this.hostDesktop_.resize(clientArea.width * this.hostOptions_.desktopScale,
271 clientArea.height * this.hostOptions_.desktopScale,
272 window.devicePixelRatio);
276 * This is a callback that gets called when the plugin notifies us of a change
277 * in the size of the remote desktop.
279 * @return {void} Nothing.
282 remoting.DesktopViewport.prototype.onDesktopSizeChanged_ = function() {
283 var dimensions = this.hostDesktop_.getDimensions();
284 console.log('desktop size changed: ' +
285 dimensions.width + 'x' +
286 dimensions.height +' @ ' +
287 dimensions.xDpi + 'x' +
288 dimensions.yDpi + ' DPI');
289 this.updateDimensions_();
290 this.updateScrollbarVisibility_();
294 * Called when the window or desktop size or the scaling settings change,
295 * to set the scroll-bar visibility.
297 * TODO(jamiewalch): crbug.com/252796: Remove this once crbug.com/240772 is
300 remoting.DesktopViewport.prototype.updateScrollbarVisibility_ = function() {
301 // TODO(kelvinp): Remove the check once app-remoting no longer depends on
303 if (!this.rootElement_) {
307 var needsScrollY = false;
308 var needsScrollX = false;
309 if (!this.hostOptions_.shrinkToFit) {
310 // Determine whether or not horizontal or vertical scrollbars are
311 // required, taking into account their width.
312 var clientArea = this.getClientArea();
313 var hostDesktop = this.hostDesktop_.getDimensions();
314 needsScrollY = clientArea.height < hostDesktop.height;
315 needsScrollX = clientArea.width < hostDesktop.width;
316 var kScrollBarWidth = 16;
317 if (needsScrollX && !needsScrollY) {
318 needsScrollY = clientArea.height - kScrollBarWidth < hostDesktop.height;
319 } else if (!needsScrollX && needsScrollY) {
320 needsScrollX = clientArea.width - kScrollBarWidth < hostDesktop.width;
324 this.rootElement_.classList.toggle('no-horizontal-scroll', !needsScrollX);
325 this.rootElement_.classList.toggle('no-vertical-scroll', !needsScrollY);
328 remoting.DesktopViewport.prototype.updateDimensions_ = function() {
329 var dimensions = this.hostDesktop_.getDimensions();
330 if (dimensions.width === 0 || dimensions.height === 0) {
334 var desktopSize = { width: dimensions.width,
335 height: dimensions.height };
336 var desktopDpi = { x: dimensions.xDpi,
337 y: dimensions.yDpi };
338 var newSize = remoting.DesktopViewport.choosePluginSize(
339 this.getClientArea(), window.devicePixelRatio,
340 desktopSize, desktopDpi, this.hostOptions_.desktopScale,
341 remoting.fullscreen.isActive(), this.hostOptions_.shrinkToFit);
343 // Resize the plugin if necessary.
344 console.log('plugin dimensions:' + newSize.width + 'x' + newSize.height);
345 this.pluginElement_.style.width = newSize.width + 'px';
346 this.pluginElement_.style.height = newSize.height + 'px';
348 // When we receive the first plugin dimensions from the host, we know that
349 // remote host has started.
350 remoting.app.onVideoStreamingStarted();
354 * Helper function accepting client and host dimensions, and returning a chosen
355 * size for the plugin element, in DIPs.
357 * @param {{width: number, height: number}} clientSizeDips Available client
358 * dimensions, in DIPs.
359 * @param {number} clientPixelRatio Number of physical pixels per client DIP.
360 * @param {{width: number, height: number}} desktopSize Size of the host desktop
361 * in physical pixels.
362 * @param {{x: number, y: number}} desktopDpi DPI of the host desktop in both
364 * @param {number} desktopScale The scale factor configured for the host.
365 * @param {boolean} isFullscreen True if full-screen mode is active.
366 * @param {boolean} shrinkToFit True if shrink-to-fit should be applied.
367 * @return {{width: number, height: number}} Chosen plugin dimensions, in DIPs.
369 remoting.DesktopViewport.choosePluginSize = function(
370 clientSizeDips, clientPixelRatio, desktopSize, desktopDpi, desktopScale,
371 isFullscreen, shrinkToFit) {
372 base.debug.assert(clientSizeDips.width > 0);
373 base.debug.assert(clientSizeDips.height > 0);
374 base.debug.assert(clientPixelRatio >= 1.0);
375 base.debug.assert(desktopSize.width > 0);
376 base.debug.assert(desktopSize.height > 0);
377 base.debug.assert(desktopDpi.x > 0);
378 base.debug.assert(desktopDpi.y > 0);
379 base.debug.assert(desktopScale > 0);
381 // We have the following goals in sizing the desktop display at the client:
382 // 1. Avoid losing detail by down-scaling beyond 1:1 host:device pixels.
383 // 2. Avoid up-scaling if that will cause the client to need scrollbars.
384 // 3. Avoid introducing blurriness with non-integer up-scaling factors.
385 // 4. Avoid having huge "letterboxes" around the desktop, if it's really
387 // 5. Compensate for mismatched DPIs, so that the behaviour of features like
388 // shrink-to-fit matches their "natural" rather than their pixel size.
389 // e.g. with shrink-to-fit active a 1024x768 low-DPI host on a 640x480
390 // high-DPI client will be up-scaled to 1280x960, rather than displayed
391 // at 1:1 host:physical client pixels.
393 // To determine the ideal size we follow a four-stage process:
394 // 1. Determine the "natural" size at which to display the desktop.
395 // a. Initially assume 1:1 mapping of desktop to client device pixels.
396 // b. If host DPI is less than the client's then up-scale accordingly.
397 // c. If desktopScale is configured for the host then allow that to
398 // reduce the amount of up-scaling from (b). e.g. if the client:host
399 // DPIs are 2:1 then a desktopScale of 1.5 would reduce the up-scale
400 // to 4:3, while a desktopScale of 3.0 would result in no up-scaling.
401 // 2. If the natural size of the desktop is smaller than the client device
402 // then apply up-scaling by an integer scale factor to avoid excessive
404 // 3. If shrink-to-fit is configured then:
405 // a. If the natural size exceeds the client size then apply down-scaling
406 // by an arbitrary scale factor.
407 // b. If we're in full-screen mode and the client & host aspect-ratios
408 // are radically different (e.g. the host is actually multi-monitor)
409 // then shrink-to-fit to the shorter dimension, rather than leaving
410 // huge letterboxes; the user can then bump-scroll around the desktop.
411 // 4. If the overall scale factor is fractionally over an integer factor
412 // then reduce it to that integer factor, to avoid blurring.
414 // All calculations are performed in device pixels.
415 var clientWidth = clientSizeDips.width * clientPixelRatio;
416 var clientHeight = clientSizeDips.height * clientPixelRatio;
418 // 1. Determine a "natural" size at which to display the desktop.
421 // Determine the effective host device pixel ratio.
422 // Note that we round up or down to the closest integer pixel ratio.
423 var hostPixelRatioX = Math.round(desktopDpi.x / 96);
424 var hostPixelRatioY = Math.round(desktopDpi.y / 96);
425 var hostPixelRatio = Math.min(hostPixelRatioX, hostPixelRatioY);
427 // Allow up-scaling to account for DPI.
428 scale = Math.max(scale, clientPixelRatio / hostPixelRatio);
430 // Allow some or all of the up-scaling to be cancelled by the desktopScale.
431 if (desktopScale > 1.0) {
432 scale = Math.max(1.0, scale / desktopScale);
435 // 2. If the host is still much smaller than the client, then up-scale to
436 // avoid wasting space, but only by an integer factor, to avoid blurring.
437 if (desktopSize.width * scale <= clientWidth &&
438 desktopSize.height * scale <= clientHeight) {
439 var scaleX = Math.floor(clientWidth / desktopSize.width);
440 var scaleY = Math.floor(clientHeight / desktopSize.height);
441 scale = Math.min(scaleX, scaleY);
442 base.debug.assert(scale >= 1.0);
445 // 3. Apply shrink-to-fit, if configured.
447 var scaleFitWidth = Math.min(scale, clientWidth / desktopSize.width);
448 var scaleFitHeight = Math.min(scale, clientHeight / desktopSize.height);
449 scale = Math.min(scaleFitHeight, scaleFitWidth);
451 // If we're running full-screen then try to handle common side-by-side
452 // multi-monitor combinations more intelligently.
454 // If the host has two monitors each the same size as the client then
455 // scale-to-fit will have the desktop occupy only 50% of the client area,
456 // in which case it would be preferable to down-scale less and let the
457 // user bump-scroll around ("scale-and-pan").
458 // Triggering scale-and-pan if less than 65% of the client area would be
459 // used adds enough fuzz to cope with e.g. 1280x800 client connecting to
460 // a (2x1280)x1024 host nicely.
461 // Note that we don't need to account for scrollbars while fullscreen.
462 if (scale <= scaleFitHeight * 0.65) {
463 scale = scaleFitHeight;
465 if (scale <= scaleFitWidth * 0.65) {
466 scale = scaleFitWidth;
471 // 4. Avoid blurring for close-to-integer up-scaling factors.
473 var scaleBlurriness = scale / Math.floor(scale);
474 if (scaleBlurriness < 1.1) {
475 scale = Math.floor(scale);
479 // Return the necessary plugin dimensions in DIPs.
480 scale = scale / clientPixelRatio;
481 var pluginWidth = Math.round(desktopSize.width * scale);
482 var pluginHeight = Math.round(desktopSize.height * scale);
483 return { width: pluginWidth, height: pluginHeight };
487 remoting.DesktopViewport.prototype.resetScroll_ = function() {
488 this.pluginContainer_.style.marginTop = '0px';
489 this.pluginContainer_.style.marginLeft = '0px';