diff --git a/js/actions/appActions.js b/js/actions/appActions.js index fb7cb6984ff..568db210f95 100644 --- a/js/actions/appActions.js +++ b/js/actions/appActions.js @@ -26,6 +26,71 @@ const AppActions = { frameOpts: frameOpts, openInForeground }) + }, + + setActiveFrame: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_SET_ACTIVE_FRAME, + frameProps: frameProps + }) + }, + + tabDragStart: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAG_START, + frameProps + }) + }, + + tabDragStop: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAG_STOP, + frameProps + }) + }, + + tabDragDraggingOverLeftHalf: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAGGING_OVER_LEFT, + frameProps + }) + }, + + tabDragDraggingOverRightHalf: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAGGING_OVER_RIGHT, + frameProps + }) + }, + + tabDragExit: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAG_EXIT, + frameProps + }) + }, + + tabDragExitRightHalf: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAG_EXIT_RIGHT, + frameProps + }) + }, + + tabDraggingOn: function (frameProps) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_DRAGGING_ON, + frameProps + }) + }, + + moveTab: function (sourceFrameProps, destinationFrameProps, prepend) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_TAB_MOVE, + sourceFrameProps, + destinationFrameProps, + prepend + }) } } diff --git a/js/components/main.js b/js/components/main.js index 746a1f628e6..01900297359 100644 --- a/js/components/main.js +++ b/js/components/main.js @@ -8,6 +8,7 @@ const AppActions = require('../actions/appActions') // Components const NavigationBar = require('./navigationBar') const Frame = require('./frame') +const Tabs = require('./tabs') // Constants const Config = require('../constants/config') @@ -34,11 +35,17 @@ class Main extends ImmutableComponent { ? 1 : b.get('key') > a.get('key') ? -1 : 0 return
-
+
+
diff --git a/js/components/tabs.js b/js/components/tabs.js new file mode 100644 index 00000000000..b59e48fb678 --- /dev/null +++ b/js/components/tabs.js @@ -0,0 +1,197 @@ +const React = require('react') +const ReactDOM = require('react-dom') + +const ImmutableComponent = require('./immutableComponent') + +const AppActions = require('../actions/appActions') +const cx = require('../lib/classSet.js') + +const getFavicon = require('../lib/faviconUtil.js') + +class DragIndicator extends ImmutableComponent { + constructor (props) { + super(props) + } + + render () { + return
+ } +} + +class Tab extends ImmutableComponent { + constructor (props) { + super(props) + } + + get displayValue () { + // YouTube tries to change the title to add a play icon when + // there is audio. Since we have our own audio indicator we get + // rid of it. + return (this.props.frameProps.get('title') || + this.props.frameProps.get('location')).replace('▶ ', '') + } + + onDragStart (e) { + AppActions.tabDragStart(this.props.frameProps) + } + + onDragEnd () { + AppActions.tabDragStop(this.props.frameProps) + } + + onDragOver (e) { + e.preventDefault() + + // Otherise, only accept it if we have some frameProps + if (!this.props.activeDraggedTab) { + AppActions.tabDraggingOn(this.props.frameProps) + return + } + + let rect = ReactDOM.findDOMNode(this.refs.tab).getBoundingClientRect() + if (e.clientX > rect.left && e.clientX < rect.left + rect.width / 2 && + !this.props.frameProps.get('tabIsDraggingOverLeftHalf')) { + AppActions.tabDragDraggingOverLeftHalf(this.props.frameProps) + } else if (e.clientX < rect.right && e.clientX >= rect.left + rect.width / 2 && + !this.props.frameProps.get('tabIsDraggingOverRightHalf')) { + AppActions.tabDragDraggingOverRightHalf(this.props.frameProps) + } + } + + onDragLeave () { + if (this.props.frameProps.get('tabIsDraggingOverLeftHalf') || + this.props.frameProps.get('tabIsDraggingOn') || + this.props.frameProps.get('tabIsDraggingOverLeftHalf')) { + AppActions.tabDragExit(this.props.frameProps) + } else if (this.props.frameProps.get('tabIsDraggingOverRightHalf')) { + AppActions.tabDragExitRightHalf(this.props.frameProps) + } + } + + onDrop (e) { + let sourceFrameProps = this.props.activeDraggedTab + if (!sourceFrameProps) { + return + } + + if (this.props.frameProps.get('tabIsDraggingOverLeftHalf')) { + AppActions.moveTab(sourceFrameProps, this.props.frameProps, true) + } else { + AppActions.moveTab(sourceFrameProps, this.props.frameProps, false) + } + AppActions.tabDragExit(this.props.frameProps) + } + + setActiveFrame () { + AppActions.setActiveFrame(this.props.frameProps) + } + + render () { + const thumbnailWidth = 160 + const thumbnailHeight = 100 + + let thumbnailStyle = { + backgroundSize: `${thumbnailWidth} ${thumbnailHeight}`, + width: thumbnailWidth, + height: thumbnailHeight + } + if (this.props.frameProps.get('thumbnailUrl')) { + thumbnailStyle.backgroundImage = `url(${this.props.frameProps.get('thumbnailUrl')})` + } + + // Style based on theme-color + var activeTabStyle = {} + if (this.props.isActive && (this.props.frameProps.get('themeColor') || this.props.frameProps.get('computedThemeColor'))) { + activeTabStyle.backgroundColor = this.props.frameProps.get('themeColor') || this.props.frameProps.get('computedThemeColor') + } + + const iconStyle = { + backgroundImage: `url(${getFavicon(this.props.frameProps)})`, + backgroundSize: 16, + width: 16, + height: 16 + } + + let playIcon = null + if (this.props.frameProps.get('audioPlaybackActive') || + this.props.frameProps.get('audioMuted')) { + playIcon = + } + + return
+ +
+
+ +
+
+ {playIcon} + {this.displayValue} +
+
+ +
+ } +} + +class Tabs extends ImmutableComponent { + constructor () { + super() + } + + render () { + var tabWidth = 100 / this.props.frames.size + + return
+
+ { + this.props.frames.map(frameProps => ) + } +
+
+ } +} + +module.exports = Tabs diff --git a/js/constants/appConstants.js b/js/constants/appConstants.js index eb14c223163..ceb22e56ac1 100644 --- a/js/constants/appConstants.js +++ b/js/constants/appConstants.js @@ -1,5 +1,14 @@ module.exports = { APP_SET_URL: 1, APP_SET_NAVBAR_INPUT: 2, - APP_NEW_FRAME: 3 + APP_NEW_FRAME: 3, + APP_SET_ACTIVE_FRAME: 4, + APP_TAB_DRAG_START: 5, + APP_TAB_DRAG_STOP: 6, + APP_TAB_DRAGGING_OVER_LEFT: 7, + APP_TAB_DRAGGING_OVER_RIGHT: 8, + APP_TAB_DRAGGING_ON: 9, + APP_TAB_DRAG_EXIT: 10, + APP_TAB_DRAG_EXIT_RIGHT: 11, + APP_TAB_MOVE: 12 } diff --git a/js/entry.js b/js/entry.js index d0eca113267..a9c3483bf27 100644 --- a/js/entry.js +++ b/js/entry.js @@ -2,6 +2,7 @@ require('../less/browser.less') require('../less/main.less') require('../less/navigationBar.less') +require('../less/tabs.less') const React = require('react') const ReactDOM = require('react-dom') diff --git a/js/lib/appUrlUtil.js b/js/lib/appUrlUtil.js new file mode 100644 index 00000000000..3b567bf9282 --- /dev/null +++ b/js/lib/appUrlUtil.js @@ -0,0 +1,74 @@ +const Immutable = require('immutable') + +/** + * Determines the path of a relative URL from the hosted app + */ +export function getAppUrl (relativeUrl = './') { + return new window.URL(relativeUrl, window.location).href +} + +/** + * Returns the URL to the application's manifest + */ +export function getManifestUrl () { + return getAppUrl('./manifest.webapp') +} + +// Map of source about: URLs mapped to target URLs +export const aboutUrls = new Immutable.Map({ + 'about:about': getAppUrl('./about-about.html'), + 'about:blank': getAppUrl('./about-blank.html'), + 'about:history': getAppUrl('./about-history.html'), + 'about:newtab': getAppUrl('./about-newtab.html'), + 'about:preferences': getAppUrl('./about-preferences.html'), + 'about:settings': getAppUrl('./about-settings.html') +}) + +// Map of target URLs mapped to source about: URLs +const aboutUrlsReverse = new Immutable.Map(aboutUrls.reduce((obj, v, k) => { + obj[v] = k + return obj +}, {})) + +/** + * Obtains the target URL associated with an about: source URL + * Example: + * about:blank -> http://localhost:8000/about-blank/index.html + */ +export function getTargetAboutUrl (input) { + return aboutUrls.get(input) +} + +/** + * Obtains the source about: URL associated with a target URL + * Example: + * http://localhost:8000/about-blank.html -> about:blank + */ +export function getSourceAboutUrl (input) { + return aboutUrlsReverse.get(input) +} + +/** + * Determines if the passed in string is a source about: URL + * Example: isSourceAboutUrl('about:blank') -> true + */ +export function isSourceAboutUrl (input) { + return !!getTargetAboutUrl(input) +} + +/** + * Determines if the passed in string is the target of a source about: URL + * Example: isTargetAboutUrl('http://localhost:8000/about-blank/index.html') -> true + */ +export function isTargetAboutUrl (input) { + return !!getSourceAboutUrl(input) +} + +/** + * Determines whether the passed in string is pointing to a URL that + * should be privileged (mozapp attribute on the iframe) + * For now this is the same as an about URL. + */ +export function isPrivilegedUrl (input) { + return isSourceAboutUrl(input) +} diff --git a/js/lib/faviconUtil.js b/js/lib/faviconUtil.js new file mode 100644 index 00000000000..6fc16bd9f10 --- /dev/null +++ b/js/lib/faviconUtil.js @@ -0,0 +1,32 @@ +import { isSourceAboutUrl } from './appUrlUtil.js' +const UrlUtil = require('../../node_modules/urlutil.js/dist/node-urlutil.js') + +module.exports = function getFavicon (frameProps) { + if (!frameProps.get('location')) { + return null + } + + var size = window.devicePixelRatio * 16 + var resolution = '#-moz-resolution=' + size + ',' + size + var iconHref = frameProps.get('icon') + + // Default to favicon.ico if we can't find an icon. + if (!iconHref) { + var loc = frameProps.get('location') + if (UrlUtil.isViewSourceUrl(loc)) { + loc = loc.substring('view-source:'.length) + } else if (UrlUtil.isImageDataUrl(loc)) { + return loc + } else if (isSourceAboutUrl(loc) || UrlUtil.isDataUrl(loc)) { + return '' + } + + try { + var defaultIcon = new window.URL('/favicon.ico' + resolution, loc) + iconHref = defaultIcon.toString() + } catch (e) { + return '' + } + } + return iconHref + resolution +} diff --git a/js/state/frameStateUtil.js b/js/state/frameStateUtil.js index f2d324a1f8c..6bbfa77b2a3 100644 --- a/js/state/frameStateUtil.js +++ b/js/state/frameStateUtil.js @@ -17,6 +17,11 @@ export function getFrameByIndex (appState, i) { return appState.getIn(['frames', i]) } +export function getActiveFrame (appState) { + const activeFrameIndex = getActiveFrameIndex(appState) + return appState.get('frames').get(activeFrameIndex) +} + export function setActiveFrameIndex (appState, i) { const frame = getFrameByIndex(appState, i) if (!frame) { diff --git a/js/stores/appStore.js b/js/stores/appStore.js index 62dd09cc64f..a9988bfaecc 100644 --- a/js/stores/appStore.js +++ b/js/stores/appStore.js @@ -17,6 +17,9 @@ let appState = Immutable.fromJS({ urlbar: { location: '' } + }, + tabs: { + activeDraggedTab: null } } }) @@ -73,6 +76,75 @@ AppDispatcher.register((action) => { nextKey, action.openInForeground ? nextKey : appState.get('activeFrameKey'))) appStore.emitChange() break + case AppConstants.APP_SET_ACTIVE_FRAME: + appState = appState.merge({ + activeFrameKey: action.frameProps.get('key') + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAG_START: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDragging: true + }) + appState = appState.setIn(['ui', 'tabs', 'activeDraggedTab'], action.frameProps) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAG_STOP: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDragging: false + }) + appState = appState.setIn(['ui', 'tabs', 'activeDraggedTab'], null) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAGGING_OVER_LEFT: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDraggingOn: false, + tabIsDraggingOverLeftHalf: true, + tabIsDraggingOverRightHalf: false + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAGGING_OVER_RIGHT: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDraggingOn: false, + tabIsDraggingOverLeftHalf: false, + tabIsDraggingOverRightHalf: true + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAG_EXIT: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDraggingOn: false, + tabIsDraggingOverLeftHalf: false, + tabIsDraggingOverRightHalf: false + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAG_EXIT_RIGHT: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDraggingOverRightHalf: false + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_DRAGGING_ON: + appState = appState.mergeIn(['frames', FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.frameProps)], { + tabIsDraggingOn: true, + tabIsDraggingOverLeftHalf: false, + tabIsDraggingOverRightHalf: false + }) + appStore.emitChange() + break + case AppConstants.APP_TAB_MOVE: + let sourceFramePropsIndex = FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.sourceFrameProps) + let newIndex = FrameStateUtil.getFramePropsIndex(appState.get('frames'), action.destinationFrameProps) + (action.prepend ? 0 : 1) + let frames = appState.get('frames').splice(sourceFramePropsIndex, 1) + if (newIndex > sourceFramePropsIndex) { + newIndex-- + } + frames = frames.splice(newIndex, 0, action.sourceFrameProps) + appState = appState.set('frames', frames) + appStore.emitChange() + break default: } }) diff --git a/less/tabs.less b/less/tabs.less new file mode 100644 index 00000000000..ca93290c687 --- /dev/null +++ b/less/tabs.less @@ -0,0 +1,155 @@ +/* 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/. */ + +@import "variables.less"; + +.tabs { + position: relative; + z-index: 310; + padding: 8px 4px 0 6px; + height: 40px; + white-space: nowrap; + background: @chromeSecondary; + box-sizing: border-box; +} + +.tab { + background: -moz-linear-gradient(to bottom, #BEBFC2, #AFB0B4); + border-top-left-radius: @tabBorderRadius; + border-top-right-radius: @tabBorderRadius; + box-sizing: border-box; + margin: 0; + position: relative; + transition: transform 200ms ease; + border: 1px solid @chromeBorderColor; + border-width: 1px 1px 0; + left: 0; + opacity: 1.0; + padding: 4px 0 0; + height: 32px; + width: 100%; + line-height: 24px; + -moz-window-dragging: no-drag; + color: #3B3B3B; + + .tabTitle { + -moz-user-select: none; + box-sizing: border-box; + display: inline-block; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: calc(~'100% - 26px'); + } + .tabIcon { + display: inline-block; + margin: 0 4px; + position: relative; + top: -5px; + background-position: center; + background-repeat: no-repeat; + } + .playIcon { + cursor: default; + margin-right: 4px; + } + + .thumbnail { + display: none; + position: absolute; + top: 32px; + left: 0; + border: 1px solid #000; + padding: 10px; + background: #fff; + pointer-events: none; + z-index: 312; + } + + &.active { + color: #fff; + background: @hoverBlue; + } + + &.private { + background: @privateTabBackground; + color: #fff; + } + + &:hover { + .closeTab { + opacity: 1; + } + + .thumbnail { + display: block; + } + } + + &:not(.active):hover { + background: -moz-linear-gradient(to bottom, #DFDFDF, #AFB0B4); + } + + &.dragging { + &:hover { + .closeTab { + opacity: 0; + } + } + } + + .closeTab { + color: white; + cursor: pointer; + font-size: 24px; + height: 24px; + opacity: 0; + position: absolute; + right: -4px; + text-align: center; + top: -8px; + width: 24px; + + &:hover { + color: @hoverBlue; + } + + background-color: black; + border: 0px solid white; + border-radius: 50%; + z-index: 3; + } +} + +.tabArea { + position: relative; + display: inline-block; + height: 100%; + max-width: 184px; + box-sizing: border-box; + padding: 0 2px 0 0; + + hr.dragIndicator { + position: absolute; + top: 0; + left: -1px; + z-index: 100; + height: 100%; + width: 1px; + + &.dragIndicatorEnd { + bottom: -5px; + top: 0; + right: -3px; + left: unset; + } + + display: none; + &.dragActive { + display: block; + color: @hoverBlue; + } + } +}