From 91551e4e232cf8417c4c6bedf0d60135ef7d0b7c Mon Sep 17 00:00:00 2001 From: Michael Jumper Date: Mon, 10 Mar 2014 09:53:33 -0700 Subject: [PATCH] GUAC-324: Add pinch and pan gesture objects. Switch to Guacamole.Touchscreen mouse emulation object. --- .../src/main/webapp/scripts/client-ui.js | 793 +++++++----------- 1 file changed, 298 insertions(+), 495 deletions(-) diff --git a/guacamole/src/main/webapp/scripts/client-ui.js b/guacamole/src/main/webapp/scripts/client-ui.js index 604abfb82..97f95015a 100644 --- a/guacamole/src/main/webapp/scripts/client-ui.js +++ b/guacamole/src/main/webapp/scripts/client-ui.js @@ -38,28 +38,7 @@ GuacUI.Client = { /** * Same as INTERACTIVE except with visible on-screen keyboard. */ - "OSK" : 1, - - /** - * No on-screen keyboard, but a visible magnifier. - */ - "MAGNIFIER" : 2, - - /** - * Arrows and a draggable view. - */ - "PAN" : 3, - - /** - * Same as PAN, but with visible native OSK. - */ - "PAN_TYPING" : 4, - - /** - * Precursor to PAN_TYPING, like PAN, except does not pan the - * screen, but rather hints at how to start typing. - */ - "WAIT_TYPING" : 5 + "OSK" : 1 }, @@ -234,8 +213,6 @@ GuacUI.Client = { /* Constants */ - "LONG_PRESS_DETECT_TIMEOUT" : 800, /* milliseconds */ - "LONG_PRESS_MOVEMENT_THRESHOLD" : 10, /* pixels */ "KEYBOARD_AUTO_RESIZE_INTERVAL" : 30, /* milliseconds */ "RECONNECT_PERIOD" : 15, /* seconds */ @@ -252,6 +229,9 @@ GuacUI.Client = { "expected_input_width" : 1, "expected_input_height" : 1, + "min_zoom" : 0, + "max_zoom" : 3, + "connectionName" : "Guacamole", "overrideAutoFit" : false, "attachedClient" : null, @@ -260,365 +240,6 @@ GuacUI.Client = { }; -/** - * Component which displays a magnified (100% zoomed) client display. - * - * @constructor - * @augments GuacUI.DraggableComponent - */ -GuacUI.Client.Magnifier = function() { - - /** - * Reference to this magnifier. - * @private - */ - var guac_magnifier = this; - - /** - * Large background div which will block touch events from reaching the - * client while also providing a click target to deactivate the - * magnifier. - * @private - */ - var magnifier_background = GuacUI.createElement("div", "magnifier-background"); - - /** - * Container div for the magnifier, providing a clipping rectangle. - * @private - */ - var magnifier = GuacUI.createChildElement(magnifier_background, - "div", "magnifier"); - - /** - * Canvas which will contain the static image copy of the display at time - * of show. - * @private - */ - var magnifier_display = GuacUI.createChildElement(magnifier, "canvas"); - - /** - * Context of magnifier display. - * @private - */ - var magnifier_context = magnifier_display.getContext("2d"); - - /* - * This component is draggable. - */ - GuacUI.DraggableComponent.apply(this, [magnifier]); - - // Ensure transformations on display originate at 0,0 - magnifier.style.transformOrigin = - magnifier.style.webkitTransformOrigin = - magnifier.style.MozTransformOrigin = - magnifier.style.OTransformOrigin = - magnifier.style.msTransformOrigin = - "0 0"; - - /* - * Reposition magnifier display relative to own position on screen. - */ - - this.onmove = function(x, y) { - - var width = magnifier.offsetWidth; - var height = magnifier.offsetHeight; - - // Update contents relative to new position - var clip_x = x - / (window.innerWidth - width) * (GuacUI.Client.attachedClient.getWidth() - width); - var clip_y = y - / (window.innerHeight - height) * (GuacUI.Client.attachedClient.getHeight() - height); - - magnifier_display.style.WebkitTransform = - magnifier_display.style.MozTransform = - magnifier_display.style.OTransform = - magnifier_display.style.msTransform = - magnifier_display.style.transform = "translate(" - + (-clip_x) + "px, " + (-clip_y) + "px)"; - - /* Update expected input rectangle */ - GuacUI.Client.expected_input_x = clip_x; - GuacUI.Client.expected_input_y = clip_y; - GuacUI.Client.expected_input_width = width; - GuacUI.Client.expected_input_height = height; - - }; - - /* - * Copy display and add self to body on show. - */ - - this.show = function() { - - // Copy displayed image - magnifier_display.width = GuacUI.Client.attachedClient.getWidth(); - magnifier_display.height = GuacUI.Client.attachedClient.getHeight(); - magnifier_context.drawImage(GuacUI.Client.attachedClient.flatten(), 0, 0); - - // Show magnifier container - document.body.appendChild(magnifier_background); - - }; - - /* - * Remove self from body on hide. - */ - - this.hide = function() { - - // Hide magnifier container - document.body.removeChild(magnifier_background); - - }; - - /* - * If the user clicks on the background, switch to INTERACTIVE mode. - */ - - magnifier_background.addEventListener("click", function() { - GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE); - }, true); - - /* - * If the user clicks on the magnifier, switch to PAN_TYPING mode. - */ - - magnifier.addEventListener("click", function(e) { - GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING); - e.stopPropagation(); - }, true); - -}; - -/* - * We inherit from GuacUI.DraggableComponent. - */ -GuacUI.Client.Magnifier.prototype = new GuacUI.DraggableComponent(); - -GuacUI.StateManager.registerComponent( - new GuacUI.Client.Magnifier(), - GuacUI.Client.states.MAGNIFIER -); - -/** - * Zoomed Display, a pseudo-component. - * - * @constructor - * @augments GuacUI.Component - */ -GuacUI.Client.ZoomedDisplay = function() { - - this.show = function() { - GuacUI.Client.overrideAutoFit = true; - GuacUI.Client.updateDisplayScale(); - }; - - this.hide = function() { - GuacUI.Client.overrideAutoFit = false; - GuacUI.Client.updateDisplayScale(); - }; - -}; - -GuacUI.Client.ZoomedDisplay.prototype = new GuacUI.Component(); - -/* - * Zoom the main display during PAN and PAN_TYPING modes. - */ - -GuacUI.StateManager.registerComponent( - new GuacUI.Client.ZoomedDisplay(), - GuacUI.Client.states.PAN, - GuacUI.Client.states.PAN_TYPING -); - -/** - * Type overlay UI. This component functions to provide a means of activating - * the keyboard, when neither panning nor magnification make sense. - * - * @constructor - * @augments GuacUI.Component - */ -GuacUI.Client.TypeOverlay = function() { - - /** - * Overlay which will provide the means of scrolling the screen. - */ - var type_overlay = GuacUI.createElement("div", "type-overlay"); - - /* - * Add exit button - */ - - var start = GuacUI.createChildElement(type_overlay, "p", "hint"); - start.textContent = "Tap here to type, or tap the screen to cancel."; - - // Begin typing when user clicks hint - start.addEventListener("click", function(e) { - GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING); - e.stopPropagation(); - }, false); - - this.show = function() { - document.body.appendChild(type_overlay); - }; - - this.hide = function() { - document.body.removeChild(type_overlay); - }; - - /* - * Cancel when user taps screen - */ - - type_overlay.addEventListener("click", function(e) { - GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE); - e.stopPropagation(); - }, false); - -}; - -GuacUI.Client.TypeOverlay.prototype = new GuacUI.Component(); - -/* - * Show the type overlay during WAIT_TYPING mode only - */ - -GuacUI.StateManager.registerComponent( - new GuacUI.Client.TypeOverlay(), - GuacUI.Client.states.WAIT_TYPING -); - -/** - * Pan overlay UI. This component functions to receive touch events and - * translate them into scrolling of the main UI. - * - * @constructor - * @augments GuacUI.Component - */ -GuacUI.Client.PanOverlay = function() { - - /** - * Overlay which will provide the means of scrolling the screen. - */ - var pan_overlay = GuacUI.createElement("div", "pan-overlay"); - - /* - * Add arrows - */ - - GuacUI.createChildElement(pan_overlay, "div", "indicator up"); - GuacUI.createChildElement(pan_overlay, "div", "indicator down"); - GuacUI.createChildElement(pan_overlay, "div", "indicator right"); - GuacUI.createChildElement(pan_overlay, "div", "indicator left"); - - /* - * Add exit button - */ - - var back = GuacUI.createChildElement(pan_overlay, "p", "hint"); - back.textContent = "Tap here to exit panning mode"; - - // Return to interactive when back is clicked - back.addEventListener("click", function() { - GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE); - }, false); - - this.show = function() { - document.body.appendChild(pan_overlay); - }; - - this.hide = function() { - document.body.removeChild(pan_overlay); - }; - - /* - * Transition to PAN_TYPING when the user taps on the overlay. - */ - - pan_overlay.addEventListener("click", function(e) { - GuacUI.StateManager.setState(GuacUI.Client.states.PAN_TYPING); - e.stopPropagation(); - }, true); - -}; - -GuacUI.Client.PanOverlay.prototype = new GuacUI.Component(); - -/* - * Show the pan overlay during PAN or PAN_TYPING modes. - */ - -GuacUI.StateManager.registerComponent( - new GuacUI.Client.PanOverlay(), - GuacUI.Client.states.PAN, - GuacUI.Client.states.PAN_TYPING -); - -/** - * Native Keyboard. This component uses a hidden textarea field to show the - * platforms native on-screen keyboard (if any) or otherwise enable typing, - * should the platform require a text field with focus for keyboard events to - * register. - * - * @constructor - * @augments GuacUI.Component - */ -GuacUI.Client.NativeKeyboard = function() { - - /** - * Event target. This is a hidden textarea element which will receive - * key events. - * @private - */ - var eventTarget = GuacUI.createElement("textarea", "event-target"); - eventTarget.setAttribute("autocorrect", "off"); - eventTarget.setAttribute("autocapitalize", "off"); - - this.show = function() { - - // Move to location of expected input - eventTarget.style.left = GuacUI.Client.expected_input_x + "px"; - eventTarget.style.top = GuacUI.Client.expected_input_y + "px"; - eventTarget.style.width = GuacUI.Client.expected_input_width + "px"; - eventTarget.style.height = GuacUI.Client.expected_input_height + "px"; - - // Show and focus target - document.body.appendChild(eventTarget); - eventTarget.focus(); - - }; - - this.hide = function() { - - // Hide and blur target - eventTarget.blur(); - document.body.removeChild(eventTarget); - - }; - - /* - * Automatically switch to INTERACTIVE mode after target loses focus - */ - - eventTarget.addEventListener("blur", function() { - GuacUI.StateManager.setState(GuacUI.Client.states.INTERACTIVE); - }, false); - -}; - -GuacUI.Client.NativeKeyboard.prototype = new GuacUI.Component(); - -/* - * Show native keyboard during PAN_TYPING mode only. - */ - -GuacUI.StateManager.registerComponent( - new GuacUI.Client.NativeKeyboard(), - GuacUI.Client.states.PAN_TYPING -); - /** * On-screen Keyboard. This component provides a clickable/touchable keyboard * which sends key events to the Guacamole client. @@ -795,6 +416,250 @@ GuacUI.Client.ModalStatus = function(title_text, text, classname, reconnect) { GuacUI.Client.ModalStatus.prototype = new GuacUI.Component(); +/** + * Monitors a given element for touch events, firing pan-specific events + * based on pre-defined gestures. + * + * @constructor + * @param {Element} element The element to monitor for touch events. + */ +GuacUI.Client.Pan = function(element) { + + /** + * Reference to this pan instance. + * @private + */ + var guac_pan = this; + + /** + * The starting X location of the pan gesture. + * @private + */ + var start_x = null; + + /** + * The starting Y location of the pan gesture. + * @private + */ + var start_y = null; + + /** + * The change in X relative to pan start. + */ + this.delta_x = 0; + + /** + * The change in X relative to pan start. + */ + this.delta_y = 0; + + /** + * Called when a pan gesture begins. + * + * @event + * @param {Number} x The relative change in X location relative to + * pan start. For pan start, this will ALWAYS be 0. + * @param {Number} y The relative change in Y location relative to + * pan start. For pan start, this will ALWAYS be 0. + */ + this.onpanstart = null; + + /** + * Called when the pan amount changes. + * + * @event + * @param {Number} x The relative change in X location relative to + * pan start. + * @param {Number} y The relative change in Y location relative to + * pan start. + */ + this.onpanchange = null; + + /** + * Called when a pan gesture ends. + * + * @event + * @param {Number} x The relative change in X location relative to + * pan start. + * @param {Number} y The relative change in Y location relative to + * pan start. + */ + this.onpanend = null; + + // When there is exactly one touch, monitor the change in location + element.addEventListener("touchmove", function(e) { + if (e.touches.length === 1) { + + e.preventDefault(); + e.stopPropagation(); + + // Get touch location + var x = e.touches[0].clientX; + var y = e.touches[0].clientY; + + // If gesture just starting, fire zoom start + if (!start_x || !start_y) { + start_x = x; + start_y = y; + guac_pan.delta_x = 0; + guac_pan.delta_y = 0; + if (guac_pan.onpanstart) + guac_pan.onpanstart(guac_pan.delta_x, guac_pan.delta_y); + } + + // Otherwise, notify of zoom change + else if (guac_pan.onpanchange) { + guac_pan.delta_x = x - start_x; + guac_pan.delta_y = y - start_y; + guac_pan.onpanchange(guac_pan.delta_x, guac_pan.delta_y); + } + + } + }, false); + + // Reset monitoring and fire end event when done + element.addEventListener("touchend", function(e) { + + if (start_x && start_y && e.touches.length === 0) { + + e.preventDefault(); + e.stopPropagation(); + + if (guac_pan.onpanend) + guac_pan.onpanend(); + + start_x = null; + start_y = null; + guac_pan.delta_x = 0; + guac_pan.delta_y = 0; + + } + + }, false); + +}; + +/** + * Monitors a given element for touch events, firing zoom-specific events + * based on pre-defined gestures. + * + * @constructor + * @param {Element} element The element to monitor for touch events. + */ +GuacUI.Client.Pinch = function(element) { + + /** + * Reference to this zoom instance. + * @private + */ + var guac_zoom = this; + + /** + * The current pinch distance, or null if the gesture has not yet started. + * @private + */ + var start_length = null; + + /** + * The current zoom ratio. + * @type Number + */ + this.ratio = 1; + + /** + * Called when a zoom gesture begins. + * + * @event + * @param {Number} ratio The relative value of the starting zoom. This will + * ALWAYS be 1. + */ + this.onzoomstart = null; + + /** + * Called when the amount of zoom changes. + * + * @event + * @param {Number} ratio The relative value of the changed zoom, with 1 + * being no change. + */ + this.onzoomchange = null; + + /** + * Called when a zoom gesture ends. + * + * @event + * @param {Number} ratio The relative value of the final zoom, with 1 + * being no change. + */ + this.onzoomend = null; + + /** + * Given a touch event, calculates the distance between the first two + * touches in pixels. + * + * @param {TouchEvent} e The touch event to use when performing distance + * calculation. + * @return {Number} The distance in pixels between the first two touches. + */ + function pinch_distance(e) { + + var touch_a = e.touches[0]; + var touch_b = e.touches[1]; + + var delta_x = touch_a.clientX - touch_b.clientX; + var delta_y = touch_a.clientY - touch_b.clientY; + + return Math.sqrt(delta_x*delta_x + delta_y*delta_y); + + } + + // When there are exactly two touches, monitor the distance between + // them, firing zoom events as appropriate + element.addEventListener("touchmove", function(e) { + if (e.touches.length === 2) { + + e.preventDefault(); + e.stopPropagation(); + + // Calculate current zoom level + var current = pinch_distance(e); + + // If gesture just starting, fire zoom start + if (!start_length) { + start_length = current; + guac_zoom.ratio = 1; + if (guac_zoom.onzoomstart) + guac_zoom.onzoomstart(guac_zoom.ratio); + } + + // Otherwise, notify of zoom change + else { + guac_zoom.ratio = current / start_length; + if (guac_zoom.onzoomchange) + guac_zoom.onzoomchange(guac_zoom.ratio); + } + + } + }, false); + + // Reset monitoring and fire end event when done + element.addEventListener("touchend", function(e) { + + if (start_length && e.touches.length < 2) { + + e.preventDefault(); + e.stopPropagation(); + + start_length = null; + if (guac_zoom.onzoomend) + guac_zoom.onzoomend(guac_zoom.ratio); + guac_zoom.ratio = 1; + } + + }, false); + +}; + /** * Flattens the attached Guacamole.Client, storing the result within the * connection history. @@ -839,25 +704,14 @@ GuacUI.Client.updateDisplayScale = function() { var guac = GuacUI.Client.attachedClient; var adjusted_scale = 1 / (window.devicePixelRatio || 1); - // If auto-fit is enabled, scale display - if (!GuacUI.Client.overrideAutoFit - && GuacamoleSessionStorage.getItem("auto-fit", true)) { + // Calculate scale to fit screen + GuacUI.Client.min_zoom = Math.min( + window.innerWidth / guac.getWidth(), + window.innerHeight / guac.getHeight() + ); - // Calculate scale to fit screen - var fit_scale = Math.min( - window.innerWidth / guac.getWidth(), - window.innerHeight / guac.getHeight() - ); - - // Scale client - if (guac.getScale() !== fit_scale) - guac.scale(fit_scale); - - } - - // Otherwise, scale to 100% - else if (guac.getScale() !== adjusted_scale) - guac.scale(adjusted_scale); + if (guac.getScale() < GuacUI.Client.min_zoom) + guac.scale(GuacUI.Client.min_zoom); }; @@ -1191,39 +1045,10 @@ GuacUI.Client.attach = function(guac) { // Mouse var mouse = new Guacamole.Mouse(guac_display); - var touch = new Guacamole.Mouse.Touchpad(guac_display); + var touch = new Guacamole.Mouse.Touchscreen(guac_display); touch.onmousedown = touch.onmouseup = touch.onmousemove = mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = function(mouseState) { - - // Determine mouse position within view - var mouse_view_x = mouseState.x + guac_display.offsetLeft - window.pageXOffset; - var mouse_view_y = mouseState.y + guac_display.offsetTop - window.pageYOffset; - - // Determine viewport dimensioins - var view_width = GuacUI.Client.viewport.offsetWidth; - var view_height = GuacUI.Client.viewport.offsetHeight; - - // Determine scroll amounts based on mouse position relative to document - - var scroll_amount_x; - if (mouse_view_x > view_width) - scroll_amount_x = mouse_view_x - view_width; - else if (mouse_view_x < 0) - scroll_amount_x = mouse_view_x; - else - scroll_amount_x = 0; - - var scroll_amount_y; - if (mouse_view_y > view_height) - scroll_amount_y = mouse_view_y - view_height; - else if (mouse_view_y < 0) - scroll_amount_y = mouse_view_y; - else - scroll_amount_y = 0; - - // Scroll (if necessary) to keep mouse on screen. - window.scrollBy(scroll_amount_x, scroll_amount_y); // Scale event by current scale var scaledState = new Guacamole.Mouse.State( @@ -1390,6 +1215,48 @@ GuacUI.Client.attach = function(guac) { }, false); + /* + * Pinch-to-zoom + */ + + var guac_pinch = new GuacUI.Client.Pinch(document.body); + var initial_scale = null; + + guac_pinch.onzoomstart = function() { + initial_scale = guac.getScale(); + }; + + guac_pinch.onzoomchange = function(ratio) { + var new_scale = initial_scale * ratio; + new_scale = Math.max(new_scale, GuacUI.Client.min_zoom); + new_scale = Math.min(new_scale, GuacUI.Client.max_zoom); + guac.scale(new_scale); + }; + + /* + * Touch panning + */ + + var guac_pan = new GuacUI.Client.Pan(document.body); + + var last_pan_dx = 0; + var last_pan_dy = 0; + + guac_pan.onpanstart = function(dx, dy) { + last_pan_dx = dx; + last_pan_dy = dy; + }; + + guac_pan.onpanchange = function(dx, dy) { + if (!touch.currentState.left) { + var change_pan_dx = dx - last_pan_dx; + var change_pan_dy = dy - last_pan_dy; + window.scrollBy(-change_pan_dx, -change_pan_dy); + last_pan_dx = dx; + last_pan_dy = dy; + } + }; + /* * Disconnect and update thumbnail on close */ @@ -1425,70 +1292,6 @@ GuacUI.Client.attach = function(guac) { GuacUI.Client.updateDisplayScale(); }); - var long_press_start_x = 0; - var long_press_start_y = 0; - var longPressTimeout = null; - - GuacUI.Client.startLongPressDetect = function() { - - if (!longPressTimeout) { - - longPressTimeout = window.setTimeout(function() { - longPressTimeout = null; - - // If screen shrunken, show magnifier - if (GuacUI.Client.attachedClient.getScale() < 1.0) - GuacUI.StateManager.setState(GuacUI.Client.states.MAGNIFIER); - - // Otherwise, if screen too big to fit, use panning mode - else if ( - GuacUI.Client.attachedClient.getWidth() > window.innerWidth - || GuacUI.Client.attachedClient.getHeight() > window.innerHeight - ) - GuacUI.StateManager.setState(GuacUI.Client.states.PAN); - - // Otherwise, just show a hint - else - GuacUI.StateManager.setState(GuacUI.Client.states.WAIT_TYPING); - }, GuacUI.Client.LONG_PRESS_DETECT_TIMEOUT); - - } - }; - - GuacUI.Client.stopLongPressDetect = function() { - window.clearTimeout(longPressTimeout); - longPressTimeout = null; - }; - - // Detect long-press at bottom of screen - GuacUI.Client.display.addEventListener('touchstart', function(e) { - - // Record touch location - if (e.touches.length === 1) { - var touch = e.touches[0]; - long_press_start_x = touch.screenX; - long_press_start_y = touch.screenY; - } - - // Start detection - GuacUI.Client.startLongPressDetect(); - - }, true); - - // Stop detection if touch moves significantly - GuacUI.Client.display.addEventListener('touchmove', function(e) { - - // If touch distance from start exceeds threshold, cancel long press - var touch = e.touches[0]; - if (Math.abs(touch.screenX - long_press_start_x) >= GuacUI.Client.LONG_PRESS_MOVEMENT_THRESHOLD - || Math.abs(touch.screenY - long_press_start_y) >= GuacUI.Client.LONG_PRESS_MOVEMENT_THRESHOLD) - GuacUI.Client.stopLongPressDetect(); - - }, true); - - // Stop detection if press stops - GuacUI.Client.display.addEventListener('touchend', GuacUI.Client.stopLongPressDetect, true); - /** * Ignores the given event. *