mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-24 01:06:07 +08:00
c7a685630d
Summary: This is a refactor of the toolbar in the contenteditable. Goals of this are: 1. Allow developers to add new buttons to the toolbar 2. Allow developers to add other component types to the floating toolbar (like the LinkEditor) 3. Make the toolbar declaratively defined instead of imperatively set 4. Separate out logical units of the toolbar into individual sections 5. Clean up `innerState` of the Contenteditable The Floating Toolbar used to be an imperative mess. Doing simple functionality additions required re-understanding a very complex set of logic to hide and show the toolbar and delecately manage focus states. There also was no real capacity for any developer to extend the toolbar. It also used to be completely outside of our `atomicEdit` system and was a legacy of having raw access to contenteditable controls (since it all used to be directly inside of the contenteditable) Finally it was difficult to declaratively define things because the `innerState` of the Contenteditable was inconsistently used and its lifecycle not properly thought through. This fixed several lifecycle bugs with that. Along the way several of the DOMUtils methods were also subtly not functional and fixed. The Toolbar is now broken apart into separate logical units. There are now `ContentedtiableExtension`s that declare what should be displayed in the toolbar at any given moment. They define a method called `toolbarComponentData`. This is a pure function of the state of the `Contenteditable`. If selection and content conditions look correct, then that method will return a component to render. This is how we declaratively define whether a toolbar should be visible or not instead of manually setting `hide` & `show` bits. There is also a `toolbarButtons` method that declaratively defines buttons that can go in the new `<ToolbarButtons>` component. The `ToolbarButtonManager` takes care of extracting these and binding the correct editorAPI context. Now the `<LinkEditor>` is a separate component from the `<ToolbarButtons>` instead of being smashed together. The `LinkManager` takes care of declaring when the `LinkEditor` should be displayed and has properly bound methods to update the `contenteditable` through the standard `atomicEdit` interface. If users have additional contenteditable popup plugins (like displaying extra info on a name or some content in the composer), they can now implement the `toolbarComponentData` api and declaratively define that information based on the state of the contenteditable. Test Plan: TODO Reviewers: bengotow, juan Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2442
948 lines
30 KiB
CoffeeScript
948 lines
30 KiB
CoffeeScript
crypto = require 'crypto'
|
|
os = require 'os'
|
|
path = require 'path'
|
|
|
|
{ipcRenderer, remote, shell} = require 'electron'
|
|
|
|
_ = require 'underscore'
|
|
{deprecate} = require 'grim'
|
|
{Emitter} = require 'event-kit'
|
|
{Model} = require 'theorist'
|
|
fs = require 'fs-plus'
|
|
{convertStackTrace, convertLine} = require 'coffeestack'
|
|
{mapSourcePosition} = require 'source-map-support'
|
|
|
|
WindowEventHandler = require './window-event-handler'
|
|
StylesElement = require './styles-element'
|
|
|
|
Utils = require './flux/models/utils'
|
|
{APIError} = require './flux/errors'
|
|
|
|
ensureInteger = (f, fallback) ->
|
|
if f is NaN or f is undefined or f is null
|
|
f = fallback
|
|
return Math.round(f)
|
|
|
|
# Essential: NylasEnv global for dealing with packages, themes, menus, and the window.
|
|
#
|
|
# The singleton of this class is always available as the `NylasEnv` global.
|
|
module.exports =
|
|
class NylasEnvConstructor extends Model
|
|
@version: 1 # Increment this when the serialization format changes
|
|
|
|
assert: (bool, msg) ->
|
|
throw new Error("Assertion error: #{msg}") if not bool
|
|
|
|
# Load or create the application environment
|
|
# Returns an NylasEnv instance, fully initialized
|
|
@loadOrCreate: ->
|
|
startTime = Date.now()
|
|
|
|
savedState = @_loadSavedState()
|
|
if savedState and savedState?.version is @version
|
|
app = new this(savedState)
|
|
else
|
|
app = new this({@version})
|
|
|
|
return app
|
|
|
|
# Loads and returns the serialized state corresponding to this window
|
|
# if it exists; otherwise returns undefined.
|
|
@_loadSavedState: ->
|
|
statePath = @getStatePath()
|
|
|
|
if fs.existsSync(statePath)
|
|
try
|
|
stateString = fs.readFileSync(statePath, 'utf8')
|
|
catch error
|
|
console.warn "Error reading window state: #{statePath}", error.stack, error
|
|
else
|
|
stateString = @getLoadSettings().windowState
|
|
|
|
try
|
|
JSON.parse(stateString) if stateString?
|
|
catch error
|
|
console.warn "Error parsing window state: #{statePath} #{error.stack}", error
|
|
|
|
# Returns the path where the state for the current window will be
|
|
# located if it exists.
|
|
@getStatePath: ->
|
|
{isSpec, mainWindow, configDirPath} = @getLoadSettings()
|
|
if isSpec
|
|
filename = 'spec-saved-state.json'
|
|
else if mainWindow
|
|
path.join(configDirPath, 'main-window-state.json')
|
|
else
|
|
null
|
|
|
|
# Returns the load settings hash associated with the current window.
|
|
@getLoadSettings: ->
|
|
@loadSettings ?= JSON.parse(decodeURIComponent(location.search.substr(14)))
|
|
|
|
cloned = Utils.deepClone(@loadSettings)
|
|
# The loadSettings.windowState could be large, request it only when needed.
|
|
cloned.__defineGetter__ 'windowState', =>
|
|
@getCurrentWindow().loadSettings.windowState
|
|
cloned.__defineSetter__ 'windowState', (value) =>
|
|
@getCurrentWindow().loadSettings.windowState = value
|
|
cloned
|
|
|
|
@getCurrentWindow: ->
|
|
remote.getCurrentWindow()
|
|
|
|
workspaceViewParentSelector: 'body'
|
|
lastUncaughtError: null
|
|
|
|
###
|
|
Section: Properties
|
|
###
|
|
|
|
# Public: A {CommandRegistry} instance
|
|
commands: null
|
|
|
|
# Public: A {Config} instance
|
|
config: null
|
|
|
|
# Public: A {Clipboard} instance
|
|
clipboard: null
|
|
|
|
# Public: A {MenuManager} instance
|
|
menu: null
|
|
|
|
# Public: A {KeymapManager} instance
|
|
keymaps: null
|
|
|
|
# Public: A {PackageManager} instance
|
|
packages: null
|
|
|
|
# Public: A {ThemeManager} instance
|
|
themes: null
|
|
|
|
# Public: A {StyleManager} instance
|
|
styles: null
|
|
|
|
###
|
|
Section: Construction and Destruction
|
|
###
|
|
|
|
# Call .loadOrCreate instead
|
|
constructor: (@savedState={}) ->
|
|
{@version} = @savedState
|
|
@emitter = new Emitter
|
|
|
|
# Sets up the basic services that should be available in all modes
|
|
# (both spec and application).
|
|
#
|
|
# Call after this instance has been assigned to the `NylasEnv` global.
|
|
initialize: ->
|
|
# Disable deprecations unless in dev mode or spec mode so that regular
|
|
# editor performance isn't impacted by generating stack traces for
|
|
# deprecated calls.
|
|
unless @inDevMode() or @inSpecMode()
|
|
require('grim').deprecate = ->
|
|
|
|
@enhanceEventObject()
|
|
|
|
@setupErrorLogger()
|
|
|
|
@unsubscribe()
|
|
|
|
@loadTime = null
|
|
|
|
Config = require './config'
|
|
KeymapManager = require './keymap-manager'
|
|
CommandRegistry = require './command-registry'
|
|
PackageManager = require './package-manager'
|
|
Clipboard = require './clipboard'
|
|
ThemeManager = require './theme-manager'
|
|
StyleManager = require './style-manager'
|
|
ActionBridge = require './flux/action-bridge'
|
|
MenuManager = require './menu-manager'
|
|
|
|
{devMode, safeMode, resourcePath, configDirPath, windowType} = @getLoadSettings()
|
|
|
|
document.body.classList.add("platform-#{process.platform}")
|
|
document.body.classList.add("window-type-#{windowType}")
|
|
|
|
# Add 'src/global' to module search path.
|
|
globalPath = path.join(resourcePath, 'src', 'global')
|
|
require('module').globalPaths.push(globalPath)
|
|
|
|
# Still set NODE_PATH since tasks may need it.
|
|
process.env.NODE_PATH = globalPath
|
|
|
|
# Make react.js faster
|
|
process.env.NODE_ENV ?= 'production' unless devMode
|
|
|
|
# Set NylasEnv's home so packages don't have to guess it
|
|
process.env.NYLAS_HOME = configDirPath
|
|
|
|
# Setup config and load it immediately so it's available to our singletons
|
|
@config = new Config({configDirPath, resourcePath})
|
|
|
|
@keymaps = new KeymapManager({configDirPath, resourcePath})
|
|
@keymaps.onDidMatchBinding (event) ->
|
|
# If the user fired a command with the application: prefix bound to
|
|
# the body, re-fire it up into the browser process. This prevents us
|
|
# from needing this crap, which has to be updated every time a new
|
|
# application: command is added:
|
|
if event.binding.command.indexOf('application:') is 0 and event.binding.selector.indexOf("body") is 0
|
|
ipcRenderer.send('command', event.binding.command)
|
|
|
|
unless @inSpecMode()
|
|
@actionBridge = new ActionBridge(ipcRenderer)
|
|
|
|
@commands = new CommandRegistry
|
|
@commands.attach(window)
|
|
|
|
specMode = @inSpecMode()
|
|
@packages = new PackageManager({devMode, configDirPath, resourcePath, safeMode, specMode})
|
|
@styles = new StyleManager
|
|
document.head.appendChild(new StylesElement)
|
|
@themes = new ThemeManager({packageManager: @packages, configDirPath, resourcePath, safeMode})
|
|
@clipboard = new Clipboard()
|
|
|
|
@menu = new MenuManager({resourcePath})
|
|
if process.platform is 'win32'
|
|
@getCurrentWindow().setMenuBarVisibility(false)
|
|
|
|
# initialize spell checking
|
|
@spellchecker = require('./nylas-spellchecker')
|
|
|
|
@subscribe @packages.onDidActivateInitialPackages => @watchThemes()
|
|
@windowEventHandler = new WindowEventHandler
|
|
|
|
window.onbeforeunload = => @_unloading()
|
|
@_unloadCallbacks = []
|
|
|
|
# Start our error reporting to the backend and attach error handlers
|
|
# to the window and the Bluebird Promise library, converting things
|
|
# back through the sourcemap as necessary.
|
|
setupErrorLogger: ->
|
|
ErrorLogger = require './error-logger'
|
|
@errorLogger = new ErrorLogger
|
|
inSpecMode: @inSpecMode()
|
|
inDevMode: @inDevMode()
|
|
resourcePath: @getLoadSettings().resourcePath
|
|
|
|
sourceMapCache = {}
|
|
|
|
window.onerror = =>
|
|
@lastUncaughtError = Array::slice.call(arguments)
|
|
[message, url, line, column, originalError] = @lastUncaughtError
|
|
|
|
{line, column} = mapSourcePosition({source: url, line, column})
|
|
|
|
eventObject = {message, url, line, column, originalError}
|
|
|
|
openDevTools = true
|
|
eventObject.preventDefault = -> openDevTools = false
|
|
|
|
@emitter.emit 'will-throw-error', eventObject
|
|
|
|
if openDevTools and @inDevMode()
|
|
@openDevTools()
|
|
@executeJavaScriptInDevTools('DevToolsAPI.showConsole()')
|
|
|
|
@emitter.emit 'did-throw-error', {message, url, line, column, originalError}
|
|
|
|
if @inSpecMode() or @inDevMode()
|
|
Promise.longStackTraces()
|
|
|
|
Promise.onPossiblyUnhandledRejection (error) =>
|
|
error.stack = convertStackTrace(error.stack, sourceMapCache)
|
|
|
|
# API Errors are logged to Sentry only under certain circumstances,
|
|
# and are logged directly from the NylasAPI class.
|
|
if error instanceof APIError
|
|
return
|
|
|
|
if @inSpecMode()
|
|
jasmine.getEnv().currentSpec.fail(error)
|
|
else if @inDevMode()
|
|
console.error(error.message, error.stack, error)
|
|
@openDevTools()
|
|
@executeJavaScriptInDevTools('InspectorFrontendAPI.showConsole()')
|
|
else
|
|
console.warn(error)
|
|
console.warn(error.stack)
|
|
|
|
@emitError(error)
|
|
|
|
emitError: (error) ->
|
|
console.error(error.message) unless @inSpecMode()
|
|
console.error(error.stack) unless @inSpecMode()
|
|
eventObject = {message: error.message, originalError: error}
|
|
@emitter.emit('will-throw-error', eventObject)
|
|
@emit('uncaught-error', error.message, null, null, null, error)
|
|
@emitter.emit('did-throw-error', eventObject)
|
|
|
|
###
|
|
Section: Event Subscription
|
|
###
|
|
|
|
# Extended: Invoke the given callback whenever {::beep} is called.
|
|
#
|
|
# * `callback` {Function} to be called whenever {::beep} is called.
|
|
#
|
|
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
|
onDidBeep: (callback) ->
|
|
@emitter.on 'did-beep', callback
|
|
|
|
# Extended: Invoke the given callback when there is an unhandled error, but
|
|
# before the devtools pop open
|
|
#
|
|
# * `callback` {Function} to be called whenever there is an unhandled error
|
|
# * `event` {Object}
|
|
# * `originalError` {Object} the original error object
|
|
# * `message` {String} the original error object
|
|
# * `url` {String} Url to the file where the error originated.
|
|
# * `line` {Number}
|
|
# * `column` {Number}
|
|
# * `preventDefault` {Function} call this to avoid popping up the dev tools.
|
|
#
|
|
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
|
onWillThrowError: (callback) ->
|
|
@emitter.on 'will-throw-error', callback
|
|
|
|
# Extended: Invoke the given callback whenever there is an unhandled error.
|
|
#
|
|
# * `callback` {Function} to be called whenever there is an unhandled error
|
|
# * `event` {Object}
|
|
# * `originalError` {Object} the original error object
|
|
# * `message` {String} the original error object
|
|
# * `url` {String} Url to the file where the error originated.
|
|
# * `line` {Number}
|
|
# * `column` {Number}
|
|
#
|
|
# Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
|
|
onDidThrowError: (callback) ->
|
|
@emitter.on 'did-throw-error', callback
|
|
|
|
# Extended: Run the Chromium content-tracing module for five seconds, and save
|
|
# the output to a file which is printed to the command-line output of the app.
|
|
# You can take the file exported by this function and load it into Chrome's
|
|
# content trace visualizer (chrome://tracing). It's like Chromium Developer
|
|
# Tools Profiler, but for all processes and threads.
|
|
trace: ->
|
|
tracing = remote.require('content-tracing')
|
|
tracing.startRecording '*', 'record-until-full,enable-sampling,enable-systrace', ->
|
|
console.log('Tracing started')
|
|
setTimeout ->
|
|
tracing.stopRecording '', (path) ->
|
|
console.log('Tracing data recorded to ' + path)
|
|
, 5000
|
|
|
|
isMainWindow: ->
|
|
!!@getLoadSettings().mainWindow
|
|
|
|
isWorkWindow: ->
|
|
@getWindowType() is 'work'
|
|
|
|
isComposerWindow: ->
|
|
@getWindowType() is 'composer'
|
|
|
|
getWindowType: ->
|
|
@getLoadSettings().windowType
|
|
|
|
# Public: Is the current window in development mode?
|
|
inDevMode: ->
|
|
@getLoadSettings().devMode
|
|
|
|
# Public: Is the current window in safe mode?
|
|
inSafeMode: ->
|
|
@getLoadSettings().safeMode
|
|
|
|
# Public: Is the current window running specs?
|
|
inSpecMode: ->
|
|
@getLoadSettings().isSpec
|
|
|
|
# Public: Get the version of N1.
|
|
#
|
|
# Returns the version text {String}.
|
|
getVersion: ->
|
|
@appVersion ?= @getLoadSettings().appVersion
|
|
|
|
# Public: Determine whether the current version is an official release.
|
|
isReleasedVersion: ->
|
|
not /\w{7}/.test(@getVersion()) # Check if the release is a 7-character SHA prefix
|
|
|
|
# Public: Get the directory path to N1's configuration area.
|
|
getConfigDirPath: => @getLoadSettings().configDirPath
|
|
|
|
# Public: Get the time taken to completely load the current window.
|
|
#
|
|
# This time include things like loading and activating packages, creating
|
|
# DOM elements for the editor, and reading the config.
|
|
#
|
|
# Returns the {Number} of milliseconds taken to load the window or null
|
|
# if the window hasn't finished loading yet.
|
|
getWindowLoadTime: ->
|
|
@loadTime
|
|
|
|
# Public: Get the load settings for the current window.
|
|
#
|
|
# Returns an {Object} containing all the load setting key/value pairs.
|
|
getLoadSettings: ->
|
|
@constructor.getLoadSettings()
|
|
|
|
###
|
|
Section: Managing The Nylas Window
|
|
###
|
|
|
|
# Essential: Close the current window.
|
|
close: ->
|
|
@getCurrentWindow().close()
|
|
|
|
quit: ->
|
|
remote.require('app').quit()
|
|
|
|
# Essential: Get the size of current window.
|
|
#
|
|
# Returns an {Object} in the format `{width: 1000, height: 700}`
|
|
getSize: ->
|
|
[width, height] = @getCurrentWindow().getSize()
|
|
{width, height}
|
|
|
|
# Essential: Set the size of current window.
|
|
#
|
|
# * `width` The {Number} of pixels.
|
|
# * `height` The {Number} of pixels.
|
|
setSize: (width, height) ->
|
|
width = ensureInteger(width, 100)
|
|
height = ensureInteger(height, 100)
|
|
@getCurrentWindow().setSize(width, height)
|
|
|
|
# Essential: Transition and set the size of the current window.
|
|
#
|
|
# * `width` The {Number} of pixels.
|
|
# * `height` The {Number} of pixels.
|
|
# * `duration` The {Number} of pixels.
|
|
setSizeAnimated: (width, height, duration=400) ->
|
|
# On Windows, the native window resizing code isn't fast enough to "animate"
|
|
# by resizing over and over again. Just turn off animation for now.
|
|
if process.platform is 'win32'
|
|
duration = 1
|
|
|
|
# Avoid divide by zero errors below
|
|
duration = Math.max(1, duration)
|
|
|
|
# Keep track of the number of times this method has been invoked, and ensure
|
|
# that we only `tick` for the last invocation. This prevents two resizes from
|
|
# running at the same time.
|
|
@_setSizeAnimatedCallCount ?= 0
|
|
@_setSizeAnimatedCallCount += 1
|
|
call = @_setSizeAnimatedCallCount
|
|
|
|
cubicInOut = (t) -> if t<.5 then 4*t**3 else (t-1)*(2*t-2)**2+1
|
|
win = @getCurrentWindow()
|
|
width = Math.round(width)
|
|
height = Math.round(height)
|
|
|
|
startBounds = win.getBounds()
|
|
startTime = Date.now() - 1 # - 1 so that if duration is 1, t = 1 on the first frame
|
|
|
|
boundsForI = (i) ->
|
|
# It's very important this function never return undefined for any of the
|
|
# keys which blows up setBounds.
|
|
x: ensureInteger(startBounds.x + (width-startBounds.width) * -0.5 * i, 0)
|
|
y: ensureInteger(startBounds.y + (height-startBounds.height) * -0.5 * i, 0)
|
|
width: ensureInteger(startBounds.width + (width-startBounds.width) * i, width)
|
|
height: ensureInteger(startBounds.height + (height-startBounds.height) * i, height)
|
|
|
|
tick = =>
|
|
return unless call is @_setSizeAnimatedCallCount
|
|
t = Math.min(1, (Date.now() - startTime) / (duration))
|
|
i = cubicInOut(t)
|
|
win.setBounds(boundsForI(i))
|
|
unless t is 1
|
|
_.defer(tick)
|
|
tick()
|
|
|
|
setMinimumWidth: (minWidth) ->
|
|
win = @getCurrentWindow()
|
|
minWidth = ensureInteger(minWidth, 0)
|
|
minHeight = win.getMinimumSize()[1]
|
|
win.setMinimumSize(minWidth, minHeight)
|
|
|
|
[currWidth, currHeight] = win.getSize()
|
|
win.setSize(minWidth, currHeight) if minWidth > currWidth
|
|
|
|
# Essential: Get the position of current window.
|
|
#
|
|
# Returns an {Object} in the format `{x: 10, y: 20}`
|
|
getPosition: ->
|
|
[x, y] = @getCurrentWindow().getPosition()
|
|
{x, y}
|
|
|
|
# Essential: Set the position of current window.
|
|
#
|
|
# * `x` The {Number} of pixels.
|
|
# * `y` The {Number} of pixels.
|
|
setPosition: (x, y) ->
|
|
x = ensureInteger(x, 0)
|
|
y = ensureInteger(y, 0)
|
|
ipcRenderer.send('call-window-method', 'setPosition', x, y)
|
|
|
|
# Extended: Get the current window
|
|
getCurrentWindow: ->
|
|
@constructor.getCurrentWindow()
|
|
|
|
# Extended: Move current window to the center of the screen.
|
|
center: ->
|
|
ipcRenderer.send('call-window-method', 'center')
|
|
|
|
# Extended: Focus the current window. Note: this will not open the window
|
|
# if it is hidden.
|
|
focus: ->
|
|
ipcRenderer.send('call-window-method', 'focus')
|
|
window.focus()
|
|
|
|
# Extended: Show the current window.
|
|
show: ->
|
|
ipcRenderer.send('call-window-method', 'show')
|
|
|
|
isVisible: ->
|
|
@getCurrentWindow().isVisible()
|
|
|
|
# Extended: Hide the current window.
|
|
hide: ->
|
|
ipcRenderer.send('call-window-method', 'hide')
|
|
|
|
# Extended: Reload the current window.
|
|
reload: ->
|
|
ipcRenderer.send('call-webcontents-method', 'reload')
|
|
|
|
# Updates the window load settings - called when the app is ready to display
|
|
# a hot-loaded window. Causes listeners registered with `onWindowPropsReceived`
|
|
# to receive new window props.
|
|
loadSettingsChanged: (event, loadSettings) =>
|
|
@loadSettings = loadSettings
|
|
@constructor.loadSettings = loadSettings
|
|
{width, height, windowProps} = loadSettings
|
|
|
|
@emitter.emit('window-props-received', windowProps ? {})
|
|
|
|
if width and height
|
|
@setWindowDimensions({width, height})
|
|
|
|
# Public: The windowProps passed when creating the window via `newWindow`.
|
|
#
|
|
getWindowProps: ->
|
|
@getLoadSettings().windowProps ? {}
|
|
|
|
# Public: If your package declares hot-loaded window types, `onWindowPropsReceived`
|
|
# fires when your hot-loaded window is about to be shown so you can update
|
|
# components to reflect the new window props.
|
|
#
|
|
# - callback: A function to call when window props are received, just before
|
|
# the hot window is shown. The first parameter is the new windowProps.
|
|
#
|
|
onWindowPropsReceived: (callback) ->
|
|
@emitter.on('window-props-received', callback)
|
|
|
|
# Extended: Is the current window maximized?
|
|
isMaximixed: ->
|
|
@getCurrentWindow().isMaximized()
|
|
|
|
maximize: ->
|
|
ipcRenderer.send('call-window-method', 'maximize')
|
|
|
|
minimize: ->
|
|
ipcRenderer.send('call-window-method', 'minimize')
|
|
|
|
# Extended: Is the current window in full screen mode?
|
|
isFullScreen: ->
|
|
@getCurrentWindow().isFullScreen()
|
|
|
|
# Extended: Set the full screen state of the current window.
|
|
setFullScreen: (fullScreen=false) ->
|
|
ipcRenderer.send('call-window-method', 'setFullScreen', fullScreen)
|
|
if fullScreen then document.body.classList.add("fullscreen") else document.body.classList.remove("fullscreen")
|
|
|
|
# Extended: Toggle the full screen state of the current window.
|
|
toggleFullScreen: ->
|
|
@setFullScreen(!@isFullScreen())
|
|
|
|
# Get the dimensions of this window.
|
|
#
|
|
# Returns an {Object} with the following keys:
|
|
# * `x` The window's x-position {Number}.
|
|
# * `y` The window's y-position {Number}.
|
|
# * `width` The window's width {Number}.
|
|
# * `height` The window's height {Number}.
|
|
getWindowDimensions: ->
|
|
browserWindow = @getCurrentWindow()
|
|
{x, y, width, height} = browserWindow.getBounds()
|
|
maximized = browserWindow.isMaximized()
|
|
fullScreen = browserWindow.isFullScreen()
|
|
{x, y, width, height, maximized, fullScreen}
|
|
|
|
# Set the dimensions of the window.
|
|
#
|
|
# The window will be centered if either the x or y coordinate is not set
|
|
# in the dimensions parameter. If x or y are omitted the window will be
|
|
# centered. If height or width are omitted only the position will be changed.
|
|
#
|
|
# * `dimensions` An {Object} with the following keys:
|
|
# * `x` The new x coordinate.
|
|
# * `y` The new y coordinate.
|
|
# * `width` The new width.
|
|
# * `height` The new height.
|
|
setWindowDimensions: ({x, y, width, height}) ->
|
|
if x? and y? and width? and height?
|
|
@getCurrentWindow().setBounds({x, y, width, height})
|
|
else if width? and height?
|
|
@setSize(width, height)
|
|
else if x? and y?
|
|
@setPosition(x, y)
|
|
else
|
|
@center()
|
|
|
|
# Returns true if the dimensions are useable, false if they should be ignored.
|
|
# Work around for https://github.com/atom/electron/issues/473
|
|
isValidDimensions: ({x, y, width, height}={}) ->
|
|
width > 0 and height > 0 and x + width > 0 and y + height > 0
|
|
|
|
getDefaultWindowDimensions: ->
|
|
screen = remote.require('screen')
|
|
{width, height} = screen.getPrimaryDisplay().workAreaSize
|
|
x = 0
|
|
y = 0
|
|
|
|
MAX_WIDTH = 1440
|
|
if width > MAX_WIDTH
|
|
x = Math.floor((width - MAX_WIDTH) / 2)
|
|
width = MAX_WIDTH
|
|
|
|
MAX_HEIGHT = 900
|
|
if height > MAX_HEIGHT
|
|
y = Math.floor((height - MAX_HEIGHT) / 2)
|
|
height = MAX_HEIGHT
|
|
|
|
{x, y, width, height}
|
|
|
|
restoreWindowDimensions: ->
|
|
dimensions = @savedState.windowDimensions
|
|
unless @isValidDimensions(dimensions)
|
|
dimensions = @getDefaultWindowDimensions()
|
|
@setWindowDimensions(dimensions)
|
|
@maximize() if dimensions.maximized and process.platform isnt 'darwin'
|
|
@setFullScreen(true) if dimensions.fullScreen
|
|
|
|
storeWindowDimensions: ->
|
|
dimensions = @getWindowDimensions()
|
|
@savedState.windowDimensions = dimensions if @isValidDimensions(dimensions)
|
|
|
|
storeColumnWidth: ({id, width}) =>
|
|
@savedState.columnWidths ?= {}
|
|
@savedState.columnWidths[id] = width
|
|
|
|
getColumnWidth: (id) =>
|
|
@savedState.columnWidths ?= {}
|
|
@savedState.columnWidths[id]
|
|
|
|
# Call this method when establishing a real application window.
|
|
startRootWindow: ->
|
|
@displayWindow()
|
|
|
|
{safeMode, windowType} = @getLoadSettings()
|
|
@registerCommands()
|
|
@loadConfig()
|
|
@keymaps.loadBundledKeymaps()
|
|
@themes.loadBaseStylesheets()
|
|
@packages.loadPackages(windowType)
|
|
@deserializePackageStates()
|
|
@deserializeSheetContainer()
|
|
@packages.activate()
|
|
@keymaps.loadUserKeymap()
|
|
@requireUserInitScript() unless safeMode
|
|
@menu.update()
|
|
|
|
@showRootWindow()
|
|
|
|
ipcRenderer.send('window-command', 'window:loaded')
|
|
|
|
showRootWindow: ->
|
|
document.getElementById("application-loading-cover").remove()
|
|
document.body.classList.add("window-loaded")
|
|
@restoreWindowDimensions()
|
|
@getCurrentWindow().setMinimumSize(875, 500)
|
|
|
|
registerCommands: ->
|
|
{resourcePath} = @getLoadSettings()
|
|
CommandInstaller = require './command-installer'
|
|
CommandInstaller.installN1Command resourcePath, false, (error) ->
|
|
console.warn error.message if error?
|
|
CommandInstaller.installApmCommand resourcePath, false, (error) ->
|
|
console.warn error.message if error?
|
|
|
|
# Call this method when establishing a secondary application window
|
|
# displaying a specific set of packages.
|
|
#
|
|
startSecondaryWindow: ->
|
|
{width,
|
|
height,
|
|
windowType,
|
|
windowPackages} = @getLoadSettings()
|
|
|
|
cover = document.getElementById("application-loading-cover")
|
|
cover.remove() if cover
|
|
|
|
@loadConfig()
|
|
|
|
@keymaps.loadBundledKeymaps()
|
|
@themes.loadBaseStylesheets()
|
|
|
|
@packages.loadPackages(windowType)
|
|
@packages.loadPackage(pack) for pack in (windowPackages ? [])
|
|
@deserializeSheetContainer()
|
|
@packages.activate()
|
|
@keymaps.loadUserKeymap()
|
|
|
|
ipcRenderer.on("load-settings-changed", @loadSettingsChanged)
|
|
|
|
@setWindowDimensions({width, height}) if width and height
|
|
|
|
@menu.update()
|
|
|
|
ipcRenderer.send('window-command', 'window:loaded')
|
|
|
|
# Requests that the backend browser bootup a new window with the given
|
|
# options.
|
|
# See the valid option types in Application::newWindow in
|
|
# src/browser/application.coffee
|
|
newWindow: (options={}) -> ipcRenderer.send('new-window', options)
|
|
|
|
# Registers a hot window for certain packages
|
|
# See the valid option types in Application::registerHotWindow in
|
|
# src/browser/application.coffee
|
|
registerHotWindow: (options={}) -> ipcRenderer.send('register-hot-window', options)
|
|
|
|
# Unregisters a hot window with the given windowType
|
|
unregisterHotWindow: (windowType) -> ipcRenderer.send('unregister-hot-window', windowType)
|
|
|
|
saveStateAndUnloadWindow: ->
|
|
@packages.deactivatePackages()
|
|
@savedState.packageStates = @packages.packageStates
|
|
@saveSync()
|
|
@windowState = null
|
|
|
|
###
|
|
Section: Messaging the User
|
|
###
|
|
|
|
displayWindow: ({maximize} = {}) ->
|
|
@show()
|
|
@focus()
|
|
@maximize() if maximize
|
|
|
|
# Essential: Visually and audibly trigger a beep.
|
|
beep: ->
|
|
shell.beep() if @config.get('core.audioBeep')
|
|
@emitter.emit 'did-beep'
|
|
|
|
# Essential: A flexible way to open a dialog akin to an alert dialog.
|
|
#
|
|
# ## Examples
|
|
#
|
|
# ```coffee
|
|
# NylasEnv.confirm
|
|
# message: 'How you feeling?'
|
|
# detailedMessage: 'Be honest.'
|
|
# buttons:
|
|
# Good: -> window.alert('good to hear')
|
|
# Bad: -> window.alert('bummer')
|
|
# ```
|
|
#
|
|
# * `options` An {Object} with the following keys:
|
|
# * `message` The {String} message to display.
|
|
# * `detailedMessage` (optional) The {String} detailed message to display.
|
|
# * `buttons` (optional) Either an array of strings or an object where keys are
|
|
# button names and the values are callbacks to invoke when clicked.
|
|
#
|
|
# Returns the chosen button index {Number} if the buttons option was an array.
|
|
confirm: ({message, detailedMessage, buttons}={}) ->
|
|
buttons ?= {}
|
|
if _.isArray(buttons)
|
|
buttonLabels = buttons
|
|
else
|
|
buttonLabels = Object.keys(buttons)
|
|
|
|
dialog = remote.require('dialog')
|
|
chosen = dialog.showMessageBox @getCurrentWindow(),
|
|
type: 'info'
|
|
message: message
|
|
detail: detailedMessage
|
|
buttons: buttonLabels
|
|
|
|
if _.isArray(buttons)
|
|
chosen
|
|
else
|
|
callback = buttons[buttonLabels[chosen]]
|
|
callback?()
|
|
|
|
###
|
|
Section: Managing the Dev Tools
|
|
###
|
|
|
|
# Extended: Open the dev tools for the current window.
|
|
openDevTools: ->
|
|
ipcRenderer.send('call-webcontents-method', 'openDevTools')
|
|
|
|
# Extended: Toggle the visibility of the dev tools for the current window.
|
|
toggleDevTools: ->
|
|
ipcRenderer.send('call-webcontents-method', 'toggleDevTools')
|
|
|
|
# Extended: Execute code in dev tools.
|
|
executeJavaScriptInDevTools: (code) ->
|
|
ipcRenderer.send('call-devtools-webcontents-method', 'executeJavaScript', code)
|
|
|
|
###
|
|
Section: Private
|
|
###
|
|
|
|
deserializeSheetContainer: ->
|
|
startTime = Date.now()
|
|
# Put state back into sheet-container? Restore app state here
|
|
@item = document.createElement("nylas-workspace")
|
|
@item.setAttribute("id", "sheet-container")
|
|
@item.setAttribute("class", "sheet-container")
|
|
@item.setAttribute("tabIndex", "-1")
|
|
|
|
React = require "react"
|
|
SheetContainer = require './sheet-container'
|
|
React.render(React.createElement(SheetContainer), @item)
|
|
document.querySelector(@workspaceViewParentSelector).appendChild(@item)
|
|
|
|
deserializePackageStates: ->
|
|
@packages.packageStates = @savedState.packageStates ? {}
|
|
delete @savedState.packageStates
|
|
|
|
loadThemes: ->
|
|
@themes.load()
|
|
|
|
loadConfig: ->
|
|
@config.setSchema null, {type: 'object', properties: _.clone(require('./config-schema'))}
|
|
@config.load()
|
|
|
|
watchThemes: ->
|
|
@themes.onDidChangeActiveThemes =>
|
|
# Only reload stylesheets from non-theme packages
|
|
for pack in @packages.getActivePackages() when pack.getType() isnt 'theme'
|
|
pack.reloadStylesheets?()
|
|
null
|
|
|
|
exit: (status) ->
|
|
app = remote.require('app')
|
|
app.emit('will-exit')
|
|
remote.process.exit(status)
|
|
|
|
showOpenDialog: (options, callback) ->
|
|
dialog = remote.require('dialog')
|
|
callback(dialog.showOpenDialog(@getCurrentWindow(), options))
|
|
|
|
showSaveDialog: (options, callback) ->
|
|
options.title ?= 'Save File'
|
|
dialog = remote.require('dialog')
|
|
callback(dialog.showSaveDialog(@getCurrentWindow(), options))
|
|
|
|
showErrorDialog: (message) ->
|
|
dialog = remote.require('dialog')
|
|
dialog.showMessageBox null, {
|
|
type: 'warning'
|
|
buttons: ['Okay'],
|
|
message: "Error"
|
|
detail: message
|
|
}
|
|
|
|
saveSync: ->
|
|
stateString = JSON.stringify(@savedState)
|
|
if statePath = @constructor.getStatePath()
|
|
fs.writeFileSync(statePath, stateString, 'utf8')
|
|
else
|
|
@getCurrentWindow().loadSettings.windowState = stateString
|
|
|
|
crashMainProcess: ->
|
|
remote.process.crash()
|
|
|
|
crashRenderProcess: ->
|
|
process.crash()
|
|
|
|
getUserInitScriptPath: ->
|
|
initScriptPath = fs.resolve(@getConfigDirPath(), 'init', ['js', 'coffee'])
|
|
initScriptPath ? path.join(@getConfigDirPath(), 'init.coffee')
|
|
|
|
requireUserInitScript: ->
|
|
if userInitScriptPath = @getUserInitScriptPath()
|
|
try
|
|
require(userInitScriptPath) if fs.isFileSync(userInitScriptPath)
|
|
catch error
|
|
console.log(error)
|
|
|
|
# Require the module with the given globals.
|
|
#
|
|
# The globals will be set on the `window` object and removed after the
|
|
# require completes.
|
|
#
|
|
# * `id` The {String} module name or path.
|
|
# * `globals` An optinal {Object} to set as globals during require.
|
|
requireWithGlobals: (id, globals={}) ->
|
|
existingGlobals = {}
|
|
for key, value of globals
|
|
existingGlobals[key] = window[key]
|
|
window[key] = value
|
|
|
|
require(id)
|
|
|
|
for key, value of existingGlobals
|
|
if value is undefined
|
|
delete window[key]
|
|
else
|
|
window[key] = value
|
|
|
|
onUpdateAvailable: (callback) ->
|
|
@emitter.on 'update-available', callback
|
|
|
|
updateAvailable: (details) ->
|
|
@emitter.emit 'update-available', details
|
|
|
|
# Lets multiple components register beforeUnload callbacks.
|
|
# The callbacks are expected to return either true or false.
|
|
#
|
|
# Note: If you return false to cancel the window close, you /must/ perform
|
|
# work and then call finishUnload. We do not support cancelling quit!
|
|
# https://phab.nylas.com/D1932#inline-11722
|
|
#
|
|
onBeforeUnload: (callback) -> @_unloadCallbacks.push(callback)
|
|
|
|
_unloading: ->
|
|
continueUnload = true
|
|
for callback in @_unloadCallbacks
|
|
returnValue = callback()
|
|
if returnValue is true
|
|
continue
|
|
else if returnValue is false
|
|
continueUnload = false
|
|
else
|
|
console.warn "You registered an `onBeforeUnload` callback that does not return either exactly `true` or `false`. It returned #{returnValue}", callback
|
|
return continueUnload
|
|
|
|
# Call this method to resume the close / quit process if you returned
|
|
# false from a onBeforeUnload handler.
|
|
#
|
|
finishUnload: ->
|
|
_.defer =>
|
|
if remote.getGlobal('application').quitting
|
|
remote.require('app').quit()
|
|
else
|
|
@close()
|
|
|
|
enhanceEventObject: ->
|
|
overriddenStop = Event::stopPropagation
|
|
Event::stopPropagation = ->
|
|
@propagationStopped = true
|
|
overriddenStop.apply(@, arguments)
|
|
Event::isPropagationStopped = ->
|
|
@propagationStopped
|