diff --git a/internal_packages/preferences/lib/tabs/workspace-section.cjsx b/internal_packages/preferences/lib/tabs/workspace-section.cjsx index c8fceb1d7..ca562797b 100644 --- a/internal_packages/preferences/lib/tabs/workspace-section.cjsx +++ b/internal_packages/preferences/lib/tabs/workspace-section.cjsx @@ -132,42 +132,6 @@ class AppearanceModeOption extends React.Component
{label}
-class ThemeSelector extends React.Component - constructor: (@props) -> - @_themeManager = NylasEnv.themes - @state = @_getState() - - componentDidMount: => - @disposable = @_themeManager.onDidChangeActiveThemes => - @setState @_getState() - - componentWillUnmount: -> - @disposable.dispose() - - _getState: => - themes: @_themeManager.getLoadedThemes() - activeTheme: @_themeManager.getActiveTheme().name - - _setActiveTheme: (theme) => - @setState activeTheme: theme - @_themeManager.setActiveTheme theme - - _onChangeTheme: (event) => - value = event.target.value - if value is 'install' - NylasEnv.commands.dispatch document.body, 'application:install-package' - else - @_setActiveTheme(value) - - render: => -
- Select theme: - -
class WorkspaceSection extends React.Component @displayName: 'WorkspaceSection' @@ -202,8 +166,6 @@ class WorkspaceSection extends React.Component keyPath="core.workspace.interfaceZoom" config={@props.config} /> - -

Layout

diff --git a/internal_packages/theme-picker/lib/main.js b/internal_packages/theme-picker/lib/main.js new file mode 100644 index 000000000..949bc9d27 --- /dev/null +++ b/internal_packages/theme-picker/lib/main.js @@ -0,0 +1,18 @@ +/** @babel */ +import React from 'react'; +import Actions from '../../../src/flux/actions' + +import ThemePicker from './theme-picker' + + +export function activate() { + this.disposable = NylasEnv.commands.add("body", + "window:launch-theme-picker", + () => Actions.openModal(children=, + height=400, + width=250)); +} + +export function deactivate() { + this.disposable.dispose(); +} diff --git a/internal_packages/theme-picker/lib/theme-option.jsx b/internal_packages/theme-picker/lib/theme-option.jsx new file mode 100644 index 000000000..96e8554fb --- /dev/null +++ b/internal_packages/theme-picker/lib/theme-option.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import fs from 'fs-plus'; +import path from 'path'; + +import {EventedIFrame} from 'nylas-component-kit'; +import LessCompileCache from '../../../src/less-compile-cache' + + +class ThemeOption extends React.Component { + static propTypes = { + theme: React.PropTypes.object.isRequired, + active: React.PropTypes.bool.isRequired, + } + + constructor(props) { + super(props); + this.lessCache = null; + } + + componentDidMount() { + this._writeContent(); + } + + _getImportPaths() { + const themes = [this.props.theme]; + // Pulls the theme package for Light as the base theme + for (const theme of NylasEnv.themes.getActiveThemes()) { + if (theme.name === NylasEnv.themes.baseThemeName()) { + themes.push(theme); + } + } + const themePaths = []; + for (const theme of themes) { + themePaths.push(theme.getStylesheetsPath()); + } + return themePaths.filter((themePath) => fs.isDirectorySync(themePath)); + } + + _loadStylesheet(stylesheetPath) { + if (path.extname(stylesheetPath) === '.less') { + return this._loadLessStylesheet(stylesheetPath); + } + return fs.readFileSync(stylesheetPath, 'utf8'); + } + + _loadLessStylesheet(lessStylesheetPath) { + const {configDirPath, resourcePath} = NylasEnv.getLoadSettings(); + if (this.lessCache) { + this.lessCache.setImportPaths(this._getImportPaths()); + } else { + const importPaths = this._getImportPaths(); + this.lessCache = new LessCompileCache({configDirPath, resourcePath, importPaths}); + } + const themeVarPath = path.relative(`${resourcePath}/internal_packages/theme-picker/preview-styles`, + this.props.theme.getStylesheetsPath()); + let varImports = `@import "../../../static/variables/ui-variables";` + if (fs.existsSync(`${this.props.theme.getStylesheetsPath()}/ui-variables.less`)) { + varImports += `@import "${themeVarPath}/ui-variables";` + } + if (fs.existsSync(`${this.props.theme.getStylesheetsPath()}/theme-colors.less`)) { + varImports += `@import "${themeVarPath}/theme-colors";` + } + const less = fs.readFileSync(lessStylesheetPath, 'utf8'); + return this.lessCache.cssForFile(lessStylesheetPath, [varImports, less].join('\n')); + } + + _writeContent() { + const domNode = React.findDOMNode(this); + const doc = domNode.contentDocument; + if (!doc) return; + + const {resourcePath} = NylasEnv.getLoadSettings(); + + const html = ` + + +
+
${this.props.theme.displayName}
+
+
+
+
+
+
+
+
+
+ ` + + doc.open(); + doc.write(html); + doc.close(); + } + + render() { + return ( + + ); + } +} + +export default ThemeOption; diff --git a/internal_packages/theme-picker/lib/theme-picker.jsx b/internal_packages/theme-picker/lib/theme-picker.jsx new file mode 100644 index 000000000..e8037d6a9 --- /dev/null +++ b/internal_packages/theme-picker/lib/theme-picker.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import Actions from '../../../src/flux/actions' + +import {Flexbox, RetinaImg} from 'nylas-component-kit'; +import ThemeOption from './theme-option'; + + +class ThemePicker extends React.Component { + static displayName = 'ThemePicker'; + + constructor(props) { + super(props); + this._themeManager = NylasEnv.themes; + this.state = this._getState(); + } + + componentDidMount() { + this.disposable = this._themeManager.onDidChangeActiveThemes(() => { + this.setState(this._getState()); + }); + } + + componentWillUnmount() { + this.disposable.dispose(); + } + + _getState() { + return { + themes: this._themeManager.getLoadedThemes(), + activeTheme: this._themeManager.getActiveTheme().name, + } + } + + _setActiveTheme(theme) { + const prevActiveTheme = this.state.activeTheme; + this.setState({activeTheme: theme}); + this._themeManager.setActiveTheme(theme); + this._rewriteIFrame(prevActiveTheme, theme); + } + + _rewriteIFrame(prevActiveTheme, activeTheme) { + const prevActiveThemeDoc = document.querySelector(`.theme-preview-${prevActiveTheme}`).contentDocument; + const prevActiveElement = prevActiveThemeDoc.querySelector(".theme-option.active-true"); + prevActiveElement.className = "theme-option active-false"; + const activeThemeDoc = document.querySelector(`.theme-preview-${activeTheme}`).contentDocument; + const activeElement = activeThemeDoc.querySelector(".theme-option.active-false"); + activeElement.className = "theme-option active-true"; + } + + _renderThemeOptions() { + const themeOptions = this.state.themes.map((theme) => +
this._setActiveTheme(theme.name)} + style={{cursor: "pointer", width: "115px", margin: "2px"}}> + +
+ ) + return themeOptions; + } + + render() { + return ( +
+ + Actions.closeModal()} /> +

Themes

+
Click any theme to preview.
+
+ + {this._renderThemeOptions()} + +
+
+
+ ); + } +} + +export default ThemePicker; diff --git a/internal_packages/theme-picker/package.json b/internal_packages/theme-picker/package.json new file mode 100644 index 000000000..6e9e18e95 --- /dev/null +++ b/internal_packages/theme-picker/package.json @@ -0,0 +1,13 @@ +{ + "name": "theme-picker", + "version": "0.1.0", + "main": "./lib/main", + "description": "View different themes and choose them easily", + "license": "GPL-3.0", + "private": true, + "engines": { + "nylas": "*" + }, + "dependencies": { + } +} diff --git a/internal_packages/theme-picker/preview-styles/theme-option.less b/internal_packages/theme-picker/preview-styles/theme-option.less new file mode 100644 index 000000000..b8bd74c8e --- /dev/null +++ b/internal_packages/theme-picker/preview-styles/theme-option.less @@ -0,0 +1,93 @@ +html, +body { + margin: 0; + height: 100%; + width: 100%; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +.theme-option { + position: absolute; + top: 0; + width: 100px; + height: 60px; + background-color: @background-secondary; + color: @text-color; + border-radius: 5px; + text-align: center; + overflow: hidden; + + &.active-true { + border: 1px solid #3187e1; + } + + &.active-false { + border: 1px solid darken(#f6f6f6, 10%); + } + + .theme-name { + font-family: @font-family; + font-size: 14px; + margin-top: 5px; + height: 18px; + overflow: hidden; + } + + .swatches { + padding-left: 27px; + padding-right: 27px; + display: flex; + flex-direction: row; + + .swatch { + flex: 1; + height: 10px; + width: 10px; + margin: 4px 2px 4px 2px; + border-radius: 2px; + border: 1px solid rgba(0, 0, 0, 0.15); + background-clip: border-box; + background-origin: border-box; + + &.font-color { + background-color: @text-color; + } + + &.active-color { + background-color: @component-active-color; + } + + &.toolbar-color { + background-color: @toolbar-background-color; + } + } + } + + .divider-black { + position: absolute; + bottom: 12px; + height: 1px; + width: 100%; + background-color: black; + opacity: 0.15; + } + + .divider-white { + position: absolute; + z-index: 10; + bottom: 11px; + height: 1px; + width: 100%; + background-color: white; + opacity: 0.15; + } + + .strip { + position: absolute; + bottom: 0; + height: 12px; + width: 100%; + background-color: @panel-background-color; + } +} diff --git a/internal_packages/theme-picker/spec/theme-picker-spec.jsx b/internal_packages/theme-picker/spec/theme-picker-spec.jsx new file mode 100644 index 000000000..094aa29ae --- /dev/null +++ b/internal_packages/theme-picker/spec/theme-picker-spec.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +const ReactTestUtils = React.addons.TestUtils; + +import ThemePackage from '../../../src/theme-package'; +import ThemePicker from '../lib/theme-picker'; + +const {resourcePath} = NylasEnv.getLoadSettings(); +const light = new ThemePackage(resourcePath + '/internal_packages/ui-light'); +const dark = new ThemePackage(resourcePath + '/internal_packages/ui-dark'); + +describe('ThemePicker', ()=> { + beforeEach(()=> { + spyOn(ThemePicker.prototype, '_setActiveTheme').andCallThrough(); + spyOn(NylasEnv.themes, 'getLoadedThemes').andReturn([light, dark]); + spyOn(NylasEnv.themes, 'getActiveTheme').andReturn(light); + this.component = ReactTestUtils.renderIntoDocument(); + }); + + it('changes the active theme when a theme is clicked', ()=> { + const themeOption = React.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithClass(this.component, 'clickable-theme-option')[1]); + ReactTestUtils.Simulate.mouseDown(themeOption); + expect(ThemePicker.prototype._setActiveTheme).toHaveBeenCalled(); + }); +}); diff --git a/menus/darwin.cson b/menus/darwin.cson index 548d53624..884a7fd23 100644 --- a/menus/darwin.cson +++ b/menus/darwin.cson @@ -5,6 +5,8 @@ { label: 'About Nylas', command: 'application:about' } { type: 'separator' } { label: 'Preferences', command: 'application:open-preferences' } + { label: 'Change Theme...', command: 'window:launch-theme-picker' } + { label: 'Install New Theme...', command: 'application:install-package' } { type: 'separator' } { label: 'Add Account...', command: 'application:add-account' } { label: 'VERSION', enabled: false } diff --git a/menus/linux.cson b/menus/linux.cson index 6525e9667..53f4232a2 100644 --- a/menus/linux.cson +++ b/menus/linux.cson @@ -32,6 +32,8 @@ ] } { type: 'separator' } { label: 'Preferences', command: 'application:open-preferences' } + { label: 'Change Theme...', command: 'window:launch-theme-picker' } + { label: 'Install New Theme...', command: 'application:install-package' } ] } diff --git a/menus/win32.cson b/menus/win32.cson index 2f04f16c9..89c4344b7 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -61,6 +61,8 @@ } { type: 'separator' } { label: 'Preferences', command: 'application:open-preferences' } + { label: 'Change Theme...', command: 'window:launch-theme-picker' } + { label: 'Install New Theme...', command: 'application:install-package' } { type: 'separator' } { label: 'Print Current Thread', command: 'application:print-thread' } { type: 'separator' } diff --git a/src/components/flexbox.cjsx b/src/components/flexbox.cjsx index aa160d70b..5cd20dee3 100644 --- a/src/components/flexbox.cjsx +++ b/src/components/flexbox.cjsx @@ -21,13 +21,17 @@ class Flexbox extends React.Component direction: React.PropTypes.string inline: React.PropTypes.bool style: React.PropTypes.object + height: React.PropTypes.string + + @defaultProps: + height: '100%' render: -> style = _.extend {}, (@props.style || {}), 'flexDirection': @props.direction, 'position':'relative' 'display': 'flex' - 'height':'100%' + 'height': @props.height if @props.inline is true style.display = 'inline-flex' diff --git a/src/components/modal.jsx b/src/components/modal.jsx new file mode 100644 index 000000000..fcc086bd3 --- /dev/null +++ b/src/components/modal.jsx @@ -0,0 +1,98 @@ +import _ from 'underscore'; +import React from 'react'; +import Actions from '../flux/actions'; + + +class Modal extends React.Component { + + static propTypes = { + className: React.PropTypes.string, + children: React.PropTypes.element, + height: React.PropTypes.number, + width: React.PropTypes.number, + }; + + constructor(props) { + super(props); + this.state = { + offset: 0, + dimensions: {}, + }; + } + + componentDidMount() { + this._focusImportantElement(); + } + + _focusImportantElement = ()=> { + const modalNode = React.findDOMNode(this); + + const focusable = modalNode.querySelectorAll("[tabIndex], input"); + const matches = _.sortBy(focusable, (node)=> { + if (node.tabIndex > 0) { + return node.tabIndex; + } else if (node.nodeName === "INPUT") { + return 1000000 + } + return 1000001 + }) + if (matches[0]) { + matches[0].focus(); + } + }; + + _computeModalStyles = (height, width)=> { + const modalStyle = { + top: "50%", + left: "50%", + margin: "-200px 0 0 -125px", + height: height, + width: width, + position: "absolute", + backgroundColor: "white", + boxShadow: "0 10px 20px rgba(0,0,0,0.19), inset 0 0 1px rgba(0,0,0,0.5)", + borderRadius: "5px", + }; + const containerStyle = { + height: "100%", + width: "100%", + zIndex: 1000, + position: "absolute", + backgroundColor: "transparent", + }; + return {containerStyle, modalStyle}; + }; + + _onBlur = (event)=> { + const target = event.nativeEvent.relatedTarget; + if (!target || (!React.findDOMNode(this).contains(target))) { + Actions.closeModal(); + } + }; + + _onKeyDown = (event)=> { + if (event.key === "Escape") { + Actions.closeModal(); + } + }; + + render() { + const {children, height, width} = this.props; + const {containerStyle, modalStyle} = this._computeModalStyles(height, width); + + return ( +
+
+ {children} +
+
+ ); + } + +} + +export default Modal; diff --git a/src/components/newsletter-signup.cjsx b/src/components/newsletter-signup.cjsx index eb373c927..a22e46a50 100644 --- a/src/components/newsletter-signup.cjsx +++ b/src/components/newsletter-signup.cjsx @@ -56,7 +56,7 @@ class NewsletterSignup extends React.Component "/newsletter-subscription/#{encodeURIComponent(props.emailAddress)}?name=#{encodeURIComponent(props.name)}" render: => - +
{@_renderControl()}
diff --git a/src/flux/actions.coffee b/src/flux/actions.coffee index efcb04d27..fa667efb4 100644 --- a/src/flux/actions.coffee +++ b/src/flux/actions.coffee @@ -518,6 +518,9 @@ class Actions @openPopover: ActionScopeWindow @closePopover: ActionScopeWindow + @openModal: ActionScopeWindow + @closeModal: ActionScopeWindow + ### Public: Set metadata for a specified model and pluginId. diff --git a/src/flux/stores/modal-store.jsx b/src/flux/stores/modal-store.jsx new file mode 100644 index 000000000..ee8b7ec99 --- /dev/null +++ b/src/flux/stores/modal-store.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import NylasStore from 'nylas-store' +import Actions from '../actions' +import {Modal} from 'nylas-component-kit'; + + +const CONTAINER_ID = "nylas-modal-container"; + +function createContainer(id) { + const element = document.createElement(id); + document.body.appendChild(element); + return element; +} + +class ModalStore extends NylasStore { + + constructor(containerId = CONTAINER_ID) { + super() + this.isOpen = false; + this.container = createContainer(containerId); + React.render(, this.container); + + this.listenTo(Actions.openModal, this.openModal); + this.listenTo(Actions.closeModal, this.closeModal); + } + + isModalOpen = ()=> { + return this.isOpen; + }; + + renderModal = (child, props, callback)=> { + const modal = ( + {child} + ); + + React.render(modal, this.container, ()=> { + this.isOpen = true; + this.trigger(); + callback(); + }); + }; + + openModal = (component, height, width, callback = ()=> {})=> { + const props = { + height: height, + width: width, + }; + + if (this.isOpen) { + this.closeModal(()=> { + this.renderModal(component, props, callback); + }) + } else { + this.renderModal(component, props, callback); + } + }; + + closeModal = (callback = ()=>{})=> { + React.render(, this.container, ()=> { + this.isOpen = false; + this.trigger(); + callback(); + }); + }; + +} + +export default new ModalStore(); diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index a0610c6a6..c0e4efb28 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -16,6 +16,7 @@ class NylasComponentKit @load "Switch", 'switch' @load "Popover", 'popover' @load "FixedPopover", 'fixed-popover' + @load "Modal", 'modal' @load "Flexbox", 'flexbox' @load "RetinaImg", 'retina-img' @load "SwipeContainer", 'swipe-container' diff --git a/src/global/nylas-exports.coffee b/src/global/nylas-exports.coffee index 8731e28fb..4adf9fcdc 100644 --- a/src/global/nylas-exports.coffee +++ b/src/global/nylas-exports.coffee @@ -130,8 +130,8 @@ class NylasExports @require "FocusedContactsStore", 'flux/stores/focused-contacts-store' @require "PreferencesUIStore", 'flux/stores/preferences-ui-store' @require "PopoverStore", 'flux/stores/popover-store' + @require "ModalStore", 'flux/stores/modal-store' @require "SearchableComponentStore", 'flux/stores/searchable-component-store' - @require "MessageBodyProcessor", 'flux/stores/message-body-processor' @require "MailRulesTemplates", 'mail-rules-templates' @require "MailRulesProcessor", 'mail-rules-processor' diff --git a/static/components/modal.less b/static/components/modal.less new file mode 100644 index 000000000..f318bd0a1 --- /dev/null +++ b/static/components/modal.less @@ -0,0 +1,19 @@ +@import "ui-variables"; + +.nylas-modal-container { + position: absolute; + z-index: 40; + + .modal { + position: absolute; + background-color: @background-primary; + border-radius: @border-radius-base; + box-shadow: 0 0.5px 0 rgba(0, 0, 0, 0.15), 0 -0.5px 0 rgba(0, 0, 0, 0.15), 0.5px 0 0 rgba(0, 0, 0, 0.15), -0.5px 0 0 rgba(0, 0, 0, 0.15), 0 4px 7px rgba(0,0,0,0.15); + } +} + +body.platform-win32 { + .modal { + border-radius: 0; + } +} \ No newline at end of file diff --git a/static/images/theme-picker/picker-close@1x.png b/static/images/theme-picker/picker-close@1x.png new file mode 100644 index 000000000..a4252ca5d Binary files /dev/null and b/static/images/theme-picker/picker-close@1x.png differ diff --git a/static/images/theme-picker/picker-close@2x.png b/static/images/theme-picker/picker-close@2x.png new file mode 100644 index 000000000..56ad7581a Binary files /dev/null and b/static/images/theme-picker/picker-close@2x.png differ diff --git a/static/index.less b/static/index.less index 65e4a1d94..5833e4cfe 100644 --- a/static/index.less +++ b/static/index.less @@ -31,4 +31,5 @@ @import "components/editable-list"; @import "components/outline-view"; @import "components/fixed-popover"; +@import "components/modal"; @import "components/date-input"; diff --git a/static/variables/ui-variables.less b/static/variables/ui-variables.less index b427b4431..7bc0d785e 100644 --- a/static/variables/ui-variables.less +++ b/static/variables/ui-variables.less @@ -90,7 +90,6 @@ @text-color-search-match: #fff000; @text-color-search-current-match: #ff8b1a; -@font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif; @font-family-sans-serif: "Nylas-Pro", "Helvetica", sans-serif; @font-family-serif: Georgia, "Times New Roman", Times, serif; @font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; @@ -448,8 +447,6 @@ rgba(253,253,253,0.75) 100%); @component-border-radius: 2px; - -@body-bg: @white; //== Panels and Sidebars @panel-background-color: @gray-lighter; @toolbar-background-color: darken(@white, 17.5%);