feat(theme-picker): Add visual theme picker to menu

Summary: Adds a new visual theme picker to the menu that allows users to select different themes based on color palettes and then change their themes live.

Test Plan: Test included.

Reviewers: evan, bengotow

Reviewed By: evan, bengotow

Differential Revision: https://phab.nylas.com/D2669
This commit is contained in:
Jackie Luo 2016-03-04 15:21:06 -08:00
parent 66c84383ff
commit c67e2a24ea
22 changed files with 543 additions and 44 deletions

View file

@ -132,42 +132,6 @@ class AppearanceModeOption extends React.Component
<div>{label}</div>
</div>
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: =>
<div className="item">
<span>Select theme: </span>
<select value={@state.activeTheme} onChange={@_onChangeTheme}>
{@state.themes.map (theme) ->
<option key={theme.name} value={theme.name}>{theme.displayName}</option>}
<option value="install">Install a theme...</option>
</select>
</div>
class WorkspaceSection extends React.Component
@displayName: 'WorkspaceSection'
@ -202,8 +166,6 @@ class WorkspaceSection extends React.Component
keyPath="core.workspace.interfaceZoom"
config={@props.config} />
<ThemeSelector />
<h2>Layout</h2>
<AppearanceModeSwitch config={@props.config} />

View file

@ -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=<ThemePicker />,
height=400,
width=250));
}
export function deactivate() {
this.disposable.dispose();
}

View file

@ -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 = `<!DOCTYPE html>
<style>${this._loadStylesheet(`${resourcePath}/internal_packages/theme-picker/preview-styles/theme-option.less`)}</style>
<body>
<div class="theme-option active-${this.props.active}">
<div class="theme-name ">${this.props.theme.displayName}</div>
<div class="swatches" style="display:flex;flex-direction:row;">
<div class="swatch font-color"></div>
<div class="swatch active-color"></div>
<div class="swatch toolbar-color"></div>
</div>
<div class="divider-black"></div>
<div class="divider-white"></div>
<div class="strip"></div>
</div>
</body>`
doc.open();
doc.write(html);
doc.close();
}
render() {
return (
<EventedIFrame ref="iframe" className={`theme-preview-${this.props.theme.name}`} frameBorder="0" width="105px" height="65px" flex="1" style={{pointerEvents: "none"}} />
);
}
}
export default ThemeOption;

View file

@ -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) =>
<div
className="clickable-theme-option"
onMouseDown={() => this._setActiveTheme(theme.name)}
style={{cursor: "pointer", width: "115px", margin: "2px"}}>
<ThemeOption
key={theme.name}
theme={theme}
active={this.state.activeTheme === theme.name} />
</div>
)
return themeOptions;
}
render() {
return (
<div style={{textAlign: "center", cursor: "default"}}>
<Flexbox direction="column">
<RetinaImg
style={{width: "14", height: "14", margin: "8px"}}
name="picker-close.png"
mode={RetinaImg.Mode.ContentDark}
onMouseDown={() => Actions.closeModal()} />
<h4 style={{color: "#313435"}}>Themes</h4>
<div style={{color: "rgba(35, 31, 32, 0.5)"}}>Click any theme to preview.</div>
<div style={{margin: "10px 5px 0 5px", height: "300px", overflow: "auto"}}>
<Flexbox
direction="row"
height="auto"
style={{alignItems: "flex-start", flexWrap: "wrap"}}>
{this._renderThemeOptions()}
</Flexbox>
</div>
</Flexbox>
</div>
);
}
}
export default ThemePicker;

View file

@ -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": {
}
}

View file

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

View file

@ -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(<ThemePicker />);
});
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();
});
});

View file

@ -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 }

View file

@ -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' }
]
}

View file

@ -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' }

View file

@ -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'

98
src/components/modal.jsx Normal file
View file

@ -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 (
<div
style={containerStyle}
className="modal-container"
onKeyDown={this._onKeyDown}
onBlur={this._onBlur}>
<div className="modal" style={modalStyle}>
{children}
</div>
</div>
);
}
}
export default Modal;

View file

@ -56,7 +56,7 @@ class NewsletterSignup extends React.Component
"/newsletter-subscription/#{encodeURIComponent(props.emailAddress)}?name=#{encodeURIComponent(props.name)}"
render: =>
<Flexbox direction='row' style={textAlign: 'left', height: 'auto'}>
<Flexbox direction='row' height='auto' style={textAlign: 'left'}>
<div style={minWidth:15}>
{@_renderControl()}
</div>

View file

@ -518,6 +518,9 @@ class Actions
@openPopover: ActionScopeWindow
@closePopover: ActionScopeWindow
@openModal: ActionScopeWindow
@closeModal: ActionScopeWindow
###
Public: Set metadata for a specified model and pluginId.

View file

@ -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(<span />, this.container);
this.listenTo(Actions.openModal, this.openModal);
this.listenTo(Actions.closeModal, this.closeModal);
}
isModalOpen = ()=> {
return this.isOpen;
};
renderModal = (child, props, callback)=> {
const modal = (
<Modal {...props}>{child}</Modal>
);
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(<span/>, this.container, ()=> {
this.isOpen = false;
this.trigger();
callback();
});
};
}
export default new ModalStore();

View file

@ -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'

View file

@ -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'

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -31,4 +31,5 @@
@import "components/editable-list";
@import "components/outline-view";
@import "components/fixed-popover";
@import "components/modal";
@import "components/date-input";

View file

@ -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%);