diff options
Diffstat (limited to 'devtools/shared/touch')
-rw-r--r-- | devtools/shared/touch/moz.build | 11 | ||||
-rw-r--r-- | devtools/shared/touch/simulator-content.js | 43 | ||||
-rw-r--r-- | devtools/shared/touch/simulator-core.js | 366 | ||||
-rw-r--r-- | devtools/shared/touch/simulator.js | 77 |
4 files changed, 497 insertions, 0 deletions
diff --git a/devtools/shared/touch/moz.build b/devtools/shared/touch/moz.build new file mode 100644 index 000000000..96fbac8eb --- /dev/null +++ b/devtools/shared/touch/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + 'simulator-content.js', + 'simulator-core.js', + 'simulator.js', +) diff --git a/devtools/shared/touch/simulator-content.js b/devtools/shared/touch/simulator-content.js new file mode 100644 index 000000000..0b10579ca --- /dev/null +++ b/devtools/shared/touch/simulator-content.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + /* globals addMessageListener, sendAsyncMessage */ +"use strict"; + +const { utils: Cu } = Components; +const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { SimulatorCore } = require("devtools/shared/touch/simulator-core"); + +/** + * Launches SimulatorCore in the content window to simulate touch events + * This frame script is managed by `simulator.js`. + */ + +var simulator = { + messages: [ + "TouchEventSimulator:Start", + "TouchEventSimulator:Stop", + ], + + init() { + this.simulatorCore = new SimulatorCore(docShell.chromeEventHandler); + this.messages.forEach(msgName => { + addMessageListener(msgName, this); + }); + }, + + receiveMessage(msg) { + switch (msg.name) { + case "TouchEventSimulator:Start": + this.simulatorCore.start(); + sendAsyncMessage("TouchEventSimulator:Started"); + break; + case "TouchEventSimulator:Stop": + this.simulatorCore.stop(); + sendAsyncMessage("TouchEventSimulator:Stopped"); + break; + } + }, +}; + +simulator.init(); diff --git a/devtools/shared/touch/simulator-core.js b/devtools/shared/touch/simulator-core.js new file mode 100644 index 000000000..6933f9207 --- /dev/null +++ b/devtools/shared/touch/simulator-core.js @@ -0,0 +1,366 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Ci, Cu } = require("chrome"); +const { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + +var systemAppOrigin = (function () { + let systemOrigin = "_"; + try { + systemOrigin = Services.io.newURI( + Services.prefs.getCharPref("b2g.system_manifest_url"), null, null) + .prePath; + } catch (e) { + // Fall back to default value + } + return systemOrigin; +})(); + +var threshold = 25; +try { + threshold = Services.prefs.getIntPref("ui.dragThresholdX"); +} catch (e) { + // Fall back to default value +} + +var delay = 500; +try { + delay = Services.prefs.getIntPref("ui.click_hold_context_menus.delay"); +} catch (e) { + // Fall back to default value +} + +function SimulatorCore(simulatorTarget) { + this.simulatorTarget = simulatorTarget; +} + +/** + * Simulate touch events for platforms where they aren't generally available. + */ +SimulatorCore.prototype = { + events: [ + "mousedown", + "mousemove", + "mouseup", + "touchstart", + "touchend", + "mouseenter", + "mouseover", + "mouseout", + "mouseleave" + ], + + contextMenuTimeout: null, + + simulatorTarget: null, + + enabled: false, + + start() { + if (this.enabled) { + // Simulator is already started + return; + } + this.events.forEach(evt => { + // Only listen trusted events to prevent messing with + // event dispatched manually within content documents + this.simulatorTarget.addEventListener(evt, this, true, false); + }); + this.enabled = true; + }, + + stop() { + if (!this.enabled) { + // Simulator isn't running + return; + } + this.events.forEach(evt => { + this.simulatorTarget.removeEventListener(evt, this, true); + }); + this.enabled = false; + }, + + handleEvent(evt) { + // The gaia system window use an hybrid system even on the device which is + // a mix of mouse/touch events. So let's not cancel *all* mouse events + // if it is the current target. + let content = this.getContent(evt.target); + if (!content) { + return; + } + let isSystemWindow = content.location.toString() + .startsWith(systemAppOrigin); + + // App touchstart & touchend should also be dispatched on the system app + // to match on-device behavior. + if (evt.type.startsWith("touch") && !isSystemWindow) { + let sysFrame = content.realFrameElement; + if (!sysFrame) { + return; + } + let sysDocument = sysFrame.ownerDocument; + let sysWindow = sysDocument.defaultView; + + let touchEvent = sysDocument.createEvent("touchevent"); + let touch = evt.touches[0] || evt.changedTouches[0]; + let point = sysDocument.createTouch(sysWindow, sysFrame, 0, + touch.pageX, touch.pageY, + touch.screenX, touch.screenY, + touch.clientX, touch.clientY, + 1, 1, 0, 0); + + let touches = sysDocument.createTouchList(point); + let targetTouches = touches; + let changedTouches = touches; + touchEvent.initTouchEvent(evt.type, true, true, sysWindow, 0, + false, false, false, false, + touches, targetTouches, changedTouches); + sysFrame.dispatchEvent(touchEvent); + return; + } + + // Ignore all but real mouse event coming from physical mouse + // (especially ignore mouse event being dispatched from a touch event) + if (evt.button || + evt.mozInputSource != Ci.nsIDOMMouseEvent.MOZ_SOURCE_MOUSE || + evt.isSynthesized) { + return; + } + + let eventTarget = this.target; + let type = ""; + switch (evt.type) { + case "mouseenter": + case "mouseover": + case "mouseout": + case "mouseleave": + // Don't propagate events which are not related to touch events + evt.stopPropagation(); + break; + + case "mousedown": + this.target = evt.target; + + this.contextMenuTimeout = this.sendContextMenu(evt); + + this.cancelClick = false; + this.startX = evt.pageX; + this.startY = evt.pageY; + + // Capture events so if a different window show up the events + // won't be dispatched to something else. + evt.target.setCapture(false); + + type = "touchstart"; + break; + + case "mousemove": + if (!eventTarget) { + // Don't propagate mousemove event when touchstart event isn't fired + evt.stopPropagation(); + return; + } + + if (!this.cancelClick) { + if (Math.abs(this.startX - evt.pageX) > threshold || + Math.abs(this.startY - evt.pageY) > threshold) { + this.cancelClick = true; + content.clearTimeout(this.contextMenuTimeout); + } + } + + type = "touchmove"; + break; + + case "mouseup": + if (!eventTarget) { + return; + } + this.target = null; + + content.clearTimeout(this.contextMenuTimeout); + type = "touchend"; + + // Only register click listener after mouseup to ensure + // catching only real user click. (Especially ignore click + // being dispatched on form submit) + if (evt.detail == 1) { + this.simulatorTarget.addEventListener("click", this, true, false); + } + break; + + case "click": + // Mouse events has been cancelled so dispatch a sequence + // of events to where touchend has been fired + evt.preventDefault(); + evt.stopImmediatePropagation(); + + this.simulatorTarget.removeEventListener("click", this, true, false); + + if (this.cancelClick) { + return; + } + + content.setTimeout(function dispatchMouseEvents(self) { + try { + self.fireMouseEvent("mousedown", evt); + self.fireMouseEvent("mousemove", evt); + self.fireMouseEvent("mouseup", evt); + } catch (e) { + console.error("Exception in touch event helper: " + e); + } + }, this.getDelayBeforeMouseEvent(evt), this); + return; + } + + let target = eventTarget || this.target; + if (target && type) { + this.sendTouchEvent(evt, target, type); + } + + if (!isSystemWindow) { + evt.preventDefault(); + evt.stopImmediatePropagation(); + } + }, + + fireMouseEvent(type, evt) { + let content = this.getContent(evt.target); + let utils = content.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + utils.sendMouseEvent(type, evt.clientX, evt.clientY, 0, 1, 0, true, 0, + Ci.nsIDOMMouseEvent.MOZ_SOURCE_TOUCH); + }, + + sendContextMenu({ target, clientX, clientY, screenX, screenY }) { + let view = target.ownerDocument.defaultView; + let { MouseEvent } = view; + let evt = new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view, + screenX, + screenY, + clientX, + clientY, + }); + let content = this.getContent(target); + let timeout = content.setTimeout((function contextMenu() { + target.dispatchEvent(evt); + this.cancelClick = true; + }).bind(this), delay); + + return timeout; + }, + + sendTouchEvent(evt, target, name) { + function clone(obj) { + return Cu.cloneInto(obj, target); + } + // When running OOP b2g desktop, we need to send the touch events + // using the mozbrowser api on the unwrapped frame. + if (target.localName == "iframe" && target.mozbrowser === true) { + if (name == "touchstart") { + this.touchstartTime = Date.now(); + } else if (name == "touchend") { + // If we have a "fast" tap, don't send a click as both will be turned + // into a click and that breaks eg. checkboxes. + if (Date.now() - this.touchstartTime < delay) { + this.cancelClick = true; + } + } + let unwrapped = XPCNativeWrapper.unwrap(target); + unwrapped.sendTouchEvent(name, clone([0]), // event type, id + clone([evt.clientX]), // x + clone([evt.clientY]), // y + clone([1]), clone([1]), // rx, ry + clone([0]), clone([0]), // rotation, force + 1); // count + return; + } + let document = target.ownerDocument; + let content = this.getContent(target); + if (!content) { + return; + } + + let touchEvent = document.createEvent("touchevent"); + let point = document.createTouch(content, target, 0, + evt.pageX, evt.pageY, + evt.screenX, evt.screenY, + evt.clientX, evt.clientY, + 1, 1, 0, 0); + + let touches = document.createTouchList(point); + let targetTouches = touches; + let changedTouches = touches; + if (name === "touchend" || name === "touchcancel") { + // "touchend" and "touchcancel" events should not have the removed touch + // neither in touches nor in targetTouches + touches = targetTouches = document.createTouchList(); + } + + touchEvent.initTouchEvent(name, true, true, content, 0, + false, false, false, false, + touches, targetTouches, changedTouches); + target.dispatchEvent(touchEvent); + }, + + getContent(target) { + let win = (target && target.ownerDocument) + ? target.ownerDocument.defaultView + : null; + return win; + }, + + getDelayBeforeMouseEvent(evt) { + // On mobile platforms, Firefox inserts a 300ms delay between + // touch events and accompanying mouse events, except if the + // content window is not zoomable and the content window is + // auto-zoomed to device-width. + + // If the preference dom.meta-viewport.enabled is set to false, + // we couldn't read viewport's information from getViewportInfo(). + // So we always simulate 300ms delay when the + // dom.meta-viewport.enabled is false. + let savedMetaViewportEnabled = + Services.prefs.getBoolPref("dom.meta-viewport.enabled"); + if (!savedMetaViewportEnabled) { + return 300; + } + + let content = this.getContent(evt.target); + if (!content) { + return 0; + } + + let utils = content.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils); + + let allowZoom = {}, + minZoom = {}, + maxZoom = {}, + autoSize = {}; + + utils.getViewportInfo(content.innerWidth, content.innerHeight, {}, + allowZoom, minZoom, maxZoom, {}, {}, autoSize); + + // FIXME: On Safari and Chrome mobile platform, if the css property + // touch-action set to none or manipulation would also suppress 300ms + // delay. But Firefox didn't support this property now, we can't get + // this value from utils.getVisitedDependentComputedStyle() to check + // if we should suppress 300ms delay. + if (!allowZoom.value || // user-scalable = no + minZoom.value === maxZoom.value || // minimum-scale = maximum-scale + autoSize.value // width = device-width + ) { + return 0; + } else { + return 300; + } + } +}; + +exports.SimulatorCore = SimulatorCore; diff --git a/devtools/shared/touch/simulator.js b/devtools/shared/touch/simulator.js new file mode 100644 index 000000000..0e6d29282 --- /dev/null +++ b/devtools/shared/touch/simulator.js @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var promise = require("promise"); +var defer = require("devtools/shared/defer"); +var Services = require("Services"); + +const FRAME_SCRIPT = + "resource://devtools/shared/touch/simulator-content.js"; + +var trackedBrowsers = new WeakMap(); +var savedTouchEventsEnabled = + Services.prefs.getIntPref("dom.w3c_touch_events.enabled"); + +/** + * Simulate touch events for platforms where they aren't generally available. + * Defers to the `simulator-content.js` frame script to perform the real work. + */ +function TouchEventSimulator(browser) { + // Returns an already instantiated simulator for this browser + let simulator = trackedBrowsers.get(browser); + if (simulator) { + return simulator; + } + + let mm = browser.frameLoader.messageManager; + mm.loadFrameScript(FRAME_SCRIPT, true); + + simulator = { + enabled: false, + + start() { + if (this.enabled) { + return promise.resolve({ isReloadNeeded: false }); + } + this.enabled = true; + + let deferred = defer(); + let isReloadNeeded = + Services.prefs.getIntPref("dom.w3c_touch_events.enabled") != 1; + Services.prefs.setIntPref("dom.w3c_touch_events.enabled", 1); + let onStarted = () => { + mm.removeMessageListener("TouchEventSimulator:Started", onStarted); + deferred.resolve({ isReloadNeeded }); + }; + mm.addMessageListener("TouchEventSimulator:Started", onStarted); + mm.sendAsyncMessage("TouchEventSimulator:Start"); + return deferred.promise; + }, + + stop() { + if (!this.enabled) { + return promise.resolve(); + } + this.enabled = false; + + let deferred = defer(); + Services.prefs.setIntPref("dom.w3c_touch_events.enabled", + savedTouchEventsEnabled); + let onStopped = () => { + mm.removeMessageListener("TouchEventSimulator:Stopped", onStopped); + deferred.resolve(); + }; + mm.addMessageListener("TouchEventSimulator:Stopped", onStopped); + mm.sendAsyncMessage("TouchEventSimulator:Stop"); + return deferred.promise; + } + }; + + trackedBrowsers.set(browser, simulator); + + return simulator; +} + +exports.TouchEventSimulator = TouchEventSimulator; |