var _ = require('lodash') var rotator = require('./rotator') module.exports = function DeviceScreenDirective( $document , ScalingService , VendorUtil , PageVisibilityService , $timeout , $window ) { return { restrict: 'E' , template: require('./screen.jade') , scope: { control: '&' , device: '&' } , link: function (scope, element) { var URL = window.URL || window.webkitURL var BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' var cssTransform = VendorUtil.style(['transform', 'webkitTransform']) var device = scope.device() , control = scope.control() var input = element.find('input') var screen = scope.screen = { rotation: 0 , bounds: { x: 0 , y: 0 , w: 0 , h: 0 } } var scaler = ScalingService.coordinator( device.display.width , device.display.height ) /** * SCREEN HANDLING * * This section should deal with updating the screen ONLY. */ ;(function() { function stop() { try { ws.onerror = ws.onclose = ws.onmessage = ws.onopen = null ws.close() ws = null } catch (err) { /* noop */ } } var ws = new WebSocket(device.display.url) ws.binaryType = 'blob' var cleanupList = [] ws.onerror = function errorListener() { // @todo Handle } ws.onclose = function closeListener() { // @todo Maybe handle } ws.onopen = function openListener() { var canvas = element.find('canvas')[0] , g = canvas.getContext('2d') , positioner = element.find('div')[0] function vendorBackingStorePixelRatio(g) { return g.webkitBackingStorePixelRatio || g.mozBackingStorePixelRatio || g.msBackingStorePixelRatio || g.oBackingStorePixelRatio || g.backingStorePixelRatio || 1 } var devicePixelRatio = window.devicePixelRatio || 1 , backingStoreRatio = vendorBackingStorePixelRatio(g) , frontBackRatio = devicePixelRatio / backingStoreRatio var options = { autoScaleForRetina: true , density: Math.max(1, Math.min(1.5, devicePixelRatio || 1)) , minscale: 0.36 } var adjustedBoundSize var cachedEnabled = false function updateBounds() { function adjustBoundedSize(w, h) { var sw = w * options.density , sh = h * options.density , f if (sw < (f = device.display.width * options.minscale)) { sw *= f / sw sh *= f / sh } if (sh < (f = device.display.height * options.minscale)) { sw *= f / sw sh *= f / sh } return { w: Math.ceil(sw) , h: Math.ceil(sh) } } // FIXME: element is an object HTMLUnknownElement in IE9 var w = screen.bounds.w = element[0].offsetWidth , h = screen.bounds.h = element[0].offsetHeight // Developer error, let's try to reduce debug time if (!w || !h) { throw new Error( 'Unable to read bounds; container must have dimensions' ) } var newAdjustedBoundSize = (function() { switch (screen.rotation) { case 90: case 270: return adjustBoundedSize(h, w) case 0: case 180: /* falls through */ default: return adjustBoundedSize(w, h) } })() if (!adjustedBoundSize || newAdjustedBoundSize.w !== adjustedBoundSize.w || newAdjustedBoundSize.h !== adjustedBoundSize.h) { adjustedBoundSize = newAdjustedBoundSize onScreenInterestAreaChanged() } } function shouldUpdateScreen() { return ( // NO if the user has disabled the screen. scope.$parent.showScreen && // NO if we're not even using the device anymore. device.using && // NO if the page is not visible (e.g. background tab). !PageVisibilityService.hidden // YES otherwise ) } function checkEnabled() { var newEnabled = shouldUpdateScreen() if (newEnabled === cachedEnabled) { updateBounds() } else if (newEnabled) { updateBounds() onScreenInterestGained() } else { g.clearRect(0, 0, canvas.width, canvas.height) onScreenInterestLost() } cachedEnabled = newEnabled } function onScreenInterestGained() { ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h) ws.send('on') } function onScreenInterestAreaChanged() { ws.send('size ' + adjustedBoundSize.w + 'x' + adjustedBoundSize.h) } function onScreenInterestLost() { ws.send('off') } ws.onmessage = (function() { var cachedScreen = { rotation: 0 , bounds: { x: 0 , y: 0 , w: 0 , h: 0 } } var cachedImageWidth = 0 , cachedImageHeight = 0 , cssRotation = 0 , alwaysUpright = false function applyQuirks(banner) { element[0].classList.toggle( 'quirk-always-upright', alwaysUpright = banner.quirks.alwaysUpright) } function hasImageAreaChanged(img) { return cachedScreen.bounds.w !== screen.bounds.w || cachedScreen.bounds.h !== screen.bounds.h || cachedImageWidth !== img.width || cachedImageHeight !== img.height || cachedScreen.rotation !== screen.rotation } function isRotated() { return screen.rotation === 90 || screen.rotation === 270 } function updateImageArea(img) { if (!hasImageAreaChanged(img)) { return } cachedImageWidth = img.width cachedImageHeight = img.height if (options.autoScaleForRetina) { canvas.width = cachedImageWidth * frontBackRatio canvas.height = cachedImageHeight * frontBackRatio g.scale(frontBackRatio, frontBackRatio) } else { canvas.width = cachedImageWidth canvas.height = cachedImageHeight } cssRotation += rotator(cachedScreen.rotation, screen.rotation) canvas.style[cssTransform] = 'rotate(' + cssRotation + 'deg)' cachedScreen.bounds.h = screen.bounds.h cachedScreen.bounds.w = screen.bounds.w cachedScreen.rotation = screen.rotation canvasAspect = canvas.width / canvas.height if (isRotated() && !alwaysUpright) { canvasAspect = img.height / img.width element[0].classList.add('rotated') } else { canvasAspect = img.width / img.height element[0].classList.remove('rotated') } if (alwaysUpright) { // If the screen image is always in upright position (but we // still want the rotation animation), we need to cancel out // the rotation by using another rotation. positioner.style[cssTransform] = 'rotate(' + -cssRotation + 'deg)' } maybeFlipLetterbox() } return function messageListener(message) { screen.rotation = device.display.rotation if (message.data instanceof Blob) { if (shouldUpdateScreen()) { if (scope.displayError) { scope.$apply(function () { scope.displayError = false }) } var blob = new Blob([message.data], { type: 'image/jpeg' }) var img = new Image() img.onload = function() { updateImageArea(this) g.drawImage(img, 0, 0, img.width, img.height) // Try to forcefully clean everything to get rid of memory // leaks. Note that despite this effort, Chrome will still // leak huge amounts of memory when the developer tools are // open, probably to save the resources for inspection. When // the developer tools are closed no memory is leaked. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null } img.onerror = function() { // Happily ignore. I suppose this shouldn't happen, but // sometimes it does, presumably when we're loading images // too quickly. // Do the same cleanup here as in onload. img.onload = img.onerror = null img.src = BLANK_IMG img = null blob = null URL.revokeObjectURL(url) url = null } var url = URL.createObjectURL(blob) img.src = url } } else if (/^start /.test(message.data)) { applyQuirks(JSON.parse(message.data.substr('start '.length))) } else if (message.data === 'secure_on') { scope.$apply(function () { scope.displayError = 'secure' }) } } })() // NOTE: instead of fa-pane-resize, a fa-child-pane-resize could be better cleanupList.push(scope.$on('fa-pane-resize', _.debounce(updateBounds, 1000))) cleanupList.push(scope.$watch('device.using', checkEnabled)) cleanupList.push(scope.$on('visibilitychange', checkEnabled)) cleanupList.push(scope.$watch('$parent.showScreen', checkEnabled)) } scope.retryLoadingScreen = function () { if (scope.displayError === 'secure') { control.home() } } scope.$on('guest-portrait', function () { control.rotate(0) }) scope.$on('guest-landscape', function () { control.rotate(90) }) var canvasAspect = 1 , parentAspect = 1 function resizeListener() { parentAspect = element[0].offsetWidth / element[0].offsetHeight maybeFlipLetterbox() } function maybeFlipLetterbox() { element[0].classList.toggle( 'letterboxed', parentAspect < canvasAspect) } $window.addEventListener('beforeunload', stop, false) $window.addEventListener('resize', resizeListener, false) scope.$on('fa-pane-resize', resizeListener) resizeListener() scope.$on('$destroy', function() { stop() $window.removeEventListener('beforeunload', stop, false) $window.removeEventListener('resize', resizeListener, false) }) })() /** * KEYBOARD HANDLING * * This should be moved elsewhere, but due to shared dependencies and * elements it's currently here. So basically due to laziness. * * For now, try to keep the whole section as a separate unit as much * as possible. */ ;(function() { function isChangeCharsetKey(e) { // Add any special key here for changing charset //console.log('e', e) // Chrome/Safari/Opera if ( // Mac | Kinesis keyboard | Karabiner | Latin key, Kana key e.keyCode === 0 && e.keyIdentifier === 'U+0010' || // Mac | MacBook Pro keyboard | Latin key, Kana key e.keyCode === 0 && e.keyIdentifier === 'U+0020' || // Win | Lenovo X230 keyboard | Alt+Latin key e.keyCode === 246 && e.keyIdentifier === 'U+00F6' || // Win | Lenovo X230 keyboard | Convert key e.keyCode === 28 && e.keyIdentifier === 'U+001C' ) { return true } // Firefox switch (e.key) { case 'Convert': // Windows | Convert key case 'Alphanumeric': // Mac | Latin key case 'RomanCharacters': // Windows/Mac | Latin key case 'KanjiMode': // Windows/Mac | Kana key return true } return false } function handleSpecialKeys(e) { if (isChangeCharsetKey(e)) { e.preventDefault() control.keyPress('switch_charset') return true } return false } function keydownListener(e) { // Prevent tab from switching focus to the next element, we only want // that to happen on the device side. if (e.keyCode === 9) { e.preventDefault() } control.keyDown(e.keyCode) } function keyupListener(e) { if (!handleSpecialKeys(e)) { control.keyUp(e.keyCode) } } function pasteListener(e) { // Prevent value change or the input event sees it. This way we get // the real value instead of any "\n" -> " " conversions we might see // in the input value. e.preventDefault() control.paste(e.clipboardData.getData('text/plain')) } function copyListener(e) { e.preventDefault() // This is asynchronous and by the time it returns we will no longer // have access to setData(). In other words it doesn't work. Currently // what happens is that on the first copy, it will attempt to fetch // the clipboard contents. Only on the second copy will it actually // copy that to the clipboard. control.getClipboardContent() if (control.clipboardContent) { e.clipboardData.setData('text/plain', control.clipboardContent) } } function inputListener() { // Why use the input event if we don't let it handle pasting? The // reason is that on latest Safari (Version 8.0 (10600.1.25)), if // you use the "Romaji" Kotoeri input method, we'll never get any // keypress events. It also causes us to lose the very first keypress // on the page. Currently I'm not sure if we can fix that one. control.type(this.value) this.value = '' } input.bind('keydown', keydownListener) input.bind('keyup', keyupListener) input.bind('input', inputListener) input.bind('paste', pasteListener) input.bind('copy', copyListener) })() /** * TOUCH HANDLING * * This should be moved elsewhere, but due to shared dependencies and * elements it's currently here. So basically due to laziness. * * For now, try to keep the whole section as a separate unit as much * as possible. */ ;(function() { var slots = [] , slotted = Object.create(null) , fingers = [] , seq = -1 , cycle = 100 , fakePinch = false , lastPossiblyBuggyMouseUpEvent = 0 function nextSeq() { return ++seq >= cycle ? (seq = 0) : seq } function createSlots() { // The reverse order is important because slots and fingers are in // opposite sort order. Anyway don't change anything here unless // you understand what it does and why. for (var i = 9; i >= 0; --i) { var finger = createFinger(i) element.append(finger) slots.push(i) fingers.unshift(finger) } } function activateFinger(index, x, y, pressure) { var scale = 0.5 + pressure fingers[index].classList.add('active') fingers[index].style[cssTransform] = 'translate3d(' + x + 'px,' + y + 'px,0) ' + 'scale(' + scale + ',' + scale + ')' } function deactivateFinger(index) { fingers[index].classList.remove('active') } function deactivateFingers() { for (var i = 0, l = fingers.length; i < l; ++i) { fingers[i].classList.remove('active') } } function createFinger(index) { var el = document.createElement('span') el.className = 'finger finger-' + index return el } function calculateBounds() { var el = element[0] screen.bounds.w = el.offsetWidth screen.bounds.h = el.offsetHeight screen.bounds.x = 0 screen.bounds.y = 0 while (el.offsetParent) { screen.bounds.x += el.offsetLeft screen.bounds.y += el.offsetTop el = el.offsetParent } } function mouseDownListener(e) { if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() fakePinch = e.altKey calculateBounds() startMousing() var x = e.pageX - screen.bounds.x , y = e.pageY - screen.bounds.y , pressure = 0.5 , scaled = scaler.coords( screen.bounds.w , screen.bounds.h , x , y , screen.rotation ) control.touchDown(nextSeq(), 0, scaled.xP, scaled.yP, pressure) if (fakePinch) { control.touchDown(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure) } control.touchCommit(nextSeq()) activateFinger(0, x, y, pressure) if (fakePinch) { activateFinger(1, -e.pageX + screen.bounds.x + screen.bounds.w, -e.pageY + screen.bounds.y + screen.bounds.h, pressure) } element.bind('mousemove', mouseMoveListener) $document.bind('mouseup', mouseUpListener) $document.bind('mouseleave', mouseUpListener) if (lastPossiblyBuggyMouseUpEvent && lastPossiblyBuggyMouseUpEvent.timeStamp > e.timeStamp) { // We got mouseup before mousedown. See mouseUpBugWorkaroundListener // for details. mouseUpListener(lastPossiblyBuggyMouseUpEvent) } else { lastPossiblyBuggyMouseUpEvent = null } } function mouseMoveListener(e) { if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() var addGhostFinger = !fakePinch && e.altKey var deleteGhostFinger = fakePinch && !e.altKey fakePinch = e.altKey var x = e.pageX - screen.bounds.x , y = e.pageY - screen.bounds.y , pressure = 0.5 , scaled = scaler.coords( screen.bounds.w , screen.bounds.h , x , y , screen.rotation ) control.touchMove(nextSeq(), 0, scaled.xP, scaled.yP, pressure) if (addGhostFinger) { control.touchDown(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure) } else if (deleteGhostFinger) { control.touchUp(nextSeq(), 1) } else if (fakePinch) { control.touchMove(nextSeq(), 1, 1 - scaled.xP, 1 - scaled.yP, pressure) } control.touchCommit(nextSeq()) activateFinger(0, x, y, pressure) if (deleteGhostFinger) { deactivateFinger(1) } else if (fakePinch) { activateFinger(1, -e.pageX + screen.bounds.x + screen.bounds.w, -e.pageY + screen.bounds.y + screen.bounds.h, pressure) } } function mouseUpListener(e) { if (e.originalEvent) { e = e.originalEvent } // Skip secondary click if (e.which === 3) { return } e.preventDefault() control.touchUp(nextSeq(), 0) if (fakePinch) { control.touchUp(nextSeq(), 1) } control.touchCommit(nextSeq()) deactivateFinger(0) if (fakePinch) { deactivateFinger(1) } stopMousing() } /** * Do NOT remove under any circumstances. Currently, in the latest * Safari (Version 8.0 (10600.1.25)), if an input field is focused * while we do a tap click on an MBP trackpad ("Tap to click" in * Settings), it sometimes causes the mouseup event to trigger before * the mousedown event (but event.timeStamp will be correct). It * doesn't happen in any other browser. The following minimal test * case triggers the same behavior (although less frequently). Keep * tapping and you'll eventually see see two mouseups in a row with * the same counter value followed by a mousedown with a new counter * value. Also, when the bug happens, the cursor in the input field * stops blinking. It may take up to 300 attempts to spot the bug on * a MacBook Pro (Retina, 15-inch, Mid 2014). * * * *
* * * * * I believe that the bug is caused by some kind of a race condition * in Safari. Using a textarea or a focused contenteditable does not * get rid of the bug. The bug also happens if the text field is * focused manually by the user (not with .focus()). * * It also doesn't help if you .blur() before .focus(). * * So basically we'll just have to store the event on mouseup and check * if we should do the browser's job in the mousedown handler. */ function mouseUpBugWorkaroundListener(e) { lastPossiblyBuggyMouseUpEvent = e } function startMousing() { control.gestureStart(nextSeq()) input[0].focus() } function stopMousing() { element.unbind('mousemove', mouseMoveListener) $document.unbind('mouseup', mouseUpListener) $document.unbind('mouseleave', mouseUpListener) deactivateFingers() control.gestureStop(nextSeq()) } function touchStartListener(e) { e.preventDefault() //Make it jQuery compatible also if (e.originalEvent) { e = e.originalEvent } calculateBounds() if (e.touches.length === e.changedTouches.length) { startTouching() } var currentTouches = Object.create(null) var i, l for (i = 0, l = e.touches.length; i < l; ++i) { currentTouches[e.touches[i].identifier] = 1; } function maybeLostTouchEnd(id) { return !(id in currentTouches) } // We might have lost a touchend event due to various edge cases // (literally) such as dragging from the bottom of the screen so that // the control center appears. If so, let's ask for a reset. if (Object.keys(slotted).some(maybeLostTouchEnd)) { Object.keys(slotted).forEach(function(id) { slots.push(slotted[id]) delete slotted[id] }) slots.sort().reverse() control.touchReset(nextSeq()) deactivateFingers() } if (!slots.length) { // This should never happen but who knows... throw new Error('Ran out of multitouch slots') } for (i = 0, l = e.changedTouches.length; i < l; ++i) { var touch = e.changedTouches[i] , slot = slots.pop() , x = touch.pageX - screen.bounds.x , y = touch.pageY - screen.bounds.y , pressure = touch.force || 0.5 , scaled = scaler.coords( screen.bounds.w , screen.bounds.h , x , y , screen.rotation ) slotted[touch.identifier] = slot control.touchDown(nextSeq(), slot, scaled.xP, scaled.yP, pressure) activateFinger(slot, x, y, pressure) } element.bind('touchmove', touchMoveListener) $document.bind('touchend', touchEndListener) $document.bind('touchleave', touchEndListener) control.touchCommit(nextSeq()) } function touchMoveListener(e) { e.preventDefault() if (e.originalEvent) { e = e.originalEvent } for (var i = 0, l = e.changedTouches.length; i < l; ++i) { var touch = e.changedTouches[i] , slot = slotted[touch.identifier] , x = touch.pageX - screen.bounds.x , y = touch.pageY - screen.bounds.y , pressure = touch.force || 0.5 , scaled = scaler.coords( screen.bounds.w , screen.bounds.h , x , y , screen.rotation ) control.touchMove(nextSeq(), slot, scaled.xP, scaled.yP, pressure) activateFinger(slot, x, y, pressure) } control.touchCommit(nextSeq()) } function touchEndListener(e) { if (e.originalEvent) { e = e.originalEvent } var foundAny = false for (var i = 0, l = e.changedTouches.length; i < l; ++i) { var touch = e.changedTouches[i] , slot = slotted[touch.identifier] if (slot === void 0) { // We've already disposed of the contact. We may have gotten a // touchend event for the same contact twice. continue } delete slotted[touch.identifier] slots.push(slot) control.touchUp(nextSeq(), slot) deactivateFinger(slot) foundAny = true } if (foundAny) { control.touchCommit(nextSeq()) if (!e.touches.length) { stopTouching() } } } function startTouching() { control.gestureStart(nextSeq()) } function stopTouching() { element.unbind('touchmove', touchMoveListener) $document.unbind('touchend', touchEndListener) $document.unbind('touchleave', touchEndListener) deactivateFingers() control.gestureStop(nextSeq()) } element.on('touchstart', touchStartListener) element.on('mousedown', mouseDownListener) element.on('mouseup', mouseUpBugWorkaroundListener) createSlots() })() } } }