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;
+ }
+ }
+}